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/// Formats release notes with theme, narrative, and categorized sections
251/// (highlights, features, fixes, improvements, documentation, maintenance, contributors).
252///
253/// # Arguments
254///
255/// * `response` - The release notes response to render
256///
257/// # Returns
258///
259/// A markdown string suitable for release notes or GitHub release descriptions.
260#[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    // Add theme as header
267    let _ = writeln!(body, "## {}\n", response.theme);
268
269    // Add narrative
270    if !response.narrative.is_empty() {
271        let _ = writeln!(body, "{}\n", response.narrative);
272    }
273
274    // Add highlights
275    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    // Add features
284    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    // Add fixes
293    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    // Add improvements
302    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    // Add documentation
311    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    // Add maintenance
320    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    // Add contributors
329    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
339/// Check if an issue has already been triaged.
340///
341/// Returns `TriageStatus` indicating whether the issue has both type and priority labels,
342/// or has an Aptu-generated comment.
343pub 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    // Check for Aptu signature in comments
355    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/// Formats an inline PR review comment body.
379///
380/// When the comment includes `suggested_code`, appends a GitHub suggestion block
381/// that renders as a one-click "Apply suggestion" button in the PR diff view.
382#[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/// Renders a concise PR review body for posting to GitHub.
396///
397/// Produces a short verdict + summary line, optionally followed by notable-change
398/// bullets when the PR touches more than five files. All inline detail lives in the
399/// anchored review comments; the body stays intentionally brief.
400///
401/// An `<!-- APTU_REVIEW -->` HTML comment is embedded so duplicate reviews can be
402/// detected programmatically.
403#[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    // Notable changes bullets: only for larger PRs to give reviewers orientation.
417    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        // Test all priority: prefix variants (high, medium, low)
497        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        // No admonition badges -- plain prose only
714        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        // Severity variants all produce plain text
721        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}