1use crate::ai::types::{IssueDetails, PrReviewComment, PrReviewResponse, TriageResponse};
9use crate::utils::is_priority_label;
10use std::fmt::Write;
11use tracing::debug;
12
13pub const APTU_SIGNATURE: &str = "Generated by Aptu";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TriageStatus {
19 pub has_type_label: bool,
21 pub has_priority_label: bool,
23 pub has_aptu_comment: bool,
25 pub label_names: Vec<String>,
27}
28
29impl TriageStatus {
30 #[must_use]
32 pub fn new(
33 has_type_label: bool,
34 has_priority_label: bool,
35 has_aptu_comment: bool,
36 label_names: Vec<String>,
37 ) -> Self {
38 Self {
39 has_type_label,
40 has_priority_label,
41 has_aptu_comment,
42 label_names,
43 }
44 }
45
46 #[must_use]
48 pub fn is_triaged(&self) -> bool {
49 (self.has_type_label && self.has_priority_label) || self.has_aptu_comment
50 }
51}
52
53fn is_type_label(label: &str) -> bool {
55 const TYPE_LABELS: &[&str] = &[
56 "bug",
57 "enhancement",
58 "documentation",
59 "question",
60 "good first issue",
61 "help wanted",
62 "duplicate",
63 "invalid",
64 "wontfix",
65 "triaged",
66 "needs-triage",
67 "status: triaged",
68 ];
69 TYPE_LABELS.contains(&label)
70}
71
72fn render_list_section_markdown(
74 title: &str,
75 items: &[String],
76 empty_msg: &str,
77 numbered: bool,
78) -> String {
79 let mut output = String::new();
80 let _ = writeln!(output, "### {title}\n");
81 if items.is_empty() {
82 let _ = writeln!(output, "{empty_msg}");
83 } else if numbered {
84 for (i, item) in items.iter().enumerate() {
85 let _ = writeln!(output, "{}. {}", i + 1, item);
86 }
87 } else {
88 for item in items {
89 let _ = writeln!(output, "- {item}");
90 }
91 }
92 output.push('\n');
93 output
94}
95
96fn render_complexity_markdown(output: &mut String, triage: &TriageResponse) {
98 use crate::ai::types::ComplexityLevel;
99 let Some(c) = &triage.complexity else {
100 return;
101 };
102 output.push_str("### Complexity\n\n");
103 let level_str = match c.level {
104 ComplexityLevel::Low => "Low",
105 ComplexityLevel::Medium => "Medium",
106 ComplexityLevel::High => "High",
107 };
108 let loc_str = c
109 .estimated_loc
110 .map(|l| format!(" (~{l} LOC)"))
111 .unwrap_or_default();
112 let _ = writeln!(output, "**Level:** {level_str}{loc_str}");
113 if c.affected_areas.is_empty() {
114 output.push('\n');
115 } else {
116 let areas: Vec<String> = c.affected_areas.iter().map(|a| format!("`{a}`")).collect();
117 let _ = writeln!(output, "Affected: {}\n", areas.join(", "));
118 }
119 if let Some(rec) = &c.recommendation
120 && !rec.is_empty()
121 {
122 let _ = writeln!(output, "**Recommendation:** {rec}\n");
123 }
124}
125
126#[must_use]
139pub fn render_triage_markdown(triage: &TriageResponse) -> String {
140 let mut output = String::new();
141
142 output.push_str("## Triage Summary\n\n");
144 output.push_str(&triage.summary);
145 output.push_str("\n\n");
146
147 let labels: Vec<String> = triage
149 .suggested_labels
150 .iter()
151 .map(|l| format!("`{l}`"))
152 .collect();
153 output.push_str(&render_list_section_markdown(
154 "Suggested Labels",
155 &labels,
156 "None",
157 false,
158 ));
159
160 if let Some(milestone) = &triage.suggested_milestone
162 && !milestone.is_empty()
163 {
164 output.push_str("### Suggested Milestone\n\n");
165 output.push_str(milestone);
166 output.push_str("\n\n");
167 }
168
169 if let Some(milestone) = &triage.suggested_milestone
171 && !milestone.is_empty()
172 {
173 output.push_str("### Suggested Milestone\n\n");
174 output.push_str(milestone);
175 output.push_str("\n\n");
176 }
177
178 render_complexity_markdown(&mut output, triage);
180
181 output.push_str(&render_list_section_markdown(
183 "Clarifying Questions",
184 &triage.clarifying_questions,
185 "None needed",
186 true,
187 ));
188
189 output.push_str(&render_list_section_markdown(
191 "Potential Duplicates",
192 &triage.potential_duplicates,
193 "None found",
194 false,
195 ));
196
197 if !triage.related_issues.is_empty() {
199 output.push_str("### Related Issues\n\n");
200 for issue in &triage.related_issues {
201 let _ = writeln!(output, "- **#{}** - {}", issue.number, issue.title);
202 let _ = writeln!(output, " > {}\n", issue.reason);
203 }
204 }
205
206 if let Some(status_note) = &triage.status_note
208 && !status_note.is_empty()
209 {
210 output.push_str("### Status\n\n");
211 output.push_str(status_note);
212 output.push_str("\n\n");
213 }
214
215 if let Some(guidance) = &triage.contributor_guidance {
217 output.push_str("### Contributor Guidance\n\n");
218 let beginner_label = if guidance.beginner_friendly {
219 "**Beginner-friendly**"
220 } else {
221 "**Advanced**"
222 };
223 let _ = writeln!(output, "{beginner_label}\n");
224 let _ = writeln!(output, "{}\n", guidance.reasoning);
225 }
226
227 if let Some(approach) = &triage.implementation_approach
229 && !approach.is_empty()
230 {
231 output.push_str("### Implementation Approach\n\n");
232 for line in approach.lines() {
233 let _ = writeln!(output, " {line}");
234 }
235 output.push('\n');
236 }
237
238 output.push_str("---\n");
240 output.push('*');
241 output.push_str(APTU_SIGNATURE);
242 output.push('*');
243 output.push('\n');
244
245 output
246}
247
248#[must_use]
261pub fn render_release_notes_markdown(response: &crate::ai::types::ReleaseNotesResponse) -> String {
262 use std::fmt::Write;
263
264 let mut body = String::new();
265
266 let _ = writeln!(body, "## {}\n", response.theme);
268
269 if !response.narrative.is_empty() {
271 let _ = writeln!(body, "{}\n", response.narrative);
272 }
273
274 if !response.highlights.is_empty() {
276 body.push_str("### Highlights\n\n");
277 for highlight in &response.highlights {
278 let _ = writeln!(body, "- {highlight}");
279 }
280 body.push('\n');
281 }
282
283 if !response.features.is_empty() {
285 body.push_str("### Features\n\n");
286 for feature in &response.features {
287 let _ = writeln!(body, "- {feature}");
288 }
289 body.push('\n');
290 }
291
292 if !response.fixes.is_empty() {
294 body.push_str("### Fixes\n\n");
295 for fix in &response.fixes {
296 let _ = writeln!(body, "- {fix}");
297 }
298 body.push('\n');
299 }
300
301 if !response.improvements.is_empty() {
303 body.push_str("### Improvements\n\n");
304 for improvement in &response.improvements {
305 let _ = writeln!(body, "- {improvement}");
306 }
307 body.push('\n');
308 }
309
310 if !response.documentation.is_empty() {
312 body.push_str("### Documentation\n\n");
313 for doc in &response.documentation {
314 let _ = writeln!(body, "- {doc}");
315 }
316 body.push('\n');
317 }
318
319 if !response.maintenance.is_empty() {
321 body.push_str("### Maintenance\n\n");
322 for maint in &response.maintenance {
323 let _ = writeln!(body, "- {maint}");
324 }
325 body.push('\n');
326 }
327
328 if !response.contributors.is_empty() {
330 body.push_str("### Contributors\n\n");
331 for contributor in &response.contributors {
332 let _ = writeln!(body, "- {contributor}");
333 }
334 }
335
336 body
337}
338
339pub fn check_already_triaged(issue: &IssueDetails) -> TriageStatus {
344 let has_type_label = issue.labels.iter().any(|label| is_type_label(label));
345 let has_priority_label = issue.labels.iter().any(|label| is_priority_label(label));
346
347 let label_names: Vec<String> = issue
348 .labels
349 .iter()
350 .filter(|label| is_type_label(label) || is_priority_label(label))
351 .cloned()
352 .collect();
353
354 let has_aptu_comment = issue
356 .comments
357 .iter()
358 .any(|comment| comment.body.contains(APTU_SIGNATURE));
359
360 if has_type_label || has_priority_label || has_aptu_comment {
361 debug!(
362 has_type_label = has_type_label,
363 has_priority_label = has_priority_label,
364 has_aptu_comment = has_aptu_comment,
365 labels = ?label_names,
366 "Issue triage status detected"
367 );
368 }
369
370 TriageStatus::new(
371 has_type_label,
372 has_priority_label,
373 has_aptu_comment,
374 label_names,
375 )
376}
377
378#[must_use]
383pub fn render_pr_review_comment_body(comment: &PrReviewComment) -> String {
384 let mut body = comment.comment.clone();
385 if let Some(code) = &comment.suggested_code
386 && !code.is_empty()
387 {
388 body.push_str("\n\n```suggestion\n");
389 body.push_str(code);
390 body.push_str("\n```");
391 }
392 body
393}
394
395#[must_use]
404pub fn render_pr_review_markdown(review: &PrReviewResponse, files_count: usize) -> String {
405 let verdict_badge = match review.verdict.as_str() {
406 "approve" => "✅ Approve",
407 "request_changes" | "request-changes" => "❌ Request Changes",
408 _ => "💬 Comment",
409 };
410
411 let mut body = format!(
412 "<!-- APTU_REVIEW -->\n## Aptu Review\n\n**{}** — {}\n",
413 verdict_badge, review.summary
414 );
415
416 if files_count > 5 && !review.concerns.is_empty() {
418 body.push('\n');
419 for c in &review.concerns {
420 let _ = writeln!(body, "- {c}");
421 }
422 }
423
424 body.push_str("\n---\n\n<sub>Posted by [aptu](https://github.com/clouatre-labs/aptu)</sub>\n");
425
426 body
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::ai::types::{CommentSeverity, IssueComment};
433
434 fn create_test_issue(labels: Vec<String>, comments: Vec<IssueComment>) -> IssueDetails {
435 IssueDetails::builder()
436 .owner("test".to_string())
437 .repo("repo".to_string())
438 .number(1)
439 .title("Test issue".to_string())
440 .body("Test body".to_string())
441 .labels(labels)
442 .comments(comments)
443 .url("https://github.com/test/repo/issues/1".to_string())
444 .build()
445 }
446
447 #[test]
448 fn test_no_triage() {
449 let issue = create_test_issue(vec![], vec![]);
450 let status = check_already_triaged(&issue);
451 assert!(!status.is_triaged());
452 assert!(!status.has_type_label);
453 assert!(!status.has_priority_label);
454 assert!(!status.has_aptu_comment);
455 assert!(status.label_names.is_empty());
456 }
457
458 #[test]
459 fn test_type_label_only() {
460 let labels = vec!["bug".to_string()];
461 let issue = create_test_issue(labels, vec![]);
462 let status = check_already_triaged(&issue);
463 assert!(!status.is_triaged());
464 assert!(status.has_type_label);
465 assert!(!status.has_priority_label);
466 assert!(!status.has_aptu_comment);
467 assert_eq!(status.label_names.len(), 1);
468 }
469
470 #[test]
471 fn test_priority_label_only() {
472 let labels = vec!["p1".to_string()];
473 let issue = create_test_issue(labels, vec![]);
474 let status = check_already_triaged(&issue);
475 assert!(!status.is_triaged());
476 assert!(!status.has_type_label);
477 assert!(status.has_priority_label);
478 assert!(!status.has_aptu_comment);
479 assert_eq!(status.label_names.len(), 1);
480 }
481
482 #[test]
483 fn test_type_and_priority_labels() {
484 let labels = vec!["bug".to_string(), "p1".to_string()];
485 let issue = create_test_issue(labels, vec![]);
486 let status = check_already_triaged(&issue);
487 assert!(status.is_triaged());
488 assert!(status.has_type_label);
489 assert!(status.has_priority_label);
490 assert!(!status.has_aptu_comment);
491 assert_eq!(status.label_names.len(), 2);
492 }
493
494 #[test]
495 fn test_priority_prefix_labels() {
496 for priority in ["priority: high", "priority: medium", "priority: low"] {
498 let labels = vec!["bug".to_string(), priority.to_string()];
499 let issue = create_test_issue(labels, vec![]);
500 let status = check_already_triaged(&issue);
501 assert!(status.is_triaged(), "Failed for {priority}");
502 assert!(status.has_type_label, "Failed for {priority}");
503 assert!(status.has_priority_label, "Failed for {priority}");
504 }
505 }
506
507 #[test]
508 fn test_aptu_comment_only() {
509 let comments = vec![IssueComment {
510 author: "aptu-bot".to_string(),
511 body: "This looks good. Generated by Aptu".to_string(),
512 }];
513 let issue = create_test_issue(vec![], comments);
514 let status = check_already_triaged(&issue);
515 assert!(status.is_triaged());
516 assert!(!status.has_type_label);
517 assert!(!status.has_priority_label);
518 assert!(status.has_aptu_comment);
519 assert!(status.label_names.is_empty());
520 }
521
522 #[test]
523 fn test_type_label_with_aptu_comment() {
524 let labels = vec!["bug".to_string()];
525 let comments = vec![IssueComment {
526 author: "aptu-bot".to_string(),
527 body: "Generated by Aptu".to_string(),
528 }];
529 let issue = create_test_issue(labels, comments);
530 let status = check_already_triaged(&issue);
531 assert!(status.is_triaged());
532 assert!(status.has_type_label);
533 assert!(!status.has_priority_label);
534 assert!(status.has_aptu_comment);
535 }
536
537 #[test]
538 fn test_partial_signature_no_match() {
539 let comments = vec![IssueComment {
540 author: "other-bot".to_string(),
541 body: "Generated by AnotherTool".to_string(),
542 }];
543 let issue = create_test_issue(vec![], comments);
544 let status = check_already_triaged(&issue);
545 assert!(!status.is_triaged());
546 assert!(!status.has_aptu_comment);
547 }
548
549 #[test]
550 fn test_irrelevant_labels() {
551 let labels = vec!["component: ui".to_string(), "needs-review".to_string()];
552 let issue = create_test_issue(labels, vec![]);
553 let status = check_already_triaged(&issue);
554 assert!(!status.is_triaged());
555 assert!(!status.has_type_label);
556 assert!(!status.has_priority_label);
557 assert!(status.label_names.is_empty());
558 }
559
560 #[test]
561 fn test_priority_label_case_insensitive() {
562 let labels = vec!["bug".to_string(), "P2".to_string()];
563 let issue = create_test_issue(labels, vec![]);
564 let status = check_already_triaged(&issue);
565 assert!(status.is_triaged());
566 assert!(status.has_priority_label);
567 }
568
569 #[test]
570 fn test_priority_prefix_case_insensitive() {
571 let labels = vec!["enhancement".to_string(), "Priority: HIGH".to_string()];
572 let issue = create_test_issue(labels, vec![]);
573 let status = check_already_triaged(&issue);
574 assert!(status.is_triaged());
575 assert!(status.has_priority_label);
576 }
577
578 #[test]
579 fn test_render_triage_markdown_basic() {
580 let triage = TriageResponse {
581 summary: "This is a test summary".to_string(),
582 implementation_approach: None,
583 clarifying_questions: vec!["Question 1?".to_string()],
584 potential_duplicates: vec![],
585 related_issues: vec![],
586 suggested_labels: vec!["bug".to_string()],
587 suggested_milestone: None,
588 status_note: None,
589 contributor_guidance: None,
590 complexity: None,
591 };
592
593 let markdown = render_triage_markdown(&triage);
594 assert!(markdown.contains("## Triage Summary"));
595 assert!(markdown.contains("This is a test summary"));
596 assert!(markdown.contains("### Clarifying Questions"));
597 assert!(markdown.contains("1. Question 1?"));
598 assert!(markdown.contains(APTU_SIGNATURE));
599 }
600
601 #[test]
602 fn test_render_triage_markdown_with_labels() {
603 let triage = TriageResponse {
604 summary: "Summary".to_string(),
605 implementation_approach: None,
606 clarifying_questions: vec![],
607 potential_duplicates: vec![],
608 related_issues: vec![],
609 suggested_labels: vec!["bug".to_string(), "p1".to_string()],
610 suggested_milestone: None,
611 status_note: None,
612 contributor_guidance: None,
613 complexity: None,
614 };
615
616 let markdown = render_triage_markdown(&triage);
617 assert!(markdown.contains("### Suggested Labels"));
618 assert!(markdown.contains("`bug`"));
619 assert!(markdown.contains("`p1`"));
620 }
621
622 #[test]
623 fn test_render_triage_markdown_multiline_approach() {
624 let triage = TriageResponse {
625 summary: "Summary".to_string(),
626 implementation_approach: Some("Line 1\nLine 2\nLine 3".to_string()),
627 clarifying_questions: vec![],
628 potential_duplicates: vec![],
629 related_issues: vec![],
630 suggested_labels: vec![],
631 suggested_milestone: None,
632 status_note: None,
633 contributor_guidance: None,
634 complexity: None,
635 };
636
637 let markdown = render_triage_markdown(&triage);
638 assert!(markdown.contains("### Implementation Approach"));
639 assert!(markdown.contains("Line 1"));
640 assert!(markdown.contains("Line 2"));
641 assert!(markdown.contains("Line 3"));
642 }
643
644 fn make_pr_review() -> PrReviewResponse {
645 PrReviewResponse {
646 summary: "Good PR overall.".to_string(),
647 verdict: "approve".to_string(),
648 strengths: vec!["Clean code".to_string(), "Good tests".to_string()],
649 concerns: vec!["Missing docs".to_string()],
650 comments: vec![PrReviewComment {
651 file: "src/lib.rs".to_string(),
652 line: Some(42),
653 comment: "Consider using a match here.".to_string(),
654 severity: CommentSeverity::Suggestion,
655 suggested_code: None,
656 }],
657 suggestions: vec!["Add a CHANGELOG entry.".to_string()],
658 disclaimer: Some("AI-generated review.".to_string()),
659 }
660 }
661
662 #[test]
663 fn test_render_pr_review_markdown_basic() {
664 let review = make_pr_review();
665 let body = render_pr_review_markdown(&review, 0);
666 assert!(body.contains("<!-- APTU_REVIEW -->"));
667 assert!(body.contains("✅ Approve"));
668 assert!(body.contains("Good PR overall."));
669 assert!(body.contains("aptu"));
670 }
671
672 #[test]
673 fn test_render_pr_review_markdown_empty_arrays() {
674 let review = PrReviewResponse {
675 summary: "LGTM".to_string(),
676 verdict: "approve".to_string(),
677 strengths: vec![],
678 concerns: vec![],
679 comments: vec![],
680 suggestions: vec![],
681 disclaimer: None,
682 };
683 let body = render_pr_review_markdown(&review, 3);
684 assert!(body.contains("<!-- APTU_REVIEW -->"));
685 assert!(!body.contains("### Strengths"));
686 assert!(!body.contains("### Concerns"));
687 assert!(!body.contains("### Inline Comments"));
688 assert!(!body.contains("### Suggestions"));
689 }
690
691 #[test]
692 fn test_render_pr_review_markdown_verdict_badges() {
693 let mut r = make_pr_review();
694 r.verdict = "approve".to_string();
695 assert!(render_pr_review_markdown(&r, 0).contains("✅ Approve"));
696 r.verdict = "request_changes".to_string();
697 assert!(render_pr_review_markdown(&r, 0).contains("❌ Request Changes"));
698 r.verdict = "request-changes".to_string();
699 assert!(render_pr_review_markdown(&r, 0).contains("❌ Request Changes"));
700 r.verdict = "comment".to_string();
701 assert!(render_pr_review_markdown(&r, 0).contains("💬 Comment"));
702 }
703
704 #[test]
705 fn test_render_pr_review_comment_body_plain_text() {
706 let base = PrReviewComment {
707 file: "f.rs".to_string(),
708 line: Some(1),
709 comment: "test msg".to_string(),
710 severity: CommentSeverity::Issue,
711 suggested_code: None,
712 };
713 let body = render_pr_review_comment_body(&base);
715 assert!(!body.contains("[!CAUTION]"));
716 assert!(!body.contains("[!WARNING]"));
717 assert!(!body.contains("[!TIP]"));
718 assert!(!body.contains("[!NOTE]"));
719 assert!(body.contains("test msg"));
720 let w = PrReviewComment {
722 severity: CommentSeverity::Warning,
723 ..base.clone()
724 };
725 assert!(!render_pr_review_comment_body(&w).contains("[!"));
726 let s = PrReviewComment {
727 severity: CommentSeverity::Suggestion,
728 ..base.clone()
729 };
730 assert!(!render_pr_review_comment_body(&s).contains("[!"));
731 let i = PrReviewComment {
732 severity: CommentSeverity::Info,
733 ..base.clone()
734 };
735 assert!(!render_pr_review_comment_body(&i).contains("[!"));
736 }
737
738 #[test]
739 fn test_render_pr_review_markdown_notable_changes_shown() {
740 let mut review = make_pr_review();
741 review.concerns = vec![
742 "Removes CodeQL without replacement".to_string(),
743 "cargo-nextest not pinned".to_string(),
744 ];
745 let body = render_pr_review_markdown(&review, 6);
746 assert!(body.contains("- Removes CodeQL without replacement"));
747 assert!(body.contains("- cargo-nextest not pinned"));
748 }
749
750 #[test]
751 fn test_render_pr_review_markdown_notable_changes_hidden() {
752 let mut review = make_pr_review();
753 review.concerns = vec!["Some concern".to_string()];
754 let body = render_pr_review_markdown(&review, 3);
755 assert!(!body.contains("- Some concern"));
756 }
757
758 #[test]
759 fn test_render_pr_review_comment_body_with_suggestion() {
760 let comment = PrReviewComment {
761 file: "src/main.rs".to_string(),
762 line: Some(10),
763 comment: "Use ? instead of unwrap.".to_string(),
764 severity: CommentSeverity::Warning,
765 suggested_code: Some(" let x = foo()?;\n".to_string()),
766 };
767 let body = render_pr_review_comment_body(&comment);
768 assert!(!body.contains("[!"));
769 assert!(body.contains("Use ? instead of unwrap."));
770 assert!(body.contains("```suggestion"));
771 assert!(body.contains("let x = foo()?;"));
772 }
773
774 #[test]
775 fn test_render_pr_review_comment_body_without_suggestion() {
776 let comment = PrReviewComment {
777 file: "src/main.rs".to_string(),
778 line: Some(10),
779 comment: "Consider refactoring this module.".to_string(),
780 severity: CommentSeverity::Info,
781 suggested_code: None,
782 };
783 let body = render_pr_review_comment_body(&comment);
784 assert!(!body.contains("[!"));
785 assert!(!body.contains("```suggestion"));
786 }
787}