"""Repo -> project binding resolution. The SessionStart hook sends the working repo's git remote; this module normalizes it to a stable `repo_key` and resolves it to a project id. The key deliberately collapses ssh/https forms of the same remote so the operator binds a repo once regardless of how it's cloned. """ from __future__ import annotations import re from sqlalchemy import select from scribe.models import async_session from scribe.models.repo_binding import RepoBinding def normalize_repo_key(raw: str) -> str: """Reduce a git remote URL to a stable, scheme-agnostic ``host/owner/repo``. Collapses the equivalent clone URLs to one key, e.g.:: git@git.fabledsword.com:bvandeusen/FabledScribe.git https://git.fabledsword.com/bvandeusen/fabledscribe.git ssh://git@git.fabledsword.com:22/bvandeusen/fabledscribe all normalize to ``git.fabledsword.com/bvandeusen/fabledscribe``. Returns "" for empty/garbage input so callers can treat it as "no repo". """ s = (raw or "").strip() if not s: return "" # scp-like syntax: git@host:owner/repo(.git) -> ssh://host/owner/repo scp = re.match(r"^[^/@]+@([^:/]+):(.+)$", s) if scp and "://" not in s: s = f"//{scp.group(1)}/{scp.group(2)}" else: # strip an explicit scheme (https://, http://, ssh://, git://, git+ssh://) s = re.sub(r"^[a-z][a-z0-9+.\-]*://", "//", s, flags=re.IGNORECASE) if not s.startswith("//"): s = "//" + s body = s[2:] # drop leading // # strip userinfo (user@ or user:pass@) on the authority body = re.sub(r"^[^/]*@", "", body) # drop an explicit port on the host (host:22/...) body = re.sub(r"^([^/:]+):\d+", r"\1", body) # strip trailing .git and surrounding slashes body = body.strip("/") if body.endswith(".git"): body = body[:-4] return body.lower() async def resolve_project(user_id: int, raw_repo: str) -> int | None: """Return the bound project id for a repo remote, or None if unbound.""" key = normalize_repo_key(raw_repo) if not key: return None async with async_session() as session: row = await session.execute( select(RepoBinding.project_id).where( RepoBinding.user_id == user_id, RepoBinding.repo_key == key ) ) 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).""" key = normalize_repo_key(raw_repo) if not key: raise ValueError("repo remote is empty or unparseable") async with async_session() as session: existing = await session.execute( select(RepoBinding).where( RepoBinding.user_id == user_id, RepoBinding.repo_key == key ) ) binding = existing.scalar_one_or_none() if binding is None: binding = RepoBinding(user_id=user_id, repo_key=key, project_id=project_id) session.add(binding) else: binding.project_id = project_id await session.commit() await session.refresh(binding) return binding async def list_bindings(user_id: int) -> list[RepoBinding]: async with async_session() as session: rows = await session.execute( select(RepoBinding) .where(RepoBinding.user_id == user_id) .order_by(RepoBinding.repo_key) ) return list(rows.scalars().all()) async def delete_binding(user_id: int, raw_repo: str) -> bool: """Remove a repo's binding. Returns True if a row was deleted.""" key = normalize_repo_key(raw_repo) if not key: return False async with async_session() as session: row = await session.execute( select(RepoBinding).where( RepoBinding.user_id == user_id, RepoBinding.repo_key == key ) ) binding = row.scalar_one_or_none() if binding is None: return False await session.delete(binding) await session.commit() return True