Skip to content

Commit 9c83077

Browse files
committed
feat: add automated CI failure notifier workflow to label and comment on failing PRs
1 parent cb877e7 commit 9c83077

1 file changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
name: CI Failure Notifier
2+
3+
on:
4+
workflow_run:
5+
workflows: ['CI Pipeline']
6+
types: [completed]
7+
workflow_dispatch: # Manually scan ALL open PRs from the Actions tab
8+
9+
permissions:
10+
issues: write
11+
pull-requests: write
12+
13+
jobs:
14+
notify-ci-failure:
15+
# Only act when CI failed and was triggered by a pull_request event
16+
if: >
17+
github.event.workflow_run.conclusion == 'failure' &&
18+
github.event.workflow_run.event == 'pull_request'
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Add label and comment on failing PR
22+
uses: actions/github-script@v7
23+
with:
24+
script: |
25+
const label = 'status:blocked';
26+
const run = context.payload.workflow_run;
27+
28+
// Retrieve the PR associated with this workflow run
29+
const prs = run.pull_requests;
30+
if (!prs || prs.length === 0) {
31+
core.warning('No pull requests associated with this workflow run. Skipping.');
32+
return;
33+
}
34+
35+
const pr = prs[0];
36+
const prNumber = pr.number;
37+
38+
// Fetch full PR data to get author and current labels
39+
const { data: fullPR } = await github.rest.pulls.get({
40+
owner: context.repo.owner,
41+
repo: context.repo.repo,
42+
pull_number: prNumber,
43+
});
44+
45+
const author = fullPR.user.login;
46+
const currentLabels = fullPR.labels.map(l => l.name);
47+
48+
// Ensure the label exists in the repo
49+
try {
50+
await github.rest.issues.getLabel({
51+
owner: context.repo.owner,
52+
repo: context.repo.repo,
53+
name: label,
54+
});
55+
} catch {
56+
await github.rest.issues.createLabel({
57+
owner: context.repo.owner,
58+
repo: context.repo.repo,
59+
name: label,
60+
color: 'dc2626',
61+
description: 'This PR is blocked due to a failing CI check.',
62+
});
63+
}
64+
65+
// Apply the label if not already present
66+
if (!currentLabels.includes(label)) {
67+
await github.rest.issues.addLabels({
68+
owner: context.repo.owner,
69+
repo: context.repo.repo,
70+
issue_number: prNumber,
71+
labels: [label],
72+
});
73+
}
74+
75+
// Check for an existing failure comment to avoid spamming
76+
const { data: comments } = await github.rest.issues.listComments({
77+
owner: context.repo.owner,
78+
repo: context.repo.repo,
79+
issue_number: prNumber,
80+
per_page: 100,
81+
});
82+
83+
const alreadyCommented = comments.some(c =>
84+
c.user?.login === 'github-actions[bot]' &&
85+
c.body?.includes('CI Pipeline is failing')
86+
);
87+
88+
if (!alreadyCommented) {
89+
const runUrl = run.html_url;
90+
91+
await github.rest.issues.createComment({
92+
owner: context.repo.owner,
93+
repo: context.repo.repo,
94+
issue_number: prNumber,
95+
body: `🚨 Hey @${author}, the **CI Pipeline is failing** on this PR and it has been marked as \`status:blocked\`.
96+
97+
Please fix the issues before this can be reviewed. Here's how:
98+
99+
**1. Run checks locally before pushing:**
100+
\`\`\`bash
101+
npm run format:check # Check Prettier formatting
102+
npm run lint # Run ESLint
103+
npm run typecheck # TypeScript type check
104+
npm run test # Run unit tests (Vitest)
105+
npm run build # Verify production build passes
106+
\`\`\`
107+
108+
**2. Auto-fix common issues:**
109+
\`\`\`bash
110+
npm run format # Auto-fix formatting with Prettier
111+
npm run lint -- --fix # Auto-fix lint errors where possible
112+
\`\`\`
113+
114+
**3. Check the full failure log here:**
115+
👉 [View CI Run](${runUrl})
116+
117+
Once you push a fix and the CI passes, the \`status:blocked\` label will be removed automatically. 💪`,
118+
});
119+
}
120+
121+
- name: Remove blocked label when CI passes
122+
if: github.event.workflow_run.conclusion == 'success'
123+
uses: actions/github-script@v7
124+
with:
125+
script: |
126+
const label = 'status:blocked';
127+
const run = context.payload.workflow_run;
128+
const prs = run.pull_requests;
129+
130+
if (!prs || prs.length === 0) return;
131+
132+
const prNumber = prs[0].number;
133+
134+
const { data: fullPR } = await github.rest.pulls.get({
135+
owner: context.repo.owner,
136+
repo: context.repo.repo,
137+
pull_number: prNumber,
138+
});
139+
140+
const hasLabel = fullPR.labels.some(l => l.name === label);
141+
if (hasLabel) {
142+
await github.rest.issues.removeLabel({
143+
owner: context.repo.owner,
144+
repo: context.repo.repo,
145+
issue_number: prNumber,
146+
name: label,
147+
});
148+
core.info(`Removed '${label}' from PR #${prNumber} — CI is now passing.`);
149+
}
150+
151+
# ── Manual scan: runs when triggered from the Actions tab ─────────────────
152+
scan-all-prs:
153+
if: github.event_name == 'workflow_dispatch'
154+
runs-on: ubuntu-latest
155+
steps:
156+
- name: Scan all open PRs for CI failures
157+
uses: actions/github-script@v7
158+
with:
159+
script: |
160+
const label = 'status:blocked';
161+
162+
// Ensure the label exists
163+
try {
164+
await github.rest.issues.getLabel({
165+
owner: context.repo.owner,
166+
repo: context.repo.repo,
167+
name: label,
168+
});
169+
} catch {
170+
await github.rest.issues.createLabel({
171+
owner: context.repo.owner,
172+
repo: context.repo.repo,
173+
name: label,
174+
color: 'dc2626',
175+
description: 'This PR is blocked due to a failing CI check.',
176+
});
177+
}
178+
179+
// Fetch all open PRs
180+
const { data: openPRs } = await github.rest.pulls.list({
181+
owner: context.repo.owner,
182+
repo: context.repo.repo,
183+
state: 'open',
184+
per_page: 100,
185+
});
186+
187+
core.info(`Scanning ${openPRs.length} open PRs...`);
188+
189+
for (const pr of openPRs) {
190+
// Get the latest CI Pipeline run for this PR's HEAD commit
191+
const { data: runsData } = await github.rest.actions.listWorkflowRunsForRepo({
192+
owner: context.repo.owner,
193+
repo: context.repo.repo,
194+
head_sha: pr.head.sha,
195+
per_page: 20,
196+
});
197+
198+
// Find the most recent completed run of our CI pipeline
199+
const ciRun = runsData.workflow_runs
200+
.filter(r => r.name === 'CI Pipeline' && r.status === 'completed')
201+
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))[0];
202+
203+
const currentLabels = pr.labels.map(l => l.name);
204+
const hasBlockedLabel = currentLabels.includes(label);
205+
206+
if (!ciRun) {
207+
core.info(`PR #${pr.number}: No completed CI run found. Skipping.`);
208+
continue;
209+
}
210+
211+
core.info(`PR #${pr.number} (@${pr.user.login}): CI conclusion = ${ciRun.conclusion}`);
212+
213+
if (ciRun.conclusion === 'failure') {
214+
// Apply label
215+
if (!hasBlockedLabel) {
216+
await github.rest.issues.addLabels({
217+
owner: context.repo.owner,
218+
repo: context.repo.repo,
219+
issue_number: pr.number,
220+
labels: [label],
221+
});
222+
}
223+
224+
// Avoid duplicate comments
225+
const { data: comments } = await github.rest.issues.listComments({
226+
owner: context.repo.owner,
227+
repo: context.repo.repo,
228+
issue_number: pr.number,
229+
per_page: 100,
230+
});
231+
232+
const alreadyCommented = comments.some(c =>
233+
c.user?.login === 'github-actions[bot]' &&
234+
c.body?.includes('CI Pipeline is failing')
235+
);
236+
237+
if (!alreadyCommented) {
238+
await github.rest.issues.createComment({
239+
owner: context.repo.owner,
240+
repo: context.repo.repo,
241+
issue_number: pr.number,
242+
body: `🚨 Hey @${pr.user.login}, the **CI Pipeline is failing** on this PR and it has been marked as \`status:blocked\`.
243+
244+
Please fix the issues before this can be reviewed. Here's how:
245+
246+
**1. Run checks locally before pushing:**
247+
\`\`\`bash
248+
npm run format:check # Check Prettier formatting
249+
npm run lint # Run ESLint
250+
npm run typecheck # TypeScript type check
251+
npm run test # Run unit tests (Vitest)
252+
npm run build # Verify production build passes
253+
\`\`\`
254+
255+
**2. Auto-fix common issues:**
256+
\`\`\`bash
257+
npm run format # Auto-fix formatting with Prettier
258+
npm run lint -- --fix # Auto-fix lint errors where possible
259+
\`\`\`
260+
261+
**3. Check the full failure log here:**
262+
👉 [View CI Run](${ciRun.html_url})
263+
264+
Once you push a fix and the CI passes, the \`status:blocked\` label will be removed automatically. 💪`,
265+
});
266+
}
267+
} else if (ciRun.conclusion === 'success' && hasBlockedLabel) {
268+
// CI is now passing — remove the stale blocked label
269+
await github.rest.issues.removeLabel({
270+
owner: context.repo.owner,
271+
repo: context.repo.repo,
272+
issue_number: pr.number,
273+
name: label,
274+
});
275+
core.info(`PR #${pr.number}: CI passing — removed '${label}'.`);
276+
}
277+
}

0 commit comments

Comments
 (0)