Files
Jan Krivanek 15c336709e Fold Close Stale PRs into deterministic pr-triage stale sweep (#928)
Replace the agentic close-stale-prs.agent.md with a deterministic
stale-PR sweep hosted in pr-triage-batch.yml. The new stale-sweep job
runs .github/scripts/pr-stale-sweep.sh weekly (cron 17 4 * * 1) and on
manual dispatch (stale_sweep=true), warning about and closing PRs with no
non-bot activity for 30/37 days. Preserves the original policy (no-stale
and maestro exemptions, drafts included, non-bot activity timer) without
model calls or token cost.

Deletes close-stale-prs.agent.md and its compiled lock file, and updates
docs/design/pr-triage-workflows.md.

Fixes #915

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-23 16:04:13 +02:00

292 lines
12 KiB
Bash

#!/usr/bin/env bash
# pr-stale-sweep.sh
#
# Deterministic stale-PR sweep. Replaces the former agentic
# `close-stale-prs.agent.md`: no model calls, no tokens — just the GitHub API.
#
# Policy (unchanged from the agentic version):
# * Consider every OPEN pull request, including drafts. The open-PR set is
# fetched with full pagination (no upper bound).
# * "Last activity" is the most recent NON-bot issue comment, review, or
# inline review comment; if there is none, it falls back to the PR's
# created_at. We deliberately ignore `updated_at` and all `[bot]` activity
# so the bot's own stale-warning comment never resets the inactivity timer.
# * created <= 30 days ago -> skip (too new)
# * 30 days < inactivity <= 37 days -> post a stale WARNING
# (once; marker-guarded)
# * inactivity > 37 days -> CLOSE the PR
# * label `no-stale` -> exempt (skip)
# * author dotnet-maestro[bot] / dotnet-maestro -> exempt (skip)
#
# Safety:
# * Fail-safe reads: if any activity/comment source cannot be read for a PR,
# that PR is SKIPPED (never decided on partial data), so a transient API
# failure can never cause a wrongful close.
# * STALE_MAX is applied only AFTER sorting eligible PRs by inactivity
# (stalest first), so the cap can never starve an old closure behind a
# newer warning.
#
# Required env:
# GH_TOKEN — token with pull-requests:write, issues:write
# GITHUB_REPOSITORY — owner/repo (set by Actions)
#
# Optional env:
# DRY_RUN — "true" to log intended actions without any writes
# STALE_MAX — hard cap on warn+close writes per run (default 25)
# WARN_DAYS — inactivity threshold for a warning (default 30)
# CLOSE_DAYS — inactivity threshold for a close (default 37)
#
# Exits 0 on success (including no-op). Non-zero only on hard failures.
set -euo pipefail
: "${GH_TOKEN:?GH_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
DRY_RUN="${DRY_RUN:-false}"
STALE_MAX="${STALE_MAX:-25}"
WARN_DAYS="${WARN_DAYS:-30}"
CLOSE_DAYS="${CLOSE_DAYS:-37}"
# Validate numeric inputs up front. They arrive as strings from env/dispatch
# inputs but are used in arithmetic and comparisons, so a non-integer (e.g. a
# typo on a manual dispatch) would otherwise blow up with an opaque bash
# arithmetic error mid-sweep. Fail fast with a clear message instead.
for _var in STALE_MAX WARN_DAYS CLOSE_DAYS; do
if ! [[ "${!_var}" =~ ^[0-9]+$ ]]; then
echo "::error::$_var must be a non-negative integer (got '${!_var}')" >&2
exit 2
fi
done
if [ "$CLOSE_DAYS" -le "$WARN_DAYS" ]; then
echo "::error::CLOSE_DAYS ($CLOSE_DAYS) must be greater than WARN_DAYS ($WARN_DAYS)" >&2
exit 2
fi
REPO="$GITHUB_REPOSITORY"
OWNER="${REPO%/*}"
NAME="${REPO#*/}"
# Idempotency marker embedded in every warning comment. Its presence on a PR
# means "already warned" regardless of how the visible text may change.
WARN_MARKER="<!-- pr-triage:stale-warning -->"
NOW_SECS=$(date -u +%s)
WARN_CUTOFF_SECS=$(( WARN_DAYS * 86400 ))
CLOSE_CUTOFF_SECS=$(( CLOSE_DAYS * 86400 ))
log() { printf '[stale-sweep] %s\n' "$*" >&2; }
summary() {
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
printf '%s\n' "$*" >> "$GITHUB_STEP_SUMMARY"
fi
}
# Parse an ISO-8601 timestamp to epoch seconds (GNU date on the runner; BSD fallback).
to_epoch() {
local ts="$1"
date -u -d "$ts" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +%s
}
WARNING_BODY() {
cat <<EOF
$WARN_MARKER
This PR has been automatically marked as stale because it has no activity for ${WARN_DAYS} days. It will be closed if no further activity occurs within another $(( CLOSE_DAYS - WARN_DAYS )) days of this comment. If it is closed, you may reopen it anytime when you're ready again.
EOF
}
CLOSING_BODY() {
cat <<EOF
This pull request has been automatically closed because it has been open for more than ${WARN_DAYS} days with no recent activity.
If you believe this work is still relevant, please feel free to reopen or create a new pull request. Thank you for your contribution!
EOF
}
# Most recent NON-bot activity epoch for a PR. Considers issue comments and
# reviews; ignores any author whose login ends in "[bot]". Falls back to the
# supplied created_at epoch when there is no human activity.
#
# Returns non-zero if ANY activity source cannot be read. Callers MUST treat a
# non-zero return as "unknown activity" and skip the PR — silently omitting a
# source could drop the only recent human touch and wrongly close a live PR.
last_non_bot_activity_epoch() {
local pr="$1" created_epoch="$2"
local issue_comments reviews review_comments newest_ts
# Three human-activity sources. Each --paginate runs --jq per page, so we
# aggregate timestamps in the shell. Do NOT swallow gh failures: if a read
# fails, propagate it so the caller skips the PR rather than deciding on
# partial data.
# 1. issue comments (repos/{}/issues/{pr}/comments) -> created_at
# 2. reviews (repos/{}/pulls/{pr}/reviews) -> submitted_at
# 3. inline review comments(repos/{}/pulls/{pr}/comments) -> created_at
if ! issue_comments=$(gh api --paginate "repos/$REPO/issues/$pr/comments" \
--jq '.[] | select(.user != null) | select((.user.login | endswith("[bot]")) | not) | .created_at'); then
return 1
fi
if ! reviews=$(gh api --paginate "repos/$REPO/pulls/$pr/reviews" \
--jq '.[] | select(.user != null) | select((.user.login | endswith("[bot]")) | not) | .submitted_at'); then
return 1
fi
if ! review_comments=$(gh api --paginate "repos/$REPO/pulls/$pr/comments" \
--jq '.[] | select(.user != null) | select((.user.login | endswith("[bot]")) | not) | .created_at'); then
return 1
fi
# `|| true` here only guards grep returning 1 on no matches (a pure text
# pipeline); the API reads above already succeeded, so no error is hidden.
newest_ts=$(printf '%s\n%s\n%s\n' "$issue_comments" "$reviews" "$review_comments" \
| grep -v -e '^null$' -e '^$' | sort | tail -n 1 || true)
if [ -z "$newest_ts" ]; then
echo "$created_epoch"
return 0
fi
to_epoch "$newest_ts"
}
# 0 = already warned, 1 = not warned, 2 = read error (caller should skip).
already_warned() {
local pr="$1" hit
if ! hit=$(gh api --paginate "repos/$REPO/issues/$pr/comments" \
--jq ".[] | select(.body != null) | select(.body | contains(\"$WARN_MARKER\")) | .id"); then
return 2
fi
[ -n "$(printf '%s' "$hit" | head -n 1)" ]
}
log "repo=$REPO dry_run=$DRY_RUN warn>${WARN_DAYS}d close>${CLOSE_DAYS}d max=$STALE_MAX"
# ----------------------------------------------------------------------
# Phase 1 — enumerate every open PR and compute a decision for each.
# ----------------------------------------------------------------------
# Fully paginated (no upper bound) so the sweep really considers EVERY open PR,
# drafts included. --paginate walks all pages; @tsv emits one line per PR.
if ! PR_ROWS=$(gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" \
--jq '.[] | [.number, .created_at, .draft, (.user.login // ""), ([.labels[].name] | join(","))] | @tsv'); then
echo "::error::failed to enumerate open PRs — aborting sweep" >&2
exit 1
fi
# Candidate records for writes, one per line:
# INACTIVE_SECS|PR|AUTHOR|CREATED_DATE|INACTIVE_DAYS|DECISION
CANDIDATES=""
FETCHED=0
SKIPPED_READ_ERR=0
while IFS=$'\t' read -r PR CREATED_AT IS_DRAFT AUTHOR LABELS; do
[ -z "${PR:-}" ] && continue
FETCHED=$(( FETCHED + 1 ))
# Exemptions ------------------------------------------------------------
if [[ ",$LABELS," == *",no-stale,"* ]]; then
log "PR #$PR: no-stale label — exempt"
continue
fi
case "$AUTHOR" in
"dotnet-maestro[bot]"|"dotnet-maestro")
log "PR #$PR: maestro-authored — exempt"
continue ;;
esac
CREATED_EPOCH=$(to_epoch "$CREATED_AT")
AGE_SECS=$(( NOW_SECS - CREATED_EPOCH ))
# Too new: opened within WARN_DAYS.
if [ "$AGE_SECS" -le "$WARN_CUTOFF_SECS" ]; then
continue
fi
# Fail safe: if any activity source can't be read, skip this PR rather than
# risk closing a PR that actually had recent human activity.
if ! LAST_EPOCH=$(last_non_bot_activity_epoch "$PR" "$CREATED_EPOCH"); then
log "::warning::PR #$PR: activity read failed — skipping (won't decide on partial data)"
SKIPPED_READ_ERR=$(( SKIPPED_READ_ERR + 1 ))
continue
fi
INACTIVE_SECS=$(( NOW_SECS - LAST_EPOCH ))
INACTIVE_DAYS=$(( INACTIVE_SECS / 86400 ))
DECISION="skip(active)"
if [ "$INACTIVE_SECS" -gt "$CLOSE_CUTOFF_SECS" ]; then
DECISION="close"
elif [ "$INACTIVE_SECS" -gt "$WARN_CUTOFF_SECS" ]; then
if already_warned "$PR"; then
DECISION="skip(already-warned)"
else
rc=$?
if [ "$rc" = "2" ]; then
log "::warning::PR #$PR: comment read failed — skipping"
SKIPPED_READ_ERR=$(( SKIPPED_READ_ERR + 1 ))
continue
fi
DECISION="warn"
fi
fi
if [ "$DECISION" = "skip(active)" ] || [ "$DECISION" = "skip(already-warned)" ]; then
log "PR #$PR: inactivity=${INACTIVE_DAYS}d -> $DECISION"
continue
fi
CANDIDATES+="${INACTIVE_SECS}|${PR}|${AUTHOR}|${CREATED_AT%%T*}|${INACTIVE_DAYS}|${DECISION}"$'\n'
done <<<"$PR_ROWS"
log "open PRs fetched: $FETCHED (read-error skips: $SKIPPED_READ_ERR)"
# ----------------------------------------------------------------------
# Phase 2 — sort eligible PRs by inactivity (oldest activity first) and apply
# STALE_MAX only after sorting, so the stalest closures are never starved by
# newer warnings when the cap is hit.
# ----------------------------------------------------------------------
summary "## Stale PR sweep"
summary ""
summary "Repo \`$REPO\` · dry_run=\`$DRY_RUN\` · warn>\`${WARN_DAYS}d\` · close>\`${CLOSE_DAYS}d\` · max=\`$STALE_MAX\`"
summary ""
summary "| PR | author | created | inactivity(d) | decision |"
summary "|---:|---|---|---:|---|"
ACTIONS=0
if [ -n "$CANDIDATES" ]; then
# -rn on the leading INACTIVE_SECS field: largest inactivity (closures) first.
SORTED=$(printf '%s' "$CANDIDATES" | grep -v '^$' | sort -t'|' -k1,1 -rn)
while IFS='|' read -r _SECS PR AUTHOR CREATED_DATE INACTIVE_DAYS DECISION; do
[ -z "${PR:-}" ] && continue
if [ "$ACTIONS" -ge "$STALE_MAX" ]; then
log "PR #$PR: reached STALE_MAX=$STALE_MAX — skipping remaining writes"
summary "| #$PR | $AUTHOR | $CREATED_DATE | $INACTIVE_DAYS | ${DECISION} (capped) |"
continue
fi
summary "| #$PR | $AUTHOR | $CREATED_DATE | $INACTIVE_DAYS | $DECISION |"
case "$DECISION" in
close)
if [ "$DRY_RUN" = "true" ]; then
log "PR #$PR: [DRY_RUN] would close (inactivity=${INACTIVE_DAYS}d)"
else
gh pr comment "$PR" --repo "$REPO" --body "$(CLOSING_BODY)" >/dev/null
gh pr close "$PR" --repo "$REPO" >/dev/null
log "PR #$PR: closed (inactivity=${INACTIVE_DAYS}d)"
fi
ACTIONS=$(( ACTIONS + 1 ))
;;
warn)
if [ "$DRY_RUN" = "true" ]; then
log "PR #$PR: [DRY_RUN] would post stale warning (inactivity=${INACTIVE_DAYS}d)"
else
gh pr comment "$PR" --repo "$REPO" --body "$(WARNING_BODY)" >/dev/null
log "PR #$PR: warned (inactivity=${INACTIVE_DAYS}d)"
fi
ACTIONS=$(( ACTIONS + 1 ))
;;
esac
done <<<"$SORTED"
fi
summary ""
summary "**Actions taken (warn+close): $ACTIONS** (dry_run=\`$DRY_RUN\`)"
[ "$SKIPPED_READ_ERR" -gt 0 ] && summary "_Skipped $SKIPPED_READ_ERR PR(s) due to activity read errors._"
log "done — actions=$ACTIONS"