systemlink-cli 1.3.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.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/workitem_click.py
ADDED
|
@@ -0,0 +1,2258 @@
|
|
|
1
|
+
"""CLI commands for managing SystemLink work items, templates, and workflows."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from . import workflow_preview
|
|
15
|
+
from .cli_utils import validate_output_format
|
|
16
|
+
from .platform import require_feature
|
|
17
|
+
from .universal_handlers import FilteredResponse, UniversalResponseHandler
|
|
18
|
+
from .utils import (
|
|
19
|
+
ExitCodes,
|
|
20
|
+
display_api_errors,
|
|
21
|
+
extract_error_type,
|
|
22
|
+
format_success,
|
|
23
|
+
get_base_url,
|
|
24
|
+
get_workspace_id_with_fallback,
|
|
25
|
+
get_workspace_map,
|
|
26
|
+
handle_api_error,
|
|
27
|
+
load_json_file,
|
|
28
|
+
make_api_request,
|
|
29
|
+
sanitize_filename,
|
|
30
|
+
save_json_file,
|
|
31
|
+
)
|
|
32
|
+
from .workspace_utils import (
|
|
33
|
+
get_effective_workspace,
|
|
34
|
+
get_workspace_display_name,
|
|
35
|
+
resolve_workspace_filter,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# URL helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _wi_url(path: str) -> str:
|
|
45
|
+
"""Return a fully-qualified niworkitem v1 URL."""
|
|
46
|
+
return f"{get_base_url()}/niworkitem/v1{path}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _wf_url(path: str) -> str:
|
|
50
|
+
"""Return a fully-qualified niworkorder v1 URL with feature flag."""
|
|
51
|
+
return f"{get_base_url()}/niworkorder/v1{path}?ff-userdefinedworkflowsfortestplaninstances=true"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Pagination helpers
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _query_all_workitems(
|
|
60
|
+
filter_expr: Optional[str] = None,
|
|
61
|
+
substitutions: Optional[List[Any]] = None,
|
|
62
|
+
workspace_filter: Optional[str] = None,
|
|
63
|
+
max_items: Optional[int] = None,
|
|
64
|
+
) -> List[Dict[str, Any]]:
|
|
65
|
+
"""Fetch work items via continuation-token pagination.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
filter_expr: Optional LINQ filter expression.
|
|
69
|
+
substitutions: Optional substitution list for the filter expression.
|
|
70
|
+
workspace_filter: Optional workspace ID to restrict results.
|
|
71
|
+
max_items: Maximum number of items to return. ``None`` means fetch
|
|
72
|
+
all. Used to guard against buggy continuation tokens that are
|
|
73
|
+
returned even when the requested take has been satisfied.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of up to *max_items* matching work items.
|
|
77
|
+
"""
|
|
78
|
+
url = _wi_url("/query-workitems")
|
|
79
|
+
all_items: List[Dict[str, Any]] = []
|
|
80
|
+
continuation_token: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
while True:
|
|
83
|
+
payload: Dict[str, Any] = {"take": 100}
|
|
84
|
+
combined_filter_parts: List[str] = []
|
|
85
|
+
combined_subs: List[Any] = []
|
|
86
|
+
|
|
87
|
+
if filter_expr:
|
|
88
|
+
combined_filter_parts.append(f"({filter_expr})")
|
|
89
|
+
combined_subs.extend(substitutions or [])
|
|
90
|
+
|
|
91
|
+
if workspace_filter:
|
|
92
|
+
idx = len(combined_subs)
|
|
93
|
+
combined_filter_parts.append(f"workspace == @{idx}")
|
|
94
|
+
combined_subs.append(workspace_filter)
|
|
95
|
+
|
|
96
|
+
if combined_filter_parts:
|
|
97
|
+
payload["filter"] = " && ".join(combined_filter_parts)
|
|
98
|
+
if combined_subs:
|
|
99
|
+
payload["substitutions"] = combined_subs
|
|
100
|
+
|
|
101
|
+
if continuation_token:
|
|
102
|
+
payload["continuationToken"] = continuation_token
|
|
103
|
+
|
|
104
|
+
resp = make_api_request("POST", url, payload)
|
|
105
|
+
data = resp.json()
|
|
106
|
+
|
|
107
|
+
page = data.get("workItems", [])
|
|
108
|
+
if not page:
|
|
109
|
+
# Empty page — no more real results regardless of token
|
|
110
|
+
break
|
|
111
|
+
all_items.extend(page)
|
|
112
|
+
|
|
113
|
+
# Honour the caller's requested limit; guards against a server-side
|
|
114
|
+
# bug where a continuation token is returned even after all matching
|
|
115
|
+
# items have been delivered.
|
|
116
|
+
if max_items is not None and len(all_items) >= max_items:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
continuation_token = data.get("continuationToken")
|
|
120
|
+
if not continuation_token:
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
if max_items is not None:
|
|
124
|
+
return all_items[:max_items]
|
|
125
|
+
return all_items
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _query_all_templates(
|
|
129
|
+
filter_expr: Optional[str] = None,
|
|
130
|
+
substitutions: Optional[List[Any]] = None,
|
|
131
|
+
workspace_filter: Optional[str] = None,
|
|
132
|
+
max_items: Optional[int] = None,
|
|
133
|
+
) -> List[Dict[str, Any]]:
|
|
134
|
+
"""Fetch work item templates via continuation-token pagination.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
filter_expr: Optional LINQ filter expression.
|
|
138
|
+
substitutions: Optional substitution list for the filter expression.
|
|
139
|
+
workspace_filter: Optional workspace ID to restrict results.
|
|
140
|
+
max_items: Maximum number of items to return. ``None`` means fetch
|
|
141
|
+
all. Used to guard against buggy continuation tokens that are
|
|
142
|
+
returned even when the requested take has been satisfied.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of up to *max_items* matching templates.
|
|
146
|
+
"""
|
|
147
|
+
url = _wi_url("/query-workitem-templates")
|
|
148
|
+
all_items: List[Dict[str, Any]] = []
|
|
149
|
+
continuation_token: Optional[str] = None
|
|
150
|
+
|
|
151
|
+
while True:
|
|
152
|
+
payload: Dict[str, Any] = {"take": 100}
|
|
153
|
+
combined_filter_parts: List[str] = []
|
|
154
|
+
combined_subs: List[Any] = []
|
|
155
|
+
|
|
156
|
+
if filter_expr:
|
|
157
|
+
combined_filter_parts.append(f"({filter_expr})")
|
|
158
|
+
combined_subs.extend(substitutions or [])
|
|
159
|
+
|
|
160
|
+
if workspace_filter:
|
|
161
|
+
idx = len(combined_subs)
|
|
162
|
+
combined_filter_parts.append(f"workspace == @{idx}")
|
|
163
|
+
combined_subs.append(workspace_filter)
|
|
164
|
+
|
|
165
|
+
if combined_filter_parts:
|
|
166
|
+
payload["filter"] = " && ".join(combined_filter_parts)
|
|
167
|
+
if combined_subs:
|
|
168
|
+
payload["substitutions"] = combined_subs
|
|
169
|
+
|
|
170
|
+
if continuation_token:
|
|
171
|
+
payload["continuationToken"] = continuation_token
|
|
172
|
+
|
|
173
|
+
resp = make_api_request("POST", url, payload)
|
|
174
|
+
data = resp.json()
|
|
175
|
+
|
|
176
|
+
page = data.get("workItemTemplates", [])
|
|
177
|
+
if not page:
|
|
178
|
+
break
|
|
179
|
+
all_items.extend(page)
|
|
180
|
+
|
|
181
|
+
if max_items is not None and len(all_items) >= max_items:
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
continuation_token = data.get("continuationToken")
|
|
185
|
+
if not continuation_token:
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
if max_items is not None:
|
|
189
|
+
return all_items[:max_items]
|
|
190
|
+
return all_items
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _query_all_workflows(
|
|
194
|
+
workspace_filter: Optional[str] = None,
|
|
195
|
+
max_items: Optional[int] = None,
|
|
196
|
+
) -> List[Dict[str, Any]]:
|
|
197
|
+
"""Fetch workflows via continuation-token pagination (niworkorder API).
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
workspace_filter: Optional workspace ID to filter by.
|
|
201
|
+
max_items: Maximum number of workflows to return. ``None`` means
|
|
202
|
+
fetch all. Used to guard against buggy continuation tokens that
|
|
203
|
+
are returned even when the requested take has been satisfied.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
List of up to *max_items* workflows.
|
|
207
|
+
"""
|
|
208
|
+
url = _wf_url("/query-workflows")
|
|
209
|
+
all_workflows: List[Dict[str, Any]] = []
|
|
210
|
+
continuation_token: Optional[str] = None
|
|
211
|
+
|
|
212
|
+
while True:
|
|
213
|
+
payload: Dict[str, Union[int, str]] = {"take": 100}
|
|
214
|
+
if workspace_filter:
|
|
215
|
+
payload["filter"] = f'WORKSPACE == "{workspace_filter}"'
|
|
216
|
+
if continuation_token:
|
|
217
|
+
payload["continuationToken"] = continuation_token
|
|
218
|
+
|
|
219
|
+
resp = make_api_request("POST", url, payload)
|
|
220
|
+
data = resp.json()
|
|
221
|
+
|
|
222
|
+
page = data.get("workflows", [])
|
|
223
|
+
if not page:
|
|
224
|
+
break
|
|
225
|
+
all_workflows.extend(page)
|
|
226
|
+
|
|
227
|
+
if max_items is not None and len(all_workflows) >= max_items:
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
continuation_token = data.get("continuationToken")
|
|
231
|
+
if not continuation_token:
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
if max_items is not None:
|
|
235
|
+
return all_workflows[:max_items]
|
|
236
|
+
return all_workflows
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Error-handling helpers
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _fetch_workitems_page(
|
|
245
|
+
filter_expr: Optional[str],
|
|
246
|
+
substitutions: Optional[List[Any]],
|
|
247
|
+
workspace_filter: Optional[str],
|
|
248
|
+
take: int,
|
|
249
|
+
continuation_token: Optional[str],
|
|
250
|
+
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
251
|
+
"""Fetch a single page of work items from the server.
|
|
252
|
+
|
|
253
|
+
Guards against a known service bug where a continuation token is returned
|
|
254
|
+
even after all matching items have been delivered: if the page is smaller
|
|
255
|
+
than *take* the returned token (if any) is discarded.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Tuple of (items, next_continuation_token).
|
|
259
|
+
"""
|
|
260
|
+
url = _wi_url("/query-workitems")
|
|
261
|
+
payload: Dict[str, Any] = {"take": take}
|
|
262
|
+
combined_filter_parts: List[str] = []
|
|
263
|
+
combined_subs: List[Any] = []
|
|
264
|
+
|
|
265
|
+
if filter_expr:
|
|
266
|
+
combined_filter_parts.append(f"({filter_expr})")
|
|
267
|
+
combined_subs.extend(substitutions or [])
|
|
268
|
+
|
|
269
|
+
if workspace_filter:
|
|
270
|
+
idx = len(combined_subs)
|
|
271
|
+
combined_filter_parts.append(f"workspace == @{idx}")
|
|
272
|
+
combined_subs.append(workspace_filter)
|
|
273
|
+
|
|
274
|
+
if combined_filter_parts:
|
|
275
|
+
payload["filter"] = " && ".join(combined_filter_parts)
|
|
276
|
+
if combined_subs:
|
|
277
|
+
payload["substitutions"] = combined_subs
|
|
278
|
+
|
|
279
|
+
if continuation_token:
|
|
280
|
+
payload["continuationToken"] = continuation_token
|
|
281
|
+
|
|
282
|
+
resp = make_api_request("POST", url, payload)
|
|
283
|
+
data = resp.json()
|
|
284
|
+
items = data.get("workItems", [])
|
|
285
|
+
|
|
286
|
+
# Only trust the continuation token when a full page was returned.
|
|
287
|
+
# Fewer items than requested means the server has nothing left, even if
|
|
288
|
+
# it (incorrectly) still emits a token.
|
|
289
|
+
next_token: Optional[str] = data.get("continuationToken") if len(items) >= take else None
|
|
290
|
+
return items, next_token
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _fetch_templates_page(
|
|
294
|
+
filter_expr: Optional[str],
|
|
295
|
+
substitutions: Optional[List[Any]],
|
|
296
|
+
workspace_filter: Optional[str],
|
|
297
|
+
take: int,
|
|
298
|
+
continuation_token: Optional[str],
|
|
299
|
+
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
300
|
+
"""Fetch a single page of work item templates from the server.
|
|
301
|
+
|
|
302
|
+
See :func:`_fetch_workitems_page` for the stale-token guard rationale.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Tuple of (items, next_continuation_token).
|
|
306
|
+
"""
|
|
307
|
+
url = _wi_url("/query-workitem-templates")
|
|
308
|
+
payload: Dict[str, Any] = {"take": take}
|
|
309
|
+
combined_filter_parts: List[str] = []
|
|
310
|
+
combined_subs: List[Any] = []
|
|
311
|
+
|
|
312
|
+
if filter_expr:
|
|
313
|
+
combined_filter_parts.append(f"({filter_expr})")
|
|
314
|
+
combined_subs.extend(substitutions or [])
|
|
315
|
+
|
|
316
|
+
if workspace_filter:
|
|
317
|
+
idx = len(combined_subs)
|
|
318
|
+
combined_filter_parts.append(f"workspace == @{idx}")
|
|
319
|
+
combined_subs.append(workspace_filter)
|
|
320
|
+
|
|
321
|
+
if combined_filter_parts:
|
|
322
|
+
payload["filter"] = " && ".join(combined_filter_parts)
|
|
323
|
+
if combined_subs:
|
|
324
|
+
payload["substitutions"] = combined_subs
|
|
325
|
+
|
|
326
|
+
if continuation_token:
|
|
327
|
+
payload["continuationToken"] = continuation_token
|
|
328
|
+
|
|
329
|
+
resp = make_api_request("POST", url, payload)
|
|
330
|
+
data = resp.json()
|
|
331
|
+
items = data.get("workItemTemplates", [])
|
|
332
|
+
|
|
333
|
+
next_token: Optional[str] = data.get("continuationToken") if len(items) >= take else None
|
|
334
|
+
return items, next_token
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fetch_workflows_page(
|
|
338
|
+
workspace_filter: Optional[str],
|
|
339
|
+
take: int,
|
|
340
|
+
continuation_token: Optional[str],
|
|
341
|
+
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
342
|
+
"""Fetch a single page of workflows from the server (niworkorder API).
|
|
343
|
+
|
|
344
|
+
See :func:`_fetch_workitems_page` for the stale-token guard rationale.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Tuple of (items, next_continuation_token).
|
|
348
|
+
"""
|
|
349
|
+
url = _wf_url("/query-workflows")
|
|
350
|
+
payload: Dict[str, Union[int, str]] = {"take": take}
|
|
351
|
+
if workspace_filter:
|
|
352
|
+
payload["filter"] = f'WORKSPACE == "{workspace_filter}"'
|
|
353
|
+
if continuation_token:
|
|
354
|
+
payload["continuationToken"] = continuation_token
|
|
355
|
+
|
|
356
|
+
resp = make_api_request("POST", url, payload)
|
|
357
|
+
data = resp.json()
|
|
358
|
+
items = data.get("workflows", [])
|
|
359
|
+
|
|
360
|
+
next_token: Optional[str] = data.get("continuationToken") if len(items) >= take else None
|
|
361
|
+
return items, next_token
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _handle_workflow_error_response(response_data: Dict[str, Any], operation_name: str) -> None:
|
|
365
|
+
"""Display detailed workflow error responses.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
response_data: JSON response data containing error information.
|
|
369
|
+
operation_name: Description of the failed operation.
|
|
370
|
+
"""
|
|
371
|
+
display_api_errors(operation_name, response_data, detailed=True)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _handle_workflow_delete_response(response_data: Dict[str, Any], workflow_id: str) -> None:
|
|
375
|
+
"""Handle workflow delete response, supporting partial success.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
response_data: JSON response data from the delete operation.
|
|
379
|
+
workflow_id: ID of the workflow that was requested to be deleted.
|
|
380
|
+
"""
|
|
381
|
+
if not response_data or response_data == {}:
|
|
382
|
+
click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
if "ids" in response_data:
|
|
386
|
+
deleted_ids = response_data.get("ids", [])
|
|
387
|
+
if workflow_id in deleted_ids:
|
|
388
|
+
click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
|
|
389
|
+
return
|
|
390
|
+
click.echo(f"✗ Unexpected response for workflow {workflow_id}:", err=True)
|
|
391
|
+
click.echo(f" Successfully deleted: {', '.join(deleted_ids)}", err=True)
|
|
392
|
+
sys.exit(1)
|
|
393
|
+
|
|
394
|
+
deleted_ids = response_data.get("deletedWorkflowIds", [])
|
|
395
|
+
failed_ids = response_data.get("failedWorkflowIds", [])
|
|
396
|
+
|
|
397
|
+
if workflow_id in deleted_ids:
|
|
398
|
+
click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
if workflow_id in failed_ids:
|
|
402
|
+
click.echo(f"✗ Failed to delete workflow {workflow_id}:", err=True)
|
|
403
|
+
error = response_data.get("error", {})
|
|
404
|
+
if error:
|
|
405
|
+
click.echo(f" {error.get('message', 'Unknown error')}", err=True)
|
|
406
|
+
for inner_error in error.get("innerErrors", []):
|
|
407
|
+
if inner_error.get("resourceId", "") == workflow_id:
|
|
408
|
+
msg = inner_error.get("message", "Unknown error")
|
|
409
|
+
name = inner_error.get("name", "")
|
|
410
|
+
if name:
|
|
411
|
+
click.echo(f" - {extract_error_type(name)}: {msg}", err=True)
|
|
412
|
+
else:
|
|
413
|
+
click.echo(f" - {msg}", err=True)
|
|
414
|
+
sys.exit(1)
|
|
415
|
+
|
|
416
|
+
click.echo(f"✗ Unexpected response for workflow {workflow_id}:", err=True)
|
|
417
|
+
if deleted_ids:
|
|
418
|
+
click.echo(f" Successfully deleted: {', '.join(deleted_ids)}", err=True)
|
|
419
|
+
if failed_ids:
|
|
420
|
+
click.echo(f" Failed to delete: {', '.join(failed_ids)}", err=True)
|
|
421
|
+
if failed_ids or not response_data:
|
|
422
|
+
sys.exit(1)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
# Main registration function
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def register_workitem_commands(cli: Any) -> None:
|
|
431
|
+
"""Register the 'workitem' command group and all subcommands."""
|
|
432
|
+
|
|
433
|
+
@cli.group()
|
|
434
|
+
def workitem() -> None:
|
|
435
|
+
"""Manage work items, templates, and workflows."""
|
|
436
|
+
|
|
437
|
+
# -----------------------------------------------------------------------
|
|
438
|
+
# workitem list
|
|
439
|
+
# -----------------------------------------------------------------------
|
|
440
|
+
@workitem.command(name="list")
|
|
441
|
+
@click.option(
|
|
442
|
+
"--format",
|
|
443
|
+
"-f",
|
|
444
|
+
type=click.Choice(["table", "json"]),
|
|
445
|
+
default="table",
|
|
446
|
+
show_default=True,
|
|
447
|
+
help="Output format",
|
|
448
|
+
)
|
|
449
|
+
@click.option(
|
|
450
|
+
"--take",
|
|
451
|
+
"-t",
|
|
452
|
+
type=int,
|
|
453
|
+
default=25,
|
|
454
|
+
show_default=True,
|
|
455
|
+
help="Maximum number of work items to return (table) or fetch (json)",
|
|
456
|
+
)
|
|
457
|
+
@click.option(
|
|
458
|
+
"--filter",
|
|
459
|
+
"filter_expr",
|
|
460
|
+
default=None,
|
|
461
|
+
help="Dynamic LINQ filter expression (e.g. 'state == \"NEW\"')",
|
|
462
|
+
)
|
|
463
|
+
@click.option(
|
|
464
|
+
"--state",
|
|
465
|
+
"-s",
|
|
466
|
+
default=None,
|
|
467
|
+
help=(
|
|
468
|
+
"Filter by state: NEW, DEFINED, REVIEWED, SCHEDULED, "
|
|
469
|
+
"IN_PROGRESS, PENDING_APPROVAL, CLOSED, CANCELED"
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
@click.option(
|
|
473
|
+
"--workspace",
|
|
474
|
+
"-w",
|
|
475
|
+
default=None,
|
|
476
|
+
help="Filter by workspace name or ID",
|
|
477
|
+
)
|
|
478
|
+
def list_workitems(
|
|
479
|
+
format: str,
|
|
480
|
+
take: int,
|
|
481
|
+
filter_expr: Optional[str],
|
|
482
|
+
state: Optional[str],
|
|
483
|
+
workspace: Optional[str],
|
|
484
|
+
) -> None:
|
|
485
|
+
"""List work items."""
|
|
486
|
+
format_output = validate_output_format(format)
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
workspace_map = get_workspace_map()
|
|
490
|
+
workspace_id = None
|
|
491
|
+
workspace = get_effective_workspace(workspace)
|
|
492
|
+
if workspace:
|
|
493
|
+
workspace_id = resolve_workspace_filter(workspace, workspace_map)
|
|
494
|
+
|
|
495
|
+
# Build final filter, combining --filter and --state
|
|
496
|
+
final_filter: Optional[str] = None
|
|
497
|
+
subs: List[Any] = []
|
|
498
|
+
parts: List[str] = []
|
|
499
|
+
|
|
500
|
+
if state:
|
|
501
|
+
parts.append(f"state == @{len(subs)}")
|
|
502
|
+
subs.append(state.upper())
|
|
503
|
+
|
|
504
|
+
if filter_expr:
|
|
505
|
+
# Offset substitution indices from user-provided filter
|
|
506
|
+
import re
|
|
507
|
+
|
|
508
|
+
def _offset(m: Any) -> str:
|
|
509
|
+
return f"@{int(m.group(1)) + len(subs)}"
|
|
510
|
+
|
|
511
|
+
user_filter = re.sub(r"@(\d+)", _offset, filter_expr)
|
|
512
|
+
parts.append(f"({user_filter})")
|
|
513
|
+
|
|
514
|
+
if parts:
|
|
515
|
+
final_filter = " && ".join(parts)
|
|
516
|
+
|
|
517
|
+
if format_output == "json":
|
|
518
|
+
items = _query_all_workitems(
|
|
519
|
+
final_filter, subs or None, workspace_id, max_items=take
|
|
520
|
+
)
|
|
521
|
+
click.echo(json.dumps(items, indent=2))
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
def _fmt(item: Dict[str, Any]) -> list:
|
|
525
|
+
ws_name = get_workspace_display_name(item.get("workspace", ""), workspace_map)
|
|
526
|
+
assigned = item.get("assignedTo", "") or ""
|
|
527
|
+
# Shorten UUID to 8 chars
|
|
528
|
+
if len(assigned) > 8:
|
|
529
|
+
assigned = assigned[:8] + "…"
|
|
530
|
+
return [
|
|
531
|
+
item.get("id", ""),
|
|
532
|
+
(item.get("name", "") or "")[:35],
|
|
533
|
+
item.get("type", "") or "",
|
|
534
|
+
item.get("state", "") or "",
|
|
535
|
+
assigned,
|
|
536
|
+
ws_name[:20],
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
# Table: server-side pagination — fetch exactly `take` items per request
|
|
540
|
+
cont: Optional[str] = None
|
|
541
|
+
displayed = 0
|
|
542
|
+
while True:
|
|
543
|
+
page, cont = _fetch_workitems_page(
|
|
544
|
+
final_filter, subs or None, workspace_id, take, cont
|
|
545
|
+
)
|
|
546
|
+
if not page:
|
|
547
|
+
if displayed == 0:
|
|
548
|
+
click.echo("No work items found.")
|
|
549
|
+
break
|
|
550
|
+
displayed += len(page)
|
|
551
|
+
resp: Any = FilteredResponse({"workItems": page})
|
|
552
|
+
UniversalResponseHandler.handle_list_response(
|
|
553
|
+
resp=resp,
|
|
554
|
+
data_key="workItems",
|
|
555
|
+
item_name="work item",
|
|
556
|
+
format_output=format_output,
|
|
557
|
+
formatter_func=_fmt,
|
|
558
|
+
headers=["ID", "Name", "Type", "State", "Assigned To", "Workspace"],
|
|
559
|
+
column_widths=[12, 36, 16, 18, 10, 21],
|
|
560
|
+
empty_message="No work items found.",
|
|
561
|
+
enable_pagination=False,
|
|
562
|
+
)
|
|
563
|
+
if not cont:
|
|
564
|
+
break
|
|
565
|
+
click.echo(f"\nShowing {displayed} work item(s). More may be available.")
|
|
566
|
+
if not click.confirm(f"Show next {take} results?", default=True):
|
|
567
|
+
break
|
|
568
|
+
|
|
569
|
+
except Exception as exc:
|
|
570
|
+
handle_api_error(exc)
|
|
571
|
+
|
|
572
|
+
# -----------------------------------------------------------------------
|
|
573
|
+
# workitem get
|
|
574
|
+
# -----------------------------------------------------------------------
|
|
575
|
+
@workitem.command(name="get")
|
|
576
|
+
@click.argument("work_item_id")
|
|
577
|
+
@click.option(
|
|
578
|
+
"--format",
|
|
579
|
+
"-f",
|
|
580
|
+
type=click.Choice(["table", "json"]),
|
|
581
|
+
default="table",
|
|
582
|
+
show_default=True,
|
|
583
|
+
help="Output format",
|
|
584
|
+
)
|
|
585
|
+
def get_workitem(work_item_id: str, format: str) -> None:
|
|
586
|
+
"""Get details for a work item by ID."""
|
|
587
|
+
format_output = validate_output_format(format)
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
url = _wi_url(f"/workitems/{work_item_id}")
|
|
591
|
+
resp = make_api_request("GET", url)
|
|
592
|
+
item: Dict[str, Any] = resp.json()
|
|
593
|
+
|
|
594
|
+
if not item:
|
|
595
|
+
click.echo(f"✗ Work item '{work_item_id}' not found.", err=True)
|
|
596
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
597
|
+
|
|
598
|
+
if format_output == "json":
|
|
599
|
+
click.echo(json.dumps(item, indent=2))
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
workspace_map = get_workspace_map()
|
|
603
|
+
ws_name = get_workspace_display_name(item.get("workspace", ""), workspace_map)
|
|
604
|
+
|
|
605
|
+
click.echo("Work Item Details:")
|
|
606
|
+
click.echo("=" * 50)
|
|
607
|
+
click.echo(f"ID: {item.get('id', 'N/A')}")
|
|
608
|
+
click.echo(f"Name: {item.get('name', 'N/A')}")
|
|
609
|
+
click.echo(f"Type: {item.get('type', 'N/A')}")
|
|
610
|
+
click.echo(f"State: {item.get('state', 'N/A')}")
|
|
611
|
+
click.echo(f"Substate: {item.get('substate', 'N/A')}")
|
|
612
|
+
click.echo(f"Description: {item.get('description', 'N/A')}")
|
|
613
|
+
click.echo(f"Assigned To: {item.get('assignedTo', 'N/A')}")
|
|
614
|
+
click.echo(f"Requested By: {item.get('requestedBy', 'N/A')}")
|
|
615
|
+
click.echo(f"Part Number: {item.get('partNumber', 'N/A')}")
|
|
616
|
+
click.echo(f"Test Program: {item.get('testProgram', 'N/A')}")
|
|
617
|
+
click.echo(f"Workspace: {ws_name}")
|
|
618
|
+
click.echo(f"Workflow ID: {item.get('workflowId', 'N/A')}")
|
|
619
|
+
click.echo(f"Created At: {item.get('createdAt', 'N/A')}")
|
|
620
|
+
click.echo(f"Updated At: {item.get('updatedAt', 'N/A')}")
|
|
621
|
+
|
|
622
|
+
if item.get("properties"):
|
|
623
|
+
click.echo("Properties:")
|
|
624
|
+
for k, v in item["properties"].items():
|
|
625
|
+
click.echo(f" {k}: {v}")
|
|
626
|
+
|
|
627
|
+
except Exception as exc:
|
|
628
|
+
handle_api_error(exc)
|
|
629
|
+
|
|
630
|
+
# -----------------------------------------------------------------------
|
|
631
|
+
# workitem create
|
|
632
|
+
# -----------------------------------------------------------------------
|
|
633
|
+
@workitem.command(name="create")
|
|
634
|
+
@click.option("--name", "-n", default=None, help="Work item name")
|
|
635
|
+
@click.option("--type", "wi_type", default=None, help="Work item type (e.g. testplan)")
|
|
636
|
+
@click.option(
|
|
637
|
+
"--state",
|
|
638
|
+
"-s",
|
|
639
|
+
default=None,
|
|
640
|
+
help="Initial state (e.g. NEW, DEFINED)",
|
|
641
|
+
)
|
|
642
|
+
@click.option("--description", "-d", default=None, help="Work item description")
|
|
643
|
+
@click.option("--assigned-to", default=None, help="User ID to assign the work item to")
|
|
644
|
+
@click.option(
|
|
645
|
+
"--workflow-id",
|
|
646
|
+
default=None,
|
|
647
|
+
help="ID of the workflow to associate with this work item",
|
|
648
|
+
)
|
|
649
|
+
@click.option("--workspace", "-w", default=None, help="Workspace name or ID")
|
|
650
|
+
@click.option(
|
|
651
|
+
"--part-number",
|
|
652
|
+
default=None,
|
|
653
|
+
help="Part number to associate with this work item",
|
|
654
|
+
)
|
|
655
|
+
@click.option(
|
|
656
|
+
"--file",
|
|
657
|
+
"-F",
|
|
658
|
+
"input_file",
|
|
659
|
+
default=None,
|
|
660
|
+
help=(
|
|
661
|
+
"JSON file with full CreateWorkItemRequest body "
|
|
662
|
+
"(field options override values in file)"
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
@click.option(
|
|
666
|
+
"--format",
|
|
667
|
+
"-f",
|
|
668
|
+
type=click.Choice(["table", "json"]),
|
|
669
|
+
default="table",
|
|
670
|
+
show_default=True,
|
|
671
|
+
help="Output format",
|
|
672
|
+
)
|
|
673
|
+
def create_workitem(
|
|
674
|
+
name: Optional[str],
|
|
675
|
+
wi_type: Optional[str],
|
|
676
|
+
state: Optional[str],
|
|
677
|
+
description: Optional[str],
|
|
678
|
+
assigned_to: Optional[str],
|
|
679
|
+
workflow_id: Optional[str],
|
|
680
|
+
workspace: Optional[str],
|
|
681
|
+
part_number: Optional[str],
|
|
682
|
+
input_file: Optional[str],
|
|
683
|
+
format: str,
|
|
684
|
+
) -> None:
|
|
685
|
+
"""Create a new work item."""
|
|
686
|
+
from .utils import check_readonly_mode
|
|
687
|
+
|
|
688
|
+
check_readonly_mode("create a work item")
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
# Load from file if provided, then overlay CLI options
|
|
692
|
+
wi_data: Dict[str, Any] = {}
|
|
693
|
+
if input_file:
|
|
694
|
+
wi_data = load_json_file(input_file)
|
|
695
|
+
|
|
696
|
+
if name is not None:
|
|
697
|
+
wi_data["name"] = name
|
|
698
|
+
if wi_type is not None:
|
|
699
|
+
wi_data["type"] = wi_type
|
|
700
|
+
if state is not None:
|
|
701
|
+
wi_data["state"] = state
|
|
702
|
+
if description is not None:
|
|
703
|
+
wi_data["description"] = description
|
|
704
|
+
if assigned_to is not None:
|
|
705
|
+
wi_data["assignedTo"] = assigned_to
|
|
706
|
+
if workflow_id is not None:
|
|
707
|
+
wi_data["workflowId"] = workflow_id
|
|
708
|
+
if workspace is None:
|
|
709
|
+
workspace = get_effective_workspace(workspace)
|
|
710
|
+
if workspace is not None:
|
|
711
|
+
ws_id = get_workspace_id_with_fallback(workspace)
|
|
712
|
+
wi_data["workspace"] = ws_id
|
|
713
|
+
if part_number is not None:
|
|
714
|
+
wi_data["partNumber"] = part_number
|
|
715
|
+
|
|
716
|
+
url = _wi_url("/workitems")
|
|
717
|
+
payload = {"workItems": [wi_data]}
|
|
718
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
719
|
+
data = resp.json()
|
|
720
|
+
|
|
721
|
+
if resp.status_code in (200, 201):
|
|
722
|
+
created = data.get("createdWorkItems", [])
|
|
723
|
+
if created:
|
|
724
|
+
item = created[0]
|
|
725
|
+
if format == "json":
|
|
726
|
+
click.echo(json.dumps(item, indent=2))
|
|
727
|
+
else:
|
|
728
|
+
format_success(
|
|
729
|
+
"Work item created",
|
|
730
|
+
{"id": item.get("id", ""), "name": item.get("name", "")},
|
|
731
|
+
)
|
|
732
|
+
else:
|
|
733
|
+
# Partial success - check failedWorkItems
|
|
734
|
+
failed = data.get("failedWorkItems", [])
|
|
735
|
+
if failed:
|
|
736
|
+
click.echo("✗ Failed to create work item.", err=True)
|
|
737
|
+
display_api_errors("Work item creation failed", data, detailed=True)
|
|
738
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
739
|
+
click.echo("✓ Work item created.")
|
|
740
|
+
else:
|
|
741
|
+
display_api_errors("Work item creation failed", data, detailed=True)
|
|
742
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
743
|
+
|
|
744
|
+
except SystemExit:
|
|
745
|
+
raise
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
handle_api_error(exc)
|
|
748
|
+
|
|
749
|
+
# -----------------------------------------------------------------------
|
|
750
|
+
# workitem create-from-template
|
|
751
|
+
# -----------------------------------------------------------------------
|
|
752
|
+
@workitem.command(name="create-from-template")
|
|
753
|
+
@click.argument("template_id")
|
|
754
|
+
@click.option("--name", "-n", default=None, help="Work item name (overrides template)")
|
|
755
|
+
@click.option(
|
|
756
|
+
"--state",
|
|
757
|
+
"-s",
|
|
758
|
+
default=None,
|
|
759
|
+
help="Initial state (e.g. NEW, DEFINED)",
|
|
760
|
+
)
|
|
761
|
+
@click.option(
|
|
762
|
+
"--description",
|
|
763
|
+
"-d",
|
|
764
|
+
default=None,
|
|
765
|
+
help="Work item description (overrides template)",
|
|
766
|
+
)
|
|
767
|
+
@click.option("--assigned-to", default=None, help="User ID to assign the work item to")
|
|
768
|
+
@click.option(
|
|
769
|
+
"--workflow-id",
|
|
770
|
+
default=None,
|
|
771
|
+
help="ID of the workflow to associate with this work item",
|
|
772
|
+
)
|
|
773
|
+
@click.option(
|
|
774
|
+
"--workspace",
|
|
775
|
+
"-w",
|
|
776
|
+
default=None,
|
|
777
|
+
help="Workspace name or ID (overrides template workspace)",
|
|
778
|
+
)
|
|
779
|
+
@click.option(
|
|
780
|
+
"--part-number",
|
|
781
|
+
default=None,
|
|
782
|
+
help="Part number (overrides template's first part number)",
|
|
783
|
+
)
|
|
784
|
+
@click.option(
|
|
785
|
+
"--format",
|
|
786
|
+
"-f",
|
|
787
|
+
type=click.Choice(["table", "json"]),
|
|
788
|
+
default="table",
|
|
789
|
+
show_default=True,
|
|
790
|
+
help="Output format",
|
|
791
|
+
)
|
|
792
|
+
def create_workitem_from_template(
|
|
793
|
+
template_id: str,
|
|
794
|
+
name: Optional[str],
|
|
795
|
+
state: Optional[str],
|
|
796
|
+
description: Optional[str],
|
|
797
|
+
assigned_to: Optional[str],
|
|
798
|
+
workflow_id: Optional[str],
|
|
799
|
+
workspace: Optional[str],
|
|
800
|
+
part_number: Optional[str],
|
|
801
|
+
format: str,
|
|
802
|
+
) -> None:
|
|
803
|
+
"""Create a new work item pre-filled from a work item template."""
|
|
804
|
+
from .utils import check_readonly_mode
|
|
805
|
+
|
|
806
|
+
check_readonly_mode("create a work item from template")
|
|
807
|
+
|
|
808
|
+
try:
|
|
809
|
+
# Fetch template by ID
|
|
810
|
+
tmpl_url = _wi_url("/query-workitem-templates")
|
|
811
|
+
tmpl_payload = {
|
|
812
|
+
"filter": "id == @0",
|
|
813
|
+
"substitutions": [template_id],
|
|
814
|
+
"take": 1,
|
|
815
|
+
}
|
|
816
|
+
tmpl_resp = make_api_request("POST", tmpl_url, tmpl_payload)
|
|
817
|
+
tmpl_data = tmpl_resp.json()
|
|
818
|
+
templates = tmpl_data.get("workItemTemplates", [])
|
|
819
|
+
|
|
820
|
+
if not templates:
|
|
821
|
+
click.echo(f"✗ Template '{template_id}' not found.", err=True)
|
|
822
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
823
|
+
|
|
824
|
+
tmpl = templates[0]
|
|
825
|
+
|
|
826
|
+
# Seed work item from template fields
|
|
827
|
+
wi_data: Dict[str, Any] = {}
|
|
828
|
+
if tmpl.get("name"):
|
|
829
|
+
wi_data["name"] = tmpl["name"]
|
|
830
|
+
if tmpl.get("type"):
|
|
831
|
+
wi_data["type"] = tmpl["type"]
|
|
832
|
+
if tmpl.get("description"):
|
|
833
|
+
wi_data["description"] = tmpl["description"]
|
|
834
|
+
if tmpl.get("workspace"):
|
|
835
|
+
wi_data["workspace"] = tmpl["workspace"]
|
|
836
|
+
template_part_numbers: List[str] = tmpl.get("partNumbers") or []
|
|
837
|
+
if template_part_numbers:
|
|
838
|
+
wi_data["partNumber"] = template_part_numbers[0]
|
|
839
|
+
|
|
840
|
+
# Apply CLI overrides
|
|
841
|
+
if name is not None:
|
|
842
|
+
wi_data["name"] = name
|
|
843
|
+
if state is not None:
|
|
844
|
+
wi_data["state"] = state
|
|
845
|
+
if description is not None:
|
|
846
|
+
wi_data["description"] = description
|
|
847
|
+
if assigned_to is not None:
|
|
848
|
+
wi_data["assignedTo"] = assigned_to
|
|
849
|
+
if workflow_id is not None:
|
|
850
|
+
wi_data["workflowId"] = workflow_id
|
|
851
|
+
if workspace is None:
|
|
852
|
+
workspace = get_effective_workspace(workspace)
|
|
853
|
+
if workspace is not None:
|
|
854
|
+
ws_id = get_workspace_id_with_fallback(workspace)
|
|
855
|
+
wi_data["workspace"] = ws_id
|
|
856
|
+
if part_number is not None:
|
|
857
|
+
wi_data["partNumber"] = part_number
|
|
858
|
+
|
|
859
|
+
# Create the work item
|
|
860
|
+
create_url = _wi_url("/workitems")
|
|
861
|
+
create_payload = {"workItems": [wi_data]}
|
|
862
|
+
resp = make_api_request("POST", create_url, create_payload, handle_errors=False)
|
|
863
|
+
data = resp.json()
|
|
864
|
+
|
|
865
|
+
if resp.status_code in (200, 201):
|
|
866
|
+
created = data.get("createdWorkItems", [])
|
|
867
|
+
if created:
|
|
868
|
+
item = created[0]
|
|
869
|
+
if format == "json":
|
|
870
|
+
click.echo(json.dumps(item, indent=2))
|
|
871
|
+
else:
|
|
872
|
+
format_success(
|
|
873
|
+
"Work item created from template",
|
|
874
|
+
{"id": item.get("id", ""), "name": item.get("name", "")},
|
|
875
|
+
)
|
|
876
|
+
else:
|
|
877
|
+
failed = data.get("failedWorkItems", [])
|
|
878
|
+
if failed:
|
|
879
|
+
click.echo("✗ Failed to create work item from template.", err=True)
|
|
880
|
+
display_api_errors("Work item creation failed", data, detailed=True)
|
|
881
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
882
|
+
click.echo("✓ Work item created from template.")
|
|
883
|
+
else:
|
|
884
|
+
display_api_errors("Work item creation failed", data, detailed=True)
|
|
885
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
886
|
+
|
|
887
|
+
except SystemExit:
|
|
888
|
+
raise
|
|
889
|
+
except Exception as exc:
|
|
890
|
+
handle_api_error(exc)
|
|
891
|
+
|
|
892
|
+
# -----------------------------------------------------------------------
|
|
893
|
+
# workitem update
|
|
894
|
+
# -----------------------------------------------------------------------
|
|
895
|
+
@workitem.command(name="update")
|
|
896
|
+
@click.argument("work_item_id")
|
|
897
|
+
@click.option("--name", "-n", default=None, help="New name")
|
|
898
|
+
@click.option("--state", "-s", default=None, help="New state")
|
|
899
|
+
@click.option("--description", "-d", default=None, help="New description")
|
|
900
|
+
@click.option("--assigned-to", default=None, help="User ID to reassign to")
|
|
901
|
+
@click.option(
|
|
902
|
+
"--file",
|
|
903
|
+
"-F",
|
|
904
|
+
"input_file",
|
|
905
|
+
default=None,
|
|
906
|
+
help=(
|
|
907
|
+
"JSON file with UpdateWorkItemRequest fields " "(field options override values in file)"
|
|
908
|
+
),
|
|
909
|
+
)
|
|
910
|
+
def update_workitem(
|
|
911
|
+
work_item_id: str,
|
|
912
|
+
name: Optional[str],
|
|
913
|
+
state: Optional[str],
|
|
914
|
+
description: Optional[str],
|
|
915
|
+
assigned_to: Optional[str],
|
|
916
|
+
input_file: Optional[str],
|
|
917
|
+
) -> None:
|
|
918
|
+
"""Update a work item by ID."""
|
|
919
|
+
from .utils import check_readonly_mode
|
|
920
|
+
|
|
921
|
+
check_readonly_mode("update a work item")
|
|
922
|
+
|
|
923
|
+
try:
|
|
924
|
+
wi_data: Dict[str, Any] = {"id": work_item_id}
|
|
925
|
+
if input_file:
|
|
926
|
+
file_data = load_json_file(input_file)
|
|
927
|
+
wi_data.update(file_data)
|
|
928
|
+
wi_data["id"] = work_item_id # ID always from arg
|
|
929
|
+
|
|
930
|
+
if name is not None:
|
|
931
|
+
wi_data["name"] = name
|
|
932
|
+
if state is not None:
|
|
933
|
+
wi_data["state"] = state
|
|
934
|
+
if description is not None:
|
|
935
|
+
wi_data["description"] = description
|
|
936
|
+
if assigned_to is not None:
|
|
937
|
+
wi_data["assignedTo"] = assigned_to
|
|
938
|
+
|
|
939
|
+
url = _wi_url("/update-workitems")
|
|
940
|
+
payload = {"workItems": [wi_data]}
|
|
941
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
942
|
+
data = resp.json()
|
|
943
|
+
|
|
944
|
+
if resp.status_code == 200:
|
|
945
|
+
updated = data.get("updatedWorkItems", [])
|
|
946
|
+
if updated:
|
|
947
|
+
click.echo(f"✓ Work item {work_item_id} updated successfully.")
|
|
948
|
+
else:
|
|
949
|
+
failed = data.get("failedWorkItems", [])
|
|
950
|
+
if failed:
|
|
951
|
+
click.echo(f"✗ Failed to update work item {work_item_id}.", err=True)
|
|
952
|
+
display_api_errors("Work item update failed", data, detailed=True)
|
|
953
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
954
|
+
else:
|
|
955
|
+
display_api_errors("Work item update failed", data, detailed=True)
|
|
956
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
957
|
+
|
|
958
|
+
except SystemExit:
|
|
959
|
+
raise
|
|
960
|
+
except Exception as exc:
|
|
961
|
+
handle_api_error(exc)
|
|
962
|
+
|
|
963
|
+
# -----------------------------------------------------------------------
|
|
964
|
+
# workitem delete
|
|
965
|
+
# -----------------------------------------------------------------------
|
|
966
|
+
@workitem.command(name="delete")
|
|
967
|
+
@click.argument("work_item_ids", nargs=-1, required=True)
|
|
968
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
969
|
+
def delete_workitems(work_item_ids: tuple, yes: bool) -> None:
|
|
970
|
+
"""Delete one or more work items by ID."""
|
|
971
|
+
from .utils import check_readonly_mode
|
|
972
|
+
|
|
973
|
+
check_readonly_mode("delete work items")
|
|
974
|
+
|
|
975
|
+
ids_list = list(work_item_ids)
|
|
976
|
+
if not yes:
|
|
977
|
+
id_str = ", ".join(ids_list)
|
|
978
|
+
if not click.confirm(f"Delete work item(s) {id_str}?"):
|
|
979
|
+
click.echo("Aborted.")
|
|
980
|
+
return
|
|
981
|
+
|
|
982
|
+
try:
|
|
983
|
+
url = _wi_url("/delete-workitems")
|
|
984
|
+
payload = {"ids": ids_list}
|
|
985
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
986
|
+
|
|
987
|
+
if resp.status_code == 204:
|
|
988
|
+
click.echo(f"✓ Work item(s) deleted successfully.")
|
|
989
|
+
return
|
|
990
|
+
|
|
991
|
+
data = resp.json() if resp.text.strip() else {}
|
|
992
|
+
|
|
993
|
+
if resp.status_code == 200:
|
|
994
|
+
deleted = data.get("deletedWorkItemIds", [])
|
|
995
|
+
failed = data.get("failedWorkItemIds", [])
|
|
996
|
+
if deleted:
|
|
997
|
+
click.echo(f"✓ Deleted: {', '.join(deleted)}")
|
|
998
|
+
if failed:
|
|
999
|
+
click.echo(f"✗ Failed to delete: {', '.join(failed)}", err=True)
|
|
1000
|
+
display_api_errors("Work item deletion failed", data, detailed=True)
|
|
1001
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1002
|
+
else:
|
|
1003
|
+
display_api_errors("Work item deletion failed", data, detailed=True)
|
|
1004
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1005
|
+
|
|
1006
|
+
except SystemExit:
|
|
1007
|
+
raise
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
handle_api_error(exc)
|
|
1010
|
+
|
|
1011
|
+
# -----------------------------------------------------------------------
|
|
1012
|
+
# workitem execute
|
|
1013
|
+
# -----------------------------------------------------------------------
|
|
1014
|
+
@workitem.command(name="execute")
|
|
1015
|
+
@click.argument("work_item_id")
|
|
1016
|
+
@click.option(
|
|
1017
|
+
"--action",
|
|
1018
|
+
"-a",
|
|
1019
|
+
required=True,
|
|
1020
|
+
help="Action to execute (e.g. START, END, COMPLETE)",
|
|
1021
|
+
)
|
|
1022
|
+
def execute_workitem(work_item_id: str, action: str) -> None:
|
|
1023
|
+
"""Execute an action on a work item (e.g. START, END)."""
|
|
1024
|
+
from .utils import check_readonly_mode
|
|
1025
|
+
|
|
1026
|
+
check_readonly_mode("execute an action on a work item")
|
|
1027
|
+
|
|
1028
|
+
try:
|
|
1029
|
+
url = _wi_url(f"/workitems/{work_item_id}/execute")
|
|
1030
|
+
payload = {"action": action.upper()}
|
|
1031
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1032
|
+
|
|
1033
|
+
if resp.status_code in (200, 202):
|
|
1034
|
+
data = resp.json() if resp.text.strip() else {}
|
|
1035
|
+
result = data.get("result", {})
|
|
1036
|
+
if result:
|
|
1037
|
+
click.echo(f"✓ Action '{action.upper()}' executed on work item {work_item_id}.")
|
|
1038
|
+
if isinstance(result, dict) and result.get("type"):
|
|
1039
|
+
click.echo(f" Execution type: {result['type']}")
|
|
1040
|
+
else:
|
|
1041
|
+
click.echo(f"✓ Action '{action.upper()}' executed on work item {work_item_id}.")
|
|
1042
|
+
else:
|
|
1043
|
+
data = resp.json() if resp.text.strip() else {}
|
|
1044
|
+
display_api_errors("Work item execution failed", data, detailed=True)
|
|
1045
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1046
|
+
|
|
1047
|
+
except SystemExit:
|
|
1048
|
+
raise
|
|
1049
|
+
except Exception as exc:
|
|
1050
|
+
handle_api_error(exc)
|
|
1051
|
+
|
|
1052
|
+
# -----------------------------------------------------------------------
|
|
1053
|
+
# workitem schedule
|
|
1054
|
+
# -----------------------------------------------------------------------
|
|
1055
|
+
@workitem.command(name="schedule")
|
|
1056
|
+
@click.argument("work_item_id")
|
|
1057
|
+
@click.option(
|
|
1058
|
+
"--start",
|
|
1059
|
+
default=None,
|
|
1060
|
+
help="Planned start date/time (ISO-8601, e.g. 2026-03-01T09:00:00Z)",
|
|
1061
|
+
)
|
|
1062
|
+
@click.option(
|
|
1063
|
+
"--end",
|
|
1064
|
+
default=None,
|
|
1065
|
+
help="Planned end date/time (ISO-8601)",
|
|
1066
|
+
)
|
|
1067
|
+
@click.option(
|
|
1068
|
+
"--duration",
|
|
1069
|
+
type=int,
|
|
1070
|
+
default=None,
|
|
1071
|
+
help="Planned duration in seconds",
|
|
1072
|
+
)
|
|
1073
|
+
@click.option("--assigned-to", default=None, help="User ID to assign")
|
|
1074
|
+
@click.option(
|
|
1075
|
+
"--system",
|
|
1076
|
+
"system_ids",
|
|
1077
|
+
multiple=True,
|
|
1078
|
+
metavar="SYSTEM_ID",
|
|
1079
|
+
help="Assign a system resource by system ID (repeatable).",
|
|
1080
|
+
)
|
|
1081
|
+
@click.option(
|
|
1082
|
+
"--fixture",
|
|
1083
|
+
"fixture_ids",
|
|
1084
|
+
multiple=True,
|
|
1085
|
+
metavar="FIXTURE_ID",
|
|
1086
|
+
help=(
|
|
1087
|
+
"Assign a fixture/slot resource by asset ID (repeatable). "
|
|
1088
|
+
"Use `slcli asset list --asset-type FIXTURE` to find IDs."
|
|
1089
|
+
),
|
|
1090
|
+
)
|
|
1091
|
+
@click.option(
|
|
1092
|
+
"--dut",
|
|
1093
|
+
"dut_ids",
|
|
1094
|
+
multiple=True,
|
|
1095
|
+
metavar="DUT_ID",
|
|
1096
|
+
help=(
|
|
1097
|
+
"Assign a DUT resource by asset ID (repeatable). "
|
|
1098
|
+
"Use `slcli asset list --asset-type DEVICE_UNDER_TEST` to find IDs."
|
|
1099
|
+
),
|
|
1100
|
+
)
|
|
1101
|
+
def schedule_workitem(
|
|
1102
|
+
work_item_id: str,
|
|
1103
|
+
start: Optional[str],
|
|
1104
|
+
end: Optional[str],
|
|
1105
|
+
duration: Optional[int],
|
|
1106
|
+
assigned_to: Optional[str],
|
|
1107
|
+
system_ids: Tuple[str, ...],
|
|
1108
|
+
fixture_ids: Tuple[str, ...],
|
|
1109
|
+
dut_ids: Tuple[str, ...],
|
|
1110
|
+
) -> None:
|
|
1111
|
+
"""Schedule a work item (set planned start/end time and/or assign resources).
|
|
1112
|
+
|
|
1113
|
+
Resources:
|
|
1114
|
+
--system assigns a system (by system/minion ID).
|
|
1115
|
+
--fixture assigns a fixture/slot (by asset ID; asset type FIXTURE).
|
|
1116
|
+
--dut assigns a DUT (by asset ID; asset type DEVICE_UNDER_TEST).
|
|
1117
|
+
|
|
1118
|
+
Multiple --system/--fixture/--dut flags can be provided for multi-resource scheduling.
|
|
1119
|
+
"""
|
|
1120
|
+
from .utils import check_readonly_mode
|
|
1121
|
+
|
|
1122
|
+
check_readonly_mode("schedule a work item")
|
|
1123
|
+
|
|
1124
|
+
if not any([start, end, duration, assigned_to, system_ids, fixture_ids, dut_ids]):
|
|
1125
|
+
click.echo(
|
|
1126
|
+
"✗ Provide at least one of --start, --end, --duration, --assigned-to, "
|
|
1127
|
+
"--system, --fixture, or --dut.",
|
|
1128
|
+
err=True,
|
|
1129
|
+
)
|
|
1130
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1131
|
+
|
|
1132
|
+
try:
|
|
1133
|
+
schedule: Dict[str, Any] = {}
|
|
1134
|
+
if start:
|
|
1135
|
+
schedule["plannedStartDateTime"] = start
|
|
1136
|
+
if end:
|
|
1137
|
+
schedule["plannedEndDateTime"] = end
|
|
1138
|
+
if duration is not None:
|
|
1139
|
+
schedule["plannedDurationInSeconds"] = duration
|
|
1140
|
+
|
|
1141
|
+
wi_req: Dict[str, Any] = {"id": work_item_id}
|
|
1142
|
+
if schedule:
|
|
1143
|
+
wi_req["schedule"] = schedule
|
|
1144
|
+
if assigned_to:
|
|
1145
|
+
wi_req["assignedTo"] = assigned_to
|
|
1146
|
+
|
|
1147
|
+
resources: Dict[str, Any] = {}
|
|
1148
|
+
if system_ids:
|
|
1149
|
+
resources["systems"] = {"selections": [{"id": sid} for sid in system_ids]}
|
|
1150
|
+
if fixture_ids:
|
|
1151
|
+
resources["fixtures"] = {"selections": [{"id": fid} for fid in fixture_ids]}
|
|
1152
|
+
if dut_ids:
|
|
1153
|
+
resources["duts"] = {"selections": [{"id": did} for did in dut_ids]}
|
|
1154
|
+
if resources:
|
|
1155
|
+
wi_req["resources"] = resources
|
|
1156
|
+
|
|
1157
|
+
url = _wi_url("/schedule-workitems")
|
|
1158
|
+
payload = {"workItems": [wi_req]}
|
|
1159
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1160
|
+
data = resp.json() if resp.text.strip() else {}
|
|
1161
|
+
|
|
1162
|
+
if resp.status_code == 200:
|
|
1163
|
+
scheduled = data.get("scheduledWorkItems", [])
|
|
1164
|
+
if scheduled:
|
|
1165
|
+
click.echo(f"✓ Work item {work_item_id} scheduled successfully.")
|
|
1166
|
+
else:
|
|
1167
|
+
failed = data.get("failedWorkItems", [])
|
|
1168
|
+
if failed:
|
|
1169
|
+
click.echo(f"✗ Failed to schedule work item {work_item_id}.", err=True)
|
|
1170
|
+
display_api_errors("Work item scheduling failed", data, detailed=True)
|
|
1171
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1172
|
+
else:
|
|
1173
|
+
display_api_errors("Work item scheduling failed", data, detailed=True)
|
|
1174
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1175
|
+
|
|
1176
|
+
except SystemExit:
|
|
1177
|
+
raise
|
|
1178
|
+
except Exception as exc:
|
|
1179
|
+
handle_api_error(exc)
|
|
1180
|
+
|
|
1181
|
+
# =======================================================================
|
|
1182
|
+
# workitem template subgroup
|
|
1183
|
+
# =======================================================================
|
|
1184
|
+
|
|
1185
|
+
@workitem.group(name="template")
|
|
1186
|
+
def template_group() -> None:
|
|
1187
|
+
"""Manage work item templates."""
|
|
1188
|
+
|
|
1189
|
+
@template_group.command(name="list")
|
|
1190
|
+
@click.option(
|
|
1191
|
+
"--format",
|
|
1192
|
+
"-f",
|
|
1193
|
+
type=click.Choice(["table", "json"]),
|
|
1194
|
+
default="table",
|
|
1195
|
+
show_default=True,
|
|
1196
|
+
help="Output format",
|
|
1197
|
+
)
|
|
1198
|
+
@click.option(
|
|
1199
|
+
"--take",
|
|
1200
|
+
"-t",
|
|
1201
|
+
type=int,
|
|
1202
|
+
default=25,
|
|
1203
|
+
show_default=True,
|
|
1204
|
+
help="Maximum number of templates to display per page (table) or fetch (json)",
|
|
1205
|
+
)
|
|
1206
|
+
@click.option(
|
|
1207
|
+
"--filter",
|
|
1208
|
+
"filter_expr",
|
|
1209
|
+
default=None,
|
|
1210
|
+
help="Dynamic LINQ filter expression",
|
|
1211
|
+
)
|
|
1212
|
+
@click.option("--workspace", "-w", default=None, help="Filter by workspace name or ID")
|
|
1213
|
+
def list_templates(
|
|
1214
|
+
format: str,
|
|
1215
|
+
take: int,
|
|
1216
|
+
filter_expr: Optional[str],
|
|
1217
|
+
workspace: Optional[str],
|
|
1218
|
+
) -> None:
|
|
1219
|
+
"""List work item templates."""
|
|
1220
|
+
format_output = validate_output_format(format)
|
|
1221
|
+
|
|
1222
|
+
try:
|
|
1223
|
+
workspace_map = get_workspace_map()
|
|
1224
|
+
workspace_id = None
|
|
1225
|
+
workspace = get_effective_workspace(workspace)
|
|
1226
|
+
if workspace:
|
|
1227
|
+
workspace_id = resolve_workspace_filter(workspace, workspace_map)
|
|
1228
|
+
|
|
1229
|
+
if format_output == "json":
|
|
1230
|
+
items = _query_all_templates(filter_expr, None, workspace_id, max_items=take)
|
|
1231
|
+
click.echo(json.dumps(items, indent=2))
|
|
1232
|
+
return
|
|
1233
|
+
|
|
1234
|
+
# Table: server-side pagination
|
|
1235
|
+
def _fmt(item: Dict[str, Any]) -> list:
|
|
1236
|
+
ws_name = get_workspace_display_name(item.get("workspace", ""), workspace_map)
|
|
1237
|
+
return [
|
|
1238
|
+
item.get("id", ""),
|
|
1239
|
+
(item.get("name", "") or "")[:40],
|
|
1240
|
+
item.get("type", "") or "",
|
|
1241
|
+
item.get("templateGroup", "") or "",
|
|
1242
|
+
ws_name[:20],
|
|
1243
|
+
]
|
|
1244
|
+
|
|
1245
|
+
cont: Optional[str] = None
|
|
1246
|
+
displayed = 0
|
|
1247
|
+
while True:
|
|
1248
|
+
page, cont = _fetch_templates_page(filter_expr, None, workspace_id, take, cont)
|
|
1249
|
+
if not page:
|
|
1250
|
+
if displayed == 0:
|
|
1251
|
+
click.echo("No templates found.")
|
|
1252
|
+
break
|
|
1253
|
+
displayed += len(page)
|
|
1254
|
+
resp: Any = FilteredResponse({"workItemTemplates": page})
|
|
1255
|
+
UniversalResponseHandler.handle_list_response(
|
|
1256
|
+
resp=resp,
|
|
1257
|
+
data_key="workItemTemplates",
|
|
1258
|
+
item_name="template",
|
|
1259
|
+
format_output=format_output,
|
|
1260
|
+
formatter_func=_fmt,
|
|
1261
|
+
headers=["ID", "Name", "Type", "Template Group", "Workspace"],
|
|
1262
|
+
column_widths=[12, 41, 16, 20, 21],
|
|
1263
|
+
empty_message="No templates found.",
|
|
1264
|
+
enable_pagination=False,
|
|
1265
|
+
)
|
|
1266
|
+
if not cont:
|
|
1267
|
+
break
|
|
1268
|
+
click.echo(f"\nShowing {displayed} template(s). More may be available.")
|
|
1269
|
+
if not click.confirm(f"Show next {take} results?", default=True):
|
|
1270
|
+
break
|
|
1271
|
+
|
|
1272
|
+
except Exception as exc:
|
|
1273
|
+
handle_api_error(exc)
|
|
1274
|
+
|
|
1275
|
+
@template_group.command(name="get")
|
|
1276
|
+
@click.argument("template_id")
|
|
1277
|
+
@click.option(
|
|
1278
|
+
"--format",
|
|
1279
|
+
"-f",
|
|
1280
|
+
type=click.Choice(["table", "json"]),
|
|
1281
|
+
default="table",
|
|
1282
|
+
show_default=True,
|
|
1283
|
+
help="Output format",
|
|
1284
|
+
)
|
|
1285
|
+
def get_template(template_id: str, format: str) -> None:
|
|
1286
|
+
"""Get details for a work item template by ID."""
|
|
1287
|
+
format_output = validate_output_format(format)
|
|
1288
|
+
|
|
1289
|
+
try:
|
|
1290
|
+
url = _wi_url("/query-workitem-templates")
|
|
1291
|
+
payload = {
|
|
1292
|
+
"filter": f"id == @0",
|
|
1293
|
+
"substitutions": [template_id],
|
|
1294
|
+
"take": 1,
|
|
1295
|
+
}
|
|
1296
|
+
resp = make_api_request("POST", url, payload)
|
|
1297
|
+
data = resp.json()
|
|
1298
|
+
items = data.get("workItemTemplates", [])
|
|
1299
|
+
|
|
1300
|
+
if not items:
|
|
1301
|
+
click.echo(f"✗ Template '{template_id}' not found.", err=True)
|
|
1302
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
1303
|
+
|
|
1304
|
+
item = items[0]
|
|
1305
|
+
|
|
1306
|
+
if format_output == "json":
|
|
1307
|
+
click.echo(json.dumps(item, indent=2))
|
|
1308
|
+
return
|
|
1309
|
+
|
|
1310
|
+
workspace_map = get_workspace_map()
|
|
1311
|
+
ws_name = get_workspace_display_name(item.get("workspace", ""), workspace_map)
|
|
1312
|
+
|
|
1313
|
+
click.echo("Work Item Template Details:")
|
|
1314
|
+
click.echo("=" * 50)
|
|
1315
|
+
click.echo(f"ID: {item.get('id', 'N/A')}")
|
|
1316
|
+
click.echo(f"Name: {item.get('name', 'N/A')}")
|
|
1317
|
+
click.echo(f"Type: {item.get('type', 'N/A')}")
|
|
1318
|
+
click.echo(f"Template Group: {item.get('templateGroup', 'N/A')}")
|
|
1319
|
+
click.echo(f"Summary: {item.get('summary', 'N/A')}")
|
|
1320
|
+
click.echo(f"Description: {item.get('description', 'N/A')}")
|
|
1321
|
+
click.echo(f"Test Program: {item.get('testProgram', 'N/A')}")
|
|
1322
|
+
click.echo(f"Workspace: {ws_name}")
|
|
1323
|
+
click.echo(f"Created At: {item.get('createdAt', 'N/A')}")
|
|
1324
|
+
click.echo(f"Updated At: {item.get('updatedAt', 'N/A')}")
|
|
1325
|
+
|
|
1326
|
+
if item.get("partNumbers"):
|
|
1327
|
+
click.echo(f"Part Numbers: {', '.join(item['partNumbers'])}")
|
|
1328
|
+
if item.get("productFamilies"):
|
|
1329
|
+
click.echo(f"Product Families: {', '.join(item['productFamilies'])}")
|
|
1330
|
+
if item.get("properties"):
|
|
1331
|
+
click.echo("Properties:")
|
|
1332
|
+
for k, v in item["properties"].items():
|
|
1333
|
+
click.echo(f" {k}: {v}")
|
|
1334
|
+
|
|
1335
|
+
except Exception as exc:
|
|
1336
|
+
handle_api_error(exc)
|
|
1337
|
+
|
|
1338
|
+
@template_group.command(name="create")
|
|
1339
|
+
@click.option("--name", "-n", required=False, default=None, help="Template name")
|
|
1340
|
+
@click.option("--type", "wi_type", default=None, help="Work item type (e.g. testplan)")
|
|
1341
|
+
@click.option("--template-group", default=None, help="Template group label")
|
|
1342
|
+
@click.option("--description", "-d", default=None, help="Template description")
|
|
1343
|
+
@click.option("--summary", default=None, help="Template summary")
|
|
1344
|
+
@click.option("--workspace", "-w", default=None, help="Workspace name or ID")
|
|
1345
|
+
@click.option(
|
|
1346
|
+
"--file",
|
|
1347
|
+
"-F",
|
|
1348
|
+
"input_file",
|
|
1349
|
+
default=None,
|
|
1350
|
+
help="JSON file with full CreateWorkItemTemplateRequest body",
|
|
1351
|
+
)
|
|
1352
|
+
@click.option(
|
|
1353
|
+
"--format",
|
|
1354
|
+
"-f",
|
|
1355
|
+
type=click.Choice(["table", "json"]),
|
|
1356
|
+
default="table",
|
|
1357
|
+
show_default=True,
|
|
1358
|
+
help="Output format",
|
|
1359
|
+
)
|
|
1360
|
+
def create_template(
|
|
1361
|
+
name: Optional[str],
|
|
1362
|
+
wi_type: Optional[str],
|
|
1363
|
+
template_group: Optional[str],
|
|
1364
|
+
description: Optional[str],
|
|
1365
|
+
summary: Optional[str],
|
|
1366
|
+
workspace: Optional[str],
|
|
1367
|
+
input_file: Optional[str],
|
|
1368
|
+
format: str,
|
|
1369
|
+
) -> None:
|
|
1370
|
+
"""Create a new work item template."""
|
|
1371
|
+
from .utils import check_readonly_mode
|
|
1372
|
+
|
|
1373
|
+
check_readonly_mode("create a work item template")
|
|
1374
|
+
|
|
1375
|
+
if not input_file and not (name and wi_type and template_group):
|
|
1376
|
+
click.echo(
|
|
1377
|
+
"✗ Provide --file or all of --name, --type, and --template-group.",
|
|
1378
|
+
err=True,
|
|
1379
|
+
)
|
|
1380
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1381
|
+
|
|
1382
|
+
try:
|
|
1383
|
+
tmpl_data: Dict[str, Any] = {}
|
|
1384
|
+
if input_file:
|
|
1385
|
+
tmpl_data = load_json_file(input_file)
|
|
1386
|
+
|
|
1387
|
+
if name is not None:
|
|
1388
|
+
tmpl_data["name"] = name
|
|
1389
|
+
if wi_type is not None:
|
|
1390
|
+
tmpl_data["type"] = wi_type
|
|
1391
|
+
if template_group is not None:
|
|
1392
|
+
tmpl_data["templateGroup"] = template_group
|
|
1393
|
+
if description is not None:
|
|
1394
|
+
tmpl_data["description"] = description
|
|
1395
|
+
if summary is not None:
|
|
1396
|
+
tmpl_data["summary"] = summary
|
|
1397
|
+
if workspace is None:
|
|
1398
|
+
workspace = get_effective_workspace(workspace)
|
|
1399
|
+
if workspace is not None:
|
|
1400
|
+
ws_id = get_workspace_id_with_fallback(workspace)
|
|
1401
|
+
tmpl_data["workspace"] = ws_id
|
|
1402
|
+
|
|
1403
|
+
url = _wi_url("/workitem-templates")
|
|
1404
|
+
payload = {"workItemTemplates": [tmpl_data]}
|
|
1405
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1406
|
+
data = resp.json()
|
|
1407
|
+
|
|
1408
|
+
if resp.status_code in (200, 201):
|
|
1409
|
+
created = data.get("createdWorkItemTemplates", [])
|
|
1410
|
+
if created:
|
|
1411
|
+
item = created[0]
|
|
1412
|
+
if format == "json":
|
|
1413
|
+
click.echo(json.dumps(item, indent=2))
|
|
1414
|
+
else:
|
|
1415
|
+
format_success(
|
|
1416
|
+
"Template created",
|
|
1417
|
+
{"id": item.get("id", ""), "name": item.get("name", "")},
|
|
1418
|
+
)
|
|
1419
|
+
else:
|
|
1420
|
+
failed = data.get("failedWorkItemTemplates", [])
|
|
1421
|
+
if failed:
|
|
1422
|
+
click.echo("✗ Failed to create template.", err=True)
|
|
1423
|
+
display_api_errors("Template creation failed", data, detailed=True)
|
|
1424
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1425
|
+
click.echo("✓ Template created.")
|
|
1426
|
+
else:
|
|
1427
|
+
display_api_errors("Template creation failed", data, detailed=True)
|
|
1428
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1429
|
+
|
|
1430
|
+
except SystemExit:
|
|
1431
|
+
raise
|
|
1432
|
+
except Exception as exc:
|
|
1433
|
+
handle_api_error(exc)
|
|
1434
|
+
|
|
1435
|
+
@template_group.command(name="update")
|
|
1436
|
+
@click.argument("template_id")
|
|
1437
|
+
@click.option("--name", "-n", default=None, help="New name")
|
|
1438
|
+
@click.option("--description", "-d", default=None, help="New description")
|
|
1439
|
+
@click.option("--summary", default=None, help="New summary")
|
|
1440
|
+
@click.option("--template-group", default=None, help="New template group")
|
|
1441
|
+
@click.option(
|
|
1442
|
+
"--file",
|
|
1443
|
+
"-F",
|
|
1444
|
+
"input_file",
|
|
1445
|
+
default=None,
|
|
1446
|
+
help="JSON file with UpdateWorkItemTemplateRequest fields",
|
|
1447
|
+
)
|
|
1448
|
+
def update_template(
|
|
1449
|
+
template_id: str,
|
|
1450
|
+
name: Optional[str],
|
|
1451
|
+
description: Optional[str],
|
|
1452
|
+
summary: Optional[str],
|
|
1453
|
+
template_group: Optional[str],
|
|
1454
|
+
input_file: Optional[str],
|
|
1455
|
+
) -> None:
|
|
1456
|
+
"""Update a work item template by ID."""
|
|
1457
|
+
from .utils import check_readonly_mode
|
|
1458
|
+
|
|
1459
|
+
check_readonly_mode("update a work item template")
|
|
1460
|
+
|
|
1461
|
+
try:
|
|
1462
|
+
tmpl_data: Dict[str, Any] = {"id": template_id}
|
|
1463
|
+
if input_file:
|
|
1464
|
+
file_data = load_json_file(input_file)
|
|
1465
|
+
tmpl_data.update(file_data)
|
|
1466
|
+
tmpl_data["id"] = template_id
|
|
1467
|
+
|
|
1468
|
+
if name is not None:
|
|
1469
|
+
tmpl_data["name"] = name
|
|
1470
|
+
if description is not None:
|
|
1471
|
+
tmpl_data["description"] = description
|
|
1472
|
+
if summary is not None:
|
|
1473
|
+
tmpl_data["summary"] = summary
|
|
1474
|
+
if template_group is not None:
|
|
1475
|
+
tmpl_data["templateGroup"] = template_group
|
|
1476
|
+
|
|
1477
|
+
url = _wi_url("/update-workitem-templates")
|
|
1478
|
+
payload = {"workItemTemplates": [tmpl_data]}
|
|
1479
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1480
|
+
data = resp.json()
|
|
1481
|
+
|
|
1482
|
+
if resp.status_code == 200:
|
|
1483
|
+
updated = data.get("updatedWorkItemTemplates", [])
|
|
1484
|
+
if updated:
|
|
1485
|
+
click.echo(f"✓ Template {template_id} updated successfully.")
|
|
1486
|
+
else:
|
|
1487
|
+
failed = data.get("failedWorkItemTemplates", [])
|
|
1488
|
+
if failed:
|
|
1489
|
+
click.echo(f"✗ Failed to update template {template_id}.", err=True)
|
|
1490
|
+
display_api_errors("Template update failed", data, detailed=True)
|
|
1491
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1492
|
+
else:
|
|
1493
|
+
display_api_errors("Template update failed", data, detailed=True)
|
|
1494
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1495
|
+
|
|
1496
|
+
except SystemExit:
|
|
1497
|
+
raise
|
|
1498
|
+
except Exception as exc:
|
|
1499
|
+
handle_api_error(exc)
|
|
1500
|
+
|
|
1501
|
+
@template_group.command(name="delete")
|
|
1502
|
+
@click.argument("template_ids", nargs=-1, required=True)
|
|
1503
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
1504
|
+
def delete_templates(template_ids: tuple, yes: bool) -> None:
|
|
1505
|
+
"""Delete one or more work item templates by ID."""
|
|
1506
|
+
from .utils import check_readonly_mode
|
|
1507
|
+
|
|
1508
|
+
check_readonly_mode("delete work item templates")
|
|
1509
|
+
|
|
1510
|
+
ids_list = list(template_ids)
|
|
1511
|
+
if not yes:
|
|
1512
|
+
if not click.confirm(f"Delete template(s) {', '.join(ids_list)}?"):
|
|
1513
|
+
click.echo("Aborted.")
|
|
1514
|
+
return
|
|
1515
|
+
|
|
1516
|
+
try:
|
|
1517
|
+
url = _wi_url("/delete-workitem-templates")
|
|
1518
|
+
payload = {"ids": ids_list}
|
|
1519
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1520
|
+
|
|
1521
|
+
if resp.status_code == 204:
|
|
1522
|
+
click.echo("✓ Template(s) deleted successfully.")
|
|
1523
|
+
return
|
|
1524
|
+
|
|
1525
|
+
data = resp.json() if resp.text.strip() else {}
|
|
1526
|
+
|
|
1527
|
+
if resp.status_code == 200:
|
|
1528
|
+
deleted = data.get("deletedWorkItemTemplateIds", [])
|
|
1529
|
+
failed = data.get("failedWorkItemTemplateIds", [])
|
|
1530
|
+
if deleted:
|
|
1531
|
+
click.echo(f"✓ Deleted: {', '.join(deleted)}")
|
|
1532
|
+
if failed:
|
|
1533
|
+
click.echo(f"✗ Failed to delete: {', '.join(failed)}", err=True)
|
|
1534
|
+
display_api_errors("Template deletion failed", data, detailed=True)
|
|
1535
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1536
|
+
else:
|
|
1537
|
+
display_api_errors("Template deletion failed", data, detailed=True)
|
|
1538
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1539
|
+
|
|
1540
|
+
except SystemExit:
|
|
1541
|
+
raise
|
|
1542
|
+
except Exception as exc:
|
|
1543
|
+
handle_api_error(exc)
|
|
1544
|
+
|
|
1545
|
+
# =======================================================================
|
|
1546
|
+
# workitem workflow subgroup (refactored from workflows_click.py)
|
|
1547
|
+
# =======================================================================
|
|
1548
|
+
|
|
1549
|
+
@workitem.group(name="workflow")
|
|
1550
|
+
@click.pass_context
|
|
1551
|
+
def workflow_group(ctx: click.Context) -> None:
|
|
1552
|
+
"""Manage workflows (create, list, import/export, update, delete, preview)."""
|
|
1553
|
+
# Check for platform feature availability
|
|
1554
|
+
# Only check if a subcommand is being invoked (not just --help)
|
|
1555
|
+
if ctx.invoked_subcommand is not None:
|
|
1556
|
+
require_feature("workflows")
|
|
1557
|
+
|
|
1558
|
+
@workflow_group.command(name="init")
|
|
1559
|
+
@click.option("--name", "-n", help="Workflow name (will prompt if not provided)")
|
|
1560
|
+
@click.option("--description", "-d", help="Workflow description (will prompt if omitted)")
|
|
1561
|
+
@click.option(
|
|
1562
|
+
"--workspace",
|
|
1563
|
+
"-w",
|
|
1564
|
+
default="Default",
|
|
1565
|
+
help="Workspace name or ID (default: 'Default')",
|
|
1566
|
+
)
|
|
1567
|
+
@click.option("--output", "-o", help="Output file path (default: <name>-workflow.json)")
|
|
1568
|
+
def init_workflow(
|
|
1569
|
+
name: Optional[str],
|
|
1570
|
+
description: Optional[str],
|
|
1571
|
+
workspace: str,
|
|
1572
|
+
output: Optional[str],
|
|
1573
|
+
) -> None:
|
|
1574
|
+
"""Create a workflow JSON skeleton."""
|
|
1575
|
+
if not name:
|
|
1576
|
+
name = click.prompt("Workflow name", type=str)
|
|
1577
|
+
if not description:
|
|
1578
|
+
description = click.prompt("Workflow description", type=str, default="")
|
|
1579
|
+
|
|
1580
|
+
if not output:
|
|
1581
|
+
assert name is not None
|
|
1582
|
+
safe_name = sanitize_filename(name, "workflow")
|
|
1583
|
+
output = f"{safe_name}-workflow.json"
|
|
1584
|
+
|
|
1585
|
+
try:
|
|
1586
|
+
workspace = get_effective_workspace(workspace) or workspace
|
|
1587
|
+
workspace_id = get_workspace_id_with_fallback(workspace)
|
|
1588
|
+
except Exception as exc:
|
|
1589
|
+
click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
|
|
1590
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
1591
|
+
|
|
1592
|
+
workflow_data = {
|
|
1593
|
+
"name": name,
|
|
1594
|
+
"description": description,
|
|
1595
|
+
"workspace": workspace_id,
|
|
1596
|
+
"actions": [
|
|
1597
|
+
{
|
|
1598
|
+
"name": "START",
|
|
1599
|
+
"displayText": "Start",
|
|
1600
|
+
"privilegeSpecificity": ["ExecuteTest"],
|
|
1601
|
+
"executionAction": {"type": "MANUAL", "action": "START"},
|
|
1602
|
+
},
|
|
1603
|
+
{
|
|
1604
|
+
"name": "COMPLETE",
|
|
1605
|
+
"displayText": "Complete",
|
|
1606
|
+
"privilegeSpecificity": ["Close"],
|
|
1607
|
+
"executionAction": {"type": "MANUAL", "action": "COMPLETE"},
|
|
1608
|
+
},
|
|
1609
|
+
{
|
|
1610
|
+
"name": "RUN_NOTEBOOK",
|
|
1611
|
+
"displayText": "Run Notebook",
|
|
1612
|
+
"iconClass": None,
|
|
1613
|
+
"i18n": [],
|
|
1614
|
+
"privilegeSpecificity": ["ExecuteTest"],
|
|
1615
|
+
"executionAction": {
|
|
1616
|
+
"action": "RUN_NOTEBOOK",
|
|
1617
|
+
"type": "NOTEBOOK",
|
|
1618
|
+
"notebookId": "00000000-0000-0000-0000-000000000000",
|
|
1619
|
+
"parameters": {
|
|
1620
|
+
"partNumber": "<partNumber>",
|
|
1621
|
+
"dut": "<dutId>",
|
|
1622
|
+
"operator": "<assignedTo>",
|
|
1623
|
+
"testProgram": "<testProgram>",
|
|
1624
|
+
"location": "<properties.region>-<properties.facility>-<properties.lab>",
|
|
1625
|
+
},
|
|
1626
|
+
},
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
"name": "PLAN_SCHEDULE",
|
|
1630
|
+
"displayText": "Schedule Test Plan",
|
|
1631
|
+
"iconClass": "SCHEDULE",
|
|
1632
|
+
"i18n": [],
|
|
1633
|
+
"privilegeSpecificity": [],
|
|
1634
|
+
"executionAction": {"action": "PLAN_SCHEDULE", "type": "SCHEDULE"},
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
"name": "RUN_JOB",
|
|
1638
|
+
"displayText": "Run Job",
|
|
1639
|
+
"iconClass": "DEPLOY",
|
|
1640
|
+
"i18n": [],
|
|
1641
|
+
"privilegeSpecificity": [],
|
|
1642
|
+
"executionAction": {
|
|
1643
|
+
"action": "RUN_JOB",
|
|
1644
|
+
"type": "JOB",
|
|
1645
|
+
"jobs": [
|
|
1646
|
+
{
|
|
1647
|
+
"functions": ["state.apply"],
|
|
1648
|
+
"arguments": [["<properties.startTestStateId>"]],
|
|
1649
|
+
"metadata": {},
|
|
1650
|
+
}
|
|
1651
|
+
],
|
|
1652
|
+
},
|
|
1653
|
+
},
|
|
1654
|
+
],
|
|
1655
|
+
"states": [
|
|
1656
|
+
{
|
|
1657
|
+
"name": "NEW",
|
|
1658
|
+
"dashboardAvailable": False,
|
|
1659
|
+
"defaultSubstate": "NEW",
|
|
1660
|
+
"substates": [
|
|
1661
|
+
{
|
|
1662
|
+
"name": "NEW",
|
|
1663
|
+
"displayText": "New",
|
|
1664
|
+
"availableActions": [
|
|
1665
|
+
{
|
|
1666
|
+
"action": "PLAN_SCHEDULE",
|
|
1667
|
+
"nextState": "SCHEDULED",
|
|
1668
|
+
"nextSubstate": "SCHEDULED",
|
|
1669
|
+
"showInUI": True,
|
|
1670
|
+
}
|
|
1671
|
+
],
|
|
1672
|
+
}
|
|
1673
|
+
],
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
"name": "SCHEDULED",
|
|
1677
|
+
"dashboardAvailable": True,
|
|
1678
|
+
"defaultSubstate": "SCHEDULED",
|
|
1679
|
+
"substates": [
|
|
1680
|
+
{
|
|
1681
|
+
"name": "SCHEDULED",
|
|
1682
|
+
"displayText": "Scheduled",
|
|
1683
|
+
"availableActions": [
|
|
1684
|
+
{
|
|
1685
|
+
"action": "START",
|
|
1686
|
+
"nextState": "IN_PROGRESS",
|
|
1687
|
+
"nextSubstate": "IN_PROGRESS",
|
|
1688
|
+
"showInUI": True,
|
|
1689
|
+
},
|
|
1690
|
+
{
|
|
1691
|
+
"action": "RUN_NOTEBOOK",
|
|
1692
|
+
"nextState": "IN_PROGRESS",
|
|
1693
|
+
"nextSubstate": "IN_PROGRESS",
|
|
1694
|
+
"showInUI": True,
|
|
1695
|
+
},
|
|
1696
|
+
],
|
|
1697
|
+
}
|
|
1698
|
+
],
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
"name": "IN_PROGRESS",
|
|
1702
|
+
"dashboardAvailable": True,
|
|
1703
|
+
"defaultSubstate": "IN_PROGRESS",
|
|
1704
|
+
"substates": [
|
|
1705
|
+
{
|
|
1706
|
+
"name": "IN_PROGRESS",
|
|
1707
|
+
"displayText": "In progress",
|
|
1708
|
+
"availableActions": [
|
|
1709
|
+
{
|
|
1710
|
+
"action": "COMPLETE",
|
|
1711
|
+
"nextState": "PENDING_APPROVAL",
|
|
1712
|
+
"nextSubstate": "PENDING_APPROVAL",
|
|
1713
|
+
"showInUI": True,
|
|
1714
|
+
}
|
|
1715
|
+
],
|
|
1716
|
+
}
|
|
1717
|
+
],
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
"name": "PENDING_APPROVAL",
|
|
1721
|
+
"dashboardAvailable": True,
|
|
1722
|
+
"defaultSubstate": "PENDING_APPROVAL",
|
|
1723
|
+
"substates": [
|
|
1724
|
+
{
|
|
1725
|
+
"name": "PENDING_APPROVAL",
|
|
1726
|
+
"displayText": "Pending approval",
|
|
1727
|
+
"availableActions": [
|
|
1728
|
+
{
|
|
1729
|
+
"action": "RUN_JOB",
|
|
1730
|
+
"nextState": "CLOSED",
|
|
1731
|
+
"nextSubstate": "CLOSED",
|
|
1732
|
+
"showInUI": True,
|
|
1733
|
+
}
|
|
1734
|
+
],
|
|
1735
|
+
}
|
|
1736
|
+
],
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
"name": "CLOSED",
|
|
1740
|
+
"dashboardAvailable": False,
|
|
1741
|
+
"defaultSubstate": "CLOSED",
|
|
1742
|
+
"substates": [
|
|
1743
|
+
{"name": "CLOSED", "displayText": "Closed", "availableActions": []}
|
|
1744
|
+
],
|
|
1745
|
+
},
|
|
1746
|
+
{
|
|
1747
|
+
"name": "CANCELED",
|
|
1748
|
+
"dashboardAvailable": False,
|
|
1749
|
+
"defaultSubstate": "CANCELED",
|
|
1750
|
+
"substates": [
|
|
1751
|
+
{
|
|
1752
|
+
"name": "CANCELED",
|
|
1753
|
+
"displayText": "Canceled",
|
|
1754
|
+
"availableActions": [],
|
|
1755
|
+
}
|
|
1756
|
+
],
|
|
1757
|
+
},
|
|
1758
|
+
],
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
try:
|
|
1762
|
+
if os.path.exists(output):
|
|
1763
|
+
if not click.confirm(f"File {output} already exists. Overwrite?"):
|
|
1764
|
+
click.echo("Workflow initialization cancelled.")
|
|
1765
|
+
return
|
|
1766
|
+
|
|
1767
|
+
with open(output, "w", encoding="utf-8") as fh:
|
|
1768
|
+
json.dump(workflow_data, fh, indent=2, ensure_ascii=False)
|
|
1769
|
+
|
|
1770
|
+
click.echo(f"✓ Workflow initialized: {output}")
|
|
1771
|
+
click.echo("Edit the file to customize your workflow:")
|
|
1772
|
+
click.echo(" - name and description are recommended")
|
|
1773
|
+
click.echo(" - Define states, substates, and actions as needed")
|
|
1774
|
+
click.echo(f" - Workspace is set to: {workspace} (ID: {workspace_id})")
|
|
1775
|
+
click.echo(" - Use 'slcli workitem workflow import' to upload when ready")
|
|
1776
|
+
|
|
1777
|
+
except Exception as exc:
|
|
1778
|
+
click.echo(f"✗ Error creating workflow file: {exc}", err=True)
|
|
1779
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1780
|
+
|
|
1781
|
+
@workflow_group.command(name="list")
|
|
1782
|
+
@click.option("--workspace", "-w", help="Filter by workspace name or ID")
|
|
1783
|
+
@click.option(
|
|
1784
|
+
"--take",
|
|
1785
|
+
"-t",
|
|
1786
|
+
type=int,
|
|
1787
|
+
default=25,
|
|
1788
|
+
show_default=True,
|
|
1789
|
+
help="Maximum number of workflows to display per page",
|
|
1790
|
+
)
|
|
1791
|
+
@click.option(
|
|
1792
|
+
"--format",
|
|
1793
|
+
"-f",
|
|
1794
|
+
type=click.Choice(["table", "json"]),
|
|
1795
|
+
default="table",
|
|
1796
|
+
show_default=True,
|
|
1797
|
+
help="Output format",
|
|
1798
|
+
)
|
|
1799
|
+
def list_workflows(
|
|
1800
|
+
workspace: Optional[str],
|
|
1801
|
+
take: int,
|
|
1802
|
+
format: str,
|
|
1803
|
+
) -> None:
|
|
1804
|
+
"""List workflows."""
|
|
1805
|
+
format_output = validate_output_format(format)
|
|
1806
|
+
|
|
1807
|
+
try:
|
|
1808
|
+
workspace_map = get_workspace_map()
|
|
1809
|
+
workspace_id = None
|
|
1810
|
+
workspace = get_effective_workspace(workspace)
|
|
1811
|
+
if workspace:
|
|
1812
|
+
workspace_id = resolve_workspace_filter(workspace, workspace_map)
|
|
1813
|
+
|
|
1814
|
+
if format_output == "json":
|
|
1815
|
+
all_workflows = _query_all_workflows(workspace_id, max_items=take)
|
|
1816
|
+
click.echo(json.dumps(all_workflows, indent=2))
|
|
1817
|
+
return
|
|
1818
|
+
|
|
1819
|
+
# Table: server-side pagination
|
|
1820
|
+
def _wf_fmt(wf: Dict[str, Any]) -> list:
|
|
1821
|
+
ws_name = get_workspace_display_name(wf.get("workspace", ""), workspace_map)
|
|
1822
|
+
return [
|
|
1823
|
+
wf.get("name", "Unknown"),
|
|
1824
|
+
ws_name,
|
|
1825
|
+
wf.get("id", ""),
|
|
1826
|
+
(wf.get("description", "") or "")[:30],
|
|
1827
|
+
]
|
|
1828
|
+
|
|
1829
|
+
cont: Optional[str] = None
|
|
1830
|
+
displayed = 0
|
|
1831
|
+
while True:
|
|
1832
|
+
page, cont = _fetch_workflows_page(workspace_id, take, cont)
|
|
1833
|
+
if not page:
|
|
1834
|
+
if displayed == 0:
|
|
1835
|
+
click.echo("No workflows found.")
|
|
1836
|
+
break
|
|
1837
|
+
displayed += len(page)
|
|
1838
|
+
resp: Any = FilteredResponse({"workflows": page})
|
|
1839
|
+
UniversalResponseHandler.handle_list_response(
|
|
1840
|
+
resp=resp,
|
|
1841
|
+
data_key="workflows",
|
|
1842
|
+
item_name="workflow",
|
|
1843
|
+
format_output=format_output,
|
|
1844
|
+
formatter_func=_wf_fmt,
|
|
1845
|
+
headers=["Name", "Workspace", "ID", "Description"],
|
|
1846
|
+
column_widths=[40, 30, 36, 32],
|
|
1847
|
+
empty_message="No workflows found.",
|
|
1848
|
+
enable_pagination=False,
|
|
1849
|
+
)
|
|
1850
|
+
if not cont:
|
|
1851
|
+
break
|
|
1852
|
+
click.echo(f"\nShowing {displayed} workflow(s). More may be available.")
|
|
1853
|
+
if not click.confirm(f"Show next {take} results?", default=True):
|
|
1854
|
+
break
|
|
1855
|
+
|
|
1856
|
+
except Exception as exc:
|
|
1857
|
+
handle_api_error(exc)
|
|
1858
|
+
|
|
1859
|
+
@workflow_group.command(name="get")
|
|
1860
|
+
@click.option("--id", "-i", "workflow_id", help="Workflow ID")
|
|
1861
|
+
@click.option("--name", "-n", "workflow_name", help="Workflow name")
|
|
1862
|
+
@click.option(
|
|
1863
|
+
"--format",
|
|
1864
|
+
"-f",
|
|
1865
|
+
type=click.Choice(["table", "json"]),
|
|
1866
|
+
default="table",
|
|
1867
|
+
show_default=True,
|
|
1868
|
+
help="Output format",
|
|
1869
|
+
)
|
|
1870
|
+
def get_workflow(
|
|
1871
|
+
workflow_id: Optional[str],
|
|
1872
|
+
workflow_name: Optional[str],
|
|
1873
|
+
format: str,
|
|
1874
|
+
) -> None:
|
|
1875
|
+
"""Show workflow details by ID or name."""
|
|
1876
|
+
if not workflow_id and not workflow_name:
|
|
1877
|
+
click.echo("✗ Must provide either --id or --name.", err=True)
|
|
1878
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1879
|
+
if workflow_id and workflow_name:
|
|
1880
|
+
click.echo("✗ Cannot specify both --id and --name.", err=True)
|
|
1881
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1882
|
+
|
|
1883
|
+
format_output = validate_output_format(format)
|
|
1884
|
+
|
|
1885
|
+
try:
|
|
1886
|
+
if workflow_name:
|
|
1887
|
+
query_url = _wf_url("/query-workflows")
|
|
1888
|
+
query_resp = make_api_request(
|
|
1889
|
+
"POST",
|
|
1890
|
+
query_url,
|
|
1891
|
+
{"take": 1000, "filter": f'NAME == "{workflow_name}"'},
|
|
1892
|
+
)
|
|
1893
|
+
workflows = query_resp.json().get("workflows", [])
|
|
1894
|
+
matching = [w for w in workflows if w.get("name") == workflow_name]
|
|
1895
|
+
if not matching:
|
|
1896
|
+
click.echo(f"✗ Workflow '{workflow_name}' not found.", err=True)
|
|
1897
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
1898
|
+
workflow_id = matching[0].get("id", "")
|
|
1899
|
+
|
|
1900
|
+
url = _wf_url(f"/workflows/{workflow_id}")
|
|
1901
|
+
resp = make_api_request("GET", url)
|
|
1902
|
+
wfl = resp.json()
|
|
1903
|
+
|
|
1904
|
+
if not wfl:
|
|
1905
|
+
click.echo(f"✗ Workflow '{workflow_id}' not found.", err=True)
|
|
1906
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
1907
|
+
|
|
1908
|
+
if format_output == "json":
|
|
1909
|
+
click.echo(json.dumps(wfl, indent=2))
|
|
1910
|
+
return
|
|
1911
|
+
|
|
1912
|
+
workspace_map = get_workspace_map()
|
|
1913
|
+
ws_name = get_workspace_display_name(wfl.get("workspace", ""), workspace_map)
|
|
1914
|
+
click.echo("Workflow Details:")
|
|
1915
|
+
click.echo("=" * 50)
|
|
1916
|
+
click.echo(f"Name: {wfl.get('name', 'N/A')}")
|
|
1917
|
+
click.echo(f"ID: {wfl.get('id', 'N/A')}")
|
|
1918
|
+
click.echo(f"Workspace: {ws_name}")
|
|
1919
|
+
click.echo(f"Description: {wfl.get('description', 'N/A')}")
|
|
1920
|
+
|
|
1921
|
+
except Exception as exc:
|
|
1922
|
+
handle_api_error(exc)
|
|
1923
|
+
|
|
1924
|
+
@workflow_group.command(name="export")
|
|
1925
|
+
@click.option("--id", "-i", "workflow_id", help="Workflow ID to export")
|
|
1926
|
+
@click.option("--name", "-n", "workflow_name", help="Workflow name to export")
|
|
1927
|
+
@click.option("--output", "-o", help="Output JSON file (default: <name>.json)")
|
|
1928
|
+
def export_workflow(
|
|
1929
|
+
workflow_id: Optional[str],
|
|
1930
|
+
workflow_name: Optional[str],
|
|
1931
|
+
output: Optional[str],
|
|
1932
|
+
) -> None:
|
|
1933
|
+
"""Export a workflow to a JSON file."""
|
|
1934
|
+
if not workflow_id and not workflow_name:
|
|
1935
|
+
click.echo("✗ Must provide either --id or --name.", err=True)
|
|
1936
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1937
|
+
if workflow_id and workflow_name:
|
|
1938
|
+
click.echo("✗ Cannot specify both --id and --name.", err=True)
|
|
1939
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1940
|
+
|
|
1941
|
+
if workflow_name:
|
|
1942
|
+
query_url = _wf_url("/query-workflows")
|
|
1943
|
+
query_resp = make_api_request(
|
|
1944
|
+
"POST",
|
|
1945
|
+
query_url,
|
|
1946
|
+
{"take": 1000, "filter": f'NAME == "{workflow_name}"'},
|
|
1947
|
+
)
|
|
1948
|
+
workflows = query_resp.json().get("workflows", [])
|
|
1949
|
+
matching = [w for w in workflows if w.get("name") == workflow_name]
|
|
1950
|
+
if not matching:
|
|
1951
|
+
click.echo(f"✗ Workflow '{workflow_name}' not found.", err=True)
|
|
1952
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
1953
|
+
workflow_id = matching[0].get("id", "")
|
|
1954
|
+
|
|
1955
|
+
url = _wf_url(f"/workflows/{workflow_id}")
|
|
1956
|
+
try:
|
|
1957
|
+
resp = make_api_request("GET", url)
|
|
1958
|
+
data = resp.json()
|
|
1959
|
+
|
|
1960
|
+
if not data:
|
|
1961
|
+
click.echo(f"✗ Workflow '{workflow_id}' not found.", err=True)
|
|
1962
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
1963
|
+
|
|
1964
|
+
if not output:
|
|
1965
|
+
wf_name = data.get("name", f"workflow-{workflow_id}")
|
|
1966
|
+
safe = sanitize_filename(wf_name, f"workflow-{workflow_id}")
|
|
1967
|
+
output = f"{safe}.json"
|
|
1968
|
+
|
|
1969
|
+
save_json_file(data, output)
|
|
1970
|
+
click.echo(f"✓ Workflow exported to {output}")
|
|
1971
|
+
except Exception as exc:
|
|
1972
|
+
handle_api_error(exc)
|
|
1973
|
+
|
|
1974
|
+
@workflow_group.command(name="import")
|
|
1975
|
+
@click.option("--file", "input_file", required=True, help="Input JSON file")
|
|
1976
|
+
@click.option(
|
|
1977
|
+
"--workspace",
|
|
1978
|
+
"-w",
|
|
1979
|
+
help="Override workspace name or ID (uses value from file if not specified)",
|
|
1980
|
+
)
|
|
1981
|
+
def import_workflow(input_file: str, workspace: Optional[str]) -> None:
|
|
1982
|
+
"""Import a workflow from JSON.
|
|
1983
|
+
|
|
1984
|
+
Workspace can be specified via --workspace or in the JSON file.
|
|
1985
|
+
The command line takes precedence over the file.
|
|
1986
|
+
"""
|
|
1987
|
+
from .utils import check_readonly_mode
|
|
1988
|
+
|
|
1989
|
+
check_readonly_mode("import a workflow")
|
|
1990
|
+
|
|
1991
|
+
url = _wf_url("/workflows")
|
|
1992
|
+
allowed_fields = {"name", "description", "actions", "states", "workspace"}
|
|
1993
|
+
|
|
1994
|
+
try:
|
|
1995
|
+
data = load_json_file(input_file)
|
|
1996
|
+
filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
|
|
1997
|
+
|
|
1998
|
+
if workspace:
|
|
1999
|
+
try:
|
|
2000
|
+
ws_id = get_workspace_id_with_fallback(workspace)
|
|
2001
|
+
filtered_data["workspace"] = ws_id
|
|
2002
|
+
except Exception as exc:
|
|
2003
|
+
click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
|
|
2004
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
2005
|
+
elif "workspace" not in filtered_data or not filtered_data["workspace"]:
|
|
2006
|
+
workspace = get_effective_workspace(None)
|
|
2007
|
+
if workspace:
|
|
2008
|
+
try:
|
|
2009
|
+
ws_id = get_workspace_id_with_fallback(workspace)
|
|
2010
|
+
filtered_data["workspace"] = ws_id
|
|
2011
|
+
except Exception as exc:
|
|
2012
|
+
click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
|
|
2013
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
2014
|
+
else:
|
|
2015
|
+
click.echo(
|
|
2016
|
+
"✗ Workspace required. Use --workspace or include 'workspace' in JSON.",
|
|
2017
|
+
err=True,
|
|
2018
|
+
)
|
|
2019
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
2020
|
+
elif filtered_data["workspace"] and not filtered_data["workspace"].startswith("//"):
|
|
2021
|
+
try:
|
|
2022
|
+
ws_id = get_workspace_id_with_fallback(filtered_data["workspace"])
|
|
2023
|
+
filtered_data["workspace"] = ws_id
|
|
2024
|
+
except Exception as exc:
|
|
2025
|
+
click.echo(
|
|
2026
|
+
f"✗ Error resolving workspace '{filtered_data['workspace']}': {exc}",
|
|
2027
|
+
err=True,
|
|
2028
|
+
)
|
|
2029
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
2030
|
+
|
|
2031
|
+
try:
|
|
2032
|
+
resp = make_api_request("POST", url, filtered_data, handle_errors=False)
|
|
2033
|
+
if resp.status_code == 201:
|
|
2034
|
+
response_data = resp.json() if resp.text.strip() else {}
|
|
2035
|
+
wf_id = response_data.get("id", "")
|
|
2036
|
+
if wf_id:
|
|
2037
|
+
click.echo(f"✓ Workflow imported successfully with ID: {wf_id}")
|
|
2038
|
+
else:
|
|
2039
|
+
click.echo("✓ Workflow imported successfully.")
|
|
2040
|
+
else:
|
|
2041
|
+
response_data = resp.json() if resp.text.strip() else {}
|
|
2042
|
+
_handle_workflow_error_response(response_data, "Workflow import failed")
|
|
2043
|
+
except requests.exceptions.HTTPError as http_exc:
|
|
2044
|
+
if hasattr(http_exc, "response") and http_exc.response is not None:
|
|
2045
|
+
try:
|
|
2046
|
+
response_data = (
|
|
2047
|
+
http_exc.response.json() if http_exc.response.text.strip() else {}
|
|
2048
|
+
)
|
|
2049
|
+
_handle_workflow_error_response(response_data, "Workflow import failed")
|
|
2050
|
+
except Exception:
|
|
2051
|
+
handle_api_error(http_exc)
|
|
2052
|
+
else:
|
|
2053
|
+
handle_api_error(http_exc)
|
|
2054
|
+
|
|
2055
|
+
except Exception as exc:
|
|
2056
|
+
handle_api_error(exc)
|
|
2057
|
+
|
|
2058
|
+
@workflow_group.command(name="delete")
|
|
2059
|
+
@click.option("--id", "-i", "workflow_id", required=True, help="Workflow ID to delete")
|
|
2060
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this workflow?")
|
|
2061
|
+
def delete_workflow(workflow_id: str) -> None:
|
|
2062
|
+
"""Delete a workflow by ID."""
|
|
2063
|
+
from .utils import check_readonly_mode
|
|
2064
|
+
|
|
2065
|
+
check_readonly_mode("delete a workflow")
|
|
2066
|
+
|
|
2067
|
+
url = _wf_url("/delete-workflows")
|
|
2068
|
+
payload = {"ids": [workflow_id]}
|
|
2069
|
+
try:
|
|
2070
|
+
try:
|
|
2071
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
2072
|
+
if resp.status_code in (200, 204):
|
|
2073
|
+
response_data = resp.json() if resp.text.strip() else {}
|
|
2074
|
+
_handle_workflow_delete_response(response_data, workflow_id)
|
|
2075
|
+
else:
|
|
2076
|
+
response_data = resp.json() if resp.text.strip() else {}
|
|
2077
|
+
_handle_workflow_delete_response(response_data, workflow_id)
|
|
2078
|
+
except requests.exceptions.HTTPError as http_exc:
|
|
2079
|
+
if hasattr(http_exc, "response") and http_exc.response is not None:
|
|
2080
|
+
try:
|
|
2081
|
+
response_data = (
|
|
2082
|
+
http_exc.response.json() if http_exc.response.text.strip() else {}
|
|
2083
|
+
)
|
|
2084
|
+
_handle_workflow_delete_response(response_data, workflow_id)
|
|
2085
|
+
except Exception:
|
|
2086
|
+
handle_api_error(http_exc)
|
|
2087
|
+
else:
|
|
2088
|
+
handle_api_error(http_exc)
|
|
2089
|
+
except Exception as exc:
|
|
2090
|
+
handle_api_error(exc)
|
|
2091
|
+
|
|
2092
|
+
@workflow_group.command(name="update")
|
|
2093
|
+
@click.option("--id", "-i", "workflow_id", required=True, help="Workflow ID to update")
|
|
2094
|
+
@click.option(
|
|
2095
|
+
"--file",
|
|
2096
|
+
"-f",
|
|
2097
|
+
"input_file",
|
|
2098
|
+
required=True,
|
|
2099
|
+
help="JSON file with updated workflow data",
|
|
2100
|
+
)
|
|
2101
|
+
@click.option("--workspace", "-w", help="Override workspace name or ID")
|
|
2102
|
+
def update_workflow(workflow_id: str, input_file: str, workspace: Optional[str]) -> None:
|
|
2103
|
+
"""Update a workflow from JSON."""
|
|
2104
|
+
from .utils import check_readonly_mode
|
|
2105
|
+
|
|
2106
|
+
check_readonly_mode("update a workflow")
|
|
2107
|
+
|
|
2108
|
+
url = _wf_url(f"/workflows/{workflow_id}")
|
|
2109
|
+
allowed_fields = {"name", "description", "actions", "states", "workspace"}
|
|
2110
|
+
try:
|
|
2111
|
+
data = load_json_file(input_file)
|
|
2112
|
+
filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
|
|
2113
|
+
|
|
2114
|
+
if workspace:
|
|
2115
|
+
try:
|
|
2116
|
+
ws_id = get_workspace_id_with_fallback(workspace)
|
|
2117
|
+
filtered_data["workspace"] = ws_id
|
|
2118
|
+
except Exception as exc:
|
|
2119
|
+
click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
|
|
2120
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
2121
|
+
elif "workspace" not in filtered_data or not filtered_data.get("workspace"):
|
|
2122
|
+
workspace = get_effective_workspace(None)
|
|
2123
|
+
if workspace:
|
|
2124
|
+
try:
|
|
2125
|
+
ws_id = get_workspace_id_with_fallback(workspace)
|
|
2126
|
+
filtered_data["workspace"] = ws_id
|
|
2127
|
+
except Exception as exc:
|
|
2128
|
+
click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
|
|
2129
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
2130
|
+
elif (
|
|
2131
|
+
"workspace" in filtered_data
|
|
2132
|
+
and filtered_data["workspace"]
|
|
2133
|
+
and not filtered_data["workspace"].startswith("//")
|
|
2134
|
+
):
|
|
2135
|
+
try:
|
|
2136
|
+
ws_id = get_workspace_id_with_fallback(filtered_data["workspace"])
|
|
2137
|
+
filtered_data["workspace"] = ws_id
|
|
2138
|
+
except Exception as exc:
|
|
2139
|
+
click.echo(
|
|
2140
|
+
f"✗ Error resolving workspace '{filtered_data['workspace']}': {exc}",
|
|
2141
|
+
err=True,
|
|
2142
|
+
)
|
|
2143
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
2144
|
+
|
|
2145
|
+
try:
|
|
2146
|
+
resp = make_api_request("PUT", url, filtered_data, handle_errors=False)
|
|
2147
|
+
if resp.status_code == 200:
|
|
2148
|
+
click.echo(f"✓ Workflow {workflow_id} updated successfully.")
|
|
2149
|
+
else:
|
|
2150
|
+
response_data = resp.json() if resp.text.strip() else {}
|
|
2151
|
+
_handle_workflow_error_response(response_data, "Workflow update failed")
|
|
2152
|
+
except requests.exceptions.HTTPError as http_exc:
|
|
2153
|
+
if hasattr(http_exc, "response") and http_exc.response is not None:
|
|
2154
|
+
try:
|
|
2155
|
+
response_data = (
|
|
2156
|
+
http_exc.response.json() if http_exc.response.text.strip() else {}
|
|
2157
|
+
)
|
|
2158
|
+
_handle_workflow_error_response(response_data, "Workflow update failed")
|
|
2159
|
+
except Exception:
|
|
2160
|
+
handle_api_error(http_exc)
|
|
2161
|
+
else:
|
|
2162
|
+
handle_api_error(http_exc)
|
|
2163
|
+
|
|
2164
|
+
except Exception as exc:
|
|
2165
|
+
handle_api_error(exc)
|
|
2166
|
+
|
|
2167
|
+
@workflow_group.command(name="preview")
|
|
2168
|
+
@click.option("--id", "-i", "workflow_id", help="Workflow ID to preview")
|
|
2169
|
+
@click.option(
|
|
2170
|
+
"--file",
|
|
2171
|
+
"-f",
|
|
2172
|
+
"input_file",
|
|
2173
|
+
help="Local JSON file to preview (use '-' for stdin)",
|
|
2174
|
+
)
|
|
2175
|
+
@click.option("--output", "-o", help="Output file path (default: open in browser)")
|
|
2176
|
+
@click.option(
|
|
2177
|
+
"--format",
|
|
2178
|
+
type=click.Choice(["html", "mmd"]),
|
|
2179
|
+
default="html",
|
|
2180
|
+
show_default=True,
|
|
2181
|
+
help="Output format",
|
|
2182
|
+
)
|
|
2183
|
+
@click.option("--no-emoji", is_flag=True, default=False, help="Disable emoji in action labels")
|
|
2184
|
+
@click.option("--no-legend", is_flag=True, default=False, help="Disable legend in HTML output")
|
|
2185
|
+
@click.option(
|
|
2186
|
+
"--no-open",
|
|
2187
|
+
is_flag=True,
|
|
2188
|
+
default=False,
|
|
2189
|
+
help="Do not auto-open browser when no --output provided",
|
|
2190
|
+
)
|
|
2191
|
+
def preview_workflow(
|
|
2192
|
+
workflow_id: Optional[str],
|
|
2193
|
+
input_file: Optional[str],
|
|
2194
|
+
output: Optional[str],
|
|
2195
|
+
format: str,
|
|
2196
|
+
no_emoji: bool,
|
|
2197
|
+
no_legend: bool,
|
|
2198
|
+
no_open: bool,
|
|
2199
|
+
) -> None:
|
|
2200
|
+
"""Preview a workflow as an HTML diagram or Mermaid file."""
|
|
2201
|
+
if bool(workflow_id) == bool(input_file):
|
|
2202
|
+
click.echo(
|
|
2203
|
+
"✗ Must specify exactly one of --id or --file (use --file - for stdin)",
|
|
2204
|
+
err=True,
|
|
2205
|
+
)
|
|
2206
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
2207
|
+
|
|
2208
|
+
try:
|
|
2209
|
+
workflow_data: Dict[str, Any]
|
|
2210
|
+
if workflow_id:
|
|
2211
|
+
url = _wf_url(f"/workflows/{workflow_id}")
|
|
2212
|
+
resp = make_api_request("GET", url)
|
|
2213
|
+
workflow_data = resp.json()
|
|
2214
|
+
if not workflow_data:
|
|
2215
|
+
click.echo(f"✗ Workflow with ID {workflow_id} not found.", err=True)
|
|
2216
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
2217
|
+
else:
|
|
2218
|
+
if input_file == "-":
|
|
2219
|
+
try:
|
|
2220
|
+
workflow_data = json.loads(sys.stdin.read())
|
|
2221
|
+
except json.JSONDecodeError as exc:
|
|
2222
|
+
click.echo(f"✗ Invalid JSON from stdin: {exc}", err=True)
|
|
2223
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
2224
|
+
else:
|
|
2225
|
+
assert input_file is not None
|
|
2226
|
+
workflow_data = load_json_file(input_file)
|
|
2227
|
+
|
|
2228
|
+
mermaid_code = workflow_preview.generate_mermaid_diagram(
|
|
2229
|
+
workflow_data, enable_emoji=not no_emoji
|
|
2230
|
+
)
|
|
2231
|
+
|
|
2232
|
+
if format == "mmd":
|
|
2233
|
+
if not output:
|
|
2234
|
+
output = f"workflow-{workflow_data.get('name', 'preview')}.mmd"
|
|
2235
|
+
with open(output, "w", encoding="utf-8") as fh:
|
|
2236
|
+
fh.write(mermaid_code)
|
|
2237
|
+
click.echo(f"✓ Mermaid diagram saved to {output}")
|
|
2238
|
+
else:
|
|
2239
|
+
html_content = workflow_preview.generate_html_with_mermaid(
|
|
2240
|
+
workflow_data, mermaid_code, include_legend=not no_legend
|
|
2241
|
+
)
|
|
2242
|
+
if output:
|
|
2243
|
+
with open(output, "w", encoding="utf-8") as fh:
|
|
2244
|
+
fh.write(html_content)
|
|
2245
|
+
click.echo(f"✓ HTML preview saved to {output}")
|
|
2246
|
+
elif not no_open:
|
|
2247
|
+
with tempfile.NamedTemporaryFile(
|
|
2248
|
+
mode="w", suffix=".html", delete=False, encoding="utf-8"
|
|
2249
|
+
) as fh:
|
|
2250
|
+
fh.write(html_content)
|
|
2251
|
+
temp_file = fh.name
|
|
2252
|
+
webbrowser.open(f"file://{Path(temp_file).absolute()}")
|
|
2253
|
+
click.echo("✓ Opening workflow preview in browser...")
|
|
2254
|
+
else:
|
|
2255
|
+
click.echo(html_content)
|
|
2256
|
+
|
|
2257
|
+
except Exception as exc:
|
|
2258
|
+
handle_api_error(exc)
|