@yokio42/unit-labs-cli 0.1.0 → 0.1.2
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 +60 -0
- package/package.json +1 -1
- package/src/config.js +34 -17
- package/src/index.js +1025 -143
package/src/index.js
CHANGED
|
@@ -3,78 +3,183 @@ const { stdin: input, stdout: output } = require("process")
|
|
|
3
3
|
const { CONFIG_FILE, DEFAULT_API_BASE, normalizeApiBase, readConfig, writeConfig } = require("./config")
|
|
4
4
|
|
|
5
5
|
const UNIT_LABS_BANNER = String.raw`
|
|
6
|
-
|
|
7
|
-
| |
|
|
8
|
-
| |
|
|
9
|
-
| |
|
|
10
|
-
|
|
6
|
+
/$$ /$$ /$$ /$$ /$$$$$$ /$$$$$$$$ /$$ /$$$$$$ /$$$$$$$ /$$$$$$
|
|
7
|
+
| $$ | $$| $$$ | $$|_ $$_/|__ $$__/ | $$ /$$__ $$| $$__ $$ /$$__ $$
|
|
8
|
+
| $$ | $$| $$$$| $$ | $$ | $$ | $$ | $$ \ $$| $$ \ $$| $$ \__/
|
|
9
|
+
| $$ | $$| $$ $$ $$ | $$ | $$ /$$$$$$| $$ | $$$$$$$$| $$$$$$$ | $$$$$$
|
|
10
|
+
| $$ | $$| $$ $$$$ | $$ | $$|______/| $$ | $$__ $$| $$__ $$ \____ $$
|
|
11
|
+
| $$ | $$| $$\ $$$ | $$ | $$ | $$ | $$ | $$| $$ \ $$ /$$ \ $$
|
|
12
|
+
| $$$$$$/| $$ \ $$ /$$$$$$ | $$ | $$$$$$$$| $$ | $$| $$$$$$$/| $$$$$$/
|
|
13
|
+
\______/ |__/ \__/|______/ |__/ |________/|__/ |__/|_______/ \______/
|
|
11
14
|
`
|
|
12
15
|
|
|
13
|
-
const
|
|
16
|
+
const ROOT_HELP = `
|
|
14
17
|
Unit Labs CLI
|
|
15
18
|
|
|
19
|
+
Usage:
|
|
20
|
+
unit-labs auth <command>
|
|
21
|
+
unit-labs config <command>
|
|
22
|
+
unit-labs project <command>
|
|
23
|
+
unit-labs stage <command>
|
|
24
|
+
unit-labs task <command>
|
|
25
|
+
unit-labs --help
|
|
26
|
+
|
|
27
|
+
Quick start:
|
|
28
|
+
unit-labs auth login
|
|
29
|
+
unit-labs project ls
|
|
30
|
+
unit-labs project use 1
|
|
31
|
+
unit-labs task ls
|
|
32
|
+
unit-labs task new "Ship MVP"
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
const AUTH_HELP = `
|
|
16
36
|
Usage:
|
|
17
37
|
unit-labs auth login [--login <email>] [--password <password>] [--api <url>]
|
|
18
|
-
unit-labs auth logout
|
|
19
38
|
unit-labs auth whoami
|
|
39
|
+
unit-labs auth logout
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
const CONFIG_HELP = `
|
|
43
|
+
Usage:
|
|
20
44
|
unit-labs config show
|
|
21
45
|
unit-labs config set-api <url>
|
|
22
|
-
|
|
46
|
+
`
|
|
23
47
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
-
|
|
48
|
+
const PROJECT_HELP = `
|
|
49
|
+
Usage:
|
|
50
|
+
unit-labs project ls [--team <teamId>] [--json]
|
|
51
|
+
unit-labs project new --name "<name>" [--workflow stages|flat] [--team <teamId>]
|
|
52
|
+
unit-labs project view <number|name|id> [--json]
|
|
53
|
+
unit-labs project use <number|name|id>
|
|
54
|
+
unit-labs project current [--json]
|
|
30
55
|
`
|
|
31
56
|
|
|
32
|
-
|
|
57
|
+
const STAGE_HELP = `
|
|
58
|
+
Usage:
|
|
59
|
+
unit-labs stage ls [--project <number|name|id>] [--json]
|
|
60
|
+
unit-labs stage new --name "<name>" --description "<text>" [--project <number|name|id>]
|
|
61
|
+
unit-labs stage use <number|name|id> [--project <number|name|id>]
|
|
62
|
+
unit-labs stage current [--json]
|
|
63
|
+
`
|
|
64
|
+
|
|
65
|
+
const TASK_HELP = `
|
|
66
|
+
Usage:
|
|
67
|
+
unit-labs task ls [--project <number|name|id>] [--stage <number|name|id>] [--all] [--json]
|
|
68
|
+
unit-labs task new "<title>" [--project <number|name|id>] [--stage <number|name|id>]
|
|
69
|
+
unit-labs task done <number|title|id> [--project <number|name|id>] [--stage <number|name|id>]
|
|
70
|
+
unit-labs task close <number|title|id> [--project <number|name|id>] [--stage <number|name|id>]
|
|
71
|
+
unit-labs task assign <number|title|id> <me|none|number|username|email|id> [--project ...] [--stage ...]
|
|
72
|
+
unit-labs task unassign <number|title|id> [--project ...] [--stage ...]
|
|
73
|
+
unit-labs task edit <number|title|id> --title "<new title>" [--project ...] [--stage ...]
|
|
74
|
+
unit-labs task rm <number|title|id> [--project ...] [--stage ...]
|
|
75
|
+
unit-labs task delete <number|title|id> [--project ...] [--stage ...]
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
const ASSIGNEE_SELF_REFS = new Set(["me", "self", "@me"])
|
|
79
|
+
const ASSIGNEE_CLEAR_REFS = new Set(["none", "null", "-", "unassign", "clear"])
|
|
80
|
+
|
|
81
|
+
function parseArgs(argv, shortAliases = {}) {
|
|
33
82
|
const flags = {}
|
|
83
|
+
const positionals = []
|
|
34
84
|
|
|
35
85
|
for (let i = 0; i < argv.length; i += 1) {
|
|
36
|
-
const
|
|
37
|
-
const value = argv[i + 1]
|
|
86
|
+
const arg = argv[i]
|
|
38
87
|
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
continue
|
|
43
|
-
}
|
|
44
|
-
if (key === "-e" || key === "--email") {
|
|
45
|
-
flags.login = value
|
|
46
|
-
i += 1
|
|
47
|
-
continue
|
|
88
|
+
if (arg === "--") {
|
|
89
|
+
positionals.push(...argv.slice(i + 1))
|
|
90
|
+
break
|
|
48
91
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
|
|
93
|
+
if (arg.startsWith("--")) {
|
|
94
|
+
const [rawKey, inlineValue] = arg.slice(2).split("=")
|
|
95
|
+
if (inlineValue !== undefined) {
|
|
96
|
+
flags[rawKey] = inlineValue
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const next = argv[i + 1]
|
|
101
|
+
if (next && !next.startsWith("-")) {
|
|
102
|
+
flags[rawKey] = next
|
|
103
|
+
i += 1
|
|
104
|
+
} else {
|
|
105
|
+
flags[rawKey] = true
|
|
106
|
+
}
|
|
52
107
|
continue
|
|
53
108
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
109
|
+
|
|
110
|
+
if (arg.startsWith("-") && arg.length > 1) {
|
|
111
|
+
const short = arg.slice(1)
|
|
112
|
+
const key = shortAliases[short] || short
|
|
113
|
+
const next = argv[i + 1]
|
|
114
|
+
if (next && !next.startsWith("-")) {
|
|
115
|
+
flags[key] = next
|
|
116
|
+
i += 1
|
|
117
|
+
} else {
|
|
118
|
+
flags[key] = true
|
|
119
|
+
}
|
|
57
120
|
continue
|
|
58
121
|
}
|
|
122
|
+
|
|
123
|
+
positionals.push(arg)
|
|
59
124
|
}
|
|
60
125
|
|
|
61
|
-
return flags
|
|
126
|
+
return { flags, positionals }
|
|
62
127
|
}
|
|
63
128
|
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
129
|
+
function shortId(id) {
|
|
130
|
+
if (!id) return "-"
|
|
131
|
+
const value = String(id)
|
|
132
|
+
if (value.length <= 8) return value
|
|
133
|
+
return `${value.slice(0, 4)}…${value.slice(-4)}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function printJson(value) {
|
|
137
|
+
console.log(JSON.stringify(value, null, 2))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function printProjects(projects, currentProjectId) {
|
|
141
|
+
if (!Array.isArray(projects) || projects.length === 0) {
|
|
142
|
+
console.log("No projects")
|
|
143
|
+
return
|
|
67
144
|
}
|
|
68
145
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
146
|
+
projects.forEach((project, index) => {
|
|
147
|
+
const marker = String(project._id) === String(currentProjectId) ? "*" : " "
|
|
148
|
+
const workflow = project.workflow_type || "stages"
|
|
149
|
+
console.log(
|
|
150
|
+
`${String(index + 1).padStart(2)}.${marker} ${project.project_name} [${workflow}] ${shortId(project._id)}`
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
}
|
|
73
154
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
155
|
+
function printStages(stages, currentStageId) {
|
|
156
|
+
if (!Array.isArray(stages) || stages.length === 0) {
|
|
157
|
+
console.log("No stages")
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stages.forEach((stage, index) => {
|
|
162
|
+
const marker = String(stage._id) === String(currentStageId) ? "*" : " "
|
|
163
|
+
const status = stage.status || "-"
|
|
164
|
+
console.log(
|
|
165
|
+
`${String(index + 1).padStart(2)}.${marker} ${stage.stage_name} [${status}] ${shortId(stage._id)}`
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function printTasks(tasks, withStage = false) {
|
|
171
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
172
|
+
console.log("No tasks")
|
|
173
|
+
return
|
|
77
174
|
}
|
|
175
|
+
|
|
176
|
+
tasks.forEach((task, index) => {
|
|
177
|
+
const done = task.is_done ? "x" : " "
|
|
178
|
+
const stagePrefix = withStage && task._stageName ? `(${task._stageName}) ` : ""
|
|
179
|
+
console.log(
|
|
180
|
+
`${String(index + 1).padStart(2)}. [${done}] ${stagePrefix}${task.title} ${shortId(task._id)}`
|
|
181
|
+
)
|
|
182
|
+
})
|
|
78
183
|
}
|
|
79
184
|
|
|
80
185
|
function printWelcomeBanner() {
|
|
@@ -82,180 +187,958 @@ function printWelcomeBanner() {
|
|
|
82
187
|
console.log(UNIT_LABS_BANNER)
|
|
83
188
|
}
|
|
84
189
|
|
|
85
|
-
|
|
86
|
-
|
|
190
|
+
function getErrorMessage(payload) {
|
|
191
|
+
if (typeof payload === "string") return payload
|
|
192
|
+
if (payload && typeof payload === "object" && payload.message) return payload.message
|
|
193
|
+
try {
|
|
194
|
+
return JSON.stringify(payload)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return "unknown error"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
87
199
|
|
|
88
|
-
|
|
89
|
-
|
|
200
|
+
function resolveMemberByRef(members, ref) {
|
|
201
|
+
if (!ref) {
|
|
202
|
+
throw new Error("assignee reference is required")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!Array.isArray(members) || members.length === 0) {
|
|
206
|
+
throw new Error("this team has no members to assign")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const raw = String(ref).trim()
|
|
210
|
+
if (!raw) {
|
|
211
|
+
throw new Error("assignee reference is required")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (/^\d+$/.test(raw)) {
|
|
215
|
+
const index = Number(raw) - 1
|
|
216
|
+
if (index < 0 || index >= members.length) {
|
|
217
|
+
throw new Error(`assignee index out of range: ${raw}`)
|
|
218
|
+
}
|
|
219
|
+
return members[index]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const exactId = members.find(member => String(member.id || member._id) === raw)
|
|
223
|
+
if (exactId) {
|
|
224
|
+
return exactId
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const lower = raw.toLowerCase()
|
|
228
|
+
const exactEmail = members.filter(member => String(member.email || "").toLowerCase() === lower)
|
|
229
|
+
if (exactEmail.length === 1) {
|
|
230
|
+
return exactEmail[0]
|
|
231
|
+
}
|
|
232
|
+
if (exactEmail.length > 1) {
|
|
233
|
+
throw new Error(`ambiguous assignee email "${raw}"`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const exactUsername = members.filter(member => String(member.username || "").toLowerCase() === lower)
|
|
237
|
+
if (exactUsername.length === 1) {
|
|
238
|
+
return exactUsername[0]
|
|
239
|
+
}
|
|
240
|
+
if (exactUsername.length > 1) {
|
|
241
|
+
throw new Error(`ambiguous assignee username "${raw}"`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const partial = members.filter(member => {
|
|
245
|
+
const username = String(member.username || "").toLowerCase()
|
|
246
|
+
const email = String(member.email || "").toLowerCase()
|
|
247
|
+
return username.includes(lower) || email.includes(lower)
|
|
248
|
+
})
|
|
249
|
+
if (partial.length === 1) {
|
|
250
|
+
return partial[0]
|
|
251
|
+
}
|
|
252
|
+
if (partial.length > 1) {
|
|
253
|
+
throw new Error(`ambiguous assignee reference "${raw}"`)
|
|
90
254
|
}
|
|
91
255
|
|
|
256
|
+
throw new Error(`assignee not found: ${raw}`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function request({ apiBase, token, method, path, body, requiresAuth = true }) {
|
|
260
|
+
if (requiresAuth && !token) {
|
|
261
|
+
throw new Error("not logged in. run: unit-labs auth login")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const headers = {}
|
|
265
|
+
headers["X-Client-Source"] = "cli"
|
|
266
|
+
headers["User-Agent"] = "unit-labs-cli/0.1.0"
|
|
267
|
+
if (requiresAuth && token) {
|
|
268
|
+
headers.Authorization = `Bearer ${token}`
|
|
269
|
+
}
|
|
92
270
|
if (body) {
|
|
93
271
|
headers["Content-Type"] = "application/json"
|
|
94
272
|
}
|
|
95
273
|
|
|
96
|
-
const response = await fetch(
|
|
274
|
+
const response = await fetch(`${apiBase}${path}`, {
|
|
97
275
|
method,
|
|
98
276
|
headers,
|
|
99
277
|
body: body ? JSON.stringify(body) : undefined,
|
|
100
278
|
})
|
|
101
279
|
|
|
102
|
-
const
|
|
103
|
-
let payload =
|
|
280
|
+
const rawText = await response.text()
|
|
281
|
+
let payload = rawText
|
|
104
282
|
|
|
105
283
|
try {
|
|
106
|
-
payload =
|
|
284
|
+
payload = rawText ? JSON.parse(rawText) : null
|
|
107
285
|
} catch (error) {
|
|
108
|
-
payload =
|
|
286
|
+
payload = rawText
|
|
109
287
|
}
|
|
110
288
|
|
|
111
289
|
if (!response.ok) {
|
|
112
|
-
|
|
113
|
-
? payload
|
|
114
|
-
: payload?.message || JSON.stringify(payload)
|
|
115
|
-
throw new Error(`${response.status} ${message}`)
|
|
290
|
+
throw new Error(`${response.status} ${getErrorMessage(payload)}`)
|
|
116
291
|
}
|
|
117
292
|
|
|
118
293
|
return payload
|
|
119
294
|
}
|
|
120
295
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
296
|
+
function resolveByRef(items, ref, nameSelector, label) {
|
|
297
|
+
if (!ref) {
|
|
298
|
+
throw new Error(`${label} reference is required`)
|
|
299
|
+
}
|
|
125
300
|
|
|
126
|
-
if (!
|
|
127
|
-
throw new Error(
|
|
301
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
302
|
+
throw new Error(`no ${label}s found`)
|
|
128
303
|
}
|
|
129
304
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
email: credentials.login,
|
|
135
|
-
password: credentials.password,
|
|
136
|
-
},
|
|
137
|
-
})
|
|
305
|
+
const raw = String(ref).trim()
|
|
306
|
+
if (!raw) {
|
|
307
|
+
throw new Error(`${label} reference is required`)
|
|
308
|
+
}
|
|
138
309
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
310
|
+
if (/^\d+$/.test(raw)) {
|
|
311
|
+
const index = Number(raw) - 1
|
|
312
|
+
if (index < 0 || index >= items.length) {
|
|
313
|
+
throw new Error(`${label} index out of range: ${raw}`)
|
|
314
|
+
}
|
|
315
|
+
return items[index]
|
|
142
316
|
}
|
|
143
317
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
318
|
+
const exactId = items.find(item => String(item._id) === raw)
|
|
319
|
+
if (exactId) {
|
|
320
|
+
return exactId
|
|
321
|
+
}
|
|
148
322
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
323
|
+
const lower = raw.toLowerCase()
|
|
324
|
+
const exactNameMatches = items.filter(item => String(nameSelector(item)).toLowerCase() === lower)
|
|
325
|
+
if (exactNameMatches.length === 1) {
|
|
326
|
+
return exactNameMatches[0]
|
|
327
|
+
}
|
|
328
|
+
if (exactNameMatches.length > 1) {
|
|
329
|
+
throw new Error(`ambiguous ${label} name "${raw}"`)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const partialMatches = items.filter(item => String(nameSelector(item)).toLowerCase().includes(lower))
|
|
333
|
+
if (partialMatches.length === 1) {
|
|
334
|
+
return partialMatches[0]
|
|
335
|
+
}
|
|
336
|
+
if (partialMatches.length > 1) {
|
|
337
|
+
throw new Error(`ambiguous ${label} reference "${raw}"`)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
throw new Error(`${label} not found: ${raw}`)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function fetchProjects(config, teamId = null) {
|
|
344
|
+
if (teamId) {
|
|
345
|
+
const payload = await request({
|
|
346
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
347
|
+
token: config.token,
|
|
152
348
|
method: "GET",
|
|
153
|
-
|
|
154
|
-
token,
|
|
349
|
+
path: `/teams/${teamId}/projects`,
|
|
155
350
|
})
|
|
156
|
-
|
|
157
|
-
|
|
351
|
+
|
|
352
|
+
return payload?.data?.projects || []
|
|
158
353
|
}
|
|
159
354
|
|
|
160
|
-
|
|
355
|
+
const payload = await request({
|
|
356
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
357
|
+
token: config.token,
|
|
358
|
+
method: "GET",
|
|
359
|
+
path: "/projects",
|
|
360
|
+
})
|
|
161
361
|
|
|
162
|
-
|
|
163
|
-
console.log(`Logged in as ${me.email}`)
|
|
164
|
-
} else {
|
|
165
|
-
console.log("Logged in")
|
|
166
|
-
}
|
|
167
|
-
console.log(`Config saved: ${CONFIG_FILE}`)
|
|
362
|
+
return payload?.projects || []
|
|
168
363
|
}
|
|
169
364
|
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
365
|
+
async function fetchProjectById(config, projectId) {
|
|
366
|
+
return request({
|
|
367
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
368
|
+
token: config.token,
|
|
369
|
+
method: "GET",
|
|
370
|
+
path: `/projects/${projectId}`,
|
|
175
371
|
})
|
|
176
|
-
console.log("Logged out")
|
|
177
372
|
}
|
|
178
373
|
|
|
179
|
-
async function
|
|
180
|
-
const
|
|
181
|
-
|
|
374
|
+
async function fetchStages(config, projectId) {
|
|
375
|
+
const payload = await request({
|
|
376
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
377
|
+
token: config.token,
|
|
378
|
+
method: "GET",
|
|
379
|
+
path: `/projects/${projectId}/stages`,
|
|
380
|
+
})
|
|
182
381
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
382
|
+
return Array.isArray(payload) ? payload : []
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function fetchCurrentUser(config) {
|
|
386
|
+
return request({
|
|
387
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
388
|
+
token: config.token,
|
|
389
|
+
method: "GET",
|
|
390
|
+
path: "/me",
|
|
391
|
+
})
|
|
392
|
+
}
|
|
186
393
|
|
|
187
|
-
|
|
394
|
+
async function fetchTeamMembers(config, teamId) {
|
|
395
|
+
const payload = await request({
|
|
396
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
397
|
+
token: config.token,
|
|
188
398
|
method: "GET",
|
|
189
|
-
|
|
190
|
-
token,
|
|
399
|
+
path: `/teams/${teamId}/members`,
|
|
191
400
|
})
|
|
192
401
|
|
|
193
|
-
|
|
402
|
+
return payload?.data?.members || []
|
|
194
403
|
}
|
|
195
404
|
|
|
196
|
-
function
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
405
|
+
async function fetchFlatTasks(config, projectId) {
|
|
406
|
+
const payload = await request({
|
|
407
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
408
|
+
token: config.token,
|
|
409
|
+
method: "GET",
|
|
410
|
+
path: `/projects/${projectId}/tasks`,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
return Array.isArray(payload) ? payload : []
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function fetchStageTasks(config, projectId, stageId) {
|
|
417
|
+
const payload = await request({
|
|
418
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
419
|
+
token: config.token,
|
|
420
|
+
method: "GET",
|
|
421
|
+
path: `/projects/${projectId}/stages/${stageId}/tasks`,
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
return Array.isArray(payload) ? payload : []
|
|
202
425
|
}
|
|
203
426
|
|
|
204
|
-
function
|
|
205
|
-
|
|
206
|
-
|
|
427
|
+
async function resolveProjectContext(config, projectRef, requireProject = true) {
|
|
428
|
+
const projects = await fetchProjects(config)
|
|
429
|
+
let project = null
|
|
430
|
+
|
|
431
|
+
if (projectRef) {
|
|
432
|
+
project = resolveByRef(projects, projectRef, p => p.project_name, "project")
|
|
433
|
+
} else if (config.currentProjectId) {
|
|
434
|
+
project = projects.find(p => String(p._id) === String(config.currentProjectId)) || null
|
|
207
435
|
}
|
|
208
436
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
})
|
|
215
|
-
console.log(`API saved: ${apiBase}`)
|
|
437
|
+
if (!project && requireProject) {
|
|
438
|
+
throw new Error("no project selected. run: unit-labs project ls && unit-labs project use <number|name>")
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { project, projects }
|
|
216
442
|
}
|
|
217
443
|
|
|
218
|
-
async function
|
|
219
|
-
if (
|
|
220
|
-
|
|
444
|
+
async function resolveStageContext(config, project, stageRef, requireStage = true) {
|
|
445
|
+
if (!project) {
|
|
446
|
+
throw new Error("project is required")
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if ((project.workflow_type || "stages") === "flat") {
|
|
450
|
+
throw new Error("this project uses flat workflow. stage commands are unavailable")
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const stages = await fetchStages(config, project._id)
|
|
454
|
+
let stage = null
|
|
455
|
+
|
|
456
|
+
if (stageRef) {
|
|
457
|
+
stage = resolveByRef(stages, stageRef, s => s.stage_name, "stage")
|
|
458
|
+
} else if (config.currentStageId) {
|
|
459
|
+
stage = stages.find(s => String(s._id) === String(config.currentStageId)) || null
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!stage && requireStage) {
|
|
463
|
+
throw new Error("no stage selected. run: unit-labs stage ls && unit-labs stage use <number|name>")
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { stage, stages }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function resolveAssigneeUserId(config, project, assigneeRef) {
|
|
470
|
+
const raw = String(assigneeRef || "").trim()
|
|
471
|
+
if (!raw) {
|
|
472
|
+
throw new Error("assignee reference is required. use: unit-labs task assign <task> <me|none|number|username|email|id>")
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const normalized = raw.toLowerCase()
|
|
476
|
+
if (ASSIGNEE_CLEAR_REFS.has(normalized)) {
|
|
477
|
+
return { userId: null, label: "none" }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (ASSIGNEE_SELF_REFS.has(normalized)) {
|
|
481
|
+
const me = await fetchCurrentUser(config)
|
|
482
|
+
const meId = me?.id || me?._id
|
|
483
|
+
if (!meId) {
|
|
484
|
+
throw new Error("failed to resolve current user id from /me")
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
userId: String(meId),
|
|
488
|
+
label: me?.email || me?.username || String(meId),
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (project.team_id) {
|
|
493
|
+
const members = await fetchTeamMembers(config, project.team_id)
|
|
494
|
+
const member = resolveMemberByRef(members, raw)
|
|
495
|
+
const memberId = member?.id || member?._id
|
|
496
|
+
if (!memberId) {
|
|
497
|
+
throw new Error("failed to resolve member id")
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
userId: String(memberId),
|
|
501
|
+
label: member?.email || member?.username || String(memberId),
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const me = await fetchCurrentUser(config)
|
|
506
|
+
const meId = me?.id || me?._id
|
|
507
|
+
const meEmail = String(me?.email || "").toLowerCase()
|
|
508
|
+
const meUsername = String(me?.username || "").toLowerCase()
|
|
509
|
+
const matchesMe = [String(meId || ""), meEmail, meUsername].includes(normalized)
|
|
510
|
+
if (!matchesMe) {
|
|
511
|
+
throw new Error("for personal project assignee can only be project owner (use `me`)")
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
userId: String(meId),
|
|
516
|
+
label: me?.email || me?.username || String(meId),
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function saveConfigPatch(config, patch) {
|
|
521
|
+
const next = {
|
|
522
|
+
...config,
|
|
523
|
+
...patch,
|
|
524
|
+
}
|
|
525
|
+
writeConfig(next)
|
|
526
|
+
return next
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function promptMissingCredentials({ login, password }) {
|
|
530
|
+
if (login && password) {
|
|
531
|
+
return { login, password }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const rl = readline.createInterface({ input, output })
|
|
535
|
+
const nextLogin = login || (await rl.question("Login: ")).trim()
|
|
536
|
+
const nextPassword = password || (await rl.question("Password: ")).trim()
|
|
537
|
+
rl.close()
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
login: nextLogin,
|
|
541
|
+
password: nextPassword,
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function runAuth(command, args, config) {
|
|
546
|
+
const { flags } = parseArgs(args, { l: "login", e: "login", p: "password", h: "help" })
|
|
547
|
+
const apiBase = normalizeApiBase(flags.api || config.apiBase || DEFAULT_API_BASE)
|
|
548
|
+
|
|
549
|
+
if (!command || command === "--help" || command === "-h" || flags.help) {
|
|
550
|
+
console.log(AUTH_HELP.trim())
|
|
221
551
|
return
|
|
222
552
|
}
|
|
223
553
|
|
|
224
|
-
|
|
554
|
+
if (command === "login") {
|
|
555
|
+
const credentials = await promptMissingCredentials(flags)
|
|
556
|
+
if (!credentials.login || !credentials.password) {
|
|
557
|
+
throw new Error("login and password are required")
|
|
558
|
+
}
|
|
225
559
|
|
|
226
|
-
|
|
227
|
-
|
|
560
|
+
const signinPayload = await request({
|
|
561
|
+
apiBase,
|
|
562
|
+
method: "POST",
|
|
563
|
+
path: "/signin",
|
|
564
|
+
body: { email: credentials.login, password: credentials.password },
|
|
565
|
+
requiresAuth: false,
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
const token = signinPayload?.token
|
|
569
|
+
if (!token) {
|
|
570
|
+
throw new Error("signin response does not contain token")
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
let me = null
|
|
574
|
+
try {
|
|
575
|
+
me = await request({
|
|
576
|
+
apiBase,
|
|
577
|
+
token,
|
|
578
|
+
method: "GET",
|
|
579
|
+
path: "/me",
|
|
580
|
+
})
|
|
581
|
+
} catch (error) {
|
|
582
|
+
me = null
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
saveConfigPatch(config, {
|
|
586
|
+
apiBase,
|
|
587
|
+
token,
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
printWelcomeBanner()
|
|
591
|
+
if (me?.email) {
|
|
592
|
+
console.log(`Logged in as ${me.email}`)
|
|
593
|
+
} else {
|
|
594
|
+
console.log("Logged in")
|
|
595
|
+
}
|
|
596
|
+
console.log(`Config saved: ${CONFIG_FILE}`)
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (command === "logout") {
|
|
601
|
+
saveConfigPatch(config, { token: null })
|
|
602
|
+
console.log("Logged out")
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (command === "whoami") {
|
|
607
|
+
const me = await request({
|
|
608
|
+
apiBase,
|
|
609
|
+
token: config.token,
|
|
610
|
+
method: "GET",
|
|
611
|
+
path: "/me",
|
|
612
|
+
})
|
|
613
|
+
printJson(me)
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
throw new Error(`unknown auth command: ${command}`)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function runConfig(command, args, config) {
|
|
621
|
+
const { flags, positionals } = parseArgs(args, { h: "help" })
|
|
622
|
+
|
|
623
|
+
if (!command || command === "--help" || command === "-h" || flags.help) {
|
|
624
|
+
console.log(CONFIG_HELP.trim())
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (command === "show") {
|
|
629
|
+
printJson({
|
|
630
|
+
apiBase: normalizeApiBase(config.apiBase || DEFAULT_API_BASE),
|
|
631
|
+
loggedIn: Boolean(config.token),
|
|
632
|
+
currentProjectId: config.currentProjectId,
|
|
633
|
+
currentProjectName: config.currentProjectName,
|
|
634
|
+
currentProjectWorkflow: config.currentProjectWorkflow,
|
|
635
|
+
currentStageId: config.currentStageId,
|
|
636
|
+
currentStageName: config.currentStageName,
|
|
637
|
+
})
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (command === "set-api") {
|
|
642
|
+
const rawApi = positionals[0] || flags.api
|
|
643
|
+
if (!rawApi) {
|
|
644
|
+
throw new Error("api url is required. usage: unit-labs config set-api <url>")
|
|
645
|
+
}
|
|
646
|
+
const apiBase = normalizeApiBase(rawApi)
|
|
647
|
+
saveConfigPatch(config, { apiBase })
|
|
648
|
+
console.log(`API saved: ${apiBase}`)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
throw new Error(`unknown config command: ${command}`)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function runProject(command, args, config) {
|
|
656
|
+
const { flags, positionals } = parseArgs(args, { t: "team", w: "workflow", n: "name", j: "json", h: "help" })
|
|
657
|
+
|
|
658
|
+
if (!command || command === "--help" || command === "-h" || flags.help) {
|
|
659
|
+
console.log(PROJECT_HELP.trim())
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (command === "ls") {
|
|
664
|
+
const projects = await fetchProjects(config, flags.team || null)
|
|
665
|
+
|
|
666
|
+
if (flags.json) {
|
|
667
|
+
printJson(projects)
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
printProjects(projects, config.currentProjectId)
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (command === "new") {
|
|
676
|
+
const name = (flags.name || positionals.join(" ")).trim()
|
|
677
|
+
if (!name) {
|
|
678
|
+
throw new Error("project name is required. use: --name \"...\"")
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const workflow = (flags.workflow || "stages").trim()
|
|
682
|
+
if (!["stages", "flat"].includes(workflow)) {
|
|
683
|
+
throw new Error("workflow must be one of: stages, flat")
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const body = {
|
|
687
|
+
project_name: name,
|
|
688
|
+
workflow_type: workflow,
|
|
689
|
+
}
|
|
690
|
+
if (flags.team) {
|
|
691
|
+
body.team_id = flags.team
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const payload = await request({
|
|
695
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
696
|
+
token: config.token,
|
|
697
|
+
method: "POST",
|
|
698
|
+
path: "/projects",
|
|
699
|
+
body,
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
if (typeof payload === "string") {
|
|
703
|
+
console.log(payload)
|
|
704
|
+
} else {
|
|
705
|
+
printJson(payload)
|
|
706
|
+
}
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (command === "view") {
|
|
711
|
+
const ref = positionals[0]
|
|
712
|
+
const { project } = await resolveProjectContext(config, ref, true)
|
|
713
|
+
|
|
714
|
+
let data = project
|
|
715
|
+
try {
|
|
716
|
+
data = await fetchProjectById(config, project._id)
|
|
717
|
+
} catch (error) {
|
|
718
|
+
data = project
|
|
719
|
+
}
|
|
228
720
|
|
|
229
|
-
if (
|
|
230
|
-
|
|
721
|
+
if (flags.json) {
|
|
722
|
+
printJson(data)
|
|
231
723
|
return
|
|
232
724
|
}
|
|
233
725
|
|
|
234
|
-
|
|
235
|
-
|
|
726
|
+
console.log(`Name: ${data.project_name}`)
|
|
727
|
+
console.log(`Workflow: ${data.workflow_type || "stages"}`)
|
|
728
|
+
console.log(`Status: ${data.status || "-"}`)
|
|
729
|
+
console.log(`ID: ${data._id}`)
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (command === "use") {
|
|
734
|
+
const ref = positionals[0]
|
|
735
|
+
if (!ref) {
|
|
736
|
+
throw new Error("project reference is required. use: unit-labs project use <number|name|id>")
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const { project } = await resolveProjectContext(config, ref, true)
|
|
740
|
+
const workflow = project.workflow_type || "stages"
|
|
741
|
+
saveConfigPatch(config, {
|
|
742
|
+
currentProjectId: project._id,
|
|
743
|
+
currentProjectName: project.project_name,
|
|
744
|
+
currentProjectWorkflow: workflow,
|
|
745
|
+
currentStageId: null,
|
|
746
|
+
currentStageName: null,
|
|
747
|
+
})
|
|
748
|
+
console.log(`Current project: ${project.project_name} [${workflow}]`)
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (command === "current") {
|
|
753
|
+
if (!config.currentProjectId) {
|
|
754
|
+
throw new Error("no current project. use: unit-labs project use <number|name>")
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const { project } = await resolveProjectContext(config, null, true)
|
|
758
|
+
|
|
759
|
+
if (flags.json) {
|
|
760
|
+
printJson(project)
|
|
236
761
|
return
|
|
237
762
|
}
|
|
238
763
|
|
|
239
|
-
|
|
240
|
-
|
|
764
|
+
console.log(`Current project: ${project.project_name}`)
|
|
765
|
+
console.log(`Workflow: ${project.workflow_type || "stages"}`)
|
|
766
|
+
console.log(`ID: ${project._id}`)
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
throw new Error(`unknown project command: ${command}`)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function runStage(command, args, config) {
|
|
774
|
+
const { flags, positionals } = parseArgs(args, {
|
|
775
|
+
p: "project",
|
|
776
|
+
n: "name",
|
|
777
|
+
d: "description",
|
|
778
|
+
j: "json",
|
|
779
|
+
h: "help",
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
if (!command || command === "--help" || command === "-h" || flags.help) {
|
|
783
|
+
console.log(STAGE_HELP.trim())
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const projectRef = flags.project || null
|
|
788
|
+
const { project } = await resolveProjectContext(config, projectRef, true)
|
|
789
|
+
if ((project.workflow_type || "stages") === "flat") {
|
|
790
|
+
throw new Error("this project uses flat workflow. stage commands are unavailable")
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (command === "ls") {
|
|
794
|
+
const stages = await fetchStages(config, project._id)
|
|
795
|
+
if (flags.json) {
|
|
796
|
+
printJson(stages)
|
|
241
797
|
return
|
|
242
798
|
}
|
|
799
|
+
printStages(stages, config.currentStageId)
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (command === "new") {
|
|
804
|
+
const stageName = (flags.name || "").trim()
|
|
805
|
+
const description = (flags.description || "").trim()
|
|
806
|
+
|
|
807
|
+
if (!stageName || !description) {
|
|
808
|
+
throw new Error("stage name and description are required. use: --name \"...\" --description \"...\"")
|
|
809
|
+
}
|
|
243
810
|
|
|
244
|
-
|
|
811
|
+
const payload = await request({
|
|
812
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
813
|
+
token: config.token,
|
|
814
|
+
method: "POST",
|
|
815
|
+
path: `/projects/${project._id}/stages`,
|
|
816
|
+
body: {
|
|
817
|
+
stage_name: stageName,
|
|
818
|
+
description,
|
|
819
|
+
},
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
if (typeof payload === "string") {
|
|
823
|
+
console.log(payload)
|
|
824
|
+
} else {
|
|
825
|
+
printJson(payload)
|
|
826
|
+
}
|
|
827
|
+
return
|
|
245
828
|
}
|
|
246
829
|
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
830
|
+
if (command === "use") {
|
|
831
|
+
const ref = positionals[0]
|
|
832
|
+
if (!ref) {
|
|
833
|
+
throw new Error("stage reference is required. use: unit-labs stage use <number|name|id>")
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const { stage } = await resolveStageContext(config, project, ref, true)
|
|
837
|
+
saveConfigPatch(config, {
|
|
838
|
+
currentProjectId: project._id,
|
|
839
|
+
currentProjectName: project.project_name,
|
|
840
|
+
currentProjectWorkflow: project.workflow_type || "stages",
|
|
841
|
+
currentStageId: stage._id,
|
|
842
|
+
currentStageName: stage.stage_name,
|
|
843
|
+
})
|
|
844
|
+
console.log(`Current stage: ${stage.stage_name}`)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (command === "current") {
|
|
849
|
+
if (!config.currentStageId) {
|
|
850
|
+
throw new Error("no current stage. use: unit-labs stage use <number|name>")
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const { stage } = await resolveStageContext(config, project, null, true)
|
|
854
|
+
if (flags.json) {
|
|
855
|
+
printJson(stage)
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
console.log(`Current stage: ${stage.stage_name}`)
|
|
859
|
+
console.log(`Status: ${stage.status || "-"}`)
|
|
860
|
+
console.log(`ID: ${stage._id}`)
|
|
861
|
+
return
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
throw new Error(`unknown stage command: ${command}`)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function resolveTaskContext(config, flags, requireStage = true) {
|
|
868
|
+
const projectRef = flags.project || null
|
|
869
|
+
const { project } = await resolveProjectContext(config, projectRef, true)
|
|
870
|
+
const workflow = project.workflow_type || "stages"
|
|
871
|
+
|
|
872
|
+
if (workflow === "flat") {
|
|
873
|
+
return { project, workflow, stage: null }
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const stageRef = flags.stage || null
|
|
877
|
+
const { stage } = await resolveStageContext(config, project, stageRef, requireStage)
|
|
878
|
+
return { project, workflow, stage }
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function listAllStageTasks(config, project) {
|
|
882
|
+
const stages = await fetchStages(config, project._id)
|
|
883
|
+
const result = []
|
|
884
|
+
|
|
885
|
+
for (const stage of stages) {
|
|
886
|
+
const tasks = await fetchStageTasks(config, project._id, stage._id)
|
|
887
|
+
tasks.forEach(task => {
|
|
888
|
+
result.push({
|
|
889
|
+
...task,
|
|
890
|
+
_stageName: stage.stage_name,
|
|
891
|
+
_stageId: stage._id,
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return result
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async function runTask(command, args, config) {
|
|
900
|
+
const { flags, positionals } = parseArgs(args, {
|
|
901
|
+
p: "project",
|
|
902
|
+
s: "stage",
|
|
903
|
+
t: "title",
|
|
904
|
+
a: "all",
|
|
905
|
+
j: "json",
|
|
906
|
+
h: "help",
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
if (!command || command === "--help" || command === "-h" || flags.help) {
|
|
910
|
+
console.log(TASK_HELP.trim())
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (command === "ls") {
|
|
915
|
+
const { project, workflow, stage } = await resolveTaskContext(config, flags, !flags.all)
|
|
916
|
+
|
|
917
|
+
if (workflow === "flat") {
|
|
918
|
+
const tasks = await fetchFlatTasks(config, project._id)
|
|
919
|
+
if (flags.json) {
|
|
920
|
+
printJson(tasks)
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
printTasks(tasks, false)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (flags.all) {
|
|
928
|
+
const tasks = await listAllStageTasks(config, project)
|
|
929
|
+
if (flags.json) {
|
|
930
|
+
printJson(tasks)
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
printTasks(tasks, true)
|
|
250
934
|
return
|
|
251
935
|
}
|
|
252
936
|
|
|
253
|
-
|
|
254
|
-
|
|
937
|
+
const tasks = await fetchStageTasks(config, project._id, stage._id)
|
|
938
|
+
if (flags.json) {
|
|
939
|
+
printJson(tasks)
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
printTasks(tasks, false)
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (command === "new") {
|
|
947
|
+
const title = (flags.title || positionals.join(" ")).trim()
|
|
948
|
+
if (!title) {
|
|
949
|
+
throw new Error("task title is required. use: unit-labs task new \"Task title\"")
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
|
|
953
|
+
|
|
954
|
+
const path = workflow === "flat"
|
|
955
|
+
? `/projects/${project._id}/tasks`
|
|
956
|
+
: `/projects/${project._id}/stages/${stage._id}/tasks`
|
|
957
|
+
|
|
958
|
+
const payload = await request({
|
|
959
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
960
|
+
token: config.token,
|
|
961
|
+
method: "POST",
|
|
962
|
+
path,
|
|
963
|
+
body: { title },
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
if (Array.isArray(payload)) {
|
|
967
|
+
printTasks(payload, false)
|
|
968
|
+
} else if (typeof payload === "string") {
|
|
969
|
+
console.log(payload)
|
|
970
|
+
} else {
|
|
971
|
+
printJson(payload)
|
|
972
|
+
}
|
|
973
|
+
return
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (command === "done" || command === "close") {
|
|
977
|
+
const taskRef = positionals[0]
|
|
978
|
+
if (!taskRef) {
|
|
979
|
+
throw new Error("task reference is required. use: unit-labs task done <number|title|id>")
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
|
|
983
|
+
const tasks = workflow === "flat"
|
|
984
|
+
? await fetchFlatTasks(config, project._id)
|
|
985
|
+
: await fetchStageTasks(config, project._id, stage._id)
|
|
986
|
+
const task = resolveByRef(tasks, taskRef, t => t.title, "task")
|
|
987
|
+
|
|
988
|
+
const path = workflow === "flat"
|
|
989
|
+
? `/projects/${project._id}/tasks/${task._id}/toggle`
|
|
990
|
+
: `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}/toggle`
|
|
991
|
+
|
|
992
|
+
const updated = await request({
|
|
993
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
994
|
+
token: config.token,
|
|
995
|
+
method: "PATCH",
|
|
996
|
+
path,
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
printTasks([updated], false)
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (command === "assign" || command === "unassign") {
|
|
1004
|
+
const taskRef = positionals[0]
|
|
1005
|
+
const assigneeRef = command === "unassign" ? "none" : positionals[1]
|
|
1006
|
+
|
|
1007
|
+
if (!taskRef) {
|
|
1008
|
+
throw new Error("task reference is required. use: unit-labs task assign <number|title|id> <assignee>")
|
|
1009
|
+
}
|
|
1010
|
+
if (!assigneeRef) {
|
|
1011
|
+
throw new Error("assignee reference is required. use: unit-labs task assign <task> <me|none|number|username|email|id>")
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
|
|
1015
|
+
const tasks = workflow === "flat"
|
|
1016
|
+
? await fetchFlatTasks(config, project._id)
|
|
1017
|
+
: await fetchStageTasks(config, project._id, stage._id)
|
|
1018
|
+
const task = resolveByRef(tasks, taskRef, t => t.title, "task")
|
|
1019
|
+
const assignee = await resolveAssigneeUserId(config, project, assigneeRef)
|
|
1020
|
+
|
|
1021
|
+
const path = workflow === "flat"
|
|
1022
|
+
? `/projects/${project._id}/tasks/${task._id}/assign`
|
|
1023
|
+
: `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}/assign`
|
|
1024
|
+
|
|
1025
|
+
const updated = await request({
|
|
1026
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
1027
|
+
token: config.token,
|
|
1028
|
+
method: "PATCH",
|
|
1029
|
+
path,
|
|
1030
|
+
body: { user_id: assignee.userId },
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
if (flags.json) {
|
|
1034
|
+
printJson(updated)
|
|
255
1035
|
return
|
|
256
1036
|
}
|
|
257
1037
|
|
|
258
|
-
|
|
1038
|
+
printTasks([updated], false)
|
|
1039
|
+
if (assignee.userId) {
|
|
1040
|
+
console.log(`Assigned to: ${assignee.label}`)
|
|
1041
|
+
} else {
|
|
1042
|
+
console.log("Assignee cleared")
|
|
1043
|
+
}
|
|
1044
|
+
return
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (command === "edit") {
|
|
1048
|
+
const taskRef = positionals[0]
|
|
1049
|
+
const title = (flags.title || positionals.slice(1).join(" ")).trim()
|
|
1050
|
+
|
|
1051
|
+
if (!taskRef) {
|
|
1052
|
+
throw new Error("task reference is required. use: unit-labs task edit <number|title|id> --title \"...\"")
|
|
1053
|
+
}
|
|
1054
|
+
if (!title) {
|
|
1055
|
+
throw new Error("new title is required. use: --title \"New title\"")
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
|
|
1059
|
+
const tasks = workflow === "flat"
|
|
1060
|
+
? await fetchFlatTasks(config, project._id)
|
|
1061
|
+
: await fetchStageTasks(config, project._id, stage._id)
|
|
1062
|
+
const task = resolveByRef(tasks, taskRef, t => t.title, "task")
|
|
1063
|
+
|
|
1064
|
+
const path = workflow === "flat"
|
|
1065
|
+
? `/projects/${project._id}/tasks/${task._id}/title`
|
|
1066
|
+
: `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}/title`
|
|
1067
|
+
|
|
1068
|
+
const updated = await request({
|
|
1069
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
1070
|
+
token: config.token,
|
|
1071
|
+
method: "PATCH",
|
|
1072
|
+
path,
|
|
1073
|
+
body: { title },
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
printTasks([updated], false)
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (command === "rm" || command === "delete") {
|
|
1081
|
+
const taskRef = positionals[0]
|
|
1082
|
+
if (!taskRef) {
|
|
1083
|
+
throw new Error("task reference is required. use: unit-labs task rm <number|title|id>")
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
|
|
1087
|
+
const tasks = workflow === "flat"
|
|
1088
|
+
? await fetchFlatTasks(config, project._id)
|
|
1089
|
+
: await fetchStageTasks(config, project._id, stage._id)
|
|
1090
|
+
const task = resolveByRef(tasks, taskRef, t => t.title, "task")
|
|
1091
|
+
|
|
1092
|
+
const path = workflow === "flat"
|
|
1093
|
+
? `/projects/${project._id}/tasks/${task._id}`
|
|
1094
|
+
: `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}`
|
|
1095
|
+
|
|
1096
|
+
await request({
|
|
1097
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
1098
|
+
token: config.token,
|
|
1099
|
+
method: "DELETE",
|
|
1100
|
+
path,
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
console.log(`Deleted task: ${task.title}`)
|
|
1104
|
+
return
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
throw new Error(`unknown task command: ${command}`)
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function run(argv) {
|
|
1111
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
1112
|
+
console.log(ROOT_HELP.trim())
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const [scope, command, ...rest] = argv
|
|
1117
|
+
const config = readConfig()
|
|
1118
|
+
|
|
1119
|
+
if (scope === "auth") {
|
|
1120
|
+
await runAuth(command, rest, config)
|
|
1121
|
+
return
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (scope === "config") {
|
|
1125
|
+
await runConfig(command, rest, config)
|
|
1126
|
+
return
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (scope === "project") {
|
|
1130
|
+
await runProject(command, rest, config)
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (scope === "stage") {
|
|
1135
|
+
await runStage(command, rest, config)
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (scope === "task") {
|
|
1140
|
+
await runTask(command, rest, config)
|
|
1141
|
+
return
|
|
259
1142
|
}
|
|
260
1143
|
|
|
261
1144
|
throw new Error(`unknown scope: ${scope}`)
|
|
@@ -264,4 +1147,3 @@ async function run(argv) {
|
|
|
264
1147
|
module.exports = {
|
|
265
1148
|
run,
|
|
266
1149
|
}
|
|
267
|
-
|