Skip to content

Commit cb8c99d

Browse files
authored
Merge pull request #39 from Agent-Field/feat/pr-af-review-template
feat(hitl): render PR reviews via a dedicated hax template
2 parents bc85bd6 + d79cbbc commit cb8c99d

6 files changed

Lines changed: 202 additions & 130 deletions

File tree

src/pr_af/hitl/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
ACTION_POST,
1818
ACTION_REJECT,
1919
ACTION_RERUN,
20+
HAX_REVIEW_TEMPLATE,
2021
ReviewDecision,
21-
build_review_form,
22+
build_review_payload,
23+
clean_intent,
2224
parse_review_decision,
2325
request_review_approval,
2426
)
@@ -27,10 +29,12 @@
2729
"ACTION_POST",
2830
"ACTION_REJECT",
2931
"ACTION_RERUN",
32+
"HAX_REVIEW_TEMPLATE",
3033
"ReviewDecision",
3134
"approval_webhook_url",
3235
"build_hax_client_from_env",
33-
"build_review_form",
36+
"build_review_payload",
37+
"clean_intent",
3438
"create_hax_form_request_with_timeout",
3539
"extract_values_from_raw",
3640
"parse_review_decision",

src/pr_af/hitl/client.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ async def create_hax_form_request_with_timeout(
6363
*,
6464
app: Any,
6565
hax_client: HaxClient,
66-
form: Any,
66+
payload: dict[str, Any],
67+
request_type: str,
6768
title: str,
6869
description: str | None,
6970
expires_in_seconds: int,
@@ -72,21 +73,24 @@ async def create_hax_form_request_with_timeout(
7273
metadata: dict[str, Any] | None,
7374
timeout_seconds: float = HAX_CREATE_REQUEST_TIMEOUT_SECONDS,
7475
) -> Any:
75-
"""Submit a hax form-builder request with a hard timeout.
76+
"""Submit a hax request of ``request_type`` with a hard timeout.
7677
7778
Runs the synchronous ``hax_client.create_request`` in a worker thread under
7879
``asyncio.wait_for`` so a wedged hax-sdk fails fast (``RuntimeError``)
7980
instead of silently burning the reasoner's active-time budget. Returns the
8081
``CreatedRequest``; the caller passes ``.id`` / ``.url`` to ``app.pause``.
82+
83+
``payload`` must satisfy the registered hax template's schema — the hax
84+
service validates it server-side and rejects unknown ``request_type`` values.
8185
"""
8286
app.note(
83-
f"hitl: submitting hax form request ({title!r})",
87+
f"hitl: submitting hax request ({request_type}: {title!r})",
8488
tags=["hitl", "hax", "create_request"],
8589
)
8690

8791
kwargs: dict[str, Any] = {
88-
"type": "form-builder",
89-
"payload": form.to_payload(),
92+
"type": request_type,
93+
"payload": payload,
9094
"title": title,
9195
"expires_in_seconds": expires_in_seconds,
9296
}

src/pr_af/hitl/review_gate.py

Lines changed: 101 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import re
1718
from dataclasses import dataclass, field
1819
from typing import TYPE_CHECKING, Any
1920

@@ -28,18 +29,26 @@
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`.
3240
ACTION_POST = "post_selected"
3341
ACTION_RERUN = "rerun"
3442
ACTION_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

165164
def _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,

src/pr_af/orchestrator.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,23 @@ async def run(self) -> ReviewResult:
161161
print("[PR-AF] Phase 8: OUTPUT (direct post)", flush=True)
162162
return await self._finish(scored_findings, intake, anatomy, plan, post=True)
163163

164+
# Nothing to triage: don't bother a human (and don't auto-post an
165+
# empty "approved" review to a public repo). Complete silently.
166+
if not scored_findings:
167+
self.app.note(
168+
"hitl: no findings — skipping review gate, posting nothing",
169+
tags=["hitl", "no-post", "no-findings"],
170+
)
171+
print("[PR-AF] HITL: no findings — skipping gate, not posting", flush=True)
172+
return await self._finish(scored_findings, intake, anatomy, plan, post=False)
173+
164174
decision = await request_review_approval(
165175
app=self.app,
166176
hax_client=hax_client,
167177
pr_intent=intake.pr_summary,
168178
findings=scored_findings,
169179
pr_label=self._pr_label(),
180+
pr_meta=self._pr_meta(),
170181
webhook_url=approval_webhook_url(self.app),
171182
user_id=self.config.hitl.approval_user_id,
172183
expires_in_hours=self.config.hitl.approval_expires_in_hours,
@@ -273,6 +284,28 @@ def _pr_label(self) -> str:
273284
return f"{self.pr_data.owner}/{self.pr_data.repo}#{self.pr_data.number}"
274285
return ""
275286

287+
def _pr_meta(self) -> dict[str, Any]:
288+
"""PR metadata block for the hax review template (camelCase keys)."""
289+
pr = self.pr_data
290+
if not pr:
291+
return {}
292+
url = self.input.pr_url or ""
293+
if not url and pr.owner and pr.repo and pr.number:
294+
url = f"https://github.com/{pr.owner}/{pr.repo}/pull/{pr.number}"
295+
repo = f"{pr.owner}/{pr.repo}" if pr.owner and pr.repo else (pr.repo or "")
296+
meta: dict[str, Any] = {
297+
"title": pr.title or "",
298+
"number": pr.number or None,
299+
"url": url,
300+
"repo": repo,
301+
"author": pr.author or "",
302+
}
303+
if pr.changed_files:
304+
meta["filesChangedCount"] = len(pr.changed_files)
305+
meta["additionsCount"] = sum(f.additions for f in pr.changed_files)
306+
meta["deletionsCount"] = sum(f.deletions for f in pr.changed_files)
307+
return meta
308+
276309
def _merge_feedback(self, revision_history: list[str]) -> str:
277310
"""Collapse accumulated reviewer instructions into one guidance string."""
278311
items = [instr.strip() for instr in revision_history if instr and instr.strip()]

0 commit comments

Comments
 (0)