feat: add GitHub step evidence presets
This commit is contained in:
@@ -12,6 +12,8 @@ export const HOST_EVIDENCE_ACTIONS = [
|
|||||||
'github_pr_status',
|
'github_pr_status',
|
||||||
'github_pr_diff_stat',
|
'github_pr_diff_stat',
|
||||||
'github_run_status',
|
'github_run_status',
|
||||||
|
'github_run_jobs',
|
||||||
|
'github_workflow_file',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DB_EVIDENCE_ACTIONS = [
|
export const DB_EVIDENCE_ACTIONS = [
|
||||||
@@ -29,6 +31,8 @@ export const GITHUB_EVIDENCE_ACTIONS = [
|
|||||||
'github_pr_status',
|
'github_pr_status',
|
||||||
'github_pr_diff_stat',
|
'github_pr_diff_stat',
|
||||||
'github_run_status',
|
'github_run_status',
|
||||||
|
'github_run_jobs',
|
||||||
|
'github_workflow_file',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const ARTIFACT_EVIDENCE_KINDS = [
|
export const ARTIFACT_EVIDENCE_KINDS = [
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ export function registerHostEvidenceTools(
|
|||||||
.describe('Only for GitHub evidence, in owner/repo form.'),
|
.describe('Only for GitHub evidence, in owner/repo form.'),
|
||||||
pr_number: z.number().int().positive().optional(),
|
pr_number: z.number().int().positive().optional(),
|
||||||
run_id: z.number().int().positive().optional(),
|
run_id: z.number().int().positive().optional(),
|
||||||
|
workflow_path: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Only for github_workflow_file, e.g. .github/workflows/ci.yml.',
|
||||||
|
),
|
||||||
|
ref: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Only for github_workflow_file; branch, tag, or commit SHA.'),
|
||||||
artifact_kind: z.enum(ARTIFACT_EVIDENCE_KINDS).optional(),
|
artifact_kind: z.enum(ARTIFACT_EVIDENCE_KINDS).optional(),
|
||||||
},
|
},
|
||||||
async (args) =>
|
async (args) =>
|
||||||
@@ -129,6 +139,8 @@ export function registerHostEvidenceTools(
|
|||||||
repo: args.repo,
|
repo: args.repo,
|
||||||
pr_number: args.pr_number,
|
pr_number: args.pr_number,
|
||||||
run_id: args.run_id,
|
run_id: args.run_id,
|
||||||
|
workflow_path: args.workflow_path,
|
||||||
|
ref: args.ref,
|
||||||
artifact_kind: args.artifact_kind,
|
artifact_kind: args.artifact_kind,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -176,6 +188,16 @@ export function registerHostEvidenceTools(
|
|||||||
repo: z.string().describe('GitHub repository in owner/repo form.'),
|
repo: z.string().describe('GitHub repository in owner/repo form.'),
|
||||||
pr_number: z.number().int().positive().optional(),
|
pr_number: z.number().int().positive().optional(),
|
||||||
run_id: z.number().int().positive().optional(),
|
run_id: z.number().int().positive().optional(),
|
||||||
|
workflow_path: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Only for github_workflow_file, e.g. .github/workflows/ci.yml.',
|
||||||
|
),
|
||||||
|
ref: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Only for github_workflow_file; branch, tag, or commit SHA.'),
|
||||||
},
|
},
|
||||||
async (args) =>
|
async (args) =>
|
||||||
requestHostEvidence(options, {
|
requestHostEvidence(options, {
|
||||||
@@ -183,6 +205,8 @@ export function registerHostEvidenceTools(
|
|||||||
repo: args.repo,
|
repo: args.repo,
|
||||||
pr_number: args.pr_number,
|
pr_number: args.pr_number,
|
||||||
run_id: args.run_id,
|
run_id: args.run_id,
|
||||||
|
workflow_path: args.workflow_path,
|
||||||
|
ref: args.ref,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildGitHubEvidenceCommand,
|
buildGitHubEvidenceCommand,
|
||||||
|
decodeGitHubContentBase64,
|
||||||
|
normalizeGitHubRef,
|
||||||
normalizeGitHubRepo,
|
normalizeGitHubRepo,
|
||||||
|
normalizeGitHubWorkflowPath,
|
||||||
normalizePositiveInteger,
|
normalizePositiveInteger,
|
||||||
} from './github-evidence.js';
|
} 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', () => {
|
it('builds fixed gh commands', () => {
|
||||||
expect(
|
expect(
|
||||||
buildGitHubEvidenceCommand({
|
buildGitHubEvidenceCommand({
|
||||||
@@ -42,5 +74,42 @@ describe('GitHub evidence helpers', () => {
|
|||||||
runId: 123,
|
runId: 123,
|
||||||
}).args,
|
}).args,
|
||||||
).toContain('123');
|
).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_status',
|
||||||
'github_pr_diff_stat',
|
'github_pr_diff_stat',
|
||||||
'github_run_status',
|
'github_run_status',
|
||||||
|
'github_run_jobs',
|
||||||
|
'github_workflow_file',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type GitHubEvidenceAction = (typeof GITHUB_EVIDENCE_ACTIONS)[number];
|
export type GitHubEvidenceAction = (typeof GITHUB_EVIDENCE_ACTIONS)[number];
|
||||||
@@ -13,15 +15,20 @@ export interface GitHubEvidenceRequest {
|
|||||||
repo?: string;
|
repo?: string;
|
||||||
prNumber?: number;
|
prNumber?: number;
|
||||||
runId?: number;
|
runId?: number;
|
||||||
|
workflowPath?: string;
|
||||||
|
ref?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitHubCommandSpec {
|
interface GitHubCommandSpec {
|
||||||
file: string;
|
file: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
commandText: string;
|
commandText: string;
|
||||||
|
decodeBase64Stdout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
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_TIMEOUT_MS = 10_000;
|
||||||
const COMMAND_MAX_BUFFER = 2 * 1024 * 1024;
|
const COMMAND_MAX_BUFFER = 2 * 1024 * 1024;
|
||||||
const MAX_OUTPUT_CHARS = 24_000;
|
const MAX_OUTPUT_CHARS = 24_000;
|
||||||
@@ -53,6 +60,32 @@ export function normalizePositiveInteger(
|
|||||||
return value;
|
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(
|
export function buildGitHubEvidenceCommand(
|
||||||
request: GitHubEvidenceRequest,
|
request: GitHubEvidenceRequest,
|
||||||
): GitHubCommandSpec {
|
): GitHubCommandSpec {
|
||||||
@@ -102,6 +135,35 @@ export function buildGitHubEvidenceCommand(
|
|||||||
commandText: `gh ${args.join(' ')}`,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
const normalizedStdout = command.decodeBase64Stdout
|
||||||
|
? decodeGitHubContentBase64(stdout)
|
||||||
|
: stdout;
|
||||||
resolve({
|
resolve({
|
||||||
command: command.commandText,
|
command: command.commandText,
|
||||||
stdout: truncateGitHubEvidenceText(stdout),
|
stdout: truncateGitHubEvidenceText(normalizedStdout),
|
||||||
stderr: truncateGitHubEvidenceText(stderr),
|
stderr: truncateGitHubEvidenceText(stderr),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ describe('host evidence IPC', () => {
|
|||||||
repo: undefined,
|
repo: undefined,
|
||||||
prNumber: undefined,
|
prNumber: undefined,
|
||||||
runId: undefined,
|
runId: undefined,
|
||||||
|
workflowPath: undefined,
|
||||||
|
ref: undefined,
|
||||||
artifactKind: undefined,
|
artifactKind: undefined,
|
||||||
sourceGroup: 'other-group',
|
sourceGroup: 'other-group',
|
||||||
isMain: false,
|
isMain: false,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ describe('host evidence helpers', () => {
|
|||||||
expect(isHostEvidenceAction('db_paired_task_status')).toBe(true);
|
expect(isHostEvidenceAction('db_paired_task_status')).toBe(true);
|
||||||
expect(isHostEvidenceAction('ejclaw_deploy_state')).toBe(true);
|
expect(isHostEvidenceAction('ejclaw_deploy_state')).toBe(true);
|
||||||
expect(isHostEvidenceAction('github_pr_status')).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);
|
expect(isHostEvidenceAction('rm -rf /')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface HostEvidenceRequest {
|
|||||||
repo?: string;
|
repo?: string;
|
||||||
prNumber?: number;
|
prNumber?: number;
|
||||||
runId?: number;
|
runId?: number;
|
||||||
|
workflowPath?: string;
|
||||||
|
ref?: string;
|
||||||
artifactKind?: string;
|
artifactKind?: string;
|
||||||
sourceGroup?: string;
|
sourceGroup?: string;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
@@ -282,6 +284,8 @@ export async function runHostEvidenceRequest(
|
|||||||
repo: request.repo,
|
repo: request.repo,
|
||||||
prNumber: request.prNumber,
|
prNumber: request.prNumber,
|
||||||
runId: request.runId,
|
runId: request.runId,
|
||||||
|
workflowPath: request.workflowPath,
|
||||||
|
ref: request.ref,
|
||||||
});
|
});
|
||||||
commandText = githubResult.command;
|
commandText = githubResult.command;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export async function handleHostEvidenceRequest(
|
|||||||
repo: typeof data.repo === 'string' ? data.repo : undefined,
|
repo: typeof data.repo === 'string' ? data.repo : undefined,
|
||||||
prNumber: typeof data.pr_number === 'number' ? data.pr_number : undefined,
|
prNumber: typeof data.pr_number === 'number' ? data.pr_number : undefined,
|
||||||
runId: typeof data.run_id === 'number' ? data.run_id : 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:
|
artifactKind:
|
||||||
typeof data.artifact_kind === 'string' ? data.artifact_kind : undefined,
|
typeof data.artifact_kind === 'string' ? data.artifact_kind : undefined,
|
||||||
sourceGroup,
|
sourceGroup,
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ export interface TaskIpcPayload {
|
|||||||
repo?: string;
|
repo?: string;
|
||||||
pr_number?: number;
|
pr_number?: number;
|
||||||
run_id?: number;
|
run_id?: number;
|
||||||
|
workflow_path?: string;
|
||||||
|
ref?: string;
|
||||||
artifact_kind?: string;
|
artifact_kind?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
expected_snapshot_id?: string;
|
expected_snapshot_id?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user