CI failure nudge #150
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # "Fix this and we merge" nudge (InsForge `agent-zhang-beihai` pattern, part 3). | |
| # When a PR's CI finishes red, posts ONE sticky comment listing exactly which | |
| # jobs failed and the local one-liner that reproduces/fixes each (the automated | |
| # version of the hand-written "run `npm run format` and you're green" review | |
| # comments). The comment flips to a green confirmation once all checks pass. | |
| # | |
| # Runs in base-repo context via workflow_run — no PR code is checked out, so | |
| # fork PRs are safe. State is recomputed from check-runs each time, so the two | |
| # CI workflows (CI + Test Coverage) can complete in any order. | |
| # | |
| # Bot identity: same App-token-first / GITHUB_TOKEN-fallback pattern as | |
| # pr-triage.yml. The App has had the "Pull requests: Read & write" permission | |
| # since 2026-07-02 (corrected 2026-07-15; this comment previously said the | |
| # permission was still pending) — the fallback path stays as defense-in-depth | |
| # for whenever the App/secrets are absent. | |
| name: CI failure nudge | |
| on: | |
| workflow_run: | |
| workflows: ['CI', 'Test Coverage'] | |
| types: [completed] | |
| permissions: | |
| checks: read # read the head SHA's check-run state | |
| pull-requests: write | |
| issues: write # PR comments ride the issues API | |
| env: | |
| MARKER: '<!-- hob:ci-nudge -->' | |
| jobs: | |
| nudge: | |
| # Public repo only; PR-triggered runs only (pushes to main have no PR to nudge). | |
| if: >- | |
| github.repository == 'TestSprite/testsprite-cli' && | |
| github.event.workflow_run.event == 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - id: app-token | |
| continue-on-error: true | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }} | |
| private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }} | |
| # The script below only ever calls the App client (`appClient`) for | |
| # rest.issues.updateComment/createComment — PR comments ride the | |
| # issues API (see the header comment above). All reads (checks, | |
| # pulls, commit->PR lookup) go through the ambient `github` client, | |
| # governed by this workflow's top-level `permissions:` block, not | |
| # this minted token. Scoping to checks:read/pull-requests:write here | |
| # (matching the job's top-level block literally) would risk the mint | |
| # itself failing if the App's installation doesn't hold Checks | |
| # permission — which would silently drop the App-token path | |
| # entirely (continue-on-error swallows the failure) and always fall | |
| # back to github-actions[bot], defeating this bot's own purpose. | |
| permission-issues: write | |
| - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| env: | |
| APP_TOKEN: ${{ steps.app-token.outputs.token }} | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const run = context.payload.workflow_run; | |
| const marker = process.env.MARKER; | |
| const appClient = process.env.APP_TOKEN | |
| ? new (github.constructor)({ auth: process.env.APP_TOKEN }) | |
| : null; | |
| async function write(fn) { | |
| if (appClient) { | |
| try { return await fn(appClient.rest); } | |
| catch (e) { if (e.status !== 403 && e.status !== 404) throw e; } | |
| } | |
| return await fn(github.rest); | |
| } | |
| // Resolve the PR for this run. `workflow_run.pull_requests` is empty | |
| // for fork PRs, so fall back to the commit→PRs lookup. | |
| let pr = (run.pull_requests || [])[0]; | |
| if (!pr) { | |
| const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit( | |
| { owner, repo, commit_sha: run.head_sha }); | |
| pr = data.find(p => p.state === 'open'); | |
| } else { | |
| pr = (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data; | |
| } | |
| if (!pr || pr.state !== 'open' || pr.user.type === 'Bot') return; | |
| // Job name → the local command that reproduces/fixes it. Also the | |
| // allowlist of CI jobs this nudge watches — keep in sync with ci.yml. | |
| const FIX = { | |
| 'Lint & Format': 'run `npm run lint:fix && npm run format`, then commit', | |
| 'Typecheck': 'run `npm run typecheck` and fix the reported type errors', | |
| 'Unit Tests': 'run `npm test` and fix the failing tests', | |
| 'Build': 'run `npm run build` and fix the compile errors', | |
| 'Local E2E Tests': 'run `npm run test:e2e` (it builds first)', | |
| 'Coverage (>= 80%)': 'run `npm run test:coverage` — new code needs tests until every metric is back at 80%', | |
| 'Secret scan (gitleaks)': 'run `gitleaks detect --no-git --redact --source .` locally (config: .gitleaks.toml) and remove the secret — or, for a provable false positive, add a narrowly-anchored allowlist regex', | |
| }; | |
| // Matrix jobs publish check runs as "Unit Tests (Node 20)" etc. — | |
| // exact lookup alone would silently skip them (and the nudge would | |
| // say "all green" while they are red). Try exact first, then the | |
| // name with a trailing parenthetical stripped (review round 2). | |
| const fixFor = (name) => FIX[name] ?? FIX[name.replace(/\s*\([^)]*\)$/, '')]; | |
| // Recompute full CI state from this SHA's check runs, narrowed to the | |
| // CI jobs above — other workflows (e.g. pr-triage) also attach | |
| // github-actions check runs to the PR head and must not count here. | |
| const checks = await github.paginate(github.rest.checks.listForRef, | |
| { owner, repo, ref: run.head_sha, per_page: 100 }); | |
| const ours = checks.filter(c => c.app && c.app.slug === 'github-actions' && fixFor(c.name)); | |
| const failing = ours.filter(c => ['failure', 'timed_out'].includes(c.conclusion)); | |
| const pending = ours.filter(c => c.status !== 'completed'); | |
| const comments = await github.paginate(github.rest.issues.listComments, | |
| { owner, repo, issue_number: pr.number, per_page: 100 }); | |
| const sticky = comments.find(c => (c.body || '').includes(marker)); | |
| if (failing.length === 0) { | |
| // Only speak up on success if we previously flagged a failure, and | |
| // only once everything has actually finished. | |
| if (sticky && pending.length === 0 && !sticky.body.includes('all green')) { | |
| await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id, | |
| body: `${marker}\n✅ CI is **all green** now — thanks, @${pr.user.login}!` })); | |
| } | |
| return; | |
| } | |
| const lines = failing | |
| .sort((a, b) => a.name.localeCompare(b.name)) | |
| .map(c => { | |
| const fix = fixFor(c.name) || 'see the logs for details'; | |
| return `- **${c.name}** — ${fix} ([logs](${c.html_url}))`; | |
| }); | |
| const body = `${marker}\nThanks, @${pr.user.login}! CI is red on this PR — ` | |
| + `here's what failed and how to reproduce it locally:\n\n${lines.join('\n')}\n\n` | |
| + `Everything runs on Node 22 after \`npm ci\`. Push a fix and this comment ` | |
| + `flips green automatically once all checks pass.`; | |
| if (sticky) { | |
| if (sticky.body !== body) { | |
| await write(rest => rest.issues.updateComment( | |
| { owner, repo, comment_id: sticky.id, body })); | |
| } | |
| } else { | |
| await write(rest => rest.issues.createComment( | |
| { owner, repo, issue_number: pr.number, body })); | |
| } |