claude-dev-env 8.19.1 → 8.20.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.
@@ -25,35 +25,71 @@ import os
25
25
  import sys
26
26
  import time
27
27
  import urllib.error
28
+ import urllib.parse
28
29
  import urllib.request
29
30
  from collections.abc import Callable, Mapping, Sequence
31
+ from dataclasses import dataclass
30
32
 
31
33
  from dev_env_scripts_constants.agent_merge_check_constants import (
32
34
  ACCEPT_HEADER,
35
+ ALL_HEAD_COMMIT_NODE_KEYS,
33
36
  ALL_HOLD_REASONS_BY_STATE,
37
+ ALL_MERGE_QUEUE_REMOVAL_NODE_KEYS,
38
+ ALL_PASSING_CHECK_CONCLUSIONS,
34
39
  ALL_OUTDATED_KEYS,
35
40
  ALL_RESOLVED_KEYS,
36
41
  ALL_THREAD_NODE_KEYS,
37
42
  ALL_TOKEN_ENVIRONMENT_VARIABLES,
38
43
  AUTHORIZATION_HEADER,
44
+ BASE_KEY,
39
45
  BEARER_PREFIX,
46
+ BEHIND_BY_KEY,
47
+ BEHIND_MERGE_QUEUE_HOLD_TEMPLATE,
48
+ BLOCKED_HOLD_REASON,
49
+ BRANCH_RULES_ENDPOINT_TEMPLATE,
50
+ CHECK_NAME_PARAMETER,
51
+ CHECK_PAGE_SIZE,
52
+ CHECK_RUN_APP_ID_KEY,
53
+ CHECK_RUN_APP_KEY,
54
+ CHECK_RUN_COMPLETED_STATUS,
55
+ CHECK_RUN_CONCLUSION_KEY,
56
+ CHECK_RUN_ID_KEY,
57
+ CHECK_RUN_NAME_KEY,
58
+ CHECK_RUN_STATUS_KEY,
59
+ CHECK_RUNS_ENDPOINT_TEMPLATE,
60
+ CHECK_RUNS_KEY,
61
+ COMBINED_STATUS_ENDPOINT_TEMPLATE,
40
62
  COMMAND_DESCRIPTION,
63
+ COMMIT_KEY,
64
+ COMMITTED_DATE_KEY,
65
+ COMPARE_ENDPOINT_TEMPLATE,
41
66
  CONTENT_TYPE_HEADER,
67
+ CONTEXT_KEY,
68
+ CREATED_AT_KEY,
42
69
  DRAFT_HOLD_REASON,
43
70
  DRAFT_KEY,
44
71
  ERROR_EXIT_CODE,
72
+ FAILED_CHECKS_REMOVAL_REASON,
73
+ FAILING_REQUIRED_CHECKS_HOLD_TEMPLATE,
45
74
  GITHUB_ACCEPT_TYPE,
46
75
  GITHUB_API_ROOT,
47
76
  GITHUB_GRAPHQL_ENDPOINT,
48
77
  HEAD_KEY,
49
78
  HOLD_EXIT_CODE,
50
79
  HOLD_VERDICT_LABEL,
80
+ INTEGRATION_ID_KEY,
51
81
  JSON_CONTENT_TYPE,
52
82
  MERGE_EXIT_CODE,
83
+ MERGE_QUEUE_EJECTION_HOLD_TEMPLATE,
84
+ MERGE_QUEUE_REMOVAL_PAGE_SIZE,
85
+ MERGE_QUEUE_REMOVAL_QUERY,
86
+ MERGE_QUEUE_RULE_TYPE,
53
87
  MERGE_VERDICT_LABEL,
88
+ MERGEABLE_STATE_BLOCKED,
54
89
  MERGEABLE_STATE_CLEAN,
55
90
  MERGEABLE_STATE_KEY,
56
91
  MERGEABLE_STATE_UNKNOWN,
92
+ MISSING_CHECK_STATE,
57
93
  NAME_VARIABLE,
58
94
  NO_SIGN_IN_MESSAGE,
59
95
  NUMBER_ARGUMENT_HELP,
@@ -61,18 +97,32 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
61
97
  NUMBER_VARIABLE,
62
98
  OWNER_VARIABLE,
63
99
  PAGE_SIZE_VARIABLE,
100
+ PENDING_CHECK_STATE,
101
+ PENDING_STATUS_STATE,
102
+ PER_PAGE_PARAMETER,
64
103
  PULL_REQUEST_ENDPOINT_TEMPLATE,
65
104
  QUERY_KEY,
66
105
  READY_DETAIL,
106
+ REF_KEY,
107
+ REMOVAL_REASON_KEY,
108
+ REQUIRED_CHECK_SEPARATOR,
109
+ REQUIRED_CHECK_STATE_TEMPLATE,
110
+ REQUIRED_STATUS_CHECKS_KEY,
111
+ REQUIRED_STATUS_CHECKS_RULE_TYPE,
67
112
  REQUEST_TIMEOUT_SECONDS,
68
113
  REVIEW_THREAD_PAGE_SIZE,
69
114
  REVIEW_THREADS_ENDPOINT_TEMPLATE,
115
+ RULE_PARAMETERS_KEY,
116
+ RULE_TYPE_KEY,
70
117
  SETTLE_ATTEMPT_COUNT,
71
118
  SETTLE_WAIT_SECONDS,
72
119
  SHA_KEY,
73
120
  SHORT_SHA_LENGTH,
74
121
  SLUG_ARGUMENT_HELP,
75
122
  SLUG_SEPARATOR,
123
+ STATUS_STATE_KEY,
124
+ STATUSES_KEY,
125
+ SUCCESS_STATUS_STATE,
76
126
  UNKNOWN_STATE_HOLD_TEMPLATE,
77
127
  UNRESOLVED_THREAD_QUERY,
78
128
  UNRESOLVED_THREADS_HOLD_TEMPLATE,
@@ -86,9 +136,62 @@ class MergeCheckError(Exception):
86
136
  """Raised when the pull request state could not be read."""
87
137
 
88
138
 
139
+ @dataclass(frozen=True)
140
+ class RequiredContext:
141
+ """One status check a branch rule requires before a merge.
142
+
143
+ Attributes:
144
+ context: The check name the rule requires.
145
+ integration_id: The app that must report it, or None for any app.
146
+ """
147
+
148
+ context: str
149
+ integration_id: int | None
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class BlockedEvidence:
154
+ """What GitHub reports behind a ``blocked`` merge state.
155
+
156
+ Attributes:
157
+ all_unmet_checks: Each required check not passing on the head, as
158
+ its name and state, such as ``Ruff (failure)``.
159
+ behind_by: How many base commits the head lacks.
160
+ has_merge_queue: Whether a branch rule sends merges through a queue.
161
+ """
162
+
163
+ all_unmet_checks: tuple[str, ...]
164
+ behind_by: int
165
+ has_merge_queue: bool
166
+
167
+
168
+ def blocked_hold_reason(evidence: BlockedEvidence) -> str:
169
+ """Name what blocks a pull request GitHub reports as ``blocked``.
170
+
171
+ Args:
172
+ evidence: The required checks, the distance behind the base, and the
173
+ merge queue rule read for the pull request.
174
+
175
+ Returns:
176
+ The failing required checks by name when any is not passing. The
177
+ behind reason when every required check passes and a merge queue
178
+ refuses a head that lacks base commits. The generic blocked reason
179
+ otherwise.
180
+ """
181
+ if evidence.all_unmet_checks:
182
+ return FAILING_REQUIRED_CHECKS_HOLD_TEMPLATE.format(
183
+ checks=REQUIRED_CHECK_SEPARATOR.join(evidence.all_unmet_checks)
184
+ )
185
+ if evidence.has_merge_queue and evidence.behind_by > 0:
186
+ return BEHIND_MERGE_QUEUE_HOLD_TEMPLATE.format(count=evidence.behind_by)
187
+ return BLOCKED_HOLD_REASON
188
+
189
+
89
190
  def hold_reason(
90
191
  all_pull_request_fields: Mapping[str, object],
91
192
  unresolved_thread_count: int,
193
+ blocked_evidence: BlockedEvidence | None = None,
194
+ is_ejected_from_merge_queue: bool = False,
92
195
  ) -> str | None:
93
196
  """Return why this pull request stays open, or None when it may merge.
94
197
 
@@ -96,25 +199,193 @@ def hold_reason(
96
199
  all_pull_request_fields: The pull request as the GitHub API reports
97
200
  it, carrying its draft flag and its merge state.
98
201
  unresolved_thread_count: How many review threads are open on it.
202
+ blocked_evidence: What was read behind a ``blocked`` merge state, or
203
+ None when nothing was read.
204
+ is_ejected_from_merge_queue: Whether the merge queue ejected the
205
+ current head for failed checks.
99
206
 
100
207
  Returns:
101
- The reason text for a draft, for a merge state other than clean, and
102
- for an open review thread. None when the pull request is ready for
103
- the agent that drives it to merge it.
208
+ 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.
104
212
  """
105
213
  if all_pull_request_fields.get(DRAFT_KEY):
106
214
  return DRAFT_HOLD_REASON
107
215
  merge_state = all_pull_request_fields.get(MERGEABLE_STATE_KEY)
216
+ if merge_state == MERGEABLE_STATE_BLOCKED and blocked_evidence is not None:
217
+ return blocked_hold_reason(blocked_evidence)
108
218
  if merge_state != MERGEABLE_STATE_CLEAN:
109
219
  return ALL_HOLD_REASONS_BY_STATE.get(
110
220
  merge_state,
111
221
  UNKNOWN_STATE_HOLD_TEMPLATE.format(state=merge_state),
112
222
  )
223
+ if is_ejected_from_merge_queue:
224
+ return MERGE_QUEUE_EJECTION_HOLD_TEMPLATE.format(
225
+ base=_optional_nested_field(all_pull_request_fields, BASE_KEY, REF_KEY),
226
+ number=all_pull_request_fields.get(NUMBER_KEY),
227
+ )
113
228
  if unresolved_thread_count > 0:
114
229
  return UNRESOLVED_THREADS_HOLD_TEMPLATE.format(count=unresolved_thread_count)
115
230
  return None
116
231
 
117
232
 
233
+ def _optional_nested_field(
234
+ all_fields: Mapping[str, object],
235
+ outer_key: str,
236
+ inner_key: str,
237
+ ) -> object:
238
+ all_inner_fields = all_fields.get(outer_key)
239
+ if not isinstance(all_inner_fields, Mapping):
240
+ return None
241
+ return all_inner_fields.get(inner_key)
242
+
243
+
244
+ def required_contexts(all_rules: Sequence[object]) -> tuple[RequiredContext, ...]:
245
+ """Collect each status check the branch rules require, once, in order.
246
+
247
+ Args:
248
+ all_rules: The rules GitHub reports as active on the base branch.
249
+
250
+ Returns:
251
+ The required checks across every required status checks rule.
252
+ """
253
+ all_contexts: dict[RequiredContext, None] = {}
254
+ for each_rule in _rules_of_type(all_rules, REQUIRED_STATUS_CHECKS_RULE_TYPE):
255
+ for each_check in _required_check_records(each_rule):
256
+ all_contexts[_required_context(each_check)] = None
257
+ return tuple(all_contexts)
258
+
259
+
260
+ def has_merge_queue_rule(all_rules: Sequence[object]) -> bool:
261
+ """Report whether a branch rule sends merges through a merge queue.
262
+
263
+ Args:
264
+ all_rules: The rules GitHub reports as active on the base branch.
265
+
266
+ Returns:
267
+ True when any rule is a merge queue rule.
268
+ """
269
+ return any(_rules_of_type(all_rules, MERGE_QUEUE_RULE_TYPE))
270
+
271
+
272
+ def _rules_of_type(
273
+ all_rules: Sequence[object],
274
+ rule_type: str,
275
+ ) -> list[Mapping[str, object]]:
276
+ return [
277
+ each_rule
278
+ for each_rule in all_rules
279
+ if isinstance(each_rule, Mapping) and each_rule.get(RULE_TYPE_KEY) == rule_type
280
+ ]
281
+
282
+
283
+ def _required_check_records(
284
+ all_rule_fields: Mapping[str, object],
285
+ ) -> list[Mapping[str, object]]:
286
+ all_parameters = all_rule_fields.get(RULE_PARAMETERS_KEY)
287
+ if not isinstance(all_parameters, Mapping):
288
+ return []
289
+ all_checks = all_parameters.get(REQUIRED_STATUS_CHECKS_KEY)
290
+ if not isinstance(all_checks, list):
291
+ return []
292
+ return [
293
+ each_check
294
+ for each_check in all_checks
295
+ if isinstance(each_check, Mapping) and each_check.get(CONTEXT_KEY)
296
+ ]
297
+
298
+
299
+ def _required_context(all_check_fields: Mapping[str, object]) -> RequiredContext:
300
+ integration_id = all_check_fields.get(INTEGRATION_ID_KEY)
301
+ return RequiredContext(
302
+ context=str(all_check_fields[CONTEXT_KEY]),
303
+ integration_id=integration_id if isinstance(integration_id, int) else None,
304
+ )
305
+
306
+
307
+ def unmet_check_state(
308
+ required: RequiredContext,
309
+ all_check_runs: Sequence[object],
310
+ all_statuses: Sequence[object],
311
+ ) -> str | None:
312
+ """Return the state of a required check that is not passing on the head.
313
+
314
+ Args:
315
+ required: The check the branch rule requires.
316
+ all_check_runs: The check runs reported on the head commit.
317
+ all_statuses: The latest commit status per context on the head.
318
+
319
+ Returns:
320
+ None when the newest matching check run, or failing that the commit
321
+ status of that name, passes. Otherwise its conclusion, ``pending``,
322
+ or ``missing`` when nothing of that name reported.
323
+ """
324
+ newest_run = _newest_matching_run(required, all_check_runs)
325
+ if newest_run is not None:
326
+ return _check_run_state(newest_run)
327
+ status = _matching_status(required.context, all_statuses)
328
+ if status is not None:
329
+ return _status_state(status)
330
+ return MISSING_CHECK_STATE
331
+
332
+
333
+ def _newest_matching_run(
334
+ required: RequiredContext,
335
+ all_check_runs: Sequence[object],
336
+ ) -> Mapping[str, object] | None:
337
+ all_matching_runs = [
338
+ each_run
339
+ for each_run in all_check_runs
340
+ if isinstance(each_run, Mapping)
341
+ and each_run.get(CHECK_RUN_NAME_KEY) == required.context
342
+ and required.integration_id in (None, _check_run_app_id(each_run))
343
+ ]
344
+ if not all_matching_runs:
345
+ return None
346
+ return max(all_matching_runs, key=_check_run_id)
347
+
348
+
349
+ def _check_run_id(all_run_fields: Mapping[str, object]) -> int:
350
+ run_id = all_run_fields.get(CHECK_RUN_ID_KEY)
351
+ return run_id if isinstance(run_id, int) else 0
352
+
353
+
354
+ def _check_run_app_id(all_run_fields: Mapping[str, object]) -> object:
355
+ all_app_fields = all_run_fields.get(CHECK_RUN_APP_KEY)
356
+ if not isinstance(all_app_fields, Mapping):
357
+ return None
358
+ return all_app_fields.get(CHECK_RUN_APP_ID_KEY)
359
+
360
+
361
+ def _check_run_state(all_run_fields: Mapping[str, object]) -> str | None:
362
+ if all_run_fields.get(CHECK_RUN_STATUS_KEY) != CHECK_RUN_COMPLETED_STATUS:
363
+ return PENDING_CHECK_STATE
364
+ conclusion = all_run_fields.get(CHECK_RUN_CONCLUSION_KEY)
365
+ if conclusion in ALL_PASSING_CHECK_CONCLUSIONS:
366
+ return None
367
+ return str(conclusion)
368
+
369
+
370
+ def _matching_status(
371
+ context: str,
372
+ all_statuses: Sequence[object],
373
+ ) -> Mapping[str, object] | None:
374
+ for each_status in all_statuses:
375
+ if isinstance(each_status, Mapping) and each_status.get(CONTEXT_KEY) == context:
376
+ return each_status
377
+ return None
378
+
379
+
380
+ def _status_state(all_status_fields: Mapping[str, object]) -> str | None:
381
+ state = all_status_fields.get(STATUS_STATE_KEY)
382
+ if state == SUCCESS_STATUS_STATE:
383
+ return None
384
+ if state == PENDING_STATUS_STATE:
385
+ return PENDING_CHECK_STATE
386
+ return str(state)
387
+
388
+
118
389
  def verdict_line(
119
390
  slug: str,
120
391
  all_pull_request_fields: Mapping[str, object],
@@ -165,6 +436,27 @@ def count_unresolved_threads(all_thread_records: Sequence[object]) -> int:
165
436
  )
166
437
 
167
438
 
439
+ def _is_ejected_on_head(
440
+ all_removal_records: Sequence[object],
441
+ head_committed_at: str,
442
+ ) -> bool:
443
+ """Report whether a ``failed_checks`` removal is newer than the head.
444
+
445
+ A commit that lands after the merge queue ejected the pull request
446
+ carries a fix the queue has not seen yet, so an older removal holds
447
+ nothing back.
448
+ """
449
+ all_failed_check_times = [
450
+ str(each_removal.get(CREATED_AT_KEY))
451
+ for each_removal in all_removal_records
452
+ if isinstance(each_removal, Mapping)
453
+ and each_removal.get(REMOVAL_REASON_KEY) == FAILED_CHECKS_REMOVAL_REASON
454
+ ]
455
+ return (
456
+ bool(all_failed_check_times) and max(all_failed_check_times) > head_committed_at
457
+ )
458
+
459
+
168
460
  def _any_flag(all_thread_fields: Mapping[str, object], all_keys: Sequence[str]) -> bool:
169
461
  return any(all_thread_fields.get(each_key) for each_key in all_keys)
170
462
 
@@ -230,6 +522,137 @@ def read_pull_request(slug: str, number: int, token: str) -> Mapping[str, object
230
522
  return document
231
523
 
232
524
 
525
+ def read_blocked_evidence(
526
+ slug: str,
527
+ all_pull_request_fields: Mapping[str, object],
528
+ token: str,
529
+ ) -> BlockedEvidence:
530
+ """Read what stands behind a ``blocked`` merge state.
531
+
532
+ Args:
533
+ slug: The repository as ``owner/name``.
534
+ all_pull_request_fields: The pull request, carrying its base branch
535
+ and its head commit.
536
+ token: The GitHub token the requests authenticate with.
537
+
538
+ Returns:
539
+ The required checks not passing on the head, how far the head is
540
+ behind the base, and whether a merge queue rule applies.
541
+
542
+ Raises:
543
+ MergeCheckError: A read failed, or answered with another shape.
544
+ """
545
+ base_ref = urllib.parse.quote(
546
+ str(_nested_field(all_pull_request_fields, BASE_KEY, REF_KEY))
547
+ )
548
+ head_sha = str(_nested_field(all_pull_request_fields, HEAD_KEY, SHA_KEY))
549
+ all_rules = _read_branch_rules(slug, base_ref, token)
550
+ return BlockedEvidence(
551
+ all_unmet_checks=_read_unmet_checks(slug, head_sha, all_rules, token),
552
+ behind_by=_read_behind_by(slug, base_ref, head_sha, token),
553
+ has_merge_queue=has_merge_queue_rule(all_rules),
554
+ )
555
+
556
+
557
+ def _read_branch_rules(slug: str, base_ref: str, token: str) -> list[object]:
558
+ return _request_list(
559
+ BRANCH_RULES_ENDPOINT_TEMPLATE.format(
560
+ api_root=GITHUB_API_ROOT, slug=slug, branch=base_ref
561
+ ),
562
+ token,
563
+ )
564
+
565
+
566
+ def _read_unmet_checks(
567
+ slug: str,
568
+ head_sha: str,
569
+ all_rules: Sequence[object],
570
+ token: str,
571
+ ) -> 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
+ )
582
+ all_unmet_checks: list[str] = []
583
+ for each_required in required_contexts(all_rules):
584
+ all_check_runs = _read_check_runs(slug, head_sha, each_required.context, token)
585
+ state = unmet_check_state(each_required, all_check_runs, all_statuses)
586
+ if state is not None:
587
+ all_unmet_checks.append(
588
+ REQUIRED_CHECK_STATE_TEMPLATE.format(
589
+ context=each_required.context, state=state
590
+ )
591
+ )
592
+ return tuple(all_unmet_checks)
593
+
594
+
595
+ def _read_check_runs(
596
+ slug: str,
597
+ head_sha: str,
598
+ context: str,
599
+ token: str,
600
+ ) -> list[object]:
601
+ query = urllib.parse.urlencode(
602
+ {CHECK_NAME_PARAMETER: context, PER_PAGE_PARAMETER: CHECK_PAGE_SIZE}
603
+ )
604
+ return _request_list_field(
605
+ CHECK_RUNS_ENDPOINT_TEMPLATE.format(
606
+ api_root=GITHUB_API_ROOT, slug=slug, sha=head_sha, query=query
607
+ ),
608
+ token,
609
+ CHECK_RUNS_KEY,
610
+ )
611
+
612
+
613
+ def _read_behind_by(slug: str, base_ref: str, head_sha: str, token: str) -> int:
614
+ behind_by = _request_field(
615
+ COMPARE_ENDPOINT_TEMPLATE.format(
616
+ api_root=GITHUB_API_ROOT, slug=slug, base=base_ref, head=head_sha
617
+ ),
618
+ token,
619
+ BEHIND_BY_KEY,
620
+ )
621
+ return behind_by if isinstance(behind_by, int) else 0
622
+
623
+
624
+ def _nested_field(
625
+ all_fields: Mapping[str, object],
626
+ outer_key: str,
627
+ inner_key: str,
628
+ ) -> object:
629
+ all_inner_fields = all_fields.get(outer_key)
630
+ if not isinstance(all_inner_fields, Mapping) or not all_inner_fields.get(inner_key):
631
+ raise MergeCheckError(f"The pull request carries no {outer_key}.{inner_key}.")
632
+ return all_inner_fields[inner_key]
633
+
634
+
635
+ def _request_list(url: str, token: str) -> list[object]:
636
+ document = _request_json(url, token, None)
637
+ if not isinstance(document, list):
638
+ raise MergeCheckError(str(document))
639
+ return document
640
+
641
+
642
+ def _request_field(url: str, token: str, key: str) -> object:
643
+ document = _request_json(url, token, None)
644
+ if not isinstance(document, Mapping) or key not in document:
645
+ raise MergeCheckError(str(document))
646
+ return document[key]
647
+
648
+
649
+ def _request_list_field(url: str, token: str, key: str) -> list[object]:
650
+ all_records = _request_field(url, token, key)
651
+ if not isinstance(all_records, list):
652
+ raise MergeCheckError(str(all_records))
653
+ return all_records
654
+
655
+
233
656
  def read_unresolved_thread_count(slug: str, number: int, token: str) -> int:
234
657
  """Read how many review threads on a pull request stay open.
235
658
 
@@ -276,14 +699,20 @@ def _thread_records_over_rest(
276
699
  return document if isinstance(document, list) else None
277
700
 
278
701
 
279
- def _thread_query_payload(owner: str, name: str, number: int) -> dict[str, object]:
702
+ def _pull_request_query_payload(
703
+ query: str,
704
+ page_size: int,
705
+ slug: str,
706
+ number: int,
707
+ ) -> dict[str, object]:
708
+ owner, _, name = slug.partition(SLUG_SEPARATOR)
280
709
  return {
281
- QUERY_KEY: UNRESOLVED_THREAD_QUERY,
710
+ QUERY_KEY: query,
282
711
  VARIABLES_KEY: {
283
712
  OWNER_VARIABLE: owner,
284
713
  NAME_VARIABLE: name,
285
714
  NUMBER_VARIABLE: number,
286
- PAGE_SIZE_VARIABLE: REVIEW_THREAD_PAGE_SIZE,
715
+ PAGE_SIZE_VARIABLE: page_size,
287
716
  },
288
717
  }
289
718
 
@@ -293,11 +722,12 @@ def _read_unresolved_thread_count_over_graphql(
293
722
  number: int,
294
723
  token: str,
295
724
  ) -> int:
296
- owner, _, name = slug.partition(SLUG_SEPARATOR)
297
725
  document = _request_json(
298
726
  GITHUB_GRAPHQL_ENDPOINT,
299
727
  token,
300
- _thread_query_payload(owner, name, number),
728
+ _pull_request_query_payload(
729
+ UNRESOLVED_THREAD_QUERY, REVIEW_THREAD_PAGE_SIZE, slug, number
730
+ ),
301
731
  )
302
732
  all_thread_records = functools.reduce(
303
733
  functools.partial(_field_at, document),
@@ -315,6 +745,59 @@ def _field_at(document: object, all_fields: object, key: str) -> object:
315
745
  return all_fields.get(key)
316
746
 
317
747
 
748
+ def read_merge_queue_ejection(slug: str, number: int, token: str) -> bool:
749
+ """Read whether the merge queue ejected the pull request's current head.
750
+
751
+ Only GraphQL reports why a pull request left the merge queue. A
752
+ repository with no merge queue reports no removal events.
753
+
754
+ Args:
755
+ slug: The repository as ``owner/name``.
756
+ number: The pull request number.
757
+ token: The GitHub token the request authenticates with.
758
+
759
+ Returns:
760
+ True when a ``failed_checks`` removal is newer than the head commit.
761
+
762
+ Raises:
763
+ MergeCheckError: The query failed, or answered with another shape.
764
+ """
765
+ document = _request_json(
766
+ GITHUB_GRAPHQL_ENDPOINT,
767
+ token,
768
+ _pull_request_query_payload(
769
+ MERGE_QUEUE_REMOVAL_QUERY, MERGE_QUEUE_REMOVAL_PAGE_SIZE, slug, number
770
+ ),
771
+ )
772
+ return _is_ejected_on_head(
773
+ _list_along(document, ALL_MERGE_QUEUE_REMOVAL_NODE_KEYS),
774
+ _head_committed_at(document),
775
+ )
776
+
777
+
778
+ def _list_along(document: object, all_keys: Sequence[str]) -> list[object]:
779
+ all_records = functools.reduce(
780
+ functools.partial(_field_at, document), all_keys, document
781
+ )
782
+ if not isinstance(all_records, list):
783
+ raise MergeCheckError(str(document))
784
+ return all_records
785
+
786
+
787
+ def _head_committed_at(document: object) -> str:
788
+ all_head_commits = _list_along(document, ALL_HEAD_COMMIT_NODE_KEYS)
789
+ if not all_head_commits:
790
+ raise MergeCheckError(str(document))
791
+ head_committed_at = functools.reduce(
792
+ functools.partial(_field_at, document),
793
+ (COMMIT_KEY, COMMITTED_DATE_KEY),
794
+ all_head_commits[-1],
795
+ )
796
+ if not isinstance(head_committed_at, str):
797
+ raise MergeCheckError(str(document))
798
+ return head_committed_at
799
+
800
+
318
801
  def read_settled_pull_request(
319
802
  slug: str,
320
803
  number: int,
@@ -369,10 +852,26 @@ def main(all_arguments: Sequence[str]) -> int:
369
852
  unresolved_thread_count = read_unresolved_thread_count(
370
853
  parsed.slug, parsed.number, token
371
854
  )
855
+ is_ejected_from_merge_queue = read_merge_queue_ejection(
856
+ parsed.slug, parsed.number, token
857
+ )
372
858
  except MergeCheckError as failure:
373
859
  print(failure, file=sys.stderr)
374
860
  return ERROR_EXIT_CODE
375
- reason = hold_reason(all_pull_request_fields, unresolved_thread_count)
861
+ blocked_evidence = None
862
+ if all_pull_request_fields.get(MERGEABLE_STATE_KEY) == MERGEABLE_STATE_BLOCKED:
863
+ try:
864
+ blocked_evidence = read_blocked_evidence(
865
+ parsed.slug, all_pull_request_fields, token
866
+ )
867
+ except MergeCheckError as failure:
868
+ print(failure, file=sys.stderr)
869
+ reason = hold_reason(
870
+ all_pull_request_fields,
871
+ unresolved_thread_count,
872
+ blocked_evidence,
873
+ is_ejected_from_merge_queue,
874
+ )
376
875
  print(verdict_line(parsed.slug, all_pull_request_fields, reason))
377
876
  return HOLD_EXIT_CODE if reason else MERGE_EXIT_CODE
378
877