From a877af5575cbc34f5960e64a87ab4c23950b07d7 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:21:15 -0700 Subject: [PATCH] Fixes #970 --- CHANGELOG.md | 8 +++++ src/specify_cli/__init__.py | 72 ++++++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea672914..b84db71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.21] - 2025-10-21 + +- Fixes [#975](https://github.com/github/spec-kit/issues/975) (thank you [@fgalarraga](https://github.com/fgalarraga)). +- Adds support for Amp CLI. +- Adds support for VS Code hand-offs and moves prompts to be full-fledged chat modes. +- Adds support for `version` command (addresses [#811](https://github.com/github/spec-kit/issues/811) and [#486](https://github.com/github/spec-kit/issues/486), thank you [@mcasalaina](https://github.com/github/spec-kit/issues/811) and [@dentity007](https://github.com/dentity007)). +- Adds support for rendering the rate limit errors from the CLI when encountered ([#970](https://github.com/github/spec-kit/issues/970), thank you [@psmman](https://github.com/psmman)). + ## [0.0.20] - 2025-10-14 ### Added diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 93515388..9609a397 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -51,6 +51,7 @@ from typer.core import TyperGroup import readchar import ssl import truststore +from datetime import datetime, timezone ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -64,6 +65,63 @@ def _github_auth_headers(cli_token: str | None = None) -> dict: token = _github_token(cli_token) return {"Authorization": f"Bearer {token}"} if token else {} +def _parse_rate_limit_headers(headers: httpx.Headers) -> dict: + """Extract and parse GitHub rate-limit headers.""" + info = {} + + # Standard GitHub rate-limit headers + if "X-RateLimit-Limit" in headers: + info["limit"] = headers.get("X-RateLimit-Limit") + if "X-RateLimit-Remaining" in headers: + info["remaining"] = headers.get("X-RateLimit-Remaining") + if "X-RateLimit-Reset" in headers: + reset_epoch = int(headers.get("X-RateLimit-Reset", "0")) + if reset_epoch: + reset_time = datetime.fromtimestamp(reset_epoch, tz=timezone.utc) + info["reset_epoch"] = reset_epoch + info["reset_time"] = reset_time + info["reset_local"] = reset_time.astimezone() + + # Retry-After header (seconds or HTTP-date) + if "Retry-After" in headers: + retry_after = headers.get("Retry-After") + try: + info["retry_after_seconds"] = int(retry_after) + except ValueError: + # HTTP-date format - not implemented, just store as string + info["retry_after"] = retry_after + + return info + +def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) -> str: + """Format a user-friendly error message with rate-limit information.""" + rate_info = _parse_rate_limit_headers(headers) + + lines = [f"GitHub API returned status {status_code} for {url}"] + lines.append("") + + if rate_info: + lines.append("[bold]Rate Limit Information:[/bold]") + if "limit" in rate_info: + lines.append(f" • Rate Limit: {rate_info['limit']} requests/hour") + if "remaining" in rate_info: + lines.append(f" • Remaining: {rate_info['remaining']}") + if "reset_local" in rate_info: + reset_str = rate_info["reset_local"].strftime("%Y-%m-%d %H:%M:%S %Z") + lines.append(f" • Resets at: {reset_str}") + if "retry_after_seconds" in rate_info: + lines.append(f" • Retry after: {rate_info['retry_after_seconds']} seconds") + lines.append("") + + # Add troubleshooting guidance + lines.append("[bold]Troubleshooting Tips:[/bold]") + lines.append(" • If you're on a shared CI or corporate environment, you may be rate-limited.") + lines.append(" • Consider using a GitHub token via --github-token or the GH_TOKEN/GITHUB_TOKEN") + lines.append(" environment variable to increase rate limits.") + lines.append(" • Authenticated requests have a limit of 5,000/hour vs 60/hour for unauthenticated.") + + return "\n".join(lines) + # Agent configuration with name, folder, install URL, and CLI tool requirement AGENT_CONFIG = { "copilot": { @@ -571,10 +629,11 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri ) status = response.status_code if status != 200: - msg = f"GitHub API returned {status} for {api_url}" + # Format detailed error message with rate-limit info + error_msg = _format_rate_limit_error(status, response.headers, api_url) if debug: - msg += f"\nResponse headers: {response.headers}\nBody (truncated 500): {response.text[:500]}" - raise RuntimeError(msg) + error_msg += f"\n\n[dim]Response body (truncated 500):[/dim]\n{response.text[:500]}" + raise RuntimeError(error_msg) try: release_data = response.json() except ValueError as je: @@ -621,8 +680,11 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri headers=_github_auth_headers(github_token), ) as response: if response.status_code != 200: - body_sample = response.text[:400] - raise RuntimeError(f"Download failed with {response.status_code}\nHeaders: {response.headers}\nBody (truncated): {body_sample}") + # Handle rate-limiting on download as well + error_msg = _format_rate_limit_error(response.status_code, response.headers, download_url) + if debug: + error_msg += f"\n\n[dim]Response body (truncated 400):[/dim]\n{response.text[:400]}" + raise RuntimeError(error_msg) total_size = int(response.headers.get('content-length', 0)) with open(zip_path, 'wb') as f: if total_size == 0: