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/cli.py
ADDED
|
@@ -0,0 +1,1542 @@
|
|
|
1
|
+
# pylint: disable=f-string-without-interpolation,function-redefined,import-outside-toplevel,line-too-long,missing-docstring,no-value-for-parameter,protected-access,redefined-outer-name,subprocess-run-check,too-many-arguments,too-many-lines,too-many-locals,too-many-positional-arguments,unused-argument,use-dict-literal
|
|
2
|
+
import glob
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from typing import Any, Dict, List
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from dev_tools.config import get_config
|
|
13
|
+
from dev_tools.utils import (
|
|
14
|
+
CLIError,
|
|
15
|
+
get_or_create_log_dir,
|
|
16
|
+
log_error,
|
|
17
|
+
log_info,
|
|
18
|
+
log_warn,
|
|
19
|
+
mask_sensitive_data,
|
|
20
|
+
run_command,
|
|
21
|
+
verify_ci_metrics,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LazyOrchestrator:
|
|
26
|
+
"""Lazy proxy for the Orchestrator to defer heavy imports and initialization."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, factory):
|
|
29
|
+
# Use super().__setattr__ to avoid recursion during initialization
|
|
30
|
+
super().__setattr__("_factory", factory)
|
|
31
|
+
super().__setattr__("_instance", None)
|
|
32
|
+
|
|
33
|
+
def _get_instance(self):
|
|
34
|
+
if self._instance is None:
|
|
35
|
+
super().__setattr__("_instance", self._factory())
|
|
36
|
+
return self._instance
|
|
37
|
+
|
|
38
|
+
def __getattr__(self, name):
|
|
39
|
+
return getattr(self._get_instance(), name)
|
|
40
|
+
|
|
41
|
+
def __setattr__(self, name, value):
|
|
42
|
+
if name in ("_instance", "_factory"):
|
|
43
|
+
super().__setattr__(name, value)
|
|
44
|
+
else:
|
|
45
|
+
setattr(self._get_instance(), name, value)
|
|
46
|
+
|
|
47
|
+
def __dir__(self):
|
|
48
|
+
return dir(self._get_instance())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
PROJECT_CONFIG = get_config()
|
|
52
|
+
DEFAULT_GH_API_LIMIT = PROJECT_CONFIG.default_limit
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def limit_option(default_val=DEFAULT_GH_API_LIMIT, help_text="Limit the number of items to process"):
|
|
56
|
+
def decorator(f):
|
|
57
|
+
return click.option("--limit", type=int, default=default_val, help=help_text)(f)
|
|
58
|
+
|
|
59
|
+
return decorator
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def json_option(f):
|
|
63
|
+
"""
|
|
64
|
+
Decorator to add --json option to subcommands.
|
|
65
|
+
Allows --json to be placed after the command (e.g., 'td command --json').
|
|
66
|
+
Automatically updates ctx.obj['JSON'] if the option is provided.
|
|
67
|
+
"""
|
|
68
|
+
import functools
|
|
69
|
+
|
|
70
|
+
f = click.option("--json/--no-json", "json_output", default=None, help="Output results in JSON format")(f)
|
|
71
|
+
|
|
72
|
+
@click.pass_context
|
|
73
|
+
@functools.wraps(f)
|
|
74
|
+
def wrapper(ctx, *args, **kwargs):
|
|
75
|
+
json_output = kwargs.pop("json_output", None)
|
|
76
|
+
if json_output is not None and ctx.obj is not None:
|
|
77
|
+
ctx.obj["JSON"] = json_output
|
|
78
|
+
return ctx.invoke(f, *args, **kwargs)
|
|
79
|
+
|
|
80
|
+
return wrapper
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"], ignore_unknown_options=True, allow_extra_args=True)
|
|
84
|
+
|
|
85
|
+
# CLI Group
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
89
|
+
@click.option("--json/--no-json", "json_output", default=True, help="Output results in JSON format")
|
|
90
|
+
@click.option("--no-cache", is_flag=True, default=False, help="Bypass the disk cache for GitHub API calls")
|
|
91
|
+
@click.pass_context
|
|
92
|
+
def cli(ctx, json_output, no_cache):
|
|
93
|
+
"""Unified Tech-Dancer DevTools CLI"""
|
|
94
|
+
ctx.ensure_object(dict)
|
|
95
|
+
|
|
96
|
+
# If the user explicitly passed --no-json (if supported) or we want to detect if it's a TTY
|
|
97
|
+
# But for now, we follow the requirement to be JSON by default for machine consumption.
|
|
98
|
+
ctx.obj["JSON"] = json_output
|
|
99
|
+
ctx.obj["NO_CACHE"] = no_cache
|
|
100
|
+
|
|
101
|
+
def orchestrator_factory():
|
|
102
|
+
from dev_tools.orchestrator import Orchestrator
|
|
103
|
+
|
|
104
|
+
return Orchestrator(no_cache=no_cache)
|
|
105
|
+
|
|
106
|
+
ctx.obj["ORCHESTRATOR"] = LazyOrchestrator(orchestrator_factory)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- Utility Helpers ---
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def out(ctx, msg, data=None):
|
|
113
|
+
if ctx.obj["JSON"]:
|
|
114
|
+
payload = {"status": "success"}
|
|
115
|
+
if data:
|
|
116
|
+
payload.update(data)
|
|
117
|
+
click.echo(json.dumps(payload, indent=2))
|
|
118
|
+
else:
|
|
119
|
+
click.echo(msg)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def err(ctx, msg, code=1, data=None):
|
|
123
|
+
# Ensure sensitive data is masked in the error message
|
|
124
|
+
masked_msg = mask_sensitive_data(msg)
|
|
125
|
+
if ctx.obj["JSON"]:
|
|
126
|
+
payload = {"status": "error", "message": masked_msg, "code": code}
|
|
127
|
+
if data:
|
|
128
|
+
payload.update({"data": data})
|
|
129
|
+
click.echo(json.dumps(payload, indent=2))
|
|
130
|
+
else:
|
|
131
|
+
click.echo(f"❌ Error: {masked_msg}", err=True)
|
|
132
|
+
sys.exit(code)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _handle_unexpected_error(ctx, command_name, error):
|
|
136
|
+
"""Centralized error handling for unexpected exceptions."""
|
|
137
|
+
message = f"Unexpected error in {command_name}: {str(error)}"
|
|
138
|
+
err(ctx, message, code=1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _get_body_content(ctx, orch, file, body):
|
|
142
|
+
if file and body:
|
|
143
|
+
err(ctx, "Provide --file or --body, not both")
|
|
144
|
+
if not file and not body:
|
|
145
|
+
err(ctx, "Provide either --file or --body")
|
|
146
|
+
|
|
147
|
+
content = body if body is not None else (orch._read_safe_file(file) if file else None)
|
|
148
|
+
if content is None:
|
|
149
|
+
err(ctx, "Provide --file or --body")
|
|
150
|
+
return content
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ==========================================
|
|
154
|
+
# CONFIG COMMAND GROUP
|
|
155
|
+
# ==========================================
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@cli.group()
|
|
159
|
+
def config():
|
|
160
|
+
"""Configuration Operations"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@config.command(name="view")
|
|
164
|
+
@json_option
|
|
165
|
+
@click.pass_context
|
|
166
|
+
def config_view(ctx):
|
|
167
|
+
"""View the current project configuration as JSON."""
|
|
168
|
+
# ctx.obj might be None if the command is called directly or during unit tests
|
|
169
|
+
is_json = ctx.obj.get("JSON", True) if ctx.obj is not None else True
|
|
170
|
+
|
|
171
|
+
if is_json:
|
|
172
|
+
click.echo(json.dumps(asdict(PROJECT_CONFIG), indent=2))
|
|
173
|
+
else:
|
|
174
|
+
click.echo("Current configuration:")
|
|
175
|
+
click.echo(json.dumps(asdict(PROJECT_CONFIG), indent=2))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ==========================================
|
|
179
|
+
# REPO COMMAND GROUP
|
|
180
|
+
# ==========================================
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@cli.group()
|
|
184
|
+
def repo():
|
|
185
|
+
"""Repository Operations"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@repo.command()
|
|
189
|
+
@click.option("--grep")
|
|
190
|
+
@click.option("--worktree")
|
|
191
|
+
@click.pass_context
|
|
192
|
+
def run_playwright(ctx, grep, worktree):
|
|
193
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
194
|
+
res = orch.run_playwright(grep=grep, worktree_path=worktree)
|
|
195
|
+
out(ctx, f"Playwright run complete.", data=res)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@repo.command()
|
|
199
|
+
@click.argument("pr_number", type=int)
|
|
200
|
+
@click.option("--all", "include_all", is_flag=True, help="Include logs for successful runs")
|
|
201
|
+
@click.pass_context
|
|
202
|
+
def ci_logs(ctx, pr_number, include_all):
|
|
203
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
204
|
+
res = orch.get_ci_logs(pr_number, include_all=include_all)
|
|
205
|
+
out(ctx, f"Fetched CI logs for PR #{pr_number}", data=res)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@repo.command()
|
|
209
|
+
@click.argument("pr_number", type=int)
|
|
210
|
+
@click.option("--grep", help="Filter logs by pattern")
|
|
211
|
+
@click.pass_context
|
|
212
|
+
def logs(ctx, pr_number, grep):
|
|
213
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
214
|
+
logs_content = orch.stream_ci_logs(pr_number, grep=grep)
|
|
215
|
+
out(ctx, f"Fetched logs for PR #{pr_number}", data={"logs": logs_content})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@repo.command()
|
|
219
|
+
@click.argument("filepath")
|
|
220
|
+
@click.option("--patch-file")
|
|
221
|
+
@click.option("--patch-body")
|
|
222
|
+
@click.pass_context
|
|
223
|
+
def apply_patch(ctx, filepath, patch_file, patch_body):
|
|
224
|
+
"""Apply a patch to a file using resilient git apply."""
|
|
225
|
+
from dev_tools.utils import apply_patch as _apply
|
|
226
|
+
from dev_tools.utils import sanitize_path
|
|
227
|
+
|
|
228
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
229
|
+
|
|
230
|
+
# Security: sanitize inputs
|
|
231
|
+
filepath = sanitize_path(filepath)
|
|
232
|
+
if not filepath:
|
|
233
|
+
err(ctx, "Invalid filepath provided.")
|
|
234
|
+
return # Explicit return for security
|
|
235
|
+
|
|
236
|
+
content = patch_body
|
|
237
|
+
if patch_file:
|
|
238
|
+
safe_patch_path = sanitize_path(patch_file)
|
|
239
|
+
if not safe_patch_path:
|
|
240
|
+
err(ctx, "Invalid patch-file path provided.")
|
|
241
|
+
return # Explicit return for security
|
|
242
|
+
try:
|
|
243
|
+
# Use Orchestrator helper for safe file reading
|
|
244
|
+
content = orch._read_safe_file(safe_patch_path)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
err(ctx, f"Failed to read patch file: {str(e)}")
|
|
247
|
+
|
|
248
|
+
if not content:
|
|
249
|
+
err(ctx, "Provide either --patch-file or --patch-body")
|
|
250
|
+
return # Explicit return for security
|
|
251
|
+
|
|
252
|
+
# Basic diff format validation
|
|
253
|
+
if not any(marker in content for marker in ["--- ", "+++ ", "@@ "]):
|
|
254
|
+
err(ctx, "Invalid patch format. Content does not appear to be a unified diff.")
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
_apply(filepath, content)
|
|
259
|
+
out(ctx, f"✅ Applied patch to {filepath}")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
err(ctx, f"Failed to apply patch: {str(e)}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ==========================================
|
|
265
|
+
# GH COMMAND GROUP
|
|
266
|
+
# ==========================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@cli.group()
|
|
270
|
+
def gh():
|
|
271
|
+
"""GitHub Operations"""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@gh.command()
|
|
275
|
+
@json_option
|
|
276
|
+
@click.option("--state", default="open")
|
|
277
|
+
@limit_option(help_text="Limit the number of PRs to process")
|
|
278
|
+
@click.option("--include-drafts/--no-include-drafts", default=True)
|
|
279
|
+
@click.option("--labels")
|
|
280
|
+
@click.pass_context
|
|
281
|
+
def search_prs(ctx, state, limit, include_drafts, labels):
|
|
282
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
283
|
+
label_list = [l.strip() for l in labels.split(",")] if labels else None
|
|
284
|
+
|
|
285
|
+
res = orch.list_prs(state=state, limit=limit, includeDrafts=include_drafts, labels=label_list)
|
|
286
|
+
out(ctx, f"Found {len(res['prs'])} PRs.", data=res)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@gh.command()
|
|
290
|
+
@click.argument("pr_number", type=int)
|
|
291
|
+
@click.option("--base", default=PROJECT_CONFIG.base_branch_name)
|
|
292
|
+
@click.pass_context
|
|
293
|
+
def merge_conflicts(ctx, pr_number, base):
|
|
294
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
295
|
+
res = orch.get_merge_conflicts(pr_number, base_branch=base)
|
|
296
|
+
out(ctx, f"Checked merge conflicts for PR #{pr_number}", data=res)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@gh.command()
|
|
300
|
+
@click.argument("pr_number", type=int)
|
|
301
|
+
@click.pass_context
|
|
302
|
+
def sync_pr(ctx, pr_number):
|
|
303
|
+
"""Reliably pull the latest remote PR state to local, overwriting messy rebases."""
|
|
304
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
305
|
+
res = orch.sync_pr(pr_number)
|
|
306
|
+
out(ctx, res["message"], data=res)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@gh.command()
|
|
310
|
+
@click.argument("pr_number", type=int)
|
|
311
|
+
@click.pass_context
|
|
312
|
+
def pr_diff(ctx, pr_number):
|
|
313
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
314
|
+
res = orch.get_pr_diff_shapen(pr_number)
|
|
315
|
+
out(ctx, f"Fetched diff for PR #{pr_number}", data=res)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@gh.command()
|
|
319
|
+
@click.argument("pr_number", type=int)
|
|
320
|
+
@click.pass_context
|
|
321
|
+
def view(ctx, pr_number):
|
|
322
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
323
|
+
pr = orch.github.fetch_pr_details(pr_number)
|
|
324
|
+
# Normalize for tool consumption
|
|
325
|
+
normalized_pr = {
|
|
326
|
+
"number": pr.get("number"),
|
|
327
|
+
"title": pr.get("title"),
|
|
328
|
+
"state": pr.get("state"),
|
|
329
|
+
"headRefName": pr.get("head", {}).get("ref"),
|
|
330
|
+
"baseRefName": pr.get("base", {}).get("ref"),
|
|
331
|
+
"html_url": pr.get("html_url"),
|
|
332
|
+
}
|
|
333
|
+
out(
|
|
334
|
+
ctx,
|
|
335
|
+
f"PR #{normalized_pr['number']}: {normalized_pr['title']}\nState: {normalized_pr['state']}",
|
|
336
|
+
data={"pr": normalized_pr},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@gh.command()
|
|
341
|
+
@click.argument("file", required=False)
|
|
342
|
+
@click.option("--base")
|
|
343
|
+
@click.pass_context
|
|
344
|
+
def resolve(ctx, file, base):
|
|
345
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
346
|
+
if file:
|
|
347
|
+
if orch.resolve_conflict(file):
|
|
348
|
+
out(ctx, f"✅ Resolved conflicts in {file}", data={"resolved_file": file})
|
|
349
|
+
else:
|
|
350
|
+
err(ctx, f"Failed to resolve conflicts in {file}")
|
|
351
|
+
else:
|
|
352
|
+
resolved = orch.resolve_conflicts_headless()
|
|
353
|
+
out(ctx, f"✅ Resolved {len(resolved)} files.", data={"resolved": resolved})
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@gh.command()
|
|
357
|
+
@click.option(
|
|
358
|
+
"--check-dirs",
|
|
359
|
+
default=os.environ.get("AUDIT_CHECK_DIRS", ",".join(PROJECT_CONFIG.audit_check_dirs)),
|
|
360
|
+
help="Comma-separated list of directories to audit",
|
|
361
|
+
)
|
|
362
|
+
@click.pass_context
|
|
363
|
+
def audit(ctx, check_dirs):
|
|
364
|
+
"""Run a headless UI audit on the codebase."""
|
|
365
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
366
|
+
res = orch.get_audit_results(targets=check_dirs.split(","))
|
|
367
|
+
out(ctx, "Headless audit complete.", data=res)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@gh.command()
|
|
371
|
+
@json_option
|
|
372
|
+
@click.argument("pr_number", type=int)
|
|
373
|
+
@click.option("--fetch", is_flag=True)
|
|
374
|
+
@click.option("--audit", "run_audit", is_flag=True)
|
|
375
|
+
@click.option("--submit", is_flag=True)
|
|
376
|
+
@click.option("--cleanup", is_flag=True)
|
|
377
|
+
@click.option("--dry-run/--execute", default=True)
|
|
378
|
+
@click.option("--base")
|
|
379
|
+
@click.option("--event")
|
|
380
|
+
@click.pass_context
|
|
381
|
+
def audit_pr(ctx, pr_number, fetch, run_audit, submit, cleanup, dry_run, base, event):
|
|
382
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
383
|
+
res = orch.audit_pr(
|
|
384
|
+
pr_number,
|
|
385
|
+
fetch=fetch,
|
|
386
|
+
audit=run_audit,
|
|
387
|
+
submit=submit,
|
|
388
|
+
cleanup=cleanup,
|
|
389
|
+
dry_run=dry_run,
|
|
390
|
+
event=event,
|
|
391
|
+
)
|
|
392
|
+
out(ctx, f"✅ Audit PR #{pr_number} action complete.", data=res)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@gh.command()
|
|
396
|
+
@click.option("--title", required=True, help="Issue title")
|
|
397
|
+
@click.option("--file", help="Path to file containing issue body")
|
|
398
|
+
@click.option("--body", help="Literal body text")
|
|
399
|
+
@click.pass_context
|
|
400
|
+
def create_issue(ctx, title, file, body):
|
|
401
|
+
"""Create a new GitHub issue."""
|
|
402
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
403
|
+
|
|
404
|
+
content = _get_body_content(ctx, orch, file, body)
|
|
405
|
+
res = orch.create_issue(title, content)
|
|
406
|
+
out(ctx, f"✅ Successfully created issue: {res['issue'].get('html_url')}", data=res)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@gh.command()
|
|
410
|
+
@click.argument("issue_number", type=int)
|
|
411
|
+
@click.pass_context
|
|
412
|
+
def issue_view(ctx, issue_number):
|
|
413
|
+
"""View details of a GitHub issue."""
|
|
414
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
415
|
+
issue = orch.get_issue_details(issue_number)
|
|
416
|
+
msg = f"Issue #{issue.get('number')}: {issue.get('title')}\nState: {issue.get('state')}\n\n{issue.get('body')}"
|
|
417
|
+
out(ctx, msg, data={"issue": issue})
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@gh.command()
|
|
421
|
+
@click.argument("issue_number", type=int)
|
|
422
|
+
@click.option("--file", help="Path to file containing new issue body")
|
|
423
|
+
@click.option("--body", help="Literal body text")
|
|
424
|
+
@click.option("--labels", help="Comma-separated list of labels to set (replaces existing labels)")
|
|
425
|
+
@click.option("--add-labels", help="Comma-separated list of labels to add")
|
|
426
|
+
@click.option("--remove-labels", help="Comma-separated list of labels to remove")
|
|
427
|
+
@click.option("--state", type=click.Choice(["open", "closed"]))
|
|
428
|
+
@click.pass_context
|
|
429
|
+
def issue_update(ctx, issue_number, file, body, labels, add_labels, remove_labels, state):
|
|
430
|
+
"""Update a GitHub issue's body, labels, and/or state."""
|
|
431
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
432
|
+
|
|
433
|
+
label_list = [l.strip() for l in labels.split(",")] if labels else None
|
|
434
|
+
add_label_list = [l.strip() for l in add_labels.split(",")] if add_labels else None
|
|
435
|
+
remove_label_list = [l.strip() for l in remove_labels.split(",")] if remove_labels else None
|
|
436
|
+
|
|
437
|
+
content = None
|
|
438
|
+
if file or body:
|
|
439
|
+
content = _get_body_content(ctx, orch, file, body)
|
|
440
|
+
|
|
441
|
+
res = orch.update_issue(
|
|
442
|
+
issueNumber=issue_number,
|
|
443
|
+
body=content,
|
|
444
|
+
labels=label_list,
|
|
445
|
+
addLabels=add_label_list,
|
|
446
|
+
removeLabels=remove_label_list,
|
|
447
|
+
state=state,
|
|
448
|
+
)
|
|
449
|
+
out(ctx, f"✅ Successfully updated issue #{issue_number}", data=res)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@gh.command()
|
|
453
|
+
@click.argument("issue_number", type=int)
|
|
454
|
+
@click.option("--file", help="Path to file containing comment body")
|
|
455
|
+
@click.option("--body", help="Literal body text")
|
|
456
|
+
@click.pass_context
|
|
457
|
+
def issue_comment(ctx, issue_number, file, body):
|
|
458
|
+
"""Post a comment to a GitHub issue."""
|
|
459
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
460
|
+
content = _get_body_content(ctx, orch, file, body)
|
|
461
|
+
res = orch.post_comment(issue_number, content)
|
|
462
|
+
out(ctx, f"✅ Successfully posted comment to issue #{issue_number}", data={"comment": res})
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@gh.command()
|
|
466
|
+
@click.option("--issue-number", type=int)
|
|
467
|
+
@click.option("--all-open", is_flag=True)
|
|
468
|
+
@click.option("--post-comments", is_flag=True)
|
|
469
|
+
@click.option("--dry-run/--execute", default=True)
|
|
470
|
+
@click.pass_context
|
|
471
|
+
def validate_issue(ctx, issue_number, all_open, post_comments, dry_run):
|
|
472
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
473
|
+
res = orch.validate_issue(
|
|
474
|
+
issue_number=issue_number, all_open=all_open, post_comments=post_comments, dry_run=dry_run
|
|
475
|
+
)
|
|
476
|
+
if not ctx.obj["JSON"]:
|
|
477
|
+
for issue in res["issues"]:
|
|
478
|
+
click.echo(f"{'✅' if not issue['findings'] else '❌'} #{issue['number']}: {issue['title'][:60]}")
|
|
479
|
+
for f in issue["findings"]:
|
|
480
|
+
click.echo(f" ❌ {f}")
|
|
481
|
+
for w in issue["warnings"]:
|
|
482
|
+
click.echo(f" ⚠️ {w}")
|
|
483
|
+
if res["status"] == "error":
|
|
484
|
+
err(ctx, f"Found {res['total_findings']} blocking findings.", data=res)
|
|
485
|
+
else:
|
|
486
|
+
out(ctx, "✅ Issue validation complete.", data=res)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@gh.command()
|
|
490
|
+
@click.option("--title", required=True)
|
|
491
|
+
@click.option("--body", required=True)
|
|
492
|
+
@click.option("--head", required=True)
|
|
493
|
+
@click.option("--base", default=PROJECT_CONFIG.base_branch_name)
|
|
494
|
+
@click.option("--draft", is_flag=True)
|
|
495
|
+
@click.pass_context
|
|
496
|
+
def create_pr(ctx, title, body, head, base, draft):
|
|
497
|
+
"""Create a new pull request."""
|
|
498
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
499
|
+
res = orch.github.create_pull_request(title, body, head, base, draft=draft)
|
|
500
|
+
out(ctx, f"✅ Created PR: {res.get('html_url')}", data={"pr": res})
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@gh.command()
|
|
504
|
+
@click.argument("branch")
|
|
505
|
+
@click.pass_context
|
|
506
|
+
def checkout(ctx, branch):
|
|
507
|
+
"""Checkout a branch."""
|
|
508
|
+
run_command(["git", "checkout", branch])
|
|
509
|
+
out(ctx, f"✅ Checked out branch {branch}")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@gh.command()
|
|
513
|
+
@click.argument("target_branch")
|
|
514
|
+
@click.argument("pr_numbers", nargs=-1, type=int)
|
|
515
|
+
@click.pass_context
|
|
516
|
+
def aggregate(ctx, target_branch, pr_numbers):
|
|
517
|
+
"""Aggregate multiple PRs into a single branch."""
|
|
518
|
+
if not pr_numbers:
|
|
519
|
+
err(ctx, "Provide at least one PR number to aggregate.")
|
|
520
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
521
|
+
res = orch.aggregate_prs(target_branch, list(pr_numbers))
|
|
522
|
+
out(ctx, res["message"], data=res)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _render_conflicts(ctx, conflicts):
|
|
526
|
+
if not ctx.obj["JSON"]:
|
|
527
|
+
if not conflicts:
|
|
528
|
+
click.echo("✅ No potential merge conflicts detected.")
|
|
529
|
+
for c in conflicts:
|
|
530
|
+
prs_str = ' ↔ '.join(f'#{p}' for p in c['prs'])
|
|
531
|
+
files_count = len(c['files'])
|
|
532
|
+
click.echo(f"⚠️ {prs_str} share {files_count} file(s):")
|
|
533
|
+
for f in sorted(c["files"])[:10]:
|
|
534
|
+
click.echo(f" - {f}")
|
|
535
|
+
out(ctx, f"Found {len(conflicts)} potential conflicts.", data={"conflicts": conflicts})
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@gh.command()
|
|
539
|
+
@click.pass_context
|
|
540
|
+
def conflicts(ctx):
|
|
541
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
542
|
+
conflicts: List[Dict[str, Any]] = orch.handle_detect_conflicts()
|
|
543
|
+
_render_conflicts(ctx, conflicts)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@gh.command()
|
|
547
|
+
@click.option("--pr", required=True, type=int, help="The PR number to resolve conflicts for.")
|
|
548
|
+
@click.option("--allow-unrelated", is_flag=True, help="Allow merging unrelated histories.")
|
|
549
|
+
@click.option(
|
|
550
|
+
"--strategy",
|
|
551
|
+
type=click.Choice(["ours", "theirs"]),
|
|
552
|
+
help="Merge strategy option (-X ours/theirs).",
|
|
553
|
+
)
|
|
554
|
+
@click.option("--push", is_flag=True, help="Automatically push the resolution to origin.")
|
|
555
|
+
@click.option(
|
|
556
|
+
"--continue",
|
|
557
|
+
"continue_resolve",
|
|
558
|
+
is_flag=True,
|
|
559
|
+
help="Finalize and push an in-progress conflict resolution.",
|
|
560
|
+
)
|
|
561
|
+
@click.pass_context
|
|
562
|
+
def resolve_conflicts(ctx, pr, allow_unrelated, strategy, push, continue_resolve):
|
|
563
|
+
"""Resolve merge conflicts for a PR in a separate worktree."""
|
|
564
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
565
|
+
try:
|
|
566
|
+
res = orch.resolve_pr_conflicts(
|
|
567
|
+
pr,
|
|
568
|
+
allow_unrelated=allow_unrelated,
|
|
569
|
+
strategy=strategy,
|
|
570
|
+
push=push,
|
|
571
|
+
continue_resolve=continue_resolve,
|
|
572
|
+
)
|
|
573
|
+
out(ctx, res["message"], data=res)
|
|
574
|
+
except CLIError as e:
|
|
575
|
+
err(ctx, str(e), code=e.code)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@gh.command()
|
|
579
|
+
@click.argument("diff_input", required=False)
|
|
580
|
+
@click.pass_context
|
|
581
|
+
def verify_versions(ctx, diff_input):
|
|
582
|
+
"""Verify version changes in a diff for downgrades or hard blocks."""
|
|
583
|
+
from dev_tools.utils import resolve_resource_path
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
script_path = resolve_resource_path("verify_versions.py")
|
|
587
|
+
except FileNotFoundError:
|
|
588
|
+
# Absolute fallback if packaging failed
|
|
589
|
+
script_path = os.path.join(os.path.dirname(__file__), "verify_versions.py")
|
|
590
|
+
|
|
591
|
+
if not diff_input:
|
|
592
|
+
# If no input provided, try to get diff against main
|
|
593
|
+
try:
|
|
594
|
+
diff_input = run_command(["git", "diff", PROJECT_CONFIG.base_branch])
|
|
595
|
+
except Exception as e:
|
|
596
|
+
_handle_unexpected_error(ctx, "check-diff", e)
|
|
597
|
+
|
|
598
|
+
# Use a temporary file to avoid E2BIG/ARG_MAX issues with large diffs
|
|
599
|
+
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp:
|
|
600
|
+
tmp.write(diff_input)
|
|
601
|
+
tmp_path = tmp.name
|
|
602
|
+
|
|
603
|
+
cmd = [sys.executable, script_path, tmp_path]
|
|
604
|
+
try:
|
|
605
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
606
|
+
os.unlink(tmp_path)
|
|
607
|
+
if proc.stdout:
|
|
608
|
+
try:
|
|
609
|
+
findings = json.loads(proc.stdout)
|
|
610
|
+
if findings:
|
|
611
|
+
status = "error" if any(f["severity"] == "error" for f in findings) else "success"
|
|
612
|
+
out(
|
|
613
|
+
ctx,
|
|
614
|
+
f"Found {len(findings)} version issues.",
|
|
615
|
+
data={"status": status, "findings": findings},
|
|
616
|
+
)
|
|
617
|
+
if status == "error":
|
|
618
|
+
sys.exit(1)
|
|
619
|
+
else:
|
|
620
|
+
out(
|
|
621
|
+
ctx,
|
|
622
|
+
"✅ No version issues detected.",
|
|
623
|
+
data={"status": "success", "findings": []},
|
|
624
|
+
)
|
|
625
|
+
except json.JSONDecodeError:
|
|
626
|
+
err(ctx, f"Invalid validator output: {proc.stdout}")
|
|
627
|
+
else:
|
|
628
|
+
err(ctx, f"Validator failed: {proc.stderr}")
|
|
629
|
+
except Exception as e:
|
|
630
|
+
_handle_unexpected_error(ctx, "verify-versions", e)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@gh.command()
|
|
634
|
+
@click.option("--pr", type=int)
|
|
635
|
+
@click.pass_context
|
|
636
|
+
def detect_conflicts(ctx, pr):
|
|
637
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
638
|
+
conflicts = orch.handle_detect_conflicts(pr_num=pr)
|
|
639
|
+
_render_conflicts(ctx, conflicts)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@gh.command()
|
|
643
|
+
@click.option("--body", help="The comment body text.")
|
|
644
|
+
@click.option("--author-association", required=True, help="The author association (e.g. OWNER, MEMBER).")
|
|
645
|
+
@click.pass_context
|
|
646
|
+
def parse_comment(ctx, body, author_association):
|
|
647
|
+
"""Parse a comment body and return intended actions."""
|
|
648
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
649
|
+
# Security: Allow reading body from COMMENT_BODY env var to avoid shell injection in CI
|
|
650
|
+
content = body if body else os.environ.get("COMMENT_BODY")
|
|
651
|
+
if not content:
|
|
652
|
+
err(ctx, "Comment body is required (use --body or COMMENT_BODY env var)")
|
|
653
|
+
|
|
654
|
+
actions = orch.parse_comment(content, author_association)
|
|
655
|
+
out(ctx, "Comment parsed successfully.", data={"actions": actions})
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@gh.command()
|
|
659
|
+
@click.option("--pr", required=True, type=int, help="The PR number to comment on.")
|
|
660
|
+
@click.option("--file", type=str, help="Path to the file containing the comment body.")
|
|
661
|
+
@click.option("--body", type=str, help="Literal comment text.")
|
|
662
|
+
@click.pass_context
|
|
663
|
+
def post_comment(ctx, pr, file, body):
|
|
664
|
+
"""Post a comment to a PR."""
|
|
665
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
666
|
+
content = _get_body_content(ctx, orch, file, body)
|
|
667
|
+
res = orch.post_comment(pr, content)
|
|
668
|
+
out(ctx, f"✅ Successfully posted comment to PR #{pr}", data=res)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@gh.command(name="read-pr-comments")
|
|
672
|
+
@click.option("--pr-number", required=True, type=int, help="The PR number to fetch comments for.")
|
|
673
|
+
@click.pass_context
|
|
674
|
+
def read_pr_comments(ctx, pr_number):
|
|
675
|
+
"""Fetch and display standard and review comments for a PR."""
|
|
676
|
+
if pr_number <= 0:
|
|
677
|
+
err(ctx, "PR number must be a positive integer.")
|
|
678
|
+
|
|
679
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
680
|
+
|
|
681
|
+
res = orch.get_pr_comments(prNumber=pr_number)
|
|
682
|
+
|
|
683
|
+
# Response contract validation
|
|
684
|
+
|
|
685
|
+
if ctx.obj["JSON"]:
|
|
686
|
+
out(ctx, f"Fetched comments for PR #{pr_number}", data=res)
|
|
687
|
+
else:
|
|
688
|
+
pr = res["pr"]
|
|
689
|
+
click.echo(f"PR #{pr['number']}: {pr['title']}")
|
|
690
|
+
click.echo("--- Comments ---")
|
|
691
|
+
for comment in res.get("comments", []):
|
|
692
|
+
body = click.unstyle(comment.get("body", ""))
|
|
693
|
+
click.echo(f"[{comment['user']}]: {body}")
|
|
694
|
+
click.echo("-" * 20)
|
|
695
|
+
|
|
696
|
+
click.echo("\n--- Review Comments ---")
|
|
697
|
+
for comment in res.get("review_comments", []):
|
|
698
|
+
path = comment.get("path")
|
|
699
|
+
line = comment.get("line")
|
|
700
|
+
location = f"{path}:{line}" if line else path
|
|
701
|
+
body = click.unstyle(comment.get("body", ""))
|
|
702
|
+
click.echo(f"[{comment['user']}] in {location}: {body}")
|
|
703
|
+
click.echo("-" * 20)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@gh.command()
|
|
707
|
+
@limit_option(help_text="Limit the number of open PRs to process")
|
|
708
|
+
@click.pass_context
|
|
709
|
+
def status_board(ctx, limit):
|
|
710
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
711
|
+
prs = orch.handle_status_board(limit=limit)
|
|
712
|
+
if not ctx.obj["JSON"]:
|
|
713
|
+
click.echo("# Active Agent Work Board\n| Branch | Issue | Status |")
|
|
714
|
+
for pr in prs:
|
|
715
|
+
click.echo(f"| {pr['branch']} | {pr['issue']} | {pr['status']} |")
|
|
716
|
+
out(ctx, f"Found {len(prs)} open PRs.", data={"work": prs})
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@gh.command()
|
|
720
|
+
@click.option("--find")
|
|
721
|
+
@click.option("--migrate", nargs=2, type=str)
|
|
722
|
+
@click.option("--dry-run/--execute", default=True)
|
|
723
|
+
@click.pass_context
|
|
724
|
+
def migrate_tokens(ctx, find, migrate, dry_run):
|
|
725
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
726
|
+
matches = orch.migrate_tokens(find=find, migrate=migrate, dry_run=dry_run)
|
|
727
|
+
out(ctx, f"Found {len(matches)} matches.", data={"matches": matches})
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@gh.command()
|
|
731
|
+
@click.option("--dry-run/--execute", default=True)
|
|
732
|
+
@click.pass_context
|
|
733
|
+
def update_issues(ctx, dry_run):
|
|
734
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
735
|
+
updates = orch.update_issues(dry_run=dry_run)
|
|
736
|
+
out(ctx, f"Found {len(updates)} updates.", data={"updates": updates})
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
@gh.command()
|
|
740
|
+
@click.option("--check-responses", is_flag=True)
|
|
741
|
+
@click.option("--cleanup-comments", is_flag=True)
|
|
742
|
+
@click.option("--dry-run/--execute", default=True)
|
|
743
|
+
@limit_option(help_text="Limit the number of PRs to process")
|
|
744
|
+
@click.pass_context
|
|
745
|
+
def manage_reviews(ctx, check_responses, cleanup_comments, dry_run, limit):
|
|
746
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
747
|
+
prs = orch.manage_reviews(
|
|
748
|
+
check_responses=check_responses,
|
|
749
|
+
cleanup_comments=cleanup_comments,
|
|
750
|
+
dry_run=dry_run,
|
|
751
|
+
limit=limit,
|
|
752
|
+
)
|
|
753
|
+
out(ctx, f"Checked {len(prs)} PRs.", data={"prs": prs})
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
@gh.command()
|
|
757
|
+
@click.pass_context
|
|
758
|
+
def audit_gate(ctx):
|
|
759
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
760
|
+
res = orch.handle_audit_gate()
|
|
761
|
+
msg = f"UI Anti-Pattern Audit: Current={res['current']}, Baseline={res['baseline']}"
|
|
762
|
+
if res["status"] == "error":
|
|
763
|
+
err(ctx, msg, data=res)
|
|
764
|
+
else:
|
|
765
|
+
out(ctx, msg, data=res)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@gh.command()
|
|
769
|
+
@click.option("--pr", required=True, type=int)
|
|
770
|
+
@click.option("--status", required=True)
|
|
771
|
+
@click.option("--auditor", required=True)
|
|
772
|
+
@click.option("--dry-run/--execute", default=True)
|
|
773
|
+
@click.pass_context
|
|
774
|
+
def track_review(ctx, pr, status, auditor, dry_run):
|
|
775
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
776
|
+
res = orch.track_review(pr, status, auditor, dry_run=dry_run)
|
|
777
|
+
out(ctx, f"✅ Updated tracking for PR #{pr}", data=res)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@cli.command(name="schema")
|
|
781
|
+
@click.argument("command_path", required=False)
|
|
782
|
+
@click.pass_context
|
|
783
|
+
def schema_cmd(ctx, command_path):
|
|
784
|
+
"""Retrieve the schema for a specific subcommand or all commands."""
|
|
785
|
+
# Sanitize and validate command_path to prevent injection
|
|
786
|
+
# Allowed format: words separated by single spaces (no special shell characters)
|
|
787
|
+
if command_path:
|
|
788
|
+
import re
|
|
789
|
+
|
|
790
|
+
# Stricter regex: words containing only lowercase alphanumeric, hyphens, and underscores,
|
|
791
|
+
# separated by single spaces. No leading/trailing spaces.
|
|
792
|
+
# This prevents any shell character injection and restricts to valid command tokens.
|
|
793
|
+
word = r"[a-z0-9-_]+"
|
|
794
|
+
pattern = f"^{word}( {word})*$"
|
|
795
|
+
if not re.match(pattern, command_path):
|
|
796
|
+
err(
|
|
797
|
+
ctx,
|
|
798
|
+
"Invalid command path. Only lowercase alphanumeric, hyphens, and underscores separated by single spaces are allowed.",
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
from dev_tools.schema_utils import collect_commands, get_command_by_path
|
|
802
|
+
|
|
803
|
+
target_cmd = get_command_by_path(cli, command_path)
|
|
804
|
+
|
|
805
|
+
# If the path matches a group but not a leaf command, target_cmd will be the group.
|
|
806
|
+
# get_command_by_path returns None if not found at all.
|
|
807
|
+
|
|
808
|
+
if not target_cmd:
|
|
809
|
+
msg = "Command path not found."
|
|
810
|
+
if command_path and " " not in command_path:
|
|
811
|
+
# Provide a small hint for common top-level groups
|
|
812
|
+
if command_path in ["gh", "repo", "config", "ux", "agent", "jules"]:
|
|
813
|
+
msg += f" Did you mean 'td-cli schema {command_path}' to see subcommands?"
|
|
814
|
+
err(ctx, msg)
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
# Use a safe depth limit (5) for granular lookups to maintain performance
|
|
818
|
+
res = collect_commands(target_cmd, prefix=command_path or "", max_depth=5)
|
|
819
|
+
out(ctx, f"Schema for {command_path or 'root'}", data={"schema": res})
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _handle_gate(ctx, res, label):
|
|
823
|
+
msg = f"{label}: Current={res.get('current', res.get('size_kb'))}, Baseline={res.get('baseline', res.get('baseline_kb'))}"
|
|
824
|
+
if res["status"] == "error":
|
|
825
|
+
err(ctx, msg, data=res)
|
|
826
|
+
else:
|
|
827
|
+
out(ctx, msg, data=res)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@gh.command()
|
|
831
|
+
@click.option("--baseline-file")
|
|
832
|
+
@click.option("--update", is_flag=True)
|
|
833
|
+
@click.option("--dry-run/--execute", default=True)
|
|
834
|
+
@click.pass_context
|
|
835
|
+
def ratchet_any(ctx, baseline_file, update, dry_run):
|
|
836
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
837
|
+
res = orch.ratchet_any(update=update, baseline_file=baseline_file, dry_run=dry_run)
|
|
838
|
+
_handle_gate(ctx, res, "TypeScript 'any' Ratchet")
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@gh.command()
|
|
842
|
+
@click.option("--baseline-file")
|
|
843
|
+
@click.option("--threshold", type=int, default=50)
|
|
844
|
+
@click.option("--update", is_flag=True)
|
|
845
|
+
@click.option("--dry-run/--execute", default=True)
|
|
846
|
+
@click.pass_context
|
|
847
|
+
def bundle_size(ctx, baseline_file, threshold, update, dry_run):
|
|
848
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
849
|
+
res = orch.check_bundle_size(update=update, baseline_file=baseline_file, threshold=threshold, dry_run=dry_run)
|
|
850
|
+
_handle_gate(ctx, res, "Bundle Size Check")
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
@cli.command()
|
|
854
|
+
@click.pass_context
|
|
855
|
+
def doctor(ctx):
|
|
856
|
+
"""Runtime Consistency Check"""
|
|
857
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
858
|
+
res = orch.runtime_check()
|
|
859
|
+
out(ctx, f"✅ Runtime OK: node {res['node']}, pnpm {res['pnpm']}", data=res)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
@gh.command()
|
|
863
|
+
@click.pass_context
|
|
864
|
+
def verify_metrics(ctx):
|
|
865
|
+
"""Verify CI metrics against established thresholds."""
|
|
866
|
+
res = verify_ci_metrics()
|
|
867
|
+
if res["status"] == "error":
|
|
868
|
+
err(ctx, res["message"], data=res)
|
|
869
|
+
else:
|
|
870
|
+
out(ctx, res["message"], data=res)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
@gh.command()
|
|
874
|
+
@click.pass_context
|
|
875
|
+
def summary_report(ctx):
|
|
876
|
+
"""Generate a markdown report of CI metrics for GHA Step Summary."""
|
|
877
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
878
|
+
report = orch.generate_ci_summary_report()
|
|
879
|
+
# Always print as raw text to stdout for GHA redirection
|
|
880
|
+
click.echo(report)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@gh.command()
|
|
884
|
+
@click.pass_context
|
|
885
|
+
def pre_submit(ctx):
|
|
886
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
887
|
+
res = orch.pre_submit_checks()
|
|
888
|
+
out(ctx, "Pre-submit checks complete.", data={"results": res})
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@gh.command()
|
|
892
|
+
@limit_option(help_text="Limit the number of open PRs to process")
|
|
893
|
+
@click.pass_context
|
|
894
|
+
def overlaps(ctx, limit):
|
|
895
|
+
"""Identify and propose consolidation of PRs with high functional or structural overlap."""
|
|
896
|
+
from dev_tools.utils import resolve_resource_path
|
|
897
|
+
|
|
898
|
+
try:
|
|
899
|
+
script_path = resolve_resource_path("pr_overlap.py")
|
|
900
|
+
except FileNotFoundError:
|
|
901
|
+
# Absolute fallback if packaging failed
|
|
902
|
+
script_path = os.path.join(os.path.dirname(__file__), "pr_overlap.py")
|
|
903
|
+
cmd = [sys.executable, script_path, "--limit", str(limit)]
|
|
904
|
+
|
|
905
|
+
# Check global NO_CACHE from context object
|
|
906
|
+
if ctx.obj.get("NO_CACHE"):
|
|
907
|
+
cmd.append("--no-cache")
|
|
908
|
+
|
|
909
|
+
try:
|
|
910
|
+
subprocess.run(cmd, check=True)
|
|
911
|
+
except subprocess.CalledProcessError as e:
|
|
912
|
+
err(ctx, f"pr_overlap.py failed with exit code {e.returncode}")
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
# ==========================================
|
|
916
|
+
# UX COMMAND GROUP
|
|
917
|
+
# ==========================================
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@cli.group()
|
|
921
|
+
def ux():
|
|
922
|
+
"""UX Audit Operations"""
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
@ux.command(name="audit")
|
|
926
|
+
@click.option("--route", help="Specific route to audit")
|
|
927
|
+
@click.option("--all-routes", is_flag=True, help="Audit all discovered routes")
|
|
928
|
+
@click.option("--desktop", is_flag=True, help="Audit desktop viewports")
|
|
929
|
+
@click.option("--mobile", is_flag=True, help="Audit mobile viewports")
|
|
930
|
+
@click.pass_context
|
|
931
|
+
def ux_audit(ctx, route, all_routes, desktop, mobile):
|
|
932
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
933
|
+
res = orch.run_ux_audit(route=route, all_routes=all_routes, desktop=desktop, mobile=mobile)
|
|
934
|
+
out(ctx, "UX Audit complete.", data=res)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@ux.command()
|
|
938
|
+
@click.pass_context
|
|
939
|
+
def report(ctx):
|
|
940
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
941
|
+
res = orch.generate_ux_report()
|
|
942
|
+
out(ctx, "UX Report generated.", data=res)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
@ux.command()
|
|
946
|
+
@click.option("--route", help="Specific route for Lighthouse")
|
|
947
|
+
@click.pass_context
|
|
948
|
+
def lighthouse(ctx, route):
|
|
949
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
950
|
+
res = orch.run_lighthouse(route=route)
|
|
951
|
+
out(ctx, "Lighthouse audit complete.", data=res)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
@ux.command()
|
|
955
|
+
@click.option("--route", help="Specific route for screenshots")
|
|
956
|
+
@click.pass_context
|
|
957
|
+
def screenshots(ctx, route):
|
|
958
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
959
|
+
res = orch.run_ux_audit(route=route, screenshots_only=True)
|
|
960
|
+
out(ctx, "Screenshots captured.", data=res)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@ux.command()
|
|
964
|
+
@click.option("--route", help="Specific route for image audit")
|
|
965
|
+
@click.pass_context
|
|
966
|
+
def images(ctx, route):
|
|
967
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
968
|
+
res = orch.run_ux_audit(route=route, images_only=True)
|
|
969
|
+
out(ctx, "Image audit complete.", data=res)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
@ux.command()
|
|
973
|
+
@click.option("--route", help="Specific route for contrast check")
|
|
974
|
+
@click.pass_context
|
|
975
|
+
def contrast(ctx, route):
|
|
976
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
977
|
+
res = orch.run_ux_audit(route=route, contrast_only=True)
|
|
978
|
+
out(ctx, "Contrast check complete.", data=res)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
@ux.command()
|
|
982
|
+
@click.option("--route", help="Specific route for overflow check")
|
|
983
|
+
@click.pass_context
|
|
984
|
+
def overflow(ctx, route):
|
|
985
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
986
|
+
res = orch.run_ux_audit(route=route, overflow_only=True)
|
|
987
|
+
out(ctx, "Overflow check complete.", data=res)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
@cli.command()
|
|
991
|
+
@click.pass_context
|
|
992
|
+
def build(ctx):
|
|
993
|
+
"""Build the project after runtime check"""
|
|
994
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
995
|
+
orch.runtime_check()
|
|
996
|
+
run_command(["pnpm", "run", "build"])
|
|
997
|
+
out(ctx, "✅ Build complete")
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
@cli.command()
|
|
1001
|
+
@click.pass_context
|
|
1002
|
+
def lint(ctx):
|
|
1003
|
+
"""Lint the project after runtime check"""
|
|
1004
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1005
|
+
orch.runtime_check()
|
|
1006
|
+
run_command(["pnpm", "run", "lint"])
|
|
1007
|
+
out(ctx, "✅ Lint complete")
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
@cli.group()
|
|
1011
|
+
def test():
|
|
1012
|
+
"""Testing Operations"""
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
@test.command(name="cli")
|
|
1016
|
+
@click.pass_context
|
|
1017
|
+
def test_cli(ctx):
|
|
1018
|
+
"""Run CLI package tests"""
|
|
1019
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1020
|
+
orch.runtime_check()
|
|
1021
|
+
# pytest options are now in pyproject.toml
|
|
1022
|
+
# Get the directory of the current file to find the cli package root
|
|
1023
|
+
cli_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
1024
|
+
try:
|
|
1025
|
+
subprocess.run([sys.executable, "-m", "pytest", "tests/"], cwd=cli_dir, check=True)
|
|
1026
|
+
out(ctx, "✅ CLI tests passed")
|
|
1027
|
+
except subprocess.CalledProcessError as e:
|
|
1028
|
+
err(ctx, f"CLI tests failed with exit code {e.returncode}")
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
# ==========================================
|
|
1032
|
+
# AI COMMAND GROUP
|
|
1033
|
+
# ==========================================
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
@cli.group()
|
|
1037
|
+
def ai():
|
|
1038
|
+
"""AI Operations"""
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
@ai.command()
|
|
1042
|
+
@click.argument("pr_number", type=int)
|
|
1043
|
+
@click.option(
|
|
1044
|
+
"--no-cache",
|
|
1045
|
+
is_flag=True,
|
|
1046
|
+
default=False,
|
|
1047
|
+
help="Bust the diff cache and force a fresh review call",
|
|
1048
|
+
)
|
|
1049
|
+
@click.pass_context
|
|
1050
|
+
def review(ctx, pr_number, no_cache):
|
|
1051
|
+
# Optionally bust the review cache so stale results are not silently returned
|
|
1052
|
+
if no_cache:
|
|
1053
|
+
review_dir = get_or_create_log_dir("reviews")
|
|
1054
|
+
pattern = os.path.join(review_dir, f"review_cache_{pr_number}_*.json")
|
|
1055
|
+
removed = glob.glob(pattern)
|
|
1056
|
+
for f in removed:
|
|
1057
|
+
os.remove(f)
|
|
1058
|
+
if removed:
|
|
1059
|
+
log_info(f"🗑 Removed {len(removed)} cached diff file(s): {removed}")
|
|
1060
|
+
else:
|
|
1061
|
+
log_info("ℹ️ No cache files found to remove.")
|
|
1062
|
+
|
|
1063
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1064
|
+
res = orch.review_pr(pr_number)
|
|
1065
|
+
|
|
1066
|
+
# Surface errors clearly
|
|
1067
|
+
if (
|
|
1068
|
+
isinstance(res, dict)
|
|
1069
|
+
and res.get("recommendation") == "Not Approved"
|
|
1070
|
+
and not res.get("reviewComment", "").strip().startswith("CI")
|
|
1071
|
+
):
|
|
1072
|
+
# Likely an error result – dump full dict to stderr for diagnosis
|
|
1073
|
+
log_info(f"""⚠️ Review returned 'Not Approved' (may indicate an error).
|
|
1074
|
+
recommendation : {res.get('recommendation')}
|
|
1075
|
+
reviewComment : {res.get('reviewComment', '')[:500]}""")
|
|
1076
|
+
|
|
1077
|
+
out(ctx, f"✅ Generated review for PR #{pr_number}", data=res)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
@ai.command(name="get-context")
|
|
1081
|
+
@click.pass_context
|
|
1082
|
+
def ai_get_context(ctx):
|
|
1083
|
+
"""Retrieve dependency and semantic context for a set of changed files."""
|
|
1084
|
+
try:
|
|
1085
|
+
raw_data = sys.stdin.read()
|
|
1086
|
+
if not raw_data.strip():
|
|
1087
|
+
err(ctx, "Empty input from stdin")
|
|
1088
|
+
input_data = json.loads(raw_data)
|
|
1089
|
+
except Exception as e:
|
|
1090
|
+
_handle_unexpected_error(ctx, "ai batch-context", e)
|
|
1091
|
+
|
|
1092
|
+
if not isinstance(input_data, dict):
|
|
1093
|
+
err(ctx, "Input JSON must be a dictionary")
|
|
1094
|
+
|
|
1095
|
+
if "files" not in input_data:
|
|
1096
|
+
err(ctx, "Input JSON missing required 'files' key")
|
|
1097
|
+
|
|
1098
|
+
files_data = input_data.get("files", [])
|
|
1099
|
+
if not files_data:
|
|
1100
|
+
click.echo(json.dumps([]))
|
|
1101
|
+
return
|
|
1102
|
+
|
|
1103
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1104
|
+
results = []
|
|
1105
|
+
|
|
1106
|
+
ai_client = orch.ai
|
|
1107
|
+
graph = ai_client.dependency_graph
|
|
1108
|
+
store = ai_client.vector_store
|
|
1109
|
+
|
|
1110
|
+
for item in files_data:
|
|
1111
|
+
filepath = item.get("path")
|
|
1112
|
+
diff_text = item.get("diff")
|
|
1113
|
+
if not filepath:
|
|
1114
|
+
continue
|
|
1115
|
+
|
|
1116
|
+
context = {
|
|
1117
|
+
"path": filepath,
|
|
1118
|
+
"dependencies": graph.get_dependencies(filepath),
|
|
1119
|
+
"dependents": graph.get_dependents(filepath),
|
|
1120
|
+
"semantic": [],
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if diff_text and store.is_available():
|
|
1124
|
+
try:
|
|
1125
|
+
semantic_results = store.query(diff_text, n_results=3)
|
|
1126
|
+
for res in semantic_results:
|
|
1127
|
+
if res["metadata"].get("path") != filepath:
|
|
1128
|
+
context["semantic"].append({"path": res["metadata"].get("path"), "document": res["document"]})
|
|
1129
|
+
except Exception as e:
|
|
1130
|
+
log_warn(f"Error querying vector store for {filepath}: {e}")
|
|
1131
|
+
|
|
1132
|
+
results.append(context)
|
|
1133
|
+
|
|
1134
|
+
# Output raw JSON list for compatibility with existing consumers
|
|
1135
|
+
click.echo(json.dumps(results))
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
@ai.command()
|
|
1139
|
+
@click.argument("file")
|
|
1140
|
+
@click.pass_context
|
|
1141
|
+
def analyze(ctx, file):
|
|
1142
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1143
|
+
res = orch.analyze_file(file)
|
|
1144
|
+
out(ctx, f"✅ Analyzed {file}", data={"result": res})
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
@ai.command()
|
|
1148
|
+
@click.option("--pr", required=True, type=int)
|
|
1149
|
+
@click.option("--command", required=True)
|
|
1150
|
+
@click.option("--comment-id")
|
|
1151
|
+
@click.pass_context
|
|
1152
|
+
def comment(ctx, pr, command, comment_id):
|
|
1153
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1154
|
+
res = orch.handle_comment_command(pr, command, comment_id)
|
|
1155
|
+
if res.get("status") == "error":
|
|
1156
|
+
err(ctx, res.get("message"), data=res)
|
|
1157
|
+
else:
|
|
1158
|
+
out(ctx, res.get("message"), data=res)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
# ==========================================
|
|
1162
|
+
# AGENT COMMAND GROUP
|
|
1163
|
+
# ==========================================
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
@cli.group(name="agent")
|
|
1167
|
+
def agent_group():
|
|
1168
|
+
"""Agent Operations"""
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
@agent_group.command()
|
|
1172
|
+
@click.argument("branch")
|
|
1173
|
+
@click.argument("task")
|
|
1174
|
+
@click.pass_context
|
|
1175
|
+
def dispatch(ctx, branch, task):
|
|
1176
|
+
"""
|
|
1177
|
+
Initialize a Jules session for a specific branch and task.
|
|
1178
|
+
Note: Use 'main' branch for PR consolidation tasks to avoid rebasing issues.
|
|
1179
|
+
"""
|
|
1180
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1181
|
+
res = orch.dispatch_jules_review(branch, task)
|
|
1182
|
+
out(ctx, f"✅ Dispatched task on branch {branch}", data=res)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
@agent_group.command()
|
|
1186
|
+
@limit_option(help_text="Limit the number of sessions to retrieve")
|
|
1187
|
+
@click.pass_context
|
|
1188
|
+
def sync(ctx, limit):
|
|
1189
|
+
"""Sync active agent sessions."""
|
|
1190
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1191
|
+
sessions = orch.jules.list_sessions(pageSize=limit)
|
|
1192
|
+
|
|
1193
|
+
if not ctx.obj["JSON"]:
|
|
1194
|
+
if not sessions:
|
|
1195
|
+
click.echo("No active agent sessions found.")
|
|
1196
|
+
else:
|
|
1197
|
+
click.echo(f"{'Session ID':<20} | {'Status':<15} | {'Created':<25}")
|
|
1198
|
+
click.echo("-" * 65)
|
|
1199
|
+
for s in sessions:
|
|
1200
|
+
sid = s.get("name", "N/A").split("/")[-1]
|
|
1201
|
+
state = s.get("state", "UNKNOWN")
|
|
1202
|
+
created = s.get("createTime", "N/A")
|
|
1203
|
+
click.echo(f"{sid:<20} | {state:<15} | {created:<25}")
|
|
1204
|
+
|
|
1205
|
+
out(ctx, "Agent sync complete.", data={"sessions": sessions})
|
|
1206
|
+
|
|
1207
|
+
@agent_group.command()
|
|
1208
|
+
@click.option("--pr-number", type=int)
|
|
1209
|
+
@click.option("--issue-number", type=int)
|
|
1210
|
+
@click.option("--branch")
|
|
1211
|
+
@click.option("--api-key")
|
|
1212
|
+
@click.option("--dry-run/--execute", default=True)
|
|
1213
|
+
@click.pass_context
|
|
1214
|
+
def fix_ci(ctx, pr_number, issue_number, branch, api_key, dry_run):
|
|
1215
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1216
|
+
res = orch.fix_ci(
|
|
1217
|
+
pr_number=pr_number,
|
|
1218
|
+
issue_number=issue_number,
|
|
1219
|
+
branch=branch,
|
|
1220
|
+
api_key=api_key,
|
|
1221
|
+
dry_run=dry_run,
|
|
1222
|
+
)
|
|
1223
|
+
agent_name = res.get("agent_name", "Jules")
|
|
1224
|
+
out(ctx, f"🚀 Initialized {agent_name} session for branch `{res['branch']}`", data=res)
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
@agent_group.command()
|
|
1228
|
+
@click.option("--log")
|
|
1229
|
+
@click.option("--file")
|
|
1230
|
+
@click.option("--pr", type=int)
|
|
1231
|
+
@click.pass_context
|
|
1232
|
+
def repair_context(ctx, log, file, pr):
|
|
1233
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1234
|
+
res = orch.repair_context(log=log, log_file=file, pr_number=pr)
|
|
1235
|
+
out(ctx, f"Generated {len(res)} prompts.", data={"prompts": res})
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
@agent_group.command()
|
|
1239
|
+
@click.option("--logs")
|
|
1240
|
+
@click.option("--stdin", is_flag=True)
|
|
1241
|
+
@click.option("--worktree", is_flag=True)
|
|
1242
|
+
@click.pass_context
|
|
1243
|
+
def repair(ctx, logs, stdin, worktree):
|
|
1244
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1245
|
+
res = orch.repair_local(logs_path=logs, stdin=stdin, worktree=worktree)
|
|
1246
|
+
if res["status"] == "success":
|
|
1247
|
+
out(ctx, res["message"], data=res)
|
|
1248
|
+
else:
|
|
1249
|
+
err(ctx, res["message"], data=res)
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
@agent_group.command()
|
|
1253
|
+
@click.argument("session_id")
|
|
1254
|
+
@click.pass_context
|
|
1255
|
+
def cancel(ctx, session_id):
|
|
1256
|
+
"""Cancel a Jules session."""
|
|
1257
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1258
|
+
res = orch.jules.cancel_session(session_id)
|
|
1259
|
+
out(ctx, f"✅ Session {session_id} cancelled", data=res)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
@agent_group.command()
|
|
1263
|
+
@click.argument("session_id")
|
|
1264
|
+
@click.pass_context
|
|
1265
|
+
def get_session(ctx, session_id):
|
|
1266
|
+
"""Get details of a Jules session."""
|
|
1267
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1268
|
+
session = orch.jules.get_session(session_id)
|
|
1269
|
+
out(ctx, f"Session {session_id} details retrieved", data={"session": session})
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
@agent_group.command()
|
|
1273
|
+
@click.argument("session_id")
|
|
1274
|
+
@click.pass_context
|
|
1275
|
+
def trigger_feedback(ctx, session_id):
|
|
1276
|
+
"""Trigger CI feedback for a Jules session."""
|
|
1277
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1278
|
+
res = orch.trigger_jules_feedback(session_id)
|
|
1279
|
+
out(ctx, "Feedback triggered", data=res)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
@agent_group.command()
|
|
1283
|
+
@click.argument("session_id")
|
|
1284
|
+
@click.pass_context
|
|
1285
|
+
def messages(ctx, session_id):
|
|
1286
|
+
"""Get message history for a Jules session."""
|
|
1287
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1288
|
+
msgs = orch.jules.get_messages(session_id)
|
|
1289
|
+
if not ctx.obj["JSON"]:
|
|
1290
|
+
if not msgs:
|
|
1291
|
+
click.echo(f"No messages found for session {session_id}")
|
|
1292
|
+
else:
|
|
1293
|
+
for m in msgs:
|
|
1294
|
+
role = m["role"].upper()
|
|
1295
|
+
click.echo(f"[{m['time']}] {role}:")
|
|
1296
|
+
click.echo(m["content"])
|
|
1297
|
+
click.echo("-" * 40)
|
|
1298
|
+
out(ctx, f"Messages retrieved for {session_id}", data={"messages": msgs})
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
@agent_group.command()
|
|
1302
|
+
@click.argument("session_ids")
|
|
1303
|
+
@click.argument("message")
|
|
1304
|
+
@click.pass_context
|
|
1305
|
+
def send(ctx, session_ids, message):
|
|
1306
|
+
"""Send a message to active Jules session(s) (supports comma-separated IDs)."""
|
|
1307
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1308
|
+
|
|
1309
|
+
# Support comma-separated IDs for batching
|
|
1310
|
+
target_ids = [s.strip() for s in session_ids.split(",")] if "," in session_ids else session_ids
|
|
1311
|
+
|
|
1312
|
+
from dev_tools.models import JulesSendMessageInput
|
|
1313
|
+
from pydantic import ValidationError
|
|
1314
|
+
|
|
1315
|
+
try:
|
|
1316
|
+
JulesSendMessageInput(sessionId=target_ids, message=message)
|
|
1317
|
+
except ValidationError as e:
|
|
1318
|
+
# Extract specific error message for better feedback
|
|
1319
|
+
msg = f"Validation failed: {e.errors()[0]['msg']}"
|
|
1320
|
+
err(ctx, msg, code=1)
|
|
1321
|
+
except Exception as e:
|
|
1322
|
+
_handle_unexpected_error(ctx, "agent send", e)
|
|
1323
|
+
|
|
1324
|
+
res = orch.jules.send_message(target_ids, message)
|
|
1325
|
+
|
|
1326
|
+
# Provide clear summary for batch operations
|
|
1327
|
+
summary = res.get("message", f"Message sent to session(s) {session_ids}")
|
|
1328
|
+
if not summary.startswith("✅"):
|
|
1329
|
+
summary = f"✅ {summary}"
|
|
1330
|
+
|
|
1331
|
+
out(ctx, summary, data=res)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
@agent_group.command(name="plan-review")
|
|
1335
|
+
@click.option("--pr", "pr_number", required=True, type=int, help="Pull Request number")
|
|
1336
|
+
@click.option("--issue", "issue_number", type=int, help="Issue number")
|
|
1337
|
+
@click.pass_context
|
|
1338
|
+
def plan_review(ctx, pr_number, issue_number):
|
|
1339
|
+
"""Generate a deterministic review workflow plan for an agent."""
|
|
1340
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1341
|
+
try:
|
|
1342
|
+
res = orch.generate_review_workflow(pr_number, issue_number)
|
|
1343
|
+
out(ctx, f"Workflow plan generated: {res['plan_path']}", data=res)
|
|
1344
|
+
except Exception as e:
|
|
1345
|
+
_handle_unexpected_error(ctx, "agent plan-review", e)
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
@agent_group.command(name="plan-aggregation")
|
|
1349
|
+
@click.option(
|
|
1350
|
+
"--pr",
|
|
1351
|
+
"--prs",
|
|
1352
|
+
"pr_numbers",
|
|
1353
|
+
required=True,
|
|
1354
|
+
type=int,
|
|
1355
|
+
multiple=True,
|
|
1356
|
+
help="PR numbers to aggregate",
|
|
1357
|
+
)
|
|
1358
|
+
@click.option("--target", required=True, help="Target branch name for aggregation")
|
|
1359
|
+
@click.pass_context
|
|
1360
|
+
def plan_aggregation(ctx, pr_numbers, target):
|
|
1361
|
+
"""Generate a deterministic aggregation workflow plan for an agent."""
|
|
1362
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1363
|
+
try:
|
|
1364
|
+
res = orch.generate_aggregation_workflow(list(pr_numbers), target)
|
|
1365
|
+
out(ctx, f"Workflow plan generated: {res['plan_path']}", data=res)
|
|
1366
|
+
except Exception as e:
|
|
1367
|
+
_handle_unexpected_error(ctx, "agent plan-aggregation", e)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
@agent_group.command(name="plan-issue-audit")
|
|
1371
|
+
@json_option
|
|
1372
|
+
@click.option(
|
|
1373
|
+
"--issue",
|
|
1374
|
+
"--issues",
|
|
1375
|
+
"issue_numbers",
|
|
1376
|
+
multiple=True,
|
|
1377
|
+
type=int,
|
|
1378
|
+
help="Issue number(s) to audit (e.g. --issue 1 --issue 2)",
|
|
1379
|
+
)
|
|
1380
|
+
@click.option("--all-open", is_flag=True, help="Audit all open issues")
|
|
1381
|
+
@limit_option(help_text="Limit the number of open issues to process")
|
|
1382
|
+
@click.pass_context
|
|
1383
|
+
def plan_issue_audit(ctx, issue_numbers, all_open, limit):
|
|
1384
|
+
"""Generate deterministic roadmap and checklist for auditing open issues."""
|
|
1385
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1386
|
+
try:
|
|
1387
|
+
res = orch.plan_issue_audit(issue_numbers=list(issue_numbers), all_open=all_open, limit=limit)
|
|
1388
|
+
out(ctx, f"Issue audit plan generated. Summary: {res['status_file']}", data=res)
|
|
1389
|
+
except Exception as e:
|
|
1390
|
+
_handle_unexpected_error(ctx, "agent plan-issue-audit", e)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
@agent_group.command(name="plan-workflow-audit")
|
|
1394
|
+
@click.option("--workflow", help="Specific workflow file to audit")
|
|
1395
|
+
@click.pass_context
|
|
1396
|
+
def plan_workflow_audit(ctx, workflow):
|
|
1397
|
+
"""Generate deterministic roadmap and checklist for auditing GitHub workflows."""
|
|
1398
|
+
orch = ctx.obj["ORCHESTRATOR"]
|
|
1399
|
+
try:
|
|
1400
|
+
res = orch.plan_workflow_audit(workflow=workflow)
|
|
1401
|
+
out(ctx, f"Workflow audit plan generated. Summary: {res['status_file']}", data=res)
|
|
1402
|
+
except Exception as e:
|
|
1403
|
+
_handle_unexpected_error(ctx, "agent plan-workflow-audit", e)
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
@agent_group.command(name="run-feedback-check")
|
|
1407
|
+
@limit_option(help_text="Limit the number of active sessions to check")
|
|
1408
|
+
@click.pass_context
|
|
1409
|
+
def run_feedback_check(ctx, limit):
|
|
1410
|
+
"""Run a one-shot Automated Agent Feedback Check to trigger CI feedback for active sessions."""
|
|
1411
|
+
try:
|
|
1412
|
+
from dev_tools.daemon import JulesFeedbackDaemon
|
|
1413
|
+
|
|
1414
|
+
daemon_instance = JulesFeedbackDaemon()
|
|
1415
|
+
daemon_instance.run(limit=limit)
|
|
1416
|
+
out(ctx, "Feedback check execution completed.", data={"status": "success"})
|
|
1417
|
+
except Exception as e:
|
|
1418
|
+
_handle_unexpected_error(ctx, "agent run-feedback-check", e)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
@cli.command(name="context-warm")
|
|
1422
|
+
@click.option("--force", is_flag=True, help="Force re-generation of context even if not stale")
|
|
1423
|
+
@click.pass_context
|
|
1424
|
+
def context_warm(ctx, force):
|
|
1425
|
+
"""Pre-warm the .agent-context.json repository index."""
|
|
1426
|
+
context_file = ".agent-context.json"
|
|
1427
|
+
|
|
1428
|
+
# Determine if the context file is stale or missing
|
|
1429
|
+
is_stale = not os.path.exists(context_file) or force
|
|
1430
|
+
|
|
1431
|
+
if not is_stale:
|
|
1432
|
+
try:
|
|
1433
|
+
# Compare the last commit timestamp of the context file with the HEAD commit
|
|
1434
|
+
# This handles branch switches where the working directory might be clean but context is old
|
|
1435
|
+
res = subprocess.run(
|
|
1436
|
+
["git", "log", "-1", "--format=%ct", "--", context_file],
|
|
1437
|
+
capture_output=True,
|
|
1438
|
+
text=True,
|
|
1439
|
+
check=True,
|
|
1440
|
+
)
|
|
1441
|
+
context_ts = int(res.stdout.strip() or 0)
|
|
1442
|
+
|
|
1443
|
+
res_head = subprocess.run(["git", "log", "-1", "--format=%ct"], capture_output=True, text=True, check=True)
|
|
1444
|
+
head_ts = int(res_head.stdout.strip() or 0)
|
|
1445
|
+
|
|
1446
|
+
if head_ts > context_ts:
|
|
1447
|
+
is_stale = True
|
|
1448
|
+
else:
|
|
1449
|
+
# Also check for uncommitted changes in the working directory
|
|
1450
|
+
res_diff = subprocess.run(
|
|
1451
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
1452
|
+
capture_output=True,
|
|
1453
|
+
text=True,
|
|
1454
|
+
check=True,
|
|
1455
|
+
)
|
|
1456
|
+
is_stale = bool(res_diff.stdout.strip())
|
|
1457
|
+
except Exception as e:
|
|
1458
|
+
log_warn(f"Error checking context staleness via git: {e}")
|
|
1459
|
+
# Fallback to simple mtime check if git fails
|
|
1460
|
+
mtime = os.path.getmtime(context_file)
|
|
1461
|
+
if os.path.exists("package.json") and os.path.getmtime("package.json") > mtime:
|
|
1462
|
+
is_stale = True
|
|
1463
|
+
|
|
1464
|
+
if is_stale:
|
|
1465
|
+
log_info("🧊 Context is stale or missing. Warming up...")
|
|
1466
|
+
try:
|
|
1467
|
+
# Use dynamic resolution from ProjectConfig for the context builder script
|
|
1468
|
+
script_path = PROJECT_CONFIG.context_builder_script
|
|
1469
|
+
if not os.path.exists(script_path):
|
|
1470
|
+
err(ctx, f"Context builder script not found at {script_path}")
|
|
1471
|
+
|
|
1472
|
+
with open(context_file, "w", encoding="utf-8") as f:
|
|
1473
|
+
try:
|
|
1474
|
+
subprocess.run([sys.executable, script_path], stdout=f, check=True)
|
|
1475
|
+
except subprocess.CalledProcessError as e:
|
|
1476
|
+
err(
|
|
1477
|
+
ctx,
|
|
1478
|
+
f"Context builder script failed (exit {e.returncode}): {e.stderr or str(e)}",
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
out(ctx, "✅ Context pre-warmed successfully.", data={"path": context_file})
|
|
1482
|
+
except Exception as e:
|
|
1483
|
+
_handle_unexpected_error(ctx, "context-warm", e)
|
|
1484
|
+
else:
|
|
1485
|
+
out(ctx, "✅ Context is up-to-date.", data={"path": context_file})
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
# Register aliases for backwards compatibility
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
@cli.group(name="jules")
|
|
1492
|
+
def jules_group():
|
|
1493
|
+
"""Agent Operations (alias for agent)"""
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
for group in [jules_group]:
|
|
1497
|
+
group.add_command(dispatch)
|
|
1498
|
+
group.add_command(sync)
|
|
1499
|
+
group.add_command(fix_ci)
|
|
1500
|
+
group.add_command(repair_context)
|
|
1501
|
+
group.add_command(repair)
|
|
1502
|
+
group.add_command(cancel)
|
|
1503
|
+
group.add_command(get_session)
|
|
1504
|
+
group.add_command(trigger_feedback)
|
|
1505
|
+
group.add_command(messages)
|
|
1506
|
+
group.add_command(send)
|
|
1507
|
+
group.add_command(plan_review)
|
|
1508
|
+
group.add_command(plan_aggregation)
|
|
1509
|
+
|
|
1510
|
+
for group in [agent_group, jules_group]:
|
|
1511
|
+
group.add_command(get_session, name="session")
|
|
1512
|
+
group.add_command(sync, name="list")
|
|
1513
|
+
group.add_command(sync, name="list-sessions")
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def main():
|
|
1517
|
+
# click entry point automatically handles sys.argv
|
|
1518
|
+
try:
|
|
1519
|
+
cli(obj={})
|
|
1520
|
+
except Exception as e:
|
|
1521
|
+
# If we are in JSON mode, we should ideally output JSON error.
|
|
1522
|
+
# Detecting JSON mode from sys.argv since click context isn't available here yet if it failed early.
|
|
1523
|
+
# Note: {PROJECT_CONFIG.cli_alias} subcommands are JSON by default.
|
|
1524
|
+
is_json = "--no-json" not in sys.argv
|
|
1525
|
+
|
|
1526
|
+
if is_json:
|
|
1527
|
+
error_payload = {"status": "error", "message": str(e), "type": e.__class__.__name__}
|
|
1528
|
+
# CLIError and some others might have a custom 'code' attribute
|
|
1529
|
+
code = getattr(e, "code", 1)
|
|
1530
|
+
error_payload["code"] = code
|
|
1531
|
+
# JSON errors remain on stdout to maintain the contract for piped machine consumers
|
|
1532
|
+
# (e.g. boomtick-mcp) which may discard stderr via 2>/dev/null.
|
|
1533
|
+
print(json.dumps(error_payload, indent=2))
|
|
1534
|
+
else:
|
|
1535
|
+
log_error(str(e))
|
|
1536
|
+
code = getattr(e, "code", 1)
|
|
1537
|
+
|
|
1538
|
+
sys.exit(code)
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
if __name__ == "__main__":
|
|
1542
|
+
main()
|