|
| 1 | +import subprocess |
| 2 | +import json |
| 3 | +from datetime import datetime, timedelta, timezone |
| 4 | +import argparse |
| 5 | + |
| 6 | +argparse = argparse.ArgumentParser() |
| 7 | +argparse.add_argument("--stale-duration", required=True, help="Number of weeks after which a PR is considered stale") |
| 8 | +argparse.add_argument("--repo", required=True, help="Repo in which to check for stale PRs, eg. swiftlang/swift-syntax") |
| 9 | +argparse.add_argument("--dry-run", action="store_true", help="Repo in which to check for stale PRs, eg. swiftlang/swift-syntax") |
| 10 | +args = argparse.parse_args() |
| 11 | + |
| 12 | +stale_duration_weeks = int(args.stale_duration) |
| 13 | +repo = str(args.repo) |
| 14 | +dry_run = bool(args.dry_run) |
| 15 | + |
| 16 | +stale_date = datetime.now(timezone.utc) - timedelta(weeks=stale_duration_weeks) |
| 17 | + |
| 18 | +command = ["gh", "pr", "list", "-R", repo, "--search", f"updated:<{stale_date.isoformat()} draft:false is:pr is:open", "--json", "author,comments,commits,number,reviewDecision,reviewRequests,reviews,url"] |
| 19 | +prs = json.loads(subprocess.check_output(command, encoding="utf-8")) |
| 20 | + |
| 21 | +distant_past = datetime.fromtimestamp(0, timezone.utc) |
| 22 | + |
| 23 | + |
| 24 | +def user_has_write_access(user: str) -> bool: |
| 25 | + output = subprocess.check_output(["gh", "api", f"repos/{repo}/collaborators/{user}/permission"], encoding="utf-8") |
| 26 | + return json.loads(output)["permission"] in ["write", "push", "admin"] |
| 27 | + |
| 28 | + |
| 29 | +def print_command(command: list[str]) -> None: |
| 30 | + print(" ".join([f"'{arg}'" if " " in arg else arg for arg in command])) |
| 31 | + |
| 32 | + |
| 33 | +for pr in prs: |
| 34 | + pr_author = pr["author"]["login"] |
| 35 | + |
| 36 | + # Filter out reviews from users who aren't affiliated with the repository |
| 37 | + relevant_reviews = [review for review in pr["reviews"] if review["authorAssociation"] in ["COLLABORATOR", "MEMBER", "OWNER"]] |
| 38 | + reviewers = [review_request["login"] for review_request in pr["reviewRequests"]] + [review["author"]["login"] for review in relevant_reviews] |
| 39 | + |
| 40 | + reviewer_interaction_dates: list[str] = [] |
| 41 | + reviewer_interaction_dates.extend([review["submittedAt"] for review in relevant_reviews]) |
| 42 | + reviewer_interaction_dates.extend([comment["createdAt"] for comment in pr.get("comments", []) if comment["author"]["login"] in reviewers and "@swift-ci" not in comment["body"]]) |
| 43 | + |
| 44 | + author_interaction_dates: list[str] = [] |
| 45 | + author_interaction_dates.extend([commit["authoredDate"] for commit in pr.get("commits", [])]) |
| 46 | + author_interaction_dates.extend([commit["committedDate"] for commit in pr.get("commits", [])]) |
| 47 | + author_interaction_dates.extend([comment["createdAt"] for comment in pr.get("comments", []) if comment["author"]["login"] == pr_author and "@swift-ci" not in comment["body"]]) |
| 48 | + |
| 49 | + if reviewer_interaction_dates: |
| 50 | + last_reviewer_interaction_date = datetime.fromisoformat(max(reviewer_interaction_dates).replace("Z", "+00:00")) |
| 51 | + else: |
| 52 | + last_reviewer_interaction_date = distant_past |
| 53 | + |
| 54 | + if author_interaction_dates: |
| 55 | + last_author_interaction_date = datetime.fromisoformat(max(author_interaction_dates).replace("Z", "+00:00")) |
| 56 | + else: |
| 57 | + last_author_interaction_date = distant_past |
| 58 | + |
| 59 | + comment = f"This PR has not been modified for {stale_duration_weeks} weeks. " |
| 60 | + reviewers.sort() |
| 61 | + reviewers_ping = ", ".join(["@" + r for r in reviewers]) or "Code Owners of this repository" |
| 62 | + if pr["reviewDecision"] == "APPROVED": |
| 63 | + if user_has_write_access(pr_author): |
| 64 | + comment += f"{pr_author} given this PR has an approving review, please try and merge the PR. Should the PR be no longer relevant, please close it. Should you take more time to work on it, please mark it as draft to disable these notifications.." |
| 65 | + else: |
| 66 | + comment += f"{reviewers_ping} given this PR has an approving review but the author does not have merge access, please help the author to make the PR pass CI checks and get it merged." |
| 67 | + elif last_author_interaction_date < last_reviewer_interaction_date: |
| 68 | + comment += f"@{pr_author} to help move this PR forward, please address the review feedback. Should the PR be no longer relevant, please close it. Should you take more time to work on it, please mark it as draft to disable these notifications." |
| 69 | + else: |
| 70 | + comment += f"{reviewers_ping} to help move this PR forward, please review it." |
| 71 | + |
| 72 | + add_comment_command = ["gh", "pr", "-R", repo, "comment", str(pr["number"]), "--body", comment] |
| 73 | + if dry_run: |
| 74 | + print_command(add_comment_command) |
| 75 | + else: |
| 76 | + subprocess.check_call(add_comment_command) |
0 commit comments