UNPKG

deepsource-mcp-server

Version:
1,032 lines 113 kB
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