WorkflowsMCP

Stale branch cleanup digest: GitHub → Slack

Scan branches read-only, let people claim theirs in Slack, escalate the ignored ones to Linear.

@workflowsmcpVerified developer-workflowengineeringtech-debtrepo-hygienegit-housekeeping

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

  1. 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.

  2. 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 status
    query($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 }
    }
  3. 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 list
    curl -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

  1. 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.

  2. 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" }
          ] }
      ]
    }
  3. 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

  1. 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.

  2. 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 debt
    mutation {
      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 }
      }
    }