Skip to content

Commit 72837e3

Browse files
authored
fix(issue-fix): require PR-description recall before review notification (#2344)
* fix(issue-fix): gate reviewer notification on PR evidence * refactor(issue-fix): narrow review gate to recall evidence
1 parent 11b2d5c commit 72837e3

5 files changed

Lines changed: 143 additions & 3 deletions

File tree

examples/issue-fix-pr-description-builder-smoke.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
assert counter.read_text() == "1", "enabled build must call provider once"
8686
assert applied["description"].startswith("## 动机"), applied
8787
preference = applied["semantic_preference"]
88+
assert preference["recall_executed"] is True, preference
8889
assert preference["application_status"] == "applied", preference
8990
assert preference["receipt"]["outcome"] == "applied", preference
9091
assert preference["corpus_inventory"][0]["corpus_id"] == (
@@ -185,6 +186,7 @@
185186
)
186187
assert counter.read_text() == "1", "disabled build must not call provider"
187188
assert disabled_result["description"] == BASE, disabled_result
189+
assert disabled_result["semantic_preference"]["recall_executed"] is True
188190
assert disabled_result["semantic_preference"]["recall_status"] == "disabled"
189191

190192
def unexpected_recall(*_args, **_kwargs):
@@ -196,6 +198,7 @@ def unexpected_recall(*_args, **_kwargs):
196198
recall_fn=unexpected_recall,
197199
)
198200
assert unconfigured["description"] == BASE, unconfigured
201+
assert unconfigured["semantic_preference"]["recall_executed"] is False
199202

200203
failing = temp / "failing.json"
201204
failing.write_text(
@@ -225,6 +228,7 @@ def unexpected_recall(*_args, **_kwargs):
225228
),
226229
)
227230
assert unavailable["description"].endswith("Fixes #22\n"), unavailable
231+
assert unavailable["semantic_preference"]["recall_executed"] is True
228232
assert unavailable["fail_open_preserved_base"] is True, unavailable
229233

230234
unattributed = build_issue_fix_pr_description(

examples/issue-fix-reviewer-request-smoke.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,6 +1969,61 @@ def drain_metadata_loader(
19691969
assert drain_calls[-1]["number"] == 103
19701970
assert len(drain_calls) == 3
19711971

1972+
write(path / ".loopx/config/semantic-preference.json", "{}")
1973+
gated_combined = FakeCombinedRunner(FakeGitHubRunner(before=metadata()))
1974+
missing_publication_evidence = build_issue_fix_reviewer_request_packet(
1975+
repo_path=path,
1976+
url="https://github.com/owner/repo/pull/42",
1977+
base_ref="main",
1978+
notification_sinks_input=sinks_input,
1979+
execute=True,
1980+
runner=gated_combined,
1981+
)
1982+
assert missing_publication_evidence["ok"] is False
1983+
assert missing_publication_evidence["blocker"] == (
1984+
"pr_description_recall_evidence_required"
1985+
)
1986+
assert missing_publication_evidence["selected_reviewers"] == []
1987+
assert missing_publication_evidence["external_writes_performed"] is False
1988+
assert missing_publication_evidence["pr_description_publication_gate"][
1989+
"required_evidence_inputs"
1990+
] == ["pr_description_build_json"]
1991+
assert missing_publication_evidence["secondary_notification_status"] == (
1992+
"skipped_reviewer_unavailable"
1993+
)
1994+
assert gated_combined.github.edits == 0
1995+
assert gated_combined.github.comments == 0
1996+
assert gated_combined.lark_calls == []
1997+
assert_public_safe(missing_publication_evidence)
1998+
1999+
publication_build = {
2000+
"schema_version": "issue_fix_pr_description_build_v0",
2001+
"semantic_preference": {
2002+
"surface": "issue_fix.pr_description",
2003+
"recall_executed": True,
2004+
"recall_status": "completed",
2005+
},
2006+
}
2007+
verified_runner = FakeGitHubRunner(
2008+
before=metadata(),
2009+
after=metadata(requested=["service-owner"]),
2010+
)
2011+
verified_publication = build_issue_fix_reviewer_request_packet(
2012+
repo_path=path,
2013+
url="https://github.com/owner/repo/pull/42",
2014+
base_ref="main",
2015+
pr_description_build=publication_build,
2016+
execute=True,
2017+
runner=verified_runner,
2018+
)
2019+
assert verified_publication["ok"] is True, verified_publication
2020+
assert verified_publication["pr_description_publication_gate"]["status"] == (
2021+
"verified"
2022+
)
2023+
assert verified_publication["review_request_verified"] is True
2024+
assert verified_runner.edits == 1
2025+
assert_public_safe(verified_publication)
2026+
19722027
subprocess.run(
19732028
[
19742029
sys.executable,

loopx/capabilities/issue_fix/pr_description.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010

1111
BUILD_SCHEMA = "issue_fix_pr_description_build_v0"
12+
PUBLICATION_GATE_SCHEMA = "issue_fix_pr_description_publication_gate_v0"
1213
SURFACE = "issue_fix.pr_description"
1314
ISSUE_REFERENCE_BLOCK_SCHEMA = "issue_fix_pr_issue_reference_block_v0"
1415

@@ -34,14 +35,54 @@
3435
"resolves": "Resolves",
3536
"resolved": "Resolves",
3637
}
37-
3838
PreferenceApplier = Callable[
3939
[str, Sequence[Mapping[str, Any]]],
4040
Mapping[str, Any],
4141
]
4242
Recall = Callable[..., dict[str, Any]]
4343

4444

45+
def validate_issue_fix_pr_description_publication(
46+
*,
47+
project: str | Path,
48+
build_packet: Mapping[str, Any] | None = None,
49+
) -> dict[str, Any]:
50+
"""Require only proof that the PR-description recall path executed."""
51+
52+
recall_required = (
53+
Path(project).expanduser().resolve()
54+
/ ".loopx/config/semantic-preference.json"
55+
).exists()
56+
preference = (
57+
build_packet.get("semantic_preference")
58+
if isinstance(build_packet, Mapping)
59+
and build_packet.get("schema_version") == BUILD_SCHEMA
60+
else None
61+
)
62+
preference = preference if isinstance(preference, Mapping) else {}
63+
recall_executed = preference.get("recall_executed") is True
64+
ok = not recall_required or recall_executed
65+
66+
return {
67+
"ok": ok,
68+
"schema_version": PUBLICATION_GATE_SCHEMA,
69+
"status": (
70+
"verified"
71+
if recall_executed
72+
else "not_required"
73+
if ok
74+
else "blocked"
75+
),
76+
"blocker": None if ok else "pr_description_recall_evidence_required",
77+
"recall_required": recall_required,
78+
"recall_executed": recall_executed,
79+
"recall_status": str(preference.get("recall_status") or "") or None,
80+
"required_evidence_inputs": (
81+
["pr_description_build_json"] if recall_required else []
82+
),
83+
}
84+
85+
4586
def _normalise_issue_references(
4687
values: Sequence[str] | None, *, field: str
4788
) -> list[str]:
@@ -162,6 +203,7 @@ def _result(
162203
description: str,
163204
recall_status: str,
164205
application_status: str,
206+
recall_executed: bool = False,
165207
recalled_item_count: int = 0,
166208
applied_preference_count: int = 0,
167209
receipt: Mapping[str, Any] | None = None,
@@ -182,6 +224,7 @@ def _result(
182224
"description_changed": description_changed or issue_reference_changed,
183225
"semantic_preference": {
184226
"surface": SURFACE,
227+
"recall_executed": recall_executed,
185228
"recall_status": recall_status,
186229
"application_status": application_status,
187230
"recalled_item_count": recalled_item_count,
@@ -294,6 +337,7 @@ def build_issue_fix_pr_description(
294337
description=base_description,
295338
recall_status=recall_status,
296339
application_status=recall_status,
340+
recall_executed=True,
297341
fail_open_preserved_base=recall_status == "provider_unavailable",
298342
issue_reference_policy=issue_reference_policy,
299343
corpus_inventory=corpus_inventory,
@@ -304,6 +348,7 @@ def build_issue_fix_pr_description(
304348
description=base_description,
305349
recall_status=recall_status,
306350
application_status="available_not_applied",
351+
recall_executed=True,
307352
recalled_item_count=len(items),
308353
issue_reference_policy=issue_reference_policy,
309354
corpus_inventory=corpus_inventory,
@@ -337,6 +382,7 @@ def build_issue_fix_pr_description(
337382
description=base_description,
338383
recall_status=recall_status,
339384
application_status="application_failed",
385+
recall_executed=True,
340386
recalled_item_count=len(items),
341387
receipt=application_receipt(
342388
surface=SURFACE,
@@ -355,6 +401,7 @@ def build_issue_fix_pr_description(
355401
description=description,
356402
recall_status=recall_status,
357403
application_status=outcome,
404+
recall_executed=True,
358405
recalled_item_count=len(items),
359406
applied_preference_count=len(set(applied_refs)),
360407
receipt=application_receipt(

loopx/capabilities/issue_fix/reviewer_cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,13 @@ def register_issue_fix_reviewer_commands(
215215
"verifies GitHub state instead."
216216
),
217217
)
218+
reviewer_request_parser.add_argument(
219+
"--pr-description-build-json",
220+
help=(
221+
"Compact issue_fix_pr_description_build_v0 JSON proving the PR "
222+
"description recall path executed before notification writes."
223+
),
224+
)
218225
reviewer_request_parser.add_argument(
219226
"--notification-sinks-json",
220227
help=(
@@ -801,6 +808,11 @@ def semantic_history_matcher(
801808
reviewer_artifact_reward_memory=reviewer_artifact_reward_memory,
802809
reviewer_notification_reward_memory=reviewer_notification_reward_memory,
803810
reviewer_artifact_required=reviewer_artifact_required,
811+
pr_description_build=(
812+
load_json_object(args.pr_description_build_json)
813+
if getattr(args, "pr_description_build_json", None)
814+
else None
815+
),
804816
provider_payload=(
805817
load_json_object(args.metadata_json) if args.metadata_json else None
806818
),

loopx/capabilities/issue_fix/reviewer_request.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from ...control_plane.runtime.public_safety import public_safe_compact_text
1111
from .metadata_preview import normalise_github_issue_reference
12+
from .pr_description import validate_issue_fix_pr_description_publication
1213
from .reviewer_notification import (
1314
NotificationSinkAdapter,
1415
build_issue_fix_reviewer_notification_sinks_result,
@@ -525,6 +526,7 @@ def build_issue_fix_reviewer_request_packet(
525526
reviewer_artifact_reward_memory: Mapping[str, Any] | None = None,
526527
reviewer_notification_reward_memory: Mapping[str, Any] | None = None,
527528
reviewer_artifact_required: bool = False,
529+
pr_description_build: Mapping[str, Any] | None = None,
528530
provider_payload: Mapping[str, Any] | None = None,
529531
execute: bool = False,
530532
generated_at: str | None = "2026-07-10T00:00:00Z",
@@ -562,6 +564,10 @@ def build_issue_fix_reviewer_request_packet(
562564
)
563565
external_reads = True
564566
metadata = fetched or {}
567+
publication_gate = validate_issue_fix_pr_description_publication(
568+
project=repo_path,
569+
build_packet=pr_description_build,
570+
)
565571
identities = _metadata_identities(metadata, repo=repo, number=number)
566572
excluded = list(exclude_reviewers)
567573
if identities["author_handle"]:
@@ -604,7 +610,11 @@ def build_issue_fix_reviewer_request_packet(
604610
][:remaining_slots]
605611
author_exclusion_verified = bool(identities["author_handle"])
606612
pr_state_verified = identities["state"] in {"OPEN", "CLOSED", "MERGED"}
607-
if not author_exclusion_verified or not pr_state_verified:
613+
if (
614+
not author_exclusion_verified
615+
or not pr_state_verified
616+
or publication_gate["ok"] is not True
617+
):
608618
selected = []
609619

610620
existing_notified_reviewers = list(
@@ -633,7 +643,7 @@ def build_issue_fix_reviewer_request_packet(
633643
)
634644

635645
packet: dict[str, Any] = {
636-
"ok": metadata_error is None,
646+
"ok": metadata_error is None and publication_gate["ok"] is True,
637647
"schema_version": ISSUE_FIX_REVIEWER_REQUEST_SCHEMA_VERSION,
638648
"mode": "issue-fix-reviewer-request",
639649
"generated_at": generated_at,
@@ -681,6 +691,7 @@ def build_issue_fix_reviewer_request_packet(
681691
execute and identities["semantic_comment_notified_reviewers"]
682692
),
683693
"reviewer_comment_url": existing_comment_url,
694+
"pr_description_publication_gate": publication_gate,
684695
"private_repo_state_read": True,
685696
"local_paths_captured": False,
686697
"raw_provider_payload_captured": False,
@@ -710,6 +721,17 @@ def build_issue_fix_reviewer_request_packet(
710721
),
711722
material_change=True,
712723
)
724+
elif publication_gate["ok"] is not True:
725+
packet["blocker"] = publication_gate["blocker"]
726+
packet["transition"] = _transition(
727+
decision="blocker",
728+
action_kind="issue_fix_pr_description_recall_evidence",
729+
reason=(
730+
"Run the PR-description recall path and pass its compact build "
731+
"packet before requesting review or sending a notification."
732+
),
733+
material_change=False,
734+
)
713735
elif not author_exclusion_verified:
714736
packet["ok"] = False
715737
packet["blocker"] = "github_pr_author_unavailable"

0 commit comments

Comments
 (0)