feat(ledger): a repo binding names the branch its ledger follows — bind_repo(ref=) (#2873, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Canceled after 30s
CI & Build / Python tests (push) Canceled after 30s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Canceled after 30s
CI & Build / Python tests (push) Canceled after 30s
CI & Build / Build & push image (push) Canceled after 0s
Project 2 is bound to main, so every consolidation of the 2026-08 audit was
invisible to the ledger until the dev→main merge; the operator works on dev
(rule 1). repo_bindings.ref (migration 0082, nullable) is the branch the
coverage refresh reads; NULL keeps the forge default branch. set_binding takes
ref (name sets, "" clears, None leaves standing); bindings_for_project feeds
the refresh; bind_repo exposes ref ("-" clears). to_dict carries it.
Operator decision on #2873 (2026-08-21): per-binding ref, chosen at bind time,
default the repo default branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
|
||||
|
||||
Revision ID: 0082
|
||||
Revises: 0081
|
||||
Create Date: 2026-08-21
|
||||
|
||||
A repo binding used to imply the repo's default branch; the shape ledger
|
||||
therefore only saw work after a merge to main, while the operator's work
|
||||
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
|
||||
NULL keeps today's behaviour (the forge's default branch).
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0082"
|
||||
down_revision = "0081"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("repo_bindings", "ref")
|
||||
@@ -13,28 +13,43 @@ from scribe.services import projects as projects_svc
|
||||
from scribe.services import repo_bindings as repo_bindings_svc
|
||||
|
||||
|
||||
async def bind_repo(repo_url: str, project_id: int) -> dict:
|
||||
async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict:
|
||||
"""Bind a git repository to a Scribe project for session-start context.
|
||||
|
||||
After this, any session started in that repo auto-loads the project's
|
||||
context (the SessionStart hook sends the repo's remote; the server resolves
|
||||
it here). Idempotent — re-binding the same repo updates the target project.
|
||||
|
||||
The binding is also what the shape ledger reads (refresh_pattern_coverage):
|
||||
`ref` names the branch it follows. Default (""): the repo's default branch
|
||||
— which means the ledger only sees work after a merge. A dev-first project
|
||||
(rule 1: dev is home) should bind with ref="dev" so classification follows
|
||||
the push, not the merge. Re-binding with ref="" keeps the standing ref;
|
||||
pass ref="-" to clear it back to the default branch.
|
||||
|
||||
Args:
|
||||
repo_url: the repo's git remote (e.g. the output of
|
||||
`git remote get-url origin` — ssh or https form, both work).
|
||||
project_id: the Scribe project this repo represents.
|
||||
ref: branch the ledger follows ("" = leave as is / default branch on
|
||||
a new binding; "-" = clear to the default branch).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
project = await projects_svc.get_project(uid, project_id)
|
||||
if project is None:
|
||||
raise ValueError(f"project {project_id} not found")
|
||||
binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id)
|
||||
ref_arg = None if not ref else ("" if ref.strip() == "-" else ref)
|
||||
binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id, ref_arg)
|
||||
follows = binding.ref or "the default branch"
|
||||
return {
|
||||
"repo_key": binding.repo_key,
|
||||
"project_id": binding.project_id,
|
||||
"project_title": project.title,
|
||||
"message": f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}).",
|
||||
"ref": binding.ref,
|
||||
"message": (
|
||||
f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}); "
|
||||
f"the ledger follows {follows}."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ class RepoBinding(Base, TimestampMixin):
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
repo_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# The branch the coverage refresh reads for this binding (#2873); NULL =
|
||||
# the forge's default branch. Chosen at bind time so a dev-first project
|
||||
# can have its ledger follow dev instead of waiting for the merge.
|
||||
ref: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -35,6 +39,7 @@ class RepoBinding(Base, TimestampMixin):
|
||||
"user_id": self.user_id,
|
||||
"project_id": self.project_id,
|
||||
"repo_key": self.repo_key,
|
||||
"ref": self.ref,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ from typing import NamedTuple
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.services.forge import ForgeSelector, get_forges
|
||||
from scribe.services.repo_bindings import keys_for_project
|
||||
from scribe.services.repo_bindings import bindings_for_project
|
||||
from scribe.services.settings import get_setting, set_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -407,12 +407,15 @@ async def compute_coverage(
|
||||
# the project's repos (#2792).
|
||||
canons = None
|
||||
proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0}
|
||||
for key in await keys_for_project(user_id, project_id):
|
||||
for binding in await bindings_for_project(user_id, project_id):
|
||||
key = binding.repo_key
|
||||
hit = selector.resolve(key)
|
||||
if hit is None:
|
||||
continue # bound to a host no connection serves
|
||||
forge, api_repo = hit
|
||||
ref = await forge.default_branch(api_repo)
|
||||
# The binding's own ref when it names one (#2873: a dev-first project
|
||||
# has its ledger follow dev), else the forge's default branch.
|
||||
ref = binding.ref or await forge.default_branch(api_repo)
|
||||
definitions = definitions_from_archive(await forge.archive(api_repo, ref))
|
||||
# The head commit is provenance sugar on the ledger rows; failing to
|
||||
# learn it must not fail the sync — the ref names the point well
|
||||
|
||||
@@ -68,8 +68,16 @@ async def resolve_project(user_id: int, raw_repo: str) -> int | None:
|
||||
return row.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBinding:
|
||||
"""Create or update the binding for a repo. Idempotent on (user, repo_key)."""
|
||||
async def set_binding(
|
||||
user_id: int, raw_repo: str, project_id: int, ref: str | None = None,
|
||||
) -> RepoBinding:
|
||||
"""Create or update the binding for a repo. Idempotent on (user, repo_key).
|
||||
|
||||
``ref`` (#2873) is the branch the coverage refresh reads for this
|
||||
binding: a name sets it, ``""`` clears it back to the forge's default
|
||||
branch, ``None`` leaves whatever stands (a re-bind that only moves the
|
||||
project keeps the ref it had).
|
||||
"""
|
||||
key = normalize_repo_key(raw_repo)
|
||||
if not key:
|
||||
raise ValueError("repo remote is empty or unparseable")
|
||||
@@ -85,6 +93,8 @@ async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBindi
|
||||
session.add(binding)
|
||||
else:
|
||||
binding.project_id = project_id
|
||||
if ref is not None:
|
||||
binding.ref = ref.strip() or None
|
||||
await session.commit()
|
||||
await session.refresh(binding)
|
||||
return binding
|
||||
@@ -100,6 +110,18 @@ async def list_bindings(user_id: int) -> list[RepoBinding]:
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def bindings_for_project(user_id: int, project_id: int) -> list[RepoBinding]:
|
||||
"""Every binding of a project — key AND the ref its ledger follows (#2873)."""
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
select(RepoBinding).where(
|
||||
RepoBinding.user_id == user_id,
|
||||
RepoBinding.project_id == project_id,
|
||||
).order_by(RepoBinding.repo_key)
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def keys_for_project(user_id: int, project_id: int) -> list[str]:
|
||||
"""Every repo key bound to a project — the snippet→forge join (#2691).
|
||||
|
||||
|
||||
@@ -168,6 +168,13 @@ def test_coverage_line_is_evidence_carrying_and_labeled_estimate():
|
||||
assert "internal/api, web/src/components" in line
|
||||
|
||||
|
||||
def test_bind_repo_tool_takes_a_ref():
|
||||
"""#2873: the binding names the branch the ledger follows."""
|
||||
from scribe.mcp.server import build_mcp_server
|
||||
tool = build_mcp_server()._tool_manager.get_tool("bind_repo")
|
||||
assert "ref" in tool.parameters.get("properties", {})
|
||||
|
||||
|
||||
def test_coverage_routes_are_registered():
|
||||
from scribe.app import create_app
|
||||
|
||||
@@ -188,7 +195,8 @@ def _forge(tar_bytes: bytes):
|
||||
path = request.url.path
|
||||
if path == "/api/v1/repos/alice/widget":
|
||||
return httpx.Response(200, json={"default_branch": "main"})
|
||||
if path == "/api/v1/repos/alice/widget/archive/main.tar.gz":
|
||||
if path in ("/api/v1/repos/alice/widget/archive/main.tar.gz",
|
||||
"/api/v1/repos/alice/widget/archive/dev.tar.gz"):
|
||||
return httpx.Response(200, content=tar_bytes)
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
|
||||
@@ -533,3 +541,23 @@ def test_scoped_definitions_are_vue_script_setup_and_scoped_style_only():
|
||||
extract_definitions(".card {\n x: 1;\n}\n")) == set()
|
||||
assert scoped_definitions("src/a.py", "def load():\n pass\n", extract_definitions("def load():\n pass\n")) == set()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_binding_ref_is_the_branch_the_ledger_follows(seeded):
|
||||
"""#2873: a binding that names a ref is read at that ref (not the forge's
|
||||
default branch); "" clears it; None on a re-bind leaves it standing."""
|
||||
from scribe.services.repo_bindings import bindings_for_project, set_binding
|
||||
uid, pid = seeded["uid"], seeded["pid"]
|
||||
b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "dev")
|
||||
assert b.ref == "dev"
|
||||
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
|
||||
assert coverage["repos"][0]["ref"] == "dev"
|
||||
# A re-bind without a ref keeps it; "" clears it back to the default branch.
|
||||
b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid)
|
||||
assert b.ref == "dev"
|
||||
b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "")
|
||||
assert b.ref is None
|
||||
assert [x.ref for x in await bindings_for_project(uid, pid)] == [None]
|
||||
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
|
||||
assert coverage["repos"][0]["ref"] == "main"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user