feat(forge): GitHub adapter — second implementation keeps the seam a contract (#2693, milestone 288 step 8)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 45s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 45s
ForgeAdapter is now a named base class carrying the shared plumbing (host join, error taxonomy, contents decoding, archive, default_branch, latest_commit); GiteaForge keeps its exact behavior and GitHubForge joins with the real differences: api.github.com / GHE /api/v3 host mapping, Bearer auth, a commits call for the provenance stamp (GitHub's contents payload only carries the blob sha), and the codeload tarball redirect. The contract grew latest_commit, and with it the cached-SHA short-circuit in pull-time freshness: a stored provenance commit that still heads the recorded path confirms 'current' without a content transfer — the economy that fits pulls inside GitHub's rate limits; every surprise falls back to the full fetch. Webhook deliveries now also accept X-Hub-Signature-256 (sha256=<hex>); the payload shape was already common. Settings card copy covers both forges' token scopes; the kind selector already flowed from the server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,10 +205,19 @@ def test_forge_error_taxonomy_is_catchable_as_one_family():
|
||||
|
||||
|
||||
def test_adapter_contract_surface():
|
||||
"""Step 8's GitHub adapter implements exactly this surface — pin it."""
|
||||
for method in ("read_file", "default_branch", "resolve_repo", "check"):
|
||||
assert callable(getattr(GiteaForge, method))
|
||||
"""Both adapters implement exactly this surface — the second
|
||||
implementation is what proves it's a contract (#2693)."""
|
||||
from scribe.services.forge import FORGE_KINDS, GitHubForge
|
||||
|
||||
for cls in (GiteaForge, GitHubForge):
|
||||
for method in (
|
||||
"read_file", "latest_commit", "archive",
|
||||
"default_branch", "resolve_repo", "check",
|
||||
):
|
||||
assert callable(getattr(cls, method))
|
||||
assert GiteaForge.kind == "gitea"
|
||||
assert GitHubForge.kind == "github"
|
||||
assert set(FORGE_KINDS) == {"gitea", "github"}
|
||||
|
||||
|
||||
def test_admin_routes_registered():
|
||||
@@ -241,3 +250,122 @@ def test_config_has_the_docker_secret_channel():
|
||||
from scribe.config import Config
|
||||
for attr in ("FORGE_KIND", "FORGE_BASE_URL", "FORGE_TOKEN"):
|
||||
assert hasattr(Config, attr)
|
||||
|
||||
|
||||
# --- the GitHub adapter (#2693) ----------------------------------------------
|
||||
# Same contract, second implementation. Where behavior below differs from the
|
||||
# Gitea tests above, that difference IS the adapter's job: API host mapping,
|
||||
# Bearer auth, the missing last_commit_sha, the codeload redirect.
|
||||
|
||||
def _github(handler, base: str = "https://github.com"):
|
||||
from scribe.services.forge import GitHubForge
|
||||
|
||||
return GitHubForge(base, "gh-tok", transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def test_github_resolve_repo_is_the_same_host_join():
|
||||
from scribe.services.forge import GitHubForge
|
||||
|
||||
forge = GitHubForge("https://github.com", "t")
|
||||
assert forge.resolve_repo("git@github.com:alice/Widget.git") == "alice/widget"
|
||||
# A Gitea-hosted repo is a NORMAL miss for a GitHub forge, and vice versa.
|
||||
assert forge.resolve_repo("https://git.example.com/alice/widget") is None
|
||||
|
||||
|
||||
async def test_github_api_base_maps_dot_com_and_enterprise():
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(str(request.url))
|
||||
return _json(200, {"default_branch": "main"})
|
||||
|
||||
await _github(handler).default_branch("alice/widget")
|
||||
await _github(handler, base="https://ghe.example.com").default_branch("alice/widget")
|
||||
assert seen[0] == "https://api.github.com/repos/alice/widget"
|
||||
assert seen[1] == "https://ghe.example.com/api/v3/repos/alice/widget"
|
||||
|
||||
|
||||
async def test_github_read_file_decodes_and_stamps_from_the_commits_call():
|
||||
content = "def canonical():\n return 1\n"
|
||||
|
||||
def handler(request):
|
||||
assert request.headers["Authorization"] == "Bearer gh-tok"
|
||||
assert request.headers["X-GitHub-Api-Version"]
|
||||
if request.url.path.endswith("/commits"):
|
||||
assert request.url.params["path"] == "src/x.py"
|
||||
assert request.url.params["per_page"] == "1"
|
||||
return _json(200, [{"sha": "c" * 40}])
|
||||
return _json(200, {
|
||||
"type": "file", "encoding": "base64",
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"path": "src/x.py", "sha": "blob-sha-not-a-point-in-history",
|
||||
})
|
||||
|
||||
f = await _github(handler).read_file("alice/widget", "src/x.py")
|
||||
assert f.content == content
|
||||
# From /commits — GitHub's contents payload only carries the blob sha,
|
||||
# which is a content address, not the provenance stamp.
|
||||
assert f.commit_sha == "c" * 40
|
||||
|
||||
|
||||
async def test_github_read_file_serves_content_even_when_the_stamp_fails():
|
||||
content = "x = 1\n"
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/commits"):
|
||||
return httpx.Response(500)
|
||||
return _json(200, {"type": "file", "encoding": "base64",
|
||||
"content": base64.b64encode(content.encode()).decode()})
|
||||
|
||||
f = await _github(handler).read_file("alice/widget", "x.py")
|
||||
assert f.content == content
|
||||
assert f.commit_sha == "" # unknown stamp, not a failed read
|
||||
|
||||
|
||||
async def test_github_archive_follows_the_codeload_redirect():
|
||||
def handler(request):
|
||||
if request.url.host == "api.github.com":
|
||||
return httpx.Response(302, headers={
|
||||
"Location": "https://codeload.github.com/alice/widget/tar.gz/main",
|
||||
})
|
||||
assert request.url.host == "codeload.github.com"
|
||||
# httpx drops Authorization on the cross-host hop — codeload's URL
|
||||
# carries its own grant, and leaking the PAT there would be a bug.
|
||||
assert "Authorization" not in request.headers
|
||||
return httpx.Response(200, content=b"tarball-bytes")
|
||||
|
||||
assert await _github(handler).archive("alice/widget", "main") == b"tarball-bytes"
|
||||
|
||||
|
||||
async def test_github_check_probes_the_token_with_user():
|
||||
result = await _github(lambda r: _json(200, {"login": "octo"})).check()
|
||||
assert result["ok"] is True
|
||||
assert result["username"] == "octo"
|
||||
|
||||
|
||||
async def test_latest_commit_parses_tolerantly_on_both_adapters():
|
||||
"""The one caller treats latest_commit as an optimization with a fallback,
|
||||
so a surprising payload must read as "don't know", never raise."""
|
||||
assert await _github(
|
||||
lambda r: _json(200, [{"sha": "d" * 40}])
|
||||
).latest_commit("a/w", "x.py") == "d" * 40
|
||||
assert await _github(
|
||||
lambda r: _json(200, {"weird": True})
|
||||
).latest_commit("a/w", "x.py") == ""
|
||||
assert await _forge(
|
||||
lambda r: _json(200, [{"sha": "e" * 40}])
|
||||
).latest_commit("a/w", "x.py") == "e" * 40
|
||||
assert await _forge(lambda r: _json(200, [])).latest_commit("a/w", "x.py") == ""
|
||||
|
||||
|
||||
async def test_full_config_builds_a_github_adapter():
|
||||
from scribe.services.forge import GitHubForge
|
||||
|
||||
with _settings({
|
||||
"forge_kind": "github",
|
||||
"forge_base_url": "https://github.com",
|
||||
"forge_token": "tok",
|
||||
}), patch("scribe.services.forge.Config") as cfg:
|
||||
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
|
||||
forge = await get_forge()
|
||||
assert isinstance(forge, GitHubForge)
|
||||
|
||||
Reference in New Issue
Block a user