diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 0c9a797e79..d96deba3ce 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -3151,6 +3151,7 @@ export function shouldSyncReviewComment(options: { needsReviewCommentBodySync: boolean; needsReviewCommentHashSync: boolean; needsReviewCommentReferenceSync: boolean; + forceReviewCommentBodySync?: boolean; now?: number; }): boolean { if ( @@ -3161,6 +3162,7 @@ export function shouldSyncReviewComment(options: { return false; } if (!options.syncCommentsOnly || options.isCloseProposal) return true; + if (options.forceReviewCommentBodySync && options.needsReviewCommentBodySync) return true; if (!options.hasExistingReviewComment || options.needsReviewCommentReferenceSync) return true; if (options.commentSyncMinAgeDays <= 0) return true; if (!options.reviewCommentSyncedAt) return true; @@ -7920,12 +7922,13 @@ function syncTelegramVisibleProofLabel(options: { labels: readonly string[]; proof: Pick; dryRun: boolean; -}): string[] { +}): { labels: string[]; changed: boolean } { const nextLabels = nextTelegramVisibleProofLabels(options.labels, options.proof); const hadLabel = options.labels.includes(TELEGRAM_VISIBLE_PROOF_LABEL); const wantsLabel = nextLabels.includes(TELEGRAM_VISIBLE_PROOF_LABEL); - if (hadLabel === wantsLabel) return nextLabels; - if (options.dryRun) return nextLabels; + const changed = hadLabel !== wantsLabel; + if (!changed) return { labels: nextLabels, changed }; + if (options.dryRun) return { labels: nextLabels, changed }; if (wantsLabel) ensureTelegramVisibleProofLabel(); ghWithRetry([ "issue", @@ -7934,7 +7937,7 @@ function syncTelegramVisibleProofLabel(options: { wantsLabel ? "--add-label" : "--remove-label", TELEGRAM_VISIBLE_PROOF_LABEL, ]); - return nextLabels; + return { labels: nextLabels, changed }; } function ensurePrRatingLabel(tier: PrRatingTier): void { @@ -7984,7 +7987,7 @@ function syncPrRatingLabel(options: { labels: readonly string[]; rating: Pick; dryRun: boolean; -}): string[] { +}): { labels: string[]; changed: boolean } { const nextLabels = nextPrRatingLabels(options.labels, options.rating); const currentLabelKeys = new Set(options.labels.map((label) => label.toLowerCase())); const nextLabelKeys = new Set(nextLabels.map((label) => label.toLowerCase())); @@ -7994,8 +7997,9 @@ function syncPrRatingLabel(options: { const labelToAdd = nextLabels.find( (label) => PR_RATING_LABEL_NAMES.has(label) && !currentLabelKeys.has(label.toLowerCase()), ); - if (!labelsToRemove.length && !labelToAdd) return nextLabels; - if (options.dryRun) return nextLabels; + const changed = labelsToRemove.length > 0 || Boolean(labelToAdd); + if (!changed) return { labels: nextLabels, changed }; + if (options.dryRun) return { labels: nextLabels, changed }; if (labelToAdd) ensurePrRatingLabel(options.rating.overallTier); for (const label of labelsToRemove) { ghWithRetry(["issue", "edit", String(options.number), "--remove-label", label]); @@ -8003,7 +8007,7 @@ function syncPrRatingLabel(options: { if (labelToAdd) { ghWithRetry(["issue", "edit", String(options.number), "--add-label", labelToAdd]); } - return nextLabels; + return { labels: nextLabels, changed }; } function syncPrStatusLabel(options: { @@ -8011,7 +8015,7 @@ function syncPrStatusLabel(options: { labels: readonly string[]; statusKind: PrStatusLabelKind | null; dryRun: boolean; -}): string[] { +}): { labels: string[]; changed: boolean } { const nextLabels = nextPrStatusLabels(options.labels, options.statusKind); const currentLabelKeys = new Set(options.labels.map((label) => label.toLowerCase())); const nextLabelKeys = new Set(nextLabels.map((label) => label.toLowerCase())); @@ -8021,8 +8025,9 @@ function syncPrStatusLabel(options: { const labelToAdd = nextLabels.find( (label) => PR_STATUS_LABEL_NAMES.has(label) && !currentLabelKeys.has(label.toLowerCase()), ); - if (!labelsToRemove.length && !labelToAdd) return nextLabels; - if (options.dryRun) return nextLabels; + const changed = labelsToRemove.length > 0 || Boolean(labelToAdd); + if (!changed) return { labels: nextLabels, changed }; + if (options.dryRun) return { labels: nextLabels, changed }; if (options.statusKind && labelToAdd) ensurePrStatusLabel(options.statusKind); for (const label of labelsToRemove) { ghWithRetry(["issue", "edit", String(options.number), "--remove-label", label]); @@ -8030,7 +8035,7 @@ function syncPrStatusLabel(options: { if (labelToAdd) { ghWithRetry(["issue", "edit", String(options.number), "--add-label", labelToAdd]); } - return nextLabels; + return { labels: nextLabels, changed }; } function ensureTelegramVisibleProofLabel(): void { @@ -8115,13 +8120,16 @@ function syncRealBehaviorProofSufficientLabel(options: { labels: readonly string[]; proof: Pick; dryRun: boolean; -}): string[] { +}): { labels: string[]; changed: boolean } { const nextLabels = nextRealBehaviorProofSufficientLabels(options.labels, options.proof); const hadLabel = options.labels.includes(PROOF_SUFFICIENT_LABEL); const wantsLabel = nextLabels.includes(PROOF_SUFFICIENT_LABEL); - if (hadLabel === wantsLabel) return nextLabels; - if (options.dryRun) return nextLabels; - if (wantsLabel && !ensureRealBehaviorProofSufficientLabel()) return [...options.labels]; + const changed = hadLabel !== wantsLabel; + if (!changed) return { labels: nextLabels, changed }; + if (options.dryRun) return { labels: nextLabels, changed }; + if (wantsLabel && !ensureRealBehaviorProofSufficientLabel()) { + return { labels: [...options.labels], changed: false }; + } try { ghWithRetry([ "issue", @@ -8137,9 +8145,11 @@ function syncRealBehaviorProofSufficientLabel(options: { error instanceof Error ? error.message : String(error) }`, ); - return wantsLabel ? [...options.labels] : nextLabels; + return wantsLabel + ? { labels: [...options.labels], changed: false } + : { labels: nextLabels, changed }; } - return nextLabels; + return { labels: nextLabels, changed }; } function syncRealBehaviorProofMediaLabels(options: { @@ -8147,7 +8157,7 @@ function syncRealBehaviorProofMediaLabels(options: { labels: readonly string[]; proof: Pick; dryRun: boolean; -}): string[] { +}): { labels: string[]; changed: boolean } { const nextLabels = nextRealBehaviorProofMediaLabels(options.labels, options.proof); const currentLabelKeys = new Set(options.labels.map((label) => label.toLowerCase())); const nextLabelKeys = new Set(nextLabels.map((label) => label.toLowerCase())); @@ -8157,10 +8167,12 @@ function syncRealBehaviorProofMediaLabels(options: { const labelsToRemove = options.labels.filter( (label) => PROOF_MEDIA_LABEL_NAMES.has(label) && !nextLabelKeys.has(label.toLowerCase()), ); - if (!labelsToAdd.length && !labelsToRemove.length) return nextLabels; - if (options.dryRun) return nextLabels; + const changed = labelsToAdd.length > 0 || labelsToRemove.length > 0; + if (!changed) return { labels: nextLabels, changed }; + if (options.dryRun) return { labels: nextLabels, changed }; for (const label of labelsToAdd) { - if (!ensureRealBehaviorProofMediaLabel(label)) return [...options.labels]; + if (!ensureRealBehaviorProofMediaLabel(label)) + return { labels: [...options.labels], changed: false }; ghWithRetry(["issue", "edit", String(options.number), "--add-label", label]); } for (const label of labelsToRemove) { @@ -8175,7 +8187,7 @@ function syncRealBehaviorProofMediaLabels(options: { ); } } - return nextLabels; + return { labels: nextLabels, changed }; } function isAutomationReportAuthor(author: string | undefined): boolean { @@ -9401,6 +9413,9 @@ function renderKeepOpenCommentFromReport( labelTransitionJustificationsMarkdown(labelTransitionJustifications), ); } + if (labelJustifications.length) { + details.push("", "Label justifications:", "", labelJustificationsMarkdown(labelJustifications)); + } if (isPullRequest && reviewFindings.length) { details.push( "", @@ -11111,41 +11126,51 @@ async function applyDecisionsCommand(args: Args): Promise { let currentPrStatusKind: PrStatusLabelKind | null = null; if (state === "open" && item.kind === "pull_request") { const realBehaviorProof = reportRealBehaviorProof(markdown); - item.labels = syncRealBehaviorProofSufficientLabel({ + const proofSufficientSyncResult = syncRealBehaviorProofSufficientLabel({ number, labels: item.labels, proof: realBehaviorProof, dryRun, }); - item.labels = syncRealBehaviorProofMediaLabels({ + item.labels = proofSufficientSyncResult.labels; + clawSweeperLabelsChanged ||= proofSufficientSyncResult.changed; + const proofMediaSyncResult = syncRealBehaviorProofMediaLabels({ number, labels: item.labels, proof: realBehaviorProof, dryRun, }); - item.labels = syncPrRatingLabel({ + item.labels = proofMediaSyncResult.labels; + clawSweeperLabelsChanged ||= proofMediaSyncResult.changed; + const prRatingSyncResult = syncPrRatingLabel({ number, labels: item.labels, rating: reportPrRating(markdown), dryRun, }); + item.labels = prRatingSyncResult.labels; + clawSweeperLabelsChanged ||= prRatingSyncResult.changed; currentPrStatusKind = prStatusLabelKindFromReport( markdown, currentItemContext(), item.labels, ); - item.labels = syncPrStatusLabel({ + const prStatusSyncResult = syncPrStatusLabel({ number, labels: item.labels, statusKind: currentPrStatusKind, dryRun, }); - item.labels = syncTelegramVisibleProofLabel({ + item.labels = prStatusSyncResult.labels; + clawSweeperLabelsChanged ||= prStatusSyncResult.changed; + const telegramVisibleProofSyncResult = syncTelegramVisibleProofLabel({ number, labels: item.labels, proof: reportTelegramVisibleProof(markdown), dryRun, }); + item.labels = telegramVisibleProofSyncResult.labels; + clawSweeperLabelsChanged ||= telegramVisibleProofSyncResult.changed; } markdown = replaceFrontMatterValue(markdown, "labels", JSON.stringify(item.labels)); if ( @@ -11395,6 +11420,7 @@ async function applyDecisionsCommand(args: Args): Promise { needsReviewCommentBodySync, needsReviewCommentHashSync, needsReviewCommentReferenceSync, + forceReviewCommentBodySync: clawSweeperLabelsChanged, }); if (clawSweeperLabelsChanged && !dryRun) { markdown = replaceFrontMatterValue(markdown, "labels_synced_at", new Date().toISOString()); diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index 48e9249d9a..6317aa56ba 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -2776,8 +2776,12 @@ Full review comments: comment, /- add `merge-risk: 🚨 compatibility`: Merging changes the default upgrade behavior for existing configs\./, ); - assert.doesNotMatch(comment, /Label justifications:/); - assert.doesNotMatch(comment, /- `impact:message-loss`:/); + assert.match(comment, /Label justifications:/); + assert.match(comment, /- `P1`: The PR changes an active channel workflow affecting real users\./); + assert.match( + comment, + /- `impact:message-loss`: The diff touches message retry and delivery ordering\./, + ); }); test("public PR review details justify derived rating label changes", () => { @@ -2838,7 +2842,11 @@ Full review comments: comment, /- remove `rating: 🦞 diamond lobster`: Current PR rating is `rating: 🦪 silver shellfish`, so this older rating label is no longer current\./, ); - assert.doesNotMatch(comment, /Label justifications:/); + assert.match(comment, /Label justifications:/); + assert.match( + comment, + /- `rating: 🦪 silver shellfish`: Current PR rating is 🦪 silver shellfish because proof is 🦪 silver shellfish, patch quality is 🦞 diamond lobster, and PR readiness rating was derived from proof quality, review findings, security review, and reviewer confidence\. Replaced prior `rating: 🦞 diamond lobster`\./, + ); }); test("public PR review details justify stale owned label removals", () => { @@ -5466,6 +5474,306 @@ if (args[0] === "api" && /\\/issues\\/74477$/.test(path)) { } }); +test("apply-decisions records PR label sync as ClawSweeper-owned churn", () => { + const root = mkdtempSync(tmpPrefix); + try { + const itemsDir = join(root, "items"); + const closedDir = join(root, "closed"); + const plansDir = join(root, "plans"); + const reportPath = join(root, "apply-report.json"); + const logPath = join(root, "gh.log"); + const itemPath = join(itemsDir, "74478.md"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + writeFileSync( + itemPath, + `${reportFrontMatter({ + repository: "openclaw/clawsweeper", + type: "pull_request", + number: "74478", + title: "Record PR label churn", + url: "https://github.com/openclaw/clawsweeper/pull/74478", + decision: "keep_open", + close_reason: "none", + confidence: "high", + action_taken: "kept_open", + review_status: "complete", + local_checkout_access: "verified", + author: "contributor", + author_association: "CONTRIBUTOR", + labels: JSON.stringify([]), + item_snapshot_hash: "snapshot-a", + item_updated_at: "2026-05-19T20:00:00Z", + pull_head_sha: "abc123def456", + })} + +## Summary + +This PR has complete review metadata and needs only ClawSweeper-owned labels. + +${realBehaviorProofReportSection({ evidenceKind: "screenshot" })} + +${prRatingReportSection({ overallTier: "A" })} + +## Review Findings + +Overall correctness: patch is correct + +Overall confidence: 0.9 + +Full review comments: + +- none +`, + "utf8", + ); + + const ghMock = ` +const { appendFileSync, readFileSync } = require("fs"); +const logPath = ${JSON.stringify(logPath)}; +const rawArgs = process.argv.slice(2); +const args = rawArgs[0] === "--repo" ? rawArgs.slice(2) : rawArgs; +appendFileSync(logPath, JSON.stringify(args) + "\\n"); +const path = args[1] || ""; +if (args[0] === "api" && /\\/issues\\/74478$/.test(path)) { + console.log(JSON.stringify({ + number: 74478, + title: "Record PR label churn", + html_url: "https://github.com/openclaw/clawsweeper/pull/74478", + created_at: "2026-05-19T19:00:00Z", + updated_at: "2026-05-19T20:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "contributor" }, + labels: [], + pull_request: {} + })); +} else if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/74478\\/timeline(?:\\?|$)/.test(args[2] || "")) { + console.log("HTTP/2 200\\n\\n[]"); +} else if (args[0] === "api" && /\\/issues\\/74478\\/timeline(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[]])); +} else if (args[0] === "api" && /\\/pulls\\/74478$/.test(path)) { + console.log(JSON.stringify({ + number: 74478, + html_url: "https://github.com/openclaw/clawsweeper/pull/74478", + state: "open", + changed_files: 1, + commits: 1, + review_comments: 0, + head: { sha: "abc123def456", ref: "branch", repo: { full_name: "fork/clawsweeper" } }, + base: { sha: "base-sha", ref: "main", repo: { full_name: "openclaw/clawsweeper" } }, + user: { login: "contributor" } + })); +} else if (args[0] === "api" && /\\/pulls\\/74478\\/(files|commits|comments)(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[]])); +} else if (args[0] === "api" && /\\/issues\\/74478\\/comments(?:\\?|$)/.test(path)) { + if (args.includes("--method") && args.includes("POST")) { + const input = args[args.indexOf("--input") + 1]; + appendFileSync(logPath, JSON.stringify(["posted-comment-body", JSON.parse(readFileSync(input, "utf8")).body]) + "\\n"); + console.log(JSON.stringify({ + id: 987478, + html_url: "https://github.com/openclaw/clawsweeper/pull/74478#issuecomment-987478" + })); + } else { + console.log(JSON.stringify([[]])); + } +} else if (args[0] === "label" && args[1] === "create") { + console.log(JSON.stringify({ name: args[2] })); +} else if (args[0] === "issue" && args[1] === "edit") { + console.log(""); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`; + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: ["--sync-comments-only", "--item-numbers", "74478"], + }); + }); + + const report = readFileSync(itemPath, "utf8"); + assert.match(report, /^labels_synced_at: /m); + assert.match(report, /proof: sufficient/); + assert.match(report, /proof: 📸 screenshot/); + assert.match(report, /rating: 🦞 diamond lobster/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("apply-decisions refreshes recent PR comments after label sync adds justifications", () => { + const root = mkdtempSync(tmpPrefix); + try { + const itemsDir = join(root, "items"); + const closedDir = join(root, "closed"); + const plansDir = join(root, "plans"); + const reportPath = join(root, "apply-report.json"); + const logPath = join(root, "gh.log"); + const itemPath = join(itemsDir, "74479.md"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + writeFileSync( + itemPath, + `${reportFrontMatter({ + repository: "openclaw/clawsweeper", + type: "pull_request", + number: "74479", + title: "Refresh label explanation", + url: "https://github.com/openclaw/clawsweeper/pull/74479", + decision: "keep_open", + close_reason: "none", + confidence: "high", + action_taken: "kept_open", + review_status: "complete", + local_checkout_access: "verified", + author: "contributor", + author_association: "CONTRIBUTOR", + labels: JSON.stringify([]), + item_snapshot_hash: "snapshot-a", + item_updated_at: "2026-05-19T20:00:00Z", + pull_head_sha: "abc123def456", + review_comment_synced_at: "2026-05-19T23:59:00Z", + })} + +## Summary + +This PR needs labels and the latest comment must explain them. + +${realBehaviorProofReportSection({ evidenceKind: "screenshot" })} + +${prRatingReportSection({ overallTier: "A" })} + +## Review Findings + +Overall correctness: patch is correct + +Overall confidence: 0.9 + +Full review comments: + +- none +`, + "utf8", + ); + + const staleCommentBody = + "Codex review: needs maintainer review before merge.\n\n"; + const ghMock = ` +const { appendFileSync, readFileSync } = require("fs"); +const logPath = ${JSON.stringify(logPath)}; +const staleCommentBody = ${JSON.stringify(staleCommentBody)}; +const rawArgs = process.argv.slice(2); +const args = rawArgs[0] === "--repo" ? rawArgs.slice(2) : rawArgs; +appendFileSync(logPath, JSON.stringify(args) + "\\n"); +const path = args[1] || ""; +if (args[0] === "api" && /\\/issues\\/74479$/.test(path)) { + console.log(JSON.stringify({ + number: 74479, + title: "Refresh label explanation", + html_url: "https://github.com/openclaw/clawsweeper/pull/74479", + created_at: "2026-05-19T19:00:00Z", + updated_at: "2026-05-19T20:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "contributor" }, + labels: [], + pull_request: {} + })); +} else if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/74479\\/timeline(?:\\?|$)/.test(args[2] || "")) { + console.log("HTTP/2 200\\n\\n[]"); +} else if (args[0] === "api" && /\\/issues\\/74479\\/timeline(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[]])); +} else if (args[0] === "api" && /\\/pulls\\/74479$/.test(path)) { + console.log(JSON.stringify({ + number: 74479, + html_url: "https://github.com/openclaw/clawsweeper/pull/74479", + state: "open", + changed_files: 1, + commits: 1, + review_comments: 0, + head: { sha: "abc123def456", ref: "branch", repo: { full_name: "fork/clawsweeper" } }, + base: { sha: "base-sha", ref: "main", repo: { full_name: "openclaw/clawsweeper" } }, + user: { login: "contributor" } + })); +} else if (args[0] === "api" && /\\/pulls\\/74479\\/(files|commits|comments)(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[]])); +} else if (args[0] === "api" && /\\/issues\\/74479\\/comments(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[ + { + id: 987479, + html_url: "https://github.com/openclaw/clawsweeper/pull/74479#issuecomment-987479", + body: staleCommentBody, + user: { login: "clawsweeper[bot]" }, + created_at: "2026-05-19T23:59:00Z", + updated_at: "2026-05-19T23:59:00Z" + } + ]])); +} else if (args[0] === "api" && /\\/issues\\/comments\\/987479$/.test(path)) { + const input = args[args.indexOf("--input") + 1]; + appendFileSync(logPath, JSON.stringify(["patched-review-body", JSON.parse(readFileSync(input, "utf8")).body]) + "\\n"); + console.log(JSON.stringify({ + id: 987479, + html_url: "https://github.com/openclaw/clawsweeper/pull/74479#issuecomment-987479" + })); +} else if (args[0] === "label" && args[1] === "create") { + console.log(JSON.stringify({ name: args[2] })); +} else if (args[0] === "issue" && args[1] === "edit") { + console.log(""); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`; + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: [ + "--sync-comments-only", + "--comment-sync-min-age-days", + "7", + "--item-numbers", + "74479", + ], + }); + }); + + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + const patchedBody = calls.find((args) => args[0] === "patched-review-body")?.[1] ?? ""; + assert.match(patchedBody, /Label justifications:/); + assert.match(patchedBody, /`proof: sufficient`/); + assert.match(patchedBody, /`proof: 📸 screenshot`/); + assert.match(patchedBody, /`rating: 🦞 diamond lobster`/); + assert.match(readFileSync(itemPath, "utf8"), /^labels_synced_at: /m); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 74479, + action: "review_comment_synced", + reason: "updated durable Codex review comment; synced durable PR egg comment", + }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("apply-decisions does not advisory-label close proposals before close gates finish", () => { const root = mkdtempSync(tmpPrefix); try {