gabriel / musehub public
test_musehub_issues_branch_reachability.py python
317 lines 10.9 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 123 days ago
1 """Tests for Signal 3: branch-to-issue commit reachability.
2
3 find_proposals_by_branch_reachability returns open proposals where a commit
4 anchor is reachable from from_branch but NOT yet reachable from to_branch.
5
6 Covers:
7 - Anchor reachable exclusively from from_branch → proposal returned
8 - Anchor on to_branch (already integrated) → NOT returned
9 - Anchor reachable at depth 2 from from_branch → returned
10 - Anchor reachable from both branches (common ancestor) → NOT returned
11 - Empty commit_anchors → empty list
12 - Anchor not in any branch commit graph → empty list
13 - Short (8-char) anchor prefix resolution
14 - Merged proposals are excluded (from_branch deleted at merge)
15 - Cross-repo isolation
16 """
17 from __future__ import annotations
18
19 import secrets
20 from datetime import datetime, timezone
21
22 import pytest
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from muse.core.types import fake_id, now_utc_iso
26 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_proposal_id, compute_repo_id
27 from musehub.db.musehub_models import (
28 MusehubBranch,
29 MusehubCommit,
30 MusehubProposal,
31 MusehubRepo,
32 )
33 from musehub.services import musehub_issues
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _uid() -> str:
42 return secrets.token_hex(16)
43
44
45 def _commit_id() -> str:
46 return fake_id(_uid())
47
48
49 async def _make_repo(db: AsyncSession, slug: str) -> str:
50 created_at = datetime.now(tz=timezone.utc)
51 owner_id = compute_identity_id(b"testuser")
52 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
53 repo = MusehubRepo(
54 repo_id=repo_id,
55 name=slug, owner="testuser", slug=slug,
56 visibility="public", owner_user_id=owner_id,
57 created_at=created_at, updated_at=created_at,
58 )
59 db.add(repo)
60 await db.commit()
61 await db.refresh(repo)
62 return str(repo.repo_id)
63
64
65 async def _make_commit(
66 db: AsyncSession,
67 repo_id: str,
68 *,
69 branch: str,
70 parent_ids: list[str] | None = None,
71 commit_id: str | None = None,
72 ) -> str:
73 cid = commit_id or _commit_id()
74 db.add(MusehubCommit(
75 commit_id=cid,
76 repo_id=repo_id,
77 branch=branch,
78 parent_ids=parent_ids or [],
79 message="test",
80 author="tester",
81 timestamp=datetime.now(timezone.utc),
82 ))
83 await db.flush()
84 return cid
85
86
87 async def _make_branch(
88 db: AsyncSession, repo_id: str, name: str, head: str | None = None
89 ) -> None:
90 db.add(MusehubBranch(branch_id=compute_branch_id(repo_id, name), repo_id=repo_id, name=name, head_commit_id=head))
91 await db.flush()
92
93
94 async def _make_proposal(
95 db: AsyncSession,
96 repo_id: str,
97 *,
98 from_branch: str,
99 to_branch: str,
100 state: str = "open",
101 number: int = 1,
102 ) -> str:
103 author_id = compute_identity_id(b"tester")
104 pid = compute_proposal_id(repo_id, author_id, from_branch, to_branch, now_utc_iso())
105 db.add(MusehubProposal(
106 proposal_id=pid,
107 repo_id=repo_id,
108 proposal_number=number,
109 title=f"Proposal {number}",
110 body="",
111 state=state,
112 from_branch=from_branch,
113 to_branch=to_branch,
114 author="tester",
115 ))
116 await db.flush()
117 return pid
118
119
120 # ---------------------------------------------------------------------------
121 # Tests
122 # ---------------------------------------------------------------------------
123
124
125 async def test_empty_anchors_returns_empty(db_session: AsyncSession) -> None:
126 repo_id = await _make_repo(db_session, "br-empty")
127 result = await musehub_issues.find_proposals_by_branch_reachability(
128 db_session, repo_id, []
129 )
130 assert result == []
131
132
133 async def test_anchor_exclusively_on_from_branch_matches(db_session: AsyncSession) -> None:
134 """Anchor commit is reachable from from_branch but not from to_branch → match."""
135 repo_id = await _make_repo(db_session, "br-exclusive")
136
137 # to_branch: one commit (A)
138 a = await _make_commit(db_session, repo_id, branch="main")
139 await _make_branch(db_session, repo_id, "main", a)
140
141 # from_branch: builds on A, adds B (the anchor)
142 b = await _make_commit(db_session, repo_id, branch="feat/fix", parent_ids=[a])
143 await _make_branch(db_session, repo_id, "feat/fix", b)
144
145 pid = await _make_proposal(
146 db_session, repo_id,
147 from_branch="feat/fix", to_branch="main", number=1,
148 )
149 await db_session.commit()
150
151 results = await musehub_issues.find_proposals_by_branch_reachability(
152 db_session, repo_id, [b]
153 )
154 assert len(results) == 1
155 assert results[0]["proposal_id"] == pid
156 assert results[0]["state"] == "open"
157 assert results[0]["match_reason"] == "branch_reachability"
158
159
160 async def test_anchor_already_in_to_branch_not_matched(db_session: AsyncSession) -> None:
161 """Anchor already reachable from to_branch → NOT a match (already integrated)."""
162 repo_id = await _make_repo(db_session, "br-already-integrated")
163
164 # Both branches share commit A (which is the anchor).
165 a = await _make_commit(db_session, repo_id, branch="main")
166 b = await _make_commit(db_session, repo_id, branch="main", parent_ids=[a])
167 await _make_branch(db_session, repo_id, "main", b)
168
169 # from_branch also descends from A — but A is also in to_branch (main).
170 c = await _make_commit(db_session, repo_id, branch="feat/already", parent_ids=[a])
171 await _make_branch(db_session, repo_id, "feat/already", c)
172
173 await _make_proposal(
174 db_session, repo_id,
175 from_branch="feat/already", to_branch="main", number=1,
176 )
177 await db_session.commit()
178
179 # A is a common ancestor → excluded by the NOT EXISTS anti-join.
180 results = await musehub_issues.find_proposals_by_branch_reachability(
181 db_session, repo_id, [a]
182 )
183 assert results == []
184
185
186 async def test_anchor_at_depth_2_from_from_branch(db_session: AsyncSession) -> None:
187 """Anchor is a grandparent of from_branch HEAD (depth 2) → match."""
188 repo_id = await _make_repo(db_session, "br-depth-2")
189
190 # to_branch: just A
191 a = await _make_commit(db_session, repo_id, branch="main")
192 await _make_branch(db_session, repo_id, "main", a)
193
194 # feat: A → B (anchor) → C (HEAD)
195 b = await _make_commit(db_session, repo_id, branch="feat/deep", parent_ids=[a])
196 c = await _make_commit(db_session, repo_id, branch="feat/deep", parent_ids=[b])
197 await _make_branch(db_session, repo_id, "feat/deep", c)
198
199 pid = await _make_proposal(
200 db_session, repo_id,
201 from_branch="feat/deep", to_branch="main", number=1,
202 )
203 await db_session.commit()
204
205 # B is at depth 2 from HEAD of feat/deep; not in main.
206 results = await musehub_issues.find_proposals_by_branch_reachability(
207 db_session, repo_id, [b]
208 )
209 assert len(results) == 1
210 assert results[0]["proposal_id"] == pid
211
212
213 async def test_short_anchor_prefix_resolved(db_session: AsyncSession) -> None:
214 """8-char short anchor resolves via prefix match."""
215 repo_id = await _make_repo(db_session, "br-short-anchor")
216
217 a = await _make_commit(db_session, repo_id, branch="main")
218 await _make_branch(db_session, repo_id, "main", a)
219
220 b = await _make_commit(db_session, repo_id, branch="feat/prefix", parent_ids=[a])
221 await _make_branch(db_session, repo_id, "feat/prefix", b)
222
223 pid = await _make_proposal(
224 db_session, repo_id,
225 from_branch="feat/prefix", to_branch="main", number=1,
226 )
227 await db_session.commit()
228
229 results = await musehub_issues.find_proposals_by_branch_reachability(
230 db_session, repo_id, [b[:8]] # short-form anchor
231 )
232 assert len(results) == 1
233 assert results[0]["proposal_id"] == pid
234
235
236 async def test_merged_proposal_not_returned(db_session: AsyncSession) -> None:
237 """Merged proposals have from_branch deleted → branch HEAD gone → not returned."""
238 repo_id = await _make_repo(db_session, "br-merged-excluded")
239
240 a = await _make_commit(db_session, repo_id, branch="main")
241 # Deliberately do NOT create a "feat/done" branch row — simulates post-merge deletion.
242 await _make_branch(db_session, repo_id, "main", a)
243
244 pid = await _make_proposal(
245 db_session, repo_id,
246 from_branch="feat/done", to_branch="main",
247 state="merged", number=1,
248 )
249 await db_session.commit()
250
251 results = await musehub_issues.find_proposals_by_branch_reachability(
252 db_session, repo_id, [a]
253 )
254 # Merged proposals are filtered by state='open'; also from_branch branch row gone.
255 assert results == []
256
257
258 async def test_unresolved_anchor_returns_empty(db_session: AsyncSession) -> None:
259 """Anchor that doesn't match any stored commit → nothing to walk → empty."""
260 repo_id = await _make_repo(db_session, "br-unresolved")
261 await db_session.commit()
262
263 results = await musehub_issues.find_proposals_by_branch_reachability(
264 db_session, repo_id, ["cafebabe"]
265 )
266 assert results == []
267
268
269 async def test_cross_repo_isolation(db_session: AsyncSession) -> None:
270 """Query is strictly scoped to repo_id — no leakage between repos."""
271 repo_a = await _make_repo(db_session, "br-repo-a")
272 repo_b = await _make_repo(db_session, "br-repo-b")
273
274 a = await _make_commit(db_session, repo_a, branch="main")
275 await _make_branch(db_session, repo_a, "main", a)
276 b = await _make_commit(db_session, repo_a, branch="feat/x", parent_ids=[a])
277 await _make_branch(db_session, repo_a, "feat/x", b)
278 await _make_proposal(db_session, repo_a, from_branch="feat/x", to_branch="main")
279 await db_session.commit()
280
281 # Query against repo_b — must return nothing.
282 results = await musehub_issues.find_proposals_by_branch_reachability(
283 db_session, repo_b, [b]
284 )
285 assert results == []
286
287
288 async def test_multiple_open_proposals_only_matching_returned(
289 db_session: AsyncSession,
290 ) -> None:
291 """With two open proposals, only the one containing the anchor is returned."""
292 repo_id = await _make_repo(db_session, "br-multi")
293
294 a = await _make_commit(db_session, repo_id, branch="main")
295 await _make_branch(db_session, repo_id, "main", a)
296
297 # Proposal 1: feat/one — contains anchor B
298 b = await _make_commit(db_session, repo_id, branch="feat/one", parent_ids=[a])
299 await _make_branch(db_session, repo_id, "feat/one", b)
300 pid1 = await _make_proposal(
301 db_session, repo_id, from_branch="feat/one", to_branch="main", number=1
302 )
303
304 # Proposal 2: feat/two — contains commit C (different, not the anchor)
305 c = await _make_commit(db_session, repo_id, branch="feat/two", parent_ids=[a])
306 await _make_branch(db_session, repo_id, "feat/two", c)
307 await _make_proposal(
308 db_session, repo_id, from_branch="feat/two", to_branch="main", number=2
309 )
310
311 await db_session.commit()
312
313 results = await musehub_issues.find_proposals_by_branch_reachability(
314 db_session, repo_id, [b]
315 )
316 assert len(results) == 1
317 assert results[0]["proposal_id"] == pid1
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 123 days ago