claude-dev-env 8.19.0 → 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/.agents/skills/step-notes/scripts/step_notes_constants/config/test_constants.py +22 -0
- package/hooks/hooks_constants/test_step_note_gate_constants.py +19 -0
- package/package.json +1 -1
- package/scripts/agent_merge_check.py +383 -1
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +49 -0
- package/scripts/test_agent_merge_check.py +295 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from step_notes_constants.config import constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_flag_path_matches_the_step_notes_location() -> None:
|
|
5
|
+
assert constants.STEP_NOTES_ON_FLAG_PATH.parts[-2:] == (".claude", ".step-notes-on")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_actions_match_the_toggle_command() -> None:
|
|
9
|
+
assert (
|
|
10
|
+
constants.ON_ACTION,
|
|
11
|
+
constants.OFF_ACTION,
|
|
12
|
+
constants.STATUS_ACTION,
|
|
13
|
+
constants.FLIP_ACTION,
|
|
14
|
+
) == ("on", "off", "status", "flip")
|
|
15
|
+
assert constants.ALL_ACTIONS == ("on", "off", "status", "flip")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_reports_describe_the_flag_state() -> None:
|
|
19
|
+
assert constants.ON_REPORT == (
|
|
20
|
+
"Step notes are on. Each tool call needs a short status line before it."
|
|
21
|
+
)
|
|
22
|
+
assert constants.OFF_REPORT == "Step notes are off. Tool calls run without a status line."
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from hooks_constants import step_note_gate_constants as constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_gate_uses_the_step_notes_flag_location() -> None:
|
|
5
|
+
assert constants.STEP_NOTES_ON_FLAG_PATH.parts[-2:] == (".claude", ".step-notes-on")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_gate_uses_transcript_roles_and_block_types() -> None:
|
|
9
|
+
assert (constants.USER_ROLE, constants.ASSISTANT_ROLE) == ("user", "assistant")
|
|
10
|
+
assert (constants.TEXT_BLOCK_TYPE, constants.TOOL_USE_BLOCK_TYPE) == (
|
|
11
|
+
"text",
|
|
12
|
+
"tool_use",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_gate_exit_codes_and_message_match_the_hook_contract() -> None:
|
|
17
|
+
assert (constants.ALLOW_EXIT_CODE, constants.BLOCK_EXIT_CODE) == (0, 2)
|
|
18
|
+
assert "Status line missing." in constants.BLOCK_MESSAGE
|
|
19
|
+
assert "/step-notes" in constants.BLOCK_MESSAGE
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|