codebase-brain-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ """Text embeddings. A real local model by default; a deterministic hashing embedder for tests and offline use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from typing import Protocol
8
+
9
+ import numpy as np
10
+
11
+ from .config import Settings
12
+
13
+ HASH_DIM = 512
14
+ _TOKEN = re.compile(r"[A-Za-z][a-z]+|[A-Z]+(?![a-z])|\d+")
15
+
16
+
17
+ class Embedder(Protocol):
18
+ model_id: str
19
+
20
+ def embed_documents(self, texts: list[str]) -> np.ndarray: ...
21
+
22
+ def embed_query(self, text: str) -> np.ndarray: ...
23
+
24
+
25
+ class FastEmbedEmbedder:
26
+ """ONNX model run locally by fastembed: no API key, no network after the first download."""
27
+
28
+ def __init__(self, model_name: str):
29
+ self.model_id = f"fastembed:{model_name}"
30
+ self._model_name = model_name
31
+ self._model = None
32
+
33
+ def _load(self):
34
+ if self._model is None:
35
+ from fastembed import TextEmbedding # slow import; only pay for it when embedding
36
+
37
+ self._model = TextEmbedding(model_name=self._model_name)
38
+ return self._model
39
+
40
+ def embed_documents(self, texts: list[str]) -> np.ndarray:
41
+ if not texts:
42
+ return np.zeros((0, 0), dtype=np.float32)
43
+ return _normalise(np.array(list(self._load().passage_embed(texts, batch_size=32)), dtype=np.float32))
44
+
45
+ def embed_query(self, text: str) -> np.ndarray:
46
+ return _normalise(np.array(list(self._load().query_embed(text)), dtype=np.float32))[0]
47
+
48
+
49
+ class HashEmbedder:
50
+ """Bag of words hashed into a fixed vector. Weak semantically, but fast, offline and deterministic."""
51
+
52
+ model_id = f"hash:{HASH_DIM}"
53
+
54
+ def embed_documents(self, texts: list[str]) -> np.ndarray:
55
+ return _normalise(np.stack([self._vector(t) for t in texts])) if texts else np.zeros((0, HASH_DIM), np.float32)
56
+
57
+ def embed_query(self, text: str) -> np.ndarray:
58
+ return _normalise(self._vector(text)[None, :])[0]
59
+
60
+ @staticmethod
61
+ def _vector(text: str) -> np.ndarray:
62
+ vector = np.zeros(HASH_DIM, dtype=np.float32)
63
+ for token in _TOKEN.findall(text):
64
+ digest = hashlib.blake2b(token.lower().encode(), digest_size=4).digest()
65
+ vector[int.from_bytes(digest, "little") % HASH_DIM] += 1.0
66
+ return vector
67
+
68
+
69
+ def make_embedder(settings: Settings) -> Embedder:
70
+ if settings.embedder == "hash":
71
+ return HashEmbedder()
72
+ if settings.embedder == "fastembed":
73
+ return FastEmbedEmbedder(settings.embed_model)
74
+ raise ValueError(f"Unknown CODEBASE_BRAIN_EMBEDDER {settings.embedder!r}; use 'fastembed' or 'hash'.")
75
+
76
+
77
+ def _normalise(matrix: np.ndarray) -> np.ndarray:
78
+ norms = np.linalg.norm(matrix, axis=1, keepdims=True)
79
+ return matrix / np.where(norms == 0, 1.0, norms)
@@ -0,0 +1,372 @@
1
+ """Retrieval evaluation: does search put the right code in front of the model?
2
+
3
+ A dataset is a TOML file of queries, each with the chunks a good answer must come from, against a
4
+ repository pinned to one commit. For every search mode we measure where the first relevant result
5
+ lands, then report standard IR metrics with bootstrap confidence intervals, per-category breakdowns,
6
+ paired win/tie/loss counts between modes, and search latency.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import random
13
+ import statistics
14
+ import time
15
+ import tomllib
16
+ from collections.abc import Sequence
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from . import sources
21
+ from .brain import SEARCH_MODES, Brain, SearchMode
22
+ from .store import StoredChunk
23
+
24
+ CUTOFFS = (1, 3, 5, 10)
25
+ DEPTH = 10
26
+ """How many results each query retrieves; every metric is computed at or below this depth."""
27
+ BOOTSTRAP_SAMPLES = 2_000
28
+ BOOTSTRAP_SEED = 7
29
+
30
+
31
+ class DatasetError(ValueError):
32
+ """The dataset file is malformed, or names code that doesn't exist at the pinned commit."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Target:
37
+ path: str
38
+ symbol: str | None = None
39
+
40
+ def matches(self, chunk: StoredChunk) -> bool:
41
+ if chunk.path != self.path:
42
+ return False
43
+ if self.symbol is None:
44
+ return True
45
+ return chunk.symbol == self.symbol or (chunk.symbol or "").startswith(f"{self.symbol}.")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Query:
50
+ id: str
51
+ category: str
52
+ text: str
53
+ targets: tuple[Target, ...]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Dataset:
58
+ name: str
59
+ repo: str
60
+ commit: str
61
+ queries: tuple[Query, ...]
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class QueryResult:
66
+ query: Query
67
+ mode: str
68
+ first_relevant_rank: int | None
69
+ """1-based rank of the first relevant result within DEPTH, or None if none was found."""
70
+ targets_found: int
71
+ ndcg: float
72
+ latency_ms: float
73
+ top: tuple[str, ...]
74
+ """Citations of the top results, for reading failures."""
75
+
76
+ @property
77
+ def reciprocal_rank(self) -> float:
78
+ return 1.0 / self.first_relevant_rank if self.first_relevant_rank else 0.0
79
+
80
+ def hit(self, k: int) -> bool:
81
+ return self.first_relevant_rank is not None and self.first_relevant_rank <= k
82
+
83
+ @property
84
+ def recall(self) -> float:
85
+ return self.targets_found / len(self.query.targets)
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ModeSummary:
90
+ mode: str
91
+ queries: int
92
+ hit_at: dict[int, float]
93
+ mrr: float
94
+ ndcg: float
95
+ recall: float
96
+ mrr_ci: tuple[float, float]
97
+ hit5_ci: tuple[float, float]
98
+ latency_p50_ms: float
99
+ latency_p95_ms: float
100
+ by_category: dict[str, dict[str, float]] = field(default_factory=dict)
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class EvalReport:
105
+ dataset: Dataset
106
+ embedder: str
107
+ summaries: dict[str, ModeSummary]
108
+ results: dict[str, list[QueryResult]]
109
+ paired: dict[str, tuple[int, int, int]]
110
+ """'hybrid vs keyword' -> (wins, ties, losses) by reciprocal rank, per query."""
111
+
112
+
113
+ # ---- dataset ----------------------------------------------------------------------------------
114
+
115
+
116
+ def load_dataset(path: Path) -> Dataset:
117
+ try:
118
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
119
+ queries = tuple(
120
+ Query(
121
+ id=q["id"],
122
+ category=q["category"],
123
+ text=q["text"],
124
+ targets=tuple(Target(path=t["path"], symbol=t.get("symbol")) for t in q["targets"]),
125
+ )
126
+ for q in raw["query"]
127
+ )
128
+ dataset = Dataset(name=raw["name"], repo=raw["repo"], commit=raw["commit"], queries=queries)
129
+ except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError) as exc:
130
+ raise DatasetError(f"Can't read dataset {path}: {exc}") from exc
131
+ ids = [q.id for q in dataset.queries]
132
+ if duplicates := sorted({i for i in ids if ids.count(i) > 1}):
133
+ raise DatasetError(f"Duplicate query ids: {', '.join(duplicates)}")
134
+ if empty := [q.id for q in dataset.queries if not q.targets]:
135
+ raise DatasetError(f"Queries without targets: {', '.join(empty)}")
136
+ return dataset
137
+
138
+
139
+ def prepare(brain: Brain, dataset: Dataset) -> str:
140
+ """Clone the repo, check out the pinned commit, index it, and check every target exists there.
141
+ Returns the index name."""
142
+ root = sources.resolve(dataset.repo, brain.settings.clone_dir / "evals")
143
+ sources.run_git(root, "checkout", "-q", "--detach", dataset.commit)
144
+ name = f"eval-{dataset.name}"
145
+ brain.index(str(root), name=name)
146
+ validate_targets(brain, name, dataset)
147
+ return name
148
+
149
+
150
+ def validate_targets(brain: Brain, repo: str, dataset: Dataset) -> None:
151
+ """Fail loudly if the dataset names files or symbols that aren't in the index (dataset rot)."""
152
+ store = brain.store_for(repo)
153
+ files = store.file_paths()
154
+ symbols = set(store.symbols())
155
+ missing = []
156
+ for query in dataset.queries:
157
+ for target in query.targets:
158
+ if target.path not in files:
159
+ missing.append(f"{query.id}: no file {target.path}")
160
+ elif target.symbol and (target.symbol, target.path) not in symbols:
161
+ missing.append(f"{query.id}: no symbol {target.symbol} in {target.path}")
162
+ if missing:
163
+ raise DatasetError("Dataset doesn't match the code:\n " + "\n ".join(missing))
164
+
165
+
166
+ # ---- running ----------------------------------------------------------------------------------
167
+
168
+
169
+ def run(brain: Brain, repo: str, dataset: Dataset, modes: Sequence[SearchMode] = SEARCH_MODES) -> EvalReport:
170
+ brain.search("warm up", repo=repo, k=1) # load the model before timing anything
171
+ results: dict[str, list[QueryResult]] = {}
172
+ for mode in modes:
173
+ results[mode] = [_run_query(brain, repo, query, mode) for query in dataset.queries]
174
+ summaries = {mode: summarise(mode, rows) for mode, rows in results.items()}
175
+ paired = {}
176
+ if "hybrid" in results:
177
+ for other in modes:
178
+ if other != "hybrid":
179
+ paired[f"hybrid vs {other}"] = compare(results["hybrid"], results[other])
180
+ return EvalReport(
181
+ dataset=dataset, embedder=brain.embedder.model_id, summaries=summaries, results=results, paired=paired
182
+ )
183
+
184
+
185
+ def _run_query(brain: Brain, repo: str, query: Query, mode: SearchMode) -> QueryResult:
186
+ started = time.perf_counter()
187
+ hits = brain.search(query.text, repo=repo, k=DEPTH, mode=mode)
188
+ latency = (time.perf_counter() - started) * 1000
189
+ chunks = [hit.chunk for hit in hits]
190
+ return QueryResult(
191
+ query=query,
192
+ mode=mode,
193
+ first_relevant_rank=first_relevant_rank(chunks, query.targets),
194
+ targets_found=sum(any(t.matches(c) for c in chunks) for t in query.targets),
195
+ ndcg=ndcg(chunks, query.targets, DEPTH),
196
+ latency_ms=latency,
197
+ top=tuple(c.citation for c in chunks[:3]),
198
+ )
199
+
200
+
201
+ # ---- metrics (pure functions) ------------------------------------------------------------------
202
+
203
+
204
+ def first_relevant_rank(chunks: Sequence[StoredChunk], targets: Sequence[Target]) -> int | None:
205
+ for rank, chunk in enumerate(chunks, start=1):
206
+ if any(t.matches(chunk) for t in targets):
207
+ return rank
208
+ return None
209
+
210
+
211
+ def ndcg(chunks: Sequence[StoredChunk], targets: Sequence[Target], k: int) -> float:
212
+ """Binary-gain nDCG@k. Each target earns credit once, at the first result that matches it, so
213
+ returning five chunks of the same function doesn't inflate the score."""
214
+ credited: set[int] = set()
215
+ dcg = 0.0
216
+ for rank, chunk in enumerate(chunks[:k], start=1):
217
+ newly = [i for i, t in enumerate(targets) if i not in credited and t.matches(chunk)]
218
+ if newly:
219
+ credited.add(newly[0])
220
+ dcg += 1.0 / math.log2(rank + 1)
221
+ ideal = sum(1.0 / math.log2(rank + 1) for rank in range(1, min(len(targets), k) + 1))
222
+ return dcg / ideal if ideal else 0.0
223
+
224
+
225
+ def percentile(values: Sequence[float], q: float) -> float:
226
+ """Linear-interpolated percentile, q in [0, 100]."""
227
+ if not values:
228
+ return 0.0
229
+ ordered = sorted(values)
230
+ position = (len(ordered) - 1) * q / 100
231
+ low, high = math.floor(position), math.ceil(position)
232
+ return ordered[low] + (ordered[high] - ordered[low]) * (position - low)
233
+
234
+
235
+ def bootstrap_ci(values: Sequence[float], samples: int = BOOTSTRAP_SAMPLES, seed: int = BOOTSTRAP_SEED):
236
+ """95% percentile-bootstrap confidence interval of the mean, resampling queries."""
237
+ if not values:
238
+ return (0.0, 0.0)
239
+ rng = random.Random(seed) # noqa: S311 (statistics, not security: a seeded RNG keeps CIs reproducible)
240
+ n = len(values)
241
+ means = [statistics.fmean(rng.choices(values, k=n)) for _ in range(samples)]
242
+ return (percentile(means, 2.5), percentile(means, 97.5))
243
+
244
+
245
+ def summarise(mode: str, rows: Sequence[QueryResult]) -> ModeSummary:
246
+ rr = [r.reciprocal_rank for r in rows]
247
+ hit5 = [float(r.hit(5)) for r in rows]
248
+ latencies = [r.latency_ms for r in rows]
249
+ by_category: dict[str, dict[str, float]] = {}
250
+ for category in sorted({r.query.category for r in rows}):
251
+ subset = [r for r in rows if r.query.category == category]
252
+ by_category[category] = {
253
+ "queries": len(subset),
254
+ "hit@1": statistics.fmean(float(r.hit(1)) for r in subset),
255
+ "hit@5": statistics.fmean(float(r.hit(5)) for r in subset),
256
+ "mrr": statistics.fmean(r.reciprocal_rank for r in subset),
257
+ }
258
+ return ModeSummary(
259
+ mode=mode,
260
+ queries=len(rows),
261
+ hit_at={k: statistics.fmean(float(r.hit(k)) for r in rows) for k in CUTOFFS},
262
+ mrr=statistics.fmean(rr),
263
+ ndcg=statistics.fmean(r.ndcg for r in rows),
264
+ recall=statistics.fmean(r.recall for r in rows),
265
+ mrr_ci=bootstrap_ci(rr),
266
+ hit5_ci=bootstrap_ci(hit5),
267
+ latency_p50_ms=percentile(latencies, 50),
268
+ latency_p95_ms=percentile(latencies, 95),
269
+ by_category=by_category,
270
+ )
271
+
272
+
273
+ def compare(a: Sequence[QueryResult], b: Sequence[QueryResult]) -> tuple[int, int, int]:
274
+ """Per-query wins, ties and losses of ``a`` against ``b`` by reciprocal rank."""
275
+ wins = ties = losses = 0
276
+ for x, y in zip(a, b, strict=True):
277
+ if math.isclose(x.reciprocal_rank, y.reciprocal_rank):
278
+ ties += 1
279
+ elif x.reciprocal_rank > y.reciprocal_rank:
280
+ wins += 1
281
+ else:
282
+ losses += 1
283
+ return wins, ties, losses
284
+
285
+
286
+ # ---- reporting --------------------------------------------------------------------------------
287
+
288
+
289
+ def to_markdown(report: EvalReport) -> str:
290
+ d = report.dataset
291
+ lines = [
292
+ f"## Retrieval eval: {d.name} @ `{d.commit[:10]}`",
293
+ "",
294
+ f"{len(d.queries)} queries · embedder `{report.embedder}` · top {DEPTH} results per query · "
295
+ f"95% bootstrap CIs over queries ({BOOTSTRAP_SAMPLES} resamples)",
296
+ "",
297
+ "| Mode | Hit@1 | Hit@3 | Hit@5 | Hit@10 | MRR@10 (95% CI) | nDCG@10 | Recall@10 | p50 / p95 latency |",
298
+ "|---|---|---|---|---|---|---|---|---|",
299
+ ]
300
+ for s in report.summaries.values():
301
+ lines.append(
302
+ f"| **{s.mode}** | {_pct(s.hit_at[1])} | {_pct(s.hit_at[3])} | {_pct(s.hit_at[5])} | "
303
+ f"{_pct(s.hit_at[10])} | {s.mrr:.3f} ({s.mrr_ci[0]:.3f}–{s.mrr_ci[1]:.3f}) | {s.ndcg:.3f} | "
304
+ f"{_pct(s.recall)} | {s.latency_p50_ms:.0f} / {s.latency_p95_ms:.0f} ms |"
305
+ )
306
+ categories = sorted({c for s in report.summaries.values() for c in s.by_category})
307
+ lines += [
308
+ "",
309
+ "**By query type** (Hit@5 / MRR)",
310
+ "",
311
+ "| Mode | "
312
+ + " | ".join(
313
+ f"{c} ({int(next(iter(report.summaries.values())).by_category[c]['queries'])})" for c in categories
314
+ )
315
+ + " |",
316
+ "|---|" + "---|" * len(categories),
317
+ ]
318
+ for s in report.summaries.values():
319
+ cells = [f"{_pct(s.by_category[c]['hit@5'])} / {s.by_category[c]['mrr']:.2f}" for c in categories]
320
+ lines.append(f"| {s.mode} | " + " | ".join(cells) + " |")
321
+ if report.paired:
322
+ lines += ["", "**Paired, per query** (by reciprocal rank)", ""]
323
+ for name, (wins, ties, losses) in report.paired.items():
324
+ lines.append(f"- {name}: {wins} better, {ties} same, {losses} worse")
325
+ misses = [r for r in report.results.get("hybrid", []) if not r.hit(5)]
326
+ if misses:
327
+ lines += ["", "**Hybrid misses (not in top 5)**", ""]
328
+ for r in misses:
329
+ got = ", ".join(f"`{c}`" for c in r.top) or "nothing"
330
+ lines.append(f'- `{r.query.id}` "{r.query.text}": top results {got}')
331
+ return "\n".join(lines) + "\n"
332
+
333
+
334
+ def to_json(report: EvalReport) -> dict:
335
+ return {
336
+ "dataset": report.dataset.name,
337
+ "commit": report.dataset.commit,
338
+ "embedder": report.embedder,
339
+ "depth": DEPTH,
340
+ "summaries": {
341
+ mode: {
342
+ "hit_at": {str(k): v for k, v in s.hit_at.items()},
343
+ "mrr": s.mrr,
344
+ "mrr_ci": list(s.mrr_ci),
345
+ "hit5_ci": list(s.hit5_ci),
346
+ "ndcg": s.ndcg,
347
+ "recall": s.recall,
348
+ "latency_p50_ms": s.latency_p50_ms,
349
+ "latency_p95_ms": s.latency_p95_ms,
350
+ "by_category": s.by_category,
351
+ }
352
+ for mode, s in report.summaries.items()
353
+ },
354
+ "paired": {name: dict(zip(("wins", "ties", "losses"), v, strict=True)) for name, v in report.paired.items()},
355
+ "queries": {
356
+ mode: [
357
+ {
358
+ "id": r.query.id,
359
+ "category": r.query.category,
360
+ "rank": r.first_relevant_rank,
361
+ "ndcg": round(r.ndcg, 4),
362
+ "top": list(r.top),
363
+ }
364
+ for r in rows
365
+ ]
366
+ for mode, rows in report.results.items()
367
+ },
368
+ }
369
+
370
+
371
+ def _pct(value: float) -> str:
372
+ return f"{value * 100:.0f}%"
@@ -0,0 +1,214 @@
1
+ """Read a repository's history with git: commits, who wrote which lines, how a line range evolved, and PRs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import urllib.error
8
+ import urllib.request
9
+ from collections import Counter
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+
14
+ from .sources import SourceError, run_git
15
+
16
+ FIELD = "\x1f"
17
+ RECORD = "\x1e"
18
+ COMMIT_MARKER = "\x1dcommit\x1d"
19
+ BODY_CHARS = 1_200
20
+ DIFF_CHARS = 1_500
21
+ GITHUB_TIMEOUT_SECONDS = 8
22
+ _PR_PATTERNS = (re.compile(r"\(#(\d{1,7})\)"), re.compile(r"Merge pull request #(\d{1,7})"))
23
+ _GITHUB_REMOTE = re.compile(r"github\.com[:/]([A-Za-z0-9-]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Commit:
28
+ sha: str
29
+ author: str
30
+ date: str
31
+ """ISO 8601, author date."""
32
+ subject: str
33
+ body: str
34
+ prs: tuple[int, ...] = ()
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class LineChange:
39
+ commit: Commit
40
+ diff: str
41
+ """The part of the commit's diff that touched the range, trimmed."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class BlameShare:
46
+ sha: str
47
+ author: str
48
+ date: str
49
+ subject: str
50
+ lines: int
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PullRequest:
55
+ number: int
56
+ url: str
57
+ title: str | None = None
58
+ body: str | None = None
59
+ state: str | None = None
60
+ merged_at: str | None = None
61
+
62
+
63
+ def is_git_repo(root: Path) -> bool:
64
+ try:
65
+ return run_git(root, "rev-parse", "--is-inside-work-tree").strip() == "true"
66
+ except SourceError:
67
+ return False
68
+
69
+
70
+ def commits(root: Path, since: str | None, limit: int) -> list[Commit]:
71
+ """Commits reachable from HEAD, newest first; only those after ``since`` when it's an ancestor of HEAD."""
72
+ revision = "HEAD"
73
+ if since and _is_ancestor(root, since):
74
+ revision = f"{since}..HEAD"
75
+ out = run_git(
76
+ root, "log", revision, f"--max-count={limit}", f"--format=%H{FIELD}%an{FIELD}%aI{FIELD}%s{FIELD}%b{RECORD}"
77
+ )
78
+ return [_parse_commit(record) for record in out.split(RECORD) if record.strip()]
79
+
80
+
81
+ def line_history(root: Path, path: str, start: int, end: int, limit: int) -> list[LineChange]:
82
+ """Commits that changed lines start..end of ``path`` (following the lines as they moved), newest first."""
83
+ out = run_git(
84
+ root,
85
+ "log",
86
+ f"-L{start},{end}:{path}",
87
+ f"--max-count={limit}",
88
+ f"--format={COMMIT_MARKER}%H{FIELD}%an{FIELD}%aI{FIELD}%s{FIELD}%b{RECORD}",
89
+ )
90
+ changes = []
91
+ for block in out.split(COMMIT_MARKER):
92
+ if not block.strip() or RECORD not in block:
93
+ continue
94
+ header, diff = block.split(RECORD, 1)
95
+ changes.append(LineChange(commit=_parse_commit(header), diff=_trim(diff.strip(), DIFF_CHARS)))
96
+ return changes
97
+
98
+
99
+ def file_history(root: Path, path: str, limit: int) -> list[LineChange]:
100
+ """Commits that touched ``path`` at all (following renames), newest first, without diffs."""
101
+ out = run_git(
102
+ root,
103
+ "log",
104
+ "--follow",
105
+ f"--max-count={limit}",
106
+ f"--format=%H{FIELD}%an{FIELD}%aI{FIELD}%s{FIELD}%b{RECORD}",
107
+ "--",
108
+ path,
109
+ )
110
+ return [LineChange(commit=_parse_commit(r), diff="") for r in out.split(RECORD) if r.strip()]
111
+
112
+
113
+ def blame(root: Path, path: str, start: int, end: int) -> list[BlameShare]:
114
+ """Which commits the current lines come from, biggest share first."""
115
+ out = run_git(root, "blame", "--line-porcelain", f"-L{start},{end}", "--", path)
116
+ counts: Counter[str] = Counter()
117
+ info: dict[str, dict[str, str]] = {}
118
+ sha = None
119
+ for line in out.splitlines():
120
+ if re.match(r"^[0-9a-f]{40} ", line):
121
+ sha = line.split(" ", 1)[0]
122
+ counts[sha] += 1
123
+ info.setdefault(sha, {})
124
+ elif sha and line.startswith(("author ", "author-time ", "summary ")):
125
+ key, value = line.split(" ", 1)
126
+ info[sha].setdefault(key, value)
127
+ shares = []
128
+ for sha, lines in counts.most_common():
129
+ meta = info[sha]
130
+ shares.append(
131
+ BlameShare(
132
+ sha=sha,
133
+ author=meta.get("author", ""),
134
+ date=_epoch_to_iso(meta.get("author-time")),
135
+ subject=meta.get("summary", ""),
136
+ lines=lines,
137
+ )
138
+ )
139
+ return shares
140
+
141
+
142
+ def github_slug(root: Path) -> str | None:
143
+ """``owner/repo`` when the origin remote is on GitHub."""
144
+ try:
145
+ url = run_git(root, "remote", "get-url", "origin").strip()
146
+ except SourceError:
147
+ return None
148
+ match = _GITHUB_REMOTE.search(url)
149
+ return f"{match.group(1)}/{match.group(2)}" if match else None
150
+
151
+
152
+ def pr_numbers(message: str) -> tuple[int, ...]:
153
+ found: list[int] = []
154
+ for pattern in _PR_PATTERNS:
155
+ for match in pattern.finditer(message):
156
+ number = int(match.group(1))
157
+ if number not in found:
158
+ found.append(number)
159
+ return tuple(found)
160
+
161
+
162
+ def fetch_pull_request(slug: str, number: int, token: str | None) -> PullRequest:
163
+ """The PR's title and description from the GitHub API. On any failure, just the link."""
164
+ url = f"https://github.com/{slug}/pull/{number}"
165
+ request = urllib.request.Request(
166
+ f"https://api.github.com/repos/{slug}/pulls/{number}",
167
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "codebase-brain-mcp"},
168
+ )
169
+ if token:
170
+ request.add_header("Authorization", f"Bearer {token}")
171
+ try:
172
+ with urllib.request.urlopen(request, timeout=GITHUB_TIMEOUT_SECONDS) as response: # noqa: S310 (fixed host)
173
+ data = json.loads(response.read().decode("utf-8"))
174
+ except (urllib.error.URLError, TimeoutError, ValueError, OSError):
175
+ return PullRequest(number=number, url=url)
176
+ return PullRequest(
177
+ number=number,
178
+ url=url,
179
+ title=data.get("title"),
180
+ body=_trim(data.get("body") or "", BODY_CHARS),
181
+ state=data.get("state"),
182
+ merged_at=data.get("merged_at"),
183
+ )
184
+
185
+
186
+ def _parse_commit(record: str) -> Commit:
187
+ sha, author, date, subject, body = (record.strip("\n").split(FIELD) + [""] * 5)[:5]
188
+ body = body.strip()
189
+ return Commit(
190
+ sha=sha.strip(),
191
+ author=author,
192
+ date=date,
193
+ subject=subject,
194
+ body=_trim(body, BODY_CHARS),
195
+ prs=pr_numbers(f"{subject}\n{body}"),
196
+ )
197
+
198
+
199
+ def _is_ancestor(root: Path, sha: str) -> bool:
200
+ try:
201
+ run_git(root, "merge-base", "--is-ancestor", sha, "HEAD")
202
+ return True
203
+ except SourceError:
204
+ return False
205
+
206
+
207
+ def _trim(text: str, limit: int) -> str:
208
+ return text if len(text) <= limit else text[:limit].rstrip() + "\n…"
209
+
210
+
211
+ def _epoch_to_iso(value: str | None) -> str:
212
+ if not value:
213
+ return ""
214
+ return datetime.fromtimestamp(int(value), UTC).isoformat(timespec="seconds")
File without changes