claude-dev-env 8.33.0 → 8.33.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.33.0",
3
+ "version": "8.33.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Rule
6
6
 
7
- The agent that drives a pull request merges it. A pull request that is green, carries no open review thread, and sits at a merge state of `clean` is merged in the same run that brought it there. Waiting for the owner to type "merge" parks finished work on the person the work was done for.
7
+ The agent that drives a pull request merges it. A pull request that is green, carries no open review thread, and sits at a merge state of `clean` is merged in the same run that brought it there. A merge state of `unstable` counts as `clean` when every check's newest report on the head passes. GitHub also counts the older runs of a check that ran again, so a cancelled run followed by a passing re-run still reads `unstable`. Waiting for the owner to type "merge" parks finished work on the person the work was done for.
8
8
 
9
9
  Three things stay with the owner, and nothing else does:
10
10
 
@@ -24,7 +24,7 @@ One command prints the verdict:
24
24
  python packages/claude-dev-env/scripts/agent_merge_check.py <owner>/<name> <number>
25
25
  ```
26
26
 
27
- It prints `MERGE` and exits 0 when the pull request is ready. It prints `HOLD` with the reason and exits 1 for a draft, for a head behind or conflicting with the base, for a required check that is not passing, for a check still running or red, for a head the merge queue ejected for failed checks, and for an open review thread. It exits 2 when the state could not be read.
27
+ It prints `MERGE` and exits 0 when the pull request is ready. It prints `HOLD` with the reason and exits 1 for a draft, for a head behind or conflicting with the base, for a required check that is not passing, for a check whose newest report on the head is still running, cancelled, or red, for a head the merge queue ejected for failed checks, and for an open review thread. It exits 2 when the state could not be read.
28
28
 
29
29
  Each hold reason names its own repair, and each repair belongs to the agent:
30
30
 
@@ -33,7 +33,7 @@ Each hold reason names its own repair, and each repair belongs to the agent:
33
33
  | Head behind the base | Merge the base branch in and push |
34
34
  | Head conflicts with the base | Merge the base branch in, resolve, push |
35
35
  | A required check is red | Read the failing check, fix it, push |
36
- | A check is red or still running | Fix it, or wait for it, then read the verdict again |
36
+ | A check is red, cancelled, or still running | Fix a red one, re-run a cancelled one, or wait for a running one, then read the verdict again |
37
37
  | A review thread is open | Answer it, push the fix, resolve the thread |
38
38
  | Ejected from the merge queue for failed checks on this head | Read the merge_group run, fix the failure, push, then read the verdict again |
39
39
  | The pull request is a draft | Mark it ready once the checks pass |
@@ -70,6 +70,7 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
70
70
  DRAFT_KEY,
71
71
  ERROR_EXIT_CODE,
72
72
  FAILED_CHECKS_REMOVAL_REASON,
73
+ FIRST_PAGE_NUMBER,
73
74
  FAILING_REQUIRED_CHECKS_HOLD_TEMPLATE,
74
75
  GITHUB_ACCEPT_TYPE,
75
76
  GITHUB_API_ROOT,
@@ -89,6 +90,7 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
89
90
  MERGEABLE_STATE_CLEAN,
90
91
  MERGEABLE_STATE_KEY,
91
92
  MERGEABLE_STATE_UNKNOWN,
93
+ MERGEABLE_STATE_UNSTABLE,
92
94
  MISSING_CHECK_STATE,
93
95
  NAME_VARIABLE,
94
96
  NO_SIGN_IN_MESSAGE,
@@ -96,6 +98,7 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
96
98
  NUMBER_KEY,
97
99
  NUMBER_VARIABLE,
98
100
  OWNER_VARIABLE,
101
+ PAGE_PARAMETER,
99
102
  PAGE_SIZE_VARIABLE,
100
103
  PENDING_CHECK_STATE,
101
104
  PENDING_STATUS_STATE,
@@ -126,6 +129,7 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
126
129
  UNKNOWN_STATE_HOLD_TEMPLATE,
127
130
  UNRESOLVED_THREAD_QUERY,
128
131
  UNRESOLVED_THREADS_HOLD_TEMPLATE,
132
+ UNSTABLE_CHECKS_HOLD_TEMPLATE,
129
133
  UTF8_ENCODING,
130
134
  VARIABLES_KEY,
131
135
  VERDICT_LINE_TEMPLATE,
@@ -192,6 +196,7 @@ def hold_reason(
192
196
  unresolved_thread_count: int,
193
197
  blocked_evidence: BlockedEvidence | None = None,
194
198
  is_ejected_from_merge_queue: bool = False,
199
+ all_unstable_checks: tuple[str, ...] | None = None,
195
200
  ) -> str | None:
196
201
  """Return why this pull request stays open, or None when it may merge.
197
202
 
@@ -203,19 +208,31 @@ def hold_reason(
203
208
  None when nothing was read.
204
209
  is_ejected_from_merge_queue: Whether the merge queue ejected the
205
210
  current head for failed checks.
211
+ all_unstable_checks: Each check whose newest report on the head is
212
+ not passing, read behind an ``unstable`` merge state, or None
213
+ when nothing was read.
206
214
 
207
215
  Returns:
208
216
  The reason text for a draft, for a merge state other than clean, for
209
- a head the merge queue ejected, and for an open review thread. None
210
- when the pull request is ready for the agent that drives it to merge
211
- it.
217
+ a head the merge queue ejected, and for an open review thread. An
218
+ ``unstable`` head whose newest reports all pass reads as clean,
219
+ because GitHub also counts the older runs those reports replaced.
220
+ None when the pull request is ready for the agent that drives it to
221
+ merge it.
212
222
  """
213
223
  if all_pull_request_fields.get(DRAFT_KEY):
214
224
  return DRAFT_HOLD_REASON
215
225
  merge_state = all_pull_request_fields.get(MERGEABLE_STATE_KEY)
216
226
  if merge_state == MERGEABLE_STATE_BLOCKED and blocked_evidence is not None:
217
227
  return blocked_hold_reason(blocked_evidence)
218
- if merge_state != MERGEABLE_STATE_CLEAN:
228
+ if merge_state == MERGEABLE_STATE_UNSTABLE and all_unstable_checks:
229
+ return UNSTABLE_CHECKS_HOLD_TEMPLATE.format(
230
+ checks=REQUIRED_CHECK_SEPARATOR.join(all_unstable_checks)
231
+ )
232
+ is_superseded_unstable = (
233
+ merge_state == MERGEABLE_STATE_UNSTABLE and all_unstable_checks == ()
234
+ )
235
+ if merge_state != MERGEABLE_STATE_CLEAN and not is_superseded_unstable:
219
236
  return ALL_HOLD_REASONS_BY_STATE.get(
220
237
  merge_state,
221
238
  UNKNOWN_STATE_HOLD_TEMPLATE.format(state=merge_state),
@@ -346,6 +363,70 @@ def _newest_matching_run(
346
363
  return max(all_matching_runs, key=_check_run_id)
347
364
 
348
365
 
366
+ def unmet_newest_checks(
367
+ all_check_runs: Sequence[object],
368
+ all_statuses: Sequence[object],
369
+ ) -> tuple[str, ...]:
370
+ """Name each check whose newest report on the head is not passing.
371
+
372
+ ::
373
+
374
+ Review closure run 409 cancelled, run 412 cancelled, run 874 success
375
+ newest ^
376
+ ok: () every newest run passes
377
+ flag: ("Review closure (cancelled)",) the newest run is cancelled
378
+
379
+ Runs of one name from one app collapse to the run with the highest id,
380
+ so the latest re-run or workflow run decides the check. Statuses arrive
381
+ newest first, so the first status of each context decides it.
382
+
383
+ Args:
384
+ all_check_runs: Every check run reported on the head commit.
385
+ all_statuses: The commit statuses on the head, newest first.
386
+
387
+ Returns:
388
+ Each non-passing check as its name and state, such as
389
+ ``Ruff (failure)``, check runs first and then commit statuses.
390
+ """
391
+ return (*_unmet_run_states(all_check_runs), *_unmet_status_states(all_statuses))
392
+
393
+
394
+ def _unmet_run_states(all_check_runs: Sequence[object]) -> list[str]:
395
+ return [
396
+ REQUIRED_CHECK_STATE_TEMPLATE.format(
397
+ context=each_run.get(CHECK_RUN_NAME_KEY), state=state
398
+ )
399
+ for each_run in _newest_run_per_check(all_check_runs)
400
+ if (state := _check_run_state(each_run)) is not None
401
+ ]
402
+
403
+
404
+ def _unmet_status_states(all_statuses: Sequence[object]) -> list[str]:
405
+ all_newest_statuses: dict[object, Mapping[str, object]] = {}
406
+ for each_status in all_statuses:
407
+ if isinstance(each_status, Mapping):
408
+ all_newest_statuses.setdefault(each_status.get(CONTEXT_KEY), each_status)
409
+ return [
410
+ REQUIRED_CHECK_STATE_TEMPLATE.format(context=context, state=state)
411
+ for context, each_status in all_newest_statuses.items()
412
+ if (state := _status_state(each_status)) is not None
413
+ ]
414
+
415
+
416
+ def _newest_run_per_check(
417
+ all_check_runs: Sequence[object],
418
+ ) -> list[Mapping[str, object]]:
419
+ all_newest_runs: dict[tuple[object, object], Mapping[str, object]] = {}
420
+ for each_run in all_check_runs:
421
+ if not isinstance(each_run, Mapping):
422
+ continue
423
+ run_key = (each_run.get(CHECK_RUN_NAME_KEY), _check_run_app_id(each_run))
424
+ newest_run = all_newest_runs.get(run_key)
425
+ if newest_run is None or _check_run_id(each_run) > _check_run_id(newest_run):
426
+ all_newest_runs[run_key] = each_run
427
+ return list(all_newest_runs.values())
428
+
429
+
349
430
  def _check_run_id(all_run_fields: Mapping[str, object]) -> int:
350
431
  run_id = all_run_fields.get(CHECK_RUN_ID_KEY)
351
432
  return run_id if isinstance(run_id, int) else 0
@@ -563,22 +644,73 @@ def _read_branch_rules(slug: str, base_ref: str, token: str) -> list[object]:
563
644
  )
564
645
 
565
646
 
647
+ def read_unstable_checks(
648
+ slug: str, head_sha: str, token: str
649
+ ) -> tuple[str, ...] | None:
650
+ """Read which checks hold an ``unstable`` head back by their newest report.
651
+
652
+ Args:
653
+ slug: The repository as ``owner/name``.
654
+ head_sha: The pull request's head commit.
655
+ token: The GitHub token the requests authenticate with.
656
+
657
+ Returns:
658
+ Each check whose newest run or latest status on the head is not
659
+ passing, read across every page of check runs and of statuses. None
660
+ when the head reports no check run and no status, so nothing
661
+ explains why GitHub calls it unstable.
662
+
663
+ Raises:
664
+ MergeCheckError: A read failed, or answered with another shape.
665
+ """
666
+ all_check_runs = _read_every_page(
667
+ CHECK_RUNS_ENDPOINT_TEMPLATE, slug, head_sha, token, CHECK_RUNS_KEY
668
+ )
669
+ all_statuses = _read_statuses(slug, head_sha, token)
670
+ if not all_check_runs and not all_statuses:
671
+ return None
672
+ return unmet_newest_checks(all_check_runs, all_statuses)
673
+
674
+
675
+ def _read_every_page(
676
+ endpoint_template: str,
677
+ slug: str,
678
+ head_sha: str,
679
+ token: str,
680
+ key: str,
681
+ ) -> list[object]:
682
+ all_records: list[object] = []
683
+ page_number = FIRST_PAGE_NUMBER
684
+ while True:
685
+ query = urllib.parse.urlencode(
686
+ {PER_PAGE_PARAMETER: CHECK_PAGE_SIZE, PAGE_PARAMETER: page_number}
687
+ )
688
+ all_page_records = _request_list_field(
689
+ endpoint_template.format(
690
+ api_root=GITHUB_API_ROOT, slug=slug, sha=head_sha, query=query
691
+ ),
692
+ token,
693
+ key,
694
+ )
695
+ all_records.extend(all_page_records)
696
+ if len(all_page_records) < CHECK_PAGE_SIZE:
697
+ return all_records
698
+ page_number += 1
699
+
700
+
701
+ def _read_statuses(slug: str, head_sha: str, token: str) -> list[object]:
702
+ return _read_every_page(
703
+ COMBINED_STATUS_ENDPOINT_TEMPLATE, slug, head_sha, token, STATUSES_KEY
704
+ )
705
+
706
+
566
707
  def _read_unmet_checks(
567
708
  slug: str,
568
709
  head_sha: str,
569
710
  all_rules: Sequence[object],
570
711
  token: str,
571
712
  ) -> tuple[str, ...]:
572
- all_statuses = _request_list_field(
573
- COMBINED_STATUS_ENDPOINT_TEMPLATE.format(
574
- api_root=GITHUB_API_ROOT,
575
- slug=slug,
576
- sha=head_sha,
577
- query=urllib.parse.urlencode({PER_PAGE_PARAMETER: CHECK_PAGE_SIZE}),
578
- ),
579
- token,
580
- STATUSES_KEY,
581
- )
713
+ all_statuses = _read_statuses(slug, head_sha, token)
582
714
  all_unmet_checks: list[str] = []
583
715
  for each_required in required_contexts(all_rules):
584
716
  all_check_runs = _read_check_runs(slug, head_sha, each_required.context, token)
@@ -866,11 +998,22 @@ def main(all_arguments: Sequence[str]) -> int:
866
998
  )
867
999
  except MergeCheckError as failure:
868
1000
  print(failure, file=sys.stderr)
1001
+ all_unstable_checks = None
1002
+ if all_pull_request_fields.get(MERGEABLE_STATE_KEY) == MERGEABLE_STATE_UNSTABLE:
1003
+ try:
1004
+ all_unstable_checks = read_unstable_checks(
1005
+ parsed.slug,
1006
+ str(_nested_field(all_pull_request_fields, HEAD_KEY, SHA_KEY)),
1007
+ token,
1008
+ )
1009
+ except MergeCheckError as failure:
1010
+ print(failure, file=sys.stderr)
869
1011
  reason = hold_reason(
870
1012
  all_pull_request_fields,
871
1013
  unresolved_thread_count,
872
1014
  blocked_evidence,
873
1015
  is_ejected_from_merge_queue,
1016
+ all_unstable_checks,
874
1017
  )
875
1018
  print(verdict_line(parsed.slug, all_pull_request_fields, reason))
876
1019
  return HOLD_EXIT_CODE if reason else MERGE_EXIT_CODE
@@ -38,6 +38,8 @@ COMBINED_STATUS_ENDPOINT_TEMPLATE = (
38
38
  COMPARE_ENDPOINT_TEMPLATE = "{api_root}/repos/{slug}/compare/{base}...{head}"
39
39
  CHECK_NAME_PARAMETER = "check_name"
40
40
  PER_PAGE_PARAMETER = "per_page"
41
+ PAGE_PARAMETER = "page"
42
+ FIRST_PAGE_NUMBER = 1
41
43
  CHECK_PAGE_SIZE = 100
42
44
 
43
45
  RULE_TYPE_KEY = "type"
@@ -101,6 +103,10 @@ FAILING_REQUIRED_CHECKS_HOLD_TEMPLATE = (
101
103
  "A required check is not passing on this head: {checks}. Read the "
102
104
  "failing check, fix it, and push."
103
105
  )
106
+ UNSTABLE_CHECKS_HOLD_TEMPLATE = (
107
+ "A check on this head is not passing: {checks}. Wait for a pending "
108
+ "check, re-run a cancelled one, and fix a failing one."
109
+ )
104
110
  REQUIRED_CHECK_STATE_TEMPLATE = "{context} ({state})"
105
111
  REQUIRED_CHECK_SEPARATOR = ", "
106
112
  MISSING_CHECK_STATE = "missing"
@@ -782,3 +782,269 @@ def test_read_merge_queue_ejection_compares_failed_checks_removals_to_the_head(
782
782
  )
783
783
  is expected_ejection
784
784
  )
785
+
786
+
787
+ PULL_REQUEST_5330_HEAD_SHA = "bd616254075d6d2bbe58acadca3d6a30bf8ee814"
788
+ PULL_REQUEST_5330 = {
789
+ "number": 5330,
790
+ "draft": False,
791
+ "mergeable_state": "unstable",
792
+ "base": {"ref": "main"},
793
+ "head": {"sha": PULL_REQUEST_5330_HEAD_SHA},
794
+ }
795
+ COPILOT_REVIEWER_APP_ID = 946600
796
+ PULL_REQUEST_5330_CHECK_RUNS = [
797
+ _check_run("Review closure", "success", run_id=110871328668),
798
+ _check_run("Review closure", "cancelled", run_id=110871409317),
799
+ _check_run("Review closure", "cancelled", run_id=110871412010),
800
+ _check_run("Review closure", "cancelled", run_id=110871418946),
801
+ _check_run("Review closure", "cancelled", run_id=110871420919),
802
+ _check_run("Review closure", "success", run_id=110874389252),
803
+ _check_run("Ruff", "cancelled", run_id=110871770000),
804
+ _check_run("Ruff", "success", run_id=110887099099),
805
+ _check_run("Tip Local green", "success", run_id=110873242269),
806
+ _check_run("Merge queue build verdict", "skipped", run_id=110887297809),
807
+ _check_run(
808
+ "copilot-pull-request-reviewer",
809
+ "success",
810
+ run_id=110871360430,
811
+ app_id=COPILOT_REVIEWER_APP_ID,
812
+ ),
813
+ ]
814
+ NEWEST_REVIEW_CLOSURE_CANCELLED = [
815
+ *PULL_REQUEST_5330_CHECK_RUNS,
816
+ _check_run("Review closure", "cancelled", run_id=110890000000),
817
+ ]
818
+
819
+
820
+ def test_superseded_cancelled_runs_leave_no_unmet_newest_check() -> None:
821
+ assert agent_merge_check.unmet_newest_checks(PULL_REQUEST_5330_CHECK_RUNS, []) == ()
822
+
823
+
824
+ @pytest.mark.parametrize(
825
+ ("all_check_runs", "all_statuses", "expected_checks"),
826
+ [
827
+ (NEWEST_REVIEW_CLOSURE_CANCELLED, [], ("Review closure (cancelled)",)),
828
+ (
829
+ [_check_run("Ruff", None, run_id=2, status="in_progress")],
830
+ [],
831
+ ("Ruff (pending)",),
832
+ ),
833
+ (
834
+ [
835
+ _check_run("Ruff", "success", run_id=2),
836
+ _check_run("Ruff", "failure", run_id=5, app_id=1),
837
+ ],
838
+ [],
839
+ ("Ruff (failure)",),
840
+ ),
841
+ (
842
+ PULL_REQUEST_5330_CHECK_RUNS,
843
+ [
844
+ {"context": "local-checks", "state": "success"},
845
+ {"context": "Semgrep", "state": "failure"},
846
+ ],
847
+ ("Semgrep (failure)",),
848
+ ),
849
+ (
850
+ PULL_REQUEST_5330_CHECK_RUNS,
851
+ [
852
+ {"context": "Semgrep", "state": "success"},
853
+ {"context": "Semgrep", "state": "failure"},
854
+ ],
855
+ (),
856
+ ),
857
+ (
858
+ PULL_REQUEST_5330_CHECK_RUNS,
859
+ [
860
+ {"context": "Semgrep", "state": "pending"},
861
+ {"context": "Semgrep", "state": "success"},
862
+ ],
863
+ ("Semgrep (pending)",),
864
+ ),
865
+ ],
866
+ )
867
+ def test_unmet_newest_checks_names_each_check_whose_newest_report_is_not_passing(
868
+ all_check_runs: list[object],
869
+ all_statuses: list[object],
870
+ expected_checks: tuple[str, ...],
871
+ ) -> None:
872
+ assert (
873
+ agent_merge_check.unmet_newest_checks(all_check_runs, all_statuses)
874
+ == expected_checks
875
+ )
876
+
877
+
878
+ def test_an_unstable_head_whose_newest_runs_pass_may_merge() -> None:
879
+ assert (
880
+ agent_merge_check.hold_reason(
881
+ _pull_request(mergeable_state="unstable"), 0, all_unstable_checks=()
882
+ )
883
+ is None
884
+ )
885
+
886
+
887
+ def test_an_unstable_head_whose_newest_runs_pass_still_holds_on_an_open_thread() -> (
888
+ None
889
+ ):
890
+ reason = agent_merge_check.hold_reason(
891
+ _pull_request(mergeable_state="unstable"), 1, all_unstable_checks=()
892
+ )
893
+ assert reason is not None
894
+ assert "1 review thread" in reason
895
+
896
+
897
+ def test_an_unstable_head_names_the_check_whose_newest_run_failed() -> None:
898
+ reason = agent_merge_check.hold_reason(
899
+ _pull_request(mergeable_state="unstable"),
900
+ 0,
901
+ all_unstable_checks=("Review closure (cancelled)",),
902
+ )
903
+ assert reason is not None
904
+ assert "Review closure (cancelled)" in reason
905
+
906
+
907
+ def _unstable_answers(
908
+ all_check_runs: list[dict[str, object]],
909
+ all_threads: list[object] | None = None,
910
+ all_statuses: list[object] | None = None,
911
+ ) -> object:
912
+ def _answer(url: str, token: str, all_payload_fields: object) -> object:
913
+ parsed = urllib.parse.urlparse(url)
914
+ if parsed.path.endswith("/pulls/5330"):
915
+ return PULL_REQUEST_5330
916
+ if parsed.path.endswith("/ccr/review_threads"):
917
+ return all_threads or []
918
+ if parsed.path.endswith("/graphql"):
919
+ return _merge_queue_document([])
920
+ if parsed.path.endswith("/check-runs"):
921
+ return {"check_runs": _requested_page(parsed.query, all_check_runs)}
922
+ if parsed.path.endswith("/status"):
923
+ return {"statuses": _requested_page(parsed.query, all_statuses or [])}
924
+ raise AssertionError(url)
925
+
926
+ return _answer
927
+
928
+
929
+ def _requested_page(query: str, all_records: list[object]) -> list[object]:
930
+ all_parameters = urllib.parse.parse_qs(query)
931
+ page_size = int(all_parameters["per_page"][0])
932
+ page_number = int(all_parameters["page"][0])
933
+ first_index = (page_number - 1) * page_size
934
+ return all_records[first_index : first_index + page_size]
935
+
936
+
937
+ def _run_unstable_main(
938
+ monkeypatch: pytest.MonkeyPatch,
939
+ capsys: pytest.CaptureFixture[str],
940
+ answer: object,
941
+ ) -> tuple[int, str]:
942
+ monkeypatch.setenv("GH_TOKEN", "token")
943
+ monkeypatch.setattr(agent_merge_check, "_request_json", answer)
944
+ exit_code = agent_merge_check.main(["jl-cmd/claude-dev-env", "5330"])
945
+ return exit_code, capsys.readouterr().out
946
+
947
+
948
+ def test_the_pull_request_5330_shape_reads_merge(
949
+ monkeypatch: pytest.MonkeyPatch,
950
+ capsys: pytest.CaptureFixture[str],
951
+ ) -> None:
952
+ exit_code, line = _run_unstable_main(
953
+ monkeypatch, capsys, _unstable_answers(PULL_REQUEST_5330_CHECK_RUNS)
954
+ )
955
+ assert exit_code == 0
956
+ assert line.startswith(MERGE_VERDICT_LABEL)
957
+ assert "bd61625" in line
958
+
959
+
960
+ def test_a_newest_run_cancellation_on_an_unstable_head_holds(
961
+ monkeypatch: pytest.MonkeyPatch,
962
+ capsys: pytest.CaptureFixture[str],
963
+ ) -> None:
964
+ exit_code, line = _run_unstable_main(
965
+ monkeypatch, capsys, _unstable_answers(NEWEST_REVIEW_CLOSURE_CANCELLED)
966
+ )
967
+ assert exit_code == 1
968
+ assert line.startswith(HOLD_VERDICT_LABEL)
969
+ assert "Review closure (cancelled)" in line
970
+
971
+
972
+ def test_an_unreadable_check_listing_keeps_the_generic_unstable_reason(
973
+ monkeypatch: pytest.MonkeyPatch,
974
+ capsys: pytest.CaptureFixture[str],
975
+ ) -> None:
976
+ routed_answer = _unstable_answers(PULL_REQUEST_5330_CHECK_RUNS)
977
+
978
+ def _answer(url: str, token: str, all_payload_fields: object) -> object:
979
+ if "/check-runs" in url:
980
+ raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
981
+ return routed_answer(url, token, all_payload_fields)
982
+
983
+ exit_code, line = _run_unstable_main(monkeypatch, capsys, _answer)
984
+ assert exit_code == 1
985
+ assert UNSTABLE_HOLD_REASON in line
986
+
987
+
988
+ def test_read_unstable_checks_reads_every_page_of_check_runs(
989
+ monkeypatch: pytest.MonkeyPatch,
990
+ ) -> None:
991
+ all_passing_runs = [
992
+ _check_run(f"Shard {each_index}", "success", run_id=each_index)
993
+ for each_index in range(1, 151)
994
+ ]
995
+ all_runs_with_late_failure = [
996
+ *all_passing_runs,
997
+ _check_run("Shard 150", "failure", run_id=151),
998
+ ]
999
+ monkeypatch.setattr(
1000
+ agent_merge_check,
1001
+ "_request_json",
1002
+ _unstable_answers(all_runs_with_late_failure),
1003
+ )
1004
+ assert agent_merge_check.read_unstable_checks(
1005
+ "jl-cmd/claude-dev-env", PULL_REQUEST_5330_HEAD_SHA, "token"
1006
+ ) == ("Shard 150 (failure)",)
1007
+
1008
+
1009
+ def test_read_unstable_checks_reads_every_page_of_commit_statuses(
1010
+ monkeypatch: pytest.MonkeyPatch,
1011
+ ) -> None:
1012
+ all_statuses_with_late_failure = [
1013
+ *(
1014
+ {"context": f"Gate {each_index}", "state": "success"}
1015
+ for each_index in range(1, 121)
1016
+ ),
1017
+ {"context": "Gate 121", "state": "failure"},
1018
+ ]
1019
+ monkeypatch.setattr(
1020
+ agent_merge_check,
1021
+ "_request_json",
1022
+ _unstable_answers(
1023
+ PULL_REQUEST_5330_CHECK_RUNS,
1024
+ all_statuses=all_statuses_with_late_failure,
1025
+ ),
1026
+ )
1027
+ assert agent_merge_check.read_unstable_checks(
1028
+ "jl-cmd/claude-dev-env", PULL_REQUEST_5330_HEAD_SHA, "token"
1029
+ ) == ("Gate 121 (failure)",)
1030
+
1031
+
1032
+ def test_an_unstable_head_with_no_reported_check_keeps_the_generic_hold(
1033
+ monkeypatch: pytest.MonkeyPatch,
1034
+ capsys: pytest.CaptureFixture[str],
1035
+ ) -> None:
1036
+ exit_code, line = _run_unstable_main(monkeypatch, capsys, _unstable_answers([]))
1037
+ assert exit_code == 1
1038
+ assert UNSTABLE_HOLD_REASON in line
1039
+
1040
+
1041
+ def test_read_unstable_checks_reports_nothing_read_for_an_empty_head(
1042
+ monkeypatch: pytest.MonkeyPatch,
1043
+ ) -> None:
1044
+ monkeypatch.setattr(agent_merge_check, "_request_json", _unstable_answers([]))
1045
+ assert (
1046
+ agent_merge_check.read_unstable_checks(
1047
+ "jl-cmd/claude-dev-env", PULL_REQUEST_5330_HEAD_SHA, "token"
1048
+ )
1049
+ is None
1050
+ )