1414
1515from __future__ import annotations
1616
17+ import re
1718from dataclasses import dataclass , field
1819from typing import TYPE_CHECKING , Any
1920
2829 from ..schemas .output import ScoredFinding
2930
3031
31- # Action chosen by the reviewer in the form's "action" radio.
32+ # The hax template this gate renders into. Registered in hax-sdk under
33+ # src/templates/pr-af-review (id "pr-af-review-v1"). The template emits the same
34+ # ``{template, values: {...}}`` response envelope form-builder uses, so the
35+ # decision parser below is template-agnostic.
36+ HAX_REVIEW_TEMPLATE = "pr-af-review-v1"
37+
38+ # Action chosen by the reviewer in the template's action buttons. These string
39+ # values are the contract with the hax template's response `values.action`.
3240ACTION_POST = "post_selected"
3341ACTION_RERUN = "rerun"
3442ACTION_REJECT = "reject"
3543_VALID_ACTIONS = {ACTION_POST , ACTION_RERUN , ACTION_REJECT }
3644
37- _SEVERITY_EMOJI = {
38- "critical" : "🔴" ,
39- "important" : "🟠" ,
40- "suggestion" : "🔵" ,
41- "nitpick" : "⚪" ,
42- }
45+ # Longest PR-intent blurb shown in the form. The raw PR body (e.g. a Dependabot
46+ # changelog) is stripped of HTML and truncated to this so the form stays legible.
47+ _MAX_INTENT_CHARS = 700
48+
49+ _HTML_TAG_RE = re .compile (r"<[^>]+>" )
50+ _WS_RE = re .compile (r"[ \t]+" )
51+ _BLANKLINES_RE = re .compile (r"\n{3,}" )
4352
4453
4554@dataclass
@@ -65,101 +74,91 @@ def is_reject(self) -> bool:
6574 return self .action == ACTION_REJECT
6675
6776
68- def _finding_option (finding : ScoredFinding ) -> dict [str , str ]:
69- """One checkbox option per finding: ``id`` is the value, label is human."""
70- emoji = _SEVERITY_EMOJI .get (finding .severity , "•" )
71- loc = finding .file_path or "(no file)"
72- if finding .line_start and finding .line_start > 0 :
73- loc = f"{ loc } :{ finding .line_start } "
74- return {
75- "value" : finding .id ,
76- "label" : f"{ emoji } [{ finding .severity } ] { loc } — { finding .title } " ,
77- }
78-
79-
80- def _build_description (
81- pr_intent : str ,
82- findings : list [ScoredFinding ],
83- revision_iter : int ,
84- revision_history : list [str ],
85- ) -> str :
86- """Markdown blurb shown above the form: PR intent + what's being asked."""
87- counts : dict [str , int ] = {}
88- for f in findings :
89- counts [f .severity ] = counts .get (f .severity , 0 ) + 1
90- count_str = ", " .join (f"{ n } { sev } " for sev , n in counts .items ()) or "no findings"
77+ def clean_intent (text : str | None , max_chars : int = _MAX_INTENT_CHARS ) -> str :
78+ """Turn a raw PR body into a short, legible intent blurb.
9179
92- lines = []
93- if pr_intent :
94- lines .append ("**PR intent:** " + pr_intent .strip ())
95- lines .append ("" )
96- lines .append (
97- f"PR-AF found **{ len (findings )} ** finding(s) ({ count_str } ). "
98- "Check the ones to post, or request a re-review with instructions."
99- )
100- if revision_iter > 0 :
101- lines .append ("" )
102- lines .append (f"_Revision round { revision_iter } ._" )
103- if revision_history :
104- lines .append ("" )
105- lines .append ("Prior instructions:" )
106- for idx , instr in enumerate (revision_history , start = 1 ):
107- if instr :
108- lines .append (f"{ idx } . { instr } " )
109- return "\n " .join (lines )
110-
111-
112- def build_review_form (
80+ PR bodies (especially bot-authored ones like Dependabot) are often a wall of
81+ HTML — ``<details>``, ``<a href>``, changelog tables. The hax template
82+ renders the intent as markdown, where raw HTML shows up literally, so we
83+ strip tags, collapse whitespace, and truncate.
84+ """
85+ if not text :
86+ return ""
87+ stripped = _HTML_TAG_RE .sub (" " , text )
88+ stripped = _WS_RE .sub (" " , stripped )
89+ stripped = _BLANKLINES_RE .sub ("\n \n " , stripped )
90+ stripped = "\n " .join (line .strip () for line in stripped .splitlines ())
91+ stripped = stripped .strip ()
92+ if len (stripped ) > max_chars :
93+ stripped = stripped [:max_chars ].rstrip () + "…"
94+ return stripped
95+
96+
97+ def _finding_payload (finding : ScoredFinding ) -> dict [str , Any ]:
98+ """One finding entry for the hax template (camelCase per its zod schema)."""
99+ entry : dict [str , Any ] = {
100+ "id" : finding .id ,
101+ "severity" : finding .severity ,
102+ "title" : finding .title ,
103+ # All findings start checked, matching the prior "submit posts all" default.
104+ "defaultSelected" : True ,
105+ }
106+ if finding .file_path :
107+ entry ["filePath" ] = finding .file_path
108+ if finding .line_start and finding .line_start > 0 :
109+ entry ["lineStart" ] = finding .line_start
110+ if finding .line_end and finding .line_end > 0 :
111+ entry ["lineEnd" ] = finding .line_end
112+ if finding .body :
113+ entry ["body" ] = finding .body
114+ if finding .suggestion :
115+ entry ["suggestion" ] = finding .suggestion
116+ if finding .dimension_name :
117+ entry ["dimension" ] = finding .dimension_name
118+ if finding .confidence is not None :
119+ entry ["confidence" ] = finding .confidence
120+ return entry
121+
122+
123+ def build_review_payload (
113124 * ,
114125 pr_intent : str ,
115126 findings : list [ScoredFinding ],
116127 title : str ,
128+ pr_meta : dict [str , Any ] | None = None ,
117129 revision_iter : int = 0 ,
118130 revision_history : list [str ] | None = None ,
119- ) -> Any :
120- """Translate the review into a ``hax.FormBuilder`` (imported lazily)."""
121- from hax import FormBuilder
122-
123- all_ids = [f .id for f in findings ]
124- options = [_finding_option (f ) for f in findings ]
125-
126- form = (
127- FormBuilder ()
128- .title (title )
129- .description (
130- _build_description (pr_intent , findings , revision_iter , revision_history or [])
131- )
132- .submit_label ("Submit decision" )
133- )
131+ ) -> dict [str , Any ]:
132+ """Build the ``pr-af-review-v1`` request payload (validated server-side).
134133
135- # checkbox_group requires at least one option; skip it when there are no
136- # findings (an empty review still lets the reviewer approve/reject).
137- if options :
138- form .checkbox_group (
139- "findings_to_post" ,
140- label = "Findings to post" ,
141- description = "Only the checked findings are posted to the PR." ,
142- options = options ,
143- default_value = all_ids ,
144- )
134+ Shape matches ``prAfReviewPayloadSchema`` in the hax-sdk template.
135+ """
136+ counts : dict [str , int ] = {}
137+ for f in findings :
138+ counts [f .severity ] = counts .get (f .severity , 0 ) + 1
139+ count_str = ", " .join (f"{ n } { sev } " for sev , n in counts .items ()) or "no findings"
145140
146- form .radio_group (
147- "action" ,
148- label = "Action" ,
149- options = [
150- {"value" : ACTION_POST , "label" : "Post selected findings" },
151- {"value" : ACTION_RERUN , "label" : "Re-review with instructions below" },
152- {"value" : ACTION_REJECT , "label" : "Reject — post nothing" },
153- ],
154- default_value = ACTION_POST ,
155- )
156- form .textarea (
157- "instructions" ,
158- label = "Re-review instructions (optional)" ,
159- description = "Used when action is 'Re-review'. E.g. 'too aggressive, tone it down'." ,
160- required = False ,
161- )
162- return form
141+ payload : dict [str , Any ] = {
142+ "title" : title ,
143+ "intent" : clean_intent (pr_intent ),
144+ "reviewSummary" : f"PR-AF found { len (findings )} finding(s) ({ count_str } )." ,
145+ "findings" : [_finding_payload (f ) for f in findings ],
146+ "postLabel" : "Post selected" ,
147+ "rerunLabel" : "Re-review with instructions" ,
148+ "rejectLabel" : "Reject" ,
149+ "instructionsPlaceholder" : "e.g. too aggressive, tone it down and drop the nitpicks" ,
150+ }
151+ if pr_meta :
152+ # Drop empties so optional zod fields stay absent rather than "".
153+ cleaned = {k : v for k , v in pr_meta .items () if v not in (None , "" )}
154+ if cleaned :
155+ payload ["pr" ] = cleaned
156+ if revision_iter > 0 or revision_history :
157+ payload ["revision" ] = {
158+ "iteration" : revision_iter ,
159+ "priorInstructions" : [i for i in (revision_history or []) if i and i .strip ()],
160+ }
161+ return payload
163162
164163
165164def _coerce_str (value : Any ) -> str :
@@ -229,11 +228,12 @@ async def request_review_approval(
229228 webhook_url : str | None ,
230229 user_id : str | None ,
231230 expires_in_hours : int ,
231+ pr_meta : dict [str , Any ] | None = None ,
232232 revision_iter : int = 0 ,
233233 revision_history : list [str ] | None = None ,
234234 metadata : dict [str , Any ] | None = None ,
235235) -> ReviewDecision :
236- """Build the form , create the hax request, pause, return the decision.
236+ """Build the payload , create the hax request, pause, return the decision.
237237
238238 Any failure to create the request or pause is surfaced as a reject so the
239239 pipeline never posts an unreviewed review when the gate is enabled.
@@ -245,25 +245,27 @@ async def request_review_approval(
245245 title = f"{ title } — { pr_label } "
246246
247247 try :
248- form = build_review_form (
248+ payload = build_review_payload (
249249 pr_intent = pr_intent ,
250250 findings = findings ,
251251 title = title ,
252+ pr_meta = pr_meta ,
252253 revision_iter = revision_iter ,
253254 revision_history = revision_history ,
254255 )
255256 except Exception as exc :
256257 app .note (
257- f"hitl: failed to build review form : { exc } " ,
258- tags = ["hitl" , "form " , "error" ],
258+ f"hitl: failed to build review payload : { exc } " ,
259+ tags = ["hitl" , "payload " , "error" ],
259260 )
260- return ReviewDecision (action = ACTION_REJECT , instructions = f"form build failed: { exc } " )
261+ return ReviewDecision (action = ACTION_REJECT , instructions = f"payload build failed: { exc } " )
261262
262263 try :
263264 created = await create_hax_form_request_with_timeout (
264265 app = app ,
265266 hax_client = hax_client ,
266- form = form ,
267+ payload = payload ,
268+ request_type = HAX_REVIEW_TEMPLATE ,
267269 title = title ,
268270 description = None ,
269271 expires_in_seconds = expires_in_hours * 3600 ,
0 commit comments