UNPKG

deepsource-mcp-server

Version:
1,432 lines (1,303 loc) 120 kB
import axios, { AxiosError } 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, RepositoryMetric, RepositoryMetricItem, MetricSetting, UpdateMetricThresholdParams, UpdateMetricSettingParams, MetricThresholdUpdateResponse, MetricSettingUpdateResponse, MetricHistoryParams, MetricHistoryResponse, MetricHistoryValue, } 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 }; export type { MetricKey, MetricThresholdStatus, RepositoryMetric, RepositoryMetricItem, MetricSetting, UpdateMetricThresholdParams, UpdateMetricSettingParams, MetricThresholdUpdateResponse, MetricSettingUpdateResponse, MetricHistoryParams, MetricHistoryResponse, MetricHistoryValue, }; /** * 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 enum ReportType { // Compliance-specific report types OWASP_TOP_10 = 'OWASP_TOP_10', SANS_TOP_25 = 'SANS_TOP_25', MISRA_C = 'MISRA_C', // General report types CODE_COVERAGE = 'CODE_COVERAGE', CODE_HEALTH_TREND = 'CODE_HEALTH_TREND', ISSUE_DISTRIBUTION = 'ISSUE_DISTRIBUTION', ISSUES_PREVENTED = 'ISSUES_PREVENTED', ISSUES_AUTOFIXED = 'ISSUES_AUTOFIXED', } /* 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 enum ReportStatus { PASSING = 'PASSING', FAILING = 'FAILING', NOOP = 'NOOP', } /* eslint-enable no-unused-vars */ /** * Trend information for reports * @public */ export interface ReportTrend { label?: string; value?: number; changePercentage?: number; } /** * Severity distribution of issues * @public */ export interface SeverityDistribution { critical: number; major: number; minor: number; total: number; } /** * Security issue statistic * @public */ export interface SecurityIssueStat { key: string; title: string; occurrence: SeverityDistribution; } /** * Compliance report interface * @public */ export interface ComplianceReport { key: ReportType; title: string; currentValue?: number; status?: ReportStatus; securityIssueStats: SecurityIssueStat[]; trends?: ReportTrend[]; } /** * Represents a DeepSource project in the API * @public */ export interface DeepSourceProject { key: string; name: string; repository: { url: string; provider: string; login: string; isPrivate: boolean; isActivated: boolean; }; } /** * Represents an issue found by DeepSource analysis * @public */ export interface DeepSourceIssue { id: string; title: string; shortcode: string; category: string; severity: string; status: string; issue_text: string; file_path: string; line_number: number; tags: string[]; } /** * Distribution of occurrences by analyzer type * @public */ export interface OccurrenceDistributionByAnalyzer { analyzerShortcode: string; introduced: number; } /** * Distribution of occurrences by category * @public */ export interface OccurrenceDistributionByCategory { category: string; introduced: number; } /** * Summary of an analysis run, including counts of issues * @public */ export interface RunSummary { occurrencesIntroduced: number; occurrencesResolved: number; occurrencesSuppressed: number; occurrenceDistributionByAnalyzer?: OccurrenceDistributionByAnalyzer[]; occurrenceDistributionByCategory?: OccurrenceDistributionByCategory[]; } /** * Possible status values for an analysis run * Using a type instead of enum to avoid unused enum values linting errors * @public */ export type AnalysisRunStatus = | 'PENDING' | 'SUCCESS' | 'FAILURE' | 'TIMEOUT' | 'CANCEL' | 'READY' | 'SKIPPED'; /** * Represents a DeepSource analysis run * @public */ export interface DeepSourceRun { id: string; runUid: string; commitOid: string; branchName: string; baseOid: string; status: AnalysisRunStatus; createdAt: string; updatedAt: string; finishedAt?: string; summary: RunSummary; repository: { name: string; id: string; }; } /** * Possible severity levels for a vulnerability * Represents the qualitative assessment of the vulnerability's impact * @public */ export type VulnerabilitySeverity = /** No meaningful risk */ | 'NONE' /** Limited impact, typically requiring complex exploitation */ | 'LOW' /** Significant impact but with mitigating factors */ | 'MEDIUM' /** Serious impact with straightforward exploitation */ | 'HIGH' /** Critical impact with easy exploitation or catastrophic consequences */ | 'CRITICAL'; /** * Possible package version types * Defines how the version numbering scheme for a package should be interpreted * @public */ export type PackageVersionType = /** Semantic Versioning (major.minor.patch) */ | 'SEMVER' /** Ecosystem-specific versioning scheme */ | 'ECOSYSTEM' /** Git-based versioning (commit hashes or tags) */ | 'GIT'; /** * Possible reachability types for a vulnerability occurrence * Indicates whether the vulnerable code can be triggered in the codebase * @public */ export type VulnerabilityReachability = /** The vulnerability is reachable from execution paths in the code */ | 'REACHABLE' /** The vulnerability exists but is not reachable in execution paths */ | 'UNREACHABLE' /** Reachability could not be determined */ | 'UNKNOWN'; /** * Possible fixability types for a vulnerability occurrence * Indicates whether and how the vulnerability can be fixed * @public */ export type VulnerabilityFixability = /** An error occurred during fixability analysis */ | 'ERROR' /** The vulnerability cannot be fixed with current methods */ | 'UNFIXABLE' /** A fix is currently being generated */ | 'GENERATING_FIX' /** The vulnerability might be fixable but requires further analysis */ | 'POSSIBLY_FIXABLE' /** The vulnerability can be fixed manually following guidelines */ | 'MANUALLY_FIXABLE' /** The vulnerability can be fixed automatically */ | 'AUTO_FIXABLE'; /** * Represents a package in the DeepSource API * Contains information about a software package in a specific ecosystem * @public */ export interface Package { /** Unique identifier of the package */ id: string; /** Package ecosystem (e.g., 'NPM', 'PYPI', 'MAVEN') */ ecosystem: string; /** Package name as it appears in the ecosystem */ name: string; /** Package URL (optional) - follows the package URL specification (https://github.com/package-url/purl-spec) */ purl?: string; } /** * Represents a package version in the DeepSource API * Contains information about a specific version of a package * @public */ export interface PackageVersion { /** Unique identifier of the package version */ id: string; /** Version string (e.g., '1.2.3') */ version: string; /** Type of versioning used (SEMVER, ECOSYSTEM, GIT) */ versionType?: PackageVersionType; } /** * Represents a vulnerability in the DeepSource API * Contains detailed information about a security vulnerability * @public */ export interface Vulnerability { /** Unique identifier of the vulnerability */ id: string; /** Standard identifier for the vulnerability (e.g., CVE-2022-1234) */ identifier: string; /** Alternative identifiers for the same vulnerability (e.g., GHSA-xxxx-xxxx-xxxx) */ aliases: string[]; /** Brief description of the vulnerability */ summary?: string; /** Detailed description of the vulnerability */ details?: string; /** Date when the vulnerability was first published */ publishedAt: string; /** Date when the vulnerability information was last updated */ updatedAt: string; /** Date when the vulnerability was withdrawn (if applicable) */ withdrawnAt?: string; /** Overall severity rating of the vulnerability */ severity: VulnerabilitySeverity; // CVSS v2 information /** CVSS v2 vector string representing the vulnerability characteristics */ cvssV2Vector?: string; /** CVSS v2 base score (0.0-10.0) */ cvssV2BaseScore?: number; /** CVSS v2 qualitative severity rating */ cvssV2Severity?: VulnerabilitySeverity; // CVSS v3 information /** CVSS v3 vector string representing the vulnerability characteristics */ cvssV3Vector?: string; /** CVSS v3 base score (0.0-10.0) */ cvssV3BaseScore?: number; /** CVSS v3 qualitative severity rating */ cvssV3Severity?: VulnerabilitySeverity; // CVSS v4 information /** CVSS v4 vector string representing the vulnerability characteristics */ cvssV4Vector?: string; /** CVSS v4 base score (0.0-10.0) */ cvssV4BaseScore?: number; /** CVSS v4 qualitative severity rating */ cvssV4Severity?: VulnerabilitySeverity; // EPSS information /** Exploit Prediction Scoring System score (0.0-1.0) */ epssScore?: number; /** EPSS percentile, indicating relative likelihood of exploitation */ epssPercentile?: number; // Version information /** List of package versions where the vulnerability was introduced */ introducedVersions: string[]; /** List of package versions where the vulnerability was fixed */ fixedVersions: string[]; // References /** List of URLs to external references about this vulnerability */ referenceUrls: string[]; } /** * Represents a vulnerability occurrence in the DeepSource API * A vulnerability occurrence is an instance of a vulnerability affecting a specific package version * in a specific project context * @public */ export interface VulnerabilityOccurrence { /** Unique identifier of the vulnerability occurrence */ id: string; /** Information about the affected package */ package: Package; /** Information about the affected package version */ packageVersion: PackageVersion; /** Details about the vulnerability */ vulnerability: Vulnerability; /** Whether the vulnerability is reachable in the codebase (REACHABLE, UNREACHABLE, UNKNOWN) */ reachability: VulnerabilityReachability; /** Whether and how the vulnerability can be fixed */ fixability: VulnerabilityFixability; } /** * Parameters for paginating through API results * @public */ export interface PaginationParams { /** Legacy pagination: Number of items to skip */ offset?: number; /** Relay-style pagination: Number of items to return after the 'after' cursor */ first?: number; /** Relay-style pagination: Cursor to fetch records after this cursor */ after?: string; /** Relay-style pagination: Cursor to fetch records before this cursor */ before?: string; /** Relay-style pagination: Number of items to return before the 'before' cursor */ last?: number; } /** * Parameters for filtering issues * @public */ export interface IssueFilterParams extends PaginationParams { /** Filter issues by path (file path) */ path?: string; /** Filter issues by analyzer shortcodes (e.g. ["python", "javascript"]) */ analyzerIn?: string[]; /** Filter issues by tags */ tags?: string[]; } /** * Parameters for filtering runs * @public */ export interface RunFilterParams extends PaginationParams { /** Filter runs by analyzer shortcodes (e.g. ["python", "javascript"]) */ analyzerIn?: string[]; } /** * Generic response structure containing paginated results * @public * @template T - The type of items in the response */ export interface PaginatedResponse<T> { items: T[]; pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor?: string; endCursor?: string; }; totalCount: number; } /** * Response structure for recent run issues * @public */ export interface RecentRunIssuesResponse extends PaginatedResponse<DeepSourceIssue> { /** The most recent run for the branch */ run: DeepSourceRun; } /** * 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 */ private client; /** * Logger instance for the DeepSourceClient * @private */ private logger = createLogger('DeepSourceClient'); /** * Static logger for static methods * @private */ 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: string) { 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 */ private static extractErrorMessages(errors: Array<{ message: string }>): string { const errorMessages = errors.map((error) => error.message); return errorMessages.join(', '); } /** * Process issues from the GraphQL response * @private */ private static processRunChecksResponse(response: unknown): { issues: DeepSourceIssue[]; pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor: string | undefined; endCursor: string | undefined; }; totalCount: number; } { 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 */ private static isError(error: unknown): error is Error { return ( error !== null && typeof error === 'object' && 'message' in error && typeof (error as Record<string, unknown>).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 */ private static isErrorWithMessage(error: unknown, substring: string): error is Error { 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 */ private static isAxiosErrorWithCriteria( error: unknown, statusCode?: number, errorCode?: string ): error is AxiosError { // 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 as Partial<AxiosError>; 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 */ private static handleGraphQLSpecificError(error: unknown): never | false { if ( this.isAxiosErrorWithCriteria(error) && typeof error.response?.data === 'object' && error.response.data && // Add null check 'errors' in error.response.data ) { const graphqlErrors: Array<{ message: string }> = error.response.data.errors as Array<{ message: string; }>; // 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 */ private static handleNetworkError(error: unknown): never | false { 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 */ private static handleHttpStatusError(error: unknown): never | false { 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 as AxiosError; 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 */ private static handleGenericError(error: unknown): never { 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 */ private static handleGraphQLError(error: Error | unknown): never { // 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 */ private static createEmptyPaginatedResponse<T>(): PaginatedResponse<T> { 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 */ private static logPaginationWarning(message?: string): void { // 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 */ private static normalizePaginationParams<T extends PaginationParams>(params: T): T { 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(): Promise<DeepSourceProject[]> { 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: DeepSourceProject[] = []; 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: string, params: IssueFilterParams = {} ): Promise<PaginatedResponse<DeepSourceIssue>> { try { const projects = await this.listProjects(); const project = projects.find((p) => p.key === projectKey); if (!project) { return DeepSourceClient.createEmptyPaginatedResponse<DeepSourceIssue>(); } // 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: DeepSourceIssue[] = []; 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: string, issueId: string): Promise<DeepSourceIssue | null> { 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: string, params: RunFilterParams = {} ): Promise<PaginatedResponse<DeepSourceRun>> { try { const projects = await this.listProjects(); const project = projects.find((p) => p.key === projectKey); if (!project) { return DeepSourceClient.createEmptyPaginatedResponse<DeepSourceRun>(); } // 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: DeepSourceRun[] = []; 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: string): Promise<DeepSourceRun | null> { 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: { message: string }) => e.message.includes('not found') || e.message.includes('NoneType') ) ) { return null; } throw new Error( `GraphQL Errors: ${response.data.errors.map((e: { message: string }) => 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 */ private async findMostRecentRun(projectKey: string, branchName: string): Promise<DeepSourceRun> { let mostRecentRun: DeepSourceRun | null = null; let cursor: string | undefined; 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 */ private async validateProject(projectKey: string): Promise<void> { 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 */ 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 */ 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 */ private async fetchAllChecks( runId: string ): Promise<Array<{ id: string; analyzerShortcode: string }>> { const allChecks: Array<{ id: string; analyzerShortcode: string }> = []; const checksPerPage = 50; let checksCursor: string | undefined; 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 */ private static createIssueFromOccurrence( occurrence: Record<string, unknown> ): DeepSourceIssue | null { if (!occurrence || !occurrence.issue) return null; const issue = occurrence.issue as Record<string, unknown>; return { id: (occurrence.id as string) ?? 'unknown', shortcode: (issue.shortcode as string) ?? '', title: (issue.title as string) ?? 'Untitled Issue', category: (issue.category as string) ?? 'UNKNOWN', severity: (issue.severity as string) ?? 'UNKNOWN', status: 'OPEN', issue_text: (issue.description as string) ?? '', file_path: (occurrence.path as string) ?? 'N/A', line_number: (occurrence.beginLine as number) ?? 0, tags: (issue.tags as string[]) ?? [], }; } /** * Fetches all occurrences for a single check * @private */ private async fetchOccurrencesForCheck(checkId: string): Promise<DeepSourceIssue[]> { const issues: DeepSourceIssue[] = []; const occurrencesPerPage = 100; let occurrencesCursor: string | undefined; let hasMoreOccurrences = true; while (hasMoreOccurrences) { const o