claude-dev-env 8.19.1 → 8.19.2

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.19.1",
3
+ "version": "8.19.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,35 +25,62 @@ 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,
33
35
  ALL_HOLD_REASONS_BY_STATE,
36
+ ALL_PASSING_CHECK_CONCLUSIONS,
34
37
  ALL_OUTDATED_KEYS,
35
38
  ALL_RESOLVED_KEYS,
36
39
  ALL_THREAD_NODE_KEYS,
37
40
  ALL_TOKEN_ENVIRONMENT_VARIABLES,
38
41
  AUTHORIZATION_HEADER,
42
+ BASE_KEY,
39
43
  BEARER_PREFIX,
44
+ BEHIND_BY_KEY,
45
+ BEHIND_MERGE_QUEUE_HOLD_TEMPLATE,
46
+ BLOCKED_HOLD_REASON,
47
+ BRANCH_RULES_ENDPOINT_TEMPLATE,
48
+ CHECK_NAME_PARAMETER,
49
+ CHECK_PAGE_SIZE,
50
+ CHECK_RUN_APP_ID_KEY,
51
+ CHECK_RUN_APP_KEY,
52
+ CHECK_RUN_COMPLETED_STATUS,
53
+ CHECK_RUN_CONCLUSION_KEY,
54
+ CHECK_RUN_ID_KEY,
55
+ CHECK_RUN_NAME_KEY,
56
+ CHECK_RUN_STATUS_KEY,
57
+ CHECK_RUNS_ENDPOINT_TEMPLATE,
58
+ CHECK_RUNS_KEY,
59
+ COMBINED_STATUS_ENDPOINT_TEMPLATE,
40
60
  COMMAND_DESCRIPTION,
61
+ COMPARE_ENDPOINT_TEMPLATE,
41
62
  CONTENT_TYPE_HEADER,
63
+ CONTEXT_KEY,
42
64
  DRAFT_HOLD_REASON,
43
65
  DRAFT_KEY,
44
66
  ERROR_EXIT_CODE,
67
+ FAILING_REQUIRED_CHECKS_HOLD_TEMPLATE,
45
68
  GITHUB_ACCEPT_TYPE,
46
69
  GITHUB_API_ROOT,
47
70
  GITHUB_GRAPHQL_ENDPOINT,
48
71
  HEAD_KEY,
49
72
  HOLD_EXIT_CODE,
50
73
  HOLD_VERDICT_LABEL,
74
+ INTEGRATION_ID_KEY,
51
75
  JSON_CONTENT_TYPE,
52
76
  MERGE_EXIT_CODE,
77
+ MERGE_QUEUE_RULE_TYPE,
53
78
  MERGE_VERDICT_LABEL,
79
+ MERGEABLE_STATE_BLOCKED,
54
80
  MERGEABLE_STATE_CLEAN,
55
81
  MERGEABLE_STATE_KEY,
56
82
  MERGEABLE_STATE_UNKNOWN,
83
+ MISSING_CHECK_STATE,
57
84
  NAME_VARIABLE,
58
85
  NO_SIGN_IN_MESSAGE,
59
86
  NUMBER_ARGUMENT_HELP,
@@ -61,18 +88,31 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
61
88
  NUMBER_VARIABLE,
62
89
  OWNER_VARIABLE,
63
90
  PAGE_SIZE_VARIABLE,
91
+ PENDING_CHECK_STATE,
92
+ PENDING_STATUS_STATE,
93
+ PER_PAGE_PARAMETER,
64
94
  PULL_REQUEST_ENDPOINT_TEMPLATE,
65
95
  QUERY_KEY,
66
96
  READY_DETAIL,
97
+ REF_KEY,
98
+ REQUIRED_CHECK_SEPARATOR,
99
+ REQUIRED_CHECK_STATE_TEMPLATE,
100
+ REQUIRED_STATUS_CHECKS_KEY,
101
+ REQUIRED_STATUS_CHECKS_RULE_TYPE,
67
102
  REQUEST_TIMEOUT_SECONDS,
68
103
  REVIEW_THREAD_PAGE_SIZE,
69
104
  REVIEW_THREADS_ENDPOINT_TEMPLATE,
105
+ RULE_PARAMETERS_KEY,
106
+ RULE_TYPE_KEY,
70
107
  SETTLE_ATTEMPT_COUNT,
71
108
  SETTLE_WAIT_SECONDS,
72
109
  SHA_KEY,
73
110
  SHORT_SHA_LENGTH,
74
111
  SLUG_ARGUMENT_HELP,
75
112
  SLUG_SEPARATOR,
113
+ STATUS_STATE_KEY,
114
+ STATUSES_KEY,
115
+ SUCCESS_STATUS_STATE,
76
116
  UNKNOWN_STATE_HOLD_TEMPLATE,
77
117
  UNRESOLVED_THREAD_QUERY,
78
118
  UNRESOLVED_THREADS_HOLD_TEMPLATE,
@@ -86,9 +126,61 @@ class MergeCheckError(Exception):
86
126
  """Raised when the pull request state could not be read."""
87
127
 
88
128
 
129
+ @dataclass(frozen=True)
130
+ class RequiredContext:
131
+ """One status check a branch rule requires before a merge.
132
+
133
+ Attributes:
134
+ context: The check name the rule requires.
135
+ integration_id: The app that must report it, or None for any app.
136
+ """
137
+
138
+ context: str
139
+ integration_id: int | None
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class BlockedEvidence:
144
+ """What GitHub reports behind a ``blocked`` merge state.
145
+
146
+ Attributes:
147
+ all_unmet_checks: Each required check not passing on the head, as
148
+ its name and state, such as ``Ruff (failure)``.
149
+ behind_by: How many base commits the head lacks.
150
+ has_merge_queue: Whether a branch rule sends merges through a queue.
151
+ """
152
+
153
+ all_unmet_checks: tuple[str, ...]
154
+ behind_by: int
155
+ has_merge_queue: bool
156
+
157
+
158
+ def blocked_hold_reason(evidence: BlockedEvidence) -> str:
159
+ """Name what blocks a pull request GitHub reports as ``blocked``.
160
+
161
+ Args:
162
+ evidence: The required checks, the distance behind the base, and the
163
+ merge queue rule read for the pull request.
164
+
165
+ Returns:
166
+ The failing required checks by name when any is not passing. The
167
+ behind reason when every required check passes and a merge queue
168
+ refuses a head that lacks base commits. The generic blocked reason
169
+ otherwise.
170
+ """
171
+ if evidence.all_unmet_checks:
172
+ return FAILING_REQUIRED_CHECKS_HOLD_TEMPLATE.format(
173
+ checks=REQUIRED_CHECK_SEPARATOR.join(evidence.all_unmet_checks)
174
+ )
175
+ if evidence.has_merge_queue and evidence.behind_by > 0:
176
+ return BEHIND_MERGE_QUEUE_HOLD_TEMPLATE.format(count=evidence.behind_by)
177
+ return BLOCKED_HOLD_REASON
178
+
179
+
89
180
  def hold_reason(
90
181
  all_pull_request_fields: Mapping[str, object],
91
182
  unresolved_thread_count: int,
183
+ blocked_evidence: BlockedEvidence | None = None,
92
184
  ) -> str | None:
93
185
  """Return why this pull request stays open, or None when it may merge.
94
186
 
@@ -96,6 +188,8 @@ def hold_reason(
96
188
  all_pull_request_fields: The pull request as the GitHub API reports
97
189
  it, carrying its draft flag and its merge state.
98
190
  unresolved_thread_count: How many review threads are open on it.
191
+ blocked_evidence: What was read behind a ``blocked`` merge state, or
192
+ None when nothing was read.
99
193
 
100
194
  Returns:
101
195
  The reason text for a draft, for a merge state other than clean, and
@@ -105,6 +199,8 @@ def hold_reason(
105
199
  if all_pull_request_fields.get(DRAFT_KEY):
106
200
  return DRAFT_HOLD_REASON
107
201
  merge_state = all_pull_request_fields.get(MERGEABLE_STATE_KEY)
202
+ if merge_state == MERGEABLE_STATE_BLOCKED and blocked_evidence is not None:
203
+ return blocked_hold_reason(blocked_evidence)
108
204
  if merge_state != MERGEABLE_STATE_CLEAN:
109
205
  return ALL_HOLD_REASONS_BY_STATE.get(
110
206
  merge_state,
@@ -115,6 +211,151 @@ def hold_reason(
115
211
  return None
116
212
 
117
213
 
214
+ def required_contexts(all_rules: Sequence[object]) -> tuple[RequiredContext, ...]:
215
+ """Collect each status check the branch rules require, once, in order.
216
+
217
+ Args:
218
+ all_rules: The rules GitHub reports as active on the base branch.
219
+
220
+ Returns:
221
+ The required checks across every required status checks rule.
222
+ """
223
+ all_contexts: dict[RequiredContext, None] = {}
224
+ for each_rule in _rules_of_type(all_rules, REQUIRED_STATUS_CHECKS_RULE_TYPE):
225
+ for each_check in _required_check_records(each_rule):
226
+ all_contexts[_required_context(each_check)] = None
227
+ return tuple(all_contexts)
228
+
229
+
230
+ def has_merge_queue_rule(all_rules: Sequence[object]) -> bool:
231
+ """Report whether a branch rule sends merges through a merge queue.
232
+
233
+ Args:
234
+ all_rules: The rules GitHub reports as active on the base branch.
235
+
236
+ Returns:
237
+ True when any rule is a merge queue rule.
238
+ """
239
+ return any(_rules_of_type(all_rules, MERGE_QUEUE_RULE_TYPE))
240
+
241
+
242
+ def _rules_of_type(
243
+ all_rules: Sequence[object],
244
+ rule_type: str,
245
+ ) -> list[Mapping[str, object]]:
246
+ return [
247
+ each_rule
248
+ for each_rule in all_rules
249
+ if isinstance(each_rule, Mapping) and each_rule.get(RULE_TYPE_KEY) == rule_type
250
+ ]
251
+
252
+
253
+ def _required_check_records(
254
+ all_rule_fields: Mapping[str, object],
255
+ ) -> list[Mapping[str, object]]:
256
+ all_parameters = all_rule_fields.get(RULE_PARAMETERS_KEY)
257
+ if not isinstance(all_parameters, Mapping):
258
+ return []
259
+ all_checks = all_parameters.get(REQUIRED_STATUS_CHECKS_KEY)
260
+ if not isinstance(all_checks, list):
261
+ return []
262
+ return [
263
+ each_check
264
+ for each_check in all_checks
265
+ if isinstance(each_check, Mapping) and each_check.get(CONTEXT_KEY)
266
+ ]
267
+
268
+
269
+ def _required_context(all_check_fields: Mapping[str, object]) -> RequiredContext:
270
+ integration_id = all_check_fields.get(INTEGRATION_ID_KEY)
271
+ return RequiredContext(
272
+ context=str(all_check_fields[CONTEXT_KEY]),
273
+ integration_id=integration_id if isinstance(integration_id, int) else None,
274
+ )
275
+
276
+
277
+ def unmet_check_state(
278
+ required: RequiredContext,
279
+ all_check_runs: Sequence[object],
280
+ all_statuses: Sequence[object],
281
+ ) -> str | None:
282
+ """Return the state of a required check that is not passing on the head.
283
+
284
+ Args:
285
+ required: The check the branch rule requires.
286
+ all_check_runs: The check runs reported on the head commit.
287
+ all_statuses: The latest commit status per context on the head.
288
+
289
+ Returns:
290
+ None when the newest matching check run, or failing that the commit
291
+ status of that name, passes. Otherwise its conclusion, ``pending``,
292
+ or ``missing`` when nothing of that name reported.
293
+ """
294
+ newest_run = _newest_matching_run(required, all_check_runs)
295
+ if newest_run is not None:
296
+ return _check_run_state(newest_run)
297
+ status = _matching_status(required.context, all_statuses)
298
+ if status is not None:
299
+ return _status_state(status)
300
+ return MISSING_CHECK_STATE
301
+
302
+
303
+ def _newest_matching_run(
304
+ required: RequiredContext,
305
+ all_check_runs: Sequence[object],
306
+ ) -> Mapping[str, object] | None:
307
+ all_matching_runs = [
308
+ each_run
309
+ for each_run in all_check_runs
310
+ if isinstance(each_run, Mapping)
311
+ and each_run.get(CHECK_RUN_NAME_KEY) == required.context
312
+ and required.integration_id in (None, _check_run_app_id(each_run))
313
+ ]
314
+ if not all_matching_runs:
315
+ return None
316
+ return max(all_matching_runs, key=_check_run_id)
317
+
318
+
319
+ def _check_run_id(all_run_fields: Mapping[str, object]) -> int:
320
+ run_id = all_run_fields.get(CHECK_RUN_ID_KEY)
321
+ return run_id if isinstance(run_id, int) else 0
322
+
323
+
324
+ def _check_run_app_id(all_run_fields: Mapping[str, object]) -> object:
325
+ all_app_fields = all_run_fields.get(CHECK_RUN_APP_KEY)
326
+ if not isinstance(all_app_fields, Mapping):
327
+ return None
328
+ return all_app_fields.get(CHECK_RUN_APP_ID_KEY)
329
+
330
+
331
+ def _check_run_state(all_run_fields: Mapping[str, object]) -> str | None:
332
+ if all_run_fields.get(CHECK_RUN_STATUS_KEY) != CHECK_RUN_COMPLETED_STATUS:
333
+ return PENDING_CHECK_STATE
334
+ conclusion = all_run_fields.get(CHECK_RUN_CONCLUSION_KEY)
335
+ if conclusion in ALL_PASSING_CHECK_CONCLUSIONS:
336
+ return None
337
+ return str(conclusion)
338
+
339
+
340
+ def _matching_status(
341
+ context: str,
342
+ all_statuses: Sequence[object],
343
+ ) -> Mapping[str, object] | None:
344
+ for each_status in all_statuses:
345
+ if isinstance(each_status, Mapping) and each_status.get(CONTEXT_KEY) == context:
346
+ return each_status
347
+ return None
348
+
349
+
350
+ def _status_state(all_status_fields: Mapping[str, object]) -> str | None:
351
+ state = all_status_fields.get(STATUS_STATE_KEY)
352
+ if state == SUCCESS_STATUS_STATE:
353
+ return None
354
+ if state == PENDING_STATUS_STATE:
355
+ return PENDING_CHECK_STATE
356
+ return str(state)
357
+
358
+
118
359
  def verdict_line(
119
360
  slug: str,
120
361
  all_pull_request_fields: Mapping[str, object],
@@ -230,6 +471,137 @@ def read_pull_request(slug: str, number: int, token: str) -> Mapping[str, object
230
471
  return document
231
472
 
232
473
 
474
+ def read_blocked_evidence(
475
+ slug: str,
476
+ all_pull_request_fields: Mapping[str, object],
477
+ token: str,
478
+ ) -> BlockedEvidence:
479
+ """Read what stands behind a ``blocked`` merge state.
480
+
481
+ Args:
482
+ slug: The repository as ``owner/name``.
483
+ all_pull_request_fields: The pull request, carrying its base branch
484
+ and its head commit.
485
+ token: The GitHub token the requests authenticate with.
486
+
487
+ Returns:
488
+ The required checks not passing on the head, how far the head is
489
+ behind the base, and whether a merge queue rule applies.
490
+
491
+ Raises:
492
+ MergeCheckError: A read failed, or answered with another shape.
493
+ """
494
+ base_ref = urllib.parse.quote(
495
+ str(_nested_field(all_pull_request_fields, BASE_KEY, REF_KEY))
496
+ )
497
+ head_sha = str(_nested_field(all_pull_request_fields, HEAD_KEY, SHA_KEY))
498
+ all_rules = _read_branch_rules(slug, base_ref, token)
499
+ return BlockedEvidence(
500
+ all_unmet_checks=_read_unmet_checks(slug, head_sha, all_rules, token),
501
+ behind_by=_read_behind_by(slug, base_ref, head_sha, token),
502
+ has_merge_queue=has_merge_queue_rule(all_rules),
503
+ )
504
+
505
+
506
+ def _read_branch_rules(slug: str, base_ref: str, token: str) -> list[object]:
507
+ return _request_list(
508
+ BRANCH_RULES_ENDPOINT_TEMPLATE.format(
509
+ api_root=GITHUB_API_ROOT, slug=slug, branch=base_ref
510
+ ),
511
+ token,
512
+ )
513
+
514
+
515
+ def _read_unmet_checks(
516
+ slug: str,
517
+ head_sha: str,
518
+ all_rules: Sequence[object],
519
+ token: str,
520
+ ) -> tuple[str, ...]:
521
+ all_statuses = _request_list_field(
522
+ COMBINED_STATUS_ENDPOINT_TEMPLATE.format(
523
+ api_root=GITHUB_API_ROOT,
524
+ slug=slug,
525
+ sha=head_sha,
526
+ query=urllib.parse.urlencode({PER_PAGE_PARAMETER: CHECK_PAGE_SIZE}),
527
+ ),
528
+ token,
529
+ STATUSES_KEY,
530
+ )
531
+ all_unmet_checks: list[str] = []
532
+ for each_required in required_contexts(all_rules):
533
+ all_check_runs = _read_check_runs(slug, head_sha, each_required.context, token)
534
+ state = unmet_check_state(each_required, all_check_runs, all_statuses)
535
+ if state is not None:
536
+ all_unmet_checks.append(
537
+ REQUIRED_CHECK_STATE_TEMPLATE.format(
538
+ context=each_required.context, state=state
539
+ )
540
+ )
541
+ return tuple(all_unmet_checks)
542
+
543
+
544
+ def _read_check_runs(
545
+ slug: str,
546
+ head_sha: str,
547
+ context: str,
548
+ token: str,
549
+ ) -> list[object]:
550
+ query = urllib.parse.urlencode(
551
+ {CHECK_NAME_PARAMETER: context, PER_PAGE_PARAMETER: CHECK_PAGE_SIZE}
552
+ )
553
+ return _request_list_field(
554
+ CHECK_RUNS_ENDPOINT_TEMPLATE.format(
555
+ api_root=GITHUB_API_ROOT, slug=slug, sha=head_sha, query=query
556
+ ),
557
+ token,
558
+ CHECK_RUNS_KEY,
559
+ )
560
+
561
+
562
+ def _read_behind_by(slug: str, base_ref: str, head_sha: str, token: str) -> int:
563
+ behind_by = _request_field(
564
+ COMPARE_ENDPOINT_TEMPLATE.format(
565
+ api_root=GITHUB_API_ROOT, slug=slug, base=base_ref, head=head_sha
566
+ ),
567
+ token,
568
+ BEHIND_BY_KEY,
569
+ )
570
+ return behind_by if isinstance(behind_by, int) else 0
571
+
572
+
573
+ def _nested_field(
574
+ all_fields: Mapping[str, object],
575
+ outer_key: str,
576
+ inner_key: str,
577
+ ) -> object:
578
+ all_inner_fields = all_fields.get(outer_key)
579
+ if not isinstance(all_inner_fields, Mapping) or not all_inner_fields.get(inner_key):
580
+ raise MergeCheckError(f"The pull request carries no {outer_key}.{inner_key}.")
581
+ return all_inner_fields[inner_key]
582
+
583
+
584
+ def _request_list(url: str, token: str) -> list[object]:
585
+ document = _request_json(url, token, None)
586
+ if not isinstance(document, list):
587
+ raise MergeCheckError(str(document))
588
+ return document
589
+
590
+
591
+ def _request_field(url: str, token: str, key: str) -> object:
592
+ document = _request_json(url, token, None)
593
+ if not isinstance(document, Mapping) or key not in document:
594
+ raise MergeCheckError(str(document))
595
+ return document[key]
596
+
597
+
598
+ def _request_list_field(url: str, token: str, key: str) -> list[object]:
599
+ all_records = _request_field(url, token, key)
600
+ if not isinstance(all_records, list):
601
+ raise MergeCheckError(str(all_records))
602
+ return all_records
603
+
604
+
233
605
  def read_unresolved_thread_count(slug: str, number: int, token: str) -> int:
234
606
  """Read how many review threads on a pull request stay open.
235
607
 
@@ -372,7 +744,17 @@ def main(all_arguments: Sequence[str]) -> int:
372
744
  except MergeCheckError as failure:
373
745
  print(failure, file=sys.stderr)
374
746
  return ERROR_EXIT_CODE
375
- reason = hold_reason(all_pull_request_fields, unresolved_thread_count)
747
+ blocked_evidence = None
748
+ if all_pull_request_fields.get(MERGEABLE_STATE_KEY) == MERGEABLE_STATE_BLOCKED:
749
+ try:
750
+ blocked_evidence = read_blocked_evidence(
751
+ parsed.slug, all_pull_request_fields, token
752
+ )
753
+ except MergeCheckError as failure:
754
+ print(failure, file=sys.stderr)
755
+ reason = hold_reason(
756
+ all_pull_request_fields, unresolved_thread_count, blocked_evidence
757
+ )
376
758
  print(verdict_line(parsed.slug, all_pull_request_fields, reason))
377
759
  return HOLD_EXIT_CODE if reason else MERGE_EXIT_CODE
378
760
 
@@ -24,8 +24,44 @@ MERGEABLE_STATE_KEY = "mergeable_state"
24
24
  NUMBER_KEY = "number"
25
25
  TITLE_KEY = "title"
26
26
  HEAD_KEY = "head"
27
+ BASE_KEY = "base"
28
+ REF_KEY = "ref"
27
29
  SHA_KEY = "sha"
28
30
 
31
+ BRANCH_RULES_ENDPOINT_TEMPLATE = "{api_root}/repos/{slug}/rules/branches/{branch}"
32
+ CHECK_RUNS_ENDPOINT_TEMPLATE = (
33
+ "{api_root}/repos/{slug}/commits/{sha}/check-runs?{query}"
34
+ )
35
+ COMBINED_STATUS_ENDPOINT_TEMPLATE = (
36
+ "{api_root}/repos/{slug}/commits/{sha}/status?{query}"
37
+ )
38
+ COMPARE_ENDPOINT_TEMPLATE = "{api_root}/repos/{slug}/compare/{base}...{head}"
39
+ CHECK_NAME_PARAMETER = "check_name"
40
+ PER_PAGE_PARAMETER = "per_page"
41
+ CHECK_PAGE_SIZE = 100
42
+
43
+ RULE_TYPE_KEY = "type"
44
+ RULE_PARAMETERS_KEY = "parameters"
45
+ REQUIRED_STATUS_CHECKS_RULE_TYPE = "required_status_checks"
46
+ MERGE_QUEUE_RULE_TYPE = "merge_queue"
47
+ REQUIRED_STATUS_CHECKS_KEY = "required_status_checks"
48
+ CONTEXT_KEY = "context"
49
+ INTEGRATION_ID_KEY = "integration_id"
50
+ CHECK_RUNS_KEY = "check_runs"
51
+ CHECK_RUN_NAME_KEY = "name"
52
+ CHECK_RUN_ID_KEY = "id"
53
+ CHECK_RUN_APP_KEY = "app"
54
+ CHECK_RUN_APP_ID_KEY = "id"
55
+ CHECK_RUN_STATUS_KEY = "status"
56
+ CHECK_RUN_CONCLUSION_KEY = "conclusion"
57
+ CHECK_RUN_COMPLETED_STATUS = "completed"
58
+ ALL_PASSING_CHECK_CONCLUSIONS = frozenset({"success", "neutral", "skipped"})
59
+ STATUSES_KEY = "statuses"
60
+ STATUS_STATE_KEY = "state"
61
+ SUCCESS_STATUS_STATE = "success"
62
+ PENDING_STATUS_STATE = "pending"
63
+ BEHIND_BY_KEY = "behind_by"
64
+
29
65
  MERGEABLE_STATE_CLEAN = "clean"
30
66
  MERGEABLE_STATE_BEHIND = "behind"
31
67
  MERGEABLE_STATE_DIRTY = "dirty"
@@ -61,6 +97,19 @@ ALL_HOLD_REASONS_BY_STATE = {
61
97
  MERGEABLE_STATE_BLOCKED: BLOCKED_HOLD_REASON,
62
98
  MERGEABLE_STATE_UNSTABLE: UNSTABLE_HOLD_REASON,
63
99
  }
100
+ FAILING_REQUIRED_CHECKS_HOLD_TEMPLATE = (
101
+ "A required check is not passing on this head: {checks}. Read the "
102
+ "failing check, fix it, and push."
103
+ )
104
+ REQUIRED_CHECK_STATE_TEMPLATE = "{context} ({state})"
105
+ REQUIRED_CHECK_SEPARATOR = ", "
106
+ MISSING_CHECK_STATE = "missing"
107
+ PENDING_CHECK_STATE = "pending"
108
+ BEHIND_MERGE_QUEUE_HOLD_TEMPLATE = (
109
+ "Every required check passes, but the head is {count} commit(s) behind "
110
+ "the base branch and the merge queue will not take it. Merge the base "
111
+ "branch into this one and push."
112
+ )
64
113
  UNKNOWN_STATE_HOLD_TEMPLATE = (
65
114
  "GitHub reports the merge state as {state}, which this check does not "
66
115
  "treat as ready. Read the pull request page."
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sys
6
+ import urllib.parse
6
7
  from pathlib import Path
7
8
 
8
9
  import pytest
@@ -14,6 +15,7 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
14
15
  import agent_merge_check
15
16
  from dev_env_scripts_constants.agent_merge_check_constants import (
16
17
  BEHIND_HOLD_REASON,
18
+ BEHIND_MERGE_QUEUE_HOLD_TEMPLATE,
17
19
  BLOCKED_HOLD_REASON,
18
20
  DIRTY_HOLD_REASON,
19
21
  DRAFT_HOLD_REASON,
@@ -315,3 +317,296 @@ def test_an_unreadable_pull_request_exits_two(
315
317
  monkeypatch.setenv("GH_TOKEN", "token")
316
318
  monkeypatch.setattr(agent_merge_check, "read_pull_request", _fail)
317
319
  assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
320
+
321
+
322
+ PULL_REQUEST_4922_HEAD_SHA = "06bc18a1a8761a16e92c1419693a42f22dd4ae7c"
323
+ REQUIRED_CHECK_APP_ID = 15368
324
+ PULL_REQUEST_4922 = {
325
+ "number": 4922,
326
+ "draft": False,
327
+ "mergeable_state": "blocked",
328
+ "base": {"ref": "main"},
329
+ "head": {"sha": PULL_REQUEST_4922_HEAD_SHA},
330
+ }
331
+ MAIN_BRANCH_RULES = [
332
+ {
333
+ "type": "required_status_checks",
334
+ "parameters": {
335
+ "strict_required_status_checks_policy": False,
336
+ "required_status_checks": [
337
+ {"context": "Ruff", "integration_id": REQUIRED_CHECK_APP_ID},
338
+ {"context": "Tip Local green", "integration_id": REQUIRED_CHECK_APP_ID},
339
+ ],
340
+ },
341
+ },
342
+ {"type": "merge_queue", "parameters": {"merge_method": "MERGE"}},
343
+ ]
344
+
345
+
346
+ def _check_run(
347
+ name: str,
348
+ conclusion: str | None,
349
+ run_id: int = 1,
350
+ app_id: int = REQUIRED_CHECK_APP_ID,
351
+ status: str = "completed",
352
+ ) -> dict[str, object]:
353
+ return {
354
+ "id": run_id,
355
+ "name": name,
356
+ "status": status,
357
+ "conclusion": conclusion,
358
+ "app": {"id": app_id},
359
+ }
360
+
361
+
362
+ def _github_answers(
363
+ all_rules: list[object],
364
+ all_check_runs: list[dict[str, object]],
365
+ behind_by: int,
366
+ all_statuses: list[object] | None = None,
367
+ ) -> object:
368
+ def _answer(url: str, token: str, all_payload_fields: object) -> object:
369
+ parsed = urllib.parse.urlparse(url)
370
+ if parsed.path.endswith("/pulls/4922"):
371
+ return PULL_REQUEST_4922
372
+ if parsed.path.endswith("/ccr/review_threads"):
373
+ return []
374
+ if "/rules/branches/" in parsed.path:
375
+ return all_rules
376
+ if parsed.path.endswith("/check-runs"):
377
+ check_name = urllib.parse.parse_qs(parsed.query)["check_name"][0]
378
+ return {
379
+ "check_runs": [
380
+ each_run
381
+ for each_run in all_check_runs
382
+ if each_run["name"] == check_name
383
+ ]
384
+ }
385
+ if parsed.path.endswith("/status"):
386
+ return {"statuses": all_statuses or []}
387
+ if "/compare/" in parsed.path:
388
+ return {"behind_by": behind_by, "ahead_by": 1}
389
+ raise AssertionError(url)
390
+
391
+ return _answer
392
+
393
+
394
+ def _run_main(
395
+ monkeypatch: pytest.MonkeyPatch,
396
+ capsys: pytest.CaptureFixture[str],
397
+ answer: object,
398
+ ) -> tuple[int, str]:
399
+ monkeypatch.setenv("GH_TOKEN", "token")
400
+ monkeypatch.setattr(agent_merge_check, "_request_json", answer)
401
+ exit_code = agent_merge_check.main(["jl-cmd/claude-dev-env", "4922"])
402
+ return exit_code, capsys.readouterr().out
403
+
404
+
405
+ def test_a_green_head_behind_a_merge_queue_base_reports_the_behind_reason(
406
+ monkeypatch: pytest.MonkeyPatch,
407
+ capsys: pytest.CaptureFixture[str],
408
+ ) -> None:
409
+ exit_code, line = _run_main(
410
+ monkeypatch,
411
+ capsys,
412
+ _github_answers(
413
+ MAIN_BRANCH_RULES,
414
+ [
415
+ _check_run("Ruff", "success", run_id=11),
416
+ _check_run("Tip Local green", "success", run_id=12),
417
+ ],
418
+ behind_by=225,
419
+ ),
420
+ )
421
+ assert exit_code == 1
422
+ assert BEHIND_MERGE_QUEUE_HOLD_TEMPLATE.format(count=225) in line
423
+ assert BLOCKED_HOLD_REASON not in line
424
+ assert "06bc18a" in line
425
+
426
+
427
+ def test_a_red_required_check_is_named_ahead_of_the_behind_reason(
428
+ monkeypatch: pytest.MonkeyPatch,
429
+ capsys: pytest.CaptureFixture[str],
430
+ ) -> None:
431
+ exit_code, line = _run_main(
432
+ monkeypatch,
433
+ capsys,
434
+ _github_answers(
435
+ MAIN_BRANCH_RULES,
436
+ [
437
+ _check_run("Ruff", "success", run_id=11),
438
+ _check_run("Tip Local green", "failure", run_id=12),
439
+ ],
440
+ behind_by=225,
441
+ ),
442
+ )
443
+ assert exit_code == 1
444
+ assert "Tip Local green (failure)" in line
445
+ assert "Ruff" not in line
446
+ assert "behind" not in line
447
+
448
+
449
+ def test_an_unreadable_branch_rule_keeps_the_generic_blocked_reason(
450
+ monkeypatch: pytest.MonkeyPatch,
451
+ capsys: pytest.CaptureFixture[str],
452
+ ) -> None:
453
+ routed_answer = _github_answers(MAIN_BRANCH_RULES, [], behind_by=225)
454
+
455
+ def _answer(url: str, token: str, all_payload_fields: object) -> object:
456
+ if "/rules/branches/" in url:
457
+ raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
458
+ return routed_answer(url, token, all_payload_fields)
459
+
460
+ exit_code, line = _run_main(monkeypatch, capsys, _answer)
461
+ assert exit_code == 1
462
+ assert BLOCKED_HOLD_REASON in line
463
+
464
+
465
+ def _evidence(
466
+ all_unmet_checks: tuple[str, ...] = (),
467
+ behind_by: int = 0,
468
+ has_merge_queue: bool = True,
469
+ ) -> agent_merge_check.BlockedEvidence:
470
+ return agent_merge_check.BlockedEvidence(
471
+ all_unmet_checks=all_unmet_checks,
472
+ behind_by=behind_by,
473
+ has_merge_queue=has_merge_queue,
474
+ )
475
+
476
+
477
+ @pytest.mark.parametrize(
478
+ ("evidence", "expected_reason"),
479
+ [
480
+ (_evidence(behind_by=0), BLOCKED_HOLD_REASON),
481
+ (_evidence(behind_by=3, has_merge_queue=False), BLOCKED_HOLD_REASON),
482
+ (
483
+ _evidence(behind_by=3),
484
+ BEHIND_MERGE_QUEUE_HOLD_TEMPLATE.format(count=3),
485
+ ),
486
+ ],
487
+ )
488
+ def test_a_blocked_head_with_every_required_check_green_reads_its_reason(
489
+ evidence: agent_merge_check.BlockedEvidence,
490
+ expected_reason: str,
491
+ ) -> None:
492
+ assert (
493
+ agent_merge_check.hold_reason(
494
+ _pull_request(mergeable_state="blocked"), 0, evidence
495
+ )
496
+ == expected_reason
497
+ )
498
+
499
+
500
+ def test_blocked_evidence_is_ignored_for_a_draft() -> None:
501
+ assert (
502
+ agent_merge_check.hold_reason(
503
+ _pull_request(mergeable_state="blocked", draft=True),
504
+ 0,
505
+ _evidence(behind_by=3),
506
+ )
507
+ == DRAFT_HOLD_REASON
508
+ )
509
+
510
+
511
+ def test_required_contexts_merge_every_required_status_checks_rule() -> None:
512
+ all_rules = [
513
+ *MAIN_BRANCH_RULES,
514
+ {
515
+ "type": "required_status_checks",
516
+ "parameters": {
517
+ "required_status_checks": [
518
+ {"context": "Ruff", "integration_id": REQUIRED_CHECK_APP_ID},
519
+ {"context": "Semgrep"},
520
+ ]
521
+ },
522
+ },
523
+ ]
524
+ assert agent_merge_check.required_contexts(all_rules) == (
525
+ agent_merge_check.RequiredContext("Ruff", REQUIRED_CHECK_APP_ID),
526
+ agent_merge_check.RequiredContext("Tip Local green", REQUIRED_CHECK_APP_ID),
527
+ agent_merge_check.RequiredContext("Semgrep", None),
528
+ )
529
+
530
+
531
+ def test_a_rule_set_without_a_merge_queue_rule_reports_none() -> None:
532
+ assert agent_merge_check.has_merge_queue_rule(MAIN_BRANCH_RULES)
533
+ assert not agent_merge_check.has_merge_queue_rule(MAIN_BRANCH_RULES[:1])
534
+
535
+
536
+ RUFF_FROM_REQUIRED_APP = agent_merge_check.RequiredContext(
537
+ "Ruff", REQUIRED_CHECK_APP_ID
538
+ )
539
+
540
+
541
+ @pytest.mark.parametrize(
542
+ ("all_check_runs", "all_statuses", "expected_state"),
543
+ [
544
+ ([_check_run("Ruff", "success")], [], None),
545
+ ([_check_run("Ruff", "skipped")], [], None),
546
+ ([_check_run("Ruff", "failure")], [], "failure"),
547
+ ([_check_run("Ruff", None, status="in_progress")], [], "pending"),
548
+ (
549
+ [
550
+ _check_run("Ruff", "failure", run_id=1),
551
+ _check_run("Ruff", "success", run_id=2),
552
+ ],
553
+ [],
554
+ None,
555
+ ),
556
+ (
557
+ [
558
+ _check_run("Ruff", "success", run_id=2),
559
+ _check_run("Ruff", "failure", run_id=3),
560
+ ],
561
+ [],
562
+ "failure",
563
+ ),
564
+ ([_check_run("Ruff", "success", app_id=1)], [], "missing"),
565
+ ([], [{"context": "Ruff", "state": "success"}], None),
566
+ ([], [{"context": "Ruff", "state": "pending"}], "pending"),
567
+ ([], [{"context": "Ruff", "state": "error"}], "error"),
568
+ ([], [], "missing"),
569
+ ],
570
+ )
571
+ def test_unmet_check_state_reads_the_newest_report_of_the_required_check(
572
+ all_check_runs: list[object],
573
+ all_statuses: list[object],
574
+ expected_state: str | None,
575
+ ) -> None:
576
+ assert (
577
+ agent_merge_check.unmet_check_state(
578
+ RUFF_FROM_REQUIRED_APP, all_check_runs, all_statuses
579
+ )
580
+ == expected_state
581
+ )
582
+
583
+
584
+ def test_read_blocked_evidence_reads_the_pull_request_4922_shape(
585
+ monkeypatch: pytest.MonkeyPatch,
586
+ ) -> None:
587
+ monkeypatch.setattr(
588
+ agent_merge_check,
589
+ "_request_json",
590
+ _github_answers(
591
+ MAIN_BRANCH_RULES,
592
+ [
593
+ _check_run("Ruff", "success", run_id=11),
594
+ _check_run("Tip Local green", "success", run_id=12),
595
+ ],
596
+ behind_by=225,
597
+ ),
598
+ )
599
+ assert agent_merge_check.read_blocked_evidence(
600
+ "jl-cmd/claude-dev-env", PULL_REQUEST_4922, "token"
601
+ ) == _evidence(behind_by=225)
602
+
603
+
604
+ def test_blocked_hold_reason_names_every_unmet_required_check() -> None:
605
+ reason = agent_merge_check.blocked_hold_reason(
606
+ _evidence(
607
+ all_unmet_checks=("Ruff (failure)", "Tip Local green (missing)"),
608
+ behind_by=225,
609
+ )
610
+ )
611
+ assert "Ruff (failure), Tip Local green (missing)" in reason
612
+ assert "behind" not in reason