diff --git a/.github/workflows/pr_link_issue_reminder.yml b/.github/workflows/pr_link_issue_reminder.yml new file mode 100644 index 000000000000..13d35f3ed2ef --- /dev/null +++ b/.github/workflows/pr_link_issue_reminder.yml @@ -0,0 +1,33 @@ +name: PR Issue Link Reminder + +on: + schedule: + - cron: "30 7 * * *" + workflow_dispatch: + +jobs: + remind_or_close: + name: Remind or close PRs without a linked issue + if: github.repository == 'huggingface/diffusers' + runs-on: ubuntu-22.04 + permissions: + contents: read + pull-requests: write + issues: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.10" + + - name: Install requirements + run: | + pip install PyGithub requests + + - name: Run reminder script + run: | + python utils/remind_link_issue.py diff --git a/utils/remind_link_issue.py b/utils/remind_link_issue.py new file mode 100644 index 000000000000..a5ebda3f449f --- /dev/null +++ b/utils/remind_link_issue.py @@ -0,0 +1,104 @@ +# Copyright 2025 The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Script to remind PR authors to link an issue. + +Behavior: +- Scans open, non-draft PRs. +- A PR is considered "linked" if GitHub's GraphQL `closingIssuesReferences` returns > 0 + (covers both `Fixes #N` keywords in the body and issues linked via the GitHub UI). +- If a PR is not linked and no prior reminder is present, the script posts a single + friendly reminder comment. +- PRs labeled `no-issue-needed` and bot-authored PRs are skipped. +""" + +import os + +import requests +from github import Github + + +REPO = "huggingface/diffusers" +REMINDER_MARKER = "" +BYPASS_LABELS = {"no-issue-needed"} +CONTRIBUTION_GUIDE_URL = "https://huggingface.co/docs/diffusers/main/en/conceptual/contribution#coding-with-ai-agents" + +GRAPHQL_URL = "https://api.github.com/graphql" +GRAPHQL_QUERY = """ +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + closingIssuesReferences(first: 1) { + totalCount + } + } + } +} +""" + + +def has_linked_issue(token, owner, name, number): + response = requests.post( + GRAPHQL_URL, + json={"query": GRAPHQL_QUERY, "variables": {"owner": owner, "name": name, "number": number}}, + headers={"Authorization": f"Bearer {token}"}, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + return payload["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["totalCount"] > 0 + + +def has_existing_reminder(pr): + return any(REMINDER_MARKER in (c.body or "") for c in pr.get_issue_comments()) + + +def reminder_body(author): + return ( + f"{REMINDER_MARKER}\n" + f"Hi @{author}, thanks for the PR! It does not appear to link an issue it fixes. " + "If this PR addresses an existing issue, please add a closing keyword " + "(e.g. `Fixes #1234`) to the PR description so the issue is linked. " + f"See the [contribution guide]({CONTRIBUTION_GUIDE_URL}) for more details. " + "If this PR intentionally does not fix a tracked issue, a maintainer can " + "add the `no-issue-needed` label to silence this reminder." + ) + + +def main(): + token = os.environ["GITHUB_TOKEN"] + g = Github(token) + repo = g.get_repo(REPO) + owner, name = REPO.split("/", 1) + + for pr in repo.get_pulls(state="open"): + if pr.draft: + continue + if pr.user is None: + continue + author = pr.user.login + if not author or author.endswith("[bot]") or pr.user.type == "Bot": + continue + labels = {label.name for label in pr.labels} + if labels & BYPASS_LABELS: + continue + if has_linked_issue(token, owner, name, pr.number): + continue + if has_existing_reminder(pr): + continue + pr.create_issue_comment(reminder_body(author)) + + +if __name__ == "__main__": + main()