Skip to content

Commit 614efcd

Browse files
authored
ci: close first-time contributor PRs that lack a linked issue (#7164)
* ci: close first-time contributor PRs that lack a linked issue * ci: indent FTC close comment so the workflow YAML parses
1 parent 0e76258 commit 614efcd

3 files changed

Lines changed: 136 additions & 1 deletion

File tree

.github/CONTRIBUTING.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ chore(deps): bump pydantic to 2.11
103103
- Keep PRs focused — avoid bundling unrelated changes
104104
- PRs over 500 lines are labeled `size/XL` automatically
105105
- Title must follow the same conventional commit format
106-
- Link related issues where applicable
106+
- Link related issues where applicable (`Fixes #123`, `Closes #123`, or `Resolves #123`)
107+
- First-time contributors must open or pick an existing issue first, then include a closing keyword (`Fixes #N`, `Closes #N`, or `Resolves #N`) in the PR title or body. PRs without a linked issue are closed automatically.
107108

108109
## Testing
109110

.github/pull_request_template.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## Related issue
2+
3+
Fixes #
4+
5+
<!--
6+
First-time contributors must link an existing issue in this repo.
7+
Use a closing keyword: Fixes #123, Closes #123, or Resolves #123.
8+
PRs without a linked issue are closed automatically.
9+
-->
10+
11+
## Summary
12+
13+
<!-- Explain the solution and why. -->
14+
15+
## Verification
16+
17+
<!-- List the automated and manual checks used to verify the change. -->
18+
19+
- [ ] Tests added or updated for the changed behavior
20+
- [ ] Relevant tests and quality checks pass locally
21+
22+
## Additional context
23+
24+
<!-- Include screenshots, compatibility notes, follow-up work, or "None". -->
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
name: First-time contributor issue required
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, edited, reopened]
6+
7+
permissions:
8+
pull-requests: write
9+
issues: read
10+
11+
concurrency:
12+
group: ftc-require-issue-${{ github.event.pull_request.number }}
13+
cancel-in-progress: true
14+
15+
jobs:
16+
require-issue:
17+
if: >
18+
github.event.pull_request.user.type != 'Bot' &&
19+
contains(fromJSON('["FIRST_TIME_CONTRIBUTOR","FIRST_TIMER"]'),
20+
github.event.pull_request.author_association)
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Require a closing-keyword issue
24+
env:
25+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26+
PR_NUMBER: ${{ github.event.pull_request.number }}
27+
REPO: ${{ github.repository }}
28+
run: |
29+
python3 << 'PY'
30+
import json
31+
import os
32+
import re
33+
import subprocess
34+
import sys
35+
36+
repo = os.environ["REPO"]
37+
pr_number = os.environ["PR_NUMBER"]
38+
owner, name = repo.split("/", 1)
39+
40+
keyword = r"(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)"
41+
patterns = (
42+
re.compile(rf"(?i)\b{keyword}\s+#(\d+)\b"),
43+
re.compile(rf"(?i)\b{keyword}\s+{re.escape(owner)}/{re.escape(name)}#(\d+)\b"),
44+
re.compile(
45+
rf"(?i)\b{keyword}\s+https://github\.com/{re.escape(owner)}/{re.escape(name)}/issues/(\d+)\b"
46+
),
47+
)
48+
49+
def gh_json(*args: str) -> dict:
50+
return json.loads(
51+
subprocess.check_output(["gh", *args], text=True)
52+
)
53+
54+
def is_repo_issue(number: int) -> bool:
55+
result = subprocess.run(
56+
["gh", "api", f"repos/{repo}/issues/{number}"],
57+
capture_output=True,
58+
text=True,
59+
)
60+
if result.returncode != 0:
61+
stderr = result.stderr or ""
62+
if "404" in stderr or "Not Found" in stderr:
63+
return False
64+
raise RuntimeError(
65+
f"GitHub API error looking up #{number}: {stderr}"
66+
)
67+
return "pull_request" not in json.loads(result.stdout)
68+
69+
pr = gh_json(
70+
"pr", "view", pr_number, "--repo", repo, "--json", "title,body,state"
71+
)
72+
text = f"{pr.get('title') or ''}\n{pr.get('body') or ''}"
73+
candidates = {
74+
int(match)
75+
for pattern in patterns
76+
for match in pattern.findall(text)
77+
}
78+
if any(is_repo_issue(number) for number in sorted(candidates)):
79+
sys.exit(0)
80+
81+
if (pr.get("state") or "").upper() == "CLOSED":
82+
sys.exit(0)
83+
84+
comment = f"""Thanks for the pull request.
85+
86+
First-time contributors need an associated issue before we can review a PR.
87+
88+
1. Open an issue with a [template](https://github.com/{repo}/issues/new/choose), or pick an existing one.
89+
2. Open a new PR (or reopen this one) whose title or body includes a closing keyword, for example `Fixes #123`.
90+
91+
See the [contributing guide](https://github.com/{repo}/blob/main/.github/CONTRIBUTING.md).
92+
"""
93+
subprocess.run(
94+
[
95+
"gh",
96+
"pr",
97+
"comment",
98+
pr_number,
99+
"--repo",
100+
repo,
101+
"--body",
102+
comment,
103+
],
104+
check=True,
105+
)
106+
subprocess.run(
107+
["gh", "pr", "close", pr_number, "--repo", repo],
108+
check=True,
109+
)
110+
PY

0 commit comments

Comments
 (0)