claude-dev-env 1.79.0 → 1.81.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/pr-loop/scripts/CLAUDE.md +3 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +58 -4
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +7 -66
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +10 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
- package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +3 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +28 -7
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -5
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
- package/skills/autoconverge/workflow/converge.mjs +235 -247
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
- package/skills/pr-converge/SKILL.md +28 -2
- package/skills/pr-converge/reference/convergence-gates.md +13 -1
- package/skills/pr-converge/reference/state-schema.md +11 -0
|
@@ -11,8 +11,10 @@ Python scripts invoked at runtime by the PR-loop skills. Each script is a standa
|
|
|
11
11
|
| `post_audit_thread.py` | Posts an audit review (APPROVE / REQUEST_CHANGES) to a draft PR via the GitHub reviews API; reads the body skeleton from `audit-reply-template.md` at runtime |
|
|
12
12
|
| `grant_project_claude_permissions.py` | Writes idempotent allow-rules and `additionalDirectories` entries into `~/.claude/settings.json` so subagents can edit the project's `.claude/` tree without prompting |
|
|
13
13
|
| `revoke_project_claude_permissions.py` | Removes the allow-rules and entries that `grant_project_claude_permissions.py` wrote; safe to run when no prior grant exists |
|
|
14
|
-
| `code_rules_gate.py` | Pre-commit gate that runs `code_rules_enforcer` checks on staged Python files before a fix commit lands |
|
|
14
|
+
| `code_rules_gate.py` | Pre-commit gate that runs `code_rules_enforcer` checks on staged Python files before a fix commit lands, and the terminology sweep over the staged diff |
|
|
15
|
+
| `terminology_sweep.py` | Flags a prose term that near-misses an identifier introduced on added code lines of a unified diff (shared leading word, divergent tail) |
|
|
15
16
|
| `reviews_disabled.py` | Shared helper for the `CLAUDE_REVIEWS_DISABLED` opt-out gate; parses the env-var token to find which reviewer types are suppressed |
|
|
17
|
+
| `copilot_quota.py` | Copilot premium-request quota pre-check: resolves a configured GitHub account, reads its remaining `premium_interactions` quota via `gh api copilot_internal/user`, and exits 0 (run Copilot) or non-zero (skip: out of quota, API down, or no account configured) |
|
|
16
18
|
| `fix_hookspath.py` | Repairs a malformed `core.hooksPath` global git config entry |
|
|
17
19
|
| `_claude_permissions_common.py` | Internal helpers shared by the grant/revoke scripts: atomic settings.json writes, list mutation, path helpers |
|
|
18
20
|
|
|
@@ -15,16 +15,17 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
15
15
|
ALL_CODE_FILE_EXTENSIONS,
|
|
16
16
|
ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
|
|
17
17
|
ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
|
|
18
|
+
ALL_PYTEST_MODULE_INVOCATION,
|
|
18
19
|
ALL_TEST_FILENAME_GLOB_SUFFIXES,
|
|
19
20
|
ALL_TEST_FILENAME_SUFFIXES,
|
|
20
|
-
EXPECTED_NON_RENAME_COLUMN_COUNT,
|
|
21
|
-
EXPECTED_RENAME_COLUMN_COUNT,
|
|
22
21
|
BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
|
|
23
22
|
BANNED_NOUN_SPAN_GROUP_INDEX,
|
|
24
23
|
BANNED_NOUN_VIOLATION_PATTERN,
|
|
25
24
|
DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX,
|
|
26
25
|
DUPLICATE_BODY_SPAN_GROUP_INDEX,
|
|
27
26
|
DUPLICATE_BODY_VIOLATION_PATTERN,
|
|
27
|
+
EXPECTED_NON_RENAME_COLUMN_COUNT,
|
|
28
|
+
EXPECTED_RENAME_COLUMN_COUNT,
|
|
28
29
|
FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
|
|
29
30
|
FUNCTION_LENGTH_SPAN_GROUP_INDEX,
|
|
30
31
|
FUNCTION_LENGTH_VIOLATION_PATTERN,
|
|
@@ -35,10 +36,15 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
35
36
|
ISOLATION_VIOLATION_PATTERN,
|
|
36
37
|
MAX_VIOLATIONS_PER_CHECK,
|
|
37
38
|
PYTHON_FILE_EXTENSION,
|
|
39
|
+
STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
40
|
+
STAGED_TEST_FAILURE_HEADER,
|
|
38
41
|
TEST_CONFTEST_FILENAME,
|
|
39
42
|
TEST_FILENAME_PREFIX,
|
|
40
43
|
TESTS_PATH_SEGMENT,
|
|
41
44
|
)
|
|
45
|
+
from pr_loop_shared_constants.preflight_constants import ( # noqa: E402
|
|
46
|
+
PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
|
|
47
|
+
)
|
|
42
48
|
from pr_loop_shared_constants.inline_duplicate_body_span_constants import ( # noqa: E402
|
|
43
49
|
INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX,
|
|
44
50
|
INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX,
|
|
@@ -46,7 +52,10 @@ from pr_loop_shared_constants.inline_duplicate_body_span_constants import ( # n
|
|
|
46
52
|
INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX,
|
|
47
53
|
INLINE_DUPLICATE_BODY_VIOLATION_PATTERN,
|
|
48
54
|
)
|
|
49
|
-
|
|
55
|
+
from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
56
|
+
TERMINOLOGY_SWEEP_GATE_HEADER,
|
|
57
|
+
)
|
|
58
|
+
from terminology_sweep import staged_terminology_findings # noqa: E402
|
|
50
59
|
|
|
51
60
|
ValidateContentCallable = Callable[..., list[str]]
|
|
52
61
|
|
|
@@ -768,12 +777,13 @@ def renamed_file_source_map_since(
|
|
|
768
777
|
|
|
769
778
|
Runs `git diff --name-status -M -z merge_base..HEAD` and collects both
|
|
770
779
|
paths of every rename entry (status code starting with R, e.g. `R100`).
|
|
771
|
-
Keys are destination posix paths; values are source posix paths.
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
780
|
+
Keys are destination posix paths; values are source posix paths.
|
|
781
|
+
|
|
782
|
+
The -z flag asks git for null-terminated, unquoted output. A path
|
|
783
|
+
holding a tab or newline byte then survives column and line splitting
|
|
784
|
+
unmangled. Each rename record emits three null-terminated tokens
|
|
785
|
+
(status, source, destination). Every other status record emits two
|
|
786
|
+
(status, path).
|
|
777
787
|
|
|
778
788
|
Args:
|
|
779
789
|
repository_root: Repository root used as the ``git -C`` target.
|
|
@@ -1024,11 +1034,13 @@ def duplicate_body_span_range(violation_text: str) -> range | None:
|
|
|
1024
1034
|
The duplicate-body message carries the copied function's definition line and
|
|
1025
1035
|
its full body span: ``Function 'NAME' duplicates location.py::name — ...
|
|
1026
1036
|
(duplicate body span at line X, spanning Y lines)``. The function occupies
|
|
1027
|
-
lines ``X`` through ``X + Y - 1`` inclusive
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1037
|
+
lines ``X`` through ``X + Y - 1`` inclusive.
|
|
1038
|
+
|
|
1039
|
+
So a duplicate of a sibling helper blocks only when the diff touches the
|
|
1040
|
+
copied function. An unrelated edit that leaves a pre-existing copy
|
|
1041
|
+
untouched keeps it advisory. This matches the span-scoped PreToolUse
|
|
1042
|
+
Write/Edit behavior rather than blocking every duplicate-body message
|
|
1043
|
+
unconditionally.
|
|
1032
1044
|
|
|
1033
1045
|
Args:
|
|
1034
1046
|
violation_text: A single violation string emitted by the enforcer.
|
|
@@ -1048,16 +1060,18 @@ def duplicate_body_span_range(violation_text: str) -> range | None:
|
|
|
1048
1060
|
def inline_duplicate_body_span_lines(violation_text: str) -> frozenset[int] | None:
|
|
1049
1061
|
"""Return the union of both spans of a same-file inline-duplicate issue, or None.
|
|
1050
1062
|
|
|
1051
|
-
The same-file inline-duplicate message names two functions that share a body
|
|
1052
|
-
the helper and the enclosing function carrying the inline copy
|
|
1053
|
-
Write/Edit hook scopes the violation by the
|
|
1054
|
-
an edit touches either function. So the message carries both spans:
|
|
1055
|
-
duplicate body spans: helper at line H spanning P lines,
|
|
1056
|
-
spanning Q lines)``.
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1063
|
+
The same-file inline-duplicate message names two functions that share a body:
|
|
1064
|
+
the helper and the enclosing function carrying the inline copy. The live
|
|
1065
|
+
Write/Edit hook scopes the violation by the union of both spans. It blocks
|
|
1066
|
+
when an edit touches either function. So the message carries both spans:
|
|
1067
|
+
``(inline duplicate body spans: helper at line H spanning P lines,
|
|
1068
|
+
enclosing at line E spanning Q lines)``.
|
|
1069
|
+
|
|
1070
|
+
The two spans can be disjoint: an unrelated function may sit between the
|
|
1071
|
+
helper and its inline copy. This returns the union as a line-number set
|
|
1072
|
+
rather than a single contiguous range. A range covering the gap would
|
|
1073
|
+
wrongly block an edit confined to that intervening function, which the
|
|
1074
|
+
PreToolUse path leaves unflagged.
|
|
1061
1075
|
|
|
1062
1076
|
Args:
|
|
1063
1077
|
violation_text: A single violation string emitted by the enforcer.
|
|
@@ -1096,10 +1110,10 @@ def enclosing_span_range(violation_text: str) -> range | None:
|
|
|
1096
1110
|
|
|
1097
1111
|
Every diff-scoped enforcer check tags its message with an enclosing-unit
|
|
1098
1112
|
span fragment. This dispatcher tries each span extractor from
|
|
1099
|
-
``_all_span_range_extractors
|
|
1100
|
-
check's span through one shared mechanism
|
|
1101
|
-
adding one extractor to that registry rather than threading a new
|
|
1102
|
-
through ``split_violations_by_scope``.
|
|
1113
|
+
``_all_span_range_extractors``, so the gate reconstructs every scoped
|
|
1114
|
+
check's span through one shared mechanism. Adding a new scoped check
|
|
1115
|
+
means adding one extractor to that registry rather than threading a new
|
|
1116
|
+
branch through ``split_violations_by_scope``.
|
|
1103
1117
|
|
|
1104
1118
|
Args:
|
|
1105
1119
|
violation_text: A single violation string emitted by the enforcer.
|
|
@@ -1510,6 +1524,73 @@ def _report_partitioned_violations(
|
|
|
1510
1524
|
return 0
|
|
1511
1525
|
|
|
1512
1526
|
|
|
1527
|
+
def _staged_test_file_paths(repository_root: Path) -> list[Path]:
|
|
1528
|
+
"""Return the staged Python test files that exist under a repository.
|
|
1529
|
+
|
|
1530
|
+
Args:
|
|
1531
|
+
repository_root: The repository root whose staged index is read.
|
|
1532
|
+
|
|
1533
|
+
Returns:
|
|
1534
|
+
The staged paths whose extension is Python, whose name matches a
|
|
1535
|
+
test-file pattern, and which exist on disk.
|
|
1536
|
+
"""
|
|
1537
|
+
all_test_paths: list[Path] = []
|
|
1538
|
+
for each_path in paths_from_git_staged(repository_root):
|
|
1539
|
+
if each_path.suffix != PYTHON_FILE_EXTENSION:
|
|
1540
|
+
continue
|
|
1541
|
+
if is_test_path(str(each_path)) and each_path.is_file():
|
|
1542
|
+
all_test_paths.append(each_path)
|
|
1543
|
+
return all_test_paths
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def run_staged_test_files(repository_root: Path) -> int:
|
|
1547
|
+
"""Run pytest over the staged test files and return the gate exit code.
|
|
1548
|
+
|
|
1549
|
+
Args:
|
|
1550
|
+
repository_root: The repository root the staged test files belong to.
|
|
1551
|
+
|
|
1552
|
+
Returns:
|
|
1553
|
+
0 when no test file is staged, when the staged test files collect
|
|
1554
|
+
no tests (pytest's no-tests-collected exit code), or when every staged
|
|
1555
|
+
test passes. pytest's non-zero exit code otherwise, which blocks the
|
|
1556
|
+
commit.
|
|
1557
|
+
"""
|
|
1558
|
+
all_test_paths = _staged_test_file_paths(repository_root)
|
|
1559
|
+
if not all_test_paths:
|
|
1560
|
+
return 0
|
|
1561
|
+
pytest_process = subprocess.run(
|
|
1562
|
+
[
|
|
1563
|
+
sys.executable,
|
|
1564
|
+
*ALL_PYTEST_MODULE_INVOCATION,
|
|
1565
|
+
*[str(each_path) for each_path in all_test_paths],
|
|
1566
|
+
],
|
|
1567
|
+
cwd=str(repository_root),
|
|
1568
|
+
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
1569
|
+
check=False,
|
|
1570
|
+
)
|
|
1571
|
+
if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
|
|
1572
|
+
return 0
|
|
1573
|
+
if pytest_process.returncode != 0:
|
|
1574
|
+
print(STAGED_TEST_FAILURE_HEADER, file=sys.stderr)
|
|
1575
|
+
return pytest_process.returncode
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def _report_terminology_findings(all_findings: list[str]) -> None:
|
|
1579
|
+
"""Print the terminology-sweep findings, when any, to standard error.
|
|
1580
|
+
|
|
1581
|
+
Args:
|
|
1582
|
+
all_findings: The near-miss findings from the staged terminology sweep.
|
|
1583
|
+
"""
|
|
1584
|
+
if not all_findings:
|
|
1585
|
+
return
|
|
1586
|
+
print(
|
|
1587
|
+
TERMINOLOGY_SWEEP_GATE_HEADER.format(finding_count=len(all_findings)),
|
|
1588
|
+
file=sys.stderr,
|
|
1589
|
+
)
|
|
1590
|
+
for each_finding in all_findings:
|
|
1591
|
+
print(f" {each_finding}", file=sys.stderr)
|
|
1592
|
+
|
|
1593
|
+
|
|
1513
1594
|
def parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
|
|
1514
1595
|
"""Parse the command-line arguments for the code-rules gate.
|
|
1515
1596
|
|
|
@@ -1590,6 +1671,10 @@ def main(all_arguments: list[str]) -> int:
|
|
|
1590
1671
|
validate_content, file_paths, repository_root, all_added_lines_by_path=None
|
|
1591
1672
|
)
|
|
1592
1673
|
if arguments.staged:
|
|
1674
|
+
all_terminology_findings = staged_terminology_findings(repository_root)
|
|
1675
|
+
_report_terminology_findings(all_terminology_findings)
|
|
1676
|
+
terminology_exit_code = 1 if all_terminology_findings else 0
|
|
1677
|
+
staged_test_exit_code = run_staged_test_files(repository_root)
|
|
1593
1678
|
staged_file_paths = paths_from_git_staged(repository_root)
|
|
1594
1679
|
staged_file_paths = filter_paths_under_prefixes(
|
|
1595
1680
|
staged_file_paths,
|
|
@@ -1597,17 +1682,18 @@ def main(all_arguments: list[str]) -> int:
|
|
|
1597
1682
|
arguments.only_under,
|
|
1598
1683
|
)
|
|
1599
1684
|
if not staged_file_paths:
|
|
1600
|
-
return
|
|
1685
|
+
return terminology_exit_code or staged_test_exit_code
|
|
1601
1686
|
staged_added_lines = added_lines_by_file_staged(
|
|
1602
1687
|
repository_root, staged_file_paths
|
|
1603
1688
|
)
|
|
1604
|
-
|
|
1689
|
+
gate_exit_code = run_gate(
|
|
1605
1690
|
validate_content,
|
|
1606
1691
|
staged_file_paths,
|
|
1607
1692
|
repository_root,
|
|
1608
1693
|
all_added_lines_by_path=staged_added_lines,
|
|
1609
1694
|
read_staged_content_flag=True,
|
|
1610
1695
|
)
|
|
1696
|
+
return gate_exit_code or terminology_exit_code or staged_test_exit_code
|
|
1611
1697
|
file_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
1612
1698
|
file_paths = filter_paths_under_prefixes(
|
|
1613
1699
|
file_paths,
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Copilot premium-interaction quota pre-check.
|
|
2
|
+
|
|
3
|
+
Run this before any Copilot review step spawns. It reads a configured GitHub
|
|
4
|
+
account's remaining premium-interaction quota and decides whether Copilot has
|
|
5
|
+
quota to run. An account already out of quota skips the Copilot step instead of
|
|
6
|
+
wasting a whole review cycle on it.
|
|
7
|
+
|
|
8
|
+
Account resolution order: the ``--account`` flag, then the
|
|
9
|
+
``COPILOT_QUOTA_ACCOUNT`` environment variable, then that same key parsed from a
|
|
10
|
+
git-ignored ``.env`` file. The account's ``gh`` token is resolved with
|
|
11
|
+
``gh auth token -u <account>`` and the quota is read from
|
|
12
|
+
``gh api copilot_internal/user``. The config file names the account only and
|
|
13
|
+
stores no secret.
|
|
14
|
+
|
|
15
|
+
The exit code tells the caller what to do. Exit 0 means run Copilot. A non-zero
|
|
16
|
+
exit means skip it: the account is out of quota (scenario A), the quota API or
|
|
17
|
+
account access is down (scenario B), or no account is configured (scenario C).
|
|
18
|
+
Every path prints one line: the run line to stdout, each skip line to stderr.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from pr_loop_shared_constants.copilot_quota_constants import (
|
|
32
|
+
COPILOT_INTERNAL_USER_API_PATH,
|
|
33
|
+
COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME,
|
|
34
|
+
COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH,
|
|
35
|
+
EXIT_CODE_NO_ACCOUNT_CONFIGURED,
|
|
36
|
+
EXIT_CODE_OUT_OF_QUOTA,
|
|
37
|
+
EXIT_CODE_QUOTA_API_DOWN,
|
|
38
|
+
EXIT_CODE_QUOTA_AVAILABLE,
|
|
39
|
+
GH_TOKEN_ENV_VAR_NAME,
|
|
40
|
+
PREMIUM_ENTITLEMENT_FIELD_NAME,
|
|
41
|
+
PREMIUM_INTERACTIONS_FIELD_NAME,
|
|
42
|
+
PREMIUM_OVERAGE_PERMITTED_FIELD_NAME,
|
|
43
|
+
PREMIUM_PERCENT_REMAINING_FIELD_NAME,
|
|
44
|
+
PREMIUM_REMAINING_FIELD_NAME,
|
|
45
|
+
PREMIUM_UNLIMITED_FIELD_NAME,
|
|
46
|
+
QUOTA_SNAPSHOTS_FIELD_NAME,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class QuotaDecision:
|
|
52
|
+
"""One quota pre-check outcome: the exit code and the single log line.
|
|
53
|
+
|
|
54
|
+
The exit code tells the caller what to do, where 0 runs Copilot and any
|
|
55
|
+
non-zero skips it. The CLI prints the message on one line, to stdout on a
|
|
56
|
+
run and to stderr on a skip.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
exit_code: int
|
|
60
|
+
message: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run_gh(
|
|
64
|
+
all_command_arguments: list[str],
|
|
65
|
+
all_environment_overrides: dict[str, str] | None = None,
|
|
66
|
+
) -> tuple[int, str]:
|
|
67
|
+
"""Run a ``gh`` subprocess and return its exit code and stdout.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
all_command_arguments: Arguments after the ``gh`` program name, for
|
|
71
|
+
example ``["api", "copilot_internal/user"]``.
|
|
72
|
+
all_environment_overrides: Environment entries to overlay on the current
|
|
73
|
+
environment for this call, or None to inherit it unchanged.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The subprocess exit code paired with its captured stdout.
|
|
77
|
+
"""
|
|
78
|
+
process_environment = dict(os.environ)
|
|
79
|
+
if all_environment_overrides:
|
|
80
|
+
process_environment.update(all_environment_overrides)
|
|
81
|
+
completed_process = subprocess.run(
|
|
82
|
+
["gh", *all_command_arguments],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
encoding="utf-8",
|
|
86
|
+
errors="replace",
|
|
87
|
+
check=False,
|
|
88
|
+
env=process_environment,
|
|
89
|
+
)
|
|
90
|
+
return completed_process.returncode, completed_process.stdout
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_account_from_env_file(env_file_path: Path) -> str | None:
|
|
94
|
+
"""Read the configured account from a git-ignored ``.env`` file.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
env_file_path: Path to the ``.env`` file that may carry a
|
|
98
|
+
``COPILOT_QUOTA_ACCOUNT=<login>`` line.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The configured account login, or None when the file is absent,
|
|
102
|
+
unreadable, or carries no non-empty account line.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
file_text = env_file_path.read_text(encoding="utf-8")
|
|
106
|
+
except (OSError, UnicodeDecodeError):
|
|
107
|
+
return None
|
|
108
|
+
for each_line in file_text.splitlines():
|
|
109
|
+
stripped_line = each_line.strip()
|
|
110
|
+
if not stripped_line or stripped_line.startswith("#"):
|
|
111
|
+
continue
|
|
112
|
+
key_text, separator, raw_account = stripped_line.partition("=")
|
|
113
|
+
if not separator or key_text.strip() != COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME:
|
|
114
|
+
continue
|
|
115
|
+
resolved_login = raw_account.strip().strip('"').strip("'")
|
|
116
|
+
if resolved_login:
|
|
117
|
+
return resolved_login
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_account(cli_account: str | None, env_file_path: Path) -> str | None:
|
|
122
|
+
"""Resolve the account whose Copilot quota to check.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
cli_account: The ``--account`` value, or None when the flag is absent.
|
|
126
|
+
env_file_path: The ``.env`` file consulted when neither the flag nor
|
|
127
|
+
the environment variable names an account.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The resolved account login from the flag, then the
|
|
131
|
+
``COPILOT_QUOTA_ACCOUNT`` environment variable, then the ``.env``
|
|
132
|
+
file, or None when none of the three names an account.
|
|
133
|
+
"""
|
|
134
|
+
if cli_account and cli_account.strip():
|
|
135
|
+
return cli_account.strip()
|
|
136
|
+
environment_account = os.environ.get(COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME, "").strip()
|
|
137
|
+
if environment_account:
|
|
138
|
+
return environment_account
|
|
139
|
+
return _read_account_from_env_file(env_file_path)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_gh_token(account_login: str) -> str | None:
|
|
143
|
+
"""Resolve the account's gh token via ``gh auth token -u <account>``.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
account_login: The GitHub login to resolve a token for.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The token string, or None when ``gh`` exits non-zero or prints no
|
|
150
|
+
token, so an account that is not ``gh auth login``-ed reads as down.
|
|
151
|
+
"""
|
|
152
|
+
returncode, stdout = _run_gh(["auth", "token", "-u", account_login])
|
|
153
|
+
if returncode != 0:
|
|
154
|
+
return None
|
|
155
|
+
token = stdout.strip()
|
|
156
|
+
return token or None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _fetch_copilot_user(token: str) -> dict[str, object] | None:
|
|
160
|
+
"""Read the ``copilot_internal/user`` payload for a resolved token.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
token: A gh token authorized for the account under check.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The parsed response object, or None when ``gh`` exits non-zero or
|
|
167
|
+
returns a body that is not a JSON object.
|
|
168
|
+
"""
|
|
169
|
+
returncode, stdout = _run_gh(
|
|
170
|
+
["api", COPILOT_INTERNAL_USER_API_PATH],
|
|
171
|
+
{GH_TOKEN_ENV_VAR_NAME: token},
|
|
172
|
+
)
|
|
173
|
+
if returncode != 0:
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
parsed_payload = json.loads(stdout)
|
|
177
|
+
except json.JSONDecodeError:
|
|
178
|
+
return None
|
|
179
|
+
if not isinstance(parsed_payload, dict):
|
|
180
|
+
return None
|
|
181
|
+
return parsed_payload
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _extract_premium_snapshot(
|
|
185
|
+
all_user_fields: dict[str, object],
|
|
186
|
+
) -> dict[str, object] | None:
|
|
187
|
+
"""Pull the ``premium_interactions`` quota snapshot from the user payload.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
all_user_fields: The parsed ``copilot_internal/user`` response object.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The ``quota_snapshots.premium_interactions`` object, or None when
|
|
194
|
+
either level is missing or is not an object.
|
|
195
|
+
"""
|
|
196
|
+
quota_snapshots = all_user_fields.get(QUOTA_SNAPSHOTS_FIELD_NAME)
|
|
197
|
+
if not isinstance(quota_snapshots, dict):
|
|
198
|
+
return None
|
|
199
|
+
premium_snapshot = quota_snapshots.get(PREMIUM_INTERACTIONS_FIELD_NAME)
|
|
200
|
+
if not isinstance(premium_snapshot, dict):
|
|
201
|
+
return None
|
|
202
|
+
return premium_snapshot
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_premium_quota_exhausted(all_premium_fields: dict[str, object]) -> bool:
|
|
206
|
+
"""Report whether the premium-interaction quota is exhausted.
|
|
207
|
+
|
|
208
|
+
The quota counts as exhausted when it is not unlimited, its remaining count
|
|
209
|
+
has reached zero, and overage is not permitted. That is the one state in
|
|
210
|
+
which a Copilot review request cannot be served. Any missing or malformed
|
|
211
|
+
gating field leaves the quota reported as not exhausted, so the caller runs
|
|
212
|
+
Copilot rather than skipping on an unreadable snapshot.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
all_premium_fields: The ``premium_interactions`` quota snapshot object.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True when unlimited is False, remaining is at or below zero, and overage
|
|
219
|
+
is not permitted.
|
|
220
|
+
"""
|
|
221
|
+
is_unlimited = all_premium_fields.get(PREMIUM_UNLIMITED_FIELD_NAME)
|
|
222
|
+
is_overage_permitted = all_premium_fields.get(PREMIUM_OVERAGE_PERMITTED_FIELD_NAME)
|
|
223
|
+
remaining = all_premium_fields.get(PREMIUM_REMAINING_FIELD_NAME)
|
|
224
|
+
if not isinstance(is_unlimited, bool) or not isinstance(is_overage_permitted, bool):
|
|
225
|
+
return False
|
|
226
|
+
if isinstance(remaining, bool) or not isinstance(remaining, (int, float)):
|
|
227
|
+
return False
|
|
228
|
+
return is_unlimited is False and remaining <= 0 and is_overage_permitted is False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _describe_quota_amount(all_premium_fields: dict[str, object]) -> str:
|
|
232
|
+
"""Render the remaining/entitlement/percent fragment for a snapshot.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
all_premium_fields: The ``premium_interactions`` quota snapshot object.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
A fragment naming the remaining count over the entitlement with the
|
|
239
|
+
percent remaining, built from the snapshot's remaining, entitlement,
|
|
240
|
+
and percent-remaining fields.
|
|
241
|
+
"""
|
|
242
|
+
remaining = all_premium_fields.get(PREMIUM_REMAINING_FIELD_NAME)
|
|
243
|
+
entitlement = all_premium_fields.get(PREMIUM_ENTITLEMENT_FIELD_NAME)
|
|
244
|
+
percent_remaining = all_premium_fields.get(PREMIUM_PERCENT_REMAINING_FIELD_NAME)
|
|
245
|
+
return f"{remaining}/{entitlement} remaining ({percent_remaining}%)"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def evaluate_copilot_quota(
|
|
249
|
+
cli_account: str | None, env_file_path: Path
|
|
250
|
+
) -> QuotaDecision:
|
|
251
|
+
"""Decide whether Copilot has premium quota to run for a configured account.
|
|
252
|
+
|
|
253
|
+
Resolves the account, reads its remaining ``premium_interactions`` quota via
|
|
254
|
+
``gh``, and maps the result to one of four outcomes: premium quota available
|
|
255
|
+
(run Copilot), out of quota, quota API or account access down, and no
|
|
256
|
+
account configured.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
cli_account: The ``--account`` value, or None when the flag is absent.
|
|
260
|
+
env_file_path: The ``.env`` file consulted for the account when neither
|
|
261
|
+
the flag nor the environment variable names one.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
A QuotaDecision carrying the exit code and the single log line for the
|
|
265
|
+
resolved outcome.
|
|
266
|
+
"""
|
|
267
|
+
account_login = _resolve_account(cli_account, env_file_path)
|
|
268
|
+
if account_login is None:
|
|
269
|
+
return QuotaDecision(
|
|
270
|
+
EXIT_CODE_NO_ACCOUNT_CONFIGURED,
|
|
271
|
+
f"copilot-quota: no account configured — set "
|
|
272
|
+
f"{COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME}=<login> in {env_file_path} "
|
|
273
|
+
f"(or pass --account). Skipping Copilot (scenario C).",
|
|
274
|
+
)
|
|
275
|
+
token = _resolve_gh_token(account_login)
|
|
276
|
+
if token is None:
|
|
277
|
+
return QuotaDecision(
|
|
278
|
+
EXIT_CODE_QUOTA_API_DOWN,
|
|
279
|
+
f"copilot-quota: could not resolve a gh token for '{account_login}' "
|
|
280
|
+
f"(is it gh auth login-ed?) — skipping Copilot (scenario B).",
|
|
281
|
+
)
|
|
282
|
+
all_user_fields = _fetch_copilot_user(token)
|
|
283
|
+
if all_user_fields is None:
|
|
284
|
+
return QuotaDecision(
|
|
285
|
+
EXIT_CODE_QUOTA_API_DOWN,
|
|
286
|
+
f"copilot-quota: gh api {COPILOT_INTERNAL_USER_API_PATH} failed or "
|
|
287
|
+
f"returned non-JSON for '{account_login}' — skipping Copilot "
|
|
288
|
+
f"(scenario B).",
|
|
289
|
+
)
|
|
290
|
+
premium_snapshot = _extract_premium_snapshot(all_user_fields)
|
|
291
|
+
if premium_snapshot is None:
|
|
292
|
+
return QuotaDecision(
|
|
293
|
+
EXIT_CODE_QUOTA_API_DOWN,
|
|
294
|
+
f"copilot-quota: the {COPILOT_INTERNAL_USER_API_PATH} response for "
|
|
295
|
+
f"'{account_login}' carried no "
|
|
296
|
+
f"{QUOTA_SNAPSHOTS_FIELD_NAME}.{PREMIUM_INTERACTIONS_FIELD_NAME} — "
|
|
297
|
+
f"skipping Copilot (scenario B).",
|
|
298
|
+
)
|
|
299
|
+
if _is_premium_quota_exhausted(premium_snapshot):
|
|
300
|
+
return QuotaDecision(
|
|
301
|
+
EXIT_CODE_OUT_OF_QUOTA,
|
|
302
|
+
f"copilot-quota: {account_login} is out of premium-interaction "
|
|
303
|
+
f"quota — {_describe_quota_amount(premium_snapshot)}, overage not "
|
|
304
|
+
f"permitted — skipping Copilot (scenario A).",
|
|
305
|
+
)
|
|
306
|
+
return QuotaDecision(
|
|
307
|
+
EXIT_CODE_QUOTA_AVAILABLE,
|
|
308
|
+
f"copilot-quota: {account_login} — premium interactions "
|
|
309
|
+
f"{_describe_quota_amount(premium_snapshot)} — running Copilot.",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
314
|
+
"""Parse command-line arguments for the quota pre-check.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
all_argv: Argument list excluding the program name, typically
|
|
318
|
+
``sys.argv[1:]``.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Namespace exposing an ``account`` attribute holding the ``--account``
|
|
322
|
+
value, or None when the flag is absent.
|
|
323
|
+
"""
|
|
324
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
325
|
+
parser.add_argument(
|
|
326
|
+
"--account",
|
|
327
|
+
default=None,
|
|
328
|
+
help=(
|
|
329
|
+
"GitHub login whose Copilot premium-interaction quota to check; "
|
|
330
|
+
"overrides COPILOT_QUOTA_ACCOUNT and the .env file"
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
return parser.parse_args(all_argv)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main(all_arguments: list[str]) -> int:
|
|
337
|
+
"""Run the quota pre-check end-to-end and print its one-line decision.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
all_arguments: Argument list excluding the program name.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
The pre-check exit code: 0 to run Copilot, or a non-zero skip code for
|
|
344
|
+
out-of-quota (scenario A), quota API or access down (scenario B), and no
|
|
345
|
+
account configured (scenario C).
|
|
346
|
+
"""
|
|
347
|
+
arguments = _parse_arguments(all_arguments)
|
|
348
|
+
decision = evaluate_copilot_quota(
|
|
349
|
+
cli_account=arguments.account,
|
|
350
|
+
env_file_path=COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH,
|
|
351
|
+
)
|
|
352
|
+
message_stream = (
|
|
353
|
+
sys.stdout if decision.exit_code == EXIT_CODE_QUOTA_AVAILABLE else sys.stderr
|
|
354
|
+
)
|
|
355
|
+
print(decision.message, file=message_stream)
|
|
356
|
+
return decision.exit_code
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
if __name__ == "__main__":
|
|
360
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -15,6 +15,8 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
|
|
|
15
15
|
| `preflight_constants.py` | `preflight.py` — env-var names, git subcommands, pytest exit codes, test discovery patterns |
|
|
16
16
|
| `preflight_self_heal_constants.py` | `preflight_self_heal.py` — git config keys and local-scope detection strings |
|
|
17
17
|
| `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` token taxonomy |
|
|
18
|
+
| `copilot_quota_constants.py` | `copilot_quota.py` — `COPILOT_QUOTA_ACCOUNT` env-var name, `copilot_internal/user` API path, the `premium_interactions` gating field names, the four skip/run exit codes, and the default `.env` path |
|
|
19
|
+
| `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, and the finding-message template |
|
|
18
20
|
| `__init__.py` | Empty package marker |
|
|
19
21
|
|
|
20
22
|
## Convention
|
|
@@ -80,3 +80,16 @@ ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX: tuple[str, ...] = (
|
|
|
80
80
|
"--name-only",
|
|
81
81
|
"-z",
|
|
82
82
|
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
ALL_PYTEST_MODULE_INVOCATION: tuple[str, ...] = (
|
|
86
|
+
"-m",
|
|
87
|
+
"pytest",
|
|
88
|
+
"-q",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
STAGED_PYTEST_TIMEOUT_SECONDS: int = 600
|
|
92
|
+
|
|
93
|
+
STAGED_TEST_FAILURE_HEADER: str = (
|
|
94
|
+
"code_rules_gate: staged test file(s) failed under pytest; commit blocked."
|
|
95
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration constants for the Copilot premium-interaction quota pre-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME: str = "COPILOT_QUOTA_ACCOUNT"
|
|
8
|
+
GH_TOKEN_ENV_VAR_NAME: str = "GH_TOKEN"
|
|
9
|
+
COPILOT_INTERNAL_USER_API_PATH: str = "copilot_internal/user"
|
|
10
|
+
|
|
11
|
+
QUOTA_SNAPSHOTS_FIELD_NAME: str = "quota_snapshots"
|
|
12
|
+
PREMIUM_INTERACTIONS_FIELD_NAME: str = "premium_interactions"
|
|
13
|
+
PREMIUM_UNLIMITED_FIELD_NAME: str = "unlimited"
|
|
14
|
+
PREMIUM_REMAINING_FIELD_NAME: str = "remaining"
|
|
15
|
+
PREMIUM_OVERAGE_PERMITTED_FIELD_NAME: str = "overage_permitted"
|
|
16
|
+
PREMIUM_ENTITLEMENT_FIELD_NAME: str = "entitlement"
|
|
17
|
+
PREMIUM_PERCENT_REMAINING_FIELD_NAME: str = "percent_remaining"
|
|
18
|
+
|
|
19
|
+
EXIT_CODE_QUOTA_AVAILABLE: int = 0
|
|
20
|
+
EXIT_CODE_OUT_OF_QUOTA: int = 1
|
|
21
|
+
EXIT_CODE_QUOTA_API_DOWN: int = 2
|
|
22
|
+
EXIT_CODE_NO_ACCOUNT_CONFIGURED: int = 3
|
|
23
|
+
|
|
24
|
+
COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH: Path = Path(__file__).resolve().parents[4] / ".env"
|