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