claude-dev-env 1.36.1 → 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.
Files changed (36) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +47 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +227 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +333 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +82 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/package.json +2 -1
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