boomtick-cli 0.2.1__py3-none-any.whl
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.
- boomtick_cli-0.2.1.dist-info/METADATA +20 -0
- boomtick_cli-0.2.1.dist-info/RECORD +103 -0
- boomtick_cli-0.2.1.dist-info/WHEEL +5 -0
- boomtick_cli-0.2.1.dist-info/entry_points.txt +3 -0
- boomtick_cli-0.2.1.dist-info/top_level.txt +1 -0
- dev_tools/__init__.py +1 -0
- dev_tools/cli-schema.json +2028 -0
- dev_tools/cli.py +1542 -0
- dev_tools/config.py +235 -0
- dev_tools/constants.py +14 -0
- dev_tools/daemon.py +146 -0
- dev_tools/dist/config.js +108 -0
- dev_tools/dist/index.js +10 -0
- dev_tools/dist/lib/error_utils.js +8 -0
- dev_tools/dist/lib/git.js +31 -0
- dev_tools/dist/lib/result.js +21 -0
- dev_tools/dist/lib/shell.js +91 -0
- dev_tools/dist/lib/shell.test.js +10 -0
- dev_tools/dist/lib/td-cli.js +27 -0
- dev_tools/dist/lib/test-utils.js +20 -0
- dev_tools/dist/mcp/definitions.js +242 -0
- dev_tools/dist/mcp/server.js +317 -0
- dev_tools/dist/mcp/tools.js +18 -0
- dev_tools/dist/tools/contract.js +2069 -0
- dev_tools/dist/tools/ddgs.search.js +50 -0
- dev_tools/dist/tools/ddgs.search.test.js +67 -0
- dev_tools/dist/tools/ddgs_search.py +23 -0
- dev_tools/dist/tools/github.checkout_branch.js +20 -0
- dev_tools/dist/tools/github.comment_triage_summary.js +25 -0
- dev_tools/dist/tools/github.create_issue.js +28 -0
- dev_tools/dist/tools/github.create_issue.test.js +54 -0
- dev_tools/dist/tools/github.create_pull_request.js +44 -0
- dev_tools/dist/tools/github.get_merge_conflict_files.js +26 -0
- dev_tools/dist/tools/github.get_pr.js +18 -0
- dev_tools/dist/tools/github.get_pr_diff.js +24 -0
- dev_tools/dist/tools/github.issue_comment.js +24 -0
- dev_tools/dist/tools/github.issue_update.js +29 -0
- dev_tools/dist/tools/github.issue_view.js +28 -0
- dev_tools/dist/tools/github.open_replacement_pr.js +41 -0
- dev_tools/dist/tools/github.search_open_prs.js +31 -0
- dev_tools/dist/tools/github.search_open_prs.test.js +51 -0
- dev_tools/dist/tools/jules/cancel-session.js +20 -0
- dev_tools/dist/tools/jules/create-session.js +20 -0
- dev_tools/dist/tools/jules/create-session.test.js +99 -0
- dev_tools/dist/tools/jules/get-messages.js +19 -0
- dev_tools/dist/tools/jules/get-messages.test.js +45 -0
- dev_tools/dist/tools/jules/get-pr.js +14 -0
- dev_tools/dist/tools/jules/get-pr.test.js +37 -0
- dev_tools/dist/tools/jules/get-session.js +10 -0
- dev_tools/dist/tools/jules/list-sessions.js +43 -0
- dev_tools/dist/tools/jules/send-message.js +22 -0
- dev_tools/dist/tools/jules/send-message.test.js +56 -0
- dev_tools/dist/tools/jules/shared.js +26 -0
- dev_tools/dist/tools/jules/trigger-feedback.js +16 -0
- dev_tools/dist/tools/jules/trigger-feedback.test.js +42 -0
- dev_tools/dist/tools/repo.commit_patch.js +33 -0
- dev_tools/dist/tools/repo.create_branch.js +21 -0
- dev_tools/dist/tools/repo.create_branch.test.js +42 -0
- dev_tools/dist/tools/repo.create_repair_branch.js +38 -0
- dev_tools/dist/tools/repo.get_changed_files.js +21 -0
- dev_tools/dist/tools/repo.get_command_schema.js +27 -0
- dev_tools/dist/tools/repo.get_package_scripts.js +35 -0
- dev_tools/dist/tools/repo.get_package_scripts.test.js +39 -0
- dev_tools/dist/tools/repo.get_route_map.js +37 -0
- dev_tools/dist/tools/repo.logs.js +16 -0
- dev_tools/dist/tools/repo.read_agent_context.js +20 -0
- dev_tools/dist/tools/repo.read_ci_logs.js +18 -0
- dev_tools/dist/tools/repo.run_lighthouse.js +37 -0
- dev_tools/dist/tools/repo.run_playwright.js +29 -0
- dev_tools/dist/tools/repo.run_tests.js +43 -0
- dev_tools/dist/tools/types.js +1 -0
- dev_tools/get_ai_context.py +63 -0
- dev_tools/handlers/__init__.py +1 -0
- dev_tools/handlers/command_handler.py +69 -0
- dev_tools/mcp_server.py +36 -0
- dev_tools/models.py +354 -0
- dev_tools/orchestrator.py +2841 -0
- dev_tools/pr_overlap.py +139 -0
- dev_tools/resources/__init__.py +1 -0
- dev_tools/resources/build-repo-context.py +182 -0
- dev_tools/resources/prompt_constants.json +5 -0
- dev_tools/resources/review_template.md +41 -0
- dev_tools/resources/ux-audit.config.json +15 -0
- dev_tools/resources/visual_guidelines.json +3 -0
- dev_tools/review_read_pass.py +232 -0
- dev_tools/schema_gen.py +55 -0
- dev_tools/schema_utils.py +125 -0
- dev_tools/scope_check.py +85 -0
- dev_tools/services/__init__.py +1 -0
- dev_tools/services/ai_service.py +816 -0
- dev_tools/services/dependency_graph.py +123 -0
- dev_tools/services/github.py +783 -0
- dev_tools/services/jules.py +181 -0
- dev_tools/services/repair_service.py +199 -0
- dev_tools/services/vector_store.py +82 -0
- dev_tools/services/vision_service.py +91 -0
- dev_tools/td_cli.py +28 -0
- dev_tools/utils/__init__.py +1035 -0
- dev_tools/utils/git.py +68 -0
- dev_tools/ux_report.py +213 -0
- dev_tools/verify_infra.py +91 -0
- dev_tools/verify_versions.py +191 -0
- dev_tools/version_utils.py +175 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
# pylint: disable=too-many-lines,import-outside-toplevel,line-too-long,missing-docstring,no-else-return,raise-missing-from,redefined-outer-name,reimported,too-many-locals,unused-argument,unused-variable
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
import requests # type: ignore[import-untyped]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sanitize_path(path: str, max_length: int = 255) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Sanitizes a path to prevent traversal bugs and ensure it remains within the intended scope.
|
|
19
|
+
Validates that the resolved path is within the current working directory.
|
|
20
|
+
"""
|
|
21
|
+
if not path:
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
# 0. Length check
|
|
25
|
+
if len(path) > max_length:
|
|
26
|
+
path = path[:max_length]
|
|
27
|
+
|
|
28
|
+
# 1. Null byte protection
|
|
29
|
+
path = path.split("\0", 1)[0]
|
|
30
|
+
|
|
31
|
+
# 2. Prevent escaping repository root
|
|
32
|
+
try:
|
|
33
|
+
base_path = os.path.abspath(os.getcwd())
|
|
34
|
+
# Join and normalize to resolve '..'
|
|
35
|
+
abs_target = os.path.abspath(os.path.join(base_path, path))
|
|
36
|
+
|
|
37
|
+
if not abs_target.startswith(base_path):
|
|
38
|
+
# If it escapes, return just the basename or a safe version
|
|
39
|
+
# Here we just take the basename as a fallback to be safe but usable
|
|
40
|
+
normalized = os.path.basename(abs_target)
|
|
41
|
+
else:
|
|
42
|
+
normalized = os.path.relpath(abs_target, base_path)
|
|
43
|
+
except (ValueError, OSError):
|
|
44
|
+
normalized = os.path.basename(path)
|
|
45
|
+
|
|
46
|
+
# 3. Character Whitelisting: Allow only alphanumeric, dots, slashes, hyphens, and underscores
|
|
47
|
+
sanitized = re.sub(r"[^a-zA-Z0-9\./\-_]", "", normalized)
|
|
48
|
+
|
|
49
|
+
# 4. Collapse multiple slashes and strip
|
|
50
|
+
return re.sub(r"/+", "/", sanitized).strip("/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def escape_md(text: Any) -> str:
|
|
54
|
+
"""Escapes markdown special characters using a single regex pass."""
|
|
55
|
+
# Escape \, *, _, `, #, [, ], (, )
|
|
56
|
+
return re.sub(r"([\\*_\`#\[\]\(\)])", r"\\\1", str(text))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_git_commands(
|
|
60
|
+
commands: List[List[str]], cwd: Optional[str] = None
|
|
61
|
+
) -> List[Union[str, subprocess.CompletedProcess[str]]]:
|
|
62
|
+
"""Executes a sequence of git commands."""
|
|
63
|
+
results = []
|
|
64
|
+
for cmd in commands:
|
|
65
|
+
results.append(run_command(cmd, cwd=cwd))
|
|
66
|
+
return results
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sanitize_metadata(text: str) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Sanitizes metadata text (like PR titles or branch names) for safe use in filenames or markdown templates.
|
|
72
|
+
"""
|
|
73
|
+
if not text:
|
|
74
|
+
return ""
|
|
75
|
+
# Replace non-alphanumeric characters (except - and _) with hyphens
|
|
76
|
+
sanitized = re.sub(r"[^a-zA-Z0-9\-_]+", "-", text)
|
|
77
|
+
# Collapse multiple hyphens and strip
|
|
78
|
+
return re.sub(r"-+", "-", sanitized).strip("-")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mask_sensitive_data(msg: str) -> str:
|
|
82
|
+
"""Redacts sensitive information like GitHub tokens from strings."""
|
|
83
|
+
if not isinstance(msg, str):
|
|
84
|
+
msg = str(msg)
|
|
85
|
+
# Redact GitHub Tokens (Personal Access Tokens and Fine-grained Tokens)
|
|
86
|
+
msg = re.sub(r"ghp_[a-zA-Z0-9]{36,}", "ghp_***", msg)
|
|
87
|
+
msg = re.sub(r"github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59,}", "github_pat_***", msg)
|
|
88
|
+
# Generic token redaction for URLs or assignments (e.g., token=ABC123xyz)
|
|
89
|
+
msg = re.sub(
|
|
90
|
+
r"(?i)(token|auth|key|secret|password|access_token)([:=])[a-zA-Z0-9._-]{10,}",
|
|
91
|
+
r"\1\2***",
|
|
92
|
+
msg,
|
|
93
|
+
)
|
|
94
|
+
return msg
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def log_info(msg: str):
|
|
98
|
+
"""Logs an informational message to stderr."""
|
|
99
|
+
print(mask_sensitive_data(msg), file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def log_error(msg: str):
|
|
103
|
+
"""Logs an error message to stderr."""
|
|
104
|
+
print(f"❌ Error: {mask_sensitive_data(msg)}", file=sys.stderr)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def log_warn(msg: str):
|
|
108
|
+
"""Logs a warning message to stderr."""
|
|
109
|
+
print(f"⚠️ Warning: {mask_sensitive_data(msg)}", file=sys.stderr)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def log_debug(msg: str):
|
|
113
|
+
"""Logs a debug message to stderr."""
|
|
114
|
+
print(f"DEBUG: {mask_sensitive_data(msg)}", file=sys.stderr)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _call_api_with_retry(method: str, url: str, **kwargs) -> requests.Response:
|
|
118
|
+
"""Internal helper for making API calls with standard retry logic."""
|
|
119
|
+
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
|
|
120
|
+
from urllib3.util import Retry
|
|
121
|
+
|
|
122
|
+
# Default to 60s for standard calls, 300s for large downloads/logs
|
|
123
|
+
timeout = kwargs.pop("timeout", 60)
|
|
124
|
+
max_retries = kwargs.pop("max_retries", 3)
|
|
125
|
+
|
|
126
|
+
session = requests.Session()
|
|
127
|
+
retries = Retry(
|
|
128
|
+
total=max_retries,
|
|
129
|
+
backoff_factor=1,
|
|
130
|
+
status_forcelist=[500, 502, 503, 504],
|
|
131
|
+
raise_on_status=True,
|
|
132
|
+
)
|
|
133
|
+
session.mount("https://", HTTPAdapter(max_retries=retries))
|
|
134
|
+
session.mount("http://", HTTPAdapter(max_retries=retries))
|
|
135
|
+
|
|
136
|
+
response = session.request(method, url, timeout=timeout, **kwargs)
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def post_api_result(url: str, payload: Dict[str, Any]):
|
|
142
|
+
"""Standardizes API results back to a provided webhook or service."""
|
|
143
|
+
_call_api_with_retry("POST", url, json=payload, timeout=10, max_retries=5)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CLIError(Exception):
|
|
147
|
+
"""Base class for CLI errors with optional exit code and data."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, message, code=1, data=None):
|
|
150
|
+
self.message = message
|
|
151
|
+
self.code = code
|
|
152
|
+
self.data = data
|
|
153
|
+
super().__init__(self.message)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_base_dir() -> str:
|
|
157
|
+
"""Returns the absolute path to the CLI package root."""
|
|
158
|
+
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def resolve_resource_path(resource_name: str) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Resolves the absolute path to a package resource.
|
|
164
|
+
Handles importlib_resources with fallbacks for local development.
|
|
165
|
+
"""
|
|
166
|
+
# 1. Try the importlib_resources backport (preferred for compatibility)
|
|
167
|
+
try:
|
|
168
|
+
import importlib_resources as resources
|
|
169
|
+
|
|
170
|
+
# Try resources first
|
|
171
|
+
ref = resources.files("dev_tools.resources").joinpath(resource_name)
|
|
172
|
+
# mypy: Traversable might not have exists() depending on version, but it's common in backports
|
|
173
|
+
if hasattr(ref, "exists") and ref.exists():
|
|
174
|
+
return str(ref)
|
|
175
|
+
|
|
176
|
+
# Then try dev_tools root (for verify_versions.py etc)
|
|
177
|
+
ref = resources.files("dev_tools").joinpath(resource_name)
|
|
178
|
+
if ref.exists():
|
|
179
|
+
return str(ref)
|
|
180
|
+
except (ImportError, AttributeError, FileNotFoundError, TypeError) as e:
|
|
181
|
+
log_debug(f"importlib_resources failed for '{resource_name}': {e}. Falling back.")
|
|
182
|
+
|
|
183
|
+
# 2. Fallback to manual discovery for development/monorepo
|
|
184
|
+
# Assumes structural layout:
|
|
185
|
+
# Standalone: boomtick-pkg/cli/dev_tools/utils.py -> resources/
|
|
186
|
+
# Monorepo: boomtick-pkg/cli/dev_tools/utils.py -> scripts/ (3 levels up)
|
|
187
|
+
base_dir = Path(__file__).parent
|
|
188
|
+
|
|
189
|
+
candidates = [
|
|
190
|
+
base_dir / "resources" / resource_name,
|
|
191
|
+
base_dir / resource_name,
|
|
192
|
+
base_dir.parent / resource_name,
|
|
193
|
+
base_dir.parent.parent.parent / "scripts" / resource_name,
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
for cand in candidates:
|
|
197
|
+
# Validate existence before returning to avoid broken paths
|
|
198
|
+
if cand.exists():
|
|
199
|
+
return str(cand.absolute())
|
|
200
|
+
|
|
201
|
+
raise FileNotFoundError(f"Could not resolve resource: {resource_name}. Tried: {[str(c) for c in candidates]}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_workspace_log_dir() -> Path:
|
|
205
|
+
"""Returns the path to the workspace log directory (.boomtick/logs)."""
|
|
206
|
+
# Use CWD for workspace-based logging
|
|
207
|
+
return Path(os.getcwd()) / ".boomtick" / "logs"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def ensure_dir(*parts: str) -> str:
|
|
211
|
+
"""Joins path parts, ensures the directory exists, and returns the absolute path."""
|
|
212
|
+
# Prioritize workspace-based logging
|
|
213
|
+
path = get_workspace_log_dir().joinpath(*parts)
|
|
214
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
return str(path)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def safe_write_file(filepath: str, content: str):
|
|
219
|
+
"""
|
|
220
|
+
Writes content to a file while being symlink-aware and security-conscious.
|
|
221
|
+
If the filepath is a symlink, it resolves the real path and writes to the target,
|
|
222
|
+
preserving the symlink, but ONLY if the target is within the repository root.
|
|
223
|
+
"""
|
|
224
|
+
target_path = filepath
|
|
225
|
+
if os.path.islink(filepath):
|
|
226
|
+
target_path = os.path.realpath(filepath)
|
|
227
|
+
log_info(f"Symlink detected: {filepath} -> {target_path}")
|
|
228
|
+
|
|
229
|
+
# Security: Ensure target path is within repo root
|
|
230
|
+
repo_root = os.path.abspath(os.getcwd())
|
|
231
|
+
# Use realpath here to resolve symlinks and prevent escaping via symlink redirection
|
|
232
|
+
abs_target = os.path.realpath(target_path)
|
|
233
|
+
try:
|
|
234
|
+
# commonpath is the standard way to verify a subpath remains within a parent
|
|
235
|
+
if os.path.commonpath([repo_root, abs_target]) != repo_root:
|
|
236
|
+
raise CLIError(
|
|
237
|
+
f"Security Error: Target path {target_path} (resolved: {abs_target}) is outside of repository root."
|
|
238
|
+
)
|
|
239
|
+
except (ValueError, Exception) as e:
|
|
240
|
+
raise CLIError(f"Security Error: Target path {target_path} is invalid or outside of repository root: {e}")
|
|
241
|
+
|
|
242
|
+
# Ensure parent directory exists for the target
|
|
243
|
+
os.makedirs(os.path.dirname(abs_target), exist_ok=True)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
with open(abs_target, "w", encoding="utf-8") as f:
|
|
247
|
+
f.write(content)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
raise CLIError(f"Failed to write to {abs_target}: {e}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def apply_patch(filepath: str, patch_content: str):
|
|
253
|
+
"""
|
|
254
|
+
Applies a patch to a file using git apply with whitespace fixing.
|
|
255
|
+
Restricts application to the specific filepath for security.
|
|
256
|
+
"""
|
|
257
|
+
import tempfile
|
|
258
|
+
|
|
259
|
+
# Security: validate filepath - use realpath to resolve any symlink escapes
|
|
260
|
+
repo_root = os.path.abspath(os.getcwd())
|
|
261
|
+
abs_filepath = os.path.realpath(filepath)
|
|
262
|
+
try:
|
|
263
|
+
if os.path.commonpath([repo_root, abs_filepath]) != repo_root:
|
|
264
|
+
raise CLIError(f"Security Error: Path {filepath} is outside of repository root.")
|
|
265
|
+
except ValueError:
|
|
266
|
+
raise CLIError(f"Security Error: Path {filepath} is invalid or outside of repository root.")
|
|
267
|
+
|
|
268
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".patch", delete=False) as tmp:
|
|
269
|
+
tmp.write(patch_content)
|
|
270
|
+
tmp_path = tmp.name
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
# Use --include to restrict what git apply can touch
|
|
274
|
+
# filepath should be relative to repo root for --include
|
|
275
|
+
rel_path = os.path.relpath(abs_filepath, repo_root)
|
|
276
|
+
run_command(["git", "apply", "--whitespace=fix", "--include", rel_path, tmp_path])
|
|
277
|
+
log_info(f"Successfully applied patch to {filepath}")
|
|
278
|
+
finally:
|
|
279
|
+
if os.path.exists(tmp_path):
|
|
280
|
+
os.remove(tmp_path)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_or_create_log_dir(subdir: str) -> str:
|
|
284
|
+
"""Returns the path to a specific log subdirectory and ensures it exists."""
|
|
285
|
+
log_dir = get_workspace_log_dir() / subdir
|
|
286
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
return str(log_dir)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class DiskCache:
|
|
291
|
+
"""Lightweight disk-based cache for JSON-serializable data."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, subdir: str = "cache", no_cache: bool = False):
|
|
294
|
+
self.cache_dir = get_or_create_log_dir(subdir)
|
|
295
|
+
# Use explicit parameter or TD_NO_CACHE to bypass the cache
|
|
296
|
+
self.no_cache = no_cache or os.environ.get("TD_NO_CACHE") == "true"
|
|
297
|
+
|
|
298
|
+
def _get_path(self, key: str) -> Path:
|
|
299
|
+
hashed_key = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
300
|
+
return Path(self.cache_dir) / f"{hashed_key}.json"
|
|
301
|
+
|
|
302
|
+
def get(self, key: str) -> Optional[Any]:
|
|
303
|
+
if self.no_cache:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
path = self._get_path(key)
|
|
307
|
+
if not path.exists():
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
with path.open("r") as f:
|
|
312
|
+
data = json.load(f)
|
|
313
|
+
|
|
314
|
+
expires_at = data.get("expires_at")
|
|
315
|
+
if expires_at and time.time() > expires_at:
|
|
316
|
+
path.unlink()
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
return data.get("value")
|
|
320
|
+
except Exception as e:
|
|
321
|
+
log_warn(f"Failed to read cache for {key}: {e}")
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
def set(self, key: str, value: Any, ttl: Optional[int] = None):
|
|
325
|
+
if self.no_cache:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
path = self._get_path(key)
|
|
329
|
+
data = {
|
|
330
|
+
"value": value,
|
|
331
|
+
"created_at": time.time(),
|
|
332
|
+
"expires_at": (time.time() + ttl) if ttl else None,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
with path.open("w") as f:
|
|
337
|
+
json.dump(data, f)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
log_warn(f"Failed to write cache for {key}: {e}")
|
|
340
|
+
|
|
341
|
+
def delete(self, key: str):
|
|
342
|
+
path = self._get_path(key)
|
|
343
|
+
if path.exists():
|
|
344
|
+
try:
|
|
345
|
+
path.unlink()
|
|
346
|
+
except Exception as e:
|
|
347
|
+
log_warn(f"Failed to delete cache for {key}: {e}")
|
|
348
|
+
|
|
349
|
+
def clear(self):
|
|
350
|
+
"""Clears all cached items in this subdir without removing the directory."""
|
|
351
|
+
try:
|
|
352
|
+
for file_path in Path(self.cache_dir).iterdir():
|
|
353
|
+
if file_path.is_file():
|
|
354
|
+
file_path.unlink()
|
|
355
|
+
except Exception as e:
|
|
356
|
+
log_warn(f"Failed to clear cache: {e}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class APIConnectionError(Exception):
|
|
360
|
+
"""Custom exception for retriable API connection issues."""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _get_model_config(env_key: str, config_attr: str, fallback: str) -> str:
|
|
364
|
+
"""Helper to resolve AI models from env, then project_config, then fallback."""
|
|
365
|
+
env_val = os.environ.get(env_key)
|
|
366
|
+
if env_val:
|
|
367
|
+
return env_val
|
|
368
|
+
try:
|
|
369
|
+
from dev_tools.config import get_config
|
|
370
|
+
|
|
371
|
+
config = get_config()
|
|
372
|
+
return getattr(config, config_attr)
|
|
373
|
+
except Exception:
|
|
374
|
+
return fallback
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def get_ai_review_model() -> str:
|
|
378
|
+
"""Dynamic getter for the dedicated Code Reviewer model."""
|
|
379
|
+
return _get_model_config("AI_REVIEW_MODEL", "ai_review_model", "gpt-4o")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def get_ai_model() -> str:
|
|
383
|
+
"""Dynamic getter for the primary AI model."""
|
|
384
|
+
# Special case for legacy/variant env key
|
|
385
|
+
variant = os.environ.get("GITHUB_MODELS_MODEL")
|
|
386
|
+
if variant and not os.environ.get("AI_MODEL"):
|
|
387
|
+
return variant
|
|
388
|
+
return _get_model_config("AI_MODEL", "ai_synthesis_model", "gpt-4o-mini")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def get_gemini_model() -> str:
|
|
392
|
+
"""Dynamic getter for the Gemini model."""
|
|
393
|
+
return _get_model_config("GEMINI_MODEL", "ai_synthesis_model", "gemini-2.5-flash-lite")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def clean_llm_output(text: str) -> str:
|
|
397
|
+
"""
|
|
398
|
+
Removes markdown code blocks if present, or extracts from <findings> tags if present.
|
|
399
|
+
This utility focuses on standard LLM formatting (tags/blocks).
|
|
400
|
+
Pipeline-specific robust extraction should be handled by the caller.
|
|
401
|
+
"""
|
|
402
|
+
if not text:
|
|
403
|
+
return ""
|
|
404
|
+
|
|
405
|
+
# Handle double-escaped newlines commonly found in AI generated JSON in markdown
|
|
406
|
+
text = text.replace("\\\\n", "\\n")
|
|
407
|
+
|
|
408
|
+
# 1. Extract from <findings> tags if present
|
|
409
|
+
findings_match = re.search(r"<findings>\s*(.*?)\s*</findings>", text, re.DOTALL | re.IGNORECASE)
|
|
410
|
+
if findings_match:
|
|
411
|
+
text = findings_match.group(1).strip()
|
|
412
|
+
|
|
413
|
+
# 2. Extract from ```json or ``` code blocks
|
|
414
|
+
code_block_match = re.search(r"```(?:json|xml|tsx?|jsx?)?\s*\n?(.*?)\n?\s*```", text, re.DOTALL | re.IGNORECASE)
|
|
415
|
+
if code_block_match:
|
|
416
|
+
return code_block_match.group(1).strip()
|
|
417
|
+
|
|
418
|
+
return text.strip()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def is_ai_available() -> bool:
|
|
422
|
+
"""Checks if AI API token is present."""
|
|
423
|
+
return bool(os.getenv("GITHUB_TOKEN"))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def to_standard_schema(schema):
|
|
427
|
+
"""Recursively prepares a standard JSON schema.
|
|
428
|
+
- Ensures top-level 'type: object' if 'properties' is present.
|
|
429
|
+
- Ensures lowercase (Standard AI model naming).
|
|
430
|
+
"""
|
|
431
|
+
if isinstance(schema, dict):
|
|
432
|
+
# Auto-inject object type if properties are defined without a type
|
|
433
|
+
if "type" not in schema and "properties" in schema:
|
|
434
|
+
schema = {"type": "object", **schema}
|
|
435
|
+
|
|
436
|
+
new_schema = {}
|
|
437
|
+
for k, v in schema.items():
|
|
438
|
+
if k == "type" and isinstance(v, str):
|
|
439
|
+
new_schema[k] = v.lower()
|
|
440
|
+
else:
|
|
441
|
+
new_schema[k] = to_standard_schema(v)
|
|
442
|
+
return new_schema
|
|
443
|
+
elif isinstance(schema, list):
|
|
444
|
+
return [to_standard_schema(item) for item in schema]
|
|
445
|
+
return schema
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def call_ai(
|
|
449
|
+
prompt: str,
|
|
450
|
+
model: Optional[str] = None,
|
|
451
|
+
url: Optional[str] = None,
|
|
452
|
+
max_retries: int = 3,
|
|
453
|
+
schema: Optional[Dict[str, Any]] = None,
|
|
454
|
+
) -> Optional[str]:
|
|
455
|
+
"""Unified helper to call AI API using LangChain ChatOpenAI with retries."""
|
|
456
|
+
|
|
457
|
+
token = get_github_token()
|
|
458
|
+
if not token:
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
model = model or get_ai_model()
|
|
462
|
+
|
|
463
|
+
url_target = "https://models.inference.ai.azure.com/chat/completions"
|
|
464
|
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
465
|
+
payload = {
|
|
466
|
+
"model": model,
|
|
467
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
468
|
+
"temperature": 0.7,
|
|
469
|
+
"max_tokens": 2048,
|
|
470
|
+
}
|
|
471
|
+
if schema:
|
|
472
|
+
payload["response_format"] = {"type": "json_object"}
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
response = _call_api_with_retry(
|
|
476
|
+
"POST",
|
|
477
|
+
url_target,
|
|
478
|
+
headers=headers,
|
|
479
|
+
json=payload,
|
|
480
|
+
max_retries=max_retries,
|
|
481
|
+
retry_status_codes=[429, 500, 502, 503, 504],
|
|
482
|
+
)
|
|
483
|
+
if not response:
|
|
484
|
+
return None
|
|
485
|
+
return response.json()["choices"][0]["message"]["content"]
|
|
486
|
+
except Exception as e:
|
|
487
|
+
log_error(f"AI Call failed: {e}")
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def log_ai_run(entry: dict):
|
|
492
|
+
try:
|
|
493
|
+
log_dir = get_or_create_log_dir("ai")
|
|
494
|
+
log_file = os.path.join(log_dir, "review-run.jsonl")
|
|
495
|
+
from datetime import datetime
|
|
496
|
+
|
|
497
|
+
entry["timestamp"] = datetime.utcnow().isoformat() + "Z"
|
|
498
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
499
|
+
f.write(json.dumps(entry) + "\n")
|
|
500
|
+
except Exception as e:
|
|
501
|
+
log_error(f"Failed to append to AI run log: {e}")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def call_github_models(
|
|
505
|
+
prompt: str, model: Optional[str] = None, max_retries: int = 3, schema: Optional[Dict[str, Any]] = None
|
|
506
|
+
) -> Optional[str]:
|
|
507
|
+
"""Unified helper to call GitHub Models API (OpenAI-compatible)."""
|
|
508
|
+
token = get_github_token()
|
|
509
|
+
if not token:
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
base_url = os.environ.get("GITHUB_MODELS_BASE_URL", "https://models.inference.ai.azure.com")
|
|
513
|
+
if not base_url.endswith("/"):
|
|
514
|
+
base_url += "/"
|
|
515
|
+
target_url = urllib.parse.urljoin(base_url, "chat/completions")
|
|
516
|
+
|
|
517
|
+
data: Dict[str, Any] = {
|
|
518
|
+
"model": model or get_ai_model(),
|
|
519
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
520
|
+
"stream": False,
|
|
521
|
+
}
|
|
522
|
+
if schema:
|
|
523
|
+
# OpenAI style: prompt injection + json_object mode
|
|
524
|
+
norm_schema = to_standard_schema(schema)
|
|
525
|
+
data["response_format"] = {"type": "json_object"}
|
|
526
|
+
messages = data.get("messages")
|
|
527
|
+
if isinstance(messages, list):
|
|
528
|
+
messages.insert(
|
|
529
|
+
0,
|
|
530
|
+
{
|
|
531
|
+
"role": "system",
|
|
532
|
+
"content": f"Output MUST be valid JSON matching this schema: {json.dumps(norm_schema)}",
|
|
533
|
+
},
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
start_time = time.time()
|
|
537
|
+
try:
|
|
538
|
+
response = _call_api_with_retry(
|
|
539
|
+
"POST",
|
|
540
|
+
target_url,
|
|
541
|
+
json=data,
|
|
542
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
543
|
+
max_retries=max_retries,
|
|
544
|
+
)
|
|
545
|
+
res = response.json()
|
|
546
|
+
except Exception as e:
|
|
547
|
+
log_error(f"GitHub Models call failed: {e}")
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
551
|
+
|
|
552
|
+
if res and "usage" in res:
|
|
553
|
+
usage = res["usage"]
|
|
554
|
+
log_ai_run(
|
|
555
|
+
{
|
|
556
|
+
"type": "python-tool",
|
|
557
|
+
"model": model or get_ai_model(),
|
|
558
|
+
"inputTokens": usage.get("prompt_tokens", 0),
|
|
559
|
+
"outputTokens": usage.get("completion_tokens", 0),
|
|
560
|
+
"cacheTokens": usage.get("prompt_tokens_details", {}).get("cached_tokens", 0),
|
|
561
|
+
"totalTokens": usage.get("total_tokens", 0),
|
|
562
|
+
"durationMs": duration_ms,
|
|
563
|
+
"cost": 0,
|
|
564
|
+
"verdict": "unknown",
|
|
565
|
+
}
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
return res["choices"][0]["message"]["content"] if res and "choices" in res else None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def verify_ci_metrics(
|
|
572
|
+
input_threshold: Optional[int] = None,
|
|
573
|
+
output_threshold: Optional[int] = None,
|
|
574
|
+
total_threshold: Optional[int] = None,
|
|
575
|
+
):
|
|
576
|
+
"""Verifies that the aggregated AI token usage in the current run is within limits."""
|
|
577
|
+
|
|
578
|
+
# Use environment variables if provided, otherwise use documented defaults
|
|
579
|
+
# Note: Docs specify 150k input, 50k output, 200k total.
|
|
580
|
+
def get_limit(val, env_key, default):
|
|
581
|
+
if val is not None:
|
|
582
|
+
return int(val)
|
|
583
|
+
try:
|
|
584
|
+
return int(os.environ.get(env_key, default))
|
|
585
|
+
except (ValueError, TypeError):
|
|
586
|
+
return default
|
|
587
|
+
|
|
588
|
+
input_threshold = get_limit(input_threshold, "MAX_INPUT_TOKENS", 800000)
|
|
589
|
+
output_threshold = get_limit(output_threshold, "MAX_OUTPUT_TOKENS", 200000)
|
|
590
|
+
total_threshold = get_limit(total_threshold, "MAX_TOTAL_TOKENS", 1000000)
|
|
591
|
+
|
|
592
|
+
# Threshold validation
|
|
593
|
+
if input_threshold < 0 or output_threshold < 0 or total_threshold < 0:
|
|
594
|
+
raise CLIError("Thresholds must be non-negative integers.")
|
|
595
|
+
|
|
596
|
+
# Use Path for robust path resolution
|
|
597
|
+
log_file = Path(get_or_create_log_dir("ai")) / "review-run.jsonl"
|
|
598
|
+
|
|
599
|
+
if not log_file.exists():
|
|
600
|
+
# In multi-job CI, this might happen if reviews were skipped or logs weren't shared.
|
|
601
|
+
return {
|
|
602
|
+
"status": "success",
|
|
603
|
+
"message": "No AI usage logs found. Assuming 0 tokens used.",
|
|
604
|
+
"metrics": {
|
|
605
|
+
"inputTokens": 0,
|
|
606
|
+
"outputTokens": 0,
|
|
607
|
+
"totalTokens": 0,
|
|
608
|
+
"inputThreshold": input_threshold,
|
|
609
|
+
"outputThreshold": output_threshold,
|
|
610
|
+
"totalThreshold": total_threshold,
|
|
611
|
+
},
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
total_input = 0
|
|
615
|
+
total_output = 0
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
with log_file.open("r") as f:
|
|
619
|
+
for line in f:
|
|
620
|
+
if not line.strip():
|
|
621
|
+
continue
|
|
622
|
+
entry = json.loads(line)
|
|
623
|
+
total_input += entry.get("inputTokens", 0)
|
|
624
|
+
total_output += entry.get("outputTokens", 0)
|
|
625
|
+
except Exception as e:
|
|
626
|
+
log_error(f"Failed to read AI logs: {e}")
|
|
627
|
+
return {"status": "error", "message": f"Could not verify metrics: {e}"}
|
|
628
|
+
|
|
629
|
+
total_tokens = total_input + total_output
|
|
630
|
+
|
|
631
|
+
result = {
|
|
632
|
+
"inputTokens": total_input,
|
|
633
|
+
"outputTokens": total_output,
|
|
634
|
+
"totalTokens": total_tokens,
|
|
635
|
+
"inputThreshold": input_threshold,
|
|
636
|
+
"outputThreshold": output_threshold,
|
|
637
|
+
"totalThreshold": total_threshold,
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
errors = []
|
|
641
|
+
if total_input > input_threshold:
|
|
642
|
+
errors.append(f"Input tokens ({total_input}) exceeded limit ({input_threshold})")
|
|
643
|
+
if total_output > output_threshold:
|
|
644
|
+
errors.append(f"Output tokens ({total_output}) exceeded limit ({output_threshold})")
|
|
645
|
+
if total_tokens > total_threshold:
|
|
646
|
+
errors.append(f"Total tokens ({total_tokens}) exceeded limit ({total_threshold})")
|
|
647
|
+
|
|
648
|
+
if errors:
|
|
649
|
+
return {
|
|
650
|
+
"status": "error",
|
|
651
|
+
"message": "AI Token threshold exceeded: " + "; ".join(errors),
|
|
652
|
+
"metrics": result,
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {"status": "success", "message": "AI Token usage is within limits.", "metrics": result}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def call_gemini(
|
|
659
|
+
prompt: str, model: Optional[str] = None, max_retries: int = 3, schema: Optional[Dict[str, Any]] = None
|
|
660
|
+
) -> Optional[str]:
|
|
661
|
+
"""Unified helper to call Gemini API using LangChain."""
|
|
662
|
+
|
|
663
|
+
api_key = os.environ.get("GEMINI_API_KEY")
|
|
664
|
+
if not api_key:
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
if schema:
|
|
668
|
+
# Note: structured output handling varies by LangChain version/provider
|
|
669
|
+
# For simplicity in this shim, we'll rely on prompt engineering if bind_tools isn't used
|
|
670
|
+
prompt += f"\n\nOutput MUST be valid JSON matching this schema: {json.dumps(schema)}"
|
|
671
|
+
|
|
672
|
+
url_target = f"https://generativelanguage.googleapis.com/v1beta/models/{model or get_gemini_model()}:generateContent?key={api_key}"
|
|
673
|
+
headers = {"Content-Type": "application/json"}
|
|
674
|
+
payload = {
|
|
675
|
+
"contents": [{"parts": [{"text": prompt}]}],
|
|
676
|
+
"generationConfig": {
|
|
677
|
+
"temperature": 0.7,
|
|
678
|
+
},
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
response = _call_api_with_retry("POST", url_target, headers=headers, json=payload, max_retries=max_retries)
|
|
683
|
+
if not response:
|
|
684
|
+
return None
|
|
685
|
+
data = response.json()
|
|
686
|
+
if "candidates" in data and len(data["candidates"]) > 0:
|
|
687
|
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
|
688
|
+
return None
|
|
689
|
+
except Exception as e:
|
|
690
|
+
log_error(f"Gemini Call failed: {e}")
|
|
691
|
+
return None
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def call_ai_service(
|
|
695
|
+
prompt: str, model: Optional[str] = None, schema: Optional[Dict[str, Any]] = None
|
|
696
|
+
) -> Optional[str]:
|
|
697
|
+
"""
|
|
698
|
+
Orchestrates AI calls: GitHub Models -> Gemini.
|
|
699
|
+
"""
|
|
700
|
+
# 1. Try GitHub Models
|
|
701
|
+
res = call_github_models(prompt, model=model, schema=schema)
|
|
702
|
+
if res:
|
|
703
|
+
return res
|
|
704
|
+
|
|
705
|
+
# 2. Try Gemini
|
|
706
|
+
# Gemini model naming is different, let it use default for now
|
|
707
|
+
res = call_gemini(prompt, schema=schema)
|
|
708
|
+
if res:
|
|
709
|
+
return res
|
|
710
|
+
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def run_command(
|
|
715
|
+
cmd: Union[str, List[str]],
|
|
716
|
+
shell: bool = False,
|
|
717
|
+
check: bool = True,
|
|
718
|
+
input_str: Optional[str] = None,
|
|
719
|
+
log_on_error: bool = True,
|
|
720
|
+
**kwargs: Any,
|
|
721
|
+
) -> Union[str, subprocess.CompletedProcess[str]]:
|
|
722
|
+
"""
|
|
723
|
+
Unified command execution helper.
|
|
724
|
+
- If check=True (default): returns stripped stdout string, raises CLIError on non-zero exit.
|
|
725
|
+
- If check=False: returns CompletedProcess object.
|
|
726
|
+
"""
|
|
727
|
+
proc = subprocess.run(
|
|
728
|
+
cmd,
|
|
729
|
+
shell=shell,
|
|
730
|
+
input=input_str,
|
|
731
|
+
capture_output=True,
|
|
732
|
+
text=True,
|
|
733
|
+
check=False,
|
|
734
|
+
**kwargs,
|
|
735
|
+
)
|
|
736
|
+
if proc.returncode != 0 and log_on_error:
|
|
737
|
+
log_error(f"Command failed (exit {proc.returncode}): {proc.args}")
|
|
738
|
+
if proc.stdout:
|
|
739
|
+
log_info(f"--- stdout ---\n{proc.stdout.strip()}")
|
|
740
|
+
if proc.stderr:
|
|
741
|
+
log_info(f"--- stderr ---\n{proc.stderr.strip()}")
|
|
742
|
+
|
|
743
|
+
if check:
|
|
744
|
+
if proc.returncode != 0:
|
|
745
|
+
raise CLIError(f"Command failed with exit code {proc.returncode}", code=proc.returncode)
|
|
746
|
+
return proc.stdout.strip()
|
|
747
|
+
|
|
748
|
+
return proc
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def get_github_token() -> Optional[str]:
|
|
752
|
+
"""Retrieves the GitHub token from environment (prioritizing GITHUB_TOKEN) or falls back to gh auth token."""
|
|
753
|
+
token = os.getenv("GITHUB_TOKEN")
|
|
754
|
+
if token:
|
|
755
|
+
return token
|
|
756
|
+
# Fallback to local gh CLI auth token
|
|
757
|
+
try:
|
|
758
|
+
proc = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, check=False)
|
|
759
|
+
if proc.returncode == 0:
|
|
760
|
+
token = proc.stdout.strip()
|
|
761
|
+
if token:
|
|
762
|
+
return token
|
|
763
|
+
except Exception as e:
|
|
764
|
+
log_warn(f"Failed to discover GitHub token: {e}")
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def get_repo_name() -> Optional[str]:
|
|
769
|
+
"""Auto-detect repo from environment variables or git remote."""
|
|
770
|
+
repo = os.getenv("GITHUB_REPOSITORY") or os.getenv("GH_REPO")
|
|
771
|
+
if repo:
|
|
772
|
+
return repo
|
|
773
|
+
|
|
774
|
+
try:
|
|
775
|
+
# Using check=False here to avoid noisy logs for a common discovery step
|
|
776
|
+
res = run_command(["git", "config", "--get", "remote.origin.url"], check=False, log_on_error=False)
|
|
777
|
+
if not isinstance(res, subprocess.CompletedProcess):
|
|
778
|
+
return os.getenv("GH_REPO")
|
|
779
|
+
|
|
780
|
+
if res.returncode != 0:
|
|
781
|
+
return os.getenv("GH_REPO")
|
|
782
|
+
url = res.stdout.strip()
|
|
783
|
+
if not url:
|
|
784
|
+
return os.getenv("GH_REPO")
|
|
785
|
+
import re
|
|
786
|
+
|
|
787
|
+
match = re.search(r"[:/]([^/]+/[^/.]+)(\.git)?$", url)
|
|
788
|
+
return match.group(1) if match else url
|
|
789
|
+
except Exception as e:
|
|
790
|
+
log_warn(f"Failed to detect repository name: {e}")
|
|
791
|
+
return None
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
_gha_var_cache: Dict[str, str] = {}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def get_gha_variable(name: str) -> Optional[str]:
|
|
798
|
+
"""Helper function to retrieve a GHA variable via the native gh cli with lightweight memory cache."""
|
|
799
|
+
if name in _gha_var_cache:
|
|
800
|
+
return _gha_var_cache[name]
|
|
801
|
+
try:
|
|
802
|
+
proc = run_command(["gh", "variable", "get", name], check=False, log_on_error=False)
|
|
803
|
+
if isinstance(proc, subprocess.CompletedProcess) and proc.returncode == 0:
|
|
804
|
+
val = proc.stdout.strip()
|
|
805
|
+
_gha_var_cache[name] = val
|
|
806
|
+
return val
|
|
807
|
+
except Exception as e:
|
|
808
|
+
log_warn(f"Failed to get GHA variable {name}: {e}")
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def set_gha_variable(name: str, value: str) -> bool:
|
|
813
|
+
"""Helper function to set a GHA variable via the native gh cli."""
|
|
814
|
+
try:
|
|
815
|
+
proc = run_command(["gh", "variable", "set", name, "--body", str(value)], check=False, log_on_error=False)
|
|
816
|
+
if isinstance(proc, subprocess.CompletedProcess) and proc.returncode == 0:
|
|
817
|
+
_gha_var_cache[name] = str(value)
|
|
818
|
+
return True
|
|
819
|
+
elif isinstance(proc, subprocess.CompletedProcess):
|
|
820
|
+
log_warn(f"Failed to set GHA variable {name}. stderr: {proc.stderr}")
|
|
821
|
+
except Exception as e:
|
|
822
|
+
log_warn(f"Failed to set GHA variable {name}: {e}")
|
|
823
|
+
return False
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def extract_failing_info(logs: str) -> List[dict]:
|
|
827
|
+
"""Extracts failing test and build information from logs."""
|
|
828
|
+
findings = []
|
|
829
|
+
# TS Errors
|
|
830
|
+
ts_errors = re.findall(r"([a-zA-Z0-9_\-\./]+\.[tj]sx?):(\d+):(\d+) - error (TS\d+): (.*)", logs)
|
|
831
|
+
for file_path, line, col, code, msg in ts_errors:
|
|
832
|
+
findings.append({"file": file_path, "line": line, "message": f"{code}: {msg}", "type": "typescript"})
|
|
833
|
+
|
|
834
|
+
# Vitest Errors (Robust)
|
|
835
|
+
# Matches FAIL followed by the test file, then non-greedily finds the first ❯ trace
|
|
836
|
+
# (?!FAIL) ensures we don't skip over another FAIL block
|
|
837
|
+
vitest_matches = re.finditer(r"FAIL\s+([^\n]+)(?:(?!FAIL).)*?❯\s+([^\n:]+):(\d+):(\d+)", logs, re.DOTALL)
|
|
838
|
+
for m in vitest_matches:
|
|
839
|
+
findings.append(
|
|
840
|
+
{
|
|
841
|
+
"file": m.group(2),
|
|
842
|
+
"line": m.group(3),
|
|
843
|
+
"message": f"Test Failure in {m.group(1)}",
|
|
844
|
+
"type": "vitest",
|
|
845
|
+
}
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
# Playwright Errors
|
|
849
|
+
playwright_matches = re.finditer(r"\s*\d+\)\s+\[([^\]]+)\]\s+›\s+([^\s:]+):(\d+):(\d+)\s+›\s+(.*)", logs)
|
|
850
|
+
for m in playwright_matches:
|
|
851
|
+
findings.append(
|
|
852
|
+
{
|
|
853
|
+
"file": m.group(2),
|
|
854
|
+
"line": m.group(3),
|
|
855
|
+
"message": f"Playwright [{m.group(1)}] › {m.group(5)}",
|
|
856
|
+
"type": "playwright",
|
|
857
|
+
}
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
return findings
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def clean_gha_logs(logs: str) -> str:
|
|
864
|
+
"""Removes GitHub Action noise from logs while preserving actual error messages."""
|
|
865
|
+
if not logs:
|
|
866
|
+
return ""
|
|
867
|
+
|
|
868
|
+
lines = logs.splitlines()
|
|
869
|
+
cleaned = []
|
|
870
|
+
|
|
871
|
+
# Patterns to filter out after timestamp removal
|
|
872
|
+
noise_patterns = [
|
|
873
|
+
r"^\[command\].*",
|
|
874
|
+
r"^##\[command\].*",
|
|
875
|
+
r"^##\[warning\].*",
|
|
876
|
+
r"^##\[error\]Process completed with exit code.*",
|
|
877
|
+
r"^Removing credentials config.*",
|
|
878
|
+
r"^Stop and remove container.*",
|
|
879
|
+
r"^Remove container network.*",
|
|
880
|
+
r"^Cleaning up orphan processes.*",
|
|
881
|
+
r"^/usr/bin/docker.*",
|
|
882
|
+
]
|
|
883
|
+
combined_noise = re.compile("|".join(noise_patterns), re.IGNORECASE)
|
|
884
|
+
|
|
885
|
+
for line in lines:
|
|
886
|
+
# 1. Strip ANSI escape codes
|
|
887
|
+
line = re.sub(r"\x1b\[[0-9;]*[mGKF]", "", line)
|
|
888
|
+
|
|
889
|
+
# 2. Strip GHA timestamps
|
|
890
|
+
line = re.sub(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+", "", line)
|
|
891
|
+
|
|
892
|
+
# 3. Filter noise
|
|
893
|
+
if not combined_noise.search(line) and line.strip():
|
|
894
|
+
cleaned.append(line)
|
|
895
|
+
|
|
896
|
+
return "\n".join(cleaned)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def get_github_client():
|
|
900
|
+
from github import Auth, Github
|
|
901
|
+
|
|
902
|
+
token = get_github_token()
|
|
903
|
+
if not token:
|
|
904
|
+
raise CLIError("GitHub token not found", code=401)
|
|
905
|
+
return Github(auth=Auth.Token(token))
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def get_stack_versions(fetch_latest: bool = False) -> Dict[str, str]:
|
|
909
|
+
from dev_tools.version_utils import get_stack_versions as _get
|
|
910
|
+
|
|
911
|
+
return _get(fetch_latest=fetch_latest)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def compare_versions(v1: str, v2: str) -> int:
|
|
915
|
+
from dev_tools.version_utils import compare_versions as _cmp
|
|
916
|
+
|
|
917
|
+
return _cmp(v1, v2)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def fetch_latest_npm(package_name: str) -> Optional[str]:
|
|
921
|
+
from dev_tools.version_utils import fetch_latest_npm as _fetch
|
|
922
|
+
|
|
923
|
+
return _fetch(package_name)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def fetch_latest_gh_action(action_path: str) -> Optional[str]:
|
|
927
|
+
from dev_tools.version_utils import fetch_latest_gh_action as _fetch
|
|
928
|
+
|
|
929
|
+
return _fetch(action_path)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def fetch_latest_node() -> Optional[str]:
|
|
933
|
+
from dev_tools.version_utils import fetch_latest_node as _fetch
|
|
934
|
+
|
|
935
|
+
return _fetch()
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def walk_tsx(root_dir="src"):
|
|
939
|
+
for root, dirs, files in os.walk(root_dir):
|
|
940
|
+
for file in files:
|
|
941
|
+
if file.endswith(".tsx"):
|
|
942
|
+
yield os.path.join(root, file)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def find_patterns_in_file(filepath, patterns):
|
|
946
|
+
findings = []
|
|
947
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
948
|
+
content = f.read()
|
|
949
|
+
lines = content.split("\n")
|
|
950
|
+
for i, line in enumerate(lines):
|
|
951
|
+
for pattern, message in patterns:
|
|
952
|
+
match = re.search(pattern, line)
|
|
953
|
+
if match:
|
|
954
|
+
findings.append((i + 1, message, match.group()))
|
|
955
|
+
return findings
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def get_bundle_size(dist_dir="dist/assets"):
|
|
959
|
+
import glob
|
|
960
|
+
|
|
961
|
+
if not os.path.isdir(dist_dir):
|
|
962
|
+
log_warn(f"Bundle directory {dist_dir} not found.")
|
|
963
|
+
return 0
|
|
964
|
+
js_files = glob.glob(os.path.join(dist_dir, "*.js"))
|
|
965
|
+
if not js_files:
|
|
966
|
+
return 0
|
|
967
|
+
total_bytes = 0
|
|
968
|
+
for js_file in js_files:
|
|
969
|
+
try:
|
|
970
|
+
total_bytes += os.path.getsize(js_file)
|
|
971
|
+
except OSError as e:
|
|
972
|
+
log_error(f"getting size for {js_file}: {e}")
|
|
973
|
+
raise CLIError(f"Failed to calculate bundle size: {e}")
|
|
974
|
+
return (total_bytes + 1023) // 1024
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def get_any_count(search_dir="src"):
|
|
978
|
+
import shlex
|
|
979
|
+
|
|
980
|
+
if not os.path.isdir(search_dir):
|
|
981
|
+
log_warn(f"Search directory {search_dir} not found.")
|
|
982
|
+
return 0
|
|
983
|
+
safe_dir = shlex.quote(search_dir)
|
|
984
|
+
cmd = f"grep -rn ': any\\b\\|as any\\b' {safe_dir} --include='*.tsx' --include='*.ts'"
|
|
985
|
+
res = run_command(cmd, check=False, shell=True, log_on_error=False)
|
|
986
|
+
if res.returncode == 0:
|
|
987
|
+
return len(res.stdout.strip().split("\n")) if res.stdout.strip() else 0
|
|
988
|
+
elif res.returncode == 1:
|
|
989
|
+
return 0
|
|
990
|
+
else:
|
|
991
|
+
log_error(f"running grep: {res.stderr.strip()}")
|
|
992
|
+
raise CLIError(f"Grep failed with exit code {res.returncode}")
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def get_changed_files():
|
|
996
|
+
from dev_tools.config import get_config
|
|
997
|
+
|
|
998
|
+
config = get_config()
|
|
999
|
+
base = config.base_branch
|
|
1000
|
+
res = run_command(["git", "diff", "--name-only", base], check=False, log_on_error=False)
|
|
1001
|
+
if res.returncode == 0:
|
|
1002
|
+
return res.stdout.strip().splitlines()
|
|
1003
|
+
res = run_command(["git", "diff", "--name-only", "HEAD"], check=False, log_on_error=False)
|
|
1004
|
+
if res.returncode == 0:
|
|
1005
|
+
return res.stdout.strip().splitlines()
|
|
1006
|
+
return []
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def verify_pr_scope(file_list: Optional[List[str]] = None) -> Optional[str]:
|
|
1010
|
+
from dev_tools.config import get_config
|
|
1011
|
+
|
|
1012
|
+
if file_list is None:
|
|
1013
|
+
file_list = get_changed_files()
|
|
1014
|
+
config = get_config()
|
|
1015
|
+
core_dirs = config.core_dirs
|
|
1016
|
+
threshold = config.monolithic_pr_threshold
|
|
1017
|
+
core_files = [f for f in file_list if any(f.startswith(d) for d in core_dirs)]
|
|
1018
|
+
if len(core_files) > threshold:
|
|
1019
|
+
return f"PR scope warning: Touching {len(core_files)} core files in {core_dirs}. Consider splitting this monolithic PR to avoid merge conflicts (AGENTS.md §23)."
|
|
1020
|
+
content_scopes = config.content_scopes
|
|
1021
|
+
from typing import Set
|
|
1022
|
+
|
|
1023
|
+
active_scopes: Set[str] = set()
|
|
1024
|
+
for f in file_list:
|
|
1025
|
+
for scope_name, prefix in content_scopes.items():
|
|
1026
|
+
if f.startswith(prefix):
|
|
1027
|
+
active_scopes.add(scope_name)
|
|
1028
|
+
if len(active_scopes) > 1:
|
|
1029
|
+
scope_names = ", ".join(sorted(content_scopes.keys()))
|
|
1030
|
+
return f"Content scope warning: Mixed content domains detected ({', '.join(active_scopes)}). PRs should be split by scope: {scope_names} (AGENTS.md §21)."
|
|
1031
|
+
has_content = len(active_scopes) > 0
|
|
1032
|
+
code_files = [f for f in file_list if f.startswith("src/") and not any(f.startswith(d) for d in core_dirs)]
|
|
1033
|
+
if has_content and len(code_files) > 2:
|
|
1034
|
+
return "PR scope warning: Mixing significant code changes with content updates. Consider splitting content corrections from feature development."
|
|
1035
|
+
return None
|