Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
36 changes: 31 additions & 5 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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),
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down
85 changes: 85 additions & 0 deletions main_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
Expand Down Expand Up @@ -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")))
Expand Down Expand Up @@ -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 = []

Expand Down
Loading