diff --git a/README.md b/README.md index c8fef2e..d9b1380 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,11 @@ To submit merge request into review run command: gh review ``` +If `OPENAI_API_KEY` is configured, the review command also checks the diff for +documentation impact and posts suggested documentation updates to the merge +request when user-facing behavior, setup, configuration, or command usage +changes. + To also enable **auto-merge when the pipeline succeeds**, add `--auto_merge` or `-am` flag: ``` @@ -225,4 +230,3 @@ I suggest checking Gitlab's official API documentation: https://docs.gitlab.com/ ## Donating 💜 Make sure to check this project on [OpenPledge](https://app.openpledge.io/repositories/zigcBenx/gitHappens). - diff --git a/ai_code_review.py b/ai_code_review.py index 7c3b64f..1f0bc5d 100644 --- a/ai_code_review.py +++ b/ai_code_review.py @@ -45,6 +45,35 @@ class Colors: - MEDIUM: Code smells, potential bugs, missing error handling - LOW: Minor improvements, suggestions, style inconsistencies""" +DOCUMENTATION_PROMPT = """Documentation review: you are a technical writer reviewing a git diff for documentation impact. + +Output ONLY valid JSON - no markdown, no code blocks, no explanations. + +Identify whether the diff changes user-facing behavior, setup steps, configuration, +commands, flags, public APIs, or operational workflows that should be documented. +Do not suggest documentation for purely internal refactors, test-only changes, or +minor implementation details that users do not need to know. + +Output format: +{ + "needed": true, + "summary": "one sentence describing why docs should change", + "suggestions": [ + { + "file": "README.md", + "reason": "what changed in the diff", + "suggestion": "specific documentation update to make" + } + ] +} + +If no documentation update is needed, return: +{ + "needed": false, + "summary": "No documentation updates needed.", + "suggestions": [] +}""" + def get_branch_diff(): """Get the diff of changed files in current branch vs main branch.""" try: @@ -130,6 +159,32 @@ def review_code(diff_content): print(f"{Colors.CRITICAL}✗ Error during AI review: {e}{Colors.RESET}") return None +def suggest_documentation_updates(diff_content): + """Send code diff to OpenAI for documentation update suggestions.""" + openai = get_openai_client() + if not openai: + return None + + try: + response = openai.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": DOCUMENTATION_PROMPT}, + {"role": "user", "content": f"Documentation review for this git diff:\n\n{diff_content}"} + ], + temperature=0.2, + response_format={"type": "json_object"} + ) + + return json.loads(response.choices[0].message.content) + except json.JSONDecodeError as e: + print(f"{Colors.CRITICAL}✗ Failed to parse documentation response as JSON{Colors.RESET}") + print(f"{Colors.DIM}Error: {e}{Colors.RESET}") + return None + except Exception as e: + print(f"{Colors.CRITICAL}✗ Error during documentation review: {e}{Colors.RESET}") + return None + def print_issues(issues, severity, color, icon): """Print issues with consistent formatting.""" if not issues: @@ -213,6 +268,41 @@ def format_issues(issues, severity, emoji): return comment +def format_documentation_comment(results): + """Format documentation suggestions as a GitLab markdown comment.""" + if not results or not results.get('needed'): + return None + + comment = "## Documentation Suggestions\n\n" + + summary = results.get('summary', '') + if summary: + comment += f"{summary}\n\n" + + suggestions = results.get('suggestions', []) + if not suggestions: + comment += "- **`Documentation`**: Review the diff and update documentation as needed.\n" + return comment + + for suggestion in suggestions: + file_path = suggestion.get('file', 'Documentation') + reason = suggestion.get('reason', 'Documentation may need an update.') + update = suggestion.get('suggestion', 'Review the diff and update documentation as needed.') + comment += f"- **`{file_path}`**: {update}\n" + comment += f" - Reason: {reason}\n" + + return comment + +def display_documentation_results(results): + """Display documentation suggestions in the terminal.""" + comment = format_documentation_comment(results) + if not comment: + print(f"{Colors.INFO}ℹ No documentation updates suggested{Colors.RESET}") + return + + print(f"\n{Colors.BOLD}DOCUMENTATION SUGGESTIONS{Colors.RESET}") + print(comment) + def get_merge_request_changes(project_id, mr_id, gitlab_token, api_url): """Get the changes (diffs) from the merge request to find commit SHAs.""" import requests @@ -340,6 +430,11 @@ def run_review(): sys.exit(0) display_review_results(results) + print(f"{Colors.INFO}📝 Checking documentation impact...{Colors.RESET}") + documentation_results = suggest_documentation_updates(diff_content) + if documentation_results: + display_documentation_results(documentation_results) + def run_review_for_mr(project_id, mr_id, gitlab_token, api_url): """Run AI code review and post inline comments to GitLab merge request.""" print(f"{Colors.INFO}🤖 Running AI code review...{Colors.RESET}") @@ -353,12 +448,17 @@ def run_review_for_mr(project_id, mr_id, gitlab_token, api_url): print(f"{Colors.HIGH}⚠ AI review skipped{Colors.RESET}") return + documentation_results = suggest_documentation_updates(diff_content) + documentation_comment = format_documentation_comment(documentation_results) + # Get diff refs for inline comments diff_refs = get_diff_refs(project_id, mr_id, gitlab_token, api_url) if not diff_refs or not all(diff_refs.values()): print(f"{Colors.HIGH}⚠ Could not get diff refs, posting summary only{Colors.RESET}") comment = format_gitlab_comment(results) post_to_merge_request(comment, project_id, mr_id, gitlab_token, api_url) + if documentation_comment: + post_to_merge_request(documentation_comment, project_id, mr_id, gitlab_token, api_url) return # Post inline comments for each issue @@ -386,5 +486,8 @@ def run_review_for_mr(project_id, mr_id, gitlab_token, api_url): else: print(f"{Colors.INFO}✓ All {total_posted} issues posted as inline comments{Colors.RESET}") + if documentation_comment: + post_to_merge_request(documentation_comment, project_id, mr_id, gitlab_token, api_url) + if __name__ == '__main__': run_review() diff --git a/tests/test_ai_code_review.py b/tests/test_ai_code_review.py new file mode 100644 index 0000000..7f1b41e --- /dev/null +++ b/tests/test_ai_code_review.py @@ -0,0 +1,98 @@ +import json +import unittest +from unittest import mock + +import ai_code_review + + +class DocumentationSuggestionTest(unittest.TestCase): + def test_format_documentation_comment_returns_none_without_suggestions(self): + result = ai_code_review.format_documentation_comment({ + "needed": False, + "summary": "No docs needed", + "suggestions": [], + }) + + self.assertIsNone(result) + + def test_format_documentation_comment_includes_summary_and_suggestions(self): + result = ai_code_review.format_documentation_comment({ + "needed": True, + "summary": "The CLI behavior changed.", + "suggestions": [ + { + "file": "README.md", + "reason": "New flag added", + "suggestion": "Document the --select flag in the review section.", + } + ], + }) + + self.assertIn("## Documentation Suggestions", result) + self.assertIn("The CLI behavior changed.", result) + self.assertIn("README.md", result) + self.assertIn("Document the --select flag", result) + + def test_format_documentation_comment_keeps_summary_without_suggestions(self): + result = ai_code_review.format_documentation_comment({ + "needed": True, + "summary": "Docs should mention that review can post multiple comments.", + "suggestions": [], + }) + + self.assertIn("## Documentation Suggestions", result) + self.assertIn("Docs should mention that review can post multiple comments.", result) + self.assertIn("Review the diff and update documentation as needed.", result) + + def test_suggest_documentation_updates_uses_diff_content(self): + fake_openai = mock.Mock() + fake_openai.chat.completions.create.return_value.choices = [ + mock.Mock(message=mock.Mock(content=json.dumps({ + "needed": True, + "summary": "Docs should mention behavior.", + "suggestions": [], + }))) + ] + + with mock.patch("ai_code_review.get_openai_client", return_value=fake_openai): + result = ai_code_review.suggest_documentation_updates("diff --git a/file.py b/file.py") + + self.assertTrue(result["needed"]) + call_kwargs = fake_openai.chat.completions.create.call_args.kwargs + self.assertIn("Documentation", call_kwargs["messages"][0]["content"]) + self.assertIn("diff --git", call_kwargs["messages"][1]["content"]) + + def test_run_review_for_mr_posts_documentation_when_diff_refs_missing(self): + review_results = { + "critical": [], + "high": [], + "medium": [], + "low": [], + "summary": "No code issues.", + } + documentation_results = { + "needed": True, + "summary": "Docs should mention behavior.", + "suggestions": [ + { + "file": "README.md", + "reason": "New command behavior", + "suggestion": "Document the behavior.", + } + ], + } + + with mock.patch("ai_code_review.get_branch_diff", return_value="diff"), \ + mock.patch("ai_code_review.review_code", return_value=review_results), \ + mock.patch("ai_code_review.suggest_documentation_updates", return_value=documentation_results), \ + mock.patch("ai_code_review.get_diff_refs", return_value=None), \ + mock.patch("ai_code_review.post_to_merge_request") as post_comment: + ai_code_review.run_review_for_mr(1, 2, "token", "https://gitlab.example/api/v4") + + self.assertEqual(post_comment.call_count, 2) + posted_bodies = [call.args[0] for call in post_comment.call_args_list] + self.assertTrue(any("Documentation Suggestions" in body for body in posted_bodies)) + + +if __name__ == "__main__": + unittest.main()