@@ -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+
19031968export 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,
0 commit comments