diff --git a/.github/workflows/pr-labels-ci.yml b/.github/workflows/pr-labels-ci.yml new file mode 100644 index 0000000..e1d2ee0 --- /dev/null +++ b/.github/workflows/pr-labels-ci.yml @@ -0,0 +1,136 @@ +# mcp-awareness — ambient system awareness for AI agents +# Copyright (C) 2026 Chris Means +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +name: PR Label Automation (CI) + +# Handles label transitions when CI completes (pass or fail). +# Split from pr-labels.yml because workflow_run fires on every CI +# completion (including merges to main), which caused noisy failures +# when there was no associated PR. +# +# NOTE: References the CI workflow by name ("CI"). +# If the CI workflow in ci.yml is ever renamed, update the name here too. +# +# LIMITATION: workflow_run triggers always run from the default branch +# (main), not the PR branch. Changes to this file cannot be tested +# on a PR — they take effect only after merge. +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + pull-requests: write + checks: read + +jobs: + # When CI passes on a PR branch, promote from Awaiting CI to Ready for QA. + # Skips if Dev Active is present (dev isn't done yet). + on-ci-pass: + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_branch != github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - name: Promote to Ready for QA + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO=${{ github.repository }} + + # Look up the associated PR. + # The API can 404 after a force-push (orphaned run). + # Capture output only on success so 404 body doesn't leak into $PR. + PR="" + API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \ + --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true + + # Fallback: pull_requests array is empty for dependabot PRs. + # Search by head branch instead. + if [ -z "$PR" ]; then + HEAD_BRANCH=${{ github.event.workflow_run.head_branch }} + PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \ + --json number --jq '.[0].number // empty' 2>/dev/null) || true + fi + + if [ -z "$PR" ]; then + echo "No PR associated with this workflow run — exiting" + exit 0 + fi + + LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name') + + # Dev Active means dev isn't done — don't promote. + # on-unlabel will handle promotion when Dev Active is removed. + if echo "$LABELS" | grep -q "^Dev Active$"; then + echo "Dev Active present — skipping promotion" + exit 0 + fi + + # Promote if Awaiting CI is present + if echo "$LABELS" | grep -q "^Awaiting CI$"; then + REMOVE="Awaiting CI" + if echo "$LABELS" | grep -q "^CI Failed$"; then + REMOVE="Awaiting CI,CI Failed" + fi + gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE" + gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA" + fi + + # When CI fails on a PR branch, set CI Failed. + # Skips if Dev Active is present (dev is already working). + on-ci-fail: + if: >- + github.event.workflow_run.conclusion == 'failure' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_branch != github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - name: Set CI Failed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO=${{ github.repository }} + + PR="" + API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \ + --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true + + if [ -z "$PR" ]; then + HEAD_BRANCH=${{ github.event.workflow_run.head_branch }} + PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \ + --json number --jq '.[0].number // empty' 2>/dev/null) || true + fi + + if [ -z "$PR" ]; then + echo "No PR associated with this workflow run — exiting" + exit 0 + fi + + LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name') + + # Dev Active means dev is already working — don't add CI Failed + if echo "$LABELS" | grep -q "^Dev Active$"; then + echo "Dev Active present — skipping CI Failed" + exit 0 + fi + + # Set CI Failed if Awaiting CI is present (normal flow) + if echo "$LABELS" | grep -q "^Awaiting CI$"; then + gh pr edit "$PR" --repo "$REPO" --remove-label "Awaiting CI" + gh pr edit "$PR" --repo "$REPO" --add-label "CI Failed" + fi diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml index cca90c3..0109c18 100644 --- a/.github/workflows/pr-labels.yml +++ b/.github/workflows/pr-labels.yml @@ -16,25 +16,23 @@ name: PR Label Automation -# NOTE: The workflow_run trigger references the CI workflow by name ("CI"). -# If the CI workflow in ci.yml is ever renamed, update the name here too. +# Handles label transitions triggered by PR events (push, label, unlabel). +# CI-completion promotion lives in pr-labels-ci.yml (separate workflow_run trigger). on: pull_request: branches: [main] - types: [synchronize, labeled, unlabeled] - workflow_run: - workflows: [CI] - types: [completed] + types: [opened, synchronize, labeled, unlabeled] permissions: pull-requests: write checks: read jobs: - # When new commits are pushed to a PR, reset to Awaiting CI. - # Removes any QA/review labels that are now stale. + # When a PR is opened or new commits are pushed, reset to Awaiting CI. + # Removes stale QA/workflow labels. Keeps Dev Active if present (coexists + # with Awaiting CI — dev is still working, CI runs but won't promote). on-push: - if: github.event_name == 'pull_request' && github.event.action == 'synchronize' + if: github.event.action == 'opened' || github.event.action == 'synchronize' runs-on: ubuntu-latest steps: - name: Reset labels on new push @@ -50,9 +48,9 @@ jobs: HAD_QA_ACTIVE=true fi - # Remove stale workflow labels (coalesced where possible) + # Remove stale workflow labels (coalesced into one API call) REMOVE="" - for LABEL in "Ready for QA" "Ready for QA Signoff" "QA Approved" "QA Active" "QA Failed" "QA Invalidated"; do + for LABEL in "Ready for QA" "Ready for QA Signoff" "QA Approved" "QA Active" "QA Failed" "CI Failed"; do if echo "$PR_LABELS" | grep -q "\"$LABEL\""; then if [ -n "$REMOVE" ]; then REMOVE="$REMOVE,$LABEL" @@ -65,83 +63,24 @@ jobs: gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE" fi - # Only add Awaiting CI if Dev Active is NOT present. - # If Dev Active is on, the dev isn't done yet — on-unlabel will - # handle the transition when they remove it. - if echo "$PR_LABELS" | grep -q '"Dev Active"'; then - echo "Dev Active present — skipping Awaiting CI (dev still working)" - elif ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then + # Add Awaiting CI (unless already present). + # Dev Active is NOT removed — it coexists with Awaiting CI. + if ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then gh pr edit "$PR" --repo "$REPO" --add-label "Awaiting CI" fi - # If QA was actively reviewing, flag the invalidation + # If QA was actively reviewing, notify via comment if [ "$HAD_QA_ACTIVE" = true ]; then - gh pr edit "$PR" --repo "$REPO" --add-label "QA Invalidated" gh pr comment "$PR" --repo "$REPO" \ --body "New commits pushed while QA was active. QA review invalidated — resetting to Awaiting CI." fi - # When CI completes successfully, move from Awaiting CI to Ready for QA. - # Only works for same-origin PRs — fork PRs must be promoted manually - # because the workflow_run pull_requests array is empty for forks. - on-ci-pass: - if: >- - github.event_name == 'workflow_run' - && github.event.workflow_run.conclusion == 'success' - && github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-latest - steps: - - name: Promote to Ready for QA - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - REPO=${{ github.repository }} - - # Get the PR number from the workflow run. - # This can 404 after a force-push (the old commit's run is orphaned). - PR=$(gh api repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests \ - --jq '.[0].number // empty' 2>/dev/null) || true - - # Fallback: pull_requests array is empty for dependabot and other - # non-default-branch PRs. Search by head branch instead. - if [ -z "$PR" ]; then - HEAD_BRANCH=${{ github.event.workflow_run.head_branch }} - PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \ - --json number --jq '.[0].number // empty' 2>/dev/null) || true - fi - - if [ -z "$PR" ]; then - echo "No PR associated with this workflow run (may be a fork PR or force-pushed away)" - exit 0 - fi - - LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name') - - # Don't promote if Dev Active is present — dev isn't done yet. - # When they remove Dev Active, on-unlabel will check CI and promote. - if echo "$LABELS" | grep -q "^Dev Active$"; then - echo "Dev Active present — skipping promotion (dev still working)" - exit 0 - fi - - # Only promote if Awaiting CI is present - if echo "$LABELS" | grep -q "^Awaiting CI$"; then - # Also clean up QA Invalidated if it lingered from a previous push - REMOVE="Awaiting CI" - if echo "$LABELS" | grep -q "^QA Invalidated$"; then - REMOVE="Awaiting CI,QA Invalidated" - fi - gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE" - gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA" - fi - - # When Dev Active is removed, transition to Awaiting CI — or straight - # to Ready for QA if CI already passed (avoids stuck state when CI - # finishes before the label is removed). + # When Dev Active is removed manually, check if CI already passed. + # If yes, promote straight to Ready for QA. If no, Awaiting CI is + # already present and on-ci-pass will promote when CI completes. on-unlabel: if: >- - github.event_name == 'pull_request' - && github.event.action == 'unlabeled' + github.event.action == 'unlabeled' && github.event.label.name == 'Dev Active' runs-on: ubuntu-latest steps: @@ -153,39 +92,37 @@ jobs: PR=${{ github.event.pull_request.number }} REPO=${{ github.repository }} - # Check if CI already passed for the head commit (job-name-agnostic). + # Check if CI already passed for the head commit HEAD_SHA=${{ github.event.pull_request.head.sha }} CI_CONCLUSION=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$HEAD_SHA" \ --jq '.workflow_runs[0].conclusion // empty' 2>/dev/null) || true if [ "$CI_CONCLUSION" = "success" ]; then - # CI passed — go straight to Ready for QA + # CI passed — promote to Ready for QA REMOVE="" if echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then REMOVE="Awaiting CI" fi - if echo "$PR_LABELS" | grep -q '"QA Invalidated"'; then - REMOVE="${REMOVE:+$REMOVE,}QA Invalidated" + if echo "$PR_LABELS" | grep -q '"CI Failed"'; then + REMOVE="${REMOVE:+$REMOVE,}CI Failed" fi if [ -n "$REMOVE" ]; then gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE" fi gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA" else - # CI hasn't passed yet — ensure Awaiting CI is present + # CI hasn't passed — Awaiting CI should already be present if ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then gh pr edit "$PR" --repo "$REPO" --add-label "Awaiting CI" fi fi # When a workflow label is added, clean up labels that no longer apply. - # Only runs for labels that have cleanup rules — skips irrelevant labels - # to avoid wasted runner invocations. + # Only runs for labels that have cleanup rules. on-label: if: >- - github.event_name == 'pull_request' - && github.event.action == 'labeled' - && contains(fromJSON('["QA Active","Dev Active","Ready for QA Signoff","QA Failed","QA Approved"]'), github.event.label.name) + github.event.action == 'labeled' + && contains(fromJSON('["Ready for QA","QA Active","Dev Active","Ready for QA Signoff","QA Failed","QA Approved"]'), github.event.label.name) runs-on: ubuntu-latest steps: - name: Clean up stale labels @@ -204,12 +141,16 @@ jobs: } case "$ADDED_LABEL" in + "Ready for QA") + remove_if_present "Awaiting CI" + remove_if_present "CI Failed" + ;; "QA Active") remove_if_present "Ready for QA" ;; "Dev Active") remove_if_present "QA Failed" - remove_if_present "QA Invalidated" + remove_if_present "CI Failed" remove_if_present "Awaiting CI" remove_if_present "Ready for QA" ;; @@ -218,6 +159,7 @@ jobs: ;; "QA Failed") remove_if_present "QA Active" + remove_if_present "Ready for QA Signoff" ;; "QA Approved") remove_if_present "Ready for QA Signoff" diff --git a/docs/superpowers/plans/2026-03-30-pr-label-state-machine.md b/docs/superpowers/plans/2026-03-30-pr-label-state-machine.md new file mode 100644 index 0000000..8639760 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-pr-label-state-machine.md @@ -0,0 +1,508 @@ +# PR Label State Machine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite the PR label automation as a clean state machine with proper transitions, replacing the current buggy incremental code on the `fix/pr-labels-workflow-split` branch. + +**Architecture:** Two workflow files split by trigger type: `pr-labels.yml` (pull_request events) and `pr-labels-ci.yml` (workflow_run events). A shared PR-lookup pattern in the CI file handles 404s safely. Label cleanup rules in on-label provide defense-in-depth for every transition. + +**Tech Stack:** GitHub Actions YAML, bash shell scripts, `gh` CLI + +**Spec:** `docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md` + +**Branch:** `fix/pr-labels-workflow-split` (already exists, will be rewritten) + +--- + +### Task 1: Rewrite pr-labels.yml (pull_request triggers) + +**Files:** +- Rewrite: `.github/workflows/pr-labels.yml` + +This file handles three jobs triggered by `pull_request` events: `on-push`, `on-unlabel`, and `on-label`. + +- [ ] **Step 1: Write the complete pr-labels.yml file** + +Replace the entire contents of `.github/workflows/pr-labels.yml` with: + +```yaml +# mcp-awareness — ambient system awareness for AI agents +# Copyright (C) 2026 Chris Means +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +name: PR Label Automation + +# Handles label transitions triggered by PR events (push, label, unlabel). +# CI-completion promotion lives in pr-labels-ci.yml (separate workflow_run trigger). +on: + pull_request: + branches: [main] + types: [opened, synchronize, labeled, unlabeled] + +permissions: + pull-requests: write + checks: read + +jobs: + # When a PR is opened or new commits are pushed, reset to Awaiting CI. + # Removes stale QA/workflow labels. Keeps Dev Active if present (coexists + # with Awaiting CI — dev is still working, CI runs but won't promote). + on-push: + if: github.event.action == 'opened' || github.event.action == 'synchronize' + runs-on: ubuntu-latest + steps: + - name: Reset labels on new push + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + PR=${{ github.event.pull_request.number }} + REPO=${{ github.repository }} + + HAD_QA_ACTIVE=false + if echo "$PR_LABELS" | grep -q '"QA Active"'; then + HAD_QA_ACTIVE=true + fi + + # Remove stale workflow labels (coalesced into one API call) + REMOVE="" + for LABEL in "Ready for QA" "Ready for QA Signoff" "QA Approved" "QA Active" "QA Failed" "CI Failed"; do + if echo "$PR_LABELS" | grep -q "\"$LABEL\""; then + if [ -n "$REMOVE" ]; then + REMOVE="$REMOVE,$LABEL" + else + REMOVE="$LABEL" + fi + fi + done + if [ -n "$REMOVE" ]; then + gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE" + fi + + # Add Awaiting CI (unless already present). + # Dev Active is NOT removed — it coexists with Awaiting CI. + if ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then + gh pr edit "$PR" --repo "$REPO" --add-label "Awaiting CI" + fi + + # If QA was actively reviewing, notify via comment + if [ "$HAD_QA_ACTIVE" = true ]; then + gh pr comment "$PR" --repo "$REPO" \ + --body "New commits pushed while QA was active. QA review invalidated — resetting to Awaiting CI." + fi + + # When Dev Active is removed manually, check if CI already passed. + # If yes, promote straight to Ready for QA. If no, Awaiting CI is + # already present and on-ci-pass will promote when CI completes. + on-unlabel: + if: >- + github.event.action == 'unlabeled' + && github.event.label.name == 'Dev Active' + runs-on: ubuntu-latest + steps: + - name: Transition after Dev Active removed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + PR=${{ github.event.pull_request.number }} + REPO=${{ github.repository }} + + # Check if CI already passed for the head commit + HEAD_SHA=${{ github.event.pull_request.head.sha }} + CI_CONCLUSION=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$HEAD_SHA" \ + --jq '.workflow_runs[0].conclusion // empty' 2>/dev/null) || true + + if [ "$CI_CONCLUSION" = "success" ]; then + # CI passed — promote to Ready for QA + REMOVE="" + if echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then + REMOVE="Awaiting CI" + fi + if echo "$PR_LABELS" | grep -q '"CI Failed"'; then + REMOVE="${REMOVE:+$REMOVE,}CI Failed" + fi + if [ -n "$REMOVE" ]; then + gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE" + fi + gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA" + else + # CI hasn't passed — Awaiting CI should already be present + if ! echo "$PR_LABELS" | grep -q '"Awaiting CI"'; then + gh pr edit "$PR" --repo "$REPO" --add-label "Awaiting CI" + fi + fi + + # When a workflow label is added, clean up labels that no longer apply. + # Only runs for labels that have cleanup rules. + on-label: + if: >- + github.event.action == 'labeled' + && contains(fromJSON('["Ready for QA","QA Active","Dev Active","Ready for QA Signoff","QA Failed","QA Approved"]'), github.event.label.name) + runs-on: ubuntu-latest + steps: + - name: Clean up stale labels + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ADDED_LABEL: ${{ github.event.label.name }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + PR=${{ github.event.pull_request.number }} + REPO=${{ github.repository }} + + remove_if_present() { + if echo "$PR_LABELS" | grep -q "\"$1\""; then + gh pr edit "$PR" --repo "$REPO" --remove-label "$1" + fi + } + + case "$ADDED_LABEL" in + "Ready for QA") + remove_if_present "Awaiting CI" + remove_if_present "CI Failed" + ;; + "QA Active") + remove_if_present "Ready for QA" + ;; + "Dev Active") + remove_if_present "QA Failed" + remove_if_present "CI Failed" + remove_if_present "Awaiting CI" + remove_if_present "Ready for QA" + ;; + "Ready for QA Signoff") + remove_if_present "QA Active" + ;; + "QA Failed") + remove_if_present "QA Active" + ;; + "QA Approved") + remove_if_present "Ready for QA Signoff" + ;; + esac +``` + +- [ ] **Step 2: Verify YAML syntax** + +Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/pr-labels.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/pr-labels.yml +git commit -m "refactor: rewrite pr-labels.yml as clean state machine + +Complete rewrite based on the state machine spec. Key changes: +- Dev Active coexists with Awaiting CI (no longer blocks it) +- on-push keeps Dev Active, removes all other workflow labels +- on-unlabel checks CI status and promotes if already passed +- on-label cleanup includes Ready for QA removing Awaiting CI + CI Failed +- QA Invalidated label removed entirely (comment is sufficient) +- CI Failed added to cleanup rules for Dev Active and Ready for QA" +``` + +--- + +### Task 2: Rewrite pr-labels-ci.yml (workflow_run triggers) + +**Files:** +- Rewrite: `.github/workflows/pr-labels-ci.yml` + +This file handles two jobs triggered by CI completion: `on-ci-pass` and `on-ci-fail`. + +- [ ] **Step 1: Write the complete pr-labels-ci.yml file** + +Replace the entire contents of `.github/workflows/pr-labels-ci.yml` with: + +```yaml +# mcp-awareness — ambient system awareness for AI agents +# Copyright (C) 2026 Chris Means +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +name: PR Label Automation (CI) + +# Handles label transitions when CI completes (pass or fail). +# Split from pr-labels.yml because workflow_run fires on every CI +# completion (including merges to main), which caused noisy failures +# when there was no associated PR. +# +# NOTE: References the CI workflow by name ("CI"). +# If the CI workflow in ci.yml is ever renamed, update the name here too. +# +# LIMITATION: workflow_run triggers always run from the default branch +# (main), not the PR branch. Changes to this file cannot be tested +# on a PR — they take effect only after merge. +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + pull-requests: write + checks: read + +jobs: + # When CI passes on a PR branch, promote from Awaiting CI to Ready for QA. + # Skips if Dev Active is present (dev isn't done yet). + on-ci-pass: + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_branch != github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - name: Promote to Ready for QA + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO=${{ github.repository }} + + # Look up the associated PR. + # The API can 404 after a force-push (orphaned run). + # Capture output only on success so 404 body doesn't leak into $PR. + PR="" + API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \ + --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true + + # Fallback: pull_requests array is empty for dependabot PRs. + # Search by head branch instead. + if [ -z "$PR" ]; then + HEAD_BRANCH=${{ github.event.workflow_run.head_branch }} + PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \ + --json number --jq '.[0].number // empty' 2>/dev/null) || true + fi + + if [ -z "$PR" ]; then + echo "No PR associated with this workflow run — exiting" + exit 0 + fi + + LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name') + + # Dev Active means dev isn't done — don't promote. + # on-unlabel will handle promotion when Dev Active is removed. + if echo "$LABELS" | grep -q "^Dev Active$"; then + echo "Dev Active present — skipping promotion" + exit 0 + fi + + # Promote if Awaiting CI is present + if echo "$LABELS" | grep -q "^Awaiting CI$"; then + REMOVE="Awaiting CI" + if echo "$LABELS" | grep -q "^CI Failed$"; then + REMOVE="Awaiting CI,CI Failed" + fi + gh pr edit "$PR" --repo "$REPO" --remove-label "$REMOVE" + gh pr edit "$PR" --repo "$REPO" --add-label "Ready for QA" + fi + + # When CI fails on a PR branch, set CI Failed. + # Skips if Dev Active is present (dev is already working). + on-ci-fail: + if: >- + github.event.workflow_run.conclusion == 'failure' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_branch != github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - name: Set CI Failed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO=${{ github.repository }} + + PR="" + API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \ + --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true + + if [ -z "$PR" ]; then + HEAD_BRANCH=${{ github.event.workflow_run.head_branch }} + PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \ + --json number --jq '.[0].number // empty' 2>/dev/null) || true + fi + + if [ -z "$PR" ]; then + echo "No PR associated with this workflow run — exiting" + exit 0 + fi + + LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name') + + # Dev Active means dev is already working — don't add CI Failed + if echo "$LABELS" | grep -q "^Dev Active$"; then + echo "Dev Active present — skipping CI Failed" + exit 0 + fi + + # Set CI Failed if Awaiting CI is present (normal flow) + if echo "$LABELS" | grep -q "^Awaiting CI$"; then + gh pr edit "$PR" --repo "$REPO" --remove-label "Awaiting CI" + gh pr edit "$PR" --repo "$REPO" --add-label "CI Failed" + fi +``` + +- [ ] **Step 2: Verify YAML syntax** + +Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/pr-labels-ci.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/pr-labels-ci.yml +git commit -m "refactor: rewrite pr-labels-ci.yml with CI Failed state and safe PR lookup + +Complete rewrite based on the state machine spec. Key changes: +- on-ci-fail adds CI Failed label (not Dev Active) when CI fails +- Dev Active suppresses both pass and fail transitions +- Safe 404 handling: API output captured only on success +- head_branch filter skips CI runs on main (merge commits) +- CI Failed cleaned up on next CI pass" +``` + +--- + +### Task 3: Create CI Failed label and clean up QA Invalidated + +**Files:** None (GitHub API only) + +- [ ] **Step 1: Create the CI Failed label** + +```bash +gh label create "CI Failed" --repo cmeans/mcp-awareness --color "B60205" --description "CI failed — dev needs to fix" --force +``` + +Expected: Label created (red color matches QA Failed) + +- [ ] **Step 2: Delete QA Invalidated label if it exists** + +```bash +gh label delete "QA Invalidated" --repo cmeans/mcp-awareness --yes 2>/dev/null || echo "Label does not exist" +``` + +Expected: Label deleted or "does not exist" + +- [ ] **Step 3: Verify label set** + +```bash +gh label list --repo cmeans/mcp-awareness | grep -E "Active|Awaiting|Failed|Ready|QA|CI" +``` + +Expected: Shows `Dev Active`, `Awaiting CI`, `CI Failed`, `Ready for QA`, `QA Active`, `Ready for QA Signoff`, `QA Failed`, `QA Approved`. No `QA Invalidated`. + +- [ ] **Step 4: Commit** (no file changes — label operations are API-only, but commit the spec) + +```bash +git add docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md +git commit -m "docs: add PR label state machine design spec" +``` + +--- + +### Task 4: Update PR description and push + +- [ ] **Step 1: Update the PR #108 description to reflect the full redesign** + +```bash +gh pr edit 108 --title "refactor: PR label automation as a proper state machine" --body "$(cat <<'PREOF' +## Summary + +- Rewrites the PR label automation as a clean state machine with defined states, transitions, and cleanup rules +- Splits workflow_run trigger into separate file to eliminate failure emails on merges to main +- Adds `CI Failed` label for CI failures (covers both hard failures and Codecov) +- Removes `QA Invalidated` label (push comment is sufficient) +- `Dev Active` now coexists with `Awaiting CI` instead of blocking it + +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> Awaiting_CI : PR opened / push + Dev_Active --> Awaiting_CI : push (coexist) + Awaiting_CI --> Ready_for_QA : CI pass (no Dev Active) + Awaiting_CI --> CI_Failed : CI fail (no Dev Active) + CI_Failed --> Awaiting_CI : push (fix) + Ready_for_QA --> QA_Active : QA starts + QA_Active --> Ready_for_QA_Signoff : signoff + QA_Active --> QA_Failed : fail + Ready_for_QA_Signoff --> QA_Approved : approved + QA_Approved --> [*] : merge + QA_Failed --> Dev_Active : dev picks up +``` + +## Design spec + +`docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md` + +## Changes + +| File | What | +|------|------| +| `pr-labels.yml` | Rewritten: on-push keeps Dev Active, removes all others, adds Awaiting CI. on-unlabel promotes if CI passed. on-label has full cleanup matrix. | +| `pr-labels-ci.yml` | Rewritten: on-ci-pass promotes (skips if Dev Active). on-ci-fail adds CI Failed (skips if Dev Active). Safe 404 handling. | +| Labels | Created `CI Failed`. Deleted `QA Invalidated`. | + +## QA + +### Prerequisites +- Review both workflow files against the spec + +### Manual tests +1. - [ ] **New PR without Dev Active** — create a test PR, verify it gets `Awaiting CI` on open, `Ready for QA` after CI passes (note: on-ci-pass runs from main, so this tests the OLD code until merge) +2. - [ ] **Push with Dev Active** — add `Dev Active` to a PR, push a commit, verify both `Dev Active` and `Awaiting CI` are present +3. - [ ] **Remove Dev Active after CI passed** — remove `Dev Active`, verify `Ready for QA` appears within 15s +4. - [ ] **CI failure without Dev Active** — push a commit that breaks lint, verify `CI Failed` appears (post-merge test only — on-ci-fail runs from main) +5. - [ ] **Label cleanup** — add `Ready for QA` manually, verify `Awaiting CI` is removed +6. - [ ] **No failure emails on merge** — merge a PR, verify no "PR Label Automation (CI)" failure email + +Note: Tests 1, 4, and 6 depend on the workflow_run trigger which runs from main. These can only be fully verified after merge. +PREOF +)" +``` + +- [ ] **Step 2: Push all commits** + +```bash +git push +``` + +- [ ] **Step 3: Verify labels on PR #108** + +```bash +gh pr view 108 --json labels --jq '[.labels[].name]' +``` + +Expected: `["Ready for QA"]` (or the automation sets it after push) + +- [ ] **Step 4: If automation doesn't set Ready for QA within 30 seconds, set it manually** + +The on-push job will reset to `Awaiting CI`, but since this is a `pull_request`-triggered workflow it runs from the PR branch with the new code. After CI passes, the old `on-ci-pass` on main will try to promote. If it fails (chicken-and-egg), manually set: + +```bash +gh pr edit 108 --remove-label "Awaiting CI" --add-label "Ready for QA" +``` diff --git a/docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md b/docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md new file mode 100644 index 0000000..18a73a4 --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-pr-label-state-machine-design.md @@ -0,0 +1,176 @@ +# PR Label State Machine Design + +**Date:** 2026-03-30 +**Status:** Approved +**Scope:** `.github/workflows/pr-labels.yml`, `.github/workflows/pr-labels-ci.yml` + +## Problem + +The PR label automation grew organically and had several bugs: +- `workflow_run` trigger fired on merges to main (no associated PR), causing failure emails +- `Dev Active` blocked `Awaiting CI` from being added, creating a dead end +- `opened` event was missing, so new PRs never received `Awaiting CI` +- `Ready for QA` didn't clean up `Awaiting CI`, allowing label coexistence +- No CI failure label — GitHub's soft failures (Codecov) aren't always blocking, but the team applies stricter standards +- `QA Invalidated` was added without user direction and adds complexity without value + +These were discovered reactively across 4 incremental commits instead of being caught upfront. + +## Design + +### States + +Exactly one workflow label is active at a time, except `Dev Active` which coexists with `Awaiting CI`. + +| Label | Meaning | +|-------|---------| +| `Dev Active` | Dev is working on this PR. Don't promote even if CI passes. Set manually by the dev. | +| `Awaiting CI` | CI is running. Waiting for result. | +| `CI Failed` | CI failed (hard failure or soft/Codecov). Dev needs to fix. | +| `Ready for QA` | CI passed, QA can begin testing. | +| `QA Active` | QA is actively reviewing. | +| `Ready for QA Signoff` | QA finished review, waiting for sign-off decision. | +| `QA Failed` | QA found issues. Back to dev. | +| `QA Approved` | QA passed. Merge is unblocked (satisfies QA Gate check). | + +### Removed + +- `QA Invalidated` — removed entirely. Push resets labels and the PR comment is sufficient notification that QA's review was invalidated. + +### State Diagram + +```mermaid +stateDiagram-v2 + [*] --> Awaiting_CI : PR opened + Dev_Active --> Awaiting_CI : push (coexist) + Awaiting_CI --> Ready_for_QA : CI pass (no Dev Active) + Awaiting_CI --> CI_Failed : CI fail (no Dev Active) + CI_Failed --> Awaiting_CI : push (fix) + Ready_for_QA --> QA_Active : QA starts + QA_Active --> Ready_for_QA_Signoff : signoff + QA_Active --> QA_Failed : fail + Ready_for_QA_Signoff --> QA_Failed : fail at signoff + Ready_for_QA_Signoff --> QA_Approved : approved + QA_Approved --> [*] : merge + QA_Failed --> Dev_Active : dev picks up + + state Dev_Active { + direction LR + [*] --> holding : manual set + holding --> holding : push (CI runs, no promotion) + } + + note right of Dev_Active + Coexists with Awaiting CI. + CI runs but does not promote. + Removing Dev Active triggers + promotion if CI already passed. + end note + + note right of CI_Failed + Covers hard failures (lint, tests) + and soft failures (Codecov). + Cleaned up on next push or CI pass. + end note +``` + +### Dev Active Semantics + +`Dev Active` is a **manual hold** that coexists with `Awaiting CI`: + +- Dev sets `Dev Active` before pushing to signal they're not done +- Push adds `Awaiting CI` alongside `Dev Active` (both present) +- CI runs normally. If CI passes, `on-ci-pass` sees `Dev Active` and **does not promote** +- If CI fails with `Dev Active` present, **nothing happens** (dev is already working) +- When dev removes `Dev Active`, `on-unlabel` checks CI status: + - CI already passed -> promote to `Ready for QA` + - CI not passed -> `Awaiting CI` is already present, `on-ci-pass` will promote when CI passes + +### Transition Rules + +#### on-push (PR opened or synchronized) + +Trigger: `pull_request: [opened, synchronize]` +File: `pr-labels.yml` + +1. Remove all QA/workflow labels: `Ready for QA`, `Ready for QA Signoff`, `QA Approved`, `QA Active`, `QA Failed`, `CI Failed` +2. **Keep `Dev Active` if present** — don't remove it +3. Add `Awaiting CI` (unless already present) +4. If `QA Active` was present, add comment: "New commits pushed while QA was active." + +#### on-ci-pass (CI completes successfully) + +Trigger: `workflow_run: completed` (success, PR branch, not default branch) +File: `pr-labels-ci.yml` + +1. Look up associated PR (API with 404 safety, fallback to branch search) +2. If no PR found, exit silently +3. If `Dev Active` present, exit (don't promote) +4. If `Awaiting CI` present: remove `Awaiting CI`, remove `CI Failed` if present, add `Ready for QA` + +#### on-ci-fail (CI fails) + +Trigger: `workflow_run: completed` (failure, PR branch, not default branch) +File: `pr-labels-ci.yml` + +1. Look up associated PR (same pattern as on-ci-pass) +2. If no PR found, exit silently +3. If `Dev Active` present, exit (dev is already working) +4. If `Awaiting CI` present: remove `Awaiting CI`, add `CI Failed` + +#### on-unlabel (Dev Active removed) + +Trigger: `pull_request: unlabeled` where label is `Dev Active` +File: `pr-labels.yml` + +1. Check if CI already passed for head commit +2. If CI passed: remove `Awaiting CI` if present, add `Ready for QA` +3. If CI not passed: ensure `Awaiting CI` is present (should already be) + +#### on-label (cleanup rules) + +Trigger: `pull_request: labeled` for specific labels +File: `pr-labels.yml` + +| Label Added | Remove | +|-------------|--------| +| `Ready for QA` | `Awaiting CI`, `CI Failed` | +| `QA Active` | `Ready for QA` | +| `Dev Active` | `QA Failed`, `CI Failed`, `Awaiting CI`, `Ready for QA` | +| `Ready for QA Signoff` | `QA Active` | +| `QA Failed` | `QA Active`, `Ready for QA Signoff` | +| `QA Approved` | `Ready for QA Signoff` | + +### Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Dev sets `Dev Active`, pushes 3 times | Each push resets `Awaiting CI`, `Dev Active` persists, CI runs but doesn't promote | +| Dev removes `Dev Active` after CI passed | `on-unlabel` promotes to `Ready for QA` | +| Dev removes `Dev Active` before CI passed | `Awaiting CI` already present, `on-ci-pass` promotes later | +| CI fails without `Dev Active` | `CI Failed` added, dev pushes fix -> `Awaiting CI` | +| CI fails with `Dev Active` | Nothing, dev is already working | +| QA fails, dev adds `Dev Active`, pushes fix | `Dev Active` cleans up `QA Failed`, push adds `Awaiting CI`, flow restarts | +| New PR opened without `Dev Active` | Straight to `Awaiting CI` | +| Codecov fails but GitHub doesn't block | `CI Failed` label makes stricter standard visible | +| QA fails at signoff stage | `QA Failed` removes `Ready for QA Signoff`, back to dev | + +### File Structure + +Two workflow files, split by trigger type: + +- **`pr-labels.yml`** — `on: pull_request [opened, synchronize, labeled, unlabeled]` + - Jobs: `on-push`, `on-unlabel`, `on-label` +- **`pr-labels-ci.yml`** — `on: workflow_run [completed]` + - Jobs: `on-ci-pass`, `on-ci-fail` + +Rationale: `workflow_run` fires on every CI completion (including merges to main). Separating it prevents noisy failures when there's no associated PR. The `head_branch != default_branch` filter plus safe 404 handling provide defense in depth. + +### Known Limitation + +`workflow_run`-triggered workflows always run from the default branch (main). Changes to `pr-labels-ci.yml` cannot be tested on a PR branch — they take effect only after merge. The `pull_request`-triggered jobs in `pr-labels.yml` DO run from the PR branch and can be tested normally. + +### QA Gate + +`qa-gate.yml` is unchanged. It sets a GitHub status check based on the `QA Approved` label. This is the merge gate — independent of the label automation. +