1name: Bot - CI Failure Notifier
2
3on:
4 workflow_run:
5 workflows:
6 - "CI"
7 - "Security"
8 - "Benchmarks"
9 - "Integration"
10 types: [completed]
11
12permissions:
13 contents: read
14 pull-requests: write
15 issues: write
16 actions: read
17
18jobs:
19 notify:
20 runs-on: ubuntu-latest
21 steps:
22 - name: Install log-parser dep
23 run: npm install --no-save --prefix /tmp/botci adm-zip
24
25 - name: Process CI Result
26 uses: actions/github-script@v9
27 env:
28 NODE_PATH: /tmp/botci/node_modules
29 with:
30 github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
31 script: |
32 const run = context.payload.workflow_run;
33 const owner = context.repo.owner;
34 const repo = context.repo.repo;
35 const workflowName = run.name || "workflow";
36 const isMasterPush = run.event === 'push' && run.head_branch === 'master';
37
38 // Resolve PR
39 let pr_number = null;
40 if (run.pull_requests && run.pull_requests.length > 0) {
41 pr_number = run.pull_requests[0].number;
42 } else {
43 try {
44 const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
45 owner, repo, commit_sha: run.head_sha
46 });
47 const open = prs.find(p => p.state === 'open');
48 if (open) pr_number = open.number;
49 else if (prs.length > 0) pr_number = prs[0].number;
50 } catch (e) {
51 console.log(`PR lookup failed: ${e.message}`);
52 }
53 }
54
55 if (!pr_number && !isMasterPush) {
56 console.log(`No PR and not master push (event=${run.event} branch=${run.head_branch}). Exiting.`);
57 return;
58 }
59
60 const fixHints = {
61 lint: "Run `make lint` locally.",
62 'mod-tidy': "Run `go mod tidy` and commit `go.mod`/`go.sum`.",
63 nix: "Run `nix flake check --no-build` locally.",
64 snap: "Validate `snapcraft.yaml` with `snapcraft plugins`.",
65 flatpak: "Validate the Flatpak manifest YAML structure.",
66 website: "Run `cd docs && npm ci && npx docusaurus build`.",
67 'lua-plugins': "Run `luac -p plugins/*.lua` to check Lua syntax.",
68 goreleaser: "Run `goreleaser check -f .goreleaser.yml`.",
69 build: "Run `go build ./...` and `go test ./...` locally.",
70 govulncheck: "Run `govulncheck ./...` and update vulnerable deps.",
71 gosec: "Inspect gosec SARIF in the Security tab; fix or annotate with `//nolint:gosec` (justified).",
72 trivy: "Run `trivy fs .` and address CRITICAL/HIGH findings.",
73 codeql: "Inspect CodeQL findings in the Security tab.",
74 benchmark: "Inspect benchstat report on the PR; look for >3% regressions.",
75 'imap + smtp e2e': "Run `docker compose -f tests/integration/docker-compose.yml up -d` then `go test -tags=integration ./tests/integration/...`.",
76 'cross-platform': "Reproduce with `go test -race ./...` on the failing OS.",
77 fuzz: "Reproduce with `go test -fuzz=<Name> -fuzztime=30s ./<pkg>`.",
78 };
79
80 function hintFor(jobName) {
81 const lower = jobName.toLowerCase();
82 for (const key of Object.keys(fixHints)) {
83 if (lower.includes(key)) return fixHints[key];
84 }
85 return null;
86 }
87
88 async function downloadLogs() {
89 try {
90 const res = await github.rest.actions.downloadWorkflowRunLogs({
91 owner, repo, run_id: run.id
92 });
93 return Buffer.from(res.data);
94 } catch (e) {
95 console.log(`could not download logs: ${e.message}`);
96 return null;
97 }
98 }
99
100 async function extractFailures(zipBuf) {
101 if (!zipBuf) return [];
102 let AdmZip;
103 try {
104 AdmZip = require('adm-zip');
105 } catch (_) {
106 console.log("adm-zip not available; skipping log parse");
107 return [];
108 }
109 const zip = new AdmZip(zipBuf);
110 const failures = [];
111 const goFailRe = /^FAIL\s+(\S+)/;
112 const testFailRe = /^\s*---\s+FAIL:\s+(\S+)/;
113 const buildFailRe = /^(.+\.go):(\d+):(\d+):\s+(.+)$/;
114
115 for (const entry of zip.getEntries()) {
116 if (entry.isDirectory) continue;
117 if (!entry.entryName.endsWith('.txt')) continue;
118 const text = entry.getData().toString('utf8');
119 const pkgs = new Set();
120 const tests = new Set();
121 const buildErrs = [];
122 for (const line of text.split('\n')) {
123 let m;
124 if ((m = line.match(testFailRe))) tests.add(m[1]);
125 else if ((m = line.match(goFailRe))) {
126 if (m[1] !== '[build') pkgs.add(m[1]);
127 } else if ((m = line.match(buildFailRe))) {
128 if (buildErrs.length < 5) buildErrs.push(`${m[1]}:${m[2]}: ${m[4]}`);
129 }
130 }
131 if (pkgs.size || tests.size || buildErrs.length) {
132 failures.push({
133 file: entry.entryName,
134 packages: [...pkgs],
135 tests: [...tests],
136 buildErrors: buildErrs,
137 });
138 }
139 }
140 return failures;
141 }
142
143 function renderFailuresSection(failures) {
144 if (!failures.length) return "";
145 const seenPkgs = new Set();
146 const seenTests = new Set();
147 const seenBuild = new Set();
148 for (const f of failures) {
149 f.packages.forEach(p => seenPkgs.add(p));
150 f.tests.forEach(t => seenTests.add(t));
151 f.buildErrors.forEach(b => seenBuild.add(b));
152 }
153 const lines = ["\n#### Failure details\n"];
154 if (seenBuild.size) {
155 lines.push("**Build errors:**");
156 for (const b of [...seenBuild].slice(0, 8)) lines.push(`- \`${b}\``);
157 lines.push("");
158 }
159 if (seenPkgs.size) {
160 lines.push("**Failing packages:**");
161 for (const p of [...seenPkgs].slice(0, 20)) lines.push(`- \`${p}\``);
162 lines.push("");
163 }
164 if (seenTests.size) {
165 lines.push("**Failing tests:**");
166 for (const t of [...seenTests].slice(0, 30)) lines.push(`- \`${t}\``);
167 lines.push("");
168 }
169 return lines.join("\n");
170 }
171
172 const marker = ``;
173
174 async function upsertPRComment(body) {
175 const { data: comments } = await github.rest.issues.listComments({
176 owner, repo, issue_number: pr_number, per_page: 100
177 });
178 const existing = comments.find(c => c.body && c.body.includes(marker));
179 if (existing) {
180 await github.rest.issues.updateComment({
181 owner, repo, comment_id: existing.id, body
182 });
183 console.log(`updated comment ${existing.id} on PR #${pr_number}`);
184 } else {
185 await github.rest.issues.createComment({
186 owner, repo, issue_number: pr_number, body
187 });
188 console.log(`created comment on PR #${pr_number}`);
189 }
190 }
191
192 async function deletePRCommentIfExists() {
193 if (!pr_number) return;
194 const { data: comments } = await github.rest.issues.listComments({
195 owner, repo, issue_number: pr_number, per_page: 100
196 });
197 const existing = comments.find(c => c.body && c.body.includes(marker));
198 if (existing) {
199 try {
200 await github.rest.issues.deleteComment({
201 owner, repo, comment_id: existing.id
202 });
203 console.log(`deleted stale comment ${existing.id}`);
204 } catch (e) {
205 // Fallback: edit instead of deleting if delete denied.
206 await github.rest.issues.updateComment({
207 owner, repo, comment_id: existing.id,
208 body: `${marker}\n${workflowName} now passing on \`${run.head_sha.substring(0, 7)}\` ([run](${run.html_url})).`
209 });
210 }
211 }
212 }
213
214 // ----- failure -----
215 if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') {
216 const { data: jobsData } = await github.rest.actions.listJobsForWorkflowRun({
217 owner, repo, run_id: run.id
218 });
219 const failedJobs = jobsData.jobs
220 .filter(j => j.conclusion === 'failure' || j.conclusion === 'timed_out')
221 .map(j => ({ name: j.name, url: j.html_url }));
222
223 const zipBuf = await downloadLogs();
224 const failures = await extractFailures(zipBuf);
225
226 let lines = [];
227 lines.push(`### โ ${workflowName} failed`);
228 lines.push("");
229 lines.push(`Commit: \`${run.head_sha.substring(0, 7)}\` ยท [run logs](${run.html_url})`);
230 lines.push("");
231 if (failedJobs.length) {
232 lines.push("**Failed jobs:**");
233 for (const j of failedJobs) {
234 const hint = hintFor(j.name);
235 lines.push(`- [${j.name}](${j.url})${hint ? ` โ ${hint}` : ""}`);
236 }
237 }
238 lines.push(renderFailuresSection(failures));
239
240 const body = `${marker}\n` + lines.join("\n");
241
242 if (pr_number) {
243 await upsertPRComment(body);
244 } else if (isMasterPush) {
245 const title = `${workflowName} failed on master @ ${run.head_sha.substring(0, 7)}`;
246 const { data: existing } = await github.rest.issues.listForRepo({
247 owner, repo, state: 'open', labels: 'ci-failure', per_page: 50
248 });
249 const dup = existing.find(i => i.title === title);
250 if (dup) {
251 await github.rest.issues.createComment({
252 owner, repo, issue_number: dup.number,
253 body: `Re-run also failed: ${run.html_url}\n\n${lines.join("\n")}`
254 });
255 } else {
256 await github.rest.issues.create({
257 owner, repo, title, body, labels: ['ci-failure']
258 });
259 }
260 try {
261 await github.rest.repos.createCommitComment({
262 owner, repo, commit_sha: run.head_sha, body
263 });
264 } catch (e) {
265 console.log(`commit comment failed: ${e.message}`);
266 }
267 }
268 return;
269 }
270
271 // ----- success -----
272 if (run.conclusion === 'success') {
273 if (pr_number) {
274 await deletePRCommentIfExists();
275 }
276 if (isMasterPush) {
277 let botLogin = null;
278 try {
279 const { data: botUser } = await github.rest.users.getAuthenticated();
280 botLogin = botUser.login;
281 } catch (_) {}
282 const { data: openIssues } = await github.rest.issues.listForRepo({
283 owner, repo, state: 'open', labels: 'ci-failure', per_page: 50
284 });
285 for (const issue of openIssues) {
286 if (botLogin && issue.user && issue.user.login !== botLogin) continue;
287 if (!issue.title.includes(workflowName)) continue;
288 await github.rest.issues.createComment({
289 owner, repo, issue_number: issue.number,
290 body: `Subsequent master ${workflowName} run passed: ${run.html_url}. Closing.`
291 });
292 await github.rest.issues.update({
293 owner, repo, issue_number: issue.number, state: 'closed'
294 });
295 }
296 }
297 }