claude-dev-env 1.36.0 → 1.36.2
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/audit-contract.md +159 -0
- package/_shared/pr-loop/code-rules-gate.md +64 -0
- package/_shared/pr-loop/fix-protocol.md +37 -0
- package/_shared/pr-loop/gh-payloads.md +85 -0
- package/_shared/pr-loop/scripts/README.md +20 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
- package/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
- package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
- package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
- package/_shared/pr-loop/scripts/config/preflight_constants.py +47 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
- package/_shared/pr-loop/scripts/gh_util.py +193 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
- package/_shared/pr-loop/scripts/preflight.py +227 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
- package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
- package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +333 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +82 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/state-schema.md +81 -0
- package/package.json +2 -1
- package/skills/bugteam/SKILL.md +332 -108
- package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
- package/skills/bugteam/test_team_lifecycle.py +9 -0
- package/skills/pr-converge/SKILL.md +1005 -395
- package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
- package/skills/pr-converge/test_team_lifecycle.py +9 -0
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Constants shared by grant_project_claude_permissions and revoke_project_claude_permissions."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from config.preflight_constants import GIT_DIRECTORY_NAME
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"ALL_PERMISSION_ALLOW_TOOLS",
|
|
9
|
+
"AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE",
|
|
10
|
+
"CLAUDE_SETTINGS_DIRECTORY_NAME",
|
|
11
|
+
"CLAUDE_SETTINGS_FILENAME",
|
|
12
|
+
"GIT_DIRECTORY_NAME",
|
|
13
|
+
"TEXT_FILE_ENCODING",
|
|
14
|
+
"UNIQUE_TEMPORARY_SUFFIX_BYTE_LENGTH",
|
|
15
|
+
"get_claude_user_settings_path",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ALL_PERMISSION_ALLOW_TOOLS: tuple[str, ...] = ("Edit", "Write", "Read")
|
|
20
|
+
|
|
21
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE: str = (
|
|
22
|
+
"Trusted local workspace: {project_path}/.claude/** is the user's "
|
|
23
|
+
"project Claude Code config tree; edits inside are routine"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CLAUDE_SETTINGS_DIRECTORY_NAME: str = ".claude"
|
|
27
|
+
|
|
28
|
+
CLAUDE_SETTINGS_FILENAME: str = "settings.json"
|
|
29
|
+
|
|
30
|
+
TEXT_FILE_ENCODING: str = "utf-8"
|
|
31
|
+
|
|
32
|
+
UNIQUE_TEMPORARY_SUFFIX_BYTE_LENGTH: int = 8
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_claude_user_settings_path() -> Path:
|
|
36
|
+
return Path.home() / CLAUDE_SETTINGS_DIRECTORY_NAME / CLAUDE_SETTINGS_FILENAME
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""JSON key names for ~/.claude/settings.json sections used by grant/revoke."""
|
|
2
|
+
|
|
3
|
+
CLAUDE_SETTINGS_PERMISSIONS_KEY: str = "permissions"
|
|
4
|
+
|
|
5
|
+
CLAUDE_SETTINGS_ALLOW_KEY: str = "allow"
|
|
6
|
+
|
|
7
|
+
CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY: str = "additionalDirectories"
|
|
8
|
+
|
|
9
|
+
CLAUDE_SETTINGS_AUTO_MODE_KEY: str = "autoMode"
|
|
10
|
+
|
|
11
|
+
CLAUDE_SETTINGS_ENVIRONMENT_KEY: str = "environment"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Constants for code_rules_gate.py per CODE_RULES centralized-config rule."""
|
|
2
|
+
|
|
3
|
+
MAX_VIOLATIONS_PER_CHECK: int = 3
|
|
4
|
+
EXPECTED_TUPLE_PAIR_LENGTH: int = 2
|
|
5
|
+
|
|
6
|
+
ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
|
7
|
+
{".py", ".js", ".ts", ".tsx", ".jsx"}
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
ALL_LITERAL_KEYWORD_EXEMPTIONS: frozenset[str] = frozenset(
|
|
11
|
+
{"true", "false", "none", "null"}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
CONFIG_PATH_SEGMENT: str = "/config/"
|
|
15
|
+
|
|
16
|
+
TESTS_PATH_SEGMENT: str = "/tests/"
|
|
17
|
+
|
|
18
|
+
ALL_TEST_FILENAME_SUFFIXES: tuple[str, ...] = ("_test.py",)
|
|
19
|
+
|
|
20
|
+
ALL_TEST_FILENAME_GLOB_SUFFIXES: tuple[str, ...] = (
|
|
21
|
+
".test.",
|
|
22
|
+
".spec.",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
TEST_CONFTEST_FILENAME: str = "conftest.py"
|
|
26
|
+
|
|
27
|
+
TEST_FILENAME_PREFIX: str = "test_"
|
|
28
|
+
|
|
29
|
+
MINIMUM_COLUMN_NAME_LENGTH_AFTER_FIRST_CHAR: int = 2
|
|
30
|
+
|
|
31
|
+
COLUMN_KEY_PATTERN_TEMPLATE: str = r"^[a-z][a-z0-9_]{{{minimum_length},}}$"
|
|
32
|
+
|
|
33
|
+
GIT_NAME_STATUS_ADDED_PREFIX: str = "A"
|
|
34
|
+
|
|
35
|
+
GIT_NAME_STATUS_RENAMED_PREFIX: str = "R"
|
|
36
|
+
|
|
37
|
+
EXPECTED_RENAME_COLUMN_COUNT: int = 3
|
|
38
|
+
|
|
39
|
+
EXPECTED_NON_RENAME_COLUMN_COUNT: int = 2
|
|
40
|
+
|
|
41
|
+
PYTHON_FILE_EXTENSION: str = ".py"
|
|
42
|
+
|
|
43
|
+
ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND: tuple[str, ...] = (
|
|
44
|
+
"git",
|
|
45
|
+
"diff",
|
|
46
|
+
"--cached",
|
|
47
|
+
"--name-only",
|
|
48
|
+
"-z",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX: tuple[str, ...] = (
|
|
52
|
+
"git",
|
|
53
|
+
"diff",
|
|
54
|
+
"--name-only",
|
|
55
|
+
"-z",
|
|
56
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Configuration constants for fix_hookspath auto-remediation script."""
|
|
2
|
+
|
|
3
|
+
ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS: tuple[str, str, str] = (
|
|
4
|
+
".claude",
|
|
5
|
+
"hooks",
|
|
6
|
+
"git-hooks",
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
HOOKS_PATH_SUFFIX: str = "/".join(ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS)
|
|
10
|
+
|
|
11
|
+
HOOKS_PATH_VERIFICATION_SUFFIX: str = "/".join(ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS[-2:])
|
|
12
|
+
|
|
13
|
+
ALL_HOME_ENV_VAR_NAMES: tuple[str, str] = ("HOME", "USERPROFILE")
|
|
14
|
+
|
|
15
|
+
PREFLIGHT_NO_PYTEST_FLAG: str = "--no-pytest"
|
|
16
|
+
|
|
17
|
+
PREFLIGHT_REPO_ROOT_FLAG: str = "--repo-root"
|
|
18
|
+
|
|
19
|
+
ALL_GIT_GLOBAL_GET_CORE_HOOKS_PATH_COMMAND: tuple[str, ...] = (
|
|
20
|
+
"git",
|
|
21
|
+
"config",
|
|
22
|
+
"--global",
|
|
23
|
+
"--get",
|
|
24
|
+
"core.hooksPath",
|
|
25
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Constants for gh_util.py per CODE_RULES centralized-config rule."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_TIMEOUT_SECONDS: int = 30
|
|
4
|
+
DEFAULT_RETRIES: int = 2
|
|
5
|
+
DEFAULT_BACKOFF_SECONDS: float = 1.0
|
|
6
|
+
EXPONENTIAL_BACKOFF_BASE: int = 2
|
|
7
|
+
GH_TIMEOUT_RETURN_CODE: int = 124
|
|
8
|
+
INLINE_REVIEW_COMMENTS_PATH_TEMPLATE: str = (
|
|
9
|
+
"/repos/{owner}/{repo}/pulls/{pull_number}/comments"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
ALL_TRANSIENT_ERROR_MARKERS: tuple[str, ...] = (
|
|
13
|
+
"connection reset",
|
|
14
|
+
"connection refused",
|
|
15
|
+
"timeout",
|
|
16
|
+
"timed out",
|
|
17
|
+
"temporarily unavailable",
|
|
18
|
+
"502",
|
|
19
|
+
"503",
|
|
20
|
+
"504",
|
|
21
|
+
"rate limit",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ALL_AUTH_ERROR_MARKERS: tuple[str, ...] = (
|
|
25
|
+
"gh auth login",
|
|
26
|
+
"authentication failed",
|
|
27
|
+
"http 401",
|
|
28
|
+
"http 403",
|
|
29
|
+
"forbidden",
|
|
30
|
+
"resource not accessible",
|
|
31
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Configuration constants for the bugteam preflight script."""
|
|
2
|
+
|
|
3
|
+
BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME: str = "BUGTEAM_PREFLIGHT_SKIP"
|
|
4
|
+
|
|
5
|
+
BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE: str = "1"
|
|
6
|
+
|
|
7
|
+
GIT_DIRECTORY_NAME: str = ".git"
|
|
8
|
+
|
|
9
|
+
CLAUDE_DIRECTORY_NAME: str = ".claude"
|
|
10
|
+
|
|
11
|
+
VENV_DIRECTORY_NAME: str = ".venv"
|
|
12
|
+
|
|
13
|
+
PYTEST_INI_FILENAME: str = "pytest.ini"
|
|
14
|
+
|
|
15
|
+
PYPROJECT_TOML_FILENAME: str = "pyproject.toml"
|
|
16
|
+
|
|
17
|
+
PRE_COMMIT_CONFIG_YAML_FILENAME: str = ".pre-commit-config.yaml"
|
|
18
|
+
|
|
19
|
+
PYTEST_TOML_TABLE_PREFIX: str = "[tool.pytest"
|
|
20
|
+
|
|
21
|
+
ALL_TEST_FILE_PATTERNS_FOR_DISCOVERY: tuple[str, str] = (
|
|
22
|
+
"test_*.py",
|
|
23
|
+
"*_test.py",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
ALL_TESTS_DIRECTORY_IGNORE_PARTS: frozenset[str] = frozenset(
|
|
27
|
+
{"site-packages", VENV_DIRECTORY_NAME, "venv", "node_modules"}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
ALL_REPOSITORY_ROOT_MARKER_FILENAMES: tuple[str, str] = (
|
|
31
|
+
GIT_DIRECTORY_NAME,
|
|
32
|
+
PYTEST_INI_FILENAME,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
ALL_GIT_CONFIG_GET_CORE_HOOKS_PATH_SUBCOMMAND: tuple[str, str, str] = (
|
|
36
|
+
"config",
|
|
37
|
+
"--get",
|
|
38
|
+
"core.hooksPath",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
ALL_PRE_COMMIT_RUN_ALL_FILES_COMMAND: tuple[str, str, str] = (
|
|
42
|
+
"pre-commit",
|
|
43
|
+
"run",
|
|
44
|
+
"--all-files",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
PYTEST_NO_TESTS_COLLECTED_EXIT_CODE: int = 5
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.modules.pop("config", None)
|
|
7
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
|
|
10
|
+
from config.fix_hookspath_constants import (
|
|
11
|
+
ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS,
|
|
12
|
+
ALL_GIT_GLOBAL_GET_CORE_HOOKS_PATH_COMMAND,
|
|
13
|
+
ALL_HOME_ENV_VAR_NAMES,
|
|
14
|
+
HOOKS_PATH_SUFFIX,
|
|
15
|
+
PREFLIGHT_NO_PYTEST_FLAG,
|
|
16
|
+
PREFLIGHT_REPO_ROOT_FLAG,
|
|
17
|
+
)
|
|
18
|
+
from config.preflight_constants import GIT_DIRECTORY_NAME
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_canonical_hooks_directory(
|
|
22
|
+
all_environment_overrides: dict[str, str] | None,
|
|
23
|
+
) -> Path:
|
|
24
|
+
if all_environment_overrides is not None:
|
|
25
|
+
for each_env_var_name in ALL_HOME_ENV_VAR_NAMES:
|
|
26
|
+
home_value = all_environment_overrides.get(each_env_var_name)
|
|
27
|
+
if home_value:
|
|
28
|
+
return Path(home_value).joinpath(*ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS)
|
|
29
|
+
return Path.home().joinpath(*ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def list_local_core_hooks_path_values(
|
|
33
|
+
repository_root: Path,
|
|
34
|
+
all_environment_overrides: dict[str, str] | None,
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
git_command = [
|
|
37
|
+
"git",
|
|
38
|
+
"-C",
|
|
39
|
+
str(repository_root),
|
|
40
|
+
"config",
|
|
41
|
+
"--local",
|
|
42
|
+
"--get-all",
|
|
43
|
+
"core.hooksPath",
|
|
44
|
+
]
|
|
45
|
+
completed_process = subprocess.run(
|
|
46
|
+
git_command,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
encoding="utf-8",
|
|
50
|
+
errors="replace",
|
|
51
|
+
check=False,
|
|
52
|
+
env=all_environment_overrides,
|
|
53
|
+
)
|
|
54
|
+
if completed_process.returncode != 0:
|
|
55
|
+
diagnostic_stderr = completed_process.stderr.strip()
|
|
56
|
+
if diagnostic_stderr:
|
|
57
|
+
print(
|
|
58
|
+
"fix_hookspath: git read of local core.hooksPath on "
|
|
59
|
+
f"{repository_root} exited {completed_process.returncode}: "
|
|
60
|
+
f"{diagnostic_stderr}",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
return []
|
|
64
|
+
return [
|
|
65
|
+
each_line.strip()
|
|
66
|
+
for each_line in completed_process.stdout.splitlines()
|
|
67
|
+
if each_line.strip()
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_global_core_hooks_path(
|
|
72
|
+
all_environment_overrides: dict[str, str] | None,
|
|
73
|
+
) -> str:
|
|
74
|
+
git_command = list(ALL_GIT_GLOBAL_GET_CORE_HOOKS_PATH_COMMAND)
|
|
75
|
+
completed_process = subprocess.run(
|
|
76
|
+
git_command,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
errors="replace",
|
|
81
|
+
check=False,
|
|
82
|
+
env=all_environment_overrides,
|
|
83
|
+
)
|
|
84
|
+
if completed_process.returncode != 0:
|
|
85
|
+
diagnostic_stderr = completed_process.stderr.strip()
|
|
86
|
+
if diagnostic_stderr:
|
|
87
|
+
print(
|
|
88
|
+
"fix_hookspath: git read of global core.hooksPath exited "
|
|
89
|
+
f"{completed_process.returncode}: {diagnostic_stderr}",
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
92
|
+
return ""
|
|
93
|
+
return completed_process.stdout.strip()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def unset_local_core_hooks_path(
|
|
97
|
+
repository_root: Path,
|
|
98
|
+
all_environment_overrides: dict[str, str] | None,
|
|
99
|
+
) -> int:
|
|
100
|
+
git_command = [
|
|
101
|
+
"git",
|
|
102
|
+
"-C",
|
|
103
|
+
str(repository_root),
|
|
104
|
+
"config",
|
|
105
|
+
"--local",
|
|
106
|
+
"--unset-all",
|
|
107
|
+
"core.hooksPath",
|
|
108
|
+
]
|
|
109
|
+
completed_process = subprocess.run(
|
|
110
|
+
git_command,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
check=False,
|
|
114
|
+
env=all_environment_overrides,
|
|
115
|
+
)
|
|
116
|
+
return completed_process.returncode
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_global_core_hooks_path(
|
|
120
|
+
target_value: str,
|
|
121
|
+
all_environment_overrides: dict[str, str] | None,
|
|
122
|
+
) -> int:
|
|
123
|
+
git_command = ["git", "config", "--global", "core.hooksPath", target_value]
|
|
124
|
+
completed_process = subprocess.run(
|
|
125
|
+
git_command,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
check=False,
|
|
129
|
+
env=all_environment_overrides,
|
|
130
|
+
)
|
|
131
|
+
return completed_process.returncode
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def normalize_hooks_path(raw_value: str) -> str:
|
|
135
|
+
return raw_value.replace("\\", "/").rstrip("/")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_canonical_hooks_path(raw_value: str) -> bool:
|
|
139
|
+
if not raw_value:
|
|
140
|
+
return False
|
|
141
|
+
return normalize_hooks_path(raw_value).endswith(HOOKS_PATH_SUFFIX)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def find_repository_root(start: Path) -> Path:
|
|
145
|
+
resolved_start = start.resolve()
|
|
146
|
+
candidate_paths = [resolved_start, *resolved_start.parents]
|
|
147
|
+
for each_candidate in candidate_paths:
|
|
148
|
+
marker = each_candidate / GIT_DIRECTORY_NAME
|
|
149
|
+
if marker.is_dir() or marker.is_file():
|
|
150
|
+
return each_candidate
|
|
151
|
+
return resolved_start
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def rerun_preflight(
|
|
155
|
+
repository_root: Path,
|
|
156
|
+
all_environment_overrides: dict[str, str] | None,
|
|
157
|
+
) -> int:
|
|
158
|
+
preflight_script_path = Path(__file__).resolve().parent / "preflight.py"
|
|
159
|
+
rerun_command = [
|
|
160
|
+
sys.executable,
|
|
161
|
+
str(preflight_script_path),
|
|
162
|
+
PREFLIGHT_NO_PYTEST_FLAG,
|
|
163
|
+
PREFLIGHT_REPO_ROOT_FLAG,
|
|
164
|
+
str(repository_root),
|
|
165
|
+
]
|
|
166
|
+
completed_process = subprocess.run(
|
|
167
|
+
rerun_command,
|
|
168
|
+
check=False,
|
|
169
|
+
env=all_environment_overrides,
|
|
170
|
+
)
|
|
171
|
+
return completed_process.returncode
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_arguments(all_arguments: list[str] | None) -> argparse.Namespace:
|
|
175
|
+
parser = argparse.ArgumentParser(
|
|
176
|
+
description=(
|
|
177
|
+
"Auto-fix core.hooksPath when bugteam preflight detects a stale override. "
|
|
178
|
+
"Removes a local-scope override and ensures global core.hooksPath points "
|
|
179
|
+
"at the canonical claude-dev-env git-hooks directory."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"--repo-root",
|
|
184
|
+
type=Path,
|
|
185
|
+
default=None,
|
|
186
|
+
help="Repository root (default: discover from cwd).",
|
|
187
|
+
)
|
|
188
|
+
return parser.parse_args(all_arguments)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(
|
|
192
|
+
all_arguments: list[str],
|
|
193
|
+
all_environment_overrides: dict[str, str] | None,
|
|
194
|
+
) -> int:
|
|
195
|
+
arguments = parse_arguments(all_arguments)
|
|
196
|
+
start_directory = Path.cwd()
|
|
197
|
+
repository_root = (
|
|
198
|
+
arguments.repo_root.resolve()
|
|
199
|
+
if arguments.repo_root is not None
|
|
200
|
+
else find_repository_root(start_directory)
|
|
201
|
+
)
|
|
202
|
+
canonical_hooks_directory = resolve_canonical_hooks_directory(all_environment_overrides)
|
|
203
|
+
expected_suffix = HOOKS_PATH_SUFFIX
|
|
204
|
+
if not canonical_hooks_directory.is_dir():
|
|
205
|
+
print(
|
|
206
|
+
"fix_hookspath: canonical hooks directory does not exist: "
|
|
207
|
+
f"{canonical_hooks_directory}\n"
|
|
208
|
+
"Run: npx claude-dev-env .\n"
|
|
209
|
+
"Then re-run /bugteam. The directory must end in "
|
|
210
|
+
f"'{expected_suffix}' and contain the claude-dev-env git hook shims.",
|
|
211
|
+
file=sys.stderr,
|
|
212
|
+
)
|
|
213
|
+
return 1
|
|
214
|
+
local_hooks_path_values = list_local_core_hooks_path_values(
|
|
215
|
+
repository_root,
|
|
216
|
+
all_environment_overrides,
|
|
217
|
+
)
|
|
218
|
+
has_non_canonical_local_override = any(
|
|
219
|
+
not is_canonical_hooks_path(each_value)
|
|
220
|
+
for each_value in local_hooks_path_values
|
|
221
|
+
)
|
|
222
|
+
if has_non_canonical_local_override:
|
|
223
|
+
unset_local_returncode = unset_local_core_hooks_path(
|
|
224
|
+
repository_root, all_environment_overrides
|
|
225
|
+
)
|
|
226
|
+
if unset_local_returncode != 0:
|
|
227
|
+
print(
|
|
228
|
+
"fix_hookspath: failed to unset local core.hooksPath on "
|
|
229
|
+
f"{repository_root} (git exit {unset_local_returncode}).",
|
|
230
|
+
file=sys.stderr,
|
|
231
|
+
)
|
|
232
|
+
return 1
|
|
233
|
+
print(
|
|
234
|
+
"fix_hookspath: removed stale local core.hooksPath override on "
|
|
235
|
+
f"{repository_root}",
|
|
236
|
+
file=sys.stderr,
|
|
237
|
+
)
|
|
238
|
+
current_global_value = read_global_core_hooks_path(all_environment_overrides)
|
|
239
|
+
if not is_canonical_hooks_path(current_global_value):
|
|
240
|
+
canonical_target_value = str(canonical_hooks_directory).replace("\\", "/")
|
|
241
|
+
global_set_exit_code = set_global_core_hooks_path(
|
|
242
|
+
canonical_target_value,
|
|
243
|
+
all_environment_overrides,
|
|
244
|
+
)
|
|
245
|
+
if global_set_exit_code != 0:
|
|
246
|
+
print(
|
|
247
|
+
"fix_hookspath: failed to set global core.hooksPath to "
|
|
248
|
+
f"{canonical_target_value} (git exit {global_set_exit_code}).",
|
|
249
|
+
file=sys.stderr,
|
|
250
|
+
)
|
|
251
|
+
return 1
|
|
252
|
+
print(
|
|
253
|
+
f"fix_hookspath: set global core.hooksPath to {canonical_target_value}",
|
|
254
|
+
file=sys.stderr,
|
|
255
|
+
)
|
|
256
|
+
return rerun_preflight(repository_root, all_environment_overrides)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__":
|
|
260
|
+
raise SystemExit(main(sys.argv[1:], None))
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Shared helpers for invoking GitHub CLI with basic resiliency."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
sys.modules.pop("config", None)
|
|
12
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
14
|
+
|
|
15
|
+
from config.gh_util_constants import (
|
|
16
|
+
ALL_AUTH_ERROR_MARKERS,
|
|
17
|
+
ALL_TRANSIENT_ERROR_MARKERS,
|
|
18
|
+
DEFAULT_BACKOFF_SECONDS,
|
|
19
|
+
DEFAULT_RETRIES,
|
|
20
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
21
|
+
EXPONENTIAL_BACKOFF_BASE,
|
|
22
|
+
GH_TIMEOUT_RETURN_CODE,
|
|
23
|
+
INLINE_REVIEW_COMMENTS_PATH_TEMPLATE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class GhResult:
|
|
29
|
+
returncode: int
|
|
30
|
+
stdout: str
|
|
31
|
+
stderr: str
|
|
32
|
+
is_timed_out: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_transient_error(message: str) -> bool:
|
|
36
|
+
lowered = message.lower()
|
|
37
|
+
return any(each_marker in lowered for each_marker in ALL_TRANSIENT_ERROR_MARKERS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_auth_error(message: str) -> bool:
|
|
41
|
+
lowered = message.lower()
|
|
42
|
+
return any(each_marker in lowered for each_marker in ALL_AUTH_ERROR_MARKERS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure_text(text_or_bytes: str | bytes | None) -> str:
|
|
46
|
+
if text_or_bytes is None:
|
|
47
|
+
return ""
|
|
48
|
+
if isinstance(text_or_bytes, bytes):
|
|
49
|
+
return text_or_bytes.decode(errors="replace")
|
|
50
|
+
return text_or_bytes
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_gh(
|
|
54
|
+
all_command: Sequence[str],
|
|
55
|
+
*,
|
|
56
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
57
|
+
) -> GhResult:
|
|
58
|
+
"""Run a gh command with timeout + transient retry handling.
|
|
59
|
+
|
|
60
|
+
Retries are attempted only for transient failures (network/server/rate-limit style
|
|
61
|
+
messages). Auth/scope failures are returned immediately to fail closed.
|
|
62
|
+
"""
|
|
63
|
+
if timeout_seconds <= 0:
|
|
64
|
+
raise ValueError("timeout_seconds must be positive")
|
|
65
|
+
max_attempts = DEFAULT_RETRIES + 1
|
|
66
|
+
each_attempt = 0
|
|
67
|
+
while True:
|
|
68
|
+
try:
|
|
69
|
+
gh_completion = subprocess.run(
|
|
70
|
+
all_command,
|
|
71
|
+
check=False,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=timeout_seconds,
|
|
75
|
+
)
|
|
76
|
+
except subprocess.TimeoutExpired as error:
|
|
77
|
+
error_stderr = _ensure_text(error.stderr)
|
|
78
|
+
error_stdout = _ensure_text(error.stdout)
|
|
79
|
+
message = (
|
|
80
|
+
error_stderr or error_stdout or ""
|
|
81
|
+
).strip() or "gh command timed out"
|
|
82
|
+
last_result = GhResult(
|
|
83
|
+
returncode=GH_TIMEOUT_RETURN_CODE,
|
|
84
|
+
stdout="",
|
|
85
|
+
stderr=message,
|
|
86
|
+
is_timed_out=True,
|
|
87
|
+
)
|
|
88
|
+
if each_attempt < max_attempts - 1:
|
|
89
|
+
time.sleep(
|
|
90
|
+
DEFAULT_BACKOFF_SECONDS
|
|
91
|
+
* (EXPONENTIAL_BACKOFF_BASE**each_attempt)
|
|
92
|
+
)
|
|
93
|
+
each_attempt += 1
|
|
94
|
+
continue
|
|
95
|
+
return last_result
|
|
96
|
+
|
|
97
|
+
gh_result = GhResult(
|
|
98
|
+
returncode=gh_completion.returncode,
|
|
99
|
+
stdout=gh_completion.stdout,
|
|
100
|
+
stderr=gh_completion.stderr,
|
|
101
|
+
)
|
|
102
|
+
if gh_result.returncode == 0:
|
|
103
|
+
return gh_result
|
|
104
|
+
|
|
105
|
+
combined = f"{gh_result.stderr}\n{gh_result.stdout}".strip()
|
|
106
|
+
if _is_auth_error(combined):
|
|
107
|
+
return gh_result
|
|
108
|
+
if each_attempt < max_attempts - 1 and _is_transient_error(combined):
|
|
109
|
+
time.sleep(
|
|
110
|
+
DEFAULT_BACKOFF_SECONDS * (EXPONENTIAL_BACKOFF_BASE**each_attempt)
|
|
111
|
+
)
|
|
112
|
+
each_attempt += 1
|
|
113
|
+
continue
|
|
114
|
+
return gh_result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def fetch_inline_review_comments(
|
|
118
|
+
owner: str,
|
|
119
|
+
repo: str,
|
|
120
|
+
pull_number: int,
|
|
121
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
122
|
+
) -> list[dict[str, object]] | None:
|
|
123
|
+
"""Fetch inline review comments for a pull request from the GitHub API.
|
|
124
|
+
|
|
125
|
+
Returns the parsed list of comment objects on success, or None when the
|
|
126
|
+
gh call fails or returns invalid/unexpected JSON. This preserves the
|
|
127
|
+
distinction between "no inline comments" and "unable to determine
|
|
128
|
+
inline comments".
|
|
129
|
+
"""
|
|
130
|
+
api_path = INLINE_REVIEW_COMMENTS_PATH_TEMPLATE.format(
|
|
131
|
+
owner=owner, repo=repo, pull_number=pull_number
|
|
132
|
+
)
|
|
133
|
+
fetch_result = run_gh(
|
|
134
|
+
[
|
|
135
|
+
"gh",
|
|
136
|
+
"-R",
|
|
137
|
+
f"{owner}/{repo}",
|
|
138
|
+
"api",
|
|
139
|
+
api_path,
|
|
140
|
+
"--paginate",
|
|
141
|
+
],
|
|
142
|
+
timeout_seconds=timeout_seconds,
|
|
143
|
+
)
|
|
144
|
+
if fetch_result.returncode != 0:
|
|
145
|
+
return None
|
|
146
|
+
parsed = _parse_paginated_json_array_documents(fetch_result.stdout)
|
|
147
|
+
if parsed is None:
|
|
148
|
+
return None
|
|
149
|
+
if not all(isinstance(each_item, dict) for each_item in parsed):
|
|
150
|
+
return None
|
|
151
|
+
return parsed
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_paginated_json_array_documents(
|
|
155
|
+
raw_output: str,
|
|
156
|
+
) -> list[dict[str, object]] | None:
|
|
157
|
+
"""Parse gh --paginate output that emits one JSON array per page.
|
|
158
|
+
|
|
159
|
+
Concatenated array documents (`[...][...]`) are decoded one at a time
|
|
160
|
+
using json.JSONDecoder.raw_decode and merged into a single flat list.
|
|
161
|
+
Returns None when any decoded document is not a JSON array.
|
|
162
|
+
"""
|
|
163
|
+
decoder = json.JSONDecoder()
|
|
164
|
+
cursor_index = 0
|
|
165
|
+
output_length = len(raw_output)
|
|
166
|
+
flattened: list[dict[str, object]] = []
|
|
167
|
+
while cursor_index < output_length:
|
|
168
|
+
while cursor_index < output_length and raw_output[cursor_index].isspace():
|
|
169
|
+
cursor_index += 1
|
|
170
|
+
if cursor_index >= output_length:
|
|
171
|
+
break
|
|
172
|
+
try:
|
|
173
|
+
decoded_document, end_index = decoder.raw_decode(
|
|
174
|
+
raw_output, cursor_index
|
|
175
|
+
)
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
return None
|
|
178
|
+
if not isinstance(decoded_document, list):
|
|
179
|
+
return None
|
|
180
|
+
flattened.extend(decoded_document)
|
|
181
|
+
cursor_index = end_index
|
|
182
|
+
return flattened
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_owner_repo(repository: str) -> tuple[str, str]:
|
|
186
|
+
if "/" not in repository:
|
|
187
|
+
raise ValueError("repository must be owner/repo with exactly one slash")
|
|
188
|
+
owner, name = repository.split("/", maxsplit=1)
|
|
189
|
+
if not owner or not name:
|
|
190
|
+
raise ValueError("repository must be owner/repo with exactly one slash")
|
|
191
|
+
if "/" in name:
|
|
192
|
+
raise ValueError("repository must be owner/repo with exactly one slash")
|
|
193
|
+
return owner, name
|