feat: add GitHub step evidence presets
This commit is contained in:
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGitHubEvidenceCommand,
|
||||
decodeGitHubContentBase64,
|
||||
normalizeGitHubRef,
|
||||
normalizeGitHubRepo,
|
||||
normalizeGitHubWorkflowPath,
|
||||
normalizePositiveInteger,
|
||||
} from './github-evidence.js';
|
||||
|
||||
@@ -18,6 +21,35 @@ describe('GitHub evidence helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('validates workflow evidence paths and refs', () => {
|
||||
expect(
|
||||
normalizeGitHubWorkflowPath('.github/workflows/display-analyzer.yml'),
|
||||
).toBe('.github/workflows/display-analyzer.yml');
|
||||
expect(normalizeGitHubWorkflowPath('.github/workflows/ci.yaml')).toBe(
|
||||
'.github/workflows/ci.yaml',
|
||||
);
|
||||
expect(() => normalizeGitHubWorkflowPath('README.md')).toThrow(
|
||||
'Unsupported GitHub workflow path',
|
||||
);
|
||||
expect(() =>
|
||||
normalizeGitHubWorkflowPath('.github/workflows/../ci.yml'),
|
||||
).toThrow('Unsupported GitHub workflow path');
|
||||
|
||||
expect(normalizeGitHubRef('prod')).toBe('prod');
|
||||
expect(normalizeGitHubRef('feature/display-analyzer')).toBe(
|
||||
'feature/display-analyzer',
|
||||
);
|
||||
expect(normalizeGitHubRef('00b971972e4b2e7ecf0c6d789f405fef7edeb258')).toBe(
|
||||
'00b971972e4b2e7ecf0c6d789f405fef7edeb258',
|
||||
);
|
||||
expect(() => normalizeGitHubRef('../prod')).toThrow(
|
||||
'Missing or invalid ref',
|
||||
);
|
||||
expect(() => normalizeGitHubRef('feature branch')).toThrow(
|
||||
'Missing or invalid ref',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds fixed gh commands', () => {
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
@@ -42,5 +74,42 @@ describe('GitHub evidence helpers', () => {
|
||||
runId: 123,
|
||||
}).args,
|
||||
).toContain('123');
|
||||
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
action: 'github_run_jobs',
|
||||
repo: 'owner/repo',
|
||||
runId: 123,
|
||||
}).args,
|
||||
).toEqual([
|
||||
'run',
|
||||
'view',
|
||||
'123',
|
||||
'--repo',
|
||||
'owner/repo',
|
||||
'--json',
|
||||
'databaseId,name,status,conclusion,url,headBranch,headSha,jobs',
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
action: 'github_workflow_file',
|
||||
repo: 'owner/repo',
|
||||
workflowPath: '.github/workflows/display-analyzer-check.yml',
|
||||
ref: 'feature/display-analyzer',
|
||||
}),
|
||||
).toMatchObject({
|
||||
args: [
|
||||
'api',
|
||||
'repos/owner/repo/contents/.github/workflows/display-analyzer-check.yml?ref=feature%2Fdisplay-analyzer',
|
||||
'--jq',
|
||||
'.content',
|
||||
],
|
||||
decodeBase64Stdout: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes GitHub contents API workflow bodies', () => {
|
||||
expect(decodeGitHubContentBase64('bmFtZTogQ0kK')).toBe('name: CI\n');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ 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];
|
||||
@@ -13,15 +15,20 @@ export interface GitHubEvidenceRequest {
|
||||
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;
|
||||
@@ -53,6 +60,32 @@ export function normalizePositiveInteger(
|
||||
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 {
|
||||
@@ -102,6 +135,35 @@ export function buildGitHubEvidenceCommand(
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +204,12 @@ export function runGitHubEvidenceCommand(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const normalizedStdout = command.decodeBase64Stdout
|
||||
? decodeGitHubContentBase64(stdout)
|
||||
: stdout;
|
||||
resolve({
|
||||
command: command.commandText,
|
||||
stdout: truncateGitHubEvidenceText(stdout),
|
||||
stdout: truncateGitHubEvidenceText(normalizedStdout),
|
||||
stderr: truncateGitHubEvidenceText(stderr),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -95,6 +95,8 @@ describe('host evidence IPC', () => {
|
||||
repo: undefined,
|
||||
prNumber: undefined,
|
||||
runId: undefined,
|
||||
workflowPath: undefined,
|
||||
ref: undefined,
|
||||
artifactKind: undefined,
|
||||
sourceGroup: 'other-group',
|
||||
isMain: false,
|
||||
|
||||
@@ -13,6 +13,8 @@ describe('host evidence helpers', () => {
|
||||
expect(isHostEvidenceAction('db_paired_task_status')).toBe(true);
|
||||
expect(isHostEvidenceAction('ejclaw_deploy_state')).toBe(true);
|
||||
expect(isHostEvidenceAction('github_pr_status')).toBe(true);
|
||||
expect(isHostEvidenceAction('github_run_jobs')).toBe(true);
|
||||
expect(isHostEvidenceAction('github_workflow_file')).toBe(true);
|
||||
expect(isHostEvidenceAction('rm -rf /')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface HostEvidenceRequest {
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
runId?: number;
|
||||
workflowPath?: string;
|
||||
ref?: string;
|
||||
artifactKind?: string;
|
||||
sourceGroup?: string;
|
||||
isMain?: boolean;
|
||||
@@ -282,6 +284,8 @@ export async function runHostEvidenceRequest(
|
||||
repo: request.repo,
|
||||
prNumber: request.prNumber,
|
||||
runId: request.runId,
|
||||
workflowPath: request.workflowPath,
|
||||
ref: request.ref,
|
||||
});
|
||||
commandText = githubResult.command;
|
||||
return {
|
||||
|
||||
@@ -54,6 +54,9 @@ export async function handleHostEvidenceRequest(
|
||||
repo: typeof data.repo === 'string' ? data.repo : undefined,
|
||||
prNumber: typeof data.pr_number === 'number' ? data.pr_number : undefined,
|
||||
runId: typeof data.run_id === 'number' ? data.run_id : undefined,
|
||||
workflowPath:
|
||||
typeof data.workflow_path === 'string' ? data.workflow_path : undefined,
|
||||
ref: typeof data.ref === 'string' ? data.ref : undefined,
|
||||
artifactKind:
|
||||
typeof data.artifact_kind === 'string' ? data.artifact_kind : undefined,
|
||||
sourceGroup,
|
||||
|
||||
@@ -153,6 +153,8 @@ export interface TaskIpcPayload {
|
||||
repo?: string;
|
||||
pr_number?: number;
|
||||
run_id?: number;
|
||||
workflow_path?: string;
|
||||
ref?: string;
|
||||
artifact_kind?: string;
|
||||
profile?: string;
|
||||
expected_snapshot_id?: string;
|
||||
|
||||
Reference in New Issue
Block a user