// 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.
- → 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
- →
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 │
└──────────────────────────────────────────────┘
SYSTEMD TIMER & SERVICE
systemdA 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.
~/.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
~/.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
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
TRIGGER SCRIPT
bashThis 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.
~/.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
- 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
airbrake-issues CLI
bashThe 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.
~/.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
The fixable command is the heart of preventing duplicate work. It builds a "skip set" from three sources:
- addressed — scans all open PRs for
Fixes #NNNin the body andairbrake-NNNin the branch name - in_progress — reads the tracking JSON file for claimed issues
- labels — filters out issues with
wont-fixorunsurelabels
The tracking file auto-prunes entries older than 2 hours, so if a Claude run crashes, the issue becomes available again automatically.
// ~/.claude/airbrake-in-progress.json
{
"issues": {
"17108": {
"started_at": "2026-03-23T10:30:00Z",
"branch": "airbrake-17108"
}
}
}
/fix-airbrakes SKILL (Claude Code Command)
markdownThis 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.
.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.
~/.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.
/ship-it SKILL
markdownThe 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.
~/.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.
┌─────────────┐
│ Push + PR │
└──────┬──────┘
▼
┌─────────────┐ fail ┌──────────┐
│ Wait for CI │────────────▶│ Fix + Push│──┐
└──────┬──────┘ └──────────┘ │
│ pass ▲ │
▼ └───────┘
┌─────────────────┐
│ Request Copilot │
│ Review │
└──────┬──────────┘
▼
┌─────────────────┐ threads ┌───────────┐
│ Wait for Review │─────────▶│ Address │──┐
└──────┬──────────┘ │ Feedback │ │
│ clean └───────────┘ │
▼ ▲ │
┌─────────────┐ └────────┘
│ DONE │ (re-request review)
└─────────────┘
ANSIBLE ROLE — DEPLOYMENT
ansibleEverything 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
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
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.
| 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) |
// ~/.claude/settings.local.json
{
"env": {
"AIRBRAKE_PROJECT_ID": "146810",
"AIRBRAKE_API_KEY": "sk-..."
}
}
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).
extract-group-id command parses that link to get the group ID for API resolution.
ADAPT TO YOUR PROJECT
Here's what you need to change to use this in your own project.
fix-airbrakes.sh, change the repo and project path:export AIRBRAKE_REPO="YourOrg/your-repo"
cd /path/to/your/project
[Service]
Environment=AIRBRAKE_PROJECT_ID=12345
Environment=AIRBRAKE_API_KEY=your-key
Environment=GH_TOKEN=ghp_xxx
~/.claude/commands/fix-airbrakes.md to match your project:
- → Change
Crowd-Cow/crowdcowto your repo - → Adjust the worktree setup commands (your project might use
yarninstead ofnpm ci) - → Adjust triage rules for your error patterns
- → Change the CI tool references if you use something other than Buildkite
~/.claude/commands/ship-it.md:
- → Replace
buildkiteCLI references with your CI (GitHub Actions, CircleCI, etc.) - → Replace
gh-reviews copilot-reviewwith your review process - → Adjust the linter (rubocop → eslint, etc.)
# 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
- → Sentry: Use Sentry's GitHub integration for issue creation, replace
resolvecommand 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