From bf56bb13bd09ad5cd87e3c8d17f951982f97c176 Mon Sep 17 00:00:00 2001 From: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:48:14 +0530 Subject: [PATCH] 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. --- .github/CONTRIBUTING.md | 4 +- .github/pull_request_template.md | 5 +- .github/workflows/ftc-require-issue.yml | 37 ++++--- lib/crewai/tests/ci/test_ftc_require_issue.py | 101 ++++++++++++++++++ 4 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 lib/crewai/tests/ci/test_ftc_require_issue.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7d8c6af7e..4b0059b97 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -103,8 +103,8 @@ chore(deps): bump pydantic to 2.11 - Keep PRs focused — avoid bundling unrelated changes - PRs over 500 lines are labeled `size/XL` automatically - Title must follow the same conventional commit format -- Link related issues where applicable (`Fixes #123`, `Closes #123`, or `Resolves #123`) -- 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. +- Link related issues where applicable (`#123`, `Fixes #123`, or the issue URL) +- 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. ## Testing diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 413256877..9aada7195 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,9 +3,8 @@ Fixes # ## Summary diff --git a/.github/workflows/ftc-require-issue.yml b/.github/workflows/ftc-require-issue.yml index 4f7d7787d..2a2a842d3 100644 --- a/.github/workflows/ftc-require-issue.yml +++ b/.github/workflows/ftc-require-issue.yml @@ -14,17 +14,21 @@ concurrency: jobs: require-issue: + # Allow-list returning contributors. FIRST_TIMER / FIRST_TIME_CONTRIBUTOR + # are often NONE on pull_request_target at opened time, which skipped the + # previous deny-list and left first-timer PRs open. if: > github.event.pull_request.user.type != 'Bot' && - contains(fromJSON('["FIRST_TIME_CONTRIBUTOR","FIRST_TIMER"]'), - github.event.pull_request.author_association) + !contains(fromJSON('["MEMBER","OWNER","COLLABORATOR","CONTRIBUTOR"]'), + github.event.pull_request.author_association) runs-on: ubuntu-latest steps: - - name: Require a closing-keyword issue + - name: Require an open issue env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} + AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} run: | python3 << 'PY' import json @@ -36,13 +40,17 @@ jobs: repo = os.environ["REPO"] pr_number = os.environ["PR_NUMBER"] owner, name = repo.split("/", 1) + print( + "author_association=", + os.environ.get("AUTHOR_ASSOCIATION", ""), + sep="", + ) - keyword = r"(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)" patterns = ( - re.compile(rf"(?i)\b{keyword}\s+#(\d+)\b"), - re.compile(rf"(?i)\b{keyword}\s+{re.escape(owner)}/{re.escape(name)}#(\d+)\b"), + re.compile(r"(? bool: + def is_open_repo_issue(number: int) -> bool: result = subprocess.run( ["gh", "api", f"repos/{repo}/issues/{number}"], capture_output=True, @@ -64,7 +72,10 @@ jobs: raise RuntimeError( f"GitHub API error looking up #{number}: {stderr}" ) - return "pull_request" not in json.loads(result.stdout) + payload = json.loads(result.stdout) + if "pull_request" in payload: + return False + return (payload.get("state") or "").lower() == "open" pr = gh_json( "pr", "view", pr_number, "--repo", repo, "--json", "title,body,state" @@ -75,7 +86,7 @@ jobs: for pattern in patterns for match in pattern.findall(text) } - if any(is_repo_issue(number) for number in sorted(candidates)): + if any(is_open_repo_issue(number) for number in sorted(candidates)): sys.exit(0) if (pr.get("state") or "").upper() == "CLOSED": @@ -83,10 +94,10 @@ jobs: comment = f"""Thanks for the pull request. - First-time contributors need an associated issue before we can review a PR. + First-time contributors need an associated open issue before we can review a PR. - 1. Open an issue with a [template](https://github.com/{repo}/issues/new/choose), or pick an existing one. - 2. Open a new PR (or reopen this one) whose title or body includes a closing keyword, for example `Fixes #123`. + 1. Open an issue with a [template](https://github.com/{repo}/issues/new/choose), or pick an existing open one. + 2. Open a new PR (or reopen this one) whose title or body mentions that issue, for example `#123`. See the [contributing guide](https://github.com/{repo}/blob/main/.github/CONTRIBUTING.md). """ diff --git a/lib/crewai/tests/ci/test_ftc_require_issue.py b/lib/crewai/tests/ci/test_ftc_require_issue.py new file mode 100644 index 000000000..2f166b1f3 --- /dev/null +++ b/lib/crewai/tests/ci/test_ftc_require_issue.py @@ -0,0 +1,101 @@ +"""Regression tests for the first-time contributor issue-gate workflow.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest import mock + +import pytest + +WORKFLOW_PATH = Path(__file__).resolve().parents[4] / ( + ".github/workflows/ftc-require-issue.yml" +) + + +def _embedded_python() -> str: + text = WORKFLOW_PATH.read_text() + start = text.index("python3 << 'PY'\n") + len("python3 << 'PY'\n") + end = text.rindex("\n PY\n") + indent = " " + return "\n".join( + line[len(indent) :] if line.startswith(indent) else line + for line in text[start:end].splitlines() + ) + + +def _run_gate( + *, + title: str, + body: str, + issue_payloads: dict[int, dict], + expect_exit: int | None = 0, +) -> list[list[str]]: + calls: list[list[str]] = [] + + def fake_check_output(args: list[str], **_kwargs: object) -> str: + if args[:2] == ["gh", "pr"] and "view" in args: + return json.dumps({"title": title, "body": body, "state": "OPEN"}) + raise AssertionError(f"unexpected check_output: {args}") + + def fake_run(args: list[str], **_kwargs: object) -> mock.Mock: + calls.append(list(args)) + result = mock.Mock() + result.returncode = 0 + result.stderr = "" + result.stdout = "" + if args[:2] == ["gh", "api"] and "/issues/" in args[2]: + number = int(args[2].rsplit("/", 1)[-1]) + result.stdout = json.dumps(issue_payloads[number]) + return result + + env = { + "REPO": "crewAIInc/crewAI", + "PR_NUMBER": "99", + "AUTHOR_ASSOCIATION": "FIRST_TIME_CONTRIBUTOR", + } + with ( + mock.patch.dict(os.environ, env, clear=False), + mock.patch("subprocess.check_output", side_effect=fake_check_output), + mock.patch("subprocess.run", side_effect=fake_run), + ): + compiled = compile(_embedded_python(), "", "exec") + if expect_exit is None: + exec(compiled, {}) # noqa: S102 + else: + with pytest.raises(SystemExit) as exited: + exec(compiled, {}) # noqa: S102 + assert exited.value.code == expect_exit + return calls + + +@pytest.mark.parametrize( + "body", + [ + "Related to #123", + "crewAIInc/crewAI#123", + "https://github.com/crewAIInc/crewAI/issues/123", + ], +) +def test_open_issue_mention_blocks_close(body: str) -> None: + calls = _run_gate( + title="feat: example", + body=body, + issue_payloads={123: {"state": "open"}}, + ) + + assert not any(call[:3] == ["gh", "pr", "close"] for call in calls) + assert any(call[:2] == ["gh", "api"] and call[2].endswith("/issues/123") for call in calls) + + +def test_foreign_repo_reference_closes_pr() -> None: + calls = _run_gate( + title="feat: example", + body="other/repo#123", + issue_payloads={123: {"state": "open"}}, + expect_exit=None, + ) + + assert any(call[:3] == ["gh", "pr", "close"] for call in calls) + assert not any(call[:2] == ["gh", "api"] for call in calls)