|
| 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 needs the "Pull requests: Read & write" permission to |
| 13 | +# post as testsprite-hob[bot]; until then comments come from github-actions[bot]). |
| 14 | +name: CI failure nudge |
| 15 | + |
| 16 | +on: |
| 17 | + workflow_run: |
| 18 | + workflows: ['CI', 'Test Coverage'] |
| 19 | + types: [completed] |
| 20 | + |
| 21 | +permissions: |
| 22 | + checks: read # read the head SHA's check-run state |
| 23 | + pull-requests: write |
| 24 | + issues: write # PR comments ride the issues API |
| 25 | + |
| 26 | +env: |
| 27 | + MARKER: '<!-- hob:ci-nudge -->' |
| 28 | + |
| 29 | +jobs: |
| 30 | + nudge: |
| 31 | + # Public repo only; PR-triggered runs only (pushes to main have no PR to nudge). |
| 32 | + if: >- |
| 33 | + github.repository == 'TestSprite/testsprite-cli' && |
| 34 | + github.event.workflow_run.event == 'pull_request' |
| 35 | + runs-on: ubuntu-latest |
| 36 | + steps: |
| 37 | + - id: app-token |
| 38 | + continue-on-error: true |
| 39 | + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 |
| 40 | + with: |
| 41 | + app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }} |
| 42 | + private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }} |
| 43 | + |
| 44 | + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1 |
| 45 | + env: |
| 46 | + APP_TOKEN: ${{ steps.app-token.outputs.token }} |
| 47 | + with: |
| 48 | + script: | |
| 49 | + const { owner, repo } = context.repo; |
| 50 | + const run = context.payload.workflow_run; |
| 51 | + const marker = process.env.MARKER; |
| 52 | +
|
| 53 | + const appClient = process.env.APP_TOKEN |
| 54 | + ? require('@actions/github').getOctokit(process.env.APP_TOKEN) |
| 55 | + : null; |
| 56 | + async function write(fn) { |
| 57 | + if (appClient) { |
| 58 | + try { return await fn(appClient.rest); } |
| 59 | + catch (e) { if (e.status !== 403 && e.status !== 404) throw e; } |
| 60 | + } |
| 61 | + return await fn(github.rest); |
| 62 | + } |
| 63 | +
|
| 64 | + // Resolve the PR for this run. `workflow_run.pull_requests` is empty |
| 65 | + // for fork PRs, so fall back to the commit→PRs lookup. |
| 66 | + let pr = (run.pull_requests || [])[0]; |
| 67 | + if (!pr) { |
| 68 | + const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit( |
| 69 | + { owner, repo, commit_sha: run.head_sha }); |
| 70 | + pr = data.find(p => p.state === 'open'); |
| 71 | + } else { |
| 72 | + pr = (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data; |
| 73 | + } |
| 74 | + if (!pr || pr.state !== 'open' || pr.user.type === 'Bot') return; |
| 75 | +
|
| 76 | + // Job name → the local command that reproduces/fixes it. Also the |
| 77 | + // allowlist of CI jobs this nudge watches — keep in sync with ci.yml. |
| 78 | + const FIX = { |
| 79 | + 'Lint & Format': 'run `npm run lint:fix && npm run format`, then commit', |
| 80 | + 'Typecheck': 'run `npm run typecheck` and fix the reported type errors', |
| 81 | + 'Unit Tests': 'run `npm test` and fix the failing tests', |
| 82 | + 'Build': 'run `npm run build` and fix the compile errors', |
| 83 | + 'Local E2E Tests': 'run `npm run test:e2e` (it builds first)', |
| 84 | + 'Coverage (>= 80%)': 'run `npm run test:coverage` — new code needs tests until every metric is back at 80%', |
| 85 | + }; |
| 86 | +
|
| 87 | + // Recompute full CI state from this SHA's check runs, narrowed to the |
| 88 | + // CI jobs above — other workflows (e.g. pr-triage) also attach |
| 89 | + // github-actions check runs to the PR head and must not count here. |
| 90 | + const checks = await github.paginate(github.rest.checks.listForRef, |
| 91 | + { owner, repo, ref: run.head_sha, per_page: 100 }); |
| 92 | + const ours = checks.filter(c => c.app && c.app.slug === 'github-actions' && FIX[c.name]); |
| 93 | + const failing = ours.filter(c => ['failure', 'timed_out'].includes(c.conclusion)); |
| 94 | + const pending = ours.filter(c => c.status !== 'completed'); |
| 95 | +
|
| 96 | + const comments = await github.paginate(github.rest.issues.listComments, |
| 97 | + { owner, repo, issue_number: pr.number, per_page: 100 }); |
| 98 | + const sticky = comments.find(c => (c.body || '').includes(marker)); |
| 99 | +
|
| 100 | + if (failing.length === 0) { |
| 101 | + // Only speak up on success if we previously flagged a failure, and |
| 102 | + // only once everything has actually finished. |
| 103 | + if (sticky && pending.length === 0 && !sticky.body.includes('all green')) { |
| 104 | + await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id, |
| 105 | + body: `${marker}\n✅ CI is **all green** now — thanks, @${pr.user.login}!` })); |
| 106 | + } |
| 107 | + return; |
| 108 | + } |
| 109 | +
|
| 110 | + const lines = failing |
| 111 | + .sort((a, b) => a.name.localeCompare(b.name)) |
| 112 | + .map(c => { |
| 113 | + const fix = FIX[c.name] || 'see the logs for details'; |
| 114 | + return `- **${c.name}** — ${fix} ([logs](${c.html_url}))`; |
| 115 | + }); |
| 116 | + const body = `${marker}\nThanks, @${pr.user.login}! CI is red on this PR — ` |
| 117 | + + `here's what failed and how to reproduce it locally:\n\n${lines.join('\n')}\n\n` |
| 118 | + + `Everything runs on Node 22 after \`npm ci\`. Push a fix and this comment ` |
| 119 | + + `flips green automatically once all checks pass.`; |
| 120 | +
|
| 121 | + if (sticky) { |
| 122 | + if (sticky.body !== body) { |
| 123 | + await write(rest => rest.issues.updateComment( |
| 124 | + { owner, repo, comment_id: sticky.id, body })); |
| 125 | + } |
| 126 | + } else { |
| 127 | + await write(rest => rest.issues.createComment( |
| 128 | + { owner, repo, issue_number: pr.number, body })); |
| 129 | + } |
0 commit comments