From 14ac93f22b57d4261f9bda2dbf31075e22d322a5 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Mon, 2 Feb 2026 18:27:13 +0000 Subject: [PATCH] Initial upload with CI/CD workflow --- src/cli.py | 403 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 src/cli.py diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..d8e3924 --- /dev/null +++ b/src/cli.py @@ -0,0 +1,403 @@ +"""CLI interface for Code Pattern Search.""" + +import sys +from pathlib import Path +from typing import Any, Optional, cast + +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from .config import Config +from .github_client import GitHubClient, RateLimitExceededError +from .pattern_matcher import PatternMatcher, PatternLibrary, MatchLocation +from .cache_manager import CacheManager +from .exporter import Exporter +from .models import SearchResult + + +console = Console() + + +@click.group() +@click.option( + "--cache-dir", + type=click.Path(path_type=Path), + help="Custom cache directory", +) +@click.option( + "--cache-ttl", + type=int, + help="Cache TTL in seconds", +) +@click.option( + "--token", + envvar="GITHUB_TOKEN", + help="GitHub API token", +) +@click.option( + "--verbose", + "-v", + is_flag=True, + help="Enable verbose output", +) +@click.pass_context +def main( + ctx: click.Context, + cache_dir: Optional[Path], + cache_ttl: Optional[int], + token: Optional[str], + verbose: bool, +) -> None: + """Search GitHub repositories by code patterns.""" + ctx.ensure_object(dict) + ctx.obj["config"] = Config( + cache_dir=cache_dir, + cache_ttl=cache_ttl or 3600, + token=token, + verbose=verbose, + ) + ctx.obj["verbose"] = verbose + + +@main.command() +@click.argument("pattern", type=str, default=None, required=False) +@click.option( + "--language", "-l", + type=str, + help="Filter by programming language", +) +@click.option( + "--stars-min", + type=int, + default=0, + help="Minimum star count", +) +@click.option( + "--stars-max", + type=int, + default=None, + help="Maximum star count", +) +@click.option( + "--repos", + type=int, + default=10, + help="Number of repositories to search", +) +@click.option( + "--output", "-o", + type=click.Path(path_type=Path), + help="Export results to JSON file", +) +@click.option( + "--use-cache/--no-cache", + default=True, + help="Use cached results", +) +@click.option( + "--preset", + type=str, + help="Use a preset pattern from the library", +) +@click.pass_context +def search( + ctx: click.Context, + pattern: Optional[str], + language: Optional[str], + stars_min: int, + stars_max: Optional[int], + repos: int, + output: Optional[Path], + use_cache: bool, + preset: Optional[str], +) -> None: + """Search for a pattern in GitHub repositories.""" + config: Config = ctx.obj["config"] + verbose: bool = ctx.obj.get("verbose", False) + + if preset: + pattern = PatternLibrary.get_pattern(preset) + if not pattern: + console.print(f"[red]Error: Unknown preset '{preset}'[/red]") + console.print(f"Available presets: {', '.join(PatternLibrary.list_presets())}") + sys.exit(1) + elif pattern is None: + console.print("[red]Error: Pattern or --preset is required[/red]") + sys.exit(1) + + try: + pattern_matcher = PatternMatcher(pattern) + except Exception as e: + console.print(f"[red]Error: Invalid regex pattern: {e}[/red]") + sys.exit(1) + + cache_manager = CacheManager( + cache_dir=config.cache_dir, + ttl=config.cache_ttl, + ) + + github_client = GitHubClient( + token=config.token, + verbose=verbose, + ) + + console.print(f"Searching for pattern: [cyan]{pattern}[/cyan]") + if language: + console.print(f"Language filter: [cyan]{language}[/cyan]") + console.print(f"Star range: [cyan]{stars_min} - {stars_max or 'unlimited'}[/cyan]") + console.print() + + try: + results = search_repositories( + github_client=github_client, + pattern_matcher=pattern_matcher, + language=language, + stars_min=stars_min, + stars_max=stars_max, + max_repos=repos, + cache_manager=cache_manager, + use_cache=use_cache, + verbose=verbose, + console=console, + ) + + if not results: + console.print("[yellow]No matches found[/yellow]") + return + + sorted_results = sorted( + results, + key=lambda r: (r.score, r.total_matches), + reverse=True, + ) + + display_results(sorted_results, console) + + if output: + Exporter.export_results(sorted_results, output) + console.print(f"\n[green]Results exported to {output}[/green]") + + except RateLimitExceededError as e: + console.print("[red]Error: GitHub API rate limit exceeded[/red]") + console.print(f"Reset time: {e.reset_time}") + console.print("Tip: Set GITHUB_TOKEN environment variable for higher limits") + sys.exit(1) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + if verbose: + raise + sys.exit(1) + + +def search_repositories( + github_client: GitHubClient, + pattern_matcher: PatternMatcher, + language: Optional[str], + stars_min: int, + stars_max: Optional[int], + max_repos: int, + cache_manager: CacheManager, + use_cache: bool, + verbose: bool, + console: Console, +) -> list[SearchResult]: + """Search repositories for the pattern.""" + + search_key = f"search:{pattern_matcher.pattern}:{language}:{stars_min}:{stars_max}:{max_repos}" + + if use_cache: + cached = cache_manager.get(search_key) + if cached: + console.print("[dim]Using cached search results[/dim]") + return cached + + if verbose: + console.print("Fetching repositories...") + + repos = github_client.search_repositories( + language=language, + stars_min=stars_min, + stars_max=stars_max, + per_page=max_repos, + ) + + if not repos: + return [] + + results: list[SearchResult] = [] + + def process_repo(repo) -> Optional[SearchResult]: + repo_name = repo.full_name + + if verbose: + console.print(f" Processing {repo_name}...") + + cache_key = f"repo:{repo_name}:{pattern_matcher.pattern}" + + if use_cache: + cached_result = cache_manager.get(cache_key) + if cached_result: + return cached_result + + try: + file_tree = github_client.get_file_tree(repo.owner.login, repo.name) + + if not file_tree: + return None + + matches: list[MatchLocation] = [] + + for item in file_tree: + item_dict = cast(dict[str, Any], item) + file_path_item = item_dict["path"] + if not pattern_matcher.matches_extension(file_path_item): + continue + + content = github_client.get_file_content( + repo.owner.login, + repo.name, + file_path_item, + ) + + if content: + file_matches = pattern_matcher.find_matches(content, file_path_item) + matches.extend(file_matches) + + if matches: + result = SearchResult( + repo_name=repo_name, + repo_url=repo.html_url, + stars=repo.stargazers_count, + description=repo.description, + language=repo.language, + matches=matches, + total_matches=len(matches), + score=calculate_score(repo.stargazers_count, len(matches)), + ) + + cache_manager.set(cache_key, result) + return result + + except Exception as e: + if verbose: + console.print(f" [yellow]Warning: Error processing {repo_name}: {e}[/yellow]") + + return None + + with console.status("[bold]Searching repositories...") as status: + for repo in repos: + status.update(f"Processing {repo.full_name}...") + result = process_repo(repo) + if result: + results.append(result) + + cache_manager.set(search_key, results) + + return results + + +def calculate_score(stars: int, match_count: int) -> float: + """Calculate relevance score based on stars and match count.""" + import math + + star_score = math.log10(max(stars, 1)) * 10 + match_score = match_count * 5 + + return round(star_score + match_score, 2) + + +def display_results(results: list[SearchResult], console: Console) -> None: + """Display search results in a formatted table.""" + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Repository", width=40) + table.add_column("Stars") + table.add_column("Matches") + table.add_column("Score") + + for result in results: + repo_link = f"[link={result.repo_url}]{result.repo_name}[/link]" + table.add_row( + repo_link, + str(result.stars), + str(result.total_matches), + str(result.score), + ) + + console.print(Panel(table, title="Search Results", expand=False)) + + if len(results) > 0: + console.print(f"\n[dim]Found {len(results)} repositories with matches[/dim]") + + +@main.command() +def presets() -> None: + """List available pattern presets.""" + presets_list = PatternLibrary.list_presets() + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Preset Name") + table.add_column("Pattern") + + for name in presets_list: + pattern = PatternLibrary.get_pattern(name) + table.add_row(name, pattern) + + console.print(Panel(table, title="Available Presets", expand=False)) + + +@main.command() +@click.option( + "--all", + is_flag=True, + help="Show all cached entries", +) +@click.option( + "--clear", + is_flag=True, + help="Clear all cached data", +) +def cache(all: bool, clear: bool) -> None: + """Manage cache.""" + cache_manager = CacheManager() + + if clear: + cache_manager.clear() + console.print("[green]Cache cleared successfully[/green]") + return + + stats = cache_manager.get_stats() + + if all: + entries = cache_manager.get_all() + if entries: + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Key", width=60) + table.add_column("Type") + table.add_column("Size") + + for key, value in entries.items(): + value_type = type(value).__name__ + size = len(str(value)) + table.add_row(key[:60], value_type, str(size)) + + console.print(Panel(table, title="Cached Entries", expand=False)) + else: + console.print("[yellow]Cache is empty[/yellow]") + + console.print("\nCache statistics:") + console.print(f" Total entries: {stats['size']}") + console.print(f" Cache size: {stats['cache_size_mb']:.2f} MB") + + +@main.command() +def version() -> None: + """Show version information.""" + from . import __version__ + + console.print(f"[bold]Code Pattern Search CLI[/bold] v{__version__}") + + +if __name__ == "__main__": + main()