feat(web): the Luke GitHub App's webhook keeps what an agent published current for the plan list

Merged
DA
8d ago
+712-9

Follows #2068 (merged), which added coding_agent.published and the plan list's per-plan agents summary. This PR makes GitHub keep that column current, so a PR that is merged, closed, drafted, or whose checks fail shows in the sidebar row and hover card without anyone opening the agent's tab. Dean ruled out background polling and sweeps, so the update comes from GitHub App webhooks. The "written on every successful read" path from #2068 stays, so everything works before the webhook is configured.

What changes

  • POST /api/github/webhook (server/github-webhook-app.ts, route server/routes/github/webhook.ts, rewrites regenerated).
    • Fails closed: with no GITHUB_APP_WEBHOOK_SECRET set, every delivery gets 503 unavailable.

    • Signature check before anything else: X-Hub-Signature-256 is compared against the HMAC-SHA256 of the raw body bytes using timingSafeEqual. A mismatched or missing signature gets 401 invalid-token, and GitHub is not asked. Deliveries are bounded at 5 MB; any method other than POST gets 405.

    • Which events are read:

      • pull_request with action opened, reopened, closed, edited, synchronize, ready_for_review, or converted_to_draft
      • any check_suite
      • check_run with action completed (branches from its pull_requests)
      • status (commit statuses, which the checks word counts beside check runs)

      ping and every other event are answered 200 and ignored.

    • What a delivery does: from the payload, only the event, the action, repository.full_name, and the head branch names are read. Every other key is dropped unread. The branch finds the coding agents whose row stands on it, of any account, at most ten, newest first. Each agent's PR is then re-read from GitHub on its owner's own token through the existing agentPublished, exactly as the agent tab reads it, and that read writes coding_agent.published. This keeps the aggregate checks word correct instead of guessing it from one suite's event, and it makes redeliveries idempotent.

    • What leaves the service: nothing from a delivery is stored or logged. A failed re-read logs only the failure's tag.

  • readBodyBytesEffect / parseJsonBytes are split out of readJsonBodyEffect (same bounds, same refusals), because the signature has to be computed over the raw bytes.
  • HostedEnvironment.githubWebhookSecret is read from GITHUB_APP_WEBHOOK_SECRET (a blank value counts as absent, like every other secret).
  • listCodingAgentsOnBranches (store) finds agents by repository and branch. It is the one read that names no account; each result is then re-read on its owner's own token.

Setup for Dean (once this is merged and deployed)

  1. Make a secret: openssl rand -hex 32.
  2. Vercel: in the luke-web project, go to Settings → Environment Variables. Add GITHUB_APP_WEBHOOK_SECRET with that value for Production; previews don't receive the App's deliveries. Then redeploy production so the function reads it. Until this is set, deliveries are answered 503 and nothing changes.
  3. GitHub App settings: go to ReviewStage → Settings → Developer settings → GitHub Apps → the Luke app → General.
    • Webhook: check Active.
    • Webhook URL: https://tryluke.dev/api/github/webhook
    • Webhook secret: the same value as in step 2.
    • SSL verification: enabled.
    • Save.
  4. Permissions & events tab:
    • Repository permissions → Checks: Read-only and Commit statuses: Read-only. GitHub only lets an App subscribe to check events with this permission. #2058's CI reading may already have needed it; if it is already Read-only, nothing to do. Pull requests stays as it is.
    • Subscribe to events: check Pull request, Check suite, Check run, and Status.
    • Save. If a permission changed, each installation owner has to accept the new permission. GitHub emails them, and they accept it in the installation's settings. Until they do, check events don't arrive for that installation; pull request events still do.
  5. Verify: in the App's Advanced → Recent Deliveries, the ping delivery should show 200 with {"read":0}. After the next PR event on an agent's branch, the response is {"read":1}.

Tests

apps/web/tests/github-webhook-app.test.ts, over PGlite and a scripted GitHub:

  • Refusals: with no secret, every delivery is refused 503. A wrong secret, an empty signature, and a missing signature are each refused 401, with GitHub never asked and nothing written. GET is refused 405.
  • Pull request deliveries: a signed merge delivery re-reads the PR and keeps merged, and the same delivery again keeps the same value. ping reads nothing.
  • Check deliveries: a check run that is still running, a non-moving PR action (labeled), a branch no agent stands on, and an unrelated event all read nothing. A completed check run (shaped like GitHub's real payload, with the suite named by id only) re-reads the checks and keeps failing, and a commit status re-reads them again.

./scripts/check.sh exits 0. This is a service-only change with no Mac or UI surface.

🤖 Generated with Claude Code