"""ORM models for social collaboration — issues, proposals, forks. Tables: - musehub_issues: Issue tracker entries per repo - musehub_issue_comments: Threaded comments on issues - musehub_issue_events: Typed activity events on issues - musehub_proposals: Merge proposals - musehub_proposal_reviews: Formal reviews on merge proposals - musehub_proposal_comments: Symbol-anchored inline comments on proposals - musehub_proposal_dependencies: Hard dependency edges between proposals - musehub_proposal_simulations: Cached phased-merge simulation results - musehub_forks: Fork relationship records """ from __future__ import annotations from datetime import datetime, timezone import sqlalchemy as sa from sqlalchemy import ARRAY, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, text from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship from sqlalchemy.dialects.postgresql import JSONB from musehub.db.database import Base from musehub.types.json_types import JSONObject, JSONValue # JSONValue needed for ForwardRef resolution in Mapped[] def _utc_now() -> datetime: return datetime.now(tz=timezone.utc) class MusehubIssue(MappedAsDataclass, Base): """An issue opened against a MuseHub repo. ``number`` is auto-incremented per repo starting at 1 so contributors can reference issues as ``#1``, ``#2``, etc., independently of the global PK. ``labels`` is a JSON list of free-form strings. ``symbol_anchors`` stores structured symbol addresses (``file.py::Symbol``). ``commit_anchors`` stores commit IDs that address this issue — the VCS graph is the source of truth for which release a fix landed in. ``assignee`` is the display name or identifier of the user assigned to this issue. """ __tablename__ = "musehub_issues" __table_args__ = ( # Issue list: WHERE repo_id = ? AND state = ? (open/closed filter) Index("ix_musehub_issues_repo_state", "repo_id", "state"), # Per-repo issue lookup: WHERE repo_id = ? AND number = ? Index("ix_musehub_issues_repo_number", "repo_id", "number"), # musehub#184: backstop against the number-allocation race — any # code path that bypasses _next_issue_number's row lock fails loudly # (IntegrityError) instead of silently producing a duplicate. UniqueConstraint("repo_id", "number", name="uq_musehub_issues_repo_id_number"), ) # --- Required fields --- issue_id: Mapped[str] = mapped_column(String(128), primary_key=True) repo_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"), nullable=False, index=True, ) # Sequential per-repo issue number (1, 2, 3…) number: Mapped[int] = mapped_column(Integer, nullable=False, index=True) title: Mapped[str] = mapped_column(String(500), nullable=False) # --- Optional fields with Python-side defaults --- body: Mapped[str] = mapped_column(Text, nullable=False, default="") state: Mapped[str] = mapped_column(String(20), nullable=False, default="open", server_default="open", index=True) # list of free-form label strings labels: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default_factory=list) # Structured symbol anchors: ["file.py::Symbol", …] symbol_anchors: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default_factory=list) # Commit ID anchors: ["sha256:…", …] commit_anchors: Mapped[list[str]] = mapped_column(ARRAY(String(128)), nullable=False, default_factory=list) # Display name or identifier of the user who opened this issue author: Mapped[str] = mapped_column(String(255), nullable=False, default="") # Display name or user ID of the collaborator assigned to resolve this issue assignee: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) # Agent provenance — set when the issue is filed by an AI agent. agent_id: Mapped[str] = mapped_column(String(255), nullable=False, default="") model_id: Mapped[str] = mapped_column(String(255), nullable=False, default="") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default_factory=_utc_now ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default_factory=_utc_now, onupdate=_utc_now ) # --- Relationships --- repo: Mapped["MusehubRepo"] = relationship("MusehubRepo", back_populates="issues", init=False) comments: Mapped[list[MusehubIssueComment]] = relationship( "MusehubIssueComment", back_populates="issue", cascade="all, delete-orphan", order_by="MusehubIssueComment.created_at", init=False, default_factory=list, ) events: Mapped[list[MusehubIssueEvent]] = relationship( "MusehubIssueEvent", back_populates="issue", cascade="all, delete-orphan", order_by="MusehubIssueEvent.created_at", init=False, default_factory=list, ) class MusehubIssueComment(Base): """A comment in a threaded discussion on a MuseHub issue. Comments support threaded replies via ``parent_id``. Top-level comments have ``parent_id=None``. Markdown body is stored verbatim; rendering happens client-side. """ __tablename__ = "musehub_issue_comments" comment_id: Mapped[str] = mapped_column(String(128), primary_key=True) issue_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_issues.issue_id", ondelete="CASCADE"), nullable=False, index=True, ) repo_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"), nullable=False, index=True, ) # Display name or user ID of the comment author author: Mapped[str] = mapped_column(String(255), nullable=False, default="") # Markdown comment body body: Mapped[str] = mapped_column(Text, nullable=False) # Parent comment ID for threaded replies; null for top-level comments parent_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sa.false()) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utc_now, index=True ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utc_now, onupdate=_utc_now ) issue: Mapped[MusehubIssue] = relationship("MusehubIssue", back_populates="comments") class MusehubIssueEvent(MappedAsDataclass, Base): """A typed activity event on a MuseHub issue (Phase 4B). Stores the full activity timeline: state changes, label edits, anchor additions, proposal links, and comments. The timeline query unions these events with ``musehub_issue_comments`` sorted by ``created_at``, allowing incremental migration without touching existing comment rows. ``event_type`` values: opened | closed | reopened | commented | labeled | unlabeled | assigned | unassigned | symbol_anchored | commit_anchored | proposal_linked | proposal_merged ``payload`` is event-type-specific JSON (e.g. ``{"body": "…"}`` for ``commented``, ``{"label": "bug"}`` for ``labeled``). """ __tablename__ = "musehub_issue_events" __table_args__ = ( Index("ix_musehub_issue_events_issue_created", "issue_id", "created_at"), ) # --- Required fields --- event_id: Mapped[str] = mapped_column(String(128), primary_key=True) issue_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_issues.issue_id", ondelete="CASCADE"), nullable=False, index=True, ) repo_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"), nullable=False, index=True, ) event_type: Mapped[str] = mapped_column(String(64), nullable=False) # --- Optional fields --- actor: Mapped[str] = mapped_column(String(255), nullable=False, default="") payload: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default_factory=dict) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default_factory=_utc_now, index=True ) issue: Mapped[MusehubIssue] = relationship("MusehubIssue", back_populates="events", init=False) class MusehubProposal(MappedAsDataclass, Base): """A merge proposal — the V2 name for a proposal. ``state`` progresses: ``open`` → ``merged`` | ``closed``. ``merge_commit_id`` is populated only when state becomes ``merged``. V2 additions: - ``domain_diff``: the domain plugin's native diff payload (symbol ops for code, piano roll delta for MIDI). Populated by the proposal risk engine on creation. - ``risk_score``, ``blast_delta``, ``breakage_count``, ``test_gap_count``, ``symbols_changed``: composite risk assessment computed by the risk engine. """ __tablename__ = "musehub_proposals" __table_args__ = ( # Proposal list: WHERE repo_id = ? AND state = ? (open/closed/merged filter) Index("ix_musehub_proposals_repo_state", "repo_id", "state"), # Per-repo proposal lookup: WHERE repo_id = ? AND proposal_number = ? Index("ix_musehub_proposals_repo_number", "repo_id", "proposal_number"), # Phase 7: list + sort by date (most common access pattern) Index("ix_musehub_proposals_repo_state_created", "repo_id", "state", text("created_at DESC")), # Phase 7: list + sort by risk score Index("ix_musehub_proposals_repo_state_risk", "repo_id", "state", text("risk_score DESC NULLS LAST")), ) # --- Required fields --- proposal_id: Mapped[str] = mapped_column(String(128), primary_key=True) repo_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"), nullable=False, index=True, ) # Per-repository sequential integer. Assigned at creation time; unique within the repo. proposal_number: Mapped[int] = mapped_column(Integer, nullable=False) title: Mapped[str] = mapped_column(String(500), nullable=False) from_branch: Mapped[str] = mapped_column(String(255), nullable=False) to_branch: Mapped[str] = mapped_column(String(255), nullable=False) # --- Optional fields with Python-side defaults --- body: Mapped[str] = mapped_column(Text, nullable=False, default="") state: Mapped[str] = mapped_column(String(20), nullable=False, default="open", server_default="open", index=True) # Populated when state transitions to 'merged' merge_commit_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) merged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) author: Mapped[str] = mapped_column(String(255), nullable=False, default="") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default_factory=_utc_now ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default_factory=_utc_now, onupdate=_utc_now ) # V2: domain plugin's native diff payload domain_diff: Mapped[JSONObject | None] = mapped_column(JSONB, nullable=True, default=None) # V2: composite risk assessment (populated by proposal risk engine) risk_score: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None) blast_delta: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None) breakage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) test_gap_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) symbols_changed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Symbol addresses touched by commits on from_branch touched_symbols: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default_factory=list) # --- Phase 1: Proposal reimagination fields --- # Semantic type — governs which merge strategies and conditions apply proposal_type: Mapped[str] = mapped_column(String(50), nullable=False, default="state_merge", server_default="state_merge") # Draft proposals are visible but cannot be merged is_draft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") # Merge gate conditions override (JSON); None falls back to repo defaults merge_conditions: Mapped[JSONObject | None] = mapped_column(JSONB, nullable=True, default=None) # Conflict resolution strategy merge_strategy: Mapped[str] = mapped_column(String(50), nullable=False, default="overlay", server_default="overlay") # For DOMAIN_SELECTIVE strategy: which domains to merge selective_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True, default=None) # Per-domain risk scores {"code": 0.8, "midi": 0.3, ...} dimensional_risk: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default_factory=dict) # MIDI domain summary midi_tracks_changed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) midi_notes_delta: Mapped[int] = mapped_column(Integer, nullable=False, default=0) harmonic_tension_delta: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None) # Payment domain summary payment_claim_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) payment_ledger_delta_nano: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0) payment_avax_address: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) # Agent provenance — populated when author is an agent agent_model: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) agent_spawned_by: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) # Proposer signature — Ed25519 sig over canonical PROPOSE message proposer_signature: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) proposer_public_key: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) # Commit anchors — HEAD commit IDs of each branch at proposal creation time. from_commit_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) to_commit_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) # Snapshot anchors — the actual Muse Snapshot (manifest) ID that each commit # points to, at proposal creation time. Despite the name, these columns held # commit IDs (not snapshot IDs) from #0049 until this fix -- see musehub#144 # review. Kept for backward compatibility with existing rows/readers; new # code should prefer from_commit_id/to_commit_id when a commit is what's # actually needed. from_snapshot_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) to_snapshot_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) # --- Relationships --- repo: Mapped["MusehubRepo"] = relationship("MusehubRepo", back_populates="proposals", init=False) reviews: Mapped[list[MusehubProposalReview]] = relationship( "MusehubProposalReview", back_populates="proposal", cascade="all, delete-orphan", init=False, default_factory=list, ) review_comments: Mapped[list[MusehubProposalComment]] = relationship( "MusehubProposalComment", back_populates="proposal", cascade="all, delete-orphan", init=False, default_factory=list, ) class MusehubProposalReview(Base): """A formal review submission on a merge proposal. Tracks both reviewer assignment (``pending`` state) and submitted reviews (``approved``, ``changes_requested``, ``dismissed``). One row per (proposal_id, reviewer_username) pair — a reviewer can only hold one active state at a time. Re-submitting replaces the previous state. State lifecycle: requested (by proposal author) → pending reviewer submits → approved | changes_requested | dismissed A proposal is merge-ready when every pending/changes_requested review has been resolved to ``approved``, or the owner forces a merge. """ __tablename__ = "musehub_proposal_reviews" __table_args__ = ( # Phase 7: batch prefetch approvals — WHERE proposal_id IN (?) AND state = 'approved' Index("ix_musehub_proposal_reviews_proposal_state", "proposal_id", "state"), ) review_id: Mapped[str] = mapped_column(String(128), primary_key=True) proposal_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"), nullable=False, index=True, ) reviewer_username: Mapped[str] = mapped_column(String(255), nullable=False, index=True) # pending | approved | changes_requested | dismissed state: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", server_default="pending", index=True) body: Mapped[str | None] = mapped_column(Text, nullable=True) submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utc_now ) # Phase 1: dimensional review fields # Which domains this reviewer explicitly covered in their review reviewed_domains: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) # Per-domain risk acknowledgement — reviewer signals they understand the risk domain_risk_acknowledged: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default=dict) # Reviewer's suggested merge strategy (None = accept proposal default) suggested_merge_strategy: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None) proposal: Mapped[MusehubProposal] = relationship( "MusehubProposal", back_populates="reviews" ) class MusehubProposalComment(MappedAsDataclass, Base): """Inline review comment on a dimensional diff within a merge proposal. Domain-agnostic targeting via ``dimension_ref`` — a JSON object whose schema is defined by the repo's domain plugin. Examples: - MIDI domain: ``{"dim": "harmony", "position": {"beat": 16, "beat_end": 24}}`` - Code domain: ``{"dim": "symbol", "symbol": "AuthService.login", "file": "auth.py"}`` - Genomics: ``{"dim": "sequence", "start": 1024, "end": 2048}`` - General (no target): ``{}`` V2 adds ``symbol_address`` — direct anchor to a symbol address (e.g. ``"auth.py::AuthService.login"``). Takes precedence over ``dimension_ref`` for code-domain proposals; ``dimension_ref`` remains for non-code domains. ``parent_comment_id`` enables threaded replies. """ __tablename__ = "musehub_proposal_comments" # --- Required fields --- comment_id: Mapped[str] = mapped_column(String(128), primary_key=True) proposal_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"), nullable=False, index=True, ) repo_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) author: Mapped[str] = mapped_column(String(255), nullable=False) body: Mapped[str] = mapped_column(Text, nullable=False) # --- Optional fields --- # Content-addressed identity ID — resilient to handle changes; enables sigil without extra lookup. author_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None, index=True) # AI authorship provenance (empty string = human-authored). agent_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) model_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) # Domain-agnostic dimension reference — schema defined by the domain plugin dimension_ref: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default_factory=dict) # V2: direct symbol address anchor (e.g. "auth.py::AuthService.login") symbol_address: Mapped[str | None] = mapped_column(String(512), nullable=True, default=None) parent_comment_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True, default=None) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default_factory=_utc_now, index=True ) updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) is_deleted: Mapped[bool] = mapped_column(nullable=False, default=False, server_default=sa.text("false")) proposal: Mapped[MusehubProposal] = relationship( "MusehubProposal", back_populates="review_comments", init=False ) class MusehubProposalDependency(MappedAsDataclass, Base): """Hard dependency edge between two proposals in the same repo. ``dependent_proposal_id`` cannot be merged until ``dependency_proposal_id`` reaches MERGED state. Kahn's algorithm runs over these edges at merge-time to enforce ordering and detect cycles. Both proposals must belong to the same repo — enforced at the service layer. """ __tablename__ = "musehub_proposal_dependencies" __table_args__ = ( # Fast "what does proposal X depend on?" lookup Index("ix_musehub_prop_deps_dependent", "dependent_proposal_id"), # Fast "what proposals are blocked by X?" lookup (reverse edge) Index("ix_musehub_prop_deps_dependency", "dependency_proposal_id"), # Prevent duplicate edges sa.UniqueConstraint("dependent_proposal_id", "dependency_proposal_id", name="uq_musehub_prop_dep_edge"), ) dep_id: Mapped[str] = mapped_column(String(128), primary_key=True) dependent_proposal_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"), nullable=False, ) dependency_proposal_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"), nullable=False, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default_factory=_utc_now ) class MusehubProposalSimulation(MappedAsDataclass, Base): """Cached result of a phased-merge simulation run for a proposal. The merge engine computes this once per (proposal_id, simulation_type) and caches the result here. Stale when the proposal's from_branch advances — detected by comparing ``from_branch_commit_id`` to the live tip. ``simulation_type`` values: - ``conflict_scan`` — identifies files / symbols that will conflict - ``risk_projection`` — projects post-merge dimensional risk scores - ``dependency_order`` — Kahn's topological sort of the dependency DAG """ __tablename__ = "musehub_proposal_simulations" __table_args__ = ( # One live simulation per (proposal, type) sa.UniqueConstraint("proposal_id", "simulation_type", name="uq_musehub_prop_sim"), Index("ix_musehub_prop_sim_proposal", "proposal_id"), ) simulation_id: Mapped[str] = mapped_column(String(128), primary_key=True) proposal_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_proposals.proposal_id", ondelete="CASCADE"), nullable=False, ) simulation_type: Mapped[str] = mapped_column(String(50), nullable=False) # Commit tip of from_branch at time of simulation — used for staleness check from_branch_commit_id: Mapped[str] = mapped_column(String(128), nullable=False, default="") # JSON payload — schema determined by simulation_type result: Mapped[JSONObject] = mapped_column(JSONB, nullable=False, default_factory=dict) # Execution metrics duration_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default_factory=_utc_now ) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) class MusehubFork(Base): """Fork relationship record — links a source repo to its forked copy. One row per fork. The unique constraint on ``(source_repo_id, forked_by)`` enforces the rule that each identity may fork a given source repo at most once. Both foreign keys use ``CASCADE`` so that deleting either repo automatically removes the record. Queried two ways: - By ``source_repo_id`` to list all forks of a repo. - By ``forked_by`` to list all repos a given user has forked. """ __tablename__ = "musehub_forks" # genesis-addressed: sha256(source_repo_id NUL fork_repo_id NUL created_at_iso) fork_id: Mapped[str] = mapped_column(String(128), primary_key=True) source_repo_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"), nullable=False, index=True, ) fork_repo_id: Mapped[str] = mapped_column( String(128), ForeignKey("musehub_repos.repo_id", ondelete="CASCADE"), nullable=False, index=True, ) # MSign handle of the identity that performed the fork. forked_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utc_now ) __table_args__ = ( UniqueConstraint("source_repo_id", "forked_by", name="uq_musehub_forks"), )