/**
* @fileoverview Runs client for the DeepSource API
* This module provides functionality for working with DeepSource analysis runs.
*/
import { BaseDeepSourceClient } from './base-client.js';
import { DeepSourceRun, RunFilterParams, AnalysisRunStatus } from '../models/runs.js';
import { DeepSourceIssue } from '../models/issues.js';
import { PaginatedResponse } from '../utils/pagination/types.js';
import { isErrorWithMessage } from '../utils/errors/handlers.js';
import {
BranchName,
asGraphQLNodeId,
asRunId,
asCommitOid,
asBranchName,
} from '../types/branded.js';
/**
* Response type for recent run issues query
* @public
*/
export interface RecentRunIssuesResponse {
run: DeepSourceRun | null;
items: DeepSourceIssue[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor?: string | null;
endCursor?: string | null;
};
totalCount: number;
}
/**
* Client for interacting with DeepSource runs API
* @class
* @extends BaseDeepSourceClient
* @public
*/
export class RunsClient extends BaseDeepSourceClient {
/**
* Fetches analysis runs from a DeepSource project with optional filtering
* @param projectKey The project key to fetch runs for
* @param params Optional filter parameters
* @returns Promise that resolves to a paginated list of runs
* @throws {ClassifiedError} When the API request fails
* @public
*/
async listRuns(
projectKey: string,
params: RunFilterParams = {}
): Promise<PaginatedResponse<DeepSourceRun>> {
try {
this.logger.info('Fetching runs from DeepSource API', {
projectKey,
hasFilters: Object.keys(params).length > 0,
});
const project = await this.findProjectByKey(projectKey);
if (!project) {
return BaseDeepSourceClient.createEmptyPaginatedResponse<DeepSourceRun>();
}
const normalizedParams = BaseDeepSourceClient.normalizePaginationParams(params);
const query = RunsClient.buildRunsQuery();
const response = await this.executeGraphQL(query, {
login: project.repository.login,
name: project.repository.name,
provider: project.repository.provider,
...normalizedParams,
});
if (!response.data) {
throw new Error('No data received from GraphQL API');
}
const runs = this.extractRunsFromResponse(response.data);
this.logger.info('Successfully fetched runs', {
count: runs.length,
totalCount: runs.length, // Note: Would need cursor-based pagination for accurate total
});
return {
items: runs,
pageInfo: {
hasNextPage: false, // Simplified for now
hasPreviousPage: false,
},
totalCount: runs.length,
};
} catch (error) {
return this.handleRunsError(error);
}
}
/**
* Fetches a specific run by identifier (runUid or commitOid)
* @param runIdentifier The run UID or commit OID
* @returns Promise that resolves to the run if found, null otherwise
* @public
*/
async getRun(runIdentifier: string): Promise<DeepSourceRun | null> {
try {
this.logger.info('Fetching specific run', { runIdentifier });
// Determine if the identifier is a UUID or a commit hash
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
runIdentifier
);
const query = isUuid ? RunsClient.buildRunByUidQuery() : RunsClient.buildRunByCommitQuery();
const response = await this.executeGraphQL(query, {
[isUuid ? 'runUid' : 'commitOid']: runIdentifier,
});
if (!response.data) {
return null;
}
const run = this.extractSingleRunFromResponse(response.data);
if (!run) {
return null;
}
this.logger.info('Successfully fetched run', {
runUid: run.runUid,
status: run.status,
});
return run;
} catch (error) {
if (isErrorWithMessage(error, 'NoneType') || isErrorWithMessage(error, 'not found')) {
return null;
}
throw error;
}
}
/**
* Finds the most recent run for a specific branch
* @param projectKey The project key
* @param branchName The branch name to search for
* @returns Promise that resolves to the most recent run if found, null otherwise
* @public
*/
async findMostRecentRunForBranch(
projectKey: string,
branchName: string
): Promise<DeepSourceRun | null> {
try {
this.logger.info('Finding most recent run for branch', { projectKey, branchName });
let mostRecentRun: DeepSourceRun | null = null;
let hasNextPage = true;
let after: string | undefined;
// Paginate through runs to find the most recent for the specific branch
while (hasNextPage) {
const params: { first: number; after?: string } = {
first: 50,
};
if (after !== undefined) {
params.after = after;
}
const runs = await this.listRuns(projectKey, params);
for (const run of runs.items) {
if (run.branchName === branchName) {
// If this is the first matching run or it's more recent than our current most recent
if (!mostRecentRun || new Date(run.createdAt) > new Date(mostRecentRun.createdAt)) {
mostRecentRun = run;
}
}
}
hasNextPage = runs.pageInfo.hasNextPage;
after = runs.pageInfo.endCursor || undefined;
}
if (!mostRecentRun) {
this.logger.error(`No runs found for branch '${branchName}' in project '${projectKey}'`);
throw new Error(`No runs found for branch '${branchName}' in project '${projectKey}'`);
}
this.logger.info('Found most recent run for branch', {
runUid: mostRecentRun.runUid,
branchName: mostRecentRun.branchName,
createdAt: mostRecentRun.createdAt,
});
return mostRecentRun;
} catch (error) {
this.logger.error('Error finding most recent run for branch', {
projectKey,
branchName,
error,
});
throw error;
}
}
/**
* Fetches issues from the most recent run on a specific branch
* @param projectKey The project key
* @param branchName The branch name
* @returns Promise that resolves to recent run issues response
* @public
*/
async getRecentRunIssues(
projectKey: string,
branchName: BranchName,
paginationParams?: { first?: number; after?: string; last?: number; before?: string }
): Promise<RecentRunIssuesResponse> {
try {
this.logger.info('Fetching recent run issues', { projectKey, branchName });
// First find the most recent run for the branch
const mostRecentRun = await this.findMostRecentRunForBranch(projectKey, branchName);
if (!mostRecentRun) {
return {
run: null,
items: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
totalCount: 0,
};
}
// Now fetch the issues for this specific run
const runIssuesQuery = RunsClient.buildRunOccurrencesQuery();
const requestedLimit = paginationParams?.first ?? 100;
// To avoid fetching too much data, limit both checks and occurrences
// If user wants 100 issues total, we might fetch from up to 10 checks with 10 issues each
const checksLimit = Math.min(10, Math.ceil(requestedLimit / 10));
const occurrencesPerCheck = Math.ceil(requestedLimit / checksLimit);
const response = await this.executeGraphQL(runIssuesQuery, {
runUid: mostRecentRun.runUid,
first: occurrencesPerCheck,
checksFirst: checksLimit,
});
if (!response.data) {
this.logger.warn('No data received for run occurrences');
return {
run: mostRecentRun,
items: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
totalCount: 0,
};
}
const issues = this.extractIssuesFromRunResponse(response.data, requestedLimit);
this.logger.info('Successfully fetched run issues', {
runUid: mostRecentRun.runUid,
issueCount: issues.length,
requestedLimit,
});
return {
run: mostRecentRun,
items: issues,
pageInfo: { hasNextPage: issues.length >= requestedLimit, hasPreviousPage: false },
totalCount: issues.length,
};
} catch (error) {
this.logger.error('Error fetching recent run issues', {
projectKey,
branchName,
error,
});
throw error;
}
}
/**
* Builds the GraphQL query for fetching runs
* @private
*/
private static buildRunsQuery(): string {
return `
query getRepositoryRuns(
$login: String!
$name: String!
$provider: VCSProvider!
$first: Int
$after: String
) {
repository(login: $login, name: $name, vcsProvider: $provider) {
runs(first: $first, after: $after) {
edges {
node {
id
runUid
commitOid
branchName
baseOid
status
createdAt
updatedAt
finishedAt
summary {
occurrencesIntroduced
occurrencesResolved
occurrencesSuppressed
}
repository {
name
id
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
`;
}
/**
* Builds GraphQL query for fetching a run by UID
* @private
*/
private static buildRunByUidQuery(): string {
return `
query getRunByUid($runUid: UUID!) {
run(runUid: $runUid) {
id
runUid
commitOid
branchName
baseOid
status
createdAt
updatedAt
finishedAt
summary {
occurrencesIntroduced
occurrencesResolved
occurrencesSuppressed
}
repository {
name
id
}
}
}
`;
}
/**
* Builds GraphQL query for fetching a run by commit OID
* @private
*/
private static buildRunByCommitQuery(): string {
return `
query getRunByCommit($commitOid: String!) {
runByCommit(commitOid: $commitOid) {
id
runUid
commitOid
branchName
baseOid
status
createdAt
updatedAt
finishedAt
summary {
occurrencesIntroduced
occurrencesResolved
occurrencesSuppressed
}
repository {
name
id
}
}
}
`;
}
/**
* Extracts runs from GraphQL response
* @private
*/
private extractRunsFromResponse(responseData: unknown): DeepSourceRun[] {
const runs: DeepSourceRun[] = [];
try {
const repository = (responseData as Record<string, unknown>)?.repository as Record<
string,
unknown
>;
const repositoryRuns = repository?.runs as Record<string, unknown>;
const repoRuns = (repositoryRuns?.edges ?? []) as Array<Record<string, unknown>>;
for (const { node: run } of repoRuns) {
const runNode = run as Record<string, unknown>;
runs.push(RunsClient.mapRunNode(runNode));
}
} catch (error) {
this.logger.error('Error extracting runs from response', { error });
}
return runs;
}
/**
* Extracts a single run from GraphQL response
* @private
*/
private extractSingleRunFromResponse(responseData: unknown): DeepSourceRun | null {
try {
const data = responseData as Record<string, unknown>;
const runNode = data?.run || data?.runByCommit;
if (!runNode || typeof runNode !== 'object') {
return null;
}
return RunsClient.mapRunNode(runNode as Record<string, unknown>);
} catch (error) {
this.logger.error('Error extracting single run from response', { error });
return null;
}
}
/**
* Maps a run node from GraphQL to DeepSourceRun
* @private
*/
private static mapRunNode(runNode: Record<string, unknown>): DeepSourceRun {
const summary = (runNode.summary as Record<string, unknown>) || {};
const repository = (runNode.repository as Record<string, unknown>) || {};
return {
id: asGraphQLNodeId(String(runNode.id ?? '')),
runUid: asRunId(String(runNode.runUid ?? '')),
commitOid: asCommitOid(String(runNode.commitOid ?? '')),
branchName: asBranchName(String(runNode.branchName ?? '')),
baseOid: asCommitOid(String(runNode.baseOid ?? '')),
status: String(runNode.status ?? 'UNKNOWN') as AnalysisRunStatus,
createdAt: String(runNode.createdAt ?? ''),
updatedAt: String(runNode.updatedAt ?? ''),
finishedAt: String(runNode.finishedAt ?? ''),
summary: {
occurrencesIntroduced: Number(summary.occurrencesIntroduced ?? 0),
occurrencesResolved: Number(summary.occurrencesResolved ?? 0),
occurrencesSuppressed: Number(summary.occurrencesSuppressed ?? 0),
},
repository: {
name: String(repository.name ?? ''),
id: asGraphQLNodeId(String(repository.id ?? '')),
},
};
}
/**
* Builds GraphQL query for fetching run occurrences
* @private
*/
private static buildRunOccurrencesQuery(): string {
return `
query getRunOccurrences($runUid: UUID!, $first: Int, $checksFirst: Int) {
run(runUid: $runUid) {
checks(first: $checksFirst) {
edges {
node {
analyzer {
shortcode
}
occurrences(first: $first) {
edges {
node {
id
issue {
id
shortcode
title
category
severity
}
path
beginLine
issueText
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
}
/**
* Extracts issues from run occurrences response
* @private
*/
private extractIssuesFromRunResponse(responseData: unknown, limit?: number): DeepSourceIssue[] {
const issues: DeepSourceIssue[] = [];
const maxIssues = limit ?? Number.MAX_SAFE_INTEGER;
try {
const run = (responseData as Record<string, unknown>)?.run as Record<string, unknown>;
const checks =
((run?.checks as Record<string, unknown>)?.edges as Array<Record<string, unknown>>) || [];
for (const checkEdge of checks) {
if (issues.length >= maxIssues) break;
const checkNode = checkEdge?.node as Record<string, unknown>;
const occurrences =
((checkNode?.occurrences as Record<string, unknown>)?.edges as Array<
Record<string, unknown>
>) || [];
for (const occurrenceEdge of occurrences) {
if (issues.length >= maxIssues) break;
const occurrence = occurrenceEdge?.node as Record<string, unknown>;
const issue = occurrence?.issue as Record<string, unknown>;
if (occurrence && issue) {
issues.push({
id: String(occurrence.id ?? 'unknown'),
title: String(issue.title ?? 'Unknown Issue'),
shortcode: String(issue.shortcode ?? 'UNKNOWN'),
category: String(issue.category ?? 'UNKNOWN'),
severity: String(issue.severity ?? 'UNKNOWN'),
status: 'OPEN', // Occurrences in a run are typically open issues
issue_text: String(occurrence.issueText ?? ''),
file_path: String(occurrence.path ?? ''),
line_number: Number(occurrence.beginLine ?? 0),
tags: [], // Tags would need to be fetched separately if needed
});
}
}
}
} catch (error) {
this.logger.error('Error extracting issues from run response', { error });
}
return issues;
}
/**
* Handles errors during runs fetching
* @private
*/
private handleRunsError(error: unknown): PaginatedResponse<DeepSourceRun> {
this.logger.error('Error in listRuns', {
errorType: typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
});
// Handle special case where no runs exist
if (isErrorWithMessage(error, 'NoneType')) {
return {
items: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
totalCount: 0,
};
}
throw error;
}
}