Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Binary file modified assets/devbrief-cache.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 10 additions & 9 deletions assets/vhs/devbrief-cache.tape
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -58,12 +59,12 @@ Enter
Hide
Sleep 25s
Show
Sleep 800ms
Sleep 500ms

Type "devbrief repo https://github.com/fastapi/typer"
Sleep 300ms
Enter
Hide
Sleep 2s
Show
Sleep 2500ms
Sleep 1500ms
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand Down
14 changes: 7 additions & 7 deletions src/devbrief/github.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import requests
import base64

import httpx


def parse_repo_url(url: str) -> tuple[str, str]:
Expand All @@ -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 {
Expand All @@ -28,20 +30,18 @@ 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")


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()
Expand All @@ -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):
Expand Down
18 changes: 8 additions & 10 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand All @@ -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")

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 8 additions & 8 deletions tests/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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")

Expand All @@ -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") == ""

Expand All @@ -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") == ""

Expand All @@ -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",
Expand All @@ -128,14 +128,14 @@ 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") == []

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"]
6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading