@@ -62,12 +62,77 @@ def test_main_show_state_uses_direct_yaml_import(monkeypatch, capsys):
6262 assert "freshness_runtime_epoch" in output
6363
6464
65+ def _valid_reviewer_board_metadata ():
66+ return {
67+ "data" : {
68+ "organization" : {
69+ "projectV2" : {
70+ "id" : "PVT_kwDOB" ,
71+ "title" : "Reviewer Board" ,
72+ "fields" : {
73+ "nodes" : [
74+ {
75+ "__typename" : "ProjectV2SingleSelectField" ,
76+ "id" : "field-review-state" ,
77+ "name" : "Review State" ,
78+ "options" : [
79+ {"id" : "opt-ar" , "name" : "Awaiting Reviewer" },
80+ {"id" : "opt-ac" , "name" : "Awaiting Contributor" },
81+ {"id" : "opt-aw" , "name" : "Awaiting Write Approval" },
82+ {"id" : "opt-done" , "name" : "Done" },
83+ {"id" : "opt-unassigned" , "name" : "Unassigned" },
84+ ],
85+ },
86+ {
87+ "__typename" : "ProjectV2Field" ,
88+ "dataType" : "TEXT" ,
89+ "id" : "field-reviewer" ,
90+ "name" : "Reviewer" ,
91+ },
92+ {
93+ "__typename" : "ProjectV2Field" ,
94+ "dataType" : "DATE" ,
95+ "id" : "field-assigned-at" ,
96+ "name" : "Assigned At" ,
97+ },
98+ {
99+ "__typename" : "ProjectV2Field" ,
100+ "dataType" : "DATE" ,
101+ "id" : "field-waiting-since" ,
102+ "name" : "Waiting Since" ,
103+ },
104+ {
105+ "__typename" : "ProjectV2SingleSelectField" ,
106+ "id" : "field-needs-attention" ,
107+ "name" : "Needs Attention" ,
108+ "options" : [
109+ {"id" : "opt-no" , "name" : "No" },
110+ {"id" : "opt-warning" , "name" : "Warning Sent" },
111+ {"id" : "opt-notice" , "name" : "Transition Notice Sent" },
112+ {"id" : "opt-triage" , "name" : "Triage Approval Required" },
113+ {"id" : "opt-repair" , "name" : "Projection Repair Required" },
114+ ],
115+ },
116+ ]
117+ },
118+ }
119+ }
120+ }
121+ }
122+
123+
65124def test_classify_event_intent_cross_repo_review_is_non_mutating_defer (monkeypatch ):
66125 monkeypatch .setenv ("PR_IS_CROSS_REPOSITORY" , "true" )
67126 intent = reviewer_bot .classify_event_intent ("pull_request_review" , "submitted" )
68127 assert intent == reviewer_bot .EVENT_INTENT_NON_MUTATING_DEFER
69128
70129
130+ def test_classify_event_intent_preview_reviewer_board_is_non_mutating (monkeypatch ):
131+ monkeypatch .setenv ("MANUAL_ACTION" , "preview-reviewer-board" )
132+ intent = reviewer_bot .classify_event_intent ("workflow_dispatch" , "" )
133+ assert intent == reviewer_bot .EVENT_INTENT_NON_MUTATING_READONLY
134+
135+
71136def test_classify_event_intent_same_repo_review_is_non_mutating_defer (monkeypatch ):
72137 intent = reviewer_bot .classify_event_intent ("pull_request_review" , "submitted" )
73138 assert intent == reviewer_bot .EVENT_INTENT_NON_MUTATING_DEFER
@@ -248,6 +313,170 @@ def fake_sync_status_labels_for_items(state, issue_numbers):
248313 ]
249314
250315
316+ def test_main_preview_reviewer_board_disabled_is_clean_noop (monkeypatch , capsys ):
317+ monkeypatch .setenv ("EVENT_NAME" , "workflow_dispatch" )
318+ monkeypatch .setenv ("EVENT_ACTION" , "" )
319+ monkeypatch .setenv ("MANUAL_ACTION" , "preview-reviewer-board" )
320+ monkeypatch .setenv ("REVIEWER_BOARD_ENABLED" , "false" )
321+
322+ monkeypatch .setattr (reviewer_bot , "load_state" , lambda * args , ** kwargs : make_state ())
323+ monkeypatch .setattr (
324+ reviewer_bot ,
325+ "acquire_state_issue_lease_lock" ,
326+ lambda : (_ for _ in ()).throw (AssertionError ("preview should not acquire lock" )),
327+ )
328+ monkeypatch .setattr (
329+ reviewer_bot ,
330+ "process_pass_until_expirations" ,
331+ lambda state : (_ for _ in ()).throw (AssertionError ("preview should skip pass-until processing" )),
332+ )
333+ monkeypatch .setattr (
334+ reviewer_bot ,
335+ "sync_members_with_queue" ,
336+ lambda state : (_ for _ in ()).throw (AssertionError ("preview should skip member sync" )),
337+ )
338+ monkeypatch .setattr (
339+ reviewer_bot ,
340+ "save_state" ,
341+ lambda state : (_ for _ in ()).throw (AssertionError ("preview should not save state" )),
342+ )
343+ monkeypatch .setattr (
344+ reviewer_bot ,
345+ "sync_status_labels_for_items" ,
346+ lambda state , issue_numbers : (_ for _ in ()).throw (AssertionError ("preview should not sync labels" )),
347+ )
348+
349+ reviewer_bot .main ()
350+
351+ output = capsys .readouterr ().out
352+ assert "Reviewer board preview skipped: reviewer board is disabled." in output
353+
354+
355+ def test_main_preview_reviewer_board_missing_token_fails_clearly (monkeypatch , capsys ):
356+ monkeypatch .setenv ("EVENT_NAME" , "workflow_dispatch" )
357+ monkeypatch .setenv ("EVENT_ACTION" , "" )
358+ monkeypatch .setenv ("MANUAL_ACTION" , "preview-reviewer-board" )
359+ monkeypatch .setenv ("REVIEWER_BOARD_ENABLED" , "true" )
360+ monkeypatch .setattr (reviewer_bot , "_reviewer_board_project_metadata" , None , raising = False )
361+
362+ monkeypatch .setattr (reviewer_bot , "load_state" , lambda * args , ** kwargs : make_state ())
363+ monkeypatch .setattr (
364+ reviewer_bot ,
365+ "acquire_state_issue_lease_lock" ,
366+ lambda : (_ for _ in ()).throw (AssertionError ("preview should not acquire lock" )),
367+ )
368+ monkeypatch .setattr (
369+ reviewer_bot ,
370+ "process_pass_until_expirations" ,
371+ lambda state : (_ for _ in ()).throw (AssertionError ("preview should skip pass-until processing" )),
372+ )
373+ monkeypatch .setattr (
374+ reviewer_bot ,
375+ "sync_members_with_queue" ,
376+ lambda state : (_ for _ in ()).throw (AssertionError ("preview should skip member sync" )),
377+ )
378+
379+ with pytest .raises (SystemExit ) as excinfo :
380+ reviewer_bot .main ()
381+
382+ assert excinfo .value .code == 1
383+ assert "REVIEWER_BOARD_TOKEN not set" in capsys .readouterr ().err
384+
385+
386+ def test_main_preview_reviewer_board_invalid_manifest_fails_clearly (monkeypatch , capsys ):
387+ monkeypatch .setenv ("EVENT_NAME" , "workflow_dispatch" )
388+ monkeypatch .setenv ("EVENT_ACTION" , "" )
389+ monkeypatch .setenv ("MANUAL_ACTION" , "preview-reviewer-board" )
390+ monkeypatch .setenv ("REVIEWER_BOARD_ENABLED" , "true" )
391+ monkeypatch .setenv ("REVIEWER_BOARD_TOKEN" , "board-token" )
392+ monkeypatch .setattr (reviewer_bot , "_reviewer_board_project_metadata" , None , raising = False )
393+
394+ monkeypatch .setattr (reviewer_bot , "load_state" , lambda * args , ** kwargs : make_state ())
395+ monkeypatch .setattr (
396+ reviewer_bot ,
397+ "acquire_state_issue_lease_lock" ,
398+ lambda : (_ for _ in ()).throw (AssertionError ("preview should not acquire lock" )),
399+ )
400+ monkeypatch .setattr (
401+ reviewer_bot ,
402+ "github_graphql" ,
403+ lambda query , variables = None , * , token = None : {
404+ "data" : {
405+ "organization" : {
406+ "projectV2" : {
407+ "id" : "PVT_kwDOB" ,
408+ "title" : "Reviewer Board" ,
409+ "fields" : {"nodes" : []},
410+ }
411+ }
412+ }
413+ },
414+ )
415+
416+ with pytest .raises (SystemExit ) as excinfo :
417+ reviewer_bot .main ()
418+
419+ assert excinfo .value .code == 1
420+ assert "Missing reviewer board field: Review State" in capsys .readouterr ().err
421+
422+
423+ def test_main_preview_reviewer_board_is_read_only (monkeypatch , capsys ):
424+ monkeypatch .setenv ("EVENT_NAME" , "workflow_dispatch" )
425+ monkeypatch .setenv ("EVENT_ACTION" , "" )
426+ monkeypatch .setenv ("MANUAL_ACTION" , "preview-reviewer-board" )
427+ monkeypatch .setenv ("REVIEWER_BOARD_ENABLED" , "true" )
428+ monkeypatch .setenv ("REVIEWER_BOARD_TOKEN" , "board-token" )
429+ monkeypatch .setenv ("ISSUE_NUMBER" , "42" )
430+ monkeypatch .setattr (reviewer_bot , "_reviewer_board_project_metadata" , None , raising = False )
431+
432+ state = make_state ()
433+ state ["status_projection_epoch" ] = "status_projection_v1"
434+ review = reviewer_bot .ensure_review_entry (state , 42 , create = True )
435+ assert review is not None
436+ review ["current_reviewer" ] = "alice"
437+ review ["assigned_at" ] = "2026-03-20T12:34:56Z"
438+ review ["active_cycle_started_at" ] = "2026-03-20T12:34:56Z"
439+
440+ monkeypatch .setattr (reviewer_bot , "load_state" , lambda * args , ** kwargs : state )
441+ monkeypatch .setattr (
442+ reviewer_bot ,
443+ "acquire_state_issue_lease_lock" ,
444+ lambda : (_ for _ in ()).throw (AssertionError ("preview should not acquire lock" )),
445+ )
446+ monkeypatch .setattr (
447+ reviewer_bot ,
448+ "process_pass_until_expirations" ,
449+ lambda current : (_ for _ in ()).throw (AssertionError ("preview should skip pass-until processing" )),
450+ )
451+ monkeypatch .setattr (
452+ reviewer_bot ,
453+ "sync_members_with_queue" ,
454+ lambda current : (_ for _ in ()).throw (AssertionError ("preview should skip member sync" )),
455+ )
456+ monkeypatch .setattr (
457+ reviewer_bot ,
458+ "save_state" ,
459+ lambda current : (_ for _ in ()).throw (AssertionError ("preview should not save state" )),
460+ )
461+ monkeypatch .setattr (
462+ reviewer_bot ,
463+ "sync_status_labels_for_items" ,
464+ lambda current , issue_numbers : (_ for _ in ()).throw (AssertionError ("preview should not sync labels" )),
465+ )
466+ monkeypatch .setattr (reviewer_bot , "github_graphql" , lambda query , variables = None , * , token = None : _valid_reviewer_board_metadata ())
467+ monkeypatch .setattr (
468+ reviewer_bot ,
469+ "get_issue_or_pr_snapshot" ,
470+ lambda issue_number : {"number" : issue_number , "state" : "open" , "pull_request" : None , "labels" : []},
471+ )
472+
473+ reviewer_bot .main ()
474+
475+ output = capsys .readouterr ().out
476+ assert "classification: open_tracked_assigned" in output
477+ assert "ensure_membership: true" in output
478+
479+
251480def test_issue_close_then_close_comment_does_not_leave_active_review (monkeypatch ):
252481 state = make_state ()
253482 review = reviewer_bot .ensure_review_entry (state , 42 , create = True )
0 commit comments