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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,9 @@ click away:
> <summary>Show all 4 checks</summary>
>
> ```text
> Commit message
> Pull request
> ✔ PR title (feat: add login page)
> Commit message
> ✔ Commit 1/2 (d87faca) (feat: add login page)
> ✖ Commit 2/2 (5584f46) (1 failure)
> CC001 message
Expand Down Expand Up @@ -172,7 +173,7 @@ names the line to remove:
> | PR description | `Claude Code` | [CC013 ai-attribution](https://commit-check.com/rules/#cc013) |
>
> ```text
> PR description
> Pull request
> ✖ PR description (1 failure)
> CC013 ai-attribution
> value: Claude Code
Expand Down
5 changes: 5 additions & 0 deletions commit-check.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

inherit_from = "github:commit-check/.github:commit-check.toml"

[commit]
# Exercises pr-body-ai-attribution in the self-test: under the default
# "ignore" the PR description check runs no rule.
ai_attribution = "forbid"

[branch]
require_rebase_target = "origin/main"
allow_branch_names = ["create-pull-request/.+"]
35 changes: 33 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,35 @@ def skipped_author_scope(flag: str) -> ScopeResult:
)


#: Why an AI-only scope ran no rule: under the default policy there is none to run.
AI_POLICY_IGNORED = 'ai_attribution is "ignore": set it to "forbid" or "disclose"'


def skip_when_no_ai_rule_ran(scope: ScopeResult) -> ScopeResult:
"""Report an AI-only scope that ran no rule as skipped, not passed.

Under the default ``ai_attribution = "ignore"`` the CLI builds none of
the AI rules, so it returns an empty list of checks. Shown as a pass, that
read as "checked, nothing found" to someone who turned the input on and
forgot the policy.
"""
if scope.checks or scope.raw_text:
return scope
scope.checks = [
{
"rule_id": "CC013",
"check": "ai_attribution",
"status": "skip",
"value": "",
"error": "",
"suggest": "",
"docs_url": "",
"reason": AI_POLICY_IGNORED,
}
]
return scope


def get_pr_title() -> str | None:
"""Read PR title from GitHub event payload."""
if not is_pr_event():
Expand Down Expand Up @@ -856,7 +885,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
)
if PR_BODY_AI_ATTRIBUTION_ENABLED:
name_the_line_changes(scope, pr_body)
results.append(scope)
results.append(skip_when_no_ai_rule_ran(scope))
else:
PR_BODY_AI_TOOLS[:] = scope.ai_tools

Expand Down Expand Up @@ -924,7 +953,9 @@ def _rule_markdown_link(check: dict[str, str]) -> str:

def _scope_group(label: str) -> str:
"""Group name for a scope label, used to fold the step log output."""
if label == "PR title" or label.startswith("Commit"):
if label in ("PR title", "PR description"):
return "Pull request"
if label.startswith("Commit"):
return "Commit message"
if label.startswith("Author"):
return "Author"
Expand Down
44 changes: 37 additions & 7 deletions main_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1735,11 +1735,22 @@ def test_groups_scopes_by_category(self):
fail_scope("Branch"),
]
output = self._run(results)
# One group for commit-message scopes, one for the branch scope.
self.assertEqual(output.count("::group::"), 2)
# One group each for the pull request, the commits and the branch.
self.assertEqual(output.count("::group::"), 3)
self.assertIn("::group::Pull request", output)
self.assertIn("::group::Commit message", output)
self.assertIn("::group::Branch", output)

def test_title_and_description_share_one_group(self):
"""The commits come after both, so the commit group is not split."""
results = [
pass_scope("PR title"),
pass_scope("PR description"),
pass_scope("Commit 1/1"),
]
groups = [name for name, _ in main._grouped(results)]
self.assertEqual(groups, ["Pull request", "Commit message"])

def test_raw_text_fallback_is_printed(self):
scope = main.ScopeResult(label="Branch", raw_text="unexpected output")
output = self._run([scope])
Expand Down Expand Up @@ -1801,8 +1812,9 @@ def test_success_golden_output(self):
"<summary>Show all 4 checks</summary>\n"
"\n"
"```text\n"
"Commit message\n"
"Pull request\n"
" ✔ PR title (feat: add login page)\n"
"Commit message\n"
" ✔ Commit 1/2 (feat: add user auth)\n"
" ✔ Commit 2/2 (fix: resolve timeout)\n"
"Branch\n"
Expand Down Expand Up @@ -1839,8 +1851,9 @@ def test_failure_golden_output(self):
"<summary>Show all 3 checks</summary>\n"
"\n"
"```text\n"
"Commit message\n"
"Pull request\n"
" ✔ PR title (feat: add login page)\n"
"Commit message\n"
" ✖ Commit 2/2 (1 failure)\n"
" CC001 message\n"
" value: bad message\n"
Expand Down Expand Up @@ -1890,8 +1903,9 @@ def test_failure_golden_output_with_commits(self):
"<summary>Show all 5 checks</summary>\n"
"\n"
"```text\n"
"Commit message\n"
"Pull request\n"
" ✔ PR title (feat: Add login page)\n"
"Commit message\n"
" ✖ Commit 1/3 (d87faca) (1 failure)\n"
" CC001 message\n"
" value: bad message\n"
Expand Down Expand Up @@ -2950,8 +2964,9 @@ def test_all_skipped_golden_output(self):
"<summary>Show all 2 checks</summary>\n"
"\n"
"```text\n"
"Commit message\n"
"Pull request\n"
" ⊘ PR title (skipped)\n"
"Commit message\n"
" ⊘ Commit 1/1 (skipped)\n"
"```\n"
"\n"
Expand Down Expand Up @@ -3198,7 +3213,7 @@ def test_warning_only_golden_output(self):
"<summary>Show all 2 checks</summary>\n"
"\n"
"```text\n"
"Commit message\n"
"Pull request\n"
" ✔ PR title (feat: add login page)\n"
"Branch\n"
" ⚠ Branch (1 warning)\n"
Expand Down Expand Up @@ -3720,6 +3735,21 @@ def test_pr_body_is_a_scope_checked_with_the_ai_rules_only(self):
)
self.assertEqual([r.label for r in results], ["PR description"])

def test_no_ai_rule_ran_reads_as_skipped_not_passed(self):
"""Under ai_attribution = "ignore" the CLI runs no rule on the description."""
results, _ = self._run(True, "false")
self.assertEqual(results[0].status, "skip")
self.assertEqual(results[0].skip_reason, main.AI_POLICY_IGNORED)

def test_a_rule_that_ran_keeps_its_verdict(self):
checks = [{"rule_id": "CC013", "check": "ai_attribution", "status": "pass"}]
scope = main.ScopeResult(label="PR description", checks=list(checks))
self.assertEqual(main.skip_when_no_ai_rule_ran(scope).checks, checks)

def test_unparsable_output_still_fails(self):
scope = main.ScopeResult(label="PR description", raw_text="Error: bad config")
self.assertEqual(main.skip_when_no_ai_rule_ran(scope).status, "fail")

def test_ai_label_alone_reads_it_but_reports_no_scope(self):
results, mock_scope = self._run(False, "true")
mock_scope.assert_called_once()
Expand Down
Loading