Skip to content

Commit 301aa3c

Browse files
authored
refactor: migra GitHubCollector da requests a lab_connectors.http.HttpClient (#34)
* refactor: migra GitHubCollector da requests a lab_connectors.http.HttpClient * refactor: migra GitHubCollector a HttpClient + core dep lab-connectors Allineato al pattern toolkit e SO: lab-connectors come dipendenza core, import top-level senza try/except. Rimossa dipendenza da [mcp] extra. * fix: _raise_on_bad_status per 4xx + 6 regression test
1 parent 16bf133 commit 301aa3c

4 files changed

Lines changed: 121 additions & 33 deletions

File tree

.github/workflows/build-context.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
python-version: '3.12'
3232

3333
- name: Install
34-
run: pip install -e ".[dev,mcp]"
34+
run: pip install -e ".[dev]"
3535

3636
- name: Test
3737
run: pytest --cov=src/agent_context_builder --cov-report=xml

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ dependencies = [
2727
"pydantic>=2.0",
2828
"pyyaml>=6.0",
2929
"requests>=2.28",
30+
"lab-connectors @ git+https://github.com/dataciviclab/lab-connectors.git@v0.2.0",
3031
]
3132

3233
[project.optional-dependencies]
3334
mcp = [
3435
"mcp>=1.0",
35-
"lab-connectors @ git+https://github.com/dataciviclab/lab-connectors.git@v0.2.0",
3636
]
3737
dev = [
3838
"pytest>=7.0",

src/agent_context_builder/github.py

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""GitHub API interactions for context collection."""
22

3+
from __future__ import annotations
4+
35
from dataclasses import dataclass
46
from typing import Optional
57

6-
import requests
8+
from lab_connectors.http import HttpClient
79

810

911
@dataclass
@@ -51,11 +53,12 @@ def __init__(self, org: str, token: Optional[str] = None):
5153
self.org = org
5254
self.token = token
5355
self.base_url = "https://api.github.com"
56+
self._http = HttpClient(timeout=10)
5457
# Populated by get_prs/get_issues — maps "<repo>:prs" or "<repo>:issues" to error message
5558
self.fetch_errors: dict[str, str] = {}
5659

5760
def collector_warning(self) -> str | None:
58-
"""Return a human-readable warning if fetch errors suggest rate-limit or auth degradation."""
61+
"""Return a warning if fetch errors suggest rate-limit or auth degradation."""
5962
if not self.fetch_errors:
6063
return None
6164
msgs = " ".join(self.fetch_errors.values()).lower()
@@ -105,19 +108,29 @@ def get_issues(self, repos: list[str], state: str = "open") -> list[Issue]:
105108
self.fetch_errors[f"{repo}:issues"] = str(e)
106109
return issues
107110

111+
def _raise_on_bad_status(self, result, url_desc: str) -> None:
112+
"""Raise RuntimeError if result is error or response status >= 400."""
113+
if not result.is_ok:
114+
raise RuntimeError(f"{url_desc}: {result.err}")
115+
status = result.response.status_code # type: ignore[union-attr]
116+
if status >= 400:
117+
raise RuntimeError(f"{url_desc}: HTTP {status}")
118+
119+
def _headers(self) -> dict[str, str]:
120+
"""Build Authorization headers if token is set."""
121+
if self.token:
122+
return {"Authorization": f"token {self.token}"}
123+
return {}
124+
108125
def _get_repo_prs(self, repo: str, state: str = "open") -> list[PR]:
109126
"""Get PRs for a specific repo."""
110127
url = f"{self.base_url}/repos/{self.org}/{repo}/pulls"
111128
params = {"state": state, "per_page": 50}
112-
headers = {}
113-
if self.token:
114-
headers["Authorization"] = f"token {self.token}"
115-
116-
response = requests.get(url, params=params, headers=headers, timeout=10)
117-
response.raise_for_status()
129+
result = self._http.get(url, params=params, headers=self._headers())
130+
self._raise_on_bad_status(result, url)
118131

119132
prs = []
120-
for item in response.json():
133+
for item in result.response.json(): # type: ignore[union-attr]
121134
prs.append(
122135
PR(
123136
number=item["number"],
@@ -145,13 +158,10 @@ def get_raw_file(self, repo: str, path: str, ref: str = "main") -> str | None:
145158
Raw file content as string, or None on failure.
146159
"""
147160
url = f"https://raw.githubusercontent.com/{self.org}/{repo}/{ref}/{path}"
148-
headers = {}
149-
if self.token:
150-
headers["Authorization"] = f"token {self.token}"
161+
result = self._http.get(url, headers=self._headers())
151162
try:
152-
response = requests.get(url, headers=headers, timeout=10)
153-
response.raise_for_status()
154-
return response.text
163+
self._raise_on_bad_status(result, url)
164+
return result.response.text # type: ignore[union-attr]
155165
except Exception as exc:
156166
self.fetch_errors[f"{repo}:{path}"] = str(exc)
157167
return None
@@ -161,24 +171,21 @@ def get_repos_info(self, repos: list[str]) -> dict[str, RepoInfo]:
161171
162172
Returns a dict keyed by repo name. Missing/failed repos are skipped silently.
163173
"""
164-
result: dict[str, RepoInfo] = {}
165-
headers = {}
166-
if self.token:
167-
headers["Authorization"] = f"token {self.token}"
174+
result_map: dict[str, RepoInfo] = {}
168175
for repo in repos:
169176
try:
170177
url = f"{self.base_url}/repos/{self.org}/{repo}"
171-
response = requests.get(url, headers=headers, timeout=10)
172-
response.raise_for_status()
173-
data = response.json()
174-
result[repo] = RepoInfo(
178+
result = self._http.get(url, headers=self._headers())
179+
self._raise_on_bad_status(result, url)
180+
data = result.response.json() # type: ignore[union-attr]
181+
result_map[repo] = RepoInfo(
175182
name=repo,
176183
description=data.get("description") or "",
177184
url=data.get("html_url", ""),
178185
)
179186
except Exception as exc:
180187
self.fetch_errors[f"{repo}:info"] = str(exc)
181-
return result
188+
return result_map
182189

183190
def _get_repo_issues(self, repo: str, state: str = "open") -> list[Issue]:
184191
"""Get issues for a specific repo (excluding pull requests).
@@ -188,15 +195,11 @@ def _get_repo_issues(self, repo: str, state: str = "open") -> list[Issue]:
188195
"""
189196
url = f"{self.base_url}/repos/{self.org}/{repo}/issues"
190197
params = {"state": state, "per_page": 50}
191-
headers = {}
192-
if self.token:
193-
headers["Authorization"] = f"token {self.token}"
194-
195-
response = requests.get(url, params=params, headers=headers, timeout=10)
196-
response.raise_for_status()
198+
result = self._http.get(url, params=params, headers=self._headers())
199+
self._raise_on_bad_status(result, url)
197200

198201
issues = []
199-
for item in response.json():
202+
for item in result.response.json(): # type: ignore[union-attr]
200203
# Skip pull requests: they have a pull_request field
201204
if "pull_request" in item:
202205
continue

tests/test_github.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Tests for github module HTTP boundary."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
from agent_context_builder.github import GitHubCollector
6+
7+
8+
def _make_http_result(status_code: int, body: str = ""):
9+
"""Build a mock HttpResult with the given status code."""
10+
mock_resp = MagicMock()
11+
mock_resp.status_code = status_code
12+
mock_resp.text = body
13+
mock_resp.json.return_value = {}
14+
result = MagicMock()
15+
result.is_ok = status_code < 500 # come HttpClient reale
16+
result.response = mock_resp
17+
result.err = None
18+
return result
19+
20+
21+
class TestGetRawFile:
22+
"""get_raw_file deve gestire 4xx come errori, non restituire body di errore."""
23+
24+
def test_404_returns_none(self):
25+
"""404 su get_raw_file → None + fetch_errors popolato."""
26+
collector = GitHubCollector("test-org", token="fake")
27+
with patch.object(collector._http, "get", return_value=_make_http_result(404)):
28+
result = collector.get_raw_file("some-repo", "data/file.json")
29+
assert result is None
30+
assert "some-repo:data/file.json" in collector.fetch_errors
31+
assert "HTTP 404" in collector.fetch_errors["some-repo:data/file.json"]
32+
33+
def test_200_returns_text(self):
34+
"""200 su get_raw_file → body restituito."""
35+
collector = GitHubCollector("test-org", token="fake")
36+
with patch.object(collector._http, "get", return_value=_make_http_result(200, "ok")):
37+
result = collector.get_raw_file("some-repo", "data/file.json")
38+
assert result == "ok"
39+
40+
def test_403_records_error(self):
41+
"""403 su get_raw_file → None + errore tracciato."""
42+
collector = GitHubCollector("test-org", token="fake")
43+
with patch.object(collector._http, "get", return_value=_make_http_result(403)):
44+
result = collector.get_raw_file("some-repo", "data/file.json")
45+
assert result is None
46+
assert "HTTP 403" in collector.fetch_errors["some-repo:data/file.json"]
47+
48+
49+
class TestGetReposInfo:
50+
"""get_repos_info deve segnalare 4xx come errori, non dati vuoti."""
51+
52+
def test_403_skips_and_records_error(self):
53+
"""403 su get_repos_info → repo skippato + errore tracciato."""
54+
collector = GitHubCollector("test-org", token="fake")
55+
with patch.object(collector._http, "get", return_value=_make_http_result(403)):
56+
result = collector.get_repos_info(["some-repo"])
57+
assert "some-repo" not in result
58+
assert "some-repo:info" in collector.fetch_errors
59+
assert "HTTP 403" in collector.fetch_errors["some-repo:info"]
60+
61+
62+
class TestGetRepoPrs:
63+
"""get_prs deve propagare 4xx come errori."""
64+
65+
def test_403_records_error(self):
66+
"""403 su get_prs → lista vuota + errore tracciato."""
67+
collector = GitHubCollector("test-org", token="fake")
68+
with patch.object(collector._http, "get", return_value=_make_http_result(403)):
69+
result = collector.get_prs(["some-repo"])
70+
assert result == []
71+
assert "some-repo:prs" in collector.fetch_errors
72+
assert "HTTP 403" in collector.fetch_errors["some-repo:prs"]
73+
74+
75+
class TestGetIssues:
76+
"""get_issues deve propagare 4xx come errori."""
77+
78+
def test_403_records_error(self):
79+
"""403 su get_issues → lista vuota + errore tracciato."""
80+
collector = GitHubCollector("test-org", token="fake")
81+
with patch.object(collector._http, "get", return_value=_make_http_result(403)):
82+
result = collector.get_issues(["some-repo"])
83+
assert result == []
84+
assert "some-repo:issues" in collector.fetch_errors
85+
assert "HTTP 403" in collector.fetch_errors["some-repo:issues"]

0 commit comments

Comments
 (0)