diff --git a/main.py b/main.py index 5bb215d..4c49559 100755 --- a/main.py +++ b/main.py @@ -463,6 +463,47 @@ def get_pr_opener_tool() -> str | None: return AI_AGENT_BOTS.get(str(login).lower()) +def lines_removed(text: str, fixed: str) -> list[str]: + """The non-blank lines of ``text`` that ``fixed`` no longer has, in order. + + A line repeated in ``text`` counts once per copy ``fixed`` dropped. + """ + kept: dict[str, int] = {} + for line in fixed.splitlines(): + kept[line] = kept.get(line, 0) + 1 + removed = [] + for line in text.splitlines(): + if kept.get(line, 0): + kept[line] -= 1 + elif line.strip(): + removed.append(line.strip()) + return removed + + +def name_the_line_changes(scope: ScopeResult, body: str) -> None: + """Name the lines to change instead of repeating the description. + + For a message the CLI's ``fix`` is the corrected text, short enough to + paste. A description can run to a page, and the summary, the comment and + the annotation would each repeat it in full; the lines to delete or add + are what its author needs. + """ + for check in scope.checks: + fixed = check.get("fix") or "" + if not fixed: + continue + removed = lines_removed(body, fixed) + added = lines_removed(fixed, body) + parts = [] + if removed: + parts.append("remove " + "; ".join(f'"{line}"' for line in removed)) + if added: + parts.append("add " + "; ".join(f'"{line}"' for line in added)) + if parts: + check["suggest"] = "In the description, " + ", and ".join(parts) + check["fix"] = "" + + #: The AI tools found in the pull request description when it was read for #: the label only (``ai-label`` without ``pr-body-ai-attribution``), so it is no scope. PR_BODY_AI_TOOLS: list[str] = [] @@ -814,6 +855,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]: "PR description", ["--ai-attribution-only"], input_text=pr_body ) if PR_BODY_AI_ATTRIBUTION_ENABLED: + name_the_line_changes(scope, pr_body) results.append(scope) else: PR_BODY_AI_TOOLS[:] = scope.ai_tools diff --git a/main_test.py b/main_test.py index 208b95e..ad060cb 100644 --- a/main_test.py +++ b/main_test.py @@ -3737,6 +3737,89 @@ def test_no_description_nothing_to_check(self): self.assertEqual(results, []) +class TestDescriptionLineChanges(unittest.TestCase): + BODY = "## Summary\nAdds a parser.\n\nLink to Devin run: https://app.devin.ai/sessions/1\n" + + def _scope(self, *checks): + return main.ScopeResult(label="PR description", checks=list(checks)) + + def test_lines_removed_keeps_order_and_counts_copies(self): + self.assertEqual(main.lines_removed("a\nb\na\n\nc", "a\nc"), ["b", "a"]) + + def test_forbid_names_the_line_to_delete(self): + scope = self._scope( + { + "rule_id": "CC013", + "suggest": "Remove the AI lines.", + "fix": "## Summary\nAdds a parser.", + } + ) + main.name_the_line_changes(scope, self.BODY) + self.assertEqual( + scope.checks[0]["suggest"], + 'In the description, remove "Link to Devin run: https://app.devin.ai/sessions/1"', + ) + self.assertEqual(scope.checks[0]["fix"], "") + + def test_disclose_names_the_line_to_add_and_the_one_it_replaces(self): + body = "Adds a parser.\n\nCo-Authored-By: Claude " + scope = self._scope( + { + "rule_id": "CC015", + "suggest": "x", + "fix": "Adds a parser.\n\nAssisted-by: Claude", + }, + {"rule_id": "CC014", "suggest": "x", "fix": body + "\nAssisted-by: Claude"}, + ) + main.name_the_line_changes(scope, body) + self.assertEqual( + [c["suggest"] for c in scope.checks], + [ + ( + 'In the description, remove "Co-Authored-By: Claude' + ' ", and add "Assisted-by: Claude"' + ), + 'In the description, add "Assisted-by: Claude"', + ], + ) + + def test_a_check_without_a_fix_or_a_change_is_left_alone(self): + scope = self._scope( + {"rule_id": "CC013", "suggest": "s", "fix": ""}, + {"rule_id": "CC013", "suggest": "s", "fix": "same"}, + ) + main.name_the_line_changes(scope, "same") + self.assertEqual( + scope.checks, + [ + {"rule_id": "CC013", "suggest": "s", "fix": ""}, + {"rule_id": "CC013", "suggest": "s", "fix": "same"}, + ], + ) + + def test_run_names_the_lines_for_a_reported_description(self): + scope = self._scope( + { + "rule_id": "CC013", + "status": "fail", + "suggest": "s", + "fix": "## Summary\nAdds a parser.", + } + ) + with ( + patch("main.PR_TITLE_ENABLED", False), + patch("main.PR_BODY_AI_ATTRIBUTION_ENABLED", True), + patch.dict(os.environ, {"AI_LABEL": "false"}), + patch("main.MESSAGE_ENABLED", False), + patch("main.is_pr_event", return_value=True), + patch("main.get_pr_body", return_value=self.BODY), + patch("main.check_scope", return_value=scope), + patch("main.run_other_checks", return_value=[]), + ): + _rc, results = main.run_commit_check() + self.assertEqual(results[0].checks[0]["fix"], "") + + class TestAiToolsFound(unittest.TestCase): def setUp(self): main.PR_BODY_AI_TOOLS.clear()