claude-dev-env 1.94.0 → 2.0.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/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +41 -33
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
- package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
- package/_shared/advisor/scripts/tier_model_ids.py +18 -18
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
- package/_shared/pr-loop/worker-spawn.md +186 -0
- package/agents/code-verifier.md +1 -1
- package/bin/CLAUDE.md +10 -1
- package/bin/ever-shipped-skills.mjs +73 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +140 -7
- package/bin/install.prune.test.mjs +457 -0
- package/docs/CODE_RULES.md +1 -1
- package/hooks/advisory/refactor_guard.py +3 -4
- package/hooks/blocking/CLAUDE.md +7 -1
- package/hooks/blocking/block_main_commit.py +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_mock_completeness.py +1 -1
- package/hooks/blocking/code_rules_optional_params.py +2 -2
- package/hooks/blocking/code_rules_shared.py +83 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_test_layout.py +9 -3
- package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
- package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
- package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
- package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
- package/hooks/blocking/inventory_intent_records/records.py +271 -0
- package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
- package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
- package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
- package/hooks/blocking/pii_payload_scan.py +138 -42
- package/hooks/blocking/pii_prevention_blocker.py +185 -291
- package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
- package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
- package/hooks/blocking/plain_language_blocker.py +138 -4
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/sensitive_file_protector.py +114 -48
- package/hooks/blocking/tdd_enforcer.py +97 -601
- package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/test_code_rules_shared.py +181 -0
- package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
- package/hooks/blocking/test_pii_payload_scan.py +168 -0
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
- package/hooks/blocking/test_sensitive_file_protector.py +185 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/tests/conftest.py +10 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
- package/hooks/blocking/tests/test_repository_exemption.py +105 -0
- package/hooks/blocking/tests/test_repository_resolution.py +108 -0
- package/hooks/diagnostic/hook_log_extractor.py +12 -10
- package/hooks/git-hooks/post_commit.py +3 -4
- package/hooks/hooks_constants/CLAUDE.md +4 -2
- package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +52 -4
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/CLAUDE.md +1 -1
- package/hooks/validators/README.md +2 -0
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/python_style_checks.py +114 -136
- package/hooks/validators/python_style_helpers.py +95 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/validators/test_python_style_checks.py +0 -164
- package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
- package/hooks/validators/test_python_style_fixes.py +251 -0
- package/hooks/validators/test_python_style_helpers.py +125 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/anti-corollary-tests.md +69 -0
- package/rules/bdd.md +1 -3
- package/rules/code-reviews.md +1 -1
- package/rules/gh-paginate.md +1 -1
- package/rules/plain-language.md +2 -0
- package/scripts/CLAUDE.md +4 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
- package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
- package/scripts/dev_env_scripts_constants/timing.py +7 -1
- package/scripts/grok_headless_runner.py +294 -0
- package/scripts/grok_worker_preflight.py +410 -0
- package/scripts/invoke_code_review.py +463 -0
- package/scripts/resolve_worker_spawn.py +619 -0
- package/scripts/spawn_grok_batch.py +672 -0
- package/scripts/test_grok_headless_runner.py +626 -0
- package/scripts/test_grok_worker_preflight.py +1054 -0
- package/scripts/test_invoke_code_review.py +672 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -0
- package/scripts/test_spawn_grok_batch.py +1017 -0
- package/skills/CLAUDE.md +6 -3
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +33 -11
- package/skills/autoconverge/reference/stop-conditions.md +16 -5
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +202 -18
- package/skills/bugteam/CLAUDE.md +2 -2
- package/skills/bugteam/CONSTRAINTS.md +3 -2
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +18 -13
- package/skills/bugteam/reference/README.md +2 -3
- package/skills/bugteam/reference/audit-and-teammates.md +215 -35
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -2
- package/skills/closeout/SKILL.md +153 -0
- package/skills/closeout/reference/handoff-prompt-template.md +72 -0
- package/skills/closeout/reference/issue-body-templates.md +108 -0
- package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
- package/skills/codex-review/CLAUDE.md +46 -0
- package/skills/codex-review/SKILL.md +181 -0
- package/skills/codex-review/reference/CLAUDE.md +15 -0
- package/skills/codex-review/reference/cli-contract.md +253 -0
- package/skills/codex-review/reference/loop-integration.md +118 -0
- package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
- package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
- package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
- package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
- package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
- package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
- package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
- package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
- package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
- package/skills/codex-review/scripts/run_codex_review.py +415 -0
- package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
- package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
- package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
- package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
- package/skills/codex-review/test_skill_scaffold.py +192 -0
- package/skills/grok-spawn/CLAUDE.md +28 -0
- package/skills/grok-spawn/SKILL.md +226 -0
- package/skills/grok-spawn/reference/flag-profiles.md +132 -0
- package/skills/grok-spawn/reference/worker-briefs.md +152 -0
- package/skills/grokify/SKILL.md +9 -1
- package/skills/grokify/capability-claims.test.mjs +28 -0
- package/skills/grokify/evals/README.md +72 -0
- package/skills/grokify/evals/parse-payload.test.mjs +171 -0
- package/skills/grokify/evals/run-capability-evals.mjs +545 -0
- package/skills/orchestrator/SKILL.md +32 -22
- package/skills/orchestrator-refresh/SKILL.md +12 -8
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-converge/SKILL.md +34 -13
- package/skills/pr-converge/reference/convergence-gates.md +42 -15
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +130 -42
- package/skills/pr-converge/reference/state-schema.md +10 -0
- package/skills/pr-converge/scripts/CLAUDE.md +2 -0
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
- package/skills/pr-converge/scripts/check_convergence.py +605 -29
- package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
- package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
- package/skills/pr-converge/test_step5_host_branch.py +106 -0
- package/skills/pr-fix-protocol/SKILL.md +65 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- package/skills/skill-builder/references/skill-modularity.md +1 -1
- package/skills/team-advisor/SKILL.md +16 -12
- package/system-prompts/software-engineer.xml +7 -6
- package/hooks/validators/test_verify_paths.py +0 -32
- package/hooks/validators/verify_paths.py +0 -57
|
@@ -7,22 +7,43 @@
|
|
|
7
7
|
exit 1 flag: a gate failed (FAIL lines print to stdout)
|
|
8
8
|
exit 2 flag: gh CLI error
|
|
9
9
|
|
|
10
|
+
check_convergence.py --owner O --repo R --pr-number N --fixture PATH [flags]
|
|
11
|
+
Same stdout and exit codes as the live path, reading a frozen GitHub
|
|
12
|
+
API snapshot from PATH instead of calling ``gh`` against current state.
|
|
13
|
+
|
|
10
14
|
Each bypass flag closes one reviewer gate when that reviewer is unavailable.
|
|
11
15
|
``--bugbot-down`` skips the bugbot check-run gate. ``--copilot-down`` skips
|
|
12
16
|
the Copilot review and pending-review gates. ``--bugteam-post-blocked`` skips
|
|
13
|
-
the bugteam CLEAN-review gate.
|
|
17
|
+
the bugteam CLEAN-review gate. ``--codex-down`` skips the Codex review gate.
|
|
14
18
|
|
|
15
19
|
Each flag also honors its own reviews-disabled token: "bugbot", "copilot",
|
|
16
|
-
or "
|
|
17
|
-
exporting the token. The mark-ready blocker hook re-runs this script
|
|
18
|
-
flags, so its convergence re-check reads the bypass from the exported
|
|
19
|
-
instead.
|
|
20
|
+
"bugteam", or "codex". A caller that cannot pass the flag reaches the same
|
|
21
|
+
bypass by exporting the token. The mark-ready blocker hook re-runs this script
|
|
22
|
+
with no flags, so its convergence re-check reads the bypass from the exported
|
|
23
|
+
token instead. For Codex, sticky job-dir state ``codex_down: true`` is a third
|
|
24
|
+
bypass source when the original run could not re-pass ``--codex-down``.
|
|
25
|
+
|
|
26
|
+
Copilot and Bugbot waivers are disk-authoritative.
|
|
27
|
+
When ``~/.claude/settings.json`` is readable, its env block is the single source.
|
|
28
|
+
The frozen process env is only a fallback when that disk read fails (logged once).
|
|
29
|
+
Bugteam's opt-out is env-only via ``_resolve_bugteam_post_blocked``.
|
|
30
|
+
A probe error does not waive the gate; the live GitHub checks still run.
|
|
31
|
+
|
|
32
|
+
The Codex gate is conditional-required: it demands
|
|
33
|
+
``codex_clean_at == current_head`` only when the weekly usage probe reports
|
|
34
|
+
more than the probe's threshold percent left. At or below that threshold, null
|
|
35
|
+
usage, the codex token, or ``codex_down`` never blocks ready.
|
|
20
36
|
"""
|
|
21
37
|
|
|
22
38
|
from __future__ import annotations
|
|
23
39
|
|
|
24
40
|
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import subprocess
|
|
25
44
|
import sys
|
|
45
|
+
from collections.abc import Callable
|
|
46
|
+
from pathlib import Path
|
|
26
47
|
from typing import NamedTuple
|
|
27
48
|
|
|
28
49
|
import _pr_converge_path_setup # noqa: F401
|
|
@@ -35,11 +56,42 @@ from check_convergence_gates import (
|
|
|
35
56
|
_get_pr_head_sha,
|
|
36
57
|
_gh_api_paginated,
|
|
37
58
|
_short_sha,
|
|
59
|
+
_sort_reviews_newest_first,
|
|
60
|
+
_evaluate_mergeable_from_pr_object,
|
|
38
61
|
)
|
|
39
62
|
from check_convergence_thread_gates import (
|
|
40
63
|
_check_no_pending_reviews,
|
|
41
64
|
_count_unresolved_bot_threads,
|
|
42
65
|
)
|
|
66
|
+
from codex_review_scripts_constants.codex_usage_probe_constants import (
|
|
67
|
+
USAGE_REPORT_KEY_PERCENT_LEFT,
|
|
68
|
+
)
|
|
69
|
+
from codex_usage_probe import is_codex_review_required, probe_weekly_usage_via_subprocess
|
|
70
|
+
from pr_converge_scripts_constants.convergence_gate_constants import (
|
|
71
|
+
BUGBOT_DOWN_BYPASS_NOTE,
|
|
72
|
+
CLAUDE_JOB_DIR_ENV_VAR_NAME,
|
|
73
|
+
CODEX_BYPASS_DETAIL,
|
|
74
|
+
CODEX_CLEAN_AT_STATE_KEY,
|
|
75
|
+
CODEX_CLEAN_DETAIL_TEMPLATE,
|
|
76
|
+
CODEX_DOWN_STATE_KEY,
|
|
77
|
+
CODEX_GATE_LABEL,
|
|
78
|
+
CODEX_MISSING_CLEAN_DETAIL_TEMPLATE,
|
|
79
|
+
CODEX_SKIPPED_USAGE_DETAIL,
|
|
80
|
+
COPILOT_DOWN_BYPASS_NOTE,
|
|
81
|
+
FIXTURE_DEFAULT_PENDING_DETAIL,
|
|
82
|
+
FIXTURE_DEFAULT_THREADS_DETAIL,
|
|
83
|
+
FIXTURE_KEY_CODEX_CLEAN_AT,
|
|
84
|
+
FIXTURE_KEY_CODEX_PERCENT_LEFT,
|
|
85
|
+
FIXTURE_KEY_HEAD_SHA,
|
|
86
|
+
FIXTURE_KEY_PENDING_REVIEWS_DETAIL,
|
|
87
|
+
FIXTURE_KEY_PENDING_REVIEWS_PASSED,
|
|
88
|
+
FIXTURE_KEY_PR_OBJECT,
|
|
89
|
+
FIXTURE_KEY_REVIEWS,
|
|
90
|
+
FIXTURE_KEY_UNRESOLVED_BOT_THREADS_DETAIL,
|
|
91
|
+
FIXTURE_KEY_UNRESOLVED_BOT_THREADS_PASSED,
|
|
92
|
+
MINIMUM_ABBREVIATED_SHA_LENGTH,
|
|
93
|
+
PR_CONVERGE_STATE_FILENAME,
|
|
94
|
+
)
|
|
43
95
|
from pr_converge_skill_constants.constants import (
|
|
44
96
|
ALL_COPILOT_CLEAN_REVIEW_STATES,
|
|
45
97
|
ALL_COPILOT_DIRTY_REVIEW_STATES,
|
|
@@ -54,13 +106,33 @@ from pr_converge_skill_constants.constants import (
|
|
|
54
106
|
from reviews_disabled import (
|
|
55
107
|
is_bugbot_disabled_via_env,
|
|
56
108
|
is_bugteam_disabled_via_env,
|
|
109
|
+
is_codex_disabled_via_env,
|
|
57
110
|
is_copilot_disabled_via_env,
|
|
58
111
|
)
|
|
112
|
+
from check_convergence_availability import (
|
|
113
|
+
_resolve_bugbot_waiver,
|
|
114
|
+
_resolve_copilot_waiver,
|
|
115
|
+
_waiver_from_cli_flag,
|
|
116
|
+
)
|
|
59
117
|
|
|
60
118
|
JsonObject = dict[str, object]
|
|
61
119
|
GateCondition = tuple[str, tuple[bool, str]]
|
|
62
120
|
|
|
63
121
|
|
|
122
|
+
class ConvergenceFixture(NamedTuple):
|
|
123
|
+
"""Frozen GitHub API snapshot one fixture gate run evaluates."""
|
|
124
|
+
|
|
125
|
+
head_sha: str
|
|
126
|
+
pr_object: JsonObject
|
|
127
|
+
reviews: list[JsonObject]
|
|
128
|
+
is_zero_unresolved_bot_threads: bool
|
|
129
|
+
unresolved_bot_threads_detail: str
|
|
130
|
+
is_no_pending_reviews: bool
|
|
131
|
+
pending_reviews_detail: str
|
|
132
|
+
codex_percent_left: float | None
|
|
133
|
+
codex_clean_at: str | None
|
|
134
|
+
|
|
135
|
+
|
|
64
136
|
class GateContext(NamedTuple):
|
|
65
137
|
"""The PR coordinates and per-reviewer bypass flags one gate run evaluates."""
|
|
66
138
|
|
|
@@ -71,6 +143,11 @@ class GateContext(NamedTuple):
|
|
|
71
143
|
is_bugbot_down: bool
|
|
72
144
|
is_copilot_down: bool
|
|
73
145
|
is_bugteam_post_blocked: bool
|
|
146
|
+
is_codex_down: bool
|
|
147
|
+
bugbot_bypass_note: str = BUGBOT_DOWN_BYPASS_NOTE
|
|
148
|
+
copilot_bypass_note: str = COPILOT_DOWN_BYPASS_NOTE
|
|
149
|
+
live_codex_clean_at: str | None = None
|
|
150
|
+
fixture: ConvergenceFixture | None = None
|
|
74
151
|
|
|
75
152
|
|
|
76
153
|
def _is_bugteam_review(review_body: str) -> bool:
|
|
@@ -105,6 +182,25 @@ def _bugteam_review_detail(review: JsonObject, head_sha: str) -> tuple[bool, str
|
|
|
105
182
|
return False, f"review {review_id}, dirty bugteam audit, commit {short_commit}"
|
|
106
183
|
|
|
107
184
|
|
|
185
|
+
def _evaluate_bugteam_clean_from_reviews(
|
|
186
|
+
all_reviews: list[JsonObject], head_sha: str
|
|
187
|
+
) -> tuple[bool, str]:
|
|
188
|
+
"""Return whether the newest bugteam audit on HEAD is clean (pure, no network).
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
all_reviews: Review objects newest-first as returned by the GitHub reviews API.
|
|
192
|
+
head_sha: Current PR HEAD commit SHA.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
(passed, detail) for the first bugteam review on HEAD, or a missing-review failure.
|
|
196
|
+
"""
|
|
197
|
+
for each_review in all_reviews:
|
|
198
|
+
detail = _bugteam_review_detail(each_review, head_sha)
|
|
199
|
+
if detail is not None:
|
|
200
|
+
return detail
|
|
201
|
+
return False, f"no bugteam review found on {_short_sha(head_sha)}"
|
|
202
|
+
|
|
203
|
+
|
|
108
204
|
def _check_bugteam_clean(*, owner: str, repo: str, number: int, head_sha: str) -> tuple[bool, str]:
|
|
109
205
|
"""Return whether the newest bugteam audit review on HEAD declares a clean pass."""
|
|
110
206
|
endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
|
|
@@ -114,17 +210,30 @@ def _check_bugteam_clean(*, owner: str, repo: str, number: int, head_sha: str) -
|
|
|
114
210
|
all_flat = _flatten_paginated_reviews(stdout)
|
|
115
211
|
if all_flat is None:
|
|
116
212
|
return False, "unexpected gh api response shape (expected list)"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
213
|
+
return _evaluate_bugteam_clean_from_reviews(all_flat, head_sha)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _bypassed_note(bypass_note: str) -> str:
|
|
217
|
+
"""Format the PASS detail line for a gate bypassed for the given reason."""
|
|
218
|
+
return f"bypassed ({bypass_note})"
|
|
122
219
|
|
|
123
220
|
|
|
124
221
|
def _bugbot_conditions(context: GateContext) -> list[GateCondition]:
|
|
125
222
|
"""Build the Bugbot gate conditions, bypassed when Bugbot is down."""
|
|
126
223
|
if context.is_bugbot_down:
|
|
127
|
-
return [
|
|
224
|
+
return [
|
|
225
|
+
(
|
|
226
|
+
"bugbot_clean_at == current_head",
|
|
227
|
+
(True, _bypassed_note(context.bugbot_bypass_note)),
|
|
228
|
+
)
|
|
229
|
+
]
|
|
230
|
+
if context.fixture is not None:
|
|
231
|
+
return [
|
|
232
|
+
(
|
|
233
|
+
"bugbot_clean_at == current_head",
|
|
234
|
+
(False, "fixture has no bugbot check-runs; pass --bugbot-down"),
|
|
235
|
+
)
|
|
236
|
+
]
|
|
128
237
|
conditions: list[GateCondition] = [
|
|
129
238
|
(
|
|
130
239
|
"bugbot_clean_at == current_head",
|
|
@@ -136,7 +245,10 @@ def _bugbot_conditions(context: GateContext) -> list[GateCondition]:
|
|
|
136
245
|
(
|
|
137
246
|
"bugbot review body clean",
|
|
138
247
|
_check_bugbot_not_dirty(
|
|
139
|
-
owner=context.owner,
|
|
248
|
+
owner=context.owner,
|
|
249
|
+
repo=context.repo,
|
|
250
|
+
number=context.number,
|
|
251
|
+
head_sha=context.head_sha,
|
|
140
252
|
),
|
|
141
253
|
)
|
|
142
254
|
)
|
|
@@ -147,10 +259,18 @@ def _bugteam_condition(context: GateContext) -> GateCondition:
|
|
|
147
259
|
"""Build the bugteam CLEAN-review condition, skipped when the post was blocked."""
|
|
148
260
|
if context.is_bugteam_post_blocked:
|
|
149
261
|
return ("bugteam_clean_at == current_head", (True, "bypassed (bugteam_post_blocked)"))
|
|
262
|
+
if context.fixture is not None:
|
|
263
|
+
return (
|
|
264
|
+
"bugteam_clean_at == current_head",
|
|
265
|
+
_evaluate_bugteam_clean_from_reviews(context.fixture.reviews, context.head_sha),
|
|
266
|
+
)
|
|
150
267
|
return (
|
|
151
268
|
"bugteam_clean_at == current_head",
|
|
152
269
|
_check_bugteam_clean(
|
|
153
|
-
owner=context.owner,
|
|
270
|
+
owner=context.owner,
|
|
271
|
+
repo=context.repo,
|
|
272
|
+
number=context.number,
|
|
273
|
+
head_sha=context.head_sha,
|
|
154
274
|
),
|
|
155
275
|
)
|
|
156
276
|
|
|
@@ -158,7 +278,15 @@ def _bugteam_condition(context: GateContext) -> GateCondition:
|
|
|
158
278
|
def _copilot_review_condition(context: GateContext) -> GateCondition:
|
|
159
279
|
"""Build the Copilot review gate condition, bypassed when Copilot is down."""
|
|
160
280
|
if context.is_copilot_down:
|
|
161
|
-
return (
|
|
281
|
+
return (
|
|
282
|
+
"copilot_clean_at == current_head",
|
|
283
|
+
(True, _bypassed_note(context.copilot_bypass_note)),
|
|
284
|
+
)
|
|
285
|
+
if context.fixture is not None:
|
|
286
|
+
return (
|
|
287
|
+
"copilot_clean_at == current_head",
|
|
288
|
+
(False, "fixture has no copilot review; pass --copilot-down"),
|
|
289
|
+
)
|
|
162
290
|
return (
|
|
163
291
|
"copilot_clean_at == current_head",
|
|
164
292
|
_check_bot_review(
|
|
@@ -174,24 +302,249 @@ def _copilot_review_condition(context: GateContext) -> GateCondition:
|
|
|
174
302
|
)
|
|
175
303
|
|
|
176
304
|
|
|
305
|
+
def _sha_matches_head(stamp_sha: str, head_sha: str) -> bool:
|
|
306
|
+
"""Return True when a clean-at stamp matches the PR HEAD SHA (full or prefix).
|
|
307
|
+
|
|
308
|
+
::
|
|
309
|
+
|
|
310
|
+
stamp 86454e3d..., head 86454e3d... -> True (full match)
|
|
311
|
+
stamp 86454e3, head 86454e3d... -> True (abbreviated stamp)
|
|
312
|
+
stamp 8, head 86454e3d... -> False (below the abbreviation floor)
|
|
313
|
+
|
|
314
|
+
A prefix shorter than an abbreviated SHA matches many commits, so a stamp
|
|
315
|
+
that short never passes the gate.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
stamp_sha: The ``codex_clean_at`` stamp under test.
|
|
319
|
+
head_sha: Current PR HEAD commit SHA.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
True when the two name the same commit.
|
|
323
|
+
"""
|
|
324
|
+
if len(stamp_sha) < MINIMUM_ABBREVIATED_SHA_LENGTH:
|
|
325
|
+
return False
|
|
326
|
+
if len(head_sha) < MINIMUM_ABBREVIATED_SHA_LENGTH:
|
|
327
|
+
return False
|
|
328
|
+
lowered_stamp_sha = stamp_sha.lower()
|
|
329
|
+
lowered_head_sha = head_sha.lower()
|
|
330
|
+
return lowered_head_sha.startswith(lowered_stamp_sha) or lowered_stamp_sha.startswith(
|
|
331
|
+
lowered_head_sha
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _evaluate_codex_clean(
|
|
336
|
+
*,
|
|
337
|
+
read_percent_left: Callable[[], float | None],
|
|
338
|
+
codex_clean_at: str | None,
|
|
339
|
+
head_sha: str,
|
|
340
|
+
) -> tuple[bool, str]:
|
|
341
|
+
"""Return whether the conditional Codex gate passes for the given stamp and usage.
|
|
342
|
+
|
|
343
|
+
::
|
|
344
|
+
|
|
345
|
+
clean stamp on head -> pass (usage never read)
|
|
346
|
+
no stamp, percent None or <= limit -> skip (never blocks)
|
|
347
|
+
no stamp, percent > limit -> fail
|
|
348
|
+
|
|
349
|
+
A stamp on the current HEAD settles the gate on its own, so the usage read
|
|
350
|
+
stays behind it: the weekly probe spawns a Codex subprocess, and the common
|
|
351
|
+
already-clean tick has no reason to pay for one.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
read_percent_left: Reads weekly Codex percent remaining, or None when unknown.
|
|
355
|
+
codex_clean_at: HEAD SHA where Codex last reported clean, or None.
|
|
356
|
+
head_sha: Current PR HEAD commit SHA.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
(passed, detail) for the Codex gate condition line.
|
|
360
|
+
"""
|
|
361
|
+
if codex_clean_at is not None and _sha_matches_head(codex_clean_at, head_sha):
|
|
362
|
+
return True, CODEX_CLEAN_DETAIL_TEMPLATE % _short_sha(head_sha)
|
|
363
|
+
if not is_codex_review_required(read_percent_left()):
|
|
364
|
+
return True, CODEX_SKIPPED_USAGE_DETAIL
|
|
365
|
+
return False, CODEX_MISSING_CLEAN_DETAIL_TEMPLATE % _short_sha(head_sha)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _probe_codex_percent_left() -> float | None:
|
|
369
|
+
"""Probe weekly Codex usage and return percent remaining, or None when unknown."""
|
|
370
|
+
try:
|
|
371
|
+
usage_report = probe_weekly_usage_via_subprocess()
|
|
372
|
+
except (
|
|
373
|
+
FileNotFoundError,
|
|
374
|
+
OSError,
|
|
375
|
+
subprocess.TimeoutExpired,
|
|
376
|
+
subprocess.SubprocessError,
|
|
377
|
+
json.JSONDecodeError,
|
|
378
|
+
ValueError,
|
|
379
|
+
TypeError,
|
|
380
|
+
KeyError,
|
|
381
|
+
):
|
|
382
|
+
return None
|
|
383
|
+
raw_percent = usage_report.get(USAGE_REPORT_KEY_PERCENT_LEFT)
|
|
384
|
+
if isinstance(raw_percent, bool):
|
|
385
|
+
return None
|
|
386
|
+
if isinstance(raw_percent, (int, float)):
|
|
387
|
+
return float(raw_percent)
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _read_job_state() -> dict[str, object]:
|
|
392
|
+
"""Read the single-PR job-dir state file, or an empty mapping when unreadable.
|
|
393
|
+
|
|
394
|
+
::
|
|
395
|
+
|
|
396
|
+
CLAUDE_JOB_DIR unset -> {}
|
|
397
|
+
state file absent -> {}
|
|
398
|
+
corrupt or non-object -> {}
|
|
399
|
+
{"codex_down": true} -> {"codex_down": True}
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
None.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
The parsed state object, or an empty dict when no state is readable.
|
|
406
|
+
"""
|
|
407
|
+
job_directory = os.environ.get(CLAUDE_JOB_DIR_ENV_VAR_NAME)
|
|
408
|
+
if not job_directory:
|
|
409
|
+
return {}
|
|
410
|
+
state_path = Path(job_directory) / PR_CONVERGE_STATE_FILENAME
|
|
411
|
+
if not state_path.is_file():
|
|
412
|
+
return {}
|
|
413
|
+
try:
|
|
414
|
+
payload = json.loads(state_path.read_text(encoding="utf-8"))
|
|
415
|
+
except (OSError, json.JSONDecodeError):
|
|
416
|
+
return {}
|
|
417
|
+
if not isinstance(payload, dict):
|
|
418
|
+
return {}
|
|
419
|
+
return payload
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _read_codex_clean_at_from_job_state(all_job_state_fields: dict[str, object]) -> str | None:
|
|
423
|
+
"""Read ``codex_clean_at`` from an already-read job-dir state mapping."""
|
|
424
|
+
clean_at = all_job_state_fields.get(CODEX_CLEAN_AT_STATE_KEY)
|
|
425
|
+
if isinstance(clean_at, str) and clean_at:
|
|
426
|
+
return clean_at
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _read_codex_down_from_job_state(all_job_state_fields: dict[str, object]) -> bool:
|
|
431
|
+
"""Return True when job-dir state records sticky ``codex_down`` as JSON true.
|
|
432
|
+
|
|
433
|
+
::
|
|
434
|
+
|
|
435
|
+
{"codex_down": true} -> True
|
|
436
|
+
{"codex_down": false} -> False
|
|
437
|
+
missing key / corrupt -> False
|
|
438
|
+
|
|
439
|
+
Only exact boolean true counts; strings, numbers, and missing values do not.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
all_job_state_fields: Parsed job-dir state, empty when none is readable.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
True when the state records ``codex_down`` as JSON true.
|
|
446
|
+
"""
|
|
447
|
+
return all_job_state_fields.get(CODEX_DOWN_STATE_KEY) is True
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _resolve_live_codex_clean_at(
|
|
451
|
+
cli_codex_clean_at: str | None, all_job_state_fields: dict[str, object]
|
|
452
|
+
) -> str | None:
|
|
453
|
+
"""Prefer the CLI stamp, then the job-dir state stamp, for the live Codex gate.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
cli_codex_clean_at: Stamp passed through ``--codex-clean-at``, or None.
|
|
457
|
+
all_job_state_fields: Parsed job-dir state, empty when none is readable.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
The stamp the live Codex gate checks against HEAD, or None.
|
|
461
|
+
"""
|
|
462
|
+
if cli_codex_clean_at:
|
|
463
|
+
return cli_codex_clean_at
|
|
464
|
+
return _read_codex_clean_at_from_job_state(all_job_state_fields)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _codex_condition(context: GateContext) -> GateCondition:
|
|
468
|
+
"""Build the conditional Codex gate, bypassed when Codex is down."""
|
|
469
|
+
if context.is_codex_down:
|
|
470
|
+
return (CODEX_GATE_LABEL, (True, CODEX_BYPASS_DETAIL))
|
|
471
|
+
if context.fixture is not None:
|
|
472
|
+
fixture_percent_left = context.fixture.codex_percent_left
|
|
473
|
+
return (
|
|
474
|
+
CODEX_GATE_LABEL,
|
|
475
|
+
_evaluate_codex_clean(
|
|
476
|
+
read_percent_left=lambda: fixture_percent_left,
|
|
477
|
+
codex_clean_at=context.fixture.codex_clean_at,
|
|
478
|
+
head_sha=context.head_sha,
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
return (
|
|
482
|
+
CODEX_GATE_LABEL,
|
|
483
|
+
_evaluate_codex_clean(
|
|
484
|
+
read_percent_left=_probe_codex_percent_left,
|
|
485
|
+
codex_clean_at=context.live_codex_clean_at,
|
|
486
|
+
head_sha=context.head_sha,
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
177
491
|
def _pending_reviews_condition(context: GateContext) -> GateCondition:
|
|
178
492
|
"""Build the pending-requested-reviews condition, bypassed when Copilot is down."""
|
|
179
493
|
if context.is_copilot_down:
|
|
180
|
-
return (
|
|
494
|
+
return (
|
|
495
|
+
"no pending requested reviews",
|
|
496
|
+
(True, _bypassed_note(context.copilot_bypass_note)),
|
|
497
|
+
)
|
|
498
|
+
if context.fixture is not None:
|
|
499
|
+
return (
|
|
500
|
+
"no pending requested reviews",
|
|
501
|
+
(
|
|
502
|
+
context.fixture.is_no_pending_reviews,
|
|
503
|
+
context.fixture.pending_reviews_detail,
|
|
504
|
+
),
|
|
505
|
+
)
|
|
181
506
|
return (
|
|
182
507
|
"no pending requested reviews",
|
|
183
508
|
_check_no_pending_reviews(owner=context.owner, repo=context.repo, number=context.number),
|
|
184
509
|
)
|
|
185
510
|
|
|
186
511
|
|
|
512
|
+
def _mergeable_condition(context: GateContext) -> GateCondition:
|
|
513
|
+
"""Build the mergeable condition from live gh or the frozen fixture PR object."""
|
|
514
|
+
if context.fixture is not None:
|
|
515
|
+
return (
|
|
516
|
+
"PR is mergeable",
|
|
517
|
+
_evaluate_mergeable_from_pr_object(context.fixture.pr_object),
|
|
518
|
+
)
|
|
519
|
+
return (
|
|
520
|
+
"PR is mergeable",
|
|
521
|
+
_get_mergeable(owner=context.owner, repo=context.repo, number=context.number),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _threads_condition(context: GateContext) -> GateCondition:
|
|
526
|
+
"""Build the unresolved-bot-threads condition from live gh or the fixture."""
|
|
527
|
+
if context.fixture is not None:
|
|
528
|
+
return (
|
|
529
|
+
"zero unresolved bot threads",
|
|
530
|
+
(
|
|
531
|
+
context.fixture.is_zero_unresolved_bot_threads,
|
|
532
|
+
context.fixture.unresolved_bot_threads_detail,
|
|
533
|
+
),
|
|
534
|
+
)
|
|
535
|
+
return (
|
|
536
|
+
"zero unresolved bot threads",
|
|
537
|
+
_count_unresolved_bot_threads(
|
|
538
|
+
owner=context.owner, repo=context.repo, number=context.number
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
187
543
|
def _state_conditions(context: GateContext) -> list[GateCondition]:
|
|
188
544
|
"""Build the thread, mergeable, and pending-review conditions in gate order."""
|
|
189
545
|
return [
|
|
190
|
-
(
|
|
191
|
-
|
|
192
|
-
_count_unresolved_bot_threads(owner=context.owner, repo=context.repo, number=context.number),
|
|
193
|
-
),
|
|
194
|
-
("PR is mergeable", _get_mergeable(owner=context.owner, repo=context.repo, number=context.number)),
|
|
546
|
+
_threads_condition(context),
|
|
547
|
+
_mergeable_condition(context),
|
|
195
548
|
_pending_reviews_condition(context),
|
|
196
549
|
]
|
|
197
550
|
|
|
@@ -201,6 +554,7 @@ def _build_all_conditions(context: GateContext) -> list[GateCondition]:
|
|
|
201
554
|
conditions = list(_bugbot_conditions(context))
|
|
202
555
|
conditions.append(_bugteam_condition(context))
|
|
203
556
|
conditions.append(_copilot_review_condition(context))
|
|
557
|
+
conditions.append(_codex_condition(context))
|
|
204
558
|
conditions.extend(_state_conditions(context))
|
|
205
559
|
return conditions
|
|
206
560
|
|
|
@@ -227,15 +581,88 @@ def _evaluate_convergence(context: GateContext) -> int:
|
|
|
227
581
|
return _print_conditions(_build_all_conditions(context))
|
|
228
582
|
|
|
229
583
|
|
|
584
|
+
def _load_convergence_fixture(from_path: Path) -> ConvergenceFixture:
|
|
585
|
+
"""Load a frozen GitHub API snapshot from JSON for fixture replay.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
from_path: Path to a JSON fixture file.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
ConvergenceFixture with head, reviews, mergeable PR object, and thread/pending facts.
|
|
592
|
+
|
|
593
|
+
Raises:
|
|
594
|
+
ValueError: When required keys are missing or mistyped.
|
|
595
|
+
"""
|
|
596
|
+
payload = json.loads(from_path.read_text(encoding="utf-8"))
|
|
597
|
+
if not isinstance(payload, dict):
|
|
598
|
+
raise ValueError("fixture root must be a JSON object")
|
|
599
|
+
head_sha = payload.get(FIXTURE_KEY_HEAD_SHA)
|
|
600
|
+
pr_object = payload.get(FIXTURE_KEY_PR_OBJECT)
|
|
601
|
+
reviews = payload.get(FIXTURE_KEY_REVIEWS)
|
|
602
|
+
if not isinstance(head_sha, str) or not head_sha:
|
|
603
|
+
raise ValueError("fixture head_sha must be a non-empty string")
|
|
604
|
+
if not isinstance(pr_object, dict):
|
|
605
|
+
raise ValueError("fixture pr_object must be an object")
|
|
606
|
+
if not isinstance(reviews, list):
|
|
607
|
+
raise ValueError("fixture reviews must be a list")
|
|
608
|
+
typed_reviews = _sort_reviews_newest_first(
|
|
609
|
+
[each for each in reviews if isinstance(each, dict)]
|
|
610
|
+
)
|
|
611
|
+
is_threads_ok = payload.get(FIXTURE_KEY_UNRESOLVED_BOT_THREADS_PASSED)
|
|
612
|
+
threads_detail = payload.get(
|
|
613
|
+
FIXTURE_KEY_UNRESOLVED_BOT_THREADS_DETAIL, FIXTURE_DEFAULT_THREADS_DETAIL
|
|
614
|
+
)
|
|
615
|
+
is_pending_ok = payload.get(FIXTURE_KEY_PENDING_REVIEWS_PASSED)
|
|
616
|
+
pending_detail = payload.get(
|
|
617
|
+
FIXTURE_KEY_PENDING_REVIEWS_DETAIL, FIXTURE_DEFAULT_PENDING_DETAIL
|
|
618
|
+
)
|
|
619
|
+
if not isinstance(is_threads_ok, bool):
|
|
620
|
+
raise ValueError("fixture unresolved_bot_threads_passed must be a bool")
|
|
621
|
+
if not isinstance(threads_detail, str):
|
|
622
|
+
raise ValueError("fixture unresolved_bot_threads_detail must be a string")
|
|
623
|
+
if not isinstance(is_pending_ok, bool):
|
|
624
|
+
raise ValueError("fixture pending_reviews_passed must be a bool")
|
|
625
|
+
if not isinstance(pending_detail, str):
|
|
626
|
+
raise ValueError("fixture pending_reviews_detail must be a string")
|
|
627
|
+
codex_percent_left = payload.get(FIXTURE_KEY_CODEX_PERCENT_LEFT)
|
|
628
|
+
codex_clean_at = payload.get(FIXTURE_KEY_CODEX_CLEAN_AT)
|
|
629
|
+
if isinstance(codex_percent_left, bool):
|
|
630
|
+
raise ValueError("fixture codex_percent_left must be a number or null")
|
|
631
|
+
if codex_percent_left is not None and not isinstance(codex_percent_left, (int, float)):
|
|
632
|
+
raise ValueError("fixture codex_percent_left must be a number or null")
|
|
633
|
+
if codex_clean_at is not None and not isinstance(codex_clean_at, str):
|
|
634
|
+
raise ValueError("fixture codex_clean_at must be a string or null")
|
|
635
|
+
typed_percent_left: float | None = (
|
|
636
|
+
float(codex_percent_left) if isinstance(codex_percent_left, (int, float)) else None
|
|
637
|
+
)
|
|
638
|
+
typed_codex_clean_at: str | None = codex_clean_at if isinstance(codex_clean_at, str) else None
|
|
639
|
+
return ConvergenceFixture(
|
|
640
|
+
head_sha=head_sha,
|
|
641
|
+
pr_object=pr_object,
|
|
642
|
+
reviews=typed_reviews,
|
|
643
|
+
is_zero_unresolved_bot_threads=is_threads_ok,
|
|
644
|
+
unresolved_bot_threads_detail=threads_detail,
|
|
645
|
+
is_no_pending_reviews=is_pending_ok,
|
|
646
|
+
pending_reviews_detail=pending_detail,
|
|
647
|
+
codex_percent_left=typed_percent_left,
|
|
648
|
+
codex_clean_at=typed_codex_clean_at,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
230
652
|
def check_all(
|
|
231
653
|
owner: str,
|
|
232
654
|
repo: str,
|
|
233
655
|
number: int,
|
|
234
656
|
is_bugbot_down: bool,
|
|
235
657
|
is_copilot_down: bool,
|
|
236
|
-
is_bugteam_post_blocked: bool
|
|
658
|
+
is_bugteam_post_blocked: bool,
|
|
659
|
+
is_codex_down: bool,
|
|
660
|
+
live_codex_clean_at: str | None,
|
|
661
|
+
bugbot_bypass_note: str = BUGBOT_DOWN_BYPASS_NOTE,
|
|
662
|
+
copilot_bypass_note: str = COPILOT_DOWN_BYPASS_NOTE,
|
|
237
663
|
) -> int:
|
|
238
664
|
"""Run every convergence gate and print one PASS/FAIL line per condition.
|
|
665
|
+
|
|
239
666
|
Args:
|
|
240
667
|
owner: GitHub repository owner login.
|
|
241
668
|
repo: GitHub repository name.
|
|
@@ -243,6 +670,11 @@ def check_all(
|
|
|
243
670
|
is_bugbot_down: True bypasses the bugbot check-run and review-body gates.
|
|
244
671
|
is_copilot_down: True bypasses the Copilot review and pending gates.
|
|
245
672
|
is_bugteam_post_blocked: True skips the bugteam CLEAN-review gate.
|
|
673
|
+
is_codex_down: True bypasses the conditional Codex review gate.
|
|
674
|
+
live_codex_clean_at: Optional SHA stamp for the live Codex clean-at check.
|
|
675
|
+
bugbot_bypass_note: Detail note printed when the bugbot gate is bypassed.
|
|
676
|
+
copilot_bypass_note: Detail note printed when the Copilot gates are bypassed.
|
|
677
|
+
|
|
246
678
|
Returns:
|
|
247
679
|
0 when every gate passes, 1 when at least one gate fails.
|
|
248
680
|
"""
|
|
@@ -255,6 +687,57 @@ def check_all(
|
|
|
255
687
|
is_bugbot_down=is_bugbot_down,
|
|
256
688
|
is_copilot_down=is_copilot_down,
|
|
257
689
|
is_bugteam_post_blocked=is_bugteam_post_blocked,
|
|
690
|
+
is_codex_down=is_codex_down,
|
|
691
|
+
bugbot_bypass_note=bugbot_bypass_note,
|
|
692
|
+
copilot_bypass_note=copilot_bypass_note,
|
|
693
|
+
live_codex_clean_at=live_codex_clean_at,
|
|
694
|
+
fixture=None,
|
|
695
|
+
)
|
|
696
|
+
return _evaluate_convergence(context)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _check_all_from_fixture(
|
|
700
|
+
owner: str,
|
|
701
|
+
repo: str,
|
|
702
|
+
number: int,
|
|
703
|
+
fixture: ConvergenceFixture,
|
|
704
|
+
is_bugbot_down: bool,
|
|
705
|
+
is_copilot_down: bool,
|
|
706
|
+
is_bugteam_post_blocked: bool,
|
|
707
|
+
is_codex_down: bool,
|
|
708
|
+
bugbot_bypass_note: str = BUGBOT_DOWN_BYPASS_NOTE,
|
|
709
|
+
copilot_bypass_note: str = COPILOT_DOWN_BYPASS_NOTE,
|
|
710
|
+
) -> int:
|
|
711
|
+
"""Run every convergence gate against a frozen API snapshot.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
owner: GitHub repository owner login.
|
|
715
|
+
repo: GitHub repository name.
|
|
716
|
+
number: Pull request number recorded in the snapshot.
|
|
717
|
+
fixture: Frozen GitHub API snapshot (no live `gh` calls).
|
|
718
|
+
is_bugbot_down: True bypasses the bugbot check-run and review-body gates.
|
|
719
|
+
is_copilot_down: True bypasses the Copilot review and pending gates.
|
|
720
|
+
is_bugteam_post_blocked: True skips the bugteam CLEAN-review gate.
|
|
721
|
+
is_codex_down: True bypasses the conditional Codex review gate.
|
|
722
|
+
bugbot_bypass_note: Detail note printed when the bugbot gate is bypassed.
|
|
723
|
+
copilot_bypass_note: Detail note printed when the Copilot gates are bypassed.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
0 when every gate passes, 1 when at least one gate fails.
|
|
727
|
+
"""
|
|
728
|
+
context = GateContext(
|
|
729
|
+
owner=owner,
|
|
730
|
+
repo=repo,
|
|
731
|
+
number=number,
|
|
732
|
+
head_sha=fixture.head_sha,
|
|
733
|
+
is_bugbot_down=is_bugbot_down,
|
|
734
|
+
is_copilot_down=is_copilot_down,
|
|
735
|
+
is_bugteam_post_blocked=is_bugteam_post_blocked,
|
|
736
|
+
is_codex_down=is_codex_down,
|
|
737
|
+
bugbot_bypass_note=bugbot_bypass_note,
|
|
738
|
+
copilot_bypass_note=copilot_bypass_note,
|
|
739
|
+
live_codex_clean_at=None,
|
|
740
|
+
fixture=fixture,
|
|
258
741
|
)
|
|
259
742
|
return _evaluate_convergence(context)
|
|
260
743
|
|
|
@@ -265,6 +748,11 @@ def _build_argument_parser() -> argparse.ArgumentParser:
|
|
|
265
748
|
parser.add_argument("--owner", required=True, help="GitHub repository owner")
|
|
266
749
|
parser.add_argument("--repo", required=True, help="GitHub repository name")
|
|
267
750
|
parser.add_argument("--pr-number", required=True, type=int, help="Pull request number")
|
|
751
|
+
parser.add_argument(
|
|
752
|
+
"--fixture",
|
|
753
|
+
default=None,
|
|
754
|
+
help="Path to a frozen GitHub API JSON snapshot for offline replay.",
|
|
755
|
+
)
|
|
268
756
|
parser.add_argument(
|
|
269
757
|
"--bugbot-down",
|
|
270
758
|
action="store_true",
|
|
@@ -280,6 +768,16 @@ def _build_argument_parser() -> argparse.ArgumentParser:
|
|
|
280
768
|
action="store_true",
|
|
281
769
|
help="Skip the bugteam CLEAN-review gate when the environment refused the CLEAN post on HEAD.",
|
|
282
770
|
)
|
|
771
|
+
parser.add_argument(
|
|
772
|
+
"--codex-down",
|
|
773
|
+
action="store_true",
|
|
774
|
+
help="Bypass the conditional Codex review gate when Codex is down or opted out.",
|
|
775
|
+
)
|
|
776
|
+
parser.add_argument(
|
|
777
|
+
"--codex-clean-at",
|
|
778
|
+
default=None,
|
|
779
|
+
help="HEAD SHA where Codex last reported clean (live path; optional when job-dir state holds it).",
|
|
780
|
+
)
|
|
283
781
|
return parser
|
|
284
782
|
|
|
285
783
|
|
|
@@ -290,19 +788,29 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
|
290
788
|
all_argv: Argument list excluding the program name.
|
|
291
789
|
|
|
292
790
|
Returns:
|
|
293
|
-
Namespace exposing owner, repo, pr_number, bugbot_down, copilot_down,
|
|
294
|
-
and
|
|
791
|
+
Namespace exposing owner, repo, pr_number, fixture, bugbot_down, copilot_down,
|
|
792
|
+
bugteam_post_blocked, codex_down, and codex_clean_at attributes.
|
|
295
793
|
"""
|
|
296
794
|
return _build_argument_parser().parse_args(all_argv)
|
|
297
795
|
|
|
298
796
|
|
|
299
797
|
def _resolve_bugbot_down(is_bugbot_down_flag: bool) -> bool:
|
|
300
|
-
"""
|
|
798
|
+
"""Pinned contract/unit-test helper for the bugbot flag and env bypass.
|
|
799
|
+
|
|
800
|
+
Production ``main()`` resolves Bugbot waivers through ``_resolve_bugbot_waiver``.
|
|
801
|
+
This function serves only pinned contract and unit tests; issue #120 tracks
|
|
802
|
+
its removal.
|
|
803
|
+
"""
|
|
301
804
|
return is_bugbot_down_flag or is_bugbot_disabled_via_env()
|
|
302
805
|
|
|
303
806
|
|
|
304
807
|
def _resolve_copilot_down(is_copilot_down_flag: bool) -> bool:
|
|
305
|
-
"""
|
|
808
|
+
"""Pinned contract/unit-test helper for the copilot flag and env bypass.
|
|
809
|
+
|
|
810
|
+
Production ``main()`` resolves Copilot waivers through ``_resolve_copilot_waiver``.
|
|
811
|
+
This function serves only pinned contract and unit tests; issue #120 tracks
|
|
812
|
+
its removal.
|
|
813
|
+
"""
|
|
306
814
|
return is_copilot_down_flag or is_copilot_disabled_via_env()
|
|
307
815
|
|
|
308
816
|
|
|
@@ -321,23 +829,91 @@ def _resolve_bugteam_post_blocked(is_bugteam_post_blocked_flag: bool) -> bool:
|
|
|
321
829
|
return is_bugteam_post_blocked_flag or is_bugteam_disabled_via_env()
|
|
322
830
|
|
|
323
831
|
|
|
324
|
-
def
|
|
832
|
+
def _resolve_codex_down(
|
|
833
|
+
is_codex_down_flag: bool, all_job_state_fields: dict[str, object]
|
|
834
|
+
) -> bool:
|
|
835
|
+
"""Combine --codex-down, env opt-out, and sticky job-state ``codex_down``.
|
|
836
|
+
|
|
837
|
+
::
|
|
838
|
+
|
|
839
|
+
flag True, env unset, job unset -> True (caller passed the flag)
|
|
840
|
+
flag False, reviews-disabled lists codex -> True (exported token)
|
|
841
|
+
flag False, env unset, job codex_down true -> True (sticky job-state)
|
|
842
|
+
flag False, env unset, job unset or false -> False (gate may run)
|
|
843
|
+
|
|
844
|
+
The mark-ready blocker hook re-runs this script with no flags, so the env
|
|
845
|
+
token and sticky job-state ``codex_down`` both carry the bypass into that
|
|
846
|
+
re-check when the original run cannot re-pass ``--codex-down``.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
is_codex_down_flag: True when the caller passed ``--codex-down``.
|
|
850
|
+
all_job_state_fields: Parsed job-dir state, empty when none is readable.
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
True when any of the three bypass sources is in force.
|
|
854
|
+
"""
|
|
855
|
+
return (
|
|
856
|
+
is_codex_down_flag
|
|
857
|
+
or is_codex_disabled_via_env()
|
|
858
|
+
or _read_codex_down_from_job_state(all_job_state_fields)
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def main(
|
|
863
|
+
all_arguments: list[str],
|
|
864
|
+
bugbot_bypass_note: str = BUGBOT_DOWN_BYPASS_NOTE,
|
|
865
|
+
copilot_bypass_note: str = COPILOT_DOWN_BYPASS_NOTE,
|
|
866
|
+
) -> int:
|
|
325
867
|
"""Run the script end-to-end against parsed CLI arguments.
|
|
326
868
|
|
|
327
869
|
Args:
|
|
328
870
|
all_arguments: Argument list excluding the program name.
|
|
871
|
+
bugbot_bypass_note: Note printed when the bugbot gate is bypassed.
|
|
872
|
+
copilot_bypass_note: Note printed when the Copilot gates are bypassed.
|
|
329
873
|
|
|
330
874
|
Returns:
|
|
331
875
|
0 on full convergence, 1 on one or more gate failures.
|
|
332
876
|
"""
|
|
333
877
|
arguments = parse_arguments(all_arguments)
|
|
878
|
+
all_job_state_fields = _read_job_state()
|
|
879
|
+
is_bugteam_post_blocked = _resolve_bugteam_post_blocked(arguments.bugteam_post_blocked)
|
|
880
|
+
is_codex_down = _resolve_codex_down(arguments.codex_down, all_job_state_fields)
|
|
881
|
+
fixture_path = getattr(arguments, "fixture", None)
|
|
882
|
+
if fixture_path:
|
|
883
|
+
bugbot_waiver = _waiver_from_cli_flag(
|
|
884
|
+
arguments.bugbot_down, bugbot_bypass_note
|
|
885
|
+
)
|
|
886
|
+
copilot_waiver = _waiver_from_cli_flag(
|
|
887
|
+
arguments.copilot_down, copilot_bypass_note
|
|
888
|
+
)
|
|
889
|
+
fixture = _load_convergence_fixture(Path(fixture_path))
|
|
890
|
+
return _check_all_from_fixture(
|
|
891
|
+
owner=arguments.owner,
|
|
892
|
+
repo=arguments.repo,
|
|
893
|
+
number=getattr(arguments, "pr_number"),
|
|
894
|
+
fixture=fixture,
|
|
895
|
+
is_bugbot_down=bugbot_waiver.is_waived,
|
|
896
|
+
is_copilot_down=copilot_waiver.is_waived,
|
|
897
|
+
is_bugteam_post_blocked=is_bugteam_post_blocked,
|
|
898
|
+
is_codex_down=is_codex_down,
|
|
899
|
+
bugbot_bypass_note=bugbot_waiver.bypass_note or bugbot_bypass_note,
|
|
900
|
+
copilot_bypass_note=copilot_waiver.bypass_note or copilot_bypass_note,
|
|
901
|
+
)
|
|
902
|
+
bugbot_waiver = _resolve_bugbot_waiver(arguments.bugbot_down)
|
|
903
|
+
copilot_waiver = _resolve_copilot_waiver(arguments.copilot_down)
|
|
334
904
|
return check_all(
|
|
335
905
|
owner=arguments.owner,
|
|
336
906
|
repo=arguments.repo,
|
|
337
907
|
number=getattr(arguments, "pr_number"),
|
|
338
|
-
is_bugbot_down=
|
|
339
|
-
is_copilot_down=
|
|
340
|
-
is_bugteam_post_blocked=
|
|
908
|
+
is_bugbot_down=bugbot_waiver.is_waived,
|
|
909
|
+
is_copilot_down=copilot_waiver.is_waived,
|
|
910
|
+
is_bugteam_post_blocked=is_bugteam_post_blocked,
|
|
911
|
+
is_codex_down=is_codex_down,
|
|
912
|
+
live_codex_clean_at=_resolve_live_codex_clean_at(
|
|
913
|
+
arguments.codex_clean_at, all_job_state_fields
|
|
914
|
+
),
|
|
915
|
+
bugbot_bypass_note=bugbot_waiver.bypass_note or bugbot_bypass_note,
|
|
916
|
+
copilot_bypass_note=copilot_waiver.bypass_note or copilot_bypass_note,
|
|
341
917
|
)
|
|
342
918
|
|
|
343
919
|
|