Files
fusion/packages/core/src/pr-entity.ts
gsxdsm e617ce65d9 feat(pr): U18 — surface + gate auto-resolution of PR review comments
Adds autoResolveReviewComments project setting (default on) gating the existing
Review-response loop, a single-sourced summarizePrThreadActivity counter, and
fixed/acted thread counts in the dashboard PR summary. Resolution stays
independent of the auto-merge gate (disabled merge still resolves threads).
2026-06-15 19:44:17 -07:00

128 lines
5.5 KiB
TypeScript

// Core-owned predicates for the unified PR entity (PR-lifecycle-as-workflow-nodes, U1).
//
// These live in @fusion/core so the dashboard route, the workflow node handlers,
// and the reconcile all consult one definition and cannot drift — the same
// discipline that put isBranchGroupMemberLanded in branch-group-completion.ts.
import type { PrEntity, PrThreadState } from "./types.js";
/** Non-terminal lifecycle states — the entity is "live". */
export function isPrEntityActive(entity: Pick<PrEntity, "state">): boolean {
return entity.state !== "merged" && entity.state !== "closed" && entity.state !== "failed";
}
/**
* Whether a piece of work is "PR-backed" for the purpose of keeping it out of
* the legacy merge pipeline.
*
* Merge-target scoping is load-bearing: a shared-group MEMBER landing onto its
* group branch (mergeTargetSource === "branch-group-integration") is NOT
* PR-backed even when its group has an open PR entity — only the group's
* promotion/default-branch merge is. Treating member-integration as PR-backed
* would deadlock the group (members could never land, so it could never complete
* and the PR could never advance). Mirrors how isBranchGroupMemberLanded keys on
* the merge target rather than mere group membership.
*
* An unverified entity (imported legacy state GitHub has not corroborated) still
* counts as PR-backed (R19 hard gate): a possibly-fictional PR must not let the
* task fall back into the legacy merger and risk a double-merge. The reconcile
* clears the fiction and releases the task on its first pass.
*/
export function isPrBacked(
entity: Pick<PrEntity, "state"> | null | undefined,
opts?: { mergeTargetSource?: string },
): boolean {
if (!entity || !isPrEntityActive(entity)) return false;
// Member → group-branch integration is never PR-backed.
if (opts?.mergeTargetSource === "branch-group-integration") return false;
return true;
}
/**
* Whether the entity may participate in auto-merge evaluation or response-run
* dispatch. Unverified entities are frozen until the reconcile corroborates them
* (R19) — they are neither auto-merged nor responded to.
*/
export function isPrEntityActionable(entity: Pick<PrEntity, "state" | "unverified">): boolean {
return isPrEntityActive(entity) && !entity.unverified;
}
/**
* Auto-merge green condition (R10): opted in, approved, all checks concluded
* successful (pending is NOT green), mergeable known-clean (UNKNOWN blocks), and
* verified. Re-evaluated by the auto-merge gate after every push.
*/
export function isPrEntityAutoMergeReady(
entity: Pick<PrEntity, "state" | "unverified" | "autoMerge" | "reviewDecision" | "checksRollup" | "mergeable">,
): boolean {
if (!isPrEntityActionable(entity)) return false;
if (!entity.autoMerge) return false;
if (entity.reviewDecision !== "APPROVED") return false;
if (entity.checksRollup !== "success") return false;
// mergeable must be the known-clean state; "unknown"/conflict/undefined all block.
if (entity.mergeable !== "clean") return false;
return true;
}
/**
* Aggregate Review-response-loop activity for a single PR entity (U18, R15).
*
* A lightweight, dependency-free read seam so the Command Center / Mission
* Control can surface what the Review-response loop actually did — threads acted
* on, and the fixed-vs-disagreed split — without each surface re-deriving the
* counts from raw `PrThreadState[]` (and silently disagreeing with one another).
*
* `acted` = fixed + disagreed (threads the loop reached a terminal verdict on).
* `pending` rows are in-flight (recorded before GitHub confirmed) and are NOT
* counted as acted-on. The same discipline that put `isPrEntityAutoMergeReady`
* in @fusion/core keeps this single-sourced.
*/
export interface PrThreadActivity {
/** Total threads with a recorded outcome (fixed + disagreed + pending). */
total: number;
/** Threads the loop reached a terminal verdict on (fixed + disagreed). */
acted: number;
/** Threads fixed (a change was pushed and the thread replied/resolved). */
fixed: number;
/** Threads the loop disagreed on (reasoning posted, thread left open). */
disagreed: number;
/** Threads recorded but not yet GitHub-confirmed (in-flight). */
pending: number;
}
export function summarizePrThreadActivity(threads: PrThreadState[]): PrThreadActivity {
let fixed = 0;
let disagreed = 0;
let pending = 0;
for (const t of threads) {
if (t.outcome === "fixed") fixed += 1;
else if (t.outcome === "disagreed") disagreed += 1;
else if (t.outcome === "pending") pending += 1;
}
return {
total: threads.length,
acted: fixed + disagreed,
fixed,
disagreed,
pending,
};
}
/**
* The live auto-merge gate reason shown next to the toggle (R11). Mirrors the
* auto-merge-ready predicate ordering so every surface (the dashboard route and
* the `fn pr` CLI) reports the same status and never disagrees with what the gate
* will actually do. Shared in @fusion/core (R13) so the two surfaces cannot drift.
*/
export function autoMergeGateReason(
entity: Pick<PrEntity, "state" | "unverified" | "autoMerge" | "reviewDecision" | "checksRollup" | "mergeable">,
): string {
if (!entity.autoMerge) return "Auto-merge off";
if (entity.mergeable === "conflicting") return "Blocked: conflict";
if (entity.reviewDecision !== "APPROVED") return "Waiting for approval";
if (entity.checksRollup !== "success") return "Waiting for checks";
if (entity.mergeable !== "clean") return "Waiting for checks";
if (isPrEntityAutoMergeReady(entity)) return "Ready to merge";
return "Waiting for checks";
}