Skip to content

Commit bf56bb1

Browse files
authored
ci: require an open issue for first-time contributor PRs (#7169)
* ci: require an open issue for first-time contributor PRs Gate anyone who is not a returning contributor, and allow the PR only when a closing keyword points at an open issue in this repo. * ci: accept any open issue mention for first-timer PRs Drop the closing-keyword regex so #123, owner/repo#N, or an issue URL is enough when that issue is open. * ci: ignore foreign owner/repo#N in first-timer issue gate Bare #123 no longer matches the suffix of other/repo#123, so an open local issue cannot keep that PR open.
1 parent 614efcd commit bf56bb1

4 files changed

Lines changed: 129 additions & 18 deletions

File tree

.github/CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +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 (`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.
106+
- Link related issues where applicable (`#123`, `Fixes #123`, or the issue URL)
107+
- First-time contributors must open or pick an existing **open** issue first, then mention it in the PR title or body (for example `#123`). PRs without a linked open issue are closed automatically.
108108

109109
## Testing
110110

.github/pull_request_template.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
Fixes #
44

55
<!--
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.
6+
First-time contributors must mention an existing open issue in this repo
7+
(for example #123). PRs without a linked open issue are closed automatically.
98
-->
109

1110
## Summary

.github/workflows/ftc-require-issue.yml

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,21 @@ concurrency:
1414

1515
jobs:
1616
require-issue:
17+
# Allow-list returning contributors. FIRST_TIMER / FIRST_TIME_CONTRIBUTOR
18+
# are often NONE on pull_request_target at opened time, which skipped the
19+
# previous deny-list and left first-timer PRs open.
1720
if: >
1821
github.event.pull_request.user.type != 'Bot' &&
19-
contains(fromJSON('["FIRST_TIME_CONTRIBUTOR","FIRST_TIMER"]'),
20-
github.event.pull_request.author_association)
22+
!contains(fromJSON('["MEMBER","OWNER","COLLABORATOR","CONTRIBUTOR"]'),
23+
github.event.pull_request.author_association)
2124
runs-on: ubuntu-latest
2225
steps:
23-
- name: Require a closing-keyword issue
26+
- name: Require an open issue
2427
env:
2528
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2629
PR_NUMBER: ${{ github.event.pull_request.number }}
2730
REPO: ${{ github.repository }}
31+
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
2832
run: |
2933
python3 << 'PY'
3034
import json
@@ -36,13 +40,17 @@ jobs:
3640
repo = os.environ["REPO"]
3741
pr_number = os.environ["PR_NUMBER"]
3842
owner, name = repo.split("/", 1)
43+
print(
44+
"author_association=",
45+
os.environ.get("AUTHOR_ASSOCIATION", ""),
46+
sep="",
47+
)
3948
40-
keyword = r"(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)"
4149
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"),
50+
re.compile(r"(?<![\w./-])#(\d+)\b"),
51+
re.compile(rf"{re.escape(owner)}/{re.escape(name)}#(\d+)\b"),
4452
re.compile(
45-
rf"(?i)\b{keyword}\s+https://github\.com/{re.escape(owner)}/{re.escape(name)}/issues/(\d+)\b"
53+
rf"https://github\.com/{re.escape(owner)}/{re.escape(name)}/issues/(\d+)\b"
4654
),
4755
)
4856
@@ -51,7 +59,7 @@ jobs:
5159
subprocess.check_output(["gh", *args], text=True)
5260
)
5361
54-
def is_repo_issue(number: int) -> bool:
62+
def is_open_repo_issue(number: int) -> bool:
5563
result = subprocess.run(
5664
["gh", "api", f"repos/{repo}/issues/{number}"],
5765
capture_output=True,
@@ -64,7 +72,10 @@ jobs:
6472
raise RuntimeError(
6573
f"GitHub API error looking up #{number}: {stderr}"
6674
)
67-
return "pull_request" not in json.loads(result.stdout)
75+
payload = json.loads(result.stdout)
76+
if "pull_request" in payload:
77+
return False
78+
return (payload.get("state") or "").lower() == "open"
6879
6980
pr = gh_json(
7081
"pr", "view", pr_number, "--repo", repo, "--json", "title,body,state"
@@ -75,18 +86,18 @@ jobs:
7586
for pattern in patterns
7687
for match in pattern.findall(text)
7788
}
78-
if any(is_repo_issue(number) for number in sorted(candidates)):
89+
if any(is_open_repo_issue(number) for number in sorted(candidates)):
7990
sys.exit(0)
8091
8192
if (pr.get("state") or "").upper() == "CLOSED":
8293
sys.exit(0)
8394
8495
comment = f"""Thanks for the pull request.
8596
86-
First-time contributors need an associated issue before we can review a PR.
97+
First-time contributors need an associated open issue before we can review a PR.
8798
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`.
99+
1. Open an issue with a [template](https://github.com/{repo}/issues/new/choose), or pick an existing open one.
100+
2. Open a new PR (or reopen this one) whose title or body mentions that issue, for example `#123`.
90101
91102
See the [contributing guide](https://github.com/{repo}/blob/main/.github/CONTRIBUTING.md).
92103
"""
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Regression tests for the first-time contributor issue-gate workflow."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
from pathlib import Path
8+
from unittest import mock
9+
10+
import pytest
11+
12+
WORKFLOW_PATH = Path(__file__).resolve().parents[4] / (
13+
".github/workflows/ftc-require-issue.yml"
14+
)
15+
16+
17+
def _embedded_python() -> str:
18+
text = WORKFLOW_PATH.read_text()
19+
start = text.index("python3 << 'PY'\n") + len("python3 << 'PY'\n")
20+
end = text.rindex("\n PY\n")
21+
indent = " "
22+
return "\n".join(
23+
line[len(indent) :] if line.startswith(indent) else line
24+
for line in text[start:end].splitlines()
25+
)
26+
27+
28+
def _run_gate(
29+
*,
30+
title: str,
31+
body: str,
32+
issue_payloads: dict[int, dict],
33+
expect_exit: int | None = 0,
34+
) -> list[list[str]]:
35+
calls: list[list[str]] = []
36+
37+
def fake_check_output(args: list[str], **_kwargs: object) -> str:
38+
if args[:2] == ["gh", "pr"] and "view" in args:
39+
return json.dumps({"title": title, "body": body, "state": "OPEN"})
40+
raise AssertionError(f"unexpected check_output: {args}")
41+
42+
def fake_run(args: list[str], **_kwargs: object) -> mock.Mock:
43+
calls.append(list(args))
44+
result = mock.Mock()
45+
result.returncode = 0
46+
result.stderr = ""
47+
result.stdout = ""
48+
if args[:2] == ["gh", "api"] and "/issues/" in args[2]:
49+
number = int(args[2].rsplit("/", 1)[-1])
50+
result.stdout = json.dumps(issue_payloads[number])
51+
return result
52+
53+
env = {
54+
"REPO": "crewAIInc/crewAI",
55+
"PR_NUMBER": "99",
56+
"AUTHOR_ASSOCIATION": "FIRST_TIME_CONTRIBUTOR",
57+
}
58+
with (
59+
mock.patch.dict(os.environ, env, clear=False),
60+
mock.patch("subprocess.check_output", side_effect=fake_check_output),
61+
mock.patch("subprocess.run", side_effect=fake_run),
62+
):
63+
compiled = compile(_embedded_python(), "<workflow>", "exec")
64+
if expect_exit is None:
65+
exec(compiled, {}) # noqa: S102
66+
else:
67+
with pytest.raises(SystemExit) as exited:
68+
exec(compiled, {}) # noqa: S102
69+
assert exited.value.code == expect_exit
70+
return calls
71+
72+
73+
@pytest.mark.parametrize(
74+
"body",
75+
[
76+
"Related to #123",
77+
"crewAIInc/crewAI#123",
78+
"https://github.com/crewAIInc/crewAI/issues/123",
79+
],
80+
)
81+
def test_open_issue_mention_blocks_close(body: str) -> None:
82+
calls = _run_gate(
83+
title="feat: example",
84+
body=body,
85+
issue_payloads={123: {"state": "open"}},
86+
)
87+
88+
assert not any(call[:3] == ["gh", "pr", "close"] for call in calls)
89+
assert any(call[:2] == ["gh", "api"] and call[2].endswith("/issues/123") for call in calls)
90+
91+
92+
def test_foreign_repo_reference_closes_pr() -> None:
93+
calls = _run_gate(
94+
title="feat: example",
95+
body="other/repo#123",
96+
issue_payloads={123: {"state": "open"}},
97+
expect_exit=None,
98+
)
99+
100+
assert any(call[:3] == ["gh", "pr", "close"] for call in calls)
101+
assert not any(call[:2] == ["gh", "api"] for call in calls)

0 commit comments

Comments
 (0)