Skip to content

Commit 86fb53d

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 86fb53d

File tree

2 files changed

+221
-0
lines changed

2 files changed

+221
-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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python3
2+
##===----------------------------------------------------------------------===##
3+
##
4+
## This source file is part of the Swift.org open source project
5+
##
6+
## Copyright (c) 2026 Apple Inc. and the Swift project authors
7+
## Licensed under Apache License v2.0 with Runtime Library Exception
8+
##
9+
## See https://swift.org/LICENSE.txt for license information
10+
## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
11+
##
12+
##===----------------------------------------------------------------------===##
13+
14+
import argparse
15+
import json
16+
import subprocess
17+
from datetime import datetime, timedelta, timezone
18+
19+
argparse = argparse.ArgumentParser()
20+
argparse.add_argument(
21+
"--stale-duration",
22+
required=True,
23+
help="Number of weeks after which a PR is considered stale"
24+
)
25+
argparse.add_argument(
26+
"--repo",
27+
required=True,
28+
help="Repo in which to check for stale PRs, eg. swiftlang/swift-syntax"
29+
)
30+
argparse.add_argument(
31+
"--dry-run",
32+
action="store_true",
33+
help="Repo in which to check for stale PRs, eg. swiftlang/swift-syntax"
34+
)
35+
args = argparse.parse_args()
36+
37+
stale_duration_weeks = int(args.stale_duration)
38+
repo = str(args.repo)
39+
dry_run = bool(args.dry_run)
40+
41+
stale_date = datetime.now(timezone.utc) - timedelta(weeks=stale_duration_weeks)
42+
43+
command = [
44+
"gh", "pr", "list", "-R", repo, "--search",
45+
f"updated:<{stale_date.isoformat()} draft:false is:pr is:open",
46+
"--json", "author,comments,commits,number,reviewDecision,reviewRequests,reviews,url"
47+
]
48+
prs = json.loads(subprocess.check_output(command, encoding="utf-8"))
49+
50+
distant_past = datetime.fromtimestamp(0, timezone.utc)
51+
52+
53+
def user_has_write_access(user: str) -> bool:
54+
output = subprocess.check_output(
55+
["gh", "api", f"repos/{repo}/collaborators/{user}/permission"],
56+
encoding="utf-8"
57+
)
58+
return json.loads(output)["permission"] in ["write", "push", "admin"]
59+
60+
61+
def print_command(command: list[str]) -> None:
62+
print(" ".join([f"'{arg}'" if " " in arg else arg for arg in command]))
63+
64+
65+
for pr in prs:
66+
pr_author = pr["author"]["login"]
67+
68+
# Filter out reviews from users who aren't affiliated with the repository
69+
relevant_reviews = [
70+
review for review in pr["reviews"]
71+
if review["authorAssociation"] in ["COLLABORATOR", "MEMBER", "OWNER"]
72+
]
73+
reviewers = [review_request["login"] for review_request in pr["reviewRequests"]]
74+
reviewers.extend([review["author"]["login"] for review in relevant_reviews])
75+
76+
reviewer_interaction_dates: list[str] = []
77+
reviewer_interaction_dates.extend(
78+
[review["submittedAt"] for review in relevant_reviews]
79+
)
80+
reviewer_interaction_dates.extend([
81+
comment["createdAt"] for comment in pr["comments"]
82+
if comment["author"]["login"] in reviewers
83+
if "@swift-ci" not in comment["body"]
84+
])
85+
86+
author_interaction_dates: list[str] = []
87+
author_interaction_dates.extend(
88+
[commit["authoredDate"] for commit in pr["commits"]]
89+
)
90+
author_interaction_dates.extend(
91+
[commit["committedDate"] for commit in pr["commits"]]
92+
)
93+
author_interaction_dates.extend([
94+
comment["createdAt"] for comment in pr["comments"]
95+
if comment["author"]["login"] == pr_author
96+
if "@swift-ci" not in comment["body"]
97+
])
98+
99+
if reviewer_interaction_dates:
100+
last_reviewer_interaction_date = datetime.fromisoformat(
101+
max(reviewer_interaction_dates).replace("Z", "+00:00")
102+
)
103+
else:
104+
last_reviewer_interaction_date = distant_past
105+
106+
if author_interaction_dates:
107+
last_author_interaction_date = datetime.fromisoformat(
108+
max(author_interaction_dates).replace("Z", "+00:00")
109+
)
110+
else:
111+
last_author_interaction_date = distant_past
112+
113+
comment = f"This PR has not been modified for {stale_duration_weeks} weeks. "
114+
reviewers.sort()
115+
joined_reviewers = ", ".join(["@" + r for r in reviewers])
116+
reviewers_ping = joined_reviewers or "Code Owners of this repository"
117+
if pr["reviewDecision"] == "APPROVED":
118+
if user_has_write_access(pr_author):
119+
comment += (
120+
f"{pr_author} given this PR has an approving review, "
121+
"please try and merge the PR. Should the PR be no longer "
122+
"relevant, please close it. Should you take more time to "
123+
"work on it, please mark it as draft to disable these "
124+
"notifications.."
125+
)
126+
else:
127+
comment += (
128+
f"{reviewers_ping} given this PR has an approving review "
129+
"but the author does not have merge access, please help "
130+
"the author to make the PR pass CI checks and get it "
131+
"merged."
132+
)
133+
elif last_author_interaction_date < last_reviewer_interaction_date:
134+
comment += (
135+
f"@{pr_author} to help move this PR forward, please address "
136+
"the review feedback. Should the PR be no longer relevant, "
137+
"please close it. Should you take more time to work on it, "
138+
"please mark it as draft to disable these notifications."
139+
)
140+
else:
141+
comment += (
142+
f"{reviewers_ping} to help move this PR forward, "
143+
"please review it."
144+
)
145+
146+
add_comment_command = [
147+
"gh", "pr",
148+
"-R", repo,
149+
"comment", str(pr["number"]),
150+
"--body", comment
151+
]
152+
if dry_run:
153+
print_command(add_comment_command)
154+
else:
155+
subprocess.check_call(add_comment_command)

0 commit comments

Comments
 (0)