Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
66de51c
feat: implement issue #1881 — auto-rebase still merges main into ever…
don-petry Sep 22, 2026
f904beb
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 22, 2026
31b9b02
fix(reviews): address review comments [skip ci-relay]
don-petry Sep 22, 2026
a5848ab
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 22, 2026
41fa32f
fix(bot): address bot feedback [skip ci-relay]
don-petry Sep 22, 2026
1c27f20
fix(reviews): address review comments [skip ci-relay]
don-petry Sep 22, 2026
c1660b3
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 23, 2026
3be30a5
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 23, 2026
c8b8135
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 23, 2026
08e9acc
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 23, 2026
056058c
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 24, 2026
273a62e
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 24, 2026
3454115
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 24, 2026
7436806
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 24, 2026
e608c40
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 25, 2026
2a9fa44
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 25, 2026
5f1e49d
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 25, 2026
9ea5e73
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 26, 2026
e968118
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 26, 2026
863812b
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 27, 2026
b52039d
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 27, 2026
125e2c0
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 29, 2026
6f83c32
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 29, 2026
84b4e08
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 30, 2026
234dc64
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Sep 30, 2026
57bccf7
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 1, 2026
48875d3
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 1, 2026
e5f83e6
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 1, 2026
cce6287
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 1, 2026
a700e18
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 1, 2026
939a821
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 1, 2026
90c53e9
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 1, 2026
4beda91
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 1, 2026
e882c29
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 2, 2026
ae805e0
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 2, 2026
30f6b21
Merge branch 'main' into dev-lead/issue-1881-20260922-1659
don-petry Oct 2, 2026
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
196 changes: 188 additions & 8 deletions scripts/auto_rebase_health.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@
# audit. Repo-scoped — fleet-wide would need a cross-repo PAT this report
# does not carry.
#
# 4. Base-merge necessity (#1881) — a deterministic counter that splits the open
# BEHIND queue into base merges that are genuinely REQUIRED (being behind
# blocks the PR: the ruleset requires up-to-date branches, or the PR is queued
# to merge, or it carries the explicit request label) vs SKIPPABLE no-ops. The
# required/skippable split is derived from repository configuration (the
# ruleset's `strict_required_status_checks_policy`), so if strict is re-armed
# the counter follows with no code change. `skippable` is the no-op base-merge
# volume #1881 removes at the source — the second input to #1864 AC #4 alongside
# #1865's `reviews_carried_forward`. The gate that acts on this lives in the
# central auto-rebase reusable; this report only measures it.
#
# Layout (mirrors scripts/token_report.sh):
# * The count_*/summarize_*/fmt_*/render_report functions are PURE — they take
# JSON / scalars and write to stdout. Unit-tested in tests/auto_rebase_health.bats.
Expand Down Expand Up @@ -56,6 +67,16 @@ SENTINEL_MARKER='<!-- auto-rebase-conflict:'
REBASE_RESPONSE_MARKER='intent=rebase status='
REBASE_APPLIED_MARKER='intent=rebase status=applied'

# Label a human/PR can add to explicitly demand an up-to-date branch even when the
# ruleset does not require it — the "or a human/label requests it" arm of #1881 AC #1.
# Same label the central reusable's review-ready eligibility predicate already honours.
AUTO_REBASE_REQUEST_LABEL="${AUTO_REBASE_REQUEST_LABEL:-auto-rebase:ready}"
# Branch whose ruleset drives the up-to-date policy. Left empty by default so
# main() resolves the repo's ACTUAL default branch — an AGENT_REPO whose default
# branch is not `main` would otherwise have its rules read from the wrong branch.
# Set this env to pin a specific branch and skip the lookup.
DEFAULT_BRANCH="${DEFAULT_BRANCH:-}"

# ---------------------------------------------------------------------------
# Pure helpers (unit-tested; no network)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -112,6 +133,62 @@ summarize_merge_states() {
] | @tsv' <<< "$json"
}

# strict_from_branch_rules <rules_json>
# Reads whether the default branch's ruleset requires PRs to be up to date with
# base — the `strict_required_status_checks_policy` flag (#1864). Input is the JSON
# array from `gh api repos/{repo}/rules/branches/<branch>`. Echoes "true" iff some
# active required_status_checks rule sets that policy true, else "false". This is
# the config-derived condition of #1881 AC #2: if strict is re-armed later the flag
# flips with no code change. Absent/empty/unparseable input → "false".
strict_from_branch_rules() {
local json="${1:-}"
[ -n "$json" ] || json='[]'
local r
r="$(printf '%s' "$json" | jq -r '
[ .[]?
| select(.type == "required_status_checks")
| (.parameters.strict_required_status_checks_policy // false)
] | any' 2>/dev/null || echo false)"
echo "$r"
}

# summarize_base_merges <prs_json> <strict_policy> [request_label]
# Emits TSV: required<TAB>skippable — over the open non-draft, non-Dependabot,
# mergeStateStatus==BEHIND PRs, the count whose base merge is genuinely REQUIRED vs
# a no-op that should be SKIPPED (#1881 AC #4). A BEHIND PR's base merge is required
# when being behind actually blocks it — ANY of:
# * strict is on for THAT PR's base branch (ruleset requires up-to-date branches), OR
# * the PR is enqueued in the merge queue (mergeQueueEntry non-null — actual
# merge-queue membership, not auto-merge arming, which does not force an
# up-to-date branch), OR
# * the PR carries the explicit request label (default AUTO_REBASE_REQUEST_LABEL).
# Otherwise the base merge is skippable churn. DIRTY PRs are deliberately NOT counted:
# they take the conflict/rebase path unchanged (AC #3). Absent/empty JSON → "0\t0".
#
# <strict_policy> is either a scalar "true"/"false" applied to every base branch, OR
# a JSON object mapping baseRefName → boolean so each PR is classified against its OWN
# base's ruleset (#1887): a release branch with a different strict policy no longer
# borrows the default branch's flag. A base absent from the map defaults to false.
summarize_base_merges() {
local json="${1:-}" strict="${2:-false}" label="${3:-$AUTO_REBASE_REQUEST_LABEL}"
[ -n "$json" ] || json='[]'
jq -r --arg strict "$strict" --arg label "$label" '
($strict | (fromjson? // false)) as $sp |
[ .[]
| select((.isDraft // false) | not)
| select(((.author?.login // "" | tostring) | test("dependabot"; "i")) | not)
| select(.mergeStateStatus == "BEHIND")
] | map(
(if ($sp | type) == "object" then ($sp[(.baseRefName // "")] // false) else $sp end)
or (.mergeQueueEntry != null)
or ([ (.labels // [])[] | (.name // "") ] | index($label) != null)
) as $flags |
[
([ $flags[] | select(.) ] | length),
([ $flags[] | select(. | not) ] | length)
] | @tsv' <<< "$json" 2>/dev/null || printf '0\t0'
}

# summarize_runs <runs_json>
# Emits TSV: total<TAB>success<TAB>failed over the auto-rebase run telemetry.
summarize_runs() {
Expand Down Expand Up @@ -144,13 +221,20 @@ fmt_rate() {
echo "$(( num * 100 / denom ))%"
}

# render_report <comments_json> <runs_json> <lookback_days> <behind_prs> [today] [prs_json] [pr_list_truncated] [pr_list_limit]
# render_report <comments_json> <runs_json> <lookback_days> <behind_prs> [today] [prs_json] [pr_list_truncated] [pr_list_limit] [strict_enabled] [request_label] [strict_policy]
# Writes the full Markdown report to stdout. Pure: no network.
# pr_list_truncated=true renders a warning that BEHIND/DIRTY counts may undercount.
# strict_enabled ("true"/"false") is the DEFAULT branch's config-derived
# up-to-date-branches policy — shown as the report headline.
# strict_policy is what actually drives the base-merge-necessity counter (#1881):
# either the same scalar, or a JSON object mapping baseRefName → boolean so each PR
# is classified against its own base's ruleset (#1887). Defaults to strict_enabled.
render_report() {
local comments_json="${1:-[]}" runs_json="${2:-[]}"
local lookback="${3:-7}" behind="${4:-0}" today="${5:-}" prs_json="${6:-[]}"
local pr_list_truncated="${7:-false}" pr_list_limit="${8:-1000}"
local strict_enabled="${9:-false}" request_label="${10:-$AUTO_REBASE_REQUEST_LABEL}"
local strict_policy="${11:-$strict_enabled}"
[ -n "$today" ] || today="$(date -u +%Y-%m-%d)"

local sentinels responses applied
Expand All @@ -162,6 +246,9 @@ render_report() {
local ms_behind ms_dirty
IFS=$'\t' read -r ms_behind ms_dirty < <(summarize_merge_states "$prs_json")

local bm_required bm_skippable
IFS=$'\t' read -r bm_required bm_skippable < <(summarize_base_merges "$prs_json" "$strict_policy" "$request_label")

local fanout
fanout="$(estimate_fanout "$total" "$behind")"

Expand Down Expand Up @@ -199,7 +286,19 @@ render_report() {
fi
printf '> Snapshot of the current open-PR queue, scoped to **`%s`** — fleet-wide counts would ' "$WORKFLOW_REPO"
printf 'require a cross-repo PAT this report does not carry. Tracks the effectiveness of the '
printf 'AC1/AC2 auto-rebase fixes (petry-projects/.github#926) without a manual audit.\n'
printf 'AC1/AC2 auto-rebase fixes (petry-projects/.github#926) without a manual audit.\n\n'

printf '## Base-merge necessity (#1881)\n\n'
printf -- '- **Up-to-date-branches policy** (ruleset `strict_required_status_checks_policy`): `%s`\n' "$strict_enabled"
printf -- '- **Base merges required** (behind actually blocks: strict on, enqueued in merge queue, or `%s` label): %s\n' \
"$request_label" "$bm_required"
printf -- '- **Base merges skippable** (BEHIND with no such requirement — a no-op churn): %s\n' "$bm_skippable"
printf -- '- `base_merges_required=%s base_merges_skippable=%s`\n\n' "$bm_required" "$bm_skippable"
printf '> Deterministic counter over open non-draft non-Dependabot **BEHIND** PRs (DIRTY PRs keep the '
printf 'conflict/rebase path, so they are excluded). `skippable` is the no-op base-merge volume #1881 '
printf 'removes at the source; it is the second input to #1864 AC #4 alongside #1865 `reviews_carried_forward`. '
printf 'The policy flag is read from repository configuration — if `strict` is re-armed, every BEHIND PR '
printf 'becomes `required` with no code change.\n'
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -247,16 +346,38 @@ main() {
--paginate --jq '.workflow_runs | map({conclusion, created_at})' 2>/dev/null \
| jq -s 'add // []' 2>/dev/null || echo '[]')"

# 3. Open-PR snapshot — pulled once with the fields both the behind-PR
# multiplier and the AC7 merge-state (BEHIND/DIRTY) metric need. Best-effort;
# defaults to [] so the report still renders when the query fails.
# 3. Open-PR snapshot — pulled once with the fields the behind-PR multiplier, the
# AC7 merge-state (BEHIND/DIRTY) metric, and the #1881 base-merge-necessity
# counter (labels + mergeQueueEntry + baseRefName) all need. Fetched over GraphQL
# rather than `gh pr list --json` because `mergeQueueEntry` — actual merge-queue
# membership, the queue-specific signal #1881 wants (not `autoMergeRequest`, which
# only means auto-merge is armed) — is not a documented `gh pr list` field. The
# node set is reshaped to the same object shape the pure helpers consume. Best
# effort; defaults to [] so the report still renders when the query fails.
local owner="${WORKFLOW_REPO%%/*}" name="${WORKFLOW_REPO##*/}"
local prs_json pr_list_truncated pr_count
prs_json="$(gh pr list --repo "$WORKFLOW_REPO" --state open --limit "$PR_LIST_LIMIT" \
--json author,isDraft,mergeStateStatus 2>/dev/null || echo '[]')"
# shellcheck disable=SC2016 # $owner/$name/$endCursor are GraphQL vars, not shell vars
prs_json="$(gh api graphql --paginate \
-f owner="$owner" -f name="$name" \
-f query='query($owner:String!, $name:String!, $endCursor:String) {
repository(owner:$owner, name:$name) {
pullRequests(states: OPEN, first: 100, after: $endCursor) {
nodes {
number isDraft mergeStateStatus baseRefName
author { login }
labels(first: 100) { nodes { name } }
mergeQueueEntry { id }
}
pageInfo { hasNextPage endCursor }
}
}
}' --jq '.data.repository.pullRequests.nodes[]' 2>/dev/null \
| jq -s 'map({number, isDraft, mergeStateStatus, baseRefName, author, labels: (.labels.nodes // []), mergeQueueEntry})' 2>/dev/null || echo '[]')"
[ -n "$prs_json" ] || prs_json='[]'
pr_count="$(printf '%s' "$prs_json" | jq 'length' 2>/dev/null || echo 0)"
if [ "$pr_count" -ge "$PR_LIST_LIMIT" ]; then
pr_list_truncated=true
prs_json="$(jq -c --argjson lim "$PR_LIST_LIMIT" '.[:$lim]' <<< "$prs_json" 2>/dev/null || printf '%s' "$prs_json")"
else
pr_list_truncated=false
fi
Expand All @@ -268,8 +389,67 @@ main() {
| jq '[.[] | select((.author?.login // "") | test("dependabot"; "i") | not)] | length' \
2>/dev/null || echo 0)"

# 4. Up-to-date-branches policy — read from the default branch's ruleset so the
# base-merge-necessity counter (#1881) is derived from config, not hardcoded.
# Best-effort: if the ruleset cannot be read the report degrades to strict=false
# with a warning (so a silent read failure cannot masquerade as "no requirement"),
# matching the token-degradation posture above.
#
# Resolve the branch first: when DEFAULT_BRANCH is not pinned via env, query the
# repo's ACTUAL default branch so an AGENT_REPO whose default is not `main` reads
# its rules from the right branch (#1887). Fall back to `main` if the lookup fails.
local default_branch="${DEFAULT_BRANCH:-}"
if [ -z "$default_branch" ]; then
default_branch="$(gh api "repos/${WORKFLOW_REPO}" --jq '.default_branch' 2>/dev/null || echo main)"
[ -n "$default_branch" ] || default_branch=main
fi

# Capture the command-substitution exit status into a variable first (rather than
# running it directly inside `if`) so a failure is not swallowed under `set -e`.
local rules_json strict_enabled api_status=0
rules_json="$(gh api "repos/${WORKFLOW_REPO}/rules/branches/${default_branch}" 2>/dev/null)" || api_status=$?
if [ "$api_status" -eq 0 ]; then
[ -n "$rules_json" ] || rules_json='[]'
else
rules_json='[]'
echo "::warning::Could not read branch rules for ${WORKFLOW_REPO}@${default_branch} — base-merge necessity assumes strict=false; skippable counts may be overstated." >&2
fi
strict_enabled="$(strict_from_branch_rules "$rules_json")"

# Resolve the up-to-date policy PER base branch, not just the default (#1887). The
# open-PR snapshot has no default-branch filter and the auto-rebase workflow updates
# every behind PR, so a PR based on a release branch with a different strict policy
# would otherwise be classified against `main`'s ruleset. Build a baseRefName→bool
# map covering the distinct bases of the eligible (non-draft, non-Dependabot, BEHIND)
# PRs and hand it to the counter. Seed it with the already-resolved default branch so
# that base is not re-fetched.
local strict_policy
strict_policy="$(jq -cn --arg k "$default_branch" --argjson v "$strict_enabled" '{($k): $v}')"
local base_branches base
base_branches="$(printf '%s' "$prs_json" | jq -r '
[ .[]
| select((.isDraft // false) | not)
| select(((.author?.login // "" | tostring) | test("dependabot"; "i")) | not)
| select(.mergeStateStatus == "BEHIND")
| .baseRefName // empty
] | unique[]' 2>/dev/null || true)"
while IFS= read -r base; do
[ -n "$base" ] || continue
[ "$base" = "$default_branch" ] && continue
local b_rules b_status=0 b_strict
b_rules="$(gh api "repos/${WORKFLOW_REPO}/rules/branches/${base}" 2>/dev/null)" || b_status=$?
if [ "$b_status" -eq 0 ]; then
[ -n "$b_rules" ] || b_rules='[]'
else
b_rules='[]'
echo "::warning::Could not read branch rules for ${WORKFLOW_REPO}@${base} — base-merge necessity assumes strict=false for PRs targeting it; skippable counts may be overstated." >&2
fi
b_strict="$(strict_from_branch_rules "$b_rules")"
strict_policy="$(jq -c --arg k "$base" --argjson v "$b_strict" '. + {($k): $v}' <<< "$strict_policy")"
done <<< "$base_branches"

local report
report="$(render_report "$comments_json" "$runs_json" "$LOOKBACK_DAYS" "$behind" "$today" "$prs_json" "$pr_list_truncated" "$PR_LIST_LIMIT")"
report="$(render_report "$comments_json" "$runs_json" "$LOOKBACK_DAYS" "$behind" "$today" "$prs_json" "$pr_list_truncated" "$PR_LIST_LIMIT" "$strict_enabled" "$AUTO_REBASE_REQUEST_LABEL" "$strict_policy")"

printf '%s\n' "$report"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
Expand Down
Loading
Loading