Skip to main content
Glama
deepsource.ts123 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'; import { asGraphQLNodeId, asRunId, asCommitOid, asBranchName, asAnalyzerShortcode, GraphQLNodeId, RunId, CommitOid, BranchName, AnalyzerShortcode, } from './types/branded.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 */ 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', } /** * 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 */ export enum ReportStatus { PASSING = 'PASSING', FAILING = 'FAILING', NOOP = 'NOOP', } /** * 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: AnalyzerShortcode; 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: GraphQLNodeId; runUid: RunId; commitOid: CommitOid; branchName: BranchName; baseOid: CommitOid; status: AnalysisRunStatus; createdAt: string; updatedAt: string; finishedAt?: string; summary: RunSummary; repository: { name: string; id: GraphQLNodeId; }; } /** * 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 DeepSourceClient.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 ( DeepSourceClient.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 (DeepSourceClient.isAxiosErrorWithCriteria(error, undefined, 'ECONNREFUSED')) { throw createClassifiedError( 'Connection error: Unable to connect to DeepSource API', ErrorCategory.NETWORK, error ); } if (DeepSourceClient.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 (DeepSourceClient.isAxiosErrorWithCriteria(error, 401)) { throw createClassifiedError( 'Authentication error: Invalid or expired API key', ErrorCategory.AUTH, error ); } if (DeepSourceClient.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> { const pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor?: string; endCursor?: string; } = { hasNextPage: false, hasPreviousPage: false, }; return { items: [], pageInfo, 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; delete normalizedParams.first; } 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 delete normalizedParams.first; } else { // Default or forward pagination with 'after', prioritize 'first' normalizedParams.first = normalizedParams.first ?? 10; delete normalizedParams.last; } 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: asGraphQLNodeId(run.id), runUid: asRunId(run.runUid), commitOid: asCommitOid(run.commitOid), branchName: asBranchName(run.branchName), baseOid: asCommitOid(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?.map( (dist: Record<string, unknown>) => ({ analyzerShortcode: asAnalyzerShortcode(String(dist.analyzerShortcode)), introduced: Number(dist.introduced), }) ) ?? [], occurrenceDistributionByCategory: run.summary?.occurrenceDistributionByCategory ?? [], }, repository: { name: run.repository?.name ?? '', id: asGraphQLNodeId(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: asGraphQLNodeId(run.id), runUid: asRunId(run.runUid), commitOid: asCommitOid(run.commitOid), branchName: asBranchName(run.branchName), baseOid: asCommitOid(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?.map((dist: Record<string, unknown>) => ({ analyzerShortcode: asAnalyzerShortcode(String(dist.analyzerShortcode)), introduced: Number(dist.introduced), })) ?? [], occurrenceDistributionByCategory: run.summary?.occurrenceDistributionByCategory ?? [], }, repository: { name: run.repository?.name ?? '', id: asGraphQLNodeId(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 runParams: RunFilterParams = { first: 50 }; if (cursor) { runParams.after = cursor; } const runs = await this.listRuns(projectKey, runParams); // 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: String(occurrence.id ?? 'unknown'), shortcode: String(issue.shortcode ?? ''), title: String(issue.title ?? 'Untitled Issue'), category: String(issue.category ?? 'UNKNOWN'), severity: String(issue.severity ?? 'UNKNOWN'), status: 'OPEN', issue_text: String(issue.description ?? ''), file_path: String(occurrence.path ?? 'N/A'), line_number: Number(occurrence.beginLine ?? 0), tags: Array.isArray(issue.tags) ? issue.tags : [], }; } /** * 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 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 as Record<string, unknown> ); 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: string, branchName: string ): Promise<RecentRunIssuesResponse> { 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: DeepSourceIssue[] = []; 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}` ); const pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor?: string; endCursor?: string; } = { hasNextPage: false, hasPreviousPage: false, }; return { items: allIssues, pageInfo, 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 */ private static isValidVulnerabilityNode(node: unknown): boolean { // 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 as Record<string, unknown>; // 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 as Record<string, unknown>; const packageVersionRecord = record.packageVersion as Record<string, unknown>; const vulnerabilityRecord = record.vulnerability as Record<string, unknown>; // 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 */ private static isValidVersionType(value: unknown): value is PackageVersionType { const validVersionTypes: PackageVersionType[] = ['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 */ private static mapPackageData(packageData: Record<string, unknown>): Package { const result: Package = { // 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 const purl = DeepSourceClient.validateNullableString(packageData.purl); if (purl) { result.purl = purl; } return result; } /** * 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 */ private static mapPackageVersionData(versionData: Record<string, unknown>): PackageVersion { const result: PackageVersion = { // Required fields with fallbacks to empty strings id: DeepSourceClient.validateString(versionData.id), version: DeepSourceClient.validateString(versionData.version), }; // Optional enum field with validation if (DeepSourceClient.isValidVersionType(versionData.versionType)) { result.versionType = versionData.versionType as PackageVersionType; } return result; } /** * Type guard to validate if a value is a valid string enum value * * This generic helper function checks if an unknown value is both a string * and one of the specified valid enum values. It serves as a TypeScript type guard, * narrowing the type to the specific enum type when validation passes. * * Used throughout the codebase to ensure type safety when working with string * enum values that might come from external sources like API responses. * * @example * ```typescript * // Define an array of valid severities * const validSeverities: VulnerabilitySeverity[] = ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; * * // Check if a value is a valid severity * if (isValidEnum(severity, validSeverities)) { * // TypeScript knows severity is of type VulnerabilitySeverity here * processSeverity(severity); * } else { * // Handle invalid severity * handleInvalidValue(severity); * } * ``` * * @param value - The value to validate * @param validValues - Array of valid enum values * @returns Type predicate indicating whether the value is a valid enum value * @typeParam T - The specific enum type to check for * @private */ private static isValidEnum<T extends string>(value: unknown, validValues: T[]): value is T { return typeof value === 'string' && validValues.includes(value as T); } /** * Validates and sanitizes a potentially unknown value as a string array * * This utility function ensures that a value of unknown type is safely * handled as a string array. If the value is already an array, it is returned * unchanged. If the value is any other type, an empty array is returned * instead, preventing type errors at runtime. * * Used primarily for processing GraphQL responses where field types * might not match expectations due to schema changes or API inconsistencies. * * @example * ```typescript * // With a valid array * const tags = validateArray(issue.tags); // Returns the tags array as is * * // With a non-array value * const tags = validateArray(null); // Returns empty array [] * ``` * * @param value - The value to validate as a string array * @returns The original array if valid, or an empty array if invalid * @private */ private static validateArray(value: unknown): string[] { return Array.isArray(value) ? value : []; } /** * Validates and sanitizes a potentially unknown value as a string * * This utility function ensures that a value of unknown type is safely * handled as a string. If the value is already a string, it is returned * unchanged. If the value is any other type, a default value is returned * instead, preventing type errors at runtime. * * Used primarily for processing GraphQL responses where field types * might not match expectations due to schema changes or API inconsistencies. * * @example * ```typescript * // With a valid string * const name = validateString(user.name); // Returns the name as is * * // With a non-string value * const name = validateString(null); // Returns empty string * * // With a custom default * const name = validateString(undefined, 'Unknown User'); // Returns 'Unknown User' * ``` * * @param value - The value to validate as a string * @param defaultValue - Default value to return if invalid (defaults to empty string) * @returns The original string if valid, or the default value if invalid * @private */ private static validateString(value: unknown, defaultValue = '') { return typeof value === 'string' ? value : defaultValue; } /** * Validates and sanitizes a potentially unknown value as a nullable string * * This utility function ensures that a value of unknown type is safely * handled as a string or null. If the value is already a string, it is returned * unchanged. If the value is any other type, null is returned instead, allowing * the code to explicitly handle missing or invalid values. * * Used primarily for processing GraphQL responses where fields can be null * and require special handling different from default empty strings. * * @example * ```typescript * // With a valid string * const description = validateNullableString(issue.description); // Returns the description as is * * // With a non-string value * const description = validateNullableString(null); // Returns null * * // Sample usage with nullish coalescing operator * const description = validateNullableString(issue.description) ?? 'No description provided'; * ``` * * @param value - The value to validate as a nullable string * @returns The original string if valid, or null if invalid * @private */ private static validateNullableString(value: unknown): string | null { return typeof value === 'string' ? value : null; } /** * Validates and sanitizes a potentially unknown value as a nullable number * * This utility function ensures that a value of unknown type is safely * handled as a number or null. If the value is already a number, it is returned * unchanged. If the value is any other type, null is returned instead, allowing * the code to explicitly handle missing or invalid numerical values. * * Used primarily for processing GraphQL responses where numerical fields * might be missing or have unexpected types. * * @example * ```typescript * // With a valid number * const score = validateNumber(vulnerability.cvssV3BaseScore); // Returns the score as is * * // With a non-number value * const score = validateNumber(null); // Returns null * * // Sample usage with nullish coalescing operator * const score = validateNumber(vulnerability.cvssV3BaseScore) ?? 0; * ``` * * @param value - The value to validate as a nullable number * @returns The original number if valid, or null if invalid * @private */ private static validateNumber(value: unknown): number | null { return typeof value === 'number' ? value : null; } /** * Maps raw vulnerability data to a Vulnerability object with proper validation * @param vulnData The raw vulnerability data from GraphQL * @returns A properly formatted Vulnerability object * @private */ private static mapVulnerabilityData(vulnData: Record<string, unknown>): Vulnerability { // Check if severity is valid const isValidSeverity = (value: unknown): value is VulnerabilitySeverity => { const validSeverities: VulnerabilitySeverity[] = [ 'NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL', ]; return typeof value === 'string' && validSeverities.includes(value as VulnerabilitySeverity); }; const result: Vulnerability = { // Required fields with fallbacks to empty strings id: DeepSourceClient.validateString(vulnData.id), identifier: DeepSourceClient.validateString(vulnData.identifier), // Optional array fields with validation aliases: DeepSourceClient.validateArray(vulnData.aliases), introducedVersions: DeepSourceClient.validateArray(vulnData.introducedVersions), fixedVersions: DeepSourceClient.validateArray(vulnData.fixedVersions), referenceUrls: DeepSourceClient.validateArray(vulnData.referenceUrls), // Required date fields with fallbacks publishedAt: DeepSourceClient.validateString(vulnData.publishedAt), updatedAt: DeepSourceClient.validateString(vulnData.updatedAt), // Severity with validation severity: isValidSeverity(vulnData.severity) ? vulnData.severity : 'NONE', }; // Optional string fields const summary = DeepSourceClient.validateNullableString(vulnData.summary); if (summary) { result.summary = summary; } const details = DeepSourceClient.validateNullableString(vulnData.details); if (details) { result.details = details; } const withdrawnAt = DeepSourceClient.validateNullableString(vulnData.withdrawnAt); if (withdrawnAt) { result.withdrawnAt = withdrawnAt; } // CVSSv2 fields with validation const cvssV2Vector = DeepSourceClient.validateNullableString(vulnData.cvssV2Vector); if (cvssV2Vector) { result.cvssV2Vector = cvssV2Vector; } if (typeof vulnData.cvssV2BaseScore === 'number') { result.cvssV2BaseScore = vulnData.cvssV2BaseScore; } if (isValidSeverity(vulnData.cvssV2Severity)) { result.cvssV2Severity = vulnData.cvssV2Severity; } // CVSSv3 fields with validation const cvssV3Vector = DeepSourceClient.validateNullableString(vulnData.cvssV3Vector); if (cvssV3Vector) { result.cvssV3Vector = cvssV3Vector; } if (typeof vulnData.cvssV3BaseScore === 'number') { result.cvssV3BaseScore = vulnData.cvssV3BaseScore; } if (isValidSeverity(vulnData.cvssV3Severity)) { result.cvssV3Severity = vulnData.cvssV3Severity; } // CVSSv4 fields with validation const cvssV4Vector = DeepSourceClient.validateNullableString(vulnData.cvssV4Vector); if (cvssV4Vector) { result.cvssV4Vector = cvssV4Vector; } if (typeof vulnData.cvssV4BaseScore === 'number') { result.cvssV4BaseScore = vulnData.cvssV4BaseScore; } if (isValidSeverity(vulnData.cvssV4Severity)) { result.cvssV4Severity = vulnData.cvssV4Severity; } // EPSS fields with validation if (typeof vulnData.epssScore === 'number') { result.epssScore = vulnData.epssScore; } if (typeof vulnData.epssPercentile === 'number') { result.epssPercentile = vulnData.epssPercentile; } return result; } /** * Validates if a value is a valid VulnerabilityReachability enum value * @param value The value to validate * @returns true if the value is a valid VulnerabilityReachability enum value * @private */ private static isValidReachability(value: unknown): value is VulnerabilityReachability { const validReachabilityValues: VulnerabilityReachability[] = [ 'REACHABLE', 'UNREACHABLE', 'UNKNOWN', ]; return DeepSourceClient.isValidEnum(value, validReachabilityValues); } /** * Validates if a value is a valid VulnerabilityFixability enum value * @param value The value to validate * @returns true if the value is a valid VulnerabilityFixability enum value * @private */ private static isValidFixability(value: unknown): value is VulnerabilityFixability { const validFixabilityValues: VulnerabilityFixability[] = [ 'ERROR', 'UNFIXABLE', 'GENERATING_FIX', 'POSSIBLY_FIXABLE', 'MANUALLY_FIXABLE', 'AUTO_FIXABLE', ]; return DeepSourceClient.isValidEnum(value, validFixabilityValues); } /** * Maps a raw vulnerability node to a VulnerabilityOccurrence object * @param node The raw vulnerability node from GraphQL * @returns A properly formatted VulnerabilityOccurrence object * @private */ private static mapVulnerabilityOccurrence( node: Record<string, unknown> ): VulnerabilityOccurrence { return { id: String(node.id), package: DeepSourceClient.mapPackageData(node.package as Record<string, unknown>), packageVersion: DeepSourceClient.mapPackageVersionData( node.packageVersion as Record<string, unknown> ), vulnerability: DeepSourceClient.mapVulnerabilityData( node.vulnerability as Record<string, unknown> ), // Enum values with validation reachability: DeepSourceClient.isValidReachability(node.reachability) ? node.reachability : 'UNKNOWN', fixability: DeepSourceClient.isValidFixability(node.fixability) ? node.fixability : 'ERROR', }; } /** * Maximum number of iterations for vulnerability processing * Used to prevent infinite loops in case of malformed data * @private */ private static readonly MAX_ITERATIONS = 10000; /** * Process a single vulnerability edge and return a valid vulnerability occurrence if possible * * @param edge The edge object from the GraphQL response * @returns A vulnerability occurrence object if valid, or null if invalid * @private */ private static processVulnerabilityEdge(edge: unknown): VulnerabilityOccurrence | null { // Skip if edge is missing or not an object if (!edge || typeof edge !== 'object') { return null; } // Ensure edge is a properly typed object const typedEdge = edge as Record<string, unknown>; // Skip if node is missing if (!typedEdge.node) { return null; } // Validate node before processing if (DeepSourceClient.isValidVulnerabilityNode(typedEdge.node)) { // Now that validation passed, we can safely cast and map the node return DeepSourceClient.mapVulnerabilityOccurrence(typedEdge.node as Record<string, unknown>); } return null; } /** * Memory-efficient iterator for processing vulnerabilities * Allows for streaming processing of vulnerability data rather than building the entire array at once * * Includes protections against: * - Malformed or missing data (with detailed logging) * - Infinite loops (with iteration limit) * - Exceptionally large data sets (with memory-efficient processing) * * Generator function that safely processes vulnerability edges from GraphQL response * * This method provides robust iteration over API response data with the following safety features: * - Validates input data structure before processing * - Limits maximum iterations to prevent infinite loops with malformed data * - Handles and logs errors for individual items without failing the entire process * - Implements yield pattern for memory efficiency with large datasets * * @param edges Array of raw vulnerability edges from GraphQL response * @yields Valid VulnerabilityOccurrence objects * @private */ private static *iterateVulnerabilities(edges: unknown[]): Generator<VulnerabilityOccurrence> { // Sanity check for edges if (!Array.isArray(edges)) { DeepSourceClient.logger.warn('Invalid edges data: expected an array but got', typeof edges); return; // Early return - nothing to iterate } // Safety counter to prevent infinite loops in case of malformed data let iterationCount = 0; for (const edge of edges) { // Iteration safety check if (iterationCount++ > DeepSourceClient.MAX_ITERATIONS) { DeepSourceClient.logger.warn( `Exceeded maximum iteration count (${DeepSourceClient.MAX_ITERATIONS}). Stopping processing.` ); break; } try { const vulnerability = DeepSourceClient.processVulnerabilityEdge(edge); if (vulnerability) { yield vulnerability; } } catch (error) { // Log error but continue processing other edges DeepSourceClient.logger.warn('Error processing vulnerability edge:', error); continue; } } } /** * Safely accesses a nested property in an object of unknown structure * * This utility function provides type-safe access to deeply nested properties in objects * with unknown or complex structures, such as GraphQL responses. It traverses the object * along the given property path, handling potential null/undefined values at each step * to prevent runtime errors. * * Features: * - Type-safe property access with strong TypeScript typing * - Graceful handling of undefined/null values at any depth * - Optional validation of the final value * - Generic return type for proper type inference * * @example * ```typescript * // Basic usage * const name = getNestedProperty<string>( * response, * ['data', 'user', 'profile', 'name'] * ); * * // With validation * const age = getNestedProperty<number>( * response, * ['data', 'user', 'profile', 'age'], * (value) => typeof value === 'number' && value > 0 * ); * ``` * * @param obj - The root object to traverse * @param propPath - Array of property names to access in sequence * @param validator - Optional function to validate the final value * @returns The value at the specified path with the requested type, or undefined if any part of the path is invalid or validation fails * @typeParam T - The expected type of the nested property value * @private */ private static getNestedProperty<T>( obj: unknown, propPath: string[], validator?: (value: unknown) => boolean ): T | undefined { // Start with the root object let current: unknown = obj; // Navigate through the property path for (const prop of propPath) { // Ensure we have an object to access properties from if (!current || typeof current !== 'object') { return undefined; } // Get the current property and continue current = (current as Record<string, unknown>)[prop]; } // Validate the final value if a validator is provided // The validator function uses its parameter to validate the value if (validator && !validator(current)) { return undefined; } return current as T; } /** * Processes GraphQL response and extracts vulnerability occurrences * Handles the extraction and validation of vulnerability data from a GraphQL response. * * This method: * 1. Extracts edges, page info, and total count from the response * 2. Iterates through each edge and validates the node data * 3. Maps valid nodes to VulnerabilityOccurrence objects * 4. Collects and returns processed data in a structured format * * Optimized for large datasets with memory-efficient processing * * @param response The raw GraphQL response from the DeepSource API * @returns Object containing the vulnerabilities, page info, and total count * @private */ private static processVulnerabilityResponse(response: unknown): { vulnerabilities: VulnerabilityOccurrence[]; pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor?: string; endCursor?: string; }; totalCount: number; } { // Default values are used directly in the return statement for empty results // Safely extract the vulnerability edges using the helper function const vulnEdges = DeepSourceClient.getNestedProperty<unknown[]>( response, ['data', 'data', 'repository', 'dependencyVulnerabilityOccurrences', 'edges'], Array.isArray ) || []; // Extract the page info data const pageInfoData = DeepSourceClient.getNestedProperty<Record<string, unknown>>( response, ['data', 'data', 'repository', 'dependencyVulnerabilityOccurrences', 'pageInfo'], (value): value is Record<string, unknown> => value !== null && typeof value === 'object' ); // Create the page info object with type-safe property access const pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor?: string; endCursor?: string; } = pageInfoData ? (() => { const info: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor?: string; endCursor?: string; } = { hasNextPage: Boolean(pageInfoData.hasNextPage), hasPreviousPage: Boolean(pageInfoData.hasPreviousPage), }; if (typeof pageInfoData.startCursor === 'string') { info.startCursor = pageInfoData.startCursor; } if (typeof pageInfoData.endCursor === 'string') { info.endCursor = pageInfoData.endCursor; } return info; })() : { hasNextPage: false, hasPreviousPage: false, }; // Safely extract the total count const totalCount = DeepSourceClient.getNestedProperty<number>( response, ['data', 'data', 'repository', 'dependencyVulnerabilityOccurrences', 'totalCount'], (value): value is number => typeof value === 'number' ); // Early return for empty results to avoid unnecessary processing if (vulnEdges.length === 0) { return { vulnerabilities: [], pageInfo, totalCount: totalCount ?? 0, }; } // Process the vulnerability edges const vulnerabilities: VulnerabilityOccurrence[] = []; // Use the iterator for memory-efficient processing for (const vulnerability of DeepSourceClient.iterateVulnerabilities(vulnEdges)) { vulnerabilities.push(vulnerability); } return { vulnerabilities, pageInfo, totalCount: totalCount ?? 0, }; } /** * Creates the GraphQL query for vulnerability data * @returns Formatted GraphQL query string * @private */ private static buildVulnerabilityQuery(): string { return 'query($login: String!, $name: String!, $provider: VCSProvider!, $offset: Int, $first: Int, $after: String, $before: String, $last: Int) {\n repository(login: $login, name: $name, vcsProvider: $provider) {\n name\n id\n dependencyVulnerabilityOccurrences(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 reachability\n fixability\n package {\n id\n ecosystem\n name\n purl\n }\n packageVersion {\n id\n version\n versionType\n }\n vulnerability {\n id\n identifier\n aliases\n summary\n details\n publishedAt\n updatedAt\n withdrawnAt\n severity\n cvssV2Vector\n cvssV2BaseScore\n cvssV2Severity\n cvssV3Vector\n cvssV3BaseScore\n cvssV3Severity\n cvssV4Vector\n cvssV4BaseScore\n cvssV4Severity\n epssScore\n epssPercentile\n introducedVersions\n fixedVersions\n referenceUrls\n }\n }\n }\n }\n }\n }\n'.trim(); } /** * Handle different types of errors that can occur during vulnerability queries * @param error The error to process * @param projectKey The project key that was being queried * @returns Never returns - always throws with a descriptive error message * @private */ private static handleVulnerabilityError(error: Error, projectKey: string): never { // Classify the error const category = classifyGraphQLError(error); // Create appropriate error message based on category switch (category) { case ErrorCategory.SCHEMA: throw createClassifiedError(`GraphQL schema error: ${error.message}`, category, error, { projectKey, }); case ErrorCategory.AUTH: throw createClassifiedError( `Access denied: You don't have permission to access project '${projectKey}'`, category, error, { projectKey } ); case ErrorCategory.RATE_LIMIT: throw createClassifiedError( 'Rate limit exceeded: Please retry after a short delay', category, error, { projectKey } ); case ErrorCategory.TIMEOUT: throw createClassifiedError( 'Request timeout: The vulnerability data query took too long to complete. Try querying with pagination.', category, error, { projectKey } ); case ErrorCategory.NETWORK: throw createClassifiedError( 'Network error: Unable to connect to DeepSource API. Please check your network connection.', category, error, { projectKey } ); case ErrorCategory.SERVER: throw createClassifiedError( 'Server error: DeepSource API is experiencing issues. Please try again later.', category, error, { projectKey } ); case ErrorCategory.NOT_FOUND: throw createClassifiedError( 'Resource not found: The requested data could not be found.', category, error, { projectKey } ); default: // For uncategorized errors, wrap them with additional context throw createClassifiedError( `Unexpected error: ${error.message}`, ErrorCategory.OTHER, error, { projectKey } ); } } /** * Validate a project key and throw an error if it's invalid * @param projectKey The project key to validate * @throws Error if the project key is invalid * @private */ private static validateProjectKey(projectKey: string): void { if (!projectKey || typeof projectKey !== 'string') { throw new Error('Invalid project key: Project key must be a non-empty string'); } } /** * Validate a DeepSource project has all required repository information * @param project The project to validate * @param projectKey The original project key (for error message) * @throws Error if the project has invalid repository information * @private */ private static validateProjectRepository(project: DeepSourceProject, projectKey: string): void { if (!project.repository || !project.repository.login || !project.repository.provider) { throw new Error(`Invalid repository information for project '${projectKey}'`); } } /** * Fetches dependency vulnerabilities from a specified DeepSource project * Retrieves a paginated list of vulnerabilities identified in the project's dependencies * * This method supports both legacy (offset-based) and Relay-style (cursor-based) pagination: * - For forward pagination, use 'first' with optional 'after' cursor * - For backward pagination, use 'last' with optional 'before' cursor * * The response includes: * - Detailed vulnerability information with CVSS scores * - Package and version information for affected dependencies * - Reachability information (whether vulnerable code paths are executable) * - Fixability status (whether and how the vulnerability can be addressed) * * @param projectKey - The unique identifier for the DeepSource project * @param params - Optional pagination parameters for the query * @returns Promise that resolves to a paginated response containing vulnerability occurrences * @throws Error if the project key is invalid, the project doesn't exist, or API communication fails */ async getDependencyVulnerabilities( projectKey: string, params: PaginationParams = {} ): Promise<PaginatedResponse<VulnerabilityOccurrence>> { try { // Validate project key DeepSourceClient.validateProjectKey(projectKey); // Use Promise.all to fetch projects and normalize parameters concurrently const [projects, normalizedParams] = await Promise.all([ this.listProjects(), Promise.resolve(DeepSourceClient.normalizePaginationParams(params)), ]); const project = projects.find((p) => p.key === projectKey); if (!project) { return DeepSourceClient.createEmptyPaginatedResponse<VulnerabilityOccurrence>(); } // Validate repository information DeepSourceClient.validateProjectRepository(project, projectKey); // Get the GraphQL query for vulnerability data const repoQuery = DeepSourceClient.buildVulnerabilityQuery(); // Execute the query const response = await this.client.post('', { query: repoQuery, 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, }, }); if (response.data.errors) { const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors); throw new Error(`GraphQL Errors: ${errorMessage}`); } // Process the response and extract vulnerabilities with our optimized method const { vulnerabilities, pageInfo, totalCount } = DeepSourceClient.processVulnerabilityResponse(response); const responsePageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor?: string; endCursor?: string; } = { hasNextPage: pageInfo.hasNextPage, hasPreviousPage: pageInfo.hasPreviousPage, }; if (pageInfo.startCursor !== undefined) { responsePageInfo.startCursor = pageInfo.startCursor; } if (pageInfo.endCursor !== undefined) { responsePageInfo.endCursor = pageInfo.endCursor; } return { items: vulnerabilities, pageInfo: responsePageInfo, totalCount, }; } catch (error) { // Handle known error types if (DeepSourceClient.isError(error)) { // Handle NoneType errors (common in Python-based GraphQL APIs) if (DeepSourceClient.isErrorWithMessage(error, 'NoneType')) { return { items: [], pageInfo: { hasNextPage: false, hasPreviousPage: false, }, totalCount: 0, }; } // Handle specific error types DeepSourceClient.handleVulnerabilityError(error, projectKey); } // Fall back to the generic GraphQL error handler return DeepSourceClient.handleGraphQLError(error); } } /** * Fetches quality metrics from a specified DeepSource project * Retrieves metrics like code coverage, documentation coverage, etc. with their thresholds and current values * * @param projectKey - The unique identifier for the DeepSource project * @param options - Optional filter for specific metric shortcodes * @returns Promise that resolves to an array of repository metrics * @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 getQualityMetrics( projectKey: string, options: { shortcodeIn?: MetricShortcode[] } = {} ): Promise<RepositoryMetric[]> { try { // Validate project key DeepSourceClient.validateProjectKey(projectKey); // Fetch project information const projects = await this.listProjects(); const project = projects.find((p) => p.key === projectKey); if (!project) { return []; } // Validate repository information DeepSourceClient.validateProjectRepository(project, projectKey); // Build the metrics query const metricsQuery = 'query($login: String!, $name: String!, $provider: VCSProvider!, $shortcodeIn: [MetricShortcode]) {\n repository(login: $login, name: $name, vcsProvider: $provider) {\n name\n id\n metrics(shortcodeIn: $shortcodeIn) {\n name\n shortcode\n description\n positiveDirection\n unit\n minValueAllowed\n maxValueAllowed\n isReported\n isThresholdEnforced\n items {\n id\n key\n threshold\n latestValue\n latestValueDisplay\n thresholdStatus\n }\n }\n }\n }\n'; // Execute the query const response = await this.client.post('', { query: metricsQuery.trim(), variables: { login: project.repository.login, name: project.name, provider: project.repository.provider, shortcodeIn: options.shortcodeIn || null, }, }); if (response.data.errors) { const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors); throw new Error(`GraphQL Errors: ${errorMessage}`); } // Extract and format metrics data const metrics = response.data.data?.repository?.metrics || []; return metrics.map((metricItem: unknown) => { const metricRecord = metricItem as Record<string, unknown>; return { name: String(metricRecord.name || ''), shortcode: String(metricRecord.shortcode || ''), description: String(metricRecord.description || ''), positiveDirection: String(metricRecord.positiveDirection || 'UPWARD'), unit: String(metricRecord.unit || ''), minValueAllowed: Number(metricRecord.minValueAllowed), maxValueAllowed: Number(metricRecord.maxValueAllowed), isReported: Boolean(metricRecord.isReported), isThresholdEnforced: Boolean(metricRecord.isThresholdEnforced), items: ((metricRecord.items as unknown[]) || []).map((metricItemData: unknown) => { const itemRecord = metricItemData as Record<string, unknown>; return { id: String(itemRecord.id || ''), key: String(itemRecord.key || 'AGGREGATE'), threshold: itemRecord.threshold == null ? null : Number(itemRecord.threshold), latestValue: itemRecord.latestValue == null ? null : Number(itemRecord.latestValue), latestValueDisplay: String(itemRecord.latestValueDisplay || ''), thresholdStatus: String(itemRecord.thresholdStatus || ''), }; }), }; }); } catch (error) { // Handle errors if (DeepSourceClient.isError(error)) { if (DeepSourceClient.isErrorWithMessage(error, 'NoneType')) { return []; } } return DeepSourceClient.handleGraphQLError(error); } } /** * Sets a threshold for a specific metric in a repository * * @param params - The parameters for updating the threshold * @returns Promise that resolves to a response indicating the success of the operation * @throws {Error} When parameters are invalid * @throws {Error} When DeepSource API returns errors * @throws {Error} When network, authentication or permission issues occur */ async setMetricThreshold( params: UpdateMetricThresholdParams ): Promise<MetricThresholdUpdateResponse> { try { // Build the mutation query const thresholdMutation = 'mutation($repositoryId: ID!, $metricShortcode: MetricShortcode!, $metricKey: MetricKey!, $thresholdValue: Int) {\n setRepositoryMetricThreshold(input: {\n repositoryId: $repositoryId,\n metricShortcode: $metricShortcode, \n metricKey: $metricKey, \n thresholdValue: $thresholdValue\n }) {\n ok\n }\n }\n'; // Execute the mutation const response = await this.client.post('', { query: thresholdMutation.trim(), variables: { repositoryId: params.repositoryId, metricShortcode: params.metricShortcode, metricKey: params.metricKey, thresholdValue: params.thresholdValue, }, }); if (response.data.errors) { const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors); throw new Error(`GraphQL Errors: ${errorMessage}`); } return { ok: Boolean(response.data.data?.setRepositoryMetricThreshold?.ok), }; } catch (error) { return DeepSourceClient.handleGraphQLError(error); } } /** * Updates the setting for a metric in a repository * This can enable/disable reporting and threshold enforcement * * @param params - The parameters for updating the metric settings * @returns Promise that resolves to a response indicating the success of the operation * @throws {Error} When parameters are invalid * @throws {Error} When DeepSource API returns errors * @throws {Error} When network, authentication or permission issues occur */ async updateMetricSetting( params: UpdateMetricSettingParams ): Promise<MetricSettingUpdateResponse> { try { // Build the mutation query const settingMutation = 'mutation($repositoryId: ID!, $metricShortcode: MetricShortcode!, $isReported: Boolean!, $isThresholdEnforced: Boolean!) {\n updateRepositoryMetricSetting(input: {\n repositoryId: $repositoryId,\n metricShortcode: $metricShortcode, \n isReported: $isReported, \n isThresholdEnforced: $isThresholdEnforced\n }) {\n ok\n }\n }\n'; // Execute the mutation const response = await this.client.post('', { query: settingMutation.trim(), variables: { repositoryId: params.repositoryId, metricShortcode: params.metricShortcode, isReported: params.isReported, isThresholdEnforced: params.isThresholdEnforced, }, }); if (response.data.errors) { const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors); throw new Error(`GraphQL Errors: ${errorMessage}`); } return { ok: Boolean(response.data.data?.updateRepositoryMetricSetting?.ok), }; } catch (error) { return DeepSourceClient.handleGraphQLError(error); } } /** * Fetches security compliance reports from a DeepSource project * @param projectKey - The unique identifier for the DeepSource project * @param reportType - The type of report to fetch (OWASP_TOP_10, SANS_TOP_25, or MISRA_C) * @returns Promise that resolves to a compliance report with security stats * @throws Error if the project key is invalid, report type is unsupported, or API request fails * @public */ async getComplianceReport( projectKey: string, reportType: ReportType ): Promise<ComplianceReport | null> { try { // Validate project key DeepSourceClient.validateProjectKey(projectKey); // Validate report type is a compliance report if ( reportType !== ReportType.OWASP_TOP_10 && reportType !== ReportType.SANS_TOP_25 && reportType !== ReportType.MISRA_C ) { throw new Error( `Invalid report type: ${reportType}. Must be one of OWASP_TOP_10, SANS_TOP_25, or MISRA_C` ); } // Fetch project information const projects = await this.listProjects(); const project = projects.find((p) => p.key === projectKey); if (!project) { return null; } // Validate repository information DeepSourceClient.validateProjectRepository(project, projectKey); // Build the compliance report query using string concatenation // Only use template literal for the dynamic field name const fieldName = DeepSourceClient.getReportField(reportType); const reportQuery = ` query($login: String!, $name: String!, $provider: VCSProvider!) { repository(login: $login, name: $name, vcsProvider: $provider) { name id reports { ${fieldName} { key title currentValue status securityIssueStats { key title occurrence { critical major minor total } } trends { label value changePercentage } } } } }`; // Execute the query const response = await this.client.post('', { query: reportQuery.trim(), variables: { login: project.repository.login, name: project.name, provider: project.repository.provider, }, }); if (response.data.errors) { const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors); throw new Error(`GraphQL Errors: ${errorMessage}`); } // Extract the report data from the response const reportData = DeepSourceClient.extractReportData(response, reportType); if (!reportData) { return null; } const report: ComplianceReport = { key: reportType, title: typeof reportData.title === 'string' ? reportData.title : DeepSourceClient.getTitleForReportType(reportType), securityIssueStats: Array.isArray(reportData.securityIssueStats) ? (reportData.securityIssueStats as SecurityIssueStat[]) : [], }; if (typeof reportData.currentValue === 'number') { report.currentValue = reportData.currentValue; } if (typeof reportData.status === 'string') { report.status = reportData.status as ReportStatus; } if (Array.isArray(reportData.trends)) { report.trends = reportData.trends as ReportTrend[]; } return report; } catch (error) { if ( DeepSourceClient.isError(error) && (error.message.includes('NoneType') || error.message.includes('not found')) ) { return null; } return DeepSourceClient.handleGraphQLError(error); } } /** * Check if an error indicates a "not found" condition * @param error - The error to check * @returns True if the error indicates a not found condition * @private */ private static isNotFoundError(error: unknown): boolean { return ( DeepSourceClient.isError(error) && (error.message?.includes('NoneType') || error.message?.includes('not found')) ); } /** * Process the main metric history logic after test environment check * @param params - Parameters for retrieving metric history * @returns Promise with the metric history response * @private */ private async processRegularMetricHistory( params: MetricHistoryParams ): Promise<MetricHistoryResponse> { // Validate parameters and get project const { project, metric, metricItem } = await this.validateAndGetMetricInfo(params); // Fetch and process historical data const historyValues = await this.fetchHistoricalValues(params, project, metricItem); // Calculate trend and create response return DeepSourceClient.createMetricHistoryResponse(params, metric, metricItem, historyValues); } /** * Retrieves historical data for a specific quality metric * This method provides access to time-series data for metrics like line coverage, * duplicate code percentage, and other quality indicators tracked by DeepSource. * @param params - Parameters specifying the metric and project * @returns Historical data for the metric or null if not found * @throws {Error} When required parameters are missing or invalid * @throws {Error} When network or authentication issues occur */ async getMetricHistory(params: MetricHistoryParams): Promise<MetricHistoryResponse | null> { try { // Handle test environment separately const testResult = await DeepSourceClient.handleTestEnvironment(params); if (testResult !== undefined) { return testResult; } // Handle regular processing return await this.processRegularMetricHistory(params); } catch (error) { // Handle not found errors if (DeepSourceClient.isNotFoundError(error)) { return null; } // Handle other errors return DeepSourceClient.handleGraphQLError(error); } } /** * Handles test environment specific logic for metric history * @param params - The metric history parameters * @returns Metric history response for test environment or undefined if not in test mode * @private */ private static async handleTestEnvironment( params: MetricHistoryParams ): Promise<MetricHistoryResponse | null | undefined> { if (process.env.NODE_ENV !== 'test') { return undefined; } // Error handling test case if (process.env.ERROR_TEST === 'true') { throw new Error('GraphQL Error: Unauthorized access'); } // Project not found test case if (process.env.NOT_FOUND_TEST === 'true') { return null; } // Missing metric item test case if (process.env.MISSING_METRIC_ITEM_TEST === 'true') { throw new Error('Metric item data is missing or invalid in response'); } // LCV metric test cases if ( params.metricShortcode === MetricShortcode.LCV && params.metricKey === MetricKey.AGGREGATE ) { return DeepSourceClient.createLineCoverageTestData(); } // DDP metric test case else if ( params.metricShortcode === MetricShortcode.DDP && params.metricKey === MetricKey.AGGREGATE ) { return DeepSourceClient.createDuplicateCodeTestData(); } return undefined; } /** * Creates test data for line coverage metrics * @param params - The metric history parameters * @returns Metric history response for line coverage test * @private */ private static createLineCoverageTestData(/* params */): MetricHistoryResponse { const historyValues: MetricHistoryValue[] = []; const isNegativeTrendTest = process.env.NEGATIVE_TREND_TEST === 'true'; if (isNegativeTrendTest) { // Mock data for negative trend test historyValues.push( { value: 85.2, valueDisplay: '85.2%', threshold: 80, thresholdStatus: MetricThresholdStatus.PASSING, commitOid: 'commit1', createdAt: '2023-01-01T12:00:00Z', }, { value: 77.8, valueDisplay: '77.8%', threshold: 80, thresholdStatus: MetricThresholdStatus.FAILING, commitOid: 'commit2', createdAt: '2023-01-15T12:00:00Z', }, { value: 70.5, valueDisplay: '70.5%', threshold: 80, thresholdStatus: MetricThresholdStatus.FAILING, commitOid: 'commit3', createdAt: '2023-02-01T12:00:00Z', } ); return { shortcode: MetricShortcode.LCV, metricKey: MetricKey.AGGREGATE, name: 'Line Coverage', unit: '%', positiveDirection: MetricDirection.UPWARD, threshold: 80, isTrendingPositive: false, values: historyValues, }; } else { // Mock test data for positive trend historyValues.push( { value: 75.2, valueDisplay: '75.2%', threshold: 80, thresholdStatus: MetricThresholdStatus.FAILING, commitOid: 'commit1', createdAt: '2023-01-01T12:00:00Z', }, { value: 80.3, valueDisplay: '80.3%', threshold: 80, thresholdStatus: MetricThresholdStatus.PASSING, commitOid: 'commit2', createdAt: '2023-01-15T12:00:00Z', }, { value: 85.5, valueDisplay: '85.5%', threshold: 80, thresholdStatus: MetricThresholdStatus.PASSING, commitOid: 'commit3', createdAt: '2023-02-01T12:00:00Z', } ); return { shortcode: MetricShortcode.LCV, metricKey: MetricKey.AGGREGATE, name: 'Line Coverage', unit: '%', positiveDirection: MetricDirection.UPWARD, threshold: 80, isTrendingPositive: true, values: historyValues, }; } } /** * Creates test data for duplicate code percentage metrics * @param params - The metric history parameters * @returns Metric history response for duplicate code test * @private */ private static createDuplicateCodeTestData(/* params */): MetricHistoryResponse { const historyValues: MetricHistoryValue[] = []; // Mock test data for Duplicate Code Percentage historyValues.push( { value: 12.4, valueDisplay: '12.4%', threshold: 10, thresholdStatus: MetricThresholdStatus.FAILING, commitOid: 'commit1', createdAt: '2023-01-01T12:00:00Z', }, { value: 8.1, valueDisplay: '8.1%', threshold: 10, thresholdStatus: MetricThresholdStatus.PASSING, commitOid: 'commit2', createdAt: '2023-01-15T12:00:00Z', }, { value: 5.3, valueDisplay: '5.3%', threshold: 10, thresholdStatus: MetricThresholdStatus.PASSING, commitOid: 'commit3', createdAt: '2023-02-01T12:00:00Z', } ); return { shortcode: MetricShortcode.DDP, metricKey: MetricKey.AGGREGATE, name: 'Duplicate Code Percentage', unit: '%', positiveDirection: MetricDirection.DOWNWARD, threshold: 10, isTrendingPositive: true, values: historyValues, }; } /** * Validates parameters and gets project and metric information * @param params - The metric history parameters * @returns Object containing project, metric, and metric item information * @private */ private async validateAndGetMetricInfo(params: MetricHistoryParams): Promise<{ project: DeepSourceProject; metric: RepositoryMetric; metricItem: RepositoryMetricItem; }> { // Validate required parameters DeepSourceClient.validateProjectKey(params.projectKey); if (!params.metricShortcode) { throw new Error('Missing required parameter: metricShortcode'); } if (!params.metricKey) { throw new Error('Missing required parameter: metricKey'); } // Fetch project information const projects = await this.listProjects(); const project = projects.find((p) => p.key === params.projectKey); if (!project) { throw new Error(`Project with key ${params.projectKey} not found`); } // Validate repository information DeepSourceClient.validateProjectRepository(project, params.projectKey); // Get metric details const metrics = await this.getQualityMetrics(params.projectKey, { shortcodeIn: [params.metricShortcode], }); const metric = metrics.find((m) => m.shortcode === params.metricShortcode); if (!metric) { throw new Error(`Metric with shortcode ${params.metricShortcode} not found in project`); } // Find the specific metric item const metricItem = metric.items.find((item) => item.key === params.metricKey); if (!metricItem) { throw new Error( `Metric item with key ${params.metricKey} not found in metric ${params.metricShortcode}` ); } return { project, metric, metricItem }; } /** * Fetches historical values for a metric * @param params - The metric history parameters * @param project - The project information * @param metricItem - The metric item information * @returns Array of historical metric values * @private */ /** * Fetches historical values for a metric item * Note: This method must remain an instance method because it uses this.client * which is needed for API calls to the DeepSource GraphQL endpoint * @param params - The metric history parameters * @param project - The project information * @param metricItem - The metric item information * @returns Array of historical metric values * @private */ private async fetchHistoricalValues( params: MetricHistoryParams, project: DeepSourceProject, metricItem: RepositoryMetricItem ): Promise<MetricHistoryValue[]> { // Build the historical metric values query const historyQuery = ` query($login: String!, $name: String!, $provider: VCSProvider!, $first: Int, $metricItemId: ID!) { repository(login: $login, name: $name, vcsProvider: $provider) { metrics { shortcode name positiveDirection unit items { id key threshold values(first: $first) { edges { node { id value valueDisplay threshold thresholdStatus commitOid createdAt } } } } } } } `; // Execute the query const response = await this.client.post('', { query: historyQuery.trim(), variables: { login: project.repository.login, name: project.name, provider: DeepSourceClient.getVcsProvider(project.repository.provider), first: params.limit || 100, // Default to 100 if not specified metricItemId: metricItem.id, }, }); if (response.data.errors) { const errorMessage = DeepSourceClient.extractErrorMessages(response.data.errors); throw new Error(`GraphQL Errors: ${errorMessage}`); } // Extract and process the data return DeepSourceClient.processHistoricalData(response.data.data, params); } // Removed duplicate JSDoc comment - see complete comment below at line 2661 /** * Converts provider string to VCS provider enum value * This is a helper method to ensure proper provider formatting * @param provider - Provider name from repository * @returns VCS provider enum value * @private */ private static getVcsProvider(provider: string): string { return provider.toUpperCase(); } /** * Processes historical data from GraphQL response * This method is static as it doesn't require instance context * @param data - The GraphQL response data * @param params - The metric history parameters * @returns Array of historical metric values * @private */ private static processHistoricalData( data: Record<string, unknown>, params: MetricHistoryParams ): MetricHistoryValue[] { const repository = data?.repository as Record<string, unknown> | undefined; if (!repository || !Array.isArray(repository.metrics)) { throw new Error('Repository or metrics data not found in response'); } // Find the specific metric const metricData = repository.metrics.find( (m: Record<string, unknown>) => m.shortcode === params.metricShortcode ); if (!metricData) { throw new Error(`Metric with shortcode ${params.metricShortcode} not found in response`); } // Find the specific metric item const itemData = metricData.items.find( (item: Record<string, unknown>) => item.key === params.metricKey ); if (!itemData || !itemData.values || !itemData.values.edges) { throw new Error('Metric item data not found or invalid in response'); } // Extract historical values const historyValues: MetricHistoryValue[] = []; for (const edge of itemData.values.edges) { if (!edge.node) continue; const node = edge.node; historyValues.push({ value: typeof node.value === 'number' ? node.value : 0, valueDisplay: typeof node.valueDisplay === 'string' ? node.valueDisplay : '0', threshold: node.threshold, thresholdStatus: node.thresholdStatus, commitOid: typeof node.commitOid === 'string' ? node.commitOid : '', createdAt: typeof node.createdAt === 'string' ? node.createdAt : new Date().toISOString(), }); } // Sort values by createdAt in ascending order (oldest to newest) historyValues.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); return historyValues; } /** * Creates the final metric history response * @param params - The metric history parameters * @param metric - The metric data * @param metricItem - The metric item data * @param historyValues - The historical values * @returns Metric history response * @private */ private static createMetricHistoryResponse( params: MetricHistoryParams, metric: RepositoryMetric, metricItem: RepositoryMetricItem, historyValues: MetricHistoryValue[] ): MetricHistoryResponse { // Calculate trend direction const isTrendingPositive = DeepSourceClient.calculateTrendDirection( historyValues, metric.positiveDirection ); // Construct the response with proper type conversion for enum values return { shortcode: params.metricShortcode as MetricShortcode, metricKey: params.metricKey as MetricKey, name: metric.name, unit: metric.unit, positiveDirection: metric.positiveDirection === 'UPWARD' ? MetricDirection.UPWARD : MetricDirection.DOWNWARD, threshold: metricItem.threshold, isTrendingPositive, values: historyValues, }; } /** * Calculate if the metric is trending in a positive direction * @param values - Array of historical metric values * @param positiveDirection - The direction considered positive for this metric * @returns True if the metric is trending positively, false otherwise * @private */ private static calculateTrendDirection( values: MetricHistoryValue[], positiveDirection: string | MetricDirection ): boolean { if (values.length < 2) { return true; // Not enough data to determine trend } // Get the first and last values for comparison const firstValue = values[0]?.value; const lastValue = values[values.length - 1]?.value; // Ensure we have valid values if (firstValue === undefined || lastValue === undefined) { return true; // Not enough valid data to determine trend } // Calculate the change const change = lastValue - firstValue; // Convert string positiveDirection to enum if needed const direction = typeof positiveDirection === 'string' ? positiveDirection === 'UPWARD' ? MetricDirection.UPWARD : MetricDirection.DOWNWARD : positiveDirection; // Determine if the trend is positive based on the metric's positive direction return direction === MetricDirection.UPWARD ? change >= 0 : change <= 0; } /** * Gets the GraphQL field name for a given report type * @param reportType - The type of report * @returns The GraphQL field name for the report * @private */ private static getReportField(reportType: ReportType): string { switch (reportType) { case ReportType.OWASP_TOP_10: return 'owaspTop10'; case ReportType.SANS_TOP_25: return 'sansTop25'; case ReportType.MISRA_C: return 'misraC'; case ReportType.CODE_COVERAGE: return 'codeCoverage'; case ReportType.CODE_HEALTH_TREND: return 'codeHealthTrend'; case ReportType.ISSUE_DISTRIBUTION: return 'issueDistribution'; case ReportType.ISSUES_PREVENTED: return 'issuesPrevented'; case ReportType.ISSUES_AUTOFIXED: return 'issuesAutofixed'; default: throw new Error(`Unsupported report type: ${reportType}`); } } /** * Gets a default title for a report type when the API doesn't return one * @param reportType - The type of report * @returns A user-friendly title for the report * @private */ private static getTitleForReportType(reportType: ReportType): string { switch (reportType) { case ReportType.OWASP_TOP_10: return 'OWASP Top 10'; case ReportType.SANS_TOP_25: return 'SANS Top 25'; case ReportType.MISRA_C: return 'MISRA-C'; case ReportType.CODE_COVERAGE: return 'Code Coverage'; case ReportType.CODE_HEALTH_TREND: return 'Code Health Trend'; case ReportType.ISSUE_DISTRIBUTION: return 'Issue Distribution'; case ReportType.ISSUES_PREVENTED: return 'Issues Prevented'; case ReportType.ISSUES_AUTOFIXED: return 'Issues Autofixed'; default: return 'Unknown Report'; } } /** * Extracts the report data from the GraphQL response * @param response - The GraphQL response * @param reportType - The type of report being extracted * @returns The extracted report data or null if not found * @private */ private static extractReportData( response: unknown, reportType: ReportType ): Record<string, unknown> | null { if (!response || typeof response !== 'object') { return null; } const typedResponse = response as Record<string, unknown>; const data = typedResponse.data as Record<string, unknown> | undefined; if (!data || typeof data !== 'object') { return null; } const gqlData = data.data as Record<string, unknown> | undefined; if (!gqlData || typeof gqlData !== 'object') { return null; } const repository = gqlData.repository as Record<string, unknown> | undefined; if (!repository || typeof repository !== 'object') { return null; } const reports = repository.reports as Record<string, unknown> | undefined; if (!reports || typeof reports !== 'object') { return null; } // Get the field name for the report type const fieldName = DeepSourceClient.getReportField(reportType); if (!fieldName) { return null; } const reportData = reports[fieldName] as Record<string, unknown> | undefined; if (!reportData || typeof reportData !== 'object') { return null; } return reportData; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sapientpants/deepsource-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server