Skip to content

Commit c50eb32

Browse files
committed
chore(governance): enforce issues governance checks
1 parent f9f6738 commit c50eb32

1 file changed

Lines changed: 223 additions & 0 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
name: Issues Governance
2+
3+
on:
4+
issues:
5+
types: [opened, edited, reopened, labeled, unlabeled]
6+
schedule:
7+
- cron: "17 3 * * 1"
8+
workflow_dispatch:
9+
10+
permissions:
11+
issues: write
12+
contents: read
13+
14+
jobs:
15+
validate_issue_event:
16+
if: github.event_name == 'issues'
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Ensure governance label exists
20+
uses: actions/github-script@v7
21+
with:
22+
script: |
23+
try {
24+
await github.rest.issues.getLabel({
25+
owner: context.repo.owner,
26+
repo: context.repo.repo,
27+
name: 'governance:needs-triage'
28+
});
29+
} catch (e) {
30+
if (e.status === 404) {
31+
await github.rest.issues.createLabel({
32+
owner: context.repo.owner,
33+
repo: context.repo.repo,
34+
name: 'governance:needs-triage',
35+
color: 'B60205',
36+
description: 'Issue fails governance policy checks and needs triage'
37+
});
38+
} else {
39+
throw e;
40+
}
41+
}
42+
43+
- name: Validate issue policy
44+
uses: actions/github-script@v7
45+
with:
46+
script: |
47+
const issue = context.payload.issue;
48+
const title = (issue.title || '').trim();
49+
const body = (issue.body || '').toLowerCase();
50+
const labels = (issue.labels || []).map(l => (l.name || '').toLowerCase());
51+
52+
const violations = [];
53+
54+
// Title policy: plain imperative, no pseudo-prefixes / commit-style prefixes
55+
const disallowedTitlePrefix = /^(?:\[[^\]]+\]\s*|ticket-\d+\s*:\s*|[a-z]+-\d+\s*:\s*|(?:feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert|content|research|epic)\([^\)]+\)\s*:\s*)/i;
56+
if (!title) {
57+
violations.push('Title is required.');
58+
} else {
59+
if (title.length > 72) violations.push('Title must be 72 characters or fewer.');
60+
if (disallowedTitlePrefix.test(title)) {
61+
violations.push('Title must be plain imperative text (no prefixes like TICKET-123, [BUG], or type(scope):).');
62+
}
63+
}
64+
65+
// Required labels
66+
const hasType = labels.some(l => l.startsWith('type:'));
67+
const hasPriority = labels.some(l => l.startsWith('priority:'));
68+
const hasArea = labels.some(l => l.startsWith('area:'));
69+
if (!hasType) violations.push('Missing a type label (`type:*`).');
70+
if (!hasPriority) violations.push('Missing a priority label (`priority:*`).');
71+
if (!hasArea) violations.push('Missing an area label (`area:*`).');
72+
73+
// Body policy
74+
if (!body.includes('acceptance criteria')) {
75+
violations.push('Body must include an "Acceptance criteria" section.');
76+
}
77+
if (!(body.includes('problem') || body.includes('objective'))) {
78+
violations.push('Body must include a "Problem" or "Objective" section.');
79+
}
80+
if (!body.includes('impact')) {
81+
violations.push('Body should include an "Impact" section.');
82+
}
83+
84+
const needsLabel = 'governance:needs-triage';
85+
const hasNeedsLabel = labels.includes(needsLabel);
86+
87+
if (violations.length > 0) {
88+
if (!hasNeedsLabel) {
89+
await github.rest.issues.addLabels({
90+
owner: context.repo.owner,
91+
repo: context.repo.repo,
92+
issue_number: issue.number,
93+
labels: [needsLabel]
94+
});
95+
}
96+
97+
const marker = '<!-- issues-governance-comment -->';
98+
const bodyText = `${marker}
99+
❌ This issue is missing required governance fields:
100+
101+
${violations.map(v => `- [ ] ${v}`).join('
102+
')}
103+
104+
Please update the issue. This label will auto-clear when checks pass.`;
105+
106+
// Update prior bot comment if present; else create one
107+
const { data: comments } = await github.rest.issues.listComments({
108+
owner: context.repo.owner,
109+
repo: context.repo.repo,
110+
issue_number: issue.number,
111+
per_page: 100
112+
});
113+
const existing = comments.find(c => c.user?.type === 'Bot' && (c.body || '').includes(marker));
114+
if (existing) {
115+
await github.rest.issues.updateComment({
116+
owner: context.repo.owner,
117+
repo: context.repo.repo,
118+
comment_id: existing.id,
119+
body: bodyText
120+
});
121+
} else {
122+
await github.rest.issues.createComment({
123+
owner: context.repo.owner,
124+
repo: context.repo.repo,
125+
issue_number: issue.number,
126+
body: bodyText
127+
});
128+
}
129+
130+
core.setFailed(`Issue #${issue.number} failed governance checks.`);
131+
} else {
132+
if (hasNeedsLabel) {
133+
await github.rest.issues.removeLabel({
134+
owner: context.repo.owner,
135+
repo: context.repo.repo,
136+
issue_number: issue.number,
137+
name: needsLabel
138+
}).catch(() => {});
139+
}
140+
}
141+
142+
scheduled_audit:
143+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
144+
runs-on: ubuntu-latest
145+
steps:
146+
- name: Ensure governance label exists
147+
uses: actions/github-script@v7
148+
with:
149+
script: |
150+
try {
151+
await github.rest.issues.getLabel({
152+
owner: context.repo.owner,
153+
repo: context.repo.repo,
154+
name: 'governance:needs-triage'
155+
});
156+
} catch (e) {
157+
if (e.status === 404) {
158+
await github.rest.issues.createLabel({
159+
owner: context.repo.owner,
160+
repo: context.repo.repo,
161+
name: 'governance:needs-triage',
162+
color: 'B60205',
163+
description: 'Issue fails governance policy checks and needs triage'
164+
});
165+
} else {
166+
throw e;
167+
}
168+
}
169+
170+
- name: Audit open issues
171+
uses: actions/github-script@v7
172+
with:
173+
script: |
174+
const issues = await github.paginate(github.rest.issues.listForRepo, {
175+
owner: context.repo.owner,
176+
repo: context.repo.repo,
177+
state: 'open',
178+
per_page: 100
179+
});
180+
181+
const disallowedTitlePrefix = /^(?:\[[^\]]+\]\s*|ticket-\d+\s*:\s*|[a-z]+-\d+\s*:\s*|(?:feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert|content|research|epic)\([^\)]+\)\s*:\s*)/i;
182+
let flagged = 0;
183+
184+
for (const issue of issues) {
185+
if (issue.pull_request) continue;
186+
const title = (issue.title || '').trim();
187+
const body = (issue.body || '').toLowerCase();
188+
const labels = (issue.labels || []).map(l => (l.name || '').toLowerCase());
189+
190+
let bad = false;
191+
if (!title || title.length > 72 || disallowedTitlePrefix.test(title)) bad = true;
192+
if (!labels.some(l => l.startsWith('type:'))) bad = true;
193+
if (!labels.some(l => l.startsWith('priority:'))) bad = true;
194+
if (!labels.some(l => l.startsWith('area:'))) bad = true;
195+
if (!body.includes('acceptance criteria')) bad = true;
196+
if (!(body.includes('problem') || body.includes('objective'))) bad = true;
197+
198+
const hasNeeds = labels.includes('governance:needs-triage');
199+
if (bad && !hasNeeds) {
200+
await github.rest.issues.addLabels({
201+
owner: context.repo.owner,
202+
repo: context.repo.repo,
203+
issue_number: issue.number,
204+
labels: ['governance:needs-triage']
205+
});
206+
flagged += 1;
207+
}
208+
if (!bad && hasNeeds) {
209+
await github.rest.issues.removeLabel({
210+
owner: context.repo.owner,
211+
repo: context.repo.repo,
212+
issue_number: issue.number,
213+
name: 'governance:needs-triage'
214+
}).catch(() => {});
215+
}
216+
}
217+
218+
core.summary
219+
.addHeading('Issues Governance Audit')
220+
.addRaw(`Open issues scanned: ${issues.filter(i => !i.pull_request).length}`)
221+
.addBreak()
222+
.addRaw(`Newly flagged: ${flagged}`)
223+
.write();

0 commit comments

Comments
 (0)