Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
242ac2a
feat: automate stale and abandon PR detection and labeling
pemamian May 22, 2026
8b8e940
Merge pull request #1 from pemamian/stale-pr-detection
pemamian May 22, 2026
2586322
chore: fix PyGithub deprecation warning in stale tracker
pemamian May 22, 2026
2c22825
chore: address review comments on stale tracker by adding timeouts, o…
pemamian May 22, 2026
dbc66c6
Merge branch 'stale-pr-detection'
pemamian May 22, 2026
9ea0bc9
chore: ignore draft pull requests in stale PR tracker
pemamian May 22, 2026
20b7817
feat: improvements on stale cronjob.
pemamian May 26, 2026
9d8fa9e
feat: initial version of label synchronization workflow
pemamian May 26, 2026
6b606f7
feat: fix auth issue label syncer
pemamian May 26, 2026
1d6bd6b
feat: fix permission issue for label sync
pemamian May 26, 2026
98a2e4c
feat: getting rid of github toke pass.
pemamian May 26, 2026
c26b441
feat: additional labels needed for rules based labeling
pemamian May 27, 2026
2924f59
feat: README and rules for rule based labeling.
pemamian May 27, 2026
c4d4c25
feat: implement automated PR triage routing and validation logic with…
pemamian May 27, 2026
b4e1173
Merge pull request #2 from pemamian/pr-rules-engine
pemamian May 27, 2026
b2437e0
feat: implement guardrail to prevent unauthorized removal of active r…
pemamian May 27, 2026
12927e0
Merge branch 'main' of github.com:pemamian/python-sdk-peyman into pr-…
pemamian May 27, 2026
d8ca0c1
Merge pull request #4 from pemamian/pr-rules-engine
pemamian May 27, 2026
752c7d1
feat: enforce label guardrails as the primary triage rule and add sup…
pemamian May 27, 2026
81b9344
Merge branch 'main' into pr-rules-engine
pemamian May 27, 2026
a0a1169
Merge pull request #5 from pemamian/pr-rules-engine
pemamian May 27, 2026
6955c06
feat: implement repository-level execution scoping for PR routing rul…
pemamian May 27, 2026
0129a4e
feat(triage): implement modular rules engine with global and per-rule…
pemamian May 27, 2026
dabe013
Merge branch 'main' of github.com:pemamian/python-sdk-peyman into pr-…
pemamian May 27, 2026
625cf1c
docs(triage): change taxonomy file link to repository-root relative p…
pemamian May 27, 2026
7839747
docs(triage): replace specific user and organization handles with gen…
pemamian May 27, 2026
496053a
Merge pull request #6 from pemamian/pr-rules-engine
pemamian May 27, 2026
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
27 changes: 27 additions & 0 deletions .github/workflows/pr-cron-stale-abandon.yml
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
Comment thread
pemamian marked this conversation as resolved.
Outdated
workflow_dispatch: # Allows manual trigger for testing

permissions:
pull-requests: write
issues: write
contents: read

jobs:
Comment thread
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 }}
131 changes: 131 additions & 0 deletions .github/workflows/scripts/pr-cron-stale-abandon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "pygithub",
# ]
# ///
Comment thread
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
Comment thread
pemamian marked this conversation as resolved.
Outdated
ABANDON_THRESHOLD_DAYS = STALE_THRESHOLD_DAYS + ADDITIONAL_ABANDON_DAYS

# Label Constants
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.

Make sure to create the labels before merging :)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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:
Comment thread
pemamian marked this conversation as resolved.
Outdated
Comment thread
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()]
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.

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
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.

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)
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.

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
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.

Since iterables are accepted, why not pass them instead of making multiple API calls?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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()
Loading