test_musehub_issues_commit_graph.py
python
sha256:7d6dd8f4a89e2d1fef2d84f6e65feaff51385d382f466766b7f690a22ec18e32
fix: fall back to DB ancestry check when mpack-only fast-fo…
Sonnet 4.6
patch
7 days ago
| 1 | """Tests for Signal 1: commit graph containment in find_proposals_by_commit_graph. |
| 2 | |
| 3 | Covers: |
| 4 | - Merged proposal matched via BFS ancestor walk (depth 1 — direct parent) |
| 5 | - Merged proposal matched via BFS ancestor walk (depth 2 — grandparent) |
| 6 | - Open proposal matched via branch membership |
| 7 | - No match when anchors do not intersect the commit graph |
| 8 | - Short-form (8-char) anchor resolution |
| 9 | - Empty commit_anchors returns empty list immediately |
| 10 | - Proposals from another repo are not returned |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import secrets |
| 15 | from datetime import datetime, timezone |
| 16 | |
| 17 | import pytest |
| 18 | from sqlalchemy.ext.asyncio import AsyncSession |
| 19 | |
| 20 | from musehub.core.genesis import compute_identity_id, compute_repo_id |
| 21 | |
| 22 | from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo |
| 23 | from musehub.db.musehub_social_models import MusehubIssue, MusehubProposal |
| 24 | from musehub.services import musehub_issues |
| 25 | |
| 26 | |
| 27 | # --------------------------------------------------------------------------- |
| 28 | # Helpers |
| 29 | # --------------------------------------------------------------------------- |
| 30 | |
| 31 | |
| 32 | def _uid() -> str: |
| 33 | return secrets.token_hex(16) |
| 34 | |
| 35 | |
| 36 | def _commit_id() -> str: |
| 37 | """Return a random 64-char hex commit ID.""" |
| 38 | return secrets.token_hex(32) |
| 39 | |
| 40 | |
| 41 | async def _make_repo(db: AsyncSession, slug: str = "graph-test") -> str: |
| 42 | created_at = datetime.now(tz=timezone.utc) |
| 43 | owner_id = compute_identity_id(b"testuser") |
| 44 | repo = MusehubRepo( |
| 45 | repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()), |
| 46 | name=slug, |
| 47 | owner="testuser", |
| 48 | slug=slug, |
| 49 | visibility="public", |
| 50 | owner_user_id=owner_id, |
| 51 | created_at=created_at, |
| 52 | updated_at=created_at, |
| 53 | ) |
| 54 | db.add(repo) |
| 55 | await db.commit() |
| 56 | await db.refresh(repo) |
| 57 | return str(repo.repo_id) |
| 58 | |
| 59 | |
| 60 | async def _make_commit( |
| 61 | db: AsyncSession, |
| 62 | repo_id: str, |
| 63 | *, |
| 64 | parent_ids: list[str] | None = None, |
| 65 | branch: str = "dev", |
| 66 | commit_id: str | None = None, |
| 67 | ) -> str: |
| 68 | """Seed a commit row and return its commit_id.""" |
| 69 | cid = commit_id or _commit_id() |
| 70 | row = MusehubCommit( |
| 71 | commit_id=cid, |
| 72 | message="test commit", |
| 73 | author="tester", |
| 74 | branch=branch, |
| 75 | parent_ids=parent_ids or [], |
| 76 | timestamp=datetime.now(timezone.utc), |
| 77 | ) |
| 78 | db.add(row) |
| 79 | db.add(MusehubCommitRef(repo_id=repo_id, commit_id=cid)) |
| 80 | await db.flush() |
| 81 | return cid |
| 82 | |
| 83 | |
| 84 | async def _make_proposal( |
| 85 | db: AsyncSession, |
| 86 | repo_id: str, |
| 87 | *, |
| 88 | state: str = "merged", |
| 89 | from_branch: str = "feat/x", |
| 90 | to_branch: str = "main", |
| 91 | merge_commit_id: str | None = None, |
| 92 | number: int = 1, |
| 93 | ) -> str: |
| 94 | """Seed a proposal and return its proposal_id.""" |
| 95 | pid = _uid() |
| 96 | row = MusehubProposal( |
| 97 | proposal_id=pid, |
| 98 | repo_id=repo_id, |
| 99 | proposal_number=number, |
| 100 | title=f"Proposal {number}", |
| 101 | body="", |
| 102 | state=state, |
| 103 | from_branch=from_branch, |
| 104 | to_branch=to_branch, |
| 105 | author="tester", |
| 106 | merge_commit_id=merge_commit_id, |
| 107 | ) |
| 108 | db.add(row) |
| 109 | await db.flush() |
| 110 | return pid |
| 111 | |
| 112 | |
| 113 | # --------------------------------------------------------------------------- |
| 114 | # Tests |
| 115 | # --------------------------------------------------------------------------- |
| 116 | |
| 117 | |
| 118 | async def test_empty_anchors_returns_empty(db_session: AsyncSession) -> None: |
| 119 | """No anchors → fast-path returns [] without hitting the DB.""" |
| 120 | repo_id = await _make_repo(db_session, "empty-anchors") |
| 121 | result = await musehub_issues.find_proposals_by_commit_graph( |
| 122 | db_session, repo_id, [] |
| 123 | ) |
| 124 | assert result == [] |
| 125 | |
| 126 | |
| 127 | async def test_merged_proposal_matched_depth_1(db_session: AsyncSession) -> None: |
| 128 | """Merged proposal linked when anchor commit is a direct parent of merge_commit_id.""" |
| 129 | repo_id = await _make_repo(db_session, "merged-depth-1") |
| 130 | |
| 131 | # anchor commit (the feature branch head) |
| 132 | anchor_cid = await _make_commit(db_session, repo_id, branch="feat/security") |
| 133 | |
| 134 | # merge commit whose parent list includes the anchor |
| 135 | merge_cid = await _make_commit( |
| 136 | db_session, repo_id, parent_ids=[anchor_cid], branch="main" |
| 137 | ) |
| 138 | |
| 139 | pid = await _make_proposal( |
| 140 | db_session, repo_id, state="merged", merge_commit_id=merge_cid, number=1 |
| 141 | ) |
| 142 | await db_session.commit() |
| 143 | |
| 144 | results = await musehub_issues.find_proposals_by_commit_graph( |
| 145 | db_session, repo_id, [anchor_cid] |
| 146 | ) |
| 147 | assert len(results) == 1 |
| 148 | assert results[0]["proposal_id"] == pid |
| 149 | assert results[0]["state"] == "merged" |
| 150 | assert results[0]["match_reason"] == "commit_graph" |
| 151 | |
| 152 | |
| 153 | async def test_merged_proposal_matched_depth_2(db_session: AsyncSession) -> None: |
| 154 | """Merged proposal linked when anchor is a grandparent (depth 2) of merge_commit_id.""" |
| 155 | repo_id = await _make_repo(db_session, "merged-depth-2") |
| 156 | |
| 157 | # anchor: grandparent commit |
| 158 | grandparent_cid = await _make_commit(db_session, repo_id, branch="feat/y") |
| 159 | # parent: its child |
| 160 | parent_cid = await _make_commit( |
| 161 | db_session, repo_id, parent_ids=[grandparent_cid], branch="feat/y" |
| 162 | ) |
| 163 | # merge commit |
| 164 | merge_cid = await _make_commit( |
| 165 | db_session, repo_id, parent_ids=[parent_cid], branch="main" |
| 166 | ) |
| 167 | |
| 168 | pid = await _make_proposal( |
| 169 | db_session, repo_id, state="merged", merge_commit_id=merge_cid, number=2 |
| 170 | ) |
| 171 | await db_session.commit() |
| 172 | |
| 173 | results = await musehub_issues.find_proposals_by_commit_graph( |
| 174 | db_session, repo_id, [grandparent_cid] |
| 175 | ) |
| 176 | assert len(results) == 1 |
| 177 | assert results[0]["proposal_id"] == pid |
| 178 | assert results[0]["match_reason"] == "commit_graph" |
| 179 | |
| 180 | |
| 181 | async def test_open_proposal_matched_via_branch(db_session: AsyncSession) -> None: |
| 182 | """Open proposal linked when an anchor commit lives on the proposal's from_branch.""" |
| 183 | repo_id = await _make_repo(db_session, "open-branch") |
| 184 | |
| 185 | # A commit on the feature branch that matches the anchor |
| 186 | anchor_cid = await _make_commit( |
| 187 | db_session, repo_id, branch="feat/open-fix" |
| 188 | ) |
| 189 | pid = await _make_proposal( |
| 190 | db_session, repo_id, |
| 191 | state="open", |
| 192 | from_branch="feat/open-fix", |
| 193 | to_branch="main", |
| 194 | merge_commit_id=None, |
| 195 | number=3, |
| 196 | ) |
| 197 | await db_session.commit() |
| 198 | |
| 199 | results = await musehub_issues.find_proposals_by_commit_graph( |
| 200 | db_session, repo_id, [anchor_cid] |
| 201 | ) |
| 202 | assert len(results) == 1 |
| 203 | assert results[0]["proposal_id"] == pid |
| 204 | assert results[0]["state"] == "open" |
| 205 | assert results[0]["match_reason"] == "commit_graph" |
| 206 | |
| 207 | |
| 208 | async def test_no_match_when_anchor_not_in_graph(db_session: AsyncSession) -> None: |
| 209 | """No results when the anchor commit is unrelated to any proposal's commit graph.""" |
| 210 | repo_id = await _make_repo(db_session, "no-match") |
| 211 | |
| 212 | unrelated_cid = await _make_commit(db_session, repo_id, branch="feat/unrelated") |
| 213 | |
| 214 | # Proposal whose ancestors don't include unrelated_cid |
| 215 | other_cid = await _make_commit(db_session, repo_id, branch="feat/other") |
| 216 | merge_cid = await _make_commit( |
| 217 | db_session, repo_id, parent_ids=[other_cid], branch="main" |
| 218 | ) |
| 219 | await _make_proposal( |
| 220 | db_session, repo_id, state="merged", merge_commit_id=merge_cid, number=4 |
| 221 | ) |
| 222 | await db_session.commit() |
| 223 | |
| 224 | results = await musehub_issues.find_proposals_by_commit_graph( |
| 225 | db_session, repo_id, [unrelated_cid] |
| 226 | ) |
| 227 | assert results == [] |
| 228 | |
| 229 | |
| 230 | async def test_short_anchor_prefix_match(db_session: AsyncSession) -> None: |
| 231 | """Short (8-char) anchor resolves correctly via prefix match.""" |
| 232 | repo_id = await _make_repo(db_session, "short-anchor") |
| 233 | |
| 234 | anchor_cid = await _make_commit(db_session, repo_id, branch="feat/z") |
| 235 | merge_cid = await _make_commit( |
| 236 | db_session, repo_id, parent_ids=[anchor_cid], branch="main" |
| 237 | ) |
| 238 | pid = await _make_proposal( |
| 239 | db_session, repo_id, state="merged", merge_commit_id=merge_cid, number=5 |
| 240 | ) |
| 241 | await db_session.commit() |
| 242 | |
| 243 | # Use only the first 8 chars of the full commit ID |
| 244 | short_anchor = anchor_cid[:8] |
| 245 | results = await musehub_issues.find_proposals_by_commit_graph( |
| 246 | db_session, repo_id, [short_anchor] |
| 247 | ) |
| 248 | assert len(results) == 1 |
| 249 | assert results[0]["proposal_id"] == pid |
| 250 | |
| 251 | |
| 252 | async def test_proposals_from_other_repo_not_returned(db_session: AsyncSession) -> None: |
| 253 | """Commit graph lookup is strictly scoped to the given repo_id.""" |
| 254 | repo_a = await _make_repo(db_session, "graph-repo-a") |
| 255 | repo_b = await _make_repo(db_session, "graph-repo-b") |
| 256 | |
| 257 | # Anchor and proposal are in repo_a |
| 258 | anchor_cid = await _make_commit(db_session, repo_a, branch="feat/q") |
| 259 | merge_cid = await _make_commit( |
| 260 | db_session, repo_a, parent_ids=[anchor_cid], branch="main" |
| 261 | ) |
| 262 | await _make_proposal( |
| 263 | db_session, repo_a, state="merged", merge_commit_id=merge_cid, number=6 |
| 264 | ) |
| 265 | await db_session.commit() |
| 266 | |
| 267 | # Query against repo_b — should find nothing |
| 268 | results = await musehub_issues.find_proposals_by_commit_graph( |
| 269 | db_session, repo_b, [anchor_cid] |
| 270 | ) |
| 271 | assert results == [] |
| 272 | |
| 273 | |
| 274 | async def test_anchor_unresolved_returns_empty(db_session: AsyncSession) -> None: |
| 275 | """Anchor that doesn't match any stored commit yields an empty result.""" |
| 276 | repo_id = await _make_repo(db_session, "unresolved-anchor") |
| 277 | await db_session.commit() |
| 278 | |
| 279 | phantom_anchor = "deadbeef" # not in musehub_commits |
| 280 | results = await musehub_issues.find_proposals_by_commit_graph( |
| 281 | db_session, repo_id, [phantom_anchor] |
| 282 | ) |
| 283 | assert results == [] |
File History
1 commit
sha256:7d6dd8f4a89e2d1fef2d84f6e65feaff51385d382f466766b7f690a22ec18e32
fix: fall back to DB ancestry check when mpack-only fast-fo…
Sonnet 4.6
patch
7 days ago