From 7f4ead15829256d950cc60d720469faf03b5ba70 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen <3353385+shenxianpeng@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:54:47 +0000 Subject: [PATCH 1/2] feat: add message-ai-attribution to check commits for AI attribution only Each commit is checked with commit-check --message-ai-attribution: the AI rules (CC013 to CC017) without the format rules. It takes the place of message, which defaults to true, so it needs no other input. --- README.md | 1 + action.yml | 5 +++++ main.py | 28 ++++++++++++++++++++++----- main_test.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4b1e606..2c0d41c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ 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 | +| `message-ai-attribution` | `false` | Check every commit against the AI rules of `cchk.toml` (`ai_attribution`) only, author included, without the format rules. Takes the place of `message` | | `pr-body-ai-attribution` | `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 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 | diff --git a/action.yml b/action.yml index 09f8894..a233b6a 100644 --- a/action.yml +++ b/action.yml @@ -37,6 +37,10 @@ inputs: description: check pull request title following conventional commits required: false default: false + message-ai-attribution: + description: check each commit against the AI attribution rules (ai_attribution in cchk.toml) only, without the format rules; overrides message + required: false + default: false pr-body-ai-attribution: description: check the pull request description against the AI attribution rules (ai_attribution in cchk.toml) required: false @@ -118,6 +122,7 @@ runs: JOB_SUMMARY: ${{ inputs.job-summary }} PR_COMMENTS: ${{ inputs.pr-comments }} PR_TITLE: ${{ inputs.pr-title }} + MESSAGE_AI_ATTRIBUTION: ${{ inputs.message-ai-attribution }} PR_BODY_AI_ATTRIBUTION: ${{ inputs.pr-body-ai-attribution }} AI_LABEL: ${{ inputs.ai-label }} GITHUB_TOKEN: ${{ github.token }} diff --git a/main.py b/main.py index 4c49559..b778190 100755 --- a/main.py +++ b/main.py @@ -88,6 +88,20 @@ def _reconfigure_io() -> None: PR_COMMENTS_ENABLED = env_flag("PR_COMMENTS") PR_TITLE_ENABLED = env_flag("PR_TITLE") PR_BODY_AI_ATTRIBUTION_ENABLED = env_flag("PR_BODY_AI_ATTRIBUTION") +MESSAGE_AI_ATTRIBUTION_ENABLED = env_flag("MESSAGE_AI_ATTRIBUTION") + + +def message_check_args() -> list[str]: + """The CLI flag each commit message is checked with. + + ``message-ai-attribution: true`` narrows the check to the AI rules (CC013 + to CC017), whatever ``message`` says: ``message`` defaults to true, so + asking for the AI rules alone must not need it turned off as well. + """ + if MESSAGE_AI_ATTRIBUTION_ENABLED: + return ["--message-ai-attribution"] + return ["--message"] + #: The label ``ai-label: true`` puts on a pull request that an AI tool wrote. DEFAULT_AI_LABEL = "ai-assisted" @@ -255,6 +269,7 @@ def log_env_vars(): "PR_COMMENTS", "PR_TITLE", "PR_BODY_AI_ATTRIBUTION", + "MESSAGE_AI_ATTRIBUTION", "AI_LABEL", ): value = os.getenv(name, "false") @@ -790,7 +805,7 @@ def run_pr_message_checks(pr_commits: list[Commit]) -> list[ScopeResult]: names = ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL") env = {key: value for key, value in zip(names, author) if value} or None results.append( - check_scope(label, ["--message"], input_text=msg, sha=sha, env=env) + check_scope(label, message_check_args(), input_text=msg, sha=sha, env=env) ) return results @@ -817,7 +832,7 @@ def run_other_checks(args: list[str], rev: str | None = None) -> list[ScopeResul def build_check_args() -> list[str]: """Map enabled validation switches to commit-check CLI arguments.""" flags = [ - ("--message", MESSAGE_ENABLED), + ("--message", MESSAGE_ENABLED or MESSAGE_AI_ATTRIBUTION_ENABLED), ("--branch", BRANCH_ENABLED), ("--author-name", AUTHOR_NAME_ENABLED), ("--author-email", AUTHOR_EMAIL_ENABLED), @@ -833,7 +848,8 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: 2. PR description, AI rules only (when ``pr-body-ai-attribution: 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) + 3. Individual PR commit messages (when ``message: true`` and in a PR event; + AI rules only with ``message-ai-attribution: true``) 4. All remaining checks (branch, author name/email, etc.) Outside of a PR event all enabled checks are handed to the CLI at once. @@ -861,7 +877,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: PR_BODY_AI_TOOLS[:] = scope.ai_tools # ---- 3. Commit message checks ----------------------------------------- - if MESSAGE_ENABLED: + if MESSAGE_ENABLED or MESSAGE_AI_ATTRIBUTION_ENABLED: pr_commits = get_pr_commit_messages() if pr_commits: # In PR context: check each commit individually to avoid @@ -879,7 +895,9 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: # ---- 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())) + results.append( + check_scope("Commit message", message_check_args(), sha=head_sha()) + ) args = [a for a in args if a != "--message"] rev = None if is_pr_event() and any(flag in AUTHOR_FLAGS for flag in args): diff --git a/main_test.py b/main_test.py index ad060cb..b731a67 100644 --- a/main_test.py +++ b/main_test.py @@ -154,6 +154,7 @@ def test_every_input_is_logged_as_a_debug_command(self): "::debug::PR_COMMENTS=false", "::debug::PR_TITLE=false", "::debug::PR_BODY_AI_ATTRIBUTION=false", + "::debug::MESSAGE_AI_ATTRIBUTION=false", "::debug::AI_LABEL=false", ], ) @@ -421,6 +422,21 @@ def test_a_commit_in_the_clone_carries_its_own_author(self): self.assertEqual(env["GIT_AUTHOR_NAME"], "Renovate Bot") self.assertEqual(env["GIT_AUTHOR_EMAIL"], "bot@renovateapp.com") + def test_message_ai_attribution_checks_only_the_ai_rules(self): + """The commit still goes in with its author, so CC017 judges it.""" + self.local_author.return_value = ("Jane Dev", "jane@example.com") + mock_result = MagicMock(returncode=0, stdout=json_output(make_check("message"))) + with ( + patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True), + patch("main.subprocess.run", return_value=mock_result) as mock_run, + ): + main.run_pr_message_checks([(SHA_A, "WIP")]) + self.assertEqual( + mock_run.call_args[0][0], + ["commit-check", "--format", "json", "--message-ai-attribution"], + ) + self.assertEqual(mock_run.call_args[1]["env"]["GIT_AUTHOR_NAME"], "Jane Dev") + def test_a_blank_author_field_is_left_to_git(self): self.local_author.return_value = ("Renovate Bot", "") mock_result = MagicMock(returncode=0, stdout=json_output(make_check("message"))) @@ -960,6 +976,43 @@ def test_non_pr_message_check_uses_commit_message_scope(self): # HEAD is the commit that was checked, so the scope names it too. mock_scope.assert_called_once_with("Commit message", ["--message"], sha=SHA_A) + def test_message_ai_attribution_alone_checks_each_commit(self): + """With message: false the commits are still checked, for AI only.""" + with ( + patch("main.MESSAGE_ENABLED", False), + patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True), + patch("main.BRANCH_ENABLED", False), + patch("main.AUTHOR_NAME_ENABLED", False), + patch("main.AUTHOR_EMAIL_ENABLED", False), + patch("main.get_pr_commit_messages", return_value=[(SHA_A, "WIP")]), + patch("main.run_pr_message_checks", return_value=[pass_scope()]) as mock_pr, + patch("main.run_other_checks", return_value=[]) as mock_other, + ): + rc, _results = main.run_commit_check() + self.assertEqual(rc, 0) + mock_pr.assert_called_once_with([(SHA_A, "WIP")]) + mock_other.assert_called_once_with([], rev=None) + + def test_message_ai_attribution_narrows_the_head_check(self): + """Outside a PR, HEAD is checked for AI only, even with message: true.""" + with ( + patch("main.MESSAGE_ENABLED", True), + patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True), + patch("main.BRANCH_ENABLED", False), + patch("main.AUTHOR_NAME_ENABLED", False), + patch("main.AUTHOR_EMAIL_ENABLED", False), + patch("main.get_pr_commit_messages", return_value=[]), + patch("main.head_sha", return_value=SHA_A), + patch( + "main.check_scope", return_value=pass_scope("Commit message") + ) as mock_scope, + patch("main.run_other_checks", return_value=[]), + ): + main.run_commit_check() + mock_scope.assert_called_once_with( + "Commit message", ["--message-ai-attribution"], sha=SHA_A + ) + def test_message_flag_removed_before_other_checks_in_pr(self): captured_args = [] From 6830e0d4275249e8b378fa56993a096b4cb8583a Mon Sep 17 00:00:00 2001 From: Xianpeng Shen <3353385+shenxianpeng@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:04:35 +0000 Subject: [PATCH 2/2] refactor: keep only the AI rules in the Action, no new CLI flag message-ai-attribution now runs the usual --message check and keeps CC013 to CC017 from its result, so it works with the commit-check already pinned (2.20.2) and needs no --message-ai-attribution in the CLI. Under ai_attribution = "ignore" the scope reads as skipped. --- main.py | 34 ++++++++++++++++++++++------------ main_test.py | 48 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/main.py b/main.py index 2bb8b24..9b24db8 100755 --- a/main.py +++ b/main.py @@ -91,16 +91,8 @@ def _reconfigure_io() -> None: MESSAGE_AI_ATTRIBUTION_ENABLED = env_flag("MESSAGE_AI_ATTRIBUTION") -def message_check_args() -> list[str]: - """The CLI flag each commit message is checked with. - - ``message-ai-attribution: true`` narrows the check to the AI rules (CC013 - to CC017), whatever ``message`` says: ``message`` defaults to true, so - asking for the AI rules alone must not need it turned off as well. - """ - if MESSAGE_AI_ATTRIBUTION_ENABLED: - return ["--message-ai-attribution"] - return ["--message"] +#: The AI attribution rules: what ``message-ai-attribution: true`` keeps. +AI_RULE_IDS = frozenset({"CC013", "CC014", "CC015", "CC016", "CC017"}) #: The label ``ai-label: true`` puts on a pull request that an AI tool wrote. @@ -476,6 +468,22 @@ def skip_when_no_ai_rule_ran(scope: ScopeResult) -> ScopeResult: return scope +def keep_ai_rules(scope: ScopeResult) -> ScopeResult: + """Narrow a commit message scope to the AI rules (CC013 to CC017). + + ``message-ai-attribution: true`` does this whatever ``message`` says: + ``message`` defaults to true, so asking for the AI rules alone must not + need it turned off as well. The CLI runs every message rule and the + format rules' verdicts are dropped here, so the CLI needs no flag of + its own for it. A scope left with no rule, under the default + ``ai_attribution = "ignore"``, is reported as skipped. + """ + if not MESSAGE_AI_ATTRIBUTION_ENABLED or scope.raw_text: + return scope + scope.checks = [c for c in scope.checks if c.get("rule_id") in AI_RULE_IDS] + return skip_when_no_ai_rule_ran(scope) + + def get_pr_title() -> str | None: """Read PR title from GitHub event payload.""" if not is_pr_event(): @@ -834,7 +842,9 @@ def run_pr_message_checks(pr_commits: list[Commit]) -> list[ScopeResult]: names = ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL") env = {key: value for key, value in zip(names, author) if value} or None results.append( - check_scope(label, message_check_args(), input_text=msg, sha=sha, env=env) + keep_ai_rules( + check_scope(label, ["--message"], input_text=msg, sha=sha, env=env) + ) ) return results @@ -925,7 +935,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: # Outside a PR, check the HEAD commit message directly. if "--message" in args: results.append( - check_scope("Commit message", message_check_args(), sha=head_sha()) + keep_ai_rules(check_scope("Commit message", ["--message"], sha=head_sha())) ) args = [a for a in args if a != "--message"] rev = None diff --git a/main_test.py b/main_test.py index d9390f0..cf456be 100644 --- a/main_test.py +++ b/main_test.py @@ -423,19 +423,53 @@ def test_a_commit_in_the_clone_carries_its_own_author(self): self.assertEqual(env["GIT_AUTHOR_EMAIL"], "bot@renovateapp.com") def test_message_ai_attribution_checks_only_the_ai_rules(self): - """The commit still goes in with its author, so CC017 judges it.""" + """The format rules' verdicts are dropped; the commit still goes in + with its author, so CC017 judges it.""" self.local_author.return_value = ("Jane Dev", "jane@example.com") - mock_result = MagicMock(returncode=0, stdout=json_output(make_check("message"))) + output = json_output( + make_check("message", status="fail"), + make_check("ai_author", status="fail", rule_id="CC017"), + make_check("ai_co_author", rule_id="CC015"), + ) + mock_result = MagicMock(returncode=1, stdout=output) with ( patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True), patch("main.subprocess.run", return_value=mock_result) as mock_run, ): - main.run_pr_message_checks([(SHA_A, "WIP")]) + [scope] = main.run_pr_message_checks([(SHA_A, "WIP")]) self.assertEqual( - mock_run.call_args[0][0], - ["commit-check", "--format", "json", "--message-ai-attribution"], + mock_run.call_args[0][0], ["commit-check", "--format", "json", "--message"] ) self.assertEqual(mock_run.call_args[1]["env"]["GIT_AUTHOR_NAME"], "Jane Dev") + self.assertEqual([c["rule_id"] for c in scope.checks], ["CC017", "CC015"]) + self.assertEqual(scope.status, "fail") + + def test_message_ai_attribution_passes_a_badly_formatted_message(self): + mock_result = MagicMock( + returncode=1, + stdout=json_output( + make_check("message", status="fail"), + make_check("subject_min_length", status="fail", rule_id="CC005"), + make_check("ai_attribution", rule_id="CC013"), + ), + ) + with ( + patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True), + patch("main.subprocess.run", return_value=mock_result), + ): + [scope] = main.run_pr_message_checks([(SHA_A, "WIP")]) + self.assertEqual(scope.status, "pass") + + def test_message_ai_attribution_under_ignore_is_skipped(self): + """No AI rule ran: say so rather than report a pass.""" + mock_result = MagicMock(returncode=0, stdout=json_output(make_check("message"))) + with ( + patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True), + patch("main.subprocess.run", return_value=mock_result), + ): + [scope] = main.run_pr_message_checks([(SHA_A, "feat: x")]) + self.assertEqual(scope.status, "skip") + self.assertEqual(scope.checks[0]["reason"], main.AI_POLICY_IGNORED) def test_a_blank_author_field_is_left_to_git(self): self.local_author.return_value = ("Renovate Bot", "") @@ -1009,9 +1043,7 @@ def test_message_ai_attribution_narrows_the_head_check(self): patch("main.run_other_checks", return_value=[]), ): main.run_commit_check() - mock_scope.assert_called_once_with( - "Commit message", ["--message-ai-attribution"], sha=SHA_A - ) + mock_scope.assert_called_once_with("Commit message", ["--message"], sha=SHA_A) def test_message_flag_removed_before_other_checks_in_pr(self): captured_args = []