use crate::ai::types::{IssueDetails, PrReviewComment, PrReviewResponse, TriageResponse};
use crate::utils::is_priority_label;
use std::fmt::Write;
use tracing::debug;
pub const APTU_SIGNATURE: &str = "Generated by Aptu";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageStatus {
pub has_type_label: bool,
pub has_priority_label: bool,
pub has_aptu_comment: bool,
pub label_names: Vec<String>,
}
impl TriageStatus {
#[must_use]
pub fn new(
has_type_label: bool,
has_priority_label: bool,
has_aptu_comment: bool,
label_names: Vec<String>,
) -> Self {
Self {
has_type_label,
has_priority_label,
has_aptu_comment,
label_names,
}
}
#[must_use]
pub fn is_triaged(&self) -> bool {
(self.has_type_label && self.has_priority_label) || self.has_aptu_comment
}
}
fn is_type_label(label: &str) -> bool {
const TYPE_LABELS: &[&str] = &[
"bug",
"enhancement",
"documentation",
"question",
"good first issue",
"help wanted",
"duplicate",
"invalid",
"wontfix",
"triaged",
"needs-triage",
"status: triaged",
];
TYPE_LABELS.contains(&label)
}
fn render_list_section_markdown(
title: &str,
items: &[String],
empty_msg: &str,
numbered: bool,
) -> String {
let mut output = String::new();
let _ = writeln!(output, "### {title}\n");
if items.is_empty() {
let _ = writeln!(output, "{empty_msg}");
} else if numbered {
for (i, item) in items.iter().enumerate() {
let _ = writeln!(output, "{}. {}", i + 1, item);
}
} else {
for item in items {
let _ = writeln!(output, "- {item}");
}
}
output.push('\n');
output
}
fn render_complexity_markdown(output: &mut String, triage: &TriageResponse) {
use crate::ai::types::ComplexityLevel;
let Some(c) = &triage.complexity else {
return;
};
output.push_str("### Complexity\n\n");
let level_str = match c.level {
ComplexityLevel::Low => "Low",
ComplexityLevel::Medium => "Medium",
ComplexityLevel::High => "High",
};
let loc_str = c
.estimated_loc
.map(|l| format!(" (~{l} LOC)"))
.unwrap_or_default();
let _ = writeln!(output, "**Level:** {level_str}{loc_str}");
if c.affected_areas.is_empty() {
output.push('\n');
} else {
let areas: Vec<String> = c.affected_areas.iter().map(|a| format!("`{a}`")).collect();
let _ = writeln!(output, "Affected: {}\n", areas.join(", "));
}
if let Some(rec) = &c.recommendation
&& !rec.is_empty()
{
let _ = writeln!(output, "**Recommendation:** {rec}\n");
}
}
#[must_use]
pub fn render_triage_markdown(triage: &TriageResponse) -> String {
let mut output = String::new();
output.push_str("## Triage Summary\n\n");
output.push_str(&triage.summary);
output.push_str("\n\n");
let labels: Vec<String> = triage
.suggested_labels
.iter()
.map(|l| format!("`{l}`"))
.collect();
output.push_str(&render_list_section_markdown(
"Suggested Labels",
&labels,
"None",
false,
));
if let Some(milestone) = &triage.suggested_milestone
&& !milestone.is_empty()
{
output.push_str("### Suggested Milestone\n\n");
output.push_str(milestone);
output.push_str("\n\n");
}
if let Some(milestone) = &triage.suggested_milestone
&& !milestone.is_empty()
{
output.push_str("### Suggested Milestone\n\n");
output.push_str(milestone);
output.push_str("\n\n");
}
render_complexity_markdown(&mut output, triage);
output.push_str(&render_list_section_markdown(
"Clarifying Questions",
&triage.clarifying_questions,
"None needed",
true,
));
output.push_str(&render_list_section_markdown(
"Potential Duplicates",
&triage.potential_duplicates,
"None found",
false,
));
if !triage.related_issues.is_empty() {
output.push_str("### Related Issues\n\n");
for issue in &triage.related_issues {
let _ = writeln!(output, "- **#{}** - {}", issue.number, issue.title);
let _ = writeln!(output, " > {}\n", issue.reason);
}
}
if let Some(status_note) = &triage.status_note
&& !status_note.is_empty()
{
output.push_str("### Status\n\n");
output.push_str(status_note);
output.push_str("\n\n");
}
if let Some(guidance) = &triage.contributor_guidance {
output.push_str("### Contributor Guidance\n\n");
let beginner_label = if guidance.beginner_friendly {
"**Beginner-friendly**"
} else {
"**Advanced**"
};
let _ = writeln!(output, "{beginner_label}\n");
let _ = writeln!(output, "{}\n", guidance.reasoning);
}
if let Some(approach) = &triage.implementation_approach
&& !approach.is_empty()
{
output.push_str("### Implementation Approach\n\n");
for line in approach.lines() {
let _ = writeln!(output, " {line}");
}
output.push('\n');
}
output.push_str("---\n");
output.push('*');
output.push_str(APTU_SIGNATURE);
output.push('*');
output.push('\n');
output
}
pub fn check_already_triaged(issue: &IssueDetails) -> TriageStatus {
let has_type_label = issue.labels.iter().any(|label| is_type_label(label));
let has_priority_label = issue.labels.iter().any(|label| is_priority_label(label));
let label_names: Vec<String> = issue
.labels
.iter()
.filter(|label| is_type_label(label) || is_priority_label(label))
.cloned()
.collect();
let has_aptu_comment = issue
.comments
.iter()
.any(|comment| comment.body.contains(APTU_SIGNATURE));
if has_type_label || has_priority_label || has_aptu_comment {
debug!(
has_type_label = has_type_label,
has_priority_label = has_priority_label,
has_aptu_comment = has_aptu_comment,
labels = ?label_names,
"Issue triage status detected"
);
}
TriageStatus::new(
has_type_label,
has_priority_label,
has_aptu_comment,
label_names,
)
}
#[must_use]
pub fn render_pr_review_comment_body(comment: &PrReviewComment) -> String {
let mut body = comment.comment.clone();
if let Some(code) = &comment.suggested_code
&& !code.is_empty()
{
body.push_str("\n\n```suggestion\n");
body.push_str(code);
body.push_str("\n```");
}
body
}
#[must_use]
pub fn render_pr_review_markdown(review: &PrReviewResponse, files_count: usize) -> String {
let verdict_badge = match review.verdict.as_str() {
"approve" => "✅ Approve",
"request_changes" | "request-changes" => "❌ Request Changes",
_ => "💬 Comment",
};
let mut body = format!(
"<!-- APTU_REVIEW -->\n## Aptu Review\n\n**{}** — {}\n",
verdict_badge, review.summary
);
if files_count > 5 && !review.concerns.is_empty() {
body.push('\n');
for c in &review.concerns {
let _ = writeln!(body, "- {c}");
}
}
body.push_str("\n---\n\n<sub>Posted by [aptu](https://github.com/clouatre-labs/aptu)</sub>\n");
body
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::types::{CommentSeverity, IssueComment};
fn create_test_issue(labels: Vec<String>, comments: Vec<IssueComment>) -> IssueDetails {
IssueDetails::builder()
.owner("test".to_string())
.repo("repo".to_string())
.number(1)
.title("Test issue".to_string())
.body("Test body".to_string())
.labels(labels)
.comments(comments)
.url("https://github.com/test/repo/issues/1".to_string())
.build()
}
#[test]
fn test_no_triage() {
let issue = create_test_issue(vec![], vec![]);
let status = check_already_triaged(&issue);
assert!(!status.is_triaged());
assert!(!status.has_type_label);
assert!(!status.has_priority_label);
assert!(!status.has_aptu_comment);
assert!(status.label_names.is_empty());
}
#[test]
fn test_type_label_only() {
let labels = vec!["bug".to_string()];
let issue = create_test_issue(labels, vec![]);
let status = check_already_triaged(&issue);
assert!(!status.is_triaged());
assert!(status.has_type_label);
assert!(!status.has_priority_label);
assert!(!status.has_aptu_comment);
assert_eq!(status.label_names.len(), 1);
}
#[test]
fn test_priority_label_only() {
let labels = vec!["p1".to_string()];
let issue = create_test_issue(labels, vec![]);
let status = check_already_triaged(&issue);
assert!(!status.is_triaged());
assert!(!status.has_type_label);
assert!(status.has_priority_label);
assert!(!status.has_aptu_comment);
assert_eq!(status.label_names.len(), 1);
}
#[test]
fn test_type_and_priority_labels() {
let labels = vec!["bug".to_string(), "p1".to_string()];
let issue = create_test_issue(labels, vec![]);
let status = check_already_triaged(&issue);
assert!(status.is_triaged());
assert!(status.has_type_label);
assert!(status.has_priority_label);
assert!(!status.has_aptu_comment);
assert_eq!(status.label_names.len(), 2);
}
#[test]
fn test_priority_prefix_labels() {
for priority in ["priority: high", "priority: medium", "priority: low"] {
let labels = vec!["bug".to_string(), priority.to_string()];
let issue = create_test_issue(labels, vec![]);
let status = check_already_triaged(&issue);
assert!(status.is_triaged(), "Failed for {priority}");
assert!(status.has_type_label, "Failed for {priority}");
assert!(status.has_priority_label, "Failed for {priority}");
}
}
#[test]
fn test_aptu_comment_only() {
let comments = vec![IssueComment {
author: "aptu-bot".to_string(),
body: "This looks good. Generated by Aptu".to_string(),
}];
let issue = create_test_issue(vec![], comments);
let status = check_already_triaged(&issue);
assert!(status.is_triaged());
assert!(!status.has_type_label);
assert!(!status.has_priority_label);
assert!(status.has_aptu_comment);
assert!(status.label_names.is_empty());
}
#[test]
fn test_type_label_with_aptu_comment() {
let labels = vec!["bug".to_string()];
let comments = vec![IssueComment {
author: "aptu-bot".to_string(),
body: "Generated by Aptu".to_string(),
}];
let issue = create_test_issue(labels, comments);
let status = check_already_triaged(&issue);
assert!(status.is_triaged());
assert!(status.has_type_label);
assert!(!status.has_priority_label);
assert!(status.has_aptu_comment);
}
#[test]
fn test_partial_signature_no_match() {
let comments = vec![IssueComment {
author: "other-bot".to_string(),
body: "Generated by AnotherTool".to_string(),
}];
let issue = create_test_issue(vec![], comments);
let status = check_already_triaged(&issue);
assert!(!status.is_triaged());
assert!(!status.has_aptu_comment);
}
#[test]
fn test_irrelevant_labels() {
let labels = vec!["component: ui".to_string(), "needs-review".to_string()];
let issue = create_test_issue(labels, vec![]);
let status = check_already_triaged(&issue);
assert!(!status.is_triaged());
assert!(!status.has_type_label);
assert!(!status.has_priority_label);
assert!(status.label_names.is_empty());
}
#[test]
fn test_priority_label_case_insensitive() {
let labels = vec!["bug".to_string(), "P2".to_string()];
let issue = create_test_issue(labels, vec![]);
let status = check_already_triaged(&issue);
assert!(status.is_triaged());
assert!(status.has_priority_label);
}
#[test]
fn test_priority_prefix_case_insensitive() {
let labels = vec!["enhancement".to_string(), "Priority: HIGH".to_string()];
let issue = create_test_issue(labels, vec![]);
let status = check_already_triaged(&issue);
assert!(status.is_triaged());
assert!(status.has_priority_label);
}
#[test]
fn test_render_triage_markdown_basic() {
let triage = TriageResponse {
summary: "This is a test summary".to_string(),
implementation_approach: None,
clarifying_questions: vec!["Question 1?".to_string()],
potential_duplicates: vec![],
related_issues: vec![],
suggested_labels: vec!["bug".to_string()],
suggested_milestone: None,
status_note: None,
contributor_guidance: None,
complexity: None,
};
let markdown = render_triage_markdown(&triage);
assert!(markdown.contains("## Triage Summary"));
assert!(markdown.contains("This is a test summary"));
assert!(markdown.contains("### Clarifying Questions"));
assert!(markdown.contains("1. Question 1?"));
assert!(markdown.contains(APTU_SIGNATURE));
}
#[test]
fn test_render_triage_markdown_with_labels() {
let triage = TriageResponse {
summary: "Summary".to_string(),
implementation_approach: None,
clarifying_questions: vec![],
potential_duplicates: vec![],
related_issues: vec![],
suggested_labels: vec!["bug".to_string(), "p1".to_string()],
suggested_milestone: None,
status_note: None,
contributor_guidance: None,
complexity: None,
};
let markdown = render_triage_markdown(&triage);
assert!(markdown.contains("### Suggested Labels"));
assert!(markdown.contains("`bug`"));
assert!(markdown.contains("`p1`"));
}
#[test]
fn test_render_triage_markdown_multiline_approach() {
let triage = TriageResponse {
summary: "Summary".to_string(),
implementation_approach: Some("Line 1\nLine 2\nLine 3".to_string()),
clarifying_questions: vec![],
potential_duplicates: vec![],
related_issues: vec![],
suggested_labels: vec![],
suggested_milestone: None,
status_note: None,
contributor_guidance: None,
complexity: None,
};
let markdown = render_triage_markdown(&triage);
assert!(markdown.contains("### Implementation Approach"));
assert!(markdown.contains("Line 1"));
assert!(markdown.contains("Line 2"));
assert!(markdown.contains("Line 3"));
}
fn make_pr_review() -> PrReviewResponse {
PrReviewResponse {
summary: "Good PR overall.".to_string(),
verdict: "approve".to_string(),
strengths: vec!["Clean code".to_string(), "Good tests".to_string()],
concerns: vec!["Missing docs".to_string()],
comments: vec![PrReviewComment {
file: "src/lib.rs".to_string(),
line: Some(42),
comment: "Consider using a match here.".to_string(),
severity: CommentSeverity::Suggestion,
suggested_code: None,
}],
suggestions: vec!["Add a CHANGELOG entry.".to_string()],
disclaimer: Some("AI-generated review.".to_string()),
}
}
#[test]
fn test_render_pr_review_markdown_basic() {
let review = make_pr_review();
let body = render_pr_review_markdown(&review, 0);
assert!(body.contains("<!-- APTU_REVIEW -->"));
assert!(body.contains("✅ Approve"));
assert!(body.contains("Good PR overall."));
assert!(body.contains("aptu"));
}
#[test]
fn test_render_pr_review_markdown_empty_arrays() {
let review = PrReviewResponse {
summary: "LGTM".to_string(),
verdict: "approve".to_string(),
strengths: vec![],
concerns: vec![],
comments: vec![],
suggestions: vec![],
disclaimer: None,
};
let body = render_pr_review_markdown(&review, 3);
assert!(body.contains("<!-- APTU_REVIEW -->"));
assert!(!body.contains("### Strengths"));
assert!(!body.contains("### Concerns"));
assert!(!body.contains("### Inline Comments"));
assert!(!body.contains("### Suggestions"));
}
#[test]
fn test_render_pr_review_markdown_verdict_badges() {
let mut r = make_pr_review();
r.verdict = "approve".to_string();
assert!(render_pr_review_markdown(&r, 0).contains("✅ Approve"));
r.verdict = "request_changes".to_string();
assert!(render_pr_review_markdown(&r, 0).contains("❌ Request Changes"));
r.verdict = "request-changes".to_string();
assert!(render_pr_review_markdown(&r, 0).contains("❌ Request Changes"));
r.verdict = "comment".to_string();
assert!(render_pr_review_markdown(&r, 0).contains("💬 Comment"));
}
#[test]
fn test_render_pr_review_comment_body_plain_text() {
let base = PrReviewComment {
file: "f.rs".to_string(),
line: Some(1),
comment: "test msg".to_string(),
severity: CommentSeverity::Issue,
suggested_code: None,
};
let body = render_pr_review_comment_body(&base);
assert!(!body.contains("[!CAUTION]"));
assert!(!body.contains("[!WARNING]"));
assert!(!body.contains("[!TIP]"));
assert!(!body.contains("[!NOTE]"));
assert!(body.contains("test msg"));
let w = PrReviewComment {
severity: CommentSeverity::Warning,
..base.clone()
};
assert!(!render_pr_review_comment_body(&w).contains("[!"));
let s = PrReviewComment {
severity: CommentSeverity::Suggestion,
..base.clone()
};
assert!(!render_pr_review_comment_body(&s).contains("[!"));
let i = PrReviewComment {
severity: CommentSeverity::Info,
..base.clone()
};
assert!(!render_pr_review_comment_body(&i).contains("[!"));
}
#[test]
fn test_render_pr_review_markdown_notable_changes_shown() {
let mut review = make_pr_review();
review.concerns = vec![
"Removes CodeQL without replacement".to_string(),
"cargo-nextest not pinned".to_string(),
];
let body = render_pr_review_markdown(&review, 6);
assert!(body.contains("- Removes CodeQL without replacement"));
assert!(body.contains("- cargo-nextest not pinned"));
}
#[test]
fn test_render_pr_review_markdown_notable_changes_hidden() {
let mut review = make_pr_review();
review.concerns = vec!["Some concern".to_string()];
let body = render_pr_review_markdown(&review, 3);
assert!(!body.contains("- Some concern"));
}
#[test]
fn test_render_pr_review_comment_body_with_suggestion() {
let comment = PrReviewComment {
file: "src/main.rs".to_string(),
line: Some(10),
comment: "Use ? instead of unwrap.".to_string(),
severity: CommentSeverity::Warning,
suggested_code: Some(" let x = foo()?;\n".to_string()),
};
let body = render_pr_review_comment_body(&comment);
assert!(!body.contains("[!"));
assert!(body.contains("Use ? instead of unwrap."));
assert!(body.contains("```suggestion"));
assert!(body.contains("let x = foo()?;"));
}
#[test]
fn test_render_pr_review_comment_body_without_suggestion() {
let comment = PrReviewComment {
file: "src/main.rs".to_string(),
line: Some(10),
comment: "Consider refactoring this module.".to_string(),
severity: CommentSeverity::Info,
suggested_code: None,
};
let body = render_pr_review_comment_body(&comment);
assert!(!body.contains("[!"));
assert!(!body.contains("```suggestion"));
}
}