Skip to content

Commit 4b8d965

Browse files
committed
feat: suggest documentation updates during review
1 parent 2ff626a commit 4b8d965

3 files changed

Lines changed: 206 additions & 1 deletion

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ To submit merge request into review run command:
124124
gh review
125125
```
126126

127+
If `OPENAI_API_KEY` is configured, the review command also checks the diff for
128+
documentation impact and posts suggested documentation updates to the merge
129+
request when user-facing behavior, setup, configuration, or command usage
130+
changes.
131+
127132
To also enable **auto-merge when the pipeline succeeds**, add `--auto_merge` or `-am` flag:
128133

129134
```
@@ -225,4 +230,3 @@ I suggest checking Gitlab's official API documentation: https://docs.gitlab.com/
225230
## Donating 💜
226231

227232
Make sure to check this project on [OpenPledge](https://app.openpledge.io/repositories/zigcBenx/gitHappens).
228-

ai_code_review.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,35 @@ class Colors:
4545
- MEDIUM: Code smells, potential bugs, missing error handling
4646
- LOW: Minor improvements, suggestions, style inconsistencies"""
4747

48+
DOCUMENTATION_PROMPT = """Documentation review: you are a technical writer reviewing a git diff for documentation impact.
49+
50+
Output ONLY valid JSON - no markdown, no code blocks, no explanations.
51+
52+
Identify whether the diff changes user-facing behavior, setup steps, configuration,
53+
commands, flags, public APIs, or operational workflows that should be documented.
54+
Do not suggest documentation for purely internal refactors, test-only changes, or
55+
minor implementation details that users do not need to know.
56+
57+
Output format:
58+
{
59+
"needed": true,
60+
"summary": "one sentence describing why docs should change",
61+
"suggestions": [
62+
{
63+
"file": "README.md",
64+
"reason": "what changed in the diff",
65+
"suggestion": "specific documentation update to make"
66+
}
67+
]
68+
}
69+
70+
If no documentation update is needed, return:
71+
{
72+
"needed": false,
73+
"summary": "No documentation updates needed.",
74+
"suggestions": []
75+
}"""
76+
4877
def get_branch_diff():
4978
"""Get the diff of changed files in current branch vs main branch."""
5079
try:
@@ -130,6 +159,32 @@ def review_code(diff_content):
130159
print(f"{Colors.CRITICAL}✗ Error during AI review: {e}{Colors.RESET}")
131160
return None
132161

162+
def suggest_documentation_updates(diff_content):
163+
"""Send code diff to OpenAI for documentation update suggestions."""
164+
openai = get_openai_client()
165+
if not openai:
166+
return None
167+
168+
try:
169+
response = openai.chat.completions.create(
170+
model="gpt-4o",
171+
messages=[
172+
{"role": "system", "content": DOCUMENTATION_PROMPT},
173+
{"role": "user", "content": f"Documentation review for this git diff:\n\n{diff_content}"}
174+
],
175+
temperature=0.2,
176+
response_format={"type": "json_object"}
177+
)
178+
179+
return json.loads(response.choices[0].message.content)
180+
except json.JSONDecodeError as e:
181+
print(f"{Colors.CRITICAL}✗ Failed to parse documentation response as JSON{Colors.RESET}")
182+
print(f"{Colors.DIM}Error: {e}{Colors.RESET}")
183+
return None
184+
except Exception as e:
185+
print(f"{Colors.CRITICAL}✗ Error during documentation review: {e}{Colors.RESET}")
186+
return None
187+
133188
def print_issues(issues, severity, color, icon):
134189
"""Print issues with consistent formatting."""
135190
if not issues:
@@ -213,6 +268,41 @@ def format_issues(issues, severity, emoji):
213268

214269
return comment
215270

271+
def format_documentation_comment(results):
272+
"""Format documentation suggestions as a GitLab markdown comment."""
273+
if not results or not results.get('needed'):
274+
return None
275+
276+
comment = "## Documentation Suggestions\n\n"
277+
278+
summary = results.get('summary', '')
279+
if summary:
280+
comment += f"{summary}\n\n"
281+
282+
suggestions = results.get('suggestions', [])
283+
if not suggestions:
284+
comment += "- **`Documentation`**: Review the diff and update documentation as needed.\n"
285+
return comment
286+
287+
for suggestion in suggestions:
288+
file_path = suggestion.get('file', 'Documentation')
289+
reason = suggestion.get('reason', 'Documentation may need an update.')
290+
update = suggestion.get('suggestion', 'Review the diff and update documentation as needed.')
291+
comment += f"- **`{file_path}`**: {update}\n"
292+
comment += f" - Reason: {reason}\n"
293+
294+
return comment
295+
296+
def display_documentation_results(results):
297+
"""Display documentation suggestions in the terminal."""
298+
comment = format_documentation_comment(results)
299+
if not comment:
300+
print(f"{Colors.INFO}ℹ No documentation updates suggested{Colors.RESET}")
301+
return
302+
303+
print(f"\n{Colors.BOLD}DOCUMENTATION SUGGESTIONS{Colors.RESET}")
304+
print(comment)
305+
216306
def get_merge_request_changes(project_id, mr_id, gitlab_token, api_url):
217307
"""Get the changes (diffs) from the merge request to find commit SHAs."""
218308
import requests
@@ -340,6 +430,11 @@ def run_review():
340430
sys.exit(0)
341431
display_review_results(results)
342432

433+
print(f"{Colors.INFO}📝 Checking documentation impact...{Colors.RESET}")
434+
documentation_results = suggest_documentation_updates(diff_content)
435+
if documentation_results:
436+
display_documentation_results(documentation_results)
437+
343438
def run_review_for_mr(project_id, mr_id, gitlab_token, api_url):
344439
"""Run AI code review and post inline comments to GitLab merge request."""
345440
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):
353448
print(f"{Colors.HIGH}⚠ AI review skipped{Colors.RESET}")
354449
return
355450

451+
documentation_results = suggest_documentation_updates(diff_content)
452+
documentation_comment = format_documentation_comment(documentation_results)
453+
356454
# Get diff refs for inline comments
357455
diff_refs = get_diff_refs(project_id, mr_id, gitlab_token, api_url)
358456
if not diff_refs or not all(diff_refs.values()):
359457
print(f"{Colors.HIGH}⚠ Could not get diff refs, posting summary only{Colors.RESET}")
360458
comment = format_gitlab_comment(results)
361459
post_to_merge_request(comment, project_id, mr_id, gitlab_token, api_url)
460+
if documentation_comment:
461+
post_to_merge_request(documentation_comment, project_id, mr_id, gitlab_token, api_url)
362462
return
363463

364464
# Post inline comments for each issue
@@ -386,5 +486,8 @@ def run_review_for_mr(project_id, mr_id, gitlab_token, api_url):
386486
else:
387487
print(f"{Colors.INFO}✓ All {total_posted} issues posted as inline comments{Colors.RESET}")
388488

489+
if documentation_comment:
490+
post_to_merge_request(documentation_comment, project_id, mr_id, gitlab_token, api_url)
491+
389492
if __name__ == '__main__':
390493
run_review()

tests/test_ai_code_review.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import json
2+
import unittest
3+
from unittest import mock
4+
5+
import ai_code_review
6+
7+
8+
class DocumentationSuggestionTest(unittest.TestCase):
9+
def test_format_documentation_comment_returns_none_without_suggestions(self):
10+
result = ai_code_review.format_documentation_comment({
11+
"needed": False,
12+
"summary": "No docs needed",
13+
"suggestions": [],
14+
})
15+
16+
self.assertIsNone(result)
17+
18+
def test_format_documentation_comment_includes_summary_and_suggestions(self):
19+
result = ai_code_review.format_documentation_comment({
20+
"needed": True,
21+
"summary": "The CLI behavior changed.",
22+
"suggestions": [
23+
{
24+
"file": "README.md",
25+
"reason": "New flag added",
26+
"suggestion": "Document the --select flag in the review section.",
27+
}
28+
],
29+
})
30+
31+
self.assertIn("## Documentation Suggestions", result)
32+
self.assertIn("The CLI behavior changed.", result)
33+
self.assertIn("README.md", result)
34+
self.assertIn("Document the --select flag", result)
35+
36+
def test_format_documentation_comment_keeps_summary_without_suggestions(self):
37+
result = ai_code_review.format_documentation_comment({
38+
"needed": True,
39+
"summary": "Docs should mention that review can post multiple comments.",
40+
"suggestions": [],
41+
})
42+
43+
self.assertIn("## Documentation Suggestions", result)
44+
self.assertIn("Docs should mention that review can post multiple comments.", result)
45+
self.assertIn("Review the diff and update documentation as needed.", result)
46+
47+
def test_suggest_documentation_updates_uses_diff_content(self):
48+
fake_openai = mock.Mock()
49+
fake_openai.chat.completions.create.return_value.choices = [
50+
mock.Mock(message=mock.Mock(content=json.dumps({
51+
"needed": True,
52+
"summary": "Docs should mention behavior.",
53+
"suggestions": [],
54+
})))
55+
]
56+
57+
with mock.patch("ai_code_review.get_openai_client", return_value=fake_openai):
58+
result = ai_code_review.suggest_documentation_updates("diff --git a/file.py b/file.py")
59+
60+
self.assertTrue(result["needed"])
61+
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
62+
self.assertIn("Documentation", call_kwargs["messages"][0]["content"])
63+
self.assertIn("diff --git", call_kwargs["messages"][1]["content"])
64+
65+
def test_run_review_for_mr_posts_documentation_when_diff_refs_missing(self):
66+
review_results = {
67+
"critical": [],
68+
"high": [],
69+
"medium": [],
70+
"low": [],
71+
"summary": "No code issues.",
72+
}
73+
documentation_results = {
74+
"needed": True,
75+
"summary": "Docs should mention behavior.",
76+
"suggestions": [
77+
{
78+
"file": "README.md",
79+
"reason": "New command behavior",
80+
"suggestion": "Document the behavior.",
81+
}
82+
],
83+
}
84+
85+
with mock.patch("ai_code_review.get_branch_diff", return_value="diff"), \
86+
mock.patch("ai_code_review.review_code", return_value=review_results), \
87+
mock.patch("ai_code_review.suggest_documentation_updates", return_value=documentation_results), \
88+
mock.patch("ai_code_review.get_diff_refs", return_value=None), \
89+
mock.patch("ai_code_review.post_to_merge_request") as post_comment:
90+
ai_code_review.run_review_for_mr(1, 2, "token", "https://gitlab.example/api/v4")
91+
92+
self.assertEqual(post_comment.call_count, 2)
93+
posted_bodies = [call.args[0] for call in post_comment.call_args_list]
94+
self.assertTrue(any("Documentation Suggestions" in body for body in posted_bodies))
95+
96+
97+
if __name__ == "__main__":
98+
unittest.main()

0 commit comments

Comments
 (0)