gabriel / musehub public
blame.py python
187 lines 6.9 KB
Raw
sha256:c3910cc561368d2b40576c1fbb0841b5d3abefd0a65c96c85114a7238222c77c fix: root-of-push snapshots were never hash-verified before… Sonnet 5 patch 4 days ago
1 """MuseHub blame — symbol-level commit attribution.
2
3 Provides ``_build_real_symbol_blame`` (used by the blame UI page) and the
4 symbol blame API endpoint.
5
6 Endpoint:
7 GET /repos/{repo_id}/blame/{ref}?path=<file_path>
8
9 Returns ``SymbolBlameResponse``: each named symbol attributed to the commit
10 that last introduced or modified it, with optional intel signals (hotspot,
11 dead, blast risk).
12
13 Auth: public repos allow unauthenticated access (``optional_token``); private
14 repos require a valid MSign token.
15 """
16
17 import logging
18 from datetime import datetime, timezone
19 from typing import Annotated
20
21 from fastapi import APIRouter, Depends, HTTPException, Query, status
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from musehub.auth.dependencies import TokenClaims, optional_token
25 from musehub.db import get_db
26 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef
27 from musehub.models.musehub import SymbolBlameEntry, SymbolBlameResponse
28 from musehub.services import musehub_repository
29
30 from musehub.types.json_types import SymbolHistoryEntry
31 type SymbolHistoryDict = dict[str, list[SymbolHistoryEntry]]
32 type CommitInfoDict = dict[str, str | datetime]
33 type CommitMapDict = dict[str, CommitInfoDict]
34 type BlastAddrsDict = dict[str, list[str]]
35
36 logger = logging.getLogger(__name__)
37
38 router = APIRouter()
39
40
41 def _build_real_symbol_blame(
42 symbol_history: SymbolHistoryDict,
43 path: str,
44 commit_map: CommitMapDict,
45 intel: "IntelSnapshot | None" = None,
46 ) -> list[SymbolBlameEntry]:
47 """Build real symbol-level blame from the materialized symbol history index.
48
49 For each symbol in ``path``, walks the history entries to find the most
50 recent non-delete op and attributes the symbol to that commit.
51
52 Args:
53 symbol_history: Unpacked ``entries`` dict from the msgpack symbol index:
54 ``{address: [{commit_id, op, committed_at, ...}]}``.
55 path: File path to filter to (e.g. ``"musehub/api/routes/blame.py"``).
56 commit_map: ``{commit_id: {message, author, timestamp}}`` for lookups.
57 intel: Optional ``IntelSnapshot`` from ``compute_intel`` — when supplied,
58 populates ``is_hotspot``, ``is_dead``, ``is_blast_risk``, and
59 ``blast_co_symbols`` on each entry.
60
61 Returns:
62 List of ``SymbolBlameEntry`` objects sorted by timestamp descending
63 (most recently changed symbol first).
64 """
65 # Pre-index intel signals by address for O(1) lookup per symbol
66 hotspot_addrs: set[str] = set()
67 dead_addrs: set[str] = set()
68 blast_addrs: BlastAddrsDict = {} # address -> top co-symbols
69 if intel is not None:
70 hotspot_addrs = {h.address for h in intel.hotspots}
71 dead_addrs = {d.address for d in intel.dead_candidates}
72 blast_addrs = {b.address: b.top_co_symbols for b in intel.blast_risk}
73
74 prefix = f"{path}::"
75 results: list[SymbolBlameEntry] = []
76
77 for address, history in symbol_history.items():
78 if not address.startswith(prefix):
79 continue
80 # Skip import declarations — they're noise in blame view
81 if "::import::" in address:
82 continue
83 if not history:
84 continue
85
86 # The last op in history is the most recent change
87 last = history[-1]
88 if last.get("op") == "delete":
89 continue # symbol no longer exists at HEAD
90
91 symbol_name = address[len(prefix):]
92 commit_id = last.get("commit_id", "")
93 committed_at_str = last.get("committed_at", "")
94
95 commit_info = commit_map.get(commit_id, {})
96 message = str(commit_info.get("message", ""))
97 author = str(commit_info.get("author", ""))
98
99 raw_ts = commit_info.get("timestamp")
100 if isinstance(raw_ts, datetime):
101 ts = raw_ts
102 elif committed_at_str:
103 try:
104 ts = datetime.fromisoformat(committed_at_str)
105 except ValueError:
106 ts = datetime.now(tz=timezone.utc)
107 else:
108 ts = datetime.now(tz=timezone.utc)
109
110 results.append(SymbolBlameEntry(
111 symbol_address=address,
112 symbol_name=symbol_name,
113 commit_id=commit_id,
114 commit_message=message,
115 author=author,
116 timestamp=ts,
117 op=last.get("op", "add"),
118 change_count=len(history),
119 is_hotspot=address in hotspot_addrs,
120 is_dead=address in dead_addrs,
121 is_blast_risk=address in blast_addrs,
122 blast_co_symbols=blast_addrs.get(address, []),
123 ))
124
125 results.sort(key=lambda e: e.timestamp, reverse=True)
126 return results
127
128
129 @router.get(
130 "/repos/{repo_id}/blame/{ref}",
131 response_model=SymbolBlameResponse,
132 operation_id="getBlame",
133 summary="Attribute each symbol in a file to the commit that last modified it",
134 )
135 async def get_blame(
136 repo_id: str,
137 ref: str,
138 path: Annotated[str, Query(description="File path within the repo, e.g. 'musehub/api/routes/blame.py'")],
139 db: AsyncSession = Depends(get_db),
140 claims: TokenClaims | None = Depends(optional_token),
141 ) -> SymbolBlameResponse:
142 """Return symbol-level blame for a file at a given commit ref.
143
144 Each entry attributes a named symbol (function, class, variable) to the
145 commit that last introduced or modified it, with authorship and intel
146 signals (hotspot, dead, blast risk).
147
148 Returns 404 if the repo does not exist.
149 Returns 401 if the repo is private and no valid token is supplied.
150 Returns an empty ``entries`` list when no symbol history exists.
151 """
152 from musehub.services.musehub_symbol_indexer import load_symbol_history
153
154 repo = await musehub_repository.get_repo(db, repo_id)
155 if repo is None:
156 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
157 if repo.visibility != "public" and claims is None:
158 raise HTTPException(
159 status_code=status.HTTP_401_UNAUTHORIZED,
160 detail="Authentication required to access private repos.",
161 headers={"WWW-Authenticate": "MSign"},
162 )
163
164 from sqlalchemy import desc, select as sa_select
165
166 rows_result = await db.execute(
167 sa_select(MusehubCommit)
168 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
169 .where(MusehubCommitRef.repo_id == repo_id)
170 .order_by(desc(MusehubCommit.timestamp))
171 .limit(50)
172 )
173 commit_rows = rows_result.scalars().all()
174 commit_map = {
175 r.commit_id: {
176 "message": r.message,
177 "author": r.author,
178 "timestamp": r.timestamp,
179 }
180 for r in commit_rows
181 }
182
183 symbol_history = await load_symbol_history(db, repo_id, file_path=path)
184 entries = _build_real_symbol_blame(symbol_history, path, commit_map)
185
186 logger.info("Blame computed: repo=%s ref=%s path=%s entries=%d", repo_id, ref, path, len(entries))
187 return SymbolBlameResponse(entries=entries, total_entries=len(entries), path=path)
File History 1 commit
sha256:c3910cc561368d2b40576c1fbb0841b5d3abefd0a65c96c85114a7238222c77c fix: root-of-push snapshots were never hash-verified before… Sonnet 5 patch 4 days ago