|
| 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. |
| 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 and no prior reminder is present, the script posts a single |
| 22 | + friendly reminder comment. |
| 23 | +- PRs labeled `no-issue-needed` and bot-authored PRs are skipped. |
| 24 | +""" |
| 25 | + |
| 26 | +import logging |
| 27 | +import os |
| 28 | +from datetime import datetime, timedelta, timezone |
| 29 | + |
| 30 | +import requests |
| 31 | +from github import Github |
| 32 | + |
| 33 | + |
| 34 | +logger = logging.getLogger(__name__) |
| 35 | + |
| 36 | +REPO = "huggingface/diffusers" |
| 37 | +REMINDER_MARKER = "<!-- pr-link-issue-reminder -->" |
| 38 | +BYPASS_LABELS = {"no-issue-needed"} |
| 39 | +LOOKBACK_DAYS = 2 |
| 40 | +CONTRIBUTION_GUIDE_URL = "https://huggingface.co/docs/diffusers/main/en/conceptual/contribution#coding-with-ai-agents" |
| 41 | + |
| 42 | +GRAPHQL_URL = "https://api.github.com/graphql" |
| 43 | +GRAPHQL_QUERY = """ |
| 44 | +query($owner: String!, $name: String!, $number: Int!) { |
| 45 | + repository(owner: $owner, name: $name) { |
| 46 | + pullRequest(number: $number) { |
| 47 | + closingIssuesReferences(first: 1) { |
| 48 | + totalCount |
| 49 | + } |
| 50 | + } |
| 51 | + } |
| 52 | +} |
| 53 | +""" |
| 54 | + |
| 55 | + |
| 56 | +def has_linked_issue(token, owner, name, number): |
| 57 | + response = requests.post( |
| 58 | + GRAPHQL_URL, |
| 59 | + json={"query": GRAPHQL_QUERY, "variables": {"owner": owner, "name": name, "number": number}}, |
| 60 | + headers={"Authorization": f"Bearer {token}"}, |
| 61 | + timeout=30, |
| 62 | + ) |
| 63 | + response.raise_for_status() |
| 64 | + payload = response.json() |
| 65 | + data = payload.get("data") |
| 66 | + if not data: |
| 67 | + return False |
| 68 | + return data["repository"]["pullRequest"]["closingIssuesReferences"]["totalCount"] > 0 |
| 69 | + |
| 70 | + |
| 71 | +def has_existing_reminder(pr): |
| 72 | + return any(REMINDER_MARKER in (c.body or "") for c in pr.get_issue_comments()) |
| 73 | + |
| 74 | + |
| 75 | +def reminder_body(author): |
| 76 | + return ( |
| 77 | + f"{REMINDER_MARKER}\n" |
| 78 | + f"Hi @{author}, thanks for the PR! It does not appear to link an issue it fixes. " |
| 79 | + "If this PR addresses an existing issue, please add a closing keyword " |
| 80 | + "(e.g. `Fixes #1234`) to the PR description so the issue is linked. " |
| 81 | + f"See the [contribution guide]({CONTRIBUTION_GUIDE_URL}) for more details. " |
| 82 | + "If this PR intentionally does not fix a tracked issue, a maintainer can " |
| 83 | + "add the `no-issue-needed` label to silence this reminder." |
| 84 | + ) |
| 85 | + |
| 86 | + |
| 87 | +def main(): |
| 88 | + token = os.environ["GITHUB_TOKEN"] |
| 89 | + g = Github(token) |
| 90 | + repo = g.get_repo(REPO) |
| 91 | + owner, name = REPO.split("/", 1) |
| 92 | + cutoff = datetime.now(timezone.utc) - timedelta(days=LOOKBACK_DAYS) |
| 93 | + |
| 94 | + try: |
| 95 | + pulls = repo.get_pulls(state="open", sort="created", direction="desc") |
| 96 | + for pr in pulls: |
| 97 | + try: |
| 98 | + created_at = pr.created_at |
| 99 | + if created_at.tzinfo is None: |
| 100 | + created_at = created_at.replace(tzinfo=timezone.utc) |
| 101 | + # PRs are sorted newest-first, so once we cross the cutoff every |
| 102 | + # remaining PR is older too and we can stop paginating. |
| 103 | + if created_at < cutoff: |
| 104 | + break |
| 105 | + if pr.draft: |
| 106 | + continue |
| 107 | + if pr.user is None: |
| 108 | + continue |
| 109 | + author = pr.user.login |
| 110 | + if not author or author.endswith("[bot]") or pr.user.type == "Bot": |
| 111 | + continue |
| 112 | + labels = {label.name for label in pr.labels} |
| 113 | + if labels & BYPASS_LABELS: |
| 114 | + continue |
| 115 | + if has_linked_issue(token, owner, name, pr.number): |
| 116 | + continue |
| 117 | + if has_existing_reminder(pr): |
| 118 | + continue |
| 119 | + pr.create_issue_comment(reminder_body(author)) |
| 120 | + except Exception as e: |
| 121 | + logger.warning("Skipping PR #%s: %s", getattr(pr, "number", "?"), e) |
| 122 | + continue |
| 123 | + except Exception as e: |
| 124 | + logger.error("Failed to fetch open PRs: %s", e) |
| 125 | + raise |
| 126 | + |
| 127 | + |
| 128 | +if __name__ == "__main__": |
| 129 | + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| 130 | + main() |
0 commit comments