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.
- package/bin/install.mjs +29 -0
- package/bin/install.settings-defaults.test.mjs +53 -0
- package/bin/merge_settings_defaults.mjs +50 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +2 -1
- package/rules/review-closure-is-a-check.md +2 -0
- package/scripts/agent_merge_check.py +508 -9
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +89 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +6 -0
- package/scripts/review_closure_model.py +25 -5
- package/scripts/test_agent_merge_check.py +467 -0
- package/scripts/tests/test_review_closure.py +1 -0
- package/scripts/tests/test_review_closure_model.py +49 -4
- package/settings.json +2 -1
|
@@ -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,
|
|
102
|
-
for an open review thread. None
|
|
103
|
-
the agent that drives it to merge
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|