Skip to content
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down Expand Up @@ -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).

103 changes: 103 additions & 0 deletions ai_code_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 on lines +271 to +274

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
Expand Down Expand Up @@ -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}")
Expand All @@ -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
Expand Down Expand Up @@ -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()
98 changes: 98 additions & 0 deletions tests/test_ai_code_review.py
Original file line number Diff line number Diff line change
@@ -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()