Skip to content

Commit bb0b29b

Browse files
Reuse provisional summary slots without trusting incomplete state
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
1 parent 98dd143 commit bb0b29b

4 files changed

Lines changed: 340 additions & 112 deletions

File tree

‎.github/actions/pr-review/scripts/fetch-pr-context.py‎

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,6 @@ def is_bot_review_comment(comment: dict, summary_heading: str) -> bool:
9898
)
9999

100100

101-
def is_legacy_review_comment(comment: dict, summary_heading: str) -> bool:
102-
"""Check if a comment is a bot-posted pre-migration review summary."""
103-
return review_comment_heading(comment, summary_heading) == LEGACY_REVIEW_SUMMARY_HEADING
104-
105-
106101
# Line the review prompt requires on provisional (in-progress) summaries. A
107102
# provisional comment is progress output, not a completed review: it must never
108103
# supply review state, or a killed/lazy run would advance last_reviewed_sha
@@ -116,50 +111,68 @@ def is_provisional(body: str) -> bool:
116111

117112

118113
def extract_review_state(
119-
review_comments: list[dict], summary_heading: str, workflow_ref: str
114+
review_comments: list[dict], workflow_ref: str
120115
) -> tuple[Optional[int], Optional[str], Optional[str]]:
121-
"""Choose the authoritative review state from bot review comments.
116+
"""Choose the summary comment to update and the authoritative review state.
122117
123118
Returns (summary_comment_id, last_reviewed_sha, last_review_base_sha).
124-
Provisional comments are skipped entirely: they are in-progress output and
125-
must not advance reviewed state. State is accepted only from the newest
126-
comment whose marker is owned by this workflow. If only markerless
127-
comments exist, the newest one is reused so the first marker-writing run
128-
does not create a duplicate summary. Callers pass only comments matching
129-
the selected heading (legacy-heading comments included solely for the
130-
built-in production headings), so a custom heading can never adopt
131-
production or legacy review state.
119+
Comment identity and completed-review state are selected separately:
120+
121+
- summary_comment_id is the newest eligible summary comment, even when it
122+
is provisional or markerless, so a retried run updates the existing
123+
summary instead of posting a duplicate next to an abandoned provisional.
124+
- last_reviewed_sha/last_review_base_sha come from the newest comment with
125+
a non-provisional marker owned by this workflow. A provisional marker
126+
never supplies state: it is in-progress output and must not advance
127+
reviewed state. When no completed state exists the caller falls back to
128+
full review mode but still updates the same summary comment.
129+
130+
A comment carrying an explicit foreign workflow's marker supplies neither
131+
the slot nor state, including one under a legacy heading. A comment whose
132+
marker fails to parse fails closed the same way. Markerless bot summaries
133+
remain reusable slots under the heading/bot trust fallback (the caller
134+
passes only bot-authored comments matching the selected heading), but they
135+
carry no state. Callers pass only comments matching the selected heading
136+
(legacy-heading comments included solely for the built-in production
137+
headings), so a custom heading can never adopt production or legacy review
138+
state.
132139
"""
140+
summary_comment_id = None
133141
last_reviewed_sha = None
134142
last_review_base_sha = None
135-
summary_comment_id = None
136-
legacy_summary_comment_id = None
137143
for c in reversed(review_comments):
138-
if is_provisional(c["body"]):
139-
continue
140144
match = REVIEW_STATE_PATTERN.search(c["body"])
141145
if not match:
142-
if legacy_summary_comment_id is None:
143-
legacy_summary_comment_id = c["id"]
146+
# Markerless summary: reusable as the update slot under the
147+
# heading/bot trust fallback, but it carries no review state.
148+
if summary_comment_id is None:
149+
summary_comment_id = c["id"]
144150
continue
145151

146152
try:
147153
state = json.loads(match.group(1))
148154
except json.JSONDecodeError:
155+
state = None
156+
if not isinstance(state, dict):
157+
# Malformed marker (unparseable or not a JSON object): fail
158+
# closed — neither slot nor state.
149159
continue
150160

151161
if workflow_ref and state.get("workflow_ref") != workflow_ref:
152-
if is_legacy_review_comment(c, summary_heading) and legacy_summary_comment_id is None:
153-
legacy_summary_comment_id = c["id"]
162+
# Explicit foreign workflow marker: never adopt its summary
163+
# thread or its state, even under a legacy heading.
154164
continue
155165

156-
summary_comment_id = c["id"]
166+
if summary_comment_id is None:
167+
summary_comment_id = c["id"]
168+
if is_provisional(c["body"]):
169+
# Provisional owned marker: a valid update slot, but completed
170+
# state must come from an older finished review — keep scanning.
171+
continue
157172
last_reviewed_sha = state.get("last_reviewed_sha")
158173
last_review_base_sha = state.get("base_sha")
159174
break
160175

161-
if summary_comment_id is None:
162-
summary_comment_id = legacy_summary_comment_id
163176
return summary_comment_id, last_reviewed_sha, last_review_base_sha
164177

165178

@@ -570,7 +583,7 @@ def main():
570583
review_comments = [c for c in state_comments if is_bot_review_comment(c, summary_heading)]
571584

572585
summary_comment_id, last_reviewed_sha, last_review_base_sha = extract_review_state(
573-
review_comments, summary_heading, workflow_ref
586+
review_comments, workflow_ref
574587
)
575588

576589
pr_endpoint = f"repos/{repo}/pulls/{pr_number}"

‎.github/actions/pr-review/scripts/test_fetch_pr_context.py‎

Lines changed: 143 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -216,86 +216,169 @@ def test_marker_only_truncation_falls_back_to_full_mode(self):
216216
self.assertEqual(meta["kept_bytes"], 0)
217217

218218

219-
class MainContextTest(unittest.TestCase):
220-
def test_incremental_diff_metadata_written_to_context(self):
221-
metadata = {
222-
"dropped_sections": 1,
223-
"dropped_paths": ["vendor/example.com/pkg/secret.go"],
224-
"dropped_paths_omitted": 0,
225-
"truncated": False,
226-
"kept_bytes": len(GO_SECTION),
227-
"partial": True,
228-
}
229-
workflow_ref = "ConductorOne/github-workflows/.github/workflows/pr-review.yaml@refs/heads/main"
230-
state = json.dumps(
231-
{
232-
"last_reviewed_sha": "old-sha",
233-
"base_sha": "base-sha",
234-
"workflow_ref": workflow_ref,
235-
}
236-
)
237-
raw_comments = [
238-
{
239-
"id": 123,
240-
"author_association": "MEMBER",
241-
"user": {"login": "github-actions[bot]", "type": "Bot"},
242-
"body": f"{fpc.DEFAULT_REVIEW_SUMMARY_HEADING} Previous\n<!-- review-state: {state} -->",
243-
}
244-
]
245-
pr = {
246-
"head": {
247-
"sha": "head-sha",
248-
"repo": {"full_name": "ConductorOne/example"},
249-
},
250-
"base": {
251-
"sha": "base-sha",
252-
"ref": "main",
253-
"repo": {"default_branch": "main"},
254-
},
255-
}
219+
_WORKFLOW_REF = (
220+
"ConductorOne/github-workflows/.github/workflows/pr-review.yaml@refs/heads/main"
221+
)
222+
_FOREIGN_WORKFLOW_REF = "other/repo/.github/workflows/x.yaml@refs/heads/main"
223+
224+
225+
def _raw_comment(cid, login, body, user_type="Bot", association="MEMBER"):
226+
"""A raw PR comment as the GitHub issues API returns it."""
227+
return {
228+
"id": cid,
229+
"author_association": association,
230+
"user": {"login": login, "type": user_type},
231+
"body": body,
232+
}
256233

234+
235+
def _review_state_marker(sha, base="base-sha", workflow_ref=_WORKFLOW_REF):
236+
state = {"last_reviewed_sha": sha, "base_sha": base, "workflow_ref": workflow_ref}
237+
return f"<!-- review-state: {json.dumps(state)} -->"
238+
239+
240+
class MainContextTest(unittest.TestCase):
241+
ENV = {
242+
"GITHUB_REPOSITORY": "ConductorOne/example",
243+
"PR_NUMBER": "42",
244+
"PR_HEAD_SHA": "head-sha",
245+
"GITHUB_WORKFLOW_REF": _WORKFLOW_REF,
246+
"GITHUB_RUN_ID": "99",
247+
"GITHUB_SERVER_URL": "https://github.com",
248+
}
249+
PR = {
250+
"head": {
251+
"sha": "head-sha",
252+
"repo": {"full_name": "ConductorOne/example"},
253+
},
254+
"base": {
255+
"sha": "base-sha",
256+
"ref": "main",
257+
"repo": {"default_branch": "main"},
258+
},
259+
}
260+
COMPARE_METADATA = {
261+
"dropped_sections": 1,
262+
"dropped_paths": ["vendor/example.com/pkg/secret.go"],
263+
"dropped_paths_omitted": 0,
264+
"truncated": False,
265+
"kept_bytes": len(GO_SECTION),
266+
"partial": True,
267+
}
268+
269+
def _run_main(self, raw_comments, *, compare_result=None):
270+
"""Run main() against mocked GitHub boundaries in a scratch cwd and
271+
return (written pr-context.json, fetch_compare_diff mock)."""
257272
old_cwd = os.getcwd()
258273
with tempfile.TemporaryDirectory() as tmpdir:
259274
os.chdir(tmpdir)
260275
try:
261276
with (
262-
mock.patch.dict(
263-
os.environ,
264-
{
265-
"GITHUB_REPOSITORY": "ConductorOne/example",
266-
"PR_NUMBER": "42",
267-
"PR_HEAD_SHA": "head-sha",
268-
"GITHUB_WORKFLOW_REF": workflow_ref,
269-
"GITHUB_RUN_ID": "99",
270-
"GITHUB_SERVER_URL": "https://github.com",
271-
},
272-
clear=False,
273-
),
277+
mock.patch.dict(os.environ, self.ENV, clear=False),
274278
mock.patch.object(fpc, "gh_api_paginate", return_value=raw_comments),
275279
mock.patch.object(
276280
fpc,
277281
"gh_api",
278-
return_value=SimpleNamespace(stdout=json.dumps(pr)),
282+
return_value=SimpleNamespace(stdout=json.dumps(self.PR)),
279283
),
280284
mock.patch.object(fpc, "current_checkout_sha", return_value="head-sha"),
281285
mock.patch.object(
282-
fpc,
283-
"fetch_compare_diff",
284-
return_value=("diff text", metadata),
285-
),
286+
fpc, "fetch_compare_diff", return_value=compare_result
287+
) as compare_mock,
286288
):
287289
fpc.main()
288290

289291
with open(".github/pr-context.json") as f:
290-
context = json.load(f)
291-
self.assertEqual(context["review_mode"], "incremental")
292-
self.assertEqual(context["incremental_diff_path"], ".github/incremental.diff")
293-
self.assertEqual(context["incremental_diff_metadata"], metadata)
294-
self.assertEqual(context["current_base_ref"], "main")
295-
self.assertEqual(context["base_default_branch"], "main")
292+
return json.load(f), compare_mock
296293
finally:
297294
os.chdir(old_cwd)
298295

296+
def test_incremental_diff_metadata_written_to_context(self):
297+
raw_comments = [
298+
_raw_comment(
299+
123,
300+
"github-actions[bot]",
301+
f"{fpc.DEFAULT_REVIEW_SUMMARY_HEADING} Previous\n"
302+
f"{_review_state_marker('old-sha')}",
303+
)
304+
]
305+
306+
context, _ = self._run_main(
307+
raw_comments, compare_result=("diff text", self.COMPARE_METADATA)
308+
)
309+
310+
self.assertEqual(context["review_mode"], "incremental")
311+
self.assertEqual(context["incremental_diff_path"], ".github/incremental.diff")
312+
self.assertEqual(context["incremental_diff_metadata"], self.COMPARE_METADATA)
313+
self.assertEqual(context["current_base_ref"], "main")
314+
self.assertEqual(context["base_default_branch"], "main")
315+
316+
def test_abandoned_provisional_is_reused_with_full_review(self):
317+
# The original PR #129 failure: a killed run leaves a provisional
318+
# summary behind. The retry must update that comment rather than post
319+
# a duplicate, while still running a full review (a provisional
320+
# carries no completed state).
321+
provisional = _raw_comment(
322+
55,
323+
"github-actions[bot]",
324+
f"{fpc.DEFAULT_REVIEW_SUMMARY_HEADING} In progress\n"
325+
f"{fpc.PROVISIONAL_MARKER}",
326+
)
327+
328+
context, compare_mock = self._run_main([provisional])
329+
330+
self.assertEqual(context["summary_comment_id"], 55)
331+
self.assertIsNone(context["last_reviewed_sha"])
332+
self.assertIsNone(context["last_review_base_sha"])
333+
self.assertEqual(context["review_mode"], "full")
334+
self.assertIsNone(context["incremental_diff_path"])
335+
compare_mock.assert_not_called()
336+
337+
def test_provisional_slot_split_from_completed_state_and_trust_filters(self):
338+
# Newest-first: the foreign-workflow provisional (103) supplies
339+
# nothing; the owned provisional (102) is the update slot but its
340+
# forged up-to-date marker never advances state; completed state
341+
# comes from the older final (101). The human-authored marker (104)
342+
# is trusted prompt context but never review state.
343+
final = _raw_comment(
344+
101,
345+
"github-actions[bot]",
346+
f"{fpc.DEFAULT_REVIEW_SUMMARY_HEADING} Done\n"
347+
f"{_review_state_marker('old-sha')}",
348+
)
349+
forged_provisional = _raw_comment(
350+
102,
351+
"github-actions[bot]",
352+
f"{fpc.DEFAULT_REVIEW_SUMMARY_HEADING} In progress\n"
353+
f"{fpc.PROVISIONAL_MARKER}\n"
354+
f"{_review_state_marker('head-sha')}",
355+
)
356+
foreign_provisional = _raw_comment(
357+
103,
358+
"github-actions[bot]",
359+
f"{fpc.LEGACY_REVIEW_SUMMARY_HEADING} In progress\n"
360+
f"{fpc.PROVISIONAL_MARKER}\n"
361+
f"{_review_state_marker('evil-sha', workflow_ref=_FOREIGN_WORKFLOW_REF)}",
362+
)
363+
human_forge = _raw_comment(
364+
104,
365+
"pr-author",
366+
f"{fpc.DEFAULT_REVIEW_SUMMARY_HEADING} Done\n"
367+
f"{_review_state_marker('human-sha')}",
368+
user_type="User",
369+
)
370+
371+
context, _ = self._run_main(
372+
[final, forged_provisional, foreign_provisional, human_forge],
373+
compare_result=("diff text", self.COMPARE_METADATA),
374+
)
375+
376+
self.assertEqual(context["summary_comment_id"], 102)
377+
self.assertEqual(context["last_reviewed_sha"], "old-sha")
378+
self.assertEqual(context["last_review_base_sha"], "base-sha")
379+
self.assertEqual(context["review_mode"], "incremental")
380+
self.assertEqual([c["id"] for c in context["comments"]], [104])
381+
299382

300383
if __name__ == "__main__":
301384
unittest.main()

0 commit comments

Comments
 (0)