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.
Files changed (103) hide show
  1. boomtick_cli-0.2.1.dist-info/METADATA +20 -0
  2. boomtick_cli-0.2.1.dist-info/RECORD +103 -0
  3. boomtick_cli-0.2.1.dist-info/WHEEL +5 -0
  4. boomtick_cli-0.2.1.dist-info/entry_points.txt +3 -0
  5. boomtick_cli-0.2.1.dist-info/top_level.txt +1 -0
  6. dev_tools/__init__.py +1 -0
  7. dev_tools/cli-schema.json +2028 -0
  8. dev_tools/cli.py +1542 -0
  9. dev_tools/config.py +235 -0
  10. dev_tools/constants.py +14 -0
  11. dev_tools/daemon.py +146 -0
  12. dev_tools/dist/config.js +108 -0
  13. dev_tools/dist/index.js +10 -0
  14. dev_tools/dist/lib/error_utils.js +8 -0
  15. dev_tools/dist/lib/git.js +31 -0
  16. dev_tools/dist/lib/result.js +21 -0
  17. dev_tools/dist/lib/shell.js +91 -0
  18. dev_tools/dist/lib/shell.test.js +10 -0
  19. dev_tools/dist/lib/td-cli.js +27 -0
  20. dev_tools/dist/lib/test-utils.js +20 -0
  21. dev_tools/dist/mcp/definitions.js +242 -0
  22. dev_tools/dist/mcp/server.js +317 -0
  23. dev_tools/dist/mcp/tools.js +18 -0
  24. dev_tools/dist/tools/contract.js +2069 -0
  25. dev_tools/dist/tools/ddgs.search.js +50 -0
  26. dev_tools/dist/tools/ddgs.search.test.js +67 -0
  27. dev_tools/dist/tools/ddgs_search.py +23 -0
  28. dev_tools/dist/tools/github.checkout_branch.js +20 -0
  29. dev_tools/dist/tools/github.comment_triage_summary.js +25 -0
  30. dev_tools/dist/tools/github.create_issue.js +28 -0
  31. dev_tools/dist/tools/github.create_issue.test.js +54 -0
  32. dev_tools/dist/tools/github.create_pull_request.js +44 -0
  33. dev_tools/dist/tools/github.get_merge_conflict_files.js +26 -0
  34. dev_tools/dist/tools/github.get_pr.js +18 -0
  35. dev_tools/dist/tools/github.get_pr_diff.js +24 -0
  36. dev_tools/dist/tools/github.issue_comment.js +24 -0
  37. dev_tools/dist/tools/github.issue_update.js +29 -0
  38. dev_tools/dist/tools/github.issue_view.js +28 -0
  39. dev_tools/dist/tools/github.open_replacement_pr.js +41 -0
  40. dev_tools/dist/tools/github.search_open_prs.js +31 -0
  41. dev_tools/dist/tools/github.search_open_prs.test.js +51 -0
  42. dev_tools/dist/tools/jules/cancel-session.js +20 -0
  43. dev_tools/dist/tools/jules/create-session.js +20 -0
  44. dev_tools/dist/tools/jules/create-session.test.js +99 -0
  45. dev_tools/dist/tools/jules/get-messages.js +19 -0
  46. dev_tools/dist/tools/jules/get-messages.test.js +45 -0
  47. dev_tools/dist/tools/jules/get-pr.js +14 -0
  48. dev_tools/dist/tools/jules/get-pr.test.js +37 -0
  49. dev_tools/dist/tools/jules/get-session.js +10 -0
  50. dev_tools/dist/tools/jules/list-sessions.js +43 -0
  51. dev_tools/dist/tools/jules/send-message.js +22 -0
  52. dev_tools/dist/tools/jules/send-message.test.js +56 -0
  53. dev_tools/dist/tools/jules/shared.js +26 -0
  54. dev_tools/dist/tools/jules/trigger-feedback.js +16 -0
  55. dev_tools/dist/tools/jules/trigger-feedback.test.js +42 -0
  56. dev_tools/dist/tools/repo.commit_patch.js +33 -0
  57. dev_tools/dist/tools/repo.create_branch.js +21 -0
  58. dev_tools/dist/tools/repo.create_branch.test.js +42 -0
  59. dev_tools/dist/tools/repo.create_repair_branch.js +38 -0
  60. dev_tools/dist/tools/repo.get_changed_files.js +21 -0
  61. dev_tools/dist/tools/repo.get_command_schema.js +27 -0
  62. dev_tools/dist/tools/repo.get_package_scripts.js +35 -0
  63. dev_tools/dist/tools/repo.get_package_scripts.test.js +39 -0
  64. dev_tools/dist/tools/repo.get_route_map.js +37 -0
  65. dev_tools/dist/tools/repo.logs.js +16 -0
  66. dev_tools/dist/tools/repo.read_agent_context.js +20 -0
  67. dev_tools/dist/tools/repo.read_ci_logs.js +18 -0
  68. dev_tools/dist/tools/repo.run_lighthouse.js +37 -0
  69. dev_tools/dist/tools/repo.run_playwright.js +29 -0
  70. dev_tools/dist/tools/repo.run_tests.js +43 -0
  71. dev_tools/dist/tools/types.js +1 -0
  72. dev_tools/get_ai_context.py +63 -0
  73. dev_tools/handlers/__init__.py +1 -0
  74. dev_tools/handlers/command_handler.py +69 -0
  75. dev_tools/mcp_server.py +36 -0
  76. dev_tools/models.py +354 -0
  77. dev_tools/orchestrator.py +2841 -0
  78. dev_tools/pr_overlap.py +139 -0
  79. dev_tools/resources/__init__.py +1 -0
  80. dev_tools/resources/build-repo-context.py +182 -0
  81. dev_tools/resources/prompt_constants.json +5 -0
  82. dev_tools/resources/review_template.md +41 -0
  83. dev_tools/resources/ux-audit.config.json +15 -0
  84. dev_tools/resources/visual_guidelines.json +3 -0
  85. dev_tools/review_read_pass.py +232 -0
  86. dev_tools/schema_gen.py +55 -0
  87. dev_tools/schema_utils.py +125 -0
  88. dev_tools/scope_check.py +85 -0
  89. dev_tools/services/__init__.py +1 -0
  90. dev_tools/services/ai_service.py +816 -0
  91. dev_tools/services/dependency_graph.py +123 -0
  92. dev_tools/services/github.py +783 -0
  93. dev_tools/services/jules.py +181 -0
  94. dev_tools/services/repair_service.py +199 -0
  95. dev_tools/services/vector_store.py +82 -0
  96. dev_tools/services/vision_service.py +91 -0
  97. dev_tools/td_cli.py +28 -0
  98. dev_tools/utils/__init__.py +1035 -0
  99. dev_tools/utils/git.py +68 -0
  100. dev_tools/ux_report.py +213 -0
  101. dev_tools/verify_infra.py +91 -0
  102. dev_tools/verify_versions.py +191 -0
  103. 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