feat: add GitHub step evidence presets

This commit is contained in:
ejclaw
2026-05-28 17:07:30 +09:00
parent 9b9b6c111c
commit e8a9239971
9 changed files with 176 additions and 1 deletions

View File

@@ -12,6 +12,8 @@ export const HOST_EVIDENCE_ACTIONS = [
'github_pr_status',
'github_pr_diff_stat',
'github_run_status',
'github_run_jobs',
'github_workflow_file',
] as const;
export const DB_EVIDENCE_ACTIONS = [
@@ -29,6 +31,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 const ARTIFACT_EVIDENCE_KINDS = [

View File

@@ -114,6 +114,16 @@ export function registerHostEvidenceTools(
.describe('Only for GitHub evidence, in owner/repo form.'),
pr_number: 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(),
},
async (args) =>
@@ -129,6 +139,8 @@ export function registerHostEvidenceTools(
repo: args.repo,
pr_number: args.pr_number,
run_id: args.run_id,
workflow_path: args.workflow_path,
ref: args.ref,
artifact_kind: args.artifact_kind,
}),
);
@@ -176,6 +188,16 @@ export function registerHostEvidenceTools(
repo: z.string().describe('GitHub repository in owner/repo form.'),
pr_number: 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) =>
requestHostEvidence(options, {
@@ -183,6 +205,8 @@ export function registerHostEvidenceTools(
repo: args.repo,
pr_number: args.pr_number,
run_id: args.run_id,
workflow_path: args.workflow_path,
ref: args.ref,
}),
);
}

View File

@@ -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');
});
});

View File

@@ -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),
});
},

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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;