Skip to main content

aptu_core/
triage.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Triage status detection for GitHub issues.
4//!
5//! This module provides utilities to check whether an issue has already been triaged,
6//! either through labels or Aptu-generated comments.
7
8use crate::ai::types::{IssueDetails, PrReviewComment, PrReviewResponse, TriageResponse};
9use crate::utils::is_priority_label;
10use std::fmt::Write;
11use tracing::debug;
12
13/// Signature string used to identify Aptu-generated triage comments
14pub const APTU_SIGNATURE: &str = "Generated by Aptu";
15
16/// Status of whether an issue has already been triaged
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TriageStatus {
19    /// Whether the issue has a type label (bug, enhancement, etc.)
20    pub has_type_label: bool,
21    /// Whether the issue has a priority label (p0-p4, priority: high/medium/low)
22    pub has_priority_label: bool,
23    /// Whether the issue has an Aptu-generated comment
24    pub has_aptu_comment: bool,
25    /// List of labels that indicate triage status
26    pub label_names: Vec<String>,
27}
28
29impl TriageStatus {
30    /// Create a new `TriageStatus`.
31    #[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    /// Check if the issue has been triaged (has both type and priority labels, or Aptu comment).
47    #[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
53/// Check if a label is a type label (bug, enhancement, documentation, etc.).
54fn 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
72/// Renders a labeled list section in markdown format.
73fn 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
96/// Render the complexity section into markdown, if present.
97fn 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/// Renders triage response as markdown for posting to GitHub.
127///
128/// Generates pure markdown without terminal colors. This is the core rendering
129/// function used by both CLI and FFI layers.
130///
131/// # Arguments
132///
133/// * `triage` - The triage response to render
134///
135/// # Returns
136///
137/// A markdown string suitable for posting as a GitHub comment.
138#[must_use]
139pub fn render_triage_markdown(triage: &TriageResponse) -> String {
140    let mut output = String::new();
141
142    // Header
143    output.push_str("## Triage Summary\n\n");
144    output.push_str(&triage.summary);
145    output.push_str("\n\n");
146
147    // Labels (always show in markdown for maintainers)
148    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    // Suggested Milestone
161    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    // Suggested Milestone
170    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    // Complexity assessment
179    render_complexity_markdown(&mut output, triage);
180
181    // Questions
182    output.push_str(&render_list_section_markdown(
183        "Clarifying Questions",
184        &triage.clarifying_questions,
185        "None needed",
186        true,
187    ));
188
189    // Duplicates
190    output.push_str(&render_list_section_markdown(
191        "Potential Duplicates",
192        &triage.potential_duplicates,
193        "None found",
194        false,
195    ));
196
197    // Related issues with reason blockquote
198    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    // Status note (if present)
207    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    // Contributor guidance (if present)
216    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    // Implementation approach
228    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    // Signature
239    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/// Render a `ReleaseNotesResponse` to markdown format.
249///
250/// Check if an issue has already been triaged.
251///
252/// Returns `TriageStatus` indicating whether the issue has both type and priority labels,
253/// or has an Aptu-generated comment.
254pub 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    // Check for Aptu signature in comments
266    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/// Formats an inline PR review comment body.
290///
291/// When the comment includes `suggested_code`, appends a GitHub suggestion block
292/// that renders as a one-click "Apply suggestion" button in the PR diff view.
293#[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/// Renders a concise PR review body for posting to GitHub.
307///
308/// Produces a short verdict + summary line, optionally followed by notable-change
309/// bullets when the PR touches more than five files. All inline detail lives in the
310/// anchored review comments; the body stays intentionally brief.
311///
312/// An `<!-- APTU_REVIEW -->` HTML comment is embedded so duplicate reviews can be
313/// detected programmatically.
314#[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    // Notable changes bullets: only for larger PRs to give reviewers orientation.
328    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        // Test all priority: prefix variants (high, medium, low)
408        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        // No admonition badges -- plain prose only
625        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        // Severity variants all produce plain text
632        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}