| #!/usr/bin/env python3 |
| # Copyright (c) Meta Platforms, Inc. and affiliates. |
| # |
| # This source code is licensed under the MIT license found in the |
| # LICENSE file in the root directory of this source tree. |
| |
| |
| """ |
| GitHub Utilities |
| |
| Provides a GitHub API client, bot filtering, contributor analysis, and |
| bug-between-tags helpers used by the release-notes generator and other scripts. |
| |
| Consolidated from: |
| - src/utils/github_client.py |
| - src/utils/bot_filter.py |
| - src/scripts/bugs_between_tags.py |
| - src/scripts/list_contributors.py |
| """ |
| |
| from __future__ import annotations |
| |
| import argparse |
| import json |
| import os |
| import sys |
| import time |
| import urllib.error |
| import urllib.request |
| from datetime import datetime |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional |
| |
| |
| # --------------------------------------------------------------------------- |
| # Bot filtering |
| # --------------------------------------------------------------------------- |
| |
| |
| def is_bot(username: str, user_data: Optional[Dict[str, Any]] = None) -> bool: |
| """ |
| Determine if a user is a bot |
| |
| Args: |
| username: GitHub username |
| user_data: Optional user data from GitHub API |
| |
| Returns: |
| True if the user is a bot, False otherwise |
| """ |
| if not username: |
| return False |
| |
| # Check if username ends with [bot] |
| if username.endswith("[bot]"): |
| return True |
| |
| # Check user type if available |
| if user_data and user_data.get("type") == "Bot": |
| return True |
| |
| # Common bot patterns |
| bot_patterns = [ |
| "dependabot", |
| "renovate", |
| "greenkeeper", |
| "snyk-bot", |
| "codecov", |
| "coveralls", |
| "github-actions", |
| "netlify", |
| "vercel", |
| "imgbot", |
| "restyled-io", |
| "stale", |
| "allcontributors", |
| ] |
| |
| username_lower = username.lower() |
| return any(pattern in username_lower for pattern in bot_patterns) |
| |
| |
| # --------------------------------------------------------------------------- |
| # Environment / .env loading |
| # --------------------------------------------------------------------------- |
| |
| |
| def load_env_file(env_path: Optional[str] = None) -> None: |
| """ |
| Load environment variables from a .env file |
| |
| Args: |
| env_path: Path to .env file. If None, looks for .env in current directory and parent directories |
| """ |
| if env_path: |
| env_file = Path(env_path) |
| else: |
| # Look for .env file in current directory and walk up to find it |
| current = Path.cwd() |
| env_file = None |
| |
| # Check current directory and up to 3 parent directories |
| for _ in range(4): |
| candidate = current / ".env" |
| if candidate.exists(): |
| env_file = candidate |
| break |
| current = current.parent |
| |
| if env_file and env_file.exists(): |
| with open(env_file, "r") as f: |
| for line in f: |
| line = line.strip() |
| # Skip empty lines and comments |
| if not line or line.startswith("#"): |
| continue |
| |
| # Parse KEY=VALUE |
| if "=" in line: |
| key, value = line.split("=", 1) |
| key = key.strip() |
| value = value.strip() |
| |
| # Remove quotes if present |
| if value.startswith('"') and value.endswith('"'): |
| value = value[1:-1] |
| elif value.startswith("'") and value.endswith("'"): |
| value = value[1:-1] |
| |
| # Only set if not already in environment |
| if key and not os.getenv(key): |
| os.environ[key] = value |
| |
| |
| # --------------------------------------------------------------------------- |
| # GitHub API client |
| # --------------------------------------------------------------------------- |
| |
| |
| class GitHubClient: |
| """GitHub API client wrapper using standard library (urllib)""" |
| |
| def __init__(self, token: Optional[str] = None): |
| """ |
| Initialize GitHub client |
| |
| Args: |
| token: GitHub personal access token. If not provided, will use GITHUB_TOKEN env var |
| |
| Raises: |
| ValueError: If no token is provided and GITHUB_TOKEN env var is not set |
| """ |
| # Try to load .env file if token not provided and env var not set |
| if not token and not os.getenv("GITHUB_TOKEN"): |
| load_env_file() |
| |
| auth_token = token or os.getenv("GITHUB_TOKEN") |
| |
| if not auth_token: |
| raise ValueError( |
| "GitHub token is required. Set GITHUB_TOKEN environment variable or pass token to constructor." |
| ) |
| |
| self.token = auth_token |
| self.base_url = "https://api.github.com" |
| |
| def _make_request(self, url: str) -> Any: |
| """ |
| Make a request to the GitHub API |
| |
| Args: |
| url: API endpoint URL |
| |
| Returns: |
| Parsed JSON response |
| """ |
| headers = { |
| "Authorization": f"token {self.token}", |
| "Accept": "application/vnd.github.v3+json", |
| "User-Agent": "OSS-Health-Checker-Python", |
| } |
| |
| req = urllib.request.Request(url, headers=headers) |
| |
| try: |
| with urllib.request.urlopen(req) as response: |
| data = response.read() |
| return json.loads(data.decode("utf-8")) |
| except urllib.error.HTTPError as e: |
| error_body = e.read().decode("utf-8") |
| raise Exception(f"GitHub API error: {e.code} - {error_body}") |
| except urllib.error.URLError as e: |
| raise Exception(f"Network error: {e.reason}") |
| |
| def get_all_commits( |
| self, |
| owner: str, |
| repo: str, |
| since: Optional[str] = None, |
| until: Optional[str] = None, |
| branch: Optional[str] = None, |
| max_commits: int = 10000, |
| per_page: int = 100, |
| ) -> List[Dict[str, Any]]: |
| """ |
| Get all repository commits with pagination support |
| |
| Args: |
| owner: Repository owner |
| repo: Repository name |
| since: Only commits after this date (ISO 8601 format) |
| until: Only commits before this date (ISO 8601 format) |
| branch: Only commits from this branch (branch name or SHA) |
| max_commits: Maximum number of commits to fetch |
| per_page: Number of results per page |
| |
| Returns: |
| List of commit dictionaries |
| """ |
| all_commits = [] |
| page = 1 |
| |
| while len(all_commits) < max_commits: |
| url = f"{self.base_url}/repos/{owner}/{repo}/commits?per_page={per_page}&page={page}" |
| if since: |
| url += f"&since={since}" |
| if until: |
| url += f"&until={until}" |
| if branch: |
| url += f"&sha={branch}" |
| |
| try: |
| commits = self._make_request(url) |
| if not commits: |
| break |
| |
| all_commits.extend(commits) |
| |
| if len(commits) < per_page: |
| break |
| |
| page += 1 |
| except Exception as e: |
| print(f"Warning: Error fetching page {page}: {e}") |
| break |
| |
| return all_commits[:max_commits] |
| |
| def close(self): |
| """Close the GitHub client connection (no-op for urllib)""" |
| pass |
| |
| def __enter__(self): |
| """Context manager entry""" |
| return self |
| |
| def __exit__(self, exc_type, exc_val, exc_tb): |
| """Context manager exit""" |
| self.close() |
| |
| |
| # --------------------------------------------------------------------------- |
| # Contributor analysis |
| # --------------------------------------------------------------------------- |
| |
| |
| class ContributorAnalyzer: |
| """Analyzes and lists repository contributors""" |
| |
| def __init__(self, client: GitHubClient, owner: str, repo: str): |
| """ |
| Initialize analyzer |
| |
| Args: |
| client: GitHubClient instance |
| owner: Repository owner |
| repo: Repository name |
| """ |
| self.client = client |
| self.owner = owner |
| self.repo = repo |
| |
| def get_contributors_by_date_range( |
| self, |
| since: Optional[str] = None, |
| until: Optional[str] = None, |
| branch: Optional[str] = None, |
| ) -> Dict[str, Dict]: |
| """ |
| Get contributors within a date range |
| |
| Args: |
| since: Start date (ISO format) |
| until: End date (ISO format) |
| branch: Only include commits from this branch (e.g., 'main') |
| |
| Returns: |
| Dictionary mapping contributor ID to their info (name/username, commit count, type) |
| """ |
| print("\nš Fetching commits", end="") |
| if since: |
| print(f" since {since}", end="") |
| if until: |
| print(f" until {until}", end="") |
| if branch: |
| print(f" on branch '{branch}'", end="") |
| print("...") |
| |
| commits = self.client.get_all_commits( |
| self.owner, |
| self.repo, |
| since=since, |
| until=until, |
| branch=branch, |
| max_commits=100000, |
| ) |
| |
| print(f"ā Found {len(commits)} commits") |
| if branch: |
| print(f"ā¹ļø Note: Filtered to '{branch}' branch only") |
| else: |
| print("ā¹ļø Note: Date filtering includes commits on all branches") |
| |
| return self._extract_contributors(commits) |
| |
| def get_contributors_by_tags(self, from_tag: str, to_tag: str) -> Dict[str, Dict]: |
| """ |
| Get contributors between two release tags |
| |
| Args: |
| from_tag: Starting tag |
| to_tag: Ending tag |
| |
| Returns: |
| Dictionary mapping contributor ID to their info (name/username, commit count, type) |
| """ |
| print(f"\nš Fetching commits between tags '{from_tag}' and '{to_tag}'...") |
| |
| url = f"{self.client.base_url}/repos/{self.owner}/{self.repo}/compare/{from_tag}...{to_tag}" |
| |
| try: |
| comparison = self.client._make_request(url) |
| commits = comparison.get("commits", []) |
| |
| print(f"ā Found {len(commits)} commits between tags") |
| print( |
| "ā¹ļø Note: Tag comparison shows commits in the direct lineage between releases" |
| ) |
| |
| return self._extract_contributors(commits) |
| |
| except Exception as e: |
| print(f"ā Error comparing tags: {e}", file=sys.stderr) |
| print(" Make sure both tags exist in the repository", file=sys.stderr) |
| sys.exit(1) |
| |
| def _extract_contributors(self, commits: List[Dict]) -> Dict[str, Dict]: |
| """ |
| Extract unique contributors from commits |
| |
| Args: |
| commits: List of commit dictionaries |
| |
| Returns: |
| Dictionary mapping contributor ID to their info (bots excluded) |
| """ |
| contributors = {} |
| |
| for commit in commits: |
| contributor_id = None |
| contributor_name = None |
| contributor_type = None |
| |
| # Try to get GitHub user first |
| if "author" in commit and commit["author"]: |
| author_login = commit["author"].get("login") |
| if author_login: |
| if is_bot(author_login, commit["author"]): |
| continue |
| contributor_id = f"user:{author_login}" |
| contributor_name = author_login |
| contributor_type = "github_user" |
| |
| # Fallback to commit author name if no GitHub user |
| if not contributor_id and "commit" in commit: |
| author_info = commit["commit"].get("author", {}) |
| author_name = author_info.get("name") |
| author_email = author_info.get("email") |
| |
| if author_name: |
| if is_bot(author_name): |
| continue |
| contributor_id = f"name:{author_name}" |
| contributor_name = author_name |
| contributor_type = "name_based" |
| |
| if author_email: |
| contributor_name = f"{author_name} <{author_email}>" |
| |
| # Add or update contributor |
| if contributor_id: |
| if contributor_id not in contributors: |
| contributors[contributor_id] = { |
| "name": contributor_name, |
| "type": contributor_type, |
| "commit_count": 0, |
| } |
| # pyrefly: ignore [unsupported-operation] |
| contributors[contributor_id]["commit_count"] += 1 |
| |
| return contributors |
| |
| |
| def display_contributors( |
| contributors: Dict[str, Dict], |
| sort_by: str = "commits", |
| show_count: bool = True, |
| show_type: bool = False, |
| ): |
| """ |
| Display contributors list |
| |
| Args: |
| contributors: Dictionary of contributors |
| sort_by: Sort order ('commits', 'name') |
| show_count: Whether to show commit counts |
| show_type: Whether to show contributor type (GitHub user vs name-based) |
| """ |
| print("\n" + "=" * 80) |
| print(" CONTRIBUTORS") |
| print("=" * 80) |
| |
| # Separate GitHub users from name-based contributors |
| github_users = {k: v for k, v in contributors.items() if v["type"] == "github_user"} |
| name_based = {k: v for k, v in contributors.items() if v["type"] == "name_based"} |
| |
| print(f"\n Total unique contributors: {len(contributors)}") |
| print(f" - GitHub users: {len(github_users)}") |
| print(f" - Name-based: {len(name_based)}") |
| |
| # Display GitHub users |
| if github_users: |
| print("\n" + "-" * 80) |
| print(f" GitHub Users ({len(github_users)})") |
| print("-" * 80) |
| |
| github_sorted = sorted( |
| github_users.items(), |
| key=lambda x: (-x[1]["commit_count"], x[1]["name"].lower()) |
| if sort_by == "commits" |
| else (x[1]["name"].lower(), -x[1]["commit_count"]), |
| ) |
| |
| for i, (_contributor_id, info) in enumerate(github_sorted, 1): |
| output = f" {i:4d}. {info['name']}" |
| if show_count: |
| output += f" ({info['commit_count']} commits)" |
| print(output) |
| |
| # Display name-based contributors |
| if name_based: |
| print("\n" + "-" * 80) |
| print(f" Name-based Contributors ({len(name_based)})") |
| print("-" * 80) |
| |
| name_sorted = sorted( |
| name_based.items(), |
| key=lambda x: (-x[1]["commit_count"], x[1]["name"].lower()) |
| if sort_by == "commits" |
| else (x[1]["name"].lower(), -x[1]["commit_count"]), |
| ) |
| |
| for i, (_contributor_id, info) in enumerate(name_sorted, 1): |
| output = f" {i:4d}. {info['name']}" |
| if show_count: |
| output += f" ({info['commit_count']} commits)" |
| print(output) |
| |
| # Print comma-separated list with @ handles for GitHub users |
| if github_users: |
| print("\n" + "-" * 80) |
| print(" GitHub Handles (copy-paste ready)") |
| print("-" * 80) |
| github_sorted = sorted( |
| github_users.items(), |
| key=lambda x: (-x[1]["commit_count"], x[1]["name"].lower()) |
| if sort_by == "commits" |
| else (x[1]["name"].lower(), -x[1]["commit_count"]), |
| ) |
| handles = [f"@{info['name']}" for _, info in github_sorted] |
| print(f"\n {', '.join(handles)}") |
| |
| # Summary statistics |
| print("\n" + "=" * 80) |
| print(" SUMMARY") |
| print("=" * 80) |
| |
| total_commits = sum(c["commit_count"] for c in contributors.values()) |
| avg_commits = total_commits / len(contributors) if contributors else 0 |
| max_commits = max((c["commit_count"] for c in contributors.values()), default=0) |
| min_commits = min((c["commit_count"] for c in contributors.values()), default=0) |
| |
| print(f"\n Total contributors: {len(contributors)}") |
| print(f" Total commits: {total_commits:,}") |
| print(f" Average commits: {avg_commits:.1f} per contributor") |
| if contributors: |
| top = max(contributors.values(), key=lambda x: x["commit_count"]) |
| print(f" Max commits: {max_commits} (by {top['name']})") |
| else: |
| print(f" Max commits: {max_commits}") |
| print(f" Min commits: {min_commits}") |
| |
| |
| # --------------------------------------------------------------------------- |
| # Bugs between tags |
| # --------------------------------------------------------------------------- |
| |
| |
| def get_tag_date(client: GitHubClient, owner: str, repo: str, tag: str) -> datetime: |
| """ |
| Get the date of a git tag |
| |
| Args: |
| client: GitHubClient instance |
| owner: Repository owner |
| repo: Repository name |
| tag: Tag name (e.g., 'v1.0.0') |
| |
| Returns: |
| datetime of the tag's commit |
| """ |
| url = f"{client.base_url}/repos/{owner}/{repo}/git/refs/tags/{tag}" |
| try: |
| ref = client._make_request(url) |
| except Exception as e: |
| raise ValueError(f"Tag '{tag}' not found: {e}") |
| |
| obj_type = ref["object"]["type"] |
| obj_sha = ref["object"]["sha"] |
| |
| if obj_type == "tag": |
| # Annotated tag - need to get the tag object first |
| tag_url = f"{client.base_url}/repos/{owner}/{repo}/git/tags/{obj_sha}" |
| tag_obj = client._make_request(tag_url) |
| commit_sha = tag_obj["object"]["sha"] |
| else: |
| # Lightweight tag - points directly to commit |
| commit_sha = obj_sha |
| |
| commit_url = f"{client.base_url}/repos/{owner}/{repo}/git/commits/{commit_sha}" |
| commit = client._make_request(commit_url) |
| date_str = commit["committer"]["date"] |
| |
| return datetime.fromisoformat(date_str.replace("Z", "+00:00")) |
| |
| |
| def fetch_bug_issues( |
| client: GitHubClient, |
| owner: str, |
| repo: str, |
| start_date: datetime, |
| end_date: datetime, |
| issue_type: str = "Bug", |
| ) -> List[Dict[str, Any]]: |
| """ |
| Fetch issues with a given issue type that were closed as completed between two dates. |
| |
| Args: |
| client: GitHubClient instance |
| owner: Repository owner |
| repo: Repository name |
| start_date: Start date (from-tag date) |
| end_date: End date (to-tag date) |
| issue_type: Issue type name to filter by (e.g., "Bug") |
| |
| Returns: |
| List of matching issue dictionaries |
| """ |
| matching_issues: List[Dict[str, Any]] = [] |
| page = 1 |
| |
| while True: |
| url = ( |
| f"{client.base_url}/repos/{owner}/{repo}/issues" |
| f"?state=closed&per_page=100&page={page}" |
| f"&sort=updated&direction=desc" |
| f"&since={start_date.isoformat()}" |
| ) |
| |
| # Retry on transient server errors (e.g. 503) |
| max_retries = 3 |
| batch = None |
| for attempt in range(max_retries): |
| try: |
| batch = client._make_request(url) |
| break |
| except Exception as e: |
| if "503" in str(e) and attempt < max_retries - 1: |
| wait = 2**attempt |
| print(f" Received 503, retrying in {wait}s...") |
| time.sleep(wait) |
| else: |
| raise |
| |
| if not batch: |
| break |
| |
| for issue in batch: |
| # Skip pull requests (they appear in the issues endpoint) |
| if "pull_request" in issue: |
| continue |
| |
| # Filter by issue type (not label) |
| issue_type_obj = issue.get("type") |
| if not issue_type_obj or issue_type_obj.get("name") != issue_type: |
| continue |
| |
| # Only include issues closed as completed |
| if issue.get("state_reason") != "completed": |
| continue |
| |
| closed_at_str = issue.get("closed_at") |
| if not closed_at_str: |
| continue |
| |
| closed_at = datetime.fromisoformat(closed_at_str.replace("Z", "+00:00")) |
| |
| if start_date <= closed_at <= end_date: |
| matching_issues.append(issue) |
| |
| if len(batch) < 100: |
| break |
| |
| page += 1 |
| |
| return matching_issues |
| |
| |
| def display_bug_results( |
| issues: List[Dict[str, Any]], |
| owner: str, |
| repo: str, |
| from_tag: str, |
| to_tag: str, |
| start_date: datetime, |
| end_date: datetime, |
| issue_type: str, |
| ): |
| """ |
| Display the list of bug issues |
| |
| Args: |
| issues: List of issue dictionaries |
| owner: Repository owner |
| repo: Repository name |
| from_tag: Starting tag name |
| to_tag: Ending tag name |
| start_date: Start date |
| end_date: End date |
| issue_type: Issue type that was filtered on |
| """ |
| print("\n" + "=" * 80) |
| print(" BUG ISSUES CLOSED AS COMPLETED BETWEEN TAGS") |
| print("=" * 80) |
| print(f"\n Repository: {owner}/{repo}") |
| print(f" Tags: {from_tag} -> {to_tag}") |
| print(f" Date Range: {start_date.date()} to {end_date.date()}") |
| print(f" Issue Type: {issue_type}") |
| print(f" Total Found: {len(issues)}") |
| |
| if not issues: |
| print("\n No matching issues found.") |
| return |
| |
| print("\n" + "-" * 80) |
| for i, issue in enumerate(issues, 1): |
| closed_at = datetime.fromisoformat(issue["closed_at"].replace("Z", "+00:00")) |
| labels = ", ".join(lbl["name"] for lbl in issue.get("labels", [])) |
| print(f"\n {i}. #{issue['number']}: {issue['title']}") |
| print(f" Closed: {closed_at.date()}") |
| print(f" Labels: {labels}") |
| print(f" URL: {issue['html_url']}") |
| |
| print("\n" + "=" * 80) |
| |
| |
| # --------------------------------------------------------------------------- |
| # CLI entry points |
| # --------------------------------------------------------------------------- |
| |
| |
| def bugs_between_tags_main(): |
| """CLI entry point for bugs-between-tags""" |
| parser = argparse.ArgumentParser( |
| description="Fetch bug issues closed as completed between two release tags", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=""" |
| Examples: |
| # Fetch bugs closed between two tags |
| python3 github_utils.py bugs facebook/react --from-tag v18.0.0 --to-tag v18.2.0 |
| |
| # Use a custom issue type name |
| python3 github_utils.py bugs facebook/react --from-tag v18.0.0 --to-tag v18.2.0 --type bug |
| """, |
| ) |
| |
| parser.add_argument( |
| "repository", help="Repository in format owner/repo (e.g., facebook/react)" |
| ) |
| parser.add_argument( |
| "--from-tag", required=True, help="Starting release tag (e.g., v1.0.0)" |
| ) |
| parser.add_argument( |
| "--to-tag", required=True, help="Ending release tag (e.g., v1.1.0)" |
| ) |
| parser.add_argument( |
| "--type", |
| default="Bug", |
| help="Issue type name to filter by (default: Bug)", |
| ) |
| |
| args = parser.parse_args(sys.argv[2:]) |
| |
| # Parse repository |
| if "/" not in args.repository: |
| print("Error: Repository must be in format owner/repo", file=sys.stderr) |
| sys.exit(1) |
| |
| owner, repo = args.repository.split("/", 1) |
| |
| print("\n" + "=" * 80) |
| print(" OSS Health Checker - Bugs Between Tags") |
| print("=" * 80) |
| print(f"\n Repository: {owner}/{repo}") |
| print(f" Tags: {args.from_tag} -> {args.to_tag}") |
| print(f" Type: {args.type}") |
| |
| try: |
| with GitHubClient() as client: |
| print("\n Looking up tag dates...") |
| start_date = get_tag_date(client, owner, repo, args.from_tag) |
| end_date = get_tag_date(client, owner, repo, args.to_tag) |
| print(f" {args.from_tag}: {start_date.date()}") |
| print(f" {args.to_tag}: {end_date.date()}") |
| |
| print("\n Fetching closed bug issues...") |
| issues = fetch_bug_issues( |
| client, owner, repo, start_date, end_date, args.type |
| ) |
| |
| display_bug_results( |
| issues, |
| owner, |
| repo, |
| args.from_tag, |
| args.to_tag, |
| start_date, |
| end_date, |
| args.type, |
| ) |
| |
| print("\n Analysis complete!\n") |
| |
| except ValueError as e: |
| print(f"\n Error: {e}", file=sys.stderr) |
| print( |
| "\nPlease set GITHUB_TOKEN environment variable or create a .env file", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
| except Exception as e: |
| print(f"\n Unexpected error: {e}", file=sys.stderr) |
| import traceback |
| |
| traceback.print_exc() |
| sys.exit(1) |
| |
| |
| def list_contributors_main(): |
| """CLI entry point for list-contributors""" |
| parser = argparse.ArgumentParser( |
| description="List repository contributors with various filtering options", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=""" |
| Examples: |
| # List contributors from last 30 days |
| python3 github_utils.py contributors facebook/react --since 2024-11-01 |
| |
| # List contributors in a date range |
| python3 github_utils.py contributors facebook/react --since 2024-01-01 --until 2024-06-30 |
| |
| # List contributors between two release tags |
| python3 github_utils.py contributors facebook/react --from-tag v18.0.0 --to-tag v18.2.0 |
| """, |
| ) |
| |
| parser.add_argument( |
| "repository", help="Repository in format owner/repo (e.g., facebook/react)" |
| ) |
| |
| filter_group = parser.add_mutually_exclusive_group() |
| filter_group.add_argument( |
| "--since", help="Show contributors since this date (YYYY-MM-DD)" |
| ) |
| filter_group.add_argument( |
| "--from-tag", help="Show contributors from this tag onwards" |
| ) |
| |
| parser.add_argument( |
| "--until", |
| help="Show contributors until this date (YYYY-MM-DD). Used with --since", |
| ) |
| parser.add_argument( |
| "--to-tag", help="Show contributors up to this tag. Used with --from-tag" |
| ) |
| parser.add_argument( |
| "--branch", |
| help="Filter to specific branch (e.g., main). Only works with date filtering, not tag filtering", |
| ) |
| |
| parser.add_argument( |
| "--sort-by", |
| choices=["commits", "name"], |
| default="commits", |
| help="Sort contributors by commit count or name (default: commits)", |
| ) |
| parser.add_argument("--no-count", action="store_true", help="Hide commit counts") |
| |
| args = parser.parse_args(sys.argv[2:]) |
| |
| # Parse repository |
| if "/" not in args.repository: |
| print("ā Error: Repository must be in format owner/repo", file=sys.stderr) |
| sys.exit(1) |
| |
| owner, repo = args.repository.split("/", 1) |
| |
| print("\n" + "=" * 80) |
| print(" š Contributor List") |
| print("=" * 80) |
| print(f"\n Repository: {owner}/{repo}") |
| |
| try: |
| with GitHubClient() as client: |
| analyzer = ContributorAnalyzer(client, owner, repo) |
| |
| if args.from_tag and not args.to_tag: |
| print( |
| "ā Error: --to-tag is required when using --from-tag", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
| |
| if args.to_tag and not args.from_tag: |
| print( |
| "ā Error: --from-tag is required when using --to-tag", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
| |
| if args.branch and (args.from_tag or args.to_tag): |
| print( |
| "ā Error: --branch cannot be used with tag filtering (--from-tag/--to-tag)", |
| file=sys.stderr, |
| ) |
| print( |
| " Branch filtering only works with date filtering (--since/--until)", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
| |
| if args.from_tag and args.to_tag: |
| print(f" Filter: Tags {args.from_tag} ā {args.to_tag}") |
| contributors = analyzer.get_contributors_by_tags( |
| args.from_tag, args.to_tag |
| ) |
| else: |
| if args.since or args.until or args.branch: |
| filter_desc = "Filter: " |
| if args.since: |
| filter_desc += f"Since {args.since}" |
| if args.until: |
| filter_desc += ( |
| f" Until {args.until}" |
| if args.since |
| else f"Until {args.until}" |
| ) |
| if args.branch: |
| filter_desc += ( |
| f" Branch: {args.branch}" |
| if (args.since or args.until) |
| else f"Branch: {args.branch}" |
| ) |
| print(f" {filter_desc}") |
| else: |
| print(" Filter: All time") |
| |
| contributors = analyzer.get_contributors_by_date_range( |
| args.since, args.until, args.branch |
| ) |
| |
| display_contributors( |
| contributors, sort_by=args.sort_by, show_count=not args.no_count |
| ) |
| |
| print("\nā
Analysis complete!\n") |
| |
| except ValueError as e: |
| print(f"\nā {e}", file=sys.stderr) |
| print( |
| "\nPlease set GITHUB_TOKEN environment variable or create a .env file", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
| except Exception as e: |
| print(f"\nā Unexpected error: {e}", file=sys.stderr) |
| import traceback |
| |
| traceback.print_exc() |
| sys.exit(1) |
| |
| |
| def main(): |
| """Unified CLI entry point with subcommands""" |
| if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): |
| print("Usage: python github_utils.py <command> [options]") # noqa: T201 |
| print() |
| print("Commands:") |
| print(" bugs Fetch bug issues closed between two release tags") |
| print(" contributors List repository contributors") |
| print() |
| print( |
| "Run 'python github_utils.py <command> --help' for command-specific help." |
| ) |
| sys.exit(0) |
| |
| command = sys.argv[1] |
| |
| if command == "bugs": |
| bugs_between_tags_main() |
| elif command == "contributors": |
| list_contributors_main() |
| else: |
| print(f"Unknown command: {command}", file=sys.stderr) |
| print("Use 'bugs' or 'contributors'.", file=sys.stderr) |
| sys.exit(1) |
| |
| |
| if __name__ == "__main__": |
| try: |
| main() |
| except KeyboardInterrupt: |
| print("\n\n Analysis cancelled by user.") |
| sys.exit(0) |