claude-dev-env 1.58.0 → 1.59.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/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_duplicate_body.py +287 -0
- package/hooks/blocking/code_rules_enforcer.py +175 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/autoconverge/SKILL.md +13 -2
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +106 -36
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/update/SKILL.md +37 -5
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Usage:
|
|
4
4
|
python scripts/check_convergence.py --owner <O> --repo <R> --pr-number <N>
|
|
5
|
-
[--bugbot-down]
|
|
5
|
+
[--bugbot-down] [--copilot-down]
|
|
6
6
|
|
|
7
7
|
The bugbot check-run gate is bypassed when either ``--bugbot-down`` is
|
|
8
8
|
passed OR the ``CLAUDE_REVIEWS_DISABLED`` environment variable lists the
|
|
9
9
|
``bugbot`` token, so a Bugbot opt-out closes the gate without the flag.
|
|
10
10
|
|
|
11
|
+
The Copilot review gate and the pending-requested-reviews gate are bypassed
|
|
12
|
+
when either ``--copilot-down`` is passed OR the ``CLAUDE_REVIEWS_DISABLED``
|
|
13
|
+
environment variable lists the ``copilot`` token, so a Copilot outage or
|
|
14
|
+
quota exhaustion closes the broader convergence gate on the remaining
|
|
15
|
+
signals without the flag.
|
|
16
|
+
|
|
11
17
|
Exit codes:
|
|
12
18
|
0 — all pre-conditions met
|
|
13
19
|
1 — one or more conditions not met (FAIL lines printed to stdout)
|
|
@@ -58,7 +64,10 @@ _shared_pr_loop_scripts_dir = (
|
|
|
58
64
|
if str(_shared_pr_loop_scripts_dir) not in sys.path:
|
|
59
65
|
sys.path.insert(0, str(_shared_pr_loop_scripts_dir))
|
|
60
66
|
|
|
61
|
-
from reviews_disabled import
|
|
67
|
+
from reviews_disabled import (
|
|
68
|
+
is_bugbot_disabled_via_env,
|
|
69
|
+
is_copilot_disabled_via_env,
|
|
70
|
+
)
|
|
62
71
|
|
|
63
72
|
|
|
64
73
|
def _is_bugteam_review(review_body: str) -> bool:
|
|
@@ -489,14 +498,141 @@ def _check_no_pending_reviews(
|
|
|
489
498
|
return True, "no pending reviewers"
|
|
490
499
|
|
|
491
500
|
|
|
492
|
-
def
|
|
501
|
+
def _bugbot_conditions(
|
|
502
|
+
*, owner: str, repo: str, number: int, head_sha: str, is_bugbot_down: bool
|
|
503
|
+
) -> list[tuple[str, tuple[bool, str]]]:
|
|
504
|
+
"""Build the Bugbot gate conditions, bypassed when Bugbot is down.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
owner: GitHub repository owner login.
|
|
508
|
+
repo: GitHub repository name.
|
|
509
|
+
number: Pull request number to inspect.
|
|
510
|
+
head_sha: Current PR HEAD SHA the gates evaluate against.
|
|
511
|
+
is_bugbot_down: When True, emit a single bypassed check-run
|
|
512
|
+
condition and skip the review-body content gate entirely.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
The bugbot check-run condition, plus the review-body content
|
|
516
|
+
condition when the check-run gate is present and passing.
|
|
517
|
+
"""
|
|
518
|
+
if is_bugbot_down:
|
|
519
|
+
return [("bugbot_clean_at == current_head", (True, "bypassed (bugbot_down)"))]
|
|
520
|
+
conditions: list[tuple[str, tuple[bool, str]]] = [
|
|
521
|
+
(
|
|
522
|
+
"bugbot_clean_at == current_head",
|
|
523
|
+
_check_bugbot(owner=owner, repo=repo, sha=head_sha),
|
|
524
|
+
)
|
|
525
|
+
]
|
|
526
|
+
if conditions[-1][1][0]:
|
|
527
|
+
conditions.append(
|
|
528
|
+
(
|
|
529
|
+
"bugbot review body clean",
|
|
530
|
+
_check_bugbot_not_dirty(
|
|
531
|
+
owner=owner, repo=repo, number=number, head_sha=head_sha
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
return conditions
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _copilot_review_condition(
|
|
539
|
+
*, owner: str, repo: str, number: int, head_sha: str, is_copilot_down: bool
|
|
540
|
+
) -> tuple[str, tuple[bool, str]]:
|
|
541
|
+
"""Build the Copilot review gate condition, bypassed when Copilot is down.
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
owner: GitHub repository owner login.
|
|
545
|
+
repo: GitHub repository name.
|
|
546
|
+
number: Pull request number to inspect.
|
|
547
|
+
head_sha: Current PR HEAD SHA the gate evaluates against.
|
|
548
|
+
is_copilot_down: When True, return a bypassed condition rather than
|
|
549
|
+
demanding a Copilot review that an outage or quota exhaustion
|
|
550
|
+
keeps from landing.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
The Copilot review gate condition for the current HEAD.
|
|
554
|
+
"""
|
|
555
|
+
if is_copilot_down:
|
|
556
|
+
return ("copilot_clean_at == current_head", (True, "bypassed (copilot_down)"))
|
|
557
|
+
return (
|
|
558
|
+
"copilot_clean_at == current_head",
|
|
559
|
+
_check_bot_review(
|
|
560
|
+
owner=owner,
|
|
561
|
+
repo=repo,
|
|
562
|
+
number=number,
|
|
563
|
+
head_sha=head_sha,
|
|
564
|
+
login_substring=COPILOT_LOGIN_FILTER_SUBSTRING,
|
|
565
|
+
clean_states=ALL_COPILOT_CLEAN_REVIEW_STATES,
|
|
566
|
+
dirty_states=ALL_COPILOT_DIRTY_REVIEW_STATES,
|
|
567
|
+
label="copilot",
|
|
568
|
+
),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _pending_reviews_condition(
|
|
573
|
+
*, owner: str, repo: str, number: int, is_copilot_down: bool
|
|
574
|
+
) -> tuple[str, tuple[bool, str]]:
|
|
575
|
+
"""Build the pending-requested-reviews condition, bypassed when Copilot is down.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
owner: GitHub repository owner login.
|
|
579
|
+
repo: GitHub repository name.
|
|
580
|
+
number: Pull request number to inspect.
|
|
581
|
+
is_copilot_down: When True, return a bypassed condition so a Copilot
|
|
582
|
+
review request that will never land does not strand the gate.
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
The pending-requested-reviews condition, which the gate checks for a
|
|
586
|
+
still-pending Copilot reviewer.
|
|
587
|
+
"""
|
|
588
|
+
if is_copilot_down:
|
|
589
|
+
return ("no pending requested reviews", (True, "bypassed (copilot_down)"))
|
|
590
|
+
return (
|
|
591
|
+
"no pending requested reviews",
|
|
592
|
+
_check_no_pending_reviews(owner=owner, repo=repo, number=number),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _print_conditions(all_conditions: list[tuple[str, tuple[bool, str]]]) -> int:
|
|
597
|
+
"""Print one PASS/FAIL line per condition and return the aggregate exit code.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
all_conditions: Ordered (label, (passed, detail)) gate results.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
``0`` when every condition passed, ``1`` when at least one failed.
|
|
604
|
+
"""
|
|
605
|
+
is_all_passed = True
|
|
606
|
+
for each_index, (each_label, (each_passed, each_detail)) in enumerate(
|
|
607
|
+
all_conditions, start=1
|
|
608
|
+
):
|
|
609
|
+
status = "PASS" if each_passed else "FAIL"
|
|
610
|
+
print(f"{each_index}. {each_label}: {status} — {each_detail}")
|
|
611
|
+
if not each_passed:
|
|
612
|
+
is_all_passed = False
|
|
613
|
+
print()
|
|
614
|
+
if is_all_passed:
|
|
615
|
+
print("All pre-conditions met — PR is ready to mark ready.")
|
|
616
|
+
else:
|
|
617
|
+
print("One or more pre-conditions not met — do not mark ready.")
|
|
618
|
+
return 0 if is_all_passed else 1
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def check_all(
|
|
622
|
+
*,
|
|
623
|
+
owner: str,
|
|
624
|
+
repo: str,
|
|
625
|
+
number: int,
|
|
626
|
+
is_bugbot_down: bool,
|
|
627
|
+
is_copilot_down: bool,
|
|
628
|
+
) -> int:
|
|
493
629
|
"""Run every convergence gate and print one PASS/FAIL line per condition.
|
|
494
630
|
|
|
495
631
|
Args:
|
|
496
632
|
owner: GitHub repository owner login.
|
|
497
633
|
repo: GitHub repository name.
|
|
498
634
|
number: Pull request number to inspect.
|
|
499
|
-
|
|
635
|
+
is_bugbot_down: When True, bypass both the Cursor Bugbot check-run
|
|
500
636
|
presence gate and the bugbot review-body content gate. The
|
|
501
637
|
check-run gate appears in the condition list with a
|
|
502
638
|
``bypassed (bugbot_down)`` note; the review-body gate is
|
|
@@ -504,6 +640,13 @@ def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
|
|
|
504
640
|
declared Cursor Bugbot unreachable on the current HEAD so the
|
|
505
641
|
broader convergence gate can still close on the remaining
|
|
506
642
|
signals.
|
|
643
|
+
is_copilot_down: When True, bypass both the Copilot review gate and
|
|
644
|
+
the pending-requested-reviews gate, each shown with a
|
|
645
|
+
``bypassed (copilot_down)`` note. Callers pass True when Copilot
|
|
646
|
+
is down or out of quota on the current HEAD so the broader
|
|
647
|
+
convergence gate can still close on the remaining signals; the
|
|
648
|
+
bypassed pending gate keeps a Copilot review request that will
|
|
649
|
+
never land from stranding the gate.
|
|
507
650
|
|
|
508
651
|
Returns:
|
|
509
652
|
``0`` when every gate reports PASS, ``1`` when at least one gate
|
|
@@ -522,29 +665,15 @@ def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
|
|
|
522
665
|
print(f"HEAD: {head_sha[:7]}\n")
|
|
523
666
|
|
|
524
667
|
conditions: list[tuple[str, tuple[bool, str]]] = []
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
668
|
+
conditions.extend(
|
|
669
|
+
_bugbot_conditions(
|
|
670
|
+
owner=owner,
|
|
671
|
+
repo=repo,
|
|
672
|
+
number=number,
|
|
673
|
+
head_sha=head_sha,
|
|
674
|
+
is_bugbot_down=is_bugbot_down,
|
|
532
675
|
)
|
|
533
|
-
|
|
534
|
-
conditions.append(
|
|
535
|
-
(
|
|
536
|
-
"bugbot_clean_at == current_head",
|
|
537
|
-
_check_bugbot(owner=owner, repo=repo, sha=head_sha),
|
|
538
|
-
)
|
|
539
|
-
)
|
|
540
|
-
if conditions[-1][1][0]:
|
|
541
|
-
conditions.append(
|
|
542
|
-
(
|
|
543
|
-
"bugbot review body clean",
|
|
544
|
-
_check_bugbot_not_dirty(owner=owner, repo=repo, number=number, head_sha=head_sha),
|
|
545
|
-
)
|
|
546
|
-
)
|
|
547
|
-
|
|
676
|
+
)
|
|
548
677
|
conditions.append(
|
|
549
678
|
(
|
|
550
679
|
"bugteam_clean_at == current_head",
|
|
@@ -553,54 +682,30 @@ def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
|
|
|
553
682
|
),
|
|
554
683
|
)
|
|
555
684
|
)
|
|
556
|
-
|
|
557
685
|
conditions.append(
|
|
558
|
-
(
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
head_sha=head_sha,
|
|
565
|
-
login_substring=COPILOT_LOGIN_FILTER_SUBSTRING,
|
|
566
|
-
clean_states=ALL_COPILOT_CLEAN_REVIEW_STATES,
|
|
567
|
-
dirty_states=ALL_COPILOT_DIRTY_REVIEW_STATES,
|
|
568
|
-
label="copilot",
|
|
569
|
-
),
|
|
686
|
+
_copilot_review_condition(
|
|
687
|
+
owner=owner,
|
|
688
|
+
repo=repo,
|
|
689
|
+
number=number,
|
|
690
|
+
head_sha=head_sha,
|
|
691
|
+
is_copilot_down=is_copilot_down,
|
|
570
692
|
)
|
|
571
693
|
)
|
|
572
|
-
|
|
573
694
|
conditions.append(
|
|
574
695
|
(
|
|
575
696
|
"zero unresolved bot threads",
|
|
576
697
|
_count_unresolved_bot_threads(owner=owner, repo=repo, number=number),
|
|
577
698
|
)
|
|
578
699
|
)
|
|
579
|
-
|
|
580
700
|
conditions.append(
|
|
581
701
|
("PR is mergeable", _get_mergeable(owner=owner, repo=repo, number=number))
|
|
582
702
|
)
|
|
583
|
-
|
|
584
703
|
conditions.append(
|
|
585
|
-
(
|
|
586
|
-
|
|
587
|
-
_check_no_pending_reviews(owner=owner, repo=repo, number=number),
|
|
704
|
+
_pending_reviews_condition(
|
|
705
|
+
owner=owner, repo=repo, number=number, is_copilot_down=is_copilot_down
|
|
588
706
|
)
|
|
589
707
|
)
|
|
590
|
-
|
|
591
|
-
is_all_passed = True
|
|
592
|
-
for each_index, (each_label, (each_passed, each_detail)) in enumerate(conditions, start=1):
|
|
593
|
-
status = "PASS" if each_passed else "FAIL"
|
|
594
|
-
print(f"{each_index}. {each_label}: {status} — {each_detail}")
|
|
595
|
-
if not each_passed:
|
|
596
|
-
is_all_passed = False
|
|
597
|
-
|
|
598
|
-
print()
|
|
599
|
-
if is_all_passed:
|
|
600
|
-
print("All pre-conditions met — PR is ready to mark ready.")
|
|
601
|
-
else:
|
|
602
|
-
print("One or more pre-conditions not met — do not mark ready.")
|
|
603
|
-
return 0 if is_all_passed else 1
|
|
708
|
+
return _print_conditions(conditions)
|
|
604
709
|
|
|
605
710
|
|
|
606
711
|
def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
@@ -611,10 +716,10 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
|
611
716
|
``sys.argv[1:]``.
|
|
612
717
|
|
|
613
718
|
Returns:
|
|
614
|
-
Namespace exposing ``owner``, ``repo``, ``pr_number``,
|
|
615
|
-
``bugbot_down`` attributes. ``bugbot_down``
|
|
616
|
-
|
|
617
|
-
|
|
719
|
+
Namespace exposing ``owner``, ``repo``, ``pr_number``,
|
|
720
|
+
``bugbot_down``, and ``copilot_down`` attributes. ``bugbot_down``
|
|
721
|
+
and ``copilot_down`` default to False so the base hook contract
|
|
722
|
+
(``--owner X --repo Y --pr-number N``) picks up the full gate set.
|
|
618
723
|
"""
|
|
619
724
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
620
725
|
parser.add_argument("--owner", required=True, help="GitHub repository owner")
|
|
@@ -630,6 +735,14 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
|
630
735
|
"declared Cursor Bugbot unreachable on the current HEAD."
|
|
631
736
|
),
|
|
632
737
|
)
|
|
738
|
+
parser.add_argument(
|
|
739
|
+
"--copilot-down",
|
|
740
|
+
action="store_true",
|
|
741
|
+
help=(
|
|
742
|
+
"Bypass the Copilot review gate and the pending-requested-reviews "
|
|
743
|
+
"gate when Copilot is down or out of quota on the current HEAD."
|
|
744
|
+
),
|
|
745
|
+
)
|
|
633
746
|
return parser.parse_args(all_argv)
|
|
634
747
|
|
|
635
748
|
|
|
@@ -647,6 +760,23 @@ def _resolve_bugbot_down(bugbot_down_flag: bool) -> bool:
|
|
|
647
760
|
return bugbot_down_flag or is_bugbot_disabled_via_env()
|
|
648
761
|
|
|
649
762
|
|
|
763
|
+
def _resolve_copilot_down(is_copilot_down_flag: bool) -> bool:
|
|
764
|
+
"""Combine the explicit flag with the CLAUDE_REVIEWS_DISABLED env opt-out.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
is_copilot_down_flag: Value of the ``--copilot-down`` CLI flag.
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
True when the flag is set OR ``CLAUDE_REVIEWS_DISABLED`` lists the
|
|
771
|
+
``copilot`` token, so a Copilot outage signalled through the env var
|
|
772
|
+
bypasses the Copilot gates even when the caller omits the flag. The
|
|
773
|
+
mark-ready blocker hook re-runs this script without the flag, so the
|
|
774
|
+
env token is the only channel a genuine Copilot outage has to pass
|
|
775
|
+
that independent gate.
|
|
776
|
+
"""
|
|
777
|
+
return is_copilot_down_flag or is_copilot_disabled_via_env()
|
|
778
|
+
|
|
779
|
+
|
|
650
780
|
def main(all_arguments: list[str]) -> int:
|
|
651
781
|
"""Run the script end-to-end against parsed CLI arguments.
|
|
652
782
|
|
|
@@ -661,7 +791,8 @@ def main(all_arguments: list[str]) -> int:
|
|
|
661
791
|
owner=arguments.owner,
|
|
662
792
|
repo=arguments.repo,
|
|
663
793
|
number=getattr(arguments, "pr_number"),
|
|
664
|
-
|
|
794
|
+
is_bugbot_down=_resolve_bugbot_down(arguments.bugbot_down),
|
|
795
|
+
is_copilot_down=_resolve_copilot_down(arguments.copilot_down),
|
|
665
796
|
)
|
|
666
797
|
|
|
667
798
|
|
|
@@ -245,6 +245,34 @@ def should_resolve_bugbot_down_false_when_env_disables_only_bugteam(
|
|
|
245
245
|
assert check_convergence._resolve_bugbot_down(False) is False
|
|
246
246
|
|
|
247
247
|
|
|
248
|
+
def should_resolve_copilot_down_true_when_flag_set(
|
|
249
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
250
|
+
) -> None:
|
|
251
|
+
monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
|
|
252
|
+
assert check_convergence._resolve_copilot_down(True) is True
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def should_resolve_copilot_down_true_when_env_disables_copilot(
|
|
256
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
257
|
+
) -> None:
|
|
258
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
|
|
259
|
+
assert check_convergence._resolve_copilot_down(False) is True
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def should_resolve_copilot_down_false_when_flag_unset_and_env_empty(
|
|
263
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
264
|
+
) -> None:
|
|
265
|
+
monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
|
|
266
|
+
assert check_convergence._resolve_copilot_down(False) is False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def should_resolve_copilot_down_false_when_env_disables_only_bugbot(
|
|
270
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
271
|
+
) -> None:
|
|
272
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
|
|
273
|
+
assert check_convergence._resolve_copilot_down(False) is False
|
|
274
|
+
|
|
275
|
+
|
|
248
276
|
def should_bypass_bugbot_gates_when_bugbot_down_is_true(
|
|
249
277
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
250
278
|
) -> None:
|
|
@@ -324,7 +352,7 @@ def should_bypass_bugbot_gates_when_bugbot_down_is_true(
|
|
|
324
352
|
)
|
|
325
353
|
|
|
326
354
|
exit_code = check_convergence.check_all(
|
|
327
|
-
owner="o", repo="r", number=1,
|
|
355
|
+
owner="o", repo="r", number=1, is_bugbot_down=True, is_copilot_down=False
|
|
328
356
|
)
|
|
329
357
|
captured_stdout = capsys.readouterr().out
|
|
330
358
|
|
|
@@ -334,6 +362,122 @@ def should_bypass_bugbot_gates_when_bugbot_down_is_true(
|
|
|
334
362
|
assert exit_code == 0
|
|
335
363
|
|
|
336
364
|
|
|
365
|
+
def should_bypass_copilot_gates_when_copilot_down_is_true(
|
|
366
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
367
|
+
) -> None:
|
|
368
|
+
all_invocation_names: list[str] = []
|
|
369
|
+
|
|
370
|
+
def stub_get_pr_head_sha(*, owner: str, repo: str, number: int) -> str:
|
|
371
|
+
return CURRENT_HEAD_SHA
|
|
372
|
+
|
|
373
|
+
def stub_check_bugbot(*, owner: str, repo: str, sha: str) -> tuple[bool, str]:
|
|
374
|
+
return True, "stub passing"
|
|
375
|
+
|
|
376
|
+
def stub_check_bugbot_not_dirty(
|
|
377
|
+
*, owner: str, repo: str, number: int, head_sha: str
|
|
378
|
+
) -> tuple[bool, str]:
|
|
379
|
+
return True, "stub passing"
|
|
380
|
+
|
|
381
|
+
def stub_check_bugteam_clean(
|
|
382
|
+
*, owner: str, repo: str, number: int, head_sha: str
|
|
383
|
+
) -> tuple[bool, str]:
|
|
384
|
+
return True, "stub passing"
|
|
385
|
+
|
|
386
|
+
def stub_check_bot_review_should_not_be_called(
|
|
387
|
+
*,
|
|
388
|
+
owner: str,
|
|
389
|
+
repo: str,
|
|
390
|
+
number: int,
|
|
391
|
+
head_sha: str,
|
|
392
|
+
login_substring: str,
|
|
393
|
+
clean_states: tuple[str, ...],
|
|
394
|
+
dirty_states: tuple[str, ...],
|
|
395
|
+
label: str,
|
|
396
|
+
) -> tuple[bool, str]:
|
|
397
|
+
all_invocation_names.append("_check_bot_review")
|
|
398
|
+
raise AssertionError(
|
|
399
|
+
"_check_bot_review must not be invoked when copilot_down=True"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def stub_count_unresolved_bot_threads(
|
|
403
|
+
*, owner: str, repo: str, number: int
|
|
404
|
+
) -> tuple[bool, str]:
|
|
405
|
+
return True, "stub passing"
|
|
406
|
+
|
|
407
|
+
def stub_get_mergeable(
|
|
408
|
+
*, owner: str, repo: str, number: int
|
|
409
|
+
) -> tuple[bool, str]:
|
|
410
|
+
return True, "stub passing"
|
|
411
|
+
|
|
412
|
+
def stub_check_no_pending_reviews_should_not_be_called(
|
|
413
|
+
*, owner: str, repo: str, number: int
|
|
414
|
+
) -> tuple[bool, str]:
|
|
415
|
+
all_invocation_names.append("_check_no_pending_reviews")
|
|
416
|
+
raise AssertionError(
|
|
417
|
+
"_check_no_pending_reviews must not be invoked when copilot_down=True"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
monkeypatch.setattr(check_convergence, "_get_pr_head_sha", stub_get_pr_head_sha)
|
|
421
|
+
monkeypatch.setattr(check_convergence, "_check_bugbot", stub_check_bugbot)
|
|
422
|
+
monkeypatch.setattr(
|
|
423
|
+
check_convergence, "_check_bugbot_not_dirty", stub_check_bugbot_not_dirty
|
|
424
|
+
)
|
|
425
|
+
monkeypatch.setattr(check_convergence, "_check_bugteam_clean", stub_check_bugteam_clean)
|
|
426
|
+
monkeypatch.setattr(
|
|
427
|
+
check_convergence,
|
|
428
|
+
"_check_bot_review",
|
|
429
|
+
stub_check_bot_review_should_not_be_called,
|
|
430
|
+
)
|
|
431
|
+
monkeypatch.setattr(
|
|
432
|
+
check_convergence, "_count_unresolved_bot_threads", stub_count_unresolved_bot_threads
|
|
433
|
+
)
|
|
434
|
+
monkeypatch.setattr(check_convergence, "_get_mergeable", stub_get_mergeable)
|
|
435
|
+
monkeypatch.setattr(
|
|
436
|
+
check_convergence,
|
|
437
|
+
"_check_no_pending_reviews",
|
|
438
|
+
stub_check_no_pending_reviews_should_not_be_called,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
exit_code = check_convergence.check_all(
|
|
442
|
+
owner="o", repo="r", number=1, is_bugbot_down=False, is_copilot_down=True
|
|
443
|
+
)
|
|
444
|
+
captured_stdout = capsys.readouterr().out
|
|
445
|
+
|
|
446
|
+
assert "_check_bot_review" not in all_invocation_names
|
|
447
|
+
assert "_check_no_pending_reviews" not in all_invocation_names
|
|
448
|
+
assert "bypassed (copilot_down)" in captured_stdout
|
|
449
|
+
assert exit_code == 0
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def should_accept_copilot_down_flag_in_parsed_arguments() -> None:
|
|
453
|
+
arguments = check_convergence.parse_arguments(
|
|
454
|
+
["--owner", "o", "--repo", "r", "--pr-number", "1", "--copilot-down"]
|
|
455
|
+
)
|
|
456
|
+
assert arguments.copilot_down is True
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def should_return_zero_from_print_conditions_when_every_condition_passes(
|
|
460
|
+
capsys: pytest.CaptureFixture[str],
|
|
461
|
+
) -> None:
|
|
462
|
+
exit_code = check_convergence._print_conditions(
|
|
463
|
+
[("gate one", (True, "ok")), ("gate two", (True, "ok"))]
|
|
464
|
+
)
|
|
465
|
+
captured_stdout = capsys.readouterr().out
|
|
466
|
+
assert exit_code == 0
|
|
467
|
+
assert "All pre-conditions met" in captured_stdout
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def should_return_one_from_print_conditions_when_any_condition_fails(
|
|
471
|
+
capsys: pytest.CaptureFixture[str],
|
|
472
|
+
) -> None:
|
|
473
|
+
exit_code = check_convergence._print_conditions(
|
|
474
|
+
[("gate one", (True, "ok")), ("gate two", (False, "nope"))]
|
|
475
|
+
)
|
|
476
|
+
captured_stdout = capsys.readouterr().out
|
|
477
|
+
assert exit_code == 1
|
|
478
|
+
assert "One or more pre-conditions not met" in captured_stdout
|
|
479
|
+
|
|
480
|
+
|
|
337
481
|
def should_propagate_systemexit_from_get_pr_head_sha(
|
|
338
482
|
monkeypatch: pytest.MonkeyPatch,
|
|
339
483
|
) -> None:
|
|
@@ -347,6 +491,33 @@ def should_propagate_systemexit_from_get_pr_head_sha(
|
|
|
347
491
|
)
|
|
348
492
|
|
|
349
493
|
with pytest.raises(SystemExit) as exc_info:
|
|
350
|
-
check_convergence.check_all(
|
|
494
|
+
check_convergence.check_all(
|
|
495
|
+
owner="o", repo="r", number=1, is_bugbot_down=False, is_copilot_down=False
|
|
496
|
+
)
|
|
351
497
|
|
|
352
498
|
assert exc_info.value.code == check_convergence.EXIT_CODE_GH_ERROR
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def should_derive_copilot_down_from_env_when_main_omits_the_flag(
|
|
502
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
503
|
+
) -> None:
|
|
504
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
|
|
505
|
+
captured_copilot_down: list[bool] = []
|
|
506
|
+
|
|
507
|
+
def stub_check_all(
|
|
508
|
+
*,
|
|
509
|
+
owner: str,
|
|
510
|
+
repo: str,
|
|
511
|
+
number: int,
|
|
512
|
+
is_bugbot_down: bool,
|
|
513
|
+
is_copilot_down: bool,
|
|
514
|
+
) -> int:
|
|
515
|
+
captured_copilot_down.append(is_copilot_down)
|
|
516
|
+
return 0
|
|
517
|
+
|
|
518
|
+
monkeypatch.setattr(check_convergence, "check_all", stub_check_all)
|
|
519
|
+
exit_code = check_convergence.main(
|
|
520
|
+
["--owner", "o", "--repo", "r", "--pr-number", "1"]
|
|
521
|
+
)
|
|
522
|
+
assert exit_code == 0
|
|
523
|
+
assert captured_copilot_down == [True]
|
package/skills/update/SKILL.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: update
|
|
3
|
-
description: Fast-forwards a local git repository's main branch to a remote's main, after confirming both the local repo path and the source remote through AskUserQuestion. Fetches the chosen remote, checks that the move is a true fast-forward (never a force, never a merge commit), and updates main whether or not main is the checked-out branch. Use when the user says "/update", "update main", "fast-forward main", "sync main from origin", "pull latest main into <path>", or "bring main up to date". Triggers on "/update", "update main", "fast-forward main", "sync main".
|
|
3
|
+
description: Fast-forwards a local git repository's main branch to a remote's main, after confirming both the local repo path and the source remote through AskUserQuestion. Fetches the chosen remote, checks that the move is a true fast-forward (never a force, never a merge commit), and updates main whether or not main is the checked-out branch. When main is not the checked-out branch, it then offers to switch the checkout to main so the update reaches the files on disk. Use when the user says "/update", "update main", "fast-forward main", "sync main from origin", "pull latest main into <path>", or "bring main up to date". Triggers on "/update", "update main", "fast-forward main", "sync main".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# update
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
Fast-forwards the local `main` branch of a given repository to a chosen remote's `main`. The move is always a true fast-forward: the skill fetches the remote, checks that local `main` is an ancestor of the remote's `main`, and advances the ref. It never forces, never creates a merge commit, and never
|
|
10
|
+
Fast-forwards the local `main` branch of a given repository to a chosen remote's `main`. The move is always a true fast-forward: the skill fetches the remote, checks that local `main` is an ancestor of the remote's `main`, and advances the ref. It never forces, never creates a merge commit, and never rewrites any branch other than `main`. When `main` is not the checked-out branch, a final confirmed step offers to switch the checkout to `main` so the new commits reach the files on disk — the only branch switch the skill makes, and only after the operator approves it.
|
|
11
11
|
|
|
12
12
|
The repository is whatever path the user gives as the `/update <path>` argument. With no argument, the default is the current repository's top level. Either way the path is confirmed before any write.
|
|
13
13
|
|
|
@@ -121,11 +121,42 @@ git -C "<path>" status --short
|
|
|
121
121
|
- **Checked-out branch.** When a branch other than `main` is checked out, say so plainly: the fast-forward moves a ref only and no file on disk changes. Code that runs from this checkout comes from the checked-out branch, not from `main`.
|
|
122
122
|
- **Dirty tracked files.** List every modified tracked file from `status --short`. Uncommitted edits sit on top of the checked-out branch and are what runs — flag them, because a ref update can neither see nor repair them. When the tree is clean, report "working tree clean".
|
|
123
123
|
|
|
124
|
+
### Phase 5 — Offer to land `main` on disk
|
|
125
|
+
|
|
126
|
+
A fast-forward of the `main` ref leaves the files on disk untouched when another branch is checked out. The new commits exist in the repo but not in the working tree — the gap Phase 4 reports. This phase offers to close it.
|
|
127
|
+
|
|
128
|
+
Skip this phase and finish when the checked-out branch is already `main`: the new commits are already on disk.
|
|
129
|
+
|
|
130
|
+
Otherwise, when the checked-out branch is not `main`, run two safety checks before offering a switch:
|
|
131
|
+
|
|
132
|
+
1. **Tree clean of tracked changes.** Read `git -C "<path>" status --porcelain` and treat any line that does not start with `??` as a change to a tracked file. If any exist, do not offer the switch: report that `main` moved in the ref only, that switching would risk the uncommitted work, and stop. Never stash, reset, or discard. Untracked files (lines starting with `??`) are fine — they carry across a switch.
|
|
133
|
+
2. **`main` free to check out.** Read `git -C "<path>" worktree list`. If another worktree holds `main`, an in-place switch is impossible; report that path and stop.
|
|
134
|
+
|
|
135
|
+
When both checks pass, ask **one** `AskUserQuestion` — header "Get on disk":
|
|
136
|
+
|
|
137
|
+
- Recommended first choice, "Switch checkout to main": `git -C "<path>" checkout main` puts the new commits on disk. The branch you were on keeps its commits and is left unchanged.
|
|
138
|
+
- Second choice, "Stay on `<branch>`": leave the checkout where it is. The update stays in the `main` ref only and reaches disk later.
|
|
139
|
+
|
|
140
|
+
On "Switch", run:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
git -C "<path>" checkout main
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
If `checkout` reports it would overwrite untracked files, stop and report those paths — never pass `-f`, which would drop the operator's untracked file. On success, confirm the branch and the tip now on disk:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
git -C "<path>" branch --show-current
|
|
150
|
+
git -C "<path>" log --oneline -1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
On "Stay", report that `main` is current in the ref and the new content reaches disk only after a later switch.
|
|
154
|
+
|
|
124
155
|
## Constraints (non-negotiable)
|
|
125
156
|
|
|
126
157
|
- **Fast-forward only.** If the remote's `main` is not a descendant of local `main`, stop. Never `--force`, never `branch -f`, never a merge commit. Divergence is a job for `/rebase`.
|
|
127
158
|
- **Always confirm both** the path and the source remote first, even when the path is given as an argument. Skipping a confirmation is not allowed — the confirmation is the point of this skill.
|
|
128
|
-
- **
|
|
159
|
+
- **Switch the checkout only with approval.** The fast-forward itself touches the `main` ref alone. Switching the checked-out branch to `main` happens only in Phase 5, only after the operator approves the `AskUserQuestion`, only with a tree clean of tracked changes, and never with `-f`. The skill never switches to any branch but `main`.
|
|
129
160
|
- **Never discard local work.** A dirty tree blocks the in-place fast-forward; stop and report rather than stash or reset.
|
|
130
161
|
|
|
131
162
|
## Gotchas
|
|
@@ -135,12 +166,13 @@ git -C "<path>" status --short
|
|
|
135
166
|
- `<remote>/main` only moves after an explicit `git fetch`. Fetch inside every run; never compare against a remote-tracking ref left over from an earlier fetch.
|
|
136
167
|
- `origin` is not always the source of truth. When a fork is `origin` and the canonical repo is another remote (often `upstream`), the confirmed remote should be the canonical one, not whichever is named `origin`.
|
|
137
168
|
- Quote the path on every command — `git -C "<path>"` — so paths with spaces or a NAS drive letter survive.
|
|
138
|
-
- "Up to date" describes the `main` ref, not the running code. A checkout deployed on a feature branch, or carrying uncommitted edits, runs that branch plus those edits regardless of where `main` points. Phase 4's checkout-state report exists so the operator sees that gap on every run.
|
|
169
|
+
- "Up to date" describes the `main` ref, not the running code. A checkout deployed on a feature branch, or carrying uncommitted edits, runs that branch plus those edits regardless of where `main` points. Phase 4's checkout-state report exists so the operator sees that gap on every run, and Phase 5 offers to close it.
|
|
170
|
+
- `git checkout main` carries untracked files across unchanged, but refuses with "untracked working tree files would be overwritten" when an untracked file sits at a path `main` tracks. Phase 5 reports those paths and stops rather than passing `-f`, which would drop the operator's untracked file.
|
|
139
171
|
|
|
140
172
|
## What this skill does NOT do
|
|
141
173
|
|
|
142
174
|
- Does not push, open a PR, or change any branch other than `main`.
|
|
143
|
-
- Does not create
|
|
175
|
+
- Does not create feature branches, and switches only to `main` (Phase 5, with approval) — creating or switching to any other branch is `/fresh-branch`.
|
|
144
176
|
- Does not reconcile a diverged `main` — that is `/rebase`.
|
|
145
177
|
|
|
146
178
|
## File index
|