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