diff --git a/config/documentation-site.json b/config/documentation-site.json index ec444525ad7..fb7105785d6 100644 --- a/config/documentation-site.json +++ b/config/documentation-site.json @@ -14,6 +14,7 @@ "pr-review-comments.md", "pr-proof-triage-dashboard.md", "author-pr-budget-close-policy.md", + "implemented-on-main-close-policy.md", "obsolescence-close-policies.md", "product-direction-close-policy.md", "stalled-pr-close-policies.md", diff --git a/docs/README.md b/docs/README.md index 86b5576771f..65a424d94c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -145,6 +145,8 @@ poster or WebP over accumulated before/after sets. ## Close policy +- [Implemented-on-main paired close policy](implemented-on-main-close-policy.md) + — active policy and formal GitHub-link requirement - [Obsolescence policies](obsolescence-close-policies.md) — active policy map - [Unsponsored feature policy](unsponsored-feature-close-policy.md) — active - [Product direction policy](product-direction-close-policy.md) — active diff --git a/docs/implemented-on-main-close-policy.md b/docs/implemented-on-main-close-policy.md new file mode 100644 index 00000000000..e979bde7f39 --- /dev/null +++ b/docs/implemented-on-main-close-policy.md @@ -0,0 +1,39 @@ +# Implemented-on-main paired close policy + +- Status: active +- Owner: ClawSweeper maintainers +- Owning code: `src/clawsweeper-status-context.ts` and `src/clawsweeper-apply-close-execution.ts` +- Update when: implementation-provenance, linked-issue, or paired-close guards change + +`implemented_on_main` and `mostly_implemented_on_main` are destructive apply +paths. A review may identify semantic equivalence, but `apply-decisions` closes +only after fresh GitHub reads prove every required relationship. + +## Required relationship + +For a PR and exactly one same-repository linked issue, both independently +reviewed reports must cite the same high-confidence, GitHub-verified merged PR +on the default branch. At apply time, ClawSweeper re-reads the PR body and the +linked issue. The issue must still be open, and that exact cited same-repository +PR's current GitHub `closingIssuesReferences` relationship must contain the +still-open issue. The query excludes manually linked issues, and a generic +cross-reference (for example, `Related to #123`) is not closing provenance. A +missing, closed, unreadable, manually linked, or mismatched relation keeps both +items open. + +This is intentionally stricter than semantic review evidence: a canonical PR +must have a durable GitHub relationship to the still-open linked issue before +ClawSweeper can close the pair. A canonical PR with no such relationship is +useful review context, but it does not authorize automatic paired closure. A +maintainer can evaluate that case through the ordinary review path. + +## Mutation and visibility boundaries + +Before any mutation, apply rechecks report freshness, source state, labels, +locks, and post-review human activity. The linked issue has its own mutation +lease and ledger record; it is archived before the parent PR, so an interrupted +parent close cannot erase an independently completed issue record. + +OpenClaw Bay is unaffected. Bay remains an observer-only projection of durable +workflow state: this policy changes neither Bay's public schema nor its ability +to initiate GitHub or apply actions. diff --git a/prompts/review-item.md b/prompts/review-item.md index c99337f2c6a..87bc2836a62 100644 --- a/prompts/review-item.md +++ b/prompts/review-item.md @@ -75,6 +75,13 @@ whether the fix is in the latest release or main-only, and whether a merged or open related PR now owns the work. When current `main` solves the issue with high-confidence source, history, and release/main-only evidence, prefer an `implemented_on_main` close even if the fix has not shipped in a release yet. +For a pull request, source resemblance, an overlapping feature, or a current +main commit alone is never sufficient to propose `implemented_on_main` or +`mostly_implemented_on_main`. Require a GitHub-verified, merged fixing PR in +the same repository, with an explicit connection to the requested behavior. +If that connection is incomplete, ambiguous, cross-repository, or only +inferred from filenames/commit text, keep the PR open and do not close any +linked issue. A linked issue is not permission or proof to close either item. If a meaningful requested behavior remains missing, keep the item open or link the canonical remaining work. @@ -689,6 +696,11 @@ Use reason-specific anchors: release tag/version. If it is only on current `main`, say that and include the commit timestamp. If you cannot establish either the shipped release or the main-only timestamp with high confidence, keep the item open. + For PR closure, additionally require a same-repository merged fixing PR that + GitHub formally identifies as closing the relevant issue or request; cite its + PR URL in the review and final closeout. A commit with no such PR, a PR that + only happens to touch nearby code, an unverified issue link, or an ambiguous + partial implementation is a keep-open result, not an automatic close. - For `cannot_reproduce`, distinguish missing reporter evidence from a report whose concrete mechanism is disproved. Search the complete current tree, source history, renamed paths, and shipped version when identified; trace the diff --git a/src/clawsweeper-apply-close-execution.ts b/src/clawsweeper-apply-close-execution.ts index 237d2171aba..a0a7c740d8d 100644 --- a/src/clawsweeper-apply-close-execution.ts +++ b/src/clawsweeper-apply-close-execution.ts @@ -1,5 +1,6 @@ import type { CreateApplyDecisionWorkflowDependencies } from "./clawsweeper-apply-dependencies.js"; import { closeReasonText } from "./clawsweeper-close-reasons.js"; +import { linkedIssueNumbersForImplementationProvenance } from "./clawsweeper-status-context.js"; import { EVENT_GUARDED_OPEN_ACTIONS, REVIEW_SECTIONS, @@ -18,9 +19,16 @@ import type { } from "./clawsweeper-types.js"; import type { MaintainerDecision } from "./decision-packets.js"; import { IDEA_ARCHIVE_LABEL } from "./idea-archive-revival.js"; +import { + isGitHubRequiresAuthenticationError, + isLockedConversationCommentError, +} from "./github-retry.js"; +import { stableJson } from "./stable-json.js"; type ApplyCloseExecutionDependencies = Pick< CreateApplyDecisionWorkflowDependencies, + | "CLAWSWEEPER_BOT_AUTHORS" + | "asRecord" | "abandonedPrApplyBlockReasonSafe" | "addIssueLabel" | "applyPrCloseCoverageProofReportSection" @@ -31,7 +39,12 @@ type ApplyCloseExecutionDependencies = Pick< | "ensureCloseAppliedComment" | "ensureIdeaArchiveLabel" | "ensureRuntimeDelayFits" + | "fetchIssueReviewComments" + | "fetchItem" + | "frontMatterValue" | "GitHubRuntimeBudgetError" + | "ghJson" + | "implementedOnMainPullRequestProvenanceApplyBlock" | "issueRecentHumanCommentBlockReasonSafe" | "lowSignalUnmergeablePrApplyBlockReasonSafe" | "normalizeLabelName" @@ -53,12 +66,55 @@ type ReviewFreshnessBlock = { currentSnapshotHash?: string; }; +export function implementedOnMainCloseProvenanceBlock( + markdown: string, + itemKind: Item["kind"], + itemNumber: number, + closeReason: CloseReason, +): string | null { + if ( + itemKind !== "pull_request" || + !["implemented_on_main", "mostly_implemented_on_main"].includes(closeReason) + ) { + return null; + } + const fixedPrUrl = markdown.match(/^fixed_pr_url: (.+)$/m)?.[1]?.trim(); + const repository = markdown.match(/^repository: (.+)$/m)?.[1]?.trim(); + const fixedPrNumber = markdown.match(/^fixed_pr_number: (\d+)$/m)?.[1]?.trim(); + const fixedPrConfidence = markdown.match(/^fixed_pr_confidence: (.+)$/m)?.[1]?.trim(); + const fixedPrSource = markdown.match(/^fixed_pr_source: (.+)$/m)?.[1]?.trim(); + const fixedPrMergedAt = markdown.match(/^fixed_pr_merged_at: (.+)$/m)?.[1]?.trim(); + if ( + fixedPrUrl && + repository && + fixedPrNumber && + fixedPrNumber !== String(itemNumber) && + fixedPrUrl === `https://github.com/${repository}/pull/${fixedPrNumber}` && + fixedPrConfidence === "high" && + fixedPrSource && + fixedPrSource !== "unknown" && + fixedPrSource.includes("GitHub ") && + fixedPrMergedAt && + fixedPrMergedAt !== "unknown" + ) { + return null; + } + return "implemented-on-main close requires a GitHub-verified, same-repository merged fixing pull request"; +} + interface ApplyCloseExecutionOptions { applyCloseReasons: ReadonlySet | null; applyKind: ApplyKind; archiveClosed: (markdown: string) => void; + archivePairedIssue: (number: number) => void; + canStartPairedIssueClose: (number: number, kind: Item["kind"]) => boolean; + pairedIssueMarkdown: (number: number) => string | null; + pairedIssueReviewUpdatedAt: (number: number) => string | null; + pairedIssueDurableReviewCommentUpdatedAt: (number: number) => string | null; + pairedIssueCanonicalProvenanceBlock: (number: number) => string | null; closeDelayMs: number; closeLimitReached: boolean; + pairedIssueCloseCapacityAvailable: boolean; closeReason: CloseReason; closedDir: string; currentApplyMutationLeaseBlockReason: () => string | null; @@ -66,7 +122,9 @@ interface ApplyCloseExecutionOptions { currentObsoleteFixPrBlockReason: () => string | null; currentPrCloseCoverageProofGateBlock: () => PrCloseCoverageProofGateBlock | null; currentStaleVersionBugBlockReason: () => string | null; + currentDurableReviewCommentUpdatedAt: () => string | null; dryRun: boolean; + deferPairedIssueForThisRun: (number: number) => void; examinedItemNumbers: number[]; getMarkdown: () => string; isRetryableSkippedClose: boolean; @@ -79,11 +137,16 @@ interface ApplyCloseExecutionOptions { minAgeMs: number; number: number; onClosed: (result: ApplyResult, dryRun: boolean) => boolean; + onPairedIssueClosed: (result: ApplyResult, dryRun: boolean) => boolean; postProofCoveringPrFreshnessBlock: () => PrCloseCoverageProofGateBlock | null; - postProofFreshnessBlock: () => ReviewFreshnessBlock | null; + postProofFreshnessBlock: (options?: { force?: boolean }) => ReviewFreshnessBlock | null; proofResult: () => PrCloseCoverageProofGateResult | undefined; recordApplySkipped: (action: ActionTaken, reason: string) => boolean; recordMutation: (parentEventId?: string | null) => void; + rememberSelfMutationUpdatedAt: (options?: { + postReviewActivityStartedAtMs?: number; + requiresReviewedPrActivityCursor?: boolean; + }) => boolean; recordReviewLeaseSkip: (reason: string, preserveLease?: boolean) => boolean; recordRuntimeBudgetYield: (reason: string) => void; repo: string; @@ -92,6 +155,11 @@ interface ApplyCloseExecutionOptions { runtimeBudget: GitHubRuntimeBudget; setMarkdown: (markdown: string) => void; staleMinAgeDays: number; + withPairedIssueMutationLease: ( + number: number, + operation: () => T, + options?: { onOperationCompleted?: () => void }, + ) => T; emitEventApplyProof: boolean; } @@ -100,6 +168,8 @@ export function executeApplyClose( options: ApplyCloseExecutionOptions, ): ApplyCloseFlow { const { + CLAWSWEEPER_BOT_AUTHORS, + asRecord, abandonedPrApplyBlockReasonSafe, addIssueLabel, applyPrCloseCoverageProofReportSection, @@ -110,7 +180,12 @@ export function executeApplyClose( ensureCloseAppliedComment, ensureIdeaArchiveLabel, ensureRuntimeDelayFits, + fetchIssueReviewComments, + fetchItem, + frontMatterValue, GitHubRuntimeBudgetError, + ghJson, + implementedOnMainPullRequestProvenanceApplyBlock, issueRecentHumanCommentBlockReasonSafe, lowSignalUnmergeablePrApplyBlockReasonSafe, normalizeLabelName, @@ -128,6 +203,12 @@ export function executeApplyClose( applyCloseReasons, applyKind, archiveClosed, + archivePairedIssue, + canStartPairedIssueClose, + pairedIssueMarkdown, + pairedIssueReviewUpdatedAt, + pairedIssueDurableReviewCommentUpdatedAt, + pairedIssueCanonicalProvenanceBlock, closeDelayMs, closeLimitReached, closeReason, @@ -137,7 +218,9 @@ export function executeApplyClose( currentObsoleteFixPrBlockReason, currentPrCloseCoverageProofGateBlock, currentStaleVersionBugBlockReason, + currentDurableReviewCommentUpdatedAt, dryRun, + deferPairedIssueForThisRun, emitEventApplyProof, examinedItemNumbers, getMarkdown, @@ -151,11 +234,14 @@ export function executeApplyClose( minAgeMs, number, onClosed, + onPairedIssueClosed, + pairedIssueCloseCapacityAvailable, postProofCoveringPrFreshnessBlock, postProofFreshnessBlock, proofResult, recordApplySkipped, recordMutation, + rememberSelfMutationUpdatedAt, recordReviewLeaseSkip, recordRuntimeBudgetYield, repo, @@ -164,6 +250,7 @@ export function executeApplyClose( runtimeBudget, setMarkdown, staleMinAgeDays, + withPairedIssueMutationLease, } = options; const skip = (action: ActionTaken, reason: string, liveGuardVerified = false): ApplyCloseFlow => markApplySkipped(action, reason, liveGuardVerified) ? "stop" : "next"; @@ -171,6 +258,68 @@ export function executeApplyClose( recordApplySkipped(action, reason) ? "stop" : "next"; const skipLease = (reason: string): ApplyCloseFlow => recordReviewLeaseSkip(reason, false) ? "stop" : "next"; + const pairedIssueSourceSnapshot = (issueNumber: number): string | null => { + const issue = ghJson>(["api", `repos/${repo}/issues/${issueNumber}`]); + if (typeof issue.title !== "string" || !Array.isArray(issue.labels)) return null; + const labels = issue.labels + .map((label) => { + if (typeof label === "string") return label; + if (!label || typeof label !== "object" || Array.isArray(label)) return null; + const name = (label as { name?: unknown }).name; + return typeof name === "string" ? name : null; + }) + .filter((label): label is string => label !== null) + .sort(); + if (labels.length !== issue.labels.length) return null; + // A comment changes `comments` and may advance `updated_at`; exclude only + // those self-mutation fields. Keep the rest of the issue representation so + // changes such as assignees, milestone, state reason, or issue type block + // the paired close instead of being mistaken for the bot's comment update. + const { comments: _comments, updated_at: _updatedAt, labels: _rawLabels, ...source } = issue; + return sha256(stableJson({ ...source, labels })); + }; + const pairedIssueRecentNonSelfCommentBlockReasonSafe = ( + issueNumber: number, + reviewedAtMs: number, + ): string | null => { + try { + if (!Number.isFinite(reviewedAtMs)) { + return "linked issue review timestamp is unavailable for final activity verification"; + } + const cutoffMs = reviewedAtMs; + const closeAppliedMarker = ``; + for (const comment of fetchIssueReviewComments(issueNumber)) { + const record = asRecord(comment); + const createdAtMs = Date.parse( + typeof record.created_at === "string" ? record.created_at : "", + ); + const updatedAtMs = Date.parse( + typeof record.updated_at === "string" ? record.updated_at : "", + ); + if (!Number.isFinite(createdAtMs) || !Number.isFinite(updatedAtMs)) { + return "linked issue comment activity check returned an invalid timestamp"; + } + if (createdAtMs <= cutoffMs && updatedAtMs <= cutoffMs) continue; + const user = asRecord(record.user); + const login = typeof user.login === "string" ? user.login.trim().toLowerCase() : ""; + const body = typeof record.body === "string" ? record.body : ""; + if ( + CLAWSWEEPER_BOT_AUTHORS.has(login) && + (body.includes(closeAppliedMarker) || + body.includes(`"); + if (end < 0) break; + remaining = remaining.slice(end + 3); + htmlComment = false; + continue; + } + const start = remaining.indexOf("", + }, + ]; + let mutationCount = 0; + const publication = createReviewCommentPublication({ + root, + targetRepo: () => "openclaw/clawsweeper", + ghPaged: () => comments, + asRecord: (value: unknown) => value as Record, + ensureDir: (directory: string) => mkdirSync(directory, { recursive: true }), + sha256: () => "body-digest", + ghObservedMutationCommand: ({ args }) => { + mutationCount += 1; + const input = args[args.indexOf("--input") + 1]; + const body = JSON.parse(readFileSync(input!, "utf8")).body as string; + comments.push({ id: 2, user: { login: "clawsweeper[bot]" }, body }); + return JSON.stringify({ id: 2 }); + }, + frontMatterValue: () => undefined, + replaceFrontMatterValue: (markdown: string) => markdown, + sectionValue: () => "", + timestampMs: () => null, + sentence: (value: string) => value, + normalizedLabelSet: () => new Set(), + sectionLineValue: () => undefined, + markdownLink: (label: string, url: string) => `[${label}](${url})`, + closeAppliedCommentMarker: (number: number) => + ``, + commentId: (comment: Record | undefined) => + typeof comment?.id === "number" ? comment.id : null, + canPatchReviewComment: (comment: Record | undefined) => + (comment?.user as { login?: unknown } | undefined)?.login === "clawsweeper[bot]", + } as Parameters[0]); + const options = { + number: 321, + closeReason: "implemented_on_main" as const, + markdown: "", + itemUrl: "https://github.com/openclaw/clawsweeper/pull/321", + dryRun: false, + }; + comments[0]!.body = publication.renderCloseAppliedComment(options); + + assert.equal(publication.ensureCloseAppliedComment(options), "posted close-applied comment"); + assert.equal( + publication.ensureCloseAppliedComment(options), + "matching ClawSweeper close-applied comment already exists", + ); + assert.equal(mutationCount, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("partial label-sync authentication failures preserve labels already applied", () => { const root = mkdtempSync(tmpPrefix); try { @@ -374,7 +468,7 @@ 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] || ""; +const path = args[1] === "-i" ? args[2] || "" : args[1] || ""; if (args[0] === "api" && /\\/issues\\/${number}$/.test(path)) { console.log(JSON.stringify({ number: ${number}, @@ -425,7 +519,6 @@ if (args[0] === "api" && /\\/issues\\/${number}$/.test(path)) { extraArgs: ["--sync-comments-only", "--item-numbers", String(number)], }); }); - const calls = readFileSync(logPath, "utf8") .trim() .split("\n") @@ -498,7 +591,7 @@ 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] || ""; +const path = args[1] === "-i" ? args[2] || "" : args[1] || ""; if (args[0] === "api" && /\\/issues\\/${number}$/.test(path)) { console.log(JSON.stringify({ number: ${number}, @@ -547,7 +640,6 @@ if (args[0] === "api" && /\\/issues\\/${number}$/.test(path)) { extraArgs: ["--sync-comments-only", "--item-numbers", String(number)], }); }); - const calls = readFileSync(logPath, "utf8") .trim() .split("\n") @@ -947,6 +1039,7 @@ if (args[0] === "api" && new RegExp("/issues/comments/\\\\d+$").test(path) && ar labelIndex > commentIndex && placeholderCleanupIndex > labelIndex && closeIndex > placeholderCleanupIndex, + JSON.stringify({ commands, report: JSON.parse(readFileSync(reportPath, "utf8")) }), ); const isGuardRead = (args: string[]): boolean => args[0] === "api" && @@ -1697,7 +1790,7 @@ if (args[0] === "api" && new RegExp("/issues/${number}/comments(?:\\\\?|$)").tes console.error("unexpected gh args", JSON.stringify(args)); process.exit(1); } -`; + `; withMockGh(root, ghMock, () => { runApplyDecisionsForTest({ itemsDir, @@ -1813,7 +1906,7 @@ if (args[0] === "api" && /\\/issues\\/321\\/comments(?:\\?|$)/.test(path)) { console.error("unexpected gh args", JSON.stringify(args)); process.exit(1); } -`; + `; withMockGh(root, ghMock, () => { runApplyDecisionsForTest({ itemsDir, @@ -3583,6 +3676,12 @@ const path = args[1] || ""; if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/321\\/timeline(?:\\?|$)/.test(args[2] || "")) { const timeline = Array.from({ length: 100 }, (_, index) => ({ id: index + 1 })); console.log('HTTP/2 200\\nlink: ; rel="last"\\n\\n' + JSON.stringify(timeline)); +} else if (args[0] === "issue" && args[1] === "view") { + console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); +} else if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/456\\/timeline(?:\\?|$)/.test(args[2] || "")) { + console.log("HTTP/2 200\\n\\n[]"); +} else if (args[0] === "api" && args[1] === "graphql") { + console.log(JSON.stringify({ data: { repository: { issue: { state: "CLOSED", timelineItems: { nodes: [{ __typename: "ClosedEvent", createdAt: "2026-05-01T02:00:00Z", closer: { __typename: "PullRequest", number: 900, repository: { nameWithOwner: "openclaw/clawsweeper" } } }] } } } } })); } else if (args[0] === "api" && /\\/issues\\/321\\/comments(?:\\?|$)/.test(path)) { console.log(JSON.stringify([[{ id: 9321, @@ -3664,73 +3763,382 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/321\\/timeline(?:\\?|$ } }); -test("apply-decisions posts an explicit close-time note before closing PR proposals", () => { - 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 postedBodiesPath = join(root, "posted-bodies.jsonl"); - mkdirSync(itemsDir, { recursive: true }); - mkdirSync(plansDir, { recursive: true }); - const closeReport = `${workPlanCandidateReport({ - type: "pull_request", - 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", - fixed_pr_url: "https://github.com/openclaw/clawsweeper/pull/900", - fixed_pr_number: "900", - fixed_sha: "1234567890abcdef1234567890abcdef12345678", - fixed_at: "2026-05-01T02:00:00Z", - })}\n\n## Evidence\n\n- **main fix:** git show confirms current main has the replacement implementation and it is not in the latest release yet\n - file: [src/clawsweeper.ts](https://github.com/openclaw/clawsweeper/blob/1234567890abcdef1234567890abcdef12345678/src/clawsweeper.ts)\n - sha: [1234567890ab](https://github.com/openclaw/clawsweeper/commit/1234567890abcdef1234567890abcdef12345678)\n\n## Close Comment\n\nClosing this PR because the fix is already on main.\n`; - const synced = reportWithSyncedReviewComment(closeReport, 321, "implemented_on_main"); - writeFileSync(join(itemsDir, "321.md"), synced.report, "utf8"); +test("apply-decisions verifies provenance after a closeout note and before closing PR proposals", () => { + for (const scenario of [ + "normal", + "multiple_linked_issues", + "mismatched_canonical", + "mismatched_canonical_repository", + "missing_canonical_merge", + "paired_keep_open", + "paired_review_stale", + "paired_source_change_during_closeout", + "paired_metadata_change_during_closeout", + "paired_bot_activity_during_closeout", + "paired_self_timestamp_settles_late", + "paired_fresh_owned_review_comment", + "paired_durable_review_mismatch", + "paired_locked_closeout_cleanup", + "paired_provenance_revoked_before_close", + "paired_provenance_retargeted_before_close", + "paired_human_activity_during_lease", + ] as const) { + const lifecycleDrift = scenario === "lifecycle_drift"; + const multipleLinkedIssues = scenario === "multiple_linked_issues"; + const mismatchedCanonical = scenario === "mismatched_canonical"; + const mismatchedCanonicalRepository = scenario === "mismatched_canonical_repository"; + const missingCanonicalMerge = scenario === "missing_canonical_merge"; + const pairedKeepOpen = scenario === "paired_keep_open"; + const pairedReviewStale = scenario === "paired_review_stale"; + const pairedSourceChangeDuringCloseout = scenario === "paired_source_change_during_closeout"; + const pairedMetadataChangeDuringCloseout = + scenario === "paired_metadata_change_during_closeout"; + const pairedBotActivityDuringCloseout = scenario === "paired_bot_activity_during_closeout"; + const pairedSelfTimestampSettlesLate = scenario === "paired_self_timestamp_settles_late"; + const pairedFreshOwnedReviewComment = scenario === "paired_fresh_owned_review_comment"; + const pairedDurableReviewMismatch = scenario === "paired_durable_review_mismatch"; + const pairedLockedCloseoutCleanup = scenario === "paired_locked_closeout_cleanup"; + const pairedProvenanceRevokedBeforeClose = + scenario === "paired_provenance_revoked_before_close"; + const pairedProvenanceRetargetedBeforeClose = + scenario === "paired_provenance_retargeted_before_close"; + const pairedHumanActivityDuringLease = scenario === "paired_human_activity_during_lease"; + const lockedCloseoutComment = scenario === "locked_closeout_comment"; + const betweenFreshnessAndCloseoutHumanActivity = + scenario === "between_freshness_and_closeout_human_activity"; + const postCloseoutHumanActivity = scenario === "post_closeout_human_activity"; + const postCloseoutPrReviewActivity = scenario === "post_closeout_pr_review_activity"; + 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 postedBodiesPath = join(root, "posted-bodies.jsonl"); + const prCommentPath = join(root, "pr-review-comment"); + const linkedIssueCommentPath = join(root, "linked-issue-review-comment"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + const reviewedSourceRevision = itemSourceRevisionSha256ForTest( + { + title: "Render work plans", + labels: ["maintainer"], + }, + [[]], + ); + const closeReport = `${workPlanCandidateReport({ + type: "pull_request", + 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", + item_source_revision: reviewedSourceRevision, + pull_head_sha: "head-sha", + reproduction_status: "reproduced", + reproduction_confidence: "high", + fixed_pr_url: mismatchedCanonicalRepository + ? "https://github.com/openclaw/other-repository/pull/900" + : `https://github.com/openclaw/clawsweeper/pull/${mismatchedCanonical ? "901" : "900"}`, + fixed_pr_number: mismatchedCanonical ? "901" : "900", + fixed_pr_confidence: "high", + fixed_pr_source: "GitHub verified implementation landing", + fixed_pr_merged_at: "2026-05-01T02:00:00Z", + fixed_sha: "1234567890abcdef1234567890abcdef12345678", + fixed_at: "2026-05-01T02:00:00Z", + })}\n\n## Evidence\n\n- **main fix:** git show confirms current main has the replacement implementation and it is not in the latest release yet\n - file: [src/clawsweeper.ts](https://github.com/openclaw/clawsweeper/blob/1234567890abcdef1234567890abcdef12345678/src/clawsweeper.ts)\n - sha: [1234567890ab](https://github.com/openclaw/clawsweeper/commit/1234567890abcdef1234567890abcdef12345678)\n\n## Close Comment\n\nClosing this PR because the fix is already on main.\n`; + const synced = reportWithSyncedReviewComment(closeReport, 321, "implemented_on_main"); + const linkedIssueReport = implementedCloseReport({ + repository: "openclaw/clawsweeper", + number: 456, + type: "issue", + item_updated_at: pairedReviewStale ? "2026-05-01T00:00:00Z" : "2026-05-02T00:00:00Z", + fixed_pr_url: "https://github.com/openclaw/clawsweeper/pull/900", + fixed_pr_number: "900", + fixed_pr_confidence: "high", + fixed_pr_source: "GitHub verified implementation landing", + fixed_pr_merged_at: missingCanonicalMerge ? "unknown" : "2026-05-01T02:00:00Z", + }).replaceAll("openclaw/openclaw", "openclaw/clawsweeper"); + const linkedIssueSynced = reportWithSyncedReviewComment( + pairedKeepOpen + ? linkedIssueReport.replace(/^decision: close$/m, "decision: keep_open") + : linkedIssueReport, + 456, + "implemented_on_main", + ); + writeFileSync(join(itemsDir, "321.md"), synced.report, "utf8"); + if (!multipleLinkedIssues) { + writeFileSync(join(itemsDir, "456.md"), linkedIssueSynced.report, "utf8"); + writeFileSync( + linkedIssueCommentPath, + pairedDurableReviewMismatch + ? `${linkedIssueSynced.comment}\n\nNewer contradictory bot verdict.` + : linkedIssueSynced.comment, + "utf8", + ); + } + writeFileSync(prCommentPath, synced.comment, "utf8"); - const ghMock = ` -const { appendFileSync, readFileSync } = require("fs"); + const ghMock = ` +const { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } = require("fs"); const logPath = ${JSON.stringify(logPath)}; const postedBodiesPath = ${JSON.stringify(postedBodiesPath)}; +const graphqlStatePath = ${JSON.stringify(join(root, "graphql-reads"))}; +const closeoutPostedPath = ${JSON.stringify(join(root, "closeout-posted"))}; +const betweenFreshnessAndCloseoutHumanActivityPath = ${JSON.stringify( + join(root, "between-freshness-and-closeout-human-activity"), + )}; +const issueReadsAfterCloseoutPath = ${JSON.stringify(join(root, "issue-reads-after-closeout"))}; +const pairedIssueCloseoutPostedPath = ${JSON.stringify(join(root, "paired-issue-closeout-posted"))}; +const pairedIssueBotActivityPath = ${JSON.stringify(join(root, "paired-issue-bot-activity"))}; +const pairedIssueHumanActivityPath = ${JSON.stringify(join(root, "paired-issue-human-activity"))}; +const pairedIssueReadsAfterCloseoutPath = ${JSON.stringify(join(root, "paired-issue-reads-after-closeout"))}; +const pairedIssueLeasePath = ${JSON.stringify(join(root, "paired-issue-lease"))}; +const pairedIssueLeaseWritesPath = ${JSON.stringify(join(root, "paired-issue-lease-writes"))}; +const prCommentPath = ${JSON.stringify(prCommentPath)}; +const linkedIssueCommentPath = ${JSON.stringify(linkedIssueCommentPath)}; const comment = ${JSON.stringify(synced.comment)}; +const linkedIssueComment = ${JSON.stringify(linkedIssueSynced.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 lifecycleDrift = ${lifecycleDrift}; +const multipleLinkedIssues = ${multipleLinkedIssues}; +const lockedCloseoutComment = ${lockedCloseoutComment}; +const betweenFreshnessAndCloseoutHumanActivity = ${betweenFreshnessAndCloseoutHumanActivity}; +const postCloseoutHumanActivity = ${postCloseoutHumanActivity}; +const postCloseoutPrReviewActivity = ${postCloseoutPrReviewActivity}; +const pairedSourceChangeDuringCloseout = ${pairedSourceChangeDuringCloseout}; +const pairedMetadataChangeDuringCloseout = ${pairedMetadataChangeDuringCloseout}; +const pairedBotActivityDuringCloseout = ${pairedBotActivityDuringCloseout}; +const pairedSelfTimestampSettlesLate = ${pairedSelfTimestampSettlesLate}; +const pairedFreshOwnedReviewComment = ${pairedFreshOwnedReviewComment}; +const pairedDurableReviewMismatch = ${pairedDurableReviewMismatch}; +const pairedLockedCloseoutCleanup = ${pairedLockedCloseoutCleanup}; +const pairedProvenanceRevokedBeforeClose = ${pairedProvenanceRevokedBeforeClose}; +const pairedProvenanceRetargetedBeforeClose = ${pairedProvenanceRetargetedBeforeClose}; +const pairedHumanActivityDuringLease = ${pairedHumanActivityDuringLease}; if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/321\\/timeline(?:\\?|$)/.test(args[2] || "")) { + const timeline = existsSync(betweenFreshnessAndCloseoutHumanActivityPath) + ? [{ + id: 9323, + event: "commented", + created_at: readFileSync(betweenFreshnessAndCloseoutHumanActivityPath, "utf8"), + actor: { login: "contributor" } + }] + : []; + console.log("HTTP/2 200\\n\\n" + JSON.stringify(timeline)); +} else if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/456\\/timeline(?:\\?|$)/.test(args[2] || "")) { console.log("HTTP/2 200\\n\\n[]"); +} else if (args[0] === "issue" && args[1] === "view") { + console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); +} else if (args[0] === "api" && args[1] === "graphql") { + const graphqlReads = existsSync(graphqlStatePath) ? Number(readFileSync(graphqlStatePath, "utf8")) : 0; + writeFileSync(graphqlStatePath, String(graphqlReads + 1), "utf8"); + const closingReferenceQuery = args.some((argument) => argument.includes("closingIssuesReferences")); + const currentState = closingReferenceQuery || lifecycleDrift ? "OPEN" : "CLOSED"; + const closingReferenceNodes = closingReferenceQuery + ? pairedProvenanceRevokedBeforeClose && existsSync(pairedIssueLeasePath) + ? [] + : [{ + number: + pairedProvenanceRetargetedBeforeClose && + existsSync(pairedIssueLeaseWritesPath) && + Number(readFileSync(pairedIssueLeaseWritesPath, "utf8")) >= 2 && + existsSync(pairedIssueLeasePath) + ? 457 + : 456, + state: "OPEN", + repository: { nameWithOwner: "openclaw/clawsweeper" } + }] + : []; + const timelineNodes = lifecycleDrift + ? [] + : [{ __typename: "ClosedEvent", createdAt: "2026-05-01T02:00:00Z", closer: { __typename: "PullRequest", number: 900, url: "https://github.com/openclaw/clawsweeper/pull/900", mergedAt: "2026-05-01T02:00:00Z", repository: { nameWithOwner: "openclaw/clawsweeper" } } }]; + const repository = closingReferenceQuery + ? { pullRequest: { closingIssuesReferences: { nodes: closingReferenceNodes } } } + : { issue: { state: currentState, timelineItems: { nodes: timelineNodes } } }; + console.log(JSON.stringify({ data: { repository } })); +} else if (args[0] === "api" && /\\/commits\\/head-sha\\/(?:check-runs|status)(?:\\?|$)/.test(path)) { + console.log(JSON.stringify({ check_runs: [] })); +} else if (args[0] === "api" && /^search\\/issues\\?/.test(path)) { + console.log(JSON.stringify({ items: [] })); +} else if (args[0] === "api" && /\\/issues\\/456\\/comments(?:\\?|$)/.test(path)) { + if (args.includes("--method") && args.includes("POST")) { + const input = args[args.indexOf("--input") + 1]; + const payload = JSON.parse(readFileSync(input, "utf8")); + if (payload.body.includes("clawsweeper-review-lease item=456")) { + const leaseWrites = existsSync(pairedIssueLeaseWritesPath) + ? Number(readFileSync(pairedIssueLeaseWritesPath, "utf8")) + : 0; + writeFileSync(pairedIssueLeaseWritesPath, String(leaseWrites + 1), "utf8"); + if ( + pairedHumanActivityDuringLease && + existsSync(pairedIssueCloseoutPostedPath) && + !existsSync(pairedIssueHumanActivityPath) + ) { + writeFileSync(pairedIssueHumanActivityPath, new Date(Date.now() + 60_000).toISOString()); + } + writeFileSync(pairedIssueLeasePath, payload.body, "utf8"); + console.log(JSON.stringify({ id: 9460, html_url: "https://github.com/openclaw/clawsweeper/issues/456#issuecomment-9460" })); + process.exit(0); + } + if (pairedLockedCloseoutCleanup) { + console.error("gh: conversation is locked (HTTP 403)"); + process.exit(1); + } + appendFileSync(postedBodiesPath, JSON.stringify(payload.body) + "\\n"); + writeFileSync(linkedIssueCommentPath, payload.body, "utf8"); + if ( + pairedSourceChangeDuringCloseout || + pairedMetadataChangeDuringCloseout || + pairedBotActivityDuringCloseout || + pairedProvenanceRetargetedBeforeClose || + pairedHumanActivityDuringLease || + (pairedSelfTimestampSettlesLate && payload.body.includes("clawsweeper-close-applied")) + ) writeFileSync(pairedIssueCloseoutPostedPath, "true"); + if (pairedBotActivityDuringCloseout) writeFileSync(pairedIssueBotActivityPath, new Date(Date.now() + 60_000).toISOString()); + console.log(JSON.stringify({ id: 9456, html_url: "https://github.com/openclaw/clawsweeper/issues/456#issuecomment-9456" })); + } else { + const comments = [{ + id: 9456, + html_url: "https://github.com/openclaw/clawsweeper/issues/456#issuecomment-9456", + created_at: pairedFreshOwnedReviewComment ? new Date().toISOString() : "2026-05-01T01:00:00Z", + updated_at: pairedFreshOwnedReviewComment ? new Date().toISOString() : "2026-05-01T01:00:00Z", + user: { login: "clawsweeper[bot]" }, + body: readFileSync(linkedIssueCommentPath, "utf8") + }]; + if (pairedFreshOwnedReviewComment) { + comments.push({ + id: 9455, + html_url: "https://github.com/openclaw/clawsweeper/issues/456#issuecomment-9455", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + user: { login: "clawsweeper[bot]" }, + body: linkedIssueComment + }); + } + if (existsSync(pairedIssueBotActivityPath)) { + comments.push({ + id: 9457, + html_url: "https://github.com/openclaw/clawsweeper/issues/456#issuecomment-9457", + created_at: readFileSync(pairedIssueBotActivityPath, "utf8"), + updated_at: readFileSync(pairedIssueBotActivityPath, "utf8"), + user: { login: "third-party[bot]", type: "Bot" }, + body: "Automated follow-up." + }); + } + if (existsSync(pairedIssueHumanActivityPath)) { + comments.push({ + id: 9458, + html_url: "https://github.com/openclaw/clawsweeper/issues/456#issuecomment-9458", + created_at: readFileSync(pairedIssueHumanActivityPath, "utf8"), + updated_at: readFileSync(pairedIssueHumanActivityPath, "utf8"), + user: { login: "contributor", type: "User" }, + body: "Please keep this issue open." + }); + } + if (existsSync(pairedIssueLeasePath)) { + comments.push({ + id: 9460, + html_url: "https://github.com/openclaw/clawsweeper/issues/456#issuecomment-9460", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + user: { login: "clawsweeper[bot]" }, + body: readFileSync(pairedIssueLeasePath, "utf8") + }); + } + console.log(JSON.stringify([comments])); + } } else if (args[0] === "api" && /\\/issues\\/321\\/comments(?:\\?|$)/.test(path)) { if (args.includes("--method") && args.includes("POST")) { + if (lockedCloseoutComment) { + console.error("gh: conversation is locked (HTTP 403)"); + process.exit(1); + } const input = args[args.indexOf("--input") + 1]; const payload = JSON.parse(readFileSync(input, "utf8")); appendFileSync(postedBodiesPath, JSON.stringify(payload.body) + "\\n"); + writeFileSync(prCommentPath, payload.body, "utf8"); + writeFileSync(closeoutPostedPath, "true"); + if (betweenFreshnessAndCloseoutHumanActivity) { + writeFileSync( + betweenFreshnessAndCloseoutHumanActivityPath, + new Date(Date.now() + 60_000).toISOString(), + ); + } console.log(JSON.stringify({ id: 9322, html_url: "https://github.com/openclaw/clawsweeper/pull/321#issuecomment-9322" })); } else { - console.log(JSON.stringify([[{ + const comments = [{ id: 9321, html_url: "https://github.com/openclaw/clawsweeper/pull/321#issuecomment-9321", created_at: "2026-05-01T01:00:00Z", updated_at: "2026-05-01T01:00:00Z", user: { login: "clawsweeper[bot]" }, - body: comment - }]])); + body: readFileSync(prCommentPath, "utf8") + }]; + if (existsSync(betweenFreshnessAndCloseoutHumanActivityPath)) { + comments.push({ + id: 9323, + html_url: "https://github.com/openclaw/clawsweeper/pull/321#issuecomment-9323", + created_at: readFileSync(betweenFreshnessAndCloseoutHumanActivityPath, "utf8"), + updated_at: readFileSync(betweenFreshnessAndCloseoutHumanActivityPath, "utf8"), + user: { login: "contributor" }, + body: "Please keep this PR open." + }); + } + console.log(JSON.stringify([comments])); } +} else if (args[0] === "api" && /\\/issues\\/comments\\/9456$/.test(path) && args.includes("PATCH")) { + const input = args[args.indexOf("--input") + 1]; + const payload = JSON.parse(readFileSync(input, "utf8")); + appendFileSync(postedBodiesPath, JSON.stringify(payload.body) + "\\n"); + writeFileSync( + linkedIssueCommentPath, + pairedDurableReviewMismatch ? payload.body + "\\n\\nNewer contradictory bot verdict." : payload.body, + "utf8" + ); + console.log(JSON.stringify({ id: 9456, html_url: "https://github.com/openclaw/clawsweeper/issues/456#issuecomment-9456" })); +} else if (args[0] === "api" && /\\/issues\\/comments\\/9460$/.test(path) && args.includes("DELETE")) { + if (pairedLockedCloseoutCleanup) { + console.error("gh: conversation is locked (HTTP 403)"); + process.exit(1); + } + if (existsSync(pairedIssueLeasePath)) unlinkSync(pairedIssueLeasePath); + console.log(""); } else if (args[0] === "api" && /\\/issues\\/comments\\/9321$/.test(path)) { + const input = args[args.indexOf("--input") + 1]; + const payload = JSON.parse(readFileSync(input, "utf8")); + appendFileSync(postedBodiesPath, JSON.stringify(payload.body) + "\\n"); + writeFileSync(prCommentPath, payload.body, "utf8"); console.log(JSON.stringify({ id: 9321, html_url: "https://github.com/openclaw/clawsweeper/pull/321#issuecomment-9321" })); } else if (args[0] === "api" && /\\/issues\\/321\\/timeline(?:\\?|$)/.test(path)) { console.log(JSON.stringify([[]])); } else if (args[0] === "api" && /\\/issues\\/321$/.test(path)) { + const issueReadsAfterCloseout = existsSync(closeoutPostedPath) + ? (existsSync(issueReadsAfterCloseoutPath) + ? Number(readFileSync(issueReadsAfterCloseoutPath, "utf8") || "0") + : 0) + 1 + : 0; + if (existsSync(closeoutPostedPath)) { + writeFileSync(issueReadsAfterCloseoutPath, String(issueReadsAfterCloseout)); + } + const humanActivityLanded = + (postCloseoutHumanActivity && issueReadsAfterCloseout >= 2) || + existsSync(betweenFreshnessAndCloseoutHumanActivityPath); console.log(JSON.stringify({ number: 321, title: "Render work plans", html_url: "https://github.com/openclaw/clawsweeper/pull/321", created_at: "2026-05-01T00:00:00Z", - updated_at: "2026-05-01T00:00:00Z", + updated_at: humanActivityLanded + ? "2026-05-01T03:00:00Z" + : existsSync(closeoutPostedPath) + ? "2026-05-01T02:30:00Z" + : "2026-05-01T00:00:00Z", closed_at: null, state: "open", locked: false, @@ -3741,6 +4149,55 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/321\\/timeline(?:\\?|$ comments: 1, pull_request: { url: "https://api.github.com/repos/openclaw/clawsweeper/pulls/321" } })); +} else if (args[0] === "api" && /\\/issues\\/456$/.test(path)) { + const pairedIssueReadsAfterCloseout = existsSync(pairedIssueCloseoutPostedPath) + ? (existsSync(pairedIssueReadsAfterCloseoutPath) + ? Number(readFileSync(pairedIssueReadsAfterCloseoutPath, "utf8") || "0") + : 0) + 1 + : 0; + if (existsSync(pairedIssueCloseoutPostedPath)) { + writeFileSync(pairedIssueReadsAfterCloseoutPath, String(pairedIssueReadsAfterCloseout)); + } + console.log(JSON.stringify({ + number: 456, + title: pairedSourceChangeDuringCloseout && existsSync(pairedIssueCloseoutPostedPath) + ? "Render work plans with contributor changes" + : "Render work plans", + html_url: "https://github.com/openclaw/clawsweeper/issues/456", + created_at: "2026-05-01T00:00:00Z", + updated_at: pairedSelfTimestampSettlesLate && pairedIssueReadsAfterCloseout >= 4 + ? "2026-05-02T00:00:01Z" + : "2026-05-02T00:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "issue-reporter" }, + labels: [], + milestone: pairedMetadataChangeDuringCloseout && existsSync(pairedIssueCloseoutPostedPath) + ? { number: 1, title: "maintainer follow-up" } + : null, + body: "The tracked implementation gap.", + comments: 0, + pull_request: null + })); +} else if (args[0] === "api" && path === "repos/openclaw/clawsweeper") { + console.log(JSON.stringify({ default_branch: "main" })); +} else if (args[0] === "api" && /\\/compare\\/fixed-sha\\.\\.\\.main$/.test(path)) { + console.log(JSON.stringify({ status: "ahead" })); +} else if (args[0] === "api" && /\\/pulls\\/900$/.test(path)) { + console.log(JSON.stringify({ + number: 900, + title: "fix: rendered work plans", + html_url: "https://github.com/openclaw/clawsweeper/pull/900", + state: "closed", + merged: true, + merged_at: "2026-05-01T02:00:00Z", + merge_commit_sha: "fixed-sha", + head: { sha: "fixed-head" }, + base: { ref: "main" } + })); } else if (args[0] === "api" && /\\/pulls\\/321$/.test(path)) { console.log(JSON.stringify({ number: 321, @@ -3749,14 +4206,32 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/321\\/timeline(?:\\?|$ changed_files: 0, commits: 0, review_comments: 0, + body: multipleLinkedIssues + ? "Fixes #456\\nCloses #457" + : pairedProvenanceRetargetedBeforeClose && + existsSync(pairedIssueLeaseWritesPath) && + Number(readFileSync(pairedIssueLeaseWritesPath, "utf8")) >= 2 && + existsSync(pairedIssueLeasePath) + ? "Fixes #457" + : "Fixes #456", head: { sha: "head-sha", ref: "branch", repo: { full_name: "fork/clawsweeper" } }, base: { sha: "base-sha", ref: "main", repo: { full_name: "openclaw/clawsweeper" } }, user: { login: "reporter" } })); +} else if (args[0] === "api" && /\\/pulls\\/321\\/reviews(?:\\?|$)/.test(path)) { + console.log(JSON.stringify( + postCloseoutPrReviewActivity && existsSync(closeoutPostedPath) + ? [{ id: 7701, user: { login: "maintainer" }, state: "COMMENTED", submitted_at: "2026-08-20T00:00:00Z" }] + : [] + )); } else if (args[0] === "api" && /\\/pulls\\/321\\/(files|commits|comments|reviews)(?:\\?|$)/.test(path)) { console.log(JSON.stringify([[]])); } else if (args[0] === "pr" && args[1] === "close" && args[2] === "321") { console.log(""); +} else if (args[0] === "api" && path === "repos/openclaw/clawsweeper/issues/456" && args.includes("PATCH")) { + console.log(""); +} else if (args[0] === "issue" && args[1] === "close" && args[2] === "456") { + console.log(""); } else if (args[0] === "issue" && args[1] === "edit") { console.log(""); } else if (args[0] === "label") { @@ -3766,56 +4241,330 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/321\\/timeline(?:\\?|$ process.exit(1); } `; - withMockGh(root, ghMock, () => { - runApplyDecisionsForTest({ - itemsDir, - closedDir, - plansDir, - reportPath, - extraArgs: ["--apply-kind", "all", "--processed-limit", "2"], + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: ["--apply-kind", "all", "--processed-limit", "3"], + }); + if ( + scenario === "normal" || + pairedSelfTimestampSettlesLate || + pairedFreshOwnedReviewComment || + pairedLockedCloseoutCleanup || + pairedProvenanceRevokedBeforeClose || + pairedProvenanceRetargetedBeforeClose || + pairedHumanActivityDuringLease + ) { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: ["--apply-kind", "all", "--processed-limit", "3"], + }); + } }); - }); - - const calls = readFileSync(logPath, "utf8") - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as string[]); - const postIndex = calls.findIndex( - (args) => - args[0] === "api" && - (args[1] ?? "").endsWith("/issues/321/comments") && - args.includes("POST"), - ); - const closeIndex = calls.findIndex( - (args) => args[0] === "pr" && args[1] === "close" && args[2] === "321", - ); - assert.ok(postIndex >= 0); - assert.ok(closeIndex > postIndex); - const postedBodies = readFileSync(postedBodiesPath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as string); - assert.equal(postedBodies.length, 1); - assert.match(postedBodies[0], /ClawSweeper applied the proposed close for this PR/); - assert.match(postedBodies[0], /Close reason: already implemented on main/); - assert.match(postedBodies[0], /durable ClawSweeper review/); - assert.match(postedBodies[0], /clawsweeper-close-applied item=321/); - assert.ok(existsSync(join(closedDir, "321.md"))); - assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ - { - number: 321, - action: "review_comment_synced", - reason: "updated durable Codex review comment", - }, - { - number: 321, - action: "closed", - reason: "already implemented on main; posted close-applied comment", - }, - ]); - } finally { - rmSync(root, { recursive: true, force: true }); + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + const postIndex = calls.findIndex( + (args) => + args[0] === "api" && + (args[1] ?? "").endsWith("/issues/321/comments") && + args.includes("POST"), + ); + const closeIndex = calls.findIndex( + (args) => args[0] === "pr" && args[1] === "close" && args[2] === "321", + ); + const pairedIssueCloseIndex = calls.findIndex( + (args) => + args[0] === "api" && + args[1] === "repos/openclaw/clawsweeper/issues/456" && + args.includes("PATCH"), + ); + const pairedIssueLeaseDeleteIndex = calls.findIndex( + (args) => + args[0] === "api" && + (args[1] ?? "").endsWith("/issues/comments/9460") && + args.includes("DELETE"), + ); + const graphqlIndices = calls + .map((args, index) => (args[0] === "api" && args[1] === "graphql" ? index : -1)) + .filter((index) => index >= 0); + if (lockedCloseoutComment) { + assert.equal(closeIndex, -1); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "skipped_locked_conversation" && + entry.reason === "conversation was locked while recording closeout evidence", + ), + true, + ); + continue; + } + if (pairedLockedCloseoutCleanup) { + assert.ok(pairedIssueLeaseDeleteIndex >= 0); + assert.equal(pairedIssueCloseIndex, -1); + assert.equal(closeIndex, -1); + assert.equal(existsSync(join(closedDir, "456.md")), false); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "skipped_locked_conversation" && + entry.reason === + "linked issue conversation was locked while recording closeout evidence", + ), + true, + JSON.stringify(report), + ); + continue; + } + if (pairedDurableReviewMismatch) { + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal(pairedIssueCloseIndex, -1, JSON.stringify(report)); + assert.equal(closeIndex, -1); + assert.equal(existsSync(join(closedDir, "456.md")), false); + assert.equal(existsSync(join(closedDir, "321.md")), false); + assert.equal( + report.some( + (entry) => + entry.action === "kept_open" && + entry.reason === + "implemented-on-main paired closeout requires an exact current durable review comment for the linked issue report", + ), + true, + JSON.stringify(report), + ); + continue; + } + if (multipleLinkedIssues) { + assert.equal(closeIndex, -1); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "kept_open" && entry.reason.includes("exactly one explicit"), + ), + true, + ); + continue; + } + if ( + mismatchedCanonical || + mismatchedCanonicalRepository || + missingCanonicalMerge || + pairedKeepOpen || + pairedReviewStale || + pairedSourceChangeDuringCloseout || + pairedMetadataChangeDuringCloseout || + pairedBotActivityDuringCloseout + ) { + assert.equal(closeIndex, -1); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some((entry) => entry.action === "kept_open"), + true, + ); + continue; + } + if (pairedProvenanceRevokedBeforeClose) { + assert.ok(graphqlIndices.length >= 2); + assert.equal(pairedIssueCloseIndex, -1); + assert.equal(closeIndex, -1); + assert.equal(existsSync(join(closedDir, "456.md")), false); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "kept_open" && + entry.reason === + "implemented-on-main close no longer has current GitHub issue-to-fixing-pull-request provenance", + ), + true, + JSON.stringify(report), + ); + continue; + } + if (pairedProvenanceRetargetedBeforeClose) { + assert.equal(pairedIssueCloseIndex, -1); + assert.equal(closeIndex, -1); + assert.equal(existsSync(join(closedDir, "456.md")), false); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "kept_open" && + entry.reason === + "implemented-on-main close current issue link no longer matches the leased paired issue", + ), + true, + JSON.stringify(report), + ); + continue; + } + if (pairedHumanActivityDuringLease) { + assert.equal(pairedIssueCloseIndex, -1); + assert.equal(closeIndex, -1); + assert.equal(existsSync(join(closedDir, "456.md")), false); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "kept_open" && + entry.reason === + "implemented-on-main paired closeout requires linked issue #456 to remain unchanged, unlocked, and free of new activity after acquiring its mutation lease", + ), + true, + JSON.stringify(report), + ); + continue; + } + if (lifecycleDrift) { + assert.ok(graphqlIndices.length >= 2); + assert.equal(closeIndex, -1); + assert.ok(postIndex >= 0); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some((entry) => entry.action === "closed"), + false, + ); + continue; + } + if (postCloseoutHumanActivity) { + assert.ok(graphqlIndices.length >= 2); + assert.equal(closeIndex, -1); + assert.ok(postIndex >= 0); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "skipped_changed_since_review" && + entry.reason === "updated_at changed", + ), + true, + ); + continue; + } + if (betweenFreshnessAndCloseoutHumanActivity) { + assert.equal(closeIndex, -1); + assert.ok(postIndex >= 0); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "skipped_changed_since_review" && + entry.reason === "closeout evidence freshness receipt could not be recorded", + ), + true, + ); + continue; + } + if (postCloseoutPrReviewActivity) { + assert.equal(closeIndex, -1); + assert.ok(postIndex >= 0); + assert.equal(existsSync(join(closedDir, "321.md")), false); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + action: string; + reason: string; + }>; + assert.equal( + report.some( + (entry) => + entry.action === "skipped_changed_since_review" && + entry.reason === "closeout evidence freshness receipt could not be recorded", + ), + true, + ); + continue; + } + assert.ok(graphqlIndices.length >= 2); + assert.ok(closeIndex >= 0, `${scenario}: ${readFileSync(reportPath, "utf8")}`); + assert.ok(pairedIssueCloseIndex >= 0, scenario); + assert.ok(pairedIssueCloseIndex < closeIndex); + const postedBodies = readFileSync(postedBodiesPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string); + const closeAppliedBodies = postedBodies.filter((body) => + body.includes("clawsweeper-close-applied"), + ); + assert.equal(closeAppliedBodies.length, 2); + assert.match( + closeAppliedBodies[0], + /ClawSweeper recorded implementation evidence for this proposed close/, + ); + assert.match(closeAppliedBodies[0], /Close reason: already implemented on main/); + assert.match(closeAppliedBodies[0], /Implementation evidence: \[fix PR #900\]/); + assert.match(closeAppliedBodies[0], /clawsweeper-close-applied item=321/); + assert.match(closeAppliedBodies[1], /Implementation evidence: \[fix PR #900\]/); + assert.match(closeAppliedBodies[1], /clawsweeper-close-applied item=456/); + assert.ok(existsSync(join(closedDir, "321.md"))); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 456, + action: "closed", + reason: "already implemented on main; posted close-applied comment", + }, + { + number: 321, + action: "closed", + reason: "already implemented on main; posted close-applied comment", + }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } } }); diff --git a/test/apply-pr-coverage-proof-close.test.ts b/test/apply-pr-coverage-proof-close.test.ts index b4e5f16a946..93cecc8e085 100644 --- a/test/apply-pr-coverage-proof-close.test.ts +++ b/test/apply-pr-coverage-proof-close.test.ts @@ -562,6 +562,7 @@ test("apply-decisions records successful duplicate PR coverage proof for closed lowSignalCloseReport({ number: 360, title: "Provider route fallback", + pull_head_sha: "head-sha", close_reason: "duplicate_or_superseded", work_cluster_refs: JSON.stringify([ "Superseded by https://github.com/openclaw/openclaw/pull/400", @@ -656,6 +657,7 @@ test("apply-decisions consumes a bound precomputed proof without invoking Codex" lowSignalCloseReport({ number: 361, title: "Provider route fallback", + pull_head_sha: "head-sha", close_reason: "duplicate_or_superseded", work_cluster_refs: JSON.stringify([ "Superseded by https://github.com/openclaw/openclaw/pull/400", diff --git a/test/apply-pr-coverage-proof-recheck.test.ts b/test/apply-pr-coverage-proof-recheck.test.ts index 4a9bb5fa7a8..25d1083ccc9 100644 --- a/test/apply-pr-coverage-proof-recheck.test.ts +++ b/test/apply-pr-coverage-proof-recheck.test.ts @@ -81,6 +81,50 @@ test("post-proof freshness accepts a prior-run automation receipt only with a co assert.match(guard(false).postProofFreshnessBlock()?.reason ?? "", /updated_at changed/); }); +test("forced final freshness rechecks an item without coverage proof or a new self receipt", () => { + let fetchCount = 0; + const guards = createApplyProofFreshnessGuards({ + action: undefined, + automationItemUpdatedAt: undefined, + collectItemContext: () => { + throw new Error("changed updated_at must fail before collecting context"); + }, + completeReviewActivityReceiptMatches: () => false, + contextHasNonAutomationActivityAfter: () => false, + coveringPrCloseCoveragePullRequestSnapshotSha256: () => "c".repeat(64), + currentProofState: () => ({ + cachedPrCloseCoverageProofGateResult: undefined, + prCloseCoverageProofGateChecked: false, + prCloseCoverageProofStartedAtMs: null, + storedHash: undefined, + storedUpdatedAt: "2026-08-01T14:52:41Z", + }), + expectedReviewActivityCursor: undefined, + fetchItem: () => { + fetchCount += 1; + return { + state: "open", + item: { kind: "pull_request", updatedAt: "2026-08-01T14:53:29Z" }, + }; + }, + fetchReviewedPrActivityCursor: () => emptyReviewedPrActivityCursor, + freshPullRequestReviewHead: () => true, + GitHubRuntimeBudgetError: class extends Error {}, + itemKind: "pull_request", + itemSnapshotHash: () => "d".repeat(64), + number: 359, + reviewHasCompleteActivityIdentity: false, + reviewMarkdown: "---\ntype: pull_request\n---\n", + retryCloseCoverageCommandStatusOnlyUpdate: () => false, + selfMutationItemReceipts: [], + } as never); + + assert.equal(guards.postProofFreshnessBlock(), null); + assert.equal(fetchCount, 0); + assert.equal(guards.postProofFreshnessBlock({ force: true })?.reason, "updated_at changed"); + assert.equal(fetchCount, 1); +}); + function sourceRevisionForTest(title: string): string { return createHash("sha256") .update( @@ -547,6 +591,7 @@ test("apply-decisions retries transient duplicate PR coverage proof failures", ( lowSignalCloseReport({ number: 353, title: "Provider route fallback", + pull_head_sha: "head-sha", close_reason: "duplicate_or_superseded", work_cluster_refs: JSON.stringify([ "Superseded by https://github.com/openclaw/openclaw/pull/400", diff --git a/test/apply-pr-promotion.test.ts b/test/apply-pr-promotion.test.ts index d96d704337f..47c12fb7cd8 100644 --- a/test/apply-pr-promotion.test.ts +++ b/test/apply-pr-promotion.test.ts @@ -508,6 +508,7 @@ test("apply-decisions promotes old F-rated stale PRs with low-signal close seman mkdirSync(itemsDir, { recursive: true }); mkdirSync(plansDir, { recursive: true }); const staleReport = stalePullRequestReport({ + pull_head_sha: "head-sha", work_cluster_refs: JSON.stringify(["Related discussion in #400"]), }).replace( "## Summary\n\nThe dashboard has queue_fix_pr candidates but no generated coding plan.", @@ -587,6 +588,9 @@ test("apply-decisions promotes old F-rated stale PRs with low-signal close seman assert.doesNotMatch(promoted, /## Summary\n\nKeep open:/); const closeAppliedBody = readFileSync(closeAppliedBodyLogPath, "utf8"); assert.match(closeAppliedBody, /Close reason: low-signal unmergeable PR\./); + assert.match(closeAppliedBody, /recorded closeout evidence/); + assert.match(closeAppliedBody, /Review evidence: \[durable ClawSweeper review\]/); + assert.doesNotMatch(closeAppliedBody, /Implementation evidence:/); assert.doesNotMatch(closeAppliedBody, /Keep open:/); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/test/apply-runtime-budget.test.ts b/test/apply-runtime-budget.test.ts index 6972e61d6bb..f7b26e2582b 100644 --- a/test/apply-runtime-budget.test.ts +++ b/test/apply-runtime-budget.test.ts @@ -525,6 +525,7 @@ test("apply-decisions records a successful close before yielding after it", () = lowSignalCloseReport({ number: 727, title: "Provider route fallback", + pull_head_sha: "head-sha", close_reason: "duplicate_or_superseded", work_cluster_refs: JSON.stringify([ "Superseded by https://github.com/openclaw/openclaw/pull/400", diff --git a/test/apply-same-author-pair-close.test.ts b/test/apply-same-author-pair-close.test.ts index 1ccfef3ee76..e1ee9d06d90 100644 --- a/test/apply-same-author-pair-close.test.ts +++ b/test/apply-same-author-pair-close.test.ts @@ -12,6 +12,7 @@ import { runApplyDecisionsForTest, tmpPrefix, withMockCodexProof, + verifiedImplementationPullRequestReport, withMockGh, } from "./helpers.ts"; @@ -25,7 +26,7 @@ test("apply-decisions starts same-author pair closes from the PR side", () => { mkdirSync(itemsDir, { recursive: true }); mkdirSync(plansDir, { recursive: true }); const issueSynced = reportWithSyncedReviewComment( - implementedCloseReport({ + verifiedImplementationPullRequestReport({ repository: "openclaw/openclaw", number: 320, type: "issue", @@ -37,7 +38,7 @@ test("apply-decisions starts same-author pair closes from the PR side", () => { "implemented_on_main", ); const pullSynced = reportWithSyncedReviewComment( - implementedCloseReport({ + verifiedImplementationPullRequestReport({ repository: "openclaw/openclaw", number: 321, type: "pull_request", @@ -48,21 +49,69 @@ test("apply-decisions starts same-author pair closes from the PR side", () => { 321, "implemented_on_main", ); + const laterPullSynced = reportWithSyncedReviewComment( + verifiedImplementationPullRequestReport({ + repository: "openclaw/openclaw", + number: 322, + type: "pull_request", + title: "Later paired PR", + author: "reporter", + action_taken: "skipped_same_author_pair", + }), + 322, + "implemented_on_main", + ); + for (const synced of [issueSynced, pullSynced, laterPullSynced]) { + synced.report = synced.report.replaceAll( + "github.com/openclaw/clawsweeper/issues/", + "github.com/openclaw/openclaw/issues/", + ); + } writeFileSync(join(itemsDir, "320.md"), issueSynced.report, "utf8"); writeFileSync(join(itemsDir, "321.md"), pullSynced.report, "utf8"); + writeFileSync(join(itemsDir, "322.md"), laterPullSynced.report, "utf8"); const ghMock = ` const comments = { 320: ${JSON.stringify(issueSynced.comment)}, - 321: ${JSON.stringify(pullSynced.comment)} + 321: ${JSON.stringify(pullSynced.comment)}, + 322: ${JSON.stringify(laterPullSynced.comment)} }; const rawArgs = process.argv.slice(2); const args = rawArgs[0] === "--repo" ? rawArgs.slice(2) : rawArgs; const path = args[1] || ""; const issueNumber = (path.match(/\\/issues\\/(\\d+)/) || [])[1]; -if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/(320|321)\\/timeline(?:\\?|$)/.test(args[2] || "")) { +if (args[0] === "api" && args[1] === "graphql") { + const closingReferenceQuery = args.some((argument) => argument.includes("closingIssuesReferences")); + const repository = closingReferenceQuery + ? { pullRequest: { closingIssuesReferences: { nodes: [{ number: 320, state: "OPEN", repository: { nameWithOwner: "openclaw/openclaw" } }] } } } + : { issue: { state: "CLOSED", timelineItems: { nodes: [{ __typename: "ClosedEvent", createdAt: "2026-05-01T02:00:00Z", closer: { __typename: "PullRequest", number: 900, repository: { nameWithOwner: "openclaw/openclaw" } } }] } } }; + console.log(JSON.stringify({ data: { repository } })); + process.exit(0); +} +if (args[0] === "api" && path === "repos/openclaw/openclaw") { + console.log(JSON.stringify({ default_branch: "main" })); + process.exit(0); +} +if (args[0] === "api" && /\\/compare\\/[^/]+\\.\\.\\.main$/.test(path)) { + console.log(JSON.stringify({ status: "ahead" })); + process.exit(0); +} +if (args[0] === "api" && /\\/pulls\\/900$/.test(path)) { + console.log(JSON.stringify({ number: 900, html_url: "https://github.com/openclaw/openclaw/pull/900", title: "Current implementation", merged: true, merged_at: "2026-05-01T02:00:00Z", merge_commit_sha: "fix-sha", head: { sha: "fix-sha" }, base: { ref: "main" } })); + process.exit(0); +} +if (args[0] === "api" && /\\/pulls\\/(321|322)$/.test(path) && args.includes("--jq")) { + console.log(JSON.stringify({ body: "Fixes #320." })); + process.exit(0); +} +if (args[0] === "api" && path.startsWith("search/issues?")) { + console.log(JSON.stringify({ items: [] })); + process.exit(0); +} +if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/(320|321|322)\\/timeline(?:\\?|$)/.test(args[2] || "")) { console.log("HTTP/2 200\\n\\n[]"); -} else if (args[0] === "api" && /\\/issues\\/(320|321)\\/comments(?:\\?|$)/.test(path)) { +} else if (args[0] === "api" && /\\/issues\\/(320|321|322)\\/comments(?:\\?|$)/.test(path)) { const number = Number(issueNumber); console.log(JSON.stringify([[{ id: 9000 + number, @@ -72,7 +121,7 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/(320|321)\\/timeline(? user: { login: "clawsweeper[bot]" }, body: comments[number] }]])); -} else if (args[0] === "api" && /\\/issues\\/(320|321)\\/timeline(?:\\?|$)/.test(path)) { +} else if (args[0] === "api" && /\\/issues\\/(320|321|322)\\/timeline(?:\\?|$)/.test(path)) { console.log(JSON.stringify([[]])); } else if (args[0] === "api" && /\\/issues\\/320$/.test(path)) { console.log(JSON.stringify({ @@ -92,11 +141,12 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/(320|321)\\/timeline(? comments: 1, pull_request: null })); -} else if (args[0] === "api" && /\\/issues\\/321$/.test(path)) { +} else if (args[0] === "api" && /\\/issues\\/(321|322)$/.test(path)) { + const number = Number(issueNumber); console.log(JSON.stringify({ - number: 321, - title: "Paired PR", - html_url: "https://github.com/openclaw/openclaw/pull/321", + number, + title: number === 321 ? "Paired PR" : "Later paired PR", + html_url: "https://github.com/openclaw/openclaw/pull/" + number, body: "Fixes #320.", created_at: "2026-05-01T00:00:00Z", updated_at: "2026-05-01T00:00:00Z", @@ -108,15 +158,16 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/(320|321)\\/timeline(? user: { login: "reporter" }, labels: [], comments: 1, - pull_request: { url: "https://api.github.com/repos/openclaw/openclaw/pulls/321" } + pull_request: { url: "https://api.github.com/repos/openclaw/openclaw/pulls/" + number } })); } else if (args[0] === "issue" && args[1] === "view") { console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); -} else if (args[0] === "api" && /\\/pulls\\/321$/.test(path)) { +} else if (args[0] === "api" && /\\/pulls\\/(321|322)$/.test(path)) { + const number = Number((path.match(/\\/pulls\\/(\\d+)/) || [])[1]); console.log(JSON.stringify({ - number: 321, - title: "Paired PR", - html_url: "https://github.com/openclaw/openclaw/pull/321", + number, + title: number === 321 ? "Paired PR" : "Later paired PR", + html_url: "https://github.com/openclaw/openclaw/pull/" + number, state: "open", changed_files: 0, commits: 0, @@ -126,7 +177,7 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/(320|321)\\/timeline(? base: { sha: "base-sha", ref: "main", repo: { full_name: "openclaw/openclaw" } }, user: { login: "reporter" } })); -} else if (args[0] === "api" && /\\/pulls\\/321\\/(files|commits|comments|reviews)(?:\\?|$)/.test(path)) { +} else if (args[0] === "api" && /\\/pulls\\/(321|322)\\/(files|commits|comments|reviews)(?:\\?|$)/.test(path)) { console.log(JSON.stringify([[]])); } else if (args[0] === "label" || args[0] === "issue") { console.log(""); @@ -147,10 +198,8 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/(320|321)\\/timeline(? "--dry-run", "--apply-kind", "all", - "--item-numbers", - "321", "--processed-limit", - "4", + "2", ], }); }); @@ -163,6 +212,7 @@ if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/(320|321)\\/timeline(? report.filter((entry) => entry.action === "closed").map((entry) => entry.number), [321, 320], ); + assert.equal(report.length, 2, JSON.stringify(report, null, 2)); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index c26f9d67346..7277ac603b0 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -165,7 +165,7 @@ 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] || ""; +const path = args[1] === "-i" ? args[2] || "" : args[1] || ""; if (args[0] === "api" && /\\/issues\\/321\\/comments(?:\\?|$)/.test(path)) { console.log(JSON.stringify([[]])); } else if (args[0] === "api" && /\\/issues\\/321$/.test(path)) { @@ -571,7 +571,7 @@ 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 path = args[1] === "-i" ? args[2] || "" : args[1] || ""; const commentMatch = path.match(/\\/issues\\/(\\d+)\\/comments(?:\\?|$)/); const issueMatch = path.match(/\\/issues\\/(\\d+)$/); if (args[0] === "api" && commentMatch) { @@ -732,7 +732,7 @@ 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 path = args[1] === "-i" ? args[2] || "" : args[1] || ""; const commentMatch = path.match(/\\/issues\\/(\\d+)\\/comments(?:\\?|$)/); const issueMatch = path.match(/\\/issues\\/(\\d+)$/); if (args[0] === "api" && /\\/issues\\/comments\\/\\d+$/.test(path)) { @@ -740,6 +740,8 @@ if (args[0] === "api" && /\\/issues\\/comments\\/\\d+$/.test(path)) { const body = JSON.parse(readFileSync(inputPath, "utf8")).body; appendFileSync(logPath, JSON.stringify(["comment-patch", body]) + "\\n"); console.log(JSON.stringify({ id: 9000 + 321, html_url: "https://github.com/openclaw/clawsweeper/issues/321#issuecomment-9321", updated_at: "2026-05-01T01:02:00Z", body })); +} else if (args[0] === "api" && /\\/issues\\/\\d+\\/timeline(?:\\?|$)/.test(path)) { + console.log("HTTP/2 200\\n\\n[]"); } else if (args[0] === "api" && commentMatch) { const number = Number(commentMatch[1]); const body = comments[number]; @@ -837,7 +839,7 @@ if (args[0] === "api" && /\\/issues\\/comments\\/\\d+$/.test(path)) { (args[1] ?? "").includes("/issues/321/comments") && args.includes("--paginate"), ); - assert.equal(postMutationReviewCommentFetches.length, 0); + assert.equal(postMutationReviewCommentFetches.length, 1); assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ { number: 321, diff --git a/test/close-reasons.test.ts b/test/close-reasons.test.ts index 759d01d66a0..a61142761f6 100644 --- a/test/close-reasons.test.ts +++ b/test/close-reasons.test.ts @@ -641,6 +641,16 @@ test("implemented-on-main closes require fix provenance", () => { summary: "Current main implements the useful part of this older PR.", closeComment: "Closing this older PR because current main already covers the useful change and the remaining branch diff is obsolete.", + fixedPullRequest: { + repo: "openclaw/openclaw", + number: 900, + url: "https://github.com/openclaw/openclaw/pull/900", + title: "Current implementation", + mergedAt: "2026-04-28T12:00:00Z", + sha: "abcdef1234567890", + confidence: "high", + source: "GitHub linked-issue current closing PR", + }, }), ); assert.equal(mostlyImplementedPr.ok, true); diff --git a/test/fixed-sha-pull-resolution.test.ts b/test/fixed-sha-pull-resolution.test.ts index 57451dfb4c4..aa98bd3dd6e 100644 --- a/test/fixed-sha-pull-resolution.test.ts +++ b/test/fixed-sha-pull-resolution.test.ts @@ -1,10 +1,42 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createStatusContext } from "../dist/clawsweeper-status-context.js"; +import { + createStatusContext, + linkedIssueNumbersForPullRequestBody, +} from "../dist/clawsweeper-status-context.js"; +import { GitHubRateLimitError } from "../dist/github-retry.js"; import { closeDecision, item, reportFrontMatter } from "./helpers.ts"; -function statusContextWithCalls() { +class TestGitHubRuntimeBudgetError extends Error { + readonly reason: string; + + constructor(reason: string) { + super(reason); + this.name = "GitHubRuntimeBudgetError"; + this.reason = reason; + } +} + +function statusContextWithCalls( + defaultBranch = "main", + options: { + rateLimitOnApplyRead?: boolean; + rateLimitOnIssueRead?: boolean; + rateLimitOnContainmentRead?: boolean; + runtimeBudgetOnApplyRead?: boolean; + runtimeBudgetOnIssueRead?: boolean; + runtimeBudgetOnContainmentRead?: boolean; + freshApplyBody?: boolean; + mergeCommitReachable?: boolean; + linkedIssueCloserNumber?: number; + linkedIssueOpen?: boolean; + canonicalClosingIssueNumber?: number; + canonicalClosingIssueMissing?: boolean; + canonicalClosingIssueOpen?: boolean; + linkedIssueGenericCrossReferenceOnly?: boolean; + } = {}, +) { const calls: string[] = []; const recentPulls = [ pull(701, "cold-list-a", 101), @@ -34,8 +66,93 @@ function statusContextWithCalls() { const ghJson = (args: string[]): T => { const path = args[1] ?? ""; calls.push(path); - if (path === "repos/openclaw/openclaw") return { default_branch: "main" } as T; + if (path === "graphql") { + if (options.runtimeBudgetOnIssueRead) + throw new TestGitHubRuntimeBudgetError("runtime budget exhausted"); + if (options.rateLimitOnIssueRead) throw new GitHubRateLimitError("API rate limit exceeded"); + if (args.some((argument) => argument.includes("closingIssuesReferences"))) { + return { + data: { + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: + options.canonicalClosingIssueMissing || + options.linkedIssueGenericCrossReferenceOnly + ? [] + : [ + { + number: options.canonicalClosingIssueNumber ?? 456, + state: options.canonicalClosingIssueOpen === false ? "CLOSED" : "OPEN", + repository: { nameWithOwner: "openclaw/openclaw" }, + }, + ], + }, + ...(options.linkedIssueGenericCrossReferenceOnly + ? { + timelineItems: { + nodes: [ + { + __typename: "CrossReferencedEvent", + source: { + __typename: "PullRequest", + number: 900, + repository: { nameWithOwner: "openclaw/openclaw" }, + }, + }, + ], + }, + } + : {}), + }, + }, + }, + } as T; + } + return { + data: { + repository: { + issue: { + state: options.linkedIssueOpen ? "OPEN" : "CLOSED", + timelineItems: { + nodes: [ + { + __typename: "ClosedEvent", + createdAt: "2026-08-19T12:00:00Z", + closer: { + __typename: "PullRequest", + number: options.linkedIssueCloserNumber ?? 900, + repository: { nameWithOwner: "openclaw/openclaw" }, + }, + }, + ], + }, + }, + }, + }, + } as T; + } + if (path === "repos/openclaw/openclaw") return { default_branch: defaultBranch } as T; if (path.startsWith("repos/openclaw/openclaw/pulls?")) return recentPulls as T; + if (path === "repos/openclaw/openclaw/pulls/123") { + if (options.runtimeBudgetOnApplyRead) + throw new TestGitHubRuntimeBudgetError("runtime budget exhausted"); + if (options.rateLimitOnApplyRead) throw new GitHubRateLimitError("API rate limit exceeded"); + return { body: options.freshApplyBody ? "Fixes #456" : "No longer linked" } as T; + } + if (path === "repos/openclaw/openclaw/pulls/900") { + return { ...pull(900, "current", 456), base: { ref: defaultBranch } } as T; + } + if (path === "repos/openclaw/openclaw/pulls/901") { + return { ...pull(901, "other-current", 456), base: { ref: defaultBranch } } as T; + } + if (path.startsWith("repos/openclaw/openclaw/compare/current...")) { + if (options.runtimeBudgetOnContainmentRead) + throw new TestGitHubRuntimeBudgetError("runtime budget exhausted"); + if (options.rateLimitOnContainmentRead) + throw new GitHubRateLimitError("API rate limit exceeded"); + return { status: options.mergeCommitReachable === false ? "diverged" : "ahead" } as T; + } const fallback = path.match(/^repos\/openclaw\/openclaw\/commits\/([^/]+)\/pulls$/); if (fallback?.[1]) return (fallbackPulls.get(fallback[1]) ?? []) as T; const commit = path.match(/^repos\/openclaw\/openclaw\/commits\/([^/]+)$/); @@ -54,6 +171,7 @@ function statusContextWithCalls() { sweepStatusPath: () => "", markdownRepository: () => "openclaw/openclaw", ghJson, + GitHubRuntimeBudgetError: TestGitHubRuntimeBudgetError, asRecord: (value) => value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -104,6 +222,31 @@ function persistedReview(issueNumber: number, fixedSha: string, pullNumber: numb }); } +test("implementation provenance ignores HTML-commented issue references", () => { + assert.equal( + linkedIssueNumbersForPullRequestBody("", "openclaw/openclaw"), + null, + ); + assert.deepEqual( + linkedIssueNumbersForPullRequestBody( + "\nFixes #456", + "openclaw/openclaw", + ), + [456], + ); + assert.deepEqual( + linkedIssueNumbersForPullRequestBody("Fixes #456 ", "openclaw/openclaw"), + [456], + ); + assert.deepEqual( + linkedIssueNumbersForPullRequestBody( + "```html\n\nFixes #118669", + "openclaw/openclaw", + ), + [118669], + ); +}); + +test("PR implementation provenance caps linked issue references", () => { + assert.deepEqual( + linkedIssueNumbersForImplementationProvenance( + "Fixes #1\nFixes #2\nFixes #3\nFixes #4\nFixes #5", + "openclaw/openclaw", + ), + [1, 2, 3, 4, 5], + ); + assert.equal( + linkedIssueNumbersForImplementationProvenance( + "Fixes #1\nFixes #2\nFixes #3\nFixes #4\nFixes #5\nFixes #6", + "openclaw/openclaw", + ), + null, + ); +}); + +test("PR implementation provenance accepts only the current GitHub issue-closing PR", () => { + assert.equal( + currentClosingPullRequestReferenceFromIssueTimeline({ + data: { + repository: { + issue: { + state: "CLOSED", + timelineItems: { + nodes: [ + { + __typename: "ClosedEvent", + createdAt: "2026-08-19T12:00:00Z", + closer: { __typename: "PullRequest", number: 125023 }, + }, + { __typename: "ReopenedEvent", createdAt: "2026-08-19T12:01:00Z" }, + { __typename: "ClosedEvent", createdAt: "2026-08-19T12:02:00Z", closer: null }, + ], + }, + }, + }, + }, + }), + null, + ); + assert.deepEqual( + currentClosingPullRequestReferenceFromIssueTimeline({ + data: { + repository: { + issue: { + state: "CLOSED", + timelineItems: { + nodes: [ + { __typename: "ReopenedEvent", createdAt: "2026-08-19T12:01:00Z" }, + { + __typename: "ClosedEvent", + createdAt: "2026-08-19T12:02:00Z", + closer: { __typename: "PullRequest", number: 125023 }, + }, + ], + }, + }, + }, + }, + }), + { __typename: "PullRequest", number: 125023 }, + ); +}); + test("commit PR lookup selects the newest merged pull request", () => { const fixedPullRequest = fixedPullRequestFromCommitPullsForTest( [ @@ -745,7 +955,7 @@ test("skill-only OpenClaw PRs can close through ClawHub with upload guidance", ( assert.match(action.closeComment, /installable ClawHub package/); }); -test("ClawHub policy allows main-implemented issue and PR close proposals", () => { +test("ClawHub policy requires verified fixing provenance before main-implemented PR closure", () => { const implementedPr = validateCloseDecision( item({ repo: "openclaw/clawhub", @@ -754,7 +964,8 @@ test("ClawHub policy allows main-implemented issue and PR close proposals", () = }), closeDecision(), ); - assert.equal(implementedPr.ok, true); + assert.equal(implementedPr.ok, false); + assert.equal(implementedPr.actionTaken, "skipped_invalid_decision"); const implementedIssue = validateCloseDecision( item({ @@ -778,7 +989,7 @@ test("ClawHub policy allows main-implemented issue and PR close proposals", () = assert.equal(nonImplementedPr.actionTaken, "skipped_invalid_decision"); }); -test("ClawSweeper policy allows self implemented-on-main issue and PR close proposals", () => { +test("ClawSweeper policy requires verified fixing provenance before self PR closure", () => { const implementedPr = validateCloseDecision( item({ repo: "openclaw/clawsweeper", @@ -787,7 +998,8 @@ test("ClawSweeper policy allows self implemented-on-main issue and PR close prop }), closeDecision(), ); - assert.equal(implementedPr.ok, true); + assert.equal(implementedPr.ok, false); + assert.equal(implementedPr.actionTaken, "skipped_invalid_decision"); const implementedIssue = validateCloseDecision( item({