1use anyhow::{Context, Result};
9use octocrab::Octocrab;
10use serde::{Deserialize, Serialize};
11use tracing::{debug, instrument};
12
13use crate::ai::types::{IssueComment, IssueDetails, RepoIssueContext};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct UntriagedIssue {
18 pub number: u64,
20 pub title: String,
22 pub created_at: String,
24 pub url: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct GitTreeEntry {
31 pub path: String,
33 #[serde(rename = "type")]
35 pub type_: String,
36 pub mode: String,
38 pub sha: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct GitTreeResponse {
45 pub tree: Vec<GitTreeEntry>,
47 pub truncated: bool,
49}
50
51pub fn parse_owner_repo(s: &str) -> Result<(String, String)> {
59 let parts: Vec<&str> = s.split('/').collect();
60 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
61 anyhow::bail!(
62 "Invalid owner/repo format.\n\
63 Expected: owner/repo\n\
64 Got: {s}"
65 );
66 }
67 Ok((parts[0].to_string(), parts[1].to_string()))
68}
69
70pub fn parse_issue_reference(
86 input: &str,
87 repo_context: Option<&str>,
88) -> Result<(String, String, u64)> {
89 let input = input.trim();
90
91 if input.starts_with("https://") || input.starts_with("http://") {
93 let clean_url = input.split('#').next().unwrap_or(input);
95 let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
96
97 let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
99
100 if parts.len() < 7 {
102 anyhow::bail!(
103 "Invalid GitHub issue URL format.\n\
104 Expected: https://github.com/owner/repo/issues/123\n\
105 Got: {input}"
106 );
107 }
108
109 if !parts[2].contains("github.com") {
111 anyhow::bail!(
112 "URL must be a GitHub issue URL.\n\
113 Expected: https://github.com/owner/repo/issues/123\n\
114 Got: {input}"
115 );
116 }
117
118 if parts[5] != "issues" {
120 anyhow::bail!(
121 "URL must point to a GitHub issue.\n\
122 Expected: https://github.com/owner/repo/issues/123\n\
123 Got: {input}"
124 );
125 }
126
127 let owner = parts[3].to_string();
128 let repo = parts[4].to_string();
129 let number: u64 = parts[6].parse().with_context(|| {
130 format!(
131 "Invalid issue number '{}' in URL.\n\
132 Expected a numeric issue number.",
133 parts[6]
134 )
135 })?;
136
137 debug!(owner = %owner, repo = %repo, number = number, "Parsed issue URL");
138 return Ok((owner, repo, number));
139 }
140
141 if let Some(hash_pos) = input.find('#') {
143 let owner_repo_part = &input[..hash_pos];
144 let number_part = &input[hash_pos + 1..];
145
146 let (owner, repo) = parse_owner_repo(owner_repo_part)?;
147 let number: u64 = number_part.parse().with_context(|| {
148 format!(
149 "Invalid issue number '{number_part}' in short form.\n\
150 Expected: owner/repo#123\n\
151 Got: {input}"
152 )
153 })?;
154
155 debug!(owner = %owner, repo = %repo, number = number, "Parsed short-form issue reference");
156 return Ok((owner, repo, number));
157 }
158
159 if let Ok(number) = input.parse::<u64>() {
161 let repo_context = repo_context.ok_or_else(|| {
162 anyhow::anyhow!(
163 "Bare issue number requires repository context.\n\
164 Use one of:\n\
165 - Full URL: https://github.com/owner/repo/issues/123\n\
166 - Short form: owner/repo#123\n\
167 - Bare number with --repo flag: 123 --repo owner/repo\n\
168 Got: {input}"
169 )
170 })?;
171
172 let (owner, repo) = parse_owner_repo(repo_context)?;
173 debug!(owner = %owner, repo = %repo, number = number, "Parsed bare issue number");
174 return Ok((owner, repo, number));
175 }
176
177 anyhow::bail!(
179 "Invalid issue reference format.\n\
180 Expected one of:\n\
181 - Full URL: https://github.com/owner/repo/issues/123\n\
182 - Short form: owner/repo#123\n\
183 - Bare number with --repo flag: 123 --repo owner/repo\n\
184 Got: {input}"
185 );
186}
187
188#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
194pub async fn fetch_issue_with_comments(
195 client: &Octocrab,
196 owner: &str,
197 repo: &str,
198 number: u64,
199) -> Result<IssueDetails> {
200 debug!("Fetching issue details");
201
202 let issue = client
204 .issues(owner, repo)
205 .get(number)
206 .await
207 .with_context(|| format!("Failed to fetch issue #{number} from {owner}/{repo}"))?;
208
209 let comments_page = client
211 .issues(owner, repo)
212 .list_comments(number)
213 .per_page(5)
214 .send()
215 .await
216 .with_context(|| format!("Failed to fetch comments for issue #{number}"))?;
217
218 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
220
221 let comments: Vec<IssueComment> = comments_page
222 .items
223 .iter()
224 .map(|c| IssueComment {
225 author: c.user.login.clone(),
226 body: c.body.clone().unwrap_or_default(),
227 })
228 .collect();
229
230 let issue_url = issue.html_url.to_string();
231
232 let details = IssueDetails {
233 owner: owner.to_string(),
234 repo: repo.to_string(),
235 number,
236 title: issue.title,
237 body: issue.body.unwrap_or_default(),
238 labels,
239 comments,
240 url: issue_url,
241 repo_context: Vec::new(),
242 repo_tree: Vec::new(),
243 available_labels: Vec::new(),
244 available_milestones: Vec::new(),
245 viewer_permission: None,
246 };
247
248 debug!(
249 labels = details.labels.len(),
250 comments = details.comments.len(),
251 "Fetched issue details"
252 );
253
254 Ok(details)
255}
256
257pub fn extract_keywords(title: &str) -> Vec<String> {
273 let stop_words = [
274 "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "he", "in", "is",
275 "it", "its", "of", "on", "or", "that", "the", "to", "was", "will", "with",
276 ];
277
278 title
279 .to_lowercase()
280 .split(|c: char| !c.is_alphanumeric())
281 .filter(|word| !word.is_empty() && !stop_words.contains(word))
282 .take(5) .map(std::string::ToString::to_string)
284 .collect()
285}
286
287#[instrument(skip(client), fields(owner = %owner, repo = %repo, exclude_number = %exclude_number))]
304pub async fn search_related_issues(
305 client: &Octocrab,
306 owner: &str,
307 repo: &str,
308 title: &str,
309 exclude_number: u64,
310) -> Result<Vec<RepoIssueContext>> {
311 let keywords = extract_keywords(title);
312
313 if keywords.is_empty() {
314 debug!("No keywords extracted from title");
315 return Ok(Vec::new());
316 }
317
318 let query = format!("{} repo:{}/{} is:issue", keywords.join(" "), owner, repo);
320
321 debug!(query = %query, "Searching for related issues");
322
323 let search_result = client
325 .search()
326 .issues_and_pull_requests(&query)
327 .per_page(20)
328 .send()
329 .await
330 .with_context(|| format!("Failed to search for related issues in {owner}/{repo}"))?;
331
332 let related: Vec<RepoIssueContext> = search_result
334 .items
335 .iter()
336 .filter_map(|item| {
337 if item.pull_request.is_some() {
339 return None;
340 }
341
342 if item.number == exclude_number {
344 return None;
345 }
346
347 Some(RepoIssueContext {
348 number: item.number,
349 title: item.title.clone(),
350 labels: item.labels.iter().map(|l| l.name.clone()).collect(),
351 state: format!("{:?}", item.state).to_lowercase(),
352 })
353 })
354 .collect();
355
356 debug!(count = related.len(), "Found related issues");
357
358 Ok(related)
359}
360
361#[instrument(skip(client, body), fields(owner = %owner, repo = %repo, number = number))]
371pub async fn post_comment(
372 client: &Octocrab,
373 owner: &str,
374 repo: &str,
375 number: u64,
376 body: &str,
377) -> Result<String> {
378 debug!("Posting triage comment");
379
380 let comment = client
381 .issues(owner, repo)
382 .create_comment(number, body)
383 .await
384 .with_context(|| format!("Failed to post comment to issue #{number}"))?;
385
386 let comment_url = comment.html_url.to_string();
387
388 debug!(url = %comment_url, "Comment posted successfully");
389
390 Ok(comment_url)
391}
392
393#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
410pub async fn create_issue(
411 client: &Octocrab,
412 owner: &str,
413 repo: &str,
414 title: &str,
415 body: &str,
416) -> Result<(String, u64)> {
417 debug!("Creating GitHub issue");
418
419 let issue = client
420 .issues(owner, repo)
421 .create(title)
422 .body(body)
423 .send()
424 .await
425 .with_context(|| format!("Failed to create issue in {owner}/{repo}"))?;
426
427 let issue_url = issue.html_url.to_string();
428 let issue_number = issue.number;
429
430 debug!(number = issue_number, url = %issue_url, "Issue created successfully");
431
432 Ok((issue_url, issue_number))
433}
434
435#[derive(Debug, Clone)]
437pub struct ApplyResult {
438 pub applied_labels: Vec<String>,
440 pub applied_milestone: Option<String>,
442 pub warnings: Vec<String>,
444}
445
446#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
455#[allow(clippy::too_many_arguments)]
456pub async fn update_issue_labels_and_milestone(
457 client: &Octocrab,
458 owner: &str,
459 repo: &str,
460 number: u64,
461 suggested_labels: &[String],
462 suggested_milestone: Option<&str>,
463 available_labels: &[crate::ai::types::RepoLabel],
464 available_milestones: &[crate::ai::types::RepoMilestone],
465) -> Result<ApplyResult> {
466 debug!("Updating issue with labels and milestone");
467
468 let mut applied_labels = Vec::new();
469 let mut warnings = Vec::new();
470
471 let available_label_names: std::collections::HashSet<_> =
473 available_labels.iter().map(|l| l.name.as_str()).collect();
474
475 for label in suggested_labels {
476 if available_label_names.contains(label.as_str()) {
477 applied_labels.push(label.clone());
478 } else {
479 warnings.push(format!("Label '{label}' not found in repository"));
480 }
481 }
482
483 let mut applied_milestone = None;
485 if let Some(milestone_title) = suggested_milestone {
486 if let Some(milestone) = available_milestones
487 .iter()
488 .find(|m| m.title == milestone_title)
489 {
490 applied_milestone = Some(milestone.title.clone());
491 } else {
492 warnings.push(format!(
493 "Milestone '{milestone_title}' not found in repository"
494 ));
495 }
496 }
497
498 let issues_handler = client.issues(owner, repo);
500 let mut update_builder = issues_handler.update(number);
501
502 if !applied_labels.is_empty() {
503 update_builder = update_builder.labels(&applied_labels);
504 }
505
506 #[allow(clippy::collapsible_if)]
507 if let Some(milestone_title) = &applied_milestone {
508 if let Some(milestone) = available_milestones
509 .iter()
510 .find(|m| &m.title == milestone_title)
511 {
512 update_builder = update_builder.milestone(milestone.number);
513 }
514 }
515
516 update_builder
517 .send()
518 .await
519 .with_context(|| format!("Failed to update issue #{number}"))?;
520
521 debug!(
522 labels = ?applied_labels,
523 milestone = ?applied_milestone,
524 warnings = ?warnings,
525 "Issue updated successfully"
526 );
527
528 Ok(ApplyResult {
529 applied_labels,
530 applied_milestone,
531 warnings,
532 })
533}
534
535const EXCLUDE_PATTERNS: &[&str] = &[
538 "node_modules/",
539 "vendor/",
540 "dist/",
541 "build/",
542 "target/",
543 ".git/",
544 "cache/",
545 "docs/",
546 "examples/",
547];
548
549const DEPRIORITIZE_PATTERNS: &[&str] = &[
552 "test/",
553 "tests/",
554 "spec/",
555 "bench/",
556 "eval/",
557 "fixtures/",
558 "mocks/",
559];
560
561fn entry_point_patterns(language: &str) -> Vec<&'static str> {
564 match language.to_lowercase().as_str() {
565 "rust" => vec!["lib.rs", "mod.rs", "main.rs"],
566 "python" => vec!["__init__.py"],
567 "javascript" | "typescript" => vec!["index.ts", "index.js"],
568 "java" => vec!["Main.java"],
569 "go" => vec!["main.go"],
570 "c#" | "csharp" => vec!["Program.cs"],
571 _ => vec![],
572 }
573}
574
575fn get_extensions_for_language(language: &str) -> Vec<&'static str> {
577 match language.to_lowercase().as_str() {
578 "rust" => vec!["rs"],
579 "python" => vec!["py"],
580 "javascript" | "typescript" => vec!["js", "ts", "jsx", "tsx"],
581 "java" => vec!["java"],
582 "c" => vec!["c", "h"],
583 "c++" | "cpp" => vec!["cpp", "cc", "cxx", "h", "hpp"],
584 "c#" | "csharp" => vec!["cs"],
585 "go" => vec!["go"],
586 "ruby" => vec!["rb"],
587 "php" => vec!["php"],
588 "swift" => vec!["swift"],
589 "kotlin" => vec!["kt"],
590 "scala" => vec!["scala"],
591 "r" => vec!["r"],
592 "shell" | "bash" => vec!["sh", "bash"],
593 "html" => vec!["html", "htm"],
594 "css" => vec!["css", "scss", "sass"],
595 "json" => vec!["json"],
596 "yaml" | "yml" => vec!["yaml", "yml"],
597 "toml" => vec!["toml"],
598 "xml" => vec!["xml"],
599 "markdown" => vec!["md"],
600 _ => vec![],
601 }
602}
603
604#[allow(dead_code)]
619fn filter_tree_by_language(entries: &[GitTreeEntry], language: &str) -> Vec<String> {
620 let extensions = get_extensions_for_language(language);
621 let exclude_dirs = [
622 "node_modules/",
623 "target/",
624 "dist/",
625 "build/",
626 ".git/",
627 "vendor/",
628 "test",
629 "spec",
630 "mock",
631 "fixture",
632 ];
633
634 let mut filtered: Vec<String> = entries
635 .iter()
636 .filter(|entry| {
637 if entry.type_ != "blob" {
639 return false;
640 }
641
642 if exclude_dirs.iter().any(|dir| entry.path.contains(dir)) {
644 return false;
645 }
646
647 if extensions.is_empty() {
649 true
651 } else {
652 extensions.iter().any(|ext| entry.path.ends_with(ext))
653 }
654 })
655 .map(|e| e.path.clone())
656 .collect();
657
658 filtered.sort_by(|a, b| {
660 let depth_a = a.matches('/').count();
661 let depth_b = b.matches('/').count();
662 if depth_a == depth_b {
663 a.cmp(b)
664 } else {
665 depth_a.cmp(&depth_b)
666 }
667 });
668
669 filtered.truncate(50);
671 filtered
672}
673
674fn filter_tree_by_relevance(
693 entries: &[GitTreeEntry],
694 language: &str,
695 keywords: &[String],
696) -> Vec<String> {
697 let extensions = get_extensions_for_language(language);
698 let entry_points = entry_point_patterns(language);
699
700 let candidates: Vec<String> = entries
702 .iter()
703 .filter(|entry| {
704 if entry.type_ != "blob" {
706 return false;
707 }
708
709 if EXCLUDE_PATTERNS.iter().any(|dir| entry.path.contains(dir)) {
711 return false;
712 }
713
714 if extensions.is_empty() {
716 true
718 } else {
719 extensions.iter().any(|ext| entry.path.ends_with(ext))
720 }
721 })
722 .map(|e| e.path.clone())
723 .collect();
724
725 let mut tier1: Vec<String> = Vec::new();
727 let mut remaining: Vec<String> = Vec::new();
728
729 for path in candidates {
730 let path_lower = path.to_lowercase();
731 let matches_keyword = keywords.iter().any(|kw| path_lower.contains(kw));
732
733 if matches_keyword && tier1.len() < 35 {
734 tier1.push(path);
735 } else {
736 remaining.push(path);
737 }
738 }
739
740 let mut tier2: Vec<String> = Vec::new();
742 let mut tier3_candidates: Vec<String> = Vec::new();
743
744 for path in remaining {
745 let is_entry_point = entry_points.iter().any(|ep| path.ends_with(ep));
746 let is_deprioritized = DEPRIORITIZE_PATTERNS.iter().any(|dp| path.contains(dp));
747
748 if is_entry_point && tier2.len() < 10 {
749 tier2.push(path);
750 } else if !is_deprioritized {
751 tier3_candidates.push(path);
752 }
753 }
754
755 let mut tier3: Vec<String> = tier3_candidates.into_iter().take(15).collect();
757
758 let mut result = tier1;
760 result.append(&mut tier2);
761 result.append(&mut tier3);
762
763 result.sort_by(|a, b| {
765 let depth_a = a.matches('/').count();
766 let depth_b = b.matches('/').count();
767 if depth_a == depth_b {
768 a.cmp(b)
769 } else {
770 depth_a.cmp(&depth_b)
771 }
772 });
773
774 result.truncate(60);
776 result
777}
778
779#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
796pub async fn fetch_repo_tree(
797 client: &Octocrab,
798 owner: &str,
799 repo: &str,
800 language: &str,
801 keywords: &[String],
802) -> Result<Vec<String>> {
803 debug!("Fetching repository tree");
804
805 let branches = ["main", "master"];
807 let mut tree_response: Option<GitTreeResponse> = None;
808
809 for branch in &branches {
810 let route = format!("/repos/{owner}/{repo}/git/trees/{branch}?recursive=1");
811 match client
812 .get::<GitTreeResponse, _, _>(&route, None::<&()>)
813 .await
814 {
815 Ok(response) => {
816 tree_response = Some(response);
817 debug!(branch = %branch, "Fetched tree from branch");
818 break;
819 }
820 Err(e) => {
821 debug!(branch = %branch, error = %e, "Failed to fetch tree from branch");
822 }
823 }
824 }
825
826 let response =
827 tree_response.context("Failed to fetch repository tree from main or master branch")?;
828
829 let filtered = filter_tree_by_relevance(&response.tree, language, keywords);
830 debug!(count = filtered.len(), "Filtered tree entries");
831
832 Ok(filtered)
833}
834
835#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
852pub async fn fetch_issues_needing_triage(
853 client: &Octocrab,
854 owner: &str,
855 repo: &str,
856 since: Option<&str>,
857 force: bool,
858) -> Result<Vec<UntriagedIssue>> {
859 debug!("Fetching issues needing triage");
860
861 let issues_page: octocrab::Page<octocrab::models::issues::Issue> = client
862 .issues(owner, repo)
863 .list()
864 .state(octocrab::params::State::Open)
865 .per_page(100)
866 .send()
867 .await
868 .context("Failed to fetch issues from repository")?;
869
870 let total_issues = issues_page.items.len();
871
872 let mut issues_needing_triage: Vec<UntriagedIssue> = issues_page
873 .items
874 .into_iter()
875 .filter(|issue| {
876 if force {
877 true
878 } else {
879 issue.labels.is_empty() || issue.milestone.is_none()
880 }
881 })
882 .map(|issue| UntriagedIssue {
883 number: issue.number,
884 title: issue.title,
885 created_at: issue.created_at.to_rfc3339(),
886 url: issue.html_url.to_string(),
887 })
888 .collect();
889
890 if let Some(since_date) = since
891 && let Ok(since_timestamp) = chrono::DateTime::parse_from_rfc3339(since_date)
892 {
893 issues_needing_triage.retain(|issue| {
894 if let Ok(created_at) = chrono::DateTime::parse_from_rfc3339(&issue.created_at) {
895 created_at >= since_timestamp
896 } else {
897 true
898 }
899 });
900 }
901
902 debug!(
903 total_issues = total_issues,
904 issues_needing_triage_count = issues_needing_triage.len(),
905 "Fetched issues needing triage"
906 );
907
908 Ok(issues_needing_triage)
909}
910
911#[cfg(test)]
912mod fetch_issues_needing_triage_tests {
913 #[test]
914 fn filter_logic_unlabeled_default_mode() {
915 let labels_empty = true;
916 let milestone_none = true;
917 let force = false;
918
919 let passes = if force {
920 true
921 } else {
922 labels_empty || milestone_none
923 };
924
925 assert!(passes);
926 }
927
928 #[test]
929 fn filter_logic_labeled_default_mode() {
930 let labels_empty = false;
931 let milestone_none = true;
932 let force = false;
933
934 let passes = if force {
935 true
936 } else {
937 labels_empty || milestone_none
938 };
939
940 assert!(passes);
941 }
942
943 #[test]
944 fn filter_logic_missing_milestone_default_mode() {
945 let labels_empty = false;
946 let milestone_none = true;
947 let force = false;
948
949 let passes = if force {
950 true
951 } else {
952 labels_empty || milestone_none
953 };
954
955 assert!(passes);
956 }
957
958 #[test]
959 fn filter_logic_force_mode_returns_all() {
960 let labels_empty = false;
961 let milestone_none = false;
962 let force = true;
963
964 let passes = if force {
965 true
966 } else {
967 labels_empty || milestone_none
968 };
969
970 assert!(passes);
971 }
972
973 #[test]
974 fn filter_logic_fully_triaged_default_mode_excluded() {
975 let labels_empty = false;
976 let milestone_none = false;
977 let force = false;
978
979 let passes = if force {
980 true
981 } else {
982 labels_empty || milestone_none
983 };
984
985 assert!(!passes);
986 }
987}
988
989#[cfg(test)]
990mod tree_tests {
991 use super::*;
992
993 #[test]
994 fn filter_tree_by_relevance_keyword_matching() {
995 let entries = vec![
996 GitTreeEntry {
997 path: "src/parser.rs".to_string(),
998 type_: "blob".to_string(),
999 mode: "100644".to_string(),
1000 sha: "abc123".to_string(),
1001 },
1002 GitTreeEntry {
1003 path: "src/main.rs".to_string(),
1004 type_: "blob".to_string(),
1005 mode: "100644".to_string(),
1006 sha: "def456".to_string(),
1007 },
1008 GitTreeEntry {
1009 path: "src/utils.rs".to_string(),
1010 type_: "blob".to_string(),
1011 mode: "100644".to_string(),
1012 sha: "ghi789".to_string(),
1013 },
1014 ];
1015
1016 let keywords = vec!["parser".to_string()];
1017 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1018 assert!(filtered.contains(&"src/parser.rs".to_string()));
1019 }
1020
1021 #[test]
1022 fn filter_tree_by_relevance_entry_points() {
1023 let entries = vec![
1024 GitTreeEntry {
1025 path: "src/lib.rs".to_string(),
1026 type_: "blob".to_string(),
1027 mode: "100644".to_string(),
1028 sha: "abc123".to_string(),
1029 },
1030 GitTreeEntry {
1031 path: "src/utils.rs".to_string(),
1032 type_: "blob".to_string(),
1033 mode: "100644".to_string(),
1034 sha: "def456".to_string(),
1035 },
1036 ];
1037
1038 let keywords = vec![];
1039 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1040 assert!(filtered.contains(&"src/lib.rs".to_string()));
1041 }
1042
1043 #[test]
1044 fn filter_tree_by_relevance_excludes_tests() {
1045 let entries = vec![
1046 GitTreeEntry {
1047 path: "src/main.rs".to_string(),
1048 type_: "blob".to_string(),
1049 mode: "100644".to_string(),
1050 sha: "abc123".to_string(),
1051 },
1052 GitTreeEntry {
1053 path: "tests/integration_test.rs".to_string(),
1054 type_: "blob".to_string(),
1055 mode: "100644".to_string(),
1056 sha: "def456".to_string(),
1057 },
1058 ];
1059
1060 let keywords = vec![];
1061 let filtered = filter_tree_by_relevance(&entries, "rust", &keywords);
1062 assert!(!filtered.contains(&"tests/integration_test.rs".to_string()));
1063 assert!(filtered.contains(&"src/main.rs".to_string()));
1064 }
1065
1066 #[test]
1067 fn filter_tree_excludes_node_modules() {
1068 let entries = vec![
1069 GitTreeEntry {
1070 path: "src/main.rs".to_string(),
1071 type_: "blob".to_string(),
1072 mode: "100644".to_string(),
1073 sha: "abc123".to_string(),
1074 },
1075 GitTreeEntry {
1076 path: "node_modules/package/index.js".to_string(),
1077 type_: "blob".to_string(),
1078 mode: "100644".to_string(),
1079 sha: "def456".to_string(),
1080 },
1081 ];
1082
1083 let filtered = filter_tree_by_language(&entries, "rust");
1084 assert_eq!(filtered.len(), 1);
1085 assert_eq!(filtered[0], "src/main.rs");
1086 }
1087
1088 #[test]
1089 fn filter_tree_excludes_directories() {
1090 let entries = vec![
1091 GitTreeEntry {
1092 path: "src/main.rs".to_string(),
1093 type_: "blob".to_string(),
1094 mode: "100644".to_string(),
1095 sha: "abc123".to_string(),
1096 },
1097 GitTreeEntry {
1098 path: "src/lib".to_string(),
1099 type_: "tree".to_string(),
1100 mode: "040000".to_string(),
1101 sha: "def456".to_string(),
1102 },
1103 ];
1104
1105 let filtered = filter_tree_by_language(&entries, "rust");
1106 assert_eq!(filtered.len(), 1);
1107 assert_eq!(filtered[0], "src/main.rs");
1108 }
1109
1110 #[test]
1111 fn filter_tree_sorts_by_depth() {
1112 let entries = vec![
1113 GitTreeEntry {
1114 path: "a/b/c/d.rs".to_string(),
1115 type_: "blob".to_string(),
1116 mode: "100644".to_string(),
1117 sha: "abc123".to_string(),
1118 },
1119 GitTreeEntry {
1120 path: "a/b.rs".to_string(),
1121 type_: "blob".to_string(),
1122 mode: "100644".to_string(),
1123 sha: "def456".to_string(),
1124 },
1125 GitTreeEntry {
1126 path: "main.rs".to_string(),
1127 type_: "blob".to_string(),
1128 mode: "100644".to_string(),
1129 sha: "ghi789".to_string(),
1130 },
1131 ];
1132
1133 let filtered = filter_tree_by_language(&entries, "rust");
1134 assert_eq!(filtered[0], "main.rs");
1135 assert_eq!(filtered[1], "a/b.rs");
1136 assert_eq!(filtered[2], "a/b/c/d.rs");
1137 }
1138
1139 #[test]
1140 fn filter_tree_limits_to_50() {
1141 let entries: Vec<GitTreeEntry> = (0..100)
1142 .map(|i| GitTreeEntry {
1143 path: format!("file{i}.rs"),
1144 type_: "blob".to_string(),
1145 mode: "100644".to_string(),
1146 sha: format!("sha{i}"),
1147 })
1148 .collect();
1149
1150 let filtered = filter_tree_by_language(&entries, "rust");
1151 assert_eq!(filtered.len(), 50);
1152 }
1153
1154 #[test]
1155 fn filter_tree_by_language_rust() {
1156 let entries = vec![
1157 GitTreeEntry {
1158 path: "src/main.rs".to_string(),
1159 type_: "blob".to_string(),
1160 mode: "100644".to_string(),
1161 sha: "abc123".to_string(),
1162 },
1163 GitTreeEntry {
1164 path: "src/lib.py".to_string(),
1165 type_: "blob".to_string(),
1166 mode: "100644".to_string(),
1167 sha: "def456".to_string(),
1168 },
1169 ];
1170
1171 let filtered = filter_tree_by_language(&entries, "rust");
1172 assert_eq!(filtered.len(), 1);
1173 assert_eq!(filtered[0], "src/main.rs");
1174 }
1175
1176 #[test]
1177 fn filter_tree_by_language_python() {
1178 let entries = vec![
1179 GitTreeEntry {
1180 path: "main.py".to_string(),
1181 type_: "blob".to_string(),
1182 mode: "100644".to_string(),
1183 sha: "abc123".to_string(),
1184 },
1185 GitTreeEntry {
1186 path: "lib.rs".to_string(),
1187 type_: "blob".to_string(),
1188 mode: "100644".to_string(),
1189 sha: "def456".to_string(),
1190 },
1191 ];
1192
1193 let filtered = filter_tree_by_language(&entries, "python");
1194 assert_eq!(filtered.len(), 1);
1195 assert_eq!(filtered[0], "main.py");
1196 }
1197
1198 #[test]
1199 fn get_extensions_for_language_rust() {
1200 let exts = get_extensions_for_language("rust");
1201 assert_eq!(exts, vec!["rs"]);
1202 }
1203
1204 #[test]
1205 fn get_extensions_for_language_javascript() {
1206 let exts = get_extensions_for_language("javascript");
1207 assert!(exts.contains(&"js"));
1208 assert!(exts.contains(&"ts"));
1209 assert!(exts.contains(&"jsx"));
1210 assert!(exts.contains(&"tsx"));
1211 }
1212
1213 #[test]
1214 fn get_extensions_for_language_unknown() {
1215 let exts = get_extensions_for_language("unknown_language");
1216 assert!(exts.is_empty());
1217 }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223
1224 #[test]
1225 fn parse_reference_full_url() {
1226 let url = "https://github.com/block/goose/issues/5836";
1227 let (owner, repo, number) = parse_issue_reference(url, None).unwrap();
1228 assert_eq!(owner, "block");
1229 assert_eq!(repo, "goose");
1230 assert_eq!(number, 5836);
1231 }
1232
1233 #[test]
1234 fn parse_reference_short_form() {
1235 let reference = "block/goose#5836";
1236 let (owner, repo, number) = parse_issue_reference(reference, None).unwrap();
1237 assert_eq!(owner, "block");
1238 assert_eq!(repo, "goose");
1239 assert_eq!(number, 5836);
1240 }
1241
1242 #[test]
1243 fn parse_reference_short_form_with_context() {
1244 let reference = "block/goose#5836";
1245 let (owner, repo, number) =
1246 parse_issue_reference(reference, Some("astral-sh/ruff")).unwrap();
1247 assert_eq!(owner, "block");
1248 assert_eq!(repo, "goose");
1249 assert_eq!(number, 5836);
1250 }
1251
1252 #[test]
1253 fn parse_reference_bare_number_with_context() {
1254 let reference = "5836";
1255 let (owner, repo, number) = parse_issue_reference(reference, Some("block/goose")).unwrap();
1256 assert_eq!(owner, "block");
1257 assert_eq!(repo, "goose");
1258 assert_eq!(number, 5836);
1259 }
1260
1261 #[test]
1262 fn parse_reference_bare_number_without_context() {
1263 let reference = "5836";
1264 let result = parse_issue_reference(reference, None);
1265 assert!(result.is_err());
1266 assert!(
1267 result
1268 .unwrap_err()
1269 .to_string()
1270 .contains("Bare issue number requires repository context")
1271 );
1272 }
1273
1274 #[test]
1275 fn parse_reference_invalid_short_form_missing_slash() {
1276 let reference = "owner#123";
1277 let result = parse_issue_reference(reference, None);
1278 assert!(result.is_err());
1279 assert!(
1280 result
1281 .unwrap_err()
1282 .to_string()
1283 .contains("Invalid owner/repo format")
1284 );
1285 }
1286
1287 #[test]
1288 fn parse_reference_invalid_short_form_extra_slash() {
1289 let reference = "owner/repo/extra#123";
1290 let result = parse_issue_reference(reference, None);
1291 assert!(result.is_err());
1292 assert!(
1293 result
1294 .unwrap_err()
1295 .to_string()
1296 .contains("Invalid owner/repo format")
1297 );
1298 }
1299
1300 #[test]
1301 fn parse_reference_invalid_bare_number() {
1302 let reference = "abc";
1303 let result = parse_issue_reference(reference, Some("block/goose"));
1304 assert!(result.is_err());
1305 assert!(
1306 result
1307 .unwrap_err()
1308 .to_string()
1309 .contains("Invalid issue reference format")
1310 );
1311 }
1312
1313 #[test]
1314 fn parse_reference_whitespace_trimming() {
1315 let reference = " block/goose#5836 ";
1316 let (owner, repo, number) = parse_issue_reference(reference, None).unwrap();
1317 assert_eq!(owner, "block");
1318 assert_eq!(repo, "goose");
1319 assert_eq!(number, 5836);
1320 }
1321
1322 #[test]
1323 fn parse_reference_bare_number_whitespace() {
1324 let reference = " 5836 ";
1325 let (owner, repo, number) = parse_issue_reference(reference, Some("block/goose")).unwrap();
1326 assert_eq!(owner, "block");
1327 assert_eq!(repo, "goose");
1328 assert_eq!(number, 5836);
1329 }
1330
1331 #[test]
1332 fn extract_keywords_filters_stop_words() {
1333 let title = "The issue is about a bug in the CLI";
1334 let keywords = extract_keywords(title);
1335 assert!(!keywords.contains(&"the".to_string()));
1336 assert!(!keywords.contains(&"is".to_string()));
1337 assert!(!keywords.contains(&"a".to_string()));
1338 assert!(keywords.contains(&"issue".to_string()));
1339 assert!(keywords.contains(&"bug".to_string()));
1340 assert!(keywords.contains(&"cli".to_string()));
1341 }
1342
1343 #[test]
1344 fn extract_keywords_limits_to_five() {
1345 let title = "one two three four five six seven eight nine ten";
1346 let keywords = extract_keywords(title);
1347 assert_eq!(keywords.len(), 5);
1348 }
1349
1350 #[test]
1351 fn extract_keywords_empty_title() {
1352 let title = "the a an and or";
1353 let keywords = extract_keywords(title);
1354 assert!(keywords.is_empty());
1355 }
1356
1357 #[test]
1358 fn extract_keywords_lowercase_conversion() {
1359 let title = "CLI Bug FIX";
1360 let keywords = extract_keywords(title);
1361 assert!(keywords.iter().all(|k| k.chars().all(char::is_lowercase)));
1362 }
1363}