1use crate::ai::types::{IssueDetails, TriageResponse};
9use crate::utils::is_priority_label;
10use std::fmt::Write;
11use tracing::debug;
12
13pub const APTU_SIGNATURE: &str = "Generated by Aptu";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TriageStatus {
19 pub has_type_label: bool,
21 pub has_priority_label: bool,
23 pub has_aptu_comment: bool,
25 pub label_names: Vec<String>,
27}
28
29impl TriageStatus {
30 #[must_use]
32 pub fn new(
33 has_type_label: bool,
34 has_priority_label: bool,
35 has_aptu_comment: bool,
36 label_names: Vec<String>,
37 ) -> Self {
38 Self {
39 has_type_label,
40 has_priority_label,
41 has_aptu_comment,
42 label_names,
43 }
44 }
45
46 #[must_use]
48 pub fn is_triaged(&self) -> bool {
49 (self.has_type_label && self.has_priority_label) || self.has_aptu_comment
50 }
51}
52
53fn is_type_label(label: &str) -> bool {
55 const TYPE_LABELS: &[&str] = &[
56 "bug",
57 "enhancement",
58 "documentation",
59 "question",
60 "good first issue",
61 "help wanted",
62 "duplicate",
63 "invalid",
64 "wontfix",
65 "triaged",
66 "needs-triage",
67 "status: triaged",
68 ];
69 TYPE_LABELS.contains(&label)
70}
71
72fn render_list_section_markdown(
74 title: &str,
75 items: &[String],
76 empty_msg: &str,
77 numbered: bool,
78) -> String {
79 let mut output = String::new();
80 let _ = writeln!(output, "### {title}\n");
81 if items.is_empty() {
82 let _ = writeln!(output, "{empty_msg}");
83 } else if numbered {
84 for (i, item) in items.iter().enumerate() {
85 let _ = writeln!(output, "{}. {}", i + 1, item);
86 }
87 } else {
88 for item in items {
89 let _ = writeln!(output, "- {item}");
90 }
91 }
92 output.push('\n');
93 output
94}
95
96#[must_use]
109pub fn render_triage_markdown(triage: &TriageResponse) -> String {
110 let mut output = String::new();
111
112 output.push_str("## Triage Summary\n\n");
114 output.push_str(&triage.summary);
115 output.push_str("\n\n");
116
117 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 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 output.push_str(&render_list_section_markdown(
141 "Clarifying Questions",
142 &triage.clarifying_questions,
143 "None needed",
144 true,
145 ));
146
147 output.push_str(&render_list_section_markdown(
149 "Potential Duplicates",
150 &triage.potential_duplicates,
151 "None found",
152 false,
153 ));
154
155 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 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 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 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 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#[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 let _ = writeln!(body, "## {}\n", response.theme);
226
227 if !response.narrative.is_empty() {
229 let _ = writeln!(body, "{}\n", response.narrative);
230 }
231
232 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 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 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 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 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 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 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
297pub 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 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 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}