|
| 1 | +# "Fix this and we merge" nudge (InsForge `agent-zhang-beihai` pattern, part 3). |
| 2 | +# When a PR's CI finishes red, posts ONE sticky comment listing exactly which |
| 3 | +# jobs failed and the local one-liner that reproduces/fixes each (the automated |
| 4 | +# version of the hand-written "run `npm run format` and you're green" review |
| 5 | +# comments). The comment flips to a green confirmation once all checks pass. |
| 6 | +# |
| 7 | +# Runs in base-repo context via workflow_run — no PR code is checked out, so |
| 8 | +# fork PRs are safe. State is recomputed from check-runs each time, so the two |
| 9 | +# CI workflows (CI + Test Coverage) can complete in any order. |
| 10 | +# |
| 11 | +# Bot identity: same App-token-first / GITHUB_TOKEN-fallback pattern as |
| 12 | +# pr-triage.yml. The App has had the "Pull requests: Read & write" permission |
| 13 | +# since 2026-07-02 (corrected 2026-07-15; this comment previously said the |
| 14 | +# permission was still pending) — the fallback path stays as defense-in-depth |
| 15 | +# for whenever the App/secrets are absent. |
| 16 | +name: CI failure nudge |
| 17 | + |
| 18 | +on: |
| 19 | + workflow_run: |
| 20 | + workflows: ['CI', 'Test Coverage'] |
| 21 | + types: [completed] |
| 22 | + |
| 23 | +permissions: |
| 24 | + checks: read # read the head SHA's check-run state |
| 25 | + pull-requests: write |
| 26 | + issues: write # PR comments ride the issues API |
| 27 | + |
| 28 | +env: |
| 29 | + MARKER: '<!-- hob:ci-nudge -->' |
| 30 | + |
| 31 | +jobs: |
| 32 | + nudge: |
| 33 | + # Public repo only; PR-triggered runs only (pushes to main have no PR to nudge). |
| 34 | + if: >- |
| 35 | + github.repository == 'TestSprite/testsprite-cli' && |
| 36 | + github.event.workflow_run.event == 'pull_request' |
| 37 | + runs-on: ubuntu-latest |
| 38 | + steps: |
| 39 | + - id: app-token |
| 40 | + continue-on-error: true |
| 41 | + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 |
| 42 | + with: |
| 43 | + app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }} |
| 44 | + private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }} |
| 45 | + # The script below only ever calls the App client (`appClient`) for |
| 46 | + # rest.issues.updateComment/createComment — PR comments ride the |
| 47 | + # issues API (see the header comment above). All reads (checks, |
| 48 | + # pulls, commit->PR lookup) go through the ambient `github` client, |
| 49 | + # governed by this workflow's top-level `permissions:` block, not |
| 50 | + # this minted token. Scoping to checks:read/pull-requests:write here |
| 51 | + # (matching the job's top-level block literally) would risk the mint |
| 52 | + # itself failing if the App's installation doesn't hold Checks |
| 53 | + # permission — which would silently drop the App-token path |
| 54 | + # entirely (continue-on-error swallows the failure) and always fall |
| 55 | + # back to github-actions[bot], defeating this bot's own purpose. |
| 56 | + permission-issues: write |
| 57 | + |
| 58 | + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 |
| 59 | + env: |
| 60 | + APP_TOKEN: ${{ steps.app-token.outputs.token }} |
| 61 | + with: |
| 62 | + script: | |
| 63 | + const { owner, repo } = context.repo; |
| 64 | + const run = context.payload.workflow_run; |
| 65 | + const marker = process.env.MARKER; |
| 66 | +
|
| 67 | + const appClient = process.env.APP_TOKEN |
| 68 | + ? new (github.constructor)({ auth: process.env.APP_TOKEN }) |
| 69 | + : null; |
| 70 | + async function write(fn) { |
| 71 | + if (appClient) { |
| 72 | + try { return await fn(appClient.rest); } |
| 73 | + catch (e) { if (e.status !== 403 && e.status !== 404) throw e; } |
| 74 | + } |
| 75 | + return await fn(github.rest); |
| 76 | + } |
| 77 | +
|
| 78 | + // Resolve the PR for this run. `workflow_run.pull_requests` is empty |
| 79 | + // for fork PRs, so fall back to the commit→PRs lookup. |
| 80 | + let pr = (run.pull_requests || [])[0]; |
| 81 | + if (!pr) { |
| 82 | + const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit( |
| 83 | + { owner, repo, commit_sha: run.head_sha }); |
| 84 | + pr = data.find(p => p.state === 'open'); |
| 85 | + } else { |
| 86 | + pr = (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data; |
| 87 | + } |
| 88 | + if (!pr || pr.state !== 'open' || pr.user.type === 'Bot') return; |
| 89 | +
|
| 90 | + // Job name → the local command that reproduces/fixes it. Also the |
| 91 | + // allowlist of CI jobs this nudge watches — keep in sync with ci.yml. |
| 92 | + const FIX = { |
| 93 | + 'Lint & Format': 'run `npm run lint:fix && npm run format`, then commit', |
| 94 | + 'Typecheck': 'run `npm run typecheck` and fix the reported type errors', |
| 95 | + 'Unit Tests': 'run `npm test` and fix the failing tests', |
| 96 | + 'Build': 'run `npm run build` and fix the compile errors', |
| 97 | + 'Local E2E Tests': 'run `npm run test:e2e` (it builds first)', |
| 98 | + 'Coverage (>= 80%)': 'run `npm run test:coverage` — new code needs tests until every metric is back at 80%', |
| 99 | + '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', |
| 100 | + }; |
| 101 | + // Matrix jobs publish check runs as "Unit Tests (Node 20)" etc. — |
| 102 | + // exact lookup alone would silently skip them (and the nudge would |
| 103 | + // say "all green" while they are red). Try exact first, then the |
| 104 | + // name with a trailing parenthetical stripped (review round 2). |
| 105 | + const fixFor = (name) => FIX[name] ?? FIX[name.replace(/\s*\([^)]*\)$/, '')]; |
| 106 | +
|
| 107 | + // Recompute full CI state from this SHA's check runs, narrowed to the |
| 108 | + // CI jobs above — other workflows (e.g. pr-triage) also attach |
| 109 | + // github-actions check runs to the PR head and must not count here. |
| 110 | + const checks = await github.paginate(github.rest.checks.listForRef, |
| 111 | + { owner, repo, ref: run.head_sha, per_page: 100 }); |
| 112 | + const ours = checks.filter(c => c.app && c.app.slug === 'github-actions' && fixFor(c.name)); |
| 113 | + const failing = ours.filter(c => ['failure', 'timed_out'].includes(c.conclusion)); |
| 114 | + const pending = ours.filter(c => c.status !== 'completed'); |
| 115 | +
|
| 116 | + const comments = await github.paginate(github.rest.issues.listComments, |
| 117 | + { owner, repo, issue_number: pr.number, per_page: 100 }); |
| 118 | + const sticky = comments.find(c => (c.body || '').includes(marker)); |
| 119 | +
|
| 120 | + if (failing.length === 0) { |
| 121 | + // Only speak up on success if we previously flagged a failure, and |
| 122 | + // only once everything has actually finished. |
| 123 | + if (sticky && pending.length === 0 && !sticky.body.includes('all green')) { |
| 124 | + await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id, |
| 125 | + body: `${marker}\n✅ CI is **all green** now — thanks, @${pr.user.login}!` })); |
| 126 | + } |
| 127 | + return; |
| 128 | + } |
| 129 | +
|
| 130 | + const lines = failing |
| 131 | + .sort((a, b) => a.name.localeCompare(b.name)) |
| 132 | + .map(c => { |
| 133 | + const fix = fixFor(c.name) || 'see the logs for details'; |
| 134 | + return `- **${c.name}** — ${fix} ([logs](${c.html_url}))`; |
| 135 | + }); |
| 136 | + const body = `${marker}\nThanks, @${pr.user.login}! CI is red on this PR — ` |
| 137 | + + `here's what failed and how to reproduce it locally:\n\n${lines.join('\n')}\n\n` |
| 138 | + + `Everything runs on Node 22 after \`npm ci\`. Push a fix and this comment ` |
| 139 | + + `flips green automatically once all checks pass.`; |
| 140 | +
|
| 141 | + if (sticky) { |
| 142 | + if (sticky.body !== body) { |
| 143 | + await write(rest => rest.issues.updateComment( |
| 144 | + { owner, repo, comment_id: sticky.id, body })); |
| 145 | + } |
| 146 | + } else { |
| 147 | + await write(rest => rest.issues.createComment( |
| 148 | + { owner, repo, issue_number: pr.number, body })); |
| 149 | + } |
0 commit comments