Stale branch cleanup digest: GitHub → Slack
Scan branches read-only, let people claim theirs in Slack, escalate the ignored ones to Linear.
Stale branches pile up because nobody's job is to clear them: GitHub's auto-delete only fires when a PR merges, and community Actions that scan for staleness need permission to delete branches outright. This asks for neither a merge event nor a delete scope: it reads branch age, open-PR status and protection state, posts a weekly Slack digest where a human claims or ignores each candidate, and logs unclaimed ones as Linear tech debt. No branch is deleted by the workflow itself — the person who claims one deletes it themselves, on purpose.
How it flows
- 01
Weekly scheduled scan
A scheduled job runs the GraphQL batch query against every configured repository, pulling branch name, last commit date, and open-PR status in about 10 requests per thousand branches.
- 02
Protection cross-check
Every remaining candidate is checked against REST branches?protected=true — never against GraphQL branchProtectionRule, which misses Repository Ruleset protection — and dropped if it appears there.
- 03
Filter against the claim store
Branches already claimed in a previous digest, tracked in your own persisted store rather than in Slack itself, are excluded before the list goes anywhere.
- 04
Digest posted to Slack
One message, one Claim button per branch, sent to a shared channel — no DMs, and no delete permission requested anywhere in this pipeline.
- 05
Claim recorded
A click is acknowledged within 3 seconds, chat.update marks the row as claimed, and the branch is written to the claim store so it drops out of next week's digest.
- 06
Unclaimed branches become tech debt
After N consecutive unclaimed digests, the branch is logged as an unassigned Linear issue instead of being silently forgotten — or silently deleted. The actual delete is a manual step the person who claims a branch runs themselves; nothing in this workflow deletes a branch on its own.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
GitHub
Read-only scan for branch age, PR status, and protection
- 01
Create a Fine-grained PAT with read-only scopes only
github.com → your avatar (top right) → Settings → Developer settings (bottom of the left sidebar) → Personal access tokens → Fine-grained tokens → Generate new token. Select the target repository (or repositories), then under Repository permissions grant only Contents: Read-only (branch and commit data) and Pull requests: Read-only (open-PR status) — nothing else. This workflow never requests a scope that could delete anything; that restraint is the point of it, not an afterthought. For an organization rather than one maintainer's personal token, use a GitHub App instead (Developer settings → GitHub Apps → New GitHub App) with the same two read-only permissions, so the scan is not tied to one person's account.
- 02
Batch-scan with GraphQL, not the REST branch list
The REST branch list (GET /repos/{owner}/{repo}/branches) returns name and protected, but not a last-commit date — getting that means a second REST call per branch, GET /repos/{owner}/{repo}/branches/{branch}. On a repository with real branch counts that is roughly one call per branch: about 1,010 REST requests to scan 1,000 branches once the paginated list itself is added in. Run the GraphQL query below instead: it returns name, last commit date, and whether an open PR already exists, for up to 100 branches per request. Tested against facebook/react's 945 live branches, both a first:5 and a first:100 page cost rateLimit.cost: 1 — the whole repository scans in about 10 paginated queries, roughly 10 points against the 5,000/hour GraphQL budget, two orders of magnitude cheaper than the REST path.
Batch scan — name, last commit, open-PR statusquery($owner: String!, $name: String!, $cursor: String) { repository(owner: $owner, name: $name) { refs(refPrefix: "refs/heads/", first: 100, after: $cursor) { totalCount pageInfo { hasNextPage endCursor } nodes { name target { oid ... on Commit { committedDate } } associatedPullRequests(states: OPEN, first: 1) { totalCount } } } } rateLimit { cost remaining } } - 03
Exclude protected branches with REST `protected`, never GraphQL `branchProtectionRule`
GraphQL's branchProtectionRule field looks like the obvious way to skip protected branches inside the same scan query, but it only reports classic branch protection rules — it does not see newer Repository Rulesets, even when a ruleset is actively protecting the branch. Verified against facebook/react: its main branch is protected by an active Ruleset (confirmed via repository.rulesets), REST GET /repos/facebook/react/branches/main correctly reports "protected": true, and the GraphQL branchProtectionRule field on that same branch returns null. An implementation that trusts GraphQL alone would list main as a safe-to-clean candidate. Before anything reaches the Slack digest, cross-check the surviving candidates — or just fetch the full protected set once — against the REST call below, and drop any name that appears in it. Treat GraphQL as cheap scanning only; REST protected is the actual safety gate.
REST — the real protected-branch listcurl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2026-03-10" \ "https://api.github.com/repos/$OWNER/$REPO/branches?protected=true&per_page=100"
Slack
Publishes the digest and captures who claims each branch
- 01
Bot scopes and Interactivity
api.slack.com/apps → your app (or Create New App → From scratch) → OAuth & Permissions → Bot Token Scopes → add chat:write (post and update the digest) and channels:read (resolve the target channel id) → reinstall to the workspace. Then Interactivity & Shortcuts → toggle Interactivity on → set the Request URL to wherever your claim handler lives. Skip users:read.email and im:write: the digest posts to one shared channel, not a DM, and the claimant's identity comes straight off the button click, not an email lookup.
- 02
Post the weekly digest with a Claim button per branch
One chat.postMessage call, one block per surviving candidate (past both GitHub filters above), each button's value carrying owner/repo:branch so the click handler needs no separate lookup table.
chat.postMessage — one Claim button per branch{ "channel": "C0STALEBRANCH", "text": "Stale branch digest — 6 branches with no commits in 30+ days", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*feature/old-payment-retry*\nLast commit 127 days ago · no open PR · not protected" } }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Claim" }, "value": "acme/web:feature/old-payment-retry", "action_id": "stale_branch_claim" } ] } ] } - 03
Acknowledge the claim inside 3 seconds, then mark it
Slack POSTs a form-encoded payload (a JSON string) to your Request URL when the button is clicked, and expects a 200 within 3 seconds — acknowledge first, do the rest after. Then call chat.update on the original message (its ts came back in the chat.postMessage response; persist it keyed by owner/repo:branch or you cannot update the row later) to show the claimant's name, and write the claim to your own storage. That storage step is not optional: chat.update only changes what the message displays, it does not tell next week's scan that this branch was already claimed — without a small persisted claim list (even a flat KV store), the same branch reappears in every digest. Separately: Slack's API almost always answers with HTTP 200 and reports real failures inside the JSON body as "ok": false — a health check or retry loop written against the HTTP status code alone will treat an authentication failure as a success.
Linear
Logs branches nobody claims as tech debt
- 01
Create a Personal API key — mind the auth header format
Linear → workspace name (top left) → Settings → Account → Security & Access → Personal API keys → New API key. Linear's auth header is Authorization: <API_KEY> with no Bearer prefix — every other API in this workflow uses Bearer <token>, and carrying that habit over here fails authentication. Once the key exists, run query { teams { nodes { id name } } } and query { issueLabels { nodes { id name } } } one time each and keep the ids in config rather than looking them up on every run. For an organization rather than one person's key, prefer a Linear OAuth App; a personal key is enough to start.
- 02
Escalate unclaimed branches to a tech-debt issue
Pick a threshold — 2 to 3 consecutive weekly digests with no claim is a reasonable starting point — and once a branch crosses it, call issueCreate for it: one mutation per branch, assigneeId left unset. Leaving it unassigned is deliberate: auto-assigning the issue to whoever last touched the branch trains people to dread the digest instead of using it; let the team triage tech debt the way it triages everything else.
issueCreate — unclaimed branch becomes tech debtmutation { issueCreate(input: { teamId: "a1b2c3d4-team-id" title: "Stale branch: feature/old-payment-retry (127 days, no open PR)" description: "Auto-flagged by stale-branch digest. Repo acme/web, last commit 2026-04-03, never claimed after 3 weekly digests." labelIds: ["e5f6-tech-debt-label-id"] }) { success issue { id identifier url } } }