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, 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/// Renders triage response as markdown for posting to GitHub.
97///
98/// Generates pure markdown without terminal colors. This is the core rendering
99/// function used by both CLI and FFI layers.
100///
101/// # Arguments
102///
103/// * `triage` - The triage response to render
104///
105/// # Returns
106///
107/// A markdown string suitable for posting as a GitHub comment.
108#[must_use]
109pub fn render_triage_markdown(triage: &TriageResponse) -> String {
110    let mut output = String::new();
111
112    // Header
113    output.push_str("## Triage Summary\n\n");
114    output.push_str(&triage.summary);
115    output.push_str("\n\n");
116
117    // Labels (always show in markdown for maintainers)
118    let labels: Vec<String> = triage
119        .suggested_labels
120        .iter()
121        .map(|l| format!("`{l}`"))
122        .collect();
123    output.push_str(&render_list_section_markdown(
124        "Suggested Labels",
125        &labels,
126        "None",
127        false,
128    ));
129
130    // Suggested Milestone
131    if let Some(milestone) = &triage.suggested_milestone
132        && !milestone.is_empty()
133    {
134        output.push_str("### Suggested Milestone\n\n");
135        output.push_str(milestone);
136        output.push_str("\n\n");
137    }
138
139    // Questions
140    output.push_str(&render_list_section_markdown(
141        "Clarifying Questions",
142        &triage.clarifying_questions,
143        "None needed",
144        true,
145    ));
146
147    // Duplicates
148    output.push_str(&render_list_section_markdown(
149        "Potential Duplicates",
150        &triage.potential_duplicates,
151        "None found",
152        false,
153    ));
154
155    // Related issues with reason blockquote
156    if !triage.related_issues.is_empty() {
157        output.push_str("### Related Issues\n\n");
158        for issue in &triage.related_issues {
159            let _ = writeln!(output, "- **#{}** - {}", issue.number, issue.title);
160            let _ = writeln!(output, "  > {}\n", issue.reason);
161        }
162    }
163
164    // Status note (if present)
165    if let Some(status_note) = &triage.status_note
166        && !status_note.is_empty()
167    {
168        output.push_str("### Status\n\n");
169        output.push_str(status_note);
170        output.push_str("\n\n");
171    }
172
173    // Contributor guidance (if present)
174    if let Some(guidance) = &triage.contributor_guidance {
175        output.push_str("### Contributor Guidance\n\n");
176        let beginner_label = if guidance.beginner_friendly {
177            "**Beginner-friendly**"
178        } else {
179            "**Advanced**"
180        };
181        let _ = writeln!(output, "{beginner_label}\n");
182        let _ = writeln!(output, "{}\n", guidance.reasoning);
183    }
184
185    // Implementation approach
186    if let Some(approach) = &triage.implementation_approach
187        && !approach.is_empty()
188    {
189        output.push_str("### Implementation Approach\n\n");
190        for line in approach.lines() {
191            let _ = writeln!(output, "  {line}");
192        }
193        output.push('\n');
194    }
195
196    // Signature
197    output.push_str("---\n");
198    output.push('*');
199    output.push_str(APTU_SIGNATURE);
200    output.push('*');
201    output.push('\n');
202
203    output
204}
205
206/// Render a `ReleaseNotesResponse` to markdown format.
207///
208/// Formats release notes with theme, narrative, and categorized sections
209/// (highlights, features, fixes, improvements, documentation, maintenance, contributors).
210///
211/// # Arguments
212///
213/// * `response` - The release notes response to render
214///
215/// # Returns
216///
217/// A markdown string suitable for release notes or GitHub release descriptions.
218#[must_use]
219pub fn render_release_notes_markdown(response: &crate::ai::types::ReleaseNotesResponse) -> String {
220    use std::fmt::Write;
221
222    let mut body = String::new();
223
224    // Add theme as header
225    let _ = writeln!(body, "## {}\n", response.theme);
226
227    // Add narrative
228    if !response.narrative.is_empty() {
229        let _ = writeln!(body, "{}\n", response.narrative);
230    }
231
232    // Add highlights
233    if !response.highlights.is_empty() {
234        body.push_str("### Highlights\n\n");
235        for highlight in &response.highlights {
236            let _ = writeln!(body, "- {highlight}");
237        }
238        body.push('\n');
239    }
240
241    // Add features
242    if !response.features.is_empty() {
243        body.push_str("### Features\n\n");
244        for feature in &response.features {
245            let _ = writeln!(body, "- {feature}");
246        }
247        body.push('\n');
248    }
249
250    // Add fixes
251    if !response.fixes.is_empty() {
252        body.push_str("### Fixes\n\n");
253        for fix in &response.fixes {
254            let _ = writeln!(body, "- {fix}");
255        }
256        body.push('\n');
257    }
258
259    // Add improvements
260    if !response.improvements.is_empty() {
261        body.push_str("### Improvements\n\n");
262        for improvement in &response.improvements {
263            let _ = writeln!(body, "- {improvement}");
264        }
265        body.push('\n');
266    }
267
268    // Add documentation
269    if !response.documentation.is_empty() {
270        body.push_str("### Documentation\n\n");
271        for doc in &response.documentation {
272            let _ = writeln!(body, "- {doc}");
273        }
274        body.push('\n');
275    }
276
277    // Add maintenance
278    if !response.maintenance.is_empty() {
279        body.push_str("### Maintenance\n\n");
280        for maint in &response.maintenance {
281            let _ = writeln!(body, "- {maint}");
282        }
283        body.push('\n');
284    }
285
286    // Add contributors
287    if !response.contributors.is_empty() {
288        body.push_str("### Contributors\n\n");
289        for contributor in &response.contributors {
290            let _ = writeln!(body, "- {contributor}");
291        }
292    }
293
294    body
295}
296
297/// Check if an issue has already been triaged.
298///
299/// Returns `TriageStatus` indicating whether the issue has both type and priority labels,
300/// or has an Aptu-generated comment.
301pub fn check_already_triaged(issue: &IssueDetails) -> TriageStatus {
302    let has_type_label = issue.labels.iter().any(|label| is_type_label(label));
303    let has_priority_label = issue.labels.iter().any(|label| is_priority_label(label));
304
305    let label_names: Vec<String> = issue
306        .labels
307        .iter()
308        .filter(|label| is_type_label(label) || is_priority_label(label))
309        .cloned()
310        .collect();
311
312    // Check for Aptu signature in comments
313    let has_aptu_comment = issue
314        .comments
315        .iter()
316        .any(|comment| comment.body.contains(APTU_SIGNATURE));
317
318    if has_type_label || has_priority_label || has_aptu_comment {
319        debug!(
320            has_type_label = has_type_label,
321            has_priority_label = has_priority_label,
322            has_aptu_comment = has_aptu_comment,
323            labels = ?label_names,
324            "Issue triage status detected"
325        );
326    }
327
328    TriageStatus::new(
329        has_type_label,
330        has_priority_label,
331        has_aptu_comment,
332        label_names,
333    )
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::ai::types::IssueComment;
340
341    fn create_test_issue(labels: Vec<String>, comments: Vec<IssueComment>) -> IssueDetails {
342        IssueDetails::builder()
343            .owner("test".to_string())
344            .repo("repo".to_string())
345            .number(1)
346            .title("Test issue".to_string())
347            .body("Test body".to_string())
348            .labels(labels)
349            .comments(comments)
350            .url("https://github.com/test/repo/issues/1".to_string())
351            .build()
352    }
353
354    #[test]
355    fn test_no_triage() {
356        let issue = create_test_issue(vec![], vec![]);
357        let status = check_already_triaged(&issue);
358        assert!(!status.is_triaged());
359        assert!(!status.has_type_label);
360        assert!(!status.has_priority_label);
361        assert!(!status.has_aptu_comment);
362        assert!(status.label_names.is_empty());
363    }
364
365    #[test]
366    fn test_type_label_only() {
367        let labels = vec!["bug".to_string()];
368        let issue = create_test_issue(labels, vec![]);
369        let status = check_already_triaged(&issue);
370        assert!(!status.is_triaged());
371        assert!(status.has_type_label);
372        assert!(!status.has_priority_label);
373        assert!(!status.has_aptu_comment);
374        assert_eq!(status.label_names.len(), 1);
375    }
376
377    #[test]
378    fn test_priority_label_only() {
379        let labels = vec!["p1".to_string()];
380        let issue = create_test_issue(labels, vec![]);
381        let status = check_already_triaged(&issue);
382        assert!(!status.is_triaged());
383        assert!(!status.has_type_label);
384        assert!(status.has_priority_label);
385        assert!(!status.has_aptu_comment);
386        assert_eq!(status.label_names.len(), 1);
387    }
388
389    #[test]
390    fn test_type_and_priority_labels() {
391        let labels = vec!["bug".to_string(), "p1".to_string()];
392        let issue = create_test_issue(labels, vec![]);
393        let status = check_already_triaged(&issue);
394        assert!(status.is_triaged());
395        assert!(status.has_type_label);
396        assert!(status.has_priority_label);
397        assert!(!status.has_aptu_comment);
398        assert_eq!(status.label_names.len(), 2);
399    }
400
401    #[test]
402    fn test_priority_prefix_labels() {
403        // Test all priority: prefix variants (high, medium, low)
404        for priority in ["priority: high", "priority: medium", "priority: low"] {
405            let labels = vec!["bug".to_string(), priority.to_string()];
406            let issue = create_test_issue(labels, vec![]);
407            let status = check_already_triaged(&issue);
408            assert!(status.is_triaged(), "Failed for {priority}");
409            assert!(status.has_type_label, "Failed for {priority}");
410            assert!(status.has_priority_label, "Failed for {priority}");
411        }
412    }
413
414    #[test]
415    fn test_aptu_comment_only() {
416        let comments = vec![IssueComment {
417            author: "aptu-bot".to_string(),
418            body: "This looks good. Generated by Aptu".to_string(),
419        }];
420        let issue = create_test_issue(vec![], comments);
421        let status = check_already_triaged(&issue);
422        assert!(status.is_triaged());
423        assert!(!status.has_type_label);
424        assert!(!status.has_priority_label);
425        assert!(status.has_aptu_comment);
426        assert!(status.label_names.is_empty());
427    }
428
429    #[test]
430    fn test_type_label_with_aptu_comment() {
431        let labels = vec!["bug".to_string()];
432        let comments = vec![IssueComment {
433            author: "aptu-bot".to_string(),
434            body: "Generated by Aptu".to_string(),
435        }];
436        let issue = create_test_issue(labels, comments);
437        let status = check_already_triaged(&issue);
438        assert!(status.is_triaged());
439        assert!(status.has_type_label);
440        assert!(!status.has_priority_label);
441        assert!(status.has_aptu_comment);
442    }
443
444    #[test]
445    fn test_partial_signature_no_match() {
446        let comments = vec![IssueComment {
447            author: "other-bot".to_string(),
448            body: "Generated by AnotherTool".to_string(),
449        }];
450        let issue = create_test_issue(vec![], comments);
451        let status = check_already_triaged(&issue);
452        assert!(!status.is_triaged());
453        assert!(!status.has_aptu_comment);
454    }
455
456    #[test]
457    fn test_irrelevant_labels() {
458        let labels = vec!["component: ui".to_string(), "needs-review".to_string()];
459        let issue = create_test_issue(labels, vec![]);
460        let status = check_already_triaged(&issue);
461        assert!(!status.is_triaged());
462        assert!(!status.has_type_label);
463        assert!(!status.has_priority_label);
464        assert!(status.label_names.is_empty());
465    }
466
467    #[test]
468    fn test_priority_label_case_insensitive() {
469        let labels = vec!["bug".to_string(), "P2".to_string()];
470        let issue = create_test_issue(labels, vec![]);
471        let status = check_already_triaged(&issue);
472        assert!(status.is_triaged());
473        assert!(status.has_priority_label);
474    }
475
476    #[test]
477    fn test_priority_prefix_case_insensitive() {
478        let labels = vec!["enhancement".to_string(), "Priority: HIGH".to_string()];
479        let issue = create_test_issue(labels, vec![]);
480        let status = check_already_triaged(&issue);
481        assert!(status.is_triaged());
482        assert!(status.has_priority_label);
483    }
484
485    #[test]
486    fn test_render_triage_markdown_basic() {
487        let triage = TriageResponse {
488            summary: "This is a test summary".to_string(),
489            implementation_approach: None,
490            clarifying_questions: vec!["Question 1?".to_string()],
491            potential_duplicates: vec![],
492            related_issues: vec![],
493            suggested_labels: vec!["bug".to_string()],
494            suggested_milestone: None,
495            status_note: None,
496            contributor_guidance: None,
497        };
498
499        let markdown = render_triage_markdown(&triage);
500        assert!(markdown.contains("## Triage Summary"));
501        assert!(markdown.contains("This is a test summary"));
502        assert!(markdown.contains("### Clarifying Questions"));
503        assert!(markdown.contains("1. Question 1?"));
504        assert!(markdown.contains(APTU_SIGNATURE));
505    }
506
507    #[test]
508    fn test_render_triage_markdown_with_labels() {
509        let triage = TriageResponse {
510            summary: "Summary".to_string(),
511            implementation_approach: None,
512            clarifying_questions: vec![],
513            potential_duplicates: vec![],
514            related_issues: vec![],
515            suggested_labels: vec!["bug".to_string(), "p1".to_string()],
516            suggested_milestone: None,
517            status_note: None,
518            contributor_guidance: None,
519        };
520
521        let markdown = render_triage_markdown(&triage);
522        assert!(markdown.contains("### Suggested Labels"));
523        assert!(markdown.contains("`bug`"));
524        assert!(markdown.contains("`p1`"));
525    }
526
527    #[test]
528    fn test_render_triage_markdown_multiline_approach() {
529        let triage = TriageResponse {
530            summary: "Summary".to_string(),
531            implementation_approach: Some("Line 1\nLine 2\nLine 3".to_string()),
532            clarifying_questions: vec![],
533            potential_duplicates: vec![],
534            related_issues: vec![],
535            suggested_labels: vec![],
536            suggested_milestone: None,
537            status_note: None,
538            contributor_guidance: None,
539        };
540
541        let markdown = render_triage_markdown(&triage);
542        assert!(markdown.contains("### Implementation Approach"));
543        assert!(markdown.contains("Line 1"));
544        assert!(markdown.contains("Line 2"));
545        assert!(markdown.contains("Line 3"));
546    }
547}