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
81 changes: 73 additions & 8 deletions scripts/auto_rebase_health.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
# 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. Fleet merge-state observability (AC7, #1440) — a snapshot of how many open
# non-draft, non-Dependabot PRs are currently `mergeStateStatus: BEHIND` vs
# DIRTY. This makes the effectiveness of the AC1/AC2 auto-rebase fixes
# (petry-projects/.github#926) measurable per run without another manual
# audit. Repo-scoped — fleet-wide would need a cross-repo PAT this report
# does not carry.
#
# 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 +38,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)
# PR_LIST_LIMIT — max open PRs fetched for BEHIND/DIRTY/multiplier metrics (default: 1000)
# raise if the repo regularly has >1000 open PRs; a truncation warning
# is rendered in the report whenever the fetched count equals the limit
# 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}"
PR_LIST_LIMIT="${PR_LIST_LIMIT:-1000}"
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 @@ -78,6 +89,29 @@ summarize_sentinels() {
] | @tsv'
}

# summarize_merge_states <prs_json>
# Emits TSV: behind<TAB>dirty — counts of open PRs whose GitHub `mergeStateStatus`
# is BEHIND (head is behind base; needs an auto-rebase update) vs DIRTY (merge
# conflict; needs human/agent resolution), over the subset of PRs that are
# non-draft AND non-Dependabot-authored. This is the AC7 observability metric
# (#1440): it makes the effectiveness of the AC1/AC2 fixes measurable per run.
# Input JSON is an array of {mergeStateStatus, isDraft, author:{login}} objects
# (as returned by `gh pr list --json mergeStateStatus,isDraft,author`).
# Absent/empty JSON → "0\t0".
summarize_merge_states() {
local json="${1:-}"
[ -n "$json" ] || json='[]'
jq -r '
[ .[]
| select((.isDraft // false) | not)
| select(((.author?.login // "" | tostring) | test("dependabot"; "i")) | not)
] as $prs |
[
([$prs[] | select(.mergeStateStatus == "BEHIND")] | length),
([$prs[] | select(.mergeStateStatus == "DIRTY")] | length)
] | @tsv' <<< "$json"
}

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

# render_report <comments_json> <runs_json> <lookback_days> <behind_prs> [today]
# render_report <comments_json> <runs_json> <lookback_days> <behind_prs> [today] [prs_json] [pr_list_truncated] [pr_list_limit]
# Writes the full Markdown report to stdout. Pure: no network.
# pr_list_truncated=true renders a warning that BEHIND/DIRTY counts may undercount.
render_report() {
local comments_json="${1:-[]}" runs_json="${2:-[]}"
local lookback="${3:-7}" behind="${4:-0}" today="${5:-}"
local lookback="${3:-7}" behind="${4:-0}" today="${5:-}" prs_json="${6:-[]}"
local pr_list_truncated="${7:-false}" pr_list_limit="${8:-1000}"
[ -n "$today" ] || today="$(date -u +%Y-%m-%d)"

local sentinels responses applied
Expand All @@ -123,6 +159,9 @@ render_report() {
local total success failed
IFS=$'\t' read -r total success failed < <(summarize_runs "$runs_json")

local ms_behind ms_dirty
IFS=$'\t' read -r ms_behind ms_dirty < <(summarize_merge_states "$prs_json")

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

Expand All @@ -148,7 +187,19 @@ render_report() {
printf -- '- **Per-day baseline**: %s auto-rebase run(s)/day · ~%s CI re-run(s)/day\n\n' \
"$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'
printf 'so re-runs = runs × current open non-Dependabot PR count.\n\n'

printf '## Fleet merge-state observability (AC7)\n\n'
printf -- '- **BEHIND** (open non-draft non-Dependabot PRs `mergeStateStatus: BEHIND`): %s\n' "$ms_behind"
printf -- '- **DIRTY** (open non-draft non-Dependabot PRs `mergeStateStatus: DIRTY`): %s\n\n' "$ms_dirty"
if [ "$pr_list_truncated" = "true" ]; then
printf '> ⚠ **PR list capped at %s** — BEHIND/DIRTY counts cover only the first %s open PRs ' \
"$pr_list_limit" "$pr_list_limit"
printf 'and may undercount the true backlog. Set `PR_LIST_LIMIT` to raise the cap.\n\n'
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'
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -196,15 +247,29 @@ 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.
# 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.
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 '[]')"
[ -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
else
pr_list_truncated=false
fi

# Behind-PR multiplier — open non-Dependabot PRs (proxy for branches the
# fan-out updates), derived from the same snapshot.
local behind
behind="$(gh pr list --repo "$WORKFLOW_REPO" --state open --limit 200 --json author \
--jq '[.[] | select((.author?.login // "") | test("dependabot"; "i") | not)] | length' \
behind="$(printf '%s' "$prs_json" \
| jq '[.[] | select((.author?.login // "") | test("dependabot"; "i") | not)] | length' \
2>/dev/null || echo 0)"

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

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

# Open-PR set for merge-state (BEHIND/DIRTY) observability:
# 2 BEHIND (non-draft, human-authored) — counted
# 1 DIRTY (non-draft, human-authored) — counted
# 1 CLEAN (non-draft, human-authored) — ignored (not BEHIND/DIRTY)
# 1 BEHIND but DRAFT — excluded
# 1 DIRTY but Dependabot-authored — excluded
PRS_JSON='[
{"number":1,"mergeStateStatus":"BEHIND","isDraft":false,"author":{"login":"don-petry"}},
{"number":2,"mergeStateStatus":"BEHIND","isDraft":false,"author":{"login":"alice"}},
{"number":3,"mergeStateStatus":"DIRTY","isDraft":false,"author":{"login":"bob"}},
{"number":4,"mergeStateStatus":"CLEAN","isDraft":false,"author":{"login":"carol"}},
{"number":5,"mergeStateStatus":"BEHIND","isDraft":true,"author":{"login":"dave"}},
{"number":6,"mergeStateStatus":"DIRTY","isDraft":false,"author":{"login":"dependabot[bot]"}}
]'
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -80,6 +95,46 @@ setup() {
[ "$output" = "$(printf '0\t0\t0')" ]
}

# ---------------------------------------------------------------------------
# summarize_merge_states — BEHIND / DIRTY over open non-draft non-Dependabot PRs
# ---------------------------------------------------------------------------

@test "summarize_merge_states: counts BEHIND and DIRTY over eligible PRs" {
run summarize_merge_states "$PRS_JSON"
[ "$status" -eq 0 ]
# TSV: behind<TAB>dirty — 2 BEHIND, 1 DIRTY (draft + dependabot excluded)
[ "$output" = "$(printf '2\t1')" ]
}

@test "summarize_merge_states: excludes draft PRs" {
run summarize_merge_states '[
{"mergeStateStatus":"BEHIND","isDraft":true,"author":{"login":"alice"}}
]'
[ "$output" = "$(printf '0\t0')" ]
}

@test "summarize_merge_states: excludes Dependabot-authored PRs" {
run summarize_merge_states '[
{"mergeStateStatus":"DIRTY","isDraft":false,"author":{"login":"dependabot[bot]"}}
]'
[ "$output" = "$(printf '0\t0')" ]
}

@test "summarize_merge_states: empty/absent JSON returns zeros (no error)" {
run summarize_merge_states ""
[ "$status" -eq 0 ]
[ "$output" = "$(printf '0\t0')" ]
}
Comment thread
don-petry marked this conversation as resolved.

@test "summarize_merge_states: handles null or missing author safely" {
run summarize_merge_states '[
{"mergeStateStatus":"BEHIND","isDraft":false,"author":null},
{"mergeStateStatus":"DIRTY","isDraft":false}
]'
[ "$status" -eq 0 ]
[ "$output" = "$(printf '1\t1')" ]
}

# ---------------------------------------------------------------------------
# estimate_fanout — run_count × behind_prs
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -138,3 +193,30 @@ setup() {
[ "$status" -eq 0 ]
[[ "$output" == *"n/a"* ]]
}

@test "render_report: surfaces BEHIND/DIRTY merge-state observability section" {
run render_report "$COMMENTS_JSON" "$RUNS_JSON" 7 3 2026-06-15 "$PRS_JSON"
[ "$status" -eq 0 ]
[[ "$output" == *"merge-state"* ]]
[[ "$output" == *"BEHIND"* ]]
[[ "$output" == *"DIRTY"* ]]
}

@test "render_report: absent PR JSON renders merge-state counts as zero" {
run render_report "$COMMENTS_JSON" "$RUNS_JSON" 7 0 2026-06-15
[ "$status" -eq 0 ]
[[ "$output" == *"BEHIND"* ]]
}

@test "render_report: truncation flag renders capped-count warning in merge-state section" {
run render_report "$COMMENTS_JSON" "$RUNS_JSON" 7 3 2026-06-15 "$PRS_JSON" true 1000
[ "$status" -eq 0 ]
[[ "$output" == *"PR list capped at 1000"* ]]
[[ "$output" == *"undercount"* ]]
}

@test "render_report: no truncation flag suppresses capped-count warning" {
run render_report "$COMMENTS_JSON" "$RUNS_JSON" 7 3 2026-06-15 "$PRS_JSON" false 1000
[ "$status" -eq 0 ]
[[ "$output" != *"PR list capped"* ]]
}
Loading