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
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>
275 lines
10 KiB
Python
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
|