@iamcoder18/huly-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +2576 -0
  2. package/bin/huly +9 -0
  3. package/dist/auth/cache.js +129 -0
  4. package/dist/auth/cache.js.map +1 -0
  5. package/dist/auth/client.js +192 -0
  6. package/dist/auth/client.js.map +1 -0
  7. package/dist/auth/env.js +101 -0
  8. package/dist/auth/env.js.map +1 -0
  9. package/dist/auth/prompts.js +68 -0
  10. package/dist/auth/prompts.js.map +1 -0
  11. package/dist/cli.js +1959 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/commands/dry-run.js +39 -0
  14. package/dist/commands/dry-run.js.map +1 -0
  15. package/dist/commands/login.js +92 -0
  16. package/dist/commands/login.js.map +1 -0
  17. package/dist/commands/whoami.js +64 -0
  18. package/dist/commands/whoami.js.map +1 -0
  19. package/dist/index.js +59 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/output/errors.js +99 -0
  22. package/dist/output/errors.js.map +1 -0
  23. package/dist/output/format.js +607 -0
  24. package/dist/output/format.js.map +1 -0
  25. package/dist/output/progress.js +30 -0
  26. package/dist/output/progress.js.map +1 -0
  27. package/dist/raw/api.js +67 -0
  28. package/dist/raw/api.js.map +1 -0
  29. package/dist/raw/ws.js +157 -0
  30. package/dist/raw/ws.js.map +1 -0
  31. package/dist/resources/_helpers.js +258 -0
  32. package/dist/resources/_helpers.js.map +1 -0
  33. package/dist/resources/_project-resolve.js +24 -0
  34. package/dist/resources/_project-resolve.js.map +1 -0
  35. package/dist/resources/calendar.js +659 -0
  36. package/dist/resources/calendar.js.map +1 -0
  37. package/dist/resources/card.js +358 -0
  38. package/dist/resources/card.js.map +1 -0
  39. package/dist/resources/channel.js +709 -0
  40. package/dist/resources/channel.js.map +1 -0
  41. package/dist/resources/comment.js +142 -0
  42. package/dist/resources/comment.js.map +1 -0
  43. package/dist/resources/component.js +154 -0
  44. package/dist/resources/component.js.map +1 -0
  45. package/dist/resources/document.js +584 -0
  46. package/dist/resources/document.js.map +1 -0
  47. package/dist/resources/issue-template.js +228 -0
  48. package/dist/resources/issue-template.js.map +1 -0
  49. package/dist/resources/issue.js +909 -0
  50. package/dist/resources/issue.js.map +1 -0
  51. package/dist/resources/milestone.js +177 -0
  52. package/dist/resources/milestone.js.map +1 -0
  53. package/dist/resources/misc.js +2 -0
  54. package/dist/resources/misc.js.map +1 -0
  55. package/dist/resources/project.js +341 -0
  56. package/dist/resources/project.js.map +1 -0
  57. package/dist/resources/project.parse.js +25 -0
  58. package/dist/resources/project.parse.js.map +1 -0
  59. package/dist/resources/time.js +148 -0
  60. package/dist/resources/time.js.map +1 -0
  61. package/dist/resources/todo.js +463 -0
  62. package/dist/resources/todo.js.map +1 -0
  63. package/dist/resources/user.js +131 -0
  64. package/dist/resources/user.js.map +1 -0
  65. package/dist/resources/workspace.js +252 -0
  66. package/dist/resources/workspace.js.map +1 -0
  67. package/dist/transport/identifiers.js +67 -0
  68. package/dist/transport/identifiers.js.map +1 -0
  69. package/dist/transport/ref-resolver.js +108 -0
  70. package/dist/transport/ref-resolver.js.map +1 -0
  71. package/dist/transport/sdk.js +69 -0
  72. package/dist/transport/sdk.js.map +1 -0
  73. package/dist/types.js +2 -0
  74. package/dist/types.js.map +1 -0
  75. package/package.json +40 -0
@@ -0,0 +1,909 @@
1
+ import pkg from '@hcengineering/api-client';
2
+ const { MarkupContent } = pkg;
3
+ import { CLASS } from '../transport/identifiers.js';
4
+ import { connectCli } from '../transport/sdk.js';
5
+ import { resolveRef, resolveRefs, buildIndex, invalidateIndex } from '../transport/ref-resolver.js';
6
+ import { shouldJson, json, table, kv, header, withTimeout, COLUMNS, C, colorizeStatus, statusGlyph, priorityGlyph, relTime, isoDate, isoDay, success, updated, bulkRemoved } from "../output/format.js";
7
+ import { resolveAssignee } from "./_helpers.js";
8
+ import { withSpinner } from '../output/progress.js';
9
+ import { deleteDoc } from '../commands/dry-run.js';
10
+ import { CliError, ExitCode } from '../output/errors.js';
11
+ import { readEnv } from '../auth/env.js';
12
+ import { pickProject } from '../auth/prompts.js';
13
+ function parseDate(value, field) {
14
+ const t = new Date(value).getTime();
15
+ if (Number.isNaN(t))
16
+ throw new CliError(ExitCode.Validation, `invalid ${field}: ${value} (expected ISO date)`);
17
+ return t;
18
+ }
19
+ async function readBody(opts) {
20
+ if (opts.body && opts.bodyFile) {
21
+ throw new CliError(ExitCode.Validation, 'ambiguous body input', 'pass only one of --body or --body-file');
22
+ }
23
+ if (opts.bodyFile) {
24
+ const fs = await import('node:fs/promises');
25
+ return (await fs.readFile(opts.bodyFile, 'utf8')).trim();
26
+ }
27
+ if (opts.body)
28
+ return opts.body;
29
+ return undefined;
30
+ }
31
+ async function resolveProject(client, identifier) {
32
+ const env = readEnv();
33
+ const candidate = identifier ?? env.project;
34
+ const account = await client.getAccount();
35
+ const idx = await buildIndex(client, CLASS.Project, account.uuid);
36
+ if (candidate) {
37
+ const hit = idx.get(candidate);
38
+ if (hit) {
39
+ const doc = await client.findOne(CLASS.Project, { _id: hit });
40
+ if (doc)
41
+ return doc;
42
+ }
43
+ throw new CliError(ExitCode.NotFound, `project ${candidate} not found`);
44
+ }
45
+ const all = (await client.findAll(CLASS.Project, {}));
46
+ if (all.length === 0)
47
+ throw new CliError(ExitCode.NotFound, 'no projects in workspace');
48
+ return await pickProject(all, 'Project:');
49
+ }
50
+ async function firstStatus(client, project) {
51
+ const ofAttribute = 'tracker:attribute:IssueStatus';
52
+ const statuses = (await client.findAll(CLASS.IssueStatus, { ofAttribute }));
53
+ if (statuses.length === 0) {
54
+ await ensureDefaultStatuses(client, project);
55
+ const rechecked = (await client.findAll(CLASS.IssueStatus, { ofAttribute }));
56
+ if (rechecked.length === 0) {
57
+ throw new CliError(ExitCode.NotFound, `no IssueStatus in workspace; tried to auto-seed but the workspace model may be missing tracker attributes`, 'try running: huly issue create with --status in another workspace, then come back');
58
+ }
59
+ return rechecked.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))[0]._id;
60
+ }
61
+ return statuses.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))[0]._id;
62
+ }
63
+ /**
64
+ * Auto-seed a default set of IssueStatus records into a project that has none.
65
+ * Models after the model-tracker migration's classicStatuses:
66
+ * Backlog / To do / In progress / Done / Canceled
67
+ * The space is the model space (core:space:Model) per the platform
68
+ * pattern — IssueStatus is a model entity, not a per-project entity.
69
+ */
70
+ async function ensureDefaultStatuses(client, project) {
71
+ const ofAttribute = 'tracker:attribute:IssueStatus';
72
+ const defaults = [
73
+ { name: 'Backlog', color: 0, rank: '0|aaaaa:' },
74
+ { name: 'To do', color: 0, rank: '1|aaaaa:' },
75
+ { name: 'In progress', color: 0, rank: '2|aaaaa:' },
76
+ { name: 'Done', color: 0, rank: '3|aaaaa:' },
77
+ { name: 'Canceled', color: 0, rank: '4|aaaaa:' }
78
+ ];
79
+ // The CLI's local model believes IssueStatus inherits AttachedDoc (false).
80
+ // createDoc refuses to create AttachedDoc instances, so seeding fails.
81
+ // The workspace pod's model-upgrade txes may have already created these
82
+ // statuses — firstStatus will detect that on the second try.
83
+ for (const s of defaults) {
84
+ try {
85
+ await client.createDoc(CLASS.IssueStatus, 'core:space:Model', {
86
+ ofAttribute,
87
+ name: s.name,
88
+ color: s.color,
89
+ rank: s.rank,
90
+ space: project._id
91
+ });
92
+ }
93
+ catch {
94
+ // ignore — local model routing failure or already-exists
95
+ }
96
+ }
97
+ }
98
+ async function resolveStatus(client, project, name) {
99
+ if (!name)
100
+ return await firstStatus(client, project);
101
+ const ofAttribute = 'tracker:attribute:IssueStatus';
102
+ const all = (await client.findAll(CLASS.IssueStatus, { ofAttribute }));
103
+ const hit = all.find((s) => s.label?.toLowerCase() === name.toLowerCase() || s.name?.toLowerCase() === name.toLowerCase());
104
+ if (!hit)
105
+ throw new CliError(ExitCode.NotFound, `status ${name} not found in workspace; available: ${all.map((s) => s.label ?? s.name).join(', ')}`);
106
+ return hit._id;
107
+ }
108
+ async function resolvePriority(client, name) {
109
+ // TypeIssuePriority lives in DOMAIN_MODEL. The CLI's local model is incomplete
110
+ // so both client.findAll (local model) and conn.findAll (server may not have
111
+ // tracker migration applied) can return 0. As a last resort, fall back to the
112
+ // well-known classic tracker priority IDs which are deterministic across
113
+ // workspaces (derived from the rank value).
114
+ const conn = client.connection;
115
+ const queryAll = async () => {
116
+ if (conn !== undefined) {
117
+ const r = await conn.findAll(CLASS.TypeIssuePriority, {});
118
+ return r;
119
+ }
120
+ const r = await client.findAll(CLASS.TypeIssuePriority, {});
121
+ return r;
122
+ };
123
+ if (name) {
124
+ const all = await queryAll();
125
+ const hit = all.find((p) => p.label?.toLowerCase() === name.toLowerCase() || p.name?.toLowerCase() === name.toLowerCase());
126
+ if (hit)
127
+ return hit._id;
128
+ // CLI-13: explicit --priority with no matching priority must throw
129
+ // rather than silently dropping the user input.
130
+ const available = all.map((p) => p.label ?? p.name ?? '').filter(Boolean);
131
+ throw new CliError(ExitCode.Validation, `priority "${name}" not found in this workspace`, `available priorities: ${available.length > 0 ? available.join(', ') : '(none — workspace may not have tracker migration applied)'}`);
132
+ }
133
+ const all = await queryAll();
134
+ const normal = all.find((p) => p.label === 'Normal');
135
+ if (normal)
136
+ return normal._id;
137
+ if (all.length > 0)
138
+ return all[0]._id;
139
+ // No priorities and no migration. Skip priority — the issue will be created
140
+ // without a priority field (workspaces without migration have no priorities
141
+ // but can still create issues via direct tx).
142
+ return undefined;
143
+ }
144
+ async function resolveTaskType(client, name, project) {
145
+ const taskTypes = (await client.findAll(CLASS.TaskType, { space: project._id }));
146
+ const hit = taskTypes.find((t) => (t.label?.toLowerCase() === name.toLowerCase()) ||
147
+ (t.name?.toLowerCase() === name.toLowerCase()) ||
148
+ String(t._id) === name);
149
+ if (!hit) {
150
+ throw new CliError(ExitCode.NotFound, `task type ${name} not found in project ${project.identifier}`, `available: ${taskTypes.map((t) => t.label ?? t.name ?? t._id).join(', ') || '(none)'}`);
151
+ }
152
+ return hit._id;
153
+ }
154
+ export async function listIssues(opts) {
155
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
156
+ try {
157
+ const project = opts.project ? await resolveProject(client, opts.project) : null;
158
+ const query = {};
159
+ if (project)
160
+ query.space = project._id;
161
+ if (opts.assignee)
162
+ query.assignee = await resolveAssignee(client, opts.assignee);
163
+ if (opts.label && opts.label.length > 0)
164
+ query.labels = { $in: opts.label };
165
+ // Status filter — either direct name or by category
166
+ if (opts.status) {
167
+ let proj = project;
168
+ if (!proj) {
169
+ // Try workspace env var first, then pick the first project
170
+ const env = readEnv();
171
+ if (env.project)
172
+ proj = await resolveProject(client, env.project);
173
+ else {
174
+ const all = (await client.findAll(CLASS.Project, {}));
175
+ proj = all[0] ?? null;
176
+ }
177
+ }
178
+ if (!proj)
179
+ throw new CliError(ExitCode.Validation, '--status requires a project (use --project)');
180
+ query.status = await resolveStatus(client, proj, opts.status);
181
+ }
182
+ else if (opts.statusCategory) {
183
+ const wanted = String(opts.statusCategory);
184
+ const valid = ['UnStarted', 'ToDo', 'Active', 'Won', 'Lost'];
185
+ if (!valid.includes(wanted)) {
186
+ throw new CliError(ExitCode.Validation, `invalid --status-category: ${wanted}`, `expected one of ${valid.join(' | ')}`);
187
+ }
188
+ // CLI-11: statusCategory values are stored as "task:statusCategory:Active".
189
+ // Strip the prefix before comparing. Also resolve a project when one
190
+ // wasn't supplied (statuses are not workspace-global).
191
+ let proj = project;
192
+ if (!proj) {
193
+ const all = (await client.findAll(CLASS.Project, {}));
194
+ proj = all[0] ?? null;
195
+ }
196
+ if (!proj)
197
+ throw new CliError(ExitCode.Validation, '--status-category requires a project (use --project)');
198
+ const statuses = (await client.findAll(CLASS.IssueStatus, { space: proj._id }));
199
+ const stripPrefix = (cat) => cat.replace(/^task:statusCategory:/, '');
200
+ const matchingIds = statuses
201
+ .filter((s) => stripPrefix(String(s.category ?? '')).toLowerCase() === wanted.toLowerCase())
202
+ .map((s) => s._id);
203
+ if (matchingIds.length === 0) {
204
+ console.log('(no statuses in that category)');
205
+ return;
206
+ }
207
+ query.status = { $in: matchingIds };
208
+ }
209
+ // Description search — best-effort full-text via the REST API. The
210
+ // PlatformClient (websocket) doesn't expose searchFulltext, so we use
211
+ // a regex match on the description field (which is a markup blob) when
212
+ // possible. If the server doesn't support that pattern, results will be
213
+ // an empty set, which is no worse than not searching.
214
+ if (opts.descriptionSearch) {
215
+ query.description = { $regex: opts.descriptionSearch, $options: 'i' };
216
+ }
217
+ // Parent filter
218
+ if (opts.parent !== undefined) {
219
+ const parentRef = opts.parent === 'null' || opts.parent === '-'
220
+ ? null
221
+ : await resolveRef(opts.parent, {
222
+ client,
223
+ classId: CLASS.Issue,
224
+ workspaceId: (await client.getAccount()).uuid,
225
+ defaultProjectIdentifier: readEnv().project
226
+ });
227
+ query.parent = parentRef;
228
+ }
229
+ const result = (await withSpinner('Loading issues…', () => client.findAll(CLASS.Issue, query), opts));
230
+ let docs = result;
231
+ if (opts.offset && opts.offset > 0)
232
+ docs = docs.slice(opts.offset);
233
+ if (opts.limit && opts.limit > 0)
234
+ docs = docs.slice(0, opts.limit);
235
+ if (shouldJson({ json: opts.json, ci: opts.ci })) {
236
+ json(docs);
237
+ return;
238
+ }
239
+ table(docs, COLUMNS.issue(), { count: true, title: 'issues' });
240
+ }
241
+ finally {
242
+ await client.close();
243
+ }
244
+ }
245
+ export async function getIssue(ref, opts = {}) {
246
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
247
+ try {
248
+ const account = await client.getAccount();
249
+ const id = await resolveRef(ref, {
250
+ client,
251
+ classId: CLASS.Issue,
252
+ workspaceId: account.uuid,
253
+ defaultProjectIdentifier: readEnv().project
254
+ });
255
+ const issue = await client.findOne(CLASS.Issue, { _id: id });
256
+ if (!issue)
257
+ throw new CliError(ExitCode.NotFound, `issue ${ref} not found`);
258
+ if (opts.markdown && issue.description) {
259
+ try {
260
+ const body = await withTimeout(client.fetchMarkup(CLASS.Issue, issue._id, 'description', issue.description, 'markdown'), 5000, '(body fetch timed out)');
261
+ console.log(body);
262
+ return;
263
+ }
264
+ catch {
265
+ console.log(String(issue.description));
266
+ return;
267
+ }
268
+ }
269
+ if (shouldJson({ json: opts.json, ci: opts.ci })) {
270
+ json(issue);
271
+ return;
272
+ }
273
+ const status = String(issue.status ?? '');
274
+ const priority = String(issue.priority ?? '');
275
+ const identifier = String(issue.identifier ?? '') || '—';
276
+ const title = String(issue.title ?? '(untitled)');
277
+ // Resolve project and parent to friendly names
278
+ let projectName = null;
279
+ if (issue.space) {
280
+ const p = await client.findOne(CLASS.Project, { _id: issue.space });
281
+ projectName = p ? String(p.identifier ?? p.name ?? '') : null;
282
+ }
283
+ let parentRef = null;
284
+ if (issue.parent) {
285
+ const p = await client.findOne(CLASS.Issue, { _id: issue.parent });
286
+ parentRef = p ? String(p.identifier ?? p.title ?? p._id) : null;
287
+ }
288
+ // Resolve assignee to email from cache
289
+ let assigneeLabel = null;
290
+ if (issue.assignee) {
291
+ const a = await client.findOne(CLASS.Account, { _id: issue.assignee });
292
+ if (a) {
293
+ const a2 = a;
294
+ assigneeLabel = a2.email ?? a2.name ?? null;
295
+ }
296
+ }
297
+ const headerTitle = identifier !== '—' ? `Issue ${identifier} — ${title}` : `Issue · ${title}`;
298
+ header(headerTitle, { subtitle: `created ${relTime(issue.createdOn)} · updated ${relTime(issue.modifiedOn)}` });
299
+ kv([
300
+ ['ID', identifier !== '—' ? C.emphasis(identifier) : C.muted('—')],
301
+ ['Status', `${statusGlyph(status)} ${colorizeStatus(status)}`],
302
+ ['Priority', priorityGlyph(priority)],
303
+ ['Kind', String(issue.kind ?? '—').replace(/^tracker:issue:/, '')],
304
+ ['Project', projectName != null ? C.emphasis(projectName) : C.muted('—')],
305
+ ['Parent', parentRef != null ? C.emphasis(parentRef) : C.muted('—')],
306
+ ['Due', issue.dueDate != null ? isoDay(issue.dueDate) : C.muted('none')],
307
+ ['Labels', Array.isArray(issue.labels) && issue.labels.length > 0 ? issue.labels.join(', ') : C.muted('none')],
308
+ ['Assignee', assigneeLabel != null ? assigneeLabel : C.muted('unassigned')],
309
+ ['Created', issue.createdOn != null ? `${isoDate(issue.createdOn)} (${relTime(issue.createdOn)})` : C.muted('—')],
310
+ ['Modified', issue.modifiedOn != null ? `${isoDate(issue.modifiedOn)} (${relTime(issue.modifiedOn)})` : C.muted('—')],
311
+ ['_id', C.id(String(issue._id))]
312
+ ]);
313
+ if (issue.description !== '' && issue.description !== undefined && !opts.markdown) {
314
+ console.log();
315
+ console.log(C.emphasis('Description'));
316
+ console.log(C.muted('─'.repeat(20)));
317
+ const desc = String(issue.description);
318
+ console.log(desc.length > 500 ? desc.slice(0, 500) + '…\n' + C.muted('(truncated — use --markdown for full)') : desc);
319
+ }
320
+ }
321
+ finally {
322
+ await client.close();
323
+ }
324
+ }
325
+ export async function createIssue(opts) {
326
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
327
+ try {
328
+ const project = await resolveProject(client, opts.project);
329
+ const title = opts.title;
330
+ if (!title)
331
+ throw new CliError(ExitCode.Validation, 'missing --title');
332
+ const status = await resolveStatus(client, project, opts.status);
333
+ const priority = await resolvePriority(client, opts.priority);
334
+ const body = await readBody(opts);
335
+ const dueDate = opts.due ? parseDate(opts.due, '--due') : null;
336
+ const data = {
337
+ title,
338
+ description: opts.description ?? '',
339
+ status,
340
+ labels: opts.label ?? [],
341
+ dueDate,
342
+ space: project._id
343
+ };
344
+ if (priority !== undefined)
345
+ data.priority = priority;
346
+ if (opts.taskType) {
347
+ data.kind = await resolveTaskType(client, opts.taskType, project);
348
+ }
349
+ else {
350
+ data.kind = 'tracker:issue:default';
351
+ }
352
+ if (opts.parent) {
353
+ const parentAccount = await client.getAccount();
354
+ data.parent = await resolveRef(opts.parent, {
355
+ client,
356
+ classId: CLASS.Issue,
357
+ workspaceId: parentAccount.uuid,
358
+ defaultProjectIdentifier: readEnv().project
359
+ });
360
+ }
361
+ else if (!opts.minimal) {
362
+ // CLI-12: top-level issues must have parent=null so that
363
+ // `issue list --parent null` matches them. Setting parent=project._id
364
+ // would create a phantom parent and break the CLI's own filter.
365
+ data.parent = null;
366
+ }
367
+ if (!opts.minimal) {
368
+ data.project = project._id;
369
+ }
370
+ if (opts.assignee) {
371
+ data.assignee = await resolveAssignee(client, opts.assignee);
372
+ }
373
+ if (body)
374
+ data.description = body;
375
+ if (opts.dryRun) {
376
+ console.log('would create issue:');
377
+ console.log(JSON.stringify({ _class: CLASS.Issue, space: project._id, data }, null, 2));
378
+ return;
379
+ }
380
+ let id;
381
+ try {
382
+ id = await withSpinner('Creating issue…', () => client.createDoc(CLASS.Issue, project._id, data));
383
+ }
384
+ catch (err) {
385
+ // Workaround for C3: SDK's local model has incomplete inheritance info and
386
+ // thinks tracker:class:Issue inherits from AttachedDoc. TxOperations.createDoc
387
+ // refuses to create AttachedDoc instances. Bypass by building the TxCreateDoc
388
+ // manually and applying via the raw connection.tx RPC.
389
+ const msg = err instanceof Error ? err.message : String(err);
390
+ if (/createDoc cannot be used for objects inherited from AttachedDoc/i.test(msg)) {
391
+ const conn = client.connection;
392
+ const txFactory = client.client?.txFactory;
393
+ if (conn !== undefined && txFactory !== undefined) {
394
+ id = await withSpinner('Creating issue (bypass AttachedDoc check)…', async () => {
395
+ const tx = txFactory.createTxCreateDoc(CLASS.Issue, project._id, data, undefined);
396
+ await conn.tx(tx);
397
+ return tx._id;
398
+ });
399
+ if (id === undefined)
400
+ throw err;
401
+ }
402
+ else {
403
+ throw err;
404
+ }
405
+ }
406
+ else if (/duplicate|exists|already/i.test(msg)) {
407
+ // Idempotency: if an issue with the same title already exists in this project, return it.
408
+ const existing = (await client.findAll(CLASS.Issue, {
409
+ space: project._id,
410
+ title
411
+ }));
412
+ if (existing.length > 0) {
413
+ const found = existing[0];
414
+ if (shouldJson({ json: opts.json, ci: opts.ci })) {
415
+ json({ _id: found._id, identifier: found.identifier, title, created: false });
416
+ }
417
+ else {
418
+ console.log(`issue exists: ${found.identifier ?? found._id} (${found.title})`);
419
+ }
420
+ return;
421
+ }
422
+ throw err;
423
+ }
424
+ else {
425
+ throw err;
426
+ }
427
+ }
428
+ invalidateIndex((await client.getAccount()).uuid, CLASS.Issue);
429
+ if (shouldJson({ json: opts.json, ci: opts.ci })) {
430
+ json({ _id: id, identifier: '?', title, created: true, ...data });
431
+ return;
432
+ }
433
+ // The id from createDoc may differ from the server-assigned _id (the bypass
434
+ // path uses the locally-computed tx._id). Look up the actual stored doc so
435
+ // users can immediately `huly issue get <id>` to inspect their creation.
436
+ let actualId = id;
437
+ try {
438
+ const fresh = (await client.findOne(CLASS.Issue, { title }));
439
+ if (fresh?._id != null)
440
+ actualId = fresh._id;
441
+ }
442
+ catch { /* fall through with the local id */ }
443
+ console.log(C.ok('created issue') + C.muted(' ') + C.emphasis(title) + C.muted(' ') + C.id(`(${actualId})`));
444
+ }
445
+ finally {
446
+ await client.close();
447
+ }
448
+ }
449
+ export async function updateIssue(ref, opts) {
450
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
451
+ try {
452
+ const account = await client.getAccount();
453
+ const id = await resolveRef(ref, {
454
+ client,
455
+ classId: CLASS.Issue,
456
+ workspaceId: account.uuid,
457
+ defaultProjectIdentifier: readEnv().project
458
+ });
459
+ const issue = await client.findOne(CLASS.Issue, { _id: id });
460
+ if (!issue)
461
+ throw new CliError(ExitCode.NotFound, `issue ${ref} not found`);
462
+ const project = await client.findOne(CLASS.Project, { _id: issue.space });
463
+ if (!project)
464
+ throw new CliError(ExitCode.NotFound, 'project space not found');
465
+ const ops = {};
466
+ for (const item of opts.set ?? []) {
467
+ const eq = item.indexOf('=');
468
+ if (eq < 0)
469
+ throw new CliError(ExitCode.Validation, `invalid --set entry (expected key=value): ${item}`);
470
+ const k = item.slice(0, eq).trim();
471
+ let v = item.slice(eq + 1).trim();
472
+ if (v === 'null')
473
+ v = null;
474
+ else if (v === 'true')
475
+ v = true;
476
+ else if (v === 'false')
477
+ v = false;
478
+ else if (/^-?\d+(\.\d+)?$/.test(String(v)))
479
+ v = Number(v);
480
+ ops[k] = v;
481
+ }
482
+ for (const k of opts.unset ?? [])
483
+ ops[k] = null;
484
+ if (opts.status)
485
+ ops.status = await resolveStatus(client, project, opts.status);
486
+ if (opts.priority) {
487
+ const p = await resolvePriority(client, opts.priority);
488
+ if (p !== undefined)
489
+ ops.priority = p;
490
+ }
491
+ if (opts.assignee)
492
+ ops.assignee = await resolveAssignee(client, opts.assignee);
493
+ if (opts.title)
494
+ ops.title = opts.title;
495
+ if (opts.description)
496
+ ops.description = opts.description;
497
+ if (opts.taskType)
498
+ ops.kind = await resolveTaskType(client, opts.taskType, project);
499
+ if (opts.dryRun) {
500
+ console.log(`would update issue ${issue.identifier} (${issue._id}):`);
501
+ console.log(JSON.stringify({ _class: CLASS.Issue, objectId: issue._id, space: issue.space, ops }, null, 2));
502
+ return;
503
+ }
504
+ await withSpinner('Updating…', () => client.updateDoc(CLASS.Issue, issue.space, issue._id, ops));
505
+ updated(`updated issue`, issue._id);
506
+ }
507
+ finally {
508
+ await client.close();
509
+ }
510
+ }
511
+ export async function deleteIssues(refs, opts = {}) {
512
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
513
+ try {
514
+ const account = await client.getAccount();
515
+ const ids = await resolveRefs(refs, {
516
+ client,
517
+ classId: CLASS.Issue,
518
+ workspaceId: account.uuid,
519
+ defaultProjectIdentifier: readEnv().project
520
+ });
521
+ if (!opts.yes && ids.length > 1) {
522
+ throw new CliError(ExitCode.Validation, `destructive: deleting ${ids.length} issues requires --yes`, 're-run with --yes to confirm');
523
+ }
524
+ let deleted = 0, skipped = 0;
525
+ for (const id of ids) {
526
+ const issue = await client.findOne(CLASS.Issue, { _id: id });
527
+ if (!issue) {
528
+ skipped++;
529
+ continue;
530
+ }
531
+ const r = await deleteDoc(client, CLASS.Issue, issue.space, issue._id, opts);
532
+ if (r.skipped)
533
+ skipped++;
534
+ else {
535
+ deleted++;
536
+ await new Promise((res) => setTimeout(res, 100));
537
+ }
538
+ }
539
+ bulkRemoved(deleted, skipped);
540
+ }
541
+ finally {
542
+ await client.close();
543
+ }
544
+ }
545
+ // ---- Phase 3 additions ----
546
+ export async function addIssueLabel(ref, labelName, opts) {
547
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
548
+ try {
549
+ const account = await client.getAccount();
550
+ const issueId = await resolveRef(ref, {
551
+ client,
552
+ classId: CLASS.Issue,
553
+ workspaceId: account.uuid,
554
+ defaultProjectIdentifier: readEnv().project
555
+ });
556
+ const issue = await client.findOne(CLASS.Issue, { _id: issueId });
557
+ if (!issue)
558
+ throw new CliError(ExitCode.NotFound, `issue ${ref} not found`);
559
+ // Find or create the TagElement (label).
560
+ const tagClass = 'tags:class:TagElement';
561
+ const existingTags = (await client.findAll(tagClass, { title: labelName }));
562
+ let tagId;
563
+ const targetClass = CLASS.Issue;
564
+ const matchingTag = existingTags.find((t) => t.targetClass === targetClass || t.targetClass === undefined);
565
+ if (matchingTag) {
566
+ tagId = matchingTag._id;
567
+ }
568
+ else {
569
+ // Find a category for tags
570
+ const categories = (await client.findAll('tags:class:TagCategory', {}));
571
+ const category = categories[0]?._id;
572
+ tagId = await withSpinner('Creating label…', () => client.createDoc(tagClass, 'tags:space:Tag', {
573
+ title: labelName,
574
+ targetClass,
575
+ description: '',
576
+ color: 0,
577
+ category
578
+ }), opts);
579
+ }
580
+ // Add as TagReference collection.
581
+ await withSpinner('Adding label…', () => client.addCollection('tags:class:TagReference', issue.space, issue._id, CLASS.Issue, 'labels', { tag: tagId, title: labelName, color: 0 }), opts);
582
+ invalidateIndex(account.uuid, CLASS.Issue);
583
+ success(`added label`, labelName);
584
+ }
585
+ finally {
586
+ await client.close();
587
+ }
588
+ }
589
+ export async function removeIssueLabel(ref, labelName, opts) {
590
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
591
+ try {
592
+ const account = await client.getAccount();
593
+ const issueId = await resolveRef(ref, {
594
+ client,
595
+ classId: CLASS.Issue,
596
+ workspaceId: account.uuid,
597
+ defaultProjectIdentifier: readEnv().project
598
+ });
599
+ const issue = await client.findOne(CLASS.Issue, { _id: issueId });
600
+ if (!issue)
601
+ throw new CliError(ExitCode.NotFound, `issue ${ref} not found`);
602
+ const tagClass = 'tags:class:TagElement';
603
+ const tag = (await client.findAll(tagClass, { title: labelName }))[0];
604
+ if (!tag)
605
+ throw new CliError(ExitCode.NotFound, `label ${labelName} not found`);
606
+ const refs = (await client.findAll('tags:class:TagReference', {
607
+ attachedTo: issue._id,
608
+ tag: tag._id
609
+ }));
610
+ if (refs.length === 0)
611
+ throw new CliError(ExitCode.NotFound, `label ${labelName} not on issue ${ref}`);
612
+ for (const r of refs) {
613
+ await client.removeCollection('tags:class:TagReference', issue.space, r._id, issue._id, CLASS.Issue, 'labels');
614
+ }
615
+ invalidateIndex(account.uuid, CLASS.Issue);
616
+ success(`removed label`, labelName);
617
+ }
618
+ finally {
619
+ await client.close();
620
+ }
621
+ }
622
+ const RELATION_TYPES = ['blocks', 'isBlockedBy', 'relatesTo'];
623
+ export function validateRelationType(type) {
624
+ if (!RELATION_TYPES.includes(type)) {
625
+ throw new CliError(ExitCode.Validation, `invalid --type: ${type}`, `expected one of ${RELATION_TYPES.join(' | ')}`);
626
+ }
627
+ return type;
628
+ }
629
+ function relationField(type) {
630
+ return type === 'isBlockedBy' ? 'blockedBy' : 'relations';
631
+ }
632
+ function relationTag(type) {
633
+ if (type === 'blocks')
634
+ return 'blocks';
635
+ if (type === 'isBlockedBy')
636
+ return 'isBlockedBy';
637
+ return 'relatesTo';
638
+ }
639
+ export async function addIssueRelation(ref, type, targetRef, opts) {
640
+ const rel = validateRelationType(type);
641
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
642
+ try {
643
+ const account = await client.getAccount();
644
+ const sourceId = await resolveRef(ref, {
645
+ client,
646
+ classId: CLASS.Issue,
647
+ workspaceId: account.uuid,
648
+ defaultProjectIdentifier: readEnv().project
649
+ });
650
+ const targetId = await resolveRef(targetRef, {
651
+ client,
652
+ classId: CLASS.Issue,
653
+ workspaceId: account.uuid,
654
+ defaultProjectIdentifier: readEnv().project
655
+ });
656
+ const issue = await client.findOne(CLASS.Issue, { _id: sourceId });
657
+ if (!issue)
658
+ throw new CliError(ExitCode.NotFound, `issue ${ref} not found`);
659
+ const field = relationField(rel);
660
+ const existing = issue[field] ?? [];
661
+ const updated = [...existing, { _id: targetId, _class: CLASS.Issue }];
662
+ await withSpinner(`Adding ${rel} → ${targetRef}…`, () => client.updateDoc(CLASS.Issue, issue.space, issue._id, { [field]: updated }), opts);
663
+ success(`added ${rel}`, ref + ' → ' + targetRef);
664
+ }
665
+ finally {
666
+ await client.close();
667
+ }
668
+ }
669
+ export async function removeIssueRelation(ref, type, targetRef, opts) {
670
+ const rel = validateRelationType(type);
671
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
672
+ try {
673
+ const account = await client.getAccount();
674
+ const sourceId = await resolveRef(ref, {
675
+ client,
676
+ classId: CLASS.Issue,
677
+ workspaceId: account.uuid,
678
+ defaultProjectIdentifier: readEnv().project
679
+ });
680
+ const targetId = await resolveRef(targetRef, {
681
+ client,
682
+ classId: CLASS.Issue,
683
+ workspaceId: account.uuid,
684
+ defaultProjectIdentifier: readEnv().project
685
+ });
686
+ const issue = await client.findOne(CLASS.Issue, { _id: sourceId });
687
+ if (!issue)
688
+ throw new CliError(ExitCode.NotFound, `issue ${ref} not found`);
689
+ const field = relationField(rel);
690
+ const existing = issue[field] ?? [];
691
+ const updated = existing.filter((r) => r._id !== targetId);
692
+ await withSpinner(`Removing ${rel} → ${targetRef}…`, () => client.updateDoc(CLASS.Issue, issue.space, issue._id, { [field]: updated }), opts);
693
+ success(`removed ${rel}`, ref + ' → ' + targetRef);
694
+ }
695
+ finally {
696
+ await client.close();
697
+ }
698
+ }
699
+ export async function listIssueRelations(ref, opts) {
700
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
701
+ try {
702
+ const account = await client.getAccount();
703
+ const issueId = await resolveRef(ref, {
704
+ client,
705
+ classId: CLASS.Issue,
706
+ workspaceId: account.uuid,
707
+ defaultProjectIdentifier: readEnv().project
708
+ });
709
+ const issue = await client.findOne(CLASS.Issue, { _id: issueId });
710
+ if (!issue)
711
+ throw new CliError(ExitCode.NotFound, `issue ${ref} not found`);
712
+ const relations = issue.relations ?? [];
713
+ const blockedBy = issue.blockedBy ?? [];
714
+ const rows = [
715
+ ...relations.map((r) => ({ direction: 'relatesTo', _id: r._id })),
716
+ ...blockedBy.map((r) => ({ direction: 'isBlockedBy', _id: r._id }))
717
+ ];
718
+ if (shouldJson({ json: opts.json, ci: opts.ci })) {
719
+ json(rows);
720
+ return;
721
+ }
722
+ table(rows, [
723
+ { key: 'direction', header: 'DIRECTION', format: (r) => {
724
+ const d = String(r.direction);
725
+ return d === 'isBlockedBy' ? C.yellow('⛔ is blocked by') : C.muted('↔ relates to');
726
+ } },
727
+ { key: '_id', header: '_ID', format: (r) => C.id(String(r._id).slice(-12)) }
728
+ ], { count: true, title: 'related-issues' });
729
+ }
730
+ finally {
731
+ await client.close();
732
+ }
733
+ }
734
+ export async function linkDocument(issueRef, docRef, opts) {
735
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
736
+ try {
737
+ const account = await client.getAccount();
738
+ const issueId = await resolveRef(issueRef, {
739
+ client,
740
+ classId: CLASS.Issue,
741
+ workspaceId: account.uuid,
742
+ defaultProjectIdentifier: readEnv().project
743
+ });
744
+ const docId = await resolveRef(docRef, {
745
+ client,
746
+ classId: CLASS.Document,
747
+ workspaceId: account.uuid
748
+ });
749
+ const issue = await client.findOne(CLASS.Issue, { _id: issueId });
750
+ if (!issue)
751
+ throw new CliError(ExitCode.NotFound, `issue ${issueRef} not found`);
752
+ const relations = (issue.relations ?? []);
753
+ if (relations.some((r) => r._id === docId)) {
754
+ console.log(C.warn('⚠ document already linked'));
755
+ return;
756
+ }
757
+ const updated = [...relations, { _id: docId, _class: CLASS.Document }];
758
+ await withSpinner('Linking document…', () => client.updateDoc(CLASS.Issue, issue.space, issue._id, { relations: updated }), opts);
759
+ console.log(C.ok('linked document') + C.muted(' ') + C.emphasis(docRef) + C.muted(' → ') + C.emphasis(issueRef));
760
+ }
761
+ finally {
762
+ await client.close();
763
+ }
764
+ }
765
+ export async function unlinkDocument(issueRef, docRef, opts) {
766
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
767
+ try {
768
+ const account = await client.getAccount();
769
+ const issueId = await resolveRef(issueRef, {
770
+ client,
771
+ classId: CLASS.Issue,
772
+ workspaceId: account.uuid,
773
+ defaultProjectIdentifier: readEnv().project
774
+ });
775
+ const docId = await resolveRef(docRef, {
776
+ client,
777
+ classId: CLASS.Document,
778
+ workspaceId: account.uuid
779
+ });
780
+ const issue = await client.findOne(CLASS.Issue, { _id: issueId });
781
+ if (!issue)
782
+ throw new CliError(ExitCode.NotFound, `issue ${issueRef} not found`);
783
+ const relations = (issue.relations ?? []);
784
+ const updated = relations.filter((r) => r._id !== docId);
785
+ await withSpinner('Unlinking document…', () => client.updateDoc(CLASS.Issue, issue.space, issue._id, { relations: updated }), opts);
786
+ success(`unlinked document`, docRef);
787
+ }
788
+ finally {
789
+ await client.close();
790
+ }
791
+ }
792
+ export async function moveIssue(ref, parentRef, opts) {
793
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
794
+ try {
795
+ const account = await client.getAccount();
796
+ const issueId = await resolveRef(ref, {
797
+ client,
798
+ classId: CLASS.Issue,
799
+ workspaceId: account.uuid,
800
+ defaultProjectIdentifier: readEnv().project
801
+ });
802
+ const issue = await client.findOne(CLASS.Issue, { _id: issueId });
803
+ if (!issue)
804
+ throw new CliError(ExitCode.NotFound, `issue ${ref} not found`);
805
+ let newParent = null;
806
+ if (parentRef && parentRef !== 'null' && parentRef !== '-') {
807
+ newParent = await resolveRef(parentRef, {
808
+ client,
809
+ classId: CLASS.Issue,
810
+ workspaceId: account.uuid,
811
+ defaultProjectIdentifier: readEnv().project
812
+ });
813
+ }
814
+ if (opts.dryRun) {
815
+ console.log(`would move ${ref} → parent=${newParent ?? 'null'}`);
816
+ return;
817
+ }
818
+ await withSpinner('Moving…', () => client.updateDoc(CLASS.Issue, issue.space, issue._id, { parent: newParent }), opts);
819
+ console.log(`moved ${ref} → ${parentRef ?? 'null'}`);
820
+ }
821
+ finally {
822
+ await client.close();
823
+ }
824
+ }
825
+ export async function previewDelete(refs, opts) {
826
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
827
+ try {
828
+ const account = await client.getAccount();
829
+ const ids = await resolveRefs(refs, {
830
+ client,
831
+ classId: CLASS.Issue,
832
+ workspaceId: account.uuid,
833
+ defaultProjectIdentifier: readEnv().project
834
+ });
835
+ const preview = [];
836
+ for (const id of ids) {
837
+ const issue = await client.findOne(CLASS.Issue, { _id: id });
838
+ if (!issue)
839
+ continue;
840
+ const subIssues = (await client.findAll(CLASS.Issue, { parent: id })).length;
841
+ const relations = (issue.relations ?? []).length +
842
+ (issue.blockedBy ?? []).length;
843
+ preview.push({
844
+ ref: id,
845
+ subIssues,
846
+ comments: 0,
847
+ relations
848
+ });
849
+ }
850
+ if (shouldJson({ json: opts.json, ci: opts.ci })) {
851
+ json(preview);
852
+ return;
853
+ }
854
+ table(preview, [
855
+ { key: 'ref', header: 'REF', format: (r) => C.emphasis(String(r.ref)) },
856
+ { key: 'subIssues', header: 'SUB-ISSUES', align: 'right', format: (r) => {
857
+ const n = r.subIssues;
858
+ return n > 0 ? String(n) : C.muted('0');
859
+ } },
860
+ { key: 'relations', header: 'RELATIONS', align: 'right', format: (r) => {
861
+ const n = r.relations;
862
+ return n > 0 ? String(n) : C.muted('0');
863
+ } }
864
+ ], { count: true, title: 'delete-preview' });
865
+ }
866
+ finally {
867
+ await client.close();
868
+ }
869
+ }
870
+ export async function relatedTargets(ref, opts) {
871
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
872
+ try {
873
+ const project = await resolveProject(client, opts.project);
874
+ const targets = (await client.findAll(CLASS.RelatedIssueTarget, { space: project._id }));
875
+ if (shouldJson({ json: opts.json, ci: opts.ci })) {
876
+ json(targets);
877
+ return;
878
+ }
879
+ table(targets, [
880
+ { key: 'title', header: 'TITLE', format: (r) => C.emphasis(String(r.title ?? '')) },
881
+ { key: '_id', header: '_ID', format: (r) => C.id(String(r._id).slice(-12)) }
882
+ ], { count: true, title: 'related-targets' });
883
+ }
884
+ finally {
885
+ await client.close();
886
+ }
887
+ }
888
+ export async function setRelatedTarget(opts) {
889
+ if (!opts.source)
890
+ throw new CliError(ExitCode.Validation, 'missing --source');
891
+ if (!opts.target)
892
+ throw new CliError(ExitCode.Validation, 'missing --target');
893
+ const client = await connectCli({ url: opts.url, workspace: opts.workspace });
894
+ try {
895
+ const project = await resolveProject(client, opts.project);
896
+ const data = { source: opts.source, target: opts.target, space: project._id };
897
+ if (opts.dryRun) {
898
+ console.log('would set related-issue-target:');
899
+ console.log(JSON.stringify({ _class: CLASS.RelatedIssueTarget, space: project._id, data }, null, 2));
900
+ return;
901
+ }
902
+ const id = await withSpinner('Creating related-issue-target…', () => client.createDoc(CLASS.RelatedIssueTarget, project._id, data), opts);
903
+ console.log(`created related-issue-target: ${id}`);
904
+ }
905
+ finally {
906
+ await client.close();
907
+ }
908
+ }
909
+ //# sourceMappingURL=issue.js.map