gabriel / musehub public
musehub_social_models.py python
509 lines 24.6 KB
Raw
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor ⚠ breaking 7 days ago
1 """ORM models for social collaboration — issues, proposals, forks.
2
3 Tables:
4 - musehub_issues: Issue tracker entries per repo
5 - musehub_issue_comments: Threaded comments on issues
6 - musehub_issue_events: Typed activity events on issues
7 - musehub_proposals: Merge proposals
8 - musehub_proposal_reviews: Formal reviews on merge proposals
9 - musehub_proposal_comments: Symbol-anchored inline comments on proposals
10 - musehub_proposal_dependencies: Hard dependency edges between proposals
11 - musehub_proposal_simulations: Cached phased-merge simulation results
12 - musehub_forks: Fork relationship records
13 """
14
15 from __future__ import annotations
16
17 from datetime import datetime, timezone
18
19 import sqlalchemy as sa
20 from sqlalchemy import ARRAY, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, text
21 from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship
22 from sqlalchemy.dialects.postgresql import JSONB
23
24 from musehub.db.database import Base
25 from musehub.types.json_types import JSONObject, JSONValue # JSONValue needed for ForwardRef resolution in Mapped[]
26
27
28 def _utc_now() -> datetime:
29 return datetime.now(tz=timezone.utc)
30
31
32 class MusehubIssue(MappedAsDataclass, Base):
33 """An issue opened against a MuseHub repo.
34
35 ``number`` is auto-incremented per repo starting at 1 so contributors can
36 reference issues as ``#1``, ``#2``, etc., independently of the global PK.
37 ``labels`` is a JSON list of free-form strings.
38 ``symbol_anchors`` stores structured symbol addresses (``file.py::Symbol``).
39 ``commit_anchors`` stores commit IDs that address this issue — the VCS graph
40 is the source of truth for which release a fix landed in.
41 ``assignee`` is the display name or identifier of the user assigned to this issue.
42 """
43
44 __tablename__ = "musehub_issues"
45 __table_args__ = (
46 # Issue list: WHERE repo_id = ? AND state = ? (open/closed filter)
47 Index("ix_musehub_issues_repo_state", "repo_id", "state"),
48 # Per-repo issue lookup: WHERE repo_id = ? AND number = ?
49 Index("ix_musehub_issues_repo_number", "repo_id", "number"),
50 )
51
52 # --- Required fields ---
53 issue_id: Mapped[str] = mapped_column(String(128), primary_key=True)
54 repo_id: Mapped[str] = mapped_column(
55 String(128),
56 ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"),
57 nullable=False,
58 index=True,
59 )
60 # Sequential per-repo issue number (1, 2, 3…)
61 number: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
62 title: Mapped[str] = mapped_column(String(500), nullable=False)
63
64 # --- Optional fields with Python-side defaults ---
65 body: Mapped[str] = mapped_column(Text, nullable=False, default="")
66 state: Mapped[str] = mapped_column(String(20), nullable=False, default="open", server_default="open", index=True)
67 # list of free-form label strings
68 labels: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default_factory=list)
69 # Structured symbol anchors: ["file.py::Symbol", …]
70 symbol_anchors: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default_factory=list)
71 # Commit ID anchors: ["sha256:…", …]
72 commit_anchors: Mapped[list[str]] = mapped_column(ARRAY(String(128)), nullable=False, default_factory=list)
73 # Display name or identifier of the user who opened this issue
74 author: Mapped[str] = mapped_column(String(255), nullable=False, default="")
75 # Display name or user ID of the collaborator assigned to resolve this issue
76 assignee: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
77 # Agent provenance — set when the issue is filed by an AI agent.
78 agent_id: Mapped[str] = mapped_column(String(255), nullable=False, default="")
79 model_id: Mapped[str] = mapped_column(String(255), nullable=False, default="")
80 created_at: Mapped[datetime] = mapped_column(
81 DateTime(timezone=True), nullable=False, default_factory=_utc_now
82 )
83 updated_at: Mapped[datetime] = mapped_column(
84 DateTime(timezone=True), nullable=False, default_factory=_utc_now, onupdate=_utc_now
85 )
86
87 # --- Relationships ---
88 repo: Mapped["MusehubRepo"] = relationship("MusehubRepo", back_populates="issues", init=False)
89 comments: Mapped[list[MusehubIssueComment]] = relationship(
90 "MusehubIssueComment", back_populates="issue", cascade="all, delete-orphan",
91 order_by="MusehubIssueComment.created_at",
92 init=False, default_factory=list,
93 )
94 events: Mapped[list[MusehubIssueEvent]] = relationship(
95 "MusehubIssueEvent", back_populates="issue", cascade="all, delete-orphan",
96 order_by="MusehubIssueEvent.created_at",
97 init=False, default_factory=list,
98 )
99
100
101 class MusehubIssueComment(Base):
102 """A comment in a threaded discussion on a MuseHub issue.
103
104 Comments support threaded replies via ``parent_id``. Top-level comments
105 have ``parent_id=None``. Markdown body is stored verbatim; rendering
106 happens client-side.
107 """
108
109 __tablename__ = "musehub_issue_comments"
110
111 comment_id: Mapped[str] = mapped_column(String(128), primary_key=True)
112 issue_id: Mapped[str] = mapped_column(
113 String(128),
114 ForeignKey("musehub_issues.issue_id", ondelete="CASCADE"),
115 nullable=False,
116 index=True,
117 )
118 repo_id: Mapped[str] = mapped_column(
119 String(128),
120 ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"),
121 nullable=False,
122 index=True,
123 )
124 # Display name or user ID of the comment author
125 author: Mapped[str] = mapped_column(String(255), nullable=False, default="")
126 # Markdown comment body
127 body: Mapped[str] = mapped_column(Text, nullable=False)
128 # Parent comment ID for threaded replies; null for top-level comments
129 parent_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
130 is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sa.false())
131 created_at: Mapped[datetime] = mapped_column(
132 DateTime(timezone=True), nullable=False, default=_utc_now, index=True
133 )
134 updated_at: Mapped[datetime] = mapped_column(
135 DateTime(timezone=True), nullable=False, default=_utc_now, onupdate=_utc_now
136 )
137
138 issue: Mapped[MusehubIssue] = relationship("MusehubIssue", back_populates="comments")
139
140
141 class MusehubIssueEvent(MappedAsDataclass, Base):
142 """A typed activity event on a MuseHub issue (Phase 4B).
143
144 Stores the full activity timeline: state changes, label edits, anchor
145 additions, proposal links, and comments. The timeline query unions
146 these events with ``musehub_issue_comments`` sorted by ``created_at``,
147 allowing incremental migration without touching existing comment rows.
148
149 ``event_type`` values:
150 opened | closed | reopened | commented | labeled | unlabeled |
151 assigned | unassigned | symbol_anchored | commit_anchored |
152 proposal_linked | proposal_merged
153 ``payload`` is event-type-specific JSON (e.g. ``{"body": "…"}`` for
154 ``commented``, ``{"label": "bug"}`` for ``labeled``).
155 """
156
157 __tablename__ = "musehub_issue_events"
158 __table_args__ = (
159 Index("ix_musehub_issue_events_issue_created", "issue_id", "created_at"),
160 )
161
162 # --- Required fields ---
163 event_id: Mapped[str] = mapped_column(String(128), primary_key=True)
164 issue_id: Mapped[str] = mapped_column(
165 String(128),
166 ForeignKey("musehub_issues.issue_id", ondelete="CASCADE"),
167 nullable=False,
168 index=True,
169 )
170 repo_id: Mapped[str] = mapped_column(
171 String(128),
172 ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"),
173 nullable=False,
174 index=True,
175 )
176 event_type: Mapped[str] = mapped_column(String(64), nullable=False)
177
178 # --- Optional fields ---
179 actor: Mapped[str] = mapped_column(String(255), nullable=False, default="")
180 payload: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default_factory=dict)
181 created_at: Mapped[datetime] = mapped_column(
182 DateTime(timezone=True), nullable=False, default_factory=_utc_now, index=True
183 )
184
185 issue: Mapped[MusehubIssue] = relationship("MusehubIssue", back_populates="events", init=False)
186
187
188 class MusehubProposal(MappedAsDataclass, Base):
189 """A merge proposal — the V2 name for a proposal.
190
191 ``state`` progresses: ``open`` → ``merged`` | ``closed``.
192 ``merge_commit_id`` is populated only when state becomes ``merged``.
193
194 V2 additions:
195 - ``domain_diff``: the domain plugin's native diff payload (symbol ops for code,
196 piano roll delta for MIDI). Populated by the proposal risk engine on creation.
197 - ``risk_score``, ``blast_delta``, ``breakage_count``, ``test_gap_count``,
198 ``symbols_changed``: composite risk assessment computed by the risk engine.
199 """
200
201 __tablename__ = "musehub_proposals"
202 __table_args__ = (
203 # Proposal list: WHERE repo_id = ? AND state = ? (open/closed/merged filter)
204 Index("ix_musehub_proposals_repo_state", "repo_id", "state"),
205 # Per-repo proposal lookup: WHERE repo_id = ? AND proposal_number = ?
206 Index("ix_musehub_proposals_repo_number", "repo_id", "proposal_number"),
207 # Phase 7: list + sort by date (most common access pattern)
208 Index("ix_musehub_proposals_repo_state_created", "repo_id", "state", text("created_at DESC")),
209 # Phase 7: list + sort by risk score
210 Index("ix_musehub_proposals_repo_state_risk", "repo_id", "state", text("risk_score DESC NULLS LAST")),
211 )
212
213 # --- Required fields ---
214 proposal_id: Mapped[str] = mapped_column(String(128), primary_key=True)
215 repo_id: Mapped[str] = mapped_column(
216 String(128),
217 ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"),
218 nullable=False,
219 index=True,
220 )
221 # Per-repository sequential integer. Assigned at creation time; unique within the repo.
222 proposal_number: Mapped[int] = mapped_column(Integer, nullable=False)
223 title: Mapped[str] = mapped_column(String(500), nullable=False)
224 from_branch: Mapped[str] = mapped_column(String(255), nullable=False)
225 to_branch: Mapped[str] = mapped_column(String(255), nullable=False)
226
227 # --- Optional fields with Python-side defaults ---
228 body: Mapped[str] = mapped_column(Text, nullable=False, default="")
229 state: Mapped[str] = mapped_column(String(20), nullable=False, default="open", server_default="open", index=True)
230 # Populated when state transitions to 'merged'
231 merge_commit_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
232 merged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None)
233 author: Mapped[str] = mapped_column(String(255), nullable=False, default="")
234 created_at: Mapped[datetime] = mapped_column(
235 DateTime(timezone=True), nullable=False, default_factory=_utc_now
236 )
237 updated_at: Mapped[datetime] = mapped_column(
238 DateTime(timezone=True), nullable=False, default_factory=_utc_now, onupdate=_utc_now
239 )
240 # V2: domain plugin's native diff payload
241 domain_diff: Mapped[JSONObject | None] = mapped_column(JSONB, nullable=True, default=None)
242 # V2: composite risk assessment (populated by proposal risk engine)
243 risk_score: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None)
244 blast_delta: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None)
245 breakage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
246 test_gap_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
247 symbols_changed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
248 # Symbol addresses touched by commits on from_branch
249 touched_symbols: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default_factory=list)
250
251 # --- Phase 1: Proposal reimagination fields ---
252 # Semantic type — governs which merge strategies and conditions apply
253 proposal_type: Mapped[str] = mapped_column(String(50), nullable=False, default="state_merge", server_default="state_merge")
254 # Draft proposals are visible but cannot be merged
255 is_draft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
256 # Merge gate conditions override (JSON); None falls back to repo defaults
257 merge_conditions: Mapped[JSONObject | None] = mapped_column(JSONB, nullable=True, default=None)
258 # Conflict resolution strategy
259 merge_strategy: Mapped[str] = mapped_column(String(50), nullable=False, default="overlay", server_default="overlay")
260 # For DOMAIN_SELECTIVE strategy: which domains to merge
261 selective_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True, default=None)
262 # Per-domain risk scores {"code": 0.8, "midi": 0.3, ...}
263 dimensional_risk: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default_factory=dict)
264 # MIDI domain summary
265 midi_tracks_changed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
266 midi_notes_delta: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
267 harmonic_tension_delta: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None)
268 # Payment domain summary
269 payment_claim_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
270 payment_ledger_delta_nano: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0)
271 payment_avax_address: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
272 # Agent provenance — populated when author is an agent
273 agent_model: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
274 agent_spawned_by: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
275 # Proposer signature — Ed25519 sig over canonical PROPOSE message
276 proposer_signature: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
277 proposer_public_key: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
278 # Snapshot anchors — HEAD commit IDs of each branch at proposal creation time
279 from_snapshot_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
280 to_snapshot_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
281
282 # --- Relationships ---
283 repo: Mapped["MusehubRepo"] = relationship("MusehubRepo", back_populates="proposals", init=False)
284 reviews: Mapped[list[MusehubProposalReview]] = relationship(
285 "MusehubProposalReview", back_populates="proposal", cascade="all, delete-orphan",
286 init=False, default_factory=list,
287 )
288 review_comments: Mapped[list[MusehubProposalComment]] = relationship(
289 "MusehubProposalComment", back_populates="proposal", cascade="all, delete-orphan",
290 init=False, default_factory=list,
291 )
292
293
294 class MusehubProposalReview(Base):
295 """A formal review submission on a merge proposal.
296
297 Tracks both reviewer assignment (``pending`` state) and submitted reviews
298 (``approved``, ``changes_requested``, ``dismissed``). One row per
299 (proposal_id, reviewer_username) pair — a reviewer can only hold one active state
300 at a time. Re-submitting replaces the previous state.
301
302 State lifecycle:
303 requested (by proposal author) → pending
304 reviewer submits → approved | changes_requested | dismissed
305
306 A proposal is merge-ready when every pending/changes_requested review has been
307 resolved to ``approved``, or the owner forces a merge.
308 """
309
310 __tablename__ = "musehub_proposal_reviews"
311 __table_args__ = (
312 # Phase 7: batch prefetch approvals — WHERE proposal_id IN (?) AND state = 'approved'
313 Index("ix_musehub_proposal_reviews_proposal_state", "proposal_id", "state"),
314 )
315
316 review_id: Mapped[str] = mapped_column(String(128), primary_key=True)
317 proposal_id: Mapped[str] = mapped_column(
318 String(128),
319 ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"),
320 nullable=False,
321 index=True,
322 )
323 reviewer_username: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
324 # pending | approved | changes_requested | dismissed
325 state: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", server_default="pending", index=True)
326 body: Mapped[str | None] = mapped_column(Text, nullable=True)
327 submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
328 created_at: Mapped[datetime] = mapped_column(
329 DateTime(timezone=True), nullable=False, default=_utc_now
330 )
331 # Phase 1: dimensional review fields
332 # Which domains this reviewer explicitly covered in their review
333 reviewed_domains: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
334 # Per-domain risk acknowledgement — reviewer signals they understand the risk
335 domain_risk_acknowledged: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default=dict)
336 # Reviewer's suggested merge strategy (None = accept proposal default)
337 suggested_merge_strategy: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None)
338
339 proposal: Mapped[MusehubProposal] = relationship(
340 "MusehubProposal", back_populates="reviews"
341 )
342
343
344 class MusehubProposalComment(MappedAsDataclass, Base):
345 """Inline review comment on a dimensional diff within a merge proposal.
346
347 Domain-agnostic targeting via ``dimension_ref`` — a JSON object whose schema
348 is defined by the repo's domain plugin. Examples:
349 - MIDI domain: ``{"dim": "harmony", "position": {"beat": 16, "beat_end": 24}}``
350 - Code domain: ``{"dim": "symbol", "symbol": "AuthService.login", "file": "auth.py"}``
351 - Genomics: ``{"dim": "sequence", "start": 1024, "end": 2048}``
352 - General (no target): ``{}``
353
354 V2 adds ``symbol_address`` — direct anchor to a symbol address
355 (e.g. ``"auth.py::AuthService.login"``). Takes precedence over ``dimension_ref``
356 for code-domain proposals; ``dimension_ref`` remains for non-code domains.
357
358 ``parent_comment_id`` enables threaded replies.
359 """
360
361 __tablename__ = "musehub_proposal_comments"
362
363 # --- Required fields ---
364 comment_id: Mapped[str] = mapped_column(String(128), primary_key=True)
365 proposal_id: Mapped[str] = mapped_column(
366 String(128),
367 ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"),
368 nullable=False,
369 index=True,
370 )
371 repo_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
372 author: Mapped[str] = mapped_column(String(255), nullable=False)
373 body: Mapped[str] = mapped_column(Text, nullable=False)
374
375 # --- Optional fields ---
376 # Content-addressed identity ID — resilient to handle changes; enables sigil without extra lookup.
377 author_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None, index=True)
378 # AI authorship provenance (empty string = human-authored).
379 agent_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
380 model_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
381 # Domain-agnostic dimension reference — schema defined by the domain plugin
382 dimension_ref: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default_factory=dict)
383 # V2: direct symbol address anchor (e.g. "auth.py::AuthService.login")
384 symbol_address: Mapped[str | None] = mapped_column(String(512), nullable=True, default=None)
385 parent_comment_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True, default=None)
386 created_at: Mapped[datetime] = mapped_column(
387 DateTime(timezone=True), nullable=False, default_factory=_utc_now, index=True
388 )
389 updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None)
390 is_deleted: Mapped[bool] = mapped_column(nullable=False, default=False, server_default=sa.text("false"))
391
392 proposal: Mapped[MusehubProposal] = relationship(
393 "MusehubProposal", back_populates="review_comments", init=False
394 )
395
396
397 class MusehubProposalDependency(MappedAsDataclass, Base):
398 """Hard dependency edge between two proposals in the same repo.
399
400 ``dependent_proposal_id`` cannot be merged until ``dependency_proposal_id``
401 reaches MERGED state. Kahn's algorithm runs over these edges at merge-time
402 to enforce ordering and detect cycles.
403
404 Both proposals must belong to the same repo — enforced at the service layer.
405 """
406
407 __tablename__ = "musehub_proposal_dependencies"
408 __table_args__ = (
409 # Fast "what does proposal X depend on?" lookup
410 Index("ix_musehub_prop_deps_dependent", "dependent_proposal_id"),
411 # Fast "what proposals are blocked by X?" lookup (reverse edge)
412 Index("ix_musehub_prop_deps_dependency", "dependency_proposal_id"),
413 # Prevent duplicate edges
414 sa.UniqueConstraint("dependent_proposal_id", "dependency_proposal_id", name="uq_musehub_prop_dep_edge"),
415 )
416
417 dep_id: Mapped[str] = mapped_column(String(128), primary_key=True)
418 dependent_proposal_id: Mapped[str] = mapped_column(
419 String(128),
420 ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"),
421 nullable=False,
422 )
423 dependency_proposal_id: Mapped[str] = mapped_column(
424 String(128),
425 ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"),
426 nullable=False,
427 )
428 created_at: Mapped[datetime] = mapped_column(
429 DateTime(timezone=True), nullable=False, default_factory=_utc_now
430 )
431
432
433 class MusehubProposalSimulation(MappedAsDataclass, Base):
434 """Cached result of a phased-merge simulation run for a proposal.
435
436 The merge engine computes this once per (proposal_id, simulation_type) and
437 caches the result here. Stale when the proposal's from_branch advances —
438 detected by comparing ``from_branch_commit_id`` to the live tip.
439
440 ``simulation_type`` values:
441 - ``conflict_scan`` — identifies files / symbols that will conflict
442 - ``risk_projection`` — projects post-merge dimensional risk scores
443 - ``dependency_order`` — Kahn's topological sort of the dependency DAG
444 """
445
446 __tablename__ = "musehub_proposal_simulations"
447 __table_args__ = (
448 # One live simulation per (proposal, type)
449 sa.UniqueConstraint("proposal_id", "simulation_type", name="uq_musehub_prop_sim"),
450 Index("ix_musehub_prop_sim_proposal", "proposal_id"),
451 )
452
453 simulation_id: Mapped[str] = mapped_column(String(128), primary_key=True)
454 proposal_id: Mapped[str] = mapped_column(
455 String(128),
456 ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"),
457 nullable=False,
458 )
459 simulation_type: Mapped[str] = mapped_column(String(50), nullable=False)
460 # Commit tip of from_branch at time of simulation — used for staleness check
461 from_branch_commit_id: Mapped[str] = mapped_column(String(128), nullable=False, default="")
462 # JSON payload — schema determined by simulation_type
463 result: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default_factory=dict)
464 # Execution metrics
465 duration_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
466 created_at: Mapped[datetime] = mapped_column(
467 DateTime(timezone=True), nullable=False, default_factory=_utc_now
468 )
469 expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None)
470
471
472 class MusehubFork(Base):
473 """Fork relationship record — links a source repo to its forked copy.
474
475 One row per fork. The unique constraint on ``(source_repo_id, forked_by)``
476 enforces the rule that each identity may fork a given source repo at most once.
477 Both foreign keys use ``CASCADE`` so that deleting either repo automatically
478 removes the record.
479
480 Queried two ways:
481 - By ``source_repo_id`` to list all forks of a repo.
482 - By ``forked_by`` to list all repos a given user has forked.
483 """
484
485 __tablename__ = "musehub_forks"
486
487 # genesis-addressed: sha256(source_repo_id NUL fork_repo_id NUL created_at_iso)
488 fork_id: Mapped[str] = mapped_column(String(128), primary_key=True)
489 source_repo_id: Mapped[str] = mapped_column(
490 String(128),
491 ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"),
492 nullable=False,
493 index=True,
494 )
495 fork_repo_id: Mapped[str] = mapped_column(
496 String(128),
497 ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"),
498 nullable=False,
499 index=True,
500 )
501 # MSign handle of the identity that performed the fork.
502 forked_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
503 created_at: Mapped[datetime] = mapped_column(
504 DateTime(timezone=True), nullable=False, default=_utc_now
505 )
506
507 __table_args__ = (
508 UniqueConstraint("source_repo_id", "forked_by", name="uq_musehub_forks"),
509 )
File History 2 commits
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor 7 days ago
sha256:a909058d727faac4d77f6e659cc0b1f9315efcb6aabfd870d08763525a67093d dialing in --strategy and --history on merge proposal Human minor 10 days ago