diff --git a/.github/workflows/pr-labels-ci.yml b/.github/workflows/pr-labels-ci.yml new file mode 100644 index 0000000..55589b5 --- /dev/null +++ b/.github/workflows/pr-labels-ci.yml @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000..e60a545 --- /dev/null +++ b/.github/workflows/pr-labels.yml @@ -0,0 +1,151 @@ +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" + remove_if_present "Ready for QA Signoff" + ;; + "QA Approved") + remove_if_present "Ready for QA Signoff" + ;; + esac diff --git a/.github/workflows/qa-gate.yml b/.github/workflows/qa-gate.yml new file mode 100644 index 0000000..723c258 --- /dev/null +++ b/.github/workflows/qa-gate.yml @@ -0,0 +1,30 @@ +name: QA Gate + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + statuses: write + +jobs: + qa-approved: + runs-on: ubuntu-latest + steps: + - name: Set QA status + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if echo '${{ toJSON(github.event.pull_request.labels.*.name) }}' | grep -q '"QA Approved"'; then + STATE="success" + DESC="QA testing completed" + else + STATE="pending" + DESC="Waiting for QA — add 'QA Approved' label when testing is complete" + fi + gh api repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }} \ + --method POST \ + -f state="$STATE" \ + -f description="$DESC" \ + -f context="QA Gate"