bot-check-ci.yml

  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            }