/**
* @fileoverview AnalysisRun repository implementation
*
* Concrete implementation of IAnalysisRunRepository using DeepSource API.
*/
import { IAnalysisRunRepository } from '../../domain/aggregates/analysis-run/analysis-run.repository.js';
import { AnalysisRun } from '../../domain/aggregates/analysis-run/analysis-run.aggregate.js';
import {
RunId,
ProjectKey,
BranchName,
CommitOid,
asRunId,
asBranchName,
asCommitOid,
asGraphQLNodeId,
} from '../../types/branded.js';
import { RunStatus } from '../../domain/aggregates/analysis-run/analysis-run.types.js';
import { PaginationOptions, PaginatedResult } from '../../domain/shared/repository.interface.js';
import { DeepSourceClient } from '../../deepsource.js';
import { AnalysisRunMapper } from '../mappers/analysis-run.mapper.js';
import { createLogger } from '../../utils/logging/logger.js';
import { DeepSourceRun, AnalysisRunStatus, RunSummary } from '../../models/runs.js';
const logger = createLogger('AnalysisRunRepository');
/**
* Converts client run response to model DeepSourceRun
* The client returns plain strings but the model expects branded types
*/
function convertClientRunToModelRun(clientRun: {
id: string;
runUid: string;
commitOid: string;
branchName: string;
baseOid: string;
status: AnalysisRunStatus;
createdAt: string;
updatedAt: string;
finishedAt?: string;
summary: RunSummary;
repository: {
id: string;
name: string;
};
}): DeepSourceRun {
return {
...clientRun,
id: asGraphQLNodeId(clientRun.id),
runUid: asRunId(clientRun.runUid),
commitOid: asCommitOid(clientRun.commitOid),
branchName: asBranchName(clientRun.branchName),
baseOid: asCommitOid(clientRun.baseOid),
repository: {
...clientRun.repository,
id: asGraphQLNodeId(clientRun.repository.id),
},
};
}
/**
* Concrete implementation of IAnalysisRunRepository using DeepSource API
*
* This repository provides access to AnalysisRun aggregates by fetching data
* from the DeepSource API and mapping it to domain models.
*
* Note: The DeepSource API doesn't support querying individual runs by ID,
* so some methods need to fetch and filter runs locally. This ensures fresh
* data retrieval on every request as per requirements.
*/
export class AnalysisRunRepository implements IAnalysisRunRepository {
constructor(private readonly client: DeepSourceClient) {
// client is stored for use in methods
}
/**
* Finds a run by its unique identifier
*
* Note: DeepSource API doesn't support direct run lookup by ID,
* so this method is not efficiently implementable.
*
* @param id - The run ID
* @returns The run if found, null otherwise
*/
async findById(id: RunId): Promise<AnalysisRun | null> {
return this.findByRunId(id);
}
/**
* Finds a run by its unique ID
*
* Note: Since the API doesn't support direct lookup, this searches
* through all projects to find the run.
*
* @param id - The run ID
* @returns The run if found, null otherwise
*/
async findByRunId(id: RunId): Promise<AnalysisRun | null> {
try {
logger.debug('Finding run by ID', { runId: id });
// We need to search through all projects since we don't know which project the run belongs to
const projects = await this.client.listProjects();
for (const project of projects) {
const projectKey = project.key;
let cursor: string | undefined;
let hasNextPage = true;
while (hasNextPage) {
const filterParams: { first: number; after?: string } = { first: 50 };
if (cursor !== undefined) {
filterParams.after = cursor;
}
const response = await this.client.listRuns(projectKey, filterParams);
const run = response.items.find((r) => r.runUid === id);
if (run) {
const modelRun = convertClientRunToModelRun(run);
const analysisRun = AnalysisRunMapper.toDomain(modelRun, projectKey);
logger.debug('Run found', { runId: id, projectKey });
return analysisRun;
}
hasNextPage = response.pageInfo.hasNextPage;
cursor = response.pageInfo.endCursor;
}
}
logger.debug('Run not found', { runId: id });
return null;
} catch (error) {
logger.error('Error finding run by ID', { runId: id, error });
throw error;
}
}
/**
* Finds runs for a specific project with pagination
*
* @param projectKey - The project key
* @param options - Pagination options
* @returns Paginated analysis runs
*/
async findByProject(
projectKey: ProjectKey,
options: PaginationOptions
): Promise<PaginatedResult<AnalysisRun>> {
try {
logger.debug('Finding runs by project', { projectKey, options });
// Calculate cursor for pagination
const first = options.pageSize;
const skip = (options.page - 1) * options.pageSize;
// Fetch runs from API
const response = await this.client.listRuns(projectKey, {
first: skip + first, // Fetch enough to skip to the desired page
});
// Skip to the desired page
const items = response.items.slice(skip, skip + first);
const modelRuns = items.map(convertClientRunToModelRun);
const runs = AnalysisRunMapper.toDomainList(modelRuns, projectKey);
// Calculate total pages based on a reasonable estimate
// Since we don't have total count from API, we estimate based on hasNextPage
const hasMoreItems = response.items.length > skip + first || response.pageInfo.hasNextPage;
const estimatedTotal = hasMoreItems
? (options.page + 1) * options.pageSize + 1
: response.items.length;
const totalPages = Math.ceil(estimatedTotal / options.pageSize);
const result: PaginatedResult<AnalysisRun> = {
items: runs,
page: options.page,
pageSize: options.pageSize,
totalCount: estimatedTotal,
totalPages,
hasNextPage: options.page < totalPages,
hasPreviousPage: options.page > 1,
};
logger.debug('Runs found', { projectKey, count: runs.length });
return result;
} catch (error) {
logger.error('Error finding runs by project', { projectKey, error });
throw error;
}
}
/**
* Finds the most recent run for a project
*
* @param projectKey - The project key
* @param branch - Optional branch filter
* @returns The most recent run if found, null otherwise
*/
async findMostRecent(projectKey: ProjectKey, branch?: BranchName): Promise<AnalysisRun | null> {
try {
logger.debug('Finding most recent run', { projectKey, branch });
// Find the most recent run by fetching runs and filtering
let mostRecentRun: DeepSourceRun | null = null;
let cursor: string | undefined;
let hasNextPage = true;
while (hasNextPage) {
const filterParams: { first: number; after?: string } = { first: 50 };
if (cursor !== undefined) {
filterParams.after = cursor;
}
const response = await this.client.listRuns(projectKey, filterParams);
// Filter by branch if specified
const runsToCheck = branch
? response.items.filter((r) => r.branchName === branch)
: response.items;
// Find the most recent run
for (const run of runsToCheck) {
if (!mostRecentRun || new Date(run.createdAt) > new Date(mostRecentRun.createdAt)) {
mostRecentRun = convertClientRunToModelRun(run);
}
}
// If we found a run and no branch filter, we can stop
// (runs are returned in descending order by createdAt)
if (mostRecentRun && !branch) {
break;
}
hasNextPage = response.pageInfo.hasNextPage;
cursor = response.pageInfo.endCursor;
}
if (!mostRecentRun) {
logger.debug('No recent run found', { projectKey, branch });
return null;
}
const analysisRun = AnalysisRunMapper.toDomain(mostRecentRun, projectKey);
logger.debug('Most recent run found', {
projectKey,
branch,
runId: analysisRun.runId,
});
return analysisRun;
} catch (error) {
logger.error('Error finding most recent run', { projectKey, branch, error });
throw error;
}
}
/**
* Finds a run by commit OID
*
* @param projectKey - The project key
* @param commitOid - The commit OID
* @returns The run if found, null otherwise
*/
async findByCommit(projectKey: ProjectKey, commitOid: CommitOid): Promise<AnalysisRun | null> {
try {
logger.debug('Finding run by commit', { projectKey, commitOid });
let cursor: string | undefined;
let hasNextPage = true;
while (hasNextPage) {
const filterParams: { first: number; after?: string } = { first: 50 };
if (cursor !== undefined) {
filterParams.after = cursor;
}
const response = await this.client.listRuns(projectKey, filterParams);
const run = response.items.find((r) => r.commitOid === commitOid);
if (run) {
const modelRun = convertClientRunToModelRun(run);
const analysisRun = AnalysisRunMapper.toDomain(modelRun, projectKey);
logger.debug('Run found for commit', { projectKey, commitOid, runId: run.runUid });
return analysisRun;
}
hasNextPage = response.pageInfo.hasNextPage;
cursor = response.pageInfo.endCursor;
}
logger.debug('No run found for commit', { projectKey, commitOid });
return null;
} catch (error) {
logger.error('Error finding run by commit', { projectKey, commitOid, error });
throw error;
}
}
/**
* Finds runs by status
*
* @param projectKey - The project key
* @param status - The run status
* @param options - Pagination options
* @returns Paginated analysis runs
*/
async findByStatus(
projectKey: ProjectKey,
status: RunStatus,
options: PaginationOptions
): Promise<PaginatedResult<AnalysisRun>> {
try {
logger.debug('Finding runs by status', { projectKey, status, options });
// Fetch all runs and filter by status
// This is inefficient but necessary since API doesn't support status filtering
const allRuns: DeepSourceRun[] = [];
let cursor: string | undefined;
let hasNextPage = true;
while (hasNextPage) {
const filterParams: { first: number; after?: string } = { first: 100 };
if (cursor !== undefined) {
filterParams.after = cursor;
}
const response = await this.client.listRuns(projectKey, filterParams);
const modelRuns = response.items.map(convertClientRunToModelRun);
allRuns.push(...modelRuns);
hasNextPage = response.pageInfo.hasNextPage;
cursor = response.pageInfo.endCursor;
}
// Map and filter by status
const mappedRuns = AnalysisRunMapper.toDomainList(allRuns, projectKey);
const filteredRuns = mappedRuns.filter((run) => run.status === status);
// Apply pagination
const start = (options.page - 1) * options.pageSize;
const paginatedRuns = filteredRuns.slice(start, start + options.pageSize);
const result: PaginatedResult<AnalysisRun> = {
items: paginatedRuns,
page: options.page,
pageSize: options.pageSize,
totalCount: filteredRuns.length,
totalPages: Math.ceil(filteredRuns.length / options.pageSize),
hasNextPage: start + options.pageSize < filteredRuns.length,
hasPreviousPage: options.page > 1,
};
logger.debug('Runs found by status', {
projectKey,
status,
count: paginatedRuns.length,
totalCount: filteredRuns.length,
});
return result;
} catch (error) {
logger.error('Error finding runs by status', { projectKey, status, error });
throw error;
}
}
/**
* Finds runs within a date range
*
* @param projectKey - The project key
* @param startDate - Start date (inclusive)
* @param endDate - End date (inclusive)
* @param options - Pagination options
* @returns Paginated analysis runs
*/
async findByDateRange(
projectKey: ProjectKey,
startDate: Date,
endDate: Date,
options: PaginationOptions
): Promise<PaginatedResult<AnalysisRun>> {
try {
logger.debug('Finding runs by date range', {
projectKey,
startDate,
endDate,
options,
});
// Fetch all runs and filter by date range
const allRuns: DeepSourceRun[] = [];
let cursor: string | undefined;
let hasNextPage = true;
while (hasNextPage) {
const filterParams: { first: number; after?: string } = { first: 100 };
if (cursor !== undefined) {
filterParams.after = cursor;
}
const response = await this.client.listRuns(projectKey, filterParams);
// Filter runs by date range
const runsInRange = response.items.filter((run) => {
const runDate = new Date(run.createdAt);
return runDate >= startDate && runDate <= endDate;
});
const modelRuns = runsInRange.map(convertClientRunToModelRun);
allRuns.push(...modelRuns);
// If we found runs outside the range, we can stop
if (response.items.length > 0) {
const oldestRun = response.items[response.items.length - 1];
if (oldestRun && new Date(oldestRun.createdAt) < startDate) {
break;
}
}
hasNextPage = response.pageInfo.hasNextPage;
cursor = response.pageInfo.endCursor;
}
// Map to domain models
const mappedRuns = AnalysisRunMapper.toDomainList(allRuns, projectKey);
// Apply pagination
const start = (options.page - 1) * options.pageSize;
const paginatedRuns = mappedRuns.slice(start, start + options.pageSize);
const result: PaginatedResult<AnalysisRun> = {
items: paginatedRuns,
page: options.page,
pageSize: options.pageSize,
totalCount: mappedRuns.length,
totalPages: Math.ceil(mappedRuns.length / options.pageSize),
hasNextPage: start + options.pageSize < mappedRuns.length,
hasPreviousPage: options.page > 1,
};
logger.debug('Runs found in date range', {
projectKey,
count: paginatedRuns.length,
totalCount: mappedRuns.length,
});
return result;
} catch (error) {
logger.error('Error finding runs by date range', {
projectKey,
startDate,
endDate,
error,
});
throw error;
}
}
/**
* Counts runs for a project
*
* @param projectKey - The project key
* @returns The total number of runs
*/
async countByProject(projectKey: ProjectKey): Promise<number> {
try {
logger.debug('Counting runs for project', { projectKey });
// Count all runs
let count = 0;
let cursor: string | undefined;
let hasNextPage = true;
while (hasNextPage) {
const filterParams: { first: number; after?: string } = { first: 100 };
if (cursor !== undefined) {
filterParams.after = cursor;
}
const response = await this.client.listRuns(projectKey, filterParams);
count += response.items.length;
hasNextPage = response.pageInfo.hasNextPage;
cursor = response.pageInfo.endCursor;
}
logger.debug('Run count for project', { projectKey, count });
return count;
} catch (error) {
logger.error('Error counting runs for project', { projectKey, error });
throw error;
}
}
/**
* Counts runs by status for a project
*
* @param projectKey - The project key
* @param status - The run status
* @returns The number of runs with the given status
*/
async countByStatus(projectKey: ProjectKey, status: RunStatus): Promise<number> {
try {
logger.debug('Counting runs by status', { projectKey, status });
// Fetch all runs and count by status
let count = 0;
let cursor: string | undefined;
let hasNextPage = true;
while (hasNextPage) {
const filterParams: { first: number; after?: string } = { first: 100 };
if (cursor !== undefined) {
filterParams.after = cursor;
}
const response = await this.client.listRuns(projectKey, filterParams);
// Map and count matching status
const modelRuns = response.items.map(convertClientRunToModelRun);
const mappedRuns = AnalysisRunMapper.toDomainList(modelRuns, projectKey);
count += mappedRuns.filter((run) => run.status === status).length;
hasNextPage = response.pageInfo.hasNextPage;
cursor = response.pageInfo.endCursor;
}
logger.debug('Run count by status', { projectKey, status, count });
return count;
} catch (error) {
logger.error('Error counting runs by status', { projectKey, status, error });
throw error;
}
}
/**
* Checks if a run exists for a commit
*
* @param projectKey - The project key
* @param commitOid - The commit OID
* @returns True if a run exists, false otherwise
*/
async existsForCommit(projectKey: ProjectKey, commitOid: CommitOid): Promise<boolean> {
try {
logger.debug('Checking if run exists for commit', { projectKey, commitOid });
const run = await this.findByCommit(projectKey, commitOid);
const exists = run !== null;
logger.debug('Run existence check for commit', { projectKey, commitOid, exists });
return exists;
} catch (error) {
logger.error('Error checking run existence for commit', { projectKey, commitOid, error });
throw error;
}
}
/**
* Saves an analysis run
*
* Note: The DeepSource API doesn't support creating/updating runs
* through the GraphQL API. This method is implemented to satisfy the
* interface but will throw an error indicating the operation is not supported.
*
* @param run - The run to save
* @throws Error indicating the operation is not supported
*/
// skipcq: JS-0105 - Repository method required by interface contract
async save(run: AnalysisRun): Promise<void> {
logger.warn('Attempted to save run', {
runId: run.runId,
projectKey: run.projectKey,
});
throw new Error(
'Save operation is not supported by DeepSource API. ' +
'Analysis runs are created automatically when code is pushed to the repository.'
);
}
/**
* Deletes an analysis run
*
* Note: The DeepSource API doesn't support deleting runs
* through the GraphQL API. This method is implemented to satisfy the
* interface but will throw an error indicating the operation is not supported.
*
* @param id - The run ID to delete
* @throws Error indicating the operation is not supported
*/
// skipcq: JS-0105 - Repository method required by interface contract
async delete(id: RunId): Promise<void> {
logger.warn('Attempted to delete run', { runId: id });
throw new Error(
'Delete operation is not supported by DeepSource API. ' +
'Analysis runs cannot be deleted through the API.'
);
}
}