Skip to content

Commit 9a4dc63

Browse files
committed
Add workflow to post a comment on stale PRs
To help repository owners ensure that all PRs get reviewed and to ensure that open PRs get merged in a timely manner, add a GitHub workflow that posts comments on all PRs that haven’t been modified for a given number of weeks.
1 parent bd52b59 commit 9a4dc63

File tree

2 files changed

+142
-0
lines changed

2 files changed

+142
-0
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Ping Stale PRs
2+
3+
# Posts comments on PRs that haven't had any interaction for more than a given number of weeks.
4+
#
5+
# The workflow tries to be smart and infer whether it's the PR author's turn to move the PR forward or if it is blocked
6+
# on actions by the reviewers and ping them accordingly.concurrency:
7+
#
8+
# Example usage in a repository:
9+
#
10+
# ```
11+
# name: Ping stale PRs
12+
# permissions:
13+
# contents: read
14+
# on:
15+
# schedule:
16+
# - cron: '0 9 * * *'
17+
# workflow_dispatch:
18+
# jobs:
19+
# ping_stale_prs:
20+
# name: Ping stale PRs
21+
# uses: swiftlang/github-workflows/.github/workflows/ping_stale_prs.yml@main
22+
# permissions:
23+
# contents: write
24+
# pull-requests: write
25+
# if: (github.event_name == 'schedule' && github.repository == 'swiftlang/swift-format') || (github.event_name != 'schedule') # Ensure that we don't run this on a schedule in a fork
26+
# ```
27+
28+
29+
permissions:
30+
contents: read
31+
32+
on:
33+
workflow_call:
34+
inputs:
35+
stale_duration_weeks:
36+
type: string
37+
description: "The number of weeks after which a PR should be considered stale."
38+
default: "3"
39+
40+
jobs:
41+
ping_stale_prs:
42+
name: Ping Stale PRs
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 10
45+
permissions:
46+
pull-requests: write
47+
steps:
48+
- name: Checkout swiftlang/github-workflows repository
49+
if: ${{ github.repository != 'swiftlang/github-workflows' }}
50+
uses: actions/checkout@v4
51+
with:
52+
repository: swiftlang/github-workflows
53+
path: github-workflows
54+
- name: Determine script-root path
55+
id: script_path
56+
run: |
57+
if [ "${{ github.repository }}" = "swiftlang/github-workflows" ]; then
58+
echo "root=$GITHUB_WORKSPACE" >> $GITHUB_OUTPUT
59+
else
60+
echo "root=$GITHUB_WORKSPACE/github-workflows" >> $GITHUB_OUTPUT
61+
fi
62+
- name: Post comment on stale PRs
63+
env:
64+
GH_TOKEN: ${{ github.token }}
65+
run: |
66+
python3 ${{ steps.script_path.outputs.root }}/.github/workflows/scripts/ping_stale_prs.py --stale-duration ${{ inputs.stale_duration_weeks }} --repo ${{ github.repository }}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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

Comments
 (0)