feat: Add @codebot explain-diff command for plain-language PR explanations
All checks were successful
Enterprise AI Code Review / ai-review (pull_request) Successful in 39s

Implements code diff explainer that translates technical changes into
plain language for non-technical stakeholders (PMs, designers, new team members).

Features:
- Plain-language explanations without jargon
- File-by-file breakdown with 'what' and 'why' context
- Architecture impact analysis
- Breaking change detection
- Perfect for onboarding and cross-functional reviews

Implementation:
- Added explain_diff.md prompt template with plain-language guidelines
- Implemented _handle_explain_diff_command() in PRAgent
- Added _format_diff_explanation() for readable markdown
- Updated PRAgent.can_handle() to route explain-diff commands
- Added 'explain-diff' to config.yml commands list

Workflow Safety (prevents duplicate runs):
- Added '@codebot explain-diff' to ai-comment-reply.yml conditions
- Excluded from ai-chat.yml to prevent duplication
- Only triggers on PR comments (not issues)
- Manual command only (no automatic triggering)

Testing:
- 9 comprehensive tests in TestDiffExplanation class
- Tests command detection, formatting, plain-language output
- Verifies prompt formatting and empty section handling

Documentation:
- Updated README.md with explain-diff command and examples
- Added detailed implementation guide in CLAUDE.md
- Included plain-language rules and use cases

Related: Milestone 2 high-priority feature - code diff explainer
This commit is contained in:
2025-12-29 12:44:54 +00:00
parent 1d468e360e
commit 37f3eb45d0
8 changed files with 680 additions and 3 deletions

View File

@@ -100,7 +100,15 @@ class PRAgent(BaseAgent):
)
has_summarize = f"{mention_prefix} summarize" in comment_body.lower()
has_changelog = f"{mention_prefix} changelog" in comment_body.lower()
return is_pr and (has_review_again or has_summarize or has_changelog)
has_explain_diff = (
f"{mention_prefix} explain-diff" in comment_body.lower()
)
return is_pr and (
has_review_again
or has_summarize
or has_changelog
or has_explain_diff
)
return False
@@ -116,6 +124,8 @@ class PRAgent(BaseAgent):
return self._handle_summarize_command(context)
elif f"{mention_prefix} changelog" in comment_body.lower():
return self._handle_changelog_command(context)
elif f"{mention_prefix} explain-diff" in comment_body.lower():
return self._handle_explain_diff_command(context)
elif f"{mention_prefix} review-again" in comment_body.lower():
return self._handle_review_again(context)
@@ -1211,3 +1221,193 @@ class PRAgent(BaseAgent):
lines.append(f"- **Main components:** {', '.join(components)}")
return "\n".join(lines)
def _handle_explain_diff_command(self, context: AgentContext) -> AgentResult:
"""Handle @codebot explain-diff command from PR comments.
Generates plain-language explanation of code changes for non-technical stakeholders.
Args:
context: Agent context with event data
Returns:
AgentResult with success status and actions taken
"""
issue = context.event_data.get("issue", {})
pr_number = issue.get("number")
comment_author = (
context.event_data.get("comment", {}).get("user", {}).get("login", "user")
)
self.logger.info(
f"Generating diff explanation for PR #{pr_number} at user request"
)
try:
# Get PR data
pr = self.gitea.get_pull_request(context.owner, context.repo, pr_number)
pr_title = pr.get("title", "")
pr_description = pr.get("body", "")
# Get PR diff
diff = self._get_diff(context.owner, context.repo, pr_number)
if not diff.strip():
error_msg = (
f"@{comment_author}\n\n"
f"{self.AI_DISCLAIMER}\n\n"
"**⚠️ Diff Explanation Failed**\n\n"
"No changes found in this PR to explain."
)
self.gitea.create_issue_comment(
context.owner, context.repo, pr_number, error_msg
)
return AgentResult(
success=False,
message=f"No diff to explain for PR #{pr_number}",
)
# Load explain_diff prompt
prompt_template = self.load_prompt("explain_diff")
prompt = prompt_template.format(
pr_title=pr_title,
pr_description=pr_description or "(No description provided)",
)
prompt = f"{prompt}\n{diff}"
# Call LLM to generate explanation
result = self.call_llm_json(prompt)
# Format the explanation comment
explanation_comment = self._format_diff_explanation(result, pr_number)
# Post explanation comment
self.gitea.create_issue_comment(
context.owner, context.repo, pr_number, explanation_comment
)
return AgentResult(
success=True,
message=f"Generated diff explanation for PR #{pr_number}",
actions_taken=["Posted diff explanation comment"],
)
except Exception as e:
self.logger.error(f"Failed to generate diff explanation: {e}")
# Post error message
error_msg = (
f"@{comment_author}\n\n"
f"{self.AI_DISCLAIMER}\n\n"
"**⚠️ Diff Explanation Failed**\n\n"
f"I encountered an error while generating the explanation: {str(e)}\n\n"
"This could be due to:\n"
"- The PR is too large to analyze\n"
"- The LLM service is temporarily unavailable\n"
"- An unexpected error occurred"
)
self.gitea.create_issue_comment(
context.owner, context.repo, pr_number, error_msg
)
return AgentResult(
success=False,
message=f"Failed to generate diff explanation for PR #{pr_number}",
error=str(e),
)
def _format_diff_explanation(self, explanation_data: dict, pr_number: int) -> str:
"""Format diff explanation data into readable markdown.
Args:
explanation_data: JSON data from LLM containing explanation
pr_number: PR number for reference
Returns:
Formatted markdown explanation
"""
lines = [
self.AI_DISCLAIMER,
"",
f"## 📖 Code Changes Explained (PR #{pr_number})",
"",
]
# Overview
overview = explanation_data.get("overview", "")
if overview:
lines.append("### 🎯 Overview")
lines.append(overview)
lines.append("")
# Key changes
key_changes = explanation_data.get("key_changes", [])
if key_changes:
lines.append("### 🔍 What Changed")
lines.append("")
for change in key_changes:
file_path = change.get("file", "unknown")
status = change.get("status", "modified")
explanation = change.get("explanation", "")
why_it_matters = change.get("why_it_matters", "")
# Status emoji
status_emoji = {"new": "", "modified": "📝", "deleted": "🗑️"}
emoji = status_emoji.get(status, "📝")
lines.append(f"#### {emoji} `{file_path}` ({status})")
lines.append(f"**What changed:** {explanation}")
if why_it_matters:
lines.append(f"**Why it matters:** {why_it_matters}")
lines.append("")
# Architecture impact
arch_impact = explanation_data.get("architecture_impact", {})
if arch_impact and arch_impact.get("description"):
lines.append("---")
lines.append("")
lines.append("### 🏗️ Architecture Impact")
lines.append(arch_impact.get("description", ""))
lines.append("")
new_deps = arch_impact.get("new_dependencies", [])
if new_deps:
lines.append("**New dependencies:**")
for dep in new_deps:
lines.append(f"- {dep}")
lines.append("")
affected = arch_impact.get("affected_components", [])
if affected:
lines.append("**Affected components:**")
for comp in affected:
lines.append(f"- {comp}")
lines.append("")
# Breaking changes
breaking = explanation_data.get("breaking_changes", [])
if breaking:
lines.append("---")
lines.append("")
lines.append("### ⚠️ Breaking Changes")
for change in breaking:
lines.append(f"- **{change}**")
lines.append("")
# Technical details
tech = explanation_data.get("technical_details", {})
if tech:
lines.append("---")
lines.append("")
lines.append("### 📊 Technical Summary")
files = tech.get("files_changed", 0)
additions = tech.get("insertions", 0)
deletions = tech.get("deletions", 0)
lines.append(f"- **Files changed:** {files}")
lines.append(f"- **Lines:** +{additions} / -{deletions}")
components = tech.get("main_components", [])
if components:
lines.append(f"- **Components:** {', '.join(components)}")
return "\n".join(lines)