AsanableDocumentationSign in

Incoming: GitHub pull requests

Somebody opens a pull request on a branch called fix/AI-1867-github-webhook, and the card AI-1867 grows a Pull requests section saying what it is and where it has got to. When it merges, the section says merged. Nobody is notified — the card is where you look.

A section, not a conversation

The first version of this posted a comment per event, which notified everybody following the card and left a thread that could not be corrected: a pull request that opened, merged and was reverted produced three comments and no answer to “where is this?”. The section holds one row per pull request and the next delivery overwrites it, so the card always shows what is true now.

It still does less than the integration Asana provided, which is the product owner’s original requirement: no column changes, nothing marked done, no assignee touched. A merged pull request is one of several things a ticket needs, and a card that walked itself into Done because one landed would take a decision away from the person holding it.

A pull request may reference several cards, and a card may collect several pull requests. Both are normal and both work. The board’s card face carries a small badge with the count, so a column can be scanned for what is in review.

A pull request opened from a fork is ignored. On a public repository anybody can open one, and same-repository is the boundary because pushing a branch to it already requires write access. Nothing about the signature helps here: the delivery really is from GitHub.

The state a card shows

Read from the pull request, never from the action’s name — an edited on a pull request that has already merged is a delivery about a merged pull request.

StateWhen
draftDraft: GitHub says draft, and the pull request is neither merged nor closed. Drafts are shown, not hidden — a draft is where most work is for most of its life.
openOpen: everything else.
mergedMerged: GitHub says merged, which beats every other reading.
closedClosed: closed without merging.

The review state

A second pill, from pull_request_review deliveries and from review_requested. Absent until GitHub says something about review.

Review stateWhat sets it
review_requestedReview requested: a review was requested, and the row was not already approved. An approval is never downgraded by asking somebody else to look.
changes_requestedChanges requested: a review was submitted asking for changes.
approvedApproved: a review was submitted approving it.
clearedA review was dismissed. The pill goes away rather than being replaced by a guess.
unchangedA review that only comments. Somebody talking is not a verdict, and must not clear one.

An approval is not recomputed when new commits are pushed. GitHub’s own reviewDecision is the authority on whether an approval still stands, and reading it would mean holding a long-lived GitHub credential, which this integration deliberately does not. So a card can show approved against a branch GitHub now considers stale. That is a known approximation, not an oversight — the pull request itself is one click away, and its link is on the row.

The actions, and what each one does

ActionWhat Asanable does
openedUpdates the section. Somebody opened a pull request. The first row on the card, whether or not it is a draft.
editedUpdates the section. Somebody edited the title or the body. This is how a card link added to the body after the pull request was opened gets picked up.
reopenedUpdates the section. A closed pull request was opened again.
ready_for_reviewUpdates the section. A draft became ready for review. The row moves from draft to open, and the body is re-read.
closedUpdates the section. A pull request was closed — merged or not. Splits on merged: merged or closed. GitHub uses one action for the two most different outcomes a pull request has.
converted_to_draftUpdates the section. An open pull request went back to draft. Back to draft. Nothing was announced, so nothing has to be un-said.
assignedAcknowledged, nothing done. Somebody was assigned.
unassignedAcknowledged, nothing done. An assignee was removed.
review_requestedUpdates the section. A review was requested. Sets review_requested, unless the row is already approved.
review_request_removedUpdates the section. A review request was withdrawn.
labeledAcknowledged, nothing done. A GitHub label was added.
unlabeledAcknowledged, nothing done. A GitHub label was removed.
synchronizeUpdates the section. New commits were pushed to the branch. The title and the references are re-read. An approval already on the row is not cleared — see below.
lockedAcknowledged, nothing done. The conversation was locked.
unlockedAcknowledged, nothing done. The conversation was unlocked.
auto_merge_enabledAcknowledged, nothing done. Auto-merge was turned on.
auto_merge_disabledAcknowledged, nothing done. Auto-merge was turned off.
milestonedAcknowledged, nothing done. A milestone was set.
demilestonedAcknowledged, nothing done. A milestone was removed.
enqueuedAcknowledged, nothing done. It joined the merge queue.
dequeuedAcknowledged, nothing done. It left the merge queue.

And on a pull_request_review delivery: submitted, edited (ignored — it edits the prose only), dismissed.

An action Asanable does not handle gets a 200, not a 4xx. GitHub disables a webhook whose endpoint keeps failing, so refusing a permanently unhandled action would eventually switch the whole integration off. The same goes for the commonest outcome of all — a pull request that references no card — and for every event other than the two above, including the ping GitHub sends the moment you save the webhook.

How it finds the cards

Three places are read, in this order, because that is the confidence order:

WhereWhy it is read
The branch namefix/AI-1867-thing — where a reference ends up without anybody deciding to put it there, which is the whole reason this works.
The pull request titleWhere somebody writes “Closes AI-1867”.
The pull request bodyWhere a full card link usually is.

A reference is KEY-123 anywhere in that text, or a full card link — https://asanable.accountable.eu/AI-1867 and the ?card=AI-1867 form. Case is irrelevant: ai-42, AI-42 and AI-042 are one card, with one row on it.

Something that looks like a reference and is not — SHA-256, CVE-2020-8203 — is dropped silently, because no board is called SHA. There is no blocklist: maintaining a list of every hyphenated token in software is unwinnable where letting the database answer is free.

At most 40 reference-shaped tokens are read from one pull request and at most 10 cards are written to. Inbound text is somebody else’s to write, and a body pasted full of tokens must not become thousands of queries.

A card is never unlinked automatically. Deleting AI-42 from a description means somebody tidied the description, not that the pull request stopped being about the card. Removing a row is a decision, and it belongs to a person.

Why a redelivery is not a problem

POST body (trimmed — the real one is much larger)
{
  "action": "submitted",
  "review": { "state": "approved" },
  "pull_request": {
    "id": 1405882001,
    "number": 42,
    "title": "Add the GitHub webhook",
    "body": "Closes AI-1867.",
    "html_url": "https://github.com/Accountable-SA/asanable/pull/42",
    "draft": false,
    "merged": false,
    "state": "open",
    "created_at": "2026-09-02T09:14:00Z",
    "head": { "ref": "fix/AI-1867-github-webhook" },
    "user": { "login": "mokhtar" }
  },
  "repository": { "full_name": "Accountable-SA/asanable" }
}

GitHub is at-least-once, retries failures, lets an operator redeliver by hand, and sends opened followed by edited a minute later when somebody finishes writing the description. All four must produce nothing new, and they do: each delivery writes the pull request’s current state onto one row per card, keyed on the card and GitHub’s own numeric pull_request.id. The second write puts the same values in the same place.

The key is that numeric id rather than owner/repo#42: a rename or a transfer keeps every pull request number and changes the name, so a name-keyed row would become a second row for the same pull request the morning after a rename. The readable form is on the row itself, which is where a person reads it.

What Asanable requires of a delivery

RequirementDetail
X-Hub-Signature-256sha256= then HMAC-SHA256 over the raw body, hex, keyed with the webhook’s secret. Compared in constant time.
X-GitHub-EventMust be pull_request or pull_request_review for anything to happen.
Content typeapplication/json. The form encoding wraps the body in a payload= parameter, which this endpoint does not unwrap. GitHub signs those raw bytes too, so the signature passes and the parse is what fails — the symptom is a 400, not a 401.
A configured secretNo secret means every delivery is refused, with the same 401 a wrong signature gets. A missing environment variable never turns the check off — and never names itself to an unauthenticated caller either; the reason goes to the server log.

There is no replay window, because GitHub signs no timestamp. Not in the body, and not in a header covered by the signature — X-GitHub-Delivery is not covered, so trusting it would hand a replayer the ability to mint fresh-looking ids, and judging freshness by this server’s clock would refuse GitHub’s legitimate retries. The upsert is what buys the missing window: a replayed body writes the values it wrote the first time.

What the endpoint answers

StatusMeaning
200Dealt with — a card’s section was written, no card was referenced, the pull request came from a fork, or the action is deliberately ignored. The body says which.
401The signature could not be verified. Either the deployment’s secret differs from GitHub’s or none is configured — answered identically on purpose, because an endpoint on the open internet should not describe its own configuration to a caller who has proved nothing. The server log says which.
400Signed, but not the documented shape — no repository.full_name, no pull_request.id or no pull_request.number. Nothing was touched.
500Asanable broke. Retrying is right: the write is an upsert, so GitHub’s redelivery lands the state it was carrying.

Setting it up

An operator does this once: a webhook in GitHub pointed at /api/github/webhook with content type application/json, subscribed to Pull requests and Pull request reviews, and the same generated secret in GitHub and in the deployment’s environment. There is nothing on this page to call and no key to request.

No token, no GitHub App, no OAuth. The return leg — the card’s link appearing in the pull request itself — is a GitHub Actions workflow using the token Actions mints per run, so there is no long-lived credential in either direction and nothing to rotate.

Going the other way? Outgoing webhooks is Asanable POSTing to you when a card changes.