@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.
- package/README.md +2576 -0
- package/bin/huly +9 -0
- package/dist/auth/cache.js +129 -0
- package/dist/auth/cache.js.map +1 -0
- package/dist/auth/client.js +192 -0
- package/dist/auth/client.js.map +1 -0
- package/dist/auth/env.js +101 -0
- package/dist/auth/env.js.map +1 -0
- package/dist/auth/prompts.js +68 -0
- package/dist/auth/prompts.js.map +1 -0
- package/dist/cli.js +1959 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/dry-run.js +39 -0
- package/dist/commands/dry-run.js.map +1 -0
- package/dist/commands/login.js +92 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/whoami.js +64 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/output/errors.js +99 -0
- package/dist/output/errors.js.map +1 -0
- package/dist/output/format.js +607 -0
- package/dist/output/format.js.map +1 -0
- package/dist/output/progress.js +30 -0
- package/dist/output/progress.js.map +1 -0
- package/dist/raw/api.js +67 -0
- package/dist/raw/api.js.map +1 -0
- package/dist/raw/ws.js +157 -0
- package/dist/raw/ws.js.map +1 -0
- package/dist/resources/_helpers.js +258 -0
- package/dist/resources/_helpers.js.map +1 -0
- package/dist/resources/_project-resolve.js +24 -0
- package/dist/resources/_project-resolve.js.map +1 -0
- package/dist/resources/calendar.js +659 -0
- package/dist/resources/calendar.js.map +1 -0
- package/dist/resources/card.js +358 -0
- package/dist/resources/card.js.map +1 -0
- package/dist/resources/channel.js +709 -0
- package/dist/resources/channel.js.map +1 -0
- package/dist/resources/comment.js +142 -0
- package/dist/resources/comment.js.map +1 -0
- package/dist/resources/component.js +154 -0
- package/dist/resources/component.js.map +1 -0
- package/dist/resources/document.js +584 -0
- package/dist/resources/document.js.map +1 -0
- package/dist/resources/issue-template.js +228 -0
- package/dist/resources/issue-template.js.map +1 -0
- package/dist/resources/issue.js +909 -0
- package/dist/resources/issue.js.map +1 -0
- package/dist/resources/milestone.js +177 -0
- package/dist/resources/milestone.js.map +1 -0
- package/dist/resources/misc.js +2 -0
- package/dist/resources/misc.js.map +1 -0
- package/dist/resources/project.js +341 -0
- package/dist/resources/project.js.map +1 -0
- package/dist/resources/project.parse.js +25 -0
- package/dist/resources/project.parse.js.map +1 -0
- package/dist/resources/time.js +148 -0
- package/dist/resources/time.js.map +1 -0
- package/dist/resources/todo.js +463 -0
- package/dist/resources/todo.js.map +1 -0
- package/dist/resources/user.js +131 -0
- package/dist/resources/user.js.map +1 -0
- package/dist/resources/workspace.js +252 -0
- package/dist/resources/workspace.js.map +1 -0
- package/dist/transport/identifiers.js +67 -0
- package/dist/transport/identifiers.js.map +1 -0
- package/dist/transport/ref-resolver.js +108 -0
- package/dist/transport/ref-resolver.js.map +1 -0
- package/dist/transport/sdk.js +69 -0
- package/dist/transport/sdk.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|