Skip to content

ci: add automated workflow to tag and close inactive pull requests - #11252

Open
joehan wants to merge 6 commits into
mainfrom
cleanup-inactive-prs
Open

joehan wants to merge 6 commits into
mainfrom
cleanup-inactive-prs

Conversation

@joehan

@joehan joehan commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Description

Sets up a tag-based lifecycle to manage inactive pull requests on firebase-tools:

  1. Tagging Phase: Evaluates open PRs and tags those with >= 60 days of inactivity with the inactive label, posting a comment letting contributors know they have 5 days to respond.
    • Skips PRs with exempt labels (DO NOT MERGE, ongoing, pinned).
    • Ignores bot comments/reviews when computing inactivity.
    • Supports rate-limit-friendly batch tagging (default 30 PRs per run).
  2. Closure & Grace Period Phase: Evaluates existing PRs labeled inactive.
    • Activity Reset: If a human pushes a commit, leaves a comment, or a maintainer reopens/unlabels the PR after the inactive label was added, the label is removed.
    • Auto-Close: If >= 5 days have elapsed with no new activity, removes the label and closes the PR with a closure message explaining how to get it reopened.

Includes:

  • .github/workflows/cleanup-inactive-prs.yml: GitHub Actions workflow with a manual workflow_dispatch trigger (supports dry run and customizable thresholds). For now this is manual-only — there is a TODO to enable a daily cron schedule once we've validated the behavior through manual runs.
  • scripts/cleanup_inactive_prs.py: Zero-dependency, standalone CLI and runner script using the GitHub GraphQL API.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new utility script, scripts/cleanup_inactive_prs.py, designed to automate the lifecycle management of inactive pull requests within the firebase-tools repository. The script utilizes GitHub GraphQL to identify inactive PRs, apply warning labels, and close them after a grace period. The review feedback provides actionable improvements to enhance the script's robustness, including the implementation of a safe nested dictionary getter to handle potential null values in API responses, more rigorous error handling for subprocess execution, and validation of GraphQL response payloads to prevent runtime failures.

Comment thread scripts/cleanup_inactive_prs.py Outdated
Comment thread scripts/cleanup_inactive_prs.py Outdated
Comment thread scripts/cleanup_inactive_prs.py Outdated
Comment thread scripts/cleanup_inactive_prs.py Outdated
@joehan

joehan commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

/joe-review

Comment thread scripts/cleanup_inactive_prs.py Outdated
Comment thread scripts/cleanup_inactive_prs.py Outdated
Comment thread scripts/cleanup_inactive_prs.py
Comment thread scripts/cleanup_inactive_prs.py Outdated
Comment thread scripts/cleanup_inactive_prs.py
Comment thread scripts/cleanup_inactive_prs.py Outdated
Comment thread scripts/cleanup_inactive_prs.py
Comment thread .github/workflows/cleanup-inactive-prs.yml Outdated
- Remove unused isDraft field from GraphQL PRS_QUERY.
- Increase timeline items window to last: 100 to prevent label event truncation.
- Validate GraphQL response for errors and null payloads.
- Filter bot users in get_last_activity and omit updatedAt from inactivity calculation.
- Guard parse_iso against None before sorting dates.
- Add get_nested() helper for safe dictionary traversal on GitHub API responses.
- Check returncode on gh pr edit --remove-label and gh pr close before proceeding.
- Read workflow inputs from environment variables as defaults in cleanup_inactive_prs.py.
@joehan
joehan requested a review from ajperel October 9, 2026 21:24

@ajperel ajperel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Have a whole lot of AI feedback that sounds plausible :)

Comment thread scripts/cleanup_inactive_prs.py Outdated
author {
login
}
timelineItems(itemTypes: [LABELED_EVENT, ISSUE_COMMENT, PULL_REQUEST_COMMIT], last: 100) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [Architecture & Lifecycle Asymmetry] Unify Phase 1 and Phase 2 activity detection (or include PULL_REQUEST_REVIEW and isolate LabeledEvent)

Rationale:

  1. Missing PULL_REQUEST_REVIEW in Phase 1: Phase 2 (get_last_activity) treats PR reviews as valid activity, but TIMELINE_QUERY only fetches [LABELED_EVENT, ISSUE_COMMENT, PULL_REQUEST_COMMIT]. If an author or reviewer submits a PR review during the 5-day grace period without leaving a top-level issue comment or pushing a commit, process_existing_inactive_prs ignores it and closes the PR.
  2. LabeledEvent Eviction Hazard: Sharing a single last: 100 window across LABELED_EVENT, ISSUE_COMMENT, and PULL_REQUEST_COMMIT means a rebase or merge with >=100 commits after labeling evicts the LabeledEvent, causing lines 345–347 (if not label_events: continue) to leave the inactive label stuck on an active PR.
  3. Simpler Single-Query Architecture: Because any activity after latest_label_dt guarantees max(activity_dt) > latest_label_dt, you can include labeledEvents: timelineItems(itemTypes: [LABELED_EVENT], last: 10) directly in PRS_QUERY, compare get_last_activity(pr)[1] > latest_label_dt in Phase 1, and delete TIMELINE_QUERY (and its N+1 API calls) altogether.

Suggested Fix (if keeping TIMELINE_QUERY, separate labeledEvents and add PULL_REQUEST_REVIEW):

Suggested change
timelineItems(itemTypes: [LABELED_EVENT, ISSUE_COMMENT, PULL_REQUEST_COMMIT], last: 100) {
labeledEvents: timelineItems(itemTypes: [LABELED_EVENT], last: 10) {
nodes {
... on LabeledEvent {
createdAt
label {
name
}
}
}
}
timelineItems(itemTypes: [ISSUE_COMMENT, PULL_REQUEST_COMMIT, PULL_REQUEST_REVIEW], last: 100) {

if review_submitted_at:
dt = parse_iso(review_submitted_at)
if dt is not None:
dates.append(("review", dt))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [Activity Detection Bug] Filter out bots and iterate reversed(reviews) in get_last_activity

Rationale:

  1. Above on lines 257–264, comments iterates in reverse and filters out BOT_USERS (which includes "gemini-code-assist"). However, reviews only checks reviews[-1] without checking the review author or skipping PENDING reviews (submittedAt: null). Because gemini-code-assist submits PullRequestReview objects, an automated bot review at reviews[-1] will falsely reset the inactivity clock, or mask an earlier human review at reviews[-2]. Also consider selecting author { __typename login } on comments and reviews in GraphQL and excluding any actor with __typename == "Bot" so other bots in this repo (e.g., codecov, dependabot[bot]) are automatically excluded.
  2. Caveat on Commit Author Filtering: If you also filter BOT_USERS on commits (lines 248–254 and 364–368), note two edge cases:
    • In GitHub GraphQL, commit.author.user is null whenever a contributor's git commit email is not linked to a GitHub account (get_nested(..., "login") returns None). Treat None/"" as valid human activity (c_author not in BOT_USERS), never if c_author and ....
    • For Dependabot PRs, when Dependabot rebases a PR during the grace period (@dependabot rebase), the new commit's author is dependabot[bot]. Do not filter out dependabot[bot] on commits, or rebased Dependabot PRs will still be closed.

Suggested Fix:

Suggested change
dates.append(("review", dt))
reviews = get_nested(pr, "reviews", "nodes", default=[])
for r in reversed(reviews):
r_author = get_nested(r, "author", "login")
review_submitted_at = r.get("submittedAt")
if r_author not in BOT_USERS and review_submitted_at:
dt = parse_iso(review_submitted_at)
if dt is not None:
dates.append(("review", dt))
break

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1c6a0e8: Added author { __typename login } to GraphQL queries. get_last_activity now iterates reversed(reviews) skipping bot accounts (BOT_USERS and actor __typename == "Bot") and pending reviews (submittedAt: null). Also treated unlinked commit author emails (author.user == null) as valid human activity and allowed commits by dependabot[bot] on Dependabot PRs.

Comment thread scripts/cleanup_inactive_prs.py Outdated

# 1. Autonomous AI improvement tasks (Buganizer component 2268168)
if head.startswith("ai-improve-") or "[AI Improvement]" in title or (author == "joehan" and "[Task]" in title):
return True, "Active AI Improvement task (Buganizer component 2268168)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [Scrub AI/Agent Artifacts] Remove hardcoded internal Buganizer component ID and personal maintainer handle

Rationale: Public repository automation in firebase/firebase-tools should not hardcode internal Google Buganizer component numbers (2268168), internal task prefixes (ai-improve-*, [AI Improvement]), or personal GitHub usernames (author == "joehan"). EXEMPT_LABELS (DO NOT MERGE, ongoing, pinned) on lines 291–296 already provides a clean, repo-standard mechanism to exempt long-running PRs.

Suggested Fix: Have automated task workflows apply an exempt label (such as ongoing) when opening PRs, and simplify is_protected_pr to check EXEMPT_LABELS.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1c6a0e8: Removed all internal Buganizer component ID (2268168), ai-improve-* branch patterns, [AI Improvement] title prefixes, and maintainer handle checks from the script and docstrings. PR protection is now strictly driven by repository EXEMPT_LABELS (DO NOT MERGE, ongoing, pinned).


pr_num = pr["number"]
author = get_nested(pr, "author", "login", default="ghost")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [Lifecycle Safety] Check is_protected_pr(pr) in process_existing_inactive_prs

Rationale: tag_new_inactive_prs skips protected PRs via is_protected_pr(pr) (line 442), but process_existing_inactive_prs never checks it. If a maintainer applies DO NOT MERGE, ongoing, or pinned to an already-tagged inactive PR during the 5-day grace period without leaving a comment, Phase 1 will still close the protected PR once days_labeled >= grace_days.

Suggested Fix:

Suggested change
pr_num = pr["number"]
author = get_nested(pr, "author", "login", default="ghost")
protected, reason = is_protected_pr(pr)
if protected:
print(f" #{pr_num} by @{author}: Protected ({reason}). Skipping closure.")
continue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1c6a0e8: Added is_protected_pr(pr) check in process_existing_inactive_prs. If a PR carries an exempt label, it skips closure and removes the inactive label so it does not linger in an inactive state.

Comment thread scripts/cleanup_inactive_prs.py Outdated
check=False,
)
if res_com.returncode != 0:
print(f" Warning: Failed to comment on #{pr_num}: {res_com.stderr.strip()}", file=sys.stderr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [Partial Mutation Hazard] Post warning comment before applying inactive label

Rationale: tag_new_inactive_prs runs gh pr edit --add-label inactive (line 480) before gh pr comment (line 504). If posting the comment fails due to a transient API error or secondary rate limit, the script logs a warning and leaves the inactive label on the PR—causing Phase 1 to close the PR 5 days later without the contributor ever receiving the warning comment.

Suggested Fix: Post the warning comment first and only apply the inactive label after gh pr comment succeeds (or roll back the label if commenting fails).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1c6a0e8: Reordered mutation operations in tag_new_inactive_prs to execute gh pr comment first. If posting the comment fails, the error is logged and label application is skipped, preventing untracked inactive labels.

description: "Days before closing tagged inactive PRs (default: 5)"
required: false
type: string
default: "5"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Nit: Use type: number for numeric workflow_dispatch inputs and set timeout-minutes: 15

Rationale: GitHub Actions supports type: number for workflow_dispatch inputs (inactive_threshold_days, grace_period_days, tag_batch_limit), validating numeric inputs in the UI. Also consider adding timeout-minutes: 15 to the cleanup job (line 47) and dropping the redundant GITHUB_TOKEN env var on line 50 (gh only uses GH_TOKEN).

Suggested change
default: "5"
type: number
default: 60

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1c6a0e8: Updated inactive_threshold_days, grace_period_days, and tag_batch_limit to type: number with numeric defaults in .github/workflows/cleanup-inactive-prs.yml, added timeout-minutes: 15 to the cleanup job, and removed the duplicate GITHUB_TOKEN environment variable mapping.

Comment thread scripts/cleanup_inactive_prs.py Outdated

CLOSURE_COMMENT = """Closing this pull request after {grace_days} days of inactivity following the `{label}` notification.

Thank you for contributing to `firebase-tools`! If you would like to revisit this change or continue work, please rebase against the latest `main` branch and feel free to reopen or open a new pull request. We appreciate your time!"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [Reopen Loop & GitHub Permissions] Remove inactive on close, count REOPENED_EVENT / UNLABELED_EVENT as activity, and fix rebase/reopen instructions

Rationale:

  1. Reopen Loop: gh pr close (line 405) leaves the inactive label attached when closing a PR. If a maintainer clicks Reopen without posting a comment, the next daily run evaluates the PR in Phase 1, ignores REOPENED_EVENT, computes days_labeled >= 5 from the original pre-closure LabeledEvent, and immediately closes the newly reopened PR again with zero grace period.
  2. Manual Unlabel Loop: If a maintainer manually removes the inactive label in the GitHub UI (either during the 5-day grace period or when reopening) without leaving a comment, get_last_activity ignores UNLABELED_EVENT and REOPENED_EVENT, so Phase 2 sees days_inactive >= 60 and immediately re-applies inactive on the next daily run.
  3. GitHub Permission & Rebase Lockout: When GITHUB_TOKEN closes a PR, external contributors without write/triage access cannot click "Reopen" themselves—and if they rebase/force-push their branch while the PR is still closed as instructed here, GitHub permanently locks the PR so even maintainers cannot reopen it.

Suggested Fix:

  1. Remove the inactive label when closing a PR (or include REOPENED_EVENT and UNLABELED_EVENT for INACTIVE_LABEL in activity detection).
  2. Update the closure message:
Suggested change
Thank you for contributing to `firebase-tools`! If you would like to revisit this change or continue work, please rebase against the latest `main` branch and feel free to reopen or open a new pull request. We appreciate your time!"""
Thank you for contributing to `firebase-tools`! If you would like to continue working on this change, please leave a comment asking a maintainer to reopen this pull request (before rebasing or force-pushing), or feel free to open a new pull request. We appreciate your time!"""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1c6a0e8:

  • Phase 1 now removes the inactive label prior to closing a PR.
  • Added REOPENED_EVENT and UNLABELED_EVENT to timelineItems in PRS_QUERY and TIMELINE_QUERY, recording them as fresh activity in get_last_activity and process_existing_inactive_prs to eliminate the reopen and manual unlabel loops.
  • Updated CLOSURE_COMMENT to advise contributors to ask a maintainer to reopen before rebasing or force-pushing to prevent permanent lockouts.

totalCount
nodes {
commit {
committedDate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [Git Timestamp Bug] Query pushedDate alongside committedDate on Commit nodes

Rationale: Both PRS_QUERY (line 84) and TIMELINE_QUERY (line 136) only inspect commit.committedDate (GIT_COMMITTER_DATE), which records when the commit was created locally in Git, not when it was pushed to GitHub. If a contributor responds to an inactive warning by pushing local commits that they committed on their machine prior to latest_label_dt (or rebasing with --committer-date-is-author-date, or force-pushing to drop a broken commit), c_dt > latest_label_dt (line 366) evaluates to False and Phase 1 closes the active PR anyway.

Suggested Fix: Query pushedDate alongside committedDate on commit and use max(pushedDate, committedDate) when pushedDate is non-null:

Suggested change
committedDate
committedDate
pushedDate

for pr in labeled_prs:
if close_limit > 0 and closed_count >= close_limit:
print(f"Reached close limit of {close_limit} PRs. Stopping Phase 1.")
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [Phase 1 Starvation] Move close_limit check inside if days_labeled >= grace_days: so active PRs are still unlabeled

Rationale: if close_limit > 0 and closed_count >= close_limit: break sits at the top of for pr in labeled_prs: (lines 325–327), before if new_activity: (line 370). Because all_prs is ordered by CREATED_AT ASC (oldest first), once close_limit (50) older expired PRs are closed, Phase 1 breaks out of the loop completely and skips removing the inactive label from newer PRs where contributors have already responded.

Suggested Fix: Move the close_limit check inside if days_labeled >= grace_days: (line 397) and skip only closures (continue), so if new_activity: always removes inactive from active PRs regardless of close_limit.

}
}
}
reviews(last: 5) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 [GraphQL Slice Eviction] Increase labels(first: 20), comments(last: 10), and reviews(last: 5) slices

Rationale:

  1. In GitHub's GraphQL schema, every inline diff reply creates a PullRequestReview node (state: COMMENTED). Fetching only reviews(last: 5) and comments(last: 10) and filtering bots afterward in Python means >=5 bot review comments or >=10 bot issue comments (including dependabot[bot], which should also be added to BOT_USERS for comment filtering) evicts the latest human activity from the window.
  2. Similarly, labels(first: 20) (line 66) truncates on PRs with >20 labels, which can hide EXEMPT_LABELS or inactive.

Suggested Fix: Increase the slices in PRS_QUERY to labels(first: 100), comments(last: 50), and reviews(last: 50).

… workflow

- Filter out bot accounts and iterate reversed reviews in get_last_activity to skip pending reviews.
- Treat unlinked commit author emails as valid human activity and count Dependabot commits on Dependabot PRs.
- Scrub internal Buganizer component ID and author handle; enforce protection strictly via EXEMPT_LABELS.
- Check is_protected_pr in Phase 1 before closing; skip closure and remove inactive label if protected.
- Post warning comment before applying inactive label in Phase 2 to prevent untracked labels.
- Remove inactive label upon PR closure, query REOPENED_EVENT and UNLABELED_EVENT as activity, and update closure comment to instruct asking a maintainer to reopen before rebasing.
- Validate CLI/env input bounds and exit non-zero (1) on mutation failures.
- Pass GraphQL variables for owner and repo instead of manual string template replacement.
- Extract DEPENDABOT_USERS constant and avoid pinging @ghost in greeting.
- Use type: number for numeric workflow inputs, set 15m job timeout, and remove redundant token env var.
@ajperel

ajperel commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

This is a lot of code that I primarily looked at with AI. I could do a much more thorough human review if you want. But I think we could also instead change a few things to test this more manually as a way to get confidence.

What if we don't start this running by default on cron without a dry run (as is current) but check the workflow in with it defaulting to dry run and needing to be manually triggered.

We could then go a few weeks of taking a few minutes to do a dry run and if things look good a manual real run. If we don't see any issues or get any complaints we care about then after gaining confidence (and fixing any issues) we turn it into a real cron that modifies all the open PRs on its own.

@joehan
joehan requested a review from ajperel October 10, 2026 00:16
Remove the daily cron trigger so the workflow only runs via workflow_dispatch
while we validate it with manual runs. Leave a TODO to re-enable the schedule.

@ajperel ajperel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still probably a good idea to default to dry_run for now, but approving as is while it's all a manual workflow. We can play around and work out any kinks.

__typename
login
}
timelineItems(itemTypes: [LABELED_EVENT, UNLABELED_EVENT, REOPENED_EVENT, ISSUE_COMMENT, PULL_REQUEST_COMMIT], last: 100) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit (non-blocking): TIMELINE_QUERY (and the Phase 1 loop at lines 431–452) still omits PULL_REQUEST_REVIEW (... on PullRequestReview { submittedAt author { __typename login } }), so a maintainer or contributor submitting a PR review during the 5-day grace period won't remove the inactive label or prevent auto-closure. (Also, commits(last: 1) on line 88 could be bumped to commits(last: 10) and iterated in reverse in get_last_activity so a bot commit at the tip of a human PR doesn't hide prior human commits.) Fine to address in a follow-up if you touch this script again.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants