Skip to content

Commit 04028ad

Browse files
committed
fix: tighten replay provenance parity
1 parent 138fdec commit 04028ad

4 files changed

Lines changed: 221 additions & 23 deletions

File tree

.github/workflows/reviewer-bot-pr-comment-router.yml

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
comment_author = str((comment.get('user') or {}).get('login') or '').strip()
5353
comment_user_type = str((comment.get('user') or {}).get('type') or '').strip()
5454
comment_sender_type = str(sender.get('type') or '').strip()
55-
comment_author_association = str(comment.get('author_association') or '').strip()
55+
comment_author_association = str(comment.get('author_association') or '').strip().upper()
5656
def _classify_issue_comment_actor(comment_user_type, comment_author, comment_sender_type, installation_id, performed_via_github_app):
5757
if comment_user_type == 'Bot' or comment_author.endswith('[bot]'):
5858
return 'bot_account'
@@ -62,6 +62,21 @@ jobs:
6262
return 'repo_user_principal'
6363
return 'unknown_actor'
6464
65+
def _route_pr_comment_outcome(actor_class, repo, pr_head_full_name, pr_author, comment_author_association, pull_request_read_failed=False):
66+
if actor_class != 'repo_user_principal':
67+
return 'safe_noop'
68+
if pull_request_read_failed:
69+
return 'deferred_reconcile'
70+
if (
71+
not pr_head_full_name
72+
or not pr_author
73+
or pr_head_full_name != repo
74+
or pr_author == 'dependabot[bot]'
75+
or str(comment_author_association or '').strip().upper() not in {'OWNER', 'MEMBER', 'COLLABORATOR'}
76+
):
77+
return 'deferred_reconcile'
78+
return 'trusted_direct'
79+
6580
def _performed_via_github_app_truth(value):
6681
if isinstance(value, bool):
6782
return value
@@ -86,7 +101,7 @@ jobs:
86101
pr_author = ''
87102
88103
if actor_class != 'repo_user_principal':
89-
route_outcome = 'safe_noop'
104+
route_outcome = _route_pr_comment_outcome(actor_class, repo, pr_head_full_name, pr_author, comment_author_association)
90105
else:
91106
pr_number = issue['number']
92107
req = urllib.request.Request(
@@ -100,19 +115,12 @@ jobs:
100115
with urllib.request.urlopen(req) as response:
101116
pull_request = json.load(response)
102117
except urllib.error.URLError:
103-
route_outcome = 'deferred_reconcile'
118+
route_outcome = _route_pr_comment_outcome(actor_class, repo, pr_head_full_name, pr_author, comment_author_association, pull_request_read_failed=True)
104119
else:
105120
head_repo = pull_request.get('head', {}).get('repo') or {}
106121
pr_head_full_name = str(head_repo.get('full_name') or '').strip()
107122
pr_author = str((pull_request.get('user') or {}).get('login') or '').strip()
108-
if (
109-
not pr_head_full_name
110-
or not pr_author
111-
or pr_head_full_name != repo
112-
or pr_author == 'dependabot[bot]'
113-
or comment_author_association not in {'OWNER', 'MEMBER', 'COLLABORATOR'}
114-
):
115-
route_outcome = 'deferred_reconcile'
123+
route_outcome = _route_pr_comment_outcome(actor_class, repo, pr_head_full_name, pr_author, comment_author_association)
116124
117125
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as handle:
118126
print(f'route_outcome={route_outcome}', file=handle)

scripts/reviewer_bot_lib/reconcile_reads.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,16 @@ def read_live_comment_replay_context(live_comment: dict, payload: dict) -> LiveC
109109
comment_sender_type_available = True
110110
installation = live_comment.get("installation")
111111
comment_installation_id = None
112-
comment_installation_id_available = "installation" in live_comment
112+
comment_installation_id_available = False
113113
if isinstance(installation, dict):
114114
installation_id = installation.get("id")
115115
if installation_id is not None and str(installation_id).strip():
116-
comment_installation_id = str(installation_id).strip()
116+
try:
117+
if int(installation_id) > 0:
118+
comment_installation_id = str(installation_id).strip()
119+
comment_installation_id_available = True
120+
except (TypeError, ValueError):
121+
pass
117122
performed_via_app_available = False
118123
performed_via_app = live_comment.get("performed_via_github_app")
119124
comment_performed_via_github_app = None
@@ -122,11 +127,13 @@ def read_live_comment_replay_context(live_comment: dict, payload: dict) -> LiveC
122127
comment_performed_via_github_app = performed_via_app
123128
performed_via_app_available = True
124129
elif isinstance(performed_via_app, dict):
125-
try:
126-
comment_performed_via_github_app = int(performed_via_app.get("id") or 0) > 0
127-
except (TypeError, ValueError):
128-
comment_performed_via_github_app = False
129-
performed_via_app_available = True
130+
app_id = performed_via_app.get("id")
131+
if app_id is not None and str(app_id).strip():
132+
try:
133+
comment_performed_via_github_app = int(app_id) > 0
134+
performed_via_app_available = True
135+
except (TypeError, ValueError):
136+
pass
130137
elif performed_via_app is None:
131138
comment_performed_via_github_app = False
132139
performed_via_app_available = True

tests/contract/reviewer_bot/test_workflow_files.py

Lines changed: 148 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import yaml
1111

1212
from scripts.reviewer_bot_core import comment_routing_policy
13+
from scripts.reviewer_bot_lib.context import PrCommentAdmission
1314

1415

1516
def _load_observer_contract_matrix() -> dict:
@@ -129,6 +130,16 @@ def test_pr_comment_router_normalizes_performed_via_app_without_raw_truthiness()
129130
assert "bool(comment.get('performed_via_github_app'))" not in workflow_text
130131

131132

133+
def _load_pr_comment_router_inline_namespace():
134+
workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-router.yml").read_text(encoding="utf-8")
135+
first_def = workflow_text.index("def _classify_issue_comment_actor(")
136+
start = workflow_text.rfind("\n", 0, first_def) + 1
137+
end = workflow_text.index("\n\n performed_via_github_app =", start)
138+
namespace = {}
139+
exec(textwrap.dedent(workflow_text[start:end]), namespace)
140+
return namespace
141+
142+
132143
@pytest.mark.parametrize(
133144
("value", "expected"),
134145
[
@@ -172,11 +183,7 @@ def test_pr_comment_router_actor_classifier_matches_core_policy(
172183
installation_id,
173184
performed_via_app,
174185
):
175-
workflow_text = Path(".github/workflows/reviewer-bot-pr-comment-router.yml").read_text(encoding="utf-8")
176-
start = workflow_text.index("def _classify_issue_comment_actor(")
177-
end = workflow_text.index("\n def _performed_via_github_app_truth", start)
178-
namespace = {}
179-
exec(textwrap.dedent(workflow_text[start:end]), namespace)
186+
namespace = _load_pr_comment_router_inline_namespace()
180187

181188
request = SimpleNamespace(
182189
comment_user_type=comment_user_type,
@@ -195,6 +202,142 @@ def test_pr_comment_router_actor_classifier_matches_core_policy(
195202
) == comment_routing_policy.classify_issue_comment_actor(request)
196203

197204

205+
@pytest.mark.parametrize(
206+
"scenario",
207+
[
208+
{
209+
"name": "same_repo_trusted_member",
210+
"comment_user_type": "User",
211+
"comment_author": "alice",
212+
"comment_sender_type": "User",
213+
"installation_id": None,
214+
"performed_via_app": False,
215+
"comment_author_association": "MEMBER",
216+
"pr_head_full_name": "rustfoundation/safety-critical-rust-coding-guidelines",
217+
"pr_author": "carol",
218+
"route_outcome": comment_routing_policy.PrCommentRouterOutcome.TRUSTED_DIRECT,
219+
},
220+
{
221+
"name": "same_repo_untrusted_author_association",
222+
"comment_user_type": "User",
223+
"comment_author": "alice",
224+
"comment_sender_type": "User",
225+
"installation_id": None,
226+
"performed_via_app": False,
227+
"comment_author_association": "contributor",
228+
"pr_head_full_name": "rustfoundation/safety-critical-rust-coding-guidelines",
229+
"pr_author": "carol",
230+
"route_outcome": comment_routing_policy.PrCommentRouterOutcome.TRUSTED_DIRECT,
231+
},
232+
{
233+
"name": "cross_repo_deferred",
234+
"comment_user_type": "User",
235+
"comment_author": "alice",
236+
"comment_sender_type": "User",
237+
"installation_id": None,
238+
"performed_via_app": False,
239+
"comment_author_association": "MEMBER",
240+
"pr_head_full_name": "fork/example",
241+
"pr_author": "carol",
242+
"route_outcome": comment_routing_policy.PrCommentRouterOutcome.TRUSTED_DIRECT,
243+
},
244+
{
245+
"name": "dependabot_pr_author_deferred",
246+
"comment_user_type": "User",
247+
"comment_author": "alice",
248+
"comment_sender_type": "User",
249+
"installation_id": None,
250+
"performed_via_app": False,
251+
"comment_author_association": "OWNER",
252+
"pr_head_full_name": "rustfoundation/safety-critical-rust-coding-guidelines",
253+
"pr_author": "dependabot[bot]",
254+
"route_outcome": comment_routing_policy.PrCommentRouterOutcome.TRUSTED_DIRECT,
255+
},
256+
{
257+
"name": "automation_actor_noop",
258+
"comment_user_type": "User",
259+
"comment_author": "alice",
260+
"comment_sender_type": "Organization",
261+
"installation_id": None,
262+
"performed_via_app": False,
263+
"comment_author_association": "MEMBER",
264+
"pr_head_full_name": "rustfoundation/safety-critical-rust-coding-guidelines",
265+
"pr_author": "carol",
266+
"route_outcome": comment_routing_policy.PrCommentRouterOutcome.TRUSTED_DIRECT,
267+
},
268+
{
269+
"name": "pull_request_read_failed",
270+
"comment_user_type": "User",
271+
"comment_author": "alice",
272+
"comment_sender_type": "User",
273+
"installation_id": None,
274+
"performed_via_app": False,
275+
"comment_author_association": "MEMBER",
276+
"pr_head_full_name": "",
277+
"pr_author": "",
278+
"pull_request_read_failed": True,
279+
"route_outcome": comment_routing_policy.PrCommentRouterOutcome.DEFERRED_RECONCILE,
280+
},
281+
],
282+
ids=lambda scenario: scenario["name"],
283+
)
284+
def test_pr_comment_router_route_outcome_matches_core_policy(scenario):
285+
repo = "rustfoundation/safety-critical-rust-coding-guidelines"
286+
namespace = _load_pr_comment_router_inline_namespace()
287+
request = SimpleNamespace(
288+
is_pull_request=True,
289+
comment_user_type=scenario["comment_user_type"],
290+
comment_author=scenario["comment_author"],
291+
comment_sender_type=scenario["comment_sender_type"],
292+
comment_installation_id=scenario["installation_id"],
293+
comment_performed_via_github_app=scenario["performed_via_app"],
294+
comment_author_association=scenario["comment_author_association"],
295+
)
296+
pr_admission = PrCommentAdmission(
297+
route_outcome=scenario["route_outcome"],
298+
declared_trust_class="pr_trusted_direct",
299+
github_repository=repo,
300+
pr_head_full_name=scenario["pr_head_full_name"],
301+
pr_author=scenario["pr_author"],
302+
issue_state="open",
303+
issue_labels=(),
304+
comment_author_id=123,
305+
github_run_id=1,
306+
github_run_attempt=1,
307+
)
308+
actor_class = comment_routing_policy.classify_issue_comment_actor(request)
309+
processing_target = comment_routing_policy.classify_pr_comment_processing_target(
310+
request,
311+
pr_admission,
312+
actor_class=actor_class,
313+
is_self_comment=False,
314+
)
315+
expected = comment_routing_policy.route_issue_comment_trust(
316+
request,
317+
pr_admission,
318+
processing_target=processing_target,
319+
)
320+
workflow_actor_class = namespace["_classify_issue_comment_actor"](
321+
scenario["comment_user_type"],
322+
scenario["comment_author"],
323+
scenario["comment_sender_type"],
324+
scenario["installation_id"],
325+
scenario["performed_via_app"],
326+
)
327+
328+
workflow_route = namespace["_route_pr_comment_outcome"](
329+
workflow_actor_class,
330+
repo,
331+
scenario["pr_head_full_name"],
332+
scenario["pr_author"],
333+
scenario["comment_author_association"],
334+
pull_request_read_failed=scenario.get("pull_request_read_failed", False),
335+
)
336+
337+
assert workflow_actor_class == actor_class
338+
assert workflow_route == expected.value
339+
340+
198341
def test_pr_comment_router_workflow_builds_payload_inline_without_bot_src_root():
199342
workflow = Path(".github/workflows/reviewer-bot-pr-comment-router.yml").read_text(encoding="utf-8")
200343
assert "build_pr_comment_observer_payload" not in workflow

tests/unit/reviewer_bot/test_reconcile_artifacts.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,46 @@ def test_replay_comment_request_preserves_payload_app_truth_for_non_exact_live_s
248248
assert request.comment_performed_via_github_app is True
249249

250250

251+
@pytest.mark.parametrize("installation", [None, {}, {"id": ""}, {"id": "bad"}, {"id": 0}])
252+
def test_replay_comment_request_preserves_payload_installation_for_non_exact_live_shape(installation):
253+
payload = _load_fixture_payload("tests/fixtures/observer_payloads/workflow_pr_comment_deferred.json")
254+
payload["comment_installation_id"] = "12345"
255+
parsed_payload = reconcile_payloads.parse_deferred_context_payload(payload)
256+
live_context = reconcile_reads.read_live_comment_replay_context(
257+
{
258+
"user": {"login": "alice", "type": "User"},
259+
"installation": installation,
260+
},
261+
{"actor_login": "alice"},
262+
)
263+
264+
request = event_inputs.build_replay_comment_event_request(parsed_payload, live_comment=live_context)
265+
266+
assert live_context.comment_installation_id is None
267+
assert live_context.comment_installation_id_available is False
268+
assert request.comment_installation_id == "12345"
269+
270+
271+
@pytest.mark.parametrize("performed_via_app", [{}, {"id": ""}, {"id": "bad"}])
272+
def test_replay_comment_request_preserves_payload_app_truth_for_malformed_live_app_dict(performed_via_app):
273+
payload = _load_fixture_payload("tests/fixtures/observer_payloads/workflow_pr_comment_deferred.json")
274+
payload["comment_performed_via_github_app"] = True
275+
parsed_payload = reconcile_payloads.parse_deferred_context_payload(payload)
276+
live_context = reconcile_reads.read_live_comment_replay_context(
277+
{
278+
"user": {"login": "alice", "type": "User"},
279+
"performed_via_github_app": performed_via_app,
280+
},
281+
{"actor_login": "alice"},
282+
)
283+
284+
request = event_inputs.build_replay_comment_event_request(parsed_payload, live_comment=live_context)
285+
286+
assert live_context.comment_performed_via_github_app is None
287+
assert live_context.comment_performed_via_github_app_available is False
288+
assert request.comment_performed_via_github_app is True
289+
290+
251291
def test_replay_comment_request_preserves_payload_provenance_without_exact_live_fields():
252292
payload = _load_fixture_payload("tests/fixtures/observer_payloads/workflow_pr_comment_deferred.json")
253293
payload.update(

0 commit comments

Comments
 (0)