Skip to content

Commit df963a8

Browse files
committed
implement a bot to remind prs to link issues if not.
1 parent 40a43dd commit df963a8

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: PR Issue Link Reminder
2+
3+
on:
4+
schedule:
5+
- cron: "30 7 * * *"
6+
workflow_dispatch:
7+
8+
jobs:
9+
remind_or_close:
10+
name: Remind or close PRs without a linked issue
11+
if: github.repository == 'huggingface/diffusers'
12+
runs-on: ubuntu-22.04
13+
permissions:
14+
contents: read
15+
pull-requests: write
16+
issues: write
17+
env:
18+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19+
steps:
20+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
21+
22+
- name: Setup Python
23+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
24+
with:
25+
python-version: 3.10
26+
27+
- name: Install requirements
28+
run: |
29+
pip install PyGithub requests
30+
31+
- name: Run reminder script
32+
run: |
33+
python utils/remind_link_issue.py

utils/remind_link_issue.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Copyright 2025 The HuggingFace Team. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""
15+
Script to remind PR authors to link an issue, and close PRs that ignore the reminders.
16+
17+
Behavior:
18+
- Scans open, non-draft PRs.
19+
- A PR is considered "linked" if GitHub's GraphQL `closingIssuesReferences` returns > 0
20+
(covers both `Fixes #N` keywords in the body and issues linked via the GitHub UI).
21+
- If a PR is not linked, the script posts up to 3 reminder comments spaced 7 days apart.
22+
- If the 3rd reminder is older than 7 days and the PR is still not linked, the PR is closed.
23+
- PRs labeled `no-issue-needed` and bot-authored PRs are skipped.
24+
"""
25+
26+
import os
27+
from datetime import datetime, timedelta, timezone
28+
29+
import requests
30+
from github import Github
31+
32+
33+
REPO = "huggingface/diffusers"
34+
REMINDER_MARKER = "<!-- pr-link-issue-reminder -->"
35+
CLOSE_MARKER = "<!-- pr-link-issue-close -->"
36+
REMINDER_INTERVAL = timedelta(days=7)
37+
MAX_REMINDERS = 3
38+
BYPASS_LABELS = {"no-issue-needed"}
39+
40+
GRAPHQL_URL = "https://api.github.com/graphql"
41+
GRAPHQL_QUERY = """
42+
query($owner: String!, $name: String!, $number: Int!) {
43+
repository(owner: $owner, name: $name) {
44+
pullRequest(number: $number) {
45+
closingIssuesReferences(first: 1) {
46+
totalCount
47+
}
48+
}
49+
}
50+
}
51+
"""
52+
53+
54+
def has_linked_issue(token, owner, name, number):
55+
response = requests.post(
56+
GRAPHQL_URL,
57+
json={"query": GRAPHQL_QUERY, "variables": {"owner": owner, "name": name, "number": number}},
58+
headers={"Authorization": f"Bearer {token}"},
59+
timeout=30,
60+
)
61+
response.raise_for_status()
62+
payload = response.json()
63+
return payload["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["totalCount"] > 0
64+
65+
66+
def reminder_history(pr):
67+
reminders = [c for c in pr.get_issue_comments() if REMINDER_MARKER in (c.body or "")]
68+
reminders.sort(key=lambda c: c.created_at)
69+
return reminders
70+
71+
72+
def reminder_body(author, count):
73+
remaining = MAX_REMINDERS - count
74+
lines = [
75+
REMINDER_MARKER,
76+
f"Hi @{author}, this PR does not appear to link an issue it fixes. "
77+
"If this PR addresses an existing issue, please add a closing keyword "
78+
"(e.g. `Fixes #1234`) to the PR description so the issue is linked.",
79+
"",
80+
f"Reminder **{count}/{MAX_REMINDERS}**. ",
81+
]
82+
if remaining > 0:
83+
lines[-1] += (
84+
f"If no linked issue is added within {REMINDER_INTERVAL.days} days, "
85+
f"you will receive {remaining} more reminder(s)."
86+
)
87+
else:
88+
lines[-1] += (
89+
f"This is the final reminder. If no linked issue is added within "
90+
f"{REMINDER_INTERVAL.days} days, this PR will be closed automatically. "
91+
"If this PR intentionally does not fix a tracked issue, a maintainer "
92+
"can add the `no-issue-needed` label to bypass this check."
93+
)
94+
return "\n".join(lines)
95+
96+
97+
def close_body(author):
98+
return (
99+
f"{CLOSE_MARKER}\n"
100+
f"Closing this PR because @{author} did not add a linked issue after "
101+
f"{MAX_REMINDERS} reminders spaced {REMINDER_INTERVAL.days} days apart. "
102+
"Please reopen once the PR description references the issue it fixes "
103+
"(e.g. `Fixes #1234`), or ask a maintainer to add the `no-issue-needed` "
104+
"label if this PR is intentionally unrelated to a tracked issue."
105+
)
106+
107+
108+
def aware(ts):
109+
return ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
110+
111+
112+
def main():
113+
token = os.environ["GITHUB_TOKEN"]
114+
g = Github(token)
115+
repo = g.get_repo(REPO)
116+
owner, name = REPO.split("/", 1)
117+
118+
now = datetime.now(timezone.utc)
119+
120+
for pr in repo.get_pulls(state="open"):
121+
if pr.draft:
122+
continue
123+
if pr.user is None:
124+
continue
125+
author = pr.user.login
126+
if not author or author.endswith("[bot]") or pr.user.type == "Bot":
127+
continue
128+
labels = {label.name for label in pr.labels}
129+
if labels & BYPASS_LABELS:
130+
continue
131+
if has_linked_issue(token, owner, name, pr.number):
132+
continue
133+
134+
reminders = reminder_history(pr)
135+
count = len(reminders)
136+
137+
if count == 0:
138+
pr.create_issue_comment(reminder_body(author, 1))
139+
continue
140+
141+
if now - aware(reminders[-1].created_at) < REMINDER_INTERVAL:
142+
continue
143+
144+
if count >= MAX_REMINDERS:
145+
pr.create_issue_comment(close_body(author))
146+
pr.edit(state="closed")
147+
else:
148+
pr.create_issue_comment(reminder_body(author, count + 1))
149+
150+
151+
if __name__ == "__main__":
152+
main()

0 commit comments

Comments
 (0)