diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cb951041..ae1bcb4b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ checkpoint, and status-only commits are intentionally omitted. - Prevented completed apply and comment-sync runs from republishing stale hydrated records after their checkpoint commits, preserving concurrent apply bookkeeping while retaining a narrow final-status retry. - Persisted apply preselection reconciliation even when stricter policy or an empty candidate queue makes the run a no-op, publishing only changed record tuples, deferring concurrently updated tuples, and cleaning stale plans and decision packets for already-closed items. - Prevented overlapping exact-item reviews and stale verdict replay with owned, bounded PR-head and issue-source leases; tuple-bearing reports now enforce apply-time revision and durable-verdict CAS across label, comment, and close mutations, and failed exact reviews no longer publish event results. +- Prevented comment-only synchronization from replaying duplicate or superseded close verdicts after the linked canonical PR closes without merging. - Retried infrastructure-failed issue reviews against their exact source revision through bounded one-shot asynchronous dispatch, requeued source drift once, and preserved retry attempts in separate durable state so ambiguous timeouts cannot overwrite completed reviews. - Stopped later CI reruns from resetting PR inactivity clocks by anchoring head activity to the latest source-triggered workflow run associated with that pull request. - Prioritized ready close decisions and bounded PR close-coverage proofs before slow policy-gated candidates, kept default 20-item continuations shareable, and retried malformed successful GitHub JSON responses. diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 977ee76047..616be7acb2 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -309,6 +309,7 @@ type ActionTaken = | "kept_open" | "proposed_close" | "review_comment_synced" + | "corrected_stale_canonical_comment" | "skipped_comment_auth" | "skipped_locked_conversation" | "skipped_changed_since_review" @@ -320,6 +321,7 @@ type ActionTaken = | "skipped_protected_label" | "skipped_pr_close_coverage_proof" | "retry_pr_close_coverage_proof" + | "retry_stale_canonical_comment_sync" | "skipped_invalid_decision" | "skipped_missing_record" | "skipped_runtime_budget"; @@ -1710,6 +1712,7 @@ const CLOSED_STATE_PROBE_ACTIONS = new Set([ "skipped_open_closing_pr", "skipped_same_author_pair", "skipped_locked_conversation", + "retry_stale_canonical_comment_sync", ]); const REPRODUCTION_STATUSES = new Set([ "reproduced", @@ -14257,6 +14260,68 @@ function prCloseCoverageProofCandidateRefs(markdown: string, item: Item): PullRe return possiblePullRequestRefs.length === 1 ? possiblePullRequestRefs : []; } +function possibleCanonicalPullRequestRefsFromReport( + markdown: string, + item: Item, +): PullRequestRef[] { + if (item.kind !== "pull_request") return []; + const pendingCanonicalNumber = staleCanonicalPullRequestNumber(markdown); + if (pendingCanonicalNumber) { + return [{ number: pendingCanonicalNumber, kind: "pull_url" }]; + } + const structuredCanonicalRef = reportRootCauseCluster(markdown).canonicalRef; + if (structuredCanonicalRef) { + const parsed = parseGitHubItemRef(structuredCanonicalRef, "root_cause_cluster.canonicalRef"); + if (parsed.kind === "pull_request" && parsed.number !== item.number) { + return [{ number: parsed.number, kind: "pull_url" }]; + } + } + const linkedRefs = linkedPullRequestRefsFromReport(markdown, item.number); + const canonicalRefs = linkedRefs + .filter((ref) => linkedPullRequestHasSupersessionSignal(markdown, item.number, ref.number)) + .filter(linkedRefCanBePullRequest); + if (canonicalRefs.length > 0) return canonicalRefs; + if (frontMatterValue(markdown, "pr_close_coverage_proof_fallback_refs") === "false") return []; + const possiblePullRequestRefs = linkedRefs.filter(linkedRefCanBePullRequest); + return possiblePullRequestRefs.length === 1 ? possiblePullRequestRefs : []; +} + +interface CanonicalPullRequestCommentSyncBlock { + kind: "closed_unmerged" | "unreadable"; + number: number; + reason: string; +} + +function canonicalPullRequestCommentSyncBlock( + markdown: string, + item: Item, +): CanonicalPullRequestCommentSyncBlock | null { + for (const ref of possibleCanonicalPullRequestRefsFromReport(markdown, item)) { + const { number } = ref; + try { + const pull = asRecord(ghJson(["api", `repos/${targetRepo()}/pulls/${number}`])); + const state = stringOrUndefined(pull.state)?.toLowerCase() ?? ""; + const mergedAt = stringOrUndefined(pull.merged_at) ?? null; + if (state === "closed" && !mergedAt) { + return { + kind: "closed_unmerged", + number, + reason: `linked canonical PR #${number} is closed and unmerged; refusing duplicate/superseded auto-close`, + }; + } + } catch (error) { + if (error instanceof GitHubRuntimeBudgetError) throw error; + if (ref.kind !== "pull_url" && shorthandRefIsIssue(number)) continue; + return { + kind: "unreadable", + number, + reason: `linked canonical PR #${number} could not be read; refusing duplicate/superseded comment sync`, + }; + } + } + return null; +} + interface PrCloseCoverageProofGateBlock { actionTaken: ActionTaken; reason: string; @@ -14582,6 +14647,135 @@ function applyPrCloseCoverageProofBlockedReport( ); } +function applyClosedUnmergedCanonicalBlockedReport( + markdown: string, + block: PrCloseCoverageProofGateBlock, + canonicalNumber: number, +): string { + const rootCauseCluster = defaultRootCauseCluster(); + const nextStep = + "Run a fresh review against current main and the current related PR state before choosing a landing or close path."; + const rating: PrRating = { + ...reportPrRating(markdown), + summary: + "The prior duplicate or superseded close path is no longer valid; retain the existing readiness tiers until a fresh review.", + nextSteps: [nextStep], + }; + let next = replaceFrontMatterValue(markdown, "decision", "keep_open"); + next = replaceFrontMatterValue(next, "close_reason", "none"); + next = replaceFrontMatterValue(next, "confidence", "low"); + next = replaceFrontMatterValue(next, "action_taken", "retry_stale_canonical_comment_sync"); + next = replaceFrontMatterValue( + next, + "stale_canonical_pull_request_number", + String(canonicalNumber), + ); + next = replaceFrontMatterValue(next, "close_comment_sha256", "none"); + next = replaceFrontMatterValue(next, "work_candidate", "none"); + next = replaceFrontMatterValue(next, "work_confidence", "low"); + next = replaceFrontMatterValue(next, "work_priority", "low"); + next = replaceFrontMatterValue(next, "work_status", "none"); + next = replaceFrontMatterValue(next, "work_reason_sha256", sha256(nextStep)); + next = replaceFrontMatterValue(next, "work_cluster_refs", "[]"); + next = replaceFrontMatterValue(next, "work_validation", "[]"); + next = replaceFrontMatterValue(next, "work_likely_files", "[]"); + next = replaceFrontMatterValue(next, "merge_risk_options", "[]"); + next = replaceFrontMatterValue(next, "label_justifications", "[]"); + next = replaceFrontMatterValue(next, "review_metrics", "[]"); + next = replaceFrontMatterValue(next, "root_cause_cluster", JSON.stringify(rootCauseCluster)); + next = replaceSectionValue( + next, + "Decision", + [ + "Keep open: none", + "", + "Confidence: low", + "", + "Action taken: retry_stale_canonical_comment_sync", + ].join("\n"), + ); + next = replaceSectionValue( + next, + REVIEW_SECTIONS.summary, + `Keep this PR open. ${sentence(block.reason)}`, + ); + next = replaceSectionValue(next, REVIEW_SECTIONS.bestSolution, nextStep); + next = replaceSectionValue( + next, + REVIEW_SECTIONS.solutionAssessment, + "Needs a fresh assessment because the prior canonical PR is closed without merge.", + ); + next = replaceSectionValue( + next, + REVIEW_SECTIONS.rootCauseCluster, + renderRootCauseClusterAssessmentReportSection(rootCauseCluster), + ); + next = replaceSectionValue( + next, + REVIEW_SECTIONS.prRating, + renderPrRatingAssessmentReportSection(rating, reportRealBehaviorProof(markdown)), + ); + next = replaceSectionValue( + next, + REVIEW_SECTIONS.workCandidate, + [ + "Candidate: none", + "", + "Confidence: low", + "", + "Priority: low", + "", + "Status: none", + "", + `Reason: ${nextStep}`, + ].join("\n"), + ); + next = replaceSectionValue( + next, + REVIEW_SECTIONS.evidence, + `- **live canonical state:** ${block.reason}`, + ); + next = replaceSectionValue(next, REVIEW_SECTIONS.likelyOwners, "- none"); + next = replaceSectionValue( + next, + REVIEW_SECTIONS.risks, + "- The current branch and related work need a fresh review before merge or closure.", + ); + next = replaceSectionValue(next, REVIEW_SECTIONS.closeComment, "_No close comment posted._"); + return replaceSectionValue( + next, + PR_CLOSE_COVERAGE_PROOF_SECTION, + ["Decision: keep_open", `Reason: ${block.reason}`].join("\n"), + ); +} + +function staleCanonicalCommentSyncPendingReason(markdown: string): string | null { + if (frontMatterValue(markdown, "action_taken") !== "retry_stale_canonical_comment_sync") { + return null; + } + return ( + sectionLineValue(sectionValue(markdown, PR_CLOSE_COVERAGE_PROOF_SECTION), "Reason") ?? + "stale canonical close comment correction remains pending" + ); +} + +function staleCanonicalPullRequestNumber(markdown: string): number | null { + const number = Number(frontMatterValue(markdown, "stale_canonical_pull_request_number")); + return Number.isInteger(number) && number > 0 ? number : null; +} + +function completeStaleCanonicalCommentSyncReport(markdown: string): string { + let next = replaceFrontMatterValue(markdown, "action_taken", "corrected_stale_canonical_comment"); + next = replaceFrontMatterValue(next, "stale_canonical_pull_request_number", "none"); + const decision = sectionValue(next, "Decision"); + if (!decision) return next; + return replaceSectionValue( + next, + "Decision", + decision.replace(/^Action taken: .*$/m, "Action taken: corrected_stale_canonical_comment"), + ); +} + function recommendedPauseOrCloseOption(markdown: string): MergeRiskOption | null { return ( mergeRiskOptionsFromReport(markdown).find( @@ -16893,6 +17087,72 @@ function durableReviewVersion( return null; } +function reviewCommentHasCloseVerdictForCanonical( + comment: Record | undefined, + number: number, + reason: CloseReason, + canonicalNumber: number, +): boolean { + if (!canPatchReviewComment(comment)) return false; + const body = commentBody(comment); + if (!body) return false; + const reviewMarker = reviewCommentMarker(number); + const reviewMarkerIndex = body.lastIndexOf(reviewMarker); + if (reviewMarkerIndex < 0) return false; + if (body.slice(reviewMarkerIndex + reviewMarker.length).trim() !== "") return false; + const markerComments = trailingHtmlComments(body.slice(0, reviewMarkerIndex)); + const verdictPattern = /^$/; + let latestVerdict: { verdict: string; reason: string | undefined } | undefined; + for (const markerComment of markerComments) { + const match = markerComment.match(verdictPattern); + if (!match) continue; + const attributes = match[2] ?? ""; + if (!new RegExp(`\\bitem=${number}\\b`).test(attributes)) continue; + latestVerdict = { + verdict: match[1] ?? "", + reason: attributes.match(/\breason=([^\s>]+)/)?.[1], + }; + } + const supersessionSignal = + /\b(supersed(?:e|ed|es|ing)|replace(?:s|d|ment)?|duplicate|duplicated|canonical|covered by|landed in)\b/i; + const signaledRefs = linkedPullRequestRefsFromText(body, number).filter((ref) => + linkedPullRequestSignalContextsFromText(body, number, ref.number).some((context) => + supersessionSignal.test(context), + ), + ); + const explicitCanonicalRefs = [...body.matchAll(/^Canonical:\s+(\S+)\s*$/gm)]; + let commentCanonicalNumber: number | undefined; + if (explicitCanonicalRefs.length > 0) { + const canonicalNumbers = new Set(); + for (const match of explicitCanonicalRefs) { + try { + const parsed = parseGitHubItemRef( + match[1] ?? "", + "durable review comment root-cause canonical", + ); + // The explicit public canonical is authoritative; never reinterpret a member PR as it. + if (parsed.kind !== "pull_request") return false; + if (normalizeRepo(parsed.repo) !== normalizeRepo(targetRepo())) return false; + canonicalNumbers.add(parsed.number); + } catch { + return false; + } + } + if (canonicalNumbers.size !== 1) return false; + commentCanonicalNumber = [...canonicalNumbers][0]; + } + if (explicitCanonicalRefs.length === 0) { + const signaledCanonicalNumbers = new Set(signaledRefs.map((ref) => ref.number)); + if (signaledCanonicalNumbers.size !== 1) return false; + commentCanonicalNumber = [...signaledCanonicalNumbers][0]; + } + return ( + latestVerdict?.verdict === "close" && + latestVerdict.reason === reason && + commentCanonicalNumber === canonicalNumber + ); +} + function staleReviewCommentSyncReason( markdown: string, existingReviewComment: Record | undefined, @@ -17679,25 +17939,28 @@ function renderRealBehaviorProofReportSection(decision: Decision): string { ].join("\n"); } -function renderPrRatingReportSection(decision: Decision): string { - const nextSteps = decision.prRating.nextSteps.length - ? decision.prRating.nextSteps.map((step) => `- ${step}`).join("\n") +function renderPrRatingAssessmentReportSection( + rating: PrRating, + realBehaviorProof: RealBehaviorProof, +): string { + const nextSteps = rating.nextSteps.length + ? rating.nextSteps.map((step) => `- ${step}`).join("\n") : "- none"; - const shiny = hasShinyProof(decision.realBehaviorProof) ? " ✨" : ""; + const shiny = hasShinyProof(realBehaviorProof) ? " ✨" : ""; return [ - `Overall tier: ${decision.prRating.overallTier}`, + `Overall tier: ${rating.overallTier}`, "", - `Proof tier: ${decision.prRating.proofTier}`, + `Proof tier: ${rating.proofTier}`, "", - `Patch tier: ${decision.prRating.patchTier}`, + `Patch tier: ${rating.patchTier}`, "", - `Overall label: ${themedRatingName(decision.prRating.overallTier)}`, + `Overall label: ${themedRatingName(rating.overallTier)}`, "", - `Proof label: ${themedRatingName(decision.prRating.proofTier)}${shiny}`, + `Proof label: ${themedRatingName(rating.proofTier)}${shiny}`, "", - `Patch label: ${themedRatingName(decision.prRating.patchTier)}`, + `Patch label: ${themedRatingName(rating.patchTier)}`, "", - `Summary: ${sentence(decision.prRating.summary)}`, + `Summary: ${sentence(rating.summary)}`, "", "Next rank-up steps:", "", @@ -17705,6 +17968,10 @@ function renderPrRatingReportSection(decision: Decision): string { ].join("\n"); } +function renderPrRatingReportSection(decision: Decision): string { + return renderPrRatingAssessmentReportSection(decision.prRating, decision.realBehaviorProof); +} + function renderTelegramVisibleProofReportSection(decision: Decision): string { return [ `Status: ${decision.telegramVisibleProof.status}`, @@ -17733,28 +18000,34 @@ function renderFeatureShowcaseReportSection(decision: Decision): string { ].join("\n"); } -function renderRootCauseClusterReportSection(decision: Decision): string { - const members = decision.rootCauseCluster.members.length - ? decision.rootCauseCluster.members +function renderRootCauseClusterAssessmentReportSection( + rootCauseCluster: RootCauseClusterAssessment, +): string { + const members = rootCauseCluster.members.length + ? rootCauseCluster.members .map( (member) => `- **${member.relationship}:** ${member.ref}\n - reason: ${member.reason}`, ) .join("\n") : "- none"; return [ - `Current item relationship: ${decision.rootCauseCluster.currentItemRelationship}`, + `Current item relationship: ${rootCauseCluster.currentItemRelationship}`, "", - `Confidence: ${decision.rootCauseCluster.confidence}`, + `Confidence: ${rootCauseCluster.confidence}`, "", - `Canonical ref: ${decision.rootCauseCluster.canonicalRef ?? "none"}`, + `Canonical ref: ${rootCauseCluster.canonicalRef ?? "none"}`, "", - `Summary: ${sentence(decision.rootCauseCluster.summary)}`, + `Summary: ${sentence(rootCauseCluster.summary)}`, "", "Members:", members, ].join("\n"); } +function renderRootCauseClusterReportSection(decision: Decision): string { + return renderRootCauseClusterAssessmentReportSection(decision.rootCauseCluster); +} + function renderAgentsPolicyStatusReportSection(decision: Decision): string { return [ `Status: ${decision.agentsPolicyStatus.status}`, @@ -19649,6 +19922,11 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg const decision = frontMatterValue(markdown, "decision"); let closeReason = frontMatterValue(markdown, "close_reason") as CloseReason | undefined; const action = frontMatterValue(markdown, "action_taken"); + const changedSinceReviewDuplicateCommentRepair = + action === "skipped_changed_since_review" && + decision === "close" && + closeReason === "duplicate_or_superseded"; + let staleCanonicalCommentSyncPending = action === "retry_stale_canonical_comment_sync"; let storedHash = frontMatterValue(markdown, "item_snapshot_hash"); let storedUpdatedAt = frontMatterValue(markdown, "item_updated_at"); const storedAuthorAssociation = frontMatterValue(markdown, "author_association"); @@ -19694,11 +19972,15 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg markdown = replaceFrontMatterValue(markdown, "action_taken", actionTaken); return recordApplySkipped(actionTaken, reason); }; - const markLabelSyncAuthSkipped = (labelKind: string): boolean => - markApplySkipped( - "kept_open", - `GitHub rejected ${labelKind} label sync with Requires authentication`, - ); + const markLabelSyncAuthSkipped = (labelKind: string): boolean => { + const reason = `GitHub rejected ${labelKind} label sync with Requires authentication`; + return staleCanonicalCommentSyncPending + ? markApplySkipped( + "retry_stale_canonical_comment_sync", + `${reason}; stale canonical comment correction remains pending`, + ) + : markApplySkipped("kept_open", reason); + }; try { requiredMaintainerDecision = maintainerDecisionFromReport(markdown); } catch (error) { @@ -19782,6 +20064,7 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg let clawSweeperLabelsChanged = false; let issueAdvisoryLabelsChanged = false; const allowedSelfMutationUpdatedAts = new Set(); + let staleCanonicalClosedUnmergedValidated = false; const currentItemContext = (): ItemContext => { currentContext ??= collectItemContext(item, { fullTimelineForRelations: true }); return currentContext; @@ -19932,11 +20215,9 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg ) { return `apply mutation lease ${lease.commentId} is no longer the elected ${item.kind === "pull_request" ? "same-head" : "same-revision"} lease`; } - return staleReviewCommentSyncReason( + return canonicalBoundStaleReviewReason( markdownBeforeApplyDecisionMutations, refreshed.reviewComment, - number, - item.kind === "pull_request" ? currentItemContext() : undefined, ); } catch (error) { if (error instanceof GitHubRuntimeBudgetError) throw error; @@ -20003,19 +20284,128 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg return processedCount >= processedLimit; }; const recordReviewLeaseSkip = (reason: string, restoreOriginal = true): boolean => - recordReviewGuardSkip("kept_open", reason, restoreOriginal); + staleCanonicalCommentSyncPending + ? markApplySkipped( + "retry_stale_canonical_comment_sync", + `${reason}; stale canonical comment correction remains pending`, + ) + : recordReviewGuardSkip("kept_open", reason, restoreOriginal); const recordActiveReviewLeaseSkip = (expiresAt: string): boolean => recordReviewLeaseSkip( `${item.kind === "pull_request" ? "same-head" : "same-revision"} ClawSweeper review is active until ${expiresAt}`, ); - const refreshedReviewStaleReason = (comment: Record | undefined) => - staleReviewCommentSyncReason( - markdownBeforeApplyDecisionMutations, + let existingReviewComment: Record | undefined; + const pendingStaleCanonicalCommentReason = staleCanonicalCommentSyncPending + ? staleCanonicalCommentSyncPendingReason(markdown) + : null; + let closeBlockedForCommentSync: PrCloseCoverageProofGateBlock | null = + pendingStaleCanonicalCommentReason + ? { actionTaken: "kept_open", reason: pendingStaleCanonicalCommentReason } + : null; + let canonicalCommentSyncChecked = false; + const shouldCheckCanonicalCommentSync = (): boolean => + state === "open" && + (staleCanonicalCommentSyncPending || + (closeReason === "duplicate_or_superseded" && + (isCloseProposal || (decision === "close" && shouldProbeClosedState)))); + const applyCanonicalCommentSyncGuard = ( + forceRecheck = false, + ): { + skipCurrentItem: boolean; + stopApply: boolean; + } => { + if ((canonicalCommentSyncChecked && !forceRecheck) || !shouldCheckCanonicalCommentSync()) { + return { skipCurrentItem: false, stopApply: false }; + } + canonicalCommentSyncChecked = true; + staleCanonicalClosedUnmergedValidated = false; + const pendingCanonicalNumber = staleCanonicalCommentSyncPending + ? staleCanonicalPullRequestNumber(markdown) + : null; + if (staleCanonicalCommentSyncPending && pendingCanonicalNumber === null) { + const reason = + "pending stale canonical comment correction lacks its canonical PR identity; fresh review required"; + return { + skipCurrentItem: true, + stopApply: markApplySkipped("retry_stale_canonical_comment_sync", reason), + }; + } + const block = canonicalPullRequestCommentSyncBlock(markdown, item); + if (block?.kind === "unreadable") { + const actionTaken: ActionTaken = staleCanonicalCommentSyncPending + ? "retry_stale_canonical_comment_sync" + : "retry_pr_close_coverage_proof"; + return { + skipCurrentItem: true, + stopApply: staleCanonicalCommentSyncPending + ? markApplySkipped(actionTaken, block.reason) + : recordApplySkipped(actionTaken, block.reason), + }; + } + if (block?.kind === "closed_unmerged") { + staleCanonicalClosedUnmergedValidated = true; + closeBlockedForCommentSync = { + actionTaken: "kept_open", + reason: block.reason, + }; + markdown = applyClosedUnmergedCanonicalBlockedReport( + markdown, + closeBlockedForCommentSync, + block.number, + ); + staleCanonicalCommentSyncPending = true; + closeReason = "none"; + isCloseProposal = false; + } else if (staleCanonicalCommentSyncPending && pendingCanonicalNumber !== null) { + const reason = `linked canonical PR #${pendingCanonicalNumber} is no longer closed and unmerged; fresh review required before stale comment correction`; + return { + skipCurrentItem: true, + stopApply: markApplySkipped("retry_stale_canonical_comment_sync", reason), + }; + } + return { skipCurrentItem: false, stopApply: false }; + }; + const initialCanonicalCommentSyncGuard = applyCanonicalCommentSyncGuard(); + if (initialCanonicalCommentSyncGuard.stopApply) break; + if (initialCanonicalCommentSyncGuard.skipCurrentItem) continue; + const canonicalBoundStaleReviewReason = ( + sourceMarkdown: string, + comment: Record | undefined, + ): string | null => { + const staleReason = staleReviewCommentSyncReason( + sourceMarkdown, comment, number, item.kind === "pull_request" ? currentItemContext() : undefined, ); - let existingReviewComment: Record | undefined; + const pendingCanonicalNumber = staleCanonicalPullRequestNumber(markdown); + if (staleCanonicalClosedUnmergedValidated && pendingCanonicalNumber !== null) { + if ( + reviewCommentHasCloseVerdictForCanonical( + comment, + number, + "duplicate_or_superseded", + pendingCanonicalNumber, + ) + ) { + return null; + } + return ( + staleReason ?? + `live durable review comment is not bound to stored canonical PR #${pendingCanonicalNumber}; fresh review required before stale comment correction` + ); + } + return staleReason; + }; + const refreshedReviewStaleReason = (comment: Record | undefined) => + canonicalBoundStaleReviewReason(markdownBeforeApplyDecisionMutations, comment); + const recordRefreshedReviewStaleReason = (reason: string): boolean => + staleCanonicalCommentSyncPending + ? markApplySkipped( + "retry_stale_canonical_comment_sync", + `${reason}; stale canonical comment correction remains pending`, + ) + : recordReviewGuardSkip("skipped_stale_review_comment_sync", reason); const rememberSelfMutationUpdatedAt = (): void => { if (!dryRun) allowedSelfMutationUpdatedAts.add(fetchItem(number).item.updatedAt); }; @@ -20320,13 +20710,19 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg if (processedCount >= processedLimit) break; continue; } - if (state === "open" && !verifiedLocalCheckout) { + if (state === "open" && !verifiedLocalCheckout && !staleCanonicalCommentSyncPending) { if (isCloseProposal) { if (markApplySkipped("kept_open", "review lacks verified local checkout access")) break; } continue; } - if (state === "open" && shouldProbeClosedState && !isCloseProposal && !syncCommentsOnly) { + if ( + state === "open" && + shouldProbeClosedState && + !isCloseProposal && + !syncCommentsOnly && + !staleCanonicalCommentSyncPending + ) { continue; } const earlyLeaseState = refreshReviewStartLeaseState(); @@ -20341,7 +20737,7 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg } const earlyStaleReason = refreshedReviewStaleReason(existingReviewComment); if (state === "open" && earlyStaleReason) { - if (recordReviewGuardSkip("skipped_stale_review_comment_sync", earlyStaleReason)) break; + if (recordRefreshedReviewStaleReason(earlyStaleReason)) break; continue; } if (isUpgradedCloseCandidate) { @@ -20398,6 +20794,9 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg cachedPrCloseCoverageProofGateResult = undefined; } } + const promotedCanonicalCommentSyncGuard = applyCanonicalCommentSyncGuard(); + if (promotedCanonicalCommentSyncGuard.stopApply) break; + if (promotedCanonicalCommentSyncGuard.skipCurrentItem) continue; if ( state === "open" && isCloseProposal && @@ -20467,13 +20866,22 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg .filter((updatedAt): updatedAt is string => timestampMs(updatedAt) !== null) .sort((left, right) => (timestampMs(left) ?? 0) - (timestampMs(right) ?? 0)) .at(-1); - const staleReviewCommentReason = staleReviewCommentSyncReason( + const staleReviewCommentReason = canonicalBoundStaleReviewReason( markdown, existingReviewComment, - number, - item.kind === "pull_request" ? currentItemContext() : undefined, ); if (state === "open" && staleReviewCommentReason) { + if (staleCanonicalCommentSyncPending) { + if ( + markApplySkipped( + "retry_stale_canonical_comment_sync", + `${staleReviewCommentReason}; stale canonical comment correction remains pending`, + ) + ) { + break; + } + continue; + } markdown = replaceFrontMatterValue(markdown, "apply_checked_at", new Date().toISOString()); if (!dryRun) writeReportMarkdown(path, markdown); results.push({ @@ -20546,7 +20954,7 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg } const lateStaleReason = refreshedReviewStaleReason(lateLeaseState.comment); if (lateStaleReason) { - if (recordReviewGuardSkip("skipped_stale_review_comment_sync", lateStaleReason)) break; + if (recordRefreshedReviewStaleReason(lateStaleReason)) break; continue; } if (lateLeaseState.preserve && lateLeaseState.lease) { @@ -20665,7 +21073,6 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg reviewComment = renderCurrentReviewComment(); } let markedReviewComment = markedReviewCommentForApply(reviewComment); - let proofBlockedForCommentSync: PrCloseCoverageProofGateBlock | null = null; const protectedApplyReason = applyProtectedLabelReason(item.labels, closeReason); if (applyBlockingProtectedLabels(item.labels, closeReason).length > 0) { if (isCloseProposal) { @@ -21059,7 +21466,7 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg needsReviewCommentBodySync, needsReviewCommentHashSync, needsReviewCommentReferenceSync, - forceReviewCommentBodySync: clawSweeperLabelsChanged, + forceReviewCommentBodySync: clawSweeperLabelsChanged || Boolean(closeBlockedForCommentSync), }); if ( isCloseProposal && @@ -21102,7 +21509,7 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg break; continue; } - proofBlockedForCommentSync = prCloseCoverageBlock; + closeBlockedForCommentSync = prCloseCoverageBlock; markdown = applyPrCloseCoverageProofBlockedReport(markdown, prCloseCoverageBlock); markdown = replaceFrontMatterValue( markdown, @@ -21180,6 +21587,41 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg const labelSyncProgressMessage = issueAdvisoryLabelsChanged ? `synced advisory issue labels #${number}` : `synced ClawSweeper labels #${number}`; + if (needsReviewCommentSync && needsReviewCommentBodySync && shouldCheckCanonicalCommentSync()) { + const wasStaleCanonicalCommentSyncPending = staleCanonicalCommentSyncPending; + const mutationBoundaryGuard = applyCanonicalCommentSyncGuard(true); + if (mutationBoundaryGuard.stopApply) break; + if (mutationBoundaryGuard.skipCurrentItem) continue; + if (changedSinceReviewDuplicateCommentRepair && !staleCanonicalCommentSyncPending) { + needsReviewCommentSync = false; + } + if (!wasStaleCanonicalCommentSyncPending && staleCanonicalCommentSyncPending) { + reviewComment = renderCurrentReviewComment(); + markedReviewComment = markedReviewCommentForApply(reviewComment); + reviewCommentHash = sha256(markedReviewComment); + existingReviewCommentMatches = commentBodyMatches( + existingReviewComment, + markedReviewComment, + ); + needsReviewCommentBodySync = !existingReviewComment || !existingReviewCommentMatches; + needsReviewCommentHashSync = + frontMatterValue(markdown, "review_comment_sha256") !== reviewCommentHash; + needsReviewCommentReferenceSync = + frontMatterValue(markdown, "review_comment_id") === "unknown" || + frontMatterValue(markdown, "review_comment_url") === "unknown"; + needsReviewCommentSync = shouldSyncReviewComment({ + syncCommentsOnly, + isCloseProposal, + commentSyncMinAgeDays, + reviewCommentSyncedAt: frontMatterValue(markdown, "review_comment_synced_at"), + hasExistingReviewComment: Boolean(existingReviewComment), + needsReviewCommentBodySync, + needsReviewCommentHashSync, + needsReviewCommentReferenceSync, + forceReviewCommentBodySync: true, + }); + } + } if (needsReviewCommentSync) { const staleSyncReason = needsReviewCommentBodySync ? staleReviewCommentReason : null; if (staleSyncReason) { @@ -21197,7 +21639,13 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg } const lockedReason = needsReviewCommentBodySync ? lockedConversationApplyReason(item) : null; if (lockedReason) { - if (markApplySkipped("skipped_locked_conversation", lockedReason)) break; + const actionTaken: ActionTaken = staleCanonicalCommentSyncPending + ? "retry_stale_canonical_comment_sync" + : "skipped_locked_conversation"; + const reason = staleCanonicalCommentSyncPending + ? `${lockedReason}; stale canonical comment correction remains pending` + : lockedReason; + if (markApplySkipped(actionTaken, reason)) break; continue; } let syncedComment = existingReviewComment; @@ -21210,6 +21658,9 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg : "would create durable Codex review comment", ); } else { + const preLeaseCanonicalGuard = applyCanonicalCommentSyncGuard(true); + if (preLeaseCanonicalGuard.stopApply) break; + if (preLeaseCanonicalGuard.skipCurrentItem) continue; const mutationLeaseBlockReason = currentApplyMutationLeaseBlockReason(); if (mutationLeaseBlockReason) { if (recordReviewLeaseSkip(mutationLeaseBlockReason, false)) break; @@ -21220,11 +21671,21 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg if (recordReviewLeaseSkip(latestLeaseState.blockReason, false)) break; continue; } - const latestStaleSyncReason = staleReviewCommentSyncReason( + const finalCanonicalGuard = applyCanonicalCommentSyncGuard(true); + if (finalCanonicalGuard.stopApply) break; + if (finalCanonicalGuard.skipCurrentItem) continue; + existingReviewComment = latestLeaseState.comment; + if (staleCanonicalCommentSyncPending) { + const latestReviewCommentBody = rawCommentBody(existingReviewComment); + if (latestReviewCommentBody.trim()) { + renderOptions.previousReviewCommentBody = latestReviewCommentBody; + } + reviewComment = renderCurrentReviewComment(); + markedReviewComment = markedReviewCommentForApply(reviewComment); + } + const latestStaleSyncReason = canonicalBoundStaleReviewReason( markdown, - latestLeaseState.comment, - number, - item.kind === "pull_request" ? currentItemContext() : undefined, + existingReviewComment, ); if (latestStaleSyncReason) { markdown = replaceFrontMatterValue( @@ -21243,8 +21704,6 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg if (processedCount >= processedLimit) break; continue; } - existingReviewComment = latestLeaseState.comment; - markedReviewComment = markedReviewCommentBody(number, reviewComment); try { syncedComment = upsertReviewComment(number, markedReviewComment, existingReviewComment); const syncedCommentUpdatedAt = commentUpdatedAt(syncedComment); @@ -21255,12 +21714,18 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg } catch (error) { const commentAuthError = isGitHubRequiresAuthenticationError(error); if (!commentAuthError && !isLockedConversationCommentError(error)) throw error; - const actionTaken = commentAuthError + const fallbackActionTaken: ActionTaken = commentAuthError ? "skipped_comment_auth" : "skipped_locked_conversation"; - const reason = commentAuthError + const fallbackReason = commentAuthError ? "GitHub rejected durable review comment write with Requires authentication" : "conversation was locked while syncing review comment"; + const actionTaken: ActionTaken = staleCanonicalCommentSyncPending + ? "retry_stale_canonical_comment_sync" + : fallbackActionTaken; + const reason = staleCanonicalCommentSyncPending + ? `${fallbackReason}; stale canonical comment correction remains pending` + : fallbackReason; if (markApplySkipped(actionTaken, reason)) break; continue; } @@ -21269,13 +21734,16 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg syncReasons.push("recorded existing durable comment metadata"); } markdown = updateReviewCommentMetadata(markdown, syncedComment, markedReviewComment); + if (staleCanonicalCommentSyncPending) { + markdown = completeStaleCanonicalCommentSyncReport(markdown); + } markdown = replaceFrontMatterValue(markdown, "apply_checked_at", new Date().toISOString()); if (!dryRun) writeReportMarkdown(path, markdown); results.push({ number, - action: proofBlockedForCommentSync?.actionTaken ?? "review_comment_synced", - reason: proofBlockedForCommentSync - ? [proofBlockedForCommentSync.reason, ...syncReasons].join("; ") + action: closeBlockedForCommentSync?.actionTaken ?? "review_comment_synced", + reason: closeBlockedForCommentSync + ? [closeBlockedForCommentSync.reason, ...syncReasons].join("; ") : syncReasons.join("; "), ...(emitEventApplyProof ? { durableReviewSynced: true } : {}), }); @@ -21283,17 +21751,20 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg maybeLogProgress(`synced review comment #${number}`); if (processedCount >= processedLimit) break; } - if (proofBlockedForCommentSync) { + if (closeBlockedForCommentSync) { if (!needsReviewCommentSync) { + if (staleCanonicalCommentSyncPending) { + markdown = completeStaleCanonicalCommentSyncReport(markdown); + } markdown = replaceFrontMatterValue(markdown, "apply_checked_at", new Date().toISOString()); if (!dryRun) writeReportMarkdown(path, markdown); results.push({ number, - action: proofBlockedForCommentSync.actionTaken, - reason: proofBlockedForCommentSync.reason, + action: closeBlockedForCommentSync.actionTaken, + reason: closeBlockedForCommentSync.reason, }); processedCount += 1; - maybeLogProgress(`skipped #${number}: ${proofBlockedForCommentSync.reason}`); + maybeLogProgress(`skipped #${number}: ${closeBlockedForCommentSync.reason}`); if (processedCount >= processedLimit) break; } continue; diff --git a/src/repair/workflow-utils.ts b/src/repair/workflow-utils.ts index a084d08781..0ed609999c 100644 --- a/src/repair/workflow-utils.ts +++ b/src/repair/workflow-utils.ts @@ -785,6 +785,14 @@ const APPLY_SKIP_NEXT_ACTION_DETAILS: Record summary: "The close-coverage proof check failed transiently before reaching a decision.", next_step: "Inspect the proof failure, then retry after model and GitHub access recover.", }, + retry_stale_canonical_comment_sync: { + bucket: "review_refresh", + owner: "clawsweeper", + retryable: true, + label: "Retry comment correction", + summary: "A stale canonical close verdict still needs to be replaced on GitHub.", + next_step: "Retry durable comment sync until the conservative keep-open correction succeeds.", + }, skipped_protected_label: maintainerDecisionAction(), skipped_policy_exempt: maintainerDecisionAction(), skipped_maintainer_authored: { @@ -1885,10 +1893,22 @@ function commentSyncCandidates(targetRepo: string, applyKind: string): number[] if (frontMatterValue(markdown, "review_status") !== "complete") return []; if (!frontMatterValue(markdown, "item_snapshot_hash")) return []; const actionTaken = frontMatterValue(markdown, "action_taken"); + const storedReviewCommentId = frontMatterValue(markdown, "review_comment_id"); + const storedReviewCommentUrl = frontMatterValue(markdown, "review_comment_url"); + const hasStoredReviewComment = + Boolean(storedReviewCommentId && !["none", "unknown"].includes(storedReviewCommentId)) && + Boolean(storedReviewCommentUrl && !["none", "unknown"].includes(storedReviewCommentUrl)); + const changedDuplicateClose = + actionTaken === "skipped_changed_since_review" && + frontMatterValue(markdown, "decision") === "close" && + frontMatterValue(markdown, "close_reason") === "duplicate_or_superseded" && + hasStoredReviewComment; if ( actionTaken !== "kept_open" && actionTaken !== "proposed_close" && - actionTaken !== "skipped_pr_close_coverage_proof" + actionTaken !== "skipped_pr_close_coverage_proof" && + actionTaken !== "retry_stale_canonical_comment_sync" && + !changedDuplicateClose ) { return []; } diff --git a/test/apply-pr-coverage-proof-recheck.test.ts b/test/apply-pr-coverage-proof-recheck.test.ts index 0879600375..5f1e9b5f0a 100644 --- a/test/apply-pr-coverage-proof-recheck.test.ts +++ b/test/apply-pr-coverage-proof-recheck.test.ts @@ -13,6 +13,28 @@ import { withMockGh, } from "./helpers.ts"; +function boundDuplicateCloseComment(number: number, canonicalUrl: string): string { + const markerFields = [ + `item=${number}`, + "sha=head-sha", + "confidence=high", + "updated_at=2026-05-01T00:00:00.000Z", + "reviewed_at=2026-05-01T00:00:00.000Z", + "source_revision=reviewed-source", + "action_taken=proposed_close", + "reason=duplicate_or_superseded", + ].join(" "); + return [ + "Codex review: close this as superseded.", + "", + `Canonical: ${canonicalUrl}`, + "", + ``, + ``, + ``, + ].join("\n"); +} + test("apply-decisions allows self-synced labels after proof with truncated context", () => { const root = mkdtempSync(tmpPrefix); try { @@ -652,7 +674,7 @@ test("apply-decisions blocks duplicate close when canonical PR is a bare cluster promotionGhMock({ number: 341, title: "Already proposed duplicate close", - comment: synced.comment, + comment: boundDuplicateCloseComment(341, "https://github.com/openclaw/openclaw/pull/400"), linkedPulls: { 400: { number: 400, diff --git a/test/apply-pr-duplicate-proof.test.ts b/test/apply-pr-duplicate-proof.test.ts index 380aebe576..642615bf96 100644 --- a/test/apply-pr-duplicate-proof.test.ts +++ b/test/apply-pr-duplicate-proof.test.ts @@ -3,7 +3,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { join } from "node:path"; import test from "node:test"; -import { renderReviewCommentFromReport } from "../dist/clawsweeper.js"; +import { + renderReviewCommentFromReport, + renderReviewStartStatusComment, +} from "../dist/clawsweeper.js"; import { lowSignalCloseReport, markedReviewCommentForTest, @@ -15,6 +18,28 @@ import { withMockGh, } from "./helpers.ts"; +function boundDuplicateCloseComment(number: number, canonicalUrl: string): string { + const markerFields = [ + `item=${number}`, + "sha=head-sha", + "confidence=high", + "updated_at=2026-05-01T00:00:00.000Z", + "reviewed_at=2026-05-01T00:00:00.000Z", + "source_revision=reviewed-source", + "action_taken=proposed_close", + "reason=duplicate_or_superseded", + ].join(" "); + return [ + "Codex review: close this as superseded.", + "", + `Canonical: ${canonicalUrl}`, + "", + ``, + ``, + ``, + ].join("\n"); +} + test("apply-decisions blocks duplicate close when linked canonical PR closed unmerged", () => { const root = mkdtempSync(tmpPrefix); try { @@ -43,7 +68,7 @@ test("apply-decisions blocks duplicate close when linked canonical PR closed unm promotionGhMock({ number: 336, title: "Already proposed duplicate close", - comment: synced.comment, + comment: boundDuplicateCloseComment(336, "https://github.com/openclaw/openclaw/pull/400"), linkedPulls: { 400: { number: 400, @@ -121,7 +146,7 @@ test("apply-decisions blocks duplicate close when canonical PR is only in close promotionGhMock({ number: 346, title: "Already proposed duplicate close", - comment: synced.comment, + comment: boundDuplicateCloseComment(346, "https://github.com/openclaw/openclaw/pull/400"), linkedPulls: { 400: { number: 400, @@ -516,6 +541,799 @@ test("apply-decisions skips duplicate PR coverage proof during stale comment-onl } }); +test("apply-decisions corrects stale close comments when the canonical PR closed unmerged", () => { + 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 proofLogPath = join(root, "proof.log"); + const commentWriteLogPath = join(root, "comment-write.log"); + const canonicalUrl = "https://github.com/openclaw/openclaw/pull/400"; + const headSha = "a".repeat(40); + const reviewLeaseOwner = "stale-canonical-review"; + const reviewLeaseCommentId = 9351; + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + const rootCauseCluster = { + confidence: "high", + canonicalRef: canonicalUrl, + currentItemRelationship: "superseded", + summary: "This PR is superseded by an open, mergeable canonical PR.", + members: [ + { + ref: canonicalUrl, + relationship: "canonical", + reason: "This was the open canonical landing path.", + }, + ], + }; + const mergeRiskOptions = [ + { + title: "Close in favor of the canonical PR", + body: `Use ${canonicalUrl} as the single landing path.`, + category: "pause_or_close", + recommended: true, + automergeInstruction: "", + }, + ]; + const reportMarkdown = `${lowSignalCloseReport({ + number: 350, + title: "Provider route fallback", + action_taken: "skipped_changed_since_review", + close_reason: "duplicate_or_superseded", + work_cluster_refs: JSON.stringify([`Superseded by ${canonicalUrl}`]), + root_cause_cluster: JSON.stringify(rootCauseCluster), + merge_risk_options: JSON.stringify(mergeRiskOptions), + label_justifications: JSON.stringify([ + { label: "P2", reason: `The canonical landing path is ${canonicalUrl}.` }, + ]), + review_metrics: JSON.stringify([ + { label: "Canonical status", value: "open", reason: canonicalUrl }, + ]), + pull_head_sha: headSha, + review_lease_owner: reviewLeaseOwner, + review_lease_comment_id: String(reviewLeaseCommentId), + }) + .replace( + "The dashboard has queue_fix_pr candidates but no generated coding plan.", + `Close this PR because ${canonicalUrl} is open and canonical.`, + ) + .replace( + "- **branch shape:** PR diff is mostly unrelated provider churn around a tiny possible useful tweak", + `- **Canonical PR status:** ${canonicalUrl} is open, mergeable, and proof-positive.`, + ) + .replace( + "Closing this PR because the branch is not a useful landing base.", + `Closing this PR as superseded by ${canonicalUrl}.`, + )} + +## Best Possible Solution + +Close this branch and land ${canonicalUrl}. + +## Solution Assessment + +This branch is superseded by the open canonical PR at ${canonicalUrl}. + +## Root-Cause Cluster + +Current item relationship: superseded + +Confidence: high + +Canonical ref: ${canonicalUrl} + +Summary: This PR is superseded by an open, mergeable canonical PR. + +Members: +- **canonical:** ${canonicalUrl} + - reason: This was the open canonical landing path. + +## PR Rating + +Overall tier: F + +Proof tier: D + +Patch tier: F + +Summary: This branch is superseded by the canonical PR. + +Next rank-up steps: + +- Close this PR in favor of ${canonicalUrl}. + +## Work Candidate + +Candidate: none + +Confidence: high + +Priority: low + +Status: none + +Reason: No work is needed because ${canonicalUrl} is the canonical landing path. + +## Likely Related People + +- **contributor:** canonical candidate author + - reason: They authored the open canonical PR at ${canonicalUrl}. + - confidence: medium + +## Risks / Open Questions + +- The proof is already available on ${canonicalUrl}. +`; + const synced = reportWithSyncedReviewComment(reportMarkdown, 350, "duplicate_or_superseded"); + const recentlySyncedReport = synced.report.replace( + /^review_comment_synced_at: .*$/m, + `review_comment_synced_at: ${new Date().toISOString()}`, + ); + const reportReviewedAt = recentlySyncedReport.match(/^reviewed_at: (.+)$/m)?.[1]; + assert.ok(reportReviewedAt); + const newerStaleCloseComment = [ + "Codex review: close this as superseded.", + "", + `Canonical landing path: ${canonicalUrl}.`, + "", + "", + "", + "", + ].join("\n"); + const differentCanonicalCloseComment = newerStaleCloseComment.replace( + canonicalUrl, + "https://github.com/openclaw/openclaw/pull/401", + ); + const multiMemberCloseComment = newerStaleCloseComment.replace( + `Canonical landing path: ${canonicalUrl}.`, + [ + "**Root-cause cluster**", + `Canonical: ${canonicalUrl}`, + "Members:", + `- \`canonical\`: ${canonicalUrl} - This is the canonical landing path.`, + "- `superseded`: https://github.com/openclaw/openclaw/pull/402 - This related branch is also superseded.", + ].join("\n"), + ); + const issueCanonicalCloseComment = newerStaleCloseComment + .replace( + `Canonical landing path: ${canonicalUrl}.`, + [ + "**Root-cause cluster**", + "Canonical: https://github.com/openclaw/openclaw/issues/500", + "Members:", + `- \`superseded\`: ${canonicalUrl} - This PR is the unique superseding landing path.`, + ].join("\n"), + ) + .replaceAll("reviewed_at=2099-01-01T00:00:00.000Z", `reviewed_at=${reportReviewedAt}`); + const leaseStartedAt = new Date().toISOString(); + const leaseExpiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + const reviewLeaseComment = renderReviewStartStatusComment({ + number: 350, + kind: "pull_request", + title: "Provider route fallback", + headSha, + startedAt: leaseStartedAt, + leaseExpiresAt, + leaseOwner: reviewLeaseOwner, + }); + const leasedPromotionGhMock = (options: Parameters[0]): string => + promotionGhMock({ + ...options, + headSha, + comments: [ + { + id: 9350, + html_url: "https://github.com/openclaw/openclaw/pull/350#issuecomment-9350", + created_at: "2026-05-01T01:00:00Z", + updated_at: "2099-01-01T00:00:00.000Z", + user: { login: "clawsweeper[bot]" }, + body: options.comment, + }, + { + id: reviewLeaseCommentId, + html_url: `https://github.com/openclaw/openclaw/pull/350#issuecomment-${reviewLeaseCommentId}`, + created_at: leaseStartedAt, + updated_at: leaseStartedAt, + user: { login: "clawsweeper[bot]" }, + body: reviewLeaseComment, + }, + ], + }); + writeFileSync(join(itemsDir, "350.md"), recentlySyncedReport, "utf8"); + + withMockGh( + root, + leasedPromotionGhMock({ + number: 350, + title: "Provider route fallback", + itemUpdatedAt: "2026-05-02T00:00:00Z", + comment: newerStaleCloseComment, + commentWriteLogPath, + commentWriteError: "gh: Requires authentication (HTTP 401)", + linkedPulls: { + 400: { + number: 400, + title: "Closed canonical PR", + html_url: canonicalUrl, + state: "closed", + merged_at: null, + labels: [], + }, + }, + }), + () => { + withMockCodexProof( + root, + { type: "failure", message: "proof should not run", invocationLogPath: proofLogPath }, + () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: [ + "--target-repo", + "openclaw/openclaw", + "--apply-kind", + "all", + "--sync-comments-only", + "--comment-sync-min-age-days", + "30", + "--processed-limit", + "1", + ], + }); + }, + ); + }, + ); + + assert.equal(existsSync(proofLogPath), false); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 350, + action: "retry_stale_canonical_comment_sync", + reason: + "GitHub rejected durable review comment write with Requires authentication; stale canonical comment correction remains pending", + }, + ]); + const pendingReport = readFileSync(join(itemsDir, "350.md"), "utf8"); + assert.match(pendingReport, /^decision: keep_open$/m); + assert.match(pendingReport, /^action_taken: retry_stale_canonical_comment_sync$/m); + assert.match(pendingReport, /^stale_canonical_pull_request_number: 400$/m); + assert.equal(existsSync(join(root, "comment-state-350.json")), false); + + const commentWriteCount = (): number => + readFileSync(commentWriteLogPath, "utf8").trim().split("\n").filter(Boolean).length; + const writesAfterAuthFailure = commentWriteCount(); + const runPendingRetry = (comment: string, linkedPull: Record): void => { + withMockGh( + root, + leasedPromotionGhMock({ + number: 350, + title: "Provider route fallback", + itemUpdatedAt: "2026-05-02T00:00:00Z", + comment, + commentWriteLogPath, + linkedPulls: { 400: linkedPull }, + }), + () => { + withMockCodexProof( + root, + { type: "failure", message: "proof should not run", invocationLogPath: proofLogPath }, + () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: [ + "--target-repo", + "openclaw/openclaw", + "--apply-kind", + "all", + "--sync-comments-only", + "--comment-sync-min-age-days", + "30", + "--processed-limit", + "1", + ], + }); + }, + ); + }, + ); + }; + + runPendingRetry(newerStaleCloseComment, { + number: 400, + title: "Reopened canonical PR", + html_url: canonicalUrl, + state: "open", + merged_at: null, + labels: [], + }); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 350, + action: "retry_stale_canonical_comment_sync", + reason: + "linked canonical PR #400 is no longer closed and unmerged; fresh review required before stale comment correction", + }, + ]); + assert.equal(commentWriteCount(), writesAfterAuthFailure); + + runPendingRetry(differentCanonicalCloseComment, { + number: 400, + title: "Closed canonical PR", + html_url: canonicalUrl, + state: "closed", + merged_at: null, + labels: [], + }); + const differentCanonicalResult = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + number: number; + action: string; + reason: string; + }>; + assert.equal(differentCanonicalResult[0]?.number, 350); + assert.equal(differentCanonicalResult[0]?.action, "retry_stale_canonical_comment_sync"); + assert.match(differentCanonicalResult[0]?.reason ?? "", /newer than the local report/); + assert.match( + differentCanonicalResult[0]?.reason ?? "", + /stale canonical comment correction remains pending/, + ); + assert.equal(commentWriteCount(), writesAfterAuthFailure); + + const writesBeforeIssueCanonical = commentWriteCount(); + runPendingRetry(issueCanonicalCloseComment, { + number: 400, + title: "Closed canonical PR", + html_url: canonicalUrl, + state: "closed", + merged_at: null, + labels: [], + }); + const issueCanonicalResult = JSON.parse(readFileSync(reportPath, "utf8")) as Array<{ + number: number; + action: string; + reason: string; + }>; + assert.equal(issueCanonicalResult[0]?.number, 350); + assert.equal(issueCanonicalResult[0]?.action, "retry_stale_canonical_comment_sync"); + assert.match(issueCanonicalResult[0]?.reason ?? "", /not bound to stored canonical PR #400/); + assert.match( + issueCanonicalResult[0]?.reason ?? "", + /stale canonical comment correction remains pending/, + ); + assert.equal(commentWriteCount(), writesBeforeIssueCanonical); + + withMockGh( + root, + leasedPromotionGhMock({ + number: 350, + title: "Provider route fallback", + itemUpdatedAt: "2026-05-02T00:00:00Z", + comment: multiMemberCloseComment, + commentWriteLogPath, + linkedPulls: { + 400: { + number: 400, + title: "Closed canonical PR", + html_url: canonicalUrl, + state: "closed", + merged_at: null, + labels: [], + }, + }, + }), + () => { + withMockCodexProof( + root, + { type: "failure", message: "proof should not run", invocationLogPath: proofLogPath }, + () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: [ + "--target-repo", + "openclaw/openclaw", + "--apply-kind", + "all", + "--sync-comments-only", + "--comment-sync-min-age-days", + "30", + "--processed-limit", + "1", + ], + }); + }, + ); + }, + ); + + assert.equal(existsSync(proofLogPath), false); + assert.match(readFileSync(commentWriteLogPath, "utf8"), /issues\/comments\/9350/); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 350, + action: "kept_open", + reason: + "linked canonical PR #400 is closed and unmerged; refusing duplicate/superseded auto-close; updated durable Codex review comment", + }, + ]); + const storedReport = readFileSync(join(itemsDir, "350.md"), "utf8"); + assert.match(storedReport, /^decision: keep_open$/m); + assert.match(storedReport, /^close_reason: none$/m); + assert.match(storedReport, /^confidence: low$/m); + assert.match(storedReport, /^action_taken: corrected_stale_canonical_comment$/m); + assert.match(storedReport, /^stale_canonical_pull_request_number: none$/m); + assert.match(storedReport, /^merge_risk_options: \[\]$/m); + assert.match(storedReport, /"canonicalRef":null/); + assert.doesNotMatch(storedReport, new RegExp(canonicalUrl)); + const liveComment = ( + JSON.parse(readFileSync(join(root, "comment-state-350.json"), "utf8")) as { body: string } + ).body; + assert.match(liveComment, /closed and unmerged/); + assert.doesNotMatch(liveComment, new RegExp(canonicalUrl)); + assert.doesNotMatch(liveComment, /clawsweeper-(?:verdict:close|action:close-required)/); + assert.equal(existsSync(join(closedDir, "350.md")), false); + + const followupReportPath = join(root, "followup-apply-report.json"); + withMockGh( + root, + promotionGhMock({ + number: 350, + title: "Provider route fallback", + itemUpdatedAt: "2026-05-02T00:00:00Z", + comment: markedReviewCommentForTest(350, "Stale durable review comment."), + }), + () => { + withMockCodexProof( + root, + { type: "failure", message: "corrected report must not be promoted" }, + () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath: followupReportPath, + extraArgs: [ + "--target-repo", + "openclaw/openclaw", + "--apply-kind", + "all", + "--dry-run", + "--processed-limit", + "1", + ], + }); + }, + ); + }, + ); + assert.deepEqual(JSON.parse(readFileSync(followupReportPath, "utf8")), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("apply-decisions does not create close comments from changed reports while canonical stays open", () => { + 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 dryRunReportPath = join(root, "dry-run-apply-report.json"); + const proofLogPath = join(root, "proof.log"); + const commentWriteLogPath = join(root, "comment-write.log"); + const canonicalUrl = "https://github.com/openclaw/openclaw/pull/400"; + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + const reportMarkdown = lowSignalCloseReport({ + number: 369, + title: "Changed duplicate report without a durable comment", + action_taken: "skipped_changed_since_review", + close_reason: "duplicate_or_superseded", + work_cluster_refs: JSON.stringify([`Superseded by ${canonicalUrl}`]), + }).replace( + "Closing this PR because the branch is not a useful landing base.", + `Closing this PR as superseded by ${canonicalUrl}.`, + ); + writeFileSync(join(itemsDir, "369.md"), reportMarkdown, "utf8"); + + withMockGh( + root, + promotionGhMock({ + number: 369, + title: "Changed duplicate report without a durable comment", + labels: [], + itemUpdatedAt: "2026-05-02T00:00:00Z", + comment: "", + comments: [], + commentWriteLogPath, + linkedPulls: { + 400: { + number: 400, + title: "Open canonical PR", + html_url: canonicalUrl, + state: "open", + merged_at: null, + labels: [], + }, + }, + }), + () => { + withMockCodexProof( + root, + { type: "failure", message: "proof should not run", invocationLogPath: proofLogPath }, + () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath: dryRunReportPath, + extraArgs: [ + "--target-repo", + "openclaw/openclaw", + "--apply-kind", + "all", + "--sync-comments-only", + "--dry-run", + "--processed-limit", + "1", + ], + }); + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: [ + "--target-repo", + "openclaw/openclaw", + "--apply-kind", + "all", + "--sync-comments-only", + "--processed-limit", + "1", + ], + }); + }, + ); + }, + ); + + assert.equal(existsSync(proofLogPath), false); + assert.equal(existsSync(commentWriteLogPath), false); + assert.equal(existsSync(join(root, "comment-state-369.json")), false); + assert.deepEqual(JSON.parse(readFileSync(dryRunReportPath, "utf8")), []); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), []); + const storedReport = readFileSync(join(itemsDir, "369.md"), "utf8"); + assert.match(storedReport, /^action_taken: skipped_changed_since_review$/m); + assert.match(storedReport, /^decision: close$/m); + assert.equal(existsSync(join(closedDir, "369.md")), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("apply-decisions rechecks a structured canonical ref at the comment mutation boundary", () => { + 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 proofLogPath = join(root, "proof.log"); + const commentWriteLogPath = join(root, "comment-write.log"); + const canonicalUrl = "https://github.com/openclaw/openclaw/pull/400"; + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + const rootCauseCluster = { + confidence: "high", + canonicalRef: canonicalUrl, + currentItemRelationship: "superseded", + summary: "The structured review identified one canonical landing path.", + members: [ + { + ref: canonicalUrl, + relationship: "canonical", + reason: "This was the canonical landing path at review time.", + }, + ], + }; + const synced = reportWithSyncedReviewComment( + lowSignalCloseReport({ + number: 352, + title: "Provider route fallback", + action_taken: "skipped_changed_since_review", + close_reason: "duplicate_or_superseded", + work_cluster_refs: "[]", + root_cause_cluster: JSON.stringify(rootCauseCluster), + }), + 352, + "duplicate_or_superseded", + ); + writeFileSync(join(itemsDir, "352.md"), synced.report, "utf8"); + + withMockGh( + root, + promotionGhMock({ + number: 352, + title: "Provider route fallback", + comment: boundDuplicateCloseComment(352, canonicalUrl), + commentWriteLogPath, + linkedPulls: { + 400: { + number: 400, + title: "Canonical provider fix", + html_url: canonicalUrl, + state: "open", + merged_at: null, + labels: [], + }, + }, + linkedPullsAfterCommentRead: { + 400: { + number: 400, + title: "Canonical provider fix", + html_url: canonicalUrl, + state: "closed", + merged_at: null, + labels: [], + }, + }, + }), + () => { + withMockCodexProof( + root, + { type: "failure", message: "proof should not run", invocationLogPath: proofLogPath }, + () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: [ + "--target-repo", + "openclaw/openclaw", + "--apply-kind", + "all", + "--sync-comments-only", + "--processed-limit", + "1", + ], + }); + }, + ); + }, + ); + + assert.equal(existsSync(proofLogPath), false); + assert.match(readFileSync(commentWriteLogPath, "utf8"), /issues\/comments\/9352/); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 352, + action: "kept_open", + reason: + "linked canonical PR #400 is closed and unmerged; refusing duplicate/superseded auto-close; updated durable Codex review comment", + }, + ]); + const storedReport = readFileSync(join(itemsDir, "352.md"), "utf8"); + assert.match(storedReport, /^action_taken: corrected_stale_canonical_comment$/m); + assert.doesNotMatch(storedReport, new RegExp(canonicalUrl)); + const liveComment = ( + JSON.parse(readFileSync(join(root, "comment-state-352.json"), "utf8")) as { body: string } + ).body; + assert.match(liveComment, /closed and unmerged/); + assert.doesNotMatch(liveComment, /clawsweeper-(?:verdict:close|action:close-required)/); + assert.equal(existsSync(join(closedDir, "352.md")), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("apply-decisions keeps an unreadable canonical PR in the comment-sync queue", () => { + 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 proofLogPath = join(root, "proof.log"); + const commentWriteLogPath = join(root, "comment-write.log"); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + const canonicalUrl = "https://github.com/openclaw/openclaw/pull/400"; + const synced = reportWithSyncedReviewComment( + lowSignalCloseReport({ + number: 351, + title: "Provider route fallback", + close_reason: "duplicate_or_superseded", + work_cluster_refs: JSON.stringify([ + `Related pull request: ${canonicalUrl}`, + "Background issue: #500", + ]), + }).replace( + "Closing this PR because the branch is not a useful landing base.", + `Closing this PR as superseded by ${canonicalUrl}.`, + ), + 351, + "duplicate_or_superseded", + ); + writeFileSync(join(itemsDir, "351.md"), synced.report, "utf8"); + + withMockGh( + root, + promotionGhMock({ + number: 351, + title: "Provider route fallback", + comment: markedReviewCommentForTest(351, "Stale durable review comment."), + commentWriteLogPath, + linkedIssues: { + 500: { + number: 500, + title: "Related provider issue", + html_url: "https://github.com/openclaw/openclaw/issues/500", + state: "open", + labels: [], + }, + }, + }), + () => { + withMockCodexProof( + root, + { type: "failure", message: "proof should not run", invocationLogPath: proofLogPath }, + () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: [ + "--target-repo", + "openclaw/openclaw", + "--apply-kind", + "all", + "--sync-comments-only", + "--processed-limit", + "1", + ], + }); + }, + ); + }, + ); + + assert.equal(existsSync(proofLogPath), false); + assert.equal(existsSync(commentWriteLogPath), false); + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number: 351, + action: "retry_pr_close_coverage_proof", + reason: + "linked canonical PR #400 could not be read; refusing duplicate/superseded comment sync", + }, + ]); + const storedReport = readFileSync(join(itemsDir, "351.md"), "utf8"); + assert.match(storedReport, /^decision: close$/m); + assert.match(storedReport, /^close_reason: duplicate_or_superseded$/m); + assert.match(storedReport, /^action_taken: proposed_close$/m); + assert.match(storedReport, new RegExp(canonicalUrl)); + assert.equal(existsSync(join(root, "comment-state-351.json")), false); + assert.equal(existsSync(join(closedDir, "351.md")), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("apply-decisions gates duplicate PR closes with shorthand canonical refs", () => { const root = mkdtempSync(tmpPrefix); try { diff --git a/test/apply-pr-duplicate-ref-proof.test.ts b/test/apply-pr-duplicate-ref-proof.test.ts index 2e78c78900..6ba350018b 100644 --- a/test/apply-pr-duplicate-ref-proof.test.ts +++ b/test/apply-pr-duplicate-ref-proof.test.ts @@ -682,7 +682,7 @@ test("apply-decisions preserves full PR URL evidence over later bare refs", () = false, ); assert.match( - report.find((entry) => entry.action === "kept_open")?.reason ?? "", + report.find((entry) => entry.action === "retry_pr_close_coverage_proof")?.reason ?? "", /linked canonical PR #400 could not be read/, ); assert.equal(existsSync(proofLogPath), false); diff --git a/test/helpers.ts b/test/helpers.ts index b340fbf853..871bf19ff6 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -439,6 +439,7 @@ export function promotionGhMock(options: { issueCommentCount?: number; comment: string; commentWriteLogPath?: string; + commentWriteError?: string; closeAppliedBodyLogPath?: string; closeCommandDelayMs?: number; comments?: unknown[]; @@ -448,6 +449,7 @@ export function promotionGhMock(options: { timeline?: unknown[]; linkedPulls?: Record; linkedPullsAfterProof?: Record; + linkedPullsAfterCommentRead?: Record; linkedPullHangAfterProof?: boolean; linkedIssues?: Record; }) { @@ -486,9 +488,11 @@ export function promotionGhMock(options: { const timeline = ${JSON.stringify(timeline)}; const linkedPulls = ${JSON.stringify(linkedPulls)}; const linkedPullsAfterProof = ${JSON.stringify(options.linkedPullsAfterProof ?? {})}; + const linkedPullsAfterCommentRead = ${JSON.stringify(options.linkedPullsAfterCommentRead ?? {})}; const linkedPullHangAfterProof = ${JSON.stringify(options.linkedPullHangAfterProof ?? false)}; const linkedIssues = ${JSON.stringify(linkedIssues)}; const commentWriteLogPath = ${JSON.stringify(options.commentWriteLogPath ?? "")}; + const commentWriteError = ${JSON.stringify(options.commentWriteError ?? "")}; const closeAppliedBodyLogPath = ${JSON.stringify(options.closeAppliedBodyLogPath ?? "")}; const closeCommandDelayMs = ${JSON.stringify(options.closeCommandDelayMs ?? 0)}; const number = ${options.number}; @@ -546,9 +550,11 @@ export function promotionGhMock(options: { const proofHasRun = () => itemUpdatedAtAfterProofLogPath && existsSync(itemUpdatedAtAfterProofLogPath); - const liveLinkedPulls = proofHasRun() - ? { ...linkedPulls, ...linkedPullsAfterProof } - : linkedPulls; + const liveLinkedPulls = { + ...linkedPulls, + ...(proofHasRun() ? linkedPullsAfterProof : {}), + ...(existsSync(commentReadStatePath) ? linkedPullsAfterCommentRead : {}) + }; const liveUpdatedAt = itemUpdatedAtAfterProof && itemUpdatedAtAfterProofLogPath && @@ -564,6 +570,10 @@ export function promotionGhMock(options: { console.log("HTTP/2 200\\n\\n" + JSON.stringify(timeline)); } else if (args[0] === "api" && new RegExp("/issues/" + number + "/comments$").test(path) && args.includes("--method")) { if (commentWriteLogPath) appendFileSync(commentWriteLogPath, args.join(" ") + "\\n"); + if (commentWriteError) { + console.error(commentWriteError); + process.exit(1); + } if (closeAppliedBodyLogPath) { const input = args[args.indexOf("--input") + 1]; appendFileSync(closeAppliedBodyLogPath, JSON.parse(readFileSync(input, "utf8")).body + "\\n---body---\\n"); @@ -574,10 +584,14 @@ export function promotionGhMock(options: { console.log(""); } else if (args[0] === "api" && new RegExp("/issues/comments/\\\\d+$").test(path) && args.includes("--method")) { if (commentWriteLogPath) appendFileSync(commentWriteLogPath, args.join(" ") + "\\n"); + if (commentWriteError) { + console.error(commentWriteError); + process.exit(1); + } console.log(JSON.stringify(writeMutationComment())); } else if (args[0] === "api" && new RegExp("/issues/" + number + "/comments(?:\\\\?|$)").test(path)) { const currentComments = liveComments(); - if (commentsAfterFirstRead && !existsSync(commentReadStatePath)) writeFileSync(commentReadStatePath, "read"); + if (!existsSync(commentReadStatePath)) writeFileSync(commentReadStatePath, "read", "utf8"); console.log(JSON.stringify(slurp ? [currentComments] : currentComments)); } else if (args[0] === "api" && new RegExp("/issues/" + number + "/timeline(?:\\\\?|$)").test(path)) { console.log(JSON.stringify(slurp ? [timeline] : timeline)); diff --git a/test/repair/workflow-utils.test.ts b/test/repair/workflow-utils.test.ts index 0d8a5534cf..7efd342a41 100644 --- a/test/repair/workflow-utils.test.ts +++ b/test/repair/workflow-utils.test.ts @@ -2266,6 +2266,22 @@ test("workflow utilities select cursor-based PR comment sync batches", () => { writeCommentSyncRecord(root, 10, "pull_request", "kept_open"); writeCommentSyncRecord(root, 20, "pull_request", "proposed_close"); writeCommentSyncRecord(root, 30, "pull_request", "skipped_pr_close_coverage_proof"); + writeCommentSyncRecord(root, 34, "pull_request", "skipped_changed_since_review", { + decision: "close", + closeReason: "duplicate_or_superseded", + reviewCommentId: "9034", + reviewCommentUrl: "https://github.com/openclaw/openclaw/pull/34#issuecomment-9034", + }); + writeCommentSyncRecord(root, 35, "pull_request", "retry_stale_canonical_comment_sync"); + writeCommentSyncRecord(root, 36, "pull_request", "corrected_stale_canonical_comment"); + writeCommentSyncRecord(root, 37, "pull_request", "skipped_changed_since_review", { + decision: "close", + closeReason: "low_signal_unmergeable_pr", + }); + writeCommentSyncRecord(root, 38, "pull_request", "skipped_changed_since_review", { + decision: "close", + closeReason: "duplicate_or_superseded", + }); writeCommentSyncRecord(root, 40, "issue", "kept_open"); writeCommentSyncRecord(root, 50, "pull_request", "reviewed"); @@ -2294,15 +2310,15 @@ test("workflow utilities select cursor-based PR comment sync batches", () => { commentSyncBatchOutput({ targetRepo: "openclaw/openclaw", applyKind: "pull_request", - batchSize: 2, + batchSize: 10, cursorPath, }), ), { - item_numbers: "30", - count: "1", + item_numbers: "30,34,35", + count: "3", cursor: "20", - next_cursor: "30", + next_cursor: "35", wrapped: "false", }, ); @@ -2370,18 +2386,22 @@ function writeProposedRecord( ); } -function writeCommentSyncRecord(root, number, type, actionTaken) { +function writeCommentSyncRecord(root, number, type, actionTaken, options = {}) { + const lines = [ + "---", + "repository: openclaw/openclaw", + `type: ${type}`, + "review_status: complete", + "item_snapshot_hash: abc123", + `action_taken: ${actionTaken}`, + ]; + if (options.decision) lines.push(`decision: ${options.decision}`); + if (options.closeReason) lines.push(`close_reason: ${options.closeReason}`); + if (options.reviewCommentId) lines.push(`review_comment_id: ${options.reviewCommentId}`); + if (options.reviewCommentUrl) lines.push(`review_comment_url: ${options.reviewCommentUrl}`); + lines.push("---", ""); write( path.join(root, `records/openclaw-openclaw/items/openclaw-openclaw-${number}.md`), - [ - "---", - "repository: openclaw/openclaw", - `type: ${type}`, - "review_status: complete", - "item_snapshot_hash: abc123", - `action_taken: ${actionTaken}`, - "---", - "", - ].join("\n"), + lines.join("\n"), ); }