Skip to content
Closed
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
125 changes: 117 additions & 8 deletions scripts/auto_rebase_health.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@
# logged in this repo (the update-branch calls live in the central reusable),
# so the re-run volume is an ESTIMATE: runs × current open non-Dependabot PRs.
#
# 3. Post-restriction fan-out — since petry-projects/.github#468 (issue #465) the
# reusable only update-branches *review-ready* PRs (non-draft AND (current
# APPROVED review OR the `auto-rebase:ready` label)). To show the reduction the
# report mirrors the same predicate against the current open PRs and reports the
# eligible-PR multiplier, the restricted re-run estimate, and the reduction vs.
# the unrestricted (all-behind-PRs) multiplier. This is the cleaner before/after
# signal feeding the Merge Queue go/no-go record (#739). Like (2) it is a
# snapshot ESTIMATE — eligibility is read now, not per historical run.
#
# 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 All @@ -31,13 +40,17 @@
# GH_PAT_FALLBACK — optional fallback PAT if GH_TOKEN lacks run-telemetry access
# AGENT_REPO — repo to scan (default: petry-projects/.github-private)
# LOOKBACK_DAYS — days of history to consider (default: 7)
# READY_LABEL — label that opts a non-draft PR into auto-rebase without an
# approval; must match the reusable's `ready_label` input
# (default: auto-rebase:ready)
# AUTO_REBASE_HEALTH_OUT — optional path; report is written there in addition to stdout
# GITHUB_STEP_SUMMARY — written by the Actions runner when present

set -euo pipefail

WORKFLOW_REPO="${AGENT_REPO:-petry-projects/.github-private}"
LOOKBACK_DAYS="${LOOKBACK_DAYS:-7}"
READY_LABEL="${READY_LABEL:-auto-rebase:ready}"
AUTO_REBASE_WORKFLOW="auto-rebase.yml"

# Markers (kept in one place so a rename in the dev-lead scripts is a one-line fix).
Expand Down Expand Up @@ -110,11 +123,58 @@ fmt_rate() {
echo "$(( num * 100 / denom ))%"
}

# render_report <comments_json> <runs_json> <lookback_days> <behind_prs> [today]
# fmt_reduction <from> <to>
# Integer percentage DECREASE going from <from> to <to> (e.g. 7→3 = "57%").
# Zero/negative <from> guard renders "n/a" (no PRs to reduce). Mirrors the
# behind→eligible multiplier reduction that the eligibility gate buys.
fmt_reduction() {
local from="${1:-0}" to="${2:-0}"
if [ "$from" -le 0 ]; then
echo "n/a"
return 0
fi
echo "$(( (from - to) * 100 / from ))%"
}

# pr_has_current_approval <reviews_json>
# Returns 0 if a PR currently has at least one APPROVED review, else 1. Mirrors
# petry-projects/.github .github/scripts/auto-rebase/lib/eligibility.sh exactly:
# the reviewer's most recent decision review wins (a later CHANGES_REQUESTED or
# DISMISSED cancels an earlier APPROVED; COMMENTED/PENDING do not change a stance).
# We read real review states rather than reviewDecision, which is null on repos
# without required reviews (issue #465 implementer note).
pr_has_current_approval() {
local json="${1:-}" result
[ -n "$json" ] || json='[]'
result=$(printf '%s' "$json" | jq -r '
reduce (.[] | select((.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED") and .user?.login != null)) as $r ({}; .[$r.user.login] = $r.state)
| any(. == "APPROVED")')
[ "$result" = "true" ]
}
Comment on lines +146 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a review is submitted by a deleted user, the .user object in the GitHub API response can be null. Attempting to access .user.login directly will cause jq to throw a Cannot index null with string "login" error. We should safely navigate .user and ensure .user?.login is not null before indexing.

Suggested change
pr_has_current_approval() {
local json="${1:-}" result
[ -n "$json" ] || json='[]'
result=$(printf '%s' "$json" | jq -r '
reduce (.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED")) as $r ({}; .[$r.user.login] = $r.state)
| any(. == "APPROVED")')
[ "$result" = "true" ]
}
pr_has_current_approval() {
local json="${1:-}" result
[ -n "$json" ] || json='[]'
result=$(printf '%s' "$json" | jq -r '
reduce (.[] | select((.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED") and .user?.login != null)) as $r ({}; .[$r.user.login] = $r.state)
| any(. == "APPROVED")')
[ "$result" = "true" ]
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in scripts/auto_rebase_health.sh pr_has_current_approval: added .user?.login != null to the select predicate so reviews from deleted users (where .user is null) are filtered out before the reduce accesses .user.login, preventing the Cannot index null with string "login" jq error. Added two new bats tests covering the null-user case in tests/auto_rebase_health.bats.


# count_eligible <prs_meta_json> <ready_label>
# Counts review-ready PRs in a metadata array. Each element is
# {"draft":bool,"approved":bool,"labels":[{"name":...}]}; a PR is eligible when it
# is non-draft AND (approved OR carries <ready_label>). Same predicate as the
# reusable's `review-ready` mode. Absent/empty JSON → 0.
count_eligible() {
local json="${1:-}" label="${2:-}"
[ -n "$json" ] || json='[]'
printf '%s' "$json" | jq --arg L "$label" '
[ .[]
| select(((.draft // false) | not)
and ((.approved // false) or any(.labels[]?; .name == $L))) ]
| length'
}
Comment on lines +160 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

We can make the jq filter more idiomatic and efficient by using any/2 directly on .labels[]? instead of constructing an intermediate array [.labels[]?.name] and piping it to any.

Suggested change
count_eligible() {
local json="${1:-}" label="${2:-}"
[ -n "$json" ] || json='[]'
printf '%s' "$json" | jq --arg L "$label" '
[ .[]
| select(((.draft // false) | not)
and ((.approved // false) or ([.labels[]?.name] | any(. == $L)))) ]
| length'
}
count_eligible() {
local json="${1:-}" label="${2:-}"
[ -n "$json" ] || json='[]'
printf '%s' "$json" | jq --arg L "$label" '
[ .[]
| select(((.draft // false) | not)
and ((.approved // false) or any(.labels[]?; .name == $L))) ]
| length'
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in scripts/auto_rebase_health.sh count_eligible: replaced [.labels[]?.name] | any(. == $L) with the idiomatic any(.labels[]?; .name == $L), which skips the intermediate array allocation. All existing count_eligible bats tests continue to pass.


# render_report <comments_json> <runs_json> <lookback_days> <behind_prs> [today] [eligible_prs] [ready_label]
# Writes the full Markdown report to stdout. Pure: no network.
# When <eligible_prs> is supplied (non-empty), the post-restriction section is
# rendered; omit it to render the legacy two-section report.
render_report() {
local comments_json="${1:-[]}" runs_json="${2:-[]}"
local lookback="${3:-7}" behind="${4:-0}" today="${5:-}"
local eligible="${6:-}" ready_label="${7:-auto-rebase:ready}"
[ -n "$today" ] || today="$(date -u +%Y-%m-%d)"

local sentinels responses applied
Expand Down Expand Up @@ -149,6 +209,30 @@ render_report() {
"$runs_per_day" "$rerun_per_day"
printf '> Fan-out is an **estimate** — per-run behind-PR counts are not logged in this repo, '
printf 'so re-runs = runs × current open non-Dependabot PR count.\n'

# Post-restriction section — only when an eligible-PR count is supplied.
[ -n "$eligible" ] || return 0

local restricted reduction restricted_per_day verdict
restricted="$(estimate_fanout "$total" "$eligible")"
reduction="$(fmt_reduction "$behind" "$eligible")"
restricted_per_day="$(awk -v t="$restricted" -v d="$lookback" 'BEGIN { printf "%.1f", (d > 0 ? t / d : 0) }')"
# ≥50% multiplier reduction is the epic #736 success metric.
if [ "$behind" -gt 0 ] && [ "$(( (behind - eligible) * 100 / behind ))" -ge 50 ]; then
verdict="✅ met"
else
verdict="❌ not met"
fi

printf '\n## Post-restriction fan-out (review-ready eligibility)\n\n'
printf -- '- **Eligible PRs** (non-draft AND (current `APPROVED` review OR `%s` label)): %s of %s open non-Dependabot PRs\n' \
"$ready_label" "$eligible" "$behind"
printf -- '- **Estimated branch-update CI re-runs (restricted)**: ~%s (~%s/day)\n' \
"$restricted" "$restricted_per_day"
printf -- '- **Fan-out reduction** (behind→eligible multiplier): %s\n' "$reduction"
printf -- '- **≥50%% reduction success metric** (epic #736): %s\n\n' "$verdict"
printf '> Reduction is a point-in-time snapshot: eligibility is read now, not per '
printf 'historical run, so the multiplier (not the absolute run count) is the like-for-like signal.\n'
}

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

# 3. Behind-PR multiplier — open non-Dependabot PRs (proxy for branches the
# fan-out updates). Best-effort; defaults to 0 so the report still renders.
local behind
behind="$(gh pr list --repo "$WORKFLOW_REPO" --state open --limit 200 --json author \
--jq '[.[] | select((.author?.login // "") | test("dependabot"; "i") | not)] | length' \
2>/dev/null || echo 0)"
# 3. Behind-PR multiplier — fetch isDraft and labels in the same list call so
# we need only one reviews call per PR (not two). Best-effort; empty list →
# behind=0 so it still renders.
local open_prs behind
open_prs="$(gh pr list --repo "$WORKFLOW_REPO" --state open --limit 200 \
--json number,author,isDraft,labels \
--jq '[.[] | select((.author?.login // "") | test("dependabot"; "i") | not)]' \
2>/dev/null || echo '[]')"
behind="$(printf '%s' "$open_prs" | jq 'length')"

# 4. Eligible-PR multiplier — the review-ready subset the gate now updates. For
# each open non-Dependabot PR, read the current approval state (reviews call),
# then apply the same predicate as the reusable. No CI re-runs and no LLM cost.
# Best-effort: any failure leaves eligible empty so the post-restriction
# section is simply omitted rather than reported wrongly.
local eligible="" prs_meta
if [ "$behind" -gt 0 ]; then
local recs="[]" n draft labels reviews_json approved rec
while IFS=$'\t' read -r n draft labels; do
[ -n "$n" ] || continue
reviews_json="$(gh api --paginate "repos/${WORKFLOW_REPO}/pulls/${n}/reviews" 2>/dev/null \
| jq -s 'add // []' 2>/dev/null || echo '[]')"
if pr_has_current_approval "$reviews_json"; then approved=true; else approved=false; fi
rec="$(jq -nc --argjson d "$draft" --argjson a "$approved" --argjson l "$labels" \
'{draft:$d, approved:$a, labels:$l}')"
recs="$(printf '%s' "$recs" | jq -c --argjson r "$rec" '. + [$r]')"
done < <(printf '%s' "$open_prs" | jq -r '.[] | "\(.number)\t\(.isDraft)\t\(.labels | tostring)"')
prs_meta="$recs"
eligible="$(count_eligible "$prs_meta" "$READY_LABEL" 2>/dev/null || echo "")"
fi

local report
report="$(render_report "$comments_json" "$runs_json" "$LOOKBACK_DAYS" "$behind" "$today")"
report="$(render_report "$comments_json" "$runs_json" "$LOOKBACK_DAYS" "$behind" "$today" \
"$eligible" "$READY_LABEL")"

printf '%s\n' "$report"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
Expand Down
118 changes: 118 additions & 0 deletions tests/auto_rebase_health.bats
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ setup() {
{"conclusion":"success","created_at":"2026-06-11T00:00:00Z"},
{"conclusion":"success","created_at":"2026-06-11T06:00:00Z"}
]'

# PR eligibility metadata: 4 open non-Dependabot PRs, 2 eligible.
# approved non-draft → eligible
# ready-labelled non-draft → eligible
# plain non-draft → not eligible
# approved but draft → not eligible (draft)
PRS_META_JSON='[
{"draft":false,"approved":true,"labels":[]},
{"draft":false,"approved":false,"labels":[{"name":"auto-rebase:ready"}]},
{"draft":false,"approved":false,"labels":[{"name":"bug"}]},
{"draft":true,"approved":true,"labels":[]}
]'
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -138,3 +150,109 @@ setup() {
[ "$status" -eq 0 ]
[[ "$output" == *"n/a"* ]]
}

# ---------------------------------------------------------------------------
# fmt_reduction — behind→eligible multiplier reduction
# ---------------------------------------------------------------------------

@test "fmt_reduction: renders an integer percentage decrease" {
run fmt_reduction 8 2
[ "$output" = "75%" ]
}

@test "fmt_reduction: no reduction renders 0%" {
run fmt_reduction 5 5
[ "$output" = "0%" ]
}

@test "fmt_reduction: zero base renders n/a (no divide-by-zero)" {
run fmt_reduction 0 0
[ "$output" = "n/a" ]
}

# ---------------------------------------------------------------------------
# pr_has_current_approval — current review state wins
# ---------------------------------------------------------------------------

@test "pr_has_current_approval: a lone APPROVED review counts as approved" {
run pr_has_current_approval '[{"user":{"login":"a"},"state":"APPROVED"}]'
[ "$status" -eq 0 ]
}

@test "pr_has_current_approval: a later CHANGES_REQUESTED cancels an earlier APPROVED (same user)" {
run pr_has_current_approval '[{"user":{"login":"a"},"state":"APPROVED"},{"user":{"login":"a"},"state":"CHANGES_REQUESTED"}]'
[ "$status" -ne 0 ]
}

@test "pr_has_current_approval: one user's APPROVED survives another's CHANGES_REQUESTED" {
run pr_has_current_approval '[{"user":{"login":"a"},"state":"APPROVED"},{"user":{"login":"b"},"state":"CHANGES_REQUESTED"}]'
[ "$status" -eq 0 ]
}

@test "pr_has_current_approval: COMMENTED-only reviews are not an approval" {
run pr_has_current_approval '[{"user":{"login":"a"},"state":"COMMENTED"}]'
[ "$status" -ne 0 ]
}

@test "pr_has_current_approval: empty/absent reviews are not an approval" {
run pr_has_current_approval ""
[ "$status" -ne 0 ]
}

@test "pr_has_current_approval: null-user review is skipped without error" {
run pr_has_current_approval '[{"user":null,"state":"APPROVED"}]'
[ "$status" -ne 0 ]
}

@test "pr_has_current_approval: null-user review does not block a valid approval" {
run pr_has_current_approval '[{"user":null,"state":"CHANGES_REQUESTED"},{"user":{"login":"a"},"state":"APPROVED"}]'
[ "$status" -eq 0 ]
}

# ---------------------------------------------------------------------------
# count_eligible — non-draft AND (approved OR ready label)
# ---------------------------------------------------------------------------

@test "count_eligible: counts non-draft approved-or-labelled PRs" {
run count_eligible "$PRS_META_JSON" "auto-rebase:ready"
[ "$status" -eq 0 ]
[ "$output" -eq 2 ]
}

@test "count_eligible: a different ready label drops the label-only PR" {
run count_eligible "$PRS_META_JSON" "some-other-label"
[ "$output" -eq 1 ]
}

@test "count_eligible: empty/absent JSON returns 0 (does not error)" {
run count_eligible "" "auto-rebase:ready"
[ "$status" -eq 0 ]
[ "$output" -eq 0 ]
}

# ---------------------------------------------------------------------------
# render_report — post-restriction section (eligibility supplied)
# ---------------------------------------------------------------------------

@test "render_report: post-restriction section appears only when eligible is supplied" {
run render_report "$COMMENTS_JSON" "$RUNS_JSON" 7 8 2026-06-15
[[ "$output" != *"Post-restriction"* ]]

run render_report "$COMMENTS_JSON" "$RUNS_JSON" 7 8 2026-06-15 2 auto-rebase:ready
[[ "$output" == *"Post-restriction fan-out"* ]]
}

@test "render_report: post-restriction reports reduction and meets the ≥50% metric" {
# 8 behind → 2 eligible = 75% reduction (≥50% → met); 5 runs × 2 = 10 restricted re-runs
run render_report "$COMMENTS_JSON" "$RUNS_JSON" 7 8 2026-06-15 2 auto-rebase:ready
[ "$status" -eq 0 ]
[[ "$output" == *"75%"* ]]
[[ "$output" == *"met"* ]]
[[ "$output" == *"~10"* ]]
}

@test "render_report: sub-50% reduction is reported as not met" {
# 4 behind → 3 eligible = 25% reduction (< 50% → not met)
run render_report "$COMMENTS_JSON" "$RUNS_JSON" 7 4 2026-06-15 3 auto-rebase:ready
[[ "$output" == *"not met"* ]]
}
Loading