diff --git a/package.json b/package.json index ba19b6b..8d3e1d0 100644 --- a/package.json +++ b/package.json @@ -163,5 +163,10 @@ "drizzle-kit": { "@esbuild-kit/esm-loader": "npm:tsx@^4.20.4" } + }, + "allowScripts": { + "esbuild@0.28.0": true, + "esbuild@0.27.4": true, + "esbuild@0.25.12": true } } diff --git a/server/agentRunner.ts b/server/agentRunner.ts index bfe45b1..66a0522 100644 --- a/server/agentRunner.ts +++ b/server/agentRunner.ts @@ -232,7 +232,7 @@ export async function evaluateFixNecessityWithAgent(params: { outputFile, extractionPrompt, ], settings), - { cwd, timeoutMs: 180000 }, + { cwd, timeoutMs: 420000 }, ); if (result.code !== 0) { @@ -268,7 +268,7 @@ export async function evaluateFixNecessityWithAgent(params: { const result = await runAgentCommand( "claude", claudeArgs, - { cwd, timeoutMs: 180000 }, + { cwd, timeoutMs: 420000 }, ); if (result.code !== 0) { @@ -288,7 +288,7 @@ export async function applyFixesWithAgent(params: { onStdoutChunk?: (chunk: string) => void; onStderrChunk?: (chunk: string) => void; }): Promise { - const { agent, cwd, prompt, settings, env, timeoutMs = 900000, onStdoutChunk, onStderrChunk } = params; + const { agent, cwd, prompt, settings, env, timeoutMs = 5400000, onStdoutChunk, onStderrChunk } = params; if (agent === "codex") { const result = await runAgentCommand( diff --git a/server/babysitter.test.ts b/server/babysitter.test.ts index 6daa80e..3d46b70 100644 --- a/server/babysitter.test.ts +++ b/server/babysitter.test.ts @@ -2731,7 +2731,7 @@ test("babysitPR uses a CODEFACTORY_HOME worktree, passes GitHub context, and ver assert.deepEqual(postedFollowUps, [ { id: "gh-review-comment-1", - body: `Addressed in commit \`def456\`.\n\nRenamed the variable from \`foo\` to \`bar\` as requested.\n\n\n\n${APP_COMMENT_FOOTER}`, + body: `已在提交 \`def456\` 中处理。\n\nRenamed the variable from \`foo\` to \`bar\` as requested.\n\n\n\n${APP_COMMENT_FOOTER}`, }, ]); assert.equal(postedAgentComments.length, 1); @@ -3336,7 +3336,7 @@ test("babysitPR omits repository links in GitHub comments when disabled", async assert.deepEqual(postedFollowUps, [ { id: "gh-review-comment-1", - body: "Addressed in commit `def456`.\n\nRenamed the variable from `foo` to `bar` as requested.\n\n", + body: "已在提交 `def456` 中处理。\n\nRenamed the variable from `foo` to `bar` as requested.\n\n", }, ]); assert.equal(postedAgentComments.length, 1); @@ -3397,7 +3397,7 @@ test("babysitPR keeps acceptance local and logs best-effort reaction failures", makeFeedbackItem({ id: "gh-review-comment-3", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${firstItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${firstItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${firstItem.auditToken}

`, sourceId: "3", sourceNodeId: "PRRC_kwDO_followup_1", @@ -3412,7 +3412,7 @@ test("babysitPR keeps acceptance local and logs best-effort reaction failures", makeFeedbackItem({ id: "gh-review-comment-4", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${secondItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${secondItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${secondItem.auditToken}

`, sourceId: "4", sourceNodeId: "PRRC_kwDO_followup_2", @@ -3544,7 +3544,7 @@ test("babysitPR posts progress replies when GitHub progress replies are enabled" const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -3611,13 +3611,13 @@ test("babysitPR posts progress replies when GitHub progress replies are enabled" await babysitter.babysitPR(pr.id, "codex"); - const expectedAcceptedLine = "**Accepted** \u2014 queued for a code change."; + const expectedAcceptedLine = "**已接受** \u2014 已排队等待代码修改。"; const expectedAcceptedStatus = `${expectedAcceptedLine}\n\n${APP_COMMENT_FOOTER}`; const expectedFinalStatusBody = [ expectedAcceptedLine, - "**In progress** \u2014 applying the accepted fix.", - "**Verifying** \u2014 checking the applied changes.", - "**Resolved** \u2014 addressed in commit `def456`.", + "**进行中** \u2014 正在应用已接受的修复。", + "**验证中** \u2014 正在检查已应用的修改。", + "**已解决** \u2014 已在提交 `def456` 中处理。", "", APP_COMMENT_FOOTER, ].join("\n"); @@ -3648,7 +3648,7 @@ test("runQueuedBabysitPR recovers an existing status reply after restart", async const staleStatusReply = makeFeedbackItem({ id: "gh-review-comment-77", author: "alex-morgan-o", - body: "\u23f3 **Accepted** \u2014 queued for a code change.", + body: "\u23f3 **已接受** \u2014 已排队等待代码修改。", sourceId: "77", sourceNodeId: "PRRC_kwDO_status", sourceUrl: "https://github.com/octo/example/pull/42#discussion_r77", @@ -3770,8 +3770,8 @@ test("runQueuedBabysitPR recovers an existing status reply after restart", async await babysitter.runQueuedBabysitPR(pr.id, "codex"); - assert.ok(updatedStatusBodies.some((body) => body.includes("**In progress**"))); - assert.ok(updatedStatusBodies.at(-1)?.includes("**Resolved**")); + assert.ok(updatedStatusBodies.some((body) => body.includes("**进行中**"))); + assert.ok(updatedStatusBodies.at(-1)?.includes("**已解决**")); const [run] = await storage.listAgentRuns({ prId: pr.id }); assert.equal(run?.status, "completed"); assert.equal((run?.metadata?.statusReplyRefs as Record | undefined)?.[existingItem.id]?.commentDatabaseId, 77); @@ -3940,7 +3940,7 @@ test("babysitPR keeps failed GitHub progress replies concise", async () => { const body = statusReplyRefs.get(existingItem.id)?.body ?? ""; assert.match( body, - /\*\*Needs attention\*\* \u2014 automatic fix failed: TypeScript check failed/, + /\*\*需要关注\*\* \u2014 自动修复失败:TypeScript check failed/, ); } finally { delete process.env.CODEFACTORY_HOME; @@ -4119,7 +4119,7 @@ test("babysitPR treats footerless status replies as non-actionable", async () => await storage.updateConfig({ autoUpdateDocs: false }); const statusReply = makeFeedbackItem({ author: "octocat", - body: "**In progress** \u2014 applying the accepted fix.", + body: "**进行中** \u2014 正在应用已接受的修复。", bodyHtml: "

In progress - applying the accepted fix.

", decision: null, status: "pending", @@ -4601,16 +4601,174 @@ test("babysitPR does not pull rejected or resolved items into in_progress", asyn const updated = await storage.getPR(pr.id); const updatedRejected = updated?.feedbackItems.find((i) => i.id === rejectedItem.id); const updatedResolved = updated?.feedbackItems.find((i) => i.id === resolvedItem.id); - assert.equal(updatedRejected?.status, "rejected", "rejected item should keep its status"); + // Rejected items are no longer pulled into in_progress (no code fix), but a + // rejected review thread now gets a closing GitHub follow-up + resolution. + assert.equal(updatedRejected?.decision, "reject", "rejected item should keep its decision"); assert.equal(updatedResolved?.status, "resolved", "resolved item should keep its status"); delete process.env.CODEFACTORY_HOME; }); +test("babysitPR does not re-reply to its own rejected audit-trail comments", async () => { + const storage = new MemStorage(); + await storage.updateConfig({ autoUpdateDocs: false }); + // A comment PatchDeck itself posted (contains the audit trail marker) that was + // rejected must not trigger another follow-up reply, or it would loop forever. + const ownReply = makeFeedbackItem({ + id: "gh-review-comment-own-audit-reply", + author: "cwbcheng", + body: `已在提交 \`abc123\` 中处理。\n\n`, + status: "rejected", + decision: "reject", + statusReason: "PatchDeck audit trail comment", + }); + const pr = await storage.addPR({ + number: 106, + title: "Verbose PR", + repo: "alex-morgan-o/lolodex", + branch: "feature/verbose", + author: "octocat", + url: "https://github.com/alex-morgan-o/lolodex/pull/106", + status: "watching", + feedbackItems: [ownReply], + accepted: 0, + rejected: 1, + flagged: 0, + testsPassed: null, + lintPassed: null, + lastChecked: null, + }); + + let postedFollowUpCount = 0; + const babysitter = new PRBabysitter( + storage, + makeWatcherGitHubService({ + fetchFeedbackItemsForPR: async () => [ownReply], + fetchPullSummary: async () => makePullSummary(pr), + listFailingStatuses: async () => [], + postFollowUpForFeedbackItem: async () => { + postedFollowUpCount += 1; + }, + }), + { + resolveAgent: async () => "codex", + ciPollIntervalMs: 0, + evaluateFixNecessityWithAgent: async () => { + throw new Error("own audit-trail reply should not be evaluated"); + }, + applyFixesWithAgent: async () => { + throw new Error("own audit-trail reply should not trigger a fix run"); + }, + runCommand: makeGitRunCommand(), + }, + ); + + await babysitter.babysitPR(pr.id, "codex"); + + assert.equal(postedFollowUpCount, 0, "PatchDeck's own rejected audit-trail comment must not be re-replied"); + const updated = await storage.getPR(pr.id); + const item = updated?.feedbackItems.find((i) => i.id === ownReply.id); + assert.equal(item?.status, "rejected"); +}); + +test("babysitPR replies to and resolves rejected review threads", async () => { + const storage = new MemStorage(); + await storage.updateConfig({ autoUpdateDocs: false }); + const rejectedItem = makeFeedbackItem({ + id: "gh-review-comment-rejected-thread", + status: "rejected", + decision: "reject", + decisionReason: "The referenced code does not exist in this branch.", + statusReason: "The referenced code does not exist in this branch.", + }); + const pr = await storage.addPR({ + number: 106, + title: "Verbose PR", + repo: "alex-morgan-o/lolodex", + branch: "feature/verbose", + author: "octocat", + url: "https://github.com/alex-morgan-o/lolodex/pull/106", + status: "watching", + feedbackItems: [rejectedItem], + accepted: 0, + rejected: 1, + flagged: 0, + testsPassed: null, + lintPassed: null, + lastChecked: null, + }); + + const worktreeRoot = await mkdtemp(path.join(os.tmpdir(), "codefactory-home-")); + process.env.CODEFACTORY_HOME = worktreeRoot; + let feedbackFetchCount = 0; + const pullSummary = makePullSummary(pr); + const postedFollowUps: Array<{ id: string; body: string; resolve?: boolean }> = []; + const resolvedThreads: string[] = []; + + const babysitter = new PRBabysitter( + storage, + { + buildOctokit: async () => ({}) as never, + fetchFeedbackItemsForPR: async () => { + feedbackFetchCount += 1; + if (feedbackFetchCount === 1) return [rejectedItem]; + return [{ ...rejectedItem, threadResolved: true }]; + }, + fetchPullSummary: async () => pullSummary, + listFailingStatuses: async () => [], + checkCISettled: async () => true, + listOpenPullsForRepo: async () => [], + postFollowUpForFeedbackItem: async (_octokit, _parsed, item, body, options) => { + postedFollowUps.push({ id: item.id, body, resolve: options?.resolve }); + if (options?.resolve && item.threadId) { + resolvedThreads.push(item.threadId); + } + }, + resolveReviewThread: async (_octokit, _parsed, threadId) => { + resolvedThreads.push(threadId); + }, + resolveGitHubAuthToken: async () => "test-token", + addReactionToComment: async () => {}, + postStatusReplyForFeedbackItem: async () => null, + updateStatusReply: async () => {}, + }, + { + resolveAgent: async () => "codex", + ciPollIntervalMs: 0, + evaluateFixNecessityWithAgent: async () => { + throw new Error("evaluateFixNecessityWithAgent should not be called for already-rejected items"); + }, + applyFixesWithAgent: async () => ({ code: 0, stdout: "", stderr: "" }), + runCommand: makeGitRunCommand(), + }, + ); + + await babysitter.babysitPR(pr.id, "codex"); + + const updated = await storage.getPR(pr.id); + const updatedItem = updated?.feedbackItems.find((i) => i.id === rejectedItem.id); + + assert.equal(postedFollowUps.length, 1, "rejected review thread should get a GitHub follow-up"); + assert.equal(postedFollowUps[0]?.id, rejectedItem.id); + assert.equal(postedFollowUps[0]?.resolve, true, "follow-up should resolve the conversation"); + assert.match(postedFollowUps[0]?.body ?? "", /已拒绝|原因/, "follow-up body should explain the rejection in Chinese"); + assert.equal(resolvedThreads.includes(rejectedItem.threadId ?? ""), true, "thread should be resolved on GitHub"); + assert.equal(updatedItem?.threadResolved, true, "feedback item should be marked thread-resolved"); + + delete process.env.CODEFACTORY_HOME; +}); + test("babysitPR skips run when no items are pending or queued", async () => { const storage = new MemStorage(); await storage.updateConfig({ autoUpdateDocs: false }); - const rejectedItem = makeFeedbackItem({ status: "rejected", decision: "reject" }); + // Non-review-thread rejected feedback does not require a GitHub follow-up, + // so the run can be skipped entirely. + const rejectedItem = makeFeedbackItem({ + status: "rejected", + decision: "reject", + replyKind: "general_comment", + threadId: null, + }); const pr = await storage.addPR({ number: 106, title: "Verbose PR", @@ -5319,7 +5477,7 @@ test("babysitPR continues comment remediation when docs assessment fails", async const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -5433,7 +5591,7 @@ test("babysitPR retries accepted in-progress feedback items that still need GitH const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit abc123.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -5513,7 +5671,7 @@ test("babysitPR retries accepted in-progress feedback items that still need GitH assert.deepEqual(postedFollowUps, [ { id: "gh-review-comment-1", - body: `Addressed in commit \`abc123\`.\n\n\n\n${APP_COMMENT_FOOTER}`, + body: `已在提交 \`abc123\` 中处理。\n\n\n\n${APP_COMMENT_FOOTER}`, }, ]); assert.deepEqual(resolvedThreads, ["PRRT_kwDO_example"]); @@ -5567,7 +5725,7 @@ test("resumeInterruptedRuns replays the persisted prompt when the PR head has no const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -6031,12 +6189,23 @@ test("runQueuedBabysitPR falls back to the next coding agent when enabled", asyn }); const evaluatedAgents: string[] = []; + let feedbackFetchCount = 0; const babysitter = new PRBabysitter( storage, makeWatcherGitHubService({ - fetchFeedbackItemsForPR: async () => [existingItem], + fetchFeedbackItemsForPR: async () => { + feedbackFetchCount += 1; + if (feedbackFetchCount === 1) return [existingItem]; + // The rejected thread was replied to and resolved on GitHub. + return [{ ...existingItem, threadResolved: true }]; + }, fetchPullSummary: async () => makePullSummary(pr), listFailingStatuses: async () => [], + postFollowUpForFeedbackItem: async (_octokit, _parsed, item, _body, options) => { + if (options?.resolve && item.threadId) { + existingItem.threadResolved = true; + } + }, }), { resolveAgent: async (agent) => agent, @@ -6065,6 +6234,7 @@ test("runQueuedBabysitPR falls back to the next coding agent when enabled", asyn const updated = await storage.getPR(pr.id); assert.equal(updated?.status, "watching"); assert.equal(updated?.feedbackItems[0]?.status, "rejected"); + assert.equal(updated?.feedbackItems[0]?.threadResolved, true); const logs = await storage.getLogs(pr.id); assert.ok(logs.some((log) => log.level === "warn" && log.message.includes("Falling back from claude to codex"))); @@ -6249,6 +6419,114 @@ test("runQueuedBabysitPR launches code-owner fallback after the default run fail assert.equal(jobs[0]?.payload.monitorReason, "GitHub mergeable state is blocked"); }); +test("runQueuedBabysitPR commits and pushes uncommitted code-owner fallback agent changes", async () => { + const storage = new MemStorage(); + await storage.updateConfig({ autoUpdateDocs: false }); + const pr = await storage.addPR({ + number: 106, + title: "Verbose PR", + repo: "alex-morgan-o/lolodex", + branch: "feature/verbose", + author: "octocat", + url: "https://github.com/alex-morgan-o/lolodex/pull/106", + status: "watching", + feedbackItems: [], + accepted: 0, + rejected: 0, + flagged: 0, + testsPassed: null, + lintPassed: null, + lastChecked: null, + }); + const worktreeRoot = await mkdtemp(path.join(os.tmpdir(), "codefactory-home-")); + process.env.CODEFACTORY_HOME = worktreeRoot; + const backgroundJobQueue = new BackgroundJobQueue(storage); + const gitCommands: string[] = []; + const applyCalls: Array<{ agent: string; cwd?: string }> = []; + + try { + const babysitter = new PRBabysitter( + storage, + makeWatcherGitHubService({ + fetchPullSummary: async () => makePullSummary(pr, { mergeableState: "blocked" }), + listFailingStatuses: async () => [{ + context: "build", + description: "TypeScript compilation failed", + targetUrl: "https://github.com/octo/example/actions/runs/1", + }], + }), + { + resolveAgent: async () => "claude", + ciPollIntervalMs: 0, + evaluateFixNecessityWithAgent: async () => ({ + needsFix: true, + reason: "Build failure needs a code change", + }), + applyFixesWithAgent: async ({ agent, cwd }) => { + applyCalls.push({ agent, cwd }); + if (applyCalls.length === 1) { + return { code: 1, stdout: "", stderr: "default run failed" }; + } + // Fallback agent modified files but did not commit or push them. + return { code: 0, stdout: "fallback handled the PR", stderr: "" }; + }, + runCommand: async (command: string, args: string[]) => { + gitCommands.push([command, ...args].join(" ")); + if (command !== "git") { + return { code: 1, stdout: "", stderr: `unexpected command: ${command}` }; + } + + // Worktree status reports uncommitted changes after the fallback agent. + if (args[0] === "status" && args[1] === "--porcelain") { + return { code: 0, stdout: " M src/file.ts\n", stderr: "" }; + } + + // Local head is a new commit the agent (or PatchDeck) created. + if (args[0] === "rev-parse" && args[1] === "HEAD") { + return { code: 0, stdout: "localnew123\n", stderr: "" }; + } + + // FETCH_HEAD reflects the remote after the push: once the fetch + // happened, the remote head matches the locally created commit. + if (args[0] === "-C" && args[2] === "rev-parse" && args[3] === "FETCH_HEAD") { + const alreadyPushed = gitCommands.some((cmd) => cmd.startsWith("git push") && cmd.includes("HEAD:feature/verbose")); + return { code: 0, stdout: `${alreadyPushed ? "localnew123" : "remoteold456"}\n`, stderr: "" }; + } + + if (args[0] === "-C" && args[2] === "status") { + return { code: 0, stdout: "", stderr: "" }; + } + + if (args[0] === "-C" && args[2] === "fetch") { + return { code: 0, stdout: "fetched\n", stderr: "" }; + } + + return { code: 0, stdout: "", stderr: "" }; + }, + }, + undefined, + async (...args) => backgroundJobQueue.enqueue(...args), + ); + + await babysitter.runQueuedBabysitPR(pr.id, "claude"); + } finally { + delete process.env.CODEFACTORY_HOME; + } + + const [run] = await storage.listAgentRuns({ prId: pr.id }); + const logs = await storage.getLogs(pr.id); + + assert.equal(applyCalls.length, 2); + assert.equal(run?.status, "completed"); + assert.equal(run?.phase, "code-owner-fallback.completed"); + + // PatchDeck should have staged, committed, and pushed the uncommitted agent edits. + assert.ok(gitCommands.some((cmd) => cmd.startsWith("git add -A")), "expected git add -A"); + assert.ok(gitCommands.some((cmd) => cmd.includes("commit") && cmd.includes("--no-verify")), "expected git commit"); + assert.ok(gitCommands.some((cmd) => cmd.startsWith("git push") && cmd.includes("HEAD:feature/verbose")), "expected git push to PR branch"); + assert.ok(logs.some((log) => log.phase === "code-owner-fallback" && log.message.includes("finalized")), "expected finalize log"); +}); + test("runQueuedBabysitPR records code-owner fallback failure phase", async () => { const storage = new MemStorage(); await storage.updateConfig({ autoUpdateDocs: false }); @@ -6345,7 +6623,7 @@ test("babysitPR resolves lingering review threads without reposting an existing const priorFollowUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit abc123.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -6592,7 +6870,7 @@ test("babysitPR reposts GitHub follow-up when an earlier audit trail used the wr const priorFollowUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit abc123.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -6610,7 +6888,7 @@ test("babysitPR reposts GitHub follow-up when an earlier audit trail used the wr const correctedFollowUp = makeFeedbackItem({ id: "gh-review-comment-3", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit abc123.

${existingItem.auditToken}

`, sourceId: "3", sourceNodeId: "PRRC_kwDO_followup_corrected", @@ -6712,7 +6990,7 @@ test("babysitPR reposts GitHub follow-up when an earlier audit trail used the wr assert.deepEqual(postedFollowUps, [ { id: "gh-review-comment-1", - body: `Addressed in commit \`abc123\`.\n\n\n\n${APP_COMMENT_FOOTER}`, + body: `已在提交 \`abc123\` 中处理。\n\n\n\n${APP_COMMENT_FOOTER}`, }, ]); assert.deepEqual(resolvedThreads, ["PRRT_kwDO_example"]); @@ -7054,7 +7332,7 @@ test("babysitPR skips conflict resolution when PR is mergeable", async () => { const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", diff --git a/server/babysitter.ts b/server/babysitter.ts index b28bdc8..c665343 100644 --- a/server/babysitter.ts +++ b/server/babysitter.ts @@ -69,6 +69,7 @@ import { applyEvaluationDecision, isFeedbackClosedStatus, markInProgress, + markReviewConversationResolved, markResolved, markFailed, markRetry, @@ -83,7 +84,7 @@ const DEPENDENCY_PREFLIGHT_FAILURE_PREFIX = "Dependency preflight failed:"; const DEPENDENCY_PREFLIGHT_FAILURE_KIND = "dependency_preflight"; const CONFLICT_REPAIR_FAILURE_KIND = "merge_conflict_repair"; const CONFLICT_REPAIR_RETRY_BUDGET = 2; -const CODE_OWNER_FALLBACK_TIMEOUT_MS = 30 * 60 * 1000; +const CODE_OWNER_FALLBACK_TIMEOUT_MS = 90 * 60 * 1000; const AGENT_STREAM_LOG_LINE_LIMIT = 120; const MAX_FEEDBACK_SYNCS_PER_TICK = 20; const FEEDBACK_SYNC_RATE_LIMIT_COOLDOWN_MS = 5 * 60 * 1000; @@ -240,17 +241,17 @@ const defaultBabysitterRuntime: BabysitterRuntime = { }; const STATUS_MESSAGES = { - accepted: "**Accepted** — queued for a code change.", - agentRunning: (_agent: CodingAgent) => "**In progress** — applying the accepted fix.", + accepted: "**已接受** — 已排队等待代码修改。", + agentRunning: (_agent: CodingAgent) => "**进行中** — 正在应用已接受的修复。", agentFailed: (_agent: CodingAgent, reason?: string) => reason - ? `**Needs attention** — automatic fix failed: ${reason}` - : "**Needs attention** — automatic fix failed.", - agentCompleted: "**Verifying** — checking the applied changes.", + ? `**需要关注** — 自动修复失败:${reason}` + : "**需要关注** — 自动修复失败。", + agentCompleted: "**验证中** — 正在检查已应用的修改。", resolved: (headSha: string) => { const shortSha = headSha.trim().slice(0, 7); return shortSha - ? `**Resolved** — addressed in commit \`${shortSha}\`.` - : "**Resolved** — addressed in the latest update."; + ? `**已解决** — 已在提交 \`${shortSha}\` 中处理。` + : "**已解决** — 已在最新更新中处理。"; }, } as const; @@ -633,6 +634,7 @@ function buildCodeOwnerFallbackPrompt(params: { " - Reply directly to the GitHub comment/thread with what was done, or why it was rejected.", " - Resolve the GitHub conversation after replying.", " - If the item is not a resolvable review thread, leave the reply/comment and note that there was no thread to resolve.", + "Language: write every GitHub comment, thread reply, and summary in Simplified Chinese.", "6. When all valid feedback is handled:", " - Confirm the worktree is clean except for intended changes.", " - Commit changes if any were made.", @@ -938,7 +940,11 @@ function collectAuditTrailErrors(params: { const errors: string[] = []; for (const item of followUpTasks) { - if (!hasAuditTrail(item, pr.feedbackItems, runStartedAtMs)) { + // Rejected review-thread items are closed with an explanation reply and + // conversation resolution; there is no code change to audit, so only the + // thread resolution is verified for them. + const isRejectedReviewThread = item.decision === "reject" && item.replyKind === "review_thread"; + if (!isRejectedReviewThread && !hasAuditTrail(item, pr.feedbackItems, runStartedAtMs)) { errors.push(`missing audit trail for ${item.id}`); } @@ -954,11 +960,24 @@ function collectAuditTrailErrors(params: { } function needsGitHubFollowUp(item: FeedbackItem, feedbackItems: FeedbackItem[]): boolean { - if (item.decision !== "accept") { - return false; - } - - if (item.status !== "queued" && item.status !== "in_progress") { + // Accepted feedback items awaiting applied work. + const isAcceptedWork = item.decision === "accept" + && (item.status === "queued" || item.status === "in_progress"); + + // Rejected review-thread items still need a closing reply (why it was + // rejected) plus conversation resolution, matching the agent prompt: + // "For rejected feedback: reply ... with what was done, or why it was + // rejected. Resolve the GitHub conversation after replying." + // PatchDeck's own status/audit-trail replies are excluded: they were + // rejected as "not new work", not as reviewer feedback, so they must not + // trigger another reply (that would loop on our own comments). + const rejectedInternalReply = item.decision === "reject" + && /PatchDeck (agent command|audit trail|status) comment|Automation audit trail follow-up/i.test(item.statusReason ?? ""); + const isRejectedReviewThread = item.decision === "reject" + && item.replyKind === "review_thread" + && !rejectedInternalReply; + + if (!isAcceptedWork && !isRejectedReviewThread) { return false; } @@ -1100,12 +1119,19 @@ function buildFeedbackFollowUpBody( agentSummary?: string, ): string { const shortSha = headSha.trim() ? headSha.trim().slice(0, 7) : ""; - const headline = shortSha - ? `Addressed in commit \`${shortSha}\`.` - : "Addressed in the latest update."; + const rejected = item.decision === "reject"; + const headline = rejected + ? "已拒绝 — 未做代码修改。" + : shortSha + ? `已在提交 \`${shortSha}\` 中处理。` + : "已在最新更新中处理。"; const parts = [headline]; + if (rejected && item.statusReason) { + parts.push("", `**原因:** ${item.statusReason}`); + } + // For non-review-thread items the follow-up is posted as a top-level PR // comment, so include a reference to the original comment for a clear audit // trail linking the fix back to the feedback. @@ -1114,7 +1140,7 @@ function buildFeedbackFollowUpBody( const preview = firstLine.length > 120 ? firstLine.slice(0, 120) + "…" : firstLine; parts.push( "", - `> Responding to [comment by @${item.author}](${item.sourceUrl}):`, + `> 正在回复 [@${item.author} 的评论](${item.sourceUrl}):`, `> ${preview}`, ); } @@ -3340,6 +3366,92 @@ export class PRBabysitter { return true; }; + const finalizeCodeOwnerFallbackWorktree = async (params: { + prId: string; + cwd: string; + repoCacheDir: string; + remoteName: string; + headRef: string; + agent: CodingAgent; + }): Promise => { + const { prId, cwd, repoCacheDir, remoteName, headRef, agent } = params; + const phase = "code-owner-fallback"; + + // Commit any uncommitted agent edits so the local head captures them. + const committed = await commitDirtyWorktree({ + currentPrId: prId, + cwd, + commitArgs: ["commit", "--no-verify", "--no-edit", "-m", `Apply ${agent} code-owner fallback fixes for PR`], + phase, + context: "code-owner fallback agent run", + }); + + // If the agent already committed but did not push (or we just committed), + // push the local head to the PR branch. When the agent already pushed, + // local HEAD equals the remote HEAD and the push is a no-op success. + const pushResult = await runLoggedCommand({ + currentPrId: prId, + command: "git", + args: ["push", remoteName, `HEAD:${headRef}`], + cwd, + timeoutMs: 120000, + phase, + successMessage: `Pushed code-owner fallback work to ${remoteName}/${headRef}`, + }); + if (pushResult.code !== 0) { + throw new Error(formatGitFailure(`pushing ${remoteName}/${headRef} after code-owner fallback`, pushResult)); + } + + // Confirm the pushed head is visible on the remote. + const remoteFetch = await runLoggedCommand({ + currentPrId: prId, + command: "git", + args: ["-C", repoCacheDir, "fetch", remoteName, headRef], + timeoutMs: 120000, + phase, + successMessage: `Fetched ${remoteName}/${headRef} after code-owner fallback push`, + }); + if (remoteFetch.code !== 0) { + throw new Error(formatGitFailure(`fetching ${remoteName}/${headRef} after code-owner fallback push`, remoteFetch)); + } + + const remoteHead = await runLoggedCommand({ + currentPrId: prId, + command: "git", + args: ["-C", repoCacheDir, "rev-parse", "FETCH_HEAD"], + timeoutMs: 5000, + phase, + successMessage: "Collected remote PR head SHA after code-owner fallback push", + }); + if (remoteHead.code !== 0) { + throw new Error(formatGitFailure("reading remote PR head after code-owner fallback push", remoteHead)); + } + + const localHead = await runLoggedCommand({ + currentPrId: prId, + command: "git", + args: ["rev-parse", "HEAD"], + cwd, + timeoutMs: 5000, + phase, + successMessage: "Collected local PR head SHA after code-owner fallback", + }); + if (localHead.code !== 0) { + throw new Error(formatGitFailure("reading local PR head after code-owner fallback", localHead)); + } + + if (localHead.stdout.trim() !== remoteHead.stdout.trim()) { + throw new Error( + `Code-owner fallback work was not reflected on ${remoteName}/${headRef} (local ${localHead.stdout.trim().slice(0, 8)}, remote ${remoteHead.stdout.trim().slice(0, 8)})`, + ); + } + + await queueLog(prId, "info", `Code-owner fallback work finalized on ${remoteName}/${headRef}`, { + phase, + metadata: { committed, remoteHead: remoteHead.stdout.trim().slice(0, 12) }, + }); + }; + const runCodeOwnerFallbackAfterFailure = async (params: { pr: PR; failureMessage: string; @@ -3482,6 +3594,20 @@ export class PRBabysitter { metadata: { agent, cwd }, }); + // The code-owner fallback agent is asked to commit and push its own + // changes, but agents sometimes finish without doing so (e.g. a + // truncated model response). If the worktree has uncommitted changes + // or a local commit that was not pushed, PatchDeck finalizes the PR + // branch so the review threads can be closed against the real head. + await finalizeCodeOwnerFallbackWorktree({ + prId: pr.id, + cwd: worktreePath, + repoCacheDir, + remoteName, + headRef: pullSummary.headRef, + agent, + }); + return { agent, prompt }; } finally { await stdoutLogger.flush(); @@ -5254,7 +5380,12 @@ export class PRBabysitter { }, }); - await updateItemStatus(item.id, STATUS_MESSAGES.resolved(headShaForFollowUp)); + await updateItemStatus( + item.id, + item.decision === "reject" + ? "**已拒绝** — 已回复评论线程并解决会话。" + : STATUS_MESSAGES.resolved(headShaForFollowUp), + ); } pr = await this.syncFeedbackForPR(pr.id, { @@ -5286,9 +5417,16 @@ export class PRBabysitter { if (followUpTasks.length > 0) { const resolvedIds = new Set(followUpTasks.map((item) => item.id)); - const resolvedItems = pr.feedbackItems.map((item) => - resolvedIds.has(item.id) ? markResolved(item) : item, - ); + const resolvedItems = pr.feedbackItems.map((item) => { + if (!resolvedIds.has(item.id)) { + return item; + } + // Rejected review threads keep their rejection decision; only the + // conversation resolution flag is advanced. + return item.decision === "reject" + ? markReviewConversationResolved(item) + : markResolved(item); + }); const resolvedCounters = countDecisions(resolvedItems); const resolvedPR = await this.storage.updatePR(pr.id, { feedbackItems: resolvedItems, diff --git a/server/github.ts b/server/github.ts index 3b9386c..cc2a057 100644 --- a/server/github.ts +++ b/server/github.ts @@ -474,7 +474,7 @@ export function buildFeedbackAuditToken(feedbackId: string): string { const APP_COMMENT_FOOTER_PATTERN = /Posted by \[[^\]]+\]\(https:\/\/github\.com\/jeremymcs\/patchdeck\)/i; const AGENT_COMMAND_COMMENT_MARKER = ""; const AUDIT_TRAIL_COMMENT_PATTERN = //i; -export const APP_STATUS_COMMENT_PATTERN = /\*\*(?:Accepted|Agent running|Agent failed|Agent completed|In progress|Needs attention|Verifying|Resolved)\*\*\s*(?:[—-]|$)/i; +export const APP_STATUS_COMMENT_PATTERN = /\*\*(?:Accepted|Agent running|Agent failed|Agent completed|In progress|Needs attention|Verifying|Resolved|已接受|进行中|需要关注|验证中|已解决)\*\*\s*(?:[—-]|$)/i; function classifyNonActionableAppFeedback(body: string): string | null { if (body.includes(AGENT_COMMAND_COMMENT_MARKER)) {