diff --git a/main.py b/main.py index 6fdc056..6aa9a83 100755 --- a/main.py +++ b/main.py @@ -502,6 +502,22 @@ def keep_ai_rules(scope: ScopeResult) -> ScopeResult: return skip_when_no_ai_rule_ran(scope) +#: Rules about a commit rather than about a line of text: it has a body +#: (CC011), a sign-off (CC012), an author that is no AI tool (CC017). A pull +#: request title has neither body nor trailers nor an author of its own, so +#: judged as a commit message it could never pass the first two, and the +#: third would judge whatever git identity the runner has. +COMMIT_ONLY_RULE_IDS = frozenset({"CC011", "CC012", "CC017"}) + + +def drop_commit_only_rules(scope: ScopeResult) -> ScopeResult: + """Narrow the PR title scope to the rules a one-line title can meet.""" + scope.checks = [ + c for c in scope.checks if c.get("rule_id") not in COMMIT_ONLY_RULE_IDS + ] + return scope + + def get_pr_title() -> str | None: """Read PR title from GitHub event payload.""" if not is_pr_event(): @@ -972,7 +988,8 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: if PR_TITLE_ENABLED and is_pr_event(): pr_title = get_pr_title() if pr_title: - results.append(check_scope("PR title", ["--message"], input_text=pr_title)) + scope = check_scope("PR title", ["--message"], input_text=pr_title) + results.append(drop_commit_only_rules(scope)) # ---- 2. PR description ------------------------------------------------ if (PR_BODY_AI_ATTRIBUTION_ENABLED or ai_label_name()) and is_pr_event(): diff --git a/main_test.py b/main_test.py index 2779b5f..99d7b55 100644 --- a/main_test.py +++ b/main_test.py @@ -1004,6 +1004,33 @@ def test_pr_title_check_runs_when_enabled(self): "PR title", ["--message"], input_text="feat: a feature" ) + def test_pr_title_is_not_held_to_commit_only_rules(self): + # require_body and require_signed_off_by judge a commit; a title has + # no body or trailers, so it could never pass them. + title = main.ScopeResult( + label="PR title", + checks=[ + make_check("message", "pass", "CC001", value="feat: add login"), + make_check("require_body", "fail", "CC011", value="feat: add login"), + make_check("require_signed_off_by", "fail", "CC012"), + make_check("ai_author", "fail", "CC017", value="Claude "), + ], + ) + with ( + patch("main.PR_TITLE_ENABLED", True), + patch("main.MESSAGE_ENABLED", False), + patch("main.BRANCH_ENABLED", False), + patch("main.AUTHOR_NAME_ENABLED", False), + patch("main.AUTHOR_EMAIL_ENABLED", False), + patch("main.is_pr_event", return_value=True), + patch("main.get_pr_title", return_value="feat: add login"), + patch("main.check_scope", return_value=title), + patch("main.run_other_checks", return_value=[]), + ): + rc, results = main.run_commit_check() + self.assertEqual(rc, 0) + self.assertEqual([c["rule_id"] for c in results[0].checks], ["CC001"]) + def test_pr_title_failure_propagates(self): with ( patch("main.PR_TITLE_ENABLED", True), @@ -4222,6 +4249,18 @@ def test_failing_message(self): main.render_report([scope]), ) + def test_a_title_meets_only_the_rules_a_title_can(self): + with open(self.config, "w", encoding="utf-8") as f: + f.write("[commit]\nrequire_body = true\nrequire_signed_off_by = true\n") + rc, data = self._run("feat: add login page") + failed = {c["rule_id"] for c in data["checks"] if c["status"] == "fail"} + self.assertEqual(rc, 1) + self.assertEqual(failed, {"CC011", "CC012"}) + scope = main.drop_commit_only_rules( + main.ScopeResult(label="PR title", checks=data["checks"]) + ) + self.assertEqual(scope.status, "pass") + class TestStderrIsNotPartOfTheJson(unittest.TestCase): """stdout is the JSON; stderr is what the CLI has to say to a person.