Skip to content

Commit 83f88db

Browse files
authored
feat(build): allow draft PR body refs-only lint path (#14702) (#14703)
1 parent ae756ce commit 83f88db

4 files changed

Lines changed: 105 additions & 26 deletions

File tree

.agents/skills/pull-request/references/pull-request-workflow.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ Before PR prose, read [`reference-hygiene.md`](../../../../learn/agentos/process
281281
`Resolves #N`. `Closes` and `Fixes` are forbidden; comma-separated
282282
`Resolves #X, #Y` is forbidden. Multiple delivered tickets get one standalone
283283
line each.
284+
- Draft-only exception: `Refs #N` / `Related: #N` may replace `Resolves #N`
285+
only while the PR is draft. Before `ready_for_review`, add the honest delivered
286+
leaf close target or split/file the narrow ticket; that event reruns lint.
284287
- For referenced tickets that must remain open, branch history must also avoid
285288
stale magic-close keywords. Before handoff, run:
286289

.github/workflows/agent-pr-body-lint.yml

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Agent PR Body Lint
22
on:
33
pull_request:
4-
types: [opened, edited, synchronize]
4+
types: [opened, edited, synchronize, ready_for_review]
55

66

77
jobs:
@@ -15,6 +15,7 @@ jobs:
1515
const pr = context.payload.pull_request;
1616
const author = pr.user.login;
1717
const body = pr.body || "";
18+
const isDraft = Boolean(pr.draft);
1819
1920
// Only enforce for known AI agent accounts or PRs labeled with 'ai'
2021
const agentAuthors = ['neo-opus-ada', 'neo-opus-grace', 'neo-opus-vega', 'neo-gemini-pro', 'neo-gpt'];
@@ -76,10 +77,12 @@ jobs:
7677
// superseded / dropped) — that outcome needs NO PR, so a PR must never carry it.
7778
// - `Fixes #N` is FORBIDDEN: ambiguous; use `Resolves` (bug fixes included).
7879
// - `Refs #N` / `Related: #N` are allowed as ADDITIONAL references (e.g. a parent
79-
// epic), and multiple `Resolves` lines are fine — but they never substitute for
80-
// the mandatory `Resolves`.
81-
const hasResolves = /\bResolves:?\s+#\d+/i.test(body);
82-
const forbiddenClose = body.match(/\b(Closes|Fixes):?\s+#\d+/i);
80+
// epic). Draft PRs may temporarily use `Refs #N` / `Related: #N`
81+
// without `Resolves #N`, but `ready_for_review` reruns this workflow and
82+
// restores the mandatory close-target gate before handoff.
83+
const hasResolves = /\bResolves:?\s+#\d+/i.test(body);
84+
const hasNonClosingReference = /\b(Refs|Related):?\s+#\d+/i.test(body);
85+
const forbiddenClose = body.match(/\b(Closes|Fixes):?\s+#\d+/i);
8386
8487
if (forbiddenClose) {
8588
missingVisible.push(
@@ -88,8 +91,10 @@ jobs:
8891
);
8992
}
9093
91-
if (!hasResolves) {
92-
missingVisible.push("`Resolves #N` (mandatory closing keyword — `Refs`/`Related` alone is NOT sufficient)");
94+
if (!hasResolves && !(isDraft && hasNonClosingReference)) {
95+
missingVisible.push(isDraft
96+
? "`Refs #N` or `Related: #N` (draft-only non-closing reference required when `Resolves #N` is absent)"
97+
: "`Resolves #N` (mandatory closing keyword — `Refs`/`Related` alone is NOT sufficient)");
9398
}
9499
95100
if (missingVisible.length === 0 && missingInvisible.length === 0) {

buildScripts/util/agent-preflight.mjs

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ export const INVISIBLE_PR_BODY_ANCHORS = [
2424
];
2525

2626
const
27-
RESOLVES_PATTERN = /\bResolves:?\s+#\d+/i,
28-
FORBIDDEN_CLOSE_PATTERN = /\b(Closes|Fixes):?\s+#\d+/i;
27+
RESOLVES_PATTERN = /\bResolves:?\s+#\d+/i,
28+
NON_CLOSING_REFERENCE_PATTERN = /\b(Refs|Related):?\s+#\d+/i,
29+
FORBIDDEN_CLOSE_PATTERN = /\b(Closes|Fixes):?\s+#\d+/i;
2930

3031
/**
3132
* @summary Builds the Commander program for the agent preflight helper.
@@ -37,6 +38,7 @@ export function createProgram() {
3738
.description('Runs the agent commit/PR preflight gates in one pass; default mode may repair block alignment.')
3839
.usage('[options] [files...]')
3940
.option('--pr-body <file>', 'Run local PR-body template lint against the given markdown file.')
41+
.option('--pr-draft', 'Validate --pr-body as a draft PR: Refs/Related may temporarily stand in for Resolves.')
4042
.option('--no-fix', 'Check-only mode: skip the check-block-alignment --fix repair pass.')
4143
.argument('[files...]', 'Optional file paths. When omitted, staged ACMR files are read from git.')
4244
}
@@ -56,10 +58,11 @@ export function parseArgs(argv) {
5658
const options = program.opts();
5759

5860
return {
59-
files : program.args,
60-
fix : options.fix,
61-
help : false,
62-
prBody: options.prBody || null
61+
files : program.args,
62+
fix : options.fix,
63+
help : false,
64+
prBody : options.prBody || null,
65+
prDraft: options.prDraft || false
6366
}
6467
}
6568

@@ -96,20 +99,26 @@ export function filterMjsFiles(files) {
9699
/**
97100
* @summary Mirrors the Agent PR Body Lint workflow's local body-shape checks.
98101
* @param {String} body
102+
* @param {Object} [options]
103+
* @param {Boolean} [options.draft=false]
99104
* @returns {Object}
100105
*/
101-
export function validatePrBody(body) {
106+
export function validatePrBody(body, {draft = false} = {}) {
102107
const
103-
missingVisible = VISIBLE_PR_BODY_ANCHORS.filter(anchor => !body.includes(anchor)),
104-
missingInvisible = INVISIBLE_PR_BODY_ANCHORS.filter(anchor => !body.includes(anchor)),
105-
forbiddenClose = body.match(FORBIDDEN_CLOSE_PATTERN);
108+
missingVisible = VISIBLE_PR_BODY_ANCHORS.filter(anchor => !body.includes(anchor)),
109+
missingInvisible = INVISIBLE_PR_BODY_ANCHORS.filter(anchor => !body.includes(anchor)),
110+
forbiddenClose = body.match(FORBIDDEN_CLOSE_PATTERN),
111+
hasResolves = RESOLVES_PATTERN.test(body),
112+
hasNonClosingReference = NON_CLOSING_REFERENCE_PATTERN.test(body);
106113

107114
if (forbiddenClose) {
108115
missingVisible.push(`\`${forbiddenClose[1]} #N\` is forbidden; use \`Resolves #N\``)
109116
}
110117

111-
if (!RESOLVES_PATTERN.test(body)) {
112-
missingVisible.push('`Resolves #N` is required')
118+
if (!hasResolves && !(draft && hasNonClosingReference)) {
119+
missingVisible.push(draft
120+
? 'Draft PR bodies without `Resolves #N` require `Refs #N` or `Related: #N`'
121+
: '`Resolves #N` is required')
113122
}
114123

115124
return {
@@ -282,7 +291,7 @@ function runNodeGate({args, cwd, execFileSyncImpl, name}) {
282291
}
283292
}
284293

285-
function runPrBodyGate({cwd, existsSyncImpl, prBody, readFileSyncImpl}) {
294+
function runPrBodyGate({cwd, existsSyncImpl, prBody, prDraft, readFileSyncImpl}) {
286295
const filePath = path.resolve(cwd, prBody);
287296

288297
if (!existsSyncImpl(filePath)) {
@@ -293,7 +302,7 @@ function runPrBodyGate({cwd, existsSyncImpl, prBody, readFileSyncImpl}) {
293302
}
294303
}
295304

296-
return validatePrBody(readFileSyncImpl(filePath, 'utf8'))
305+
return validatePrBody(readFileSyncImpl(filePath, 'utf8'), {draft: prDraft})
297306
}
298307

299308
/**
@@ -387,6 +396,7 @@ export function runAgentPreflight({
387396
cwd,
388397
existsSyncImpl,
389398
prBody: options.prBody,
399+
prDraft: options.prDraft,
390400
readFileSyncImpl
391401
});
392402

test/playwright/unit/ai/buildScripts/util/agent-preflight.spec.mjs

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ const validBody = [
2929
'- None.'
3030
].join('\n');
3131

32+
const draftBody = [
33+
'Refs #12345',
34+
'',
35+
'Authored by Euclid (GPT-5, Codex Desktop). Session test.',
36+
'',
37+
'Evidence: L2 local unit coverage. Draft-only helper; no close target yet.',
38+
'',
39+
'## Deltas from ticket',
40+
'- Draft helper extracted before the leaf close target is complete.',
41+
'',
42+
'## Test Evidence',
43+
'- npm run test-unit -- test/playwright/unit/ai/buildScripts/util/agent-preflight.spec.mjs',
44+
'',
45+
'## Post-Merge Validation',
46+
'- None while draft.'
47+
].join('\n');
48+
3249
test.describe('agent-preflight utility', () => {
3350
test('builds the Commander program with the expected option surface', () => {
3451
const program = createProgram();
@@ -38,16 +55,18 @@ test.describe('agent-preflight utility', () => {
3855
expect(help).toContain('default mode may repair');
3956
expect(help).toContain('block alignment');
4057
expect(help).toContain('--pr-body <file>');
58+
expect(help).toContain('--pr-draft');
4159
expect(help).toContain('--no-fix');
4260
expect(help).toContain('Check-only mode')
4361
});
4462

4563
test('parses files, optional PR body, and fix mode through Commander', () => {
46-
expect(parseArgs(['--pr-body', 'body.md', '--no-fix', 'src/a.mjs'])).toEqual({
47-
files : ['src/a.mjs'],
48-
fix : false,
49-
help : false,
50-
prBody: 'body.md'
64+
expect(parseArgs(['--pr-body', 'body.md', '--pr-draft', '--no-fix', 'src/a.mjs'])).toEqual({
65+
files : ['src/a.mjs'],
66+
fix : false,
67+
help : false,
68+
prBody : 'body.md',
69+
prDraft: true
5170
});
5271
});
5372

@@ -64,6 +83,28 @@ test.describe('agent-preflight utility', () => {
6483
expect(validatePrBody(validBody).valid).toBe(true)
6584
});
6685

86+
test('accepts a draft PR body with a non-closing reference instead of Resolves', () => {
87+
const result = validatePrBody(draftBody, {draft: true});
88+
89+
expect(result.valid).toBe(true);
90+
expect(result.missingVisible).toEqual([]);
91+
expect(result.missingInvisible).toEqual([])
92+
});
93+
94+
test('keeps Refs-only bodies invalid for ready PR validation', () => {
95+
const result = validatePrBody(draftBody);
96+
97+
expect(result.valid).toBe(false);
98+
expect(result.missingVisible).toContain('`Resolves #N` is required')
99+
});
100+
101+
test('requires at least a non-closing issue reference for draft bodies without Resolves', () => {
102+
const result = validatePrBody(draftBody.replace('Refs #12345', 'Draft-only note.'), {draft: true});
103+
104+
expect(result.valid).toBe(false);
105+
expect(result.missingVisible).toContain('Draft PR bodies without `Resolves #N` require `Refs #N` or `Related: #N`')
106+
});
107+
67108
test('reports visible misses and forbidden close keywords without naming structural anchors', () => {
68109
const result = validatePrBody([
69110
'Closes #12345',
@@ -178,6 +219,26 @@ test.describe('agent-preflight utility', () => {
178219
expect(stdout).toContain('agent-preflight: PR body contains the required template anchors.')
179220
});
180221

222+
test('runs the draft PR body gate when requested', () => {
223+
let stdout = '';
224+
let stderr = '';
225+
226+
const status = runAgentPreflight({
227+
argv : ['--pr-body', 'body.md', '--pr-draft'],
228+
cwd : '/repo',
229+
execFileSyncImpl: cmd => cmd === 'git' ? '' : '',
230+
existsSyncImpl : () => true,
231+
readFileSyncImpl: () => draftBody,
232+
stderr : {write: value => { stderr += value }},
233+
stdout : {write: value => { stdout += value }}
234+
});
235+
236+
expect(status).toBe(0);
237+
expect(stderr).toBe('');
238+
expect(stdout).toContain('agent-preflight: 0 .mjs files in scope; skipped source gates.');
239+
expect(stdout).toContain('agent-preflight: PR body contains the required template anchors.')
240+
});
241+
181242
test('returns failure when the local PR body is missing required anchors', () => {
182243
let stderr = '';
183244

0 commit comments

Comments
 (0)