Skip to content

Commit 312cb24

Browse files
committed
fix(ci): address Codex findings on PR quality gates
Reject untouched PR templates, require low ahead-of-main for ancestry, and checkpoint draft/title ownership before mutations.
1 parent 71acb9d commit 312cb24

5 files changed

Lines changed: 167 additions & 22 deletions

File tree

.github/scripts/pr-quality.cjs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,37 @@ const {
88
} = require(path.join(__dirname, "issue-quality.cjs"));
99

1010
const ANCESTRY_BEHIND_THRESHOLD = 20;
11+
/** Cap on ahead_by vs main so stale `dev` forks (many commits ahead of main) are not flagged. */
12+
const ANCESTRY_AHEAD_MAIN_MAX = 5;
1113
const MIN_SECTION_LEN = 40;
1214
const MIN_RICH_SECTIONS = 2;
1315
const UNSTRUCTURED_MIN_LEN = 120;
1416
const UNSTRUCTURED_MIN_BLOCKS = 2;
1517

16-
function isWrongAncestry({ behindMain, behindBase, threshold = ANCESTRY_BEHIND_THRESHOLD }) {
17-
return behindMain === 0 && behindBase >= threshold;
18+
/**
19+
* Exact instruction / checklist lines from `.github/PULL_REQUEST_TEMPLATE.md`.
20+
* Untouched templates must not count as substance.
21+
*/
22+
const PR_TEMPLATE_BOILERPLATE_LINES = new Set([
23+
"explain the user-visible or maintainer-facing change.",
24+
"list the commands or checks you ran.",
25+
"scope stays focused and avoids unrelated cleanup.",
26+
"docs or release notes were updated when needed.",
27+
"security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.",
28+
]);
29+
30+
function isWrongAncestry({
31+
behindMain,
32+
behindBase,
33+
aheadMain = 0,
34+
threshold = ANCESTRY_BEHIND_THRESHOLD,
35+
aheadMainMax = ANCESTRY_AHEAD_MAIN_MAX,
36+
}) {
37+
return (
38+
behindMain === 0 &&
39+
behindBase >= threshold &&
40+
aheadMain <= aheadMainMax
41+
);
1842
}
1943

2044
function authorHasPushPermission(permission) {
@@ -45,16 +69,40 @@ function countContentBlocks(text) {
4569
return Math.max(blocks.length, bullets.length);
4670
}
4771

72+
function normalizeTemplateLine(line) {
73+
return line
74+
.replace(/^\s*[-*+]\s+/, "")
75+
.replace(/^\s*\[[ xX]\]\s+/, "")
76+
.replace(/^\s*#{1,6}\s+/, "")
77+
.trim()
78+
.toLowerCase();
79+
}
80+
81+
/** Drop stock PR template headings, instructions, and checklist lines. */
82+
function stripPrTemplateBoilerplate(text) {
83+
return text
84+
.split("\n")
85+
.filter((line) => {
86+
const normalized = normalizeTemplateLine(line);
87+
if (!normalized) return true;
88+
if (PR_TEMPLATE_BOILERPLATE_LINES.has(normalized)) return false;
89+
if (/^(summary|verification|checklist)$/.test(normalized)) return false;
90+
return true;
91+
})
92+
.join("\n");
93+
}
94+
4895
function assessPrDescription(body) {
4996
if (typeof body !== "string" || !body.trim()) {
5097
return { ok: false, reason: "empty" };
5198
}
5299
if (hasEscapedNewlines(body)) {
53100
return { ok: false, reason: "escaped_newlines" };
54101
}
55-
const cleaned = clean(body);
102+
const withoutTemplate = stripPrTemplateBoilerplate(body);
103+
const cleaned = clean(withoutTemplate);
56104
if (!cleaned) {
57-
const strippedComments = body.replace(/<!--[\s\S]*?-->/g, "").trim();
105+
const strippedComments = withoutTemplate.replace(/<!--[\s\S]*?-->/g, "").trim();
58106
if (!strippedComments) return { ok: false, reason: "empty" };
59107
if (isPlaceholderOnlyValue(strippedComments)) {
60108
return { ok: false, reason: "placeholder" };
@@ -82,6 +130,7 @@ function collectPrQualityFailures({
82130
body,
83131
behindMain,
84132
behindBase,
133+
aheadMain = 0,
85134
authorPermission,
86135
permissionLookupFailed = false,
87136
ancestryLookupFailed = false,
@@ -96,7 +145,10 @@ function collectPrQualityFailures({
96145
const skipAncestry =
97146
ancestryLookupFailed ||
98147
(!permissionLookupFailed && authorHasPushPermission(authorPermission));
99-
if (!skipAncestry && isWrongAncestry({ behindMain, behindBase })) {
148+
if (
149+
!skipAncestry &&
150+
isWrongAncestry({ behindMain, behindBase, aheadMain })
151+
) {
100152
failures.push({ code: "wrong_ancestry" });
101153
}
102154
}
@@ -110,9 +162,11 @@ function collectPrQualityFailures({
110162

111163
module.exports = {
112164
ANCESTRY_BEHIND_THRESHOLD,
165+
ANCESTRY_AHEAD_MAIN_MAX,
113166
isWrongAncestry,
114167
authorHasPushPermission,
115168
assessPrDescription,
116169
collectPrQualityFailures,
117170
hasEscapedNewlines,
171+
stripPrTemplateBoilerplate,
118172
};

.github/scripts/pr-quality.test.cjs

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,28 @@ const {
1111
} = require("./pr-quality.cjs");
1212

1313
describe("isWrongAncestry", () => {
14-
it("flags #644-shaped compares (0 behind main, far behind base)", () => {
14+
it("flags #644-shaped compares (0 behind main, far behind base, few ahead of main)", () => {
1515
assert.equal(
16-
isWrongAncestry({ behindMain: 0, behindBase: 44 }),
16+
isWrongAncestry({ behindMain: 0, behindBase: 44, aheadMain: 1 }),
1717
true,
1818
);
1919
});
2020

2121
it("uses threshold 20 by default", () => {
2222
assert.equal(ANCESTRY_BEHIND_THRESHOLD, 20);
23-
assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 20 }), true);
24-
assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 19 }), false);
23+
assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 20, aheadMain: 1 }), true);
24+
assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 19, aheadMain: 1 }), false);
2525
});
2626

2727
it("passes when head is behind main (not sitting on main tip)", () => {
28-
assert.equal(isWrongAncestry({ behindMain: 1, behindBase: 44 }), false);
28+
assert.equal(isWrongAncestry({ behindMain: 1, behindBase: 44, aheadMain: 1 }), false);
29+
});
30+
31+
it("passes stale dev-based branches that are many commits ahead of main", () => {
32+
assert.equal(
33+
isWrongAncestry({ behindMain: 0, behindBase: 44, aheadMain: 50 }),
34+
false,
35+
);
2936
});
3037
});
3138

@@ -65,6 +72,27 @@ describe("assessPrDescription", () => {
6572
assert.equal(assessPrDescription("fix stuff").reason, "thin");
6673
});
6774

75+
it("rejects an untouched GitHub PR template as empty/thin", () => {
76+
const body = [
77+
"## Summary",
78+
"",
79+
"- Explain the user-visible or maintainer-facing change.",
80+
"",
81+
"## Verification",
82+
"",
83+
"- List the commands or checks you ran.",
84+
"",
85+
"## Checklist",
86+
"",
87+
"- [ ] Scope stays focused and avoids unrelated cleanup.",
88+
"- [ ] Docs or release notes were updated when needed.",
89+
"- [ ] Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.",
90+
].join("\n");
91+
const result = assessPrDescription(body);
92+
assert.equal(result.ok, false);
93+
assert.ok(result.reason === "empty" || result.reason === "thin");
94+
});
95+
6896
it("accepts two rich markdown sections", () => {
6997
const body = [
7098
"## Summary",
@@ -130,6 +158,7 @@ describe("collectPrQualityFailures", () => {
130158
].join("\n"),
131159
behindMain: 0,
132160
behindBase: 44,
161+
aheadMain: 1,
133162
authorPermission: "read",
134163
});
135164
assert.deepEqual(
@@ -145,6 +174,7 @@ describe("collectPrQualityFailures", () => {
145174
body: "",
146175
behindMain: 0,
147176
behindBase: 44,
177+
aheadMain: 1,
148178
authorPermission: "write",
149179
});
150180
assert.ok(!failures.some((f) => f.code === "wrong_ancestry"));
@@ -165,12 +195,33 @@ describe("collectPrQualityFailures", () => {
165195
].join("\n"),
166196
behindMain: 0,
167197
behindBase: 44,
198+
aheadMain: 1,
168199
authorPermission: null,
169200
permissionLookupFailed: true,
170201
});
171202
assert.ok(failures.some((f) => f.code === "wrong_ancestry"));
172203
});
173204

205+
it("does not flag stale dev-based branches that are far ahead of main", () => {
206+
const failures = collectPrQualityFailures({
207+
baseRef: "dev",
208+
allowedBases: allowed,
209+
body: [
210+
"## Summary",
211+
"This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.",
212+
"",
213+
"## Test plan",
214+
"- Launch the tray app after setting CODEX_HOME",
215+
"- Confirm the listener and launcher use the same workspace root",
216+
].join("\n"),
217+
behindMain: 0,
218+
behindBase: 44,
219+
aheadMain: 50,
220+
authorPermission: "read",
221+
});
222+
assert.ok(!failures.some((f) => f.code === "wrong_ancestry"));
223+
});
224+
174225
it("skips ancestry when compare lookup failed (cannot evaluate)", () => {
175226
const failures = collectPrQualityFailures({
176227
baseRef: "dev",
@@ -185,6 +236,7 @@ describe("collectPrQualityFailures", () => {
185236
].join("\n"),
186237
behindMain: 0,
187238
behindBase: 0,
239+
aheadMain: 0,
188240
authorPermission: "read",
189241
ancestryLookupFailed: true,
190242
});

.github/workflows/enforce-pr-target.yml

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ jobs:
277277
278278
let behindMain = 0;
279279
let behindBase = 0;
280+
let aheadMain = 0;
280281
let ancestryLookupFailed = false;
281282
const baseAllowed = ALLOWED_BASES.includes(pr.base.ref);
282283
@@ -290,6 +291,7 @@ jobs:
290291
basehead: `main...${headSha}`
291292
});
292293
behindMain = mainCompare.behind_by;
294+
aheadMain = mainCompare.ahead_by;
293295
294296
const { data: baseCompare } =
295297
await github.rest.repos.compareCommitsWithBasehead({
@@ -312,6 +314,7 @@ jobs:
312314
body: pr.body,
313315
behindMain,
314316
behindBase,
317+
aheadMain,
315318
authorPermission,
316319
permissionLookupFailed,
317320
ancestryLookupFailed
@@ -346,10 +349,10 @@ jobs:
346349
state.titlePrefixedByBot &&
347350
pr.title.startsWith(TITLE_PREFIX);
348351
352+
// Claim title ownership before adding a prefix. Do not clear
353+
// ownership until strip succeeds — a failed update must retry.
349354
if (willPrefixTitle) {
350355
state.titlePrefixedByBot = true;
351-
} else if (shouldStripTitlePrefix) {
352-
state.titlePrefixedByBot = false;
353356
}
354357
355358
let draftConversionFailed = false;
@@ -380,12 +383,35 @@ jobs:
380383
pull_number,
381384
title: pr.title.slice(TITLE_PREFIX.length)
382385
});
386+
state.titlePrefixedByBot = false;
387+
await upsertComment(
388+
[
389+
COMMENT_MARKER,
390+
stateMarker(state),
391+
"",
392+
...failureSections,
393+
"",
394+
"Stale title prefix removed; continuing…"
395+
].join("\n")
396+
);
383397
}
384398
385399
if (!pr.draft) {
400+
// Claim draft ownership before the mutation so a successful
401+
// convert followed by a failed comment still restores later.
402+
state.autoDraftedByBot = true;
403+
await upsertComment(
404+
[
405+
COMMENT_MARKER,
406+
stateMarker(state),
407+
"",
408+
...failureSections,
409+
"",
410+
"Draft conversion pending…"
411+
].join("\n")
412+
);
386413
try {
387414
await convertToDraft();
388-
state.autoDraftedByBot = true;
389415
await upsertComment(
390416
[
391417
COMMENT_MARKER,

0 commit comments

Comments
 (0)