aptu_core/github/
issues.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! GitHub issue operations for the triage command.
4//!
5//! Provides functionality to parse issue URLs, fetch issue details,
6//! and post triage comments.
7
8use 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/// A GitHub issue without labels (untriaged).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct UntriagedIssue {
20    /// Issue number.
21    pub number: u64,
22    /// Issue title.
23    pub title: String,
24    /// Creation timestamp (ISO 8601).
25    pub created_at: String,
26    /// Issue URL.
27    pub url: String,
28}
29
30/// A single entry in a Git tree response.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct GitTreeEntry {
33    /// File path relative to repository root.
34    pub path: String,
35    /// Type of entry: "blob" (file) or "tree" (directory).
36    #[serde(rename = "type")]
37    pub type_: String,
38    /// File mode (e.g., "100644" for regular files).
39    pub mode: String,
40    /// SHA-1 hash of the entry.
41    pub sha: String,
42}
43
44/// Response from GitHub Git Trees API.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct GitTreeResponse {
47    /// List of entries in the tree.
48    pub tree: Vec<GitTreeEntry>,
49    /// Whether the tree is truncated (too many entries).
50    pub truncated: bool,
51}
52
53/// Parses an owner/repo string to extract owner and repo.
54///
55/// Validates format: exactly one `/`, non-empty parts.
56///
57/// # Errors
58///
59/// Returns an error if the format is invalid.
60pub 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
72/// Parses a GitHub issue reference in multiple formats.
73///
74/// Supports:
75/// - Full URL: `https://github.com/owner/repo/issues/123`
76/// - Short form: `owner/repo#123`
77/// - Bare number: `123` (requires `repo_context`)
78///
79/// # Arguments
80///
81/// * `input` - The issue reference to parse
82/// * `repo_context` - Optional repository context for bare numbers (e.g., "owner/repo")
83///
84/// # Errors
85///
86/// Returns an error if the format is invalid or bare number is used without context.
87pub fn parse_issue_reference(
88    input: &str,
89    repo_context: Option<&str>,
90) -> Result<(String, String, u64)> {
91    let input = input.trim();
92
93    // Try full URL first
94    if input.starts_with("https://") || input.starts_with("http://") {
95        // Remove trailing fragments and query params
96        let clean_url = input.split('#').next().unwrap_or(input);
97        let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
98
99        // Parse the URL path
100        let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
101
102        // Expected: ["https:", "", "github.com", "owner", "repo", "issues", "123"]
103        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        // Verify it's a github.com URL
112        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        // Verify it's an issues path
121        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    // Try short form: owner/repo#123
144    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    // Try bare number: 123 (requires repo_context)
162    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    // If we get here, it's an invalid format
180    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/// Fetches issue details including comments from GitHub.
191///
192/// # Errors
193///
194/// Returns an error if the API request fails or the issue is not found.
195#[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    // Fetch the issue with retry logic
205    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    // Fetch comments (limited to first page) with retry logic
224    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    // Convert to our types
245    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
283/// Extracts significant keywords from an issue title for search.
284///
285/// Filters out common stop words and returns lowercase keywords.
286/// Extracts keywords from an issue title for relevance matching.
287///
288/// Filters out common stop words and limits to 5 keywords.
289/// Used for prioritizing relevant files in repository tree filtering.
290///
291/// # Arguments
292///
293/// * `title` - Issue title to extract keywords from
294///
295/// # Returns
296///
297/// Vector of lowercase keywords (max 5), excluding stop words.
298pub 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) // Limit to first 5 keywords
309        .map(std::string::ToString::to_string)
310        .collect()
311}
312
313/// Searches for related issues in a repository based on title keywords.
314///
315/// Extracts keywords from the issue title and searches the repository
316/// for matching issues. Returns up to 20 results, excluding the specified issue.
317///
318/// # Arguments
319///
320/// * `client` - Authenticated Octocrab client
321/// * `owner` - Repository owner
322/// * `repo` - Repository name
323/// * `title` - Issue title to extract keywords from
324/// * `exclude_number` - Issue number to exclude from results
325///
326/// # Errors
327///
328/// Returns an error if the search API request fails.
329#[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    // Build search query: keyword1 keyword2 ... repo:owner/repo is:issue
345    let query = format!("{} repo:{}/{} is:issue", keywords.join(" "), owner, repo);
346
347    debug!(query = %query, "Searching for related issues");
348
349    // Search for issues with retry logic
350    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    // Convert to our context type
371    let related: Vec<RepoIssueContext> = search_result
372        .items
373        .iter()
374        .filter_map(|item| {
375            // Only include issues (not PRs)
376            if item.pull_request.is_some() {
377                return None;
378            }
379
380            // Exclude the issue being triaged
381            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/// Posts a triage comment to a GitHub issue.
400///
401/// # Returns
402///
403/// The URL of the created comment.
404///
405/// # Errors
406///
407/// Returns an error if the API request fails.
408#[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/// Creates a new GitHub issue.
432///
433/// Posts a new issue with the given title and body to the repository.
434/// Returns the issue URL and issue number.
435///
436/// # Arguments
437///
438/// * `client` - Authenticated Octocrab client
439/// * `owner` - Repository owner
440/// * `repo` - Repository name
441/// * `title` - Issue title
442/// * `body` - Issue body (markdown)
443///
444/// # Errors
445///
446/// Returns an error if the GitHub API call fails.
447#[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/// Result of applying labels and milestone to an issue.
474#[derive(Debug, Clone)]
475pub struct ApplyResult {
476    /// Labels that were successfully applied.
477    pub applied_labels: Vec<String>,
478    /// Milestone that was successfully applied, if any.
479    pub applied_milestone: Option<String>,
480    /// Warnings about labels or milestones that could not be applied.
481    pub warnings: Vec<String>,
482}
483
484/// Updates an issue with labels and milestone.
485///
486/// Validates suggested labels and milestone against available options before applying.
487/// Returns what was actually applied and any warnings.
488///
489/// # Errors
490///
491/// Returns an error if the GitHub API call fails.
492#[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    // Validate and collect labels
510    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    // Validate and find milestone
522    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    // Apply updates to the issue
537    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
573/// Priority labels that should be included first in tiered filtering.
574/// These labels are most actionable for issue triage.
575const 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/// Filters labels using tiered selection: priority labels first, then remaining labels.
591///
592/// Implements two-tier filtering:
593/// - Tier 1: Priority labels (case-insensitive matching)
594/// - Tier 2: Remaining labels to fill up to `max_labels`
595///
596/// This ensures the AI sees the most actionable labels regardless of repository size.
597///
598/// # Arguments
599///
600/// * `labels` - List of available labels from the repository
601/// * `max_labels` - Maximum number of labels to return
602///
603/// # Returns
604///
605/// Filtered list of labels with priority labels first.
606#[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    // Separate labels into priority and other
619    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    // Combine: priority labels first, then fill remaining slots with other labels
633    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    // Limit to max_labels
638    result.truncate(max_labels);
639    result
640}
641
642/// Patterns for directories/files to completely exclude from tree filtering.
643/// Based on GitHub Linguist vendor.yml and common build artifacts.
644const EXCLUDE_PATTERNS: &[&str] = &[
645    "node_modules/",
646    "vendor/",
647    "dist/",
648    "build/",
649    "target/",
650    ".git/",
651    "cache/",
652    "docs/",
653    "examples/",
654];
655
656/// Patterns for directories to deprioritize but not exclude.
657/// These contain test/benchmark code less relevant to issue triage.
658const DEPRIORITIZE_PATTERNS: &[&str] = &[
659    "test/",
660    "tests/",
661    "spec/",
662    "bench/",
663    "eval/",
664    "fixtures/",
665    "mocks/",
666];
667
668/// Returns language-specific entry point file patterns.
669/// These are prioritized as they often contain the main logic.
670fn 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
682/// Maps programming languages to their common file extensions.
683fn 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/// Filters repository tree entries by language-specific extensions.
712///
713/// Removes common non-source directories and limits results to 50 paths.
714/// Prioritizes shallow paths (fewer `/` characters).
715/// This is a legacy function kept for backward compatibility with existing tests.
716///
717/// # Arguments
718///
719/// * `entries` - Raw tree entries from GitHub API
720/// * `language` - Repository primary language for extension filtering
721///
722/// # Returns
723///
724/// Filtered and sorted list of file paths (max 50).
725#[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            // Only include files (blobs), not directories
745            if entry.type_ != "blob" {
746                return false;
747            }
748
749            // Exclude paths containing excluded directories
750            if exclude_dirs.iter().any(|dir| entry.path.contains(dir)) {
751                return false;
752            }
753
754            // Filter by extension if language is recognized
755            if extensions.is_empty() {
756                // If language not recognized, include all files
757                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    // Sort by path depth (fewer slashes first), then alphabetically
766    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    // Limit to 50 paths
777    filtered.truncate(50);
778    filtered
779}
780
781/// Filters repository tree entries by relevance using tiered keyword matching.
782///
783/// Implements three-tier filtering:
784/// - Tier 1: Files matching keywords (max 35)
785/// - Tier 2: Language entry points (max 10)
786/// - Tier 3: Other relevant files (max 15)
787///
788/// Removes common non-source directories and limits results to 60 paths.
789///
790/// # Arguments
791///
792/// * `entries` - Raw tree entries from GitHub API
793/// * `language` - Repository primary language for extension filtering
794/// * `keywords` - Optional keywords extracted from issue title for relevance matching
795///
796/// # Returns
797///
798/// Filtered and sorted list of file paths (max 60).
799fn 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    // Filter to valid source files
808    let candidates: Vec<String> = entries
809        .iter()
810        .filter(|entry| {
811            // Only include files (blobs), not directories
812            if entry.type_ != "blob" {
813                return false;
814            }
815
816            // Exclude paths containing excluded directories
817            if EXCLUDE_PATTERNS.iter().any(|dir| entry.path.contains(dir)) {
818                return false;
819            }
820
821            // Filter by extension if language is recognized
822            if extensions.is_empty() {
823                // If language not recognized, include all files
824                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    // Tier 1: Files matching keywords (max 35)
833    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    // Tier 2: Entry point files (max 10)
848    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    // Tier 3: Other relevant files (max 15)
863    let mut tier3: Vec<String> = tier3_candidates.into_iter().take(15).collect();
864
865    // Combine and sort by depth within each tier
866    let mut result = tier1;
867    result.append(&mut tier2);
868    result.append(&mut tier3);
869
870    // Sort by path depth (fewer slashes first), then alphabetically
871    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    // Limit to 60 paths
882    result.truncate(60);
883    result
884}
885
886/// Fetches the repository file tree from GitHub.
887///
888/// Attempts to fetch from the default branch (main, then master).
889/// Returns filtered list of source file paths based on repository language and optional keywords.
890///
891/// # Arguments
892///
893/// * `client` - Authenticated Octocrab client
894/// * `owner` - Repository owner
895/// * `repo` - Repository name
896/// * `language` - Repository primary language for filtering
897/// * `keywords` - Optional keywords extracted from issue title for relevance matching
898///
899/// # Errors
900///
901/// Returns an error if the API request fails (but not if tree is unavailable).
902#[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    // Try main branch first, then master
913    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/// Fetches issues needing triage from a specific repository.
957///
958/// In default mode (force=false), returns issues that are either unlabeled OR missing a milestone.
959/// In force mode (force=true), returns ALL open issues with no filtering.
960///
961/// # Arguments
962///
963/// * `client` - The Octocrab GitHub client
964/// * `owner` - Repository owner
965/// * `repo` - Repository name
966/// * `since` - Optional RFC3339 timestamp to filter issues created after this date (client-side filtering)
967/// * `force` - If true, return all open issues; if false, filter to unlabeled or milestone-missing issues
968///
969/// # Errors
970///
971/// Returns an error if the REST API request fails.
972#[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}