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
|
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.
|
"""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
|
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
|
context (the SessionStart hook sends the repo's remote; the server resolves
|
||||||
it here). Idempotent — re-binding the same repo updates the target project.
|
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:
|
Args:
|
||||||
repo_url: the repo's git remote (e.g. the output of
|
repo_url: the repo's git remote (e.g. the output of
|
||||||
`git remote get-url origin` — ssh or https form, both work).
|
`git remote get-url origin` — ssh or https form, both work).
|
||||||
project_id: the Scribe project this repo represents.
|
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()
|
uid = current_user_id()
|
||||||
project = await projects_svc.get_project(uid, project_id)
|
project = await projects_svc.get_project(uid, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise ValueError(f"project {project_id} not found")
|
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 {
|
return {
|
||||||
"repo_key": binding.repo_key,
|
"repo_key": binding.repo_key,
|
||||||
"project_id": binding.project_id,
|
"project_id": binding.project_id,
|
||||||
"project_title": project.title,
|
"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
|
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
repo_key: Mapped[str] = mapped_column(Text, 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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +39,7 @@ class RepoBinding(Base, TimestampMixin):
|
|||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
"project_id": self.project_id,
|
"project_id": self.project_id,
|
||||||
"repo_key": self.repo_key,
|
"repo_key": self.repo_key,
|
||||||
|
"ref": self.ref,
|
||||||
"created_at": iso(self.created_at),
|
"created_at": iso(self.created_at),
|
||||||
"updated_at": iso(self.updated_at),
|
"updated_at": iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from typing import NamedTuple
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from scribe.services.forge import ForgeSelector, get_forges
|
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
|
from scribe.services.settings import get_setting, set_setting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -407,12 +407,15 @@ async def compute_coverage(
|
|||||||
# the project's repos (#2792).
|
# the project's repos (#2792).
|
||||||
canons = None
|
canons = None
|
||||||
proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0}
|
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)
|
hit = selector.resolve(key)
|
||||||
if hit is None:
|
if hit is None:
|
||||||
continue # bound to a host no connection serves
|
continue # bound to a host no connection serves
|
||||||
forge, api_repo = hit
|
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))
|
definitions = definitions_from_archive(await forge.archive(api_repo, ref))
|
||||||
# The head commit is provenance sugar on the ledger rows; failing to
|
# 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
|
# 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()
|
return row.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBinding:
|
async def set_binding(
|
||||||
"""Create or update the binding for a repo. Idempotent on (user, repo_key)."""
|
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)
|
key = normalize_repo_key(raw_repo)
|
||||||
if not key:
|
if not key:
|
||||||
raise ValueError("repo remote is empty or unparseable")
|
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)
|
session.add(binding)
|
||||||
else:
|
else:
|
||||||
binding.project_id = project_id
|
binding.project_id = project_id
|
||||||
|
if ref is not None:
|
||||||
|
binding.ref = ref.strip() or None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(binding)
|
await session.refresh(binding)
|
||||||
return binding
|
return binding
|
||||||
@@ -100,6 +110,18 @@ async def list_bindings(user_id: int) -> list[RepoBinding]:
|
|||||||
return list(rows.scalars().all())
|
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]:
|
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).
|
"""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
|
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():
|
def test_coverage_routes_are_registered():
|
||||||
from scribe.app import create_app
|
from scribe.app import create_app
|
||||||
|
|
||||||
@@ -188,7 +195,8 @@ def _forge(tar_bytes: bytes):
|
|||||||
path = request.url.path
|
path = request.url.path
|
||||||
if path == "/api/v1/repos/alice/widget":
|
if path == "/api/v1/repos/alice/widget":
|
||||||
return httpx.Response(200, json={"default_branch": "main"})
|
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(200, content=tar_bytes)
|
||||||
return httpx.Response(404, json={"message": "not found"})
|
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()
|
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()
|
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