"""Tests for Signal 1: commit graph containment in find_proposals_by_commit_graph. Covers: - Merged proposal matched via BFS ancestor walk (depth 1 — direct parent) - Merged proposal matched via BFS ancestor walk (depth 2 — grandparent) - Open proposal matched via branch membership - No match when anchors do not intersect the commit graph - Short-form (8-char) anchor resolution - Empty commit_anchors returns empty list immediately - Proposals from another repo are not returned """ from __future__ import annotations import secrets from datetime import datetime, timezone import pytest from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_identity_id, compute_repo_id from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo from musehub.db.musehub_social_models import MusehubIssue, MusehubProposal from musehub.services import musehub_issues # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _uid() -> str: return secrets.token_hex(16) def _commit_id() -> str: """Return a random 64-char hex commit ID.""" return secrets.token_hex(32) async def _make_repo(db: AsyncSession, slug: str = "graph-test") -> str: created_at = datetime.now(tz=timezone.utc) owner_id = compute_identity_id(b"testuser") repo = MusehubRepo( repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()), 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, *, parent_ids: list[str] | None = None, branch: str = "dev", commit_id: str | None = None, ) -> str: """Seed a commit row and return its commit_id.""" cid = commit_id or _commit_id() row = MusehubCommit( commit_id=cid, message="test commit", author="tester", branch=branch, parent_ids=parent_ids or [], timestamp=datetime.now(timezone.utc), ) db.add(row) db.add(MusehubCommitRef(repo_id=repo_id, commit_id=cid)) await db.flush() return cid async def _make_proposal( db: AsyncSession, repo_id: str, *, state: str = "merged", from_branch: str = "feat/x", to_branch: str = "main", merge_commit_id: str | None = None, number: int = 1, ) -> str: """Seed a proposal and return its proposal_id.""" pid = _uid() row = 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", merge_commit_id=merge_commit_id, ) db.add(row) await db.flush() return pid # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- async def test_empty_anchors_returns_empty(db_session: AsyncSession) -> None: """No anchors → fast-path returns [] without hitting the DB.""" repo_id = await _make_repo(db_session, "empty-anchors") result = await musehub_issues.find_proposals_by_commit_graph( db_session, repo_id, [] ) assert result == [] async def test_merged_proposal_matched_depth_1(db_session: AsyncSession) -> None: """Merged proposal linked when anchor commit is a direct parent of merge_commit_id.""" repo_id = await _make_repo(db_session, "merged-depth-1") # anchor commit (the feature branch head) anchor_cid = await _make_commit(db_session, repo_id, branch="feat/security") # merge commit whose parent list includes the anchor merge_cid = await _make_commit( db_session, repo_id, parent_ids=[anchor_cid], branch="main" ) pid = await _make_proposal( db_session, repo_id, state="merged", merge_commit_id=merge_cid, number=1 ) await db_session.commit() results = await musehub_issues.find_proposals_by_commit_graph( db_session, repo_id, [anchor_cid] ) assert len(results) == 1 assert results[0]["proposal_id"] == pid assert results[0]["state"] == "merged" assert results[0]["match_reason"] == "commit_graph" async def test_merged_proposal_matched_depth_2(db_session: AsyncSession) -> None: """Merged proposal linked when anchor is a grandparent (depth 2) of merge_commit_id.""" repo_id = await _make_repo(db_session, "merged-depth-2") # anchor: grandparent commit grandparent_cid = await _make_commit(db_session, repo_id, branch="feat/y") # parent: its child parent_cid = await _make_commit( db_session, repo_id, parent_ids=[grandparent_cid], branch="feat/y" ) # merge commit merge_cid = await _make_commit( db_session, repo_id, parent_ids=[parent_cid], branch="main" ) pid = await _make_proposal( db_session, repo_id, state="merged", merge_commit_id=merge_cid, number=2 ) await db_session.commit() results = await musehub_issues.find_proposals_by_commit_graph( db_session, repo_id, [grandparent_cid] ) assert len(results) == 1 assert results[0]["proposal_id"] == pid assert results[0]["match_reason"] == "commit_graph" async def test_open_proposal_matched_via_branch(db_session: AsyncSession) -> None: """Open proposal linked when an anchor commit lives on the proposal's from_branch.""" repo_id = await _make_repo(db_session, "open-branch") # A commit on the feature branch that matches the anchor anchor_cid = await _make_commit( db_session, repo_id, branch="feat/open-fix" ) pid = await _make_proposal( db_session, repo_id, state="open", from_branch="feat/open-fix", to_branch="main", merge_commit_id=None, number=3, ) await db_session.commit() results = await musehub_issues.find_proposals_by_commit_graph( db_session, repo_id, [anchor_cid] ) assert len(results) == 1 assert results[0]["proposal_id"] == pid assert results[0]["state"] == "open" assert results[0]["match_reason"] == "commit_graph" async def test_no_match_when_anchor_not_in_graph(db_session: AsyncSession) -> None: """No results when the anchor commit is unrelated to any proposal's commit graph.""" repo_id = await _make_repo(db_session, "no-match") unrelated_cid = await _make_commit(db_session, repo_id, branch="feat/unrelated") # Proposal whose ancestors don't include unrelated_cid other_cid = await _make_commit(db_session, repo_id, branch="feat/other") merge_cid = await _make_commit( db_session, repo_id, parent_ids=[other_cid], branch="main" ) await _make_proposal( db_session, repo_id, state="merged", merge_commit_id=merge_cid, number=4 ) await db_session.commit() results = await musehub_issues.find_proposals_by_commit_graph( db_session, repo_id, [unrelated_cid] ) assert results == [] async def test_short_anchor_prefix_match(db_session: AsyncSession) -> None: """Short (8-char) anchor resolves correctly via prefix match.""" repo_id = await _make_repo(db_session, "short-anchor") anchor_cid = await _make_commit(db_session, repo_id, branch="feat/z") merge_cid = await _make_commit( db_session, repo_id, parent_ids=[anchor_cid], branch="main" ) pid = await _make_proposal( db_session, repo_id, state="merged", merge_commit_id=merge_cid, number=5 ) await db_session.commit() # Use only the first 8 chars of the full commit ID short_anchor = anchor_cid[:8] results = await musehub_issues.find_proposals_by_commit_graph( db_session, repo_id, [short_anchor] ) assert len(results) == 1 assert results[0]["proposal_id"] == pid async def test_proposals_from_other_repo_not_returned(db_session: AsyncSession) -> None: """Commit graph lookup is strictly scoped to the given repo_id.""" repo_a = await _make_repo(db_session, "graph-repo-a") repo_b = await _make_repo(db_session, "graph-repo-b") # Anchor and proposal are in repo_a anchor_cid = await _make_commit(db_session, repo_a, branch="feat/q") merge_cid = await _make_commit( db_session, repo_a, parent_ids=[anchor_cid], branch="main" ) await _make_proposal( db_session, repo_a, state="merged", merge_commit_id=merge_cid, number=6 ) await db_session.commit() # Query against repo_b — should find nothing results = await musehub_issues.find_proposals_by_commit_graph( db_session, repo_b, [anchor_cid] ) assert results == [] async def test_anchor_unresolved_returns_empty(db_session: AsyncSession) -> None: """Anchor that doesn't match any stored commit yields an empty result.""" repo_id = await _make_repo(db_session, "unresolved-anchor") await db_session.commit() phantom_anchor = "deadbeef" # not in musehub_commits results = await musehub_issues.find_proposals_by_commit_graph( db_session, repo_id, [phantom_anchor] ) assert results == []