---
name: PR Automation
slug: pr-automation
category: Automation
description: PR Automation orchestrates PR triage with a label-based state machine, checking CI, running review or fix steps, and moving eligible pull requests toward merge. Use it when a daemon or the /pr-automation command should process open PRs.
github: "https://github.com/FerroxLabs/wayland/tree/main/.claude/skills/pr-automation"
language: TypeScript
stars: 596
forks: 113
install: "npx degit https://github.com/FerroxLabs/wayland/tree/main/.claude/skills/pr-automation ~/.claude/skills/pr-automation"
installs_to: ~/.claude/skills/pr-automation
source_path: .claude/skills/pr-automation/SKILL.md
collection_size: 25
category_size: 1754
collection_url: "https://dirskills.com/collections/FerroxLabs/wayland"
added: 2026-09-03T06:03:22.153Z
last_synced: 2026-09-03T06:03:22.153Z
canonical_url: "https://dirskills.com/skills/pr-automation"
---

# PR Automation

PR Automation orchestrates PR triage with a label-based state machine, checking CI, running review or fix steps, and moving eligible pull requests toward merge. Use it when a daemon or the /pr-automation command should process open PRs.

**Install:**

```bash
npx degit https://github.com/FerroxLabs/wayland/tree/main/.claude/skills/pr-automation ~/.claude/skills/pr-automation
```

## README

# PR Automation

Orchestrate the full PR automation lifecycle using a label-based state machine.
Each invocation performs at most one "heavy" action (review or fix), then exits.
Pure skips continue within the same session to find the next eligible PR.

**Announce at start:** "I'm using pr-automation skill to process PRs."

## Usage

```
/pr-automation
```

No arguments required. The daemon script `scripts/pr-automation.sh` manages the automation loop.

## Configuration

```
TRUSTED_CONTRIBUTORS_TEAM: detected from REPO org (e.g. FerroxLabs/trusted-contributors)
CRITICAL_PATH_PATTERN: env var, default in scripts/pr-automation.conf
LARGE_PR_FILE_THRESHOLD: env var (default: 50), also in scripts/pr-automation.conf
PR_DAYS_LOOKBACK: env var (default: 7), also in scripts/pr-automation.conf
```

**REPO** is detected automatically at runtime - do not hardcode it:

```bash
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
ORG=$(echo "$REPO" | cut -d'/' -f1)
```

## Label State Machine

| Label                    | Meaning                                                                 | Terminal? |
| ------------------------ | ----------------------------------------------------------------------- | --------- |
| `bot:reviewing`          | Review in progress (mutex)                                              | No        |
| `bot:ready-to-fix`       | CONDITIONAL review done, waiting for bot to fix next session            | No        |
| `bot:fixing`             | Fix in progress (mutex)                                                 | No        |
| `bot:ci-waiting`         | CI failed and author notified - snoozed until author pushes new commits | No        |
| `bot:needs-rebase`       | Merge conflict that bot cannot auto-resolve - author must rebase        | No        |
| `bot:needs-human-review` | Blocking issues - human must intervene                                  | Yes       |
| `bot:ready-to-merge`     | Bot done, code is clean - human just needs to confirm and merge         | Yes       |

## Exit Rules

- **Any substantive action** (approve workflow, post comment, run review, run fix) → EXIT after completing
- **Pure skip** (WIP, draft, terminal label, CI running, mergeability unknown, `bot:ci-waiting`) → continue to find next PR in same session

---

## Steps

### Step 1 - Fetch Candidate PRs

Read the lookback window from the environment (default 7 days):

```bash
DAYS=${PR_DAYS_LOOKBACK:-7}
gh pr list \
  --state open \
  --search "created:>=$(date -v-${DAYS}d '+%Y-%m-%d' 2>/dev/null || date -d "${DAYS} days ago" '+%Y-%m-%d') -is:draft" \
  --json number,title,labels,createdAt,author \
  --limit 50
```

Save the result as `candidate_prs`.

If `candidate_prs` is empty: log `[pr-automation] No open PRs found. Exiting.` then log `[pr-automation:exit] action=no_prs reason="no open PRs"` and EXIT.

### Step 2 - Get Trusted Contributors

```bash
gh api orgs/${ORG}/teams/trusted-contributors/members --jq '[.[].login]'
```

Save as `trusted_logins`. If API call fails, treat as empty array.

### Step 3 - Select Target PR

Sort `candidate_prs` using this **three-key** order:

1. **Primary**: has label `bot:ready-to-fix` → these PRs first
2. **Secondary**: author.login in `trusted_logins` → trusted PRs next
3. **Tertiary**: createdAt ascending (oldest first / FIFO)

Iterate through sorted list to find the **first eligible PR**.

**Skip conditions** (skip this PR, try next - stay in session):

| Condition                               | Check                                                                       |
| --------------------------------------- | --------------------------------------------------------------------------- |
| Title contains `WIP` (case-insensitive) | `title.toLowerCase().includes('wip')`                                       |
| Has label `bot:needs-rebase`            | check labels array                                                          |
| Has label `bot:needs-human-review`      | check labels array                                                          |
| Has label `bot:ready-to-merge`          | check labels array                                                          |
| Has label `bot:done`                    | check labels array                                                          |
| Has label `bot:reviewing`               | check labels array                                                          |
| Has label `bot:fixing`                  | check labels array                                                          |
| Has label `bot:ci-waiting`              | check labels array - wake-up check runs as fallback if no eligible PR found |

**When eligible PR found:**

For **fresh PRs** (no bot: label): add `bot:reviewing` to claim it:

```bash
gh pr edit <PR_NUMBER> --add-label "bot:reviewing"
```

For **`bot:ready-to-fix` PRs**: swap label atomically:

```bash
gh pr edit <PR_NUMBER> --remove-label "bot:ready-to-fix" --add-label "bot:fixing"
```

Save this PR as `target_pr` (number, title, author.login, is_ready_to_fix).

**If no eligible PR found after full iteration:** run the snoozed PR wake-up checks as a fallback before giving up.

**Fallback: Wake Up Snoozed PRs**

Check both `bot:ci-waiting` and `bot:needs-rebase` PRs for new commits.

**1. Wake up `bot:ci-waiting` PRs:**

Fetch all open PRs with `bot:ci-waiting` and check if the author has pushed new commits since the last CI failure comment:

```bash
WAITING_PRS=$(gh pr list --state open --label "bot:ci-waiting" \
  --json number,createdAt,author --limit 50)
```

For each PR in `WAITING_PRS` (sorted by createdAt ascending, oldest first):

```bash
PR_NUMBER=<number>

LAST_CI_COMMENT_TIME=$(gh pr view $PR_NUMBER --json comments \
  --jq '[.comments[] | select(.body | test("<!-- pr-review-bot -->") and test("CI checks failed"))] | last | .createdAt // ""')

LATEST_COMMIT_TIME=$(gh pr view $PR_NUMBER --json commits \
  --jq '.commits | last | .committedDate')
```

If `LATEST_COMMIT_TIME > LAST_CI_COMMENT_TIME` (author pushed new commits since the CI failure comment):

```bash
gh pr edit $PR_NUMBER --remove-label "bot:ci-waiting" --add-label "bot:reviewing"
```

Log: `[pr-automation] PR #<PR_NUMBER> woke up from ci-waiting: new commits detected. Claiming as target.`

Save this PR as `target_pr` and **continue to Step 4** (treat it as a freshly claimed PR).

**2. Wake up `bot:needs-rebase` PRs:**

If no `bot:ci-waiting` PR was woken up, check `bot:needs-rebase` PRs:

```bash
REBASE_PRS=$(gh pr list --state open --label "bot:needs-rebase" \
  --json number,createdAt,author --limit 50)
```

For each PR in `REBASE_PRS` (sorted by createdAt ascending, oldest first):

```bash
PR_NUMBER=<number>

LAST_CONFLICT_COMMENT_TIME=$(gh pr view $PR_NUMBER --json comments \
  --jq '[.comments[] | select(.body | test("<!-- pr-review-bot -->") and test("Merge Conflict"))] | last | .createdAt // ""')

LATEST_COMMIT_TIME=$(gh pr view $PR_NUMBER --json commits \
  --jq '.commits | last | .committedDate')
```

If `LATEST_COMMIT_TIME > LAST_CONFLICT_COMMENT_TIME` (author pushed new commits since the conflict comment):

```bash
gh pr edit $PR_NUMBER --remove-label "bot:needs-rebase" --add-label "bot:reviewing"
```

Log: `[pr-automation] PR #<PR_NUMBER> woke up from needs-rebase: new commits detected. Claiming as target.`

Save this PR as `target_pr` and **continue to Step 4** (treat it as a freshly claimed PR).

If no PRs were woken up: log `[pr-automation] No eligible PR found this round.` then log `[pr-automation:exit] action=no_eligible_pr reason="all PRs skipped, no snoozed PRs woken up"` and EXIT.

### Step 3b - Handle bot:ready-to-fix PR

Taken when selected PR had `bot:ready-to-fix` (CONDITIONAL review already done in a previous session).

**First: check for new commits since the review** (author may have pushed fixes):

```bash
LAST_REVIEW_TIME=$(gh pr view <PR_NUMBER> --json comments \
  --jq '[.comments[] | select(.body | startswith("<!-- pr-review-bot -->"))] | last | .createdAt // ""')

LATEST_COMMIT_TIME=$(gh pr view <PR_NUMBER> --json commits \
  --jq '.commits | last | .committedDate')
```

If `LATEST_COMMIT_TIME > LAST_REVIEW_TIME` (author pushed new commits since review):

```bash
gh pr edit <PR_NUMBER> --remove-label "bot:fixing"
```

Log: `[pr-automation] PR #<PR_NUMBER> has new commits since review - re-queuing for fresh review.`
Log: `[pr-automation:exit] action=requeue pr=#<PR_NUMBER> reason="new commits since review"`

**EXIT.** (PR re-enters normal queue with no bot: label → will be fully re-reviewed next round)

If no new commits, continue below.

**Re-check CI** (new commits may have been pushed since review):

```bash
gh pr view <PR_NUMBER> --json statusCheckRollup \
  --jq '.statusCheckRollup[] | {name: .name, status: .status, conclusion: .conclusion}'
```

Required jobs: `Code Quality`, `Unit Tests (ubuntu-latest)`, `Unit Tests (macos-14)`, `Unit Tests (windows-2022)`, `Coverage Test`, `i18n-check`

| Condition                                                          | Action                                                              |
| ------------------------------------------------------------------ | ------------------------------------------------------------------- |
| All required jobs SUCCESS                                          | Continue to pr-fix below                                            |
| Any job QUEUED or IN_PROGRESS                                      | Remove `bot:fixing` → log "CI still running for PR #N" → EXIT       |
| Any **non-informational** job FAILURE or CANCELLED (excl. codecov) | Remove `bot:fixing` → log "CI failed for PR #N, re-queueing" → EXIT |

**Load the existing review report into the current session** (pr-fix requires it to be present):

```bash
gh pr view <PR_NUMBER> --json comments \
  --jq '.comments[] | select(.body | startswith("<!-- pr-review-bot -->")) | .body' \
  | tail -1
```

Output the fetched review report in the conversation so pr-fix can find it. If no review comment is found, abort:

```bash
gh pr edit <PR_NUMBER> --remove-label "bot:fixing" --add-label "bot:needs-human-review"
```

Log: `[pr-automation] PR #<PR_NUMBER> no review report found - cannot fix. Transferred to human review.`
Log: `[pr-automation:exit] action=needs_human pr=#<PR_NUMBER> reason="no review report found"`

**EXIT.**

**Run pr-fix:**

```
/pr-fix <PR_NUMBER> --automation
```

After pr-fix completes, check if pr-fix already handled everything (fork fallback path):

```bash
PR_STATE=$(gh pr view <PR_NUMBER> --json state --jq '.state')
```

If `PR_STATE` is `CLOSED` (pr-fix used fork fallback - closed the original PR and created a replacement):

Log: `[pr-automation] PR #<PR_NUMBER> fork fallback handled by pr-fix - original closed, replacement PR created.`
Log: `[pr-automation:exit] action=fork_fallback pr=#<PR_NUMBER> reason="pr-fix closed original and created replacement PR"`

**EXIT.**

Otherwise, post comment and mark ready to merge:

```bash
gh pr edit <PR_NUMBER> --remove-label "bot:fixing" --add-label "bot:ready-to-merge"
gh pr comment <PR_NUMBER> --body "<!-- pr-automation-bot -->
✅ Automatically fixed. No blocking issues found in the code - please confirm and merge."
```

Log: `[pr-automation] PR #<PR_NUMBER> fix complete - marked bot:ready-to-merge.`
Log: `[pr-automation:exit] action=ready_to_merge pr=#<PR_NUMBER> reason="fix complete, needs human confirmation to merge"`

**EXIT.**

### Step 4 - Check CI Status

```bash
gh pr view <PR_NUMBER> --json statusCheckRollup \
  --jq '.statusCheckRollup[] | {name: .name, status: .status, conclusion: .conclusion}'
```

Required jobs: `Code Quality`, `Unit Tests (ubuntu-latest)`, `Unit Tests (macos-14)`, `Unit Tests (windows-2022)`, `Coverage Test`, `i18n-check`

| Condition                                                                                | Action                                                                                                                             |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| All required jobs SUCCESS **and** no non-informational jobs FAILURE/CANCELLED            | Continue to Step 4.5                                                                                                               |
| Any **required** job QUEUED or IN_PROGRESS                                               | Remove `bot:reviewing` → log `[pr-automation:skip] action=ci_running pr=#<PR_NUMBER> reason="CI still running"` → **find next PR** |
| `statusCheckRollup` empty (CI never triggered)                                           | Approve workflow (see below) → remove `bot:reviewing` → **EXIT**                                                                   |
| Any **non-informational** job (required or not) FAILURE or CANCELLED (excl. `codecov/*`) | Check dedup (see below) → **find next PR** or post comment → **EXIT**                                                              |

**Workflow approval** (CI never triggered):

Use the PR's head commit SHA to precisely find `action_required` runs for this PR
(avoids missing fork PRs that `gh run list` may not return in default pagination):

```bash
HEAD_SHA=$(gh pr view <PR_NUMBER> --json headRefOid --jq '.headRefOid')
RUN_IDS=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&status=action_required" \
  --jq '.workflow_runs[].id')
for RUN_ID in $RUN_IDS; do
  gh run approve "$RUN_ID" --repo "$REPO"
done
```

Log: `[pr-automation] Approved workflow runs for PR #<PR_NUMBER>.`
Log: `[pr-automation:exit] action=workflow_approved pr=#<PR_NUMBER> reason="CI not triggered, approved workflow runs"`

Remove `bot:reviewing`:

```bash
gh pr edit <PR_NUMBER> --remove-label "bot:reviewing"
```

**EXIT.**

**CI failure dedup check:**

```bash
# Last CI failure bot comment time
LAST_CI_COMMENT_TIME=$(gh pr view <PR_NUMBER> --json comments \
  --jq '[.comments[] | select(.body | test("<!-- pr-review-bot -->") and test("CI checks failed"))] | last | .createdAt // ""')

# Latest commit time
LATEST_COMMIT_TIME=$(gh pr view <PR_NUMBER> --json commits \
  --jq '.commits | last | .committedDate')
```

- If `LAST_CI_COMMENT_TIME` is non-empty AND `LATEST_COMMIT_TIME <= LAST_CI_COMMENT_TIME`:
  No new commits since last CI failure comment - swap labels and find next PR:

  ```bash
  gh pr edit <PR_NUMBER> --remove-label "bot:reviewing" --add-label "bot:ci-waiting"
  ```

  Log `[pr-automation:skip] action=ci_failure_dedup pr=#<PR_NUMBER> reason="CI failed, no new commits since last comment"` → **find next PR**

- Otherwise: post CI failure comment below → log `[pr-automation:exit] action=ci_failed pr=#<PR_NUMBER> reason="CI failure, commented"` → remove `bot:reviewing` → **EXIT**

**CI failure comment:**

```bash
gh pr comment <PR_NUMBER> --body "<!-- pr-review-bot -->

## CI checks failed

The following jobs did not pass during this automated review - please fix them:

| Job | Conclusion |
|-----|------------|
| <failed job name> | ❌ <FAILURE or CANCELLED> |

Automated review is paused. This PR will be re-processed once all CI checks pass."
```

### Step 4.5 - Resolve Merge Conflicts

```bash
gh pr view <PR_NUMBER> --json mergeable,mergeStateStatus,headRefName,baseRefName \
  --jq '{mergeable, mergeStateStatus, head: .headRefName, base: .baseRefName}'
```

| `mergeable`   | `mergeStateStatus` | Action                                                                                                                                                |
| ------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MERGEABLE`   | any                | Continue to Step 5                                                                                                                                    |
| `UNKNOWN`     | any                | Remove `bot:reviewing` → log `[pr-automation:skip] action=merge_unknown pr=#<PR_NUMBER> reason="mergeability unknown, will retry"` → **find next PR** |
| `CONFLICTING` | any                | Run conflict dedup check (see below)                                                                                                                  |

**Merge conflict dedup check:**

```bash
# Last conflict bot comment time
LAST_CONFLICT_COMMENT_TIME=$(gh pr view <PR_NUMBER> --json comments \
  --jq '[.comments[] | select(.body | test("<!-- pr-review-bot -->") and test("Merge Conflict"))] | last | .createdAt // ""')

LATEST_COMMIT_TIME=$(gh pr view <PR_NUMBER> --json commits \
  --jq '.commits | last | .committedDate')
```

- If `LAST_CONFLICT_COMMENT_TIME` is non-empty AND `LATEST_COMMIT_TIME <= LAST_CONFLICT_COMMENT_TIME`:
  No new commits - remove `bot:reviewing` → log `[pr-automation:skip] action=conflict_dedup pr=#<PR_NUMBER> reason="conflict already notified, no new commits"` → **find next PR** (no new action)

- Otherwise: attempt auto-rebase below.

**Auto-rebase attempt (in worktree):**

```bash
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_DIR="/tmp/wayland-pr-${PR_NUMBER}"

# Clean up any stale worktree
git worktree remove "$WORKTREE_DIR" --force 2>/dev/null || true

# Create worktree in detached HEAD mode (no local branch created)
git fetch origin <head_branch>
git fetch origin <base_branch>
git worktree add "$WORKTREE_DIR" origin/<head_branch> --detach

# Symlink node_modules so tsc/lint can run in the worktree
ln -s "$REPO_ROOT/node_modules" "$WORKTREE_DIR/node_modules"

cd "$WORKTREE_DIR"
git rebase origin/<base_branch>
```

If rebase succeeds, run quality check:

```bash
cd "$WORKTREE_DIR"
bunx tsc --noEmit
bun run lint:fix
```

If quality check passes:

```bash
cd "$WORKTREE_DIR"
git push --force-with-lease origin HEAD:<head_branch>
cd "$REPO_ROOT"
git worktree remove "$WORKTREE_DIR" --force 2>/dev/null || true
gh pr edit <PR_NUMBER> --remove-label "bot:reviewing"
```

Log: `[pr-automation] Resolved merge conflicts for PR #<PR_NUMBER>, pushed rebase.`

**EXIT** (CI re-triggers automatically).

**Fallback** - if rebase fails OR quality check fails:

```bash
cd "$REPO_ROOT"
git worktree remove "$WORKTREE_DIR" --force 2>/dev/null || true
```

Post comment:

```bash
gh pr comment <PR_NUMBER> --body "<!-- pr-review-bot -->

## Merge Conflict (cannot auto-resolve)

This PR has conflicts with the target branch, and the automatic rebase could not resolve them cleanly. Please rebase manually and push again:

\`\`\`bash
git fetch origin
git rebase origin/<base_branch>
# After resolving conflicts:
git push --force-with-lease
\`\`\`"
```

```bash
gh pr edit <PR_NUMBER> --remove-label "bot:reviewing" --add-label "bot:needs-rebase"
```

Log: `[pr-automation:exit] action=conflict_unresolved pr=#<PR_NUMBER> reason="merge conflict, needs human rebase"`

**EXIT.**

### Step 5 - Assess PR Scale and Critical Path

No checkout needed - use remote refs to check the diff:

```bash
git fetch origin pull/${PR_NUMBER}/head
BASE_REF=$(gh pr view <PR_NUMBER> --json baseRefName --jq '.baseRefName')
git fetch origin "$BASE_REF"

FILES_CHANGED=$(git diff origin/${BASE_REF}...FETCH_HEAD --name-only | wc -l | tr -d ' ')

# CRITICAL_PATH_PATTERN: defined in Configuration section above
CRITICAL_FILES=""
if [ -n "$CRITICAL_PATH_PATTERN" ]; then
  CRITICAL_FILES=$(git diff origin/${BASE_REF}...FETCH_HEAD --name-only \
    | grep -E "$CRITICAL_PATH_PATTERN")
  [ -n "$CRITICAL_FILES" ] && HAS_CRITICAL=true || HAS_CRITICAL=false
else
  HAS_CRITICAL=false
fi
```

Save `FILES_CHANGED`, `HAS_CRITICAL`, and `CRITICAL_FILES` for later steps.

### Step 6 - Run pr-review (automation mode)

**Before running a new review, check if a valid cached review already exists:**

```bash
LAST_REVIEW_TIME=$(gh pr view <PR_NUMBER> --json comments \
  --jq '[.co
