From 567fa1c2c091e9169b7fc3f38df7b218f08dcca3 Mon Sep 17 00:00:00 2001 From: Matars Date: Sun, 21 Dec 2025 23:06:22 +0100 Subject: [PATCH 1/9] provider class --- src/gitfetch/providers.py | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/gitfetch/providers.py diff --git a/src/gitfetch/providers.py b/src/gitfetch/providers.py new file mode 100644 index 0000000..83fab94 --- /dev/null +++ b/src/gitfetch/providers.py @@ -0,0 +1,55 @@ +""" +Provider definitions and configuration for gitfetch. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +class ProviderType(Enum): + """Supported git hosting providers.""" + GITHUB = "github" + GITLAB = "gitlab" + GITEA = "gitea" + SOURCEHUT = "sourcehut" + + +# Environment variable names for each provider's token +PROVIDER_ENV_VARS = { + "github": "GH_TOKEN", + "gitlab": "GITLAB_TOKEN", + "gitea": "GITEA_TOKEN", + "sourcehut": "SOURCEHUT_TOKEN", +} + +# Default URLs for providers +PROVIDER_DEFAULT_URLS = { + "github": "https://api.github.com", + "gitlab": "https://gitlab.com", + "sourcehut": "https://git.sr.ht", + # gitea requires user-provided URL +} + + +@dataclass +class ProviderConfig: + """Configuration for a git provider.""" + name: str + username: str + url: str + token: str = "" + + @property + def token_env_var(self) -> str: + """Get the environment variable name for this provider's token.""" + return PROVIDER_ENV_VARS.get(self.name, "") + + @property + def default_url(self) -> Optional[str]: + """Get the default URL for this provider, if any.""" + return PROVIDER_DEFAULT_URLS.get(self.name) + + def has_token(self) -> bool: + """Check if a token is configured.""" + return bool(self.token) From e313041bbbfe7346e05054ac963ad9c3e1ae3918 Mon Sep 17 00:00:00 2001 From: Matars Date: Sun, 21 Dec 2025 23:15:29 +0100 Subject: [PATCH 2/9] fix os.getenv('GH_TOKEN') returning None --- src/gitfetch/fetcher.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/gitfetch/fetcher.py b/src/gitfetch/fetcher.py index 23a1591..89bdab0 100644 --- a/src/gitfetch/fetcher.py +++ b/src/gitfetch/fetcher.py @@ -78,8 +78,7 @@ def _build_contribution_graph_from_git(repo_path: str = ".") -> list: # Get commit dates result = subprocess.run( ['git', 'log', '--pretty=format:%ai', '--all'], - capture_output=True, text=True, cwd=repo_path, - env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} + capture_output=True, text=True, cwd=repo_path ) if result.returncode != 0: return [] @@ -133,8 +132,19 @@ def __init__(self, token: Optional[str] = None): Args: token: Optional GitHub personal access token """ - self.token=token - pass + super().__init__(token) + + def _build_env(self) -> dict: + """ + Build environment dict with token if available. + + Returns: + Environment dict for subprocess calls + """ + env = os.environ.copy() + if self.token: + env['GH_TOKEN'] = self.token + return env def _check_gh_cli(self) -> None: """Check if GitHub CLI is installed and authenticated.""" @@ -217,7 +227,7 @@ def _gh_api(self, endpoint: str, method: str = "GET") -> Any: capture_output=True, text=True, timeout=30, - env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} + env=self._build_env() ) if result.returncode != 0: raise Exception(f"gh api failed: {result.stderr}") @@ -442,7 +452,7 @@ def _search_items(self, query: str, per_page: int = 5) -> Dict[str, Any]: capture_output=True, text=True, timeout=30, - env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} + env=self._build_env() ) if result.returncode != 0: return {'total_count': 0, 'items': []} @@ -554,7 +564,7 @@ def _fetch_contribution_graph(self, username: str) -> list: capture_output=True, text=True, timeout=30, - env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} + env=self._build_env() ) if result.returncode != 0: @@ -646,8 +656,7 @@ def _api_request(self, endpoint: str) -> Any: cmd, capture_output=True, text=True, - timeout=30, - env={**os.environ, 'GH_TOKEN': os.getenv('GH_TOKEN')} + timeout=30 ) if result.returncode != 0: raise Exception(f"API request failed: {result.stderr}") From 0e878e74b8c63c16a0fa3d65cbbac4fb0fd21ea9 Mon Sep 17 00:00:00 2001 From: Matars Date: Sun, 21 Dec 2025 23:16:46 +0100 Subject: [PATCH 3/9] moidfy config to use new provider logic --- src/gitfetch/config.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/gitfetch/config.py b/src/gitfetch/config.py index f01b069..cb08e0d 100644 --- a/src/gitfetch/config.py +++ b/src/gitfetch/config.py @@ -3,10 +3,13 @@ """ import configparser +import os from pathlib import Path from typing import Optional import webcolors +from .providers import ProviderConfig, PROVIDER_ENV_VARS, PROVIDER_DEFAULT_URLS + class ConfigManager: """Manages gitfetch configuration.""" @@ -276,6 +279,69 @@ def set_token(self, token: str) -> None: self.config['DEFAULT'] = {} self.config['DEFAULT']['token'] = token + def get_provider_config(self) -> Optional[ProviderConfig]: + """ + Get complete provider configuration with token resolution. + + Token resolution chain: + 1. Read from provider section + 2. Fall back to environment variable + + Returns: + ProviderConfig with resolved token, or None if no provider set + """ + provider_name = self.get_provider() + if not provider_name: + return None + + section = provider_name # e.g., "github" + + # Try provider section first, fall back to DEFAULT for backward compat + if self.config.has_section(section): + username = self.config.get(section, 'username', fallback='') + url = self.config.get(section, 'url', fallback='') + token = self.config.get(section, 'token', fallback='') + else: + # Backward compatibility: read from DEFAULT + username = self.get_default_username() or '' + url = self.get_provider_url() or '' + token = self.get_token() or '' + + # Token resolution: config -> env var + if not token: + env_var = PROVIDER_ENV_VARS.get(provider_name, '') + if env_var: + token = os.getenv(env_var, '') or '' + + # Use default URL if not specified + if not url: + url = PROVIDER_DEFAULT_URLS.get(provider_name, '') + + return ProviderConfig( + name=provider_name, + username=username, + url=url, + token=token + ) + + def set_provider_config(self, config: ProviderConfig) -> None: + """ + Save provider configuration to its dedicated section. + + Args: + config: ProviderConfig to save + """ + section = config.name + if not self.config.has_section(section): + self.config.add_section(section) + + self.config.set(section, 'username', config.username) + self.config.set(section, 'url', config.url) + self.config.set(section, 'token', config.token) + + # Also set provider in DEFAULT + self.set_provider(config.name) + def save(self) -> None: """Save configuration to file.""" import os @@ -318,6 +384,17 @@ def save(self) -> None: f.write("\n") + # Write provider sections + known_providers = ['github', 'gitlab', 'gitea', 'sourcehut'] + for provider_section in known_providers: + if self.config.has_section(provider_section): + f.write(f"[{provider_section}]\n") + for key in ['username', 'url', 'token']: + value = self.config.get(provider_section, key, + fallback='') + f.write(f"{key} = {value}\n") + f.write("\n") + if 'COLORS' in self.config._sections: f.write("[COLORS]\n") # Find the longest key for alignment From 14a47dfa984a2c2e78badf1c93fe009ac4ad84bf Mon Sep 17 00:00:00 2001 From: Matars Date: Sun, 21 Dec 2025 23:24:45 +0100 Subject: [PATCH 4/9] update the cli --- src/gitfetch/cli.py | 94 ++++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/src/gitfetch/cli.py b/src/gitfetch/cli.py index e898804..e09ccbb 100644 --- a/src/gitfetch/cli.py +++ b/src/gitfetch/cli.py @@ -12,6 +12,7 @@ from .display import DisplayFormatter from .cache import CacheManager from .config import ConfigManager +from .providers import ProviderConfig, PROVIDER_ENV_VARS, PROVIDER_DEFAULT_URLS from . import __version__ @@ -25,16 +26,16 @@ def _background_refresh_cache_subprocess(username: str) -> None: config_manager = ConfigManager() cache_expiry = config_manager.get_cache_expiry_minutes() cache_manager = CacheManager(cache_expiry_minutes=cache_expiry) - provider = config_manager.get_provider() - provider_url = config_manager.get_provider_url() - token = config_manager.get_token() - if provider == None: - print("Provider not set") + + provider_config = config_manager.get_provider_config() + if not provider_config: exit(1) - if provider_url == None: - print("Provider url not set") + if not provider_config.url: exit(1) - fetcher = _create_fetcher(provider, provider_url, token) + + fetcher = _create_fetcher( + provider_config.name, provider_config.url, provider_config.token or None + ) fresh_user_data = fetcher.fetch_user_data(username) fresh_stats = fetcher.fetch_user_stats(username, fresh_user_data) @@ -264,17 +265,18 @@ def main() -> int: # Initialize components cache_expiry = config_manager.get_cache_expiry_minutes() cache_manager = CacheManager(cache_expiry_minutes=cache_expiry) - provider = config_manager.get_provider() - provider_url = config_manager.get_provider_url() - token = config_manager.get_token() - if provider == None: - print("Provider not set") + + provider_config = config_manager.get_provider_config() + if not provider_config: + print("Provider not set. Run: gitfetch --change-provider") return 1 - if provider_url == None: - print("Provider url not set") + if not provider_config.url: + print("Provider URL not set. Run: gitfetch --change-provider") return 1 - fetcher = _create_fetcher(provider, provider_url, token) + fetcher = _create_fetcher( + provider_config.name, provider_config.url, provider_config.token or None + ) # Handle custom box character custom_box = args.custom_box @@ -565,48 +567,53 @@ def _initialize_gitfetch(config_manager: ConfigManager) -> bool: if not provider: return False - config_manager.set_provider(provider) - - # Set default URL for known providers + # Determine URL for provider if provider == 'github': - config_manager.set_provider_url('https://api.github.com') + url = PROVIDER_DEFAULT_URLS.get('github', 'https://api.github.com') elif provider == 'gitlab': - config_manager.set_provider_url('https://gitlab.com') + url = PROVIDER_DEFAULT_URLS.get('gitlab', 'https://gitlab.com') elif provider == 'gitea': url = input("Enter Gitea/Forgejo/Codeberg URL: ").strip() if not url: print("Provider URL required", file=sys.stderr) return False - config_manager.set_provider_url(url) elif provider == 'sourcehut': - config_manager.set_provider_url('https://git.sr.ht') - - # Ask for token if needed - token = None - if provider in ['gitlab', 'gitea', 'sourcehut', 'github']: - token_input = input( - f"Enter your {provider} personal access token{', needed for private repositories' if provider == 'github' else ''}\n" - + - "(optional, press Enter to skip): " - ).strip() - if token_input: - token = token_input - config_manager.set_token(token) - - # Create appropriate fetcher - url = config_manager.get_provider_url() - if url == None: - print("Provider url could not be found.", file=sys.stderr) + url = PROVIDER_DEFAULT_URLS.get('sourcehut', 'https://git.sr.ht') + else: + print(f"Unsupported provider: {provider}", file=sys.stderr) return False - fetcher = _create_fetcher( - provider, url, token + # Ask for token + token = '' + env_var = PROVIDER_ENV_VARS.get(provider, '') + token_msg = f"Enter your {provider} personal access token" + if provider == 'github': + token_msg += " (needed for private repositories)" + token_msg += f"\n(optional, press Enter to skip" + if env_var: + token_msg += f", or set {env_var} env var" + token_msg += "): " + + token_input = input(token_msg).strip() + if token_input: + token = token_input + + # Create provider config + provider_config = ProviderConfig( + name=provider, + username='', # Will be set after fetcher auth + url=url, + token=token ) + # Create fetcher to get authenticated user + fetcher = _create_fetcher(provider, url, token or None) + # Try to get authenticated user try: username = fetcher.get_authenticated_user() print(f"Using authenticated user: {username}") + provider_config.username = username except Exception as e: print(f"Could not get authenticated user: {e}") if provider == 'github': @@ -634,7 +641,8 @@ def _initialize_gitfetch(config_manager: ConfigManager) -> bool: else: config_manager.set_cache_expiry_minutes(15) - # Save configuration + # Save configuration using new provider config system + config_manager.set_provider_config(provider_config) config_manager.set_default_username(username) config_manager.save() From adb07e471d6aca2c7f64ce665e0fffd54b2dd433 Mon Sep 17 00:00:00 2001 From: Matars Date: Sun, 21 Dec 2025 23:29:26 +0100 Subject: [PATCH 5/9] update config to view all providers and keep track of keys --- src/gitfetch/config.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/gitfetch/config.py b/src/gitfetch/config.py index cb08e0d..55932ec 100644 --- a/src/gitfetch/config.py +++ b/src/gitfetch/config.py @@ -384,16 +384,15 @@ def save(self) -> None: f.write("\n") - # Write provider sections + # Write all provider sections (empty if not configured) known_providers = ['github', 'gitlab', 'gitea', 'sourcehut'] for provider_section in known_providers: - if self.config.has_section(provider_section): - f.write(f"[{provider_section}]\n") - for key in ['username', 'url', 'token']: - value = self.config.get(provider_section, key, - fallback='') - f.write(f"{key} = {value}\n") - f.write("\n") + f.write(f"[{provider_section}]\n") + for key in ['username', 'url', 'token']: + value = self.config.get(provider_section, key, + fallback='') if self.config.has_section(provider_section) else '' + f.write(f"{key} = {value}\n") + f.write("\n") if 'COLORS' in self.config._sections: f.write("[COLORS]\n") From 82cf8ad335242aa844be6c352c04de8c183b3c4f Mon Sep 17 00:00:00 2001 From: Matars Date: Sun, 21 Dec 2025 23:34:16 +0100 Subject: [PATCH 6/9] hotfix glab with same token setup --- src/gitfetch/fetcher.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/gitfetch/fetcher.py b/src/gitfetch/fetcher.py index 89bdab0..cf36e32 100644 --- a/src/gitfetch/fetcher.py +++ b/src/gitfetch/fetcher.py @@ -596,6 +596,18 @@ def __init__(self, base_url: str = "https://gitlab.com", self.base_url = base_url.rstrip('/') self.api_base = f"{self.base_url}/api/v4" + def _build_env(self) -> dict: + """ + Build environment dict with token if available. + + Returns: + Environment dict for subprocess calls + """ + env = os.environ.copy() + if self.token: + env['GITLAB_TOKEN'] = self.token + return env + def _check_glab_cli(self) -> None: """Check if GitLab CLI is installed and authenticated.""" try: @@ -630,7 +642,8 @@ def get_authenticated_user(self) -> str: ['glab', 'api', '/user'], capture_output=True, text=True, - timeout=10 + timeout=10, + env=self._build_env() ) if result.returncode != 0: raise Exception("Failed to get user info") @@ -656,7 +669,8 @@ def _api_request(self, endpoint: str) -> Any: cmd, capture_output=True, text=True, - timeout=30 + timeout=30, + env=self._build_env() ) if result.returncode != 0: raise Exception(f"API request failed: {result.stderr}") From 1cfbd45ce6114e66916656b6202a5bb54962434b Mon Sep 17 00:00:00 2001 From: Matars Date: Sun, 21 Dec 2025 23:36:42 +0100 Subject: [PATCH 7/9] small docs update --- docs/providers.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/providers.md b/docs/providers.md index 159f431..6861008 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -18,6 +18,17 @@ gitfetch supports multiple Git hosting platforms with different authentication m 2. Run `gh auth login` to authenticate 3. gitfetch will detect and use your GitHub credentials +**Token Configuration**: + +You can also set a token via: +- Config file: set `token` in `[github]` section of `~/.config/gitfetch/gitfetch.conf` +- Environment variable: `GH_TOKEN` + +**Rate Limits**: + +- **No token**: 60 requests/hour (limited, may hit limits) +- **With token**: 5,000 requests/hour (recommended) + ## GitLab **Authentication**: Uses GitLab CLI (glab) @@ -28,6 +39,12 @@ gitfetch supports multiple Git hosting platforms with different authentication m 2. Run `glab auth login` to authenticate 3. gitfetch will detect and use your GitLab credentials +**Token Configuration**: + +You can also set a token via: +- Config file: set `token` in `[gitlab]` section of `~/.config/gitfetch/gitfetch.conf` +- Environment variable: `GITLAB_TOKEN` + ## Gitea/Forgejo/Codeberg **Authentication**: Personal access tokens From bce0e8ba551647b0a9a2fd93b1f8a673b5f3962c Mon Sep 17 00:00:00 2001 From: Matars Date: Sun, 21 Dec 2025 23:40:48 +0100 Subject: [PATCH 8/9] output default urls in config --- src/gitfetch/config.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/gitfetch/config.py b/src/gitfetch/config.py index 55932ec..58301cf 100644 --- a/src/gitfetch/config.py +++ b/src/gitfetch/config.py @@ -385,13 +385,28 @@ def save(self) -> None: f.write("\n") # Write all provider sections (empty if not configured) + # Use known default URLs for providers known_providers = ['github', 'gitlab', 'gitea', 'sourcehut'] for provider_section in known_providers: f.write(f"[{provider_section}]\n") - for key in ['username', 'url', 'token']: - value = self.config.get(provider_section, key, - fallback='') if self.config.has_section(provider_section) else '' - f.write(f"{key} = {value}\n") + has_section = self.config.has_section(provider_section) + + # Username + username = self.config.get(provider_section, 'username', + fallback='') if has_section else '' + f.write(f"username = {username}\n") + + # URL - use default if not set + url = self.config.get(provider_section, 'url', + fallback='') if has_section else '' + if not url: + url = PROVIDER_DEFAULT_URLS.get(provider_section, '') + f.write(f"url = {url}\n") + + # Token + token = self.config.get(provider_section, 'token', + fallback='') if has_section else '' + f.write(f"token = {token}\n") f.write("\n") if 'COLORS' in self.config._sections: From 332f8c3919764c51b3f76c67ba08bca691d3eede Mon Sep 17 00:00:00 2001 From: Matars Date: Mon, 22 Dec 2025 00:01:03 +0100 Subject: [PATCH 9/9] better logs on cli --- src/gitfetch/cli.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/gitfetch/cli.py b/src/gitfetch/cli.py index e09ccbb..dc6480e 100644 --- a/src/gitfetch/cli.py +++ b/src/gitfetch/cli.py @@ -583,13 +583,18 @@ def _initialize_gitfetch(config_manager: ConfigManager) -> bool: print(f"Unsupported provider: {provider}", file=sys.stderr) return False - # Ask for token + # Ask for token (optional - only for extra rate limits) token = '' env_var = PROVIDER_ENV_VARS.get(provider, '') + + # Make it clear CLI is required, token is optional for rate limits + if provider in ['github', 'gitlab']: + cli_name = 'gh' if provider == 'github' else 'glab' + print(f"\nNote: {cli_name} CLI is required for authentication.") + print(f"Token is optional but increases rate limits.\n") + token_msg = f"Enter your {provider} personal access token" - if provider == 'github': - token_msg += " (needed for private repositories)" - token_msg += f"\n(optional, press Enter to skip" + token_msg += f"\n(optional - for higher rate limits, press Enter to skip" if env_var: token_msg += f", or set {env_var} env var" token_msg += "): " @@ -612,14 +617,16 @@ def _initialize_gitfetch(config_manager: ConfigManager) -> bool: # Try to get authenticated user try: username = fetcher.get_authenticated_user() - print(f"Using authenticated user: {username}") + # Show auth status with token info + token_status = "with token" if token else "without token (limited rate)" + print(f"✓ Authenticated as: {username} ({token_status})") provider_config.username = username except Exception as e: print(f"Could not get authenticated user: {e}") if provider == 'github': - print("Please authenticate with: gh auth login") + print("Please install gh CLI and run: gh auth login") elif provider == 'gitlab': - print("Please authenticate with: glab auth login") + print("Please install glab CLI and run: glab auth login") else: print("Please ensure you have a valid token configured") return False