Files

353 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { repositoryApplicationBase } from './repository-app-base.mjs';
import { requiredChecksFor } from './required-checks.mjs';
import {
isSourceBaselineRef,
isSharedTaskBase,
} from './source-baseline-ref.mjs';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import {
GitHubClient,
TaskInputError,
appendGithubOutput,
issueNumberFromEvent,
} from './factory-lib.mjs';
import {
claimComment,
receiptsFor,
resolveBuildTask,
listAll,
} from './comment-queue.mjs';
import {
isManualIssue,
isPresetIssue,
preparePresetIssue,
} from './issue-presets.mjs';
import {
recordedContinuationBase,
resolveTaskBranch,
taskIssueNumber,
} from './task-compat.mjs';
import { resolveTargetBranch, pinInitialBase } from './task-base.mjs';
import { taskEvaluationIdentity } from './evaluation-identity.mjs';
import {
gateSample,
recordTerminal,
resolveSample,
} from './evaluation-sample.mjs';
const args = parseArgs(process.argv.slice(2));
const event = JSON.parse(readFileSync(args.event, 'utf8'));
const outputPath = args.output ?? process.env.GITHUB_OUTPUT;
const repository = process.env.GITHUB_REPOSITORY;
const owner = event.repository?.owner?.login ?? repository?.split('/')[0];
const client = new GitHubClient({
token: process.env.GITHUB_TOKEN,
repository,
apiUrl: process.env.GITHUB_API_URL,
});
let issueNumber;
try {
issueNumber = issueNumberFromEvent(event);
appendGithubOutput(outputPath, 'issue_number', issueNumber);
let issue = await client.getIssue(issueNumber);
if (issue.pull_request) {
throw new TaskInputError('任务编号必须指向 Issue,不能指向 Pull Request。');
}
if (isManualIssue(issue)) {
appendGithubOutput(outputPath, 'status', 'manual');
process.exit(0);
}
if (isPresetIssue(issue)) {
appendGithubOutput(outputPath, 'status', 'preset');
process.exit(0);
}
// Batch samples prove their frozen batch from bot receipts; ordinary Issues get null.
const sample = await resolveSample(client, issueNumber, { issue });
await client.ensureStatusLabels();
const buildCommentId = event.client_payload?.build_comment_id;
const continuation = event.action === 'code-agent-continue';
const batchSample = sample && !buildCommentId ? sample : null;
const runId = Number(process.env.GITHUB_RUN_ID);
let budgetUsed = null;
if (sample) {
// One gate, shared with the Agent job's admission (see gateSample for the rules).
const gate = await gateSample(client, sample, {
runId,
attempt: Number(process.env.GITHUB_RUN_ATTEMPT || 1),
incremental: !batchSample,
fresh: Boolean(batchSample) && !continuation,
controlSha: process.env.FACTORY_CONTROL_SHA,
serverUrl: process.env.GITHUB_SERVER_URL,
});
if (gate.decision === 'rejected') throw new TaskInputError(gate.reason);
if (gate.decision !== 'run') {
// Decisions the coordinator cannot see in a Run's own result are recorded for it.
if (
batchSample &&
['cancelled', 'budget-exhausted'].includes(gate.decision)
)
await recordTerminal(
client,
batchSample,
runId,
gate.decision,
gate.reason,
);
if (gate.decision !== 'duplicate')
await client.setIssueStatus(issue, 'agent:failed', gate.reason);
appendGithubOutput(outputPath, 'status', gate.decision);
process.exit(0);
}
budgetUsed = gate.used;
}
if (buildCommentId) {
const { receipts } = await receiptsFor(client, issueNumber);
if (
receipts.some(
(item) => item.id === Number(buildCommentId) && item.status === 'done',
)
) {
appendGithubOutput(outputPath, 'status', 'duplicate');
process.exit(0);
}
}
const prepared = await preparePresetIssue(client, issue);
issue = prepared.issue;
const task = buildCommentId
? await resolveBuildTask(client, issue, buildCommentId, prepared.task)
: prepared.task;
if (buildCommentId) {
appendGithubOutput(outputPath, 'build_comment_id', buildCommentId);
appendGithubOutput(outputPath, 'comment_kind', task.commentKind);
}
// External Issues archive submissions; their open/closed state is not run state.
// Only explicit dispatches and their validated recovery/handoff chain opt in.
const externalTask =
issue.labels?.some(
(label) =>
(typeof label === 'string' ? label : label.name) === 'factory:external',
) &&
((process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' &&
(event.inputs?.external_run_id || event.inputs?.recovery_run_id)) ||
(process.env.GITHUB_EVENT_NAME === 'repository_dispatch' &&
continuation &&
event.client_payload?.previous_run_id));
if (issue.state !== 'open' && task.commentKind !== 'reply' && !externalTask) {
throw new TaskInputError('只有打开状态的 Issue 才能运行搭建任务。');
}
const repositoryInfo = await client.getRepository();
const defaultBranch = repositoryInfo.default_branch;
// Find this Issue's PR independently of its base (legacy tasks included).
const openPullRequests = await listAll(client, '/pulls', { state: 'open' });
const application = repositoryApplicationBase(
task.targetBranch,
defaultBranch,
);
task.targetBranch = application.target;
task.targetBranch = await resolveTargetBranch(
client,
issueNumber,
task.targetBranch,
openPullRequests,
defaultBranch,
);
repositoryApplicationBase(task.targetBranch, defaultBranch);
let targetRef = await client.getRef(task.targetBranch, true);
let targetCreated = false;
if (!targetRef && isSourceBaselineRef(task.targetBranch)) {
throw new TaskInputError('指定的源码基线不存在,不能从默认分支伪造替代。');
}
if (!targetRef) {
const defaultRef = await client.getRef(application.creationBase);
targetRef = await client.createRef(
task.targetBranch,
defaultRef.object.sha,
);
targetCreated = true;
}
const workBranch = await resolveTaskBranch(
client,
issueNumber,
openPullRequests,
repository,
);
const ownPullRequest = openPullRequests.find(
(pull) =>
pull.head?.repo?.full_name === repository &&
pull.head?.ref === workBranch,
);
if (ownPullRequest && ownPullRequest.base.ref !== task.targetBranch) {
throw new TaskInputError(
'现有 PR 的合并目标与任务不一致;请先对齐配置,不自动改写旧 PR。',
);
}
// External submissions have separate Issue/work branch/PR identities; an old
// submission's unmerged PR must not block another run of the saved task.
const blockingPullRequest =
!externalTask &&
!isSharedTaskBase(task.targetBranch, defaultBranch) &&
openPullRequests.find(
(pull) =>
pull.base?.ref === task.targetBranch &&
pull.head?.repo?.full_name === repository &&
taskIssueNumber(pull.head?.ref) != null &&
pull.head.ref !== workBranch,
);
const metadata = {
schemaVersion: 1,
...(prepared.preset ? { preset: prepared.preset } : {}),
...(buildCommentId ? { buildCommentId } : {}),
repository,
owner,
defaultBranch,
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
author: issue.user.login,
},
task,
// Stable logical-run identity for exported evaluations; not business input.
evaluation: {
requiredChecks: batchSample
? (batchSample.manifest.cases.find(
(c) => c.key === batchSample.receipt.caseKey,
).requiredChecks ?? [])
: requiredChecksFor(prepared.preset?.sourceIssueNumber),
...taskEvaluationIdentity({
repository,
issueNumber: issue.number,
buildCommentId: buildCommentId ? Number(buildCommentId) : null,
sample: batchSample ? batchSample.receipt : null,
}),
...(batchSample
? {
budget: batchSample.receipt.budget,
// GitHub-measured usage so far: the checkpoint's floor for this execution.
budgetUsed,
coordinatorIssue: batchSample.receipt.coordinatorIssue,
manifestHash: batchSample.manifestHash,
}
: {}),
},
workBranch,
targetCreated,
existingPullRequest: ownPullRequest
? { number: ownPullRequest.number, url: ownPullRequest.html_url }
: null,
};
mkdirSync(path.dirname(args.metadata), { recursive: true });
writeFileSync(args.metadata, `${JSON.stringify(metadata, null, 2)}\n`);
appendGithubOutput(outputPath, 'work_branch', workBranch);
if (blockingPullRequest) {
issue = await client.setIssueStatus(
issue,
'agent:waiting',
[
`目标分支 \`${task.targetBranch}\` 当前已有未合并的 Code Agent PR:${blockingPullRequest.html_url}。`,
'',
'这个任务会在该 PR 关闭后自动重新进入全局队列。',
].join('\n'),
);
appendGithubOutput(outputPath, 'status', 'waiting');
process.exit(0);
}
if (
buildCommentId &&
!(await claimComment(
client,
issueNumber,
buildCommentId,
process.env.GITHUB_RUN_ID,
event.action === 'code-agent-continue'
? event.client_payload.previous_run_id
: undefined,
))
) {
appendGithubOutput(outputPath, 'status', 'duplicate');
process.exit(0);
}
const workRef = await client.getRef(workBranch, true);
const baseRef = workRef ? workBranch : task.targetBranch;
const baseSha =
workRef?.object?.sha ??
(isSharedTaskBase(task.targetBranch, defaultBranch)
? await pinInitialBase(
client,
issueNumber,
task.targetBranch,
targetRef.object.sha,
)
: targetRef.object.sha);
if (batchSample && !workRef && baseSha !== batchSample.receipt.baseSha) {
throw new TaskInputError(
'评测样本的代码起点与批次冻结基线不一致,拒绝改用其他基线。',
);
}
appendGithubOutput(outputPath, 'base_ref', baseRef);
appendGithubOutput(outputPath, 'base_sha', baseSha);
appendGithubOutput(outputPath, 'status', 'ready');
// A continuation keeps the base its source run recorded (continuationBase in
// handoff-control.mjs), so show that commit rather than the live head.
const shownBaseSha = continuation
? (recordedContinuationBase(process.env.FACTORY_PREVIOUS_TASK, baseRef) ??
baseSha)
: baseSha;
if (task.commentKind !== 'reply')
await client.setIssueStatus(
issue,
'agent:running',
[
`Code Agent 工厂已开始处理。`,
'',
`- PR 合并目标:\`${task.targetBranch}\`${targetCreated ? '(刚从默认分支创建)' : ''}`,
`- 工作分支:\`${workBranch}\``,
`- 本轮代码起点:\`${baseRef} @ ${shownBaseSha}\`${shownBaseSha !== baseSha ? '(续跑沿用上一轮记录的起点)' : ''}`,
`- [查看本次运行](${process.env.GITHUB_SERVER_URL}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID})`,
].join('\n'),
);
} catch (error) {
if (!(error instanceof TaskInputError) || !issueNumber) throw error;
const issue = await client.getIssue(issueNumber);
await client.ensureStatusLabels();
await client.setIssueStatus(
issue,
'agent:needs-input',
`任务未进入队列:${error.message}`,
);
appendGithubOutput(outputPath, 'status', 'rejected');
console.error(error.message);
}
function parseArgs(argv) {
const parsed = {};
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index]?.replace(/^--/, '');
const value = argv[index + 1];
if (!key || value == null)
throw new Error(`Invalid argument near ${argv[index]}`);
parsed[key] = value;
}
if (!parsed.event || !parsed.metadata) {
throw new Error(
'Usage: prepare-task.mjs --event <path> --metadata <path> [--output <path>]',
);
}
return parsed;
}