Files

269 lines
9.9 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.
// Queue receipts live in trusted bot comments, independently of runner/artifact
// retention. Only the serialized queue workflow may mutate these receipts.
import { isTrustedAuthor, isTrustedIssue, parseIssueTask, TaskInputError } from './factory-lib.mjs';
const marker = '<!-- factory-build-v1\n';
export const runTitle = /^Factory issue #(\d+) build (\d+)(?:\s|$)/;
export function parseBuild(body) {
return (
/^\/build[\t ]*\r?\n([\s\S]*\S)\s*$/
.exec(body?.trim() ?? '')?.[1]
?.trim() || null
);
}
export function readReceipt(comment) {
if (
comment.user?.login !== 'github-actions[bot]' ||
comment.user?.type !== 'Bot'
)
return null;
const body = comment.body ?? '';
const start = body.lastIndexOf(marker);
if (start < 0) return null;
try {
const receipt = JSON.parse(
body.slice(start + marker.length, body.indexOf('\n-->', start)),
);
if (
!Number.isSafeInteger(receipt.id) ||
receipt.id <= 0 ||
!['queued', 'dispatched', 'done'].includes(receipt.status)
)
return null;
return { ...receipt, receiptId: comment.id };
} catch {
return null;
}
}
export function receiptBody(receipt) {
const data = { ...receipt };
delete data.receiptId;
data.excerpt = String(data.excerpt ?? data.prompt ?? '').slice(0, 1500);
delete data.prompt;
delete data.task;
const labels = {
queued: '排队中',
dispatched: '已调度 / 执行中',
done: '本轮已结束',
};
const json = JSON.stringify(data)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e');
const quote = data.excerpt
.slice(0, 1500)
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
const text = `回复[原评论 #${data.id}](${data.url})\n\n${quote}\n\n${data.kind === 'reply' ? '问答' : '搭建'}指令 #${data.id}:${labels[data.status]}${data.conclusion ? `(${data.conclusion})` : ''}。\n\n${data.runId ? `Actions run: ${data.runId}\n\n` : ''}后续指令继续使用本 Issue 的工作分支与 PR;失败轮次仅继承已保存到分支的成果。\n\n${marker}${json}\n-->`;
if (text.length > 60000)
throw new TaskInputError(
'搭建指令与 Issue 正文过长,请缩短后重新发表评论。',
);
return text;
}
export async function listAll(client, route, query = {}, key) {
const values = [];
for (let page = 1; ; page++) {
const result = await client.request('GET', route, {
query: { ...query, per_page: 100, page },
});
const items = key ? result[key] : result;
values.push(...items);
if (items.length < 100) return values;
}
}
export async function receiptsFor(client, issueNumber) {
const comments = await listAll(client, `/issues/${issueNumber}/comments`);
return {
comments,
receipts: comments
.map(readReceipt)
.filter(Boolean)
.sort((a, b) => a.id - b.id),
};
}
export async function saveReceipt(client, issueNumber, receipt) {
const body = receiptBody(receipt);
if (receipt.receiptId) {
await client.request('PATCH', `/issues/comments/${receipt.receiptId}`, {
body: { body },
});
} else {
const comment = await client.addComment(issueNumber, body);
receipt.receiptId = comment.id;
}
}
// Only the live Issue/comment is an input source. Queue excerpts
// are display hints, including for receipts written by the older snapshot code.
export function sourceComment(client, issueNumber, comments, id) {
const comment = comments.find((item) => item.id === Number(id));
if (
!comment ||
!comment.user?.login ||
comment.user?.type === 'Bot' ||
!isTrustedAuthor(comment)
) {
throw new TaskInputError('原评论已删除或作者不再符合执行条件。');
}
const buildPrompt = parseBuild(comment.body);
const prompt = buildPrompt || comment.body?.trim();
if (!prompt) throw new TaskInputError('原评论内容为空。');
return {
id: comment.id,
kind: buildPrompt ? 'build' : 'reply',
url: `https://github.com/${client.repository}/issues/${issueNumber}#issuecomment-${comment.id}`,
prompt,
};
}
export const UNTRUSTED_ISSUE =
'Issue 作者没有仓库权限,评论不会启动搭建或问答;维护者可通过 workflow_dispatch 显式运行该 Issue。';
export async function resolveBuildTask(client, issue, buildId, initialTask) {
// The Issue body is the round's requirements: re-checked for every comment
// round prepare starts (dispatch, continuation, recovery), like the comment.
if (!isTrustedIssue(issue)) throw new TaskInputError(UNTRUSTED_ISSUE);
const { comments, receipts } = await receiptsFor(client, issue.number);
const current = receipts.find((item) => item.id === Number(buildId));
if (!current || current.status !== 'dispatched')
throw new TaskInputError('追加指令未入队或已经结束;不能重复执行。');
const source = sourceComment(client, issue.number, comments, buildId);
const task = initialTask ?? parseIssueTask(issue);
const previous = receipts.filter(
(item) =>
item.id < current.id &&
item.status === 'done' &&
!/^(rejected:|cancelled:)/.test(item.conclusion ?? ''),
);
const history = previous.flatMap((item) => {
try {
const value = sourceComment(client, issue.number, comments, item.id);
return [
`## 先前${value.kind === 'reply' ? '讨论' : '追加指令'} #${item.id}(当前评论文本,原执行结果:${item.conclusion};不表示已按编辑后的内容重做)`,
value.prompt,
];
} catch (error) {
if (!(error instanceof TaskInputError)) throw error;
return [
`## 先前评论 #${item.id} 已删除或不可用,以实际代码及已有回复为准。`,
];
}
});
return {
...task,
// Agent replies are fallible context, not user requirements. Keep all user
// constraints above, but bound model-generated history to the latest notes.
discussionContext: comments
.filter(
(comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.user?.type === 'Bot' &&
previous.some((item) =>
comment.body?.endsWith(`<!-- factory-comment-reply:${item.id} -->`),
),
)
.slice(-3)
.map(
(comment) =>
`旧回复 #${comment.id}(非本轮要求)\n${comment.body.slice(0, 1900)}`,
)
.join('\n\n'),
commentKind: source.kind,
sourceComment: { id: source.id, url: source.url, prompt: source.prompt },
requirements: [
'## Issue 当前业务需求(背景与回归约束)',
task.requirements,
...history,
`## 本轮${source.kind === 'reply' ? '需要回答的评论' : '必须实现的追加指令'} #${source.id}`,
source.prompt,
'在已有工作分支上增量修改。先检查当前代码和上轮失败记录;以 Issue 和本轮评论的当前明确要求为准。',
].join('\n\n'),
acceptanceCriteria:
source.kind === 'build'
? `${task.acceptanceCriteria}\n\n同时逐条验证本轮追加指令及其验收要求:\n${source.prompt}`
: task.acceptanceCriteria,
};
}
export async function admitComments(client, issue, comments, receipts) {
// Refresh queued display/type and cancel deleted inputs. Dispatched inputs
// are read again by prepare; already completed entries are never replayed.
for (const receipt of receipts.filter((item) => item.status === 'queued')) {
const before = JSON.stringify(receipt);
try {
const source = sourceComment(client, issue.number, comments, receipt.id);
receipt.kind = source.kind;
receipt.excerpt = source.prompt.slice(0, 1500);
} catch (error) {
if (!(error instanceof TaskInputError)) throw error;
receipt.status = 'done';
receipt.conclusion = `cancelled: ${error.message}`;
}
delete receipt.prompt;
delete receipt.task;
if (JSON.stringify(receipt) !== before)
await saveReceipt(client, issue.number, receipt);
}
for (const comment of comments.sort((a, b) => a.id - b.id)) {
if (
!comment.body?.trim() ||
!comment.user?.login ||
comment.user?.type === 'Bot' ||
!isTrustedAuthor(comment) ||
receipts.some((item) => item.id === comment.id)
)
continue;
const source = sourceComment(client, issue.number, comments, comment.id);
const receipt = {
id: source.id,
kind: source.kind,
url: source.url,
excerpt: source.prompt.slice(0, 1500),
status: 'queued',
};
await saveReceipt(client, issue.number, receipt);
receipts.push(receipt);
}
receipts.sort((a, b) => a.id - b.id);
}
// The task workflow is serialized per Issue. Claims use separate, append-only
// comments so they cannot race with the scheduler's receipt updates.
export function claimedRuns(comments, buildId) {
return comments
.filter(
(comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.user?.type === 'Bot',
)
.map(
(comment) =>
new RegExp(`<!-- factory-comment-claim:${buildId}:(\\d+) -->$`).exec(
comment.body,
)?.[1],
)
.filter(Boolean)
.map(Number);
}
export async function claimComment(
client,
issueNumber,
buildId,
runId,
previousRunId,
) {
const { comments } = await receiptsFor(client, issueNumber);
const claims = claimedRuns(comments, buildId);
if (claims.includes(Number(runId)))
return Number(runId) === Math.max(...claims); // only retry the latest attempt in the chain
if (
claims.length &&
(!previousRunId || Number(previousRunId) !== Math.max(...claims))
)
return false;
if (!Number.isSafeInteger(Number(runId)) || Number(runId) <= 0)
throw new Error('Invalid claiming run ID');
await client.addComment(
issueNumber,
`开始处理[原评论 #${buildId}](https://github.com/${client.repository}/issues/${issueNumber}#issuecomment-${buildId}):[查看本轮 Actions](https://github.com/${client.repository}/actions/runs/${runId})。\n\n<!-- factory-comment-claim:${buildId}:${runId} -->`,
);
return true;
}