Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/pr-policy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,24 @@ jobs:
files: changedFiles.map((file) => file.filename),
});

const warnings = result.warnings || [];
await core.summary
.addHeading("PR policy")
.addRaw(`Clinical risk: ${result.classification.clinicalRisk ? "yes" : "no"}<br>`)
.addRaw(`Operational risk: ${result.classification.operationalRisk ? "yes" : "no"}<br>`)
.addRaw(`UI change: ${result.classification.ui ? "yes" : "no"}<br>`)
.addRaw(`Blocking issues: ${result.errors.length}<br>`)
.addRaw(`Advisory warnings: ${warnings.length}<br>`)
.write();

// Advisory metadata nudges (title/summary/verification/UI/risk) are
// surfaced but never fail the check. Only a clinical-risk PR with an
// incomplete Clinical Governance Preflight blocks the merge.
for (const warning of warnings) core.warning(warning);

if (!result.ok) {
for (const error of result.errors) core.error(error);
core.setFailed(`PR policy found ${result.errors.length} issue(s). Edit the PR title/body and rerun.`);
core.setFailed(
`PR policy found ${result.errors.length} blocking clinical-governance issue(s). Complete the Clinical Governance Preflight and rerun.`,
);
}
83 changes: 57 additions & 26 deletions scripts/pr-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,12 @@ export function classifyPullRequestFiles(files) {
}

export function evaluatePullRequestPolicy({ title, body, headRef, files }) {
// Only genuine clinical-risk governance gaps block the PR (hard failure).
// Every other metadata expectation (title, summary, verification, UI, risk
// and rollout) is advisory: it is surfaced as a warning so authors still get
// the nudge, but it never fails the check or blocks a merge.
const errors = [];
const warnings = [];
const classification = classifyPullRequestFiles(files);
const summary = section(body, "Summary");
// The summary must be its own prose: content nested under a sub-heading
Expand All @@ -158,24 +163,31 @@ export function evaluatePullRequestPolicy({ title, body, headRef, files }) {
const governance = section(body, "Clinical Governance Preflight");

if (String(title ?? "").trim().length < 12)
errors.push("Use a specific, outcome-focused PR title (at least 12 characters).");
if (branchLikeTitle(title, headRef)) errors.push("Replace the branch-style PR title with an outcome-focused title.");
warnings.push("Use a specific, outcome-focused PR title (at least 12 characters).");
if (branchLikeTitle(title, headRef))
warnings.push("Replace the branch-style PR title with an outcome-focused title.");
if (!meaningfulText(summaryDirect))
errors.push("Complete the `## Summary` section with the outcome and affected area.");
warnings.push("Complete the `## Summary` section with the outcome and affected area.");
if (!meaningfulText(verification)) {
errors.push("Complete the `## Verification` section with exact results or a reason checks were not run.");
warnings.push("Complete the `## Verification` section with exact results or a reason checks were not run.");
} else if (!/-\s*\[[xX]\]/.test(verification) && !explicitNotRun(verification)) {
errors.push("Verification must contain a checked result or an explicit `Verification not run: <reason>` entry.");
warnings.push(
"Verification should contain a checked result or an explicit `Verification not run: <reason>` entry.",
);
}

if (
classification.ui &&
!checkedCommand(verification, "npm run verify:ui") &&
!explicitNotRun(verification, "UI verification")
) {
errors.push("UI changes require checked `npm run verify:ui` evidence or `UI verification not run: <reason>`.");
warnings.push(
"UI changes should include checked `npm run verify:ui` evidence or `UI verification not run: <reason>`.",
);
}

// Blocking gate: a clinical-risk PR must carry a complete Clinical Governance
// Preflight. This is the only condition that fails the check.
if (classification.clinicalRisk) {
if (!meaningfulText(governance)) {
errors.push("Clinical-risk paths require the `## Clinical Governance Preflight` section.");
Expand All @@ -195,18 +207,18 @@ export function evaluatePullRequestPolicy({ title, body, headRef, files }) {

if (classification.clinicalRisk || classification.operationalRisk) {
if (!meaningfulText(riskAndRollout)) {
errors.push("High-risk changes require the `## Risk and rollout` section.");
warnings.push("High-risk changes should include the `## Risk and rollout` section.");
} else {
if (!substantiveRisk(fieldValue(riskAndRollout, "Risk"))) {
errors.push("Risk and rollout must include `Risk: <low|medium|high and rationale>`.");
warnings.push("Risk and rollout should include `Risk: <low|medium|high and rationale>`.");
}
if (!substantiveRollback(fieldValue(riskAndRollout, "Rollback"))) {
errors.push("Risk and rollout must include a concrete `Rollback: <plan>`.");
warnings.push("Risk and rollout should include a concrete `Rollback: <plan>`.");
}
}
}

return { classification, errors, ok: errors.length === 0 };
return { classification, errors, warnings, ok: errors.length === 0 };
}

function selfTest() {
Expand All @@ -221,22 +233,22 @@ function selfTest() {
}).ok,
true,
);
assert.match(
evaluatePullRequestPolicy({
title: "Codex/pr-policy",
body: "",
headRef: "codex/pr-policy",
files: [],
}).errors.join(" "),
/branch-style/,
);
// Advisory metadata issues are surfaced as warnings and NEVER block the check.
const branchStyle = evaluatePullRequestPolicy({
title: "Codex/pr-policy",
body: "",
headRef: "codex/pr-policy",
files: [],
});
assert.match(branchStyle.warnings.join(" "), /branch-style/);
assert.equal(branchStyle.ok, true, "advisory-only issues must not fail the check");
assert.match(
evaluatePullRequestPolicy({
title: "fix: update search behavior",
body: completeBody.replace("- [x] `npm run verify:ui`\n", ""),
headRef: "codex/search-fix",
files: ["src/components/search.tsx"],
}).errors.join(" "),
}).warnings.join(" "),
/verify:ui/,
);
// Outline semantics: a ### sub-heading inside a required section must not
Expand All @@ -258,7 +270,7 @@ function selfTest() {
body: "## Summary\n\n### Verification\n\n- [x] `npm run verify:pr-local`\n",
headRef: "codex/x",
files: ["docs/a.md"],
}).errors.join(" "),
}).warnings.join(" "),
/## Summary/,
);
// ...but a level-1 heading is shallower than ## and DOES end the section, so
Expand All @@ -269,7 +281,7 @@ function selfTest() {
body: completeBody.replace("- [x] `npm run verify:ui`\n", "# Appendix\n\n- [x] `npm run verify:ui`\n"),
headRef: "codex/search-fix",
files: ["src/components/search.tsx"],
}).errors.join(" "),
}).warnings.join(" "),
/verify:ui/,
);
// Tolerant governance: a lightly reworded but still-checked item passes — the
Expand Down Expand Up @@ -324,16 +336,16 @@ function selfTest() {
body: completeBody.replace("Risk: low; metadata-only validation.", "Risk: low"),
headRef: "codex/pr-policy",
files: [".github/workflows/pr-policy.yml"],
}).errors.join(" "),
/Risk and rollout must include/,
}).warnings.join(" "),
/Risk and rollout should include/,
);
assert.match(
evaluatePullRequestPolicy({
title: "ci: enforce pull request evidence",
body: completeBody.replace("Rollback: revert the workflow commit.", "Rollback: none because this is small"),
headRef: "codex/pr-policy",
files: [".github/workflows/pr-policy.yml"],
}).errors.join(" "),
}).warnings.join(" "),
/concrete `Rollback/,
);
assert.match(
Expand All @@ -342,7 +354,7 @@ function selfTest() {
body: "## Summary\n\n- Useful documentation.\n\n## Verification\n\n- [ ] `npm run verify:pr-local`\n<!-- Use `Verification not run: <reason>` when blocked. -->",
headRef: "codex/review-docs",
files: ["docs/process-hardening.md"],
}).errors.join(" "),
}).warnings.join(" "),
/checked result/,
);
assert.deepEqual(classifyPullRequestFiles(["src/app/api/search/route.ts"]), {
Expand Down Expand Up @@ -376,6 +388,25 @@ function selfTest() {
}).ok,
true,
);
// Block-only contract: a completely bare body on non-clinical files raises
// several advisory warnings but never blocks the check.
const bare = evaluatePullRequestPolicy({
title: "x",
body: "",
headRef: "codex/whatever",
files: ["src/components/ui/button.tsx"],
});
assert.equal(bare.ok, true, "non-clinical PRs must never be blocked by advisory metadata gaps");
assert.ok(bare.warnings.length > 0, "advisory gaps should still be surfaced as warnings");
// ...but a clinical-risk PR with no governance section is the one hard block.
const clinicalBlocked = evaluatePullRequestPolicy({
title: "fix: adjust answer synthesis grounding",
body: "## Summary\n\n- Tweak synthesis.",
headRef: "codex/answer-fix",
files: ["src/lib/answer-synthesis.ts"],
});
assert.equal(clinicalBlocked.ok, false, "clinical-risk PRs missing governance must still block");
assert.match(clinicalBlocked.errors.join(" "), /Clinical Governance Preflight/);
assert.equal(
section("### Summary ###\n\n- concise summary\n\n### Verification\n\n- [x] `npm run verify:pr-local`\n", "Summary"),
"- concise summary",
Expand Down