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 GitTreeEntry {
18 pub path: String,
20 #[serde(rename = "type")]
22 pub type_: String,
23 pub mode: String,
25 pub sha: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct GitTreeResponse {
32 pub tree: Vec<GitTreeEntry>,
34 pub truncated: bool,
36}
37
38fn parse_owner_repo(s: &str) -> Result<(String, String)> {
46 let parts: Vec<&str> = s.split('/').collect();
47 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
48 anyhow::bail!(
49 "Invalid owner/repo format.\n\
50 Expected: owner/repo\n\
51 Got: {s}"
52 );
53 }
54 Ok((parts[0].to_string(), parts[1].to_string()))
55}
56
57pub fn parse_issue_reference(
73 input: &str,
74 repo_context: Option<&str>,
75) -> Result<(String, String, u64)> {
76 let input = input.trim();
77
78 if input.starts_with("https://") || input.starts_with("http://") {
80 let clean_url = input.split('#').next().unwrap_or(input);
82 let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
83
84 let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
86
87 if parts.len() < 7 {
89 anyhow::bail!(
90 "Invalid GitHub issue URL format.\n\
91 Expected: https://github.com/owner/repo/issues/123\n\
92 Got: {input}"
93 );
94 }
95
96 if !parts[2].contains("github.com") {
98 anyhow::bail!(
99 "URL must be a GitHub issue URL.\n\
100 Expected: https://github.com/owner/repo/issues/123\n\
101 Got: {input}"
102 );
103 }
104
105 if parts[5] != "issues" {
107 anyhow::bail!(
108 "URL must point to a GitHub issue.\n\
109 Expected: https://github.com/owner/repo/issues/123\n\
110 Got: {input}"
111 );
112 }
113
114 let owner = parts[3].to_string();
115 let repo = parts[4].to_string();
116 let number: u64 = parts[6].parse().with_context(|| {
117 format!(
118 "Invalid issue number '{}' in URL.\n\
119 Expected a numeric issue number.",
120 parts[6]
121 )
122 })?;
123
124 debug!(owner = %owner, repo = %repo, number = number, "Parsed issue URL");
125 return Ok((owner, repo, number));
126 }
127
128 if let Some(hash_pos) = input.find('#') {
130 let owner_repo_part = &input[..hash_pos];
131 let number_part = &input[hash_pos + 1..];
132
133 let (owner, repo) = parse_owner_repo(owner_repo_part)?;
134 let number: u64 = number_part.parse().with_context(|| {
135 format!(
136 "Invalid issue number '{number_part}' in short form.\n\
137 Expected: owner/repo#123\n\
138 Got: {input}"
139 )
140 })?;
141
142 debug!(owner = %owner, repo = %repo, number = number, "Parsed short-form issue reference");
143 return Ok((owner, repo, number));
144 }
145
146 if let Ok(number) = input.parse::<u64>() {
148 let repo_context = repo_context.ok_or_else(|| {
149 anyhow::anyhow!(
150 "Bare issue number requires repository context.\n\
151 Use one of:\n\
152 - Full URL: https://github.com/owner/repo/issues/123\n\
153 - Short form: owner/repo#123\n\
154 - Bare number with --repo flag: 123 --repo owner/repo\n\
155 Got: {input}"
156 )
157 })?;
158
159 let (owner, repo) = parse_owner_repo(repo_context)?;
160 debug!(owner = %owner, repo = %repo, number = number, "Parsed bare issue number");
161 return Ok((owner, repo, number));
162 }
163
164 anyhow::bail!(
166 "Invalid issue reference format.\n\
167 Expected one of:\n\
168 - Full URL: https://github.com/owner/repo/issues/123\n\
169 - Short form: owner/repo#123\n\
170 - Bare number with --repo flag: 123 --repo owner/repo\n\
171 Got: {input}"
172 );
173}
174
175#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
181pub async fn fetch_issue_with_comments(
182 client: &Octocrab,
183 owner: &str,
184 repo: &str,
185 number: u64,
186) -> Result<IssueDetails> {
187 debug!("Fetching issue details");
188
189 let issue = client
191 .issues(owner, repo)
192 .get(number)
193 .await
194 .with_context(|| format!("Failed to fetch issue #{number} from {owner}/{repo}"))?;
195
196 let comments_page = client
198 .issues(owner, repo)
199 .list_comments(number)
200 .per_page(5)
201 .send()
202 .await
203 .with_context(|| format!("Failed to fetch comments for issue #{number}"))?;
204
205 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
207
208 let comments: Vec<IssueComment> = comments_page
209 .items
210 .iter()
211 .map(|c| IssueComment {
212 author: c.user.login.clone(),
213 body: c.body.clone().unwrap_or_default(),
214 })
215 .collect();
216
217 let issue_url = issue.html_url.to_string();
218
219 let details = IssueDetails {
220 owner: owner.to_string(),
221 repo: repo.to_string(),
222 number,
223 title: issue.title,
224 body: issue.body.unwrap_or_default(),
225 labels,
226 comments,
227 url: issue_url,
228 repo_context: Vec::new(),
229 repo_tree: Vec::new(),
230 };
231
232 debug!(
233 labels = details.labels.len(),
234 comments = details.comments.len(),
235 "Fetched issue details"
236 );
237
238 Ok(details)
239}
240
241fn extract_keywords(title: &str) -> Vec<String> {
245 let stop_words = [
246 "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "he", "in", "is",
247 "it", "its", "of", "on", "or", "that", "the", "to", "was", "will", "with",
248 ];
249
250 title
251 .to_lowercase()
252 .split(|c: char| !c.is_alphanumeric())
253 .filter(|word| !word.is_empty() && !stop_words.contains(word))
254 .take(5) .map(std::string::ToString::to_string)
256 .collect()
257}
258
259#[instrument(skip(client), fields(owner = %owner, repo = %repo, exclude_number = %exclude_number))]
276pub async fn search_related_issues(
277 client: &Octocrab,
278 owner: &str,
279 repo: &str,
280 title: &str,
281 exclude_number: u64,
282) -> Result<Vec<RepoIssueContext>> {
283 let keywords = extract_keywords(title);
284
285 if keywords.is_empty() {
286 debug!("No keywords extracted from title");
287 return Ok(Vec::new());
288 }
289
290 let query = format!("{} repo:{}/{} is:issue", keywords.join(" "), owner, repo);
292
293 debug!(query = %query, "Searching for related issues");
294
295 let search_result = client
297 .search()
298 .issues_and_pull_requests(&query)
299 .per_page(20)
300 .send()
301 .await
302 .with_context(|| format!("Failed to search for related issues in {owner}/{repo}"))?;
303
304 let related: Vec<RepoIssueContext> = search_result
306 .items
307 .iter()
308 .filter_map(|item| {
309 if item.pull_request.is_some() {
311 return None;
312 }
313
314 if item.number == exclude_number {
316 return None;
317 }
318
319 Some(RepoIssueContext {
320 number: item.number,
321 title: item.title.clone(),
322 labels: item.labels.iter().map(|l| l.name.clone()).collect(),
323 state: format!("{:?}", item.state).to_lowercase(),
324 })
325 })
326 .collect();
327
328 debug!(count = related.len(), "Found related issues");
329
330 Ok(related)
331}
332
333#[instrument(skip(client, body), fields(owner = %owner, repo = %repo, number = number))]
343pub async fn post_comment(
344 client: &Octocrab,
345 owner: &str,
346 repo: &str,
347 number: u64,
348 body: &str,
349) -> Result<String> {
350 debug!("Posting triage comment");
351
352 let comment = client
353 .issues(owner, repo)
354 .create_comment(number, body)
355 .await
356 .with_context(|| format!("Failed to post comment to issue #{number}"))?;
357
358 let comment_url = comment.html_url.to_string();
359
360 debug!(url = %comment_url, "Comment posted successfully");
361
362 Ok(comment_url)
363}
364
365fn get_extensions_for_language(language: &str) -> Vec<&'static str> {
367 match language.to_lowercase().as_str() {
368 "rust" => vec!["rs"],
369 "python" => vec!["py"],
370 "javascript" | "typescript" => vec!["js", "ts", "jsx", "tsx"],
371 "java" => vec!["java"],
372 "c" => vec!["c", "h"],
373 "c++" | "cpp" => vec!["cpp", "cc", "cxx", "h", "hpp"],
374 "c#" | "csharp" => vec!["cs"],
375 "go" => vec!["go"],
376 "ruby" => vec!["rb"],
377 "php" => vec!["php"],
378 "swift" => vec!["swift"],
379 "kotlin" => vec!["kt"],
380 "scala" => vec!["scala"],
381 "r" => vec!["r"],
382 "shell" | "bash" => vec!["sh", "bash"],
383 "html" => vec!["html", "htm"],
384 "css" => vec!["css", "scss", "sass"],
385 "json" => vec!["json"],
386 "yaml" | "yml" => vec!["yaml", "yml"],
387 "toml" => vec!["toml"],
388 "xml" => vec!["xml"],
389 "markdown" => vec!["md"],
390 _ => vec![],
391 }
392}
393
394fn filter_tree_by_language(entries: &[GitTreeEntry], language: &str) -> Vec<String> {
408 let extensions = get_extensions_for_language(language);
409 let exclude_dirs = [
410 "node_modules/",
411 "target/",
412 "dist/",
413 "build/",
414 ".git/",
415 "vendor/",
416 "test",
417 "spec",
418 "mock",
419 "fixture",
420 ];
421
422 let mut filtered: Vec<String> = entries
423 .iter()
424 .filter(|entry| {
425 if entry.type_ != "blob" {
427 return false;
428 }
429
430 if exclude_dirs.iter().any(|dir| entry.path.contains(dir)) {
432 return false;
433 }
434
435 if extensions.is_empty() {
437 true
439 } else {
440 extensions.iter().any(|ext| entry.path.ends_with(ext))
441 }
442 })
443 .map(|e| e.path.clone())
444 .collect();
445
446 filtered.sort_by(|a, b| {
448 let depth_a = a.matches('/').count();
449 let depth_b = b.matches('/').count();
450 if depth_a == depth_b {
451 a.cmp(b)
452 } else {
453 depth_a.cmp(&depth_b)
454 }
455 });
456
457 filtered.truncate(50);
459 filtered
460}
461
462#[instrument(skip(client), fields(owner = %owner, repo = %repo))]
478pub async fn fetch_repo_tree(
479 client: &Octocrab,
480 owner: &str,
481 repo: &str,
482 language: &str,
483) -> Result<Vec<String>> {
484 debug!("Fetching repository tree");
485
486 let branches = ["main", "master"];
488 let mut tree_response: Option<GitTreeResponse> = None;
489
490 for branch in &branches {
491 let route = format!("/repos/{owner}/{repo}/git/trees/{branch}?recursive=1");
492 match client
493 .get::<GitTreeResponse, _, _>(&route, None::<&()>)
494 .await
495 {
496 Ok(response) => {
497 tree_response = Some(response);
498 debug!(branch = %branch, "Fetched tree from branch");
499 break;
500 }
501 Err(e) => {
502 debug!(branch = %branch, error = %e, "Failed to fetch tree from branch");
503 }
504 }
505 }
506
507 let response =
508 tree_response.context("Failed to fetch repository tree from main or master branch")?;
509
510 let filtered = filter_tree_by_language(&response.tree, language);
511 debug!(count = filtered.len(), "Filtered tree entries");
512
513 Ok(filtered)
514}
515
516#[cfg(test)]
517mod tree_tests {
518 use super::*;
519
520 #[test]
521 fn filter_tree_excludes_node_modules() {
522 let entries = vec![
523 GitTreeEntry {
524 path: "src/main.rs".to_string(),
525 type_: "blob".to_string(),
526 mode: "100644".to_string(),
527 sha: "abc123".to_string(),
528 },
529 GitTreeEntry {
530 path: "node_modules/package/index.js".to_string(),
531 type_: "blob".to_string(),
532 mode: "100644".to_string(),
533 sha: "def456".to_string(),
534 },
535 ];
536
537 let filtered = filter_tree_by_language(&entries, "rust");
538 assert_eq!(filtered.len(), 1);
539 assert_eq!(filtered[0], "src/main.rs");
540 }
541
542 #[test]
543 fn filter_tree_excludes_directories() {
544 let entries = vec![
545 GitTreeEntry {
546 path: "src/main.rs".to_string(),
547 type_: "blob".to_string(),
548 mode: "100644".to_string(),
549 sha: "abc123".to_string(),
550 },
551 GitTreeEntry {
552 path: "src/lib".to_string(),
553 type_: "tree".to_string(),
554 mode: "040000".to_string(),
555 sha: "def456".to_string(),
556 },
557 ];
558
559 let filtered = filter_tree_by_language(&entries, "rust");
560 assert_eq!(filtered.len(), 1);
561 assert_eq!(filtered[0], "src/main.rs");
562 }
563
564 #[test]
565 fn filter_tree_sorts_by_depth() {
566 let entries = vec![
567 GitTreeEntry {
568 path: "a/b/c/d.rs".to_string(),
569 type_: "blob".to_string(),
570 mode: "100644".to_string(),
571 sha: "abc123".to_string(),
572 },
573 GitTreeEntry {
574 path: "a/b.rs".to_string(),
575 type_: "blob".to_string(),
576 mode: "100644".to_string(),
577 sha: "def456".to_string(),
578 },
579 GitTreeEntry {
580 path: "main.rs".to_string(),
581 type_: "blob".to_string(),
582 mode: "100644".to_string(),
583 sha: "ghi789".to_string(),
584 },
585 ];
586
587 let filtered = filter_tree_by_language(&entries, "rust");
588 assert_eq!(filtered[0], "main.rs");
589 assert_eq!(filtered[1], "a/b.rs");
590 assert_eq!(filtered[2], "a/b/c/d.rs");
591 }
592
593 #[test]
594 fn filter_tree_limits_to_50() {
595 let entries: Vec<GitTreeEntry> = (0..100)
596 .map(|i| GitTreeEntry {
597 path: format!("file{i}.rs"),
598 type_: "blob".to_string(),
599 mode: "100644".to_string(),
600 sha: format!("sha{i}"),
601 })
602 .collect();
603
604 let filtered = filter_tree_by_language(&entries, "rust");
605 assert_eq!(filtered.len(), 50);
606 }
607
608 #[test]
609 fn filter_tree_by_language_rust() {
610 let entries = vec![
611 GitTreeEntry {
612 path: "src/main.rs".to_string(),
613 type_: "blob".to_string(),
614 mode: "100644".to_string(),
615 sha: "abc123".to_string(),
616 },
617 GitTreeEntry {
618 path: "src/lib.py".to_string(),
619 type_: "blob".to_string(),
620 mode: "100644".to_string(),
621 sha: "def456".to_string(),
622 },
623 ];
624
625 let filtered = filter_tree_by_language(&entries, "rust");
626 assert_eq!(filtered.len(), 1);
627 assert_eq!(filtered[0], "src/main.rs");
628 }
629
630 #[test]
631 fn filter_tree_by_language_python() {
632 let entries = vec![
633 GitTreeEntry {
634 path: "main.py".to_string(),
635 type_: "blob".to_string(),
636 mode: "100644".to_string(),
637 sha: "abc123".to_string(),
638 },
639 GitTreeEntry {
640 path: "lib.rs".to_string(),
641 type_: "blob".to_string(),
642 mode: "100644".to_string(),
643 sha: "def456".to_string(),
644 },
645 ];
646
647 let filtered = filter_tree_by_language(&entries, "python");
648 assert_eq!(filtered.len(), 1);
649 assert_eq!(filtered[0], "main.py");
650 }
651
652 #[test]
653 fn get_extensions_for_language_rust() {
654 let exts = get_extensions_for_language("rust");
655 assert_eq!(exts, vec!["rs"]);
656 }
657
658 #[test]
659 fn get_extensions_for_language_javascript() {
660 let exts = get_extensions_for_language("javascript");
661 assert!(exts.contains(&"js"));
662 assert!(exts.contains(&"ts"));
663 assert!(exts.contains(&"jsx"));
664 assert!(exts.contains(&"tsx"));
665 }
666
667 #[test]
668 fn get_extensions_for_language_unknown() {
669 let exts = get_extensions_for_language("unknown_language");
670 assert!(exts.is_empty());
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn parse_reference_full_url() {
680 let url = "https://github.com/block/goose/issues/5836";
681 let (owner, repo, number) = parse_issue_reference(url, None).unwrap();
682 assert_eq!(owner, "block");
683 assert_eq!(repo, "goose");
684 assert_eq!(number, 5836);
685 }
686
687 #[test]
688 fn parse_reference_short_form() {
689 let reference = "block/goose#5836";
690 let (owner, repo, number) = parse_issue_reference(reference, None).unwrap();
691 assert_eq!(owner, "block");
692 assert_eq!(repo, "goose");
693 assert_eq!(number, 5836);
694 }
695
696 #[test]
697 fn parse_reference_short_form_with_context() {
698 let reference = "block/goose#5836";
699 let (owner, repo, number) =
700 parse_issue_reference(reference, Some("astral-sh/ruff")).unwrap();
701 assert_eq!(owner, "block");
702 assert_eq!(repo, "goose");
703 assert_eq!(number, 5836);
704 }
705
706 #[test]
707 fn parse_reference_bare_number_with_context() {
708 let reference = "5836";
709 let (owner, repo, number) = parse_issue_reference(reference, Some("block/goose")).unwrap();
710 assert_eq!(owner, "block");
711 assert_eq!(repo, "goose");
712 assert_eq!(number, 5836);
713 }
714
715 #[test]
716 fn parse_reference_bare_number_without_context() {
717 let reference = "5836";
718 let result = parse_issue_reference(reference, None);
719 assert!(result.is_err());
720 assert!(
721 result
722 .unwrap_err()
723 .to_string()
724 .contains("Bare issue number requires repository context")
725 );
726 }
727
728 #[test]
729 fn parse_reference_invalid_short_form_missing_slash() {
730 let reference = "owner#123";
731 let result = parse_issue_reference(reference, None);
732 assert!(result.is_err());
733 assert!(
734 result
735 .unwrap_err()
736 .to_string()
737 .contains("Invalid owner/repo format")
738 );
739 }
740
741 #[test]
742 fn parse_reference_invalid_short_form_extra_slash() {
743 let reference = "owner/repo/extra#123";
744 let result = parse_issue_reference(reference, None);
745 assert!(result.is_err());
746 assert!(
747 result
748 .unwrap_err()
749 .to_string()
750 .contains("Invalid owner/repo format")
751 );
752 }
753
754 #[test]
755 fn parse_reference_invalid_bare_number() {
756 let reference = "abc";
757 let result = parse_issue_reference(reference, Some("block/goose"));
758 assert!(result.is_err());
759 assert!(
760 result
761 .unwrap_err()
762 .to_string()
763 .contains("Invalid issue reference format")
764 );
765 }
766
767 #[test]
768 fn parse_reference_whitespace_trimming() {
769 let reference = " block/goose#5836 ";
770 let (owner, repo, number) = parse_issue_reference(reference, None).unwrap();
771 assert_eq!(owner, "block");
772 assert_eq!(repo, "goose");
773 assert_eq!(number, 5836);
774 }
775
776 #[test]
777 fn parse_reference_bare_number_whitespace() {
778 let reference = " 5836 ";
779 let (owner, repo, number) = parse_issue_reference(reference, Some("block/goose")).unwrap();
780 assert_eq!(owner, "block");
781 assert_eq!(repo, "goose");
782 assert_eq!(number, 5836);
783 }
784
785 #[test]
786 fn extract_keywords_filters_stop_words() {
787 let title = "The issue is about a bug in the CLI";
788 let keywords = extract_keywords(title);
789 assert!(!keywords.contains(&"the".to_string()));
790 assert!(!keywords.contains(&"is".to_string()));
791 assert!(!keywords.contains(&"a".to_string()));
792 assert!(keywords.contains(&"issue".to_string()));
793 assert!(keywords.contains(&"bug".to_string()));
794 assert!(keywords.contains(&"cli".to_string()));
795 }
796
797 #[test]
798 fn extract_keywords_limits_to_five() {
799 let title = "one two three four five six seven eight nine ten";
800 let keywords = extract_keywords(title);
801 assert_eq!(keywords.len(), 5);
802 }
803
804 #[test]
805 fn extract_keywords_empty_title() {
806 let title = "the a an and or";
807 let keywords = extract_keywords(title);
808 assert!(keywords.is_empty());
809 }
810
811 #[test]
812 fn extract_keywords_lowercase_conversion() {
813 let title = "CLI Bug FIX";
814 let keywords = extract_keywords(title);
815 assert!(keywords.iter().all(|k| k.chars().all(char::is_lowercase)));
816 }
817}