"""Tests for Signal 3: branch-to-issue commit reachability. find_proposals_by_branch_reachability returns open proposals where a commit anchor is reachable from from_branch but NOT yet reachable from to_branch. Covers: - Anchor reachable exclusively from from_branch → proposal returned - Anchor on to_branch (already integrated) → NOT returned - Anchor reachable at depth 2 from from_branch → returned - Anchor reachable from both branches (common ancestor) → NOT returned - Empty commit_anchors → empty list - Anchor not in any branch commit graph → empty list - Short (8-char) anchor prefix resolution - Merged proposals are excluded (from_branch deleted at merge) - Cross-repo isolation """ from __future__ import annotations import secrets from datetime import datetime, timezone import pytest from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id, now_utc_iso from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_proposal_id, compute_repo_id from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo from musehub.db.musehub_social_models import MusehubProposal from musehub.services import musehub_issues # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _uid() -> str: return secrets.token_hex(16) def _commit_id() -> str: return fake_id(_uid()) async def _make_repo(db: AsyncSession, slug: str) -> str: created_at = datetime.now(tz=timezone.utc) owner_id = compute_identity_id(b"testuser") repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat()) repo = MusehubRepo( repo_id=repo_id, name=slug, owner="testuser", slug=slug, visibility="public", owner_user_id=owner_id, created_at=created_at, updated_at=created_at, ) db.add(repo) await db.commit() await db.refresh(repo) return str(repo.repo_id) async def _make_commit( db: AsyncSession, repo_id: str, *, branch: str, parent_ids: list[str] | None = None, commit_id: str | None = None, ) -> str: cid = commit_id or _commit_id() db.add(MusehubCommit( commit_id=cid, branch=branch, parent_ids=parent_ids or [], message="test", author="tester", timestamp=datetime.now(timezone.utc), )) db.add(MusehubCommitRef(repo_id=repo_id, commit_id=cid)) await db.flush() return cid async def _make_branch( db: AsyncSession, repo_id: str, name: str, head: str | None = None ) -> None: db.add(MusehubBranch(branch_id=compute_branch_id(repo_id, name), repo_id=repo_id, name=name, head_commit_id=head)) await db.flush() async def _make_proposal( db: AsyncSession, repo_id: str, *, from_branch: str, to_branch: str, state: str = "open", number: int = 1, ) -> str: author_id = compute_identity_id(b"tester") pid = compute_proposal_id(repo_id, author_id, from_branch, to_branch, now_utc_iso()) db.add(MusehubProposal( proposal_id=pid, repo_id=repo_id, proposal_number=number, title=f"Proposal {number}", body="", state=state, from_branch=from_branch, to_branch=to_branch, author="tester", )) await db.flush() return pid # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- async def test_empty_anchors_returns_empty(db_session: AsyncSession) -> None: repo_id = await _make_repo(db_session, "br-empty") result = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_id, [] ) assert result == [] async def test_anchor_exclusively_on_from_branch_matches(db_session: AsyncSession) -> None: """Anchor commit is reachable from from_branch but not from to_branch → match.""" repo_id = await _make_repo(db_session, "br-exclusive") # to_branch: one commit (A) a = await _make_commit(db_session, repo_id, branch="main") await _make_branch(db_session, repo_id, "main", a) # from_branch: builds on A, adds B (the anchor) b = await _make_commit(db_session, repo_id, branch="feat/fix", parent_ids=[a]) await _make_branch(db_session, repo_id, "feat/fix", b) pid = await _make_proposal( db_session, repo_id, from_branch="feat/fix", to_branch="main", number=1, ) await db_session.commit() results = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_id, [b] ) assert len(results) == 1 assert results[0]["proposal_id"] == pid assert results[0]["state"] == "open" assert results[0]["match_reason"] == "branch_reachability" async def test_anchor_already_in_to_branch_not_matched(db_session: AsyncSession) -> None: """Anchor already reachable from to_branch → NOT a match (already integrated).""" repo_id = await _make_repo(db_session, "br-already-integrated") # Both branches share commit A (which is the anchor). a = await _make_commit(db_session, repo_id, branch="main") b = await _make_commit(db_session, repo_id, branch="main", parent_ids=[a]) await _make_branch(db_session, repo_id, "main", b) # from_branch also descends from A — but A is also in to_branch (main). c = await _make_commit(db_session, repo_id, branch="feat/already", parent_ids=[a]) await _make_branch(db_session, repo_id, "feat/already", c) await _make_proposal( db_session, repo_id, from_branch="feat/already", to_branch="main", number=1, ) await db_session.commit() # A is a common ancestor → excluded by the NOT EXISTS anti-join. results = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_id, [a] ) assert results == [] async def test_anchor_at_depth_2_from_from_branch(db_session: AsyncSession) -> None: """Anchor is a grandparent of from_branch HEAD (depth 2) → match.""" repo_id = await _make_repo(db_session, "br-depth-2") # to_branch: just A a = await _make_commit(db_session, repo_id, branch="main") await _make_branch(db_session, repo_id, "main", a) # feat: A → B (anchor) → C (HEAD) b = await _make_commit(db_session, repo_id, branch="feat/deep", parent_ids=[a]) c = await _make_commit(db_session, repo_id, branch="feat/deep", parent_ids=[b]) await _make_branch(db_session, repo_id, "feat/deep", c) pid = await _make_proposal( db_session, repo_id, from_branch="feat/deep", to_branch="main", number=1, ) await db_session.commit() # B is at depth 2 from HEAD of feat/deep; not in main. results = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_id, [b] ) assert len(results) == 1 assert results[0]["proposal_id"] == pid async def test_short_anchor_prefix_resolved(db_session: AsyncSession) -> None: """8-char short anchor resolves via prefix match.""" repo_id = await _make_repo(db_session, "br-short-anchor") a = await _make_commit(db_session, repo_id, branch="main") await _make_branch(db_session, repo_id, "main", a) b = await _make_commit(db_session, repo_id, branch="feat/prefix", parent_ids=[a]) await _make_branch(db_session, repo_id, "feat/prefix", b) pid = await _make_proposal( db_session, repo_id, from_branch="feat/prefix", to_branch="main", number=1, ) await db_session.commit() results = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_id, [b[:8]] # short-form anchor ) assert len(results) == 1 assert results[0]["proposal_id"] == pid async def test_merged_proposal_not_returned(db_session: AsyncSession) -> None: """Merged proposals have from_branch deleted → branch HEAD gone → not returned.""" repo_id = await _make_repo(db_session, "br-merged-excluded") a = await _make_commit(db_session, repo_id, branch="main") # Deliberately do NOT create a "feat/done" branch row — simulates post-merge deletion. await _make_branch(db_session, repo_id, "main", a) pid = await _make_proposal( db_session, repo_id, from_branch="feat/done", to_branch="main", state="merged", number=1, ) await db_session.commit() results = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_id, [a] ) # Merged proposals are filtered by state='open'; also from_branch branch row gone. assert results == [] async def test_unresolved_anchor_returns_empty(db_session: AsyncSession) -> None: """Anchor that doesn't match any stored commit → nothing to walk → empty.""" repo_id = await _make_repo(db_session, "br-unresolved") await db_session.commit() results = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_id, ["cafebabe"] ) assert results == [] async def test_cross_repo_isolation(db_session: AsyncSession) -> None: """Query is strictly scoped to repo_id — no leakage between repos.""" repo_a = await _make_repo(db_session, "br-repo-a") repo_b = await _make_repo(db_session, "br-repo-b") a = await _make_commit(db_session, repo_a, branch="main") await _make_branch(db_session, repo_a, "main", a) b = await _make_commit(db_session, repo_a, branch="feat/x", parent_ids=[a]) await _make_branch(db_session, repo_a, "feat/x", b) await _make_proposal(db_session, repo_a, from_branch="feat/x", to_branch="main") await db_session.commit() # Query against repo_b — must return nothing. results = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_b, [b] ) assert results == [] async def test_multiple_open_proposals_only_matching_returned( db_session: AsyncSession, ) -> None: """With two open proposals, only the one containing the anchor is returned.""" repo_id = await _make_repo(db_session, "br-multi") a = await _make_commit(db_session, repo_id, branch="main") await _make_branch(db_session, repo_id, "main", a) # Proposal 1: feat/one — contains anchor B b = await _make_commit(db_session, repo_id, branch="feat/one", parent_ids=[a]) await _make_branch(db_session, repo_id, "feat/one", b) pid1 = await _make_proposal( db_session, repo_id, from_branch="feat/one", to_branch="main", number=1 ) # Proposal 2: feat/two — contains commit C (different, not the anchor) c = await _make_commit(db_session, repo_id, branch="feat/two", parent_ids=[a]) await _make_branch(db_session, repo_id, "feat/two", c) await _make_proposal( db_session, repo_id, from_branch="feat/two", to_branch="main", number=2 ) await db_session.commit() results = await musehub_issues.find_proposals_by_branch_reachability( db_session, repo_id, [b] ) assert len(results) == 1 assert results[0]["proposal_id"] == pid1