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
dev_tools/config.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# pylint: disable=import-outside-toplevel,line-too-long,missing-docstring,too-many-branches,too-many-instance-attributes,too-many-statements,use-maxsplit-arg
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import subprocess
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ProjectConfig:
|
|
15
|
+
github_repo: str | None = None
|
|
16
|
+
github_token_env: str = "GITHUB_TOKEN"
|
|
17
|
+
jules_api_url: str | None = None
|
|
18
|
+
core_dirs: List[str] = field(default_factory=lambda: ["src/layouts/", "src/components/"])
|
|
19
|
+
monolithic_pr_threshold: int = 3
|
|
20
|
+
base_branch: str = "origin/main"
|
|
21
|
+
vite_base_path: str = "/tech-dancer/"
|
|
22
|
+
gh_path: str = "gh"
|
|
23
|
+
max_diff_chars: int = 40000
|
|
24
|
+
content_scopes: Dict[str, str] = field(
|
|
25
|
+
default_factory=lambda: {
|
|
26
|
+
"resources": "content/resources/",
|
|
27
|
+
"posts": "content/posts/",
|
|
28
|
+
"blog": "content/blog/",
|
|
29
|
+
"studies": "content/studies/",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
ai_synthesis_model: str = "gpt-4o-mini"
|
|
33
|
+
ai_review_model: str = "gpt-4o"
|
|
34
|
+
ai_vision_model: str = "gpt-4o"
|
|
35
|
+
ui_indicators: List[str] = field(
|
|
36
|
+
default_factory=lambda: [
|
|
37
|
+
"src/components",
|
|
38
|
+
"src/pages",
|
|
39
|
+
"src/layouts",
|
|
40
|
+
"src/index.css",
|
|
41
|
+
"tailwind",
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
tailwind_indicators: List[str] = field(default_factory=lambda: ["px-", "py-", "mt-", "flex", "grid", "text-["])
|
|
45
|
+
audit_check_dirs: List[str] = field(
|
|
46
|
+
default_factory=lambda: [
|
|
47
|
+
"src/features",
|
|
48
|
+
"src/pages",
|
|
49
|
+
"src/components",
|
|
50
|
+
"src/layouts",
|
|
51
|
+
"src/App.tsx",
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
cli_alias: str = "td-cli"
|
|
55
|
+
default_limit: int = 10
|
|
56
|
+
allowed_bots: List[str] = field(default_factory=lambda: ["github-actions[bot]"])
|
|
57
|
+
worktree_prefix: str = "bt-repair-"
|
|
58
|
+
pnpm_version: str = "10.28.2"
|
|
59
|
+
infra_file_paths: List[str] = field(
|
|
60
|
+
default_factory=lambda: [
|
|
61
|
+
"scripts/",
|
|
62
|
+
"boomtick-pkg/cli/",
|
|
63
|
+
".github/",
|
|
64
|
+
"setup-agent.sh",
|
|
65
|
+
"Dockerfile",
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
infra_feedback: str = (
|
|
69
|
+
"- **Infrastructure/Bootstrap Change:** Low-level script changes detected.\n"
|
|
70
|
+
" - *Review focus:* Ensure idempotency, portability (avoid bashisms), and robust error handling (`set -e`, `set -u`).\n"
|
|
71
|
+
" - *Verification:* If full system setup is risky, verify via dry-runs, `bash -n`, or log inspection. Document verification method in the PR.\n"
|
|
72
|
+
)
|
|
73
|
+
temp_file_patterns: List[str] = field(
|
|
74
|
+
default_factory=lambda: [
|
|
75
|
+
r".*\.tmp$",
|
|
76
|
+
r"^[^/]+\.py$",
|
|
77
|
+
r".*audit.*\.md$",
|
|
78
|
+
r".*dump.*\.json$",
|
|
79
|
+
r".*\.jsonl$",
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
temp_file_feedback: str = (
|
|
83
|
+
"- **Stray/Temporary Files:** Suspicious files (scripts, logs, audits) detected. "
|
|
84
|
+
"Verify if these are intended to be committed.\n"
|
|
85
|
+
)
|
|
86
|
+
spec_sections: List[str] = field(
|
|
87
|
+
default_factory=lambda: [
|
|
88
|
+
"Problem Statement",
|
|
89
|
+
"Goal",
|
|
90
|
+
"Non-Goals",
|
|
91
|
+
"Proposed Approach",
|
|
92
|
+
"Alternatives Considered",
|
|
93
|
+
"Architectural Impact",
|
|
94
|
+
"Scope",
|
|
95
|
+
"UNDERSTAND THE ISSUE",
|
|
96
|
+
"DETERMINE APPROACH",
|
|
97
|
+
"SPECIFY SCOPE",
|
|
98
|
+
"DEFINITION OF DONE",
|
|
99
|
+
]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def base_branch_name(self) -> str:
|
|
104
|
+
"""Returns the base branch name without the remote prefix (e.g., 'main' for 'origin/main')."""
|
|
105
|
+
if not self.base_branch:
|
|
106
|
+
return "main"
|
|
107
|
+
return self.base_branch.split("/")[-1]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def context_builder_script(self) -> str:
|
|
111
|
+
"""Returns the absolute path to the context builder script."""
|
|
112
|
+
from dev_tools.utils import resolve_resource_path
|
|
113
|
+
|
|
114
|
+
return resolve_resource_path("build-repo-context.py")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@functools.lru_cache()
|
|
118
|
+
def get_config(path: str | Path = "project_config.json") -> ProjectConfig:
|
|
119
|
+
"""Returns a cached singleton instance of ProjectConfig."""
|
|
120
|
+
return load_project_config(path)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@functools.lru_cache()
|
|
124
|
+
def _detect_repo_name() -> str | None:
|
|
125
|
+
"""Safely detects repository name from git remote."""
|
|
126
|
+
try:
|
|
127
|
+
res = subprocess.run(
|
|
128
|
+
["git", "config", "--get", "remote.origin.url"],
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
check=False,
|
|
132
|
+
)
|
|
133
|
+
if res.returncode != 0:
|
|
134
|
+
return None
|
|
135
|
+
url = res.stdout.strip()
|
|
136
|
+
if not url:
|
|
137
|
+
return None
|
|
138
|
+
match = re.search(r"[:/]([^/]+/[^/.]+)(\.git)?$", url)
|
|
139
|
+
return match.group(1) if match else url
|
|
140
|
+
except Exception:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_project_config(path: str | Path = "project_config.json") -> ProjectConfig:
|
|
145
|
+
p = Path(path)
|
|
146
|
+
|
|
147
|
+
raw: Dict[str, Any] = {}
|
|
148
|
+
if p.exists():
|
|
149
|
+
try:
|
|
150
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
151
|
+
except (json.JSONDecodeError, IOError):
|
|
152
|
+
pass
|
|
153
|
+
elif path != "project_config.json":
|
|
154
|
+
# If a specific path was requested but doesn't exist, return default
|
|
155
|
+
return ProjectConfig()
|
|
156
|
+
else:
|
|
157
|
+
# Check parent directories for project_config.json if not in CWD
|
|
158
|
+
# This helps when running from subdirectories
|
|
159
|
+
current = Path.cwd()
|
|
160
|
+
for parent in [current] + list(current.parents):
|
|
161
|
+
check_path = parent / "project_config.json"
|
|
162
|
+
if check_path.exists():
|
|
163
|
+
try:
|
|
164
|
+
raw = json.loads(check_path.read_text(encoding="utf-8"))
|
|
165
|
+
break
|
|
166
|
+
except (json.JSONDecodeError, IOError):
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
def get_list(key: str) -> Optional[List[str]]:
|
|
170
|
+
val = raw.get(key)
|
|
171
|
+
if val is None:
|
|
172
|
+
return None
|
|
173
|
+
if isinstance(val, str):
|
|
174
|
+
return [val]
|
|
175
|
+
if isinstance(val, list):
|
|
176
|
+
return [str(item) for item in val]
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def get_dict(key: str) -> Optional[Dict[str, str]]:
|
|
180
|
+
val = raw.get(key)
|
|
181
|
+
if isinstance(val, dict):
|
|
182
|
+
return {str(k): str(v) for k, v in val.items()}
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
kwargs: Dict[str, Any] = {}
|
|
186
|
+
if "github_repo" in raw or "repo_name" in raw:
|
|
187
|
+
kwargs["github_repo"] = raw.get("github_repo") or raw.get("repo_name")
|
|
188
|
+
else:
|
|
189
|
+
kwargs["github_repo"] = _detect_repo_name() or "arii/tech-dancer"
|
|
190
|
+
|
|
191
|
+
if "vite_base_path" in raw:
|
|
192
|
+
kwargs["vite_base_path"] = raw["vite_base_path"]
|
|
193
|
+
if "gh_path" in raw:
|
|
194
|
+
kwargs["gh_path"] = raw["gh_path"]
|
|
195
|
+
if "github_token_env" in raw:
|
|
196
|
+
kwargs["github_token_env"] = raw["github_token_env"]
|
|
197
|
+
if "jules_api_url" in raw:
|
|
198
|
+
kwargs["jules_api_url"] = raw["jules_api_url"]
|
|
199
|
+
if "monolithic_pr_threshold" in raw:
|
|
200
|
+
kwargs["monolithic_pr_threshold"] = int(raw["monolithic_pr_threshold"])
|
|
201
|
+
if "base_branch" in raw:
|
|
202
|
+
kwargs["base_branch"] = raw["base_branch"]
|
|
203
|
+
if "max_diff_chars" in raw:
|
|
204
|
+
kwargs["max_diff_chars"] = int(raw["max_diff_chars"])
|
|
205
|
+
if "ai_synthesis_model" in raw:
|
|
206
|
+
kwargs["ai_synthesis_model"] = raw["ai_synthesis_model"]
|
|
207
|
+
if "ai_review_model" in raw:
|
|
208
|
+
kwargs["ai_review_model"] = raw["ai_review_model"]
|
|
209
|
+
if "ai_vision_model" in raw:
|
|
210
|
+
kwargs["ai_vision_model"] = raw["ai_vision_model"]
|
|
211
|
+
if "worktree_prefix" in raw:
|
|
212
|
+
kwargs["worktree_prefix"] = raw["worktree_prefix"]
|
|
213
|
+
if "pnpm_version" in raw:
|
|
214
|
+
kwargs["pnpm_version"] = raw["pnpm_version"]
|
|
215
|
+
if "temp_file_feedback" in raw:
|
|
216
|
+
kwargs["temp_file_feedback"] = raw["temp_file_feedback"]
|
|
217
|
+
|
|
218
|
+
for list_key in [
|
|
219
|
+
"core_dirs",
|
|
220
|
+
"ui_indicators",
|
|
221
|
+
"tailwind_indicators",
|
|
222
|
+
"audit_check_dirs",
|
|
223
|
+
"allowed_bots",
|
|
224
|
+
"spec_sections",
|
|
225
|
+
"temp_file_patterns",
|
|
226
|
+
]:
|
|
227
|
+
val = get_list(list_key)
|
|
228
|
+
if val is not None:
|
|
229
|
+
kwargs[list_key] = val
|
|
230
|
+
|
|
231
|
+
content_scopes = get_dict("content_scopes")
|
|
232
|
+
if content_scopes is not None:
|
|
233
|
+
kwargs["content_scopes"] = content_scopes
|
|
234
|
+
|
|
235
|
+
return ProjectConfig(**kwargs)
|
dev_tools/constants.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Constants for dev_tools."""
|
|
2
|
+
|
|
3
|
+
# Robust placeholder detection using regex to handle minor variants
|
|
4
|
+
REVIEW_PLACEHOLDERS = [
|
|
5
|
+
r"<findings\s*/?>",
|
|
6
|
+
r"<summary\s*/?>",
|
|
7
|
+
r"<filename\s*/?>",
|
|
8
|
+
r"<feedback\s*/?>",
|
|
9
|
+
r"<Approved\s*\|\s*Approved\s*with\s*Minor\s*Changes\s*\|\s*Not\s*Approved>",
|
|
10
|
+
r"##\s+ANTI-AI-SLOP",
|
|
11
|
+
r"##\s+FINDINGS",
|
|
12
|
+
r"##\s+FINAL RECOMMENDATION",
|
|
13
|
+
r"<!--\s*td-review-manager-comment\s*-->",
|
|
14
|
+
]
|
dev_tools/daemon.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# pylint: disable=arguments-differ,attribute-defined-outside-init,line-too-long,logging-fstring-interpolation,missing-docstring,too-few-public-methods
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from dev_tools.orchestrator import Orchestrator
|
|
7
|
+
from dev_tools.services.github import GitHubClient
|
|
8
|
+
from dev_tools.services.jules import JulesClient
|
|
9
|
+
|
|
10
|
+
logging.basicConfig(level=logging.INFO)
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExtendedJulesClient(JulesClient):
|
|
15
|
+
"""JulesClient subclass with extended timeouts for daemon use."""
|
|
16
|
+
|
|
17
|
+
def get_messages(self, session_id: str, timeout: int = 30) -> List[Dict[str, Any]]:
|
|
18
|
+
# Set default timeout to 30s instead of the default 10s
|
|
19
|
+
return super().get_messages(session_id, timeout=timeout)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JulesFeedbackDaemon:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.jules = ExtendedJulesClient()
|
|
25
|
+
self.github = GitHubClient()
|
|
26
|
+
self.orchestrator = Orchestrator()
|
|
27
|
+
# Ensure the orchestrator uses our extended client
|
|
28
|
+
self.orchestrator.initialize_jules(self.jules)
|
|
29
|
+
|
|
30
|
+
def run(self, limit: int = 10):
|
|
31
|
+
logger.info("Starting Jules Feedback Daemon")
|
|
32
|
+
try:
|
|
33
|
+
sessions = self.jules.list_sessions(pageSize=limit)
|
|
34
|
+
logger.info(f"Found {len(sessions)} sessions")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error(f"Error fetching sessions: {e}")
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
self._pr_cache: Dict[int, Any] = {}
|
|
40
|
+
self._session_to_pr_map: Dict[str, Any] = {}
|
|
41
|
+
|
|
42
|
+
# Batch pre-match sessions to PRs where possible to reduce API hits
|
|
43
|
+
self._pre_match_sessions_batch(sessions)
|
|
44
|
+
|
|
45
|
+
for session in sessions:
|
|
46
|
+
self._process_session(session)
|
|
47
|
+
|
|
48
|
+
def _pre_match_sessions_batch(self, sessions: List[Dict[str, Any]]):
|
|
49
|
+
"""Batch search for multiple session IDs in one GitHub API call."""
|
|
50
|
+
if not sessions:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
session_ids = []
|
|
54
|
+
for s in sessions:
|
|
55
|
+
sid = s.get("name", "").split("/")[-1]
|
|
56
|
+
if sid:
|
|
57
|
+
session_ids.append(sid)
|
|
58
|
+
|
|
59
|
+
if not session_ids:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
# GitHub Search API query construction
|
|
63
|
+
# Example: (ID1 OR ID2 OR ID3) in:body,title state:open
|
|
64
|
+
id_query = " OR ".join([f'"{sid}"' for sid in session_ids])
|
|
65
|
+
full_query = f"({id_query}) in:body,title state:open"
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
logger.info(f"Performing batch PR search for {len(session_ids)} session IDs")
|
|
69
|
+
found_prs = self.github.search_pull_requests(full_query, limit=50)
|
|
70
|
+
|
|
71
|
+
for pr in found_prs:
|
|
72
|
+
body = (pr.get("body") or "").lower()
|
|
73
|
+
title = (pr.get("title") or "").lower()
|
|
74
|
+
for sid in session_ids:
|
|
75
|
+
if sid.lower() in body or sid.lower() in title:
|
|
76
|
+
self._session_to_pr_map[sid] = pr
|
|
77
|
+
# Cache the PR details as well
|
|
78
|
+
self._pr_cache[pr["number"]] = pr
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.warning(f"Batch PR search failed, will fallback to individual lookups: {e}")
|
|
81
|
+
|
|
82
|
+
def _get_pr_for_session(self, session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
83
|
+
"""Optimized PR lookup for a session."""
|
|
84
|
+
session_id = session.get("name", "").replace("sessions/", "")
|
|
85
|
+
|
|
86
|
+
# 0. Check batch pre-match results
|
|
87
|
+
if session_id in self._session_to_pr_map:
|
|
88
|
+
return self._session_to_pr_map[session_id]
|
|
89
|
+
|
|
90
|
+
# 1-3. Delegate to orchestrator logic
|
|
91
|
+
pr_num = self.orchestrator.get_pr_for_session(session)
|
|
92
|
+
if pr_num:
|
|
93
|
+
if pr_num not in self._pr_cache:
|
|
94
|
+
self._pr_cache[pr_num] = self.github.fetch_pr_details(pr_num)
|
|
95
|
+
return self._pr_cache[pr_num]
|
|
96
|
+
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def _process_session(self, session: Dict[str, Any]):
|
|
100
|
+
session_id = session.get("name", "").replace("sessions/", "")
|
|
101
|
+
if not session_id:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
logger.info(f"Processing session {session_id}")
|
|
105
|
+
matched_pr = self._get_pr_for_session(session)
|
|
106
|
+
|
|
107
|
+
if not matched_pr:
|
|
108
|
+
logger.info(f"No matching PR found for session {session_id}")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
messages = self.jules.get_messages(session_id)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error(f"Error getting messages for session {session_id}: {e}")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if not messages:
|
|
118
|
+
logger.info(f"No messages for session {session_id}")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
logger.info(
|
|
122
|
+
f"Matched PR #{matched_pr['number']} ({matched_pr.get('headRefName')}, {matched_pr.get('title')}) for session {session_id}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
last_message = messages[-1]
|
|
126
|
+
if last_message.get("role") == "user":
|
|
127
|
+
logger.info(f"Last message from user, skipping feedback to avoid double-feedback for {session_id}")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if last_message.get("role") == "jules":
|
|
131
|
+
logger.info(f"Triggering feedback for session {session_id} matching PR #{matched_pr['number']}")
|
|
132
|
+
try:
|
|
133
|
+
# Use orchestrator.trigger_jules_feedback which executes CI validation logic
|
|
134
|
+
res = self.orchestrator.trigger_jules_feedback(session_id)
|
|
135
|
+
logger.info(f"Feedback triggered successfully: {res.get('status', 'unknown')}")
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.error(f"Error triggering feedback for {session_id}: {e}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
try:
|
|
142
|
+
daemon = JulesFeedbackDaemon()
|
|
143
|
+
daemon.run()
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"Daemon crashed: {e}")
|
|
146
|
+
sys.exit(1)
|
dev_tools/dist/config.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
dotenv.config({
|
|
8
|
+
path: path.resolve(__dirname, "../.env"),
|
|
9
|
+
quiet: true
|
|
10
|
+
});
|
|
11
|
+
function getGithubToken() {
|
|
12
|
+
if (process.env.GITHUB_TOKEN) {
|
|
13
|
+
return process.env.GITHUB_TOKEN;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const token = execSync("gh auth token", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
17
|
+
if (token) {
|
|
18
|
+
return token;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (e) { }
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
let cachedDynamicConfig = null;
|
|
25
|
+
/**
|
|
26
|
+
* Explicitly initializes dynamic configuration from the Python CLI.
|
|
27
|
+
* Should be called once during server startup.
|
|
28
|
+
*/
|
|
29
|
+
export function initializeConfig() {
|
|
30
|
+
if (cachedDynamicConfig !== null) {
|
|
31
|
+
return cachedDynamicConfig;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
// Attempt to load core properties from the Python CLI to avoid duplication
|
|
35
|
+
const cmd = `td-cli config view`;
|
|
36
|
+
const output = execSync(cmd, {
|
|
37
|
+
encoding: "utf-8",
|
|
38
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
39
|
+
});
|
|
40
|
+
cachedDynamicConfig = JSON.parse(output);
|
|
41
|
+
return cachedDynamicConfig;
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
45
|
+
const errorPrefix = `CRITICAL: Failed to load dynamic config from Python CLI. Ensure Python 3 is installed and boomtick-pkg/cli is in your PYTHONPATH. Details: ${message}`;
|
|
46
|
+
if (process.env.NODE_ENV === "development" || process.env.CI === "true") {
|
|
47
|
+
throw new Error(errorPrefix);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.warn(errorPrefix);
|
|
51
|
+
}
|
|
52
|
+
// Set an empty object to prevent repeated attempts if initialization fails in non-blocking mode
|
|
53
|
+
cachedDynamicConfig = {};
|
|
54
|
+
}
|
|
55
|
+
return cachedDynamicConfig;
|
|
56
|
+
}
|
|
57
|
+
function findRepoRoot() {
|
|
58
|
+
if (process.env.BOOMTICK_REPO_PATH) {
|
|
59
|
+
return process.env.BOOMTICK_REPO_PATH;
|
|
60
|
+
}
|
|
61
|
+
// Traverse up to find workspace.json or .git
|
|
62
|
+
let current = __dirname;
|
|
63
|
+
while (current !== path.parse(current).root) {
|
|
64
|
+
if (fs.existsSync(path.join(current, "workspace.json")) || fs.existsSync(path.join(current, ".git"))) {
|
|
65
|
+
return current;
|
|
66
|
+
}
|
|
67
|
+
current = path.dirname(current);
|
|
68
|
+
}
|
|
69
|
+
// Fallback to the monolithic relative path if discovery fails
|
|
70
|
+
return path.resolve(__dirname, "../../../");
|
|
71
|
+
}
|
|
72
|
+
export const config = {
|
|
73
|
+
get githubToken() { return getGithubToken(); },
|
|
74
|
+
get githubOwner() {
|
|
75
|
+
if (process.env.GITHUB_OWNER)
|
|
76
|
+
return process.env.GITHUB_OWNER;
|
|
77
|
+
const repoString = cachedDynamicConfig?.github_repo;
|
|
78
|
+
if (typeof repoString !== "string" || !repoString.includes("/")) {
|
|
79
|
+
throw new Error("GITHUB_OWNER must be set via environment variable or project_config.json");
|
|
80
|
+
}
|
|
81
|
+
return repoString.split("/")[0];
|
|
82
|
+
},
|
|
83
|
+
get githubRepo() {
|
|
84
|
+
if (process.env.GITHUB_REPO)
|
|
85
|
+
return process.env.GITHUB_REPO;
|
|
86
|
+
const repoString = cachedDynamicConfig?.github_repo;
|
|
87
|
+
if (typeof repoString !== "string" || !repoString.includes("/")) {
|
|
88
|
+
throw new Error("GITHUB_REPO must be set via environment variable or project_config.json");
|
|
89
|
+
}
|
|
90
|
+
return repoString.split("/")[1];
|
|
91
|
+
},
|
|
92
|
+
get repoPath() {
|
|
93
|
+
return findRepoRoot();
|
|
94
|
+
},
|
|
95
|
+
get defaultBaseBranch() {
|
|
96
|
+
return process.env.DEFAULT_BASE_BRANCH || cachedDynamicConfig?.base_branch?.split("/").pop() || "main";
|
|
97
|
+
},
|
|
98
|
+
get viteBasePath() {
|
|
99
|
+
const path = process.env.VITE_BASE_PATH || cachedDynamicConfig?.vite_base_path;
|
|
100
|
+
if (!path) {
|
|
101
|
+
throw new Error("VITE_BASE_PATH must be set via environment variable or project_config.json");
|
|
102
|
+
}
|
|
103
|
+
return path;
|
|
104
|
+
},
|
|
105
|
+
get ghPath() {
|
|
106
|
+
return process.env.GH_PATH || cachedDynamicConfig?.gh_path || "gh";
|
|
107
|
+
}
|
|
108
|
+
};
|
dev_tools/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { BoomtickMCPServer } from "./mcp/server.js";
|
|
3
|
+
import { initializeConfig } from "./config.js";
|
|
4
|
+
// Initialize configuration once at startup
|
|
5
|
+
initializeConfig();
|
|
6
|
+
const server = new BoomtickMCPServer();
|
|
7
|
+
server.run().catch((error) => {
|
|
8
|
+
console.error("Fatal error running server:", error);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizes stderr from CLI commands to prevent implementation detail leakage
|
|
3
|
+
* in error messages returned to the AI agent.
|
|
4
|
+
*/
|
|
5
|
+
export function sanitizeError(stderr) {
|
|
6
|
+
// Take first line and truncate to 200 chars to balance detail vs security
|
|
7
|
+
return (stderr.split("\n")[0] || "Unknown error").slice(0, 200);
|
|
8
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { runCommand } from "./shell.js";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
function validatePrNumber(value) {
|
|
5
|
+
const prNumber = Number(value);
|
|
6
|
+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
7
|
+
throw new Error("Invalid PR number");
|
|
8
|
+
}
|
|
9
|
+
return prNumber;
|
|
10
|
+
}
|
|
11
|
+
export async function createWorktree(branch, prNumber) {
|
|
12
|
+
const safePrNumber = validatePrNumber(prNumber);
|
|
13
|
+
const workspaceRoot = "/tmp/boomtick-worktrees";
|
|
14
|
+
// nosemgrep
|
|
15
|
+
const worktreePath = path.join(workspaceRoot, `boomtick-mcp-rescue-${safePrNumber}`);
|
|
16
|
+
// Clean up if exists
|
|
17
|
+
try {
|
|
18
|
+
await fs.rm(worktreePath, { recursive: true, force: true });
|
|
19
|
+
await runCommand("git", ["worktree", "prune"]);
|
|
20
|
+
}
|
|
21
|
+
catch (e) { }
|
|
22
|
+
const result = await runCommand("git", ["worktree", "add", "-b", `repair-pr-${prNumber}-${Date.now()}`, worktreePath, branch]);
|
|
23
|
+
if (result.exitCode !== 0) {
|
|
24
|
+
throw new Error(`Failed to create worktree: ${result.stderr}`);
|
|
25
|
+
}
|
|
26
|
+
return worktreePath;
|
|
27
|
+
}
|
|
28
|
+
export async function removeWorktree(worktreePath) {
|
|
29
|
+
await runCommand("git", ["worktree", "remove", "--force", worktreePath]);
|
|
30
|
+
await runCommand("git", ["worktree", "prune"]);
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function createSuccessResult(data) {
|
|
2
|
+
return {
|
|
3
|
+
content: [
|
|
4
|
+
{
|
|
5
|
+
type: "text",
|
|
6
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
|
|
7
|
+
},
|
|
8
|
+
],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function createErrorResult(message) {
|
|
12
|
+
return {
|
|
13
|
+
content: [
|
|
14
|
+
{
|
|
15
|
+
type: "text",
|
|
16
|
+
text: message,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
isError: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
export const ALLOWED_COMMANDS = {
|
|
6
|
+
git: "git",
|
|
7
|
+
gh: "gh",
|
|
8
|
+
"td-cli": "td-cli",
|
|
9
|
+
pnpm: "pnpm",
|
|
10
|
+
ls: "ls",
|
|
11
|
+
rm: "rm",
|
|
12
|
+
mkdir: "mkdir",
|
|
13
|
+
cp: "cp",
|
|
14
|
+
python3: "python3"
|
|
15
|
+
};
|
|
16
|
+
export async function runCommand(cmd, args, options = {}) {
|
|
17
|
+
if (!(cmd in ALLOWED_COMMANDS)) {
|
|
18
|
+
throw new Error(`Command not allowed: ${cmd}`);
|
|
19
|
+
}
|
|
20
|
+
const safeCmd = cmd;
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
const timeout = options.timeout || 60000;
|
|
23
|
+
// Resolve path for 'gh' or 'td-cli' if specified
|
|
24
|
+
let finalCmd = ALLOWED_COMMANDS[safeCmd];
|
|
25
|
+
if (safeCmd === "gh") {
|
|
26
|
+
finalCmd = config.ghPath;
|
|
27
|
+
}
|
|
28
|
+
else if (safeCmd === "td-cli") {
|
|
29
|
+
// If we have a repo-relative .venv, prioritize it
|
|
30
|
+
const venvBin = path.join(config.repoPath, ".venv", "bin", "td-cli");
|
|
31
|
+
const localVenvBin = path.join(process.cwd(), ".venv", "bin", "td-cli");
|
|
32
|
+
const parentVenvBin = path.join(config.repoPath, "..", ".venv", "bin", "td-cli");
|
|
33
|
+
// In standalone mode, config.repoPath is the boomtick-pkg root.
|
|
34
|
+
// We check common venv locations to ensure we call the isolated binary.
|
|
35
|
+
if (fs.existsSync(venvBin)) {
|
|
36
|
+
finalCmd = venvBin;
|
|
37
|
+
}
|
|
38
|
+
else if (fs.existsSync(localVenvBin)) {
|
|
39
|
+
finalCmd = localVenvBin;
|
|
40
|
+
}
|
|
41
|
+
else if (fs.existsSync(parentVenvBin)) {
|
|
42
|
+
finalCmd = parentVenvBin;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const env = {
|
|
46
|
+
...process.env,
|
|
47
|
+
...options.env,
|
|
48
|
+
GH_TOKEN: config.githubToken,
|
|
49
|
+
GITHUB_TOKEN: config.githubToken
|
|
50
|
+
};
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
// nosemgrep
|
|
53
|
+
const child = spawn(finalCmd, args, {
|
|
54
|
+
cwd: options.cwd || config.repoPath,
|
|
55
|
+
env
|
|
56
|
+
});
|
|
57
|
+
let stdout = "";
|
|
58
|
+
let stderr = "";
|
|
59
|
+
child.stdout.on("data", (data) => {
|
|
60
|
+
stdout += data.toString();
|
|
61
|
+
});
|
|
62
|
+
child.stderr.on("data", (data) => {
|
|
63
|
+
stderr += data.toString();
|
|
64
|
+
});
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
child.kill();
|
|
67
|
+
reject(new Error(`Command timed out: ${cmd} ${args.join(" ")}`));
|
|
68
|
+
}, timeout);
|
|
69
|
+
child.on("close", (code) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
const durationMs = Date.now() - start;
|
|
72
|
+
// Redact token from output
|
|
73
|
+
if (config.githubToken) {
|
|
74
|
+
const redact = (str) => str.replace(new RegExp(config.githubToken, "g"), "REDACTED");
|
|
75
|
+
stdout = redact(stdout);
|
|
76
|
+
stderr = redact(stderr);
|
|
77
|
+
}
|
|
78
|
+
resolve({
|
|
79
|
+
stdout,
|
|
80
|
+
stderr,
|
|
81
|
+
exitCode: code,
|
|
82
|
+
durationMs,
|
|
83
|
+
command: `${cmd} ${args.join(" ")}`
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
child.on("error", (err) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
reject(err);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import path from "path";
|
|
3
|
+
// Simple test to verify environment and vitest setup
|
|
4
|
+
describe("Basic Environment", () => {
|
|
5
|
+
it("should have a valid repo path", () => {
|
|
6
|
+
const repoPath = process.env.BOOMTICK_REPO_PATH || "/app";
|
|
7
|
+
expect(repoPath).toBeDefined();
|
|
8
|
+
expect(path.isAbsolute(repoPath)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
});
|