@yokio42/unit-labs-cli 0.1.1 → 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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/src/index.js +181 -0
package/README.md CHANGED
@@ -47,6 +47,8 @@ unit-labs task new "Ship MVP"
47
47
  - `unit-labs task new "<title>" [--project <number|name|id>] [--stage <number|name|id>]`
48
48
  - `unit-labs task done <number|title|id>`
49
49
  - `unit-labs task close <number|title|id>`
50
+ - `unit-labs task assign <number|title|id> <me|none|number|username|email|id>`
51
+ - `unit-labs task unassign <number|title|id>`
50
52
  - `unit-labs task edit <number|title|id> --title "<new title>"`
51
53
  - `unit-labs task rm <number|title|id>`
52
54
  - `unit-labs task delete <number|title|id>`
@@ -56,4 +58,3 @@ unit-labs task new "Ship MVP"
56
58
  - По умолчанию API: `https://popuitka2-be.onrender.com`
57
59
  - CLI хранит `apiBase`, токен и текущий контекст в `~/.unit-labs/config.json`
58
60
  - Для проектов с `workflow=flat` команды `stage` недоступны
59
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yokio42/unit-labs-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Unit Labs command line interface",
5
5
  "bin": {
6
6
  "unit-labs": "./bin/unit-labs.js"
package/src/index.js CHANGED
@@ -68,11 +68,16 @@ Usage:
68
68
  unit-labs task new "<title>" [--project <number|name|id>] [--stage <number|name|id>]
69
69
  unit-labs task done <number|title|id> [--project <number|name|id>] [--stage <number|name|id>]
70
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 ...]
71
73
  unit-labs task edit <number|title|id> --title "<new title>" [--project ...] [--stage ...]
72
74
  unit-labs task rm <number|title|id> [--project ...] [--stage ...]
73
75
  unit-labs task delete <number|title|id> [--project ...] [--stage ...]
74
76
  `
75
77
 
78
+ const ASSIGNEE_SELF_REFS = new Set(["me", "self", "@me"])
79
+ const ASSIGNEE_CLEAR_REFS = new Set(["none", "null", "-", "unassign", "clear"])
80
+
76
81
  function parseArgs(argv, shortAliases = {}) {
77
82
  const flags = {}
78
83
  const positionals = []
@@ -192,12 +197,73 @@ function getErrorMessage(payload) {
192
197
  }
193
198
  }
194
199
 
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}"`)
254
+ }
255
+
256
+ throw new Error(`assignee not found: ${raw}`)
257
+ }
258
+
195
259
  async function request({ apiBase, token, method, path, body, requiresAuth = true }) {
196
260
  if (requiresAuth && !token) {
197
261
  throw new Error("not logged in. run: unit-labs auth login")
198
262
  }
199
263
 
200
264
  const headers = {}
265
+ headers["X-Client-Source"] = "cli"
266
+ headers["User-Agent"] = "unit-labs-cli/0.1.0"
201
267
  if (requiresAuth && token) {
202
268
  headers.Authorization = `Bearer ${token}`
203
269
  }
@@ -316,6 +382,26 @@ async function fetchStages(config, projectId) {
316
382
  return Array.isArray(payload) ? payload : []
317
383
  }
318
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
+ }
393
+
394
+ async function fetchTeamMembers(config, teamId) {
395
+ const payload = await request({
396
+ apiBase: normalizeApiBase(config.apiBase),
397
+ token: config.token,
398
+ method: "GET",
399
+ path: `/teams/${teamId}/members`,
400
+ })
401
+
402
+ return payload?.data?.members || []
403
+ }
404
+
319
405
  async function fetchFlatTasks(config, projectId) {
320
406
  const payload = await request({
321
407
  apiBase: normalizeApiBase(config.apiBase),
@@ -380,6 +466,57 @@ async function resolveStageContext(config, project, stageRef, requireStage = tru
380
466
  return { stage, stages }
381
467
  }
382
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
+
383
520
  function saveConfigPatch(config, patch) {
384
521
  const next = {
385
522
  ...config,
@@ -863,6 +1000,50 @@ async function runTask(command, args, config) {
863
1000
  return
864
1001
  }
865
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)
1035
+ return
1036
+ }
1037
+
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
+
866
1047
  if (command === "edit") {
867
1048
  const taskRef = positionals[0]
868
1049
  const title = (flags.title || positionals.slice(1).join(" ")).trim()