1use anyhow::{Context, Result};
9use backon::Retryable;
10use octocrab::Octocrab;
11use serde::{Deserialize, Serialize};
12use tracing::{debug, instrument};
13
14use super::{ReferenceKind, parse_github_reference};
15use crate::ai::types::{IssueComment, IssueDetails, RepoIssueContext};
16use crate::retry::retry_backoff;
17use crate::utils::is_priority_label;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct UntriagedIssue {
22 pub number: u64,
24 pub title: String,
26 pub created_at: String,
28 pub url: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct GitTreeEntry {
35 pub path: String,
37 #[serde(rename = "type")]
39 pub type_: String,
40 pub mode: String,
42 pub sha: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct GitTreeResponse {
49 pub tree: Vec<GitTreeEntry>,
51 pub truncated: bool,
53}
54
55pub fn parse_owner_repo(s: &str) -> Result<(String, String)> {
63 let parts: Vec<&str> = s.split('/').collect();
64 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
65 anyhow::bail!(
66 "Invalid owner/repo format.\n\
67 Expected: owner/repo\n\
68 Got: {s}"
69 );
70 }
71 Ok((parts[0].to_string(), parts[1].to_string()))
72}
73
74pub fn parse_issue_reference(
90 input: &str,
91 repo_context: Option<&str>,
92) -> Result<(String, String, u64)> {
93 parse_github_reference(ReferenceKind::Issue, input, repo_context)
94}
95
96#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
102pub async fn fetch_issue_with_comments(
103 client: &Octocrab,
104 owner: &str,
105 repo: &str,
106 number: u64,
107) -> Result<IssueDetails> {
108 debug!("Fetching issue details");
109
110 let issue = (|| async {
112 client
113 .issues(owner, repo)
114 .get(number)
115 .await
116 .map_err(|e| anyhow::anyhow!(e))
117 })
118 .retry(retry_backoff())
119 .notify(|err, dur| {
120 tracing::warn!(
121 error = %err,
122 retry_after = ?dur,
123 "Retrying fetch_issue_with_comments (issue fetch)"
124 );
125 })
126 .await
127 .with_context(|| format!("Failed to fetch issue #{number} from {owner}/{repo}"))?;
128
129 let comments_page = (|| async {
131 client
132 .issues(owner, repo)
133 .list_comments(number)
134 .per_page(5)
135 .send()
136 .await
137 .map_err(|e| anyhow::anyhow!(e))
138 })
139 .retry(retry_backoff())
140 .notify(|err, dur| {
141 tracing::warn!(
142 error = %err,
143 retry_after = ?dur,
144 "Retrying fetch_issue_with_comments (comments fetch)"
145 );
146 })
147 .await
148 .with_context(|| format!("Failed to fetch comments for issue #{number}"))?;
149
150 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
152
153 let comments: Vec<IssueComment> = comments_page
154 .items
155 .iter()
156 .map(|c| IssueComment {
157 id: c.id.0,
158 author: c.user.login.clone(),
159 body: c.body.clone().unwrap_or_default(),
160 })
161 .collect();
162
163 let issue_url = issue.html_url.to_string();
164
165 let details = IssueDetails::builder()
166 .owner(owner.to_string())
167 .repo(repo.to_string())
168 .number(number)
169 .title(issue.title)
170 .body(issue.body.unwrap_or_default())
171 .labels(labels)
172 .comments(comments)
173 .url(issue_url)
174 .build();
175
176 debug!(
177 labels = details.labels.len(),
178 comments = details.comments.len(),
179 "Fetched issue details"
180 );
181
182 Ok(details)
183}
184
185pub fn extract_keywords(title: &str) -> Vec<String> {
201 let stop_words = [
202 "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "he", "in", "is",
203 "it", "its", "of", "on", "or", "that", "the", "to", "was", "will", "with",
204 ];
205
206 title
207 .to_lowercase()
208 .split(|c: char| !c.is_alphanumeric())
209 .filter(|word| !word.is_empty() && !stop_words.contains(word))
210 .take(5) .map(std::string::ToString::to_string)
212 .collect()
213}
214
215#[instrument(skip(client), fields(owner = %owner, repo = %repo, exclude_number = %exclude_number))]
232pub async fn search_related_issues(
233 client: &Octocrab,
234 owner: &str,
235 repo: &str,
236 title: &str,
237 exclude_number: u64,
238) -> Result<Vec<RepoIssueContext>> {
239 let keywords = extract_keywords(title);
240
241 if keywords.is_empty() {
242 debug!("No keywords extracted from title");
243 return Ok(Vec::new());
244 }
245
246 let query = format!("{} repo:{}/{} is:issue", keywords.join(" "), owner, repo);
248
249 debug!(query = %query, "Searching for related issues");
250
251 let search_result = (|| async {
253 client
254 .search()
255 .issues_and_pull_requests(&query)
256 .per_page(20)
257 .send()
258 .await
259 .map_err(|e| anyhow::anyhow!(e))
260 })
261 .retry(retry_backoff())
262 .notify(|err, dur| {
263 tracing::warn!(
264 error = %err,
265 retry_after = ?dur,
266 "Retrying search_related_issues"
267 );
268 })
269 .await
270 .with_context(|| format!("Failed to search for related issues in {owner}/{repo}"))?;
271
272 let related: Vec<RepoIssueContext> = search_result
274 .items
275 .iter()
276 .filter_map(|item| {
277 if item.pull_request.is_some() {
279 return None;
280 }
281
282 if item.number == exclude_number {
284 return None;
285 }
286
287 Some(RepoIssueContext {
288 number: item.number,
289 title: item.title.clone(),
290 labels: item.labels.iter().map(|l| l.name.clone()).collect(),
291 state: format!("{:?}", item.state).to_lowercase(),
292 })
293 })
294 .collect();
295
296 debug!(count = related.len(), "Found related issues");
297
298 Ok(related)
299}
300
301#[instrument(skip(client, body), fields(owner = %owner, repo = %repo, number = number))]
311pub async fn post_comment(
312 client: &Octocrab,
313 owner: &str,
314 repo: &str,
315 number: u64,
316 body: &str,
317) -> Result<String> {
318 debug!("Posting triage comment");
319
320 let comment = client
321 .issues(owner, repo)
322 .create_comment(number, body)
323 .await
324 .with_context(|| format!("Failed to post comment to issue #{number}"))?;
325
326 let comment_url = comment.html_url.to_string();
327
328 debug!(url = %comment_url, "Comment posted successfully");
329
330 Ok(comment_url)
331}
332
333#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
340pub async fn delete_issue_comment(
341 client: &Octocrab,
342 owner: &str,
343 repo: &str,
344 comment_id: u64,
345) -> Result<()> {
346 debug!("Deleting issue comment");
347
348 let route = format!("/repos/{owner}/{repo}/issues/comments/{comment_id}");
349
350 let empty_body = serde_json::json!({});
352 let result: std::result::Result<serde_json::Value, _> =
353 client.delete(&route, Some(&empty_body)).await;
354
355 match result {
356 Ok(_) => {
357 debug!("Comment deleted successfully");
358 Ok(())
359 }
360 Err(e)
361 if let octocrab::Error::GitHub { source, .. } = &e
362 && source.status_code.as_u16() == 404 =>
363 {
364 debug!("Comment already deleted (404); treating as success");
365 Ok(())
366 }
367 Err(e) => Err(e).with_context(|| format!("Failed to delete comment #{comment_id}")),
368 }
369}
370
371#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number, label = label))]
378pub async fn remove_issue_label(
379 client: &Octocrab,
380 owner: &str,
381 repo: &str,
382 number: u64,
383 label: &str,
384) -> Result<()> {
385 debug!("Removing label from issue");
386
387 let encoded_label =
389 percent_encoding::percent_encode(label.as_bytes(), percent_encoding::NON_ALPHANUMERIC)
390 .to_string();
391 let route = format!("/repos/{owner}/{repo}/issues/{number}/labels/{encoded_label}");
392
393 let empty_body = serde_json::json!({});
395 let result: std::result::Result<serde_json::Value, _> =
396 client.delete(&route, Some(&empty_body)).await;
397
398 match result {
399 Ok(_) => {
400 debug!("Label removed successfully");
401 Ok(())
402 }
403 Err(e)
404 if let octocrab::Error::GitHub { source, .. } = &e
405 && source.status_code.as_u16() == 404 =>
406 {
407 debug!("Label not found (404); treating as success");
408 Ok(())
409 }
410 Err(e) => {
411 Err(e).with_context(|| format!("Failed to remove label '{label}' from issue #{number}"))
412 }
413 }
414}
415
416#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
433pub async fn create_issue(
434 client: &Octocrab,
435 owner: &str,
436 repo: &str,
437 title: &str,
438 body: &str,
439) -> Result<(String, u64)> {
440 debug!("Creating GitHub issue");
441
442 let issue = Box::pin(client.issues(owner, repo).create(title).body(body).send())
443 .await
444 .with_context(|| format!("Failed to create issue in {owner}/{repo}"))?;
445
446 let issue_url = issue.html_url.to_string();
447 let issue_number = issue.number;
448
449 debug!(number = issue_number, url = %issue_url, "Issue created successfully");
450
451 Ok((issue_url, issue_number))
452}
453
454#[derive(Debug, Clone)]
456pub struct ApplyResult {
457 pub applied_labels: Vec<String>,
459 pub applied_milestone: Option<String>,
461 pub warnings: Vec<String>,
463}
464
465const MAINTAINER_ONLY_LABELS: &[&str] = &["good first issue", "help wanted"];
468
469fn merge_labels(existing_labels: &[String], suggested_labels: &[String]) -> Vec<String> {
484 let has_priority = existing_labels.iter().any(|label| is_priority_label(label));
486
487 let mut merged = existing_labels.to_vec();
489
490 for suggested in suggested_labels {
492 if is_priority_label(suggested) && has_priority {
494 continue;
495 }
496
497 if MAINTAINER_ONLY_LABELS
499 .iter()
500 .any(|&m| m.eq_ignore_ascii_case(suggested))
501 {
502 continue;
503 }
504
505 if !merged
507 .iter()
508 .any(|l| l.to_lowercase() == suggested.to_lowercase())
509 {
510 merged.push(suggested.clone());
511 }
512 }
513
514 merged
515}
516
517#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
527#[allow(clippy::too_many_arguments)]
528pub async fn update_issue_labels_and_milestone(
529 client: &Octocrab,
530 owner: &str,
531 repo: &str,
532 number: u64,
533 existing_labels: &[String],
534 suggested_labels: &[String],
535 existing_milestone: Option<&str>,
536 suggested_milestone: Option<&str>,
537 available_labels: &[crate::ai::types::RepoLabel],
538 available_milestones: &[crate::ai::types::RepoMilestone],
539) -> Result<ApplyResult> {
540 debug!("Updating issue with labels and milestone");
541
542 let mut warnings = Vec::new();
543
544 let available_label_names: std::collections::HashSet<_> =
546 available_labels.iter().map(|l| l.name.as_str()).collect();
547
548 let mut valid_suggested = Vec::new();
550 for label in suggested_labels {
551 if available_label_names.contains(label.as_str()) {
552 valid_suggested.push(label.clone());
553 } else {
554 warnings.push(format!("Label '{label}' not found in repository"));
555 }
556 }
557
558 let applied_labels = merge_labels(existing_labels, &valid_suggested);
560
561 let applied_milestone = if existing_milestone.is_none() {
563 if let Some(milestone_title) = suggested_milestone {
564 if let Some(milestone) = available_milestones
565 .iter()
566 .find(|m| m.title == milestone_title)
567 {
568 Some(milestone.title.clone())
569 } else {
570 warnings.push(format!(
571 "Milestone '{milestone_title}' not found in repository"
572 ));
573 None
574 }
575 } else {
576 None
577 }
578 } else {
579 None
580 };
581
582 let issues_handler = client.issues(owner, repo);
584 let mut update_builder = issues_handler.update(number);
585
586 if !applied_labels.is_empty() {
587 update_builder = update_builder.labels(&applied_labels);
588 }
589
590 #[allow(clippy::collapsible_if)]
591 if let Some(milestone_title) = &applied_milestone {
592 if let Some(milestone) = available_milestones
593 .iter()
594 .find(|m| &m.title == milestone_title)
595 {
596 update_builder = update_builder.milestone(milestone.number);
597 }
598 }
599
600 update_builder
601 .send()
602 .await
603 .with_context(|| format!("Failed to update issue #{number}"))?;
604
605 debug!(
606 labels = ?applied_labels,
607 milestone = ?applied_milestone,
608 warnings = ?warnings,
609 "Issue updated successfully"
610 );
611
612 Ok(ApplyResult {
613 applied_labels,
614 applied_milestone,
615 warnings,
616 })
617}
618
619#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
624pub async fn apply_labels_to_number(
625 client: &Octocrab,
626 owner: &str,
627 repo: &str,
628 number: u64,
629 labels: &[String],
630) -> Result<Vec<String>> {
631 debug!("Applying labels to issue/PR");
632
633 if labels.is_empty() {
634 debug!("No labels to apply");
635 return Ok(Vec::new());
636 }
637
638 let route = format!("/repos/{owner}/{repo}/issues/{number}/labels");
639 let payload = serde_json::json!({ "labels": labels });
640
641 client
642 .post::<_, serde_json::Value>(route, Some(&payload))
643 .await
644 .with_context(|| {
645 format!(
646 "Failed to apply labels to issue/PR #{number} in {owner}/{repo}. \
647 Check that you have write access to the repository."
648 )
649 })?;
650
651 debug!(labels = ?labels, "Labels applied successfully");
652
653 Ok(labels.to_vec())
654}
655
656const PRIORITY_LABELS: &[&str] = &[
659 "bug",
660 "enhancement",
661 "documentation",
662 "good first issue",
663 "help wanted",
664 "question",
665 "feature",
666 "fix",
667 "breaking",
668 "security",
669 "performance",
670 "breaking-change",
671];
672
673#[must_use]
690pub fn filter_labels_by_relevance(
691 labels: &[crate::ai::types::RepoLabel],
692 max_labels: usize,
693) -> Vec<crate::ai::types::RepoLabel> {
694 if labels.is_empty() || max_labels == 0 {
695 return Vec::new();
696 }
697
698 let mut priority_labels = Vec::new();
699 let mut other_labels = Vec::new();
700
701 for label in labels {
703 let label_lower = label.name.to_lowercase();
704 let is_priority = PRIORITY_LABELS
705 .iter()
706 .any(|&p| label_lower == p.to_lowercase());
707
708 if is_priority {
709 priority_labels.push(label.clone());
710 } else {
711 other_labels.push(label.clone());
712 }
713 }
714
715 let mut result = priority_labels;
717 let remaining_slots = max_labels.saturating_sub(result.len());
718 result.extend(other_labels.into_iter().take(remaining_slots));
719
720 result.truncate(max_labels);
722 result
723}
724
725const EXCLUDE_PATTERNS: &[&str] = &[
728 "node_modules/",
729 "vendor/",
730 "dist/",
731 "build/",
732 "target/",
733 ".git/",
734 "cache/",
735 "docs/",
736 "examples/",
737];
738
739const DEPRIORITIZE_PATTERNS: &[&str] = &[
742 "test/",
743 "tests/",
744 "spec/",
745 "bench/",
746 "eval/",
747 "fixtures/",
748 "mocks/",
749];
750
751fn entry_point_patterns(language: &str) -> Vec<&'static str> {
754 match language.to_lowercase().as_str() {
755 "rust" => vec!["lib.rs", "mod.rs", "main.rs"],
756 "python" => vec!["__init__.py"],
757 "javascript" | "typescript" => vec!["index.ts", "index.js"],
758 "java" => vec!["Main.java"],
759 "go" => vec!["main.go"],
760 "c#" | "csharp" => vec!["Program.cs"],
761 _ => vec![],
762 }
763}
764
765fn get_extensions_for_language(language: &str) -> Vec<&'static str> {
767 match language.to_lowercase().as_str() {
768 "rust" => vec!["rs"],
769 "python" => vec!["py"],
770 "javascript" | "typescript" => vec!["js", "ts", "jsx", "tsx"],
771 "java" => vec!["java"],
772 "c" => vec!["c", "h"],
773 "c++" | "cpp" => vec!["cpp", "cc", "cxx", "h", "hpp"],
774 "c#" | "csharp" => vec!["cs"],
775 "go" => vec!["go"],
776 "ruby" => vec!["rb"],
777 "php" => vec!["php"],
778 "swift" => vec!["swift"],
779 "kotlin" => vec!["kt"],
780 "scala" => vec!["scala"],
781 "r" => vec!["r"],
782 "shell" | "bash" => vec!["sh", "bash"],
783 "html" => vec!["html", "htm"],
784 "css" => vec!["css", "scss", "sass"],
785 "json" => vec!["json"],
786 "yaml" | "yml" => vec!["yaml", "yml"],
787 "toml" => vec!["toml"],
788 "xml" => vec!["xml"],
789 "markdown" => vec!["md"],
790 _ => vec![],
791 }
792}
793
794fn filter_tree_by_relevance(
813 entries: &[GitTreeEntry],
814 language: &str,
815 keywords: &[String],
816) -> Vec<String> {
817 let extensions = get_extensions_for_language(language);
818 let entry_points = entry_point_patterns(language);
819
820 let candidates: Vec<String> = entries
822 .iter()
823 .filter(|entry| {
824 if entry.type_ != "blob" {
826 return false;
827 }
828
829 if EXCLUDE_PATTERNS.iter().any(|dir| entry.path.contains(dir)) {
831 return false;
832 }
833
834 if extensions.is_empty() {
836 true
838 } else {
839 extensions.iter().any(|ext| entry.path.ends_with(ext))
840 }
841 })
842 .map(|e| e.path.clone())
843 .collect();
844
845 let mut tier1: Vec<String> = Vec::new();
847 let mut remaining: Vec<String> = Vec::new();
848
849 for path in candidates {
850 let path_lower = path.to_lowercase();
851 let matches_keyword = keywords.iter().any(|kw| path_lower.contains(kw));
852
853 if matches_keyword && tier1.len() < 35 {
854 tier1.push(path);
855 } else {
856 remaining.push(path);
857 }
858 }
859
860 let mut tier2: Vec<String> = Vec::new();
862 let mut tier3_candidates: Vec<String> = Vec::new();
863
864 for path in remaining {
865 let is_entry_point = entry_points.iter().any(|ep| path.ends_with(ep));
866 let is_deprioritized = DEPRIORITIZE_PATTERNS.iter().any(|dp| path.contains(dp));
867
868 if is_entry_point && tier2.len() < 10 {
869 tier2.push(path);
870 } else if !is_deprioritized {
871 tier3_candidates.push(path);
872 }
873 }
874
875 let mut tier3: Vec<String> = tier3_candidates.into_iter().take(15).collect();
877
878 let mut result = tier1;
880 result.append(&mut tier2);
881 result.append(&mut tier3);
882
883 result.sort_by(|a, b| {
885 let depth_a = a.matches('/').count();
886 let depth_b = b.matches('/').count();
887 if depth_a == depth_b {
888 a.cmp(b)
889 } else {
890 depth_a.cmp(&depth_b)
891 }
892 });
893
894 result.truncate(60);
896 result
897}
898
899#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
916pub async fn fetch_repo_tree(
917 client: &Octocrab,
918 owner: &str,
919 repo: &str,
920 language: &str,
921 keywords: &[String],
922) -> Result<Vec<String>> {
923 debug!("Fetching repository tree");
924
925 let branches = ["main", "master"];
927 let mut tree_response: Option<GitTreeResponse> = None;
928
929 for branch in &branches {
930 let route = format!("/repos/{owner}/{repo}/git/trees/{branch}?recursive=1");
931 let result = (|| async {
932 client
933 .get::<GitTreeResponse, _, _>(&route, None::<&()>)
934 .await
935 .map_err(|e| anyhow::anyhow!(e))
936 })
937 .retry(retry_backoff())
938 .notify(|err, dur| {
939 tracing::warn!(
940 error = %err,
941 retry_after = ?dur,
942 branch = %branch,
943 "Retrying fetch_repo_tree"
944 );
945 })
946 .await;
947
948 match result {
949 Ok(response) => {
950 tree_response = Some(response);
951 debug!(branch = %branch, "Fetched tree from branch");
952 break;
953 }
954 Err(e) => {
955 debug!(branch = %branch, error = %e, "Failed to fetch tree from branch");
956 }
957 }
958 }
959
960 let response =
961 tree_response.context("Failed to fetch repository tree from main or master branch")?;
962
963 let filtered = filter_tree_by_relevance(&response.tree, language, keywords);
964 debug!(count = filtered.len(), "Filtered tree entries");
965
966 Ok(filtered)
967}
968
969#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
987pub async fn fetch_issues_needing_triage(
988 client: &Octocrab,
989 owner: &str,
990 repo: &str,
991 since: Option<&str>,
992 force: bool,
993 state: octocrab::params::State,
994) -> Result<Vec<UntriagedIssue>> {
995 debug!("Fetching issues needing triage");
996
997 let issues_page: octocrab::Page<octocrab::models::issues::Issue> = client
998 .issues(owner, repo)
999 .list()
1000 .state(state)
1001 .per_page(100)
1002 .send()
1003 .await
1004 .context("Failed to fetch issues from repository")?;
1005
1006 let total_issues = issues_page.items.len();
1007
1008 let mut issues_needing_triage: Vec<UntriagedIssue> = issues_page
1009 .items
1010 .into_iter()
1011 .filter(|issue| {
1012 if force {
1013 true
1014 } else {
1015 issue.labels.is_empty() || issue.milestone.is_none()
1016 }
1017 })
1018 .map(|issue| UntriagedIssue {
1019 number: issue.number,
1020 title: issue.title,
1021 created_at: issue.created_at.to_rfc3339(),
1022 url: issue.html_url.to_string(),
1023 })
1024 .collect();
1025
1026 if let Some(since_date) = since
1027 && let Ok(since_timestamp) = chrono::DateTime::parse_from_rfc3339(since_date)
1028 {
1029 issues_needing_triage.retain(|issue| {
1030 if let Ok(created_at) = chrono::DateTime::parse_from_rfc3339(&issue.created_at) {
1031 created_at >= since_timestamp
1032 } else {
1033 true
1034 }
1035 });
1036 }
1037
1038 debug!(
1039 total_issues = total_issues,
1040 issues_needing_triage_count = issues_needing_triage.len(),
1041 "Fetched issues needing triage"
1042 );
1043
1044 Ok(issues_needing_triage)
1045}
1046
1047#[cfg(test)]
1048mod fetch_issues_needing_triage_tests {
1049 #[test]
1050 fn filter_logic_unlabeled_default_mode() {
1051 let labels_empty = true;
1052 let milestone_none = true;
1053 let force = false;
1054
1055 let passes = if force {
1056 true
1057 } else {
1058 labels_empty || milestone_none
1059 };
1060
1061 assert!(passes);
1062 }
1063
1064 #[test]
1065 fn filter_logic_labeled_default_mode() {
1066 let labels_empty = false;
1067 let milestone_none = true;
1068 let force = false;
1069
1070 let passes = if force {
1071 true
1072 } else {
1073 labels_empty || milestone_none
1074 };
1075
1076 assert!(passes);
1077 }
1078
1079 #[test]
1080 fn filter_logic_missing_milestone_default_mode() {
1081 let labels_empty = false;
1082 let milestone_none = true;
1083 let force = false;
1084
1085 let passes = if force {
1086 true
1087 } else {
1088 labels_empty || milestone_none
1089 };
1090
1091 assert!(passes);
1092 }
1093
1094 #[test]
1095 fn filter_logic_force_mode_returns_all() {
1096 let labels_empty = false;
1097 let milestone_none = false;
1098 let force = true;
1099
1100 let passes = if force {
1101 true
1102 } else {
1103 labels_empty || milestone_none
1104 };
1105
1106 assert!(passes);
1107 }
1108
1109 #[test]
1110 fn filter_logic_fully_triaged_default_mode_excluded() {
1111 let labels_empty = false;
1112 let milestone_none = false;
1113 let force = false;
1114
1115 let passes = if force {
1116 true
1117 } else {
1118 labels_empty || milestone_none
1119 };
1120
1121 assert!(!passes);
1122 }
1123}
1124
1125#[cfg(test)]
1126mod tree_tests {
1127 use super::*;
1128
1129 #[test]
1130 fn filter_tree_by_relevance_keyword_matching() {
1131 let entries = vec![
1132 GitTreeEntry {
1133 path: "src/parser.rs".to_string(),
1134 type_: "blob".to_string(),
1135 mode: "100644".to_string(),
1136 sha: "abc123".to_string(),
1137 },
1138 GitTreeEntry {
1139 path: "src/main.rs".to_string(),
1140 type_: "blob".to_string(),
1141 mode: "100644".to_string(),
1142 sha: "def456".to_string(),
1143 },
1144 GitTreeEntry {
1145 path: "src/utils.rs".to_string(),
1146 type_: "blob".to_string(),
1147 mode: "100644".to_string(),
1148 sha: "ghi789".to_string(),
1149 },
1150 ];
1151
1152 let keywords = vec!["parser".to_string()];
1153 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1154 assert!(filtered.contains(&"src/parser.rs".to_string()));
1155 }
1156
1157 #[test]
1158 fn filter_tree_by_relevance_entry_points() {
1159 let entries = vec![
1160 GitTreeEntry {
1161 path: "src/lib.rs".to_string(),
1162 type_: "blob".to_string(),
1163 mode: "100644".to_string(),
1164 sha: "abc123".to_string(),
1165 },
1166 GitTreeEntry {
1167 path: "src/utils.rs".to_string(),
1168 type_: "blob".to_string(),
1169 mode: "100644".to_string(),
1170 sha: "def456".to_string(),
1171 },
1172 ];
1173
1174 let keywords = vec![];
1175 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1176 assert!(filtered.contains(&"src/lib.rs".to_string()));
1177 }
1178
1179 #[test]
1180 fn filter_tree_by_relevance_excludes_tests() {
1181 let entries = vec![
1182 GitTreeEntry {
1183 path: "src/main.rs".to_string(),
1184 type_: "blob".to_string(),
1185 mode: "100644".to_string(),
1186 sha: "abc123".to_string(),
1187 },
1188 GitTreeEntry {
1189 path: "tests/integration_test.rs".to_string(),
1190 type_: "blob".to_string(),
1191 mode: "100644".to_string(),
1192 sha: "def456".to_string(),
1193 },
1194 ];
1195
1196 let keywords = vec![];
1197 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1198 assert!(!filtered.contains(&"tests/integration_test.rs".to_string()));
1199 assert!(filtered.contains(&"src/main.rs".to_string()));
1200 }
1201
1202 #[test]
1203 fn get_extensions_for_language_rust() {
1204 let exts = get_extensions_for_language("rust");
1205 assert_eq!(exts, vec!["rs"]);
1206 }
1207
1208 #[test]
1209 fn get_extensions_for_language_javascript() {
1210 let exts = get_extensions_for_language("javascript");
1211 assert!(exts.contains(&"js"));
1212 assert!(exts.contains(&"ts"));
1213 assert!(exts.contains(&"jsx"));
1214 assert!(exts.contains(&"tsx"));
1215 }
1216
1217 #[test]
1218 fn get_extensions_for_language_unknown() {
1219 let exts = get_extensions_for_language("unknown_language");
1220 assert!(exts.is_empty());
1221 }
1222}
1223
1224#[cfg(test)]
1225mod merge_labels_tests {
1226 use super::*;
1227
1228 #[test]
1229 fn preserves_existing_and_adds_new() {
1230 let existing = vec!["bug".to_string(), "enhancement".to_string()];
1231 let suggested = vec!["documentation".to_string()];
1232 let merged = merge_labels(&existing, &suggested);
1233 assert_eq!(merged.len(), 3);
1234 assert!(merged.contains(&"bug".to_string()));
1235 assert!(merged.contains(&"enhancement".to_string()));
1236 assert!(merged.contains(&"documentation".to_string()));
1237 }
1238
1239 #[test]
1240 fn deduplicates_case_insensitive() {
1241 let existing = vec!["Bug".to_string()];
1242 let suggested = vec!["bug".to_string(), "enhancement".to_string()];
1243 let merged = merge_labels(&existing, &suggested);
1244 assert_eq!(merged.len(), 2);
1245 assert!(merged.contains(&"Bug".to_string()));
1246 assert!(merged.contains(&"enhancement".to_string()));
1247 }
1248
1249 #[test]
1250 fn skips_priority_when_existing_has_one() {
1251 let existing = vec!["P1".to_string()];
1253 let suggested = vec!["p2".to_string(), "bug".to_string()];
1254 let merged = merge_labels(&existing, &suggested);
1255 assert_eq!(merged.len(), 2);
1256 assert!(merged.contains(&"P1".to_string()));
1257 assert!(merged.contains(&"bug".to_string()));
1258 assert!(!merged.contains(&"p2".to_string()));
1259 }
1260
1261 #[test]
1262 fn handles_empty_inputs() {
1263 let merged = merge_labels(&[], &["bug".to_string(), "p1".to_string()]);
1265 assert_eq!(merged.len(), 2);
1266
1267 let merged = merge_labels(&["bug".to_string()], &[]);
1269 assert_eq!(merged.len(), 1);
1270 assert!(merged.contains(&"bug".to_string()));
1271 }
1272
1273 #[test]
1274 fn filters_maintainer_only_labels() {
1275 let existing = vec![];
1276 let suggested = vec![
1277 "good first issue".to_string(),
1278 "help wanted".to_string(),
1279 "bug".to_string(),
1280 ];
1281 let merged = merge_labels(&existing, &suggested);
1282 assert_eq!(merged.len(), 1);
1283 assert!(merged.contains(&"bug".to_string()));
1284 assert!(!merged.contains(&"good first issue".to_string()));
1285 assert!(!merged.contains(&"help wanted".to_string()));
1286 }
1287
1288 #[test]
1289 fn filters_maintainer_only_case_insensitive() {
1290 let existing = vec![];
1291 let suggested = vec![
1292 "Good First Issue".to_string(),
1293 "HELP WANTED".to_string(),
1294 "enhancement".to_string(),
1295 ];
1296 let merged = merge_labels(&existing, &suggested);
1297 assert_eq!(merged.len(), 1);
1298 assert!(merged.contains(&"enhancement".to_string()));
1299 assert!(!merged.contains(&"Good First Issue".to_string()));
1300 assert!(!merged.contains(&"HELP WANTED".to_string()));
1301 }
1302
1303 #[test]
1304 fn skips_priority_prefix_when_existing_has_one() {
1305 let existing = vec!["priority: high".to_string()];
1307 let suggested = vec!["priority: medium".to_string(), "bug".to_string()];
1308 let merged = merge_labels(&existing, &suggested);
1309 assert_eq!(merged.len(), 2);
1310 assert!(merged.contains(&"priority: high".to_string()));
1311 assert!(merged.contains(&"bug".to_string()));
1312 assert!(!merged.contains(&"priority: medium".to_string()));
1313 }
1314
1315 #[test]
1316 fn skips_mixed_priority_formats_when_existing_has_one() {
1317 let existing = vec!["p1".to_string()];
1319 let suggested = vec!["priority: high".to_string(), "bug".to_string()];
1320 let merged = merge_labels(&existing, &suggested);
1321 assert_eq!(merged.len(), 2);
1322 assert!(merged.contains(&"p1".to_string()));
1323 assert!(merged.contains(&"bug".to_string()));
1324 assert!(!merged.contains(&"priority: high".to_string()));
1325 }
1326}
1327
1328#[cfg(test)]
1329mod label_tests {
1330 use super::*;
1331
1332 #[test]
1333 fn filter_labels_empty_input() {
1334 let labels = vec![];
1335 let filtered = filter_labels_by_relevance(&labels, 30);
1336 assert!(filtered.is_empty());
1337 }
1338
1339 #[test]
1340 fn filter_labels_zero_max() {
1341 let labels = vec![crate::ai::types::RepoLabel {
1342 name: "bug".to_string(),
1343 color: "ff0000".to_string(),
1344 description: "Bug report".to_string(),
1345 }];
1346 let filtered = filter_labels_by_relevance(&labels, 0);
1347 assert!(filtered.is_empty());
1348 }
1349
1350 #[test]
1351 fn filter_labels_priority_first() {
1352 let labels = vec![
1353 crate::ai::types::RepoLabel {
1354 name: "documentation".to_string(),
1355 color: "0075ca".to_string(),
1356 description: "Documentation".to_string(),
1357 },
1358 crate::ai::types::RepoLabel {
1359 name: "other".to_string(),
1360 color: "cccccc".to_string(),
1361 description: "Other".to_string(),
1362 },
1363 crate::ai::types::RepoLabel {
1364 name: "bug".to_string(),
1365 color: "ff0000".to_string(),
1366 description: "Bug".to_string(),
1367 },
1368 ];
1369 let filtered = filter_labels_by_relevance(&labels, 30);
1370 assert_eq!(filtered.len(), 3);
1371 assert_eq!(filtered[0].name, "documentation");
1372 assert_eq!(filtered[1].name, "bug");
1373 assert_eq!(filtered[2].name, "other");
1374 }
1375
1376 #[test]
1377 fn filter_labels_case_insensitive() {
1378 let labels = vec![
1379 crate::ai::types::RepoLabel {
1380 name: "Bug".to_string(),
1381 color: "ff0000".to_string(),
1382 description: "Bug".to_string(),
1383 },
1384 crate::ai::types::RepoLabel {
1385 name: "ENHANCEMENT".to_string(),
1386 color: "a2eeef".to_string(),
1387 description: "Enhancement".to_string(),
1388 },
1389 ];
1390 let filtered = filter_labels_by_relevance(&labels, 30);
1391 assert_eq!(filtered.len(), 2);
1392 assert_eq!(filtered[0].name, "Bug");
1393 assert_eq!(filtered[1].name, "ENHANCEMENT");
1394 }
1395
1396 #[test]
1397 fn filter_labels_over_limit_with_priorities() {
1398 let mut labels = vec![];
1399 for i in 0..20 {
1400 labels.push(crate::ai::types::RepoLabel {
1401 name: format!("label{i}"),
1402 color: "cccccc".to_string(),
1403 description: format!("Label {i}"),
1404 });
1405 }
1406 labels.push(crate::ai::types::RepoLabel {
1407 name: "bug".to_string(),
1408 color: "ff0000".to_string(),
1409 description: "Bug".to_string(),
1410 });
1411 labels.push(crate::ai::types::RepoLabel {
1412 name: "enhancement".to_string(),
1413 color: "a2eeef".to_string(),
1414 description: "Enhancement".to_string(),
1415 });
1416
1417 let filtered = filter_labels_by_relevance(&labels, 10);
1418 assert_eq!(filtered.len(), 10);
1419 assert_eq!(filtered[0].name, "bug");
1420 assert_eq!(filtered[1].name, "enhancement");
1421 }
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427
1428 #[test]
1431 fn parse_issue_reference_delegates_to_shared() {
1432 let (owner, repo, number) =
1433 parse_issue_reference("https://github.com/block/goose/issues/5836", None).unwrap();
1434 assert_eq!(owner, "block");
1435 assert_eq!(repo, "goose");
1436 assert_eq!(number, 5836);
1437 }
1438
1439 #[test]
1440 fn extract_keywords_filters_stop_words() {
1441 let title = "The issue is about a bug in the CLI";
1442 let keywords = extract_keywords(title);
1443 assert!(!keywords.contains(&"the".to_string()));
1444 assert!(!keywords.contains(&"is".to_string()));
1445 assert!(!keywords.contains(&"a".to_string()));
1446 assert!(keywords.contains(&"issue".to_string()));
1447 assert!(keywords.contains(&"bug".to_string()));
1448 assert!(keywords.contains(&"cli".to_string()));
1449 }
1450
1451 #[test]
1452 fn extract_keywords_limits_to_five() {
1453 let title = "one two three four five six seven eight nine ten";
1454 let keywords = extract_keywords(title);
1455 assert_eq!(keywords.len(), 5);
1456 }
1457
1458 #[test]
1459 fn extract_keywords_empty_title() {
1460 let title = "the a an and or";
1461 let keywords = extract_keywords(title);
1462 assert!(keywords.is_empty());
1463 }
1464
1465 #[test]
1466 fn extract_keywords_lowercase_conversion() {
1467 let title = "CLI Bug FIX";
1468 let keywords = extract_keywords(title);
1469 assert!(keywords.iter().all(|k| k.chars().all(char::is_lowercase)));
1470 }
1471}