Skip to content

Commit 44e7dd1

Browse files
svelderrainruizGitHub Copilot
andauthored
Issue Milestone Hygiene: restore GH_TOKEN-backed evaluation and failure reporting (#907) (#910)
* Restore milestone hygiene auth and failure reports (#907) * Normalize milestone hygiene fallback inputs (#907) --------- Co-authored-by: GitHub Copilot <copilot@users.noreply.github.com>
1 parent fa412cb commit 44e7dd1

6 files changed

Lines changed: 328 additions & 5 deletions

.github/workflows/issue-milestone-hygiene.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ on:
3838
default: false
3939
type: boolean
4040

41+
concurrency:
42+
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.issue.number || github.ref || github.run_id }}
43+
cancel-in-progress: true
44+
4145
permissions:
4246
contents: read
4347
issues: write
@@ -53,6 +57,8 @@ jobs:
5357
shell: bash
5458
env:
5559
REPO_SLUG: ${{ github.repository }}
60+
GITHUB_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN || github.token }}
61+
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN || github.token }}
5662
APPLY_INPUT: ${{ inputs.apply_default_milestone }}
5763
CREATE_INPUT: ${{ inputs.create_default_milestone }}
5864
DEFAULT_INPUT: ${{ inputs.default_milestone }}
@@ -133,12 +139,17 @@ jobs:
133139
const remaining = report.summary?.remainingViolationCount ?? 0;
134140
const lines = [
135141
'## Issue Milestone Hygiene',
142+
`- execution status: \`${report.execution?.status ?? 'unknown'}\``,
136143
`- remaining violations: \`${remaining}\``,
137144
`- required issues evaluated: \`${report.summary?.requiredIssueCount ?? 0}\``,
138145
`- auto-assigned this run: \`${report.summary?.assignedDefaultMilestoneCount ?? 0}\``,
139146
`- failed assignments: \`${report.summary?.failedAssignmentsCount ?? 0}\``,
140147
`- default milestone created this run: \`${report.milestones?.createdDefaultMilestone ?? false}\``
141148
];
149+
const errors = Array.isArray(report.execution?.errors) ? report.execution.errors : [];
150+
if (errors.length > 0) {
151+
lines.push(`- execution errors: \`${errors.length}\``);
152+
}
142153
if (process.env.GITHUB_STEP_SUMMARY) {
143154
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join('\\n')}\\n`);
144155
}
@@ -151,4 +162,4 @@ jobs:
151162
with:
152163
name: issue-milestone-hygiene-report
153164
path: tests/results/_agent/issue/milestone-hygiene-report.json
154-
if-no-files-found: warn
165+
if-no-files-found: error

docs/schemas/issue-milestone-hygiene-report-v1.schema.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"generatedAt",
1111
"repository",
1212
"state",
13+
"execution",
1314
"flags",
1415
"policy",
1516
"milestones",
@@ -35,6 +36,21 @@
3536
"state": {
3637
"enum": ["open", "all"]
3738
},
39+
"execution": {
40+
"type": "object",
41+
"additionalProperties": false,
42+
"required": ["status", "errors"],
43+
"properties": {
44+
"status": {
45+
"type": "string",
46+
"enum": ["pass", "warn", "fail", "error"]
47+
},
48+
"errors": {
49+
"type": "array",
50+
"items": { "type": "string" }
51+
}
52+
}
53+
},
3854
"flags": {
3955
"type": "object",
4056
"additionalProperties": false,

tools/priority/__tests__/issue-milestone-hygiene-schema.test.mjs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { readFile } from 'node:fs/promises';
99
import { fileURLToPath } from 'node:url';
1010
import Ajv2020 from 'ajv/dist/2020.js';
1111
import addFormats from 'ajv-formats';
12-
import { runMilestoneHygiene } from '../check-issue-milestones.mjs';
12+
import { runMilestoneHygiene, runMilestoneHygieneWithFailureReport } from '../check-issue-milestones.mjs';
1313

1414
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
1515

@@ -93,7 +93,47 @@ test('issue milestone hygiene schema validates generated report and asserts labe
9393

9494
assert.equal(report.flags.createDefaultMilestone, true);
9595
assert.equal(report.flags.applyDefaultMilestone, true);
96+
assert.equal(report.execution.status, 'pass');
9697
assert.equal(report.policy.requiredLabels.includes('program'), true);
9798
assert.equal(report.reconciliations[0].triggers.includes('label:program'), true);
9899
assert.equal(report.summary.triggerCounts['label:program'], 1);
99100
});
101+
102+
test('issue milestone hygiene schema validates generated error report when evaluation aborts early', async () => {
103+
const schemaPath = path.join(repoRoot, 'docs', 'schemas', 'issue-milestone-hygiene-report-v1.schema.json');
104+
const schema = JSON.parse(await readFile(schemaPath, 'utf8'));
105+
106+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'milestone-hygiene-error-schema-'));
107+
const outputPath = path.join(tmpDir, 'report.json');
108+
109+
const result = await runMilestoneHygieneWithFailureReport({
110+
argv: ['--repo', 'example/repo', '--report', outputPath],
111+
now: new Date('2026-03-06T12:00:00Z'),
112+
loadPolicyFn: async () => ({
113+
path: path.join(repoRoot, 'tools', 'policy', 'issue-milestone-hygiene.json'),
114+
required: {
115+
labels: ['standing-priority', 'program'],
116+
titlePriorityPattern: String.raw`\[(P0|P1)\]`,
117+
requireOpenMilestone: true
118+
},
119+
defaultMilestone: null,
120+
defaultMilestoneDueOn: null,
121+
warnOnly: false,
122+
createDefaultMilestone: false
123+
}),
124+
runGhJsonFn: () => {
125+
throw new Error('gh issue list failed: gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable.');
126+
}
127+
});
128+
129+
assert.equal(result.exitCode, 1);
130+
131+
const report = JSON.parse(await readFile(outputPath, 'utf8'));
132+
const ajv = new Ajv2020({ allErrors: true, strict: false });
133+
addFormats(ajv);
134+
const validate = ajv.compile(schema);
135+
const valid = validate(report);
136+
assert.equal(valid, true, JSON.stringify(validate.errors, null, 2));
137+
assert.equal(report.execution.status, 'error');
138+
assert.equal(report.summary.issueCount, 0);
139+
});

tools/priority/__tests__/issue-milestone-hygiene-workflow-contract.test.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ test('issue milestone hygiene workflow has deterministic trigger and artifact co
2020
assert.match(workflow, /-\s*demilestoned/);
2121
assert.match(workflow, /schedule:\s*\n\s*-\s*cron:\s*'25 \* \* \* \*'/);
2222
assert.match(workflow, /workflow_dispatch:/);
23+
assert.match(workflow, /concurrency:\s*\n\s*group:\s+\$\{\{\s*github\.workflow\s*\}\}-\$\{\{\s*github\.event_name\s*\}\}-\$\{\{\s*github\.event\.issue\.number \|\| github\.ref \|\| github\.run_id\s*\}\}/);
24+
assert.match(workflow, /cancel-in-progress:\s+true/);
2325

2426
assert.match(workflow, /apply_default_milestone:/);
2527
assert.match(workflow, /create_default_milestone:/);
@@ -28,9 +30,13 @@ test('issue milestone hygiene workflow has deterministic trigger and artifact co
2830
assert.match(workflow, /warn_only:/);
2931

3032
assert.match(workflow, /issues:\s*write/);
33+
assert.match(workflow, /GITHUB_TOKEN:\s+\$\{\{\s*secrets\.GH_TOKEN \|\| secrets\.GITHUB_TOKEN \|\| github\.token\s*\}\}/);
34+
assert.match(workflow, /GH_TOKEN:\s+\$\{\{\s*secrets\.GH_TOKEN \|\| secrets\.GITHUB_TOKEN \|\| github\.token\s*\}\}/);
3135
assert.match(workflow, /priority:milestone:hygiene/);
3236
assert.match(workflow, /--create-default-milestone/);
3337
assert.match(workflow, /--default-milestone-due-on/);
3438
assert.match(workflow, /tests\/results\/_agent\/issue\/milestone-hygiene-report\.json/);
39+
assert.match(workflow, /execution status/);
3540
assert.match(workflow, /uses:\s+actions\/upload-artifact@v5/);
41+
assert.match(workflow, /if-no-files-found:\s+error/);
3642
});

tools/priority/__tests__/issue-milestone-hygiene.test.mjs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
parseArgs,
99
normalizePolicy,
1010
evaluateIssue,
11-
runMilestoneHygiene
11+
runMilestoneHygiene,
12+
runMilestoneHygieneWithFailureReport
1213
} from '../check-issue-milestones.mjs';
1314

1415
function makeRunGhJson({ issues = [], milestones = [], createdMilestone = null, calls = [] } = {}) {
@@ -124,6 +125,8 @@ test('runMilestoneHygiene fails when required issues have no milestone and emits
124125
});
125126

126127
assert.equal(result.exitCode, 1);
128+
assert.equal(result.report.execution.status, 'fail');
129+
assert.deepEqual(result.report.execution.errors, []);
127130
assert.equal(result.report.flags.requireOpenMilestone, true);
128131
assert.equal(result.report.summary.remainingViolationCount, 1);
129132
assert.equal(result.report.summary.triggerCounts['title-priority'], 1);
@@ -173,6 +176,7 @@ test('runMilestoneHygiene assigns default milestone in apply mode', async () =>
173176
});
174177

175178
assert.equal(result.exitCode, 0);
179+
assert.equal(result.report.execution.status, 'pass');
176180
assert.equal(result.report.summary.initialViolationCount, 1);
177181
assert.equal(result.report.summary.remainingViolationCount, 0);
178182
assert.equal(result.report.summary.assignedDefaultMilestoneCount, 1);
@@ -242,6 +246,7 @@ test('runMilestoneHygiene creates missing default milestone when requested', asy
242246
});
243247

244248
assert.equal(result.exitCode, 0);
249+
assert.equal(result.report.execution.status, 'pass');
245250
assert.equal(result.report.milestones.createdDefaultMilestone, true);
246251
assert.equal(result.report.policy.defaultMilestoneDueOn, '2026-06-30T00:00:00Z');
247252
assert.equal(result.report.summary.assignedDefaultMilestoneCount, 1);
@@ -279,3 +284,73 @@ test('runMilestoneHygiene rejects closed default milestone in strict mode', asyn
279284
/closed/
280285
);
281286
});
287+
288+
test('runMilestoneHygieneWithFailureReport emits an error report when gh evaluation fails early', async () => {
289+
const writes = [];
290+
const result = await runMilestoneHygieneWithFailureReport({
291+
argv: ['--repo', 'example/repo', '--report', 'tests/results/_agent/issue/milestone-hygiene-report.json'],
292+
loadPolicyFn: async () => ({
293+
path: path.join(process.cwd(), 'tools', 'policy', 'issue-milestone-hygiene.json'),
294+
required: {
295+
labels: ['standing-priority', 'program'],
296+
titlePriorityPattern: String.raw`\[(P0|P1)\]`,
297+
requireOpenMilestone: true
298+
},
299+
defaultMilestone: null,
300+
defaultMilestoneDueOn: null,
301+
warnOnly: false,
302+
createDefaultMilestone: false
303+
}),
304+
runGhJsonFn: () => {
305+
throw new Error(
306+
'gh issue list --repo example/repo failed: gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable.'
307+
);
308+
},
309+
writeJsonReportFn: async (reportPath, payload) => {
310+
writes.push({ reportPath, payload });
311+
return reportPath;
312+
}
313+
});
314+
315+
assert.equal(result.exitCode, 1);
316+
assert.equal(result.report.execution.status, 'error');
317+
assert.match(result.report.execution.errors[0], /GH_TOKEN environment variable/i);
318+
assert.equal(result.report.summary.issueCount, 0);
319+
assert.equal(writes.length, 1);
320+
});
321+
322+
test('runMilestoneHygieneWithFailureReport normalizes invalid fallback state and due date values', async () => {
323+
const result = await runMilestoneHygieneWithFailureReport({
324+
argv: [
325+
'--repo',
326+
'example/repo',
327+
'--report',
328+
'tests/results/_agent/issue/milestone-hygiene-report.json',
329+
'--state',
330+
'All',
331+
'--default-milestone-due-on',
332+
'not-a-date'
333+
],
334+
loadPolicyFn: async () => ({
335+
path: path.join(process.cwd(), 'tools', 'policy', 'issue-milestone-hygiene.json'),
336+
required: {
337+
labels: ['standing-priority', 'program'],
338+
titlePriorityPattern: String.raw`\[(P0|P1)\]`,
339+
requireOpenMilestone: true
340+
},
341+
defaultMilestone: null,
342+
defaultMilestoneDueOn: null,
343+
warnOnly: false,
344+
createDefaultMilestone: false
345+
}),
346+
runGhJsonFn: () => {
347+
throw new Error('simulated gh auth failure');
348+
},
349+
writeJsonReportFn: async (reportPath, payload) => ({ reportPath, payload })
350+
});
351+
352+
assert.equal(result.exitCode, 1);
353+
assert.equal(result.report.state, 'all');
354+
assert.equal(result.report.policy.defaultMilestoneDueOn, null);
355+
assert.ok(result.report.execution.errors.some((entry) => /default milestone due date/i.test(entry)));
356+
});

0 commit comments

Comments
 (0)