import { execFile } from 'child_process'; export const GITHUB_EVIDENCE_ACTIONS = [ 'github_pr_status', 'github_pr_diff_stat', 'github_run_status', 'github_run_jobs', 'github_workflow_file', ] as const; export type GitHubEvidenceAction = (typeof GITHUB_EVIDENCE_ACTIONS)[number]; export interface GitHubEvidenceRequest { action: GitHubEvidenceAction; repo?: string; prNumber?: number; runId?: number; workflowPath?: string; ref?: string; } interface GitHubCommandSpec { file: string; args: string[]; commandText: string; decodeBase64Stdout?: boolean; } const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/; const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/; const COMMAND_TIMEOUT_MS = 10_000; const COMMAND_MAX_BUFFER = 2 * 1024 * 1024; const MAX_OUTPUT_CHARS = 24_000; export function isGitHubEvidenceAction( value: unknown, ): value is GitHubEvidenceAction { return ( typeof value === 'string' && GITHUB_EVIDENCE_ACTIONS.includes(value as GitHubEvidenceAction) ); } export function normalizeGitHubRepo(value?: string): string { const repo = value?.trim(); if (!repo || repo.includes('..') || !REPO_PATTERN.test(repo)) { throw new Error(`Unsupported GitHub repo for evidence: ${value}`); } return repo; } export function normalizePositiveInteger( value: number | undefined, label: string, ): number { if (value == null || !Number.isInteger(value) || value <= 0) { throw new Error(`Missing or invalid ${label} for GitHub evidence`); } return value; } export function normalizeGitHubWorkflowPath(value?: string): string { const workflowPath = value?.trim(); if (!workflowPath || !WORKFLOW_PATH_PATTERN.test(workflowPath)) { throw new Error(`Unsupported GitHub workflow path for evidence: ${value}`); } return workflowPath; } export function normalizeGitHubRef(value?: string): string { const ref = value?.trim(); if ( !ref || ref.includes('..') || ref.includes('//') || ref.endsWith('/') || !REF_PATTERN.test(ref) ) { throw new Error(`Missing or invalid ref for GitHub evidence`); } return ref; } export function decodeGitHubContentBase64(value: string): string { return Buffer.from(value.replace(/\s+/g, ''), 'base64').toString('utf8'); } export function buildGitHubEvidenceCommand( request: GitHubEvidenceRequest, ): GitHubCommandSpec { const repo = normalizeGitHubRepo(request.repo); switch (request.action) { case 'github_pr_status': { const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number'); const args = [ 'pr', 'view', String(prNumber), '--repo', repo, '--json', 'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup', ]; return { file: 'gh', args, commandText: `gh ${args.join(' ')}`, }; } case 'github_pr_diff_stat': { const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number'); const args = ['pr', 'diff', String(prNumber), '--repo', repo, '--stat']; return { file: 'gh', args, commandText: `gh ${args.join(' ')}`, }; } case 'github_run_status': { const runId = normalizePositiveInteger(request.runId, 'run_id'); const args = [ 'run', 'view', String(runId), '--repo', repo, '--json', 'status,conclusion,name,displayTitle,url,headBranch,headSha,event,createdAt,updatedAt', ]; return { file: 'gh', args, commandText: `gh ${args.join(' ')}`, }; } case 'github_run_jobs': { const runId = normalizePositiveInteger(request.runId, 'run_id'); const args = [ 'run', 'view', String(runId), '--repo', repo, '--json', 'databaseId,name,status,conclusion,url,headBranch,headSha,jobs', ]; return { file: 'gh', args, commandText: `gh ${args.join(' ')}`, }; } case 'github_workflow_file': { const workflowPath = normalizeGitHubWorkflowPath(request.workflowPath); const ref = normalizeGitHubRef(request.ref); const endpoint = `repos/${repo}/contents/${workflowPath}?ref=${encodeURIComponent(ref)}`; const args = ['api', endpoint, '--jq', '.content']; return { file: 'gh', args, commandText: `gh ${args.join(' ')}`, decodeBase64Stdout: true, }; } } } function truncateGitHubEvidenceText(value: string | undefined): string { if (!value) return ''; if (value.length <= MAX_OUTPUT_CHARS) { return value; } return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`; } export function runGitHubEvidenceCommand( request: GitHubEvidenceRequest, ): Promise<{ command: string; stdout: string; stderr: string; }> { const command = buildGitHubEvidenceCommand(request); return new Promise((resolve, reject) => { execFile( command.file, command.args, { encoding: 'utf8', timeout: COMMAND_TIMEOUT_MS, maxBuffer: COMMAND_MAX_BUFFER, env: process.env, }, (error, stdout, stderr) => { if (error) { reject( Object.assign(error, { command: command.commandText, stdout: truncateGitHubEvidenceText(stdout), stderr: truncateGitHubEvidenceText(stderr), }), ); return; } const normalizedStdout = command.decodeBase64Stdout ? decodeGitHubContentBase64(stdout) : stdout; resolve({ command: command.commandText, stdout: truncateGitHubEvidenceText(normalizedStdout), stderr: truncateGitHubEvidenceText(stderr), }); }, ); }); }