Skip to content

Commit c4b8af4

Browse files
refactor(queue): extract the public-comment merge-facts derivation from maybePublishPrPublicSurface (#4607) (#5787)
`maybePublishPrPublicSurface` is 2,600+ lines, and #4607's stated cost is that "most cross-cutting review-pipeline concerns live as unnamed inline blocks in one function rather than named, independently-testable steps". This lifts one such block into a named step. `derivePublicCommentMergeFacts` derives the five merge/disposition values the public PR comment renders -- ciState, mergeStateLabel, mergeReadiness, heldForReview, neverClosed -- from the live CI aggregate, the live merge-state refresh, the repo settings and the changed files. Its output IS buildUnifiedCommentBody's input contract for those fields, so the seam is a real step rather than an arbitrary cut. It is pure: the `incr()` metrics emit that sat in the middle of the block stays at the call site, since it reads the gate conclusion the derivation never sees. These flags exist so the COMMENT agrees with the ACTION the disposition planner takes -- heldForReview for a guardrail-touching diff (#guarded-hold-comment), neverClosed for an owner/protected-automation author (#8/#9). Until now they were only reachable by standing up a whole webhook delivery: the renderer's own suite passes them in as hand-written literals, and the queue suites exercise them transitively. They now have direct table tests, including the empty-changed-file-list fail-safe (an unresolved file list HOLDS for review rather than claiming safe-to-merge), which had no test at all. No behavior change: the 882 tests in the existing queue suites pass unchanged.
1 parent 1c4f59e commit c4b8af4

2 files changed

Lines changed: 224 additions & 55 deletions

File tree

src/queue/processors.ts

Lines changed: 74 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1900,6 +1900,71 @@ export async function regatePullRequest(
19001900
}
19011901
}
19021902

1903+
/** The merge/disposition facts the public PR comment renders, derived from the live CI + merge-state refresh
1904+
* (#4607). Extracted out of `maybePublishPrPublicSurface`'s inline body: it is pure, and its output IS the
1905+
* renderer's input contract (`buildUnifiedCommentBody`'s ciState/mergeStateLabel/mergeReadiness/heldForReview/
1906+
* neverClosed arguments), so the seam is a real step rather than an arbitrary cut. */
1907+
export type PublicCommentMergeFacts = {
1908+
ciState: MergeReadiness["ciState"];
1909+
mergeStateLabel: string | undefined;
1910+
mergeReadiness: MergeReadiness;
1911+
heldForReview: boolean;
1912+
neverClosed: boolean;
1913+
};
1914+
1915+
/** Public-safe projection of one failing check: name + short reason only, dropping absent optionals so the
1916+
* rendered chip never carries an `undefined` summary/url. */
1917+
function publicCheckFailureDetails(details: LiveCiAggregate["failingDetails"]): CheckFailureDetail[] {
1918+
return details.map((detail) => ({
1919+
name: detail.name,
1920+
...(detail.summary ? { summary: detail.summary } : {}),
1921+
...(detail.detailsUrl ? { detailsUrl: detail.detailsUrl } : {}),
1922+
}));
1923+
}
1924+
1925+
/**
1926+
* Derive the public comment's merge-readiness + disposition flags (#4607). Pure: no D1, no GitHub client, no
1927+
* metrics — the caller keeps the `incr()` emit, which reads the gate conclusion this function never sees.
1928+
*
1929+
* The two flags exist so the COMMENT agrees with the ACTION the disposition planner will take:
1930+
* - `heldForReview` — a clean, green PR whose diff touches a hard-guardrail path is held for owner review by
1931+
* `planAgentMaintenanceActions`, never auto-merged, so the comment must not headline "safe to merge"
1932+
* (#guarded-hold-comment). Uses the same shared `isGuardrailHit` the planner uses, not a second copy.
1933+
* - `neverClosed` — the disposition never auto-closes a repo-owner or protected-automation PR, so a gate
1934+
* "close" verdict on one must headline "held", not "Closed" (#8/#9).
1935+
*/
1936+
export function derivePublicCommentMergeFacts(args: {
1937+
liveMergeState: string | undefined;
1938+
mergeableState: string | null | undefined;
1939+
authorLogin: string | null | undefined;
1940+
liveCi: Pick<LiveCiAggregate, "ciState" | "failingDetails" | "nonRequiredFailingDetails">;
1941+
settings: Pick<RepositorySettings, "hardGuardrailGlobs" | "hardGuardrailGlobsOverridesInvariants">;
1942+
unifiedFiles: Awaited<ReturnType<typeof listPullRequestFiles>>;
1943+
repoFullName: string;
1944+
}): PublicCommentMergeFacts {
1945+
const mergeStateLabel = args.liveMergeState ?? args.mergeableState ?? undefined; // fail-safe to the stored value
1946+
const ciState: MergeReadiness["ciState"] =
1947+
args.liveCi.ciState === "passed" ? "passed" : args.liveCi.ciState === "failed" ? "failed" : "unverified";
1948+
// Per-failed-check WHY (codecov %/test/lint reason) from each check-run output or commit-status description.
1949+
const failingDetails = publicCheckFailureDetails(args.liveCi.failingDetails);
1950+
// Non-required-but-red checks (#4414-class advisory holds): surfaced so a flagged check is never silently
1951+
// invisible, but never folded into failingChecks/failingDetails -- those two drive ciState/close.
1952+
const nonRequiredFailingDetails = publicCheckFailureDetails(args.liveCi.nonRequiredFailingDetails);
1953+
const mergeReadiness: MergeReadiness = {
1954+
ciState,
1955+
...(mergeStateLabel ? { mergeStateLabel } : {}),
1956+
...(failingDetails.length > 0 ? { failingChecks: failingDetails.map((detail) => detail.name) } : {}),
1957+
...(failingDetails.length > 0 ? { failingDetails } : {}),
1958+
...(nonRequiredFailingDetails.length > 0 ? { nonRequiredFailingDetails } : {}),
1959+
};
1960+
const heldForReview = isGuardrailHit(changedPathsForGuardrail(args.unifiedFiles), resolveHardGuardrailGlobs(args.settings));
1961+
const repoOwner = args.repoFullName.includes("/") ? args.repoFullName.slice(0, args.repoFullName.indexOf("/")) : "";
1962+
const authorLogin = args.authorLogin ?? "";
1963+
const neverClosed =
1964+
(authorLogin.length > 0 && authorLogin.toLowerCase() === repoOwner.toLowerCase()) || isProtectedAutomationAuthor(args.authorLogin);
1965+
return { ciState, mergeStateLabel, mergeReadiness, heldForReview, neverClosed };
1966+
}
1967+
19031968
export function changedPathsForGuardrail(
19041969
files: Awaited<ReturnType<typeof listPullRequestFiles>>,
19051970
): string[] {
@@ -9833,40 +9898,15 @@ async function maybePublishPrPublicSurface(
98339898
// The stored pr.mergeableState lags GitHub's async recompute, and the gate's own check/review publication can
98349899
// also advance mergeability after readiness ran, so refresh at this post-publish boundary.
98359900
const liveMergeState = await refreshLiveMergeState(env, repoFullName, webhook.liveFacts, pr.number, token, admissionKey).catch(() => undefined);
9836-
const mergeStateLabel = liveMergeState ?? pr.mergeableState; // fail-safe to the stored value
9837-
const ciState: MergeReadiness["ciState"] =
9838-
liveCi.ciState === "passed"
9839-
? "passed"
9840-
: liveCi.ciState === "failed"
9841-
? "failed"
9842-
: "unverified";
9843-
// Per-failed-check WHY (codecov %/test/lint reason) from each check-run output or commit-status
9844-
// description — capped + public-safe (name + short reason only). The renderer lists these under the CI chip.
9845-
const failingDetails: CheckFailureDetail[] = liveCi.failingDetails.map(
9846-
(detail) => ({
9847-
name: detail.name,
9848-
...(detail.summary ? { summary: detail.summary } : {}),
9849-
...(detail.detailsUrl ? { detailsUrl: detail.detailsUrl } : {}),
9850-
}),
9851-
);
9852-
// Non-required-but-red checks (#4414-class advisory holds): surfaced so a flagged check is never silently
9853-
// invisible, but never folded into failingChecks/failingDetails -- those two drive ciState/close.
9854-
const nonRequiredFailingDetails: CheckFailureDetail[] = liveCi.nonRequiredFailingDetails.map(
9855-
(detail) => ({
9856-
name: detail.name,
9857-
...(detail.summary ? { summary: detail.summary } : {}),
9858-
...(detail.detailsUrl ? { detailsUrl: detail.detailsUrl } : {}),
9859-
}),
9860-
);
9861-
const mergeReadiness: MergeReadiness = {
9862-
ciState,
9863-
...(mergeStateLabel ? { mergeStateLabel } : {}),
9864-
...(failingDetails.length > 0
9865-
? { failingChecks: failingDetails.map((detail) => detail.name) }
9866-
: {}),
9867-
...(failingDetails.length > 0 ? { failingDetails } : {}),
9868-
...(nonRequiredFailingDetails.length > 0 ? { nonRequiredFailingDetails } : {}),
9869-
};
9901+
const { ciState, mergeStateLabel, mergeReadiness, heldForReview, neverClosed } = derivePublicCommentMergeFacts({
9902+
liveMergeState,
9903+
mergeableState: pr.mergeableState,
9904+
authorLogin: pr.authorLogin,
9905+
liveCi,
9906+
settings,
9907+
unifiedFiles,
9908+
repoFullName,
9909+
});
98709910
// The public comment must match the authoritative Gate check-run conclusion.
98719911
const commentGate = gateEvaluation;
98729912
// Observability (#reviews-dashboard): record the would-be gate verdict so the Grafana panel shows the
@@ -9875,27 +9915,6 @@ async function maybePublishPrPublicSurface(
98759915
repo: repoFullName,
98769916
conclusion: commentGate.conclusion,
98779917
});
9878-
// Guarded-hold (#guarded-hold-comment): a clean+green PR whose diff touches a hard-guardrail path is HELD
9879-
// for owner review by the disposition (planAgentMaintenanceActions), never auto-merged — so the comment
9880-
// must render "held for review", not "✅ safe to merge". Compute the SAME guardrail-hit the disposition uses
9881-
// (shared isGuardrailHit) and thread it so the signal and the action agree (the #4220 class, clean variant).
9882-
const commentHardGuardrailGlobs = resolveHardGuardrailGlobs(settings);
9883-
const heldForReview = isGuardrailHit(
9884-
changedPathsForGuardrail(unifiedFiles),
9885-
commentHardGuardrailGlobs,
9886-
);
9887-
// Held-vs-closed parity (#8/#9): the disposition NEVER auto-closes an owner / automation-bot PR, so a gate
9888-
// "close" verdict on one must headline "held", not "Closed". Compute the same author classification the
9889-
// planner uses (repo-owner login match + protected automation author) and thread it to the comment.
9890-
const commentRepoOwner = repoFullName.includes("/")
9891-
? repoFullName.slice(0, repoFullName.indexOf("/"))
9892-
: "";
9893-
const commentAuthorLogin = pr.authorLogin ?? "";
9894-
const neverClosed =
9895-
(commentAuthorLogin.length > 0 &&
9896-
commentAuthorLogin.toLowerCase() ===
9897-
commentRepoOwner.toLowerCase()) ||
9898-
isProtectedAutomationAuthor(pr.authorLogin);
98999918
const { rows, readinessTotal } = buildPublicPrPanelSignalRows({
99009919
repo,
99019920
pr,
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { derivePublicCommentMergeFacts } from "../../src/queue/processors";
4+
import type { PullRequestFileRecord, RepositorySettings } from "../../src/types";
5+
6+
// `src/rules/**` is one of the ENGINE_DECISION_GUARDRAIL_GLOBS defaults (src/review/guardrail-config.ts), so a
7+
// diff touching it is a hard-guardrail hit; README.md is not guarded by any default glob.
8+
const GUARDED_FILE = { path: "src/rules/advisory.ts" } as PullRequestFileRecord;
9+
const UNGUARDED_FILE = { path: "README.md" } as PullRequestFileRecord;
10+
11+
const NO_GUARDRAIL_OVERRIDES = {
12+
hardGuardrailGlobs: [],
13+
hardGuardrailGlobsOverridesInvariants: false,
14+
} as Pick<RepositorySettings, "hardGuardrailGlobs" | "hardGuardrailGlobsOverridesInvariants">;
15+
16+
function facts(overrides: Partial<Parameters<typeof derivePublicCommentMergeFacts>[0]> = {}) {
17+
return derivePublicCommentMergeFacts({
18+
liveMergeState: "clean",
19+
mergeableState: "dirty",
20+
authorLogin: "contributor",
21+
liveCi: { ciState: "passed", failingDetails: [], nonRequiredFailingDetails: [] },
22+
settings: NO_GUARDRAIL_OVERRIDES,
23+
unifiedFiles: [UNGUARDED_FILE],
24+
repoFullName: "acme/widgets",
25+
...overrides,
26+
});
27+
}
28+
29+
describe("derivePublicCommentMergeFacts() — mergeStateLabel (#4607)", () => {
30+
it("prefers the live merge state, falls back to the stored one, and omits the label when neither is known", () => {
31+
expect(facts({ liveMergeState: "clean", mergeableState: "dirty" }).mergeStateLabel).toBe("clean");
32+
// The live refresh can fail (it is a `.catch(() => undefined)` at the call site) — fail safe to the stored value.
33+
expect(facts({ liveMergeState: undefined, mergeableState: "dirty" }).mergeStateLabel).toBe("dirty");
34+
const unknown = facts({ liveMergeState: undefined, mergeableState: null });
35+
expect(unknown.mergeStateLabel).toBeUndefined();
36+
expect(unknown.mergeReadiness).not.toHaveProperty("mergeStateLabel");
37+
});
38+
});
39+
40+
describe("derivePublicCommentMergeFacts() — ciState (#4607)", () => {
41+
it("passes through passed/failed and collapses everything else to unverified", () => {
42+
expect(facts({ liveCi: { ciState: "passed", failingDetails: [], nonRequiredFailingDetails: [] } }).ciState).toBe("passed");
43+
expect(facts({ liveCi: { ciState: "failed", failingDetails: [], nonRequiredFailingDetails: [] } }).ciState).toBe("failed");
44+
// Both "pending" and "unverified" mean "we cannot claim green" — the comment must never imply a pass.
45+
expect(facts({ liveCi: { ciState: "pending", failingDetails: [], nonRequiredFailingDetails: [] } }).ciState).toBe("unverified");
46+
expect(facts({ liveCi: { ciState: "unverified", failingDetails: [], nonRequiredFailingDetails: [] } }).ciState).toBe("unverified");
47+
});
48+
});
49+
50+
describe("derivePublicCommentMergeFacts() — failing-check projection (#4607)", () => {
51+
it("omits the failing keys entirely when nothing is red", () => {
52+
const { mergeReadiness } = facts();
53+
expect(mergeReadiness).toEqual({ ciState: "passed", mergeStateLabel: "clean" });
54+
});
55+
56+
it("projects name + optional summary/detailsUrl, dropping absent optionals", () => {
57+
const { mergeReadiness } = facts({
58+
liveCi: {
59+
ciState: "failed",
60+
failingDetails: [
61+
{ name: "codecov/patch", summary: "77% of diff hit", detailsUrl: "https://ci.example/1" },
62+
{ name: "lint" },
63+
],
64+
nonRequiredFailingDetails: [],
65+
},
66+
});
67+
expect(mergeReadiness.failingChecks).toEqual(["codecov/patch", "lint"]);
68+
expect(mergeReadiness.failingDetails).toEqual([
69+
{ name: "codecov/patch", summary: "77% of diff hit", detailsUrl: "https://ci.example/1" },
70+
{ name: "lint" },
71+
]);
72+
// An absent summary/detailsUrl must be OMITTED, never rendered as an `undefined` chip.
73+
expect(mergeReadiness.failingDetails?.[1]).not.toHaveProperty("summary");
74+
expect(mergeReadiness.failingDetails?.[1]).not.toHaveProperty("detailsUrl");
75+
expect(mergeReadiness).not.toHaveProperty("nonRequiredFailingDetails");
76+
});
77+
78+
it("keeps non-required red checks visible WITHOUT folding them into failingChecks (#4414-class)", () => {
79+
const { mergeReadiness, ciState } = facts({
80+
liveCi: {
81+
ciState: "passed",
82+
failingDetails: [],
83+
nonRequiredFailingDetails: [{ name: "advisory-scan", summary: "1 note", detailsUrl: "https://ci.example/2" }],
84+
},
85+
});
86+
// The whole point: a non-required red check is surfaced, but it must not turn the PR red or drive close.
87+
expect(ciState).toBe("passed");
88+
expect(mergeReadiness).not.toHaveProperty("failingChecks");
89+
expect(mergeReadiness).not.toHaveProperty("failingDetails");
90+
expect(mergeReadiness.nonRequiredFailingDetails).toEqual([
91+
{ name: "advisory-scan", summary: "1 note", detailsUrl: "https://ci.example/2" },
92+
]);
93+
});
94+
});
95+
96+
describe("derivePublicCommentMergeFacts() — heldForReview (#guarded-hold-comment, #4607)", () => {
97+
it("holds a PR whose diff touches a hard-guardrail path, and does not hold one that doesn't", () => {
98+
expect(facts({ unifiedFiles: [GUARDED_FILE] }).heldForReview).toBe(true);
99+
expect(facts({ unifiedFiles: [UNGUARDED_FILE] }).heldForReview).toBe(false);
100+
expect(facts({ unifiedFiles: [UNGUARDED_FILE, GUARDED_FILE] }).heldForReview).toBe(true);
101+
});
102+
103+
it("fails SAFE: an empty changed-file list holds for review rather than claiming safe-to-merge", () => {
104+
// isGuardrailHit (src/signals/change-guardrail.ts) treats "no known changed paths" as a hit — the file list
105+
// may simply not have resolved, and a false "safe to merge" on an unguarded-looking diff is the dangerous
106+
// direction. Pinned here because the extraction makes this fail-safe reachable in a unit test for the first
107+
// time; previously it could only be hit by standing up a whole webhook delivery.
108+
expect(facts({ unifiedFiles: [] }).heldForReview).toBe(true);
109+
});
110+
111+
it("honours a repo that overrides the invariant guardrail globs away", () => {
112+
expect(
113+
facts({
114+
unifiedFiles: [GUARDED_FILE],
115+
settings: { hardGuardrailGlobs: [], hardGuardrailGlobsOverridesInvariants: true } as Pick<
116+
RepositorySettings,
117+
"hardGuardrailGlobs" | "hardGuardrailGlobsOverridesInvariants"
118+
>,
119+
}).heldForReview,
120+
).toBe(false);
121+
});
122+
});
123+
124+
describe("derivePublicCommentMergeFacts() — neverClosed (#8/#9, #4607)", () => {
125+
it("is true for the repo owner, case-insensitively", () => {
126+
expect(facts({ repoFullName: "acme/widgets", authorLogin: "acme" }).neverClosed).toBe(true);
127+
expect(facts({ repoFullName: "Acme/widgets", authorLogin: "aCmE" }).neverClosed).toBe(true);
128+
});
129+
130+
it("is true for a protected automation author who is NOT the owner", () => {
131+
expect(facts({ authorLogin: "dependabot[bot]" }).neverClosed).toBe(true);
132+
expect(facts({ authorLogin: "github-actions[bot]" }).neverClosed).toBe(true);
133+
});
134+
135+
it("is false for an ordinary contributor", () => {
136+
expect(facts({ authorLogin: "contributor" }).neverClosed).toBe(false);
137+
});
138+
139+
it("is false — never accidentally true — when the author login is missing", () => {
140+
// Guard against the empty-string trap: a malformed repoFullName yields an empty owner, and "" === "" would
141+
// otherwise make an author-less PR look like the repo owner and become un-closable.
142+
expect(facts({ authorLogin: null, repoFullName: "no-slash-name" }).neverClosed).toBe(false);
143+
expect(facts({ authorLogin: undefined, repoFullName: "acme/widgets" }).neverClosed).toBe(false);
144+
expect(facts({ authorLogin: "", repoFullName: "no-slash-name" }).neverClosed).toBe(false);
145+
});
146+
147+
it("treats a repoFullName with no owner segment as having no owner", () => {
148+
expect(facts({ repoFullName: "no-slash-name", authorLogin: "contributor" }).neverClosed).toBe(false);
149+
});
150+
});

0 commit comments

Comments
 (0)