-
Notifications
You must be signed in to change notification settings - Fork 369
Fold "Close Stale PRs" into a deterministic pr-triage stale sweep #928
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JanKrivanek
merged 3 commits into
main
from
jankrivanek-merge-close-stale-prs-into-triage
Jul 23, 2026
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
1b0a8f7
Fold Close Stale PRs into deterministic pr-triage stale sweep
JanKrivanek 9f81276
Harden stale-sweep: fail-safe reads, sort-before-cap, full pagination…
JanKrivanek 3a3c9b9
Address re-review: validate numeric inputs, split concurrency, fix doc
JanKrivanek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| #!/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. | ||
| # * "Last activity" is the most recent NON-bot comment or review; 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) | ||
| # | ||
| # 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}" | ||
|
|
||
| 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. | ||
| last_non_bot_activity_epoch() { | ||
| local pr="$1" created_epoch="$2" | ||
| local newest_ts | ||
|
|
||
| # Emit one timestamp per non-bot comment/review, then pick the max. --paginate | ||
| # runs --jq per page, so we must aggregate in the shell, not inside jq. | ||
| newest_ts=$( | ||
| { | ||
| gh api --paginate "repos/$REPO/issues/$pr/comments" \ | ||
| --jq '.[] | select((.user.login | endswith("[bot]")) | not) | .created_at' 2>/dev/null || true | ||
| gh api --paginate "repos/$REPO/pulls/$pr/reviews" \ | ||
| --jq '.[] | select(.user != null) | select((.user.login | endswith("[bot]")) | not) | .submitted_at' 2>/dev/null || true | ||
|
JanKrivanek marked this conversation as resolved.
Outdated
|
||
| } | grep -v '^null$' | sort | tail -n 1 | ||
|
JanKrivanek marked this conversation as resolved.
Outdated
|
||
| ) | ||
|
|
||
| if [ -z "$newest_ts" ]; then | ||
| echo "$created_epoch" | ||
| return | ||
| fi | ||
| to_epoch "$newest_ts" | ||
| } | ||
|
|
||
| already_warned() { | ||
| local pr="$1" hit | ||
| hit=$(gh api --paginate "repos/$REPO/issues/$pr/comments" \ | ||
| --jq ".[] | select(.body | contains(\"$WARN_MARKER\")) | .id" 2>/dev/null | head -n 1) | ||
| [ -n "$hit" ] | ||
| } | ||
|
|
||
| log "repo=$REPO dry_run=$DRY_RUN warn>${WARN_DAYS}d close>${CLOSE_DAYS}d max=$STALE_MAX" | ||
|
|
||
| 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 "|---:|---|---|---:|---|" | ||
|
|
||
| # Enumerate all open PRs (drafts included). --limit caps the working set; PRs | ||
| # newer than WARN_DAYS are filtered out per-PR below. | ||
| PRS_JSON=$(gh pr list --repo "$REPO" --state open --limit 500 \ | ||
| --json number,createdAt,isDraft,author,labels) | ||
|
|
||
|
JanKrivanek marked this conversation as resolved.
Outdated
|
||
| COUNT=$(jq 'length' <<<"$PRS_JSON") | ||
| log "open PRs fetched: $COUNT" | ||
|
|
||
| ACTIONS=0 | ||
| processed=0 | ||
| while IFS= read -r row; do | ||
|
JanKrivanek marked this conversation as resolved.
Outdated
|
||
| PR=$(jq -r '.number' <<<"$row") | ||
| CREATED_AT=$(jq -r '.createdAt' <<<"$row") | ||
| AUTHOR=$(jq -r '.author.login // ""' <<<"$row") | ||
| LABELS=$(jq -r '[.labels[].name] | join(",")' <<<"$row") | ||
|
|
||
| # 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 | ||
|
|
||
| LAST_EPOCH=$(last_non_bot_activity_epoch "$PR" "$CREATED_EPOCH") | ||
| 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 | ||
| DECISION="warn" | ||
| fi | ||
| fi | ||
|
|
||
| if [ "$DECISION" = "skip(active)" ] || [ "$DECISION" = "skip(already-warned)" ]; then | ||
| log "PR #$PR: inactivity=${INACTIVE_DAYS}d -> $DECISION" | ||
| continue | ||
| fi | ||
|
|
||
| summary "| #$PR | $AUTHOR | ${CREATED_AT%%T*} | $INACTIVE_DAYS | $DECISION |" | ||
|
|
||
| if [ "$ACTIONS" -ge "$STALE_MAX" ]; then | ||
| log "PR #$PR: reached STALE_MAX=$STALE_MAX — skipping remaining writes" | ||
| continue | ||
| fi | ||
|
|
||
| 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 | ||
| processed=$(( processed + 1 )) | ||
| done < <(jq -c '.[]' <<<"$PRS_JSON") | ||
|
|
||
| summary "" | ||
| summary "**Actions taken (warn+close): $ACTIONS** (dry_run=\`$DRY_RUN\`)" | ||
| log "done — actions=$ACTIONS" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.