Skip to content

The GitHub Webhook

Josh edited this page Nov 5, 2026 · 1 revision

Describes robot-council/core v0.8.3.

Core learns about issues and pull requests from GitHub's webhook, and reads nothing from GitHub that decides anything: when GitHub is unreachable, events simply stop arriving and no coordination decision waits on it. The one read it makes is the lane board's open-issue counts, which decide nothing -- see The Lane Board. From each delivery it stores the issue's or pull request's state, and it frees the lane working on it.

Which events finish which tasks, and what a fork's pull request matches, is the table under Tasks finished by GitHub in The Task Lifecycle. Each is recorded in the change feed attributed to no session, naming the task and not the issue.

A task GitHub completes keeps what finished it (#433). Its result carries a github entry with the reason and either the issue and GitHub's state_reason, or the pull request, merged and the merge commit's SHA. Only a session that may read the task sees its result. The session that held the task can still call complete with its own result once, within an hour of GitHub finishing it: the result is merged into the recorded one, and the status stays done. GitHub's github entry wins over a github key the holder sends, and a result sent as a list is kept under reported. The call answers 200 with applied: false and result_added: true, and the feed records task.result_added rather than a second task.completed. Any other session, a second addition, or a call after the hour gets the usual 409. A release records nothing, since the task goes back to the queue with a clean slate. A delivery replayed with the same X-GitHub-Delivery id changes nothing, and one older than what is stored is ignored, since GitHub does not promise order.

Enabling it (an operator step)

  1. Choose a secret of at least 16 characters and set it as ROBOT_COUNCIL_GITHUB_WEBHOOK_SECRET on the deployment. Until one is set, the endpoint answers 404.

  2. On GitHub, add a webhook to each repository (or the organization) with:

    • Payload URL: https://your-fleet.example.com{prefix}/api/github/webhook
    • Content type: application/json (form-encoded also works)
    • Secret: the same value
    • Events: Issues, Pull requests, Issue dependencies, and Branch or tag creation and Branch or tag deletion -- the last two let a placement warn when a ticket's branch already exists
  3. GitHub sends a ping, which answers 200. Its Recent Deliveries tab shows each delivery's status: 401 is a signature mismatch, 422 a payload the service refused.

  4. Backfill what was already open, once, since a webhook reports only what happens afterwards. With your own GitHub credentials:

    gh api --paginate --slurp 'repos/OWNER/REPO/issues?state=open&per_page=100' > issues.json
    gh api --paginate --slurp 'repos/OWNER/REPO/pulls?state=open&per_page=100' > pulls.json
    php artisan robot-council:github-import issues.json
    php artisan robot-council:github-import pulls.json

    The import stores state and frees no lane.

  5. Backfill the blocked_by edges too. Neither file carries them, and the webhook reports an edge only when it changes, so a ticket blocked since before the webhook reads as unblocked: the shortlist offers it and the documentation-ahead warning counts it. One paginated query reads every open issue's blockers:

    gh api graphql --paginate --slurp -F owner=OWNER -F name=REPO -f query='
    query($owner: String!, $name: String!, $endCursor: String) {
      repository(owner: $owner, name: $name) {
        nameWithOwner
        issues(first: 100, states: OPEN, after: $endCursor) {
          pageInfo { hasNextPage endCursor }
          nodes { number blockedBy(first: 100) { totalCount nodes { number state title updatedAt repository { nameWithOwner } } } }
        }
      }
    }' > blockers.json
    php artisan robot-council:github-import-blockers blockers.json

    It also records each closed blocker it has no row for, from the state the query reads, because the item import above brings in open issues only and an edge to a blocker with no row blocks: without it, a ticket blocked only by long-closed issues stays off the shortlist. A file read without state still stores its edges, and fails the run for that reason.

    It only adds edges, so it is safe to run again, and it never removes one. An edge removed on GitHub is not in a file read afterwards, so read the file just before importing it: a file read before a removal and imported after it puts the edge back, and that edge fails closed -- a placement names the blocker, and removing and re-adding the edge on GitHub clears it -- as a delivery received out of order does. It exits non-zero on a page GitHub answered with errors -- a rate limit arrives that way, as HTTP 200 -- on a blocker list cut short, and on a file whose last page has a next one.

Deliveries are rate-limited per source address by robot-council.rate_limits.github_webhook_per_minute (600), and the limiter runs before the signature check.

Clone this wiki locally