Files
FabledScribe/tests/test_snippet_live_body.py
T
bvandeusenandClaude Fable 5 1faf8f3ece
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
A forge token is a user's credential, not an instance's. The single
admin-settings config is replaced by per-user keyring rows (one per forge
host), and every server-side forge read runs on the PROJECT OWNER's keyring:

- forge_connections table + projects.forge_connection_id pin (migration 0078,
  which also carries the existing admin config into the first admin's row and
  deletes the old setting keys — no legacy dual-read)
- get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector;
  resolve(repo) picks the connection whose host serves the repo. A pinned
  project uses ONLY its pinned connection; a stale pin (ownership moved) is
  ignored, never honored across users
- env FORGE_* config survives as an implicit entry for admin owners only;
  a stored row for the same host beats it
- consumers threaded: pull-time freshness (owner of the note), coverage
  (owner of the project), coverage routes' configured flag
- routes: /api/settings/forge-connections CRUD + per-connection test
  (own-rows only, tokens never returned); /api/admin/forge shrinks to
  /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins,
  owner-or-admin asking, owner's connections only
- UI: Git Forges card moves to Settings -> Integrations as a connection
  list; webhook secret stays in the admin Config tab; owner-only forge
  select on the project coverage card
- backups exclude forge_connections (credentials, api_keys precedent) and
  the pin, so restores fall back to keyring resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:23:22 -04:00

275 lines
10 KiB
Python

"""Pull-time freshness (#2690) — attach_live_body against a mocked forge.
The properties that must hold, each with its own way of rotting:
- A no-forge instance's pull response is BYTE-IDENTICAL to today's (rule
#115 — the baseline, not a degraded mode).
- The probe never rewrites the body: "current" refreshes provenance,
"diverged" reports, and neither clobbers the record mid-read.
- A 404 stamps the mechanically-true 'missing' verdict — once, not on
every pull of an already-flagged record.
- The pull is never slower than the budget: a hung forge costs bounded
time and then the cache serves.
"""
import asyncio
import base64
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from scribe.services import background
from scribe.services import snippets as svc
from scribe.services.forge import GiteaForge
BASE = "https://git.example.com"
CODE = "def helper(x):\n return x + 1\n"
SHA = "f" * 40
def _note():
return SimpleNamespace(id=7, user_id=3, title="", body="", tags=[], data=None)
def _data(*, repo=f"{BASE}/alice/widget", code=CODE, verification=None, provenance=None):
snippet = {
"code": code,
"locations": [{"repo": repo, "path": "src/helper.py", "symbol": "helper"}],
}
if verification:
snippet["verification"] = verification
if provenance:
snippet["provenance"] = provenance
return {"snippet": snippet}
def _forge_with(handler) -> GiteaForge:
return GiteaForge(BASE, "tok", transport=httpx.MockTransport(handler))
def _file_response(content: str, commit_sha: str = SHA) -> httpx.Response:
return httpx.Response(200, json={
"type": "file", "encoding": "base64",
"content": base64.b64encode(content.encode()).decode(),
"last_commit_sha": commit_sha, "path": "src/helper.py",
})
def _patched(forge):
"""Stub the owner-keyring lookup (#2778): None → an empty selector, i.e.
the owner has no connections — the old 'no forge configured' state."""
from scribe.services.forge import ForgeSelector
selector = ForgeSelector(() if forge is None else (forge,))
return patch(
"scribe.services.forge.get_forges", AsyncMock(return_value=selector)
)
async def test_no_forge_attaches_nothing():
data = _data()
before = repr(data)
with _patched(None):
await svc.attach_live_body(_note(), data)
assert repr(data) == before
assert "body_source" not in data
async def test_current_code_confirms_and_refreshes_provenance():
# The file wraps the cached code with extra context and trailing spaces —
# containment is judged after the same normalization the verdict hash uses.
file_content = "import os\n\n" + CODE.replace(" + 1", " + 1 ").rstrip() + "\n\n# eof\n"
forge = _forge_with(lambda r: _file_response(file_content))
saved = {}
async def fake_update(uid, nid, **fields):
saved.update(fields)
data = _data()
with _patched(forge), patch.object(svc.notes_svc, "update_note", fake_update):
await svc.attach_live_body(_note(), data)
await background.drain()
assert data["body_source"] == "forge"
assert data["body_freshness"] == "current"
# Reflected in the response...
assert data["snippet"]["provenance"]["commit_sha"] == SHA
# ...and persisted, without touching the body.
assert saved["data"]["provenance"]["commit_sha"] == SHA
assert "body" not in saved
async def test_current_with_same_stored_sha_skips_the_write():
forge = _forge_with(lambda r: _file_response("prefix\n" + CODE))
update = AsyncMock()
data = _data(provenance={"commit_sha": SHA, "fetched_at": "t"})
with _patched(forge), patch.object(svc.notes_svc, "update_note", update):
await svc.attach_live_body(_note(), data)
await background.drain()
assert data["body_freshness"] == "current"
update.assert_not_called()
async def test_stored_sha_short_circuit_skips_the_content_fetch():
"""#2693: when provenance already names a commit and the forge reports no
newer commit touching the path, the pull is confirmed current WITHOUT a
content transfer — the economy that fits pull-time freshness inside
GitHub's rate limits."""
calls = []
def handler(request):
calls.append(request.url.path)
if request.url.path.endswith("/commits"):
return httpx.Response(200, json=[{"sha": SHA}])
raise AssertionError("the content fetch should have been skipped")
update = AsyncMock()
data = _data(provenance={"commit_sha": SHA, "fetched_at": "t"})
with _patched(_forge_with(handler)), patch.object(svc.notes_svc, "update_note", update):
await svc.attach_live_body(_note(), data)
await background.drain()
assert data["body_source"] == "forge"
assert data["body_freshness"] == "current"
assert len(calls) == 1
update.assert_not_called() # same stamp — nothing to persist
async def test_moved_file_falls_through_to_the_full_fetch():
new_sha = "0" * 40
def handler(request):
if request.url.path.endswith("/commits"):
return httpx.Response(200, json=[{"sha": new_sha}])
return _file_response("prefix\n" + CODE, commit_sha=new_sha)
saved = {}
async def fake_update(uid, nid, **fields):
saved.update(fields)
data = _data(provenance={"commit_sha": SHA, "fetched_at": "t"})
with _patched(_forge_with(handler)), patch.object(
svc.notes_svc, "update_note", fake_update
):
await svc.attach_live_body(_note(), data)
await background.drain()
# The file moved but still contains the code — current, with the stamp
# advanced by the authoritative full fetch.
assert data["body_freshness"] == "current"
assert saved["data"]["provenance"]["commit_sha"] == new_sha
async def test_short_circuit_failure_degrades_to_the_full_fetch():
"""A forge whose commits endpoint errors must cost nothing: the full
fetch stays the authoritative path and the pull behaves as before."""
def handler(request):
if request.url.path.endswith("/commits"):
return httpx.Response(500)
return _file_response("prefix\n" + CODE)
update = AsyncMock()
data = _data(provenance={"commit_sha": SHA, "fetched_at": "t"})
with _patched(_forge_with(handler)), patch.object(svc.notes_svc, "update_note", update):
await svc.attach_live_body(_note(), data)
await background.drain()
assert data["body_freshness"] == "current"
update.assert_not_called() # same sha via the full fetch → same-sha skip
async def test_diverged_reports_without_clobbering():
forge = _forge_with(lambda r: _file_response("def helper(x):\n return x - 1\n"))
update = AsyncMock()
data = _data()
with _patched(forge), patch.object(svc.notes_svc, "update_note", update):
await svc.attach_live_body(_note(), data)
await background.drain()
assert data["body_source"] == "cache"
assert data["body_freshness"] == "diverged"
assert data["snippet"]["code"] == CODE
update.assert_not_called()
async def test_missing_stamps_the_verdict_once():
forge = _forge_with(lambda r: httpx.Response(404, json={}))
data = _data()
with _patched(forge), patch.object(
svc, "record_verification", AsyncMock()
) as verdict:
await svc.attach_live_body(_note(), data)
await background.drain()
assert data["body_freshness"] == "missing"
verdict.assert_awaited_once()
assert verdict.await_args.kwargs["status"] == svc.VERIFY_MISSING
# Already stamped missing → no re-stamp on the next pull.
data2 = _data(verification={"status": svc.VERIFY_MISSING, "code_sha": "x"})
with _patched(forge), patch.object(
svc, "record_verification", AsyncMock()
) as verdict2:
await svc.attach_live_body(_note(), data2)
await background.drain()
assert data2["body_freshness"] == "missing"
verdict2.assert_not_awaited()
async def test_unreachable_falls_back_to_cache():
def handler(request):
raise httpx.ConnectError("down", request=request)
data = _data()
with _patched(_forge_with(handler)):
await svc.attach_live_body(_note(), data)
assert data["body_source"] == "cache"
assert data["body_freshness"] == "unreachable"
async def test_hung_forge_costs_bounded_time(monkeypatch):
async def slow_handler(request):
await asyncio.sleep(30)
return _file_response(CODE)
monkeypatch.setattr(svc, "PULL_FETCH_BUDGET_S", 0.2)
data = _data()
start = time.monotonic()
with _patched(_forge_with(slow_handler)):
await svc.attach_live_body(_note(), data)
assert time.monotonic() - start < 2.0
assert data["body_freshness"] == "unreachable"
async def test_foreign_repo_and_placeless_records_read_as_cache():
forge = GiteaForge(BASE, "tok")
data = _data(repo="https://github.com/alice/widget")
with _patched(forge):
await svc.attach_live_body(_note(), data)
assert data["body_freshness"] == "repo-not-on-this-forge"
placeless = {"snippet": {"code": CODE, "locations": []}}
with _patched(forge):
await svc.attach_live_body(_note(), placeless)
assert placeless["body_freshness"] == "no-recorded-location"
def test_both_pull_surfaces_attach_freshness():
"""Source-inspection guard (the CI convention for wiring assertions): the
MCP pull and the REST detail route both decorate — a surface that forgets
is a surface whose readers silently lose freshness."""
import pathlib
root = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe"
mcp_src = (root / "mcp" / "tools" / "snippets.py").read_text()
rest_src = (root / "routes" / "snippets.py").read_text()
assert "attach_live_body" in mcp_src
assert "attach_live_body" in rest_src
async def test_forge_failure_inside_lookup_never_breaks_the_pull():
with patch(
"scribe.services.forge.get_forges", AsyncMock(side_effect=RuntimeError("cfg"))
):
data = _data()
await svc.attach_live_body(_note(), data)
assert "body_source" not in data