From 634b919dc613ef3b1fdb6c3955225e728b537baf Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Tue, 12 May 2026 23:19:43 +0100 Subject: [PATCH] feat: sync advisory issue labels --- CHANGELOG.md | 4 + README.md | 8 + docs/work-lane.md | 33 ++ package.json | 2 +- scripts/check-active-surface.ts | 5 +- src/clawsweeper.ts | 290 +++++++++++- src/command.ts | 23 +- src/commit-sweeper.ts | 2 +- test/clawsweeper.test.ts | 795 +++++++++++++++++++++++++++++++- 9 files changed, 1146 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae1e764e3..c6c9e7defe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ checkpoint, and status-only commits are intentionally omitted. - Added a light privacy reminder and stronger screenshot-or-video nudge to real behavior proof review guidance. - Added agent-led real behavior proof judgement so ClawSweeper can inspect linked screenshots, videos, logs, and terminal output with a read-only GitHub token, explain the proof verdict in the review comment, tell contributors how to trigger a fresh review after adding proof, and sync `proof: sufficient` when the evidence is convincing. - Added a real behavior proof assessment to PR reviews so missing, mock-only, or insufficient contributor proof blocks pass/automerge markers and asks for screenshots, terminal output, redacted logs, recordings, linked artifacts, or copied live output instead. +- Added advisory issue labels for reproduction, linked-PR, work-lane, + missing-info, product-decision, and security-review routing states, projected + from existing review report fields without changing repair, merge, or close + behavior. - Added `config/automation-limits.json` plus docs and a drift check so review, commit-review, repair, and issue-implementation capacity defaults have one checked-in source of truth. diff --git a/README.md b/README.md index a07428597c..d3b94ca466 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,14 @@ hidden verdict/action markers so trusted repair and automerge flows can continue without scraping visible prose. See [`docs/pr-review-comments.md`](docs/pr-review-comments.md). +For open issues with complete, current kept-open reviews, ClawSweeper also +projects selected structured review conclusions into advisory GitHub labels for +maintainer filtering and project views. These labels expose states such as +current-main reproduction, source reproduction, linked open PRs, queueable +fixes, missing info, and product/security review needs. They are advisory only +and do not trigger repair, merge, or close behavior. See +[`docs/work-lane.md`](docs/work-lane.md). + ### Apply and State Apply mode re-fetches live GitHub state, checks labels, maintainer authorship, diff --git a/docs/work-lane.md b/docs/work-lane.md index 553be71011..814f45a2ad 100644 --- a/docs/work-lane.md +++ b/docs/work-lane.md @@ -23,6 +23,39 @@ dashboard links both the source report and the generated coding plan so maintainers can promote from a concise implementation view without editing the durable report. +For open issues with complete, current kept-open reviews, apply/comment-sync +also projects a small owned set of advisory GitHub labels from the same +structured fields. Comments explain the evidence; labels expose routing state +for GitHub issue lists, searches, and project views. These labels do not +dispatch repair, merge, or close work, and they do not replace maintainer-owned +action labels such as `clawsweeper:autofix` or `clawsweeper:automerge`. +Failed or stale reports are skipped so outdated review conclusions do not mutate +live issue labels. +Close proposals are not label-mutated during apply, so advisory label writes do +not advance an issue's `updated_at` before close eligibility gates have finished. + +| Label | Source condition | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `clawsweeper:current-main-repro` | `type: issue`, `reproduction_status: reproduced`, and `reproduction_confidence: high` | +| `clawsweeper:source-repro` | `type: issue`, `reproduction_status: source_reproducible`, and `reproduction_confidence: high` | +| `clawsweeper:not-repro-on-main` | `type: issue`, `reproduction_status: not_reproduced`, and `reproduction_confidence: high` | +| `clawsweeper:needs-live-repro` | `type: issue`, `reproduction_status: source_reproducible`, and reproduction confidence below high | +| `clawsweeper:needs-info` | `type: issue`, `reproduction_status: unclear`, and reproduction confidence below high | +| `clawsweeper:linked-pr-open` | the live issue has an open GitHub closing-PR reference | +| `clawsweeper:no-new-fix-pr` | an open linked PR, manual-review lane, product decision, or security review means a new automated fix PR should not be queued | +| `clawsweeper:queueable-fix` | `work_candidate: queue_fix_pr`, `work_status: candidate`, and `work_confidence: high` | +| `clawsweeper:fix-shape-clear` | high-confidence `queue_fix_pr` or `manual_review` work includes a repair prompt, likely files, or validation | +| `clawsweeper:needs-maintainer-review` | `work_candidate: manual_review` or `work_status: manual_review` | +| `clawsweeper:needs-product-decision` | `requires_product_decision: true` | +| `clawsweeper:needs-security-review` | `item_category: security` or a `securityReview` status of `needs_attention` | + +The advisory-label sync owns only this label group. Reruns add labels that match +the latest report, remove stale labels from this group, and preserve unrelated +labels plus action/proof labels such as `clawsweeper:autofix`, +`clawsweeper:automerge`, `clawsweeper:human-review`, +`clawsweeper:merge-ready`, `proof: sufficient`, and +`mantis: telegram-visible-proof`. + Plan artifacts are generated state. They are removed when the item closes, archives, becomes stale, or is reclassified away from `queue_fix_pr`; regenerate them from the source report instead of editing them by hand. diff --git a/package.json b/package.json index 6bc9f9141f..7ceb8fc91b 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "check:active-surface": "node scripts/check-active-surface.ts", "check:limits": "node scripts/check-limits.ts", "lint": "pnpm run lint:src && pnpm run lint:repair && pnpm run lint:scripts", - "lint:src": "oxlint src/*.ts --tsconfig tsconfig.json --type-aware --deny-warnings --report-unused-disable-directives -D correctness", + "lint:src": "oxlint src --ignore-pattern \"src/repair/**\" --tsconfig tsconfig.json --type-aware --deny-warnings --report-unused-disable-directives -D correctness", "lint:repair": "oxlint src/repair --tsconfig tsconfig.repair.json --deny-warnings --report-unused-disable-directives -D correctness", "lint:scripts": "oxlint scripts test --deny-warnings --report-unused-disable-directives -D correctness", "format": "oxfmt --write src scripts test package.json tsconfig.json tsconfig.repair.json .oxfmtrc.json config schema .github/actions .github/workflows", diff --git a/scripts/check-active-surface.ts b/scripts/check-active-surface.ts index 57817dace3..0a7b60a9ad 100644 --- a/scripts/check-active-surface.ts +++ b/scripts/check-active-surface.ts @@ -90,7 +90,8 @@ function scan(absolute: string): void { } if (!stat.isFile() || !isTextFile(absolute)) return; const relative = path.relative(root, absolute); - if (relative === "scripts/check-active-surface.ts") return; + const canonicalRelative = relative.split(path.sep).join("/"); + if (canonicalRelative === "scripts/check-active-surface.ts") return; const text = fs.readFileSync(absolute, "utf8"); const lines = text.split(/\r?\n/); lines.forEach((line, index) => { @@ -98,7 +99,7 @@ function scan(absolute: string): void { const match = retired.pattern.exec(line); if (!match) continue; findings.push({ - file: relative, + file: canonicalRelative, line: index + 1, column: match.index + 1, label: retired.label, diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 1b5c812500..d9a3d1c48d 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -673,6 +673,73 @@ const PROOF_SUFFICIENT_LABEL = "proof: sufficient"; const TELEGRAM_VISIBLE_PROOF_LABEL = "mantis: telegram-visible-proof"; const TELEGRAM_VISIBLE_PROOF_LABEL_COLOR = "5319e7"; const TELEGRAM_VISIBLE_PROOF_LABEL_DESCRIPTION = "Mantis should capture Telegram visible proof."; +const ISSUE_ADVISORY_LABELS = [ + { + name: "clawsweeper:current-main-repro", + color: "1D76DB", + description: "ClawSweeper found a high-confidence current-main issue reproduction.", + }, + { + name: "clawsweeper:source-repro", + color: "1D76DB", + description: "ClawSweeper found a high-confidence source-level issue reproduction.", + }, + { + name: "clawsweeper:not-repro-on-main", + color: "C2E0C6", + description: + "ClawSweeper found high-confidence evidence that this issue no longer reproduces on main.", + }, + { + name: "clawsweeper:needs-live-repro", + color: "FBCA04", + description: + "ClawSweeper needs live local, crabbox, or manual validation to confirm this issue.", + }, + { + name: "clawsweeper:needs-info", + color: "D876E3", + description: "ClawSweeper needs more reporter information before it can verify this issue.", + }, + { + name: "clawsweeper:linked-pr-open", + color: "5319E7", + description: "ClawSweeper found an open linked pull request for this issue.", + }, + { + name: "clawsweeper:no-new-fix-pr", + color: "BFDADC", + description: "ClawSweeper does not recommend queueing a new automated fix PR for this issue.", + }, + { + name: "clawsweeper:queueable-fix", + color: "0E8A16", + description: "ClawSweeper marked this issue as an existing queue_fix_pr work candidate.", + }, + { + name: "clawsweeper:fix-shape-clear", + color: "0E8A16", + description: "ClawSweeper found a clear likely implementation shape for this issue.", + }, + { + name: "clawsweeper:needs-maintainer-review", + color: "FBCA04", + description: "ClawSweeper marked this issue as needing maintainer review before automation.", + }, + { + name: "clawsweeper:needs-product-decision", + color: "FBCA04", + description: "ClawSweeper marked this issue as needing a product or behavior decision.", + }, + { + name: "clawsweeper:needs-security-review", + color: "B60205", + description: "ClawSweeper marked this issue as needing security-sensitive review.", + }, +] as const; +const ISSUE_ADVISORY_LABEL_NAMES = new Set( + ISSUE_ADVISORY_LABELS.map((label) => label.name.toLowerCase()), +); const PROTECTED_LABELS = new Set(["security", "beta-blocker", "release-blocker", "maintainer"]); const ALLOWED_REASONS = new Set([ "implemented_on_main", @@ -4901,6 +4968,183 @@ export function telegramVisibleProofLabelsForTest( return nextTelegramVisibleProofLabels(labels, { status: proofStatus }); } +interface IssueAdvisoryLabelState { + type: string | undefined; + itemCategory: string | undefined; + reproductionStatus: string | undefined; + reproductionConfidence: string | undefined; + requiresProductDecision: boolean; + securityReviewStatus: string | undefined; + workCandidate: string | undefined; + workStatus: string | undefined; + workConfidence: string | undefined; + hasWorkShape: boolean; + hasOpenLinkedPullRequest: boolean; +} + +function isIssueAdvisoryLabel(label: string): boolean { + return ISSUE_ADVISORY_LABEL_NAMES.has(label.toLowerCase()); +} + +function wantedIssueAdvisoryLabels(state: IssueAdvisoryLabelState): Set { + const labels = new Set(); + if (state.type !== "issue") return labels; + if (state.reproductionConfidence === "high") { + if (state.reproductionStatus === "reproduced") labels.add("clawsweeper:current-main-repro"); + if (state.reproductionStatus === "source_reproducible") labels.add("clawsweeper:source-repro"); + if (state.reproductionStatus === "not_reproduced") labels.add("clawsweeper:not-repro-on-main"); + } + if ( + state.reproductionStatus === "source_reproducible" && + state.reproductionConfidence !== "high" + ) { + labels.add("clawsweeper:needs-live-repro"); + } + if (state.reproductionStatus === "unclear" && state.reproductionConfidence !== "high") { + labels.add("clawsweeper:needs-info"); + } + if (state.hasOpenLinkedPullRequest) { + labels.add("clawsweeper:linked-pr-open"); + } + if ( + state.workCandidate === "queue_fix_pr" && + state.workStatus === "candidate" && + state.workConfidence === "high" + ) { + labels.add("clawsweeper:queueable-fix"); + } + if ( + state.workConfidence === "high" && + state.hasWorkShape && + (state.workCandidate === "queue_fix_pr" || state.workCandidate === "manual_review") + ) { + labels.add("clawsweeper:fix-shape-clear"); + } + if (state.workCandidate === "manual_review" || state.workStatus === "manual_review") { + labels.add("clawsweeper:needs-maintainer-review"); + } + if (state.requiresProductDecision) { + labels.add("clawsweeper:needs-product-decision"); + } + if (state.itemCategory === "security" || state.securityReviewStatus === "needs_attention") { + labels.add("clawsweeper:needs-security-review"); + } + if ( + state.hasOpenLinkedPullRequest || + state.workCandidate === "manual_review" || + state.workStatus === "manual_review" || + state.requiresProductDecision || + state.itemCategory === "security" || + state.securityReviewStatus === "needs_attention" + ) { + labels.add("clawsweeper:no-new-fix-pr"); + } + return labels; +} + +function nextIssueAdvisoryLabels( + labels: readonly string[], + state: IssueAdvisoryLabelState, +): string[] { + const wantedLabels = wantedIssueAdvisoryLabels(state); + const nextLabels = labels.filter((label) => !isIssueAdvisoryLabel(label)); + for (const label of ISSUE_ADVISORY_LABELS) { + if (wantedLabels.has(label.name)) nextLabels.push(label.name); + } + return nextLabels; +} + +export function issueAdvisoryLabelsForTest( + labels: readonly string[], + state: Partial, +): string[] { + return nextIssueAdvisoryLabels(labels, { + type: state.type, + itemCategory: state.itemCategory, + reproductionStatus: state.reproductionStatus, + reproductionConfidence: state.reproductionConfidence, + requiresProductDecision: state.requiresProductDecision ?? false, + securityReviewStatus: state.securityReviewStatus, + workCandidate: state.workCandidate, + workStatus: state.workStatus, + workConfidence: state.workConfidence, + hasWorkShape: state.hasWorkShape ?? false, + hasOpenLinkedPullRequest: state.hasOpenLinkedPullRequest ?? false, + }); +} + +function issueAdvisoryLabelStateFromReport( + markdown: string, + options: { hasOpenLinkedPullRequest?: boolean } = {}, +): IssueAdvisoryLabelState { + const workLikelyFiles = frontMatterStringArray(markdown, "work_likely_files"); + const workValidation = frontMatterStringArray(markdown, "work_validation"); + const workPrompt = reviewSectionValue(markdown, "repairWorkPrompt").trim(); + return { + type: frontMatterValue(markdown, "type"), + itemCategory: frontMatterValue(markdown, "item_category"), + reproductionStatus: frontMatterValue(markdown, "reproduction_status"), + reproductionConfidence: frontMatterValue(markdown, "reproduction_confidence"), + requiresProductDecision: frontMatterValue(markdown, "requires_product_decision") === "true", + securityReviewStatus: reportSecurityReview(markdown).status, + workCandidate: frontMatterValue(markdown, "work_candidate"), + workStatus: frontMatterValue(markdown, "work_status"), + workConfidence: frontMatterValue(markdown, "work_confidence"), + hasWorkShape: Boolean(workPrompt || workLikelyFiles.length || workValidation.length), + hasOpenLinkedPullRequest: options.hasOpenLinkedPullRequest === true, + }; +} + +function ensureIssueAdvisoryLabel(name: string): void { + const definition = ISSUE_ADVISORY_LABELS.find((label) => label.name === name); + if (!definition) return; + try { + ghWithRetry( + [ + "label", + "create", + definition.name, + "--color", + definition.color, + "--description", + definition.description, + ], + 2, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/already exists/i.test(message)) throw error; + } +} + +function syncIssueAdvisoryLabels(options: { + number: number; + labels: readonly string[]; + state: IssueAdvisoryLabelState; + dryRun: boolean; +}): { labels: string[]; changed: boolean } { + const nextLabels = nextIssueAdvisoryLabels(options.labels, options.state); + const currentLabelKeys = new Set(options.labels.map((label) => label.toLowerCase())); + const nextLabelKeys = new Set(nextLabels.map((label) => label.toLowerCase())); + const labelsToAdd = nextLabels.filter( + (label) => isIssueAdvisoryLabel(label) && !currentLabelKeys.has(label.toLowerCase()), + ); + const labelsToRemove = options.labels.filter( + (label) => isIssueAdvisoryLabel(label) && !nextLabelKeys.has(label.toLowerCase()), + ); + 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) { + ensureIssueAdvisoryLabel(label); + ghWithRetry(["issue", "edit", String(options.number), "--add-label", label]); + } + for (const label of labelsToRemove) { + ghWithRetry(["issue", "edit", String(options.number), "--remove-label", label]); + } + return { labels: nextLabels, changed }; +} + function syncTelegramVisibleProofLabel(options: { number: number; labels: readonly string[]; @@ -6886,6 +7130,8 @@ function applyDecisionsCommand(args: Args): void { } const { item, state } = fetchItem(number); let currentContext: ItemContext | undefined; + let currentClosingPullRequests: unknown[] | undefined; + let issueAdvisoryLabelsChanged = false; const currentItemContext = (): ItemContext => { currentContext ??= collectItemContext(item); return currentContext; @@ -6953,6 +7199,9 @@ function applyDecisionsCommand(args: Args): void { } const updatedSinceReview = Boolean(storedUpdatedAt && item.updatedAt !== storedUpdatedAt); const reviewCommentOnlyUpdate = item.updatedAt === commentUpdatedAt(existingReviewComment); + const unchangedSinceReview = storedUpdatedAt + ? !updatedSinceReview || reviewCommentOnlyUpdate + : false; if (state !== "open") { if (item.closedAt) { markdown = replaceFrontMatterValue(markdown, "current_item_closed_at", item.closedAt); @@ -7027,9 +7276,31 @@ function applyDecisionsCommand(args: Args): void { continue; } } + if ( + state === "open" && + item.kind === "issue" && + !isCloseProposal && + frontMatterValue(markdown, "review_status") === "complete" && + unchangedSinceReview + ) { + currentClosingPullRequests = closingPullRequestsForIssue(number); + const syncResult = syncIssueAdvisoryLabels({ + number, + labels: item.labels, + state: issueAdvisoryLabelStateFromReport(markdown, { + hasOpenLinkedPullRequest: + openClosingPullRequestApplyReason(currentClosingPullRequests) !== null, + }), + dryRun, + }); + item.labels = syncResult.labels; + issueAdvisoryLabelsChanged = syncResult.changed; + markdown = replaceFrontMatterValue(markdown, "labels", JSON.stringify(item.labels)); + } if (isCloseProposal && item.kind === "issue") { + currentClosingPullRequests ??= closingPullRequestsForIssue(number); const openClosingPullRequestReason = openClosingPullRequestApplyReason( - closingPullRequestsForIssue(number), + currentClosingPullRequests, ); if (openClosingPullRequestReason) { if (markApplySkipped("skipped_open_closing_pr", openClosingPullRequestReason)) break; @@ -7109,6 +7380,23 @@ function applyDecisionsCommand(args: Args): void { maybeLogProgress(`synced review comment #${number}`); if (processedCount >= processedLimit) break; } + if ( + issueAdvisoryLabelsChanged && + !needsReviewCommentSync && + (!isCloseProposal || syncCommentsOnly) + ) { + if (!dryRun) writeFileSync(path, markdown, "utf8"); + results.push({ + number, + action: "kept_open", + reason: dryRun + ? "dry-run: would sync advisory issue labels" + : "synced advisory issue labels", + }); + processedCount += 1; + maybeLogProgress(`synced advisory issue labels #${number}`); + if (processedCount >= processedLimit) break; + } if (syncCommentsOnly) continue; if (!isCloseProposal || !closeReason) { continue; diff --git a/src/command.ts b/src/command.ts index 0c5ceff80f..d8f807a383 100644 --- a/src/command.ts +++ b/src/command.ts @@ -19,7 +19,8 @@ export function runText( trim = "end", }: RunTextOptions = {}, ): string { - const text = execFileSync(resolveExecutable(command), args, { + const resolved = resolveCommand(command, args); + const text = execFileSync(resolved.command, resolved.args, { cwd, encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...env }, @@ -31,6 +32,26 @@ export function runText( return text; } +function resolveCommand(command: string, args: string[]): { command: string; args: string[] } { + if (command === "gh" && process.env.GH_BIN) { + return { + command: process.env.GH_BIN, + args: [...envArgs("GH_BIN_ARGS"), ...args], + }; + } + return { command: resolveExecutable(command), args }; +} + function resolveExecutable(command: string): string { return command === "git" ? (process.env.GIT_BIN ?? "/usr/bin/git") : command; } + +function envArgs(name: string): string[] { + const value = process.env[name]; + if (!value) return []; + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) { + throw new Error(`${name} must be a JSON string array`); + } + return parsed; +} diff --git a/src/commit-sweeper.ts b/src/commit-sweeper.ts index 03396bb308..28a91f29be 100644 --- a/src/commit-sweeper.ts +++ b/src/commit-sweeper.ts @@ -58,7 +58,7 @@ function repoSlug(targetRepo: string): string { } export function commitReportRelativePath(targetRepo: string, sha: string): string { - return join("records", repoSlug(targetRepo), "commits", `${assertSha(sha)}.md`); + return `records/${repoSlug(targetRepo)}/commits/${assertSha(sha)}.md`; } function artifactReportRelativePath(targetRepo: string, sha: string): string { diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index 0d665804bc..b82922f197 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -31,6 +32,7 @@ import { isGitHubNotFoundError, isGitHubRequiresAuthenticationError, isLockedConversationCommentError, + issueAdvisoryLabelsForTest, isProtectedItem, itemNumbersArg, lockedConversationApplyReason, @@ -2580,6 +2582,88 @@ Render generated plan markdown from existing report fields. `; } +function markedReviewCommentForTest(number: number, body: string): string { + return `${body.trimEnd()}\n\n`; +} + +function sha256ForTest(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function reportWithSyncedReviewComment( + report: string, + number: number, + reason = "none", +): { + report: string; + comment: string; +} { + const comment = markedReviewCommentForTest(number, renderReviewCommentFromReport(report, reason)); + return { + report: report.replace( + /^---\n/, + [ + "---", + `review_comment_sha256: ${sha256ForTest(comment)}`, + `review_comment_id: ${9000 + number}`, + `review_comment_url: https://github.com/openclaw/clawsweeper/issues/${number}#issuecomment-${9000 + number}`, + "review_comment_synced_at: 2026-05-01T01:00:00Z", + "", + ].join("\n"), + ), + comment, + }; +} + +function withMockGh(root: string, script: string, run: () => void): void { + const originalGhBin = process.env.GH_BIN; + const originalGhBinArgs = process.env.GH_BIN_ARGS; + const binDir = join(root, "bin"); + mkdirSync(binDir, { recursive: true }); + const ghPath = join(binDir, "gh.js"); + writeFileSync(ghPath, script, { mode: 0o755 }); + try { + process.env.GH_BIN = process.execPath; + process.env.GH_BIN_ARGS = JSON.stringify([ghPath]); + run(); + } finally { + if (originalGhBin === undefined) delete process.env.GH_BIN; + else process.env.GH_BIN = originalGhBin; + if (originalGhBinArgs === undefined) delete process.env.GH_BIN_ARGS; + else process.env.GH_BIN_ARGS = originalGhBinArgs; + } +} + +function runApplyDecisionsForTest(options: { + itemsDir: string; + closedDir: string; + plansDir: string; + reportPath: string; + extraArgs?: string[]; +}): void { + execFileSync(process.execPath, [ + "dist/clawsweeper.js", + "apply-decisions", + "--target-repo", + "openclaw/clawsweeper", + "--items-dir", + options.itemsDir, + "--closed-dir", + options.closedDir, + "--plans-dir", + options.plansDir, + "--report-path", + options.reportPath, + "--limit", + "10", + "--processed-limit", + "1", + "--close-delay-ms", + "0", + ...(options.extraArgs ?? []), + ]); +} + test("renderWorkPlanFromReport renders dashboard plan artifacts for fresh queue_fix_pr candidates", () => { const plan = renderWorkPlanFromReport(workPlanCandidateReport(), { reportPath: "records/openclaw-clawsweeper/items/321.md", @@ -2663,7 +2747,8 @@ test("apply-artifacts writes and removes generated work plans", () => { test("apply-decisions removes archived work plans from the scoped plans directory", () => { const root = mkdtempSync(tmpPrefix); - const originalPath = process.env.PATH; + const originalGhBin = process.env.GH_BIN; + const originalGhBinArgs = process.env.GH_BIN_ARGS; const defaultPlanDir = join(process.cwd(), "records", "openclaw-clawsweeper", "plans"); const defaultPlanPath = join(defaultPlanDir, "321.md"); try { @@ -2675,9 +2760,7 @@ test("apply-decisions removes archived work plans from the scoped plans director mkdirSync(itemsDir, { recursive: true }); mkdirSync(plansDir, { recursive: true }); mkdirSync(defaultPlanDir, { recursive: true }); - writeFileSync( - join(binDir, "gh"), - `#!/usr/bin/env node + const ghMock = `#!/usr/bin/env node const args = process.argv.slice(2).join(" "); if (args.includes("/comments")) { console.log(JSON.stringify([[]])); @@ -2698,9 +2781,8 @@ if (args.includes("/comments")) { pull_request: null })); } -`, - { mode: 0o755 }, - ); +`; + writeFileSync(join(binDir, "gh.js"), ghMock, { mode: 0o755 }); writeFileSync( join(itemsDir, "321.md"), workPlanCandidateReport({ @@ -2712,7 +2794,8 @@ if (args.includes("/comments")) { writeFileSync(join(plansDir, "321.md"), "scoped generated plan\n", "utf8"); writeFileSync(defaultPlanPath, "default generated plan\n", "utf8"); - process.env.PATH = `${binDir}:${originalPath ?? ""}`; + process.env.GH_BIN = process.execPath; + process.env.GH_BIN_ARGS = JSON.stringify([join(binDir, "gh.js")]); execFileSync(process.execPath, [ "dist/clawsweeper.js", "apply-decisions", @@ -2736,12 +2819,524 @@ if (args.includes("/comments")) { assert.ok(existsSync(defaultPlanPath)); assert.ok(existsSync(join(closedDir, "321.md"))); } finally { - process.env.PATH = originalPath; + if (originalGhBin === undefined) delete process.env.GH_BIN; + else process.env.GH_BIN = originalGhBin; + if (originalGhBinArgs === undefined) delete process.env.GH_BIN_ARGS; + else process.env.GH_BIN_ARGS = originalGhBinArgs; rmSync(root, { recursive: true, force: true }); rmSync(defaultPlanPath, { force: true }); } }); +test("apply-decisions skips advisory label sync when a close report changed since review", () => { + 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"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + writeFileSync( + join(itemsDir, "321.md"), + workPlanCandidateReport({ + decision: "close", + action_taken: "proposed_close", + close_reason: "implemented_on_main", + confidence: "high", + item_snapshot_hash: "reviewed-snapshot", + item_updated_at: "2026-05-01T00:00:00Z", + reproduction_status: "reproduced", + reproduction_confidence: "high", + }), + "utf8", + ); + + const ghMock = ` +const { appendFileSync } = 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\\/321\\/comments(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[]])); +} else if (args[0] === "api" && /\\/issues\\/321$/.test(path)) { + console.log(JSON.stringify({ + number: 321, + title: "Render work plans", + html_url: "https://github.com/openclaw/clawsweeper/issues/321", + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-03T00:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "reporter" }, + labels: [], + pull_request: null + })); +} else if (args[0] === "issue" && args[1] === "view") { + console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); +} else if (args[0] === "label" || args[0] === "issue") { + console.log(""); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`; + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ itemsDir, closedDir, plansDir, reportPath }); + }); + + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + assert.equal( + calls.some((args) => args[0] === "issue" && args[1] === "edit"), + false, + ); + assert.equal( + calls.some((args) => args[0] === "label" && args[1] === "create"), + false, + ); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 321, + action: "skipped_changed_since_review", + reason: "updated_at changed", + }, + ]); + } 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 { + 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"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + const closeReport = workPlanCandidateReport({ + decision: "close", + action_taken: "proposed_close", + close_reason: "implemented_on_main", + confidence: "high", + item_snapshot_hash: "reviewed-snapshot", + item_updated_at: "2026-05-01T00:00:00Z", + reproduction_status: "reproduced", + reproduction_confidence: "high", + }); + const synced = reportWithSyncedReviewComment(closeReport, 321, "implemented_on_main"); + writeFileSync(join(itemsDir, "321.md"), synced.report, "utf8"); + + const ghMock = ` +const { appendFileSync } = require("fs"); +const logPath = ${JSON.stringify(logPath)}; +const comment = ${JSON.stringify(synced.comment)}; +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\\/321\\/comments(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[{ + id: 9321, + html_url: "https://github.com/openclaw/clawsweeper/issues/321#issuecomment-9321", + created_at: "2026-05-01T01:00:00Z", + updated_at: "2026-05-01T01:00:00Z", + user: { login: "clawsweeper[bot]" }, + body: comment + }]])); +} else if (args[0] === "api" && /\\/issues\\/321\\/timeline(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[]])); +} else if (args[0] === "api" && /\\/issues\\/321$/.test(path)) { + console.log(JSON.stringify({ + number: 321, + title: "Render work plans", + html_url: "https://github.com/openclaw/clawsweeper/issues/321", + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "reporter" }, + labels: [], + comments: 0, + pull_request: null + })); +} else if (args[0] === "issue" && args[1] === "view") { + console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); +} else if (args[0] === "label" || args[0] === "issue") { + console.log(""); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`; + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: ["--apply-close-reasons", "stale_insufficient_info"], + }); + }); + + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + assert.equal( + calls.some((args) => args[0] === "issue" && args[1] === "edit"), + false, + ); + assert.equal( + calls.some((args) => args[0] === "label" && args[1] === "create"), + false, + ); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 321, + action: "kept_open", + reason: "close reason implemented_on_main is not enabled for this apply run", + }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("apply-decisions skips advisory labels for failed or stale kept-open reports", () => { + 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"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + + const failed = reportWithSyncedReviewComment( + workPlanCandidateReport({ + number: 321, + review_status: "failed", + item_snapshot_hash: "reviewed-snapshot-321", + item_updated_at: "2026-05-01T00:00:00Z", + reproduction_status: "unclear", + reproduction_confidence: "low", + work_candidate: "none", + work_status: "none", + work_confidence: "low", + }), + 321, + ); + const stale = reportWithSyncedReviewComment( + workPlanCandidateReport({ + number: 322, + item_snapshot_hash: "reviewed-snapshot-322", + item_updated_at: "2026-05-01T00:00:00Z", + reproduction_status: "reproduced", + reproduction_confidence: "high", + }), + 322, + ); + writeFileSync(join(itemsDir, "321.md"), failed.report, "utf8"); + writeFileSync(join(itemsDir, "322.md"), stale.report, "utf8"); + + const ghMock = ` +const { appendFileSync } = require("fs"); +const logPath = ${JSON.stringify(logPath)}; +const comments = ${JSON.stringify({ 321: failed.comment, 322: stale.comment })}; +const updatedAt = { 321: "2026-05-01T00:00:00Z", 322: "2026-05-02T00:00:00Z" }; +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] || ""; +const commentMatch = path.match(/\\/issues\\/(\\d+)\\/comments(?:\\?|$)/); +const issueMatch = path.match(/\\/issues\\/(\\d+)$/); +if (args[0] === "api" && commentMatch) { + const number = Number(commentMatch[1]); + console.log(JSON.stringify([[{ + id: 9000 + number, + html_url: "https://github.com/openclaw/clawsweeper/issues/" + number + "#issuecomment-" + (9000 + number), + created_at: "2026-05-01T01:00:00Z", + updated_at: "2026-05-01T01:00:00Z", + user: { login: "clawsweeper[bot]" }, + body: comments[number] + }]])); +} else if (args[0] === "api" && issueMatch) { + const number = Number(issueMatch[1]); + console.log(JSON.stringify({ + number, + title: "Render work plans", + html_url: "https://github.com/openclaw/clawsweeper/issues/" + number, + created_at: "2026-05-01T00:00:00Z", + updated_at: updatedAt[number], + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "reporter" }, + labels: [], + pull_request: null + })); +} else if (args[0] === "issue" && args[1] === "view") { + console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); +} else if (args[0] === "label" || args[0] === "issue") { + console.log(""); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`; + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ itemsDir, closedDir, plansDir, reportPath }); + }); + + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + assert.equal( + calls.some((args) => args[0] === "issue" && args[1] === "edit"), + false, + ); + assert.equal( + calls.some((args) => args[0] === "label" && args[1] === "create"), + false, + ); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("apply-decisions counts advisory label-only syncs against the processed limit", () => { + 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"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + + const first = reportWithSyncedReviewComment( + workPlanCandidateReport({ + number: 321, + reviewed_at: "2026-05-01T00:00:00Z", + item_snapshot_hash: "reviewed-snapshot-321", + item_updated_at: "2026-05-01T00:00:00Z", + }), + 321, + ); + const second = reportWithSyncedReviewComment( + workPlanCandidateReport({ + number: 322, + reviewed_at: "2026-05-01T00:00:00Z", + item_snapshot_hash: "reviewed-snapshot-322", + item_updated_at: "2026-05-01T00:00:00Z", + }), + 322, + ); + writeFileSync(join(itemsDir, "321.md"), first.report, "utf8"); + writeFileSync(join(itemsDir, "322.md"), second.report, "utf8"); + + const ghMock = ` +const { appendFileSync } = require("fs"); +const logPath = ${JSON.stringify(logPath)}; +const comments = ${JSON.stringify({ 321: first.comment, 322: second.comment })}; +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] || ""; +const commentMatch = path.match(/\\/issues\\/(\\d+)\\/comments(?:\\?|$)/); +const issueMatch = path.match(/\\/issues\\/(\\d+)$/); +if (args[0] === "api" && commentMatch) { + const number = Number(commentMatch[1]); + const body = comments[number]; + console.log(JSON.stringify([[{ + id: 9000 + number, + html_url: "https://github.com/openclaw/clawsweeper/issues/" + number + "#issuecomment-" + (9000 + number), + created_at: "2026-05-01T01:00:00Z", + updated_at: "2026-05-01T01:00:00Z", + user: { login: "clawsweeper[bot]" }, + body + }]])); +} else if (args[0] === "api" && issueMatch) { + const number = Number(issueMatch[1]); + console.log(JSON.stringify({ + number, + title: "Render work plans", + html_url: "https://github.com/openclaw/clawsweeper/issues/" + number, + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "reporter" }, + labels: [], + pull_request: null + })); +} else if (args[0] === "issue" && args[1] === "view") { + console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); +} else if (args[0] === "label" && args[1] === "create") { + console.log(""); +} 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 }); + }); + + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + const editCalls = calls.filter((args) => args[0] === "issue" && args[1] === "edit"); + assert.ok(editCalls.length > 0); + assert.deepEqual([...new Set(editCalls.map((args) => args[2]))], ["321"]); + assert.equal( + calls.some((args) => args.some((arg) => arg.includes("/issues/322"))), + false, + ); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 321, + action: "kept_open", + reason: "synced advisory issue labels", + }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("apply-decisions dry-run computes advisory labels without mutating GitHub labels", () => { + 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"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + + const synced = reportWithSyncedReviewComment( + workPlanCandidateReport({ + number: 321, + reviewed_at: "2026-05-01T00:00:00Z", + item_snapshot_hash: "reviewed-snapshot-321", + item_updated_at: "2026-05-01T00:00:00Z", + }), + 321, + ); + const itemPath = join(itemsDir, "321.md"); + writeFileSync(itemPath, synced.report, "utf8"); + + const ghMock = ` +const { appendFileSync } = require("fs"); +const logPath = ${JSON.stringify(logPath)}; +const comment = ${JSON.stringify(synced.comment)}; +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\\/321\\/comments(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[{ + id: 9321, + html_url: "https://github.com/openclaw/clawsweeper/issues/321#issuecomment-9321", + created_at: "2026-05-01T01:00:00Z", + updated_at: "2026-05-01T01:00:00Z", + user: { login: "clawsweeper[bot]" }, + body: comment + }]])); +} else if (args[0] === "api" && /\\/issues\\/321$/.test(path)) { + console.log(JSON.stringify({ + number: 321, + title: "Render work plans", + html_url: "https://github.com/openclaw/clawsweeper/issues/321", + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "reporter" }, + labels: [], + pull_request: null + })); +} else if (args[0] === "issue" && args[1] === "view") { + console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); +} else if (args[0] === "label" || args[0] === "issue") { + console.log(""); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`; + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: ["--dry-run"], + }); + }); + + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + assert.equal( + calls.some((args) => args[0] === "issue" && args[1] === "edit"), + false, + ); + assert.equal( + calls.some((args) => args[0] === "label" && args[1] === "create"), + false, + ); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 321, + action: "kept_open", + reason: "dry-run: would sync advisory issue labels", + }, + ]); + assert.doesNotMatch(readFileSync(itemPath, "utf8"), /clawsweeper:queueable-fix/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("security-needs-attention reports block unopted repair and automerge pass markers", () => { const securitySection = ` ## Security Review @@ -3204,6 +3799,183 @@ test("ClawSweeper Telegram proof judgement controls the Mantis proof label", () ); }); +test("ClawSweeper issue advisory labels expose high-confidence reproduction state", () => { + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + reproductionStatus: "reproduced", + reproductionConfidence: "high", + }), + ["bug", "clawsweeper:current-main-repro"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + reproductionStatus: "source_reproducible", + reproductionConfidence: "high", + }), + ["bug", "clawsweeper:source-repro"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + reproductionStatus: "reproduced", + reproductionConfidence: "medium", + }), + ["bug"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + reproductionStatus: "not_reproduced", + reproductionConfidence: "high", + }), + ["bug", "clawsweeper:not-repro-on-main"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + reproductionStatus: "source_reproducible", + reproductionConfidence: "medium", + }), + ["bug", "clawsweeper:needs-live-repro"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + reproductionStatus: "unclear", + reproductionConfidence: "low", + }), + ["bug", "clawsweeper:needs-info"], + ); +}); + +test("ClawSweeper issue advisory labels expose work-lane routing state", () => { + assert.deepEqual( + issueAdvisoryLabelsForTest(["clawsweeper"], { + type: "issue", + workCandidate: "queue_fix_pr", + workStatus: "candidate", + workConfidence: "high", + hasWorkShape: true, + }), + ["clawsweeper", "clawsweeper:queueable-fix", "clawsweeper:fix-shape-clear"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["clawsweeper"], { + type: "issue", + workCandidate: "queue_fix_pr", + workStatus: "candidate", + workConfidence: "medium", + }), + ["clawsweeper"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["clawsweeper"], { + type: "issue", + workCandidate: "manual_review", + }), + ["clawsweeper", "clawsweeper:no-new-fix-pr", "clawsweeper:needs-maintainer-review"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["clawsweeper"], { + type: "issue", + workStatus: "manual_review", + }), + ["clawsweeper", "clawsweeper:no-new-fix-pr", "clawsweeper:needs-maintainer-review"], + ); +}); + +test("ClawSweeper issue advisory labels expose linked PR and human decision blockers", () => { + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + hasOpenLinkedPullRequest: true, + }), + ["bug", "clawsweeper:linked-pr-open", "clawsweeper:no-new-fix-pr"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + requiresProductDecision: true, + }), + ["bug", "clawsweeper:no-new-fix-pr", "clawsweeper:needs-product-decision"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + securityReviewStatus: "needs_attention", + }), + ["bug", "clawsweeper:no-new-fix-pr", "clawsweeper:needs-security-review"], + ); + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "issue", + itemCategory: "security", + }), + ["bug", "clawsweeper:no-new-fix-pr", "clawsweeper:needs-security-review"], + ); +}); + +test("ClawSweeper issue advisory labels remove stale owned labels and preserve other labels", () => { + assert.deepEqual( + issueAdvisoryLabelsForTest( + [ + "bug", + "clawsweeper:source-repro", + "clawsweeper:not-repro-on-main", + "clawsweeper:needs-live-repro", + "clawsweeper:needs-info", + "clawsweeper:linked-pr-open", + "clawsweeper:no-new-fix-pr", + "clawsweeper:queueable-fix", + "clawsweeper:fix-shape-clear", + "clawsweeper:needs-product-decision", + "clawsweeper:needs-security-review", + "clawsweeper:autofix", + "clawsweeper:automerge", + "clawsweeper:human-review", + "clawsweeper:merge-ready", + "proof: sufficient", + "mantis: telegram-visible-proof", + ], + { + type: "issue", + reproductionStatus: "reproduced", + reproductionConfidence: "high", + }, + ), + [ + "bug", + "clawsweeper:autofix", + "clawsweeper:automerge", + "clawsweeper:human-review", + "clawsweeper:merge-ready", + "proof: sufficient", + "mantis: telegram-visible-proof", + "clawsweeper:current-main-repro", + ], + ); +}); + +test("ClawSweeper issue advisory labels do not apply to pull requests", () => { + assert.deepEqual( + issueAdvisoryLabelsForTest(["bug"], { + type: "pull_request", + reproductionStatus: "reproduced", + reproductionConfidence: "high", + workCandidate: "queue_fix_pr", + workStatus: "candidate", + workConfidence: "high", + hasOpenLinkedPullRequest: true, + requiresProductDecision: true, + securityReviewStatus: "needs_attention", + hasWorkShape: true, + }), + ["bug"], + ); +}); + test("review workflow gives Codex a read-only inspection token", () => { const workflow = readFileSync(".github/workflows/sweep.yml", "utf8"); @@ -3390,7 +4162,10 @@ test("sweep target write tokens can merge pull requests", () => { test("sweep review recovery uses explicit failed shard artifacts", () => { const workflow = readFileSync(".github/workflows/sweep.yml", "utf8"); - assert.match(workflow, /- name: Review shard\n\s+id: review-shard\n\s+continue-on-error: true/); + assert.match( + workflow, + /- name: Review shard\r?\n\s+id: review-shard\r?\n\s+continue-on-error: true/, + ); assert.match(workflow, /- name: Record failed review shard/); assert.match(workflow, /steps\.review-shard\.outcome == 'failure'/); assert.match(workflow, /name: review-failed-shard-\$\{\{ matrix\.shard \}\}/);