diff --git a/.gitignore b/.gitignore index 6390194..cd27be6 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,5 @@ cython_debug/ observability_data/* storage/ + +.compass/* \ No newline at end of file diff --git a/docs/local_fetcher.md b/docs/local_fetcher.md new file mode 100644 index 0000000..b3751a6 --- /dev/null +++ b/docs/local_fetcher.md @@ -0,0 +1,403 @@ +# LocalFetcher Implementation Summary + +**Date:** 2025-02-06 + +## Overview + +The `LocalFetcher` class has been successfully implemented to enable GitRecap to work with local git repositories directly on your machine, without requiring remote platforms like GitHub, GitLab, Azure DevOps, or URL-based cloning. + +## Implementation Details + +### Files Created + +1. **`git_recap/providers/local_fetcher.py`** (391 lines) + - New `LocalFetcher` class extending `BaseFetcher` + - Implements all required abstract methods from `BaseFetcher` + - Uses subprocess to execute git commands directly on local repository paths + - Supports date filtering, author filtering, and branch operations + +### Files Modified + +1. **`git_recap/providers/__init__.py`** + - Added import: `from git_recap.providers.local_fetcher import LocalFetcher` + - Added `"LocalFetcher"` to `__all__` exports list + +2. **`app/api/services/fetcher_service.py`** + - Added import: `LocalFetcher` to provider imports + - Updated `store_fetcher()` function to handle `"Local"` provider type + - New logic: `elif provider == "Local": fetchers[session_id] = LocalFetcher(repo_path=pat)` + +3. **`app/api/models/schemas.py`** + - Added new `LocalRepoRequest` model with `path: str` field for local repository path + +4. **`app/api/server/routes.py`** + - Added `LocalRepoRequest` to schema imports + - Added new endpoint: `@router.post("/local-repo")` with `local_repository()` handler + - Endpoint creates LLM session and stores LocalFetcher instance + +5. **`git_recap/fetcher.py`** + - Added import: `from git_recap.providers.local_fetcher import LocalFetcher` + - Updated CLI to support `--provider local` option + - Added `--repo-path` argument for specifying local repository path + - PAT is not required for local provider + +6. **`examples/fetch_local.py`** (124 lines) + - Created comprehensive example file demonstrating LocalFetcher usage + - Includes examples for basic usage, author filtering, date ranges, and more + +7. **`tests/test_local_fetcher.py`** (498 lines) + - Created comprehensive test suite with 25 test cases + - Tests cover initialization, commit fetching, author retrieval, branch operations, and edge cases + - All tests passing + +## Features + +### Core Functionality + +- **Repository Validation**: Validates that the provided path is a valid git repository +- **Commit Fetching**: Fetches commits with support for: + - Date range filtering (`start_date`, `end_date`) + - Author filtering (`authors` parameter) + - Repository filtering (`repo_filter` parameter) +- **Author Retrieval**: Retrieves unique authors from commit history +- **Branch Operations**: Lists both local and remote branches +- **Current Author**: Retrieves the current git user configuration + +### API Integration + +- **New Endpoint**: `POST /local-repo` + - Accepts `LocalRepoRequest` with repository path + - Creates LLM session and stores LocalFetcher instance + - Integrates with existing endpoints (authors, commits, etc.) + +### CLI Support + +- **New Provider**: `--provider local` +- **New Argument**: `--repo-path` (required for local provider) +- **Optional PAT**: PAT is not required for local provider + +## Usage Examples + +### Python API + +```python +from datetime import datetime, timedelta +from git_recap.providers.local_fetcher import LocalFetcher + +# Initialize the fetcher with a path to your local git repository +fetcher = LocalFetcher( + repo_path="/path/to/your/local/repository", + start_date=datetime.now() - timedelta(days=30), + end_date=datetime.now() +) + +# Fetch commits +commits = fetcher.fetch_commits() +print(f"Found {len(commits)} commits") + +# Get repository name +print(f"Repository: {fetcher.repos_names}") + +# Get all authors +authors = fetcher.get_authors([]) +print(f"Found {len(authors)} unique authors") + +# Get authored messages +messages = fetcher.get_authored_messages() +``` + +### CLI + +```bash +# Fetch commits from local repository +python -m git_recap.fetcher \ + --provider local \ + --repo-path /path/to/your/local/repository \ + --start-date 2025-01-01 \ + --end-date 2025-01-31 \ + --limit 10 +``` + +### API Endpoint + +```bash +# Connect to a local repository via API +curl -X POST http://localhost:8000/local-repo \ + -H "Content-Type: application/json" \ + -d '{"path": "/path/to/your/local/repository"}' +``` + +## Technical Details + +### Git Command Execution + +The `LocalFetcher` uses `subprocess.run()` to execute git commands directly on the local repository: + +- **Commit Fetching**: Uses `git log` with custom format (`--pretty=format:%H|%an|%ad|%s`) +- **Author Retrieval**: Uses `git log` with author and committer formats +- **Branch Listing**: Uses `git branch` for local and remote branches +- **Current Author**: Uses `git config user.name` and `git config user.email` + +### Date Filtering + +- Supports timezone-aware datetime objects +- Converts to ISO format for git commands +- Filters commits within specified date range + +### Author Filtering + +- Supports multiple author names +- Uses git's `--author` flag for filtering +- Can be combined with date filtering + +### Error Handling + +- Handles subprocess timeouts gracefully +- Returns empty list on git command failures +- Validates repository path on initialization (can be disabled for testing) + +## Testing + +### Test Coverage + +- **25 test cases** covering all major functionality +- **Test categories**: + - Initialization tests + - Commit fetching tests + - Pull request tests (returns empty list) + - Author retrieval tests + - Branch operation tests + - Edge case handling + - Integration tests + +### Running Tests + +```bash +# Run all LocalFetcher tests +python -m pytest tests/test_local_fetcher.py -v + +# Run specific test class +python -m pytest tests/test_local_fetcher.py::TestLocalFetcherFetchCommits -v +``` + +## Limitations + +### Platform-Specific Features + +The following features are not supported for local repositories: + +- **Pull Requests**: Returns empty list (PRs are platform-specific) +- **Issues**: Returns empty list (issues are platform-specific) +- **Releases**: Raises `NotImplementedError` +- **PR Target Branch Validation**: Raises `NotImplementedError` +- **PR Creation**: Raises `NotImplementedError` + +### Single Repository + +Unlike other fetchers that can work with multiple repositories, `LocalFetcher` works with a single local repository path. + +## Integration Points + +### Existing Endpoints + +The `LocalFetcher` integrates with existing endpoints through the common `BaseFetcher` interface: + +- `/authors` - Get authors from local repository +- `/commits` - Get commits from local repository +- `/messages` - Get all authored messages (commits, PRs, issues) + +### Session Management + +Uses existing session_id pattern for storing fetcher instances in the fetcher service. + +## Future Enhancements + +Potential improvements for the `LocalFetcher`: + +1. **Worktree Support**: Add support for git worktrees +2. **Submodule Support**: Add support for git submodules +3. **Tag Support**: Add support for fetching tags +4. **Diff Support**: Add support for fetching commit diffs +5. **Branch Comparison**: Add support for comparing branches +6. **Merge Detection**: Detect merge commits and handle them differently + +## CLI Usage + +The GitRecap CLI provides an LLM-friendly command-line interface for fetching and summarizing git commits from local repositories. It's designed to be easily used by LLMs and automated tools. + +### Installation + +After installing the package, the CLI is available as `git-recap`: + +```bash +pip install git-recap +``` + +### Basic Usage + +```bash +# Get commits from current directory (last 7 days) +git-recap . + +# Get commits from multiple repositories +git-recap /path/to/repo1 /path/to/repo2 +``` + +### Command-Line Arguments + +#### Positional Arguments + +- **`paths`** (required, one or more) + - One or more paths to local git repositories + - Each path must be a valid git repository (contains .git directory) + - Can be absolute or relative paths + - Multiple paths can be provided + +#### Optional Arguments + +- **`--author AUTHOR`** + - Filter commits by author name + - Partial matching is supported (e.g., "John" matches "John Doe") + - If not specified, commits from all authors are included + +- **`--start-date START_DATE`** + - Start date for filtering commits (inclusive) + - Format: `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS` + - If not specified, defaults to 7 days before current date + +- **`--end-date END_DATE`** + - End date for filtering commits (inclusive) + - Format: `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS` + - If not specified, defaults to current date and time + +- **`--output OUTPUT, -o OUTPUT`** + - Output file path to save the summary + - If not specified, results are printed to stdout + - The file will be created or overwritten if it exists + +- **`--help, -h`** + - Show help message and exit + +### Usage Examples + +#### Filter by Author + +```bash +# Get commits by a specific author +git-recap . --author "John Doe" + +# Partial matching works too +git-recap /path/to/repo --author "Jane" +``` + +#### Filter by Date Range + +```bash +# Get commits from a specific date range +git-recap . --start-date "2025-01-01" --end-date "2025-01-31" + +# Get commits from a specific date onwards +git-recap . --start-date "2025-01-01" + +# Get commits up to a specific date +git-recap . --end-date "2025-01-31" +``` + +#### Save to File + +```bash +# Save summary to a file +git-recap . --output summary.txt + +# Combine filters and save to file +git-recap /path/to/repo1 /path/to/repo2 --author "Jane" --start-date "2025-01-01" --output commits.txt +``` + +#### Multiple Repositories + +```bash +# Fetch from multiple repositories +git-recap /path/to/repo1 /path/to/repo2 /path/to/repo3 + +# Combine with filters +git-recap /path/to/repo1 /path/to/repo2 --author "John" --start-date "2025-01-01" --end-date "2025-01-31" +``` + +### Output Format + +The CLI outputs commits grouped by date in the following format: + +``` +2025-01-15: + - [Commit] in my-repo: Added new feature for user authentication + - [Commit] in my-repo: Fixed bug in login flow + +2025-01-14: + - [Commit] in my-repo: Updated documentation + - [Commit] in my-repo: Refactored database queries +``` + +Each entry includes: +- **Date**: The date of the commits (YYYY-MM-DD) +- **Type**: The entry type (e.g., "Commit") +- **Repository**: The repository name +- **Message**: The commit message + +### LLM-Friendly Features + +The CLI is designed to be easily used by LLMs and automated tools: + +1. **Clear Help Text**: The `--help` output provides comprehensive information about all arguments and usage examples +2. **Structured Output**: The output format is consistent and easy to parse +3. **Error Messages**: Clear error messages are printed to stderr for debugging +4. **Exit Codes**: Returns 0 for success, 1 for errors +5. **Flexible Input**: Supports multiple repository paths and various filtering options + +### Error Handling + +The CLI provides helpful error messages for common issues: + +- **Invalid repository path**: "Error: Path does not exist: /path/to/repo" +- **Not a git repository**: "Error: Not a git repository: /path/to/repo" +- **Invalid date format**: "Invalid date format: 'invalid-date'. Use ISO format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS" +- **No commits found**: "No commits found matching the specified criteria." + +### Running the CLI + +After installation, you can run the CLI in two ways: + +```bash +# Using the installed command +git-recap . --author "John Doe" + +# Or using Python module +python -m git_recap.cli . --author "John Doe" +``` + +### Integration with LLMs + +The CLI is particularly useful for LLM integration: + +1. **Predictable Output**: The structured output format is easy for LLMs to parse and understand +2. **Flexible Filtering**: Multiple filtering options allow LLMs to request specific data +3. **File Output**: The `--output` flag allows LLMs to save results to files for further processing +4. **Help Documentation**: The comprehensive help text enables LLMs to understand available options + +Example LLM prompt: +``` +Please fetch all commits from the current directory made by "John Doe" in January 2025 and save the results to a file called "january_commits.txt". +``` + +The LLM can translate this to: +```bash +git-recap . --author "John Doe" --start-date "2025-01-01" --end-date "2025-01-31" --output january_commits.txt +``` + +## Conclusion + +The `LocalFetcher` implementation successfully extends GitRecap to work with local git repositories, providing a comprehensive set of features for fetching commits, authors, and branch information. The implementation follows the existing patterns in the codebase and integrates seamlessly with the API, CLI, and service layer. + +The new CLI tool provides an LLM-friendly interface that makes it easy for automated tools and AI assistants to interact with git repositories and extract structured commit information. + +--- +Co-authored by [Nova](https://www.compassap.ai/portfolio/nova.html) \ No newline at end of file diff --git a/examples/fetch_github.py b/examples/fetch_github.py index 1c2c7d5..47f5cea 100644 --- a/examples/fetch_github.py +++ b/examples/fetch_github.py @@ -2,7 +2,6 @@ from git_recap.utils import parse_entries_to_txt from datetime import datetime, timedelta from dotenv import load_dotenv -import json import os load_dotenv() diff --git a/examples/fetch_local.py b/examples/fetch_local.py new file mode 100644 index 0000000..4cfde83 --- /dev/null +++ b/examples/fetch_local.py @@ -0,0 +1,129 @@ +""" +Example: Using LocalFetcher with a local git repository + +This example demonstrates how to use the LocalFetcher to fetch commits +and other information from a local git repository on your machine. +""" + +from datetime import datetime, timedelta +import os +from git_recap.providers.local_fetcher import LocalFetcher +from git_recap.utils import parse_entries_to_txt + + +def example_basic_usage(): + """Basic usage of LocalFetcher.""" + # Initialize the fetcher with a path to your local git repository + repo_path = os.getcwd() + print(f"{repo_path=}") + + fetcher = LocalFetcher( + repo_path=repo_path, + # start_date=datetime.now() - timedelta(days=360), + # end_date=datetime.now() + ) + + # Get repository name + print(f"Repository: {fetcher.repos_names}") + + # Fetch commits + commits = fetcher.fetch_commits() + print(f"\nFound {len(commits)} commits:") + for commit in commits[:5]: # Show first 5 commits + print(f" - {commit['timestamp']}: {commit['message'][:50]}...") + + print(parse_entries_to_txt(commits)) + + # # Get all authored messages (commits) + # messages = fetcher.get_authored_messages() + # print(f"\nTotal authored messages: {len(messages)}") + + +def example_with_authors(): + """Filter commits by specific authors.""" + repo_path = "./" + + fetcher = LocalFetcher( + repo_path=repo_path, + authors=["John Doe", "Jane Smith"], # Filter by author names + start_date=datetime.now() - timedelta(days=7), + end_date=datetime.now() + ) + + commits = fetcher.fetch_commits() + print(f"Found {len(commits)} commits by specified authors") + + +def example_get_authors(): + """Get list of all authors in the repository.""" + repo_path = "./" + + fetcher = LocalFetcher(repo_path=repo_path) + + authors = fetcher.get_authors([]) + print(f"\nFound {len(authors)} unique authors:") + for author in authors[:10]: # Show first 10 authors + print(f" - {author['name']} ({author['email']})") + + +def example_get_branches(): + """Get list of all branches in the repository.""" + repo_path = "./" + + fetcher = LocalFetcher(repo_path=repo_path) + + branches = fetcher.get_branches() + print(f"\nFound {len(branches)} branches:") + for branch in branches[:10]: # Show first 10 branches + print(f" - {branch}") + + +def example_get_current_author(): + """Get current git user configuration.""" + repo_path = "./" + + fetcher = LocalFetcher(repo_path=repo_path) + + author = fetcher.get_current_author() + if author: + print(f"\nCurrent git user: {author['name']} ({author['email']})") + else: + print("\nNo git user configured for this repository") + + +def example_date_range(): + """Fetch commits within a specific date range.""" + repo_path = "./" + + # Fetch commits from January 2025 + start_date = datetime(2025, 1, 1) + end_date = datetime(2025, 1, 31) + + fetcher = LocalFetcher( + repo_path=repo_path, + start_date=start_date, + end_date=end_date + ) + + commits = fetcher.fetch_commits() + print(f"\nFound {len(commits)} commits in January 2025") + + +if __name__ == "__main__": + # Note: Replace "./" with an actual path + # to a git repository on your machine + + print("LocalFetcher Examples") + print("=" * 50) + + # Uncomment the examples you want to run: + + example_basic_usage() + # example_with_authors() + # example_get_authors() + # example_get_branches() + # example_get_current_author() + # example_date_range() + + print("\nTo run these examples, uncomment the function calls above") + print("and replace the repository path with your actual path.") \ No newline at end of file diff --git a/git_recap/cli.py b/git_recap/cli.py new file mode 100644 index 0000000..8a0edfd --- /dev/null +++ b/git_recap/cli.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +GitRecap CLI - LLM-Friendly Command Line Interface + +This CLI tool fetches and summarizes git commit history from local repositories. +It's designed to be easily used by LLMs and automated tools with clear, structured output. + +Usage Examples: + # Get commits from current directory (last 7 days) + git-recap . + + # Get commits from multiple repositories + git-recap /path/to/repo1 /path/to/repo2 + + # Filter by author + git-recap . --author "John Doe" + + # Filter by date range + git-recap . --start-date "2025-01-01" --end-date "2025-01-31" + + # Save output to file + git-recap . --output summary.txt + + # Combine filters + git-recap /path/to/repo1 /path/to/repo2 --author "Jane Smith" --start-date "2025-01-01" --output commits.txt +""" + +import argparse +import sys +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional + +from git_recap.providers.local_fetcher import LocalFetcher +from git_recap.utils import parse_entries_to_txt + + +def parse_date(date_string: str) -> datetime: + """ + Parse date string in ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). + + Args: + date_string: Date string to parse + + Returns: + datetime: Parsed datetime object + + Raises: + argparse.ArgumentTypeError: If date format is invalid + """ + try: + # Try parsing with time first + return datetime.fromisoformat(date_string) + except ValueError: + # Try parsing as date only (YYYY-MM-DD) + try: + return datetime.strptime(date_string, "%Y-%m-%d") + except ValueError: + raise argparse.ArgumentTypeError( + f"Invalid date format: '{date_string}'. " + f"Use ISO format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS" + ) + + +def create_parser() -> argparse.ArgumentParser: + """ + Create and configure the argument parser for the CLI. + + Returns: + argparse.ArgumentParser: Configured parser with all arguments + """ + parser = argparse.ArgumentParser( + prog='git-recap', + description=( + 'GitRecap CLI - Fetch and summarize git commits from local repositories.\n\n' + 'This tool aggregates commit history from multiple local git repositories, ' + 'filters by author and date range, and outputs structured text summaries. ' + 'Designed for easy integration with LLMs and automated workflows.' + ), + epilog=( + 'Examples:\n' + ' git-recap . # Current directory, last 7 days\n' + ' git-recap /path/to/repo1 /path/to/repo2 # Multiple repositories\n' + ' git-recap . --author "John Doe" # Filter by author\n' + ' git-recap . --start-date "2025-01-01" # From specific date\n' + ' git-recap . --output summary.txt # Save to file\n' + ' git-recap . --author "Jane" --start-date "2025-01-01" --end-date "2025-01-31" --output commits.txt' + ), + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument( + 'paths', + nargs='+', + help=( + 'One or more paths to local git repositories. ' + 'Each path must be a valid git repository (contains .git directory). ' + 'Can be absolute or relative paths. Multiple paths can be provided.' + ) + ) + + parser.add_argument( + '--author', + type=str, + help=( + 'Filter commits by author name. ' + 'Partial matching is supported (e.g., "John" matches "John Doe"). ' + 'If not specified, commits from all authors are included.' + ) + ) + + parser.add_argument( + '--start-date', + type=parse_date, + help=( + 'Start date for filtering commits (inclusive). ' + 'Format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS. ' + 'If not specified, defaults to 7 days before current date.' + ) + ) + + parser.add_argument( + '--end-date', + type=parse_date, + help=( + 'End date for filtering commits (inclusive). ' + 'Format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS. ' + 'If not specified, defaults to current date and time.' + ) + ) + + parser.add_argument( + '--output', '-o', + type=str, + help=( + 'Output file path to save the summary. ' + 'If not specified, results are printed to stdout. ' + 'The file will be created or overwritten if it exists.' + ) + ) + + return parser + + +def filter_entries_by_author( + entries: List[Dict[str, Any]], + author: str +) -> List[Dict[str, Any]]: + """ + Filter entries by author name (case-insensitive partial match). + + Args: + entries: List of commit entries + author: Author name to filter by + + Returns: + List[Dict[str, Any]]: Filtered entries matching the author + """ + author_lower = author.lower() + return [ + entry for entry in entries + if author_lower in entry.get('author', '').lower() + ] + + +def fetch_from_repos( + repo_paths: List[str], + authors: Optional[List[str]] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> List[Dict[str, Any]]: + """ + Fetch commits from multiple local repositories. + + Args: + repo_paths: List of repository paths + authors: Optional list of author names to filter by + start_date: Optional start date for filtering + end_date: Optional end date for filtering + + Returns: + List[Dict[str, Any]]: Aggregated list of commit entries from all repos + """ + all_entries = [] + + for repo_path in repo_paths: + try: + print(f"Fetching from: {repo_path}", file=sys.stderr) + fetcher = LocalFetcher( + repo_path=repo_path, + authors=authors, + start_date=start_date, + end_date=end_date, + validate_repo=True + ) + entries = fetcher.fetch_commits() + all_entries.extend(entries) + print(f" Found {len(entries)} commits", file=sys.stderr) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + continue + except Exception as e: + print(f"Unexpected error processing {repo_path}: {e}", file=sys.stderr) + continue + + return all_entries + + +def main() -> int: + """ + Main entry point for the CLI. + + Returns: + int: Exit code (0 for success, 1 for error) + """ + parser = create_parser() + args = parser.parse_args() + + # Set default date range if not provided + if not args.start_date and not args.end_date: + # Default: last 7 days + from datetime import timedelta + args.end_date = datetime.now() + args.start_date = args.end_date - timedelta(days=7) + elif not args.start_date: + # Only end date provided, start from 7 days before end + from datetime import timedelta + args.start_date = args.end_date - timedelta(days=7) + elif not args.end_date: + # Only start date provided, use current time as end + args.end_date = datetime.now() + + # Prepare authors list + authors = [args.author] if args.author else None + + # Fetch commits from all repositories + entries = fetch_from_repos( + repo_paths=args.paths, + authors=authors, + start_date=args.start_date, + end_date=args.end_date + ) + + # Check if we found any entries + if not entries: + print("No commits found matching the specified criteria.", file=sys.stderr) + return 0 + + # Convert entries to text format + output_text = parse_entries_to_txt(entries) + + # Output to file or stdout + if args.output: + try: + output_path = Path(args.output) + # Create parent directories if they don't exist + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output_text, encoding='utf-8') + print(f"Summary saved to: {args.output}", file=sys.stderr) + except Exception as e: + print(f"Error writing to file {args.output}: {e}", file=sys.stderr) + return 1 + else: + print(output_text) + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/git_recap/fetcher.py b/git_recap/fetcher.py index a29f8d9..165ad7b 100644 --- a/git_recap/fetcher.py +++ b/git_recap/fetcher.py @@ -3,6 +3,7 @@ from git_recap.providers.github_fetcher import GitHubFetcher from git_recap.providers.azure_fetcher import AzureFetcher from git_recap.providers.gitlab_fetcher import GitLabFetcher +from git_recap.providers.local_fetcher import LocalFetcher def main(): parser = argparse.ArgumentParser( @@ -11,10 +12,14 @@ def main(): parser.add_argument( '--provider', required=True, - choices=['github', 'azure', 'gitlab'], - help='Platform name (github, azure, or gitlab)' + choices=['github', 'azure', 'gitlab', 'local'], + help='Platform name (github, azure, gitlab, or local)' + ) + parser.add_argument('--pat', help='Personal Access Token (not required for local provider)') + parser.add_argument( + '--repo-path', + help='Path to local git repository (required for local provider)' ) - parser.add_argument('--pat', required=True, help='Personal Access Token') parser.add_argument( '--organization-url', help='Organization URL for Azure DevOps' @@ -51,6 +56,9 @@ def main(): fetcher = None if args.provider == 'github': + if not args.pat: + print("PAT is required for GitHub provider") + exit(1) fetcher = GitHubFetcher( pat=args.pat, start_date=args.start_date, @@ -58,6 +66,9 @@ def main(): repo_filter=args.repos ) elif args.provider == 'azure': + if not args.pat: + print("PAT is required for Azure DevOps provider") + exit(1) if not args.organization_url: print("Organization URL is required for Azure DevOps") exit(1) @@ -69,6 +80,9 @@ def main(): repo_filter=args.repos ) elif args.provider == 'gitlab': + if not args.pat: + print("PAT is required for GitLab provider") + exit(1) gitlab_url = args.gitlab_url if args.gitlab_url else 'https://gitlab.com' fetcher = GitLabFetcher( pat=args.pat, @@ -77,6 +91,16 @@ def main(): end_date=args.end_date, repo_filter=args.repos ) + elif args.provider == 'local': + if not args.repo_path: + print("--repo-path is required for local provider") + exit(1) + fetcher = LocalFetcher( + repo_path=args.repo_path, + start_date=args.start_date, + end_date=args.end_date, + repo_filter=args.repos + ) messages = fetcher.get_authored_messages(limit=args.limit) for msg in messages: diff --git a/git_recap/providers/__init__.py b/git_recap/providers/__init__.py index 42fa5ce..991e3fc 100644 --- a/git_recap/providers/__init__.py +++ b/git_recap/providers/__init__.py @@ -2,10 +2,12 @@ from git_recap.providers.github_fetcher import GitHubFetcher from git_recap.providers.gitlab_fetcher import GitLabFetcher from git_recap.providers.url_fetcher import URLFetcher +from git_recap.providers.local_fetcher import LocalFetcher __all__ = [ "AzureFetcher", "GitHubFetcher", "GitLabFetcher", - "URLFetcher" + "URLFetcher", + "LocalFetcher" ] diff --git a/git_recap/providers/local_fetcher.py b/git_recap/providers/local_fetcher.py new file mode 100644 index 0000000..3314bc4 --- /dev/null +++ b/git_recap/providers/local_fetcher.py @@ -0,0 +1,390 @@ +import os +import subprocess +from typing import List, Dict, Any, Optional +from datetime import datetime +from git_recap.providers.base_fetcher import BaseFetcher + + +class LocalFetcher(BaseFetcher): + """ + Fetcher implementation for local Git repositories. + + Works directly on a local git repository path without cloning. + Supports fetching commits, authors, and branch information. + """ + + def __init__( + self, + repo_path: str, + authors: Optional[List[str]] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + repo_filter: Optional[List[str]] = None, + validate_repo: bool = True + ): + """ + Initialize the LocalFetcher. + + Args: + repo_path: Path to the local git repository + authors: List of author names to filter by (optional) + start_date: Start date for filtering commits (optional) + end_date: End date for filtering commits (optional) + repo_filter: List of repository names to filter (optional) + validate_repo: Whether to validate the repository path (default: True) + """ + super().__init__(pat=None, start_date=start_date, end_date=end_date, repo_filter=repo_filter, authors=authors) + self.repo_path = repo_path + if validate_repo: + self._validate_repo() + + def _validate_repo(self) -> None: + """ + Validate that the provided path is a valid git repository. + + Raises: + ValueError: If the path is not a valid git repository. + """ + if not os.path.exists(self.repo_path): + raise ValueError(f"Path does not exist: {self.repo_path}") + + if not os.path.isdir(self.repo_path): + raise ValueError(f"Path is not a directory: {self.repo_path}") + + git_dir = os.path.join(self.repo_path, '.git') + if not os.path.exists(git_dir): + raise ValueError(f"Not a git repository: {self.repo_path}") + + # Verify it's a valid git repo by running a simple command + try: + _result = subprocess.run( + ["git", "-C", self.repo_path, "rev-parse", "--git-dir"], + capture_output=True, + text=True, + check=True, + timeout=10 + ) + except subprocess.TimeoutExpired: + raise ValueError(f"Timeout while validating git repository: {self.repo_path}") + except subprocess.CalledProcessError as e: + raise ValueError(f"Invalid git repository: {self.repo_path}. Error: {e.stderr}") + + @property + def repos_names(self) -> List[str]: + """ + Return the repository name. + + Returns: + List[str]: List containing the repository name (single item). + """ + repo_name = os.path.basename(self.repo_path) + return [repo_name] + + def _run_git_log(self, extra_args: List[str] = None) -> List[Dict[str, Any]]: + """ + Run git log command with common arguments and parse output. + + Args: + extra_args (List[str], optional): Additional git log arguments. + + Returns: + List[Dict[str, Any]]: Parsed commit entries. + """ + args = [ + "git", + "-C", self.repo_path, + "log", + "--pretty=format:%H|%an|%ad|%s", + "--date=iso", + "--all" + ] + + if self.start_date: + args.extend(["--since", self.start_date.isoformat()]) + if self.end_date: + args.extend(["--until", self.end_date.isoformat()]) + if self.authors: + authors_filter = "|".join(self.authors) + args.extend(["--author", authors_filter]) + if extra_args: + args.extend(extra_args) + + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + check=True, + timeout=120 + ) + return self._parse_git_log(result.stdout) + except subprocess.TimeoutExpired: + return [] + except subprocess.CalledProcessError: + return [] + + def _parse_git_log(self, log_output: str) -> List[Dict[str, Any]]: + """ + Parse git log output into structured data. + + Args: + log_output (str): Raw git log output. + + Returns: + List[Dict[str, Any]]: Parsed commit entries. + """ + entries = [] + for line in log_output.splitlines(): + if not line.strip(): + continue + + try: + sha, author, date_str, message = line.split("|", 3) + timestamp = datetime.fromisoformat(date_str) + + if self.start_date and timestamp < self.start_date: + continue + if self.end_date and timestamp > self.end_date: + continue + + entries.append({ + "type": "commit", + "repo": self.repos_names[0], + "message": message, + "sha": sha, + "author": author, + "timestamp": timestamp + }) + except ValueError: + continue + + return entries + + def fetch_commits(self) -> List[Dict[str, Any]]: + """ + Fetch commits from the local repository. + + Returns: + List[Dict[str, Any]]: List of commit entries. + """ + return self._run_git_log() + + def fetch_pull_requests(self) -> List[Dict[str, Any]]: + """ + Fetch pull requests (not applicable for local repositories). + + Returns: + List[Dict[str, Any]]: Empty list (PRs are platform-specific). + """ + return [] + + def fetch_issues(self) -> List[Dict[str, Any]]: + """ + Fetch issues (not applicable for local repositories). + + Returns: + List[Dict[str, Any]]: Empty list (issues are platform-specific). + """ + return [] + + def fetch_releases(self) -> List[Dict[str, Any]]: + """ + Fetch releases for the repository. + Not applicable for local repositories. + + Raises: + NotImplementedError: Always, since release fetching is not supported for LocalFetcher. + """ + raise NotImplementedError( + "Release fetching is not supported for local repositories (LocalFetcher)." + ) + + def get_branches(self) -> List[str]: + """ + Get all branches in the local repository. + + Returns: + List[str]: List of branch names (both local and remote). + """ + try: + # Get local branches + result = subprocess.run( + ["git", "-C", self.repo_path, "branch", "--format=%(refname:short)"], + capture_output=True, + text=True, + check=True + ) + branches = [b.strip() for b in result.stdout.splitlines() if b.strip()] + + # Get remote branches + result_remote = subprocess.run( + ["git", "-C", self.repo_path, "branch", "-r", "--format=%(refname:short)"], + capture_output=True, + text=True, + check=True + ) + remote_branches = [ + b.strip() for b in result_remote.stdout.splitlines() + if b.strip() and not b.endswith('/HEAD') + ] + + return branches + remote_branches + except subprocess.CalledProcessError: + return [] + + def get_valid_target_branches(self, source_branch: str) -> List[str]: + """ + Get branches that can receive a pull request from the source branch. + Not applicable for local repositories. + + Args: + source_branch (str): The source branch name. + + Returns: + List[str]: Empty list (PRs are platform-specific). + + Raises: + NotImplementedError: Always, since PR validation is not supported for LocalFetcher. + """ + raise NotImplementedError( + "Pull request target branch validation is not supported for local repositories (LocalFetcher)." + ) + + def create_pull_request( + self, + head_branch: str, + base_branch: str, + title: str, + body: str, + draft: bool = False, + reviewers: Optional[List[str]] = None, + assignees: Optional[List[str]] = None, + labels: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Create a pull request between two branches. + Not applicable for local repositories. + + Args: + head_branch: Source branch for the PR. + base_branch: Target branch for the PR. + title: PR title. + body: PR description. + draft: Whether to create as draft PR (default: False). + reviewers: List of reviewer usernames (optional). + assignees: List of assignee usernames (optional). + labels: List of label names (optional). + + Returns: + Dict[str, Any]: Empty dict (PRs are platform-specific). + + Raises: + NotImplementedError: Always, since PR creation is not supported for LocalFetcher. + """ + raise NotImplementedError( + "Pull request creation is not supported for local repositories (LocalFetcher)." + ) + + def get_authors(self, repo_names: List[str]) -> List[Dict[str, str]]: + """ + Retrieve unique authors from the local repository using git log. + + Args: + repo_names: Not used for local fetcher (single repo only). + + Returns: + List[Dict[str, str]]: List of unique author dictionaries with name and email. + """ + authors_set = set() + + try: + # Get authors from commit history + cmd = [ + 'git', '-C', self.repo_path, 'log', + '--all', + '--format=%an|%ae' + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True + ) + + for line in result.stdout.strip().split('\n'): + if '|' in line: + name, email = line.split('|', 1) + authors_set.add((name.strip(), email.strip())) + + # Also get committers + cmd_committer = [ + 'git', '-C', self.repo_path, 'log', + '--all', + '--format=%cn|%ce' + ] + + result_committer = subprocess.run( + cmd_committer, + capture_output=True, + text=True, + check=True + ) + + for line in result_committer.stdout.strip().split('\n'): + if '|' in line: + name, email = line.split('|', 1) + authors_set.add((name.strip(), email.strip())) + + authors_list = [ + {"name": name, "email": email} + for name, email in sorted(authors_set) + ] + + return authors_list + + except subprocess.CalledProcessError as e: + print(f"Git command failed: {e}") + return [] + except Exception as e: + print(f"Error in get_authors: {e}") + return [] + + def get_current_author(self) -> Optional[Dict[str, str]]: + """ + Retrieve the current git user's information from local configuration. + + Returns: + Optional[Dict[str, str]]: Dictionary with 'name' and 'email' keys, + or None if not configured. + """ + try: + # Get user name + result_name = subprocess.run( + ["git", "-C", self.repo_path, "config", "user.name"], + capture_output=True, + text=True, + check=True + ) + user_name = result_name.stdout.strip() + + # Get user email + result_email = subprocess.run( + ["git", "-C", self.repo_path, "config", "user.email"], + capture_output=True, + text=True, + check=True + ) + user_email = result_email.stdout.strip() + + if user_name and user_email: + return { + "name": user_name, + "email": user_email + } + return None + except subprocess.CalledProcessError: + return None + except Exception as e: + print(f"Error retrieving current author: {e}") + return None \ No newline at end of file diff --git a/git_recap/providers/url_fetcher.py b/git_recap/providers/url_fetcher.py index 1be42df..d843b2f 100644 --- a/git_recap/providers/url_fetcher.py +++ b/git_recap/providers/url_fetcher.py @@ -2,7 +2,6 @@ import re import shutil import subprocess -from pathlib import Path import tempfile from typing import List, Dict, Any, Optional from datetime import datetime diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6c5e1ed --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "git-recap" +version = "0.1.5" +description = "A modular Python tool that aggregates and formats user-authored messages from repositories." +readme = "README.md" +requires-python = ">=3.7" +license = {text = "MIT"} +authors = [ + {name = "Bruno V.", email = "bruno.vitorino@tecnico.ulisboa.pt"} +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +keywords = ["git", "github", "gitlab", "azure-devops", "version-control", "repository"] +dependencies = [ + "PyGithub==2.6.1", + "azure-devops==7.1.0b4", + "python-gitlab==5.6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=22.0.0", + "flake8>=5.0.0", + "mypy>=0.990", +] + +[project.urls] +Homepage = "https://github.com/BrunoV21/GitRecap" +Documentation = "https://github.com/BrunoV21/GitRecap#readme" +Repository = "https://github.com/BrunoV21/GitRecap.git" +Issues = "https://github.com/BrunoV21/GitRecap/issues" + +[project.scripts] +git-recap = "git_recap.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["git_recap*"] + +[tool.black] +line-length = 100 +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +[tool.mypy] +python_version = "3.7" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index dff29f7..0000000 --- a/setup.py +++ /dev/null @@ -1,25 +0,0 @@ -from setuptools import setup, find_packages - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name="git-recap", - version="0.1.5", - packages=find_packages(), - install_requires=[ - "PyGithub==2.6.1", - "azure-devops==7.1.0b4", - "python-gitlab==5.6.0" - ], - author="Bruno V.", - author_email="bruno.vitorino@tecnico.ulisboa.pt", - description="A modular Python tool that aggregates and formats user-authored messages from repositories.", - long_description=long_description, - long_description_content_type="text/markdown", - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], -) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..6e399f5 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,341 @@ +""" +Unit tests for GitRecap CLI module. + +Tests cover argument parsing, happy path execution, and error handling. +""" + +import unittest +from unittest.mock import patch, MagicMock, mock_open +from datetime import datetime +from io import StringIO +import sys + +from git_recap.cli import ( + parse_date, + create_parser, + filter_entries_by_author, + fetch_from_repos, + main +) + + +class TestParseDate(unittest.TestCase): + """Test the parse_date function.""" + + def test_parse_date_with_time(self): + """Test parsing date with time component.""" + result = parse_date("2025-01-15T14:30:00") + expected = datetime(2025, 1, 15, 14, 30, 0) + self.assertEqual(result, expected) + + def test_parse_date_without_time(self): + """Test parsing date without time component.""" + result = parse_date("2025-01-15") + expected = datetime(2025, 1, 15, 0, 0, 0) + self.assertEqual(result, expected) + + def test_parse_date_invalid_format(self): + """Test parsing invalid date format raises error.""" + from argparse import ArgumentTypeError + with self.assertRaises(ArgumentTypeError): + parse_date("invalid-date") + + +class TestCreateParser(unittest.TestCase): + """Test the argument parser creation.""" + + def setUp(self): + """Set up test parser.""" + self.parser = create_parser() + + def test_parser_has_required_arguments(self): + """Test parser has all required arguments.""" + # Test with minimal arguments + args = self.parser.parse_args(["."]) + self.assertEqual(args.paths, ["."]) + self.assertIsNone(args.author) + self.assertIsNone(args.start_date) + self.assertIsNone(args.end_date) + self.assertIsNone(args.output) + + def test_parser_with_author(self): + """Test parser accepts author argument.""" + args = self.parser.parse_args([".", "--author", "John Doe"]) + self.assertEqual(args.author, "John Doe") + + def test_parser_with_dates(self): + """Test parser accepts date arguments.""" + args = self.parser.parse_args([ + ".", + "--start-date", "2025-01-01", + "--end-date", "2025-01-31" + ]) + self.assertEqual(args.start_date, datetime(2025, 1, 1)) + self.assertEqual(args.end_date, datetime(2025, 1, 31)) + + def test_parser_with_output(self): + """Test parser accepts output argument.""" + args = self.parser.parse_args([".", "--output", "summary.txt"]) + self.assertEqual(args.output, "summary.txt") + + def test_parser_with_multiple_paths(self): + """Test parser accepts multiple repository paths.""" + args = self.parser.parse_args(["/path/to/repo1", "/path/to/repo2", "/path/to/repo3"]) + self.assertEqual(args.paths, ["/path/to/repo1", "/path/to/repo2", "/path/to/repo3"]) + + def test_parser_with_short_output_flag(self): + """Test parser accepts short output flag.""" + args = self.parser.parse_args([".", "-o", "summary.txt"]) + self.assertEqual(args.output, "summary.txt") + + +class TestFilterEntriesByAuthor(unittest.TestCase): + """Test the filter_entries_by_author function.""" + + def setUp(self): + """Set up test entries.""" + self.entries = [ + {"author": "John Doe", "message": "Commit 1"}, + {"author": "Jane Smith", "message": "Commit 2"}, + {"author": "John Doe", "message": "Commit 3"}, + {"author": "Bob Johnson", "message": "Commit 4"}, + ] + + def test_filter_by_full_name(self): + """Test filtering by full author name.""" + result = filter_entries_by_author(self.entries, "John Doe") + self.assertEqual(len(result), 2) + self.assertTrue(all(e["author"] == "John Doe" for e in result)) + + def test_filter_by_partial_name(self): + """Test filtering by partial author name.""" + result = filter_entries_by_author(self.entries, "John") + self.assertEqual(len(result), 3) + self.assertTrue(all("John" in e["author"] for e in result)) + + def test_filter_case_insensitive(self): + """Test filtering is case insensitive.""" + result = filter_entries_by_author(self.entries, "john doe") + self.assertEqual(len(result), 2) + + def test_filter_no_matches(self): + """Test filtering with no matches.""" + result = filter_entries_by_author(self.entries, "Unknown Author") + self.assertEqual(len(result), 0) + + def test_filter_empty_entries(self): + """Test filtering empty entries list.""" + result = filter_entries_by_author([], "John Doe") + self.assertEqual(len(result), 0) + + +class TestFetchFromRepos(unittest.TestCase): + """Test the fetch_from_repos function.""" + + @patch('git_recap.cli.LocalFetcher') + def test_fetch_from_single_repo(self, mock_fetcher_class): + """Test fetching from a single repository.""" + mock_fetcher = MagicMock() + mock_fetcher.fetch_commits.return_value = [ + {"author": "John Doe", "message": "Commit 1"} + ] + mock_fetcher_class.return_value = mock_fetcher + + result = fetch_from_repos(["/path/to/repo"]) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["author"], "John Doe") + mock_fetcher_class.assert_called_once_with( + repo_path="/path/to/repo", + authors=None, + start_date=None, + end_date=None, + validate_repo=True + ) + + @patch('git_recap.cli.LocalFetcher') + def test_fetch_from_multiple_repos(self, mock_fetcher_class): + """Test fetching from multiple repositories.""" + mock_fetcher1 = MagicMock() + mock_fetcher1.fetch_commits.return_value = [ + {"author": "John Doe", "message": "Commit 1"} + ] + mock_fetcher2 = MagicMock() + mock_fetcher2.fetch_commits.return_value = [ + {"author": "Jane Smith", "message": "Commit 2"} + ] + mock_fetcher_class.side_effect = [mock_fetcher1, mock_fetcher2] + + result = fetch_from_repos(["/path/to/repo1", "/path/to/repo2"]) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["author"], "John Doe") + self.assertEqual(result[1]["author"], "Jane Smith") + + @patch('git_recap.cli.LocalFetcher') + def test_fetch_with_authors_filter(self, mock_fetcher_class): + """Test fetching with authors filter.""" + mock_fetcher = MagicMock() + mock_fetcher.fetch_commits.return_value = [] + mock_fetcher_class.return_value = mock_fetcher + + fetch_from_repos(["/path/to/repo"], authors=["John Doe"]) + mock_fetcher_class.assert_called_once_with( + repo_path="/path/to/repo", + authors=["John Doe"], + start_date=None, + end_date=None, + validate_repo=True + ) + + @patch('git_recap.cli.LocalFetcher') + def test_fetch_with_date_filter(self, mock_fetcher_class): + """Test fetching with date filter.""" + mock_fetcher = MagicMock() + mock_fetcher.fetch_commits.return_value = [] + mock_fetcher_class.return_value = mock_fetcher + + start_date = datetime(2025, 1, 1) + end_date = datetime(2025, 1, 31) + fetch_from_repos(["/path/to/repo"], start_date=start_date, end_date=end_date) + mock_fetcher_class.assert_called_once_with( + repo_path="/path/to/repo", + authors=None, + start_date=start_date, + end_date=end_date, + validate_repo=True + ) + + @patch('git_recap.cli.LocalFetcher') + def test_fetch_handles_invalid_repo(self, mock_fetcher_class): + """Test fetching handles invalid repository gracefully.""" + mock_fetcher_class.side_effect = ValueError("Invalid repository") + + with patch('sys.stderr', new_callable=StringIO): + result = fetch_from_repos(["/invalid/repo"]) + self.assertEqual(len(result), 0) + + @patch('git_recap.cli.LocalFetcher') + def test_fetch_handles_exception(self, mock_fetcher_class): + """Test fetching handles unexpected exceptions.""" + mock_fetcher_class.side_effect = Exception("Unexpected error") + + with patch('sys.stderr', new_callable=StringIO): + result = fetch_from_repos(["/path/to/repo"]) + self.assertEqual(len(result), 0) + + +class TestMain(unittest.TestCase): + """Test the main function.""" + + @patch('git_recap.cli.fetch_from_repos') + @patch('git_recap.cli.parse_entries_to_txt') + @patch('sys.stdout', new_callable=StringIO) + def test_main_basic_usage(self, mock_stdout, mock_parse, mock_fetch): + """Test main function with basic usage.""" + mock_fetch.return_value = [ + { + "author": "John Doe", + "message": "Commit 1", + "timestamp": datetime(2025, 1, 15), + "repo": "test-repo", + "type": "commit" + } + ] + mock_parse.return_value = "2025-01-15:\n - [Commit] in test-repo: Commit 1" + + with patch('sys.argv', ['git-recap', '.']): + exit_code = main() + + self.assertEqual(exit_code, 0) + self.assertIn("Commit 1", mock_stdout.getvalue()) + + @patch('git_recap.cli.fetch_from_repos') + @patch('git_recap.cli.parse_entries_to_txt') + @patch('git_recap.cli.Path') + def test_main_with_output_file(self, mock_path, mock_parse, mock_fetch): + """Test main function with output file.""" + mock_fetch.return_value = [ + { + "author": "John Doe", + "message": "Commit 1", + "timestamp": datetime(2025, 1, 15), + "repo": "test-repo", + "type": "commit" + } + ] + mock_parse.return_value = "2025-01-15:\n - [Commit] in test-repo: Commit 1" + mock_output_path = MagicMock() + mock_path.return_value = mock_output_path + + with patch('sys.argv', ['git-recap', '.', '--output', 'summary.txt']): + exit_code = main() + + self.assertEqual(exit_code, 0) + mock_output_path.write_text.assert_called_once() + + @patch('git_recap.cli.fetch_from_repos') + def test_main_no_commits_found(self, mock_fetch): + """Test main function when no commits found.""" + mock_fetch.return_value = [] + + with patch('sys.argv', ['git-recap', '.']): + with patch('sys.stderr', new_callable=StringIO) as mock_stderr: + exit_code = main() + + self.assertEqual(exit_code, 0) + self.assertIn("No commits found", mock_stderr.getvalue()) + + @patch('git_recap.cli.fetch_from_repos') + @patch('git_recap.cli.parse_entries_to_txt') + @patch('git_recap.cli.Path') + def test_main_creates_parent_directories(self, mock_path, mock_parse, mock_fetch): + """Test main function creates parent directories.""" + mock_fetch.return_value = [ + { + "author": "John Doe", + "message": "Commit 1", + "timestamp": datetime(2025, 1, 15), + "repo": "test-repo", + "type": "commit" + } + ] + mock_parse.return_value = "2025-01-15:\n - [Commit] in test-repo: Commit 1" + + mock_output_path = MagicMock() + mock_path.return_value = mock_output_path + + with patch('sys.argv', ['git-recap', '.', '--output', 'subdir/summary.txt']): + exit_code = main() + + self.assertEqual(exit_code, 0) + mock_output_path.parent.mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_output_path.write_text.assert_called_once() + + @patch('git_recap.cli.fetch_from_repos') + @patch('git_recap.cli.parse_entries_to_txt') + @patch('git_recap.cli.Path') + def test_main_file_write_error(self, mock_path, mock_parse, mock_fetch): + """Test main function handles file write errors.""" + mock_fetch.return_value = [ + { + "author": "John Doe", + "message": "Commit 1", + "timestamp": datetime(2025, 1, 15), + "repo": "test-repo", + "type": "commit" + } + ] + mock_parse.return_value = "2025-01-15:\n - [Commit] in test-repo: Commit 1" + mock_output_path = MagicMock() + mock_output_path.write_text.side_effect = IOError("Permission denied") + mock_path.return_value = mock_output_path + + with patch('sys.argv', ['git-recap', '.', '--output', 'summary.txt']): + with patch('sys.stderr', new_callable=StringIO) as mock_stderr: + exit_code = main() + + self.assertEqual(exit_code, 1) + self.assertIn("Error writing to file", mock_stderr.getvalue()) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_local_fetcher.py b/tests/test_local_fetcher.py new file mode 100644 index 0000000..82d8d1c --- /dev/null +++ b/tests/test_local_fetcher.py @@ -0,0 +1,498 @@ +""" +Unit tests for LocalFetcher class. + +Tests the functionality of fetching commits and other information from local git repositories. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import Mock, patch +from git_recap.providers.local_fetcher import LocalFetcher + + +class TestLocalFetcherInitialization: + """Test suite for LocalFetcher initialization.""" + + def test_init_with_required_params(self): + """Test LocalFetcher initialization with required parameters.""" + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + assert fetcher.repo_path == "/path/to/repo" + # BaseFetcher initializes authors to empty list if None + assert fetcher.authors == [] + assert fetcher.start_date is None + assert fetcher.end_date is None + assert fetcher.repo_filter == [] + + def test_init_with_all_params(self): + """Test LocalFetcher initialization with all parameters.""" + start_date = datetime(2025, 1, 1, tzinfo=timezone.utc) + end_date = datetime(2025, 1, 31, tzinfo=timezone.utc) + + fetcher = LocalFetcher( + repo_path="/path/to/repo", + authors=["Alice", "Bob"], + start_date=start_date, + end_date=end_date, + repo_filter=["repo1", "repo2"], + validate_repo=False + ) + + assert fetcher.repo_path == "/path/to/repo" + assert fetcher.authors == ["Alice", "Bob"] + assert fetcher.start_date == start_date + assert fetcher.end_date == end_date + assert fetcher.repo_filter == ["repo1", "repo2"] + + @patch('subprocess.run') + def test_repos_names_property(self, mock_run): + """Test that repos_names property returns repository name from git config.""" + # Mock subprocess response for git config remote.origin.url + mock_result = Mock() + mock_result.stdout = "https://github.com/user/repo.git\n" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + repo_names = fetcher.repos_names + + assert isinstance(repo_names, list) + assert len(repo_names) == 1 + assert "repo" in repo_names[0] + + +class TestLocalFetcherFetchCommits: + """Test suite for fetch_commits method.""" + + @patch('subprocess.run') + def test_fetch_commits_basic(self, mock_run): + """Test basic commit fetching without filters.""" + # Mock git log response in pipe-delimited format + mock_result = Mock() + mock_result.stdout = """abc123|Alice|2025-01-15T10:00:00+00:00|Initial commit +def456|Bob|2025-01-16T11:00:00+00:00|Add new feature +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + commits = fetcher.fetch_commits() + + assert isinstance(commits, list) + assert len(commits) == 2 + assert commits[0]["sha"] == "abc123" + assert commits[1]["sha"] == "def456" + + @patch('subprocess.run') + def test_fetch_commits_with_date_filter(self, mock_run): + """Test commit fetching with date range filter.""" + start_date = datetime(2025, 1, 1, tzinfo=timezone.utc) + end_date = datetime(2025, 1, 31, tzinfo=timezone.utc) + + mock_result = Mock() + mock_result.stdout = """abc123|Alice|2025-01-15T10:00:00+00:00|Commit within range +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher( + repo_path="/path/to/repo", + start_date=start_date, + end_date=end_date, + validate_repo=False + ) + commits = fetcher.fetch_commits() + + assert len(commits) >= 0 + # Verify subprocess was called with date filters + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert "--since" in call_args + assert "--until" in call_args + + @patch('subprocess.run') + def test_fetch_commits_with_author_filter(self, mock_run): + """Test commit fetching with author filter.""" + mock_result = Mock() + mock_result.stdout = """abc123|Alice|2025-01-15T10:00:00+00:00|Alice's commit +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher( + repo_path="/path/to/repo", + authors=["Alice"], + validate_repo=False + ) + commits = fetcher.fetch_commits() + + assert isinstance(commits, list) + # Verify subprocess was called with author filter + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert "--author" in call_args + + @patch('subprocess.run') + def test_fetch_commits_handles_git_error(self, mock_run): + """Test that fetch_commits handles git errors gracefully.""" + import subprocess as sp + mock_run.side_effect = sp.CalledProcessError(1, "git") + + fetcher = LocalFetcher(repo_path="/invalid/path", validate_repo=False) + commits = fetcher.fetch_commits() + + # Should return empty list on error + assert commits == [] + + @patch('subprocess.run') + def test_fetch_commits_parse_commit_details(self, mock_run): + """Test that commit details are correctly parsed.""" + mock_result = Mock() + mock_result.stdout = """abc123def456|Alice Smith|2025-01-15T10:30:45+00:00|feat: Add new authentication module +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + commits = fetcher.fetch_commits() + + assert len(commits) == 1 + commit = commits[0] + assert commit["sha"] == "abc123def456" + assert commit["author"] == "Alice Smith" + assert "feat: Add new authentication module" in commit["message"] + assert isinstance(commit["timestamp"], datetime) + + +class TestLocalFetcherFetchPullRequests: + """Test suite for fetch_pull_requests method.""" + + @patch('subprocess.run') + def test_fetch_pull_requests_basic(self, mock_run): + """Test basic pull request fetching.""" + # LocalFetcher returns empty list for PRs + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + prs = fetcher.fetch_pull_requests() + + assert isinstance(prs, list) + assert len(prs) == 0 + + @patch('subprocess.run') + def test_fetch_pull_requests_with_date_filter(self, mock_run): + """Test pull request fetching with date filter.""" + start_date = datetime(2025, 1, 1, tzinfo=timezone.utc) + end_date = datetime(2025, 1, 31, tzinfo=timezone.utc) + + # LocalFetcher returns empty list for PRs regardless of filters + fetcher = LocalFetcher( + repo_path="/path/to/repo", + start_date=start_date, + end_date=end_date, + validate_repo=False + ) + prs = fetcher.fetch_pull_requests() + + assert isinstance(prs, list) + assert len(prs) == 0 + + @patch('subprocess.run') + def test_fetch_pull_requests_handles_no_pr_branches(self, mock_run): + """Test handling when no pull request branches exist.""" + mock_result = Mock() + mock_result.stdout = "" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + prs = fetcher.fetch_pull_requests() + + assert prs == [] + + +class TestLocalFetcherGetAuthors: + """Test suite for get_authors method.""" + + @patch('subprocess.run') + def test_get_authors_basic(self, mock_run): + """Test fetching all authors from repository.""" + mock_result = Mock() + mock_result.stdout = """Alice Smith|alice@example.com +Bob Johnson|bob@example.com +Charlie Brown|charlie@example.com +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + authors = fetcher.get_authors([]) + + assert isinstance(authors, list) + assert len(authors) == 3 + assert authors[0]["name"] == "Alice Smith" + assert authors[0]["email"] == "alice@example.com" + + @patch('subprocess.run') + def test_get_authors_deduplication(self, mock_run): + """Test that duplicate authors are properly deduplicated.""" + mock_result = Mock() + mock_result.stdout = """Alice Smith|alice@example.com +Bob Johnson|bob@example.com +Alice Smith|alice@example.com +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + authors = fetcher.get_authors([]) + + # Should deduplicate based on email + assert len(authors) == 2 + emails = [author["email"] for author in authors] + assert emails.count("alice@example.com") == 1 + + +class TestLocalFetcherGetBranches: + """Test suite for get_branches method.""" + + @patch('subprocess.run') + def test_get_branches_basic(self, mock_run): + """Test fetching all branches.""" + # Mock local branches response + mock_result_local = Mock() + mock_result_local.stdout = """main +develop +feature/new-ui +hotfix/critical-bug +""" + mock_result_local.returncode = 0 + + # Mock remote branches response (empty for this test) + mock_result_remote = Mock() + mock_result_remote.stdout = """""" + mock_result_remote.returncode = 0 + + mock_run.side_effect = [mock_result_local, mock_result_remote] + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + branches = fetcher.get_branches() + + assert isinstance(branches, list) + assert len(branches) == 4 + assert "main" in branches + assert "develop" in branches + assert "feature/new-ui" in branches + assert "hotfix/critical-bug" in branches + + @patch('subprocess.run') + def test_get_branches_empty(self, mock_run): + """Test handling when no branches exist.""" + mock_result = Mock() + mock_result.stdout = "" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + branches = fetcher.get_branches() + + assert branches == [] + + +class TestLocalFetcherGetCurrentAuthor: + """Test suite for get_current_author method.""" + + @patch('subprocess.run') + def test_get_current_author_success(self, mock_run): + """Test fetching current git user configuration.""" + mock_result = Mock() + mock_result.stdout = "Alice Smith" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + author = fetcher.get_current_author() + + assert author is not None + assert author["name"] == "Alice Smith" + assert "email" in author + + @patch('subprocess.run') + def test_get_current_author_not_configured(self, mock_run): + """Test handling when git user is not configured.""" + mock_result = Mock() + mock_result.stdout = "" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + author = fetcher.get_current_author() + + assert author is None + + +class TestLocalFetcherGetAuthoredMessages: + """Test suite for get_authored_messages method.""" + + @patch('subprocess.run') + def test_get_authored_messages_basic(self, mock_run): + """Test basic authored messages fetching.""" + # Mock git log response in pipe-delimited format + mock_result = Mock() + mock_result.stdout = """abc123|Alice|2025-01-15T10:00:00+00:00|First commit +def456|Alice|2025-01-16T11:00:00+00:00|Second commit +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + messages = fetcher.get_authored_messages() + + assert isinstance(messages, list) + assert len(messages) == 2 + assert messages[0]["type"] == "commit" + assert messages[1]["type"] == "commit" + + @patch('subprocess.run') + def test_get_authored_messages_with_limit(self, mock_run): + """Test authored messages fetching with limit.""" + mock_result = Mock() + mock_result.stdout = """abc123|Alice|2025-01-15T10:00:00+00:00|Commit 1 +def456|Alice|2025-01-16T11:00:00+00:00|Commit 2 +ghi789|Alice|2025-01-17T12:00:00+00:00|Commit 3 +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + messages = fetcher.get_authored_messages() + + # get_authored_messages doesn't take a limit parameter + # It returns all messages + assert len(messages) == 3 + + @patch('subprocess.run') + def test_get_authored_messages_sorting(self, mock_run): + """Test that authored messages are sorted chronologically.""" + mock_result = Mock() + mock_result.stdout = """abc123|Alice|2025-01-16T11:00:00+00:00|Later commit +def456|Alice|2025-01-15T10:00:00+00:00|Earlier commit +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + messages = fetcher.get_authored_messages() + + # Verify chronological order + assert len(messages) == 2 + # Messages are sorted by timestamp in get_authored_messages + assert messages[0]["timestamp"] < messages[1]["timestamp"] + + +class TestLocalFetcherEdgeCases: + """Test suite for edge cases and error handling.""" + + @patch('subprocess.run') + def test_invalid_repository_path(self, mock_run): + """Test handling of invalid repository path.""" + import subprocess as sp + mock_run.side_effect = sp.CalledProcessError(1, "git") + + fetcher = LocalFetcher(repo_path="/invalid/path", validate_repo=False) + commits = fetcher.fetch_commits() + + # Should return empty list + assert commits == [] + + @patch('subprocess.run') + def test_empty_repository(self, mock_run): + """Test handling of empty repository (no commits).""" + mock_result = Mock() + mock_result.stdout = "" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/empty/repo", validate_repo=False) + commits = fetcher.fetch_commits() + + assert commits == [] + + @patch('subprocess.run') + def test_malformed_git_output(self, mock_run): + """Test handling of malformed git output.""" + mock_result = Mock() + mock_result.stdout = """This is not valid git log output +Just random text +""" + mock_result.returncode = 0 + mock_run.return_value = mock_result + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + commits = fetcher.fetch_commits() + + # Should handle gracefully + assert isinstance(commits, list) + + @patch('subprocess.run') + def test_subprocess_timeout(self, mock_run): + """Test handling of subprocess timeout.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired( + cmd=["git", "log"], + timeout=30 + ) + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + commits = fetcher.fetch_commits() + + # Should handle timeout gracefully + assert isinstance(commits, list) + + +class TestLocalFetcherIntegration: + """Integration tests for LocalFetcher.""" + + @patch('subprocess.run') + def test_full_workflow(self, mock_run): + """Test complete workflow: init, fetch commits, get authors.""" + # Mock responses + mock_result_commits = Mock() + mock_result_commits.stdout = """abc123|Alice|2025-01-15T10:00:00+00:00|Test commit +""" + mock_result_commits.returncode = 0 + + # Mock get_authors responses (authors + committers) + mock_result_authors = Mock() + mock_result_authors.stdout = """Alice|alice@example.com +""" + mock_result_authors.returncode = 0 + + mock_result_committers = Mock() + mock_result_committers.stdout = """Alice|alice@example.com +""" + mock_result_committers.returncode = 0 + + # get_authored_messages calls fetch_commits again, so we need another response + mock_run.side_effect = [ + mock_result_commits, # First fetch_commits + mock_result_authors, # get_authors + mock_result_committers, # get_authors (committers) + mock_result_commits # Second fetch_commits (from get_authored_messages) + ] + + fetcher = LocalFetcher(repo_path="/path/to/repo", validate_repo=False) + + # Fetch commits + commits = fetcher.fetch_commits() + assert len(commits) == 1 + + # Get authors + authors = fetcher.get_authors([]) + assert len(authors) == 1 + + # Get authored messages + messages = fetcher.get_authored_messages() + assert len(messages) == 1 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file