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,181 @@
|
|
|
1
|
+
# pylint: disable=invalid-name,line-too-long,missing-docstring,missing-timeout,raise-missing-from
|
|
2
|
+
import os
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
from typing import Any, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from dev_tools.utils import log_debug, log_info, log_warn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JulesClient:
|
|
11
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
12
|
+
self.api_key = api_key or os.environ.get("JULES_API_KEY")
|
|
13
|
+
if not self.api_key:
|
|
14
|
+
raise ValueError("JULES_API_KEY is not set or empty")
|
|
15
|
+
|
|
16
|
+
self.base_url = "https://jules.googleapis.com/v1alpha"
|
|
17
|
+
self.legacy_url = "https://api.jules.ai/v1/sessions"
|
|
18
|
+
self.headers = {"Content-Type": "application/json", "x-goog-api-key": self.api_key}
|
|
19
|
+
|
|
20
|
+
def _get_clean_id(self, res_id: str, prefix: str) -> str:
|
|
21
|
+
return res_id.replace(f"{prefix}/", "")
|
|
22
|
+
|
|
23
|
+
def list_sources(self) -> List[Dict[str, Any]]:
|
|
24
|
+
url = f"{self.base_url}/sources"
|
|
25
|
+
response = requests.get(url, headers=self.headers, timeout=10)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
return response.json().get("sources", [])
|
|
28
|
+
|
|
29
|
+
def list_sessions(self, pageSize: int = 100) -> List[Dict[str, Any]]:
|
|
30
|
+
url = f"{self.base_url}/sessions"
|
|
31
|
+
params = {"pageSize": pageSize}
|
|
32
|
+
response = requests.get(url, headers=self.headers, params=params, timeout=10)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
return response.json().get("sessions", [])
|
|
35
|
+
|
|
36
|
+
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
37
|
+
clean_id = self._get_clean_id(session_id, "sessions")
|
|
38
|
+
url = f"{self.base_url}/sessions/{clean_id}"
|
|
39
|
+
response = requests.get(url, headers=self.headers, timeout=10)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
return response.json()
|
|
42
|
+
|
|
43
|
+
def discover_source_id(self, repo_full_name: str) -> Optional[str]:
|
|
44
|
+
sources = self.list_sources()
|
|
45
|
+
for s in sources:
|
|
46
|
+
ctx = s.get("githubRepo", {})
|
|
47
|
+
owner, repo = ctx.get("owner"), ctx.get("repo")
|
|
48
|
+
if owner and repo and f"{owner}/{repo}" == repo_full_name:
|
|
49
|
+
return self._get_clean_id(s.get("name") or "", "sources")
|
|
50
|
+
if repo_full_name in s.get("displayName", ""):
|
|
51
|
+
return self._get_clean_id(s.get("name") or "", "sources")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def create_session_from_source(self, source_id: str, branch: str, prompt: str) -> Optional[Dict[str, Any]]:
|
|
55
|
+
url = f"{self.base_url}/sessions"
|
|
56
|
+
clean_source_id = self._get_clean_id(source_id, "sources")
|
|
57
|
+
payload = {
|
|
58
|
+
"prompt": prompt,
|
|
59
|
+
"sourceContext": {
|
|
60
|
+
"source": f"sources/{clean_source_id}",
|
|
61
|
+
"githubRepoContext": {"startingBranch": branch},
|
|
62
|
+
},
|
|
63
|
+
"automationMode": "AUTO_CREATE_PR",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
log_debug(f"Creating Jules session at {url}")
|
|
67
|
+
log_debug(f"Payload: {payload}")
|
|
68
|
+
|
|
69
|
+
response = requests.post(url, headers=self.headers, json=payload, timeout=15)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
return response.json()
|
|
72
|
+
|
|
73
|
+
def create_session(self, prompt: str, branch: str, title: str, owner: str, repo_name: str) -> str:
|
|
74
|
+
"""Creates a new Jules session via the legacy API."""
|
|
75
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
76
|
+
payload = {
|
|
77
|
+
"prompt": prompt,
|
|
78
|
+
"branch": branch,
|
|
79
|
+
"title": title,
|
|
80
|
+
"owner": owner,
|
|
81
|
+
"repo_name": repo_name,
|
|
82
|
+
}
|
|
83
|
+
try:
|
|
84
|
+
response = requests.post(self.legacy_url, headers=headers, json=payload)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
session_id = response.json().get("id")
|
|
87
|
+
if not session_id:
|
|
88
|
+
raise RuntimeError("Could not find session ID in API response.")
|
|
89
|
+
return session_id
|
|
90
|
+
except requests.exceptions.RequestException as e:
|
|
91
|
+
error_msg = f"Error creating Jules session: {e}"
|
|
92
|
+
if e.response is not None:
|
|
93
|
+
error_msg += f"\nResponse: {e.response.text}"
|
|
94
|
+
raise RuntimeError(error_msg)
|
|
95
|
+
|
|
96
|
+
def _extract_activity_content(self, act: Dict[str, Any]) -> str:
|
|
97
|
+
if act.get("userMessaged"):
|
|
98
|
+
um = act["userMessaged"]
|
|
99
|
+
if isinstance(um, str):
|
|
100
|
+
return um
|
|
101
|
+
if isinstance(um, dict):
|
|
102
|
+
user_msg = um.get("userMessage", "")
|
|
103
|
+
return user_msg.get("body", "") if isinstance(user_msg, dict) else user_msg
|
|
104
|
+
elif act.get("progressUpdated") and isinstance(act["progressUpdated"], dict):
|
|
105
|
+
return act["progressUpdated"].get("description", "")
|
|
106
|
+
elif act.get("planGenerated") and isinstance(act["planGenerated"], dict):
|
|
107
|
+
plan = act["planGenerated"].get("plan") or {}
|
|
108
|
+
steps = plan.get("steps", []) if isinstance(plan, dict) else []
|
|
109
|
+
return "Generated plan:\n" + "\n".join(
|
|
110
|
+
f"- {s.get('description', '')}" for s in steps if isinstance(s, dict)
|
|
111
|
+
)
|
|
112
|
+
elif act.get("sessionCompleted"):
|
|
113
|
+
return "Session completed successfully."
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
def get_messages(self, session_id: str, timeout: int = 10) -> List[Dict[str, Any]]:
|
|
117
|
+
clean_id = self._get_clean_id(session_id, "sessions")
|
|
118
|
+
url = f"{self.base_url}/sessions/{clean_id}/activities"
|
|
119
|
+
response = requests.get(url, headers=self.headers, timeout=timeout)
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
activities = response.json().get("activities", [])
|
|
122
|
+
messages = []
|
|
123
|
+
for act in activities:
|
|
124
|
+
content = self._extract_activity_content(act)
|
|
125
|
+
if content:
|
|
126
|
+
messages.append(
|
|
127
|
+
{
|
|
128
|
+
"role": "user" if act.get("originator") == "user" else "jules",
|
|
129
|
+
"content": content,
|
|
130
|
+
"time": act.get("createTime"),
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
return messages
|
|
134
|
+
|
|
135
|
+
def send_message(self, session_id: Union[str, List[str]], message: str) -> Dict[str, Any]:
|
|
136
|
+
session_ids = [session_id] if isinstance(session_id, str) else session_id
|
|
137
|
+
|
|
138
|
+
if len(session_ids) == 1:
|
|
139
|
+
return self._send_single_message(session_ids[0], message)
|
|
140
|
+
|
|
141
|
+
# The Pydantic model now enforces the BATCH_CAP, but we keep this as a secondary safety
|
|
142
|
+
BATCH_CAP = 50
|
|
143
|
+
if len(session_ids) > BATCH_CAP:
|
|
144
|
+
log_warn(f"Batch size {len(session_ids)} exceeds cap of {BATCH_CAP}. Truncating.")
|
|
145
|
+
session_ids = session_ids[:BATCH_CAP]
|
|
146
|
+
|
|
147
|
+
log_info(f"Batch sending message to {len(session_ids)} sessions...")
|
|
148
|
+
|
|
149
|
+
results = []
|
|
150
|
+
with ThreadPoolExecutor(max_workers=min(len(session_ids), 10)) as executor:
|
|
151
|
+
# Submit all tasks and keep track of futures in order
|
|
152
|
+
futures = [executor.submit(self._send_single_message, sid, message) for sid in session_ids]
|
|
153
|
+
|
|
154
|
+
# Reconstruct results in the original input order by iterating over the futures list
|
|
155
|
+
for sid, future in zip(session_ids, futures):
|
|
156
|
+
try:
|
|
157
|
+
future.result()
|
|
158
|
+
results.append({"sessionId": sid, "status": "success"})
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
log_warn(f"Failed to send message to {sid}: {exc}")
|
|
161
|
+
results.append({"sessionId": sid, "status": "error", "message": str(exc)})
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"status": "success" if any(r["status"] == "success" for r in results) else "error",
|
|
165
|
+
"message": f"Batch send completed. {sum(1 for r in results if r['status'] == 'success')}/{len(session_ids)} successful.",
|
|
166
|
+
"results": results,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def _send_single_message(self, session_id: str, message: str) -> Dict[str, Any]:
|
|
170
|
+
clean_id = self._get_clean_id(session_id, "sessions")
|
|
171
|
+
url = f"{self.base_url}/sessions/{clean_id}:sendMessage"
|
|
172
|
+
response = requests.post(url, headers=self.headers, json={"prompt": message}, timeout=10)
|
|
173
|
+
response.raise_for_status()
|
|
174
|
+
return {"status": "success", "message": "Message sent successfully"}
|
|
175
|
+
|
|
176
|
+
def cancel_session(self, session_id: str) -> Dict[str, Any]:
|
|
177
|
+
clean_id = self._get_clean_id(session_id, "sessions")
|
|
178
|
+
url = f"{self.base_url}/sessions/{clean_id}"
|
|
179
|
+
response = requests.delete(url, headers=self.headers, timeout=10)
|
|
180
|
+
response.raise_for_status()
|
|
181
|
+
return {"status": "success", "message": "Session cancelled successfully"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# pylint: disable=f-string-without-interpolation,line-too-long,missing-docstring,too-few-public-methods,unused-variable
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from dev_tools.utils import log_warn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_file_path(path: str) -> Optional[str]:
|
|
12
|
+
"""Resolves path relative to current working directory or via filename lookup."""
|
|
13
|
+
if not path:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
# 1. Direct check (if absolute and exists, we take it, but we prefer relative)
|
|
17
|
+
if os.path.isabs(path) and os.path.exists(path):
|
|
18
|
+
# If it's absolute but under current directory, make it relative
|
|
19
|
+
try:
|
|
20
|
+
rel = os.path.relpath(path)
|
|
21
|
+
if not rel.startswith(".."):
|
|
22
|
+
return rel
|
|
23
|
+
except ValueError:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
# 2. Try stripping leading common absolute paths (like /app/ or /workspace/)
|
|
27
|
+
# We look for the first part that actually exists in the current directory relative to CWD
|
|
28
|
+
parts = [p for p in path.split(os.sep) if p]
|
|
29
|
+
for i in range(len(parts)):
|
|
30
|
+
subpath = os.path.join(*parts[i:])
|
|
31
|
+
if subpath and os.path.exists(subpath):
|
|
32
|
+
return subpath
|
|
33
|
+
|
|
34
|
+
# 3. Fallback to basename lookup in current directory (recursive)
|
|
35
|
+
filename = os.path.basename(path)
|
|
36
|
+
for root, dirs, files in os.walk("."):
|
|
37
|
+
if filename in files:
|
|
38
|
+
found_path = os.path.join(root, filename)
|
|
39
|
+
if found_path.startswith("./"):
|
|
40
|
+
return found_path[2:]
|
|
41
|
+
return found_path
|
|
42
|
+
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def strip_ansi(text: str) -> str:
|
|
47
|
+
"""Strips ANSI color codes from string."""
|
|
48
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
49
|
+
return ansi_escape.sub("", text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SignatureExtractor:
|
|
53
|
+
"""Extracts file, line, and error signature from logs."""
|
|
54
|
+
|
|
55
|
+
# ESLint stylish: /app/src/App.tsx:10:5: 'unused' is defined but never used. [eslint/no-unused-vars]
|
|
56
|
+
# ESLint compact: /app/src/App.tsx: line 10, col 5, Error - 'unused' is defined but never used. [eslint/no-unused-vars]
|
|
57
|
+
ESLINT_PATTERN = re.compile(r"^(.*?):(\d+):(\d+): (.*) \[(.*)\]$")
|
|
58
|
+
ESLINT_COMPACT_PATTERN = re.compile(r"^(.*?): line (\d+), col (\d+), (?:Error|Warning) - (.*) \[(.*)\]$")
|
|
59
|
+
|
|
60
|
+
# TS example: src/App.tsx:10:5 - error TS2322: Type 'string' is not assignable to type 'number'.
|
|
61
|
+
TS_PATTERN = re.compile(r"^(.*?):(\d+):(\d+) - error (TS\d+): (.*)$")
|
|
62
|
+
|
|
63
|
+
# TS alternate: src/App.tsx(10,5): error TS2322: ...
|
|
64
|
+
TS_ALT_PATTERN = re.compile(r"^(.*?)\((\d+),(\d+)\): error (TS\d+): (.*)$")
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _parse_ts(match) -> Dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"file": match.group(1),
|
|
70
|
+
"line": int(match.group(2)),
|
|
71
|
+
"col": int(match.group(3)),
|
|
72
|
+
"message": match.group(5),
|
|
73
|
+
"signature": f"ts/{match.group(4)[2:]}", # Normalize TS2322 to ts/2322
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def extract(cls, log_line: str) -> Optional[Dict[str, Any]]:
|
|
78
|
+
log_line = strip_ansi(log_line.strip())
|
|
79
|
+
|
|
80
|
+
# Try ESLint
|
|
81
|
+
match = cls.ESLINT_PATTERN.match(log_line) or cls.ESLINT_COMPACT_PATTERN.match(log_line)
|
|
82
|
+
if match:
|
|
83
|
+
sig = match.group(5)
|
|
84
|
+
if not sig.startswith("eslint/") and "/" not in sig:
|
|
85
|
+
sig = f"eslint/{sig}"
|
|
86
|
+
return {
|
|
87
|
+
"file": match.group(1),
|
|
88
|
+
"line": int(match.group(2)),
|
|
89
|
+
"col": int(match.group(3)),
|
|
90
|
+
"message": match.group(4),
|
|
91
|
+
"signature": sig,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Try TS
|
|
95
|
+
match = cls.TS_PATTERN.match(log_line)
|
|
96
|
+
if match:
|
|
97
|
+
return cls._parse_ts(match)
|
|
98
|
+
|
|
99
|
+
# Try TS Alt
|
|
100
|
+
match = cls.TS_ALT_PATTERN.match(log_line)
|
|
101
|
+
if match:
|
|
102
|
+
return cls._parse_ts(match)
|
|
103
|
+
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ASTContextualizer:
|
|
108
|
+
"""Extracts surrounding code block for a given line."""
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def extract_context(filepath: str, line_number: int, window: int = 15) -> Optional[str]:
|
|
112
|
+
"""
|
|
113
|
+
Extracts a deterministic window of code around line_number.
|
|
114
|
+
Uses a fixed window to avoid brittle indentation heuristics.
|
|
115
|
+
"""
|
|
116
|
+
if not os.path.exists(filepath):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
121
|
+
lines = f.readlines()
|
|
122
|
+
except Exception as e:
|
|
123
|
+
log_warn(f"Failed to read file {filepath} for context extraction: {e}")
|
|
124
|
+
return None
|
|
125
|
+
if not lines:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Adjust to 0-indexed
|
|
129
|
+
idx = line_number - 1
|
|
130
|
+
if idx < 0 or idx >= len(lines):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Use a deterministic window
|
|
134
|
+
start_idx = max(0, idx - window)
|
|
135
|
+
end_idx = min(len(lines) - 1, idx + window)
|
|
136
|
+
|
|
137
|
+
context_lines = lines[start_idx : end_idx + 1]
|
|
138
|
+
return "".join(context_lines)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RepairService:
|
|
142
|
+
"""Coordinates extraction, lookup, and prompt construction."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, knowledge_base_path: Optional[str] = None):
|
|
145
|
+
if knowledge_base_path is None:
|
|
146
|
+
if os.path.exists(".agents/knowledge/errors.json"):
|
|
147
|
+
knowledge_base_path = ".agents/knowledge/errors.json"
|
|
148
|
+
else:
|
|
149
|
+
knowledge_base_path = ".jules/knowledge/errors.json"
|
|
150
|
+
self.knowledge_base_path = knowledge_base_path
|
|
151
|
+
self.knowledge_base = self._load_kb()
|
|
152
|
+
|
|
153
|
+
def _load_kb(self) -> Dict[str, Any]:
|
|
154
|
+
if not os.path.exists(self.knowledge_base_path):
|
|
155
|
+
return {}
|
|
156
|
+
try:
|
|
157
|
+
with open(self.knowledge_base_path, "r", encoding="utf-8") as f:
|
|
158
|
+
return json.load(f)
|
|
159
|
+
except json.JSONDecodeError as e:
|
|
160
|
+
print(f"⚠️ Warning: Knowledge base JSON is invalid: {e}", file=sys.stderr)
|
|
161
|
+
return {}
|
|
162
|
+
except Exception as e:
|
|
163
|
+
print(f"⚠️ Warning: Failed to load knowledge base: {e}", file=sys.stderr)
|
|
164
|
+
return {}
|
|
165
|
+
|
|
166
|
+
def generate_prompt(self, log_line: str) -> Optional[str]:
|
|
167
|
+
extracted = SignatureExtractor.extract(log_line)
|
|
168
|
+
if not extracted:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
sig = extracted["signature"]
|
|
172
|
+
strategy_data = self.knowledge_base.get(sig, {})
|
|
173
|
+
strategy = strategy_data.get("strategy", "Investigate the error and apply a fix based on common practices.")
|
|
174
|
+
examples = strategy_data.get("examples", [])
|
|
175
|
+
|
|
176
|
+
# Adjust file path for context extraction
|
|
177
|
+
filepath = resolve_file_path(extracted["file"])
|
|
178
|
+
context = ASTContextualizer.extract_context(filepath, extracted["line"]) if filepath else None
|
|
179
|
+
|
|
180
|
+
prompt = f"### Error Report\n"
|
|
181
|
+
prompt += f"- **File**: `{extracted['file']}`\n"
|
|
182
|
+
prompt += f"- **Line**: {extracted['line']}\n"
|
|
183
|
+
prompt += f"- **Error**: `{extracted['signature']}`: {extracted['message']}\n\n"
|
|
184
|
+
|
|
185
|
+
prompt += f"### Fix Strategy\n"
|
|
186
|
+
prompt += f"{strategy}\n\n"
|
|
187
|
+
|
|
188
|
+
if examples:
|
|
189
|
+
prompt += "### Few-Shot Examples\n"
|
|
190
|
+
for ex in examples:
|
|
191
|
+
prompt += f"**Before:**\n```\n{ex['before']}\n```\n"
|
|
192
|
+
prompt += f"**After:**\n```\n{ex['after']}\n```\n"
|
|
193
|
+
prompt += "\n"
|
|
194
|
+
|
|
195
|
+
if context:
|
|
196
|
+
prompt += "### Code Context\n"
|
|
197
|
+
prompt += f"```tsx\n{context}\n```\n"
|
|
198
|
+
|
|
199
|
+
return prompt
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# pylint: disable=missing-docstring
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
|
|
4
|
+
import chromadb
|
|
5
|
+
from chromadb.utils import embedding_functions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VectorStore:
|
|
9
|
+
def __init__(self, persist_directory: str = "artifacts/chroma_db", collection_name: str = "codebase"):
|
|
10
|
+
self.persist_directory = persist_directory
|
|
11
|
+
self.collection_name = collection_name
|
|
12
|
+
self._client = None
|
|
13
|
+
self._collection = None
|
|
14
|
+
self._embedding_fn = None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def client(self):
|
|
18
|
+
if self._client is None and self.is_available():
|
|
19
|
+
self._client = chromadb.PersistentClient(path=self.persist_directory)
|
|
20
|
+
return self._client
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def embedding_fn(self):
|
|
24
|
+
if self._embedding_fn is None and self.is_available():
|
|
25
|
+
self._embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2")
|
|
26
|
+
return self._embedding_fn
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def collection(self):
|
|
30
|
+
if self._collection is None:
|
|
31
|
+
client = self.client
|
|
32
|
+
embedding_fn = self.embedding_fn
|
|
33
|
+
if client and embedding_fn:
|
|
34
|
+
self._collection = client.get_or_create_collection(
|
|
35
|
+
name=self.collection_name, embedding_function=embedding_fn
|
|
36
|
+
)
|
|
37
|
+
return self._collection
|
|
38
|
+
|
|
39
|
+
def is_available(self) -> bool:
|
|
40
|
+
"""Checks if ChromaDB and dependencies are available."""
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
def add_documents(self, documents: List[str], metadatas: List[Dict[str, Any]], ids: List[str]):
|
|
44
|
+
"""Adds documents to the collection."""
|
|
45
|
+
collection = self.collection
|
|
46
|
+
if collection:
|
|
47
|
+
collection.add(documents=documents, metadatas=metadatas, ids=ids)
|
|
48
|
+
|
|
49
|
+
def query(self, query_text: str, n_results: int = 5) -> List[Dict[str, Any]]:
|
|
50
|
+
"""Queries the collection for similar documents."""
|
|
51
|
+
collection = self.collection
|
|
52
|
+
if not collection:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
results = collection.query(query_texts=[query_text], n_results=n_results)
|
|
56
|
+
|
|
57
|
+
# Format results for easier consumption
|
|
58
|
+
formatted_results = []
|
|
59
|
+
if results["documents"]:
|
|
60
|
+
for i in range(len(results["documents"][0])):
|
|
61
|
+
formatted_results.append(
|
|
62
|
+
{
|
|
63
|
+
"document": results["documents"][0][i],
|
|
64
|
+
"metadata": results["metadatas"][0][i],
|
|
65
|
+
"id": results["ids"][0][i],
|
|
66
|
+
"distance": results["distances"][0][i] if "distances" in results else None,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
return formatted_results
|
|
70
|
+
|
|
71
|
+
def reset(self):
|
|
72
|
+
"""Resets the collection."""
|
|
73
|
+
client = self.client
|
|
74
|
+
embedding_fn = self.embedding_fn
|
|
75
|
+
if not client or not embedding_fn:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
client.delete_collection(self.collection_name)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
self._collection = client.create_collection(name=self.collection_name, embedding_function=embedding_fn)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# pylint: disable=import-outside-toplevel,line-too-long,missing-docstring,no-name-in-module
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from dev_tools.config import load_project_config
|
|
8
|
+
from dev_tools.utils import get_github_token, log_error
|
|
9
|
+
|
|
10
|
+
PROJECT_CONFIG = load_project_config()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VisionService:
|
|
14
|
+
"""Vision-based regression audit service via AI API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, model: Optional[str] = None):
|
|
17
|
+
self.model = model or os.environ.get("VISION_MODEL", PROJECT_CONFIG.ai_vision_model)
|
|
18
|
+
self.token = get_github_token()
|
|
19
|
+
|
|
20
|
+
def call_ai(self, prompt: str, image_paths: List[str]) -> Optional[str]:
|
|
21
|
+
"""Calls the AI model with text prompt and images."""
|
|
22
|
+
images = []
|
|
23
|
+
for p in image_paths:
|
|
24
|
+
if os.path.exists(p):
|
|
25
|
+
with open(p, "rb") as f:
|
|
26
|
+
images.append(base64.b64encode(f.read()).decode("utf-8"))
|
|
27
|
+
|
|
28
|
+
if not images:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
if not self.token:
|
|
32
|
+
return "No GITHUB_TOKEN found."
|
|
33
|
+
|
|
34
|
+
from dev_tools.utils import _request
|
|
35
|
+
|
|
36
|
+
message_content = [{"type": "text", "text": prompt}]
|
|
37
|
+
for img in images:
|
|
38
|
+
message_content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}})
|
|
39
|
+
|
|
40
|
+
url_target = "https://models.inference.ai.azure.com/chat/completions"
|
|
41
|
+
headers = {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
|
|
42
|
+
payload = {
|
|
43
|
+
"model": self.model,
|
|
44
|
+
"messages": [{"role": "user", "content": message_content}],
|
|
45
|
+
"temperature": 0.7,
|
|
46
|
+
"max_tokens": 2048,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
response = _request(
|
|
51
|
+
"POST",
|
|
52
|
+
url_target,
|
|
53
|
+
headers=headers,
|
|
54
|
+
json=payload,
|
|
55
|
+
retry_status_codes=[429, 500, 502, 503, 504],
|
|
56
|
+
)
|
|
57
|
+
if not response:
|
|
58
|
+
return None
|
|
59
|
+
return response.json()["choices"][0]["message"]["content"]
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return f"❌ Vision call failed: {e}"
|
|
62
|
+
|
|
63
|
+
def analyze_visual_changes(self, summary_path: str, project_root: str) -> Dict[str, str]:
|
|
64
|
+
"""Analyzes visual changes based on a summary JSON file."""
|
|
65
|
+
if not os.path.exists(summary_path):
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with open(summary_path, "r", encoding="utf-8") as f:
|
|
70
|
+
data = json.load(f)
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
log_error(f"Visual summary file not found: {summary_path}")
|
|
73
|
+
return {}
|
|
74
|
+
except json.JSONDecodeError as e:
|
|
75
|
+
log_error(f"Malformed visual summary JSON: {e}")
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
routes = data.get("routes", [])
|
|
79
|
+
results = {}
|
|
80
|
+
|
|
81
|
+
for s in routes:
|
|
82
|
+
before, after = s.get("beforeCroppedPath"), s.get("afterCroppedPath")
|
|
83
|
+
if not (before and after):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
prompt = f"Analyze visual changes for {s['route']}. Describe what changed between BEFORE and AFTER. Identify bugs vs improvements. Be concise."
|
|
87
|
+
res = self.call_ai(prompt, [os.path.join(project_root, before), os.path.join(project_root, after)])
|
|
88
|
+
if res:
|
|
89
|
+
results[s["route"]] = res
|
|
90
|
+
|
|
91
|
+
return results
|
dev_tools/td_cli.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# pylint: disable=import-outside-toplevel,missing-docstring
|
|
2
|
+
#!/usr/bin/env python3
|
|
3
|
+
"""
|
|
4
|
+
td_cli.py - Project Developer CLI Shim
|
|
5
|
+
|
|
6
|
+
This script is a thin wrapper around the unified dev_tools CLI.
|
|
7
|
+
It maintains backward compatibility for existing scripts and CI workflows.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
"""Entry point for the td-cli shim."""
|
|
15
|
+
try:
|
|
16
|
+
from dev_tools.cli import main as cli_main
|
|
17
|
+
|
|
18
|
+
cli_main()
|
|
19
|
+
except ImportError as e:
|
|
20
|
+
# Handle missing dependencies gracefully (e.g. click, pydantic)
|
|
21
|
+
print(f"❌ Error: {e}", file=sys.stderr)
|
|
22
|
+
print(" The environment might not be fully bootstrapped.", file=sys.stderr)
|
|
23
|
+
print(" Run `pip install -e boomtick-pkg/cli/` to install dependencies.", file=sys.stderr)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|