2121
2222import json
2323from collections .abc import Sequence
24+ from dataclasses import dataclass
2425
2526from whygraph .core .config import RationaleConfig
2627from 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+
69117def _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 (
0 commit comments