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
|
@@ -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,
|
|
@@ -288,6 +290,11 @@ def test_a_ready_pull_request_exits_zero(monkeypatch: pytest.MonkeyPatch) -> Non
|
|
|
288
290
|
"read_unresolved_thread_count",
|
|
289
291
|
lambda slug, number, token: 0,
|
|
290
292
|
)
|
|
293
|
+
monkeypatch.setattr(
|
|
294
|
+
agent_merge_check,
|
|
295
|
+
"read_merge_queue_ejection",
|
|
296
|
+
lambda slug, number, token: False,
|
|
297
|
+
)
|
|
291
298
|
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 0
|
|
292
299
|
|
|
293
300
|
|
|
@@ -303,6 +310,11 @@ def test_a_held_pull_request_exits_one(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
303
310
|
"read_unresolved_thread_count",
|
|
304
311
|
lambda slug, number, token: 0,
|
|
305
312
|
)
|
|
313
|
+
monkeypatch.setattr(
|
|
314
|
+
agent_merge_check,
|
|
315
|
+
"read_merge_queue_ejection",
|
|
316
|
+
lambda slug, number, token: False,
|
|
317
|
+
)
|
|
306
318
|
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 1
|
|
307
319
|
|
|
308
320
|
|
|
@@ -315,3 +327,458 @@ def test_an_unreadable_pull_request_exits_two(
|
|
|
315
327
|
monkeypatch.setenv("GH_TOKEN", "token")
|
|
316
328
|
monkeypatch.setattr(agent_merge_check, "read_pull_request", _fail)
|
|
317
329
|
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
HEAD_COMMITTED_AT = "2026-09-26T16:00:00Z"
|
|
333
|
+
PULL_REQUEST_4922_HEAD_SHA = "06bc18a1a8761a16e92c1419693a42f22dd4ae7c"
|
|
334
|
+
REQUIRED_CHECK_APP_ID = 15368
|
|
335
|
+
PULL_REQUEST_4922 = {
|
|
336
|
+
"number": 4922,
|
|
337
|
+
"draft": False,
|
|
338
|
+
"mergeable_state": "blocked",
|
|
339
|
+
"base": {"ref": "main"},
|
|
340
|
+
"head": {"sha": PULL_REQUEST_4922_HEAD_SHA},
|
|
341
|
+
}
|
|
342
|
+
MAIN_BRANCH_RULES = [
|
|
343
|
+
{
|
|
344
|
+
"type": "required_status_checks",
|
|
345
|
+
"parameters": {
|
|
346
|
+
"strict_required_status_checks_policy": False,
|
|
347
|
+
"required_status_checks": [
|
|
348
|
+
{"context": "Ruff", "integration_id": REQUIRED_CHECK_APP_ID},
|
|
349
|
+
{"context": "Tip Local green", "integration_id": REQUIRED_CHECK_APP_ID},
|
|
350
|
+
],
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
{"type": "merge_queue", "parameters": {"merge_method": "MERGE"}},
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _check_run(
|
|
358
|
+
name: str,
|
|
359
|
+
conclusion: str | None,
|
|
360
|
+
run_id: int = 1,
|
|
361
|
+
app_id: int = REQUIRED_CHECK_APP_ID,
|
|
362
|
+
status: str = "completed",
|
|
363
|
+
) -> dict[str, object]:
|
|
364
|
+
return {
|
|
365
|
+
"id": run_id,
|
|
366
|
+
"name": name,
|
|
367
|
+
"status": status,
|
|
368
|
+
"conclusion": conclusion,
|
|
369
|
+
"app": {"id": app_id},
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _github_answers(
|
|
374
|
+
all_rules: list[object],
|
|
375
|
+
all_check_runs: list[dict[str, object]],
|
|
376
|
+
behind_by: int,
|
|
377
|
+
all_statuses: list[object] | None = None,
|
|
378
|
+
) -> object:
|
|
379
|
+
def _answer(url: str, token: str, all_payload_fields: object) -> object:
|
|
380
|
+
parsed = urllib.parse.urlparse(url)
|
|
381
|
+
if parsed.path.endswith("/pulls/4922"):
|
|
382
|
+
return PULL_REQUEST_4922
|
|
383
|
+
if parsed.path.endswith("/ccr/review_threads"):
|
|
384
|
+
return []
|
|
385
|
+
if "/rules/branches/" in parsed.path:
|
|
386
|
+
return all_rules
|
|
387
|
+
if parsed.path.endswith("/check-runs"):
|
|
388
|
+
check_name = urllib.parse.parse_qs(parsed.query)["check_name"][0]
|
|
389
|
+
return {
|
|
390
|
+
"check_runs": [
|
|
391
|
+
each_run
|
|
392
|
+
for each_run in all_check_runs
|
|
393
|
+
if each_run["name"] == check_name
|
|
394
|
+
]
|
|
395
|
+
}
|
|
396
|
+
if parsed.path.endswith("/status"):
|
|
397
|
+
return {"statuses": all_statuses or []}
|
|
398
|
+
if "/compare/" in parsed.path:
|
|
399
|
+
return {"behind_by": behind_by, "ahead_by": 1}
|
|
400
|
+
if parsed.path.endswith("/graphql"):
|
|
401
|
+
return _merge_queue_document([])
|
|
402
|
+
raise AssertionError(url)
|
|
403
|
+
|
|
404
|
+
return _answer
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _merge_queue_document(all_removals: list[dict[str, object]]) -> object:
|
|
408
|
+
return {
|
|
409
|
+
"data": {
|
|
410
|
+
"repository": {
|
|
411
|
+
"pullRequest": {
|
|
412
|
+
"commits": {
|
|
413
|
+
"nodes": [{"commit": {"committedDate": HEAD_COMMITTED_AT}}]
|
|
414
|
+
},
|
|
415
|
+
"timelineItems": {"nodes": all_removals},
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _run_main(
|
|
423
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
424
|
+
capsys: pytest.CaptureFixture[str],
|
|
425
|
+
answer: object,
|
|
426
|
+
) -> tuple[int, str]:
|
|
427
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
428
|
+
monkeypatch.setattr(agent_merge_check, "_request_json", answer)
|
|
429
|
+
exit_code = agent_merge_check.main(["jl-cmd/claude-dev-env", "4922"])
|
|
430
|
+
return exit_code, capsys.readouterr().out
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_a_green_head_behind_a_merge_queue_base_reports_the_behind_reason(
|
|
434
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
435
|
+
capsys: pytest.CaptureFixture[str],
|
|
436
|
+
) -> None:
|
|
437
|
+
exit_code, line = _run_main(
|
|
438
|
+
monkeypatch,
|
|
439
|
+
capsys,
|
|
440
|
+
_github_answers(
|
|
441
|
+
MAIN_BRANCH_RULES,
|
|
442
|
+
[
|
|
443
|
+
_check_run("Ruff", "success", run_id=11),
|
|
444
|
+
_check_run("Tip Local green", "success", run_id=12),
|
|
445
|
+
],
|
|
446
|
+
behind_by=225,
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
assert exit_code == 1
|
|
450
|
+
assert BEHIND_MERGE_QUEUE_HOLD_TEMPLATE.format(count=225) in line
|
|
451
|
+
assert BLOCKED_HOLD_REASON not in line
|
|
452
|
+
assert "06bc18a" in line
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_a_red_required_check_is_named_ahead_of_the_behind_reason(
|
|
456
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
457
|
+
capsys: pytest.CaptureFixture[str],
|
|
458
|
+
) -> None:
|
|
459
|
+
exit_code, line = _run_main(
|
|
460
|
+
monkeypatch,
|
|
461
|
+
capsys,
|
|
462
|
+
_github_answers(
|
|
463
|
+
MAIN_BRANCH_RULES,
|
|
464
|
+
[
|
|
465
|
+
_check_run("Ruff", "success", run_id=11),
|
|
466
|
+
_check_run("Tip Local green", "failure", run_id=12),
|
|
467
|
+
],
|
|
468
|
+
behind_by=225,
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
assert exit_code == 1
|
|
472
|
+
assert "Tip Local green (failure)" in line
|
|
473
|
+
assert "Ruff" not in line
|
|
474
|
+
assert "behind" not in line
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_an_unreadable_branch_rule_keeps_the_generic_blocked_reason(
|
|
478
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
479
|
+
capsys: pytest.CaptureFixture[str],
|
|
480
|
+
) -> None:
|
|
481
|
+
routed_answer = _github_answers(MAIN_BRANCH_RULES, [], behind_by=225)
|
|
482
|
+
|
|
483
|
+
def _answer(url: str, token: str, all_payload_fields: object) -> object:
|
|
484
|
+
if "/rules/branches/" in url:
|
|
485
|
+
raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
|
|
486
|
+
return routed_answer(url, token, all_payload_fields)
|
|
487
|
+
|
|
488
|
+
exit_code, line = _run_main(monkeypatch, capsys, _answer)
|
|
489
|
+
assert exit_code == 1
|
|
490
|
+
assert BLOCKED_HOLD_REASON in line
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _evidence(
|
|
494
|
+
all_unmet_checks: tuple[str, ...] = (),
|
|
495
|
+
behind_by: int = 0,
|
|
496
|
+
has_merge_queue: bool = True,
|
|
497
|
+
) -> agent_merge_check.BlockedEvidence:
|
|
498
|
+
return agent_merge_check.BlockedEvidence(
|
|
499
|
+
all_unmet_checks=all_unmet_checks,
|
|
500
|
+
behind_by=behind_by,
|
|
501
|
+
has_merge_queue=has_merge_queue,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@pytest.mark.parametrize(
|
|
506
|
+
("evidence", "expected_reason"),
|
|
507
|
+
[
|
|
508
|
+
(_evidence(behind_by=0), BLOCKED_HOLD_REASON),
|
|
509
|
+
(_evidence(behind_by=3, has_merge_queue=False), BLOCKED_HOLD_REASON),
|
|
510
|
+
(
|
|
511
|
+
_evidence(behind_by=3),
|
|
512
|
+
BEHIND_MERGE_QUEUE_HOLD_TEMPLATE.format(count=3),
|
|
513
|
+
),
|
|
514
|
+
],
|
|
515
|
+
)
|
|
516
|
+
def test_a_blocked_head_with_every_required_check_green_reads_its_reason(
|
|
517
|
+
evidence: agent_merge_check.BlockedEvidence,
|
|
518
|
+
expected_reason: str,
|
|
519
|
+
) -> None:
|
|
520
|
+
assert (
|
|
521
|
+
agent_merge_check.hold_reason(
|
|
522
|
+
_pull_request(mergeable_state="blocked"), 0, evidence
|
|
523
|
+
)
|
|
524
|
+
== expected_reason
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def test_blocked_evidence_is_ignored_for_a_draft() -> None:
|
|
529
|
+
assert (
|
|
530
|
+
agent_merge_check.hold_reason(
|
|
531
|
+
_pull_request(mergeable_state="blocked", draft=True),
|
|
532
|
+
0,
|
|
533
|
+
_evidence(behind_by=3),
|
|
534
|
+
)
|
|
535
|
+
== DRAFT_HOLD_REASON
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def test_required_contexts_merge_every_required_status_checks_rule() -> None:
|
|
540
|
+
all_rules = [
|
|
541
|
+
*MAIN_BRANCH_RULES,
|
|
542
|
+
{
|
|
543
|
+
"type": "required_status_checks",
|
|
544
|
+
"parameters": {
|
|
545
|
+
"required_status_checks": [
|
|
546
|
+
{"context": "Ruff", "integration_id": REQUIRED_CHECK_APP_ID},
|
|
547
|
+
{"context": "Semgrep"},
|
|
548
|
+
]
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
]
|
|
552
|
+
assert agent_merge_check.required_contexts(all_rules) == (
|
|
553
|
+
agent_merge_check.RequiredContext("Ruff", REQUIRED_CHECK_APP_ID),
|
|
554
|
+
agent_merge_check.RequiredContext("Tip Local green", REQUIRED_CHECK_APP_ID),
|
|
555
|
+
agent_merge_check.RequiredContext("Semgrep", None),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def test_a_rule_set_without_a_merge_queue_rule_reports_none() -> None:
|
|
560
|
+
assert agent_merge_check.has_merge_queue_rule(MAIN_BRANCH_RULES)
|
|
561
|
+
assert not agent_merge_check.has_merge_queue_rule(MAIN_BRANCH_RULES[:1])
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
RUFF_FROM_REQUIRED_APP = agent_merge_check.RequiredContext(
|
|
565
|
+
"Ruff", REQUIRED_CHECK_APP_ID
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@pytest.mark.parametrize(
|
|
570
|
+
("all_check_runs", "all_statuses", "expected_state"),
|
|
571
|
+
[
|
|
572
|
+
([_check_run("Ruff", "success")], [], None),
|
|
573
|
+
([_check_run("Ruff", "skipped")], [], None),
|
|
574
|
+
([_check_run("Ruff", "failure")], [], "failure"),
|
|
575
|
+
([_check_run("Ruff", None, status="in_progress")], [], "pending"),
|
|
576
|
+
(
|
|
577
|
+
[
|
|
578
|
+
_check_run("Ruff", "failure", run_id=1),
|
|
579
|
+
_check_run("Ruff", "success", run_id=2),
|
|
580
|
+
],
|
|
581
|
+
[],
|
|
582
|
+
None,
|
|
583
|
+
),
|
|
584
|
+
(
|
|
585
|
+
[
|
|
586
|
+
_check_run("Ruff", "success", run_id=2),
|
|
587
|
+
_check_run("Ruff", "failure", run_id=3),
|
|
588
|
+
],
|
|
589
|
+
[],
|
|
590
|
+
"failure",
|
|
591
|
+
),
|
|
592
|
+
([_check_run("Ruff", "success", app_id=1)], [], "missing"),
|
|
593
|
+
([], [{"context": "Ruff", "state": "success"}], None),
|
|
594
|
+
([], [{"context": "Ruff", "state": "pending"}], "pending"),
|
|
595
|
+
([], [{"context": "Ruff", "state": "error"}], "error"),
|
|
596
|
+
([], [], "missing"),
|
|
597
|
+
],
|
|
598
|
+
)
|
|
599
|
+
def test_unmet_check_state_reads_the_newest_report_of_the_required_check(
|
|
600
|
+
all_check_runs: list[object],
|
|
601
|
+
all_statuses: list[object],
|
|
602
|
+
expected_state: str | None,
|
|
603
|
+
) -> None:
|
|
604
|
+
assert (
|
|
605
|
+
agent_merge_check.unmet_check_state(
|
|
606
|
+
RUFF_FROM_REQUIRED_APP, all_check_runs, all_statuses
|
|
607
|
+
)
|
|
608
|
+
== expected_state
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def test_read_blocked_evidence_reads_the_pull_request_4922_shape(
|
|
613
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
614
|
+
) -> None:
|
|
615
|
+
monkeypatch.setattr(
|
|
616
|
+
agent_merge_check,
|
|
617
|
+
"_request_json",
|
|
618
|
+
_github_answers(
|
|
619
|
+
MAIN_BRANCH_RULES,
|
|
620
|
+
[
|
|
621
|
+
_check_run("Ruff", "success", run_id=11),
|
|
622
|
+
_check_run("Tip Local green", "success", run_id=12),
|
|
623
|
+
],
|
|
624
|
+
behind_by=225,
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
assert agent_merge_check.read_blocked_evidence(
|
|
628
|
+
"jl-cmd/claude-dev-env", PULL_REQUEST_4922, "token"
|
|
629
|
+
) == _evidence(behind_by=225)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def test_blocked_hold_reason_names_every_unmet_required_check() -> None:
|
|
633
|
+
reason = agent_merge_check.blocked_hold_reason(
|
|
634
|
+
_evidence(
|
|
635
|
+
all_unmet_checks=("Ruff (failure)", "Tip Local green (missing)"),
|
|
636
|
+
behind_by=225,
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
assert "Ruff (failure), Tip Local green (missing)" in reason
|
|
640
|
+
assert "behind" not in reason
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
QUEUED_PULL_REQUEST = {
|
|
644
|
+
"number": 1442,
|
|
645
|
+
"draft": False,
|
|
646
|
+
"mergeable_state": "clean",
|
|
647
|
+
"base": {"ref": "main"},
|
|
648
|
+
"head": {"sha": "ab845eb6945650055ebe852312a9057b9f067d6a"},
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _removal(created_at: str, reason: str) -> dict[str, object]:
|
|
653
|
+
return {"createdAt": created_at, "reason": reason}
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _merge_queue_answers(
|
|
657
|
+
all_removals: list[dict[str, object]],
|
|
658
|
+
) -> object:
|
|
659
|
+
def _answer(url: str, token: str, all_payload_fields: object) -> object:
|
|
660
|
+
if url.endswith("/pulls/1442"):
|
|
661
|
+
return QUEUED_PULL_REQUEST
|
|
662
|
+
if url.endswith("/ccr/review_threads"):
|
|
663
|
+
return []
|
|
664
|
+
if url.endswith("/graphql"):
|
|
665
|
+
return _merge_queue_document(all_removals)
|
|
666
|
+
raise AssertionError(url)
|
|
667
|
+
|
|
668
|
+
return _answer
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _run_queued_main(
|
|
672
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
673
|
+
capsys: pytest.CaptureFixture[str],
|
|
674
|
+
all_removals: list[dict[str, object]],
|
|
675
|
+
) -> tuple[int, str]:
|
|
676
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
677
|
+
monkeypatch.setattr(
|
|
678
|
+
agent_merge_check, "_request_json", _merge_queue_answers(all_removals)
|
|
679
|
+
)
|
|
680
|
+
exit_code = agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"])
|
|
681
|
+
return exit_code, capsys.readouterr().out
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def test_a_head_ejected_from_the_merge_queue_for_failed_checks_holds(
|
|
685
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
686
|
+
capsys: pytest.CaptureFixture[str],
|
|
687
|
+
) -> None:
|
|
688
|
+
exit_code, line = _run_queued_main(
|
|
689
|
+
monkeypatch,
|
|
690
|
+
capsys,
|
|
691
|
+
[
|
|
692
|
+
_removal("2026-09-26T15:09:20Z", "failed_checks"),
|
|
693
|
+
_removal("2026-09-26T17:11:35Z", "failed_checks"),
|
|
694
|
+
],
|
|
695
|
+
)
|
|
696
|
+
assert exit_code == 1
|
|
697
|
+
assert line.startswith(HOLD_VERDICT_LABEL)
|
|
698
|
+
assert "merge queue" in line
|
|
699
|
+
assert "failed checks" in line
|
|
700
|
+
assert "merge_group" in line
|
|
701
|
+
assert "gh-readonly-queue/main/pr-1442-<sha>" in line
|
|
702
|
+
assert "before you enqueue it again" in line
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def test_a_fix_pushed_after_the_merge_queue_ejection_clears_the_hold(
|
|
706
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
707
|
+
capsys: pytest.CaptureFixture[str],
|
|
708
|
+
) -> None:
|
|
709
|
+
exit_code, line = _run_queued_main(
|
|
710
|
+
monkeypatch,
|
|
711
|
+
capsys,
|
|
712
|
+
[_removal("2026-09-26T15:09:20Z", "failed_checks")],
|
|
713
|
+
)
|
|
714
|
+
assert exit_code == 0
|
|
715
|
+
assert line.startswith(MERGE_VERDICT_LABEL)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def test_a_manual_merge_queue_removal_does_not_hold(
|
|
719
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
720
|
+
capsys: pytest.CaptureFixture[str],
|
|
721
|
+
) -> None:
|
|
722
|
+
exit_code, line = _run_queued_main(
|
|
723
|
+
monkeypatch,
|
|
724
|
+
capsys,
|
|
725
|
+
[
|
|
726
|
+
_removal("2026-09-26T15:09:20Z", "failed_checks"),
|
|
727
|
+
_removal("2026-09-26T17:58:17Z", "manual"),
|
|
728
|
+
],
|
|
729
|
+
)
|
|
730
|
+
assert exit_code == 0
|
|
731
|
+
assert line.startswith(MERGE_VERDICT_LABEL)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def test_a_repository_with_no_merge_queue_removal_may_merge(
|
|
735
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
736
|
+
capsys: pytest.CaptureFixture[str],
|
|
737
|
+
) -> None:
|
|
738
|
+
exit_code, line = _run_queued_main(monkeypatch, capsys, [])
|
|
739
|
+
assert exit_code == 0
|
|
740
|
+
assert line.startswith(MERGE_VERDICT_LABEL)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def test_a_merge_queue_answer_of_another_shape_exits_two(
|
|
744
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
745
|
+
capsys: pytest.CaptureFixture[str],
|
|
746
|
+
) -> None:
|
|
747
|
+
routed_answer = _merge_queue_answers([])
|
|
748
|
+
|
|
749
|
+
def _answer(url: str, token: str, all_payload_fields: object) -> object:
|
|
750
|
+
if url.endswith("/graphql"):
|
|
751
|
+
return {"errors": [{"message": "Bad credentials"}]}
|
|
752
|
+
return routed_answer(url, token, all_payload_fields)
|
|
753
|
+
|
|
754
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
755
|
+
monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
|
|
756
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
|
|
757
|
+
assert capsys.readouterr().out == ""
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@pytest.mark.parametrize(
|
|
761
|
+
("all_removals", "expected_ejection"),
|
|
762
|
+
[
|
|
763
|
+
([_removal("2026-09-26T17:11:35Z", "failed_checks")], True),
|
|
764
|
+
([_removal("2026-09-26T15:09:20Z", "failed_checks")], False),
|
|
765
|
+
([_removal("2026-09-26T17:58:17Z", "manual")], False),
|
|
766
|
+
([], False),
|
|
767
|
+
],
|
|
768
|
+
)
|
|
769
|
+
def test_read_merge_queue_ejection_compares_failed_checks_removals_to_the_head(
|
|
770
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
771
|
+
all_removals: list[dict[str, object]],
|
|
772
|
+
expected_ejection: bool,
|
|
773
|
+
) -> None:
|
|
774
|
+
monkeypatch.setattr(
|
|
775
|
+
agent_merge_check,
|
|
776
|
+
"_request_json",
|
|
777
|
+
lambda url, token, all_payload_fields: _merge_queue_document(all_removals),
|
|
778
|
+
)
|
|
779
|
+
assert (
|
|
780
|
+
agent_merge_check.read_merge_queue_ejection(
|
|
781
|
+
"jl-cmd/claude-dev-env", 1442, "token"
|
|
782
|
+
)
|
|
783
|
+
is expected_ejection
|
|
784
|
+
)
|
|
@@ -21,6 +21,7 @@ from dev_env_scripts_constants.review_closure_constants import (
|
|
|
21
21
|
|
|
22
22
|
DRIVING_AGENT = frozenset({"claude[bot]"})
|
|
23
23
|
REVIEW_BOT = "qodo-merge-pro[bot]"
|
|
24
|
+
REVIEWER = "maintainer"
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def thread(
|
|
@@ -313,10 +314,12 @@ def top_level_comment(
|
|
|
313
314
|
posted_minute: int,
|
|
314
315
|
edited_minute: int | None = None,
|
|
315
316
|
url: str = BOT_SUMMARY_URL,
|
|
317
|
+
is_bot: bool = True,
|
|
316
318
|
) -> model.TopLevelComment:
|
|
317
319
|
return model.TopLevelComment(
|
|
318
320
|
identifier=posted_minute,
|
|
319
321
|
author_login=author_login,
|
|
322
|
+
is_bot=is_bot,
|
|
320
323
|
created_at=at_minute(posted_minute),
|
|
321
324
|
updated_at=at_minute(posted_minute if edited_minute is None else edited_minute),
|
|
322
325
|
url=url,
|
|
@@ -355,9 +358,9 @@ def should_close_every_earlier_comment_with_one_driver_comment() -> None:
|
|
|
355
358
|
assert model.top_level_findings(all_comments, DRIVING_AGENT) == ()
|
|
356
359
|
|
|
357
360
|
|
|
358
|
-
def
|
|
361
|
+
def should_reopen_a_person_comment_edited_after_the_driver_reply() -> None:
|
|
359
362
|
all_comments = (
|
|
360
|
-
top_level_comment(
|
|
363
|
+
top_level_comment(REVIEWER, 1, edited_minute=5, is_bot=False),
|
|
361
364
|
top_level_comment("claude[bot]", 2),
|
|
362
365
|
)
|
|
363
366
|
|
|
@@ -366,6 +369,15 @@ def should_reopen_a_bot_comment_edited_after_the_driver_reply() -> None:
|
|
|
366
369
|
assert [each.subject for each in all_findings] == [BOT_SUMMARY_URL]
|
|
367
370
|
|
|
368
371
|
|
|
372
|
+
def should_keep_a_bot_summary_closed_when_edited_after_the_driver_reply() -> None:
|
|
373
|
+
all_comments = (
|
|
374
|
+
top_level_comment(REVIEW_BOT, 1, edited_minute=5),
|
|
375
|
+
top_level_comment("claude[bot]", 2),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
assert model.top_level_findings(all_comments, DRIVING_AGENT) == ()
|
|
379
|
+
|
|
380
|
+
|
|
369
381
|
def should_leave_a_bot_comment_posted_after_the_driver_open() -> None:
|
|
370
382
|
all_comments = (
|
|
371
383
|
top_level_comment("claude[bot]", 1),
|
|
@@ -403,9 +415,28 @@ def should_close_a_bot_comment_last_edited_at_the_driver_time() -> None:
|
|
|
403
415
|
assert model.top_level_finding(comment, DRIVING_AGENT, at_minute(4)) is None
|
|
404
416
|
|
|
405
417
|
|
|
406
|
-
def
|
|
418
|
+
def should_open_a_person_comment_edited_after_the_driver_time() -> None:
|
|
419
|
+
comment = top_level_comment(REVIEWER, 1, edited_minute=5, is_bot=False)
|
|
420
|
+
|
|
421
|
+
assert model.top_level_finding(
|
|
422
|
+
comment, DRIVING_AGENT, at_minute(4)
|
|
423
|
+
) == model.OpenFinding(
|
|
424
|
+
subject=BOT_SUMMARY_URL,
|
|
425
|
+
reason=TOP_LEVEL_OPEN_REASON_TEMPLATE.format(author=REVIEWER),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def should_close_a_bot_comment_posted_before_and_edited_after_the_driver_time() -> (
|
|
430
|
+
None
|
|
431
|
+
):
|
|
407
432
|
comment = top_level_comment(REVIEW_BOT, 1, edited_minute=5)
|
|
408
433
|
|
|
434
|
+
assert model.top_level_finding(comment, DRIVING_AGENT, at_minute(4)) is None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def should_open_a_bot_comment_posted_after_the_driver_time() -> None:
|
|
438
|
+
comment = top_level_comment(REVIEW_BOT, 5)
|
|
439
|
+
|
|
409
440
|
assert model.top_level_finding(
|
|
410
441
|
comment, DRIVING_AGENT, at_minute(4)
|
|
411
442
|
) == model.OpenFinding(
|
|
@@ -426,7 +457,7 @@ def should_read_a_top_level_comment_record() -> None:
|
|
|
426
457
|
comment = model.parse_top_level_comment(
|
|
427
458
|
{
|
|
428
459
|
"id": 1,
|
|
429
|
-
"user": {"login": REVIEW_BOT},
|
|
460
|
+
"user": {"login": REVIEW_BOT, "type": "Bot"},
|
|
430
461
|
"created_at": "2026-09-26T12:01:00Z",
|
|
431
462
|
"updated_at": "2026-09-26T12:05:00Z",
|
|
432
463
|
"html_url": BOT_SUMMARY_URL,
|
|
@@ -434,3 +465,17 @@ def should_read_a_top_level_comment_record() -> None:
|
|
|
434
465
|
)
|
|
435
466
|
|
|
436
467
|
assert comment == top_level_comment(REVIEW_BOT, 1, edited_minute=5)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def should_read_a_person_top_level_comment_record() -> None:
|
|
471
|
+
comment = model.parse_top_level_comment(
|
|
472
|
+
{
|
|
473
|
+
"id": 1,
|
|
474
|
+
"user": {"login": REVIEWER, "type": "User"},
|
|
475
|
+
"created_at": "2026-09-26T12:01:00Z",
|
|
476
|
+
"updated_at": "2026-09-26T12:05:00Z",
|
|
477
|
+
"html_url": BOT_SUMMARY_URL,
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
assert comment == top_level_comment(REVIEWER, 1, edited_minute=5, is_bot=False)
|