Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions .github/scripts/auto-rebase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,17 @@ workflow version. Set `tooling_ref` only to test a branch end-to-end.

## `lib/eligibility.sh`

Pure, side-effect-free predicates. Source the file, then call:
Pure, side-effect-free predicate. 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.
| `auto_rebase_pr_eligible MODE` | mode string | `0` eligible, `2` unknown mode |

### 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 |
| `all` (default) | every behind PR, including drafts |

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.
Expand Down
64 changes: 17 additions & 47 deletions .github/scripts/auto-rebase/lib/eligibility.sh
Original file line number Diff line number Diff line change
@@ -1,63 +1,33 @@
#!/usr/bin/env bash
# Eligibility predicates for the auto-rebase reusable workflow.
# Eligibility predicate 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.
# Pure and side-effect-free so it 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.
#
# 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).
# auto_rebase_pr_eligible MODE
# Decides whether a behind PR should be updated. Returns 0 (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.
# all every behind PR, including drafts.
#
# Kept as a mode dispatch so future modes (e.g. a "front-of-queue N") can be
# added here and selected via the `eligibility` input without changing the
# workflow file.
auto_rebase_pr_eligible() {
local mode="$1" is_draft="$2" is_approved="$3" has_label="$4"
local mode="$1"
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
# Deprecated alias: review-ready was removed in issue #927; treat as `all`
# so existing callers continue to work without breaking.
echo "auto_rebase_pr_eligible: 'review-ready' is deprecated and will be removed; use 'all' instead" >&2
return 0
;;
*)
echo "auto_rebase_pr_eligible: unknown eligibility mode '$mode'" >&2
Expand Down
83 changes: 18 additions & 65 deletions .github/workflows/auto-rebase-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@
# 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. Keeps only the PRs eligible under the `eligibility` input (default
# `review-ready`: non-draft AND (current APPROVED review OR a ready label))
# `all`: every behind PR, including drafts)
# 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.
# The eligibility 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.
Expand Down Expand Up @@ -48,19 +46,20 @@ on:
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.
all (default) — every behind PR, including drafts.
New modes can be added to lib/eligibility.sh and selected here without
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
changing this workflow.
required: false
default: 'review-ready'
default: 'all'
type: string
ready_label:
description: 'Label that opts a non-draft PR into auto-rebase even without an approval.'
description: |
Deprecated — this input is accepted but silently ignored so callers
that were passing it (when eligibility: review-ready was supported)
continue to dispatch without a GitHub Actions validation error.
Remove it from your caller when convenient.
required: false
default: 'auto-rebase:ready'
default: ''
type: string
tooling_ref:
description: |
Expand Down Expand Up @@ -111,7 +110,6 @@ jobs:
HAS_PAT: ${{ (secrets.GH_PAT_DON_PETRY || 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
Expand All @@ -128,63 +126,18 @@ jobs:
fi

while IFS=' ' read -r PR_NUMBER HEAD_REF; do
# 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
BASE_BRANCH=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.base.ref')

# Eligibility gate: which behind PRs to update, selectable via the
# `eligibility` input (default `all` — every behind PR).
ELIG=0
auto_rebase_pr_eligible "$ELIGIBILITY" "$IS_DRAFT" "$IS_APPROVED" "$HAS_LABEL" || ELIG=$?
auto_rebase_pr_eligible "$ELIGIBILITY" || 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"

# Escalate silently-rotting PRs (issue #711): a skipped PR that is
# already CONFLICTING will never be updated by auto-rebase and cannot
# be approved while red (pr-review skips red PRs) — a deadlock that
# rots the PR for weeks with no signal. Surface it exactly once so a
# human can act. mergeable_state comes free from PR_JSON (no extra
# API call); it may be "unknown" until GitHub computes it, in which
# case a later run catches it. Idempotent via the stuck sentinel.
MERGE_STATE=$(jq -r '.mergeable_state // "unknown"' <<< "$PR_JSON")
if [[ "$MERGE_STATE" == "dirty" ]]; then
STUCK_SENTINEL="<!-- auto-rebase-stuck -->"
STUCK_POSTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
--json comments --jq "[.comments[] | select(.body | contains(\"$STUCK_SENTINEL\"))] | length" 2>/dev/null || echo "-1")
if [[ "$STUCK_POSTED" -eq 0 ]]; then
echo " Conflicting + not review-ready — posting one-time manual-attention notice"
STUCK_BODY="$STUCK_SENTINEL"
STUCK_BODY+=$'\n'"**Auto-rebase is skipping this PR.** It has merge conflicts with \`$BASE_BRANCH\`"
STUCK_BODY+=" but is not review-ready (draft=$IS_DRAFT, approved=$IS_APPROVED,"
STUCK_BODY+=" \`$READY_LABEL\`=$HAS_LABEL), so auto-rebase will not update it — and it cannot be"
STUCK_BODY+=" approved while red. Left alone it will not progress."
STUCK_BODY+=$'\n\n'"To unblock, do one of:"
STUCK_BODY+=$'\n'"- add the \`$READY_LABEL\` label (opts it into auto-rebase without an approval), or"
STUCK_BODY+=$'\n'"- get it approved, or"
STUCK_BODY+=$'\n'"- resolve manually:"
STUCK_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin"$'\n'"git merge origin/$BASE_BRANCH"$'\n'"# resolve conflicts, then:"$'\n'"git add . && git commit && git push"$'\n'"\`\`\`"
# Best-effort: a comment-side failure (e.g. the 2500-comment cap)
# must not abort the step or starve other PRs — logged and swallowed.
auto_rebase_post_comment_best_effort "$PR_NUMBER" "$REPO" "$STUCK_BODY"
fi
fi
echo "PR #$PR_NUMBER ($HEAD_REF) not eligible under '$ELIGIBILITY' — skipping"
continue
fi

Expand Down
2 changes: 1 addition & 1 deletion docs/initiatives/pull-request-limits-adr.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ on GitHub's side. Stories 2 and 3 must be planned on that basis.
| **dev-lead** | Yes | `dev-lead/*` branches | Largest single source in the baseline (§5). One PR per assigned issue. |
| **Claude / agentic PRs** | Yes | `claude/*` branches | Code-agent PRs; a distinct source the planner did not separately name but the baseline surfaced. |
| **initiative-driver / initiative-planner** | Yes (intermittent) | initiative branches | None open at measurement time, but a known burst source when an epic is fanned out. |
| **auto-rebase fan-out** | **No** | n/a | Important correction: [auto-rebase-reusable.yml](../../.github/workflows/auto-rebase-reusable.yml) **updates the branches of existing PRs** — it does not open new ones. It is a **CI-load amplifier**, not a PR source. A count limit on open PRs will not throttle it directly; the relevant lever for it is its `eligibility` input (already `review-ready`). |
| **auto-rebase fan-out** | **No** | n/a | Important correction: [auto-rebase-reusable.yml](../../.github/workflows/auto-rebase-reusable.yml) **updates the branches of existing PRs** — it does not open new ones. It is a **CI-load amplifier**, not a PR source. A count limit on open PRs will not throttle it directly; the relevant lever for it is its `eligibility` input (default: `all` — every behind PR). |

---

Expand Down
20 changes: 8 additions & 12 deletions standards/ci-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -1032,21 +1032,17 @@ On each run the workflow:
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:** Which behind PRs to update is selectable via 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.
| `all` (default) | always — every behind PR, including drafts |

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).
Expand Down
10 changes: 4 additions & 6 deletions standards/workflows/auto-rebase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,12 @@
# 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.:
# By default the reusable updates *every* behind PR (`eligibility: all`). The
# `eligibility` input is a tunable extension point for future modes; to select
# one, pass it 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
# eligibility: all # default — update every behind PR
#
name: Auto-rebase non-Dependabot PRs

Expand Down
Loading
Loading