deepsource-mcp-server
Version:
Model Context Protocol server for DeepSource
1,032 lines • 113 kB
JavaScript
import axios from 'axios';
import { createLogger } from './utils/logger.js';
import { ErrorCategory, createClassifiedError, classifyGraphQLError } from './utils/errors.js';
import { RunChecksProcessor } from './utils/graphql/processors/run-checks-processor.js';
import { MetricShortcode, MetricKey, MetricThresholdStatus, MetricDirection, } from './types/metrics.js';
/**
* @fileoverview DeepSource API client for interacting with the DeepSource service.
* This module exports interfaces and classes for working with the DeepSource API.
* @packageDocumentation
*/
// Interfaces and types below are exported as part of the public API
// Re-export quality metrics types
export { MetricShortcode, MetricDirection };
/**
* Available report types in DeepSource
* This enum combines both compliance-specific and general report types
* and is referenced in API functions like getComplianceReport() and handleDeepsourceComplianceReport().
* @public
*/
/* eslint-disable no-unused-vars */
export var ReportType;
(function (ReportType) {
// Compliance-specific report types
ReportType["OWASP_TOP_10"] = "OWASP_TOP_10";
ReportType["SANS_TOP_25"] = "SANS_TOP_25";
ReportType["MISRA_C"] = "MISRA_C";
// General report types
ReportType["CODE_COVERAGE"] = "CODE_COVERAGE";
ReportType["CODE_HEALTH_TREND"] = "CODE_HEALTH_TREND";
ReportType["ISSUE_DISTRIBUTION"] = "ISSUE_DISTRIBUTION";
ReportType["ISSUES_PREVENTED"] = "ISSUES_PREVENTED";
ReportType["ISSUES_AUTOFIXED"] = "ISSUES_AUTOFIXED";
})(ReportType || (ReportType = {}));
/* eslint-enable no-unused-vars */
/**
* Report status indicating whether the report is passing, failing, or not applicable
* This enum is exported as part of the public API for use in MCP tools
* and is referenced in handleDeepsourceComplianceReport().
* @public
*/
/* eslint-disable no-unused-vars */
export var ReportStatus;
(function (ReportStatus) {
ReportStatus["PASSING"] = "PASSING";
ReportStatus["FAILING"] = "FAILING";
ReportStatus["NOOP"] = "NOOP";
})(ReportStatus || (ReportStatus = {}));
/**
* Client for interacting with the DeepSource GraphQL API
* Provides methods for querying projects, issues, analysis runs, and dependency vulnerabilities
* Supports both legacy and Relay-style cursor-based pagination
* @class
*/
export class DeepSourceClient {
/**
* HTTP client for making API requests to DeepSource
* @private
*/
client;
/**
* Logger instance for the DeepSourceClient
* @private
*/
logger = createLogger('DeepSourceClient');
/**
* Static logger for static methods
* @private
*/
static logger = createLogger('DeepSourceClient:static');
/**
* Creates a new DeepSourceClient instance
* @param apiKey - The DeepSource API key for authentication
* @param options - Additional configuration options
* @param options.baseURL - Custom API endpoint URL (defaults to DeepSource production API)
* @param options.timeout - Request timeout in milliseconds (defaults to 30000ms)
* @throws {Error} When apiKey is not provided or invalid
* @throws {Error} When timeout is not a valid number
* @param apiKey - DeepSource API key for authentication
*/
constructor(apiKey) {
this.client = axios.create({
baseURL: 'https://api.deepsource.io/graphql/',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
}
/**
* Extracts error messages from GraphQL error response
* @param errors - Array of GraphQL error objects
* @returns Formatted error message string
* @private
*/
static extractErrorMessages(errors) {
const errorMessages = errors.map((error) => error.message);
return errorMessages.join(', ');
}
/**
* Process issues from the GraphQL response
* @private
*/
static processRunChecksResponse(response) {
return RunChecksProcessor.process(response);
}
/**
* Type guard to check if an unknown error is an Error object
* @param error The error to check
* @returns True if the error is an Error instance
* @private
*/
static isError(error) {
return (error !== null &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string');
}
/**
* Type guard to check if an error contains a specific message substring
* @param error The error to check
* @param substring The substring to search for in the error message
* @returns True if the error is an Error with the specified substring
* @private
*/
static isErrorWithMessage(error, substring) {
return this.isError(error) && error.message?.includes(substring);
}
/**
* Checks if an error is an Axios error with specific characteristics
* @param error The error to check
* @param statusCode Optional HTTP status code to match
* @param errorCode Optional Axios error code to match
* @returns True if the error matches the criteria and is an AxiosError, false otherwise
* @private
*/
static isAxiosErrorWithCriteria(error, statusCode, errorCode) {
// Check if it's an object first
if (!error || typeof error !== 'object') {
return false;
}
// Check if it has the axios error shape and matches all criteria
const potentialAxiosError = error;
return (Boolean(potentialAxiosError.isAxiosError) &&
(statusCode === undefined || potentialAxiosError.response?.status === statusCode) &&
(errorCode === undefined || potentialAxiosError.code === errorCode));
}
/**
* Handles GraphQL-specific errors from Axios responses
* @param error The error to check for GraphQL errors
* @returns True if the error was handled (and thrown)
* @private
*/
static handleGraphQLSpecificError(error) {
if (this.isAxiosErrorWithCriteria(error) &&
typeof error.response?.data === 'object' &&
error.response.data && // Add null check
'errors' in error.response.data) {
const graphqlErrors = error.response.data.errors; // Type assertion after validation
const errorMessage = DeepSourceClient.extractErrorMessages(graphqlErrors);
// Create a combined error message
const combinedError = new Error(`GraphQL Error: ${errorMessage}`);
// Classify the error
const category = classifyGraphQLError(combinedError);
throw createClassifiedError(combinedError.message, category, error, { graphqlErrors });
}
return false;
}
/**
* Handles network and connection errors
* @param error The error to check
* @returns True if the error was handled (and thrown)
* @private
*/
static handleNetworkError(error) {
if (this.isAxiosErrorWithCriteria(error, undefined, 'ECONNREFUSED')) {
throw createClassifiedError('Connection error: Unable to connect to DeepSource API', ErrorCategory.NETWORK, error);
}
if (this.isAxiosErrorWithCriteria(error, undefined, 'ETIMEDOUT')) {
throw createClassifiedError('Timeout error: DeepSource API request timed out', ErrorCategory.TIMEOUT, error);
}
return false;
}
/**
* Handles HTTP status-specific errors
* @param error The error to check
* @returns True if the error was handled (and thrown)
* @private
*/
static handleHttpStatusError(error) {
if (this.isAxiosErrorWithCriteria(error, 401)) {
throw createClassifiedError('Authentication error: Invalid or expired API key', ErrorCategory.AUTH, error);
}
if (this.isAxiosErrorWithCriteria(error, 429)) {
throw createClassifiedError('Rate limit exceeded: Too many requests to DeepSource API', ErrorCategory.RATE_LIMIT, error);
}
// Handle other common HTTP status codes
const axiosError = error;
if (axiosError.response?.status) {
const status = axiosError.response.status;
if (status >= 500) {
throw createClassifiedError(`Server error (${status}): DeepSource API server error`, ErrorCategory.SERVER, error);
}
if (status === 404) {
throw createClassifiedError('Not found (404): The requested resource was not found', ErrorCategory.NOT_FOUND, error);
}
if (status >= 400 && status < 500) {
throw createClassifiedError(`Client error (${status}): ${axiosError.response.statusText || 'Bad request'}`, ErrorCategory.CLIENT, error);
}
}
return false;
}
/**
* Handles generic errors
* @param error The error to process
* @returns Never returns, always throws
* @private
*/
static handleGenericError(error) {
if (DeepSourceClient.isError(error)) {
throw new Error(`DeepSource API error: ${error.message}`);
}
throw new Error('Unknown error occurred while communicating with DeepSource API');
}
/**
* Main error handler that coordinates all error processing
* @param error The error to handle
* @throws {Error} Appropriate error message based on error type
* @throws {Error} Classified error with category, original error, and additional metadata
* @private
*/
static handleGraphQLError(error) {
// If it's already a classified error, just throw it
if (error && typeof error === 'object' && 'category' in error) {
throw error;
}
// Try handling specific error types in order of specificity
if (this.handleGraphQLSpecificError(error)) {
// If handleGraphQLSpecificError returns true, it already threw an error
// This line will never be reached, but is needed for type checking
throw new Error('Unreachable code - handleGraphQLSpecificError should have thrown');
}
if (this.handleNetworkError(error)) {
throw new Error('Unreachable code - handleNetworkError should have thrown');
}
if (this.handleHttpStatusError(error)) {
throw new Error('Unreachable code - handleHttpStatusError should have thrown');
}
// If no specific handler worked, convert to a classified error
if (DeepSourceClient.isError(error)) {
const category = classifyGraphQLError(error);
throw createClassifiedError(`DeepSource API error: ${error.message}`, category, error);
}
// Last resort for truly unknown errors
throw createClassifiedError('Unknown error occurred while communicating with DeepSource API', ErrorCategory.OTHER, error);
}
/**
* Creates an empty paginated response
* @template T The type of items in the response
* @returns {PaginatedResponse<T>} Empty paginated response with consistent structure
* @private
*/
static createEmptyPaginatedResponse() {
return {
items: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: undefined,
endCursor: undefined,
},
totalCount: 0,
};
}
/**
* Logs a warning message about non-standard pagination usage
*
* This method provides consistent warning messages for pagination anti-patterns
* in Relay-style cursor-based pagination. It helps developers understand
* why their pagination approach might cause unexpected behavior.
*
* @param message Optional custom warning message to use instead of the default
* @private
*/
static logPaginationWarning(message) {
// Using the static logger instead of console.warn for better log management
const warningMessage = message ||
'Non-standard pagination: Using "last" without "before" is not recommended in Relay pagination';
DeepSourceClient.logger.warn(warningMessage);
}
/**
* Normalizes pagination parameters for GraphQL queries
* Ensures consistency in pagination parameters following Relay pagination best practices
*
* Normalization rules:
* 1. If 'before' is provided (backward pagination):
* - Use 'last' as the count parameter (default: 10)
* - Remove any 'first' parameter to avoid ambiguity
* 2. If 'last' is provided without 'before' (non-standard but supported):
* - Keep 'last' as is
* - Remove any 'first' parameter to avoid ambiguity
* - Log a warning about non-standard usage
* 3. Otherwise (forward pagination or defaults):
* - Use 'first' as the count parameter (default: 10)
* - Remove any 'last' parameter to avoid ambiguity
*
* @template T Type that extends PaginationParams
* @param {T} params - Original pagination parameters
* @returns {T} Normalized pagination parameters with consistent values
* @private
*/
static normalizePaginationParams(params) {
const normalizedParams = { ...params };
// Validate and normalize numerical parameters
if (normalizedParams.offset !== undefined) {
normalizedParams.offset = Math.max(0, Math.floor(Number(normalizedParams.offset)));
}
if (normalizedParams.first !== undefined) {
// Ensure first is a positive integer or undefined
normalizedParams.first = Math.max(1, Math.floor(Number(normalizedParams.first)));
}
if (normalizedParams.last !== undefined) {
// Ensure last is a positive integer or undefined
normalizedParams.last = Math.max(1, Math.floor(Number(normalizedParams.last)));
}
// Validate cursor parameters (ensure they're valid strings)
if (normalizedParams.after !== undefined && typeof normalizedParams.after !== 'string') {
normalizedParams.after = String(normalizedParams.after ?? '');
}
if (normalizedParams.before !== undefined && typeof normalizedParams.before !== 'string') {
normalizedParams.before = String(normalizedParams.before ?? '');
}
// Apply Relay pagination rules
if (normalizedParams.before) {
// When fetching backwards with 'before', prioritize 'last'
normalizedParams.last = normalizedParams.last ?? normalizedParams.first ?? 10;
normalizedParams.first = undefined;
}
else if (normalizedParams.last) {
// If 'last' is provided without 'before', log a warning but still use 'last'
DeepSourceClient.logPaginationWarning(`Non-standard pagination: Using "last=${normalizedParams.last}" without "before" cursor is not recommended`);
// Keep normalizedParams.last as is
normalizedParams.first = undefined;
}
else {
// Default or forward pagination with 'after', prioritize 'first'
normalizedParams.first = normalizedParams.first ?? 10;
normalizedParams.last = undefined;
}
return normalizedParams;
}
/**
* Fetches a list of all accessible DeepSource projects
* @returns Promise that resolves to an array of DeepSourceProject objects
* @throws {Error} When DeepSource API returns errors
* @throws {Error} When network or authentication issues occur
*/
async listProjects() {
try {
const viewerQuery = 'query {\n viewer {\n email\n accounts {\n edges {\n node {\n login\n repositories(first: 100) {\n edges {\n node {\n name\n defaultBranch\n dsn\n isPrivate\n isActivated\n vcsProvider\n }\n }\n }\n }\n }\n }\n }\n }\n';
const response = await this.client.post('', {
query: viewerQuery.trim(),
});
if (response.data.errors) {
const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors);
throw new Error(`GraphQL Errors: ${errorMessage}`);
}
const accounts = response.data.data?.viewer?.accounts?.edges ?? [];
const allRepos = [];
for (const { node: account } of accounts) {
const repos = account.repositories?.edges ?? [];
for (const { node: repo } of repos) {
if (!repo.dsn)
continue;
allRepos.push({
key: repo.dsn,
name: repo.name ?? 'Unnamed Repository',
repository: {
url: repo.dsn,
provider: repo.vcsProvider ?? 'N/A',
login: account.login,
isPrivate: repo.isPrivate ?? false,
isActivated: repo.isActivated ?? false,
},
});
}
}
return allRepos;
}
catch (error) {
if (DeepSourceClient.isErrorWithMessage(error, 'NoneType')) {
return [];
}
return DeepSourceClient.handleGraphQLError(error);
}
}
/**
* Fetches issues from a specified DeepSource project
* @param projectKey - The unique identifier for the DeepSource project
* @param params - Optional pagination and filtering parameters for the query.
* Supports both legacy pagination (offset) and Relay-style cursor-based pagination.
* For forward pagination use 'first' with optional 'after' cursor.
* For backward pagination use 'last' with optional 'before' cursor.
* Note: Using both 'first' and 'last' together is not recommended and will prioritize
* 'last' if 'before' is provided, otherwise will prioritize 'first'.
*
* When 'last' is provided without 'before', a warning will be logged, but the
* request will still be processed using 'last'. For standard Relay behavior,
* 'last' should always be accompanied by 'before'.
*
* Filtering parameters:
* - path: Filter issues by specific file path
* - analyzerIn: Filter issues by specific analyzers
* - tags: Filter issues by tags
* @returns Promise that resolves to a paginated response containing DeepSource issues
* @throws {Error} When project key is invalid or project doesn't exist
* @throws {Error} When DeepSource API returns errors
* @throws {Error} When network, authentication or permission issues occur
*/
async getIssues(projectKey, params = {}) {
try {
const projects = await this.listProjects();
const project = projects.find((p) => p.key === projectKey);
if (!project) {
return DeepSourceClient.createEmptyPaginatedResponse();
}
// Normalize pagination parameters using the static helper method
const normalizedParams = DeepSourceClient.normalizePaginationParams(params);
// Keeping template literal here since it contains a lot of variable references
// with complex GraphQL query structure. The benefits of converting to string
// concatenation would be outweighed by reduced readability
const repoQuery = 'query($login: String!, $name: String!, $provider: VCSProvider!, $offset: Int, $first: Int, $after: String, $before: String, $last: Int, $path: String, $analyzerIn: [String], $tags: [String]) {\n repository(login: $login, name: $name, vcsProvider: $provider) {\n name\n defaultBranch\n dsn\n isPrivate\n issues(offset: $offset, first: $first, after: $after, before: $before, last: $last, path: $path, analyzerIn: $analyzerIn, tags: $tags) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n totalCount\n edges {\n node {\n id\n issue {\n shortcode\n title\n category\n severity\n description\n tags\n }\n occurrences(first: 100) {\n edges {\n node {\n id\n path\n beginLine\n endLine\n beginColumn\n endColumn\n title\n }\n }\n }\n }\n }\n }\n }\n }\n';
const response = await this.client.post('', {
query: repoQuery.trim(),
variables: {
login: project.repository.login,
name: project.name,
provider: project.repository.provider,
offset: normalizedParams.offset,
first: normalizedParams.first,
after: normalizedParams.after,
before: normalizedParams.before,
last: normalizedParams.last,
path: normalizedParams.path,
analyzerIn: normalizedParams.analyzerIn,
tags: normalizedParams.tags,
},
});
if (response.data.errors) {
const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors);
throw new Error(`GraphQL Errors: ${errorMessage}`);
}
const issues = [];
const repoIssues = response.data.data?.repository?.issues?.edges ?? [];
const pageInfo = response.data.data?.repository?.issues?.pageInfo ?? {
hasNextPage: false,
hasPreviousPage: false,
};
const totalCount = response.data.data?.repository?.issues?.totalCount ?? 0;
for (const { node: repoIssue } of repoIssues) {
const occurrences = repoIssue.occurrences?.edges ?? [];
for (const { node: occurrence } of occurrences) {
issues.push({
id: occurrence.id ?? 'unknown',
shortcode: repoIssue.issue?.shortcode ?? '',
title: repoIssue.issue?.title ?? 'Untitled Issue',
category: repoIssue.issue?.category ?? 'UNKNOWN',
severity: repoIssue.issue?.severity ?? 'UNKNOWN',
status: 'OPEN',
issue_text: repoIssue.issue?.description ?? '',
file_path: occurrence.path ?? 'N/A',
line_number: occurrence.beginLine ?? 0,
tags: repoIssue.issue?.tags ?? [],
});
}
}
return {
items: issues,
pageInfo: {
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
startCursor: pageInfo.startCursor,
endCursor: pageInfo.endCursor,
},
totalCount,
};
}
catch (error) {
if (DeepSourceClient.isErrorWithMessage(error, 'NoneType')) {
return {
items: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
totalCount: 0,
};
}
return DeepSourceClient.handleGraphQLError(error);
}
}
/**
* Fetches a specific issue from a DeepSource project by its ID
* @param projectKey - The unique identifier for the DeepSource project
* @param issueId - The unique identifier of the issue to retrieve
* @returns Promise that resolves to the issue if found, or null if not found
* @throws {Error} When DeepSource API returns errors
* @throws {Error} When network, authentication or permission issues occur
*/
async getIssue(projectKey, issueId) {
try {
const result = await this.getIssues(projectKey);
const issue = result.items.find((issue) => issue.id === issueId);
return issue || null;
}
catch (error) {
return DeepSourceClient.handleGraphQLError(error);
}
}
/**
* Fetches analysis runs for a specified DeepSource project
* @param projectKey - The unique identifier for the DeepSource project
* @param params - Optional pagination and filtering parameters for the query
* Pagination supports both legacy pagination (offset) and Relay-style cursor-based pagination.
* Filtering parameters:
* - analyzerIn: Filter runs by specific analyzers
* @returns Promise that resolves to a paginated response containing DeepSource runs
* @throws {Error} When project key is invalid or project doesn't exist
* @throws {Error} When DeepSource API returns errors
* @throws {Error} When network, authentication or permission issues occur
*/
async listRuns(projectKey, params = {}) {
try {
const projects = await this.listProjects();
const project = projects.find((p) => p.key === projectKey);
if (!project) {
return DeepSourceClient.createEmptyPaginatedResponse();
}
// Normalize pagination parameters using the static helper method
const normalizedParams = DeepSourceClient.normalizePaginationParams(params);
const repoQuery = 'query($login: String!, $name: String!, $provider: VCSProvider!, $offset: Int, $first: Int, $after: String, $before: String, $last: Int, $analyzerIn: [String]) {\n repository(login: $login, name: $name, vcsProvider: $provider) {\n name\n id\n analysisRuns(offset: $offset, first: $first, after: $after, before: $before, last: $last) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n totalCount\n edges {\n node {\n id\n runUid\n commitOid\n branchName\n baseOid\n status\n createdAt\n updatedAt\n finishedAt\n summary {\n occurrencesIntroduced\n occurrencesResolved\n occurrencesSuppressed\n occurrenceDistributionByAnalyzer {\n analyzerShortcode\n introduced\n }\n occurrenceDistributionByCategory {\n category\n introduced\n }\n }\n repository {\n name\n id\n }\n checks(analyzerIn: $analyzerIn) {\n edges {\n node {\n analyzer {\n shortcode\n }\n }\n }\n }\n }\n }\n }\n }\n }\n';
const response = await this.client.post('', {
query: repoQuery.trim(),
variables: {
login: project.repository.login,
name: project.name,
provider: project.repository.provider,
offset: normalizedParams.offset,
first: normalizedParams.first,
after: normalizedParams.after,
before: normalizedParams.before,
last: normalizedParams.last,
analyzerIn: normalizedParams.analyzerIn,
},
});
if (response.data.errors) {
const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors);
throw new Error(`GraphQL Errors: ${errorMessage}`);
}
const runs = [];
const repoRuns = response.data.data?.repository?.analysisRuns?.edges ?? [];
const pageInfo = response.data.data?.repository?.analysisRuns?.pageInfo ?? {
hasNextPage: false,
hasPreviousPage: false,
};
const totalCount = response.data.data?.repository?.analysisRuns?.totalCount ?? 0;
for (const { node: run } of repoRuns) {
runs.push({
id: run.id,
runUid: run.runUid,
commitOid: run.commitOid,
branchName: run.branchName,
baseOid: run.baseOid,
status: run.status,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
finishedAt: run.finishedAt,
summary: {
occurrencesIntroduced: run.summary?.occurrencesIntroduced ?? 0,
occurrencesResolved: run.summary?.occurrencesResolved ?? 0,
occurrencesSuppressed: run.summary?.occurrencesSuppressed ?? 0,
occurrenceDistributionByAnalyzer: run.summary?.occurrenceDistributionByAnalyzer ?? [],
occurrenceDistributionByCategory: run.summary?.occurrenceDistributionByCategory ?? [],
},
repository: {
name: run.repository?.name ?? '',
id: run.repository?.id ?? '',
},
});
}
return {
items: runs,
pageInfo: {
hasNextPage: pageInfo.hasNextPage,
hasPreviousPage: pageInfo.hasPreviousPage,
startCursor: pageInfo.startCursor,
endCursor: pageInfo.endCursor,
},
totalCount,
};
}
catch (error) {
if (DeepSourceClient.isErrorWithMessage(error, 'NoneType')) {
return {
items: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
totalCount: 0,
};
}
return DeepSourceClient.handleGraphQLError(error);
}
}
/**
* Fetches a specific analysis run by ID or commit hash
* @param runIdentifier - The runUid or commitOid to identify the run
* @returns Promise that resolves to the run if found, or null if not found
* @throws {Error} When runIdentifier is invalid
* @throws {Error} When DeepSource API returns errors
* @throws {Error} When network, authentication or permission issues occur
*/
async getRun(runIdentifier) {
try {
// 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 runQuery = 'query($runUid: UUID, $commitOid: String) {\n run(runUid: $runUid, commitOid: $commitOid) {\n id\n runUid\n commitOid\n branchName\n baseOid\n status\n createdAt\n updatedAt\n finishedAt\n summary {\n occurrencesIntroduced\n occurrencesResolved\n occurrencesSuppressed\n occurrenceDistributionByAnalyzer {\n analyzerShortcode\n introduced\n }\n occurrenceDistributionByCategory {\n category\n introduced\n }\n }\n repository {\n name\n id\n }\n }\n }\n';
const response = await this.client.post('', {
query: runQuery.trim(),
variables: {
runUid: isUuid ? runIdentifier : null,
commitOid: !isUuid ? runIdentifier : null,
},
});
if (response.data.errors) {
// If the error is about not finding the run, return null
if (response.data.errors.some((e) => e.message.includes('not found') || e.message.includes('NoneType'))) {
return null;
}
throw new Error(`GraphQL Errors: ${response.data.errors.map((e) => e.message).join(', ')}`);
}
const run = response.data.data?.run;
if (!run) {
return null;
}
return {
id: run.id,
runUid: run.runUid,
commitOid: run.commitOid,
branchName: run.branchName,
baseOid: run.baseOid,
status: run.status,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
finishedAt: run.finishedAt,
summary: {
occurrencesIntroduced: run.summary?.occurrencesIntroduced ?? 0,
occurrencesResolved: run.summary?.occurrencesResolved ?? 0,
occurrencesSuppressed: run.summary?.occurrencesSuppressed ?? 0,
occurrenceDistributionByAnalyzer: run.summary?.occurrenceDistributionByAnalyzer ?? [],
occurrenceDistributionByCategory: run.summary?.occurrenceDistributionByCategory ?? [],
},
repository: {
name: run.repository?.name ?? '',
id: run.repository?.id ?? '',
},
};
}
catch (error) {
if (DeepSourceClient.isError(error) &&
(error.message.includes('NoneType') || error.message.includes('not found'))) {
return null;
}
return DeepSourceClient.handleGraphQLError(error);
}
}
/**
* Find the most recent run for a specific branch
* This includes runs that are still in progress
* @private
*/
async findMostRecentRun(projectKey, branchName) {
let mostRecentRun = null;
let cursor;
let hasNextPage = true;
while (hasNextPage) {
const runs = await this.listRuns(projectKey, {
first: 50,
after: cursor,
});
// Check each run in this page
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;
}
}
}
// Update pagination info
hasNextPage = runs.pageInfo.hasNextPage;
cursor = runs.pageInfo.endCursor;
}
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}'`);
}
return mostRecentRun;
}
/**
* Validates that a project exists
* @private
*/
async validateProject(projectKey) {
const projects = await this.listProjects();
const project = projects.find((p) => p.key === projectKey);
if (!project) {
this.logger.error(`Project with key ${projectKey} not found`);
throw new Error(`Project with key ${projectKey} not found`);
}
}
/**
* GraphQL query to get checks for a run
* @private
*/
static getChecksQuery = `
query($runId: UUID!, $first: Int, $after: String) {
run(runUid: $runId) {
checks(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
analyzer {
shortcode
}
}
}
}
}
}
`;
/**
* GraphQL query to get occurrences for a check
* @private
*/
static getOccurrencesQuery = `
query($checkId: ID!, $first: Int, $after: String) {
node(id: $checkId) {
... on Check {
id
occurrences(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
totalCount
edges {
node {
id
issue {
shortcode
title
category
severity
description
tags
}
path
beginLine
}
}
}
}
}
}
`;
/**
* Fetches all checks for a run
* @private
*/
async fetchAllChecks(runId) {
const allChecks = [];
const checksPerPage = 50;
let checksCursor;
let hasMoreChecks = true;
while (hasMoreChecks) {
const checksResponse = await this.client.post('', {
query: DeepSourceClient.getChecksQuery.trim(),
variables: {
runId,
first: checksPerPage,
after: checksCursor,
},
});
if (checksResponse.data.errors) {
const errorMessage = DeepSourceClient.extractErrorMessages(checksResponse.data.errors);
throw new Error(`GraphQL Errors: ${errorMessage}`);
}
const checks = checksResponse.data.data?.run?.checks?.edges ?? [];
for (const { node: check } of checks) {
allChecks.push({
id: check.id,
analyzerShortcode: check.analyzer?.shortcode || 'unknown',
});
}
const checksPageInfo = checksResponse.data.data?.run?.checks?.pageInfo;
hasMoreChecks = checksPageInfo?.hasNextPage || false;
checksCursor = checksPageInfo?.endCursor;
}
return allChecks;
}
/**
* Creates a DeepSourceIssue from an occurrence node
* @private
*/
static createIssueFromOccurrence(occurrence) {
if (!occurrence || !occurrence.issue)
return null;
const issue = occurrence.issue;
return {
id: occurrence.id ?? 'unknown',
shortcode: issue.shortcode ?? '',
title: issue.title ?? 'Untitled Issue',
category: issue.category ?? 'UNKNOWN',
severity: issue.severity ?? 'UNKNOWN',
status: 'OPEN',
issue_text: issue.description ?? '',
file_path: occurrence.path ?? 'N/A',
line_number: occurrence.beginLine ?? 0,
tags: issue.tags ?? [],
};
}
/**
* Fetches all occurrences for a single check
* @private
*/
async fetchOccurrencesForCheck(checkId) {
const issues = [];
const occurrencesPerPage = 100;
let occurrencesCursor;
let hasMoreOccurrences = true;
while (hasMoreOccurrences) {
const occurrencesResponse = await this.client.post('', {
query: DeepSourceClient.getOccurrencesQuery.trim(),
variables: {
checkId,
first: occurrencesPerPage,
after: occurrencesCursor,
},
});
if (occurrencesResponse.data.errors) {
const errorMessage = DeepSourceClient.extractErrorMessages(occurrencesResponse.data.errors);
throw new Error(`GraphQL Errors: ${errorMessage}`);
}
const nodeData = occurrencesResponse.data.data?.node;
if (nodeData) {
const occurrences = nodeData.occurrences?.edges ?? [];
for (const { node: occurrence } of occurrences) {
const issue = DeepSourceClient.createIssueFromOccurrence(occurrence);
if (issue) {
issues.push(issue);
}
}
const occurrencesPageInfo = nodeData.occurrences?.pageInfo;
hasMoreOccurrences = occurrencesPageInfo?.hasNextPage || false;
occurrencesCursor = occurrencesPageInfo?.endCursor;
}
else {
hasMoreOccurrences = false;
}
}
return issues;
}
/**
* Fetches all issues from the most recent analysis run on a specific branch
* This method automatically pages through all issues and returns them in a single response
* @param projectKey - The unique identifier for the DeepSource project
* @param branchName - The branch name to get the most recent run from
* @returns Promise that resolves to all issues from the most recent run
* @throws {Error} When no runs are found for the specified branch
* @throws {Error} When DeepSource API returns errors
* @throws {Error} When network, authentication or permission issues occur
*/
async getRecentRunIssues(projectKey, branchName) {
try {
this.logger.info(`getRecentRunIssues called for project: ${projectKey}, branch: ${branchName}`);
// Validate project exists
await this.validateProject(projectKey);
// Get runs for the project and find the most recent one for the branch
const mostRecentRun = await this.findMostRecentRun(projectKey, branchName);
this.logger.debug(`Found most recent run: ${mostRecentRun.runUid} for branch: ${branchName}`);
// Fetch all checks for the run
const allChecks = await this.fetchAllChecks(mostRecentRun.runUid);
this.logger.debug(`Found ${allChecks.length} checks for run ${mostRecentRun.runUid}`);
// Fetch all issues from all checks
const allIssues = [];
for (const check of allChecks) {
const checkIssues = await this.fetchOccurrencesForCheck(check.id);
allIssues.push(...checkIssues);
}
this.logger.debug(`Retrieved ${allIssues.length} total issues from run ${mostRecentRun.runUid}`);
return {
items: allIssues,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: undefined,
endCursor: undefined,
},
totalCount: allIssues.length,
run: mostRecentRun,
};
}
catch (error) {
return DeepSourceClient.handleGraphQLError(error);
}
}
/**
* Helper method to validate and process vulnerability node data
* Performs comprehensive validation on a vulnerability node from the GraphQL response
* to ensure all required fields are present and of the correct type.
*
* Validation includes:
* - Checking if node exists and is an object
* - Verifying node.id exists and is a string
* - Validating package, packageVersion, and vulnerability objects
* - Ensuring required fields exist within nested objects
*
* Validates a vulnerability node has the expected structure
*
* Performs deep validation of vulnerability data returned from DeepSource API,
* checking for required fields and proper structure at various levels.
* Logs detailed warnings for specific validation failures to aid in debugging.
*
* @param node The unknown object to validate as a vulnerability node
* @returns true if the node has valid structure, false otherwise
* @private
*/
static isValidVulnerabilityNode(node) {
// Validation logic defined inline
// Validate root level structure
if (!node || typeof node !== 'object') {
DeepSourceClient.logger.warn('Skipping invalid vulnerability node: not an object');
return false;
}
const record = node;
// Check if id exists and is a string (for backward compatibility with tests)
if (!('id' in record) || typeof record.id !== 'string') {
DeepSourceClient.logger.warn('Skipping vulnerability node with missing or invalid ID', node);
return false;
}
// Check for package field (for backward compatibility with tests)
if (!('package' in record) || typeof record.package !== 'object' || record.package === null) {
DeepSourceClient.logger.warn('Skipping vulnerability node with missing or invalid package', node);
return false;
}
// Check for packageVersion field (for backward compatibility with tests)
if (!('packageVersion' in record) ||
typeof record.packageVersion !== 'object' ||
record.packageVersion === null) {
DeepSourceClient.logger.warn('Skipping vulnerability node with missing or invalid packageVersion', node);
return false;
}
// Check for vulnerability field (for backward compatibility with tests)
if (!('vulnerability' in record) ||
typeof record.vulnerability !== 'object' ||
record.vulnerability === null) {
DeepSourceClient.logger.warn('Skipping vulnerability node with missing or invalid vulnerability', node);
return false;
}
// Now check the required fields in each nested object
const packageRecord = record.package;
const packageVersionRecord = record.packageVersion;
const vulnerabilityRecord = record.vulnerability;
// Package validations (for backward compatibility with tests)
if (!('id' in packageRecord) || !('ecosystem' in packageRecord) || !('name' in packageRecord)) {
DeepSourceClient.logger.warn('Skipping vulnerability with incomplete package information', packageRecord);
return false;
}
// PackageVersion validations (for backward compatibility with tests)
if (!('id' in packageVersionRecord) || !('version' in packageVersionRecord)) {
DeepSourceClient.logger.warn('Skipping vulnerability with incomplete package version information', packageVersionRecord);
return false;
}
// Vulnerability validations (for backward compatibility with tests)
if (!('id' in vulnerabilityRecord) || !('identifier' in vulnerabilityRecord)) {
DeepSourceClient.logger.warn('Skipping vulnerability with incomplete vulnerability information', vulnerabilityRecord);
return false;
}
return true;
}
/**
* Validates if a value is a valid PackageVersionType enum value
* @param value The value to validate
* @returns true if the value is a valid PackageVersionType enum value
* @private
*/
static isValidVersionType(value) {
const validVersionTypes = ['SEMVER', 'ECOSYSTEM', 'GIT'];
return DeepSourceClient.isValidEnum(value, validVersionTypes);
}
/**
* Maps raw package data to a Package object with proper validation
* @param packageData The raw package data from GraphQL
* @returns A properly formatted Package object
* @private
*/
static mapPackageData(packageData) {
return {
// Required fields with fallbacks to empty strings
id: DeepSourceClient.validateString(packageData.id),
ecosystem: DeepSourceClient.validateString(packageData.ecosystem),
name: DeepSourceClient.validateString(packageData.name),
// Optional URL field
purl: DeepSourceClient.validateNullableString(packageData.purl) ?? undefined,
};
}
/**
* Maps raw package version data to a PackageVersion object with proper validation
* @param versionData The raw package version data from GraphQL
* @returns A properly formatted PackageVersion object
* @private
*/
static mapPackageVersionData(versionData) {
return {
// Required fields with fallbacks to empty strings
id: DeepSourceClient.v