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            id: 1,
422            author: "aptu-bot".to_string(),
423            body: "This looks good. Generated by Aptu".to_string(),
424        }];
425        let issue = create_test_issue(vec![], comments);
426        let status = check_already_triaged(&issue);
427        assert!(status.is_triaged());
428        assert!(!status.has_type_label);
429        assert!(!status.has_priority_label);
430        assert!(status.has_aptu_comment);
431        assert!(status.label_names.is_empty());
432    }
433
434    #[test]
435    fn test_type_label_with_aptu_comment() {
436        let labels = vec!["bug".to_string()];
437        let comments = vec![IssueComment {
438            id: 2,
439            author: "aptu-bot".to_string(),
440            body: "Generated by Aptu".to_string(),
441        }];
442        let issue = create_test_issue(labels, comments);
443        let status = check_already_triaged(&issue);
444        assert!(status.is_triaged());
445        assert!(status.has_type_label);
446        assert!(!status.has_priority_label);
447        assert!(status.has_aptu_comment);
448    }
449
450    #[test]
451    fn test_partial_signature_no_match() {
452        let comments = vec![IssueComment {
453            id: 3,
454            author: "other-bot".to_string(),
455            body: "Generated by AnotherTool".to_string(),
456        }];
457        let issue = create_test_issue(vec![], comments);
458        let status = check_already_triaged(&issue);
459        assert!(!status.is_triaged());
460        assert!(!status.has_aptu_comment);
461    }
462
463    #[test]
464    fn test_irrelevant_labels() {
465        let labels = vec!["component: ui".to_string(), "needs-review".to_string()];
466        let issue = create_test_issue(labels, vec![]);
467        let status = check_already_triaged(&issue);
468        assert!(!status.is_triaged());
469        assert!(!status.has_type_label);
470        assert!(!status.has_priority_label);
471        assert!(status.label_names.is_empty());
472    }
473
474    #[test]
475    fn test_priority_label_case_insensitive() {
476        let labels = vec!["bug".to_string(), "P2".to_string()];
477        let issue = create_test_issue(labels, vec![]);
478        let status = check_already_triaged(&issue);
479        assert!(status.is_triaged());
480        assert!(status.has_priority_label);
481    }
482
483    #[test]
484    fn test_priority_prefix_case_insensitive() {
485        let labels = vec!["enhancement".to_string(), "Priority: HIGH".to_string()];
486        let issue = create_test_issue(labels, vec![]);
487        let status = check_already_triaged(&issue);
488        assert!(status.is_triaged());
489        assert!(status.has_priority_label);
490    }
491
492    #[test]
493    fn test_render_triage_markdown_basic() {
494        let triage = TriageResponse {
495            summary: "This is a test summary".to_string(),
496            implementation_approach: None,
497            clarifying_questions: vec!["Question 1?".to_string()],
498            potential_duplicates: vec![],
499            related_issues: vec![],
500            suggested_labels: vec!["bug".to_string()],
501            suggested_milestone: None,
502            status_note: None,
503            contributor_guidance: None,
504            complexity: None,
505        };
506
507        let markdown = render_triage_markdown(&triage);
508        assert!(markdown.contains("## Triage Summary"));
509        assert!(markdown.contains("This is a test summary"));
510        assert!(markdown.contains("### Clarifying Questions"));
511        assert!(markdown.contains("1. Question 1?"));
512        assert!(markdown.contains(APTU_SIGNATURE));
513    }
514
515    #[test]
516    fn test_render_triage_markdown_with_labels() {
517        let triage = TriageResponse {
518            summary: "Summary".to_string(),
519            implementation_approach: None,
520            clarifying_questions: vec![],
521            potential_duplicates: vec![],
522            related_issues: vec![],
523            suggested_labels: vec!["bug".to_string(), "p1".to_string()],
524            suggested_milestone: None,
525            status_note: None,
526            contributor_guidance: None,
527            complexity: None,
528        };
529
530        let markdown = render_triage_markdown(&triage);
531        assert!(markdown.contains("### Suggested Labels"));
532        assert!(markdown.contains("`bug`"));
533        assert!(markdown.contains("`p1`"));
534    }
535
536    #[test]
537    fn test_render_triage_markdown_multiline_approach() {
538        let triage = TriageResponse {
539            summary: "Summary".to_string(),
540            implementation_approach: Some("Line 1\nLine 2\nLine 3".to_string()),
541            clarifying_questions: vec![],
542            potential_duplicates: vec![],
543            related_issues: vec![],
544            suggested_labels: vec![],
545            suggested_milestone: None,
546            status_note: None,
547            contributor_guidance: None,
548            complexity: None,
549        };
550
551        let markdown = render_triage_markdown(&triage);
552        assert!(markdown.contains("### Implementation Approach"));
553        assert!(markdown.contains("Line 1"));
554        assert!(markdown.contains("Line 2"));
555        assert!(markdown.contains("Line 3"));
556    }
557
558    fn make_pr_review() -> PrReviewResponse {
559        PrReviewResponse {
560            summary: "Good PR overall.".to_string(),
561            verdict: "approve".to_string(),
562            strengths: vec!["Clean code".to_string(), "Good tests".to_string()],
563            concerns: vec!["Missing docs".to_string()],
564            comments: vec![PrReviewComment {
565                file: "src/lib.rs".to_string(),
566                line: Some(42),
567                comment: "Consider using a match here.".to_string(),
568                severity: CommentSeverity::Suggestion,
569                suggested_code: None,
570            }],
571            suggestions: vec!["Add a CHANGELOG entry.".to_string()],
572            disclaimer: Some("AI-generated review.".to_string()),
573        }
574    }
575
576    #[test]
577    fn test_render_pr_review_markdown_basic() {
578        let review = make_pr_review();
579        let body = render_pr_review_markdown(&review, 0);
580        assert!(body.contains("<!-- APTU_REVIEW -->"));
581        assert!(body.contains("✅ Approve"));
582        assert!(body.contains("Good PR overall."));
583        assert!(body.contains("aptu"));
584    }
585
586    #[test]
587    fn test_render_pr_review_markdown_empty_arrays() {
588        let review = PrReviewResponse {
589            summary: "LGTM".to_string(),
590            verdict: "approve".to_string(),
591            strengths: vec![],
592            concerns: vec![],
593            comments: vec![],
594            suggestions: vec![],
595            disclaimer: None,
596        };
597        let body = render_pr_review_markdown(&review, 3);
598        assert!(body.contains("<!-- APTU_REVIEW -->"));
599        assert!(!body.contains("### Strengths"));
600        assert!(!body.contains("### Concerns"));
601        assert!(!body.contains("### Inline Comments"));
602        assert!(!body.contains("### Suggestions"));
603    }
604
605    #[test]
606    fn test_render_pr_review_markdown_verdict_badges() {
607        let mut r = make_pr_review();
608        r.verdict = "approve".to_string();
609        assert!(render_pr_review_markdown(&r, 0).contains("✅ Approve"));
610        r.verdict = "request_changes".to_string();
611        assert!(render_pr_review_markdown(&r, 0).contains("❌ Request Changes"));
612        r.verdict = "request-changes".to_string();
613        assert!(render_pr_review_markdown(&r, 0).contains("❌ Request Changes"));
614        r.verdict = "comment".to_string();
615        assert!(render_pr_review_markdown(&r, 0).contains("💬 Comment"));
616    }
617
618    #[test]
619    fn test_render_pr_review_comment_body_plain_text() {
620        let base = PrReviewComment {
621            file: "f.rs".to_string(),
622            line: Some(1),
623            comment: "test msg".to_string(),
624            severity: CommentSeverity::Issue,
625            suggested_code: None,
626        };
627        // No admonition badges -- plain prose only
628        let body = render_pr_review_comment_body(&base);
629        assert!(!body.contains("[!CAUTION]"));
630        assert!(!body.contains("[!WARNING]"));
631        assert!(!body.contains("[!TIP]"));
632        assert!(!body.contains("[!NOTE]"));
633        assert!(body.contains("test msg"));
634        // Severity variants all produce plain text
635        let w = PrReviewComment {
636            severity: CommentSeverity::Warning,
637            ..base.clone()
638        };
639        assert!(!render_pr_review_comment_body(&w).contains("[!"));
640        let s = PrReviewComment {
641            severity: CommentSeverity::Suggestion,
642            ..base.clone()
643        };
644        assert!(!render_pr_review_comment_body(&s).contains("[!"));
645        let i = PrReviewComment {
646            severity: CommentSeverity::Info,
647            ..base.clone()
648        };
649        assert!(!render_pr_review_comment_body(&i).contains("[!"));
650    }
651
652    #[test]
653    fn test_render_pr_review_markdown_notable_changes_shown() {
654        let mut review = make_pr_review();
655        review.concerns = vec![
656            "Removes CodeQL without replacement".to_string(),
657            "cargo-nextest not pinned".to_string(),
658        ];
659        let body = render_pr_review_markdown(&review, 6);
660        assert!(body.contains("- Removes CodeQL without replacement"));
661        assert!(body.contains("- cargo-nextest not pinned"));
662    }
663
664    #[test]
665    fn test_render_pr_review_markdown_notable_changes_hidden() {
666        let mut review = make_pr_review();
667        review.concerns = vec!["Some concern".to_string()];
668        let body = render_pr_review_markdown(&review, 3);
669        assert!(!body.contains("- Some concern"));
670    }
671
672    #[test]
673    fn test_render_pr_review_comment_body_with_suggestion() {
674        let comment = PrReviewComment {
675            file: "src/main.rs".to_string(),
676            line: Some(10),
677            comment: "Use ? instead of unwrap.".to_string(),
678            severity: CommentSeverity::Warning,
679            suggested_code: Some("    let x = foo()?;\n".to_string()),
680        };
681        let body = render_pr_review_comment_body(&comment);
682        assert!(!body.contains("[!"));
683        assert!(body.contains("Use ? instead of unwrap."));
684        assert!(body.contains("```suggestion"));
685        assert!(body.contains("let x = foo()?;"));
686    }
687
688    #[test]
689    fn test_render_pr_review_comment_body_without_suggestion() {
690        let comment = PrReviewComment {
691            file: "src/main.rs".to_string(),
692            line: Some(10),
693            comment: "Consider refactoring this module.".to_string(),
694            severity: CommentSeverity::Info,
695            suggested_code: None,
696        };
697        let body = render_pr_review_comment_body(&comment);
698        assert!(!body.contains("[!"));
699        assert!(!body.contains("```suggestion"));
700    }
701}