@openhands/extensions 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +6 -0
  2. package/.codex-plugin/marketplace.json +6 -0
  3. package/.plugin/marketplace.json +6 -0
  4. package/.release-please-manifest.json +1 -1
  5. package/README.md +3 -2
  6. package/automations/bundle-index.js +11 -7
  7. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  8. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  9. package/automations/catalog-index.js +2 -0
  10. package/marketplaces/openhands-extensions.json +6 -0
  11. package/package.json +1 -1
  12. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  13. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  14. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  15. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +116 -0
  16. package/pyproject.toml +1 -1
  17. package/python/openhands_extensions/_version.py +1 -1
  18. package/skills/github/scripts/github_client.py +116 -0
  19. package/skills/github-agents-md-maintainer/scripts/github_client.py +116 -0
  20. package/skills/github-delivery-watchdog/scripts/github_client.py +116 -0
  21. package/skills/github-issue-to-pr/scripts/github_client.py +116 -0
  22. package/skills/github-issue-triage/scripts/github_client.py +116 -0
  23. package/skills/github-pr-review/SKILL.md +12 -2
  24. package/skills/github-pr-reviewer/README.md +35 -0
  25. package/skills/github-pr-reviewer/SKILL.md +156 -4
  26. package/skills/github-pr-reviewer/references/state-schema.md +15 -0
  27. package/skills/github-pr-reviewer/scripts/github_client.py +116 -0
  28. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  29. package/skills/github-pr-reviewer/scripts/worker.py +820 -58
  30. package/skills/github-repo-monitor/scripts/github_client.py +116 -0
  31. package/skills/github-repo-monitor/scripts/main.py +32 -1
  32. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  33. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  34. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  35. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  36. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  37. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  38. package/skills/github-stale-ci-pr-closer/scripts/worker.py +335 -0
  39. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +213 -0
  40. package/skills/index.js +12 -3
  41. package/skills/openhands-sdk/SKILL.md +1 -0
  42. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  43. package/tests/test_github_automation_foundation.py +158 -0
  44. package/tests/test_github_repo_monitor.py +107 -0
  45. package/tests/test_github_reviewer_delivery.py +2108 -3
@@ -37,6 +37,15 @@ def _reviewer(tmp_path, monkeypatch):
37
37
  run.token_name = "FACTORY_GITHUB_REVIEWER_TOKEN"
38
38
  run.github_login = "all-hands-bot"
39
39
  run.dispatcher = Mock()
40
+ # Default to a head with no reported check runs or workflow runs, which the
41
+ # gate reads as green. Gate-specific tests override these.
42
+ run.check_runs = lambda sha: []
43
+ run.workflow_runs = lambda sha: []
44
+ run.statuses = lambda sha: {}
45
+ # Default to no required-check signal so the gate uses its conservative
46
+ # all-run fallback, matching the pre-required-check behavior. Tests that
47
+ # exercise required-only classification override this.
48
+ run.required_check_contexts = lambda number: []
40
49
  monkeypatch.delenv("AUTOMATION_EVENT_PAYLOAD", raising=False)
41
50
  monkeypatch.setattr(
42
51
  module.workflow,
@@ -46,6 +55,65 @@ def _reviewer(tmp_path, monkeypatch):
46
55
  return module, run
47
56
 
48
57
 
58
+ def _checks(*runs, sha="head-2"):
59
+ """Check runs as the API reports them: name, status, and conclusion."""
60
+ return [{"name": name, "status": status, "conclusion": conclusion, "head_sha": sha}
61
+ for name, status, conclusion in runs]
62
+
63
+
64
+ def _run(
65
+ name,
66
+ status,
67
+ conclusion,
68
+ *,
69
+ app="github-actions",
70
+ started_at="",
71
+ run_id=0,
72
+ sha="head-2",
73
+ suite_id=0,
74
+ ):
75
+ """One check run with the app identity and ordering fields the API sends."""
76
+ return {
77
+ "name": name,
78
+ "status": status,
79
+ "conclusion": conclusion,
80
+ "head_sha": sha,
81
+ "app": {"slug": app},
82
+ "started_at": started_at,
83
+ "id": run_id,
84
+ "check_suite": {"id": suite_id},
85
+ }
86
+
87
+
88
+ def _workflow_run(
89
+ name,
90
+ status,
91
+ conclusion,
92
+ *,
93
+ run_id=0,
94
+ suite_id=0,
95
+ workflow_id=0,
96
+ sha="head-2",
97
+ started_at="",
98
+ ):
99
+ """One Actions workflow run, as the runs endpoint reports it.
100
+
101
+ `check_suite_id` links the run to its check runs; a suite whose workflows
102
+ failed before any job reported has no check runs under it.
103
+ """
104
+ return {
105
+ "name": name,
106
+ "status": status,
107
+ "conclusion": conclusion,
108
+ "head_sha": sha,
109
+ "id": run_id,
110
+ "check_suite_id": suite_id,
111
+ "workflow_id": workflow_id,
112
+ "run_started_at": started_at,
113
+ "created_at": started_at,
114
+ }
115
+
116
+
49
117
  def _event(monkeypatch, *, action="review_requested", login="all-hands-bot"):
50
118
  payload = {
51
119
  "action": action,
@@ -70,7 +138,9 @@ def test_reviewer_submits_each_labeled_exact_head(tmp_path, monkeypatch):
70
138
  "head": {"sha": "head-2"},
71
139
  "labels": [{"name": "openhands-review"}],
72
140
  },
73
- {"number": 1, "head": {"sha": "head-1"}, "labels": []},
141
+ # A draft is not eligible for the unrequested scan, so it is skipped
142
+ # without a full read and only the labeled head is submitted.
143
+ {"number": 1, "head": {"sha": "head-1"}, "labels": [], "draft": True},
74
144
  ]
75
145
  run.gh_pages = lambda path: prs
76
146
  run.gh = Mock(side_effect=[{"id": 99}, prs[0]])
@@ -349,9 +419,12 @@ def test_reviewer_does_not_trust_another_reviewers_verdict(tmp_path, monkeypatch
349
419
  run.dispatcher.deliver.assert_called_once()
350
420
 
351
421
 
352
- def test_reviewer_ignores_unlabeled_prs(tmp_path, monkeypatch):
422
+ def test_reviewer_ignores_a_draft_unlabeled_unrequested_pr(tmp_path, monkeypatch):
423
+ """The unrequested scan skips a draft: it is not reviewable yet."""
353
424
  _module, run = _reviewer(tmp_path, monkeypatch)
354
- run.gh_pages = lambda path: [{"number": 1, "labels": [], "head": {"sha": "head"}}]
425
+ run.gh_pages = lambda path: [
426
+ {"number": 1, "labels": [], "head": {"sha": "head"}, "draft": True}
427
+ ]
355
428
  run.gh = Mock(return_value={"id": 99})
356
429
  submit = Mock()
357
430
  run.dispatcher.deliver = submit
@@ -359,6 +432,8 @@ def test_reviewer_ignores_unlabeled_prs(tmp_path, monkeypatch):
359
432
  run.run()
360
433
 
361
434
  submit.assert_not_called()
435
+ # Skipped before the full PR read, so only the repository-id lookup ran.
436
+ assert run.gh.call_count == 1
362
437
 
363
438
 
364
439
  def test_reviewer_continues_after_one_submission_fails_then_reports_run_failure(
@@ -551,3 +626,2033 @@ def test_reviewer_hands_scope_stop_to_maintainer(tmp_path, monkeypatch):
551
626
  "/issues/2/labels/openhands-review",
552
627
  )
553
628
  run.dispatcher.deliver.assert_not_called()
629
+
630
+
631
+ # --------------------------------------------------------------------------- #
632
+ # Head-eligibility gate: a deterministically blocked head spends no agent work.
633
+ # --------------------------------------------------------------------------- #
634
+
635
+
636
+ def _labeled_pr(number=2, sha="head-2"):
637
+ return {
638
+ "number": number,
639
+ "head": {"sha": sha},
640
+ "labels": [{"name": "openhands-review"}],
641
+ }
642
+
643
+
644
+ def _comment(body, login="all-hands-bot", comment_id=900):
645
+ return {
646
+ "id": comment_id,
647
+ "body": body,
648
+ "user": {"login": login},
649
+ }
650
+
651
+
652
+ def _gate_pages(pr, comments):
653
+ return lambda path: (
654
+ [pr] if path.startswith("/pulls?") else comments
655
+ )
656
+
657
+
658
+ def _gate_comment_calls(run, number=2):
659
+ return [
660
+ call
661
+ for call in run.gh.call_args_list
662
+ if call.args[1] == f"/issues/{number}/comments"
663
+ ]
664
+
665
+
666
+ def test_reviewer_blocking_current_head_check_creates_no_conversation(
667
+ tmp_path, monkeypatch
668
+ ):
669
+ _module, run = _reviewer(tmp_path, monkeypatch)
670
+ pr = _labeled_pr()
671
+ run.gh_pages = _gate_pages(pr, [])
672
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
673
+ run.check_runs = lambda sha: _checks(
674
+ ("Validate PR description", "completed", "failure"), sha=sha
675
+ )
676
+
677
+ run.run()
678
+
679
+ run.dispatcher.deliver.assert_not_called()
680
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
681
+ assert len(posted) == 1
682
+ body = posted[0].args[2]["body"]
683
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in body
684
+ assert "`Validate PR description`" in body
685
+ assert "no AI was used to generate this comment" in body
686
+
687
+
688
+ def test_reviewer_green_current_head_creates_exactly_one_conversation(
689
+ tmp_path, monkeypatch
690
+ ):
691
+ _module, run = _reviewer(tmp_path, monkeypatch)
692
+ pr = _labeled_pr()
693
+ run.gh_pages = _gate_pages(pr, [])
694
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
695
+ run.check_runs = lambda sha: _checks(
696
+ ("Validate PR description", "completed", "success"),
697
+ ("lint", "completed", "skipped"),
698
+ ("optional", "completed", "neutral"),
699
+ sha=sha,
700
+ )
701
+ run.dispatcher.deliver.return_value = {
702
+ "disposition": "created",
703
+ "conversation_id": "conversation",
704
+ }
705
+
706
+ run.run()
707
+
708
+ run.dispatcher.deliver.assert_called_once()
709
+ assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "7:head-2"
710
+
711
+
712
+ def test_reviewer_pending_current_head_check_waits_without_a_conversation(
713
+ tmp_path, monkeypatch
714
+ ):
715
+ _module, run = _reviewer(tmp_path, monkeypatch)
716
+ pr = _labeled_pr()
717
+ run.gh_pages = _gate_pages(pr, [])
718
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
719
+ run.check_runs = lambda sha: _checks(
720
+ ("ci", "completed", "success"),
721
+ ("slow-e2e", "in_progress", None),
722
+ sha=sha,
723
+ )
724
+
725
+ run.run()
726
+
727
+ run.dispatcher.deliver.assert_not_called()
728
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
729
+ assert len(posted) == 1
730
+ body = posted[0].args[2]["body"]
731
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
732
+ assert "`slow-e2e`" in body
733
+ assert "waiting on checks" in body
734
+
735
+
736
+ def test_reviewer_ignores_checks_from_an_obsolete_head(tmp_path, monkeypatch):
737
+ _module, run = _reviewer(tmp_path, monkeypatch)
738
+ pr = _labeled_pr()
739
+ run.gh_pages = _gate_pages(pr, [])
740
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
741
+ run.check_runs = lambda sha: _checks(
742
+ ("Validate PR description", "completed", "failure"), sha="head-1"
743
+ )
744
+ run.dispatcher.deliver.return_value = {
745
+ "disposition": "created",
746
+ "conversation_id": "conversation",
747
+ }
748
+
749
+ run.run()
750
+
751
+ run.dispatcher.deliver.assert_called_once()
752
+ assert _gate_comment_calls(run) == []
753
+
754
+
755
+ def test_reviewer_blocks_a_workflow_that_failed_with_no_check_runs(
756
+ tmp_path, monkeypatch
757
+ ):
758
+ """The #426 case: green check-run rollup, but a workflow failed before jobs.
759
+
760
+ `Tests`, `Check Extensions`, and `Deprecation deadlines` failed with zero
761
+ jobs, so they contributed no check runs and the commit's rollup was a green
762
+ `pr-title`. Reading only check runs launched the reviewer for red CI.
763
+ """
764
+ _module, run = _reviewer(tmp_path, monkeypatch)
765
+ pr = _labeled_pr()
766
+ run.gh_pages = _gate_pages(pr, [])
767
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
768
+ run.check_runs = lambda sha: _checks(
769
+ ("pr-title / Lint PR title (conventional)", "completed", "success"), sha=sha
770
+ )
771
+ run.workflow_runs = lambda sha: [
772
+ _workflow_run(
773
+ "Tests",
774
+ "completed",
775
+ "failure",
776
+ run_id=3,
777
+ suite_id=3003,
778
+ workflow_id=236324519,
779
+ sha=sha,
780
+ ),
781
+ _workflow_run(
782
+ "Check Extensions",
783
+ "completed",
784
+ "failure",
785
+ run_id=2,
786
+ suite_id=3002,
787
+ workflow_id=260888338,
788
+ sha=sha,
789
+ ),
790
+ _workflow_run(
791
+ "Deprecation deadlines",
792
+ "completed",
793
+ "failure",
794
+ run_id=1,
795
+ suite_id=3001,
796
+ workflow_id=311570275,
797
+ sha=sha,
798
+ ),
799
+ ]
800
+
801
+ run.run()
802
+
803
+ run.dispatcher.deliver.assert_not_called()
804
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
805
+ assert len(posted) == 1
806
+ body = posted[0].args[2]["body"]
807
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in body
808
+ assert "`Tests`" in body
809
+ assert "`Check Extensions`" in body
810
+ assert "`Deprecation deadlines`" in body
811
+
812
+
813
+ def test_reviewer_does_not_double_report_a_workflow_that_has_check_runs(
814
+ tmp_path, monkeypatch
815
+ ):
816
+ """A workflow whose suite already has check runs is not reported twice."""
817
+ _module, run = _reviewer(tmp_path, monkeypatch)
818
+ pr = _labeled_pr()
819
+ run.gh_pages = _gate_pages(pr, [])
820
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
821
+ # The suite the workflow run points at is the one that reported the check,
822
+ # so the workflow run is redundant and must not be reported a second time.
823
+ run.check_runs = lambda sha: [
824
+ _run(
825
+ "Validate PR description",
826
+ "completed",
827
+ "success",
828
+ started_at="2026-09-22T13:00:00Z",
829
+ run_id=1,
830
+ sha=sha,
831
+ suite_id=900,
832
+ )
833
+ ]
834
+ run.workflow_runs = lambda sha: [
835
+ _workflow_run(
836
+ "PR Description Check",
837
+ "completed",
838
+ "success",
839
+ run_id=5,
840
+ suite_id=900,
841
+ workflow_id=341671185,
842
+ sha=sha,
843
+ )
844
+ ]
845
+ run.dispatcher.deliver.return_value = {
846
+ "disposition": "created",
847
+ "conversation_id": "conversation",
848
+ }
849
+
850
+ run.run()
851
+
852
+ run.dispatcher.deliver.assert_called_once()
853
+ assert _gate_comment_calls(run) == []
854
+
855
+
856
+ def test_reviewer_waits_for_a_workflow_run_that_has_not_finished(
857
+ tmp_path, monkeypatch
858
+ ):
859
+ """A current-head workflow run still in progress makes the head wait."""
860
+ _module, run = _reviewer(tmp_path, monkeypatch)
861
+ pr = _labeled_pr()
862
+ run.gh_pages = _gate_pages(pr, [])
863
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
864
+ run.workflow_runs = lambda sha: [
865
+ _workflow_run(
866
+ "Tests",
867
+ "in_progress",
868
+ None,
869
+ run_id=4,
870
+ suite_id=4004,
871
+ workflow_id=236324519,
872
+ sha=sha,
873
+ )
874
+ ]
875
+
876
+ run.run()
877
+
878
+ run.dispatcher.deliver.assert_not_called()
879
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
880
+ assert len(posted) == 1
881
+ body = posted[0].args[2]["body"]
882
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
883
+ assert "`Tests`" in body
884
+
885
+
886
+ def test_reviewer_ignores_workflow_runs_from_an_obsolete_head(
887
+ tmp_path, monkeypatch
888
+ ):
889
+ """A failed workflow on an earlier head must not block the push that fixed it."""
890
+ _module, run = _reviewer(tmp_path, monkeypatch)
891
+ pr = _labeled_pr()
892
+ run.gh_pages = _gate_pages(pr, [])
893
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
894
+ run.workflow_runs = lambda sha: [
895
+ _workflow_run(
896
+ "Tests",
897
+ "completed",
898
+ "failure",
899
+ run_id=1,
900
+ suite_id=3001,
901
+ workflow_id=236324519,
902
+ sha="head-1",
903
+ )
904
+ ]
905
+ run.dispatcher.deliver.return_value = {
906
+ "disposition": "created",
907
+ "conversation_id": "conversation",
908
+ }
909
+
910
+ run.run()
911
+
912
+ run.dispatcher.deliver.assert_called_once()
913
+ assert _gate_comment_calls(run) == []
914
+
915
+
916
+ def test_reviewer_lets_a_green_workflow_rerun_supersede_a_failed_one(
917
+ tmp_path, monkeypatch
918
+ ):
919
+ """The latest run of a workflow decides, so a successful re-run clears it."""
920
+ _module, run = _reviewer(tmp_path, monkeypatch)
921
+ pr = _labeled_pr()
922
+ run.gh_pages = _gate_pages(pr, [])
923
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
924
+ run.workflow_runs = lambda sha: [
925
+ _workflow_run(
926
+ "Tests",
927
+ "completed",
928
+ "failure",
929
+ run_id=1,
930
+ suite_id=3001,
931
+ workflow_id=236324519,
932
+ sha=sha,
933
+ started_at="2026-09-22T13:00:00Z",
934
+ ),
935
+ _workflow_run(
936
+ "Tests",
937
+ "completed",
938
+ "success",
939
+ run_id=2,
940
+ suite_id=3002,
941
+ workflow_id=236324519,
942
+ sha=sha,
943
+ started_at="2026-09-22T13:10:00Z",
944
+ ),
945
+ ]
946
+ run.dispatcher.deliver.return_value = {
947
+ "disposition": "created",
948
+ "conversation_id": "conversation",
949
+ }
950
+
951
+ run.run()
952
+
953
+ run.dispatcher.deliver.assert_called_once()
954
+ assert _gate_comment_calls(run) == []
955
+
956
+
957
+ def test_reviewer_ignores_an_optional_zero_job_workflow_on_a_green_required_head(
958
+ tmp_path, monkeypatch
959
+ ):
960
+ """The #17200 case: the only required check passes, an optional workflow does not.
961
+
962
+ OpenHands/OpenHands#17200 passes `test-and-build (ubuntu)`, its only
963
+ required check, while the optional `release ready` workflow has a zero-job
964
+ `startup_failure`. Scheduled discovery must classify on the required check
965
+ and dispatch, not block on the optional workflow.
966
+ """
967
+ _module, run = _reviewer(tmp_path, monkeypatch)
968
+ pr = _labeled_pr()
969
+ run.gh_pages = _gate_pages(pr, [])
970
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
971
+ run.required_check_contexts = lambda number: [
972
+ {"name": "test-and-build (ubuntu)", "kind": "CheckRun"}
973
+ ]
974
+ run.check_runs = lambda sha: _checks(
975
+ ("test-and-build (ubuntu)", "completed", "success"), sha=sha
976
+ )
977
+ run.workflow_runs = lambda sha: [
978
+ _workflow_run(
979
+ "release ready",
980
+ "completed",
981
+ "startup_failure",
982
+ run_id=1,
983
+ suite_id=1001,
984
+ workflow_id=305675242,
985
+ sha=sha,
986
+ )
987
+ ]
988
+ run.dispatcher.deliver.return_value = {
989
+ "disposition": "created",
990
+ "conversation_id": "conversation",
991
+ }
992
+
993
+ run.run()
994
+
995
+ run.dispatcher.deliver.assert_called_once()
996
+ assert _gate_comment_calls(run) == []
997
+
998
+
999
+ def test_reviewer_blocks_when_a_required_check_fails(tmp_path, monkeypatch):
1000
+ """A failed required check still blocks scheduled discovery."""
1001
+ _module, run = _reviewer(tmp_path, monkeypatch)
1002
+ pr = _labeled_pr()
1003
+ run.gh_pages = _gate_pages(pr, [])
1004
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1005
+ run.required_check_contexts = lambda number: [
1006
+ {"name": "test-and-build (ubuntu)", "kind": "CheckRun"}
1007
+ ]
1008
+ run.check_runs = lambda sha: _checks(
1009
+ ("test-and-build (ubuntu)", "completed", "failure"),
1010
+ ("optional", "completed", "startup_failure"),
1011
+ sha=sha,
1012
+ )
1013
+
1014
+ run.run()
1015
+
1016
+ run.dispatcher.deliver.assert_not_called()
1017
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1018
+ assert len(posted) == 1
1019
+ body = posted[0].args[2]["body"]
1020
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in body
1021
+ assert "`test-and-build (ubuntu)`" in body
1022
+ assert "`optional`" not in body
1023
+
1024
+
1025
+ def test_reviewer_waits_for_a_pending_required_check(tmp_path, monkeypatch):
1026
+ """A required check that has not finished makes scheduled discovery wait."""
1027
+ _module, run = _reviewer(tmp_path, monkeypatch)
1028
+ pr = _labeled_pr()
1029
+ run.gh_pages = _gate_pages(pr, [])
1030
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1031
+ run.required_check_contexts = lambda number: [
1032
+ {"name": "test-and-build (ubuntu)", "kind": "CheckRun"}
1033
+ ]
1034
+ run.check_runs = lambda sha: _checks(
1035
+ ("test-and-build (ubuntu)", "in_progress", None), sha=sha
1036
+ )
1037
+
1038
+ run.run()
1039
+
1040
+ run.dispatcher.deliver.assert_not_called()
1041
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1042
+ assert len(posted) == 1
1043
+ body = posted[0].args[2]["body"]
1044
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
1045
+ assert "`test-and-build (ubuntu)`" in body
1046
+
1047
+
1048
+ def test_reviewer_waits_for_a_required_context_with_no_current_head_run(
1049
+ tmp_path, monkeypatch
1050
+ ):
1051
+ """A required name that reported no check run is expected, so the head waits.
1052
+
1053
+ A required context can appear without a matching current-head check run (a
1054
+ required commit status, or a re-run whose name now resolves elsewhere).
1055
+ Unfulfilled is never approval.
1056
+ """
1057
+ _module, run = _reviewer(tmp_path, monkeypatch)
1058
+ pr = _labeled_pr()
1059
+ run.gh_pages = _gate_pages(pr, [])
1060
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1061
+ run.required_check_contexts = lambda number: [
1062
+ {"name": "test", "kind": "CheckRun"},
1063
+ ]
1064
+ run.check_runs = lambda sha: []
1065
+
1066
+ run.run()
1067
+
1068
+ run.dispatcher.deliver.assert_not_called()
1069
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1070
+ assert len(posted) == 1
1071
+ body = posted[0].args[2]["body"]
1072
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
1073
+ assert "`test`" in body
1074
+
1075
+
1076
+ def test_reviewer_falls_back_to_all_checks_when_required_reported_nothing(
1077
+ tmp_path, monkeypatch
1078
+ ):
1079
+ """The #426 shape through the required path: required checks never reported.
1080
+
1081
+ `isRequired` is a field on each context in the head's rollup, so a required
1082
+ check that failed before creating any check run has no node and the required
1083
+ set comes back empty. An empty set must fall back to the full current-head
1084
+ rollup rather than treating the head as green.
1085
+ """
1086
+ _module, run = _reviewer(tmp_path, monkeypatch)
1087
+ pr = _labeled_pr()
1088
+ run.gh_pages = _gate_pages(pr, [])
1089
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1090
+ run.required_check_contexts = lambda number: []
1091
+ run.check_runs = lambda sha: _checks(
1092
+ ("pr-title / Lint PR title (conventional)", "completed", "success"), sha=sha
1093
+ )
1094
+ run.workflow_runs = lambda sha: [
1095
+ _workflow_run(
1096
+ "Tests",
1097
+ "completed",
1098
+ "failure",
1099
+ run_id=1,
1100
+ suite_id=3001,
1101
+ workflow_id=236324519,
1102
+ sha=sha,
1103
+ )
1104
+ ]
1105
+
1106
+ run.run()
1107
+
1108
+ run.dispatcher.deliver.assert_not_called()
1109
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1110
+ assert len(posted) == 1
1111
+ body = posted[0].args[2]["body"]
1112
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in body
1113
+ assert "`Tests`" in body
1114
+
1115
+
1116
+ def test_reviewer_ignores_an_optional_failure_but_reads_required_statuses(
1117
+ tmp_path, monkeypatch
1118
+ ):
1119
+ """A required commit status is classified alongside required check runs."""
1120
+ _module, run = _reviewer(tmp_path, monkeypatch)
1121
+ pr = _labeled_pr()
1122
+ run.gh_pages = _gate_pages(pr, [])
1123
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1124
+ run.required_check_contexts = lambda number: [
1125
+ {"name": "test-and-build (ubuntu)", "kind": "CheckRun"},
1126
+ {"name": "ci/required", "kind": "StatusContext"},
1127
+ ]
1128
+ run.check_runs = lambda sha: _checks(
1129
+ ("test-and-build (ubuntu)", "completed", "success"), sha=sha
1130
+ )
1131
+ run.statuses = lambda sha: {"ci/required": "failure"}
1132
+
1133
+ run.run()
1134
+
1135
+ run.dispatcher.deliver.assert_not_called()
1136
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1137
+ assert len(posted) == 1
1138
+ assert "`ci/required`" in posted[0].args[2]["body"]
1139
+
1140
+
1141
+ def test_reviewer_falls_back_to_every_check_when_required_signal_is_unavailable(
1142
+ tmp_path, monkeypatch
1143
+ ):
1144
+ """A failed required-check read must not approve silently."""
1145
+
1146
+ _module, run = _reviewer(tmp_path, monkeypatch)
1147
+ pr = _labeled_pr()
1148
+ run.gh_pages = _gate_pages(pr, [])
1149
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1150
+
1151
+ def unavailable(number):
1152
+ raise RuntimeError("GitHub required-check query failed")
1153
+
1154
+ run.required_check_contexts = unavailable
1155
+ run.check_runs = lambda sha: _checks(("ci", "completed", "failure"), sha=sha)
1156
+
1157
+ run.run()
1158
+
1159
+ run.dispatcher.deliver.assert_not_called()
1160
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1161
+ assert len(posted) == 1
1162
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in posted[0].args[2]["body"]
1163
+
1164
+
1165
+ def test_reviewer_fails_closed_on_an_unknown_conclusion(tmp_path, monkeypatch):
1166
+ _module, run = _reviewer(tmp_path, monkeypatch)
1167
+ pr = _labeled_pr()
1168
+ run.gh_pages = _gate_pages(pr, [])
1169
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1170
+ run.check_runs = lambda sha: _checks(("mystery", "completed", "action_required"), sha=sha)
1171
+
1172
+ run.run()
1173
+
1174
+ run.dispatcher.deliver.assert_not_called()
1175
+ body = [
1176
+ call.args[2]["body"]
1177
+ for call in _gate_comment_calls(run)
1178
+ if call.args[0] == "POST"
1179
+ ][0]
1180
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in body
1181
+
1182
+
1183
+ def test_reviewer_does_not_duplicate_the_gate_comment_for_the_same_head(
1184
+ tmp_path, monkeypatch
1185
+ ):
1186
+ """An identical explanation already on the PR is left untouched."""
1187
+ _module, run = _reviewer(tmp_path, monkeypatch)
1188
+ pr = _labeled_pr()
1189
+ existing = _comment(run._gate_body("head-2", "blocked", ["ci"], scheduled=True))
1190
+ run.gh_pages = _gate_pages(pr, [existing])
1191
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1192
+ run.check_runs = lambda sha: _checks(("ci", "completed", "failure"), sha=sha)
1193
+
1194
+ run.run()
1195
+
1196
+ run.dispatcher.deliver.assert_not_called()
1197
+ assert _gate_comment_calls(run) == []
1198
+ assert [call for call in run.gh.call_args_list if call.args[0] == "PATCH"] == []
1199
+
1200
+
1201
+ def test_reviewer_rewrites_a_stale_gate_comment_body_for_the_same_head(
1202
+ tmp_path, monkeypatch
1203
+ ):
1204
+ """Same head, changed wording: the managed comment is rewritten, not stacked."""
1205
+ _module, run = _reviewer(tmp_path, monkeypatch)
1206
+ pr = _labeled_pr()
1207
+ stale = _comment(
1208
+ "old wording\n\n<!-- openhands-review-gate:blocked:head-2 -->"
1209
+ )
1210
+ run.gh_pages = _gate_pages(pr, [stale])
1211
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {}])
1212
+ run.check_runs = lambda sha: _checks(("ci", "completed", "failure"), sha=sha)
1213
+
1214
+ run.run()
1215
+
1216
+ assert [call for call in _gate_comment_calls(run) if call.args[0] == "POST"] == []
1217
+ patched = [call for call in run.gh.call_args_list if call.args[0] == "PATCH"]
1218
+ assert len(patched) == 1
1219
+ assert patched[0].args[1] == "/issues/comments/900"
1220
+ body = patched[0].args[2]["body"]
1221
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in body
1222
+ assert "old wording" not in body
1223
+
1224
+
1225
+ def test_reviewer_updates_its_gate_comment_when_the_head_moves(
1226
+ tmp_path, monkeypatch
1227
+ ):
1228
+ _module, run = _reviewer(tmp_path, monkeypatch)
1229
+ pr = _labeled_pr()
1230
+ stale = _comment("stale head\n\n<!-- openhands-review-gate:blocked:head-1 -->")
1231
+ run.gh_pages = _gate_pages(pr, [stale])
1232
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {}])
1233
+ run.check_runs = lambda sha: _checks(("ci", "completed", "failure"), sha=sha)
1234
+
1235
+ run.run()
1236
+
1237
+ patched = [call for call in run.gh.call_args_list if call.args[0] == "PATCH"]
1238
+ assert len(patched) == 1
1239
+ assert patched[0].args[1] == "/issues/comments/900"
1240
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in patched[0].args[2]["body"]
1241
+ assert run.dispatcher.deliver.call_count == 0
1242
+
1243
+
1244
+ def test_reviewer_defers_to_an_equivalent_workflow_remediation_comment(
1245
+ tmp_path, monkeypatch
1246
+ ):
1247
+ _module, run = _reviewer(tmp_path, monkeypatch)
1248
+ pr = _labeled_pr()
1249
+ workflow = _comment(
1250
+ "The `Validate PR description` check still fails: the PR description's "
1251
+ "`HUMAN:` section needs at least 20 characters of what you tested.\n\n"
1252
+ "_This is an automated check - no AI was used to generate this comment._"
1253
+ )
1254
+ run.gh_pages = _gate_pages(pr, [workflow])
1255
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1256
+ run.check_runs = lambda sha: _checks(
1257
+ ("Validate PR description", "completed", "failure"), sha=sha
1258
+ )
1259
+
1260
+ run.run()
1261
+
1262
+ run.dispatcher.deliver.assert_not_called()
1263
+ assert _gate_comment_calls(run) == []
1264
+
1265
+
1266
+ def test_reviewer_ignores_a_marked_comment_a_pr_author_wrote(
1267
+ tmp_path, monkeypatch
1268
+ ):
1269
+ """A forged marker is untrusted: it neither suppresses nor gets PATCHed."""
1270
+ _module, run = _reviewer(tmp_path, monkeypatch)
1271
+ pr = _labeled_pr()
1272
+ forged = _comment(
1273
+ "I already handled this\n\n<!-- openhands-review-gate:blocked:head-2 -->",
1274
+ login="neubig",
1275
+ )
1276
+ run.gh_pages = _gate_pages(pr, [forged])
1277
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1278
+ run.check_runs = lambda sha: _checks(("ci", "completed", "failure"), sha=sha)
1279
+
1280
+ run.run()
1281
+
1282
+ run.dispatcher.deliver.assert_not_called()
1283
+ assert [call for call in run.gh.call_args_list if call.args[0] == "PATCH"] == []
1284
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1285
+ assert len(posted) == 1
1286
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in posted[0].args[2]["body"]
1287
+
1288
+
1289
+ def test_reviewer_does_not_defer_to_a_workflow_comment_about_another_check(
1290
+ tmp_path, monkeypatch
1291
+ ):
1292
+ """A disclosure for a different check must not suppress this head's gate."""
1293
+ _module, run = _reviewer(tmp_path, monkeypatch)
1294
+ pr = _labeled_pr()
1295
+ unrelated = _comment(
1296
+ "The `Validate PR description` check still fails.\n\n"
1297
+ "_This is an automated check - no AI was used to generate this comment._"
1298
+ )
1299
+ run.gh_pages = _gate_pages(pr, [unrelated])
1300
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1301
+ run.check_runs = lambda sha: _checks(("lint", "completed", "failure"), sha=sha)
1302
+
1303
+ run.run()
1304
+
1305
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1306
+ assert len(posted) == 1
1307
+ assert "`lint`" in posted[0].args[2]["body"]
1308
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in posted[0].args[2]["body"]
1309
+
1310
+
1311
+ def test_reviewer_explicit_request_bypasses_the_ci_gate(tmp_path, monkeypatch):
1312
+ """An explicit `all-hands-bot` request is the intake exception.
1313
+
1314
+ The caller asked for this head by name, so a red or pending required check
1315
+ must not stop the dispatch - and the gate leaves no explanatory comment.
1316
+ """
1317
+ _module, run = _reviewer(tmp_path, monkeypatch)
1318
+ _event(monkeypatch)
1319
+ pr = {"number": 2, "head": {"sha": "head-2"}, "labels": []}
1320
+ request = {
1321
+ "id": 42,
1322
+ "event": "review_requested",
1323
+ "created_at": "2026-01-01T00:00:00Z",
1324
+ "requested_reviewer": {"login": "all-hands-bot"},
1325
+ }
1326
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1327
+ run.gh_pages = lambda path: [request] if path.endswith("/events") else []
1328
+ run.check_runs = lambda sha: _checks(
1329
+ ("Validate PR description", "completed", "failure"), sha=sha
1330
+ )
1331
+ run.required_check_contexts = lambda number: [
1332
+ {"name": "Validate PR description", "kind": "CheckRun"}
1333
+ ]
1334
+ run.dispatcher.deliver.return_value = {
1335
+ "disposition": "created",
1336
+ "conversation_id": "conversation",
1337
+ }
1338
+
1339
+ run.run()
1340
+
1341
+ run.dispatcher.deliver.assert_called_once()
1342
+ assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "42:head-2"
1343
+ assert _gate_comment_calls(run) == []
1344
+
1345
+
1346
+ def test_reviewer_waiting_head_does_not_consume_a_later_green_request(
1347
+ tmp_path, monkeypatch
1348
+ ):
1349
+ """The trigger survives a waiting run, so the head is reviewed once green."""
1350
+ _module, run = _reviewer(tmp_path, monkeypatch)
1351
+ pr = _labeled_pr()
1352
+ pending = _checks(("slow-e2e", "queued", None), sha="head-2")
1353
+ state = {"runs": pending}
1354
+ run.gh_pages = _gate_pages(pr, [])
1355
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}, pr])
1356
+ run.check_runs = lambda sha: state["runs"]
1357
+ run.dispatcher.deliver.return_value = {
1358
+ "disposition": "created",
1359
+ "conversation_id": "conversation",
1360
+ }
1361
+
1362
+ run.run()
1363
+ assert run.dispatcher.deliver.call_count == 0
1364
+
1365
+ run.gh_pages = _gate_pages(pr, [])
1366
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1367
+ state["runs"] = _checks(("slow-e2e", "completed", "success"), sha="head-2")
1368
+ run.run()
1369
+ assert run.dispatcher.deliver.call_count == 1
1370
+
1371
+
1372
+
1373
+ def test_reviewer_ignores_a_superseded_failure_for_the_same_check(
1374
+ tmp_path, monkeypatch
1375
+ ):
1376
+ """The live #647/#649 case: an early failure and a later success, one SHA.
1377
+
1378
+ GitHub lists both `Validate PR description` runs on a single commit when the
1379
+ PR body is fixed without a code push, so only the latest run may count.
1380
+ """
1381
+ _module, run = _reviewer(tmp_path, monkeypatch)
1382
+ pr = _labeled_pr()
1383
+ run.gh_pages = _gate_pages(pr, [])
1384
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1385
+ run.check_runs = lambda sha: [
1386
+ _run(
1387
+ "Validate PR description",
1388
+ "completed",
1389
+ "success",
1390
+ started_at="2026-09-22T13:18:41Z",
1391
+ run_id=106761769135,
1392
+ ),
1393
+ _run(
1394
+ "Validate PR description",
1395
+ "completed",
1396
+ "failure",
1397
+ started_at="2026-09-22T13:04:07Z",
1398
+ run_id=106756418507,
1399
+ ),
1400
+ ]
1401
+ run.dispatcher.deliver.return_value = {
1402
+ "disposition": "created",
1403
+ "conversation_id": "conversation",
1404
+ }
1405
+
1406
+ run.run()
1407
+
1408
+ run.dispatcher.deliver.assert_called_once()
1409
+ assert _gate_comment_calls(run) == []
1410
+
1411
+
1412
+ def test_reviewer_prefers_run_id_to_break_a_start_time_tie(tmp_path, monkeypatch):
1413
+ """Two runs sharing a start time are ordered by run ID, not list order."""
1414
+ _module, run = _reviewer(tmp_path, monkeypatch)
1415
+ pr = _labeled_pr()
1416
+ run.gh_pages = _gate_pages(pr, [])
1417
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1418
+ run.check_runs = lambda sha: [
1419
+ _run(
1420
+ "ci",
1421
+ "completed",
1422
+ "failure",
1423
+ started_at="2026-09-22T13:04:07Z",
1424
+ run_id=106756418507,
1425
+ ),
1426
+ _run(
1427
+ "ci",
1428
+ "completed",
1429
+ "success",
1430
+ started_at="2026-09-22T13:04:07Z",
1431
+ run_id=106761769135,
1432
+ ),
1433
+ ]
1434
+ run.dispatcher.deliver.return_value = {
1435
+ "disposition": "created",
1436
+ "conversation_id": "conversation",
1437
+ }
1438
+
1439
+ run.run()
1440
+
1441
+ run.dispatcher.deliver.assert_called_once()
1442
+ assert _gate_comment_calls(run) == []
1443
+
1444
+
1445
+ def test_reviewer_waits_for_a_newer_rerun_that_superseded_a_success(
1446
+ tmp_path, monkeypatch
1447
+ ):
1448
+ """A later queued re-run of the same check makes the head wait, not pass."""
1449
+ _module, run = _reviewer(tmp_path, monkeypatch)
1450
+ pr = _labeled_pr()
1451
+ run.gh_pages = _gate_pages(pr, [])
1452
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1453
+ run.check_runs = lambda sha: [
1454
+ _run("ci", "completed", "success", started_at="2026-09-22T13:00:00Z", run_id=1),
1455
+ _run("ci", "queued", None, started_at="2026-09-22T13:10:00Z", run_id=2),
1456
+ ]
1457
+
1458
+ run.run()
1459
+
1460
+ run.dispatcher.deliver.assert_not_called()
1461
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1462
+ assert len(posted) == 1
1463
+ body = posted[0].args[2]["body"]
1464
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
1465
+ assert "`ci`" in body
1466
+
1467
+
1468
+ def test_reviewer_treats_same_name_from_different_apps_as_distinct_checks(
1469
+ tmp_path, monkeypatch
1470
+ ):
1471
+ """Grouping by name and app identity keeps two apps' checks independent."""
1472
+ _module, run = _reviewer(tmp_path, monkeypatch)
1473
+ pr = _labeled_pr()
1474
+ run.gh_pages = _gate_pages(pr, [])
1475
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1476
+ run.check_runs = lambda sha: [
1477
+ _run(
1478
+ "ci",
1479
+ "completed",
1480
+ "success",
1481
+ app="github-actions",
1482
+ started_at="2026-09-22T13:10:00Z",
1483
+ run_id=2,
1484
+ ),
1485
+ _run(
1486
+ "ci",
1487
+ "completed",
1488
+ "failure",
1489
+ app="custom-ci",
1490
+ started_at="2026-09-22T13:00:00Z",
1491
+ run_id=1,
1492
+ ),
1493
+ ]
1494
+
1495
+ run.run()
1496
+
1497
+ run.dispatcher.deliver.assert_not_called()
1498
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1499
+ assert len(posted) == 1
1500
+ assert "<!-- openhands-review-gate:blocked:head-2 -->" in posted[0].args[2]["body"]
1501
+
1502
+
1503
+ def test_reviewer_waits_for_a_newer_queued_run_without_a_start_time(
1504
+ tmp_path, monkeypatch
1505
+ ):
1506
+ """A queued re-run with `started_at: null` still supersedes an earlier run.
1507
+
1508
+ GitHub allows a queued or requested check run before it has a start
1509
+ timestamp, so ordering must not treat the absent start time as earlier than
1510
+ a completed run's timestamp and let the stale success launch the review.
1511
+ """
1512
+ _module, run = _reviewer(tmp_path, monkeypatch)
1513
+ pr = _labeled_pr()
1514
+ run.gh_pages = _gate_pages(pr, [])
1515
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1516
+ run.check_runs = lambda sha: [
1517
+ _run(
1518
+ "ci",
1519
+ "completed",
1520
+ "success",
1521
+ started_at="2026-09-22T13:00:00Z",
1522
+ run_id=1,
1523
+ ),
1524
+ _run("ci", "queued", None, started_at=None, run_id=2),
1525
+ ]
1526
+
1527
+ run.run()
1528
+
1529
+ run.dispatcher.deliver.assert_not_called()
1530
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1531
+ assert len(posted) == 1
1532
+ body = posted[0].args[2]["body"]
1533
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in body
1534
+ assert "`ci`" in body
1535
+
1536
+
1537
+ # Scheduled retry: a five-minute scan resumes an outstanding review request.
1538
+ # --------------------------------------------------------------------------- #
1539
+
1540
+
1541
+ def _requested_pr(number=2, sha="head-2", draft=False):
1542
+ return {
1543
+ "number": number,
1544
+ "head": {"sha": sha},
1545
+ "labels": [],
1546
+ "draft": draft,
1547
+ "requested_reviewers": [{"login": "all-hands-bot"}],
1548
+ }
1549
+
1550
+
1551
+ def _review_request_event(request_id=42):
1552
+ return {
1553
+ "id": request_id,
1554
+ "event": "review_requested",
1555
+ "created_at": "2026-01-01T00:00:00Z",
1556
+ "requested_reviewer": {"login": "all-hands-bot"},
1557
+ }
1558
+
1559
+
1560
+ def _request_pages(pr, request, comments):
1561
+ return lambda path: (
1562
+ [pr]
1563
+ if path.startswith("/pulls?")
1564
+ else ([request] if path.endswith("/events") else comments)
1565
+ )
1566
+
1567
+
1568
+ def test_reviewer_scheduled_scan_reviews_an_outstanding_request(tmp_path, monkeypatch):
1569
+ """A request made before CI finished is reviewed once the head is green."""
1570
+ _module, run = _reviewer(tmp_path, monkeypatch)
1571
+ pr = _requested_pr()
1572
+ state = {"runs": _checks(("slow-e2e", "queued", None), sha="head-2")}
1573
+ run.check_runs = lambda sha: state["runs"]
1574
+ run.gh_pages = _request_pages(pr, _review_request_event(), [])
1575
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}, pr])
1576
+ run.dispatcher.deliver.return_value = {
1577
+ "disposition": "created",
1578
+ "conversation_id": "conversation",
1579
+ }
1580
+
1581
+ run.run()
1582
+ assert run.dispatcher.deliver.call_count == 0
1583
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1584
+ assert len(posted) == 1
1585
+ waiting = posted[0].args[2]["body"]
1586
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in waiting
1587
+ assert "The scheduled scan retries" in waiting
1588
+
1589
+ run.gh_pages = _request_pages(pr, _review_request_event(), [])
1590
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1591
+ state["runs"] = _checks(("slow-e2e", "completed", "success"), sha="head-2")
1592
+ run.run()
1593
+
1594
+ run.dispatcher.deliver.assert_called_once()
1595
+ call = run.dispatcher.deliver.call_args.kwargs
1596
+ assert call["delivery"] == "42:head-2"
1597
+ assert "latest review request for `all-hands-bot` event 42" in call["prompt"]
1598
+
1599
+
1600
+ def test_reviewer_repeated_scans_do_not_duplicate_a_requested_review(
1601
+ tmp_path, monkeypatch
1602
+ ):
1603
+ """Two scans over one outstanding request produce one stable delivery key."""
1604
+ _module, run = _reviewer(tmp_path, monkeypatch)
1605
+ pr = _requested_pr()
1606
+ run.check_runs = lambda sha: _checks(("ci", "completed", "success"), sha=sha)
1607
+ run.dispatcher.deliver.return_value = {
1608
+ "disposition": "created",
1609
+ "conversation_id": "conversation",
1610
+ }
1611
+
1612
+ for _ in range(2):
1613
+ run.gh_pages = _request_pages(pr, _review_request_event(), [])
1614
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1615
+ run.run()
1616
+
1617
+ assert run.dispatcher.deliver.call_count == 2
1618
+ deliveries = {
1619
+ call.kwargs["delivery"] for call in run.dispatcher.deliver.call_args_list
1620
+ }
1621
+ assert deliveries == {"42:head-2"}
1622
+
1623
+
1624
+ def test_reviewer_scheduled_scan_ignores_a_draft_with_a_request(
1625
+ tmp_path, monkeypatch
1626
+ ):
1627
+ _module, run = _reviewer(tmp_path, monkeypatch)
1628
+ pr = _requested_pr(draft=True)
1629
+ run.gh_pages = lambda path: [pr]
1630
+ run.gh = Mock(return_value={"id": 99})
1631
+
1632
+ run.run()
1633
+
1634
+ run.dispatcher.deliver.assert_not_called()
1635
+
1636
+
1637
+ def test_reviewer_scheduled_scan_reviews_an_unrequested_green_pr(
1638
+ tmp_path, monkeypatch
1639
+ ):
1640
+ """A green PR with no request and no label is still reviewed on a scan."""
1641
+ _module, run = _reviewer(tmp_path, monkeypatch)
1642
+ pr = {"number": 3, "head": {"sha": "head-3"}, "labels": [], "draft": False}
1643
+ run.gh_pages = lambda path: (
1644
+ [] if path.endswith("/reviews") else [pr]
1645
+ )
1646
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1647
+ run.check_runs = lambda sha: _checks(("ci", "completed", "success"), sha=sha)
1648
+ run.dispatcher.deliver.return_value = {
1649
+ "disposition": "created",
1650
+ "conversation_id": "conversation",
1651
+ }
1652
+
1653
+ run.run()
1654
+
1655
+ run.dispatcher.deliver.assert_called_once()
1656
+ call = run.dispatcher.deliver.call_args.kwargs
1657
+ assert call["subject"] == "99:pr:3"
1658
+ assert call["delivery"] == "scan:owner/repo:3:head-3"
1659
+ assert "scheduled scan of open, non-draft pull requests" in call["prompt"]
1660
+
1661
+
1662
+ def test_reviewer_scheduled_scan_skips_a_pr_with_a_current_head_review(
1663
+ tmp_path, monkeypatch
1664
+ ):
1665
+ """An already-reviewed head is not re-reviewed by the unrequested scan."""
1666
+ _module, run = _reviewer(tmp_path, monkeypatch)
1667
+ pr = {"number": 3, "head": {"sha": "head-3"}, "labels": [], "draft": False}
1668
+ run.gh_pages = lambda path: (
1669
+ _reviews(sha="head-3") if path.endswith("/reviews") else [pr]
1670
+ )
1671
+ # The repository-id lookup, the full PR read, and the head re-read that the
1672
+ # completion path performs to confirm the reviewed head has not moved.
1673
+ run.gh = Mock(side_effect=[{"id": 99}, pr, pr])
1674
+ run.check_runs = lambda sha: _checks(("ci", "completed", "success"), sha=sha)
1675
+
1676
+ run.run()
1677
+
1678
+ run.dispatcher.deliver.assert_not_called()
1679
+
1680
+
1681
+ def test_reviewer_scheduled_scan_keeps_the_label_path_unchanged(
1682
+ tmp_path, monkeypatch
1683
+ ):
1684
+ """A labeled PR is still driven by its label event, not by a request."""
1685
+ _module, run = _reviewer(tmp_path, monkeypatch)
1686
+ pr = _labeled_pr()
1687
+ run.gh_pages = _gate_pages(pr, [])
1688
+ run.gh = Mock(side_effect=[{"id": 99}, pr])
1689
+ run.check_runs = lambda sha: _checks(("ci", "completed", "success"), sha=sha)
1690
+ run.dispatcher.deliver.return_value = {
1691
+ "disposition": "created",
1692
+ "conversation_id": "conversation",
1693
+ }
1694
+
1695
+ run.run()
1696
+
1697
+ run.dispatcher.deliver.assert_called_once()
1698
+ assert run.dispatcher.deliver.call_args.kwargs["delivery"] == "7:head-2"
1699
+
1700
+
1701
+ def test_reviewer_event_body_names_the_request_retry_not_a_scan(
1702
+ tmp_path, monkeypatch
1703
+ ):
1704
+ """The event-only body names the retry it actually honors.
1705
+
1706
+ Explicit `all-hands-bot` requests bypass the CI gate, so this body is not
1707
+ reached through `run()`; assert the wording contract directly.
1708
+ """
1709
+ _module, run = _reviewer(tmp_path, monkeypatch)
1710
+ body = run._gate_body("head-2", "waiting", ["slow-e2e"], scheduled=False)
1711
+
1712
+ assert "remove the outstanding `all-hands-bot` request and request " in body
1713
+ assert "GitHub will not accept a second request" in body
1714
+ assert "scheduled" not in body.lower()
1715
+
1716
+
1717
+ def test_reviewer_event_blocked_body_names_the_request_retry_not_a_scan(
1718
+ tmp_path, monkeypatch
1719
+ ):
1720
+ _module, run = _reviewer(tmp_path, monkeypatch)
1721
+ body = run._gate_body("head-2", "blocked", ["ci"], scheduled=False)
1722
+
1723
+ assert "remove the outstanding `all-hands-bot` request and request " in body
1724
+ assert "GitHub will not accept a second request" in body
1725
+ assert "scheduled" not in body.lower()
1726
+
1727
+
1728
+ def test_reviewer_rewords_the_managed_comment_when_an_event_deployment_becomes_scheduled(
1729
+ tmp_path, monkeypatch
1730
+ ):
1731
+ """An event-only gate comment is reworded once a cron scan exists.
1732
+
1733
+ A PR first gated by the event-only deployment keeps a "remove and re-request"
1734
+ comment on the same head. When the automation is switched to a scheduled scan
1735
+ the comment must name the retry that is now actually deployed, in place,
1736
+ without creating a second comment.
1737
+ """
1738
+ _module, run = _reviewer(tmp_path, monkeypatch)
1739
+ pr = _requested_pr()
1740
+ request = _review_request_event()
1741
+ run.check_runs = lambda sha: _checks(("slow-e2e", "in_progress", None), sha=sha)
1742
+
1743
+ # The comment an event-only deployment would have left on this head.
1744
+ event_body = run._gate_body("head-2", "waiting", ["slow-e2e"], scheduled=False)
1745
+ assert "remove the outstanding `all-hands-bot` request" in event_body
1746
+ managed = _comment(event_body)
1747
+
1748
+ run.gh_pages = _request_pages(pr, request, [managed])
1749
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {}])
1750
+ run.run()
1751
+
1752
+ assert [call for call in _gate_comment_calls(run) if call.args[0] == "POST"] == []
1753
+ patched = [call for call in run.gh.call_args_list if call.args[0] == "PATCH"]
1754
+ assert len(patched) == 1
1755
+ assert patched[0].args[1] == "/issues/comments/900"
1756
+ scheduled_body = patched[0].args[2]["body"]
1757
+ assert "<!-- openhands-review-gate:waiting:head-2 -->" in scheduled_body
1758
+ assert "The scheduled scan retries" in scheduled_body
1759
+ assert "remove the outstanding" not in scheduled_body
1760
+
1761
+
1762
+ def test_reviewer_scheduled_scan_blocked_comment_names_the_scan(
1763
+ tmp_path, monkeypatch
1764
+ ):
1765
+ _module, run = _reviewer(tmp_path, monkeypatch)
1766
+ pr = _requested_pr()
1767
+ run.gh_pages = _request_pages(pr, _review_request_event(), [])
1768
+ run.gh = Mock(side_effect=[{"id": 99}, pr, {"id": 1234}])
1769
+ run.check_runs = lambda sha: _checks(("ci", "completed", "failure"), sha=sha)
1770
+
1771
+ run.run()
1772
+
1773
+ posted = [call for call in _gate_comment_calls(run) if call.args[0] == "POST"]
1774
+ body = posted[0].args[2]["body"]
1775
+ assert "The scheduled scan then starts the review" in body
1776
+
1777
+
1778
+
1779
+ # --------------------------------------------------------------------------- #
1780
+ # Bounded intake: one scheduled scan starts a small, configurable number of new
1781
+ # review conversations across every configured repository, oldest request first.
1782
+ # --------------------------------------------------------------------------- #
1783
+
1784
+
1785
+ class _DedupeDispatcher:
1786
+ """An in-memory stand-in for the shipped dispatcher's dedupe contract.
1787
+
1788
+ The real AgentConversationDispatcher needs an agent server and SDK, so this
1789
+ reproduces only the contract the worker relies on: a delivery keyed by
1790
+ (subject, delivery) starts one conversation and reports `created` once, then
1791
+ reports `deduplicated` for the same key. That is what lets a repeat scan
1792
+ reuse a conversation without spending another intake slot.
1793
+ """
1794
+
1795
+ def __init__(self):
1796
+ self.seen = {}
1797
+ self.calls = []
1798
+ self.prompts = []
1799
+
1800
+ def deliver(self, *, subject, delivery, prompt):
1801
+ self.calls.append((subject, delivery))
1802
+ self.prompts.append(prompt)
1803
+ if self.seen.get(subject) == delivery:
1804
+ return {"disposition": "deduplicated", "conversation_id": subject}
1805
+ self.seen[subject] = delivery
1806
+ return {"disposition": "created", "conversation_id": subject}
1807
+
1808
+
1809
+ def _scan_reviewers(tmp_path, monkeypatch, repositories, *, max_new=None):
1810
+ """One module and one reviewer per repository, as the shipped scan builds them."""
1811
+ module = worker("github-pr-reviewer", tmp_path, monkeypatch)
1812
+ monkeypatch.delenv("AUTOMATION_EVENT_PAYLOAD", raising=False)
1813
+ monkeypatch.setattr(
1814
+ module.workflow,
1815
+ "_latest_trigger_label_event",
1816
+ lambda *args: {"id": 7, "created_at": "2026-01-01T00:00:00Z"},
1817
+ )
1818
+ reviewers = []
1819
+ for repository in repositories:
1820
+ run = object.__new__(module.PullRequestReviewer)
1821
+ run.config = {"trigger_label": "openhands-review"}
1822
+ if max_new is not None:
1823
+ run.config["max_new_per_run"] = max_new
1824
+ run.repository = repository
1825
+ run.token = "token"
1826
+ run.token_name = "FACTORY_GITHUB_REVIEWER_TOKEN"
1827
+ run.github_login = "all-hands-bot"
1828
+ run.dispatcher = _DedupeDispatcher()
1829
+ run.check_runs = lambda sha: []
1830
+ run.workflow_runs = lambda sha: []
1831
+ reviewers.append(run)
1832
+ return module, reviewers
1833
+
1834
+
1835
+ def _run_scan(module, reviewers):
1836
+ """Drive the shipped scheduled-scan control flow over the given reviewers.
1837
+
1838
+ `module.run_scan` is the shipped entrypoint function, so the shared-intake
1839
+ and drain wiring under test is the one that runs in production. Only the
1840
+ repository objects it drives are supplied here: the GitHub transport is
1841
+ already stubbed on each reviewer, so `run_repositories` is replaced by a
1842
+ loop over them in the order a scan would visit the configured repositories.
1843
+ """
1844
+ dispatcher = reviewers[0].dispatcher if reviewers else None
1845
+ by_repository = {run.repository: run for run in reviewers}
1846
+
1847
+ def fake_run_repositories(automation_type, conversation=None, dispatcher=None):
1848
+ for source in by_repository.values():
1849
+ run = object.__new__(automation_type)
1850
+ run.__dict__.update(source.__dict__)
1851
+ run.run()
1852
+
1853
+ original = module.run_repositories
1854
+ module.run_repositories = fake_run_repositories
1855
+ try:
1856
+ module.run_scan(dispatcher)
1857
+ finally:
1858
+ module.run_repositories = original
1859
+ module.PullRequestReviewer.scan_intake = None
1860
+
1861
+
1862
+ def _eligible_pr(number, sha, request_id, created_at, *, checks=()):
1863
+ """An open non-draft PR holding an outstanding all-hands-bot request."""
1864
+ return {
1865
+ "number": number,
1866
+ "head": {"sha": sha},
1867
+ "labels": [],
1868
+ "draft": False,
1869
+ "requested_reviewers": [{"login": "all-hands-bot"}],
1870
+ "_request": {
1871
+ "id": request_id,
1872
+ "event": "review_requested",
1873
+ "created_at": created_at,
1874
+ "requested_reviewer": {"login": "all-hands-bot"},
1875
+ },
1876
+ "_checks": list(checks),
1877
+ }
1878
+
1879
+
1880
+ def _unrequested_pr(
1881
+ number,
1882
+ sha,
1883
+ *,
1884
+ created_at="2026-01-01T00:00:00Z",
1885
+ checks=(),
1886
+ workflows=(),
1887
+ reviews=(),
1888
+ author="someone",
1889
+ ):
1890
+ """An open non-draft PR nobody requested, as the unrequested scan sees it."""
1891
+ return {
1892
+ "number": number,
1893
+ "head": {"sha": sha},
1894
+ "labels": [],
1895
+ "draft": False,
1896
+ "created_at": created_at,
1897
+ "user": {"login": author},
1898
+ "requested_reviewers": [],
1899
+ "_checks": list(checks),
1900
+ "_workflows": list(workflows),
1901
+ "_reviews": list(reviews),
1902
+ }
1903
+
1904
+
1905
+ def _wire_scan(run, prs, repository_id):
1906
+ """Point one reviewer at its PRs, requests, reviews, and runs."""
1907
+ by_number = {pr["number"]: pr for pr in prs}
1908
+
1909
+ def gh(method, path, body=None):
1910
+ if path == "":
1911
+ return {"id": repository_id}
1912
+ if method == "GET" and path.startswith("/pulls/"):
1913
+ return by_number[int(path.split("/")[2])]
1914
+ if method == "POST" and path.endswith("/comments"):
1915
+ return {"id": 1234}
1916
+ return {}
1917
+
1918
+ def gh_pages(path):
1919
+ if path.startswith("/pulls?"):
1920
+ return list(prs)
1921
+ if path.endswith("/reviews"):
1922
+ return list(by_number[int(path.split("/")[2])].get("_reviews", []))
1923
+ if path.endswith("/events"):
1924
+ request = by_number[int(path.split("/")[2])].get("_request")
1925
+ return [request] if request else []
1926
+ return []
1927
+
1928
+ def check_runs(sha):
1929
+ for pr in by_number.values():
1930
+ if pr["head"]["sha"] == sha:
1931
+ return [
1932
+ {
1933
+ "name": name,
1934
+ "status": status,
1935
+ "conclusion": conclusion,
1936
+ "head_sha": sha,
1937
+ }
1938
+ for name, status, conclusion in pr.get("_checks", [])
1939
+ ]
1940
+ return []
1941
+
1942
+ def workflow_runs(sha):
1943
+ for pr in by_number.values():
1944
+ if pr["head"]["sha"] == sha:
1945
+ return [
1946
+ {
1947
+ "name": name,
1948
+ "status": status,
1949
+ "conclusion": conclusion,
1950
+ "head_sha": sha,
1951
+ "id": run_id,
1952
+ "check_suite_id": suite_id,
1953
+ "workflow_id": workflow_id,
1954
+ "run_started_at": "",
1955
+ "created_at": "",
1956
+ }
1957
+ for name, status, conclusion, run_id, suite_id, workflow_id in pr.get(
1958
+ "_workflows", []
1959
+ )
1960
+ ]
1961
+ return []
1962
+
1963
+ run.gh = gh
1964
+ run.gh_pages = gh_pages
1965
+ run.check_runs = check_runs
1966
+ run.workflow_runs = workflow_runs
1967
+
1968
+
1969
+ def test_one_scan_starts_at_most_the_maximum_and_a_later_scan_reaches_the_rest(
1970
+ tmp_path, monkeypatch
1971
+ ):
1972
+ """Three eligible PRs across two repositories, default maximum of two."""
1973
+ module, (one, two) = _scan_reviewers(
1974
+ tmp_path, monkeypatch, ["owner/one", "owner/two"]
1975
+ )
1976
+ # Oldest request first: #5 (Jan 1), then #2 (Jan 2), then #9 (Jan 3).
1977
+ _wire_scan(
1978
+ one,
1979
+ [
1980
+ _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"),
1981
+ _eligible_pr(9, "head-9", 900, "2026-01-03T00:00:00Z"),
1982
+ ],
1983
+ 101,
1984
+ )
1985
+ _wire_scan(two, [_eligible_pr(2, "head-2", 200, "2026-01-02T00:00:00Z")], 102)
1986
+
1987
+ _run_scan(module, [one, two])
1988
+
1989
+ started = set(one.dispatcher.calls) | set(two.dispatcher.calls)
1990
+ assert started == {("101:pr:5", "500:head-5"), ("102:pr:2", "200:head-2")}
1991
+ assert one.dispatcher.seen == {"101:pr:5": "500:head-5"}
1992
+ assert two.dispatcher.seen == {"102:pr:2": "200:head-2"}
1993
+
1994
+ # A later scan sees the first two as already-running and reaches the rest.
1995
+ _run_scan(module, [one, two])
1996
+
1997
+ assert one.dispatcher.seen == {
1998
+ "101:pr:5": "500:head-5",
1999
+ "101:pr:9": "900:head-9",
2000
+ }
2001
+ assert two.dispatcher.seen == {"102:pr:2": "200:head-2"}
2002
+
2003
+
2004
+ def test_a_pending_check_candidate_consumes_no_slot_and_does_not_block_the_queue(
2005
+ tmp_path, monkeypatch
2006
+ ):
2007
+ """The oldest candidate is waiting on CI, so the next two drain instead."""
2008
+ module, (one, two) = _scan_reviewers(
2009
+ tmp_path, monkeypatch, ["owner/one", "owner/two"]
2010
+ )
2011
+ _wire_scan(
2012
+ one,
2013
+ [
2014
+ # Oldest request, but its exact head still has a queued check.
2015
+ _eligible_pr(
2016
+ 5,
2017
+ "head-5",
2018
+ 500,
2019
+ "2026-01-01T00:00:00Z",
2020
+ checks=[("slow-e2e", "queued", None)],
2021
+ ),
2022
+ _eligible_pr(9, "head-9", 900, "2026-01-03T00:00:00Z"),
2023
+ ],
2024
+ 101,
2025
+ )
2026
+ _wire_scan(two, [_eligible_pr(2, "head-2", 200, "2026-01-02T00:00:00Z")], 102)
2027
+
2028
+ _run_scan(module, [one, two])
2029
+
2030
+ assert one.dispatcher.seen == {"101:pr:9": "900:head-9"}
2031
+ assert two.dispatcher.seen == {"102:pr:2": "200:head-2"}
2032
+
2033
+
2034
+ def test_non_dispatching_work_still_runs_after_the_maximum_is_reached(
2035
+ tmp_path, monkeypatch
2036
+ ):
2037
+ """A blocked candidate past the maximum still gets its gate explanation."""
2038
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2039
+ _wire_scan(
2040
+ one,
2041
+ [
2042
+ _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"),
2043
+ _eligible_pr(6, "head-6", 600, "2026-01-02T00:00:00Z"),
2044
+ _eligible_pr(
2045
+ 7,
2046
+ "head-7",
2047
+ 700,
2048
+ "2026-01-04T00:00:00Z",
2049
+ checks=[("ci", "completed", "failure")],
2050
+ ),
2051
+ ],
2052
+ 101,
2053
+ )
2054
+ one.gh = Mock(side_effect=one.gh)
2055
+
2056
+ _run_scan(module, [one])
2057
+
2058
+ # Only the two oldest start; the blocked third consumes no slot.
2059
+ assert set(one.dispatcher.seen) == {"101:pr:5", "101:pr:6"}
2060
+ posted = [call for call in one.gh.call_args_list if call.args[0] == "POST"]
2061
+ assert len(posted) == 1
2062
+ assert "<!-- openhands-review-gate:blocked:head-7 -->" in posted[0].args[2]["body"]
2063
+
2064
+
2065
+ def test_a_failing_dispatch_is_reported_without_consuming_a_slot_or_aborting(
2066
+ tmp_path, monkeypatch
2067
+ ):
2068
+ """The oldest dispatch raises; the next candidates still get their turn."""
2069
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2070
+ _wire_scan(
2071
+ one,
2072
+ [
2073
+ _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"),
2074
+ _eligible_pr(6, "head-6", 600, "2026-01-02T00:00:00Z"),
2075
+ _eligible_pr(7, "head-7", 700, "2026-01-03T00:00:00Z"),
2076
+ ],
2077
+ 101,
2078
+ )
2079
+
2080
+ def deliver(*, subject, delivery, prompt):
2081
+ if subject == "101:pr:5":
2082
+ raise RuntimeError("agent server unavailable")
2083
+ one.dispatcher.seen[subject] = delivery
2084
+ return {"disposition": "created", "conversation_id": subject}
2085
+
2086
+ one.dispatcher.deliver = deliver
2087
+
2088
+ with pytest.raises(RuntimeError, match="#5"):
2089
+ _run_scan(module, [one])
2090
+
2091
+ assert set(one.dispatcher.seen) == {"101:pr:6", "101:pr:7"}
2092
+
2093
+
2094
+ def test_the_maximum_is_configurable_through_the_rendered_config(tmp_path, monkeypatch):
2095
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=1)
2096
+ _wire_scan(
2097
+ one,
2098
+ [
2099
+ _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"),
2100
+ _eligible_pr(6, "head-6", 600, "2026-01-02T00:00:00Z"),
2101
+ ],
2102
+ 101,
2103
+ )
2104
+
2105
+ _run_scan(module, [one])
2106
+
2107
+ assert set(one.dispatcher.seen) == {"101:pr:5"}
2108
+
2109
+
2110
+ def test_the_rendered_config_defaults_the_maximum_to_two(tmp_path, monkeypatch):
2111
+ """An unset max_new_per_run still bounds a scheduled scan to two."""
2112
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2113
+ _wire_scan(
2114
+ one,
2115
+ [
2116
+ _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"),
2117
+ _eligible_pr(6, "head-6", 600, "2026-01-02T00:00:00Z"),
2118
+ _eligible_pr(7, "head-7", 700, "2026-01-03T00:00:00Z"),
2119
+ ],
2120
+ 101,
2121
+ )
2122
+
2123
+ _run_scan(module, [one])
2124
+
2125
+ assert module.workflow.MAX_NEW_PER_RUN == 2
2126
+ assert set(one.dispatcher.seen) == {"101:pr:5", "101:pr:6"}
2127
+
2128
+
2129
+ def test_the_explicit_request_event_path_is_not_bounded(tmp_path, monkeypatch):
2130
+ """Only the scheduled scan's new conversations are bounded."""
2131
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2132
+ monkeypatch.setenv(
2133
+ "AUTOMATION_EVENT_PAYLOAD",
2134
+ json.dumps(
2135
+ {
2136
+ "automation_id": "automation",
2137
+ "event": {
2138
+ "payload": {
2139
+ "action": "review_requested",
2140
+ "repository": {"full_name": "owner/one"},
2141
+ "pull_request": {"number": 5},
2142
+ "requested_reviewer": {"login": "all-hands-bot"},
2143
+ }
2144
+ },
2145
+ }
2146
+ ),
2147
+ )
2148
+ pr = {"number": 5, "head": {"sha": "head-5"}, "labels": []}
2149
+ one.gh = Mock(side_effect=[{"id": 101}, pr])
2150
+ one.gh_pages = lambda path: (
2151
+ [
2152
+ {
2153
+ "id": 500,
2154
+ "event": "review_requested",
2155
+ "created_at": "2026-01-01T00:00:00Z",
2156
+ "requested_reviewer": {"login": "all-hands-bot"},
2157
+ }
2158
+ ]
2159
+ if path.endswith("/events")
2160
+ else []
2161
+ )
2162
+ one.workflow_runs = lambda sha: []
2163
+
2164
+ one.run()
2165
+
2166
+ assert one.dispatcher.seen == {"101:pr:5": "500:head-5"}
2167
+
2168
+
2169
+ def test_the_rendered_config_reads_a_positive_max_new_per_run(tmp_path, monkeypatch):
2170
+ """The rendered config overrides the default, following the key the other
2171
+ automations already use."""
2172
+ module = worker("github-pr-reviewer", tmp_path, monkeypatch)
2173
+ (tmp_path / "github-pr-reviewer" / "config.json").write_text(
2174
+ json.dumps(
2175
+ {
2176
+ "repos": ["owner/one"],
2177
+ "max_new_per_run": 3,
2178
+ "github_token_secret": "GITHUB_TOKEN",
2179
+ }
2180
+ )
2181
+ )
2182
+
2183
+ config = module.workflow.load_config(tmp_path / "github-pr-reviewer")
2184
+
2185
+ assert config["max_new_per_run"] == 3
2186
+
2187
+
2188
+ @pytest.mark.parametrize("bad", [0, -1, True, "2"])
2189
+ def test_the_rendered_config_rejects_a_misbehaving_max_new_per_run(
2190
+ tmp_path, monkeypatch, bad
2191
+ ):
2192
+ """A non-positive or non-integer bound is a hard error, not a coercion: the
2193
+ alternative is a scan that either never starts or starts `True` conversations."""
2194
+ module = worker("github-pr-reviewer", tmp_path, monkeypatch)
2195
+ (tmp_path / "github-pr-reviewer" / "config.json").write_text(
2196
+ json.dumps({"repos": ["owner/one"], "max_new_per_run": bad})
2197
+ )
2198
+
2199
+ with pytest.raises(SystemExit):
2200
+ module.workflow.load_config(tmp_path / "github-pr-reviewer")
2201
+
2202
+ # --------------------------------------------------------------------------- #
2203
+ # Unrequested scheduled scan: an open, non-draft PR with a green current head and
2204
+ # no all-hands-bot review is reviewed without any request or trigger label.
2205
+ # --------------------------------------------------------------------------- #
2206
+
2207
+
2208
+ def test_unrequested_scan_reviews_a_green_pr_with_no_request_or_label(
2209
+ tmp_path, monkeypatch
2210
+ ):
2211
+ """The core new behavior: a green PR nobody requested starts a review."""
2212
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2213
+ _wire_scan(one, [_unrequested_pr(5, "head-5")], 101)
2214
+
2215
+ _run_scan(module, [one])
2216
+
2217
+ assert one.dispatcher.seen == {"101:pr:5": "scan:owner/one:5:head-5"}
2218
+
2219
+
2220
+ def test_unrequested_scan_blocks_a_failed_zero_job_workflow(tmp_path, monkeypatch):
2221
+ """A green check-run rollup with a failed zero-job workflow still blocks.
2222
+
2223
+ The workflow failed before creating any check run, so the commit's rollup is
2224
+ green while the Actions run is `completed`/`failure`. The gate must read the
2225
+ workflow run and start nothing - and, because nobody requested this PR, it
2226
+ must do so without posting a managed gate comment.
2227
+ """
2228
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2229
+ _wire_scan(
2230
+ one,
2231
+ [
2232
+ _unrequested_pr(
2233
+ 5,
2234
+ "head-5",
2235
+ checks=(("pr-title", "completed", "success"),),
2236
+ workflows=(("Tests", "completed", "failure", 3, 3003, 236324519),),
2237
+ )
2238
+ ],
2239
+ 101,
2240
+ )
2241
+ one.gh = Mock(side_effect=one.gh)
2242
+
2243
+ _run_scan(module, [one])
2244
+
2245
+ assert one.dispatcher.seen == {}
2246
+ assert [call for call in one.gh.call_args_list if call.args[0] == "POST"] == []
2247
+
2248
+
2249
+ def test_unrequested_scan_waits_on_a_pending_workflow(tmp_path, monkeypatch):
2250
+ """A current-head workflow still in progress makes the unrequested head wait.
2251
+
2252
+ The unrequested head starts no conversation and, being unrequested, gets no
2253
+ managed gate comment either.
2254
+ """
2255
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2256
+ _wire_scan(
2257
+ one,
2258
+ [
2259
+ _unrequested_pr(
2260
+ 5,
2261
+ "head-5",
2262
+ workflows=(("Tests", "in_progress", None, 4, 4004, 236324519),),
2263
+ )
2264
+ ],
2265
+ 101,
2266
+ )
2267
+ one.gh = Mock(side_effect=one.gh)
2268
+
2269
+ _run_scan(module, [one])
2270
+
2271
+ assert one.dispatcher.seen == {}
2272
+ assert [call for call in one.gh.call_args_list if call.args[0] == "POST"] == []
2273
+
2274
+
2275
+ def test_unrequested_scan_does_not_duplicate_across_repeated_scans(
2276
+ tmp_path, monkeypatch
2277
+ ):
2278
+ """The stable repository/PR/head key makes a repeat scan a no-op."""
2279
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2280
+ _wire_scan(one, [_unrequested_pr(5, "head-5")], 101)
2281
+
2282
+ _run_scan(module, [one])
2283
+ _run_scan(module, [one])
2284
+
2285
+ assert one.dispatcher.calls == [
2286
+ ("101:pr:5", "scan:owner/one:5:head-5"),
2287
+ ("101:pr:5", "scan:owner/one:5:head-5"),
2288
+ ]
2289
+ assert one.dispatcher.seen == {"101:pr:5": "scan:owner/one:5:head-5"}
2290
+
2291
+
2292
+ def test_unrequested_scan_reviews_a_changed_head_again_under_a_new_key(
2293
+ tmp_path, monkeypatch
2294
+ ):
2295
+ """A new head is eligible again and reviewed once under its own key."""
2296
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2297
+ _wire_scan(one, [_unrequested_pr(5, "head-5")], 101)
2298
+ _run_scan(module, [one])
2299
+
2300
+ # The PR is pushed to a new head, so the old key no longer matches.
2301
+ _wire_scan(one, [_unrequested_pr(5, "head-6")], 101)
2302
+ _run_scan(module, [one])
2303
+
2304
+ assert one.dispatcher.seen == {"101:pr:5": "scan:owner/one:5:head-6"}
2305
+ assert one.dispatcher.calls[-1] == ("101:pr:5", "scan:owner/one:5:head-6")
2306
+
2307
+
2308
+ def test_unrequested_scan_bound_spans_repositories_with_explicit_priority(
2309
+ tmp_path, monkeypatch
2310
+ ):
2311
+ """The cap is global; an explicit request outranks unrequested candidates.
2312
+
2313
+ Repository `owner/one` holds an unrequested PR older than `owner/two`'s
2314
+ explicit request. The request must win the single slot even though it is
2315
+ younger, because explicit requests are ordered first.
2316
+ """
2317
+ module, (one, two) = _scan_reviewers(
2318
+ tmp_path, monkeypatch, ["owner/one", "owner/two"], max_new=1
2319
+ )
2320
+ _wire_scan(
2321
+ one, [_unrequested_pr(5, "head-5", created_at="2026-01-01T00:00:00Z")], 101
2322
+ )
2323
+ _wire_scan(two, [_eligible_pr(2, "head-2", 200, "2026-01-05T00:00:00Z")], 102)
2324
+
2325
+ _run_scan(module, [one, two])
2326
+
2327
+ assert one.dispatcher.seen == {}
2328
+ assert two.dispatcher.seen == {"102:pr:2": "200:head-2"}
2329
+
2330
+
2331
+ def test_unrequested_scan_reviews_the_oldest_eligible_prs_under_the_cap(
2332
+ tmp_path, monkeypatch
2333
+ ):
2334
+ """With no requests, the cap starts the oldest unrequested PRs first."""
2335
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2336
+ _wire_scan(
2337
+ one,
2338
+ [
2339
+ _unrequested_pr(9, "head-9", created_at="2026-01-03T00:00:00Z"),
2340
+ _unrequested_pr(5, "head-5", created_at="2026-01-01T00:00:00Z"),
2341
+ _unrequested_pr(7, "head-7", created_at="2026-01-02T00:00:00Z"),
2342
+ ],
2343
+ 101,
2344
+ )
2345
+
2346
+ _run_scan(module, [one])
2347
+
2348
+ assert one.dispatcher.seen == {
2349
+ "101:pr:5": "scan:owner/one:5:head-5",
2350
+ "101:pr:7": "scan:owner/one:7:head-7",
2351
+ }
2352
+
2353
+
2354
+ def test_unrequested_scan_reconciles_a_completed_review_and_hands_off(
2355
+ tmp_path, monkeypatch
2356
+ ):
2357
+ """A head that already carries a completed review is not re-dispatched.
2358
+
2359
+ The review is found on the current head, so the scan reconciles it and runs
2360
+ the existing maintainer handoff instead of starting a second conversation.
2361
+ """
2362
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2363
+ _wire_scan(
2364
+ one,
2365
+ [
2366
+ _unrequested_pr(
2367
+ 5,
2368
+ "head-5",
2369
+ reviews=[
2370
+ {
2371
+ "body": "Review body\n\n✅ APPROVED",
2372
+ "commit_id": "head-5",
2373
+ "submitted_at": "2026-01-02T00:00:00Z",
2374
+ "user": {"login": "all-hands-bot"},
2375
+ }
2376
+ ],
2377
+ )
2378
+ ],
2379
+ 101,
2380
+ )
2381
+ one.config["maintainers"] = "neubig"
2382
+ handoff = Mock(return_value="neubig")
2383
+ monkeypatch.setattr(module, "request_maintainer_review", handoff)
2384
+
2385
+ _run_scan(module, [one])
2386
+
2387
+ assert one.dispatcher.seen == {}
2388
+ handoff.assert_called_once()
2389
+
2390
+
2391
+ def test_unrequested_scan_still_gates_a_blocked_pr_past_the_cap(
2392
+ tmp_path, monkeypatch
2393
+ ):
2394
+ """An ineligible unrequested PR past the cap consumes no slot and stays silent.
2395
+
2396
+ Two green unrequested PRs fill the cap, and a third blocked one - older than
2397
+ them - is skipped without consuming a slot, without aborting the scan, and
2398
+ without a managed gate comment, because nobody requested it.
2399
+ """
2400
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2401
+ _wire_scan(
2402
+ one,
2403
+ [
2404
+ _unrequested_pr(
2405
+ 5,
2406
+ "head-5",
2407
+ created_at="2026-01-01T00:00:00Z",
2408
+ checks=(("ci", "completed", "failure"),),
2409
+ ),
2410
+ _unrequested_pr(6, "head-6", created_at="2026-01-02T00:00:00Z"),
2411
+ _unrequested_pr(7, "head-7", created_at="2026-01-03T00:00:00Z"),
2412
+ ],
2413
+ 101,
2414
+ )
2415
+ one.gh = Mock(side_effect=one.gh)
2416
+
2417
+ _run_scan(module, [one])
2418
+
2419
+ assert set(one.dispatcher.seen) == {"101:pr:6", "101:pr:7"}
2420
+ assert [call for call in one.gh.call_args_list if call.args[0] == "POST"] == []
2421
+
2422
+
2423
+ def test_an_explicit_request_still_gets_its_managed_gate_comment(
2424
+ tmp_path, monkeypatch
2425
+ ):
2426
+ """The managed comment answers an explicit request, red or pending.
2427
+
2428
+ Suppressing the unrequested comment must not silence the explanation for the
2429
+ request a caller actually made: an outstanding request whose head is failing
2430
+ still gets its blocked gate comment.
2431
+ """
2432
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2433
+ _wire_scan(
2434
+ one,
2435
+ [
2436
+ _eligible_pr(
2437
+ 5,
2438
+ "head-5",
2439
+ 500,
2440
+ "2026-01-01T00:00:00Z",
2441
+ checks=(("ci", "completed", "failure"),),
2442
+ )
2443
+ ],
2444
+ 101,
2445
+ )
2446
+ one.gh = Mock(side_effect=one.gh)
2447
+
2448
+ _run_scan(module, [one])
2449
+
2450
+ assert one.dispatcher.seen == {}
2451
+ posted = [call for call in one.gh.call_args_list if call.args[0] == "POST"]
2452
+ assert len(posted) == 1
2453
+ assert "<!-- openhands-review-gate:blocked:head-5 -->" in posted[0].args[2]["body"]
2454
+
2455
+
2456
+ def test_unrequested_scan_marks_a_self_authored_pr_for_the_comment_verdict(
2457
+ tmp_path, monkeypatch
2458
+ ):
2459
+ """A bot-authored PR is told to use the non-approval verdict path.
2460
+
2461
+ GitHub ignores a self-review request, so the reviewer must publish the clean
2462
+ review as a `COMMENT` event that keeps the approved verdict instead of being
2463
+ skipped.
2464
+ """
2465
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"])
2466
+ _wire_scan(one, [_unrequested_pr(5, "head-5", author="all-hands-bot")], 101)
2467
+
2468
+ _run_scan(module, [one])
2469
+
2470
+ assert one.dispatcher.seen == {"101:pr:5": "scan:owner/one:5:head-5"}
2471
+ prompt = one.dispatcher.prompts[-1]
2472
+ assert "authored by the configured reviewer account" in prompt
2473
+ assert "keep the approved verdict" in prompt
2474
+
2475
+
2476
+ # --------------------------------------------------------------------------- #
2477
+ # Bounded rotating scan window: each scheduled scan examines a bounded slice of
2478
+ # the unrequested backlog, remembers its position in the Automation KV store, and
2479
+ # resumes there on the next scan. Explicit requests are never subject to it.
2480
+ # --------------------------------------------------------------------------- #
2481
+
2482
+
2483
+ def _kv_store(module, monkeypatch):
2484
+ """Back the scan cursor with an in-memory stand-in for the Automation KV store.
2485
+
2486
+ The real store is an HTTP service; this reproduces the contract the cursor
2487
+ relies on - a per-key `GET` that misses with None and a `PUT` that replaces
2488
+ the value - so the shipped `ScanCursor` persists and reloads through it.
2489
+ """
2490
+ values = {}
2491
+ monkeypatch.setattr(module.workflow, "_kv_available", lambda: True)
2492
+ monkeypatch.setattr(
2493
+ module.workflow, "_kv_get", lambda key: values.get(key)
2494
+ )
2495
+
2496
+ def put(key, value):
2497
+ values[key] = value
2498
+
2499
+ monkeypatch.setattr(module.workflow, "_kv_set", put)
2500
+ return values
2501
+
2502
+
2503
+ def _many_unrequested(count, *, checks=()):
2504
+ """`count` open, non-draft PRs nobody requested, numbered 1..count."""
2505
+ return [
2506
+ _unrequested_pr(
2507
+ number,
2508
+ f"head-{number}",
2509
+ created_at=f"2026-01-{number:02d}T00:00:00Z",
2510
+ checks=checks,
2511
+ )
2512
+ for number in range(1, count + 1)
2513
+ ]
2514
+
2515
+
2516
+ def _examined(run):
2517
+ """The PR numbers a scan actually dispatched, in call order."""
2518
+ return [int(subject.split(":")[-1]) for subject, _ in run.dispatcher.calls]
2519
+
2520
+
2521
+ def test_scan_examines_a_bounded_rotating_window_of_unrequested_prs(
2522
+ tmp_path, monkeypatch
2523
+ ):
2524
+ """Each scan covers the next slice, so the whole backlog is reached in turn."""
2525
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2526
+ monkeypatch.setattr(module, "SCAN_WINDOW", 2)
2527
+ _kv_store(module, monkeypatch)
2528
+ _wire_scan(one, _many_unrequested(5), 101)
2529
+
2530
+ _run_scan(module, [one])
2531
+ assert _examined(one) == [1, 2]
2532
+
2533
+ _run_scan(module, [one])
2534
+ assert _examined(one)[-2:] == [3, 4]
2535
+
2536
+ _run_scan(module, [one])
2537
+ assert _examined(one)[-1:] == [5]
2538
+
2539
+ # Past the end the window wraps, so the backlog keeps rotating.
2540
+ _run_scan(module, [one])
2541
+ assert _examined(one)[-2:] == [1, 2]
2542
+
2543
+
2544
+ def test_the_scan_position_is_persisted_per_repository_in_the_kv_store(
2545
+ tmp_path, monkeypatch
2546
+ ):
2547
+ """The cursor is written under a per-repository key, so a later run resumes.
2548
+
2549
+ A cron run is a fresh process, so the only way the window survives is the
2550
+ Automation KV store; this pins the key and the value the next scan reads.
2551
+ """
2552
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2553
+ monkeypatch.setattr(module, "SCAN_WINDOW", 2)
2554
+ values = _kv_store(module, monkeypatch)
2555
+ _wire_scan(one, _many_unrequested(5), 101)
2556
+
2557
+ _run_scan(module, [one])
2558
+
2559
+ assert values == {"review-scan:owner__one": {"cursor": 2}}
2560
+
2561
+
2562
+ def test_explicit_requests_are_examined_regardless_of_the_rotation_window(
2563
+ tmp_path, monkeypatch
2564
+ ):
2565
+ """An explicit request is never skipped because the window sits elsewhere.
2566
+
2567
+ The cursor points at the last unrequested PR, but the explicit request on
2568
+ PR #1 is still examined and dispatched, because a caller asked for it.
2569
+ """
2570
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2571
+ monkeypatch.setattr(module, "SCAN_WINDOW", 1)
2572
+ values = _kv_store(module, monkeypatch)
2573
+ # Only PR #1 holds a request; the rest are unrequested and the window sits on
2574
+ # the last of them, so the request must still be examined.
2575
+ _wire_scan(
2576
+ one,
2577
+ [_eligible_pr(1, "head-1", 100, "2026-01-01T00:00:00Z")]
2578
+ + _many_unrequested(4)[1:],
2579
+ 101,
2580
+ )
2581
+ values["review-scan:owner__one"] = {"cursor": 2}
2582
+
2583
+ _run_scan(module, [one])
2584
+
2585
+ assert one.dispatcher.seen == {
2586
+ "101:pr:1": "100:head-1",
2587
+ "101:pr:4": "scan:owner/one:4:head-4",
2588
+ }
2589
+
2590
+
2591
+ def test_unrequested_red_and_pending_prs_post_no_gate_comments(
2592
+ tmp_path, monkeypatch
2593
+ ):
2594
+ """A scan over a large unrequested backlog posts no managed gate comments.
2595
+
2596
+ This is the storm the canary exposed: every red or pending head used to get a
2597
+ comment. Unrequested heads now stay silent; only an explicit request gets the
2598
+ managed explanation.
2599
+ """
2600
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2601
+ monkeypatch.setattr(module, "SCAN_WINDOW", 20)
2602
+ _wire_scan(
2603
+ one,
2604
+ [
2605
+ _unrequested_pr(
2606
+ 5, "head-5", checks=(("ci", "completed", "failure"),)
2607
+ ),
2608
+ _unrequested_pr(
2609
+ 6, "head-6", checks=(("slow-e2e", "in_progress", None),)
2610
+ ),
2611
+ _unrequested_pr(
2612
+ 7, "head-7", checks=(("ci", "completed", "success"),)
2613
+ ),
2614
+ ],
2615
+ 101,
2616
+ )
2617
+ one.gh = Mock(side_effect=one.gh)
2618
+
2619
+ _run_scan(module, [one])
2620
+
2621
+ assert [call for call in one.gh.call_args_list if call.args[0] == "POST"] == []
2622
+ # The green unrequested head still reaches the bounded dispatch queue.
2623
+ assert one.dispatcher.seen == {"101:pr:7": "scan:owner/one:7:head-7"}
2624
+
2625
+
2626
+ def test_scan_reads_a_bounded_number_of_pull_requests_per_repository(
2627
+ tmp_path, monkeypatch
2628
+ ):
2629
+ """A scan reads one list page plus at most one head per examined candidate.
2630
+
2631
+ A full scan would issue one full pull read per open PR - the API cost that
2632
+ made the canary time out. With a window of 10 over 40 PRs, only the 10 in the
2633
+ window are read.
2634
+ """
2635
+ module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=5)
2636
+ monkeypatch.setattr(module, "SCAN_WINDOW", 10)
2637
+ _kv_store(module, monkeypatch)
2638
+ _wire_scan(one, _many_unrequested(40), 101)
2639
+
2640
+ list_reads = []
2641
+ original_pages = one.gh_pages
2642
+
2643
+ def counting_pages(path):
2644
+ if path.startswith("/pulls?"):
2645
+ list_reads.append(path)
2646
+ return original_pages(path)
2647
+
2648
+ one.gh_pages = counting_pages
2649
+ one.gh = Mock(side_effect=one.gh)
2650
+
2651
+ _run_scan(module, [one])
2652
+
2653
+ full_reads = [
2654
+ call for call in one.gh.call_args_list
2655
+ if call.args[0] == "GET" and str(call.args[1]).startswith("/pulls/")
2656
+ ]
2657
+ assert len(list_reads) == 1
2658
+ assert len(full_reads) == 10