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