Skip to content

CI failure nudge

CI failure nudge #161

Workflow file for this run

# "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 }));
}