Quartet / Quintet

2026-09-04  ·  githubactions · github · ci · claudecode

Automatically Update Issue Labels Based on PR Activity

When tracking progress by adding labels like status:in-progress or status:review to Issues, you will inevitably forget to apply them.

Humans forget. AI also forgets. After finishing implementation and opening a PR, the Issue label remains in-progress. The next day, you check and wonder, "Was this already finished?"

The correct approach is to delegate state management to machines. Below is the full workflow I am currently using.

What it does

Event in PR Issue Label
PR opened status:review
Review with changes requested status:changes-requested
PR merged status:done

It identifies the Issue from Closes #<number> in the PR body.

Full Code

name: issue-status

on:
  pull_request:
    types: [opened, reopened, ready_for_review, closed]
  pull_request_review:
    types: [submitted]

permissions:
  contents: read
  issues: write
  pull-requests: read

concurrency:
  group: issue-status-${{ github.event.pull_request.number }}
  cancel-in-progress: false

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - name: Sync label on the linked issue
        env:
          GH_TOKEN: ${{ github.token }}
          REPO: ${{ github.repository }}
          PR_BODY: ${{ github.event.pull_request.body }}
          PR_MERGED: ${{ github.event.pull_request.merged }}
          ACTION: ${{ github.event.action }}
          REVIEW_STATE: ${{ github.event.review.state }}
        run: |
          set -euo pipefail

          issues="$(printf '%s' "${PR_BODY:-}" \
            | grep -oiE '(close[sd]?|fixe?[sd]?|resolve[sd]?)[[:space:]]*#[0-9]+' \
            | grep -oE '[0-9]+' \
            | sort -u || true)"

          if [ -z "$issues" ]; then
            echo "No linked issue in the PR body. Nothing to do."
            exit 0
          fi

          target=""
          if [ "$ACTION" = "closed" ]; then
            if [ "$PR_MERGED" = "true" ]; then target="status:done"; fi
          elif [ "$ACTION" = "submitted" ]; then
            case "${REVIEW_STATE:-}" in
              changes_requested) target="status:changes-requested" ;;
            esac
          else
            target="status:review"
          fi

          [ -z "$target" ] && { echo "No state change for this event"; exit 0; }

          all="status:planned status:in-progress status:review \
               status:changes-requested status:conflict status:done"

          for n in $issues; do
            echo "Issue #${n} -> ${target}"
            gh issue edit "$n" --repo "$REPO" --add-label "$target" >/dev/null
            for l in $all; do
              [ "$l" = "$target" ] && continue
              gh issue edit "$n" --repo "$REPO" --remove-label "$l" >/dev/null 2>&1 || true
            done
          done

Pitfalls Encountered

Deleting a non-existent label causes failure

gh issue edit --remove-label fails if the label is not attached. Since I want to keep status: labels to exactly one at all times, I remove all of them. Without handling this, set -e would cause the script to exit on the first missing label.

gh issue edit "$n" --remove-label "$l" >/dev/null 2>&1 || true

I suppress the error. However, I do not suppress errors for --add-label. If adding a label fails, it is a real problem, so I let it fail to make it noticeable.

Race conditions with consecutive events

If a review is submitted immediately after opening a PR, two events run almost simultaneously. Both touch the same Issue labels, and depending on the order, an older state might overwrite a newer one.

concurrency:
  group: issue-status-${{ github.event.pull_request.number }}
  cancel-in-progress: false

I serialize them per PR number. cancel-in-progress: false is critical; if set to true, the preceding label update is cancelled mid-execution. It is better to wait.

Ignoring raw references like #45

Changing state based on mentions like "Related: #45" causes accidents. I only capture lines where Closes / Fixes / Resolves precede the number.

Here are the actual verification results:

Closes #12                    -> [12]
Fixes #7 and Closes #9        -> [7 9]
Resolved #3                   -> [3]
この PR は #45 に関連します      -> []        ← Not captured
issue #8 を見てください          -> []        ← Not captured

Minimize permissions

The default GITHUB_TOKEN has broad permissions, so I restrict them explicitly. Only Issue writing is required for this workflow.

Does not work for PRs from forks

The GITHUB_TOKEN in the fork's source repository is read-only, so labels are not updated for external contributor PRs. This is not an issue for personal projects or internal repositories, but if you use this in OSS, you would need to consider pull_request_target (though additional security precautions are required).

Create Labels First

If the labels do not exist, --add-label will fail, so I also script their creation.

labels=(
  "status:planned|ededed|Issue created, not started"
  "status:in-progress|1d76db|Implementation in progress"
  "status:review|fbca04|PR opened, waiting for review"
  "status:changes-requested|d93f0b|Changes requested"
  "status:conflict|b60205|Waiting for conflict resolution"
  "status:done|0e8a16|Merged and closed"
)

for entry in "${labels[@]}"; do
  IFS='|' read -r name color desc <<< "${entry}"
  gh label create "${name}" --color "${color}" --description "${desc}" 2>/dev/null \
    || gh label edit "${name}" --color "${color}" --description "${desc}"
done

If creation fails, it falls back to editing, so running it multiple times yields the same result.

Why delegate to machines?

This is part of a setup where I assign multiple personas to Claude Code to drive development. When dividing roles into designer, implementer, and reviewer, you want each persona to handle label operations. However, if you leave this to the personas, they will inevitably forget.

Let machines hold the state, and let personas focus only on "what was done." Since making this distinction, the Issue list no longer diverges from reality.

I have open-sourced the settings for these four personas under the MIT license. Copy them and run ./setup.sh to get started.


I publish the configuration for splitting Claude Code into separate personas — Architect, Coder, Reviewer, Conflict Resolver — under MIT. Copy it, run ./setup.sh, and it works. It does not depend on your tech stack.

https://github.com/quintetkit/quartet

I built one real tool using nothing but this workflow. Every Issue, PR, review and merge is still there. The parts that went wrong were not deleted.

https://github.com/quintetkit/mdlinkcheck

The version that adds a UI Designer persona, review criteria, a per-Issue parallel execution script and a 10-chapter guide is on the product page.

The workflow itself is available

Quartet, the four-persona version, is published free under MIT. Quintet adds a UI Designer persona, review criteria, a per-Issue parallel execution script, and a 10-chapter guide.

See the free version Product page