deepsource-mcp-server
Version:
Model Context Protocol server for DeepSource
1,432 lines (1,303 loc) • 120 kB
text/typescript
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