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 octocrab::Octocrab;
10use serde::{Deserialize, Serialize};
11use tracing::{debug, instrument};
12
13use crate::ai::types::{IssueComment, IssueDetails, RepoIssueContext};
14
15/// A single entry in a Git tree response.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct GitTreeEntry {
18    /// File path relative to repository root.
19    pub path: String,
20    /// Type of entry: "blob" (file) or "tree" (directory).
21    #[serde(rename = "type")]
22    pub type_: String,
23    /// File mode (e.g., "100644" for regular files).
24    pub mode: String,
25    /// SHA-1 hash of the entry.
26    pub sha: String,
27}
28
29/// Response from GitHub Git Trees API.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct GitTreeResponse {
32    /// List of entries in the tree.
33    pub tree: Vec<GitTreeEntry>,
34    /// Whether the tree is truncated (too many entries).
35    pub truncated: bool,
36}
37
38/// Parses an owner/repo string to extract owner and repo.
39///
40/// Validates format: exactly one `/`, non-empty parts.
41///
42/// # Errors
43///
44/// Returns an error if the format is invalid.
45fn 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
57/// Parses a GitHub issue reference in multiple formats.
58///
59/// Supports:
60/// - Full URL: `https://github.com/owner/repo/issues/123`
61/// - Short form: `owner/repo#123`
62/// - Bare number: `123` (requires `repo_context`)
63///
64/// # Arguments
65///
66/// * `input` - The issue reference to parse
67/// * `repo_context` - Optional repository context for bare numbers (e.g., "owner/repo")
68///
69/// # Errors
70///
71/// Returns an error if the format is invalid or bare number is used without context.
72pub fn parse_issue_reference(
73    input: &str,
74    repo_context: Option<&str>,
75) -> Result<(String, String, u64)> {
76    let input = input.trim();
77
78    // Try full URL first
79    if input.starts_with("https://") || input.starts_with("http://") {
80        // Remove trailing fragments and query params
81        let clean_url = input.split('#').next().unwrap_or(input);
82        let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
83
84        // Parse the URL path
85        let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
86
87        // Expected: ["https:", "", "github.com", "owner", "repo", "issues", "123"]
88        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        // Verify it's a github.com URL
97        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        // Verify it's an issues path
106        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    // Try short form: owner/repo#123
129    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    // Try bare number: 123 (requires repo_context)
147    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    // If we get here, it's an invalid format
165    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/// Fetches issue details including comments from GitHub.
176///
177/// # Errors
178///
179/// Returns an error if the API request fails or the issue is not found.
180#[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    // Fetch the issue
190    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    // Fetch comments (limited to first page)
197    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    // Convert to our types
206    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
241/// Extracts significant keywords from an issue title for search.
242///
243/// Filters out common stop words and returns lowercase keywords.
244fn 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) // Limit to first 5 keywords
255        .map(std::string::ToString::to_string)
256        .collect()
257}
258
259/// Searches for related issues in a repository based on title keywords.
260///
261/// Extracts keywords from the issue title and searches the repository
262/// for matching issues. Returns up to 20 results, excluding the specified issue.
263///
264/// # Arguments
265///
266/// * `client` - Authenticated Octocrab client
267/// * `owner` - Repository owner
268/// * `repo` - Repository name
269/// * `title` - Issue title to extract keywords from
270/// * `exclude_number` - Issue number to exclude from results
271///
272/// # Errors
273///
274/// Returns an error if the search API request fails.
275#[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    // Build search query: keyword1 keyword2 ... repo:owner/repo is:issue
291    let query = format!("{} repo:{}/{} is:issue", keywords.join(" "), owner, repo);
292
293    debug!(query = %query, "Searching for related issues");
294
295    // Search for issues
296    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    // Convert to our context type
305    let related: Vec<RepoIssueContext> = search_result
306        .items
307        .iter()
308        .filter_map(|item| {
309            // Only include issues (not PRs)
310            if item.pull_request.is_some() {
311                return None;
312            }
313
314            // Exclude the issue being triaged
315            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/// Posts a triage comment to a GitHub issue.
334///
335/// # Returns
336///
337/// The URL of the created comment.
338///
339/// # Errors
340///
341/// Returns an error if the API request fails.
342#[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
365/// Maps programming languages to their common file extensions.
366fn 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
394/// Filters repository tree entries by language-specific extensions.
395///
396/// Removes common non-source directories and limits results to 50 paths.
397/// Prioritizes shallow paths (fewer `/` characters).
398///
399/// # Arguments
400///
401/// * `entries` - Raw tree entries from GitHub API
402/// * `language` - Repository primary language for extension filtering
403///
404/// # Returns
405///
406/// Filtered and sorted list of file paths (max 50).
407fn 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            // Only include files (blobs), not directories
426            if entry.type_ != "blob" {
427                return false;
428            }
429
430            // Exclude paths containing excluded directories
431            if exclude_dirs.iter().any(|dir| entry.path.contains(dir)) {
432                return false;
433            }
434
435            // Filter by extension if language is recognized
436            if extensions.is_empty() {
437                // If language not recognized, include all files
438                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    // Sort by path depth (fewer slashes first), then alphabetically
447    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    // Limit to 50 paths
458    filtered.truncate(50);
459    filtered
460}
461
462/// Fetches the repository file tree from GitHub.
463///
464/// Attempts to fetch from the default branch (main, then master).
465/// Returns filtered list of source file paths based on repository language.
466///
467/// # Arguments
468///
469/// * `client` - Authenticated Octocrab client
470/// * `owner` - Repository owner
471/// * `repo` - Repository name
472/// * `language` - Repository primary language for filtering
473///
474/// # Errors
475///
476/// Returns an error if the API request fails (but not if tree is unavailable).
477#[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    // Try main branch first, then master
487    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}