From 444d48821986ad6dfe0cba735656eda2d543b563 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen <3353385+shenxianpeng@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:53:06 +0000 Subject: [PATCH 1/3] feat: check the PR description and label AI-written pull requests pr-body: true checks the pull request description against the AI rules of cchk.toml only (commit-check --ai-only). ai-label: true (or a label name) labels the pull request ai-assisted when an AI tool wrote any of it: a commit message or author, the description, or an AI agent's app that opened it. The label is only added, never removed, and a failure to add it warns without failing the step. The result output gains ai_tools. --- README.md | 8 +- action.yml | 10 ++ main.py | 168 ++++++++++++++++++++++++- main_test.py | 339 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 517 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8edc378..0207ae6 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,16 @@ runners need a few tools — see [Good to know](#good-to-know). | `author-name` | `false` | Check each commit's author name | | `author-email` | `false` | Check each commit's author email | | `pr-title` | `false` | Check the pull request title against Conventional Commits — the one that matters for squash merges. Pull request events only | +| `pr-body` | `false` | Check the pull request description against the AI rules of `cchk.toml` (`ai_attribution`) only. Pull request events only | +| `ai-label` | `false` | Label the pull request when an AI tool wrote any of it (a commit, the description, or an agent's app that opened it): `true` for `ai-assisted`, or the label's name. Labels are only added, never removed. Needs `pull-requests: write`; skipped on fork pull requests | | `pr-comments` | `false` | Post the report as a pull request comment, edited in place on later runs. Needs `pull-requests: write`; skipped on fork pull requests | | `job-summary` | `true` | Write the report to the job summary | | `dry-run` | `false` | Report failures as warnings and always exit 0 | > [!TIP] -> `pull_request` does not fire when a title is edited. With `pr-title: true`, -> add `types: [opened, synchronize, reopened, edited]` to re-check a renamed -> pull request. +> `pull_request` does not fire when a title or description is edited. With +> `pr-title` or `pr-body`, add `types: [opened, synchronize, reopened, edited]` +> to re-check an edited pull request. ## Configuration diff --git a/action.yml b/action.yml index f0e489c..edfae12 100644 --- a/action.yml +++ b/action.yml @@ -37,6 +37,14 @@ inputs: description: check pull request title following conventional commits required: false default: false + pr-body: + description: check the pull request description against the AI attribution rules (ai_attribution in cchk.toml) + required: false + default: false + ai-label: + description: label the pull request when an AI tool wrote any of it; true for the label ai-assisted, or the label's name + required: false + default: false outputs: result: description: Structured check results as JSON (status pass/warn/skip/fail + per-scope label, sha and checks). Consume with fromJSON(steps..outputs.result). @@ -110,4 +118,6 @@ runs: JOB_SUMMARY: ${{ inputs.job-summary }} PR_COMMENTS: ${{ inputs.pr-comments }} PR_TITLE: ${{ inputs.pr-title }} + PR_BODY: ${{ inputs.pr-body }} + AI_LABEL: ${{ inputs.ai-label }} GITHUB_TOKEN: ${{ github.token }} diff --git a/main.py b/main.py index a63907c..f1645e3 100755 --- a/main.py +++ b/main.py @@ -87,6 +87,38 @@ def _reconfigure_io() -> None: JOB_SUMMARY_ENABLED = env_flag("JOB_SUMMARY") PR_COMMENTS_ENABLED = env_flag("PR_COMMENTS") PR_TITLE_ENABLED = env_flag("PR_TITLE") +PR_BODY_ENABLED = env_flag("PR_BODY") + +#: The label ``ai-label: true`` puts on a pull request that an AI tool wrote. +DEFAULT_AI_LABEL = "ai-assisted" +#: The colour a label gets when the Action has to create it. +AI_LABEL_COLOR = "8250df" + + +def ai_label_name() -> str | None: + """The label to put on an AI-written pull request, or ``None`` for none. + + ``ai-label: true`` means the default label; any other value but + ``false`` is the label's own name. + """ + value = os.getenv("AI_LABEL", "false").strip() + if not value or value.lower() == "false": + return None + return DEFAULT_AI_LABEL if value.lower() == "true" else value + + +#: AI coding agents that open pull requests from their own GitHub App, by +#: login, with the tool each one is. The same list as the GitHub App's. +AI_AGENT_BOTS = { + "amazon-q-developer[bot]": "Amazon Q Developer", + "chatgpt-codex-connector[bot]": "OpenAI Codex", + "claude[bot]": "Claude Code", + "copilot-swe-agent[bot]": "GitHub Copilot", + "cursor[bot]": "Cursor", + "devin-ai-integration[bot]": "Devin", + "factory-droid[bot]": "Factory Droid", + "google-labs-jules[bot]": "Jules", +} @dataclass @@ -109,6 +141,8 @@ class ScopeResult: checks: list[dict[str, str]] = field(default_factory=list) raw_text: str = "" sha: str = "" + #: The AI tools the CLI found in what this scope checked (``ai_tools``). + ai_tools: list[str] = field(default_factory=list) @property def display_label(self) -> str: @@ -220,6 +254,8 @@ def log_env_vars(): "JOB_SUMMARY", "PR_COMMENTS", "PR_TITLE", + "PR_BODY", + "AI_LABEL", ): value = os.getenv(name, "false") print(f"::debug::{name}={value}") @@ -412,6 +448,26 @@ def get_pr_title() -> str | None: return None +def get_pr_body() -> str | None: + """Read the pull request description from the event payload. + + GitHub sends ``null`` for a pull request opened with no description. + """ + body = get_pr_event().get("body") + return body if isinstance(body, str) and body.strip() else None + + +def get_pr_opener_tool() -> str | None: + """The AI tool that opened the pull request from its own app, if any.""" + login = (get_pr_event().get("user") or {}).get("login") or "" + return AI_AGENT_BOTS.get(str(login).lower()) + + +#: The AI tools found in the pull request description when it was read for +#: the label only (``ai-label`` without ``pr-body``), so it is no scope. +PR_BODY_AI_TOOLS: list[str] = [] + + #: One commit to check: ``(full sha, message)``. Commit = tuple[str, str] @@ -663,7 +719,13 @@ def check_scope( """ _rc, data, raw = run_check_json(args, input_text=input_text, env=env) if isinstance(data, dict): - return ScopeResult(label=label, checks=data.get("checks", []), sha=sha) + tools = data.get("ai_tools") + return ScopeResult( + label=label, + checks=data.get("checks", []), + sha=sha, + ai_tools=[str(t) for t in tools] if isinstance(tools, list) else [], + ) return ScopeResult(label=label, raw_text=raw, sha=sha) @@ -727,8 +789,11 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: Checks are evaluated in order: 1. PR title (when ``pr-title: true`` and in a PR event) - 2. Individual PR commit messages (when ``message: true`` and in a PR event) - 3. All remaining checks (branch, author name/email, etc.) + 2. PR description, AI rules only (when ``pr-body: true`` and in a PR + event; with only ``ai-label`` set it is read for the label, and no + scope is reported) + 3. Individual PR commit messages (when ``message: true`` and in a PR event) + 4. All remaining checks (branch, author name/email, etc.) Outside of a PR event all enabled checks are handed to the CLI at once. """ @@ -741,7 +806,17 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: if pr_title: results.append(check_scope("PR title", ["--message"], input_text=pr_title)) - # ---- 2. Commit message checks ----------------------------------------- + # ---- 2. PR description ------------------------------------------------ + if (PR_BODY_ENABLED or ai_label_name()) and is_pr_event(): + pr_body = get_pr_body() + if pr_body: + scope = check_scope("PR description", ["--ai-only"], input_text=pr_body) + if PR_BODY_ENABLED: + results.append(scope) + else: + PR_BODY_AI_TOOLS[:] = scope.ai_tools + + # ---- 3. Commit message checks ----------------------------------------- if MESSAGE_ENABLED: pr_commits = get_pr_commit_messages() if pr_commits: @@ -757,7 +832,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: "Could not list the pull request's commits", "only HEAD was checked" ) - # ---- 3. Remaining checks (branch, author, etc.) ----------------------- + # ---- 4. Remaining checks (branch, author, etc.) ----------------------- # Outside a PR, check the HEAD commit message directly. if "--message" in args: results.append(check_scope("Commit message", ["--message"], sha=head_sha())) @@ -1560,6 +1635,7 @@ def set_result_output(results: list[ScopeResult]) -> None: return payload = { "status": overall_status(results), + "ai_tools": ai_tools_found(results), "scopes": [ { "label": scope.label, @@ -1782,6 +1858,87 @@ def add_pr_comments(results: list[ScopeResult]) -> int: return 0 +def ai_tools_found(results: list[ScopeResult]) -> list[str]: + """Every AI tool found in the pull request, sorted. + + In the checked commits (messages and authors), the title, the + description, and the app that opened the pull request. + """ + tools = {tool for scope in results for tool in scope.ai_tools} + tools.update(PR_BODY_AI_TOOLS) + if is_pr_event(): + opener = get_pr_opener_tool() + if opener: + tools.add(opener) + return sorted(tools) + + +def add_ai_label(results: list[ScopeResult]) -> None: + """Label the pull request when an AI tool wrote any of it (``ai-label``). + + The label is only ever added, never removed: a maintainer may have put + it there by hand. Best-effort, like the comment: a failure warns and + never fails the step. + """ + name = ai_label_name() + if not name or not is_pr_event(): + return + tools = ai_tools_found(results) + if not tools: + print("No AI tool found in the pull request; no label added.") + return + found = ", ".join(tools) + if is_fork_pr_with_readonly_token(): + print( + f"::warning::AI tools found ({found}), but the label '{name}' was " + "not added: pull requests from forked repositories get a read-only " + "GITHUB_TOKEN under the pull_request event. " + "See https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md" + ) + return + + try: + from github import Auth, Github, GithubException # type: ignore + except ImportError as e: + print(f"::warning::Unable to add the AI label: {e}", file=sys.stderr) + return + + try: + token = os.getenv("GITHUB_TOKEN") + repo_name = os.getenv("GITHUB_REPOSITORY") + if not token: + raise ValueError("GITHUB_TOKEN is not set") + if not repo_name: + raise ValueError("GITHUB_REPOSITORY is not set") + pr_number = get_pr_number() + repo = Github(auth=Auth.Token(token)).get_repo(repo_name) + issue = repo.get_issue(pr_number) + if any(label.name == name for label in issue.labels): + print(f"PR #{pr_number} already has the label '{name}' ({found}).") + return + try: + repo.get_label(name) + except GithubException as e: + if e.status != 404: + raise + repo.create_label( + name, AI_LABEL_COLOR, "Written with the help of an AI tool" + ) + issue.add_to_labels(name) + print(f"Added the label '{name}' to PR #{pr_number}: {found}.") + except GithubException as e: + hint = ( + " Ensure your workflow grants 'pull-requests: write' permission." + if e.status == 403 + else "" + ) + print( + f"::warning::Unable to add the AI label.{hint} Error: {e}", file=sys.stderr + ) + except Exception as e: # noqa: BLE001 - the label is best-effort + print(f"::warning::Unable to add the AI label: {e}", file=sys.stderr) + + def log_error_and_exit(ret_code: int) -> None: """Exit with the given code. @@ -1806,6 +1963,7 @@ def main(): set_result_output(results) ret_code = max(ret_code, add_job_summary(results), add_pr_comments(results)) + add_ai_label(results) if DRY_RUN_ENABLED: ret_code = 0 diff --git a/main_test.py b/main_test.py index 2c3280a..d70957d 100644 --- a/main_test.py +++ b/main_test.py @@ -153,6 +153,8 @@ def test_every_input_is_logged_as_a_debug_command(self): "::debug::JOB_SUMMARY=false", "::debug::PR_COMMENTS=false", "::debug::PR_TITLE=false", + "::debug::PR_BODY=false", + "::debug::AI_LABEL=false", ], ) @@ -3613,5 +3615,342 @@ def test_unparsable_output_keeps_both_streams(self): self.assertIn("Expected ']'", scope.raw_text) +def _pr_event(pull_request: dict) -> str: + """Write a pull_request event payload and return its path.""" + path = os.path.join(tempfile.mkdtemp(), "event.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"pull_request": pull_request}, f) + return path + + +class TestAiLabelName(unittest.TestCase): + def test_values(self): + cases = { + "false": None, + "FALSE": None, + "": None, + " ": None, + "true": "ai-assisted", + "True": "ai-assisted", + "AI generated": "AI generated", + " needs-ai-review ": "needs-ai-review", + } + for value, expected in cases.items(): + with self.subTest(value=value), patch.dict(os.environ, {"AI_LABEL": value}): + self.assertEqual(main.ai_label_name(), expected) + + def test_unset_is_off(self): + with patch.dict(os.environ, {}, clear=True): + self.assertIsNone(main.ai_label_name()) + + +class TestPrBodyAndOpener(unittest.TestCase): + def _env(self, pull_request: dict): + return patch.dict( + os.environ, + { + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_EVENT_PATH": _pr_event(pull_request), + }, + ) + + def test_body(self): + with self._env({"body": "Adds a parser."}): + self.assertEqual(main.get_pr_body(), "Adds a parser.") + + def test_no_description_is_none(self): + for body in (None, "", " \n "): + with self.subTest(body=body), self._env({"body": body}): + self.assertIsNone(main.get_pr_body()) + + def test_an_agent_app_opened_it(self): + with self._env({"user": {"login": "Copilot-SWE-Agent[bot]"}}): + self.assertEqual(main.get_pr_opener_tool(), "GitHub Copilot") + + def test_a_person_or_another_bot_opened_it(self): + for user in ({"login": "octocat"}, {"login": "dependabot[bot]"}, None, {}): + with self.subTest(user=user), self._env({"user": user}): + self.assertIsNone(main.get_pr_opener_tool()) + + +class TestCheckScopeAiTools(unittest.TestCase): + def _scope(self, payload): + result = MagicMock(returncode=0, stdout=json.dumps(payload), stderr="") + with patch("main.subprocess.run", return_value=result): + return main.check_scope("Commit 1/1", ["--message"]) + + def test_reads_the_tools(self): + scope = self._scope({"checks": [], "ai_tools": ["Claude Code"]}) + self.assertEqual(scope.ai_tools, ["Claude Code"]) + + def test_an_older_cli_reports_none(self): + self.assertEqual(self._scope({"checks": []}).ai_tools, []) + self.assertEqual(self._scope({"checks": [], "ai_tools": "x"}).ai_tools, []) + + +class TestRunCommitCheckPrBody(unittest.TestCase): + def setUp(self): + main.PR_BODY_AI_TOOLS.clear() + self.addCleanup(main.PR_BODY_AI_TOOLS.clear) + + def _run(self, pr_body_enabled, ai_label, body="Adds a parser."): + body_scope = main.ScopeResult( + label="PR description", checks=[], ai_tools=["OpenAI Codex"] + ) + with ( + patch("main.PR_TITLE_ENABLED", False), + patch("main.PR_BODY_ENABLED", pr_body_enabled), + patch.dict(os.environ, {"AI_LABEL": ai_label}), + patch("main.MESSAGE_ENABLED", False), + patch("main.BRANCH_ENABLED", False), + patch("main.AUTHOR_NAME_ENABLED", False), + patch("main.AUTHOR_EMAIL_ENABLED", False), + patch("main.is_pr_event", return_value=True), + patch("main.get_pr_body", return_value=body), + patch("main.check_scope", return_value=body_scope) as mock_scope, + patch("main.run_other_checks", return_value=[]), + ): + _rc, results = main.run_commit_check() + return results, mock_scope + + def test_pr_body_is_a_scope_checked_with_the_ai_rules_only(self): + results, mock_scope = self._run(True, "false") + mock_scope.assert_called_once_with( + "PR description", ["--ai-only"], input_text="Adds a parser." + ) + self.assertEqual([r.label for r in results], ["PR description"]) + + def test_ai_label_alone_reads_it_but_reports_no_scope(self): + results, mock_scope = self._run(False, "true") + mock_scope.assert_called_once() + self.assertEqual(results, []) + self.assertEqual(main.PR_BODY_AI_TOOLS, ["OpenAI Codex"]) + + def test_off_by_default(self): + results, mock_scope = self._run(False, "false") + mock_scope.assert_not_called() + self.assertEqual(results, []) + + def test_no_description_nothing_to_check(self): + results, mock_scope = self._run(True, "true", body=None) + mock_scope.assert_not_called() + self.assertEqual(results, []) + + +class TestAiToolsFound(unittest.TestCase): + def setUp(self): + main.PR_BODY_AI_TOOLS.clear() + self.addCleanup(main.PR_BODY_AI_TOOLS.clear) + + def test_union_of_scopes_description_and_opener(self): + main.PR_BODY_AI_TOOLS[:] = ["OpenAI Codex"] + results = [ + main.ScopeResult(label="Commit 1/2", ai_tools=["Claude Code"]), + main.ScopeResult(label="Commit 2/2", ai_tools=["Claude Code", "Cursor"]), + ] + with ( + patch("main.is_pr_event", return_value=True), + patch("main.get_pr_opener_tool", return_value="Devin"), + ): + found = main.ai_tools_found(results) + self.assertEqual(found, ["Claude Code", "Cursor", "Devin", "OpenAI Codex"]) + + def test_outside_a_pull_request_only_the_scopes(self): + results = [main.ScopeResult(label="Commit message", ai_tools=["Aider"])] + with ( + patch("main.is_pr_event", return_value=False), + patch("main.get_pr_opener_tool") as opener, + ): + self.assertEqual(main.ai_tools_found(results), ["Aider"]) + opener.assert_not_called() + + def test_result_output_carries_them(self): + output_path = os.path.join(tempfile.mkdtemp(), "output.txt") + results = [main.ScopeResult(label="Commit 1/1", ai_tools=["Claude Code"])] + with ( + patch.dict(os.environ, {"GITHUB_OUTPUT": output_path}), + patch("main.is_pr_event", return_value=False), + ): + main.set_result_output(results) + payload = json.loads(read_github_output(output_path)["result"]) + self.assertEqual(payload["ai_tools"], ["Claude Code"]) + + +class TestAddAiLabel(unittest.TestCase): + """``ai-label``: added when an AI tool wrote any of the PR, never removed.""" + + AI_SCOPE = main.ScopeResult(label="Commit 1/1", ai_tools=["Claude Code"]) + + def setUp(self): + main.PR_BODY_AI_TOOLS.clear() + + def _run(self, results, label="true", labels=(), get_label=None, add=None): + issue = MagicMock() + issue.labels = [MagicMock() for _ in labels] + for mock_label, name in zip(issue.labels, labels): + mock_label.name = name + if add is not None: + issue.add_to_labels.side_effect = add + repo = MagicMock() + repo.get_issue.return_value = issue + if get_label is not None: + repo.get_label.side_effect = get_label + github_module = MagicMock() + github_module.GithubException = _StubGithubException + github_module.Github.return_value.get_repo.return_value = repo + with ( + patch.dict( + os.environ, + { + "AI_LABEL": label, + "GITHUB_TOKEN": "token", + "GITHUB_REPOSITORY": "owner/repo", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_REF": "refs/pull/12/merge", + }, + ), + patch("main.is_fork_pr_with_readonly_token", return_value=False), + patch("main.get_pr_opener_tool", return_value=None), + patch.dict(sys.modules, {"github": github_module}), + patch("builtins.print") as mock_print, + ): + main.add_ai_label(results) + printed = [ + c[0][0] + for c in mock_print.call_args_list + if c[0] and isinstance(c[0][0], str) + ] + return repo, issue, printed, github_module + + def test_adds_the_default_label(self): + repo, issue, printed, _ = self._run([self.AI_SCOPE]) + issue.add_to_labels.assert_called_once_with("ai-assisted") + repo.create_label.assert_not_called() + self.assertIn("Added the label 'ai-assisted' to PR #12: Claude Code.", printed) + + def test_a_custom_label(self): + _repo, issue, _printed, _ = self._run([self.AI_SCOPE], label="AI generated") + issue.add_to_labels.assert_called_once_with("AI generated") + + def test_creates_a_missing_label(self): + repo, issue, _printed, _ = self._run( + [self.AI_SCOPE], get_label=_StubGithubException(404) + ) + repo.create_label.assert_called_once_with( + "ai-assisted", main.AI_LABEL_COLOR, "Written with the help of an AI tool" + ) + issue.add_to_labels.assert_called_once_with("ai-assisted") + + def test_already_labelled(self): + _repo, issue, printed, _ = self._run([self.AI_SCOPE], labels=("ai-assisted",)) + issue.add_to_labels.assert_not_called() + self.assertTrue(any("already has the label" in p for p in printed)) + + def test_no_ai_no_label_and_no_api_call(self): + _repo, _issue, printed, github_module = self._run( + [main.ScopeResult(label="Commit 1/1")] + ) + github_module.Github.assert_not_called() + self.assertIn("No AI tool found in the pull request; no label added.", printed) + + def test_off_or_outside_a_pull_request_does_nothing(self): + _repo, _issue, printed, github_module = self._run( + [self.AI_SCOPE], label="false" + ) + github_module.Github.assert_not_called() + self.assertEqual(printed, []) + with ( + patch.dict(os.environ, {"AI_LABEL": "true", "GITHUB_EVENT_NAME": "push"}), + patch("builtins.print") as mock_print, + ): + main.add_ai_label([self.AI_SCOPE]) + mock_print.assert_not_called() + + def test_forbidden_names_the_permission(self): + _repo, _issue, printed, _ = self._run( + [self.AI_SCOPE], add=_StubGithubException(403) + ) + warning = next(p for p in printed if "::warning::" in p) + self.assertIn("pull-requests: write", warning) + + def test_other_api_errors_warn(self): + _repo, _issue, printed, _ = self._run( + [self.AI_SCOPE], get_label=_StubGithubException(500) + ) + warning = next(p for p in printed if "::warning::" in p) + self.assertNotIn("pull-requests: write", warning) + + def test_any_other_error_warns(self): + _repo, _issue, printed, _ = self._run([self.AI_SCOPE], add=RuntimeError("boom")) + self.assertIn("::warning::Unable to add the AI label: boom", printed) + + def test_missing_token_warns(self): + with ( + patch.dict( + os.environ, + {"AI_LABEL": "true", "GITHUB_EVENT_NAME": "pull_request"}, + clear=True, + ), + patch("main.is_fork_pr_with_readonly_token", return_value=False), + patch("main.get_pr_opener_tool", return_value=None), + patch.dict( + sys.modules, + {"github": MagicMock(GithubException=_StubGithubException)}, + ), + patch("builtins.print") as mock_print, + ): + main.add_ai_label([self.AI_SCOPE]) + self.assertIn("GITHUB_TOKEN is not set", mock_print.call_args[0][0]) + + def test_missing_repository_warns(self): + with ( + patch.dict( + os.environ, + { + "AI_LABEL": "true", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_TOKEN": "t", + }, + clear=True, + ), + patch("main.is_fork_pr_with_readonly_token", return_value=False), + patch("main.get_pr_opener_tool", return_value=None), + patch.dict( + sys.modules, + {"github": MagicMock(GithubException=_StubGithubException)}, + ), + patch("builtins.print") as mock_print, + ): + main.add_ai_label([self.AI_SCOPE]) + self.assertIn("GITHUB_REPOSITORY is not set", mock_print.call_args[0][0]) + + def test_fork_with_a_read_only_token_warns(self): + with ( + patch.dict( + os.environ, {"AI_LABEL": "true", "GITHUB_EVENT_NAME": "pull_request"} + ), + patch("main.is_fork_pr_with_readonly_token", return_value=True), + patch("main.get_pr_opener_tool", return_value=None), + patch("builtins.print") as mock_print, + ): + main.add_ai_label([self.AI_SCOPE]) + warning = mock_print.call_args[0][0] + self.assertIn("::warning::AI tools found (Claude Code)", warning) + self.assertIn("read-only", warning) + + def test_a_missing_pygithub_warns(self): + with ( + patch.dict( + os.environ, {"AI_LABEL": "true", "GITHUB_EVENT_NAME": "pull_request"} + ), + patch("main.is_fork_pr_with_readonly_token", return_value=False), + patch("main.get_pr_opener_tool", return_value=None), + patch.dict(sys.modules, {"github": None}), + patch("builtins.print") as mock_print, + ): + main.add_ai_label([self.AI_SCOPE]) + self.assertIn("Unable to add the AI label", mock_print.call_args[0][0]) + + if __name__ == "__main__": unittest.main() From f074938608bf8a6101b3e4b4bffeb11ed91207df Mon Sep 17 00:00:00 2001 From: Xianpeng Shen <3353385+shenxianpeng@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:21:27 +0000 Subject: [PATCH 2/3] chore: install commit-check 2.20.1 for the PR description check --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 08754b0..30e0a9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Install commit-check CLI # For details please see: https://github.com/commit-check/commit-check -commit-check==2.20.0 +commit-check==2.20.1 # Interact with the GitHub API. PyGithub==2.10.0 From d2daa87a93cf6f4b9236a7a209af00d65be62fbe Mon Sep 17 00:00:00 2001 From: Xianpeng Shen <3353385+shenxianpeng@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:26:00 +0000 Subject: [PATCH 3/3] fix: label on GitHub Enterprise and when another run creates the label first --- README.md | 2 +- main.py | 14 ++++++++++---- main_test.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0207ae6..ae941c5 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ runners need a few tools — see [Good to know](#good-to-know). | `author-email` | `false` | Check each commit's author email | | `pr-title` | `false` | Check the pull request title against Conventional Commits — the one that matters for squash merges. Pull request events only | | `pr-body` | `false` | Check the pull request description against the AI rules of `cchk.toml` (`ai_attribution`) only. Pull request events only | -| `ai-label` | `false` | Label the pull request when an AI tool wrote any of it (a commit, the description, or an agent's app that opened it): `true` for `ai-assisted`, or the label's name. Labels are only added, never removed. Needs `pull-requests: write`; skipped on fork pull requests | +| `ai-label` | `false` | Label the pull request when an AI tool wrote any of it (a commit, the description, or an agent's app that opened it): `true` for `ai-assisted`, or the label's name. Labels are only added, never removed. Needs `pull-requests: write`; skipped on fork pull requests under `pull_request` | | `pr-comments` | `false` | Post the report as a pull request comment, edited in place on later runs. Needs `pull-requests: write`; skipped on fork pull requests | | `job-summary` | `true` | Write the report to the job summary | | `dry-run` | `false` | Report failures as warnings and always exit 0 | diff --git a/main.py b/main.py index f1645e3..165c6f3 100755 --- a/main.py +++ b/main.py @@ -1911,7 +1911,8 @@ def add_ai_label(results: list[ScopeResult]) -> None: if not repo_name: raise ValueError("GITHUB_REPOSITORY is not set") pr_number = get_pr_number() - repo = Github(auth=Auth.Token(token)).get_repo(repo_name) + api_url = os.getenv("GITHUB_API_URL", "https://api.github.com") + repo = Github(auth=Auth.Token(token), base_url=api_url).get_repo(repo_name) issue = repo.get_issue(pr_number) if any(label.name == name for label in issue.labels): print(f"PR #{pr_number} already has the label '{name}' ({found}).") @@ -1921,9 +1922,14 @@ def add_ai_label(results: list[ScopeResult]) -> None: except GithubException as e: if e.status != 404: raise - repo.create_label( - name, AI_LABEL_COLOR, "Written with the help of an AI tool" - ) + try: + repo.create_label( + name, AI_LABEL_COLOR, "Written with the help of an AI tool" + ) + except GithubException as created: + # 422: another run created it in the meantime; use that one. + if created.status != 422: + raise issue.add_to_labels(name) print(f"Added the label '{name}' to PR #{pr_number}: {found}.") except GithubException as e: diff --git a/main_test.py b/main_test.py index d70957d..7a9fead 100644 --- a/main_test.py +++ b/main_test.py @@ -3784,7 +3784,9 @@ class TestAddAiLabel(unittest.TestCase): def setUp(self): main.PR_BODY_AI_TOOLS.clear() - def _run(self, results, label="true", labels=(), get_label=None, add=None): + def _run( + self, results, label="true", labels=(), get_label=None, add=None, create=None + ): issue = MagicMock() issue.labels = [MagicMock() for _ in labels] for mock_label, name in zip(issue.labels, labels): @@ -3795,6 +3797,8 @@ def _run(self, results, label="true", labels=(), get_label=None, add=None): repo.get_issue.return_value = issue if get_label is not None: repo.get_label.side_effect = get_label + if create is not None: + repo.create_label.side_effect = create github_module = MagicMock() github_module.GithubException = _StubGithubException github_module.Github.return_value.get_repo.return_value = repo @@ -3807,6 +3811,7 @@ def _run(self, results, label="true", labels=(), get_label=None, add=None): "GITHUB_REPOSITORY": "owner/repo", "GITHUB_EVENT_NAME": "pull_request", "GITHUB_REF": "refs/pull/12/merge", + "GITHUB_API_URL": "https://ghe.example.com/api/v3", }, ), patch("main.is_fork_pr_with_readonly_token", return_value=False), @@ -3841,6 +3846,30 @@ def test_creates_a_missing_label(self): ) issue.add_to_labels.assert_called_once_with("ai-assisted") + def test_uses_the_api_url_of_the_server(self): + _repo, _issue, _printed, github_module = self._run([self.AI_SCOPE]) + self.assertEqual( + github_module.Github.call_args.kwargs["base_url"], + "https://ghe.example.com/api/v3", + ) + + def test_a_label_created_meanwhile_is_still_added(self): + _repo, issue, printed, _ = self._run( + [self.AI_SCOPE], + get_label=_StubGithubException(404), + create=_StubGithubException(422), + ) + issue.add_to_labels.assert_called_once_with("ai-assisted") + self.assertIn("Added the label 'ai-assisted' to PR #12: Claude Code.", printed) + + def test_a_label_that_cannot_be_created_is_not_added(self): + _repo, issue, _printed, _ = self._run( + [self.AI_SCOPE], + get_label=_StubGithubException(404), + create=_StubGithubException(403), + ) + issue.add_to_labels.assert_not_called() + def test_already_labelled(self): _repo, issue, printed, _ = self._run([self.AI_SCOPE], labels=("ai-assisted",)) issue.add_to_labels.assert_not_called()