Skip to content

Commit 25b7143

Browse files
authored
feat: add message-ai-attribution to check commits for AI attribution only (#329)
1 parent 53ac25e commit 25b7143

4 files changed

Lines changed: 122 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ runners need a few tools — see [Good to know](#good-to-know).
8080
| `author-name` | `false` | Check each commit's author name |
8181
| `author-email` | `false` | Check each commit's author email |
8282
| `pr-title` | `false` | Check the pull request title against Conventional Commits — the one that matters for squash merges. Pull request events only |
83+
| `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` |
8384
| `pr-body-ai-attribution` | `false` | Check the pull request description against the AI rules of `cchk.toml` (`ai_attribution`) only. Pull request events only |
8485
| `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` |
8586
| `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 |

‎action.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ inputs:
3737
description: check pull request title following conventional commits
3838
required: false
3939
default: false
40+
message-ai-attribution:
41+
description: check each commit against the AI attribution rules (ai_attribution in cchk.toml) only, without the format rules; overrides message
42+
required: false
43+
default: false
4044
pr-body-ai-attribution:
4145
description: check the pull request description against the AI attribution rules (ai_attribution in cchk.toml)
4246
required: false
@@ -122,6 +126,7 @@ runs:
122126
JOB_SUMMARY: ${{ inputs.job-summary }}
123127
PR_COMMENTS: ${{ inputs.pr-comments }}
124128
PR_TITLE: ${{ inputs.pr-title }}
129+
MESSAGE_AI_ATTRIBUTION: ${{ inputs.message-ai-attribution }}
125130
PR_BODY_AI_ATTRIBUTION: ${{ inputs.pr-body-ai-attribution }}
126131
AI_LABEL: ${{ inputs.ai-label }}
127132
GITHUB_TOKEN: ${{ github.token }}

‎main.py‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ def _reconfigure_io() -> None:
102102
PR_COMMENTS_ENABLED = env_flag("PR_COMMENTS")
103103
PR_TITLE_ENABLED = env_flag("PR_TITLE")
104104
PR_BODY_AI_ATTRIBUTION_ENABLED = env_flag("PR_BODY_AI_ATTRIBUTION")
105+
MESSAGE_AI_ATTRIBUTION_ENABLED = env_flag("MESSAGE_AI_ATTRIBUTION")
106+
107+
108+
#: The AI attribution rules: what ``message-ai-attribution: true`` keeps.
109+
AI_RULE_IDS = frozenset({"CC013", "CC014", "CC015", "CC016", "CC017"})
110+
105111

106112
#: The label ``ai-label: true`` puts on a pull request that an AI tool wrote.
107113
DEFAULT_AI_LABEL = "ai-assisted"
@@ -269,6 +275,7 @@ def log_env_vars():
269275
"PR_COMMENTS",
270276
"PR_TITLE",
271277
"PR_BODY_AI_ATTRIBUTION",
278+
"MESSAGE_AI_ATTRIBUTION",
272279
"AI_LABEL",
273280
):
274281
value = os.getenv(name, "false")
@@ -475,6 +482,22 @@ def skip_when_no_ai_rule_ran(scope: ScopeResult) -> ScopeResult:
475482
return scope
476483

477484

485+
def keep_ai_rules(scope: ScopeResult) -> ScopeResult:
486+
"""Narrow a commit message scope to the AI rules (CC013 to CC017).
487+
488+
``message-ai-attribution: true`` does this whatever ``message`` says:
489+
``message`` defaults to true, so asking for the AI rules alone must not
490+
need it turned off as well. The CLI runs every message rule and the
491+
format rules' verdicts are dropped here, so the CLI needs no flag of
492+
its own for it. A scope left with no rule, under the default
493+
``ai_attribution = "ignore"``, is reported as skipped.
494+
"""
495+
if not MESSAGE_AI_ATTRIBUTION_ENABLED or scope.raw_text:
496+
return scope
497+
scope.checks = [c for c in scope.checks if c.get("rule_id") in AI_RULE_IDS]
498+
return skip_when_no_ai_rule_ran(scope)
499+
500+
478501
def get_pr_title() -> str | None:
479502
"""Read PR title from GitHub event payload."""
480503
if not is_pr_event():
@@ -835,7 +858,7 @@ def run_pr_message_checks(pr_commits: list[Commit]) -> list[ScopeResult]:
835858
scope = check_scope(label, ["--message"], input_text=msg, sha=sha, env=env)
836859
else:
837860
scope = check_empty_message(label, sha, env)
838-
results.append(scope)
861+
results.append(keep_ai_rules(scope))
839862
return results
840863

841864

@@ -908,7 +931,7 @@ def run_other_checks(args: list[str], rev: str | None = None) -> list[ScopeResul
908931
def build_check_args() -> list[str]:
909932
"""Map enabled validation switches to commit-check CLI arguments."""
910933
flags = [
911-
("--message", MESSAGE_ENABLED),
934+
("--message", MESSAGE_ENABLED or MESSAGE_AI_ATTRIBUTION_ENABLED),
912935
("--branch", BRANCH_ENABLED),
913936
("--author-name", AUTHOR_NAME_ENABLED),
914937
("--author-email", AUTHOR_EMAIL_ENABLED),
@@ -924,7 +947,8 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
924947
2. PR description, AI rules only (when ``pr-body-ai-attribution: true`` and in a PR
925948
event; with only ``ai-label`` set it is read for the label, and no
926949
scope is reported)
927-
3. Individual PR commit messages (when ``message: true`` and in a PR event)
950+
3. Individual PR commit messages (when ``message: true`` and in a PR event;
951+
AI rules only with ``message-ai-attribution: true``)
928952
4. Author checks on every PR commit's author, then the remaining checks
929953
(branch; outside a PR, the HEAD commit and its author)
930954
@@ -954,7 +978,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
954978

955979
# ---- 3. Commit message checks -----------------------------------------
956980
pr_commits: list[Commit] | None = None
957-
if MESSAGE_ENABLED:
981+
if MESSAGE_ENABLED or MESSAGE_AI_ATTRIBUTION_ENABLED:
958982
pr_commits = get_pr_commit_messages()
959983
if pr_commits:
960984
# In PR context: check each commit individually to avoid
@@ -972,7 +996,9 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
972996
# ---- 4. Remaining checks (branch, author, etc.) -----------------------
973997
# Outside a PR, check the HEAD commit message directly.
974998
if "--message" in args:
975-
results.append(check_scope("Commit message", ["--message"], sha=head_sha()))
999+
results.append(
1000+
keep_ai_rules(check_scope("Commit message", ["--message"], sha=head_sha()))
1001+
)
9761002
args = [a for a in args if a != "--message"]
9771003
rev = None
9781004
author_flags = [flag for flag in args if flag in AUTHOR_FLAGS]

‎main_test.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def test_every_input_is_logged_as_a_debug_command(self):
154154
"::debug::PR_COMMENTS=false",
155155
"::debug::PR_TITLE=false",
156156
"::debug::PR_BODY_AI_ATTRIBUTION=false",
157+
"::debug::MESSAGE_AI_ATTRIBUTION=false",
157158
"::debug::AI_LABEL=false",
158159
],
159160
)
@@ -424,6 +425,55 @@ def test_a_commit_in_the_clone_carries_its_own_author(self):
424425
self.assertEqual(env["GIT_AUTHOR_NAME"], "Renovate Bot")
425426
self.assertEqual(env["GIT_AUTHOR_EMAIL"], "bot@renovateapp.com")
426427

428+
def test_message_ai_attribution_checks_only_the_ai_rules(self):
429+
"""The format rules' verdicts are dropped; the commit still goes in
430+
with its author, so CC017 judges it."""
431+
self.local_author.return_value = ("Jane Dev", "jane@example.com")
432+
output = json_output(
433+
make_check("message", status="fail"),
434+
make_check("ai_author", status="fail", rule_id="CC017"),
435+
make_check("ai_co_author", rule_id="CC015"),
436+
)
437+
mock_result = MagicMock(returncode=1, stdout=output)
438+
with (
439+
patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True),
440+
patch("main.subprocess.run", return_value=mock_result) as mock_run,
441+
):
442+
[scope] = main.run_pr_message_checks([(SHA_A, "WIP")])
443+
self.assertEqual(
444+
mock_run.call_args[0][0], ["commit-check", "--format", "json", "--message"]
445+
)
446+
self.assertEqual(mock_run.call_args[1]["env"]["GIT_AUTHOR_NAME"], "Jane Dev")
447+
self.assertEqual([c["rule_id"] for c in scope.checks], ["CC017", "CC015"])
448+
self.assertEqual(scope.status, "fail")
449+
450+
def test_message_ai_attribution_passes_a_badly_formatted_message(self):
451+
mock_result = MagicMock(
452+
returncode=1,
453+
stdout=json_output(
454+
make_check("message", status="fail"),
455+
make_check("subject_min_length", status="fail", rule_id="CC005"),
456+
make_check("ai_attribution", rule_id="CC013"),
457+
),
458+
)
459+
with (
460+
patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True),
461+
patch("main.subprocess.run", return_value=mock_result),
462+
):
463+
[scope] = main.run_pr_message_checks([(SHA_A, "WIP")])
464+
self.assertEqual(scope.status, "pass")
465+
466+
def test_message_ai_attribution_under_ignore_is_skipped(self):
467+
"""No AI rule ran: say so rather than report a pass."""
468+
mock_result = MagicMock(returncode=0, stdout=json_output(make_check("message")))
469+
with (
470+
patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True),
471+
patch("main.subprocess.run", return_value=mock_result),
472+
):
473+
[scope] = main.run_pr_message_checks([(SHA_A, "feat: x")])
474+
self.assertEqual(scope.status, "skip")
475+
self.assertEqual(scope.checks[0]["reason"], main.AI_POLICY_IGNORED)
476+
427477
def test_a_blank_author_field_is_left_to_git(self):
428478
self.local_author.return_value = ("Renovate Bot", "")
429479
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):
9631013
# HEAD is the commit that was checked, so the scope names it too.
9641014
mock_scope.assert_called_once_with("Commit message", ["--message"], sha=SHA_A)
9651015

1016+
def test_message_ai_attribution_alone_checks_each_commit(self):
1017+
"""With message: false the commits are still checked, for AI only."""
1018+
with (
1019+
patch("main.MESSAGE_ENABLED", False),
1020+
patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True),
1021+
patch("main.BRANCH_ENABLED", False),
1022+
patch("main.AUTHOR_NAME_ENABLED", False),
1023+
patch("main.AUTHOR_EMAIL_ENABLED", False),
1024+
patch("main.get_pr_commit_messages", return_value=[(SHA_A, "WIP")]),
1025+
patch("main.run_pr_message_checks", return_value=[pass_scope()]) as mock_pr,
1026+
patch("main.run_other_checks", return_value=[]) as mock_other,
1027+
):
1028+
rc, _results = main.run_commit_check()
1029+
self.assertEqual(rc, 0)
1030+
mock_pr.assert_called_once_with([(SHA_A, "WIP")])
1031+
mock_other.assert_called_once_with([], rev=None)
1032+
1033+
def test_message_ai_attribution_narrows_the_head_check(self):
1034+
"""Outside a PR, HEAD is checked for AI only, even with message: true."""
1035+
with (
1036+
patch("main.MESSAGE_ENABLED", True),
1037+
patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True),
1038+
patch("main.BRANCH_ENABLED", False),
1039+
patch("main.AUTHOR_NAME_ENABLED", False),
1040+
patch("main.AUTHOR_EMAIL_ENABLED", False),
1041+
patch("main.get_pr_commit_messages", return_value=[]),
1042+
patch("main.head_sha", return_value=SHA_A),
1043+
patch(
1044+
"main.check_scope", return_value=pass_scope("Commit message")
1045+
) as mock_scope,
1046+
patch("main.run_other_checks", return_value=[]),
1047+
):
1048+
main.run_commit_check()
1049+
mock_scope.assert_called_once_with("Commit message", ["--message"], sha=SHA_A)
1050+
9661051
def test_message_flag_removed_before_other_checks_in_pr(self):
9671052
captured_args = []
9681053

0 commit comments

Comments
 (0)