AIRBRAKE → PR AUTOMATION PIPELINE

Claude Code automatically fixes production errors and ships PRs

STATUS: OPERATIONAL

// SYSTEM OVERVIEW

This system automatically detects production errors from Airbrake, creates GitHub issues, and then uses Claude Code to read the error, find the bug, fix it, and ship a PR — completely autonomously.

What it does
  • → Polls for unfixed Airbrake errors every 60 seconds
  • → Triages: closes unfixable, labels uncertain, picks 1 to fix
  • → Creates a git worktree, reads the backtrace, fixes the bug
  • → Runs rubocop + specs, creates PR, waits for CI
  • → Requests Copilot code review, addresses feedback
  • → Resolves the Airbrake error group via API
Prerequisites
  • claude — Claude Code CLI (Opus model)
  • gh — GitHub CLI, authenticated
  • jq — JSON processor
  • doppler — Secrets management (or set env vars)
  • → Airbrake → GitHub issue integration (or similar)
  • → A project with specs and a linter

// DATA FLOW

                        ┌─────────────────────┐
                        │    AIRBRAKE          │
                        │  (error monitoring)  │
                        └─────────┬───────────┘
                                  │ webhook
                                  ▼
                        ┌─────────────────────┐
                        │   GITHUB ISSUES     │
                        │  (error → issue)    │
                        └─────────┬───────────┘
                                  │
          ┌───────────────────────┤
          │                       │
          ▼                       ▼
┌──────────────────┐    ┌─────────────────────┐
│  SYSTEMD TIMER   │    │  airbrake-issues    │
│  (every 60s)     │───▶│  fixable            │
└────────┬─────────┘    └─────────┬───────────┘
         │                        │ filters out:
         │                        │  - already has PR
         │                        │  - in-progress
         │                        │  - wont-fix/unsure
         ▼                        ▼
┌──────────────────────────────────────────────┐
│          fix-airbrakes.sh                    │
│  (loads secrets, launches Claude)            │
└────────────────────┬─────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────┐
│       CLAUDE CODE  (/fix-airbrakes skill)    │
│                                              │
│  1. Read all fixable issues                  │
│  2. Triage: close unfixable, label unsure    │
│  3. Pick 1 issue group to fix                │
│  4. Claim it (prevent duplicate work)        │
│  5. Create worktree (airbrake-NNNN)          │
│  6. Read backtrace → find bug → fix it       │
│  7. Run /ship-it:                            │
│     a. rubocop + specs                       │
│     b. commit, push, create PR               │
│     c. Wait for Buildkite CI                 │
│     d. Request Copilot review                │
│     e. Address feedback, re-run CI           │
│  8. Unclaim issue                            │
│  9. Resolve Airbrake error group             │
└──────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────┐
│              GITHUB PR                       │
│  CI green ✓  Copilot reviewed ✓  Ready       │
└──────────────────────────────────────────────┘
1

SYSTEMD TIMER & SERVICE

systemd

A systemd user timer fires every 60 seconds, invoking the fixer script. The service has a 30-minute timeout — plenty of time for Claude to fix a bug and ship a PR. Using scope: user means no root access needed.

FILE: ~/.config/systemd/user/airbrake-fixer.timer
# Runs the airbrake fixer on a loop
[Unit]
Description=Run airbrake fixer every 60 seconds

[Timer]
OnBootSec=60
OnUnitActiveSec=60
Persistent=true

[Install]
WantedBy=timers.target
FILE: ~/.config/systemd/user/airbrake-fixer.service
[Unit]
Description=Airbrake issue fixer (Claude Code)

[Service]
Type=oneshot
ExecStart=%h/.local/bin/fix-airbrakes.sh
Environment=HOME=%h
TimeoutStartSec=1800
ENABLE IT:
systemctl --user daemon-reload
systemctl --user enable --now airbrake-fixer.timer

# Check status
systemctl --user status airbrake-fixer.timer
journalctl --user -u airbrake-fixer.service --no-pager -n 20
2

TRIGGER SCRIPT

bash

This is the entry point that systemd calls. It uses flock to prevent concurrent runs, loads secrets from Doppler, checks for fixable issues, and launches Claude Code with the /fix-airbrakes skill.

FILE: ~/.local/bin/fix-airbrakes.sh
#!/bin/bash
# Scan for fixable Airbrake issues and launch Claude Code to fix them.
# Runs every 60 seconds via systemd timer.

set -euo pipefail

LOCKFILE="/tmp/fix-airbrakes.lock"

# Prevent concurrent runs with flock
exec 200>"$LOCKFILE"
if ! flock -n 200; then
  echo "$(date): Already running, skipping"
  exit 0
fi

cd /home/deploy/projects/crowdcow

export HOME=/home/deploy
export PATH="$HOME/.local/bin:$PATH"
export AIRBRAKE_REPO="Crowd-Cow/crowdcow"

# Load secrets from Doppler
set +u
eval "$(doppler secrets download --project devbox --config prd --no-file --format env 2>/dev/null)"
unset DOPPLER_PROJECT DOPPLER_CONFIG DOPPLER_ENVIRONMENT
eval "$(doppler secrets download --project crowdcow --config dev --no-file --format env 2>/dev/null)"
unset DOPPLER_PROJECT DOPPLER_CONFIG DOPPLER_ENVIRONMENT
set -u

# Check for fixable issues
issues=$("$HOME/.local/bin/airbrake-issues" fixable 2>&1)

if [ "$issues" = "[]" ] || [ -z "$issues" ]; then
  echo "$(date): No fixable issues found"
  exit 0
fi

# Launch Claude to fix the issue
LOGFILE="$HOME/logs/fix-airbrake/$(date +%Y%m%d-%H%M%S).log"
echo "$(date): Found fixable issues, launching claude (log: $LOGFILE)"
claude --dangerously-skip-permissions -p "/fix-airbrakes" --model opus >> "$LOGFILE" 2>&1
KEY DESIGN DECISIONS:
  • flock — prevents the timer from stacking up runs if a fix takes longer than 60s
  • -p "/fix-airbrakes" — runs the skill as a single prompt (non-interactive Claude)
  • --model opus — uses the most capable model for complex bug fixing
  • --dangerously-skip-permissions — no human in the loop (runs autonomously)
  • Doppler — secrets are loaded at runtime, not stored in files
3

airbrake-issues CLI

bash

The core CLI tool that manages the issue lifecycle. It wraps gh and jq to query GitHub issues, track in-progress work, and resolve Airbrake errors via the API. Claude calls this tool during the fix workflow.

fixable
Open issues minus: has PR, in-progress, wont-fix, unsure
claim / unclaim
Lock tracking to prevent duplicate work (auto-prunes after 2h)
resolve
Marks the Airbrake error group as resolved via API
FILE: ~/.local/bin/airbrake-issues
#!/usr/bin/env bash
set -euo pipefail

# Airbrake issue manager for automated fix workflows.
# Tracks which issues are being worked on to prevent duplicate PRs.
#
# Prerequisites: gh (authenticated), jq
#
# Usage:
#   airbrake-issues list                     # List open airbrake issues (JSON)
#   airbrake-issues fixable                  # List issues not yet addressed
#   airbrake-issues addressed                # Issue numbers with open PRs
#   airbrake-issues claim ISSUE [BRANCH]     # Mark issue as in-progress
#   airbrake-issues unclaim ISSUE            # Remove in-progress mark
#   airbrake-issues status                   # Show tracking file
#   airbrake-issues comment ISSUE 'msg'      # Post a comment on issue
#   airbrake-issues resolve ISSUE            # Resolve Airbrake error group

# Auto-detect repo from git remote
REPO="${AIRBRAKE_REPO:-$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || echo "")}"
if [[ -z "$REPO" ]]; then
  echo "Error: Could not detect GitHub repo. Set AIRBRAKE_REPO or run from a git repo." >&2
  exit 1
fi

TRACKING_FILE="$HOME/.claude/airbrake-in-progress.json"

# Load env vars from Claude settings if not already set
if [[ -z "${AIRBRAKE_PROJECT_ID:-}" || -z "${AIRBRAKE_API_KEY:-}" ]] && [[ -f "$HOME/.claude/settings.local.json" ]]; then
  AIRBRAKE_PROJECT_ID="${AIRBRAKE_PROJECT_ID:-$(jq -r '.env.AIRBRAKE_PROJECT_ID // empty' "$HOME/.claude/settings.local.json" 2>/dev/null)}"
  AIRBRAKE_API_KEY="${AIRBRAKE_API_KEY:-$(jq -r '.env.AIRBRAKE_API_KEY // empty' "$HOME/.claude/settings.local.json" 2>/dev/null)}"
fi

AIRBRAKE_PROJECT_ID="${AIRBRAKE_PROJECT_ID:?Set AIRBRAKE_PROJECT_ID env var or in ~/.claude/settings.local.json}"

ensure_tracking_file() {
  mkdir -p "$(dirname "$TRACKING_FILE")"
  if [[ ! -f "$TRACKING_FILE" ]]; then
    echo '{"issues":{}}' > "$TRACKING_FILE"
  fi
}

# Remove entries older than 2 hours (prevents stale locks)
prune_stale() {
  ensure_tracking_file
  local cutoff
  cutoff=$(date -u -v-2H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)

  local pruned
  pruned=$(jq --arg cutoff "$cutoff" '
    .issues |= with_entries(select(.value.started_at > $cutoff))
  ' "$TRACKING_FILE")
  echo "$pruned" > "$TRACKING_FILE"
}

cmd_list() {
  gh issue list --repo "$REPO" --state open --limit 200 \
    --json number,title,body,labels,createdAt,comments
}

cmd_addressed() {
  # Find issue numbers referenced by open PRs (Fixes/Closes/Resolves #NNN)
  local prs
  prs=$(gh pr list --repo "$REPO" --state open --limit 100 --json number,title,body,headRefName)

  echo "$prs" | jq -r '
    [.[] | (
      # Check PR body for Fixes/Closes/Resolves #NNN
      (.body // "" | [scan("(?i)(?:fixes|closes|resolves)\\s+#(\\d+)")] | .[][] // empty),
      # Check branch name for airbrake-NNN pattern
      (.headRefName | capture("airbrake[/-](?<n>\\d+)")? | .n // empty)
    )] | map(select(. != null and . != "")) | unique | sort | .[]
  '
}

cmd_fixable() {
  prune_stale

  local issues addressed in_progress

  issues=$(cmd_list)
  addressed=$(cmd_addressed)
  in_progress=$(jq -r '.issues | keys[]' "$TRACKING_FILE")

  # Combine addressed + in_progress into a skip set
  local skip_set
  skip_set=$(printf '%s\n%s' "$addressed" "$in_progress" | sort -u | grep -v '^$' || true)

  # Filter out already-triaged issues
  local filtered
  filtered=$(echo "$issues" | jq '
    [.[] | select(
      (.labels // []) | map(.name) | any(. == "wont-fix" or . == "unsure") | not
    )]
  ')

  if [[ -z "$skip_set" ]]; then
    echo "$filtered"
  else
    echo "$filtered" | jq --argjson skip "$(echo "$skip_set" | jq -R 'tonumber' | jq -s '.')" '
      [.[] | select(.number as $n | ($skip | index($n)) | not)]
    '
  fi
}

cmd_claim() {
  local issue="${1:-}"
  local branch="${2:-}"

  if [[ -z "$issue" ]]; then
    echo "Usage: airbrake-issues claim ISSUE [BRANCH]" >&2
    exit 1
  fi

  ensure_tracking_file
  prune_stale

  local now
  now=$(date -u +%Y-%m-%dT%H:%M:%SZ)

  jq --arg issue "$issue" --arg branch "$branch" --arg now "$now" '
    .issues[$issue] = {started_at: $now, branch: $branch}
  ' "$TRACKING_FILE" > "${TRACKING_FILE}.tmp" && mv "${TRACKING_FILE}.tmp" "$TRACKING_FILE"

  echo "Claimed issue #$issue"
}

cmd_unclaim() {
  local issue="${1:-}"
  if [[ -z "$issue" ]]; then
    echo "Usage: airbrake-issues unclaim ISSUE" >&2; exit 1
  fi
  ensure_tracking_file
  jq --arg issue "$issue" 'del(.issues[$issue])' "$TRACKING_FILE" \
    > "${TRACKING_FILE}.tmp" && mv "${TRACKING_FILE}.tmp" "$TRACKING_FILE"
  echo "Unclaimed issue #$issue"
}

cmd_status() {
  ensure_tracking_file
  prune_stale
  jq '.' "$TRACKING_FILE"
}

cmd_comment() {
  local issue="${1:-}"
  local message="${2:-}"
  if [[ -z "$issue" || -z "$message" ]]; then
    echo "Usage: airbrake-issues comment ISSUE 'message'" >&2; exit 1
  fi
  gh issue comment "$issue" --repo "$REPO" --body "$message"
  echo "Commented on issue #$issue"
}

cmd_extract_group_id() {
  local issue="${1:-}"
  if [[ -z "$issue" ]]; then
    echo "Usage: airbrake-issues extract-group-id ISSUE" >&2; exit 1
  fi
  local body
  body=$(gh issue view "$issue" --repo "$REPO" --json body -q .body)
  echo "$body" | grep -oE 'airbrake\.io/projects/[0-9]+/groups/[0-9]+' | head -1 | grep -oE '[0-9]+$'
}

cmd_resolve() {
  local issue="${1:-}"
  if [[ -z "$issue" ]]; then
    echo "Usage: airbrake-issues resolve ISSUE_NUMBER" >&2; exit 1
  fi

  : "${AIRBRAKE_API_KEY:?Set AIRBRAKE_API_KEY}"

  local group_id
  group_id=$(cmd_extract_group_id "$issue")
  if [[ -z "$group_id" ]]; then
    echo "Error: could not extract Airbrake group ID from issue #$issue" >&2; exit 1
  fi

  echo "Resolving Airbrake group $group_id (from issue #$issue)..."

  local response
  response=$(curl -s -w "\n%{http_code}" \
    -X PUT \
    "https://api.airbrake.io/api/v4/projects/${AIRBRAKE_PROJECT_ID}/groups/${group_id}/resolved?key=${AIRBRAKE_API_KEY}")

  local http_code body
  http_code=$(echo "$response" | tail -1)
  body=$(echo "$response" | sed '$d')

  if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
    echo "Resolved Airbrake group $group_id (HTTP $http_code)"
  else
    echo "Error resolving group $group_id (HTTP $http_code):" >&2
    echo "$body" >&2; exit 1
  fi
}

case "${1:-}" in
  list)             cmd_list ;;
  fixable)          cmd_fixable ;;
  addressed)        cmd_addressed ;;
  claim)            shift; cmd_claim "$@" ;;
  unclaim)          shift; cmd_unclaim "$@" ;;
  status)           cmd_status ;;
  comment)          shift; cmd_comment "$@" ;;
  extract-group-id) shift; cmd_extract_group_id "$@" ;;
  resolve)          shift; cmd_resolve "$@" ;;
  *)                echo "Usage: airbrake-issues <command> [args]" >&2; exit 1 ;;
esac
HOW THE DEDUP LOGIC WORKS:

The fixable command is the heart of preventing duplicate work. It builds a "skip set" from three sources:

  1. addressed — scans all open PRs for Fixes #NNN in the body and airbrake-NNN in the branch name
  2. in_progress — reads the tracking JSON file for claimed issues
  3. labels — filters out issues with wont-fix or unsure labels

The tracking file auto-prunes entries older than 2 hours, so if a Claude run crashes, the issue becomes available again automatically.

TRACKING FILE FORMAT:
// ~/.claude/airbrake-in-progress.json
{
  "issues": {
    "17108": {
      "started_at": "2026-03-23T10:30:00Z",
      "branch": "airbrake-17108"
    }
  }
}
4

/fix-airbrakes SKILL (Claude Code Command)

markdown

This is a Claude Code custom command — a markdown file in ~/.claude/commands/ that becomes a slash command. When Claude is launched with -p "/fix-airbrakes", this file becomes the system prompt that tells Claude exactly what to do.

HOW CLAUDE CODE COMMANDS WORK:
Place a .md file in ~/.claude/commands/ (global) or .claude/commands/ (per-project). The filename (minus extension) becomes the slash command name. The markdown content is the prompt Claude receives. Frontmatter (name, description) is optional metadata.
FILE: ~/.claude/commands/fix-airbrakes.md
---
name: fix-airbrakes
description: Scan open Airbrake GitHub issues, group duplicates, and create
  PRs to fix them. Uses worktrees and /ship-it for each fix. Skips issues
  already addressed by open PRs.
---

# Fix Airbrakes

Automatically scan open Airbrake-labeled GitHub issues, identify fixable
bugs, and create PRs.

## CLI Helper

Use `airbrake-issues` for dedup, tracking, and Airbrake API:
- `airbrake-issues fixable` — returns open issues NOT already addressed
  by a PR or in-progress (full JSON with bodies)
- `airbrake-issues claim ISSUE BRANCH` — mark issue as in-progress
- `airbrake-issues unclaim ISSUE` — remove in-progress mark
- `airbrake-issues status` — show what's currently being tracked
- `airbrake-issues comment ISSUE 'message'` — post a comment on issue
- `airbrake-issues resolve ISSUE` — resolve the Airbrake error group

## Workflow (strict order)

### 1. Get and read all fixable issues

```
airbrake-issues fixable
```

Read ALL the issue bodies. Each contains error type, message, backtrace,
URL, occurrences, and severity. Use this to:
- Group issues that are the same underlying bug (same error type +
  same code path)
- Identify which are fixable vs transient/external
- Pick the best one to fix

If no fixable issues, report "No fixable Airbrake issues found" and stop.

### 2. Triage unfixable and uncertain issues

**Unfixable issues** — external API errors, gem-only backtraces, timeouts,
transient issues, `[safely]`-wrapped errors, 503/502 errors from external
services — close with `wont-fix`:

```bash
gh issue close ISSUE_NUMBER --repo Crowd-Cow/crowdcow \
  --comment "[Airbrake Bot] Closing: <reason>"
gh issue edit ISSUE_NUMBER --repo Crowd-Cow/crowdcow --add-label wont-fix
```

**Uncertain issues** — you can't tell from the backtrace alone whether it's
fixable — label with `unsure` and leave open:

```bash
gh issue edit ISSUE_NUMBER --repo Crowd-Cow/crowdcow --add-label unsure
gh issue comment ISSUE_NUMBER --repo Crowd-Cow/crowdcow \
  --body "[Airbrake Bot] Unsure: <what you'd need to know>"
```

Do this for ALL unfixable/uncertain issues in the batch.

### 3. Select an issue group to fix

Pick at most 1 issue group per run. Prioritize by:
1. Groups with higher issue counts (more impact)
2. Groups with clearer, more localized fixes
3. Older groups first

### 4. Claim the issue

```
airbrake-issues claim ISSUE_NUMBER BRANCH_NAME
```

### 5. Fix the bug in a worktree

Use `EnterWorktree` to create a worktree named `airbrake-NNNN`.

After entering the worktree:
- Copy `.env` if needed
- Run `bundle install -j4` and `npm ci`

Then:
1. Read and understand the relevant source files from the backtrace
2. Identify the root cause
3. Implement the fix — keep it minimal
4. If there are relevant specs, run them to verify

### 6. Ship it

Run the `ship-it` workflow. This will:
- Run rubocop + specs
- Commit, push, and create a PR
- Wait for CI
- Request and address Copilot reviews

**IMPORTANT:** PR title should start with `[Airbrake]` and body
must include `Fixes #NNNN` for each issue in the group.

### 7. Clean up tracking and resolve Airbrake

```
airbrake-issues unclaim ISSUE_NUMBER
airbrake-issues resolve ISSUE_NUMBER
```

### 8. Report results

Report:
- Which issues were processed
- PR URL if created
- Whether Airbrake errors were resolved
- Any issues that were skipped and why

## Important rules

- One fix per run. Do not attempt multiple unrelated fixes.
- Stay in the worktree for the entire workflow including ship-it.
- Do not fix issues in gem code. Only fix code under PROJECT_ROOT.
- If you can't figure out the fix, close the issue. Add wont-fix.
- Never mutate production data.
5

/ship-it SKILL

markdown

The shipping workflow called by /fix-airbrakes after the bug is fixed. This is a general-purpose skill — it handles linting, testing, committing, pushing, creating the PR, waiting for CI, and looping through code reviews until everything is clean.

FILE: ~/.claude/commands/ship-it.md
---
name: ship-it
description: Full end-to-end workflow that lands work, requests a GitHub
  Copilot review, waits for feedback, and addresses it in a loop.
---

# Ship It

Full end-to-end workflow that lands work, requests a GitHub Copilot review,
waits for feedback, and addresses it in a loop.

## CLI tools

These CLIs wrap complex APIs and handle pagination, polling, and edge cases.
Always use them instead of raw `gh api` calls.

- `buildkite` — CI status, wait-for-completion, failure logs
- `gh-reviews` — Review threads, replies, resolution, Copilot review

## Workflow (strict order)

1. Run the `land-the-plane` workflow.
   - This handles rubocop, specs, commit, sync, push, and PR creation.

2. Wait for Buildkite CI to pass.
   - Run the `check-ci` workflow (uses `buildkite wait` to poll).
   - If CI fails and the failure is fixable (rubocop, specs), fix it,
     commit, push, and re-run `check-ci`.
   - If CI fails with infrastructure issue, report and continue.

3. Request a Copilot review.
   - `gh-reviews copilot-review`

4. Wait for review to complete.
   - `gh-reviews copilot-wait` (polls automatically, 10min max)
   - If timeout, continue anyway.

5. Address feedback loop.
   - If there are unresolved threads, run `check-for-reviews`.
   - Always use `gh-reviews` for thread operations.
   - After addressing, re-request review with `gh-reviews copilot-review`,
     then repeat wait/poll until no unresolved threads remain.
   - After pushing review fixes, re-run `check-ci`.

6. Confirm and report PR URL.
   - `gh pr view --json url -q .url`

## Completion criteria

You are not done until ALL of the following are true:
- CI is green.
- Every review thread is either resolved (fix applied) or has a
  reply explaining why it was not fixed.
- PR URL is confirmed.

## Notes

- This is an iterative workflow:
  land → CI → review → fix → CI → review → fix → done
- Never use `gh api` for review operations — `gh-reviews` handles it.
- Never use `gh pr checks` for CI — `buildkite` has wait/poll support.
THE REVIEW LOOP VISUALIZED:
  ┌─────────────┐
  │ Push + PR    │
  └──────┬──────┘
         ▼
  ┌─────────────┐     fail    ┌──────────┐
  │ Wait for CI │────────────▶│ Fix + Push│──┐
  └──────┬──────┘             └──────────┘  │
         │ pass                      ▲       │
         ▼                           └───────┘
  ┌─────────────────┐
  │ Request Copilot │
  │ Review          │
  └──────┬──────────┘
         ▼
  ┌─────────────────┐  threads  ┌───────────┐
  │ Wait for Review │─────────▶│ Address    │──┐
  └──────┬──────────┘          │ Feedback   │  │
         │ clean               └───────────┘  │
         ▼                           ▲        │
  ┌─────────────┐                    └────────┘
  │   DONE      │               (re-request review)
  └─────────────┘
6

ANSIBLE ROLE — DEPLOYMENT

ansible

Everything is deployed via Ansible — never manually. This role installs the CLI tool, the trigger script, the Claude commands, configures secrets, and enables the systemd timer. Run with: ansible-playbook site.yml --tags airbrake_fixer -l devbox -c local

FILE: ansible/roles/airbrake_fixer/tasks/main.yml
# Role: airbrake_fixer
# Automated Airbrake issue fixer — runs Claude Code every 60 seconds
# to scan for fixable Airbrake GitHub issues and create PRs.
#
# Requires: claude_code role, projects role
---
- name: Deploy airbrake-issues CLI
  ansible.builtin.copy:
    src: airbrake-issues
    dest: "/home/{{ deploy_user }}/.local/bin/airbrake-issues"
    mode: "0755"

- name: Deploy fix-airbrakes cron script
  ansible.builtin.template:
    src: fix-airbrakes.sh.j2
    dest: "/home/{{ deploy_user }}/.local/bin/fix-airbrakes.sh"
    mode: "0755"

- name: Create log directory
  ansible.builtin.file:
    path: "/home/{{ deploy_user }}/logs/fix-airbrake"
    state: directory
    mode: "0755"

- name: Populate settings.local.json with Airbrake keys from Doppler
  ansible.builtin.shell: |
    AIRBRAKE_PROJECT_ID=$(doppler secrets get AIRBRAKE_PROJECT_ID \
      --project crowdcow --config dev --plain 2>/dev/null)
    AIRBRAKE_API_KEY=$(doppler secrets get AIRBRAKE_API_KEY \
      --project crowdcow --config dev --plain 2>/dev/null)
    if [ -z "$AIRBRAKE_PROJECT_ID" ] || [ -z "$AIRBRAKE_API_KEY" ]; then
      echo "Failed to fetch Airbrake keys from Doppler" >&2
      exit 1
    fi
    SETTINGS="$HOME/.claude/settings.local.json"
    [ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
    jq --arg pid "$AIRBRAKE_PROJECT_ID" --arg key "$AIRBRAKE_API_KEY" \
      '.env = (.env // {}) |
       .env.AIRBRAKE_PROJECT_ID = $pid |
       .env.AIRBRAKE_API_KEY = $key' \
      "$SETTINGS" > "${SETTINGS}.tmp" && mv "${SETTINGS}.tmp" "$SETTINGS"
    chmod 600 "$SETTINGS"
  become_user: "{{ deploy_user }}"

- name: Create Claude commands directory
  ansible.builtin.file:
    path: "/home/{{ deploy_user }}/.claude/commands"
    state: directory
    mode: "0755"

- name: Deploy fix-airbrakes command
  ansible.builtin.copy:
    src: fix-airbrakes.md
    dest: "/home/{{ deploy_user }}/.claude/commands/fix-airbrakes.md"
    mode: "0644"

- name: Deploy ship-it command
  ansible.builtin.copy:
    src: ship-it.md
    dest: "/home/{{ deploy_user }}/.claude/commands/ship-it.md"
    mode: "0644"

- name: Deploy airbrake-fixer systemd user service
  ansible.builtin.template:
    src: airbrake-fixer.service.j2
    dest: "~/.config/systemd/user/airbrake-fixer.service"

- name: Deploy airbrake-fixer systemd user timer
  ansible.builtin.template:
    src: airbrake-fixer.timer.j2
    dest: "~/.config/systemd/user/airbrake-fixer.timer"

- name: Enable and start airbrake-fixer timer
  ansible.builtin.systemd:
    name: airbrake-fixer.timer
    enabled: true
    state: started
    daemon_reload: true
    scope: user
7

SECRETS & CONFIGURATION

The system needs a few secrets and config values. Secrets are managed via Doppler and injected into ~/.claude/settings.local.json at deploy time.

REQUIRED SECRETS
AIRBRAKE_PROJECT_ID Your Airbrake project ID
AIRBRAKE_API_KEY Airbrake API key (for resolving)
CLAUDE_CODE_OAUTH_TOKEN Claude Code auth token
GH_TOKEN GitHub token (for gh CLI)
SETTINGS FILE
// ~/.claude/settings.local.json
{
  "env": {
    "AIRBRAKE_PROJECT_ID": "146810",
    "AIRBRAKE_API_KEY": "sk-..."
  }
}
The airbrake-issues CLI reads this file as a fallback when env vars aren't set. This way the CLI works both from the systemd timer (Doppler env) and from interactive Claude sessions (settings file).
AIRBRAKE → GITHUB INTEGRATION
This system assumes Airbrake errors already become GitHub issues. Airbrake has a built-in GitHub integration that creates issues when new error groups are detected. Each issue body contains the error type, message, backtrace, and a link back to the Airbrake group. The extract-group-id command parses that link to get the group ID for API resolution.
8

ADAPT TO YOUR PROJECT

Here's what you need to change to use this in your own project.

1. SET YOUR REPO
In fix-airbrakes.sh, change the repo and project path:
export AIRBRAKE_REPO="YourOrg/your-repo"
cd /path/to/your/project
2. CONFIGURE SECRETS
Either use Doppler (change project names) or set env vars directly in the service file:
[Service]
Environment=AIRBRAKE_PROJECT_ID=12345
Environment=AIRBRAKE_API_KEY=your-key
Environment=GH_TOKEN=ghp_xxx
3. CUSTOMIZE THE FIX-AIRBRAKES SKILL
Edit ~/.claude/commands/fix-airbrakes.md to match your project:
  • → Change Crowd-Cow/crowdcow to your repo
  • → Adjust the worktree setup commands (your project might use yarn instead of npm ci)
  • → Adjust triage rules for your error patterns
  • → Change the CI tool references if you use something other than Buildkite
4. CUSTOMIZE THE SHIP-IT SKILL
Edit ~/.claude/commands/ship-it.md:
  • → Replace buildkite CLI references with your CI (GitHub Actions, CircleCI, etc.)
  • → Replace gh-reviews copilot-review with your review process
  • → Adjust the linter (rubocop → eslint, etc.)
5. INSTALL & ENABLE
# Install the files
cp airbrake-issues ~/.local/bin/
cp fix-airbrakes.sh ~/.local/bin/
chmod +x ~/.local/bin/airbrake-issues ~/.local/bin/fix-airbrakes.sh
mkdir -p ~/.claude/commands ~/logs/fix-airbrake

cp fix-airbrakes.md ~/.claude/commands/
cp ship-it.md ~/.claude/commands/

# Install systemd units
mkdir -p ~/.config/systemd/user
cp airbrake-fixer.service ~/.config/systemd/user/
cp airbrake-fixer.timer ~/.config/systemd/user/

# Enable and start
systemctl --user daemon-reload
systemctl --user enable --now airbrake-fixer.timer

# Verify
systemctl --user status airbrake-fixer.timer
journalctl --user -u airbrake-fixer.service -f
6. NOT USING AIRBRAKE?
The pattern works with any error-to-issue pipeline. Replace the Airbrake-specific parts:
  • Sentry: Use Sentry's GitHub integration for issue creation, replace resolve command with Sentry API call
  • Bugsnag: Same pattern — webhook → issue → Claude → PR
  • Custom: Any system that creates GitHub issues with error details works

// FILE STRUCTURE

~/.local/bin/
  airbrake-issues           # CLI tool — dedup, claim, resolve
  fix-airbrakes.sh          # Trigger script — loads secrets, launches Claude

~/.claude/
  commands/fix-airbrakes.md # Claude skill — triage + fix workflow
  commands/ship-it.md       # Claude skill — lint, test, PR, CI, review
  settings.local.json       # Airbrake API keys (env vars)
  airbrake-in-progress.json # Tracking state (auto-prunes)

~/.config/systemd/user/
  airbrake-fixer.timer      # Fires every 60s
  airbrake-fixer.service    # Runs fix-airbrakes.sh (30min timeout)

~/logs/fix-airbrake/
  20260323-103000.log       # Claude output per run