1use anyhow::{Context, Result};
9use backon::Retryable;
10use octocrab::Octocrab;
11use serde::{Deserialize, Serialize};
12use tracing::{debug, instrument};
13
14use crate::ai::types::{IssueComment, IssueDetails, RepoIssueContext};
15use crate::retry::retry_backoff;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct UntriagedIssue {
20 pub number: u64,
22 pub title: String,
24 pub created_at: String,
26 pub url: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct GitTreeEntry {
33 pub path: String,
35 #[serde(rename = "type")]
37 pub type_: String,
38 pub mode: String,
40 pub sha: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct GitTreeResponse {
47 pub tree: Vec<GitTreeEntry>,
49 pub truncated: bool,
51}
52
53pub fn parse_owner_repo(s: &str) -> Result<(String, String)> {
61 let parts: Vec<&str> = s.split('/').collect();
62 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
63 anyhow::bail!(
64 "Invalid owner/repo format.\n\
65 Expected: owner/repo\n\
66 Got: {s}"
67 );
68 }
69 Ok((parts[0].to_string(), parts[1].to_string()))
70}
71
72pub fn parse_issue_reference(
88 input: &str,
89 repo_context: Option<&str>,
90) -> Result<(String, String, u64)> {
91 let input = input.trim();
92
93 if input.starts_with("https://") || input.starts_with("http://") {
95 let clean_url = input.split('#').next().unwrap_or(input);
97 let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
98
99 let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
101
102 if parts.len() < 7 {
104 anyhow::bail!(
105 "Invalid GitHub issue URL format.\n\
106 Expected: https://github.com/owner/repo/issues/123\n\
107 Got: {input}"
108 );
109 }
110
111 if !parts[2].contains("github.com") {
113 anyhow::bail!(
114 "URL must be a GitHub issue URL.\n\
115 Expected: https://github.com/owner/repo/issues/123\n\
116 Got: {input}"
117 );
118 }
119
120 if parts[5] != "issues" {
122 anyhow::bail!(
123 "URL must point to a GitHub issue.\n\
124 Expected: https://github.com/owner/repo/issues/123\n\
125 Got: {input}"
126 );
127 }
128
129 let owner = parts[3].to_string();
130 let repo = parts[4].to_string();
131 let number: u64 = parts[6].parse().with_context(|| {
132 format!(
133 "Invalid issue number '{}' in URL.\n\
134 Expected a numeric issue number.",
135 parts[6]
136 )
137 })?;
138
139 debug!(owner = %owner, repo = %repo, number = number, "Parsed issue URL");
140 return Ok((owner, repo, number));
141 }
142
143 if let Some(hash_pos) = input.find('#') {
145 let owner_repo_part = &input[..hash_pos];
146 let number_part = &input[hash_pos + 1..];
147
148 let (owner, repo) = parse_owner_repo(owner_repo_part)?;
149 let number: u64 = number_part.parse().with_context(|| {
150 format!(
151 "Invalid issue number '{number_part}' in short form.\n\
152 Expected: owner/repo#123\n\
153 Got: {input}"
154 )
155 })?;
156
157 debug!(owner = %owner, repo = %repo, number = number, "Parsed short-form issue reference");
158 return Ok((owner, repo, number));
159 }
160
161 if let Ok(number) = input.parse::<u64>() {
163 let repo_context = repo_context.ok_or_else(|| {
164 anyhow::anyhow!(
165 "Bare issue number requires repository context.\n\
166 Use one of:\n\
167 - Full URL: https://github.com/owner/repo/issues/123\n\
168 - Short form: owner/repo#123\n\
169 - Bare number with --repo flag: 123 --repo owner/repo\n\
170 Got: {input}"
171 )
172 })?;
173
174 let (owner, repo) = parse_owner_repo(repo_context)?;
175 debug!(owner = %owner, repo = %repo, number = number, "Parsed bare issue number");
176 return Ok((owner, repo, number));
177 }
178
179 anyhow::bail!(
181 "Invalid issue reference format.\n\
182 Expected one of:\n\
183 - Full URL: https://github.com/owner/repo/issues/123\n\
184 - Short form: owner/repo#123\n\
185 - Bare number with --repo flag: 123 --repo owner/repo\n\
186 Got: {input}"
187 );
188}
189
190#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
196pub async fn fetch_issue_with_comments(
197 client: &Octocrab,
198 owner: &str,
199 repo: &str,
200 number: u64,
201) -> Result<IssueDetails> {
202 debug!("Fetching issue details");
203
204 let issue = (|| async {
206 client
207 .issues(owner, repo)
208 .get(number)
209 .await
210 .map_err(|e| anyhow::anyhow!(e))
211 })
212 .retry(retry_backoff())
213 .notify(|err, dur| {
214 tracing::warn!(
215 error = %err,
216 retry_after = ?dur,
217 "Retrying fetch_issue_with_comments (issue fetch)"
218 );
219 })
220 .await
221 .with_context(|| format!("Failed to fetch issue #{number} from {owner}/{repo}"))?;
222
223 let comments_page = (|| async {
225 client
226 .issues(owner, repo)
227 .list_comments(number)
228 .per_page(5)
229 .send()
230 .await
231 .map_err(|e| anyhow::anyhow!(e))
232 })
233 .retry(retry_backoff())
234 .notify(|err, dur| {
235 tracing::warn!(
236 error = %err,
237 retry_after = ?dur,
238 "Retrying fetch_issue_with_comments (comments fetch)"
239 );
240 })
241 .await
242 .with_context(|| format!("Failed to fetch comments for issue #{number}"))?;
243
244 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
246
247 let comments: Vec<IssueComment> = comments_page
248 .items
249 .iter()
250 .map(|c| IssueComment {
251 author: c.user.login.clone(),
252 body: c.body.clone().unwrap_or_default(),
253 })
254 .collect();
255
256 let issue_url = issue.html_url.to_string();
257
258 let details = IssueDetails {
259 owner: owner.to_string(),
260 repo: repo.to_string(),
261 number,
262 title: issue.title,
263 body: issue.body.unwrap_or_default(),
264 labels,
265 comments,
266 url: issue_url,
267 repo_context: Vec::new(),
268 repo_tree: Vec::new(),
269 available_labels: Vec::new(),
270 available_milestones: Vec::new(),
271 viewer_permission: None,
272 };
273
274 debug!(
275 labels = details.labels.len(),
276 comments = details.comments.len(),
277 "Fetched issue details"
278 );
279
280 Ok(details)
281}
282
283pub fn extract_keywords(title: &str) -> Vec<String> {
299 let stop_words = [
300 "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "he", "in", "is",
301 "it", "its", "of", "on", "or", "that", "the", "to", "was", "will", "with",
302 ];
303
304 title
305 .to_lowercase()
306 .split(|c: char| !c.is_alphanumeric())
307 .filter(|word| !word.is_empty() && !stop_words.contains(word))
308 .take(5) .map(std::string::ToString::to_string)
310 .collect()
311}
312
313#[instrument(skip(client), fields(owner = %owner, repo = %repo, exclude_number = %exclude_number))]
330pub async fn search_related_issues(
331 client: &Octocrab,
332 owner: &str,
333 repo: &str,
334 title: &str,
335 exclude_number: u64,
336) -> Result<Vec<RepoIssueContext>> {
337 let keywords = extract_keywords(title);
338
339 if keywords.is_empty() {
340 debug!("No keywords extracted from title");
341 return Ok(Vec::new());
342 }
343
344 let query = format!("{} repo:{}/{} is:issue", keywords.join(" "), owner, repo);
346
347 debug!(query = %query, "Searching for related issues");
348
349 let search_result = (|| async {
351 client
352 .search()
353 .issues_and_pull_requests(&query)
354 .per_page(20)
355 .send()
356 .await
357 .map_err(|e| anyhow::anyhow!(e))
358 })
359 .retry(retry_backoff())
360 .notify(|err, dur| {
361 tracing::warn!(
362 error = %err,
363 retry_after = ?dur,
364 "Retrying search_related_issues"
365 );
366 })
367 .await
368 .with_context(|| format!("Failed to search for related issues in {owner}/{repo}"))?;
369
370 let related: Vec<RepoIssueContext> = search_result
372 .items
373 .iter()
374 .filter_map(|item| {
375 if item.pull_request.is_some() {
377 return None;
378 }
379
380 if item.number == exclude_number {
382 return None;
383 }
384
385 Some(RepoIssueContext {
386 number: item.number,
387 title: item.title.clone(),
388 labels: item.labels.iter().map(|l| l.name.clone()).collect(),
389 state: format!("{:?}", item.state).to_lowercase(),
390 })
391 })
392 .collect();
393
394 debug!(count = related.len(), "Found related issues");
395
396 Ok(related)
397}
398
399#[instrument(skip(client, body), fields(owner = %owner, repo = %repo, number = number))]
409pub async fn post_comment(
410 client: &Octocrab,
411 owner: &str,
412 repo: &str,
413 number: u64,
414 body: &str,
415) -> Result<String> {
416 debug!("Posting triage comment");
417
418 let comment = client
419 .issues(owner, repo)
420 .create_comment(number, body)
421 .await
422 .with_context(|| format!("Failed to post comment to issue #{number}"))?;
423
424 let comment_url = comment.html_url.to_string();
425
426 debug!(url = %comment_url, "Comment posted successfully");
427
428 Ok(comment_url)
429}
430
431#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
448pub async fn create_issue(
449 client: &Octocrab,
450 owner: &str,
451 repo: &str,
452 title: &str,
453 body: &str,
454) -> Result<(String, u64)> {
455 debug!("Creating GitHub issue");
456
457 let issue = client
458 .issues(owner, repo)
459 .create(title)
460 .body(body)
461 .send()
462 .await
463 .with_context(|| format!("Failed to create issue in {owner}/{repo}"))?;
464
465 let issue_url = issue.html_url.to_string();
466 let issue_number = issue.number;
467
468 debug!(number = issue_number, url = %issue_url, "Issue created successfully");
469
470 Ok((issue_url, issue_number))
471}
472
473#[derive(Debug, Clone)]
475pub struct ApplyResult {
476 pub applied_labels: Vec<String>,
478 pub applied_milestone: Option<String>,
480 pub warnings: Vec<String>,
482}
483
484#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
493#[allow(clippy::too_many_arguments)]
494pub async fn update_issue_labels_and_milestone(
495 client: &Octocrab,
496 owner: &str,
497 repo: &str,
498 number: u64,
499 suggested_labels: &[String],
500 suggested_milestone: Option<&str>,
501 available_labels: &[crate::ai::types::RepoLabel],
502 available_milestones: &[crate::ai::types::RepoMilestone],
503) -> Result<ApplyResult> {
504 debug!("Updating issue with labels and milestone");
505
506 let mut applied_labels = Vec::new();
507 let mut warnings = Vec::new();
508
509 let available_label_names: std::collections::HashSet<_> =
511 available_labels.iter().map(|l| l.name.as_str()).collect();
512
513 for label in suggested_labels {
514 if available_label_names.contains(label.as_str()) {
515 applied_labels.push(label.clone());
516 } else {
517 warnings.push(format!("Label '{label}' not found in repository"));
518 }
519 }
520
521 let mut applied_milestone = None;
523 if let Some(milestone_title) = suggested_milestone {
524 if let Some(milestone) = available_milestones
525 .iter()
526 .find(|m| m.title == milestone_title)
527 {
528 applied_milestone = Some(milestone.title.clone());
529 } else {
530 warnings.push(format!(
531 "Milestone '{milestone_title}' not found in repository"
532 ));
533 }
534 }
535
536 let issues_handler = client.issues(owner, repo);
538 let mut update_builder = issues_handler.update(number);
539
540 if !applied_labels.is_empty() {
541 update_builder = update_builder.labels(&applied_labels);
542 }
543
544 #[allow(clippy::collapsible_if)]
545 if let Some(milestone_title) = &applied_milestone {
546 if let Some(milestone) = available_milestones
547 .iter()
548 .find(|m| &m.title == milestone_title)
549 {
550 update_builder = update_builder.milestone(milestone.number);
551 }
552 }
553
554 update_builder
555 .send()
556 .await
557 .with_context(|| format!("Failed to update issue #{number}"))?;
558
559 debug!(
560 labels = ?applied_labels,
561 milestone = ?applied_milestone,
562 warnings = ?warnings,
563 "Issue updated successfully"
564 );
565
566 Ok(ApplyResult {
567 applied_labels,
568 applied_milestone,
569 warnings,
570 })
571}
572
573const PRIORITY_LABELS: &[&str] = &[
576 "bug",
577 "enhancement",
578 "documentation",
579 "good first issue",
580 "help wanted",
581 "question",
582 "feature",
583 "fix",
584 "breaking",
585 "security",
586 "performance",
587 "breaking-change",
588];
589
590#[must_use]
607pub fn filter_labels_by_relevance(
608 labels: &[crate::ai::types::RepoLabel],
609 max_labels: usize,
610) -> Vec<crate::ai::types::RepoLabel> {
611 if labels.is_empty() || max_labels == 0 {
612 return Vec::new();
613 }
614
615 let mut priority_labels = Vec::new();
616 let mut other_labels = Vec::new();
617
618 for label in labels {
620 let label_lower = label.name.to_lowercase();
621 let is_priority = PRIORITY_LABELS
622 .iter()
623 .any(|&p| label_lower == p.to_lowercase());
624
625 if is_priority {
626 priority_labels.push(label.clone());
627 } else {
628 other_labels.push(label.clone());
629 }
630 }
631
632 let mut result = priority_labels;
634 let remaining_slots = max_labels.saturating_sub(result.len());
635 result.extend(other_labels.into_iter().take(remaining_slots));
636
637 result.truncate(max_labels);
639 result
640}
641
642const EXCLUDE_PATTERNS: &[&str] = &[
645 "node_modules/",
646 "vendor/",
647 "dist/",
648 "build/",
649 "target/",
650 ".git/",
651 "cache/",
652 "docs/",
653 "examples/",
654];
655
656const DEPRIORITIZE_PATTERNS: &[&str] = &[
659 "test/",
660 "tests/",
661 "spec/",
662 "bench/",
663 "eval/",
664 "fixtures/",
665 "mocks/",
666];
667
668fn entry_point_patterns(language: &str) -> Vec<&'static str> {
671 match language.to_lowercase().as_str() {
672 "rust" => vec!["lib.rs", "mod.rs", "main.rs"],
673 "python" => vec!["__init__.py"],
674 "javascript" | "typescript" => vec!["index.ts", "index.js"],
675 "java" => vec!["Main.java"],
676 "go" => vec!["main.go"],
677 "c#" | "csharp" => vec!["Program.cs"],
678 _ => vec![],
679 }
680}
681
682fn get_extensions_for_language(language: &str) -> Vec<&'static str> {
684 match language.to_lowercase().as_str() {
685 "rust" => vec!["rs"],
686 "python" => vec!["py"],
687 "javascript" | "typescript" => vec!["js", "ts", "jsx", "tsx"],
688 "java" => vec!["java"],
689 "c" => vec!["c", "h"],
690 "c++" | "cpp" => vec!["cpp", "cc", "cxx", "h", "hpp"],
691 "c#" | "csharp" => vec!["cs"],
692 "go" => vec!["go"],
693 "ruby" => vec!["rb"],
694 "php" => vec!["php"],
695 "swift" => vec!["swift"],
696 "kotlin" => vec!["kt"],
697 "scala" => vec!["scala"],
698 "r" => vec!["r"],
699 "shell" | "bash" => vec!["sh", "bash"],
700 "html" => vec!["html", "htm"],
701 "css" => vec!["css", "scss", "sass"],
702 "json" => vec!["json"],
703 "yaml" | "yml" => vec!["yaml", "yml"],
704 "toml" => vec!["toml"],
705 "xml" => vec!["xml"],
706 "markdown" => vec!["md"],
707 _ => vec![],
708 }
709}
710
711#[allow(dead_code)]
726fn filter_tree_by_language(entries: &[GitTreeEntry], language: &str) -> Vec<String> {
727 let extensions = get_extensions_for_language(language);
728 let exclude_dirs = [
729 "node_modules/",
730 "target/",
731 "dist/",
732 "build/",
733 ".git/",
734 "vendor/",
735 "test",
736 "spec",
737 "mock",
738 "fixture",
739 ];
740
741 let mut filtered: Vec<String> = entries
742 .iter()
743 .filter(|entry| {
744 if entry.type_ != "blob" {
746 return false;
747 }
748
749 if exclude_dirs.iter().any(|dir| entry.path.contains(dir)) {
751 return false;
752 }
753
754 if extensions.is_empty() {
756 true
758 } else {
759 extensions.iter().any(|ext| entry.path.ends_with(ext))
760 }
761 })
762 .map(|e| e.path.clone())
763 .collect();
764
765 filtered.sort_by(|a, b| {
767 let depth_a = a.matches('/').count();
768 let depth_b = b.matches('/').count();
769 if depth_a == depth_b {
770 a.cmp(b)
771 } else {
772 depth_a.cmp(&depth_b)
773 }
774 });
775
776 filtered.truncate(50);
778 filtered
779}
780
781fn filter_tree_by_relevance(
800 entries: &[GitTreeEntry],
801 language: &str,
802 keywords: &[String],
803) -> Vec<String> {
804 let extensions = get_extensions_for_language(language);
805 let entry_points = entry_point_patterns(language);
806
807 let candidates: Vec<String> = entries
809 .iter()
810 .filter(|entry| {
811 if entry.type_ != "blob" {
813 return false;
814 }
815
816 if EXCLUDE_PATTERNS.iter().any(|dir| entry.path.contains(dir)) {
818 return false;
819 }
820
821 if extensions.is_empty() {
823 true
825 } else {
826 extensions.iter().any(|ext| entry.path.ends_with(ext))
827 }
828 })
829 .map(|e| e.path.clone())
830 .collect();
831
832 let mut tier1: Vec<String> = Vec::new();
834 let mut remaining: Vec<String> = Vec::new();
835
836 for path in candidates {
837 let path_lower = path.to_lowercase();
838 let matches_keyword = keywords.iter().any(|kw| path_lower.contains(kw));
839
840 if matches_keyword && tier1.len() < 35 {
841 tier1.push(path);
842 } else {
843 remaining.push(path);
844 }
845 }
846
847 let mut tier2: Vec<String> = Vec::new();
849 let mut tier3_candidates: Vec<String> = Vec::new();
850
851 for path in remaining {
852 let is_entry_point = entry_points.iter().any(|ep| path.ends_with(ep));
853 let is_deprioritized = DEPRIORITIZE_PATTERNS.iter().any(|dp| path.contains(dp));
854
855 if is_entry_point && tier2.len() < 10 {
856 tier2.push(path);
857 } else if !is_deprioritized {
858 tier3_candidates.push(path);
859 }
860 }
861
862 let mut tier3: Vec<String> = tier3_candidates.into_iter().take(15).collect();
864
865 let mut result = tier1;
867 result.append(&mut tier2);
868 result.append(&mut tier3);
869
870 result.sort_by(|a, b| {
872 let depth_a = a.matches('/').count();
873 let depth_b = b.matches('/').count();
874 if depth_a == depth_b {
875 a.cmp(b)
876 } else {
877 depth_a.cmp(&depth_b)
878 }
879 });
880
881 result.truncate(60);
883 result
884}
885
886#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
903pub async fn fetch_repo_tree(
904 client: &Octocrab,
905 owner: &str,
906 repo: &str,
907 language: &str,
908 keywords: &[String],
909) -> Result<Vec<String>> {
910 debug!("Fetching repository tree");
911
912 let branches = ["main", "master"];
914 let mut tree_response: Option<GitTreeResponse> = None;
915
916 for branch in &branches {
917 let route = format!("/repos/{owner}/{repo}/git/trees/{branch}?recursive=1");
918 let result = (|| async {
919 client
920 .get::<GitTreeResponse, _, _>(&route, None::<&()>)
921 .await
922 .map_err(|e| anyhow::anyhow!(e))
923 })
924 .retry(retry_backoff())
925 .notify(|err, dur| {
926 tracing::warn!(
927 error = %err,
928 retry_after = ?dur,
929 branch = %branch,
930 "Retrying fetch_repo_tree"
931 );
932 })
933 .await;
934
935 match result {
936 Ok(response) => {
937 tree_response = Some(response);
938 debug!(branch = %branch, "Fetched tree from branch");
939 break;
940 }
941 Err(e) => {
942 debug!(branch = %branch, error = %e, "Failed to fetch tree from branch");
943 }
944 }
945 }
946
947 let response =
948 tree_response.context("Failed to fetch repository tree from main or master branch")?;
949
950 let filtered = filter_tree_by_relevance(&response.tree, language, keywords);
951 debug!(count = filtered.len(), "Filtered tree entries");
952
953 Ok(filtered)
954}
955
956#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
973pub async fn fetch_issues_needing_triage(
974 client: &Octocrab,
975 owner: &str,
976 repo: &str,
977 since: Option<&str>,
978 force: bool,
979) -> Result<Vec<UntriagedIssue>> {
980 debug!("Fetching issues needing triage");
981
982 let issues_page: octocrab::Page<octocrab::models::issues::Issue> = client
983 .issues(owner, repo)
984 .list()
985 .state(octocrab::params::State::Open)
986 .per_page(100)
987 .send()
988 .await
989 .context("Failed to fetch issues from repository")?;
990
991 let total_issues = issues_page.items.len();
992
993 let mut issues_needing_triage: Vec<UntriagedIssue> = issues_page
994 .items
995 .into_iter()
996 .filter(|issue| {
997 if force {
998 true
999 } else {
1000 issue.labels.is_empty() || issue.milestone.is_none()
1001 }
1002 })
1003 .map(|issue| UntriagedIssue {
1004 number: issue.number,
1005 title: issue.title,
1006 created_at: issue.created_at.to_rfc3339(),
1007 url: issue.html_url.to_string(),
1008 })
1009 .collect();
1010
1011 if let Some(since_date) = since
1012 && let Ok(since_timestamp) = chrono::DateTime::parse_from_rfc3339(since_date)
1013 {
1014 issues_needing_triage.retain(|issue| {
1015 if let Ok(created_at) = chrono::DateTime::parse_from_rfc3339(&issue.created_at) {
1016 created_at >= since_timestamp
1017 } else {
1018 true
1019 }
1020 });
1021 }
1022
1023 debug!(
1024 total_issues = total_issues,
1025 issues_needing_triage_count = issues_needing_triage.len(),
1026 "Fetched issues needing triage"
1027 );
1028
1029 Ok(issues_needing_triage)
1030}
1031
1032#[cfg(test)]
1033mod fetch_issues_needing_triage_tests {
1034 #[test]
1035 fn filter_logic_unlabeled_default_mode() {
1036 let labels_empty = true;
1037 let milestone_none = true;
1038 let force = false;
1039
1040 let passes = if force {
1041 true
1042 } else {
1043 labels_empty || milestone_none
1044 };
1045
1046 assert!(passes);
1047 }
1048
1049 #[test]
1050 fn filter_logic_labeled_default_mode() {
1051 let labels_empty = false;
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_missing_milestone_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_force_mode_returns_all() {
1081 let labels_empty = false;
1082 let milestone_none = false;
1083 let force = true;
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_fully_triaged_default_mode_excluded() {
1096 let labels_empty = false;
1097 let milestone_none = false;
1098 let force = false;
1099
1100 let passes = if force {
1101 true
1102 } else {
1103 labels_empty || milestone_none
1104 };
1105
1106 assert!(!passes);
1107 }
1108}
1109
1110#[cfg(test)]
1111mod tree_tests {
1112 use super::*;
1113
1114 #[test]
1115 fn filter_tree_by_relevance_keyword_matching() {
1116 let entries = vec![
1117 GitTreeEntry {
1118 path: "src/parser.rs".to_string(),
1119 type_: "blob".to_string(),
1120 mode: "100644".to_string(),
1121 sha: "abc123".to_string(),
1122 },
1123 GitTreeEntry {
1124 path: "src/main.rs".to_string(),
1125 type_: "blob".to_string(),
1126 mode: "100644".to_string(),
1127 sha: "def456".to_string(),
1128 },
1129 GitTreeEntry {
1130 path: "src/utils.rs".to_string(),
1131 type_: "blob".to_string(),
1132 mode: "100644".to_string(),
1133 sha: "ghi789".to_string(),
1134 },
1135 ];
1136
1137 let keywords = vec!["parser".to_string()];
1138 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1139 assert!(filtered.contains(&"src/parser.rs".to_string()));
1140 }
1141
1142 #[test]
1143 fn filter_tree_by_relevance_entry_points() {
1144 let entries = vec![
1145 GitTreeEntry {
1146 path: "src/lib.rs".to_string(),
1147 type_: "blob".to_string(),
1148 mode: "100644".to_string(),
1149 sha: "abc123".to_string(),
1150 },
1151 GitTreeEntry {
1152 path: "src/utils.rs".to_string(),
1153 type_: "blob".to_string(),
1154 mode: "100644".to_string(),
1155 sha: "def456".to_string(),
1156 },
1157 ];
1158
1159 let keywords = vec![];
1160 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1161 assert!(filtered.contains(&"src/lib.rs".to_string()));
1162 }
1163
1164 #[test]
1165 fn filter_tree_by_relevance_excludes_tests() {
1166 let entries = vec![
1167 GitTreeEntry {
1168 path: "src/main.rs".to_string(),
1169 type_: "blob".to_string(),
1170 mode: "100644".to_string(),
1171 sha: "abc123".to_string(),
1172 },
1173 GitTreeEntry {
1174 path: "tests/integration_test.rs".to_string(),
1175 type_: "blob".to_string(),
1176 mode: "100644".to_string(),
1177 sha: "def456".to_string(),
1178 },
1179 ];
1180
1181 let keywords = vec![];
1182 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1183 assert!(!filtered.contains(&"tests/integration_test.rs".to_string()));
1184 assert!(filtered.contains(&"src/main.rs".to_string()));
1185 }
1186
1187 #[test]
1188 fn filter_tree_excludes_node_modules() {
1189 let entries = vec![
1190 GitTreeEntry {
1191 path: "src/main.rs".to_string(),
1192 type_: "blob".to_string(),
1193 mode: "100644".to_string(),
1194 sha: "abc123".to_string(),
1195 },
1196 GitTreeEntry {
1197 path: "node_modules/package/index.js".to_string(),
1198 type_: "blob".to_string(),
1199 mode: "100644".to_string(),
1200 sha: "def456".to_string(),
1201 },
1202 ];
1203
1204 let filtered = filter_tree_by_language(&entries, "rust");
1205 assert_eq!(filtered.len(), 1);
1206 assert_eq!(filtered[0], "src/main.rs");
1207 }
1208
1209 #[test]
1210 fn filter_tree_excludes_directories() {
1211 let entries = vec![
1212 GitTreeEntry {
1213 path: "src/main.rs".to_string(),
1214 type_: "blob".to_string(),
1215 mode: "100644".to_string(),
1216 sha: "abc123".to_string(),
1217 },
1218 GitTreeEntry {
1219 path: "src/lib".to_string(),
1220 type_: "tree".to_string(),
1221 mode: "040000".to_string(),
1222 sha: "def456".to_string(),
1223 },
1224 ];
1225
1226 let filtered = filter_tree_by_language(&entries, "rust");
1227 assert_eq!(filtered.len(), 1);
1228 assert_eq!(filtered[0], "src/main.rs");
1229 }
1230
1231 #[test]
1232 fn filter_tree_sorts_by_depth() {
1233 let entries = vec![
1234 GitTreeEntry {
1235 path: "a/b/c/d.rs".to_string(),
1236 type_: "blob".to_string(),
1237 mode: "100644".to_string(),
1238 sha: "abc123".to_string(),
1239 },
1240 GitTreeEntry {
1241 path: "a/b.rs".to_string(),
1242 type_: "blob".to_string(),
1243 mode: "100644".to_string(),
1244 sha: "def456".to_string(),
1245 },
1246 GitTreeEntry {
1247 path: "main.rs".to_string(),
1248 type_: "blob".to_string(),
1249 mode: "100644".to_string(),
1250 sha: "ghi789".to_string(),
1251 },
1252 ];
1253
1254 let filtered = filter_tree_by_language(&entries, "rust");
1255 assert_eq!(filtered[0], "main.rs");
1256 assert_eq!(filtered[1], "a/b.rs");
1257 assert_eq!(filtered[2], "a/b/c/d.rs");
1258 }
1259
1260 #[test]
1261 fn filter_tree_limits_to_50() {
1262 let entries: Vec<GitTreeEntry> = (0..100)
1263 .map(|i| GitTreeEntry {
1264 path: format!("file{i}.rs"),
1265 type_: "blob".to_string(),
1266 mode: "100644".to_string(),
1267 sha: format!("sha{i}"),
1268 })
1269 .collect();
1270
1271 let filtered = filter_tree_by_language(&entries, "rust");
1272 assert_eq!(filtered.len(), 50);
1273 }
1274
1275 #[test]
1276 fn filter_tree_by_language_rust() {
1277 let entries = vec![
1278 GitTreeEntry {
1279 path: "src/main.rs".to_string(),
1280 type_: "blob".to_string(),
1281 mode: "100644".to_string(),
1282 sha: "abc123".to_string(),
1283 },
1284 GitTreeEntry {
1285 path: "src/lib.py".to_string(),
1286 type_: "blob".to_string(),
1287 mode: "100644".to_string(),
1288 sha: "def456".to_string(),
1289 },
1290 ];
1291
1292 let filtered = filter_tree_by_language(&entries, "rust");
1293 assert_eq!(filtered.len(), 1);
1294 assert_eq!(filtered[0], "src/main.rs");
1295 }
1296
1297 #[test]
1298 fn filter_tree_by_language_python() {
1299 let entries = vec![
1300 GitTreeEntry {
1301 path: "main.py".to_string(),
1302 type_: "blob".to_string(),
1303 mode: "100644".to_string(),
1304 sha: "abc123".to_string(),
1305 },
1306 GitTreeEntry {
1307 path: "lib.rs".to_string(),
1308 type_: "blob".to_string(),
1309 mode: "100644".to_string(),
1310 sha: "def456".to_string(),
1311 },
1312 ];
1313
1314 let filtered = filter_tree_by_language(&entries, "python");
1315 assert_eq!(filtered.len(), 1);
1316 assert_eq!(filtered[0], "main.py");
1317 }
1318
1319 #[test]
1320 fn get_extensions_for_language_rust() {
1321 let exts = get_extensions_for_language("rust");
1322 assert_eq!(exts, vec!["rs"]);
1323 }
1324
1325 #[test]
1326 fn get_extensions_for_language_javascript() {
1327 let exts = get_extensions_for_language("javascript");
1328 assert!(exts.contains(&"js"));
1329 assert!(exts.contains(&"ts"));
1330 assert!(exts.contains(&"jsx"));
1331 assert!(exts.contains(&"tsx"));
1332 }
1333
1334 #[test]
1335 fn get_extensions_for_language_unknown() {
1336 let exts = get_extensions_for_language("unknown_language");
1337 assert!(exts.is_empty());
1338 }
1339}
1340
1341#[cfg(test)]
1342mod label_tests {
1343 use super::*;
1344
1345 #[test]
1346 fn filter_labels_empty_input() {
1347 let labels = vec![];
1348 let filtered = filter_labels_by_relevance(&labels, 30);
1349 assert!(filtered.is_empty());
1350 }
1351
1352 #[test]
1353 fn filter_labels_zero_max() {
1354 let labels = vec![crate::ai::types::RepoLabel {
1355 name: "bug".to_string(),
1356 color: "ff0000".to_string(),
1357 description: "Bug report".to_string(),
1358 }];
1359 let filtered = filter_labels_by_relevance(&labels, 0);
1360 assert!(filtered.is_empty());
1361 }
1362
1363 #[test]
1364 fn filter_labels_priority_first() {
1365 let labels = vec![
1366 crate::ai::types::RepoLabel {
1367 name: "documentation".to_string(),
1368 color: "0075ca".to_string(),
1369 description: "Documentation".to_string(),
1370 },
1371 crate::ai::types::RepoLabel {
1372 name: "other".to_string(),
1373 color: "cccccc".to_string(),
1374 description: "Other".to_string(),
1375 },
1376 crate::ai::types::RepoLabel {
1377 name: "bug".to_string(),
1378 color: "ff0000".to_string(),
1379 description: "Bug".to_string(),
1380 },
1381 ];
1382 let filtered = filter_labels_by_relevance(&labels, 30);
1383 assert_eq!(filtered.len(), 3);
1384 assert_eq!(filtered[0].name, "documentation");
1385 assert_eq!(filtered[1].name, "bug");
1386 assert_eq!(filtered[2].name, "other");
1387 }
1388
1389 #[test]
1390 fn filter_labels_case_insensitive() {
1391 let labels = vec![
1392 crate::ai::types::RepoLabel {
1393 name: "Bug".to_string(),
1394 color: "ff0000".to_string(),
1395 description: "Bug".to_string(),
1396 },
1397 crate::ai::types::RepoLabel {
1398 name: "ENHANCEMENT".to_string(),
1399 color: "a2eeef".to_string(),
1400 description: "Enhancement".to_string(),
1401 },
1402 ];
1403 let filtered = filter_labels_by_relevance(&labels, 30);
1404 assert_eq!(filtered.len(), 2);
1405 assert_eq!(filtered[0].name, "Bug");
1406 assert_eq!(filtered[1].name, "ENHANCEMENT");
1407 }
1408
1409 #[test]
1410 fn filter_labels_over_limit_with_priorities() {
1411 let mut labels = vec![];
1412 for i in 0..20 {
1413 labels.push(crate::ai::types::RepoLabel {
1414 name: format!("label{}", i),
1415 color: "cccccc".to_string(),
1416 description: format!("Label {}", i),
1417 });
1418 }
1419 labels.push(crate::ai::types::RepoLabel {
1420 name: "bug".to_string(),
1421 color: "ff0000".to_string(),
1422 description: "Bug".to_string(),
1423 });
1424 labels.push(crate::ai::types::RepoLabel {
1425 name: "enhancement".to_string(),
1426 color: "a2eeef".to_string(),
1427 description: "Enhancement".to_string(),
1428 });
1429
1430 let filtered = filter_labels_by_relevance(&labels, 10);
1431 assert_eq!(filtered.len(), 10);
1432 assert_eq!(filtered[0].name, "bug");
1433 assert_eq!(filtered[1].name, "enhancement");
1434 }
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439 use super::*;
1440
1441 #[test]
1442 fn parse_reference_full_url() {
1443 let url = "https://github.com/block/goose/issues/5836";
1444 let (owner, repo, number) = parse_issue_reference(url, None).unwrap();
1445 assert_eq!(owner, "block");
1446 assert_eq!(repo, "goose");
1447 assert_eq!(number, 5836);
1448 }
1449
1450 #[test]
1451 fn parse_reference_short_form() {
1452 let reference = "block/goose#5836";
1453 let (owner, repo, number) = parse_issue_reference(reference, None).unwrap();
1454 assert_eq!(owner, "block");
1455 assert_eq!(repo, "goose");
1456 assert_eq!(number, 5836);
1457 }
1458
1459 #[test]
1460 fn parse_reference_short_form_with_context() {
1461 let reference = "block/goose#5836";
1462 let (owner, repo, number) =
1463 parse_issue_reference(reference, Some("astral-sh/ruff")).unwrap();
1464 assert_eq!(owner, "block");
1465 assert_eq!(repo, "goose");
1466 assert_eq!(number, 5836);
1467 }
1468
1469 #[test]
1470 fn parse_reference_bare_number_with_context() {
1471 let reference = "5836";
1472 let (owner, repo, number) = parse_issue_reference(reference, Some("block/goose")).unwrap();
1473 assert_eq!(owner, "block");
1474 assert_eq!(repo, "goose");
1475 assert_eq!(number, 5836);
1476 }
1477
1478 #[test]
1479 fn parse_reference_bare_number_without_context() {
1480 let reference = "5836";
1481 let result = parse_issue_reference(reference, None);
1482 assert!(result.is_err());
1483 assert!(
1484 result
1485 .unwrap_err()
1486 .to_string()
1487 .contains("Bare issue number requires repository context")
1488 );
1489 }
1490
1491 #[test]
1492 fn parse_reference_invalid_short_form_missing_slash() {
1493 let reference = "owner#123";
1494 let result = parse_issue_reference(reference, None);
1495 assert!(result.is_err());
1496 assert!(
1497 result
1498 .unwrap_err()
1499 .to_string()
1500 .contains("Invalid owner/repo format")
1501 );
1502 }
1503
1504 #[test]
1505 fn parse_reference_invalid_short_form_extra_slash() {
1506 let reference = "owner/repo/extra#123";
1507 let result = parse_issue_reference(reference, None);
1508 assert!(result.is_err());
1509 assert!(
1510 result
1511 .unwrap_err()
1512 .to_string()
1513 .contains("Invalid owner/repo format")
1514 );
1515 }
1516
1517 #[test]
1518 fn parse_reference_invalid_bare_number() {
1519 let reference = "abc";
1520 let result = parse_issue_reference(reference, Some("block/goose"));
1521 assert!(result.is_err());
1522 assert!(
1523 result
1524 .unwrap_err()
1525 .to_string()
1526 .contains("Invalid issue reference format")
1527 );
1528 }
1529
1530 #[test]
1531 fn parse_reference_whitespace_trimming() {
1532 let reference = " block/goose#5836 ";
1533 let (owner, repo, number) = parse_issue_reference(reference, None).unwrap();
1534 assert_eq!(owner, "block");
1535 assert_eq!(repo, "goose");
1536 assert_eq!(number, 5836);
1537 }
1538
1539 #[test]
1540 fn parse_reference_bare_number_whitespace() {
1541 let reference = " 5836 ";
1542 let (owner, repo, number) = parse_issue_reference(reference, Some("block/goose")).unwrap();
1543 assert_eq!(owner, "block");
1544 assert_eq!(repo, "goose");
1545 assert_eq!(number, 5836);
1546 }
1547
1548 #[test]
1549 fn extract_keywords_filters_stop_words() {
1550 let title = "The issue is about a bug in the CLI";
1551 let keywords = extract_keywords(title);
1552 assert!(!keywords.contains(&"the".to_string()));
1553 assert!(!keywords.contains(&"is".to_string()));
1554 assert!(!keywords.contains(&"a".to_string()));
1555 assert!(keywords.contains(&"issue".to_string()));
1556 assert!(keywords.contains(&"bug".to_string()));
1557 assert!(keywords.contains(&"cli".to_string()));
1558 }
1559
1560 #[test]
1561 fn extract_keywords_limits_to_five() {
1562 let title = "one two three four five six seven eight nine ten";
1563 let keywords = extract_keywords(title);
1564 assert_eq!(keywords.len(), 5);
1565 }
1566
1567 #[test]
1568 fn extract_keywords_empty_title() {
1569 let title = "the a an and or";
1570 let keywords = extract_keywords(title);
1571 assert!(keywords.is_empty());
1572 }
1573
1574 #[test]
1575 fn extract_keywords_lowercase_conversion() {
1576 let title = "CLI Bug FIX";
1577 let keywords = extract_keywords(title);
1578 assert!(keywords.iter().all(|k| k.chars().all(char::is_lowercase)));
1579 }
1580}