Skip to content

Commit 48c87f5

Browse files
authored
Merge pull request #30 from mtrdesign/feature/squash-merge-recovery
Recover squash-merged PR commits
2 parents c1ed1ac + af1040d commit 48c87f5

22 files changed

Lines changed: 2309 additions & 29 deletions

docs/squash-merge-recovery-plan.md

Lines changed: 517 additions & 0 deletions
Large diffs are not rendered by default.

src/whygraph/analyze/rationale.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ class CommitEvidence:
3939
4040
* ``"blame"`` — line-level attribution from the target's current
4141
range (highest-precision signal).
42+
* ``"pr-origin"`` — an original feature-branch commit recovered
43+
from a squash-merged PR: when the queried lines blame to a
44+
squash commit, they are re-blamed at the PR's ``head_sha`` so
45+
each line maps back to the commit that actually authored it.
4246
* ``"blame-walked"`` — surfaced only after blame walked past a
4347
refactor-heavy commit. Still line-level, but one or more boring
4448
commits were skipped to reach this author.

src/whygraph/analyze/rationale_generator.py

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import json
2323
from collections.abc import Sequence
24+
from dataclasses import dataclass
2425

2526
from whygraph.core.config import RationaleConfig
2627
from whygraph.db.models import Commit, Issue, PullRequest
@@ -66,6 +67,53 @@ def _labels_suffix(raw: str) -> str:
6667
return " [" + ", ".join(str(label) for label in labels) + "]"
6768

6869

70+
def _json_list(raw: str | None) -> list:
71+
"""Decode a JSON-encoded list column; empty list on anything malformed.
72+
73+
Mirrors :func:`whygraph.mcp.evidence._json_list` — kept local so the
74+
formatters do not depend on the MCP layer.
75+
"""
76+
if not raw:
77+
return []
78+
try:
79+
parsed = json.loads(raw)
80+
except (TypeError, json.JSONDecodeError):
81+
return []
82+
return parsed if isinstance(parsed, list) else []
83+
84+
85+
@dataclass(frozen=True, slots=True)
86+
class _PrRenderCaps:
87+
"""Size caps for rendering a PR block into the rationale prompt.
88+
89+
Passed explicitly into the module-level formatters (rather than read
90+
from a global :func:`get_config`) so they stay pure and unit-testable.
91+
Defaults mirror :class:`~whygraph.core.config.RationaleConfig`.
92+
93+
Attributes
94+
----------
95+
pr_roster_max_commits : int
96+
Max squashed-commit headlines rendered into a PR block.
97+
pr_discussion_max_comments : int
98+
Max PR comments rendered into a PR block.
99+
pr_comment_max_chars : int
100+
Per-comment body clip.
101+
"""
102+
103+
pr_roster_max_commits: int = 30
104+
pr_discussion_max_comments: int = 20
105+
pr_comment_max_chars: int = 500
106+
107+
@classmethod
108+
def from_config(cls, config: RationaleConfig) -> "_PrRenderCaps":
109+
"""Project the three rendering caps out of a :class:`RationaleConfig`."""
110+
return cls(
111+
pr_roster_max_commits=config.pr_roster_max_commits,
112+
pr_discussion_max_comments=config.pr_discussion_max_comments,
113+
pr_comment_max_chars=config.pr_comment_max_chars,
114+
)
115+
116+
69117
def _indent_block(text: str, prefix: str) -> str:
70118
"""Indent every line of ``text`` by ``prefix``."""
71119
return "\n".join(prefix + line for line in text.splitlines())
@@ -90,15 +138,38 @@ def _format_commit(commit: Commit) -> list[str]:
90138
return lines
91139

92140

93-
def _format_pr(pr: PullRequest) -> list[str]:
94-
"""Render one pull request as the indented lines of an evidence block."""
141+
def _format_pr(pr: PullRequest, caps: _PrRenderCaps = _PrRenderCaps()) -> list[str]:
142+
"""Render one pull request as the indented lines of an evidence block.
143+
144+
Appends the squashed-commit roster and the PR discussion so the LLM
145+
sees the narrative a squash merge collapsed. Both are clipped by
146+
``caps`` to bound the prompt size; ``pr.commit_titles`` / ``pr.comments``
147+
are JSON-encoded list columns decoded via :func:`_json_list`.
148+
"""
95149
when = f"merged {pr.merged_at}" if pr.merged_at else pr.state
96150
author = f"by {pr.author}" if pr.author else "by unknown"
97151
lines = [f" PR #{pr.number} {author} {when}{_labels_suffix(pr.labels)}"]
98152
lines.append(f" Title: {pr.title}")
99153
if pr.body and pr.body.strip():
100154
lines.append(" Body:")
101155
lines.append(_indent_block(pr.body.strip(), " "))
156+
titles = _json_list(pr.commit_titles)[: caps.pr_roster_max_commits]
157+
if titles:
158+
lines.append(" Squashed commits:")
159+
for c in titles:
160+
if not isinstance(c, dict):
161+
continue
162+
oid = (c.get("oid") or "")[:9]
163+
lines.append(f" - {c.get('headline', '')} ({oid})")
164+
comments = _json_list(pr.comments)[: caps.pr_discussion_max_comments]
165+
if comments:
166+
lines.append(" Discussion:")
167+
for cm in comments:
168+
if not isinstance(cm, dict):
169+
continue
170+
who = cm.get("author") or "unknown"
171+
body = (cm.get("body") or "").strip()[: caps.pr_comment_max_chars]
172+
lines.append(_indent_block(f"[{who}] {body}", " "))
102173
return lines
103174

104175

@@ -118,13 +189,16 @@ def _format_issue(issue: Issue) -> list[str]:
118189

119190
_SOURCE_LABELS = {
120191
"blame": "line-blame",
192+
"pr-origin": "original commit recovered from a squash-merged PR",
121193
"blame-walked": "line-blame (skipped a refactor commit)",
122194
"predecessor-blame": "line-blame on a pre-rename predecessor file",
123195
"area": "area-history (touched the file but not these lines)",
124196
}
125197

126198

127-
def _format_evidence(evidence: Sequence[CommitEvidence]) -> str:
199+
def _format_evidence(
200+
evidence: Sequence[CommitEvidence], caps: _PrRenderCaps = _PrRenderCaps()
201+
) -> str:
128202
"""Render an evidence bundle as the text payload for the rationale prompt.
129203
130204
Commits are formatted in the order given — the caller controls
@@ -135,6 +209,9 @@ def _format_evidence(evidence: Sequence[CommitEvidence]) -> str:
135209
the row reached this bundle (line-blame, area-history, etc.), so the
136210
LLM can weight precision-vs-coverage signals when synthesising the
137211
rationale.
212+
213+
``caps`` bounds the per-PR roster / discussion rendering (see
214+
:func:`_format_pr`).
138215
"""
139216
n_prs = sum(len(item.pull_requests) for item in evidence)
140217
n_issues = sum(len(item.issues) for item in evidence)
@@ -149,7 +226,7 @@ def _format_evidence(evidence: Sequence[CommitEvidence]) -> str:
149226
lines.insert(1, f" Source: {label}")
150227
for pr in item.pull_requests:
151228
lines.append("")
152-
lines.extend(_format_pr(pr))
229+
lines.extend(_format_pr(pr, caps))
153230
for issue in item.issues:
154231
lines.append("")
155232
lines.extend(_format_issue(issue))
@@ -331,6 +408,11 @@ class RationaleGenerator:
331408
``task`` should contain the
332409
:data:`~whygraph.analyze.prompt.RATIONALE_PLACEHOLDER` token. Mostly
333410
used in tests and one-off overrides.
411+
caps : _PrRenderCaps, optional
412+
Size caps for rendering each PR's squashed-commit roster and
413+
discussion into the prompt. ``None`` (default) uses the
414+
:class:`~whygraph.core.config.RationaleConfig` defaults;
415+
:meth:`from_config` projects them from the loaded config.
334416
335417
Examples
336418
--------
@@ -345,9 +427,11 @@ def __init__(
345427
*,
346428
timeout_sec: int | None = None,
347429
rationale_prompt: Prompt | None = None,
430+
caps: _PrRenderCaps | None = None,
348431
) -> None:
349432
self._client = client
350433
self._timeout_sec = timeout_sec
434+
self._caps = caps if caps is not None else _PrRenderCaps()
351435
self._rationale_prompt = (
352436
rationale_prompt
353437
if rationale_prompt is not None
@@ -390,7 +474,11 @@ def from_config(
390474
"""
391475
factory = factory if factory is not None else LlmClientFactory()
392476
client = factory.make(config.provider, model=config.model)
393-
return cls(client, timeout_sec=config.timeout_sec)
477+
return cls(
478+
client,
479+
timeout_sec=config.timeout_sec,
480+
caps=_PrRenderCaps.from_config(config),
481+
)
394482

395483
def generate(
396484
self,
@@ -433,7 +521,7 @@ def generate(
433521

434522
# TODO: capping bundle size belongs to the future evidence-bundle
435523
# builder — the generator neither truncates nor chunks its input.
436-
bundle = _format_evidence(evidence)
524+
bundle = _format_evidence(evidence, self._caps)
437525
if symbol_context is not None:
438526
bundle = f"{_format_symbol_context(symbol_context)}\n\n{bundle}"
439527
task = render(

src/whygraph/cli/commands/scan.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
from rich.table import Table
1313
from rich.text import Text
1414

15-
from whygraph.scan import CodeGraphCrawler, Crawler, GitCrawler, GitHubCrawler
15+
from whygraph.scan import (
16+
CodeGraphCrawler,
17+
Crawler,
18+
GitCrawler,
19+
GitHubCrawler,
20+
PROriginEnricher,
21+
)
1622

1723
from ..console import console
1824

@@ -73,11 +79,25 @@
7379
"auto-rescan git hooks (`whygraph hooks install`). Default: on."
7480
),
7581
)
82+
@click.option(
83+
"--pr-origins/--no-pr-origins",
84+
"enrich_pr_origins",
85+
default=True,
86+
help=(
87+
"Recover a squash-merged PR's original feature-branch commits — "
88+
"one targeted `git fetch` of the gated PRs' heads, persisted as "
89+
"`commit` rows flagged off the default-branch walk so they enrich "
90+
"evidence without polluting area-history / refactor-walk. Needs "
91+
"the network, so it always runs in the remote phase and is skipped "
92+
"under `--no-remote`. Default: on."
93+
),
94+
)
7695
def scan_cmd(
7796
no_llm_descriptions: bool,
7897
refresh_codegraph: bool,
7998
codegraph_image: str | None,
8099
remote: bool,
100+
enrich_pr_origins: bool,
81101
) -> None:
82102
"""Run the source crawlers, then describe each commit with the LLM."""
83103
# Lazy-imported so that --help and other lightweight CLI surfaces
@@ -121,6 +141,7 @@ def scan_cmd(
121141
analyze_skip=analyze_skip,
122142
codegraph_enabled=refresh_codegraph,
123143
remote_enabled=remote,
144+
pr_origins_enabled=enrich_pr_origins and github_client is not None,
124145
)
125146

126147
scan_log_path = db_path.parent / "scan.log"
@@ -142,8 +163,11 @@ def scan_cmd(
142163
if github_client is not None:
143164
phase1.append(GitHubCrawler(progress, client=github_client))
144165

145-
# Phase 2 — the analyzer, started only once phase 1 has joined
146-
# (it reads the commits phase 1 persisted).
166+
# Phase 2 — started only once phase 1 has joined (it reads the
167+
# commits + PRs phase 1 persisted). The analyzer and the PR-origin
168+
# enricher run concurrently: analyze only touches main-walk commits,
169+
# the enricher only inserts new on_default_branch=0 rows, so they
170+
# never contend over the same commit row.
147171
phase2: list[Crawler] = []
148172
if descriptor is not None:
149173
phase2.append(
@@ -155,6 +179,18 @@ def scan_cmd(
155179
large_commit_file_count=config.analyze.large_commit_file_count,
156180
)
157181
)
182+
# The enricher needs PR rows (the GitHub crawler ran) and the
183+
# network for its fetch — so it is gated on a resolved client, which
184+
# is itself None under --no-remote.
185+
if enrich_pr_origins and github_client is not None:
186+
phase2.append(
187+
PROriginEnricher(
188+
progress,
189+
repository=repository,
190+
min_commits=config.analyze.pr_origin_min_commits,
191+
large_commit_file_count=config.analyze.large_commit_file_count,
192+
)
193+
)
158194

159195
if codegraph_crawler is not None:
160196
codegraph_crawler.start()
@@ -257,6 +293,7 @@ def _render_scan_panel(
257293
analyze_skip: str | None,
258294
codegraph_enabled: bool,
259295
remote_enabled: bool,
296+
pr_origins_enabled: bool,
260297
) -> None:
261298
"""Print a summary panel of what the upcoming scan will collect.
262299
@@ -316,6 +353,14 @@ def _render_scan_panel(
316353
("LLM descriptions", Text(f"skipped — {analyze_skip}", style="yellow"))
317354
)
318355
rows.append(("Worker threads", str(config.scan_max_workers)))
356+
rows.append(
357+
(
358+
"PR commit recovery",
359+
"recover squash-merged PR commits"
360+
if pr_origins_enabled
361+
else Text("skipped", style="yellow"),
362+
)
363+
)
319364

320365
grid = Table.grid(padding=(0, 3))
321366
grid.add_column(style="bold cyan", justify="right", no_wrap=True)

src/whygraph/core/config.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,20 @@ class AnalyzeConfig:
243243
timeout_sec : int or None
244244
Per-call timeout forwarded into :class:`CompletionRequest`.
245245
``None`` (default) defers to the bound adapter's default.
246+
pr_origin_min_commits : int
247+
Commit-rich half of the squash-merge enrichment gate
248+
(:mod:`whygraph.scan.pr_origin_enricher`). A squash-merged PR has
249+
its original feature-branch commits recovered when it collapsed at
250+
least this many commits (the file-bulk half reuses
251+
``large_commit_file_count``). Must be ``>= 1``.
246252
"""
247253

248254
provider: str = "anthropic"
249255
model: str | None = None
250256
max_diff_chars: int = 50_000
251257
large_commit_file_count: int = 30
252258
timeout_sec: int | None = None
259+
pr_origin_min_commits: int = 5
253260

254261

255262
@dataclass(frozen=True, slots=True)
@@ -277,11 +284,24 @@ class RationaleConfig:
277284
timeout_sec : int or None
278285
Per-call timeout forwarded into :class:`CompletionRequest`.
279286
``None`` (default) defers to the bound adapter's default.
287+
pr_roster_max_commits : int
288+
Cap on how many squashed-commit headlines are rendered into a
289+
single PR block in the rationale prompt. Bounds the prompt size
290+
when a squash collapsed a long feature branch. Must be ``>= 1``.
291+
pr_discussion_max_comments : int
292+
Cap on how many PR comments are rendered into a single PR block
293+
in the rationale prompt. Must be ``>= 1``.
294+
pr_comment_max_chars : int
295+
Per-comment body clip applied before rendering a PR comment into
296+
the rationale prompt. Must be ``>= 1``.
280297
"""
281298

282299
provider: str = "anthropic"
283300
model: str | None = None
284301
timeout_sec: int | None = None
302+
pr_roster_max_commits: int = 30
303+
pr_discussion_max_comments: int = 20
304+
pr_comment_max_chars: int = 500
285305

286306

287307
@dataclass(frozen=True, slots=True)
@@ -449,9 +469,12 @@ def __post_init__(self) -> None:
449469
ConfigError
450470
If ``log_level`` is not a known :class:`LogLevel` name, if
451471
``scan_max_workers`` is less than ``1``, if ``scan_provider``
452-
is not one of ``"off"`` / ``"github"`` / ``"auto"``, or if
453-
``analyze.max_diff_chars`` or ``analyze.large_commit_file_count``
454-
is less than ``1``.
472+
is not one of ``"off"`` / ``"github"`` / ``"auto"``, if
473+
``analyze.max_diff_chars``, ``analyze.large_commit_file_count``
474+
or ``analyze.pr_origin_min_commits`` is less than ``1``, or if
475+
any of the ``rationale`` PR-rendering caps
476+
(``pr_roster_max_commits``, ``pr_discussion_max_comments``,
477+
``pr_comment_max_chars``) is less than ``1``.
455478
"""
456479
try:
457480
LogLevel[self.log_level.upper()]
@@ -476,6 +499,26 @@ def __post_init__(self) -> None:
476499
"analyze.large_commit_file_count must be >= 1, "
477500
f"got {self.analyze.large_commit_file_count}"
478501
)
502+
if self.analyze.pr_origin_min_commits < 1:
503+
raise ConfigError(
504+
"analyze.pr_origin_min_commits must be >= 1, "
505+
f"got {self.analyze.pr_origin_min_commits}"
506+
)
507+
if self.rationale.pr_roster_max_commits < 1:
508+
raise ConfigError(
509+
"rationale.pr_roster_max_commits must be >= 1, "
510+
f"got {self.rationale.pr_roster_max_commits}"
511+
)
512+
if self.rationale.pr_discussion_max_comments < 1:
513+
raise ConfigError(
514+
"rationale.pr_discussion_max_comments must be >= 1, "
515+
f"got {self.rationale.pr_discussion_max_comments}"
516+
)
517+
if self.rationale.pr_comment_max_chars < 1:
518+
raise ConfigError(
519+
"rationale.pr_comment_max_chars must be >= 1, "
520+
f"got {self.rationale.pr_comment_max_chars}"
521+
)
479522

480523
@classmethod
481524
def from_toml(cls, path: Path) -> Config:

0 commit comments

Comments
 (0)