diff --git a/README.md b/README.md index 65fce8e..b15c89d 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 8c829c0..9a1487c 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 @@ -122,6 +126,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 975a298..a12e051 100755 --- a/main.py +++ b/main.py @@ -102,6 +102,12 @@ 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") + + +#: 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. DEFAULT_AI_LABEL = "ai-assisted" @@ -269,6 +275,7 @@ def log_env_vars(): "PR_COMMENTS", "PR_TITLE", "PR_BODY_AI_ATTRIBUTION", + "MESSAGE_AI_ATTRIBUTION", "AI_LABEL", ): value = os.getenv(name, "false") @@ -475,6 +482,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(): @@ -835,7 +858,7 @@ def run_pr_message_checks(pr_commits: list[Commit]) -> list[ScopeResult]: scope = check_scope(label, ["--message"], input_text=msg, sha=sha, env=env) else: scope = check_empty_message(label, sha, env) - results.append(scope) + results.append(keep_ai_rules(scope)) return results @@ -908,7 +931,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), @@ -924,7 +947,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. Author checks on every PR commit's author, then the remaining checks (branch; outside a PR, the HEAD commit and its author) @@ -954,7 +978,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: # ---- 3. Commit message checks ----------------------------------------- pr_commits: list[Commit] | None = None - 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 @@ -972,7 +996,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( + keep_ai_rules(check_scope("Commit message", ["--message"], sha=head_sha())) + ) args = [a for a in args if a != "--message"] rev = None author_flags = [flag for flag in args if flag in AUTHOR_FLAGS] diff --git a/main_test.py b/main_test.py index 542de69..3b55797 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", ], ) @@ -424,6 +425,55 @@ 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 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") + 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, + ): + [scope] = main.run_pr_message_checks([(SHA_A, "WIP")]) + self.assertEqual( + 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", "") mock_result = MagicMock(returncode=0, stdout=json_output(make_check("message"))) @@ -963,6 +1013,41 @@ 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"], sha=SHA_A) + def test_message_flag_removed_before_other_checks_in_pr(self): captured_args = []