use crate::ai::types::{IssueDetails, 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
}
#[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");
}
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
}
#[must_use]
pub fn render_release_notes_markdown(response: &crate::ai::types::ReleaseNotesResponse) -> String {
use std::fmt::Write;
let mut body = String::new();
let _ = writeln!(body, "## {}\n", response.theme);
if !response.narrative.is_empty() {
let _ = writeln!(body, "{}\n", response.narrative);
}
if !response.highlights.is_empty() {
body.push_str("### Highlights\n\n");
for highlight in &response.highlights {
let _ = writeln!(body, "- {highlight}");
}
body.push('\n');
}
if !response.features.is_empty() {
body.push_str("### Features\n\n");
for feature in &response.features {
let _ = writeln!(body, "- {feature}");
}
body.push('\n');
}
if !response.fixes.is_empty() {
body.push_str("### Fixes\n\n");
for fix in &response.fixes {
let _ = writeln!(body, "- {fix}");
}
body.push('\n');
}
if !response.improvements.is_empty() {
body.push_str("### Improvements\n\n");
for improvement in &response.improvements {
let _ = writeln!(body, "- {improvement}");
}
body.push('\n');
}
if !response.documentation.is_empty() {
body.push_str("### Documentation\n\n");
for doc in &response.documentation {
let _ = writeln!(body, "- {doc}");
}
body.push('\n');
}
if !response.maintenance.is_empty() {
body.push_str("### Maintenance\n\n");
for maint in &response.maintenance {
let _ = writeln!(body, "- {maint}");
}
body.push('\n');
}
if !response.contributors.is_empty() {
body.push_str("### Contributors\n\n");
for contributor in &response.contributors {
let _ = writeln!(body, "- {contributor}");
}
}
body
}
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,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::types::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,
};
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,
};
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,
};
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"));
}
}