diff --git a/.github/scripts/auto-rebase/README.md b/.github/scripts/auto-rebase/README.md new file mode 100644 index 000000000..8c83423ec --- /dev/null +++ b/.github/scripts/auto-rebase/README.md @@ -0,0 +1,37 @@ +# auto-rebase scripts + +Supporting logic for the org-level **auto-rebase** reusable workflow +(`.github/workflows/auto-rebase-reusable.yml`). All bash/jq decision logic +lives here so it can be unit-tested with bats +(`test/workflows/auto-rebase/`) instead of being trapped inline in YAML. + +The reusable workflow checks this repo out at `inputs.tooling_ref` (default +`v1`) and sources `lib/eligibility.sh` to decide which behind PRs to update. + +## `lib/eligibility.sh` + +Pure, side-effect-free predicates. Source the file, then call: + +| Function | Input | Returns | +|----------|-------|---------| +| `auto_rebase_has_current_approval` | PR reviews JSON array on stdin (`GET /repos/{repo}/pulls/{n}/reviews`, oldest-first) | `0` if the PR has a current APPROVED review, else `1` | +| `auto_rebase_has_ready_label LABEL` | PR labels JSON array on stdin | `0` if a label named `LABEL` is present, else `1` | +| `auto_rebase_pr_eligible MODE IS_DRAFT IS_APPROVED HAS_LABEL` | mode + three `true`/`false` strings | `0` eligible, `1` not eligible, `2` unknown mode | + +### Approval semantics + +`auto_rebase_has_current_approval` inspects the **actual review states**, not +`reviewDecision` (which is `null` on repos without required reviews). The most +recent *decision* review per reviewer wins — a later `CHANGES_REQUESTED` or +`DISMISSED` cancels an earlier `APPROVED`, while `COMMENTED`/`PENDING` reviews +do not change a reviewer's stance. + +### Eligibility modes (the tunable `eligibility` workflow input) + +| Mode | Meaning | +|------|---------| +| `review-ready` (default) | non-draft **AND** (current approval **OR** carries the ready label) | +| `all` | every behind PR, including drafts — restores the original unrestricted fan-out | + +New modes (e.g. a future "front-of-queue N") can be added here and selected by +callers via the `eligibility` input with no change to the workflow file. diff --git a/.github/scripts/auto-rebase/lib/eligibility.sh b/.github/scripts/auto-rebase/lib/eligibility.sh new file mode 100644 index 000000000..61f0b3a3d --- /dev/null +++ b/.github/scripts/auto-rebase/lib/eligibility.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Eligibility predicates for the auto-rebase reusable workflow. +# +# These are pure, side-effect-free functions so they can be unit-tested with +# bats (see test/workflows/auto-rebase/eligibility.bats). The reusable +# workflow sources this file and uses the predicate to decide which behind +# PRs to update-branch, instead of fanning out to every behind PR. +# +# Contract: see .github/scripts/auto-rebase/README.md + +# auto_rebase_has_current_approval +# Reads a GitHub pull-request reviews JSON array on stdin (the response of +# `GET /repos/{repo}/pulls/{n}/reviews`, oldest-first) and returns 0 if the +# PR currently has at least one APPROVED review, else 1. +# +# "Current" means the reviewer's most recent decision review wins: a later +# CHANGES_REQUESTED or DISMISSED cancels an earlier APPROVED, while +# COMMENTED/PENDING reviews do not change a reviewer's stance. We check the +# real review states here rather than reviewDecision, which is null on repos +# without required reviews (issue #465 implementer note). +auto_rebase_has_current_approval() { + local result + result=$(jq -r ' + reduce (.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED")) as $r ({}; .[$r.user.login] = $r.state) + | any(. == "APPROVED") + ') + [[ "$result" == "true" ]] +} + +# auto_rebase_has_ready_label LABEL +# Reads a PR labels JSON array on stdin (objects with a .name field) and +# returns 0 if a label named LABEL is present, else 1. +auto_rebase_has_ready_label() { + local label="$1" present + present=$(jq -r --arg L "$label" 'any(.[]; .name == $L)') + [[ "$present" == "true" ]] +} + +# auto_rebase_pr_eligible MODE IS_DRAFT IS_APPROVED HAS_LABEL +# Decides whether a behind PR should be updated, given its draft/approval/ +# label state. IS_DRAFT, IS_APPROVED and HAS_LABEL are the strings "true" +# or "false". Returns 0 (eligible), 1 (not eligible), or 2 (unknown mode). +# +# Modes (the tunable `eligibility` workflow input): +# review-ready non-draft AND (approved OR carries the ready label). +# The default — restricts fan-out to review-ready PRs. +# all every behind PR, including drafts. Escape hatch that +# restores the original unrestricted fan-out behavior. +auto_rebase_pr_eligible() { + local mode="$1" is_draft="$2" is_approved="$3" has_label="$4" + case "$mode" in + all) + return 0 + ;; + review-ready) + [[ "$is_draft" == "true" ]] && return 1 + if [[ "$is_approved" == "true" || "$has_label" == "true" ]]; then + return 0 + fi + return 1 + ;; + *) + echo "auto_rebase_pr_eligible: unknown eligibility mode '$mode'" >&2 + return 2 + ;; + esac +} diff --git a/.github/workflows/auto-rebase-reusable.yml b/.github/workflows/auto-rebase-reusable.yml index e3461d717..7d2b20a4d 100644 --- a/.github/workflows/auto-rebase-reusable.yml +++ b/.github/workflows/auto-rebase-reusable.yml @@ -10,7 +10,16 @@ # # Solution: after every push to main (typically a merged PR), this workflow: # 1. Finds all open non-Dependabot PRs from the same repo (no fork PRs) -# 2. Updates behind PRs using the merge method (not rebase) +# 2. Keeps only the PRs eligible under the `eligibility` input (default +# `review-ready`: non-draft AND (current APPROVED review OR a ready label)) +# 3. Updates the remaining behind PRs using the merge method (not rebase) +# +# Why the eligibility gate (issue #465): updating *every* behind PR fans out +# 170-220 redundant branch-update CI runs/day, most re-staled before review. +# Restricting to review-ready PRs cuts that waste for free. The predicate lives +# in .github/scripts/auto-rebase/lib/eligibility.sh (unit-tested via bats) and +# is selectable via the `eligibility` input so a future "front-of-queue N" +# variant needs no change to this file. # # Skips Dependabot PRs — those are handled by dependabot-rebase-reusable.yml. # Skips fork PRs — update-branch requires push access to the head branch. @@ -35,6 +44,33 @@ name: Auto-rebase non-Dependabot PRs (Reusable) on: workflow_call: + inputs: + eligibility: + description: | + Which behind PRs to update: + review-ready (default) — non-draft AND (current APPROVED review OR + the `ready_label`). Restricts fan-out to review-ready PRs. + all — every behind PR, including drafts. Escape hatch that restores + the original unrestricted fan-out. + New modes can be added to lib/eligibility.sh and selected here without + changing this workflow. + required: false + default: 'review-ready' + type: string + ready_label: + description: 'Label that opts a non-draft PR into auto-rebase even without an approval.' + required: false + default: 'auto-rebase:ready' + type: string + tooling_ref: + description: | + Ref of petry-projects/.github to source the auto-rebase scripts from. + Defaults to `v1` to match the @v1 pin used by caller stubs. Override + only to test a fork/PR branch (`tooling_ref: my-branch`) or main + (`tooling_ref: main`) end-to-end. + required: false + default: 'v1' + type: string secrets: GH_PAT_WORKFLOWS: description: "PAT with workflows scope — required for sentinel comment to trigger dev-lead rebase" @@ -47,12 +83,26 @@ jobs: contents: write # needed for update-branch (may touch .github/workflows/) pull-requests: write # needed to post comments on PRs steps: + - name: Checkout auto-rebase tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: petry-projects/.github + ref: ${{ inputs.tooling_ref }} + path: .auto-rebase-tooling + fetch-depth: 1 + persist-credentials: false + - name: Update behind non-Dependabot PRs env: GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} HAS_PAT: ${{ secrets.GH_PAT_WORKFLOWS != '' }} REPO: ${{ github.repository }} + ELIGIBILITY: ${{ inputs.eligibility }} + READY_LABEL: ${{ inputs.ready_label }} run: | + # shellcheck source=/dev/null + . .auto-rebase-tooling/.github/scripts/auto-rebase/lib/eligibility.sh + # Find open non-Dependabot PRs from the same repo (exclude forks) PRS=$(gh api "repos/$REPO/pulls?state=open&per_page=100" \ --jq '.[] | select(.user.login != "dependabot[bot]") | select(.head.repo != null) | select(.head.repo.full_name == .base.repo.full_name) | "\(.number) \(.head.ref)"') @@ -63,8 +113,35 @@ jobs: fi while IFS=' ' read -r PR_NUMBER HEAD_REF; do - # Get the base branch for this PR - BASE_BRANCH=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.base.ref') + # Fetch PR metadata once: base branch, draft state, labels. + PR_JSON=$(gh api "repos/$REPO/pulls/$PR_NUMBER") + BASE_BRANCH=$(jq -r '.base.ref' <<< "$PR_JSON") + IS_DRAFT=$(jq -r '.draft' <<< "$PR_JSON") + + # Eligibility gate (issue #465): only update review-ready PRs. + # Approval is read from the actual review states, not reviewDecision + # (which is null on repos without required reviews). + if auto_rebase_has_ready_label "$READY_LABEL" <<< "$(jq -c '.labels' <<< "$PR_JSON")"; then + HAS_LABEL=true + else + HAS_LABEL=false + fi + if gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/reviews" | jq -s 'add | .' | auto_rebase_has_current_approval; then + IS_APPROVED=true + else + IS_APPROVED=false + fi + + ELIG=0 + auto_rebase_pr_eligible "$ELIGIBILITY" "$IS_DRAFT" "$IS_APPROVED" "$HAS_LABEL" || ELIG=$? + if [[ "$ELIG" -eq 2 ]]; then + echo "::error::Unknown eligibility mode '$ELIGIBILITY'" + exit 1 + fi + if [[ "$ELIG" -ne 0 ]]; then + echo "PR #$PR_NUMBER ($HEAD_REF) not eligible under '$ELIGIBILITY' (draft=$IS_DRAFT approved=$IS_APPROVED label=$HAS_LABEL) — skipping" + continue + fi BEHIND=$(gh api "repos/$REPO/compare/${BASE_BRANCH}...${HEAD_REF}" \ --jq '.behind_by') diff --git a/.github/workflows/auto-rebase-tests.yml b/.github/workflows/auto-rebase-tests.yml new file mode 100644 index 000000000..f0590abf3 --- /dev/null +++ b/.github/workflows/auto-rebase-tests.yml @@ -0,0 +1,65 @@ +# Quality gates for the reusable auto-rebase workflow and its supporting +# eligibility scripts. +# +# Triggered on any PR that touches: +# - .github/workflows/auto-rebase*.yml +# - .github/scripts/auto-rebase/** +# - test/workflows/auto-rebase/** +# - standards/workflows/auto-rebase.yml +# +# Gates (all must pass before merge): +# 1. shellcheck — static analysis for the eligibility lib +# 2. bats — unit tests for the eligibility predicate +# +# Standard: this workflow enforces the contract documented in +# .github/scripts/auto-rebase/README.md. + +name: Auto-rebase Tests + +on: + pull_request: + paths: + - '.github/workflows/auto-rebase*.yml' + - '.github/scripts/auto-rebase/**' + - 'test/workflows/auto-rebase/**' + - 'standards/workflows/auto-rebase.yml' + push: + branches: + - main + paths: + - '.github/workflows/auto-rebase*.yml' + - '.github/scripts/auto-rebase/**' + - 'test/workflows/auto-rebase/**' + - 'standards/workflows/auto-rebase.yml' + +permissions: + contents: read + +concurrency: + group: auto-rebase-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint and bats + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 1 + + - name: Install bats, shellcheck, and jq + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends bats shellcheck jq + + - name: shellcheck + run: | + set -euo pipefail + shellcheck -x .github/scripts/auto-rebase/lib/eligibility.sh + + - name: Run bats suite + run: bats --print-output-on-failure test/workflows/auto-rebase/ diff --git a/standards/ci-standards.md b/standards/ci-standards.md index e06b1be59..340bf5c2d 100644 --- a/standards/ci-standards.md +++ b/standards/ci-standards.md @@ -638,13 +638,32 @@ A copy-paste ready template is available at [`standards/workflows/auto-rebase.ym On each run the workflow: 1. Lists all open same-repo PRs excluding `dependabot[bot]` and fork PRs. -2. For each PR that is behind the base branch, calls `PUT /pulls/{n}/update-branch` with `merge` method to fast-forward it. +2. Keeps only the PRs that are **eligible** under the `eligibility` input (see below), + then for each eligible PR that is behind the base branch, calls + `PUT /pulls/{n}/update-branch` with `merge` method to fast-forward it. + The `merge` method is used (never `rebase`) so existing approvals are not invalidated. 3. On `workflows` permission error: posts an idempotent comment (sentinel ``) asking the author to rebase manually. 4. On merge conflict (422): deletes any prior sentinel and posts a fresh comment (sentinel ``), which triggers the `claude-rebase` job in `claude-code-reusable.yml` to automatically resolve the conflict. If Claude cannot resolve it, it posts a clear failure comment with manual instructions. +**Eligibility (fan-out restriction):** Updating *every* behind PR generates a large +volume of redundant branch-update CI runs, most re-staled before review. The reusable +therefore gates updates on a tunable `eligibility` input: + +| `eligibility` | Updates a behind PR when… | +|---------------|---------------------------| +| `review-ready` (default) | the PR is **non-draft** AND (it has a **current `APPROVED` review** OR carries the `ready_label`, default `auto-rebase:ready`) | +| `all` | always — restores the original unrestricted fan-out | + +Approval is determined from the **actual review states** (latest decision review per +reviewer wins; a later `CHANGES_REQUESTED`/`DISMISSED` cancels an earlier `APPROVED`), +**not** `reviewDecision`, which is `null` on repos without required reviews. The +predicate lives in `.github/scripts/auto-rebase/lib/eligibility.sh` and is unit-tested +via bats; new modes (e.g. a future "front-of-queue N") can be added there and selected +through the `eligibility` input with no change to the workflow file. + **Secrets:** `GH_PAT_WORKFLOWS` is optional but **required for `claude-rebase` to be triggered** — comments posted with `GITHUB_TOKEN` do not fire `issue_comment` workflow runs (GitHub limitation). Without it the sentinel comment still appears but no automatic resolution will run. diff --git a/standards/workflows/auto-rebase.yml b/standards/workflows/auto-rebase.yml index 0e60e37cc..3e91300dc 100644 --- a/standards/workflows/auto-rebase.yml +++ b/standards/workflows/auto-rebase.yml @@ -19,6 +19,16 @@ # Auto-rebase non-Dependabot PRs — thin caller for the org-level reusable. # To adopt: copy this file to .github/workflows/auto-rebase.yml in your repo. # No secrets required — uses GITHUB_TOKEN only. +# +# By default the reusable only updates *review-ready* PRs: non-draft AND +# (carrying a current APPROVED review OR the `auto-rebase:ready` label). This +# keeps the workflow from fanning out branch-update CI runs to every behind PR. +# To tune it, pass inputs to the reusable, e.g.: +# +# with: +# eligibility: review-ready # default; or `all` to update every behind PR +# ready_label: auto-rebase:ready # label that opts a non-draft PR in +# name: Auto-rebase non-Dependabot PRs on: diff --git a/test/workflows/auto-rebase/eligibility.bats b/test/workflows/auto-rebase/eligibility.bats new file mode 100644 index 000000000..311aea553 --- /dev/null +++ b/test/workflows/auto-rebase/eligibility.bats @@ -0,0 +1,146 @@ +#!/usr/bin/env bats +# Tests for .github/scripts/auto-rebase/lib/eligibility.sh +# +# Pins issue #465: auto-rebase must update a behind PR only when it is +# non-draft AND (has a current APPROVED review OR carries the ready label), +# with the predicate selectable via a tunable mode. + +load 'helpers/setup' + +setup() { + # shellcheck source=/dev/null + . "${TT_SCRIPTS_DIR}/lib/eligibility.sh" +} + +# ── auto_rebase_has_current_approval ───────────────────────────────────────── + +@test "has_current_approval: single APPROVED review counts as approved" { + run auto_rebase_has_current_approval <<'JSON' +[{"user":{"login":"alice"},"state":"APPROVED","submitted_at":"2026-06-01T00:00:00Z"}] +JSON + [ "$status" -eq 0 ] +} + +@test "has_current_approval: no reviews is not approved" { + run auto_rebase_has_current_approval <<'JSON' +[] +JSON + [ "$status" -ne 0 ] +} + +@test "has_current_approval: only COMMENTED reviews are not approved" { + run auto_rebase_has_current_approval <<'JSON' +[{"user":{"login":"alice"},"state":"COMMENTED","submitted_at":"2026-06-01T00:00:00Z"}] +JSON + [ "$status" -ne 0 ] +} + +@test "has_current_approval: reviewer who approved then requested changes is not approved" { + run auto_rebase_has_current_approval <<'JSON' +[ + {"user":{"login":"alice"},"state":"APPROVED","submitted_at":"2026-06-01T00:00:00Z"}, + {"user":{"login":"alice"},"state":"CHANGES_REQUESTED","submitted_at":"2026-06-02T00:00:00Z"} +] +JSON + [ "$status" -ne 0 ] +} + +@test "has_current_approval: reviewer who requested changes then approved is approved" { + run auto_rebase_has_current_approval <<'JSON' +[ + {"user":{"login":"alice"},"state":"CHANGES_REQUESTED","submitted_at":"2026-06-01T00:00:00Z"}, + {"user":{"login":"alice"},"state":"APPROVED","submitted_at":"2026-06-02T00:00:00Z"} +] +JSON + [ "$status" -eq 0 ] +} + +@test "has_current_approval: a later COMMENTED review does not cancel an approval" { + run auto_rebase_has_current_approval <<'JSON' +[ + {"user":{"login":"alice"},"state":"APPROVED","submitted_at":"2026-06-01T00:00:00Z"}, + {"user":{"login":"alice"},"state":"COMMENTED","submitted_at":"2026-06-03T00:00:00Z"} +] +JSON + [ "$status" -eq 0 ] +} + +@test "has_current_approval: a dismissed approval is not approved" { + run auto_rebase_has_current_approval <<'JSON' +[ + {"user":{"login":"alice"},"state":"APPROVED","submitted_at":"2026-06-01T00:00:00Z"}, + {"user":{"login":"alice"},"state":"DISMISSED","submitted_at":"2026-06-02T00:00:00Z"} +] +JSON + [ "$status" -ne 0 ] +} + +@test "has_current_approval: one approver among several reviewers counts" { + run auto_rebase_has_current_approval <<'JSON' +[ + {"user":{"login":"alice"},"state":"CHANGES_REQUESTED","submitted_at":"2026-06-01T00:00:00Z"}, + {"user":{"login":"bob"},"state":"APPROVED","submitted_at":"2026-06-02T00:00:00Z"} +] +JSON + [ "$status" -eq 0 ] +} + +# ── auto_rebase_has_ready_label ────────────────────────────────────────────── + +@test "has_ready_label: label present" { + run auto_rebase_has_ready_label "auto-rebase:ready" <<'JSON' +[{"name":"enhancement"},{"name":"auto-rebase:ready"}] +JSON + [ "$status" -eq 0 ] +} + +@test "has_ready_label: label absent" { + run auto_rebase_has_ready_label "auto-rebase:ready" <<'JSON' +[{"name":"enhancement"}] +JSON + [ "$status" -ne 0 ] +} + +@test "has_ready_label: empty labels array" { + run auto_rebase_has_ready_label "auto-rebase:ready" <<'JSON' +[] +JSON + [ "$status" -ne 0 ] +} + +# ── auto_rebase_pr_eligible ────────────────────────────────────────────────── + +@test "pr_eligible review-ready: non-draft + approved is eligible" { + run auto_rebase_pr_eligible review-ready false true false + [ "$status" -eq 0 ] +} + +@test "pr_eligible review-ready: non-draft + ready label is eligible" { + run auto_rebase_pr_eligible review-ready false false true + [ "$status" -eq 0 ] +} + +@test "pr_eligible review-ready: non-draft but neither approved nor labelled is ineligible" { + run auto_rebase_pr_eligible review-ready false false false + [ "$status" -eq 1 ] +} + +@test "pr_eligible review-ready: draft is ineligible even when approved" { + run auto_rebase_pr_eligible review-ready true true true + [ "$status" -eq 1 ] +} + +@test "pr_eligible all: always eligible (legacy fan-out escape hatch)" { + run auto_rebase_pr_eligible all false false false + [ "$status" -eq 0 ] +} + +@test "pr_eligible all: eligible even for drafts (preserves prior behavior)" { + run auto_rebase_pr_eligible all true false false + [ "$status" -eq 0 ] +} + +@test "pr_eligible: unknown mode errors out (exit 2)" { + run auto_rebase_pr_eligible bogus-mode false true false + [ "$status" -eq 2 ] +} diff --git a/test/workflows/auto-rebase/helpers/setup.bash b/test/workflows/auto-rebase/helpers/setup.bash new file mode 100644 index 000000000..fc05da5da --- /dev/null +++ b/test/workflows/auto-rebase/helpers/setup.bash @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Common test helpers for the auto-rebase bats suite. + +# Repo root, regardless of where bats is invoked from. +TT_REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd)" +export TT_REPO_ROOT + +TT_SCRIPTS_DIR="${TT_REPO_ROOT}/.github/scripts/auto-rebase" +export TT_SCRIPTS_DIR + +# Per-test scratch dir, auto-cleaned by bats. +tt_make_tmpdir() { + TT_TMP="$(mktemp -d)" + export TT_TMP +} + +tt_cleanup_tmpdir() { + if [ -n "${TT_TMP:-}" ] && [ -d "${TT_TMP}" ]; then + rm -rf "${TT_TMP}" + fi +}