-
Notifications
You must be signed in to change notification settings - Fork 45
feat: automate stale and abandon PR detection and labeling #41
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
base: main
Are you sure you want to change the base?
Changes from 2 commits
242ac2a
8b8e940
2586322
2c22825
dbc66c6
9ea0bc9
20b7817
9d8fa9e
6b606f7
1d6bd6b
98a2e4c
c26b441
2924f59
c4d4c25
b4e1173
b2437e0
12927e0
d8ca0c1
752c7d1
81b9344
a0a1169
6955c06
0129a4e
dabe013
625cf1c
7839747
496053a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| --- | ||
| name: PR Stale and Abandon Tracker | ||
|
|
||
| on: | ||
| schedule: | ||
| - cron: '0 2 * * *' # Daily at 2:00 AM UTC | ||
| workflow_dispatch: # Allows manual trigger for testing | ||
|
|
||
| permissions: | ||
| pull-requests: write | ||
| issues: write | ||
| contents: read | ||
|
|
||
| jobs: | ||
|
pemamian marked this conversation as resolved.
|
||
| stale_tracker: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Set up uv | ||
| uses: astral-sh/setup-uv@v5 | ||
|
|
||
| - name: Trace and Mark Stale PRs | ||
| run: uv run .github/workflows/scripts/pr-cron-stale-abandon.py | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| #!/usr/bin/env python3 | ||
| # /// script | ||
| # dependencies = [ | ||
| # "pygithub", | ||
| # ] | ||
| # /// | ||
|
pemamian marked this conversation as resolved.
Outdated
|
||
| import os | ||
| import sys | ||
| from datetime import datetime, timedelta, timezone | ||
| from github import Github | ||
|
|
||
| def main(): | ||
| token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") | ||
| if not token: | ||
| print("[ERROR] GH_TOKEN or GITHUB_TOKEN is not set.") | ||
| sys.exit(1) | ||
|
|
||
| g = Github(token) | ||
| repo_name = os.environ.get("GITHUB_REPOSITORY") | ||
| repo = g.get_repo(repo_name) | ||
|
|
||
| # Threshold Config (abandon is an additional 30 days on top of stale) | ||
| STALE_THRESHOLD_DAYS = 30 | ||
| ADDITIONAL_ABANDON_DAYS = 30 | ||
|
pemamian marked this conversation as resolved.
Outdated
|
||
| ABANDON_THRESHOLD_DAYS = STALE_THRESHOLD_DAYS + ADDITIONAL_ABANDON_DAYS | ||
|
|
||
| # Label Constants | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make sure to create the labels before merging :)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think github automatically creates labels that don't exist. (based on small tests I've done) |
||
| LABEL_UNDER_REVIEW = "status:under-review" | ||
| LABEL_BLOCKED = "blocked" | ||
| LABEL_STALE_REVIEW = "status:stale-review" | ||
| LABEL_NEEDS_TRIAGE = "status:needs-triage" | ||
| LABEL_ABANDON_CANDIDATE = "status:abandon-candidate" | ||
|
|
||
| now = datetime.now(timezone.utc) | ||
| stale_limit = now - timedelta(days=STALE_THRESHOLD_DAYS) | ||
| abandon_limit = now - timedelta(days=ABANDON_THRESHOLD_DAYS) | ||
|
|
||
| print(f"[CONFIG] Stale limit: >{STALE_THRESHOLD_DAYS} days ({stale_limit.isoformat()})") | ||
| print(f"[CONFIG] Abandon limit: >{ABANDON_THRESHOLD_DAYS} days ({abandon_limit.isoformat()})") | ||
|
|
||
| # Fetch all open pull requests | ||
| pulls = repo.get_pulls(state="open") | ||
|
|
||
| stale_found = 0 | ||
| stale_labeled = 0 | ||
| abandon_found = 0 | ||
| abandon_labeled = 0 | ||
| already_labeled_count = 0 | ||
| total_scanned = 0 | ||
|
|
||
| for pr in pulls: | ||
|
pemamian marked this conversation as resolved.
Outdated
pemamian marked this conversation as resolved.
Outdated
|
||
| total_scanned += 1 | ||
| # pr.updated_at is already timezone-aware in PyGithub (utc) | ||
| updated_at = pr.updated_at | ||
| if updated_at.tzinfo is None: | ||
| updated_at = updated_at.replace(tzinfo=timezone.utc) | ||
|
|
||
| labels = [l.name for l in pr.get_labels()] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could try pr.labels to avoid an API request, as the pr object might already have that? |
||
|
|
||
| is_stale = updated_at < stale_limit | ||
| is_abandon_candidate = updated_at < abandon_limit | ||
|
|
||
| # Determine matching category | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we have a label removal logic too? If the PR is active again? |
||
| is_stale_review = is_stale and (LABEL_UNDER_REVIEW in labels) | ||
| is_blocked_abandon = is_abandon_candidate and (LABEL_BLOCKED in labels) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is abandon just for blocked PRs? |
||
|
|
||
| if not is_stale_review and not is_blocked_abandon: | ||
| continue | ||
|
|
||
| print(f'[INACTIVE] PR #{pr.number} "{pr.title}" is inactive since {pr.updated_at.isoformat()}') | ||
|
|
||
| # Select target labels based on matching category | ||
| if is_blocked_abandon: | ||
| target_labels = [LABEL_STALE_REVIEW, LABEL_ABANDON_CANDIDATE, LABEL_NEEDS_TRIAGE] | ||
| abandon_found += 1 | ||
| else: | ||
| target_labels = [LABEL_STALE_REVIEW, LABEL_NEEDS_TRIAGE] | ||
| stale_found += 1 | ||
|
|
||
| # Keep only labels that are missing from the PR | ||
| missing_labels = [l for l in target_labels if l not in labels] | ||
|
|
||
| if missing_labels: | ||
| print(f'[ACTION] PR #{pr.number} "{pr.title}" is inactive. Adding missing labels: {missing_labels}') | ||
| # PyGithub add_to_labels accepts iterable of strings or label objects | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since iterables are accepted, why not pass them instead of making multiple API calls?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reduces unnecessary 'add' calls that could cause a notification. |
||
| for label in missing_labels: | ||
| pr.add_to_labels(label) | ||
|
|
||
| comment_body = "" | ||
| # Abandon candidate notification comment | ||
| if is_blocked_abandon and (LABEL_ABANDON_CANDIDATE in missing_labels): | ||
| comment_body = ( | ||
| f"This pull request has been blocked and inactive for " | ||
| f"{ABANDON_THRESHOLD_DAYS} days. " | ||
| f"It has been marked as an abandon candidate. " | ||
| f"Please resolve the blockers to resume review." | ||
| ) | ||
| # Stale review notification comment | ||
| elif is_stale_review and (LABEL_STALE_REVIEW in missing_labels): | ||
| comment_body = ( | ||
| f"This pull request has been inactive for " | ||
| f"{STALE_THRESHOLD_DAYS} days. " | ||
| f"Could you please provide an update or follow up on reviews?" | ||
| ) | ||
|
|
||
| if comment_body: | ||
| print(f"[ACTION] Posting stale/abandon comment on PR #{pr.number}") | ||
| pr.create_issue_comment(comment_body) | ||
|
|
||
| if is_stale_review: | ||
| stale_labeled += 1 | ||
| if is_blocked_abandon: | ||
| abandon_labeled += 1 | ||
| else: | ||
| already_labeled_count += 1 | ||
|
|
||
| print("\n========================================") | ||
| print(" STALE TRACKER RUN SUMMARY") | ||
| print("========================================") | ||
| print(f"Total Open PRs Scanned: {total_scanned}") | ||
| print(f"PRs Already Correctly Labeled: {already_labeled_count}") | ||
| print("----------------------------------------") | ||
| print(f"Stale Reviews (>{STALE_THRESHOLD_DAYS}d) Found: {stale_found}") | ||
| print(f"Stale Reviews Newly Labeled: {stale_labeled}") | ||
| print("----------------------------------------") | ||
| print(f"Abandon Candidates (>{ABANDON_THRESHOLD_DAYS}d) Found: {abandon_found}") | ||
| print(f"Abandon Candidates Newly Labeled: {abandon_labeled}") | ||
| print("========================================\n") | ||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Uh oh!
There was an error while loading. Please reload this page.