diff --git a/CLAUDE.md b/CLAUDE.md index 1b3ac26..c220a24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ devbrief/ | Subcommand | Status | Notes | |-----------------|-------------|------------------------------------------------| -| devbrief repo | LIVE | v0.3.1, cache layer (SHA-keyed, ~/.cache/devbrief/), --no-cache/--refresh | +| devbrief repo | LIVE | v0.3.2, cache layer (SHA-keyed, ~/.cache/devbrief/), --no-cache/--refresh | | devbrief auth | LIVE | v0.2.0, key validation, config write/read/clear, 600 perms | | devbrief logs | LIVE | v0.3.0, FastAPI+HTMX polling dashboard, ring buffer, file (1s tail)/stdin | | devbrief env | PLANNED | Rust entry point via maturin/PyO3 | @@ -152,4 +152,5 @@ devbrief/ 3. [x] v0.2.0: CLI restructure (`devbrief repo`), `devbrief auth`, credential + model resolution 4. [x] v0.3.0: `devbrief logs` — FastAPI+HTMX polling dashboard, ring buffer, file/stdin 5. [x] v0.3.1: `devbrief repo` cache layer — SHA-keyed local cache, --no-cache/--refresh flags -6. [ ] Await spec card before touching any subcommand +6. [x] v0.3.2: `github.py` migrated from `requests` to `httpx` — closes HTTP client tech debt +7. [ ] Await spec card before touching any subcommand diff --git a/assets/devbrief-cache.gif b/assets/devbrief-cache.gif index 1392b5c..6931a55 100644 Binary files a/assets/devbrief-cache.gif and b/assets/devbrief-cache.gif differ diff --git a/assets/vhs/devbrief-cache.tape b/assets/vhs/devbrief-cache.tape index 3a2a835..f6210c6 100644 --- a/assets/vhs/devbrief-cache.tape +++ b/assets/vhs/devbrief-cache.tape @@ -2,7 +2,7 @@ # Pattern: Hide + Sleep to mask API wait time # Requires: rm -rf ~/.cache/devbrief/ before running -Output devbrief-cache.gif +Output ../devbrief-cache.gif Set FontSize 16 Set Width 1200 @@ -13,8 +13,9 @@ Set Padding 20 Set CursorBlink false Set TypingSpeed 40ms Set WindowBar Colorful +Set PlaybackSpeed 1.5 -# ── Setup: clear cache ────────────────────────────────────── +# ── Setup: vider le cache ────────────────────────── Hide Type "rm -rf ~/.cache/devbrief/" Enter @@ -23,16 +24,16 @@ Type "clear" Enter Show -# ── Scene 1: cold run (cache miss) ───────────────────────── +# ── Scene 1: cold run (cache miss) ───────────────── Type "devbrief repo https://github.com/fastapi/typer" Sleep 400ms Enter Hide Sleep 25s Show -Sleep 2500ms +Sleep 1500ms -# ── Scene 2: cache hit ────────────────────────────────────── +# ── Scene 2: cache hit ───────────────────────────── Hide Type "clear" Enter @@ -44,9 +45,9 @@ Enter Hide Sleep 2s Show -Sleep 2500ms +Sleep 1500ms -# ── Scene 3: --refresh then cache hit ─────────────────────── +# ── Scene 3: --refresh puis hit ──────────────────── Hide Type "clear" Enter @@ -58,7 +59,7 @@ Enter Hide Sleep 25s Show -Sleep 800ms +Sleep 500ms Type "devbrief repo https://github.com/fastapi/typer" Sleep 300ms @@ -66,4 +67,4 @@ Enter Hide Sleep 2s Show -Sleep 2500ms \ No newline at end of file +Sleep 1500ms diff --git a/pyproject.toml b/pyproject.toml index 62cabe7..187ec0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devbrief" -version = "0.3.1" +version = "0.3.2" description = "Generate a human-readable brief for any GitHub repository using Claude AI" readme = "README.md" requires-python = ">=3.12" @@ -9,7 +9,7 @@ dependencies = [ "fastapi>=0.115.0", "jinja2>=3.1.0", "python-dotenv>=1.2.2", - "requests>=2.32.5", + "httpx>=0.27.0", "rich>=14.3.3", "typer>=0.15.0", "uvicorn>=0.30.0", diff --git a/src/devbrief/github.py b/src/devbrief/github.py index 818954a..e6cc682 100644 --- a/src/devbrief/github.py +++ b/src/devbrief/github.py @@ -1,4 +1,6 @@ -import requests +import base64 + +import httpx def parse_repo_url(url: str) -> tuple[str, str]: @@ -12,7 +14,7 @@ def parse_repo_url(url: str) -> tuple[str, str]: def fetch_repo_data(owner: str, repo: str) -> dict: """Fetch repository metadata from GitHub API.""" url = f"https://api.github.com/repos/{owner}/{repo}" - response = requests.get(url, timeout=10) + response = httpx.get(url, timeout=10) response.raise_for_status() data = response.json() return { @@ -28,12 +30,10 @@ def fetch_repo_data(owner: str, repo: str) -> dict: def fetch_readme(owner: str, repo: str) -> str: """Fetch README content from GitHub API (decoded from base64).""" url = f"https://api.github.com/repos/{owner}/{repo}/readme" - response = requests.get(url, timeout=10) + response = httpx.get(url, timeout=10) if response.status_code == 404: return "" response.raise_for_status() - import base64 - content = response.json().get("content", "") return base64.b64decode(content).decode("utf-8", errors="replace") @@ -41,7 +41,7 @@ def fetch_readme(owner: str, repo: str) -> str: def fetch_file_tree(owner: str, repo: str) -> list[str]: """Fetch top-level file/directory names from GitHub API.""" url = f"https://api.github.com/repos/{owner}/{repo}/contents" - response = requests.get(url, timeout=10) + response = httpx.get(url, timeout=10) if response.status_code == 404: return [] response.raise_for_status() @@ -53,7 +53,7 @@ def fetch_latest_commit_sha(owner: str, repo: str) -> str | None: """Return the SHA of the most recent commit, or None if unreachable.""" url = f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1" try: - response = requests.get(url, timeout=5) + response = httpx.get(url, timeout=5) response.raise_for_status() commits = response.json() if commits and isinstance(commits, list): diff --git a/tests/test_cache.py b/tests/test_cache.py index 7fcf3dd..c0c8379 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -228,7 +228,7 @@ def _run(self, mocker, tmp_cache, url=REPO_URL, extra_args=None): tree_resp = _make_mock_response([{"name": "src"}], 200) get_mock = mocker.patch( - "devbrief.github.requests.get", + "devbrief.github.httpx.get", side_effect=[commit_resp, repo_resp, readme_resp, tree_resp], ) brief_mock = mocker.patch( @@ -272,9 +272,7 @@ def test_cache_hit_same_sha_skips_api(self, mocker, tmp_path, mock_env): # Only commit SHA fetch should happen — no repo/readme/tree/brief calls commit_resp = _make_mock_response([{"sha": COMMIT_SHA}]) - get_mock = mocker.patch( - "devbrief.github.requests.get", return_value=commit_resp - ) + get_mock = mocker.patch("devbrief.github.httpx.get", return_value=commit_resp) brief_mock = mocker.patch("devbrief.commands.repo.generate_brief") runner = CliRunner() @@ -323,7 +321,7 @@ def test_sha_changed_invalidates_cache(self, mocker, tmp_path, mock_env): commit_resp2 = _make_mock_response([{"sha": "new-sha"}]) mocker.patch( - "devbrief.github.requests.get", + "devbrief.github.httpx.get", side_effect=[commit_resp, repo_resp, readme_resp, tree_resp, commit_resp2], ) brief_mock = mocker.patch( @@ -337,7 +335,7 @@ def test_sha_changed_invalidates_cache(self, mocker, tmp_path, mock_env): brief_mock.assert_called_once() def test_github_api_unreachable_serves_cached(self, mocker, tmp_path, mock_env): - import requests as req_lib + import httpx from typer.testing import CliRunner from devbrief.cli import app @@ -351,8 +349,8 @@ def test_github_api_unreachable_serves_cached(self, mocker, tmp_path, mock_env): # Commit SHA fetch raises (network error) mocker.patch( - "devbrief.github.requests.get", - side_effect=req_lib.exceptions.ConnectionError("unreachable"), + "devbrief.github.httpx.get", + side_effect=httpx.ConnectError("unreachable"), ) brief_mock = mocker.patch("devbrief.commands.repo.generate_brief") @@ -391,7 +389,7 @@ def test_no_cache_flag_bypasses_cache(self, mocker, tmp_path, mock_env): ) tree_resp = _make_mock_response([{"name": "src"}], 200) mocker.patch( - "devbrief.github.requests.get", + "devbrief.github.httpx.get", side_effect=[repo_resp, readme_resp, tree_resp], ) brief_mock = mocker.patch( @@ -431,7 +429,7 @@ def test_refresh_flag_is_alias_for_no_cache(self, mocker, tmp_path, mock_env): ) tree_resp = _make_mock_response([{"name": "src"}], 200) mocker.patch( - "devbrief.github.requests.get", + "devbrief.github.httpx.get", side_effect=[repo_resp, readme_resp, tree_resp], ) brief_mock = mocker.patch( diff --git a/tests/test_github.py b/tests/test_github.py index 62e2aa1..448c258 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -46,7 +46,7 @@ def test_maps_api_fields(self, mocker): "topics": ["cli", "ai"], "homepage": "https://example.com", } - mocker.patch("devbrief.github.requests.get", return_value=mock_response) + mocker.patch("devbrief.github.httpx.get", return_value=mock_response) result = fetch_repo_data("owner", "my-repo") @@ -62,7 +62,7 @@ def test_maps_api_fields(self, mocker): def test_missing_fields_use_defaults(self, mocker): mock_response = MagicMock() mock_response.json.return_value = {} - mocker.patch("devbrief.github.requests.get", return_value=mock_response) + mocker.patch("devbrief.github.httpx.get", return_value=mock_response) result = fetch_repo_data("owner", "repo") @@ -82,14 +82,14 @@ def test_decodes_base64_content(self, mocker): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"content": encoded} - mocker.patch("devbrief.github.requests.get", return_value=mock_response) + mocker.patch("devbrief.github.httpx.get", return_value=mock_response) assert fetch_readme("owner", "repo") == "# Hello World\n" def test_returns_empty_string_on_404(self, mocker): mock_response = MagicMock() mock_response.status_code = 404 - mocker.patch("devbrief.github.requests.get", return_value=mock_response) + mocker.patch("devbrief.github.httpx.get", return_value=mock_response) assert fetch_readme("owner", "repo") == "" @@ -98,7 +98,7 @@ def test_returns_empty_string_when_content_missing(self, mocker): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"content": encoded} - mocker.patch("devbrief.github.requests.get", return_value=mock_response) + mocker.patch("devbrief.github.httpx.get", return_value=mock_response) assert fetch_readme("owner", "repo") == "" @@ -117,7 +117,7 @@ def test_returns_names_list(self, mocker): {"name": "README.md", "type": "file"}, {"name": "pyproject.toml", "type": "file"}, ] - mocker.patch("devbrief.github.requests.get", return_value=mock_response) + mocker.patch("devbrief.github.httpx.get", return_value=mock_response) assert fetch_file_tree("owner", "repo") == [ "src", @@ -128,7 +128,7 @@ def test_returns_names_list(self, mocker): def test_returns_empty_list_on_404(self, mocker): mock_response = MagicMock() mock_response.status_code = 404 - mocker.patch("devbrief.github.requests.get", return_value=mock_response) + mocker.patch("devbrief.github.httpx.get", return_value=mock_response) assert fetch_file_tree("owner", "repo") == [] @@ -136,6 +136,6 @@ def test_skips_non_dict_items(self, mocker): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = [{"name": "src"}, "unexpected-string"] - mocker.patch("devbrief.github.requests.get", return_value=mock_response) + mocker.patch("devbrief.github.httpx.get", return_value=mock_response) assert fetch_file_tree("owner", "repo") == ["src"] diff --git a/uv.lock b/uv.lock index 3f3c1d8..1ac0a78 100644 --- a/uv.lock +++ b/uv.lock @@ -208,14 +208,14 @@ wheels = [ [[package]] name = "devbrief" -version = "0.3.0" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "anthropic" }, { name = "fastapi" }, + { name = "httpx" }, { name = "jinja2" }, { name = "python-dotenv" }, - { name = "requests" }, { name = "rich" }, { name = "typer" }, { name = "uvicorn" }, @@ -233,9 +233,9 @@ dev = [ requires-dist = [ { name = "anthropic", specifier = ">=0.84.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, - { name = "requests", specifier = ">=2.32.5" }, { name = "rich", specifier = ">=14.3.3" }, { name = "typer", specifier = ">=0.15.0" }, { name = "uvicorn", specifier = ">=0.30.0" },