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
248pub fn check_already_triaged(issue: &IssueDetails) -> TriageStatus {
255 let has_type_label = issue.labels.iter().any(|label| is_type_label(label));
256 let has_priority_label = issue.labels.iter().any(|label| is_priority_label(label));
257
258 let label_names: Vec<String> = issue
259 .labels
260 .iter()
261 .filter(|label| is_type_label(label) || is_priority_label(label))
262 .cloned()
263 .collect();
264
265 let has_aptu_comment = issue
267 .comments
268 .iter()
269 .any(|comment| comment.body.contains(APTU_SIGNATURE));
270
271 if has_type_label || has_priority_label || has_aptu_comment {
272 debug!(
273 has_type_label = has_type_label,
274 has_priority_label = has_priority_label,
275 has_aptu_comment = has_aptu_comment,
276 labels = ?label_names,
277 "Issue triage status detected"
278 );
279 }
280
281 TriageStatus::new(
282 has_type_label,
283 has_priority_label,
284 has_aptu_comment,
285 label_names,
286 )
287}
288
289#[must_use]
294pub fn render_pr_review_comment_body(comment: &PrReviewComment) -> String {
295 let mut body = comment.comment.clone();
296 if let Some(code) = &comment.suggested_code
297 && !code.is_empty()
298 {
299 body.push_str("\n\n```suggestion\n");
300 body.push_str(code);
301 body.push_str("\n```");
302 }
303 body
304}
305
306#[must_use]
315pub fn render_pr_review_markdown(review: &PrReviewResponse, files_count: usize) -> String {
316 let verdict_badge = match review.verdict.as_str() {
317 "approve" => "✅ Approve",
318 "request_changes" | "request-changes" => "❌ Request Changes",
319 _ => "💬 Comment",
320 };
321
322 let mut body = format!(
323 "<!-- APTU_REVIEW -->\n## Aptu Review\n\n**{}** — {}\n",
324 verdict_badge, review.summary
325 );
326
327 if files_count > 5 && !review.concerns.is_empty() {
329 body.push('\n');
330 for c in &review.concerns {
331 let _ = writeln!(body, "- {c}");
332 }
333 }
334
335 body.push_str("\n---\n\n<sub>Posted by [aptu](https://github.com/clouatre-labs/aptu)</sub>\n");
336
337 body
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use crate::ai::types::{CommentSeverity, IssueComment};
344
345 fn create_test_issue(labels: Vec<String>, comments: Vec<IssueComment>) -> IssueDetails {
346 IssueDetails::builder()
347 .owner("test".to_string())
348 .repo("repo".to_string())
349 .number(1)
350 .title("Test issue".to_string())
351 .body("Test body".to_string())
352 .labels(labels)
353 .comments(comments)
354 .url("https://github.com/test/repo/issues/1".to_string())
355 .build()
356 }
357
358 #[test]
359 fn test_no_triage() {
360 let issue = create_test_issue(vec![], vec![]);
361 let status = check_already_triaged(&issue);
362 assert!(!status.is_triaged());
363 assert!(!status.has_type_label);
364 assert!(!status.has_priority_label);
365 assert!(!status.has_aptu_comment);
366 assert!(status.label_names.is_empty());
367 }
368
369 #[test]
370 fn test_type_label_only() {
371 let labels = vec!["bug".to_string()];
372 let issue = create_test_issue(labels, vec![]);
373 let status = check_already_triaged(&issue);
374 assert!(!status.is_triaged());
375 assert!(status.has_type_label);
376 assert!(!status.has_priority_label);
377 assert!(!status.has_aptu_comment);
378 assert_eq!(status.label_names.len(), 1);
379 }
380
381 #[test]
382 fn test_priority_label_only() {
383 let labels = vec!["p1".to_string()];
384 let issue = create_test_issue(labels, vec![]);
385 let status = check_already_triaged(&issue);
386 assert!(!status.is_triaged());
387 assert!(!status.has_type_label);
388 assert!(status.has_priority_label);
389 assert!(!status.has_aptu_comment);
390 assert_eq!(status.label_names.len(), 1);
391 }
392
393 #[test]
394 fn test_type_and_priority_labels() {
395 let labels = vec!["bug".to_string(), "p1".to_string()];
396 let issue = create_test_issue(labels, vec![]);
397 let status = check_already_triaged(&issue);
398 assert!(status.is_triaged());
399 assert!(status.has_type_label);
400 assert!(status.has_priority_label);
401 assert!(!status.has_aptu_comment);
402 assert_eq!(status.label_names.len(), 2);
403 }
404
405 #[test]
406 fn test_priority_prefix_labels() {
407 for priority in ["priority: high", "priority: medium", "priority: low"] {
409 let labels = vec!["bug".to_string(), priority.to_string()];
410 let issue = create_test_issue(labels, vec![]);
411 let status = check_already_triaged(&issue);
412 assert!(status.is_triaged(), "Failed for {priority}");
413 assert!(status.has_type_label, "Failed for {priority}");
414 assert!(status.has_priority_label, "Failed for {priority}");
415 }
416 }
417
418 #[test]
419 fn test_aptu_comment_only() {
420 let comments = vec![IssueComment {
421 author: "aptu-bot".to_string(),
422 body: "This looks good. Generated by Aptu".to_string(),
423 }];
424 let issue = create_test_issue(vec![], comments);
425 let status = check_already_triaged(&issue);
426 assert!(status.is_triaged());
427 assert!(!status.has_type_label);
428 assert!(!status.has_priority_label);
429 assert!(status.has_aptu_comment);
430 assert!(status.label_names.is_empty());
431 }
432
433 #[test]
434 fn test_type_label_with_aptu_comment() {
435 let labels = vec!["bug".to_string()];
436 let comments = vec![IssueComment {
437 author: "aptu-bot".to_string(),
438 body: "Generated by Aptu".to_string(),
439 }];
440 let issue = create_test_issue(labels, comments);
441 let status = check_already_triaged(&issue);
442 assert!(status.is_triaged());
443 assert!(status.has_type_label);
444 assert!(!status.has_priority_label);
445 assert!(status.has_aptu_comment);
446 }
447
448 #[test]
449 fn test_partial_signature_no_match() {
450 let comments = vec![IssueComment {
451 author: "other-bot".to_string(),
452 body: "Generated by AnotherTool".to_string(),
453 }];
454 let issue = create_test_issue(vec![], comments);
455 let status = check_already_triaged(&issue);
456 assert!(!status.is_triaged());
457 assert!(!status.has_aptu_comment);
458 }
459
460 #[test]
461 fn test_irrelevant_labels() {
462 let labels = vec!["component: ui".to_string(), "needs-review".to_string()];
463 let issue = create_test_issue(labels, vec![]);
464 let status = check_already_triaged(&issue);
465 assert!(!status.is_triaged());
466 assert!(!status.has_type_label);
467 assert!(!status.has_priority_label);
468 assert!(status.label_names.is_empty());
469 }
470
471 #[test]
472 fn test_priority_label_case_insensitive() {
473 let labels = vec!["bug".to_string(), "P2".to_string()];
474 let issue = create_test_issue(labels, vec![]);
475 let status = check_already_triaged(&issue);
476 assert!(status.is_triaged());
477 assert!(status.has_priority_label);
478 }
479
480 #[test]
481 fn test_priority_prefix_case_insensitive() {
482 let labels = vec!["enhancement".to_string(), "Priority: HIGH".to_string()];
483 let issue = create_test_issue(labels, vec![]);
484 let status = check_already_triaged(&issue);
485 assert!(status.is_triaged());
486 assert!(status.has_priority_label);
487 }
488
489 #[test]
490 fn test_render_triage_markdown_basic() {
491 let triage = TriageResponse {
492 summary: "This is a test summary".to_string(),
493 implementation_approach: None,
494 clarifying_questions: vec!["Question 1?".to_string()],
495 potential_duplicates: vec![],
496 related_issues: vec![],
497 suggested_labels: vec!["bug".to_string()],
498 suggested_milestone: None,
499 status_note: None,
500 contributor_guidance: None,
501 complexity: None,
502 };
503
504 let markdown = render_triage_markdown(&triage);
505 assert!(markdown.contains("## Triage Summary"));
506 assert!(markdown.contains("This is a test summary"));
507 assert!(markdown.contains("### Clarifying Questions"));
508 assert!(markdown.contains("1. Question 1?"));
509 assert!(markdown.contains(APTU_SIGNATURE));
510 }
511
512 #[test]
513 fn test_render_triage_markdown_with_labels() {
514 let triage = TriageResponse {
515 summary: "Summary".to_string(),
516 implementation_approach: None,
517 clarifying_questions: vec![],
518 potential_duplicates: vec![],
519 related_issues: vec![],
520 suggested_labels: vec!["bug".to_string(), "p1".to_string()],
521 suggested_milestone: None,
522 status_note: None,
523 contributor_guidance: None,
524 complexity: None,
525 };
526
527 let markdown = render_triage_markdown(&triage);
528 assert!(markdown.contains("### Suggested Labels"));
529 assert!(markdown.contains("`bug`"));
530 assert!(markdown.contains("`p1`"));
531 }
532
533 #[test]
534 fn test_render_triage_markdown_multiline_approach() {
535 let triage = TriageResponse {
536 summary: "Summary".to_string(),
537 implementation_approach: Some("Line 1\nLine 2\nLine 3".to_string()),
538 clarifying_questions: vec![],
539 potential_duplicates: vec![],
540 related_issues: vec![],
541 suggested_labels: vec![],
542 suggested_milestone: None,
543 status_note: None,
544 contributor_guidance: None,
545 complexity: None,
546 };
547
548 let markdown = render_triage_markdown(&triage);
549 assert!(markdown.contains("### Implementation Approach"));
550 assert!(markdown.contains("Line 1"));
551 assert!(markdown.contains("Line 2"));
552 assert!(markdown.contains("Line 3"));
553 }
554
555 fn make_pr_review() -> PrReviewResponse {
556 PrReviewResponse {
557 summary: "Good PR overall.".to_string(),
558 verdict: "approve".to_string(),
559 strengths: vec!["Clean code".to_string(), "Good tests".to_string()],
560 concerns: vec!["Missing docs".to_string()],
561 comments: vec![PrReviewComment {
562 file: "src/lib.rs".to_string(),
563 line: Some(42),
564 comment: "Consider using a match here.".to_string(),
565 severity: CommentSeverity::Suggestion,
566 suggested_code: None,
567 }],
568 suggestions: vec!["Add a CHANGELOG entry.".to_string()],
569 disclaimer: Some("AI-generated review.".to_string()),
570 }
571 }
572
573 #[test]
574 fn test_render_pr_review_markdown_basic() {
575 let review = make_pr_review();
576 let body = render_pr_review_markdown(&review, 0);
577 assert!(body.contains("<!-- APTU_REVIEW -->"));
578 assert!(body.contains("✅ Approve"));
579 assert!(body.contains("Good PR overall."));
580 assert!(body.contains("aptu"));
581 }
582
583 #[test]
584 fn test_render_pr_review_markdown_empty_arrays() {
585 let review = PrReviewResponse {
586 summary: "LGTM".to_string(),
587 verdict: "approve".to_string(),
588 strengths: vec![],
589 concerns: vec![],
590 comments: vec![],
591 suggestions: vec![],
592 disclaimer: None,
593 };
594 let body = render_pr_review_markdown(&review, 3);
595 assert!(body.contains("<!-- APTU_REVIEW -->"));
596 assert!(!body.contains("### Strengths"));
597 assert!(!body.contains("### Concerns"));
598 assert!(!body.contains("### Inline Comments"));
599 assert!(!body.contains("### Suggestions"));
600 }
601
602 #[test]
603 fn test_render_pr_review_markdown_verdict_badges() {
604 let mut r = make_pr_review();
605 r.verdict = "approve".to_string();
606 assert!(render_pr_review_markdown(&r, 0).contains("✅ Approve"));
607 r.verdict = "request_changes".to_string();
608 assert!(render_pr_review_markdown(&r, 0).contains("❌ Request Changes"));
609 r.verdict = "request-changes".to_string();
610 assert!(render_pr_review_markdown(&r, 0).contains("❌ Request Changes"));
611 r.verdict = "comment".to_string();
612 assert!(render_pr_review_markdown(&r, 0).contains("💬 Comment"));
613 }
614
615 #[test]
616 fn test_render_pr_review_comment_body_plain_text() {
617 let base = PrReviewComment {
618 file: "f.rs".to_string(),
619 line: Some(1),
620 comment: "test msg".to_string(),
621 severity: CommentSeverity::Issue,
622 suggested_code: None,
623 };
624 let body = render_pr_review_comment_body(&base);
626 assert!(!body.contains("[!CAUTION]"));
627 assert!(!body.contains("[!WARNING]"));
628 assert!(!body.contains("[!TIP]"));
629 assert!(!body.contains("[!NOTE]"));
630 assert!(body.contains("test msg"));
631 let w = PrReviewComment {
633 severity: CommentSeverity::Warning,
634 ..base.clone()
635 };
636 assert!(!render_pr_review_comment_body(&w).contains("[!"));
637 let s = PrReviewComment {
638 severity: CommentSeverity::Suggestion,
639 ..base.clone()
640 };
641 assert!(!render_pr_review_comment_body(&s).contains("[!"));
642 let i = PrReviewComment {
643 severity: CommentSeverity::Info,
644 ..base.clone()
645 };
646 assert!(!render_pr_review_comment_body(&i).contains("[!"));
647 }
648
649 #[test]
650 fn test_render_pr_review_markdown_notable_changes_shown() {
651 let mut review = make_pr_review();
652 review.concerns = vec![
653 "Removes CodeQL without replacement".to_string(),
654 "cargo-nextest not pinned".to_string(),
655 ];
656 let body = render_pr_review_markdown(&review, 6);
657 assert!(body.contains("- Removes CodeQL without replacement"));
658 assert!(body.contains("- cargo-nextest not pinned"));
659 }
660
661 #[test]
662 fn test_render_pr_review_markdown_notable_changes_hidden() {
663 let mut review = make_pr_review();
664 review.concerns = vec!["Some concern".to_string()];
665 let body = render_pr_review_markdown(&review, 3);
666 assert!(!body.contains("- Some concern"));
667 }
668
669 #[test]
670 fn test_render_pr_review_comment_body_with_suggestion() {
671 let comment = PrReviewComment {
672 file: "src/main.rs".to_string(),
673 line: Some(10),
674 comment: "Use ? instead of unwrap.".to_string(),
675 severity: CommentSeverity::Warning,
676 suggested_code: Some(" let x = foo()?;\n".to_string()),
677 };
678 let body = render_pr_review_comment_body(&comment);
679 assert!(!body.contains("[!"));
680 assert!(body.contains("Use ? instead of unwrap."));
681 assert!(body.contains("```suggestion"));
682 assert!(body.contains("let x = foo()?;"));
683 }
684
685 #[test]
686 fn test_render_pr_review_comment_body_without_suggestion() {
687 let comment = PrReviewComment {
688 file: "src/main.rs".to_string(),
689 line: Some(10),
690 comment: "Consider refactoring this module.".to_string(),
691 severity: CommentSeverity::Info,
692 suggested_code: None,
693 };
694 let body = render_pr_review_comment_body(&comment);
695 assert!(!body.contains("[!"));
696 assert!(!body.contains("```suggestion"));
697 }
698}