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          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            }