@sqaoss/flowy 1.3.1 → 1.5.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/package.json +1 -1
- package/server/src/resolvers.test.ts +196 -0
- package/server/src/resolvers.ts +68 -0
- package/server/src/schema.ts +2 -0
- package/src/commands/feature.test.ts +161 -1
- package/src/commands/feature.ts +69 -0
- package/src/commands/project.test.ts +169 -2
- package/src/commands/project.ts +60 -0
- package/src/commands/task.test.ts +308 -3
- package/src/commands/task.ts +136 -5
package/package.json
CHANGED
|
@@ -1099,4 +1099,200 @@ describe('createResolvers', () => {
|
|
|
1099
1099
|
expect(results).toEqual([])
|
|
1100
1100
|
})
|
|
1101
1101
|
})
|
|
1102
|
+
|
|
1103
|
+
describe('Query.readyTasks', () => {
|
|
1104
|
+
function setStatus(id: string, status: string): void {
|
|
1105
|
+
resolvers.Mutation.updateNode(null, { id, status })
|
|
1106
|
+
}
|
|
1107
|
+
function block(blockerId: string, blockedId: string): void {
|
|
1108
|
+
resolvers.Mutation.createEdge(null, {
|
|
1109
|
+
sourceId: blockerId,
|
|
1110
|
+
targetId: blockedId,
|
|
1111
|
+
relation: 'blocks',
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
it('returns an unblocked, not-done task', () => {
|
|
1116
|
+
const t = create(resolvers, { type: 'task', title: 'Free' })
|
|
1117
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1118
|
+
expect(ready.map((n) => n.id)).toEqual([t.id])
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('excludes done and cancelled tasks', () => {
|
|
1122
|
+
const open = create(resolvers, { type: 'task', title: 'Open' })
|
|
1123
|
+
const done = create(resolvers, { type: 'task', title: 'Done' })
|
|
1124
|
+
const cancelled = create(resolvers, { type: 'task', title: 'Cancelled' })
|
|
1125
|
+
setStatus(done.id, 'done')
|
|
1126
|
+
setStatus(cancelled.id, 'cancelled')
|
|
1127
|
+
|
|
1128
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1129
|
+
const ids = ready.map((n) => n.id)
|
|
1130
|
+
expect(ids).toContain(open.id)
|
|
1131
|
+
expect(ids).not.toContain(done.id)
|
|
1132
|
+
expect(ids).not.toContain(cancelled.id)
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
it('excludes a task blocked by an unfinished blocker', () => {
|
|
1136
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1137
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1138
|
+
block(blocker.id, blocked.id)
|
|
1139
|
+
|
|
1140
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1141
|
+
const ids = ready.map((n) => n.id)
|
|
1142
|
+
expect(ids).toContain(blocker.id)
|
|
1143
|
+
expect(ids).not.toContain(blocked.id)
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
it('includes a task once all its blockers are done', () => {
|
|
1147
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1148
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1149
|
+
block(blocker.id, blocked.id)
|
|
1150
|
+
setStatus(blocker.id, 'done')
|
|
1151
|
+
|
|
1152
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1153
|
+
const ids = ready.map((n) => n.id)
|
|
1154
|
+
// The done blocker is itself excluded; the formerly-blocked task is ready.
|
|
1155
|
+
expect(ids).not.toContain(blocker.id)
|
|
1156
|
+
expect(ids).toContain(blocked.id)
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
it('treats a cancelled blocker as no longer blocking', () => {
|
|
1160
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1161
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1162
|
+
block(blocker.id, blocked.id)
|
|
1163
|
+
setStatus(blocker.id, 'cancelled')
|
|
1164
|
+
|
|
1165
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1166
|
+
expect(ready.map((n) => n.id)).toContain(blocked.id)
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
it('still blocks when any one of several blockers is unfinished', () => {
|
|
1170
|
+
const b1 = create(resolvers, { type: 'task', title: 'B1' })
|
|
1171
|
+
const b2 = create(resolvers, { type: 'task', title: 'B2' })
|
|
1172
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1173
|
+
block(b1.id, blocked.id)
|
|
1174
|
+
block(b2.id, blocked.id)
|
|
1175
|
+
setStatus(b1.id, 'done')
|
|
1176
|
+
// b2 still open -> blocked stays not-ready
|
|
1177
|
+
|
|
1178
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1179
|
+
expect(ready.map((n) => n.id)).not.toContain(blocked.id)
|
|
1180
|
+
})
|
|
1181
|
+
|
|
1182
|
+
it('returns only tasks, never features or projects', () => {
|
|
1183
|
+
const project = create(resolvers, { type: 'project', title: 'P' })
|
|
1184
|
+
const feature = create(resolvers, { type: 'feature', title: 'F' })
|
|
1185
|
+
const task = create(resolvers, { type: 'task', title: 'T' })
|
|
1186
|
+
|
|
1187
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1188
|
+
const ids = ready.map((n) => n.id)
|
|
1189
|
+
expect(ids).toContain(task.id)
|
|
1190
|
+
expect(ids).not.toContain(project.id)
|
|
1191
|
+
expect(ids).not.toContain(feature.id)
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
it('returns exactly the unblocked not-done tasks across a mixed fixture', () => {
|
|
1195
|
+
// free: ready. blocked-by-open: not ready. blocked-by-done: ready.
|
|
1196
|
+
// done: not ready. in_progress + unblocked: ready.
|
|
1197
|
+
const free = create(resolvers, { type: 'task', title: 'free' })
|
|
1198
|
+
const openBlocker = create(resolvers, { type: 'task', title: 'open-b' })
|
|
1199
|
+
const blockedByOpen = create(resolvers, { type: 'task', title: 'bbo' })
|
|
1200
|
+
const doneBlocker = create(resolvers, { type: 'task', title: 'done-b' })
|
|
1201
|
+
const blockedByDone = create(resolvers, { type: 'task', title: 'bbd' })
|
|
1202
|
+
const finished = create(resolvers, { type: 'task', title: 'finished' })
|
|
1203
|
+
const started = create(resolvers, { type: 'task', title: 'started' })
|
|
1204
|
+
|
|
1205
|
+
block(openBlocker.id, blockedByOpen.id)
|
|
1206
|
+
block(doneBlocker.id, blockedByDone.id)
|
|
1207
|
+
setStatus(doneBlocker.id, 'done')
|
|
1208
|
+
setStatus(finished.id, 'done')
|
|
1209
|
+
setStatus(started.id, 'in_progress')
|
|
1210
|
+
|
|
1211
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1212
|
+
const ids = new Set(ready.map((n) => n.id))
|
|
1213
|
+
expect(ids).toEqual(
|
|
1214
|
+
new Set([free.id, openBlocker.id, blockedByDone.id, started.id]),
|
|
1215
|
+
)
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
it('scopes to a project via part_of when projectId is given', () => {
|
|
1219
|
+
const projA = create(resolvers, { type: 'project', title: 'A' })
|
|
1220
|
+
const featA = create(resolvers, { type: 'feature', title: 'FA' })
|
|
1221
|
+
const taskA = create(resolvers, { type: 'task', title: 'TA' })
|
|
1222
|
+
resolvers.Mutation.createEdge(null, {
|
|
1223
|
+
sourceId: featA.id,
|
|
1224
|
+
targetId: projA.id,
|
|
1225
|
+
relation: 'part_of',
|
|
1226
|
+
})
|
|
1227
|
+
resolvers.Mutation.createEdge(null, {
|
|
1228
|
+
sourceId: taskA.id,
|
|
1229
|
+
targetId: featA.id,
|
|
1230
|
+
relation: 'part_of',
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
const projB = create(resolvers, { type: 'project', title: 'B' })
|
|
1234
|
+
const featB = create(resolvers, { type: 'feature', title: 'FB' })
|
|
1235
|
+
const taskB = create(resolvers, { type: 'task', title: 'TB' })
|
|
1236
|
+
resolvers.Mutation.createEdge(null, {
|
|
1237
|
+
sourceId: featB.id,
|
|
1238
|
+
targetId: projB.id,
|
|
1239
|
+
relation: 'part_of',
|
|
1240
|
+
})
|
|
1241
|
+
resolvers.Mutation.createEdge(null, {
|
|
1242
|
+
sourceId: taskB.id,
|
|
1243
|
+
targetId: featB.id,
|
|
1244
|
+
relation: 'part_of',
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
const ready = resolvers.Query.readyTasks(null, { projectId: projA.id })
|
|
1248
|
+
expect(ready.map((n) => n.id)).toEqual([taskA.id])
|
|
1249
|
+
})
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
describe('Query.edges', () => {
|
|
1253
|
+
function block(blockerId: string, blockedId: string): void {
|
|
1254
|
+
resolvers.Mutation.createEdge(null, {
|
|
1255
|
+
sourceId: blockerId,
|
|
1256
|
+
targetId: blockedId,
|
|
1257
|
+
relation: 'blocks',
|
|
1258
|
+
})
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
it('returns blockedBy: incoming blocks edges (sources that block the node)', () => {
|
|
1262
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1263
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1264
|
+
block(blocker.id, blocked.id)
|
|
1265
|
+
|
|
1266
|
+
const blockedBy = resolvers.Query.edges(null, {
|
|
1267
|
+
nodeId: blocked.id,
|
|
1268
|
+
relation: 'blocks',
|
|
1269
|
+
direction: 'incoming',
|
|
1270
|
+
})
|
|
1271
|
+
expect(blockedBy.map((n) => n.id)).toEqual([blocker.id])
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
it('returns blocks: outgoing blocks edges (targets the node blocks)', () => {
|
|
1275
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1276
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1277
|
+
block(blocker.id, blocked.id)
|
|
1278
|
+
|
|
1279
|
+
const blocks = resolvers.Query.edges(null, {
|
|
1280
|
+
nodeId: blocker.id,
|
|
1281
|
+
relation: 'blocks',
|
|
1282
|
+
direction: 'outgoing',
|
|
1283
|
+
})
|
|
1284
|
+
expect(blocks.map((n) => n.id)).toEqual([blocked.id])
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
it('returns an empty array for a node with no edges', () => {
|
|
1288
|
+
const lonely = create(resolvers, { type: 'task', title: 'Lonely' })
|
|
1289
|
+
expect(
|
|
1290
|
+
resolvers.Query.edges(null, {
|
|
1291
|
+
nodeId: lonely.id,
|
|
1292
|
+
relation: 'blocks',
|
|
1293
|
+
direction: 'incoming',
|
|
1294
|
+
}),
|
|
1295
|
+
).toEqual([])
|
|
1296
|
+
})
|
|
1297
|
+
})
|
|
1102
1298
|
})
|
package/server/src/resolvers.ts
CHANGED
|
@@ -194,6 +194,74 @@ export function createResolvers(db: Db) {
|
|
|
194
194
|
return selectNodes(rows)
|
|
195
195
|
},
|
|
196
196
|
|
|
197
|
+
// Nodes connected to `nodeId` by `relation`, following edges in the given
|
|
198
|
+
// direction. 'incoming' returns the *sources* of edges that point at the
|
|
199
|
+
// node (e.g. for relation 'blocks', the tasks that block it — blockedBy);
|
|
200
|
+
// 'outgoing' returns the *targets* of edges originating at the node (the
|
|
201
|
+
// tasks it blocks). Default direction is 'outgoing'.
|
|
202
|
+
edges: (
|
|
203
|
+
_: unknown,
|
|
204
|
+
args: {
|
|
205
|
+
nodeId: string
|
|
206
|
+
relation: string
|
|
207
|
+
direction?: string
|
|
208
|
+
},
|
|
209
|
+
) => {
|
|
210
|
+
const incoming = args.direction === 'incoming'
|
|
211
|
+
const sql = incoming
|
|
212
|
+
? `SELECT ${prefixedCols()} FROM nodes n
|
|
213
|
+
JOIN edges e ON n.id = e.source_id
|
|
214
|
+
WHERE e.target_id = ? AND e.relation = ?`
|
|
215
|
+
: `SELECT ${prefixedCols()} FROM nodes n
|
|
216
|
+
JOIN edges e ON n.id = e.target_id
|
|
217
|
+
WHERE e.source_id = ? AND e.relation = ?`
|
|
218
|
+
const rows = db.raw
|
|
219
|
+
.query(sql)
|
|
220
|
+
.all(args.nodeId, args.relation) as NodeRow[]
|
|
221
|
+
return selectNodes(rows)
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Tasks that are actionable now: type 'task', status not done/cancelled,
|
|
225
|
+
// and not blocked by any incomplete blocker. A blocker is an incoming
|
|
226
|
+
// `blocks` edge whose source task is itself not done/cancelled. Optionally
|
|
227
|
+
// scoped to a project via the `part_of` hierarchy (task -> feature ->
|
|
228
|
+
// project).
|
|
229
|
+
readyTasks: (_: unknown, args: { projectId?: string }) => {
|
|
230
|
+
const params: string[] = []
|
|
231
|
+
let scope = ''
|
|
232
|
+
if (args.projectId) {
|
|
233
|
+
// Restrict to tasks reachable from the project through part_of edges
|
|
234
|
+
// (any depth). Edges point child -> parent, so we walk upward from
|
|
235
|
+
// each candidate task to see if the project is an ancestor.
|
|
236
|
+
scope = `AND n.id IN (
|
|
237
|
+
WITH RECURSIVE up(id) AS (
|
|
238
|
+
SELECT source_id FROM edges
|
|
239
|
+
WHERE target_id = ?1 AND relation = 'part_of'
|
|
240
|
+
UNION
|
|
241
|
+
SELECT e.source_id FROM edges e
|
|
242
|
+
JOIN up ON e.target_id = up.id AND e.relation = 'part_of'
|
|
243
|
+
)
|
|
244
|
+
SELECT id FROM up
|
|
245
|
+
)`
|
|
246
|
+
params.push(args.projectId)
|
|
247
|
+
}
|
|
248
|
+
const rows = db.raw
|
|
249
|
+
.query(
|
|
250
|
+
`SELECT ${prefixedCols()} FROM nodes n
|
|
251
|
+
WHERE n.type = 'task'
|
|
252
|
+
AND n.status NOT IN ('done', 'cancelled')
|
|
253
|
+
${scope}
|
|
254
|
+
AND NOT EXISTS (
|
|
255
|
+
SELECT 1 FROM edges b
|
|
256
|
+
JOIN nodes src ON src.id = b.source_id
|
|
257
|
+
WHERE b.target_id = n.id AND b.relation = 'blocks'
|
|
258
|
+
AND src.status NOT IN ('done', 'cancelled')
|
|
259
|
+
)`,
|
|
260
|
+
)
|
|
261
|
+
.all(...params) as NodeRow[]
|
|
262
|
+
return selectNodes(rows)
|
|
263
|
+
},
|
|
264
|
+
|
|
197
265
|
search: (
|
|
198
266
|
_: unknown,
|
|
199
267
|
args: {
|
package/server/src/schema.ts
CHANGED
|
@@ -24,6 +24,8 @@ export const typeDefs = /* GraphQL */ `
|
|
|
24
24
|
nodes(type: String): [Node!]!
|
|
25
25
|
descendants(nodeId: String!, relation: String, maxDepth: Int): [Node!]!
|
|
26
26
|
subtree(nodeId: String!, maxDepth: Int): [Node!]!
|
|
27
|
+
edges(nodeId: String!, relation: String!, direction: String): [Node!]!
|
|
28
|
+
readyTasks(projectId: String): [Node!]!
|
|
27
29
|
search(query: String!, type: String, status: String, limit: Int): [Node!]!
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from 'vitest'
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
const mockUpdateProjectConfig = vi.fn()
|
|
4
4
|
|
|
@@ -22,6 +22,10 @@ vi.mock('../util/format.ts', () => ({
|
|
|
22
22
|
}))
|
|
23
23
|
|
|
24
24
|
describe('feature command', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks()
|
|
27
|
+
})
|
|
28
|
+
|
|
25
29
|
test('exports a command group named "feature" with subcommands', async () => {
|
|
26
30
|
const { featureCommand } = await import('./feature.ts')
|
|
27
31
|
expect(featureCommand.name()).toBe('feature')
|
|
@@ -31,6 +35,8 @@ describe('feature command', () => {
|
|
|
31
35
|
expect(subcommandNames).toContain('unset')
|
|
32
36
|
expect(subcommandNames).toContain('list')
|
|
33
37
|
expect(subcommandNames).toContain('show')
|
|
38
|
+
expect(subcommandNames).toContain('update')
|
|
39
|
+
expect(subcommandNames).toContain('delete')
|
|
34
40
|
})
|
|
35
41
|
|
|
36
42
|
test('create calls outputError when no active project', async () => {
|
|
@@ -68,4 +74,158 @@ describe('feature command', () => {
|
|
|
68
74
|
|
|
69
75
|
expect(output).toHaveBeenCalledWith({ activeFeature: null })
|
|
70
76
|
})
|
|
77
|
+
|
|
78
|
+
test('update sends updateNode with only the title when title-only', async () => {
|
|
79
|
+
const { graphql } = await import('../util/client.ts')
|
|
80
|
+
const { output } = await import('../util/format.ts')
|
|
81
|
+
const { featureCommand } = await import('./feature.ts')
|
|
82
|
+
|
|
83
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
84
|
+
updateNode: { id: 'feat_1', title: 'New' },
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const updateCmd = featureCommand.commands.find(
|
|
88
|
+
(c) => c.name() === 'update',
|
|
89
|
+
)!
|
|
90
|
+
await updateCmd.parseAsync(['feat_1', '--title', 'New'], { from: 'user' })
|
|
91
|
+
|
|
92
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
93
|
+
expect.stringContaining('updateNode'),
|
|
94
|
+
{
|
|
95
|
+
id: 'feat_1',
|
|
96
|
+
title: 'New',
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
expect(output).toHaveBeenCalledWith({ id: 'feat_1', title: 'New' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('update sends updateNode with only the description when description-only', async () => {
|
|
103
|
+
const { graphql } = await import('../util/client.ts')
|
|
104
|
+
const { featureCommand } = await import('./feature.ts')
|
|
105
|
+
|
|
106
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'feat_1' } })
|
|
107
|
+
|
|
108
|
+
const updateCmd = featureCommand.commands.find(
|
|
109
|
+
(c) => c.name() === 'update',
|
|
110
|
+
)!
|
|
111
|
+
await updateCmd.parseAsync(['feat_1', '--description', 'Body'], {
|
|
112
|
+
from: 'user',
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
116
|
+
expect.stringContaining('updateNode'),
|
|
117
|
+
{
|
|
118
|
+
id: 'feat_1',
|
|
119
|
+
description: 'Body',
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('update sends updateNode with only the metadata when metadata-only', async () => {
|
|
125
|
+
const { graphql } = await import('../util/client.ts')
|
|
126
|
+
const { featureCommand } = await import('./feature.ts')
|
|
127
|
+
|
|
128
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'feat_1' } })
|
|
129
|
+
|
|
130
|
+
const updateCmd = featureCommand.commands.find(
|
|
131
|
+
(c) => c.name() === 'update',
|
|
132
|
+
)!
|
|
133
|
+
await updateCmd.parseAsync(['feat_1', '--metadata', '{"k":"v"}'], {
|
|
134
|
+
from: 'user',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
138
|
+
expect.stringContaining('updateNode'),
|
|
139
|
+
{
|
|
140
|
+
id: 'feat_1',
|
|
141
|
+
metadata: '{"k":"v"}',
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('update sends updateNode with combined fields', async () => {
|
|
147
|
+
const { graphql } = await import('../util/client.ts')
|
|
148
|
+
const { featureCommand } = await import('./feature.ts')
|
|
149
|
+
|
|
150
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'feat_1' } })
|
|
151
|
+
|
|
152
|
+
const updateCmd = featureCommand.commands.find(
|
|
153
|
+
(c) => c.name() === 'update',
|
|
154
|
+
)!
|
|
155
|
+
await updateCmd.parseAsync(
|
|
156
|
+
['feat_1', '--title', 'New', '--description', 'Body', '--metadata', '{}'],
|
|
157
|
+
{ from: 'user' },
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
161
|
+
expect.stringContaining('updateNode'),
|
|
162
|
+
{
|
|
163
|
+
id: 'feat_1',
|
|
164
|
+
title: 'New',
|
|
165
|
+
description: 'Body',
|
|
166
|
+
metadata: '{}',
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('delete sends deleteNode mutation', async () => {
|
|
172
|
+
const { graphql } = await import('../util/client.ts')
|
|
173
|
+
const { output } = await import('../util/format.ts')
|
|
174
|
+
const { featureCommand } = await import('./feature.ts')
|
|
175
|
+
|
|
176
|
+
vi.mocked(graphql).mockResolvedValueOnce({ deleteNode: true })
|
|
177
|
+
|
|
178
|
+
const deleteCmd = featureCommand.commands.find(
|
|
179
|
+
(c) => c.name() === 'delete',
|
|
180
|
+
)!
|
|
181
|
+
await deleteCmd.parseAsync(['feat_1'], { from: 'user' })
|
|
182
|
+
|
|
183
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
184
|
+
expect.stringContaining('deleteNode'),
|
|
185
|
+
{
|
|
186
|
+
id: 'feat_1',
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
expect(output).toHaveBeenCalledWith({ deleted: true })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('delete surfaces CONFLICT via outputError with its code', async () => {
|
|
193
|
+
const { graphql } = await import('../util/client.ts')
|
|
194
|
+
const { outputError } = await import('../util/format.ts')
|
|
195
|
+
const { featureCommand } = await import('./feature.ts')
|
|
196
|
+
|
|
197
|
+
const conflict = Object.assign(new Error('has children'), {
|
|
198
|
+
code: 'CONFLICT',
|
|
199
|
+
})
|
|
200
|
+
vi.mocked(graphql).mockRejectedValueOnce(conflict)
|
|
201
|
+
|
|
202
|
+
const deleteCmd = featureCommand.commands.find(
|
|
203
|
+
(c) => c.name() === 'delete',
|
|
204
|
+
)!
|
|
205
|
+
await deleteCmd.parseAsync(['feat_1'], { from: 'user' })
|
|
206
|
+
|
|
207
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
208
|
+
expect.objectContaining({ code: 'CONFLICT' }),
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('delete surfaces NOT_FOUND via outputError with its code', async () => {
|
|
213
|
+
const { graphql } = await import('../util/client.ts')
|
|
214
|
+
const { outputError } = await import('../util/format.ts')
|
|
215
|
+
const { featureCommand } = await import('./feature.ts')
|
|
216
|
+
|
|
217
|
+
const notFound = Object.assign(new Error('Node feat_x not found'), {
|
|
218
|
+
code: 'NOT_FOUND',
|
|
219
|
+
})
|
|
220
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
221
|
+
|
|
222
|
+
const deleteCmd = featureCommand.commands.find(
|
|
223
|
+
(c) => c.name() === 'delete',
|
|
224
|
+
)!
|
|
225
|
+
await deleteCmd.parseAsync(['feat_x'], { from: 'user' })
|
|
226
|
+
|
|
227
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
228
|
+
expect.objectContaining({ code: 'NOT_FOUND' }),
|
|
229
|
+
)
|
|
230
|
+
})
|
|
71
231
|
})
|
package/src/commands/feature.ts
CHANGED
|
@@ -126,6 +126,75 @@ featureCommand
|
|
|
126
126
|
}
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
featureCommand
|
|
130
|
+
.command('update')
|
|
131
|
+
.description('Update a feature')
|
|
132
|
+
.argument('[id]', 'Feature ID (defaults to active feature)')
|
|
133
|
+
.option('--title <title>', 'New title')
|
|
134
|
+
.option(
|
|
135
|
+
'--description <text>',
|
|
136
|
+
'New description, used verbatim (never read as a file path)',
|
|
137
|
+
)
|
|
138
|
+
.option(
|
|
139
|
+
'--description-file <path>',
|
|
140
|
+
'Read the new description from a file, or "-" for stdin',
|
|
141
|
+
)
|
|
142
|
+
.option('--metadata <json>', 'New metadata as a JSON string')
|
|
143
|
+
.action(async (id: string | undefined, opts) => {
|
|
144
|
+
try {
|
|
145
|
+
const featureId = id ?? resolveFeature()
|
|
146
|
+
if (!featureId) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'No feature specified. Pass an ID or set an active feature.',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
const variables: Record<string, unknown> = { id: featureId }
|
|
152
|
+
if (opts.title != null) variables.title = opts.title
|
|
153
|
+
if (opts.description != null || opts.descriptionFile != null) {
|
|
154
|
+
variables.description = await resolveDescription({
|
|
155
|
+
description: opts.description,
|
|
156
|
+
descriptionFile: opts.descriptionFile,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
160
|
+
const data = await graphql<{ updateNode: unknown }>(
|
|
161
|
+
`mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
|
|
162
|
+
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
163
|
+
id type title description status metadata createdAt updatedAt
|
|
164
|
+
}
|
|
165
|
+
}`,
|
|
166
|
+
variables,
|
|
167
|
+
)
|
|
168
|
+
output(data.updateNode)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
outputError(error)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
featureCommand
|
|
175
|
+
.command('delete')
|
|
176
|
+
.description('Delete a feature')
|
|
177
|
+
.argument('[id]', 'Feature ID (defaults to active feature)')
|
|
178
|
+
.action(async (id?: string) => {
|
|
179
|
+
try {
|
|
180
|
+
const featureId = id ?? resolveFeature()
|
|
181
|
+
if (!featureId) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
'No feature specified. Pass an ID or set an active feature.',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
const data = await graphql<{ deleteNode: boolean }>(
|
|
187
|
+
`mutation DeleteNode($id: String!) {
|
|
188
|
+
deleteNode(id: $id)
|
|
189
|
+
}`,
|
|
190
|
+
{ id: featureId },
|
|
191
|
+
)
|
|
192
|
+
output({ deleted: data.deleteNode })
|
|
193
|
+
} catch (error) {
|
|
194
|
+
outputError(error)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
129
198
|
featureCommand
|
|
130
199
|
.command('show')
|
|
131
200
|
.description('Show feature details')
|
|
@@ -3,6 +3,10 @@ import { homedir } from 'node:os'
|
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
5
5
|
|
|
6
|
+
vi.mock('../util/client.ts', () => ({
|
|
7
|
+
graphql: vi.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
6
10
|
const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
|
|
7
11
|
|
|
8
12
|
describe('project command', () => {
|
|
@@ -24,7 +28,7 @@ describe('project command', () => {
|
|
|
24
28
|
vi.restoreAllMocks()
|
|
25
29
|
})
|
|
26
30
|
|
|
27
|
-
test('exports a command group named "project" with create, set, list, show subcommands', async () => {
|
|
31
|
+
test('exports a command group named "project" with create, set, list, show, update, delete subcommands', async () => {
|
|
28
32
|
const { projectCommand } = await import('./project.ts')
|
|
29
33
|
expect(projectCommand.name()).toBe('project')
|
|
30
34
|
const subcommandNames = projectCommand.commands.map((c) => c.name())
|
|
@@ -32,7 +36,9 @@ describe('project command', () => {
|
|
|
32
36
|
expect(subcommandNames).toContain('set')
|
|
33
37
|
expect(subcommandNames).toContain('list')
|
|
34
38
|
expect(subcommandNames).toContain('show')
|
|
35
|
-
expect(
|
|
39
|
+
expect(subcommandNames).toContain('update')
|
|
40
|
+
expect(subcommandNames).toContain('delete')
|
|
41
|
+
expect(projectCommand.commands).toHaveLength(6)
|
|
36
42
|
})
|
|
37
43
|
|
|
38
44
|
test('show without id calls requireProject which throws when no project configured', async () => {
|
|
@@ -80,4 +86,165 @@ describe('project command', () => {
|
|
|
80
86
|
name: 'Second',
|
|
81
87
|
})
|
|
82
88
|
})
|
|
89
|
+
|
|
90
|
+
test('update sends updateNode with only the title when title-only', async () => {
|
|
91
|
+
const { graphql } = await import('../util/client.ts')
|
|
92
|
+
const { projectCommand } = await import('./project.ts')
|
|
93
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
94
|
+
|
|
95
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
96
|
+
updateNode: { id: 'proj_1', title: 'New' },
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const updateCmd = projectCommand.commands.find(
|
|
100
|
+
(c) => c.name() === 'update',
|
|
101
|
+
)!
|
|
102
|
+
await updateCmd.parseAsync(['proj_1', '--title', 'New'], { from: 'user' })
|
|
103
|
+
|
|
104
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
105
|
+
expect.stringContaining('updateNode'),
|
|
106
|
+
{
|
|
107
|
+
id: 'proj_1',
|
|
108
|
+
title: 'New',
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('update sends updateNode with only the description when description-only', async () => {
|
|
114
|
+
const { graphql } = await import('../util/client.ts')
|
|
115
|
+
const { projectCommand } = await import('./project.ts')
|
|
116
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
117
|
+
|
|
118
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'proj_1' } })
|
|
119
|
+
|
|
120
|
+
const updateCmd = projectCommand.commands.find(
|
|
121
|
+
(c) => c.name() === 'update',
|
|
122
|
+
)!
|
|
123
|
+
await updateCmd.parseAsync(['proj_1', '--description', 'Body'], {
|
|
124
|
+
from: 'user',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
128
|
+
expect.stringContaining('updateNode'),
|
|
129
|
+
{
|
|
130
|
+
id: 'proj_1',
|
|
131
|
+
description: 'Body',
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('update sends updateNode with only the metadata when metadata-only', async () => {
|
|
137
|
+
const { graphql } = await import('../util/client.ts')
|
|
138
|
+
const { projectCommand } = await import('./project.ts')
|
|
139
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
140
|
+
|
|
141
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'proj_1' } })
|
|
142
|
+
|
|
143
|
+
const updateCmd = projectCommand.commands.find(
|
|
144
|
+
(c) => c.name() === 'update',
|
|
145
|
+
)!
|
|
146
|
+
await updateCmd.parseAsync(['proj_1', '--metadata', '{"k":"v"}'], {
|
|
147
|
+
from: 'user',
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
151
|
+
expect.stringContaining('updateNode'),
|
|
152
|
+
{
|
|
153
|
+
id: 'proj_1',
|
|
154
|
+
metadata: '{"k":"v"}',
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('update sends updateNode with combined fields', async () => {
|
|
160
|
+
const { graphql } = await import('../util/client.ts')
|
|
161
|
+
const { projectCommand } = await import('./project.ts')
|
|
162
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
163
|
+
|
|
164
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'proj_1' } })
|
|
165
|
+
|
|
166
|
+
const updateCmd = projectCommand.commands.find(
|
|
167
|
+
(c) => c.name() === 'update',
|
|
168
|
+
)!
|
|
169
|
+
await updateCmd.parseAsync(
|
|
170
|
+
['proj_1', '--title', 'New', '--description', 'Body', '--metadata', '{}'],
|
|
171
|
+
{ from: 'user' },
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
175
|
+
expect.stringContaining('updateNode'),
|
|
176
|
+
{
|
|
177
|
+
id: 'proj_1',
|
|
178
|
+
title: 'New',
|
|
179
|
+
description: 'Body',
|
|
180
|
+
metadata: '{}',
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('delete sends deleteNode mutation', async () => {
|
|
186
|
+
const { graphql } = await import('../util/client.ts')
|
|
187
|
+
const { projectCommand } = await import('./project.ts')
|
|
188
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
189
|
+
|
|
190
|
+
vi.mocked(graphql).mockResolvedValueOnce({ deleteNode: true })
|
|
191
|
+
|
|
192
|
+
const deleteCmd = projectCommand.commands.find(
|
|
193
|
+
(c) => c.name() === 'delete',
|
|
194
|
+
)!
|
|
195
|
+
await deleteCmd.parseAsync(['proj_1'], { from: 'user' })
|
|
196
|
+
|
|
197
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
198
|
+
expect.stringContaining('deleteNode'),
|
|
199
|
+
{
|
|
200
|
+
id: 'proj_1',
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('delete surfaces CONFLICT with exit code 1', async () => {
|
|
206
|
+
const { graphql } = await import('../util/client.ts')
|
|
207
|
+
const { projectCommand } = await import('./project.ts')
|
|
208
|
+
const mockExit = vi
|
|
209
|
+
.spyOn(process, 'exit')
|
|
210
|
+
.mockImplementation(() => undefined as never)
|
|
211
|
+
const mockStderr = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
212
|
+
|
|
213
|
+
const conflict = Object.assign(new Error('has children'), {
|
|
214
|
+
code: 'CONFLICT',
|
|
215
|
+
})
|
|
216
|
+
vi.mocked(graphql).mockRejectedValueOnce(conflict)
|
|
217
|
+
|
|
218
|
+
const deleteCmd = projectCommand.commands.find(
|
|
219
|
+
(c) => c.name() === 'delete',
|
|
220
|
+
)!
|
|
221
|
+
await deleteCmd.parseAsync(['proj_1'], { from: 'user' })
|
|
222
|
+
|
|
223
|
+
expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining('CONFLICT'))
|
|
224
|
+
expect(mockExit).toHaveBeenCalledWith(1)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('delete surfaces NOT_FOUND with exit code 2', async () => {
|
|
228
|
+
const { graphql } = await import('../util/client.ts')
|
|
229
|
+
const { projectCommand } = await import('./project.ts')
|
|
230
|
+
const mockExit = vi
|
|
231
|
+
.spyOn(process, 'exit')
|
|
232
|
+
.mockImplementation(() => undefined as never)
|
|
233
|
+
const mockStderr = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
234
|
+
|
|
235
|
+
const notFound = Object.assign(new Error('Node proj_x not found'), {
|
|
236
|
+
code: 'NOT_FOUND',
|
|
237
|
+
})
|
|
238
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
239
|
+
|
|
240
|
+
const deleteCmd = projectCommand.commands.find(
|
|
241
|
+
(c) => c.name() === 'delete',
|
|
242
|
+
)!
|
|
243
|
+
await deleteCmd.parseAsync(['proj_x'], { from: 'user' })
|
|
244
|
+
|
|
245
|
+
expect(mockStderr).toHaveBeenCalledWith(
|
|
246
|
+
expect.stringContaining('NOT_FOUND'),
|
|
247
|
+
)
|
|
248
|
+
expect(mockExit).toHaveBeenCalledWith(2)
|
|
249
|
+
})
|
|
83
250
|
})
|
package/src/commands/project.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
3
|
import { loadConfig, requireProject, saveConfig } from '../util/config.ts'
|
|
4
|
+
import { resolveDescription } from '../util/description.ts'
|
|
4
5
|
import { output, outputError } from '../util/format.ts'
|
|
5
6
|
|
|
6
7
|
export const projectCommand = new Command('project').description(
|
|
@@ -102,3 +103,62 @@ projectCommand
|
|
|
102
103
|
.description('Show project details')
|
|
103
104
|
.argument('[id]', 'Project ID (defaults to active project)')
|
|
104
105
|
.action(async (id?: string) => showProject(id))
|
|
106
|
+
|
|
107
|
+
projectCommand
|
|
108
|
+
.command('update')
|
|
109
|
+
.description('Update a project')
|
|
110
|
+
.argument('[id]', 'Project ID (defaults to active project)')
|
|
111
|
+
.option('--title <title>', 'New title')
|
|
112
|
+
.option(
|
|
113
|
+
'--description <text>',
|
|
114
|
+
'New description, used verbatim (never read as a file path)',
|
|
115
|
+
)
|
|
116
|
+
.option(
|
|
117
|
+
'--description-file <path>',
|
|
118
|
+
'Read the new description from a file, or "-" for stdin',
|
|
119
|
+
)
|
|
120
|
+
.option('--metadata <json>', 'New metadata as a JSON string')
|
|
121
|
+
.action(async (id: string | undefined, opts) => {
|
|
122
|
+
try {
|
|
123
|
+
const projectId = id ?? requireProject().id
|
|
124
|
+
const variables: Record<string, unknown> = { id: projectId }
|
|
125
|
+
if (opts.title != null) variables.title = opts.title
|
|
126
|
+
if (opts.description != null || opts.descriptionFile != null) {
|
|
127
|
+
variables.description = await resolveDescription({
|
|
128
|
+
description: opts.description,
|
|
129
|
+
descriptionFile: opts.descriptionFile,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
133
|
+
const data = await graphql<{ updateNode: unknown }>(
|
|
134
|
+
`mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
|
|
135
|
+
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
136
|
+
id type title description status metadata createdAt updatedAt
|
|
137
|
+
}
|
|
138
|
+
}`,
|
|
139
|
+
variables,
|
|
140
|
+
)
|
|
141
|
+
output(data.updateNode)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
outputError(error)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
projectCommand
|
|
148
|
+
.command('delete')
|
|
149
|
+
.description('Delete a project')
|
|
150
|
+
.argument('[id]', 'Project ID (defaults to active project)')
|
|
151
|
+
.action(async (id?: string) => {
|
|
152
|
+
try {
|
|
153
|
+
const projectId = id ?? requireProject().id
|
|
154
|
+
const data = await graphql<{ deleteNode: boolean }>(
|
|
155
|
+
`mutation DeleteNode($id: String!) {
|
|
156
|
+
deleteNode(id: $id)
|
|
157
|
+
}`,
|
|
158
|
+
{ id: projectId },
|
|
159
|
+
)
|
|
160
|
+
output({ deleted: data.deleteNode })
|
|
161
|
+
} catch (error) {
|
|
162
|
+
outputError(error)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from 'vitest'
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
vi.mock('../util/config.ts', () => ({
|
|
4
4
|
requireFeature: vi.fn(() => {
|
|
@@ -6,6 +6,7 @@ vi.mock('../util/config.ts', () => ({
|
|
|
6
6
|
'No active feature. Run "flowy feature set <name-or-id>" or set FLOWY_FEATURE.',
|
|
7
7
|
)
|
|
8
8
|
}),
|
|
9
|
+
resolveProject: vi.fn(() => ({ id: 'proj_active', name: 'active' })),
|
|
9
10
|
}))
|
|
10
11
|
|
|
11
12
|
vi.mock('../util/client.ts', () => ({
|
|
@@ -18,10 +19,14 @@ vi.mock('../util/format.ts', () => ({
|
|
|
18
19
|
}))
|
|
19
20
|
|
|
20
21
|
describe('task command', () => {
|
|
21
|
-
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('exports a command group with 8 subcommands', async () => {
|
|
22
27
|
const { taskCommand } = await import('./task.ts')
|
|
23
28
|
expect(taskCommand.name()).toBe('task')
|
|
24
|
-
expect(taskCommand.commands).toHaveLength(
|
|
29
|
+
expect(taskCommand.commands).toHaveLength(8)
|
|
25
30
|
|
|
26
31
|
const names = taskCommand.commands.map((c) => c.name())
|
|
27
32
|
expect(names).toContain('create')
|
|
@@ -29,6 +34,9 @@ describe('task command', () => {
|
|
|
29
34
|
expect(names).toContain('show')
|
|
30
35
|
expect(names).toContain('block')
|
|
31
36
|
expect(names).toContain('unblock')
|
|
37
|
+
expect(names).toContain('update')
|
|
38
|
+
expect(names).toContain('delete')
|
|
39
|
+
expect(names).toContain('deps')
|
|
32
40
|
})
|
|
33
41
|
|
|
34
42
|
test('create exposes both --description and --description-file options', async () => {
|
|
@@ -88,4 +96,301 @@ describe('task command', () => {
|
|
|
88
96
|
}),
|
|
89
97
|
)
|
|
90
98
|
})
|
|
99
|
+
|
|
100
|
+
test('update sends updateNode with only the title when title-only', async () => {
|
|
101
|
+
const { graphql } = await import('../util/client.ts')
|
|
102
|
+
const { output } = await import('../util/format.ts')
|
|
103
|
+
const { taskCommand } = await import('./task.ts')
|
|
104
|
+
|
|
105
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
106
|
+
updateNode: { id: 'task_1', title: 'New' },
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
110
|
+
await updateCmd.parseAsync(['task_1', '--title', 'New'], { from: 'user' })
|
|
111
|
+
|
|
112
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
113
|
+
expect.stringContaining('updateNode'),
|
|
114
|
+
{ id: 'task_1', title: 'New' },
|
|
115
|
+
)
|
|
116
|
+
expect(output).toHaveBeenCalledWith({ id: 'task_1', title: 'New' })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('update sends updateNode with only the description when description-only', async () => {
|
|
120
|
+
const { graphql } = await import('../util/client.ts')
|
|
121
|
+
const { taskCommand } = await import('./task.ts')
|
|
122
|
+
|
|
123
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
124
|
+
updateNode: { id: 'task_1' },
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
128
|
+
await updateCmd.parseAsync(['task_1', '--description', 'Body'], {
|
|
129
|
+
from: 'user',
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
133
|
+
expect.stringContaining('updateNode'),
|
|
134
|
+
{
|
|
135
|
+
id: 'task_1',
|
|
136
|
+
description: 'Body',
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('update sends updateNode with only the metadata when metadata-only', async () => {
|
|
142
|
+
const { graphql } = await import('../util/client.ts')
|
|
143
|
+
const { taskCommand } = await import('./task.ts')
|
|
144
|
+
|
|
145
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
146
|
+
updateNode: { id: 'task_1' },
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
150
|
+
await updateCmd.parseAsync(['task_1', '--metadata', '{"k":"v"}'], {
|
|
151
|
+
from: 'user',
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
155
|
+
expect.stringContaining('updateNode'),
|
|
156
|
+
{
|
|
157
|
+
id: 'task_1',
|
|
158
|
+
metadata: '{"k":"v"}',
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('update sends updateNode with combined fields', async () => {
|
|
164
|
+
const { graphql } = await import('../util/client.ts')
|
|
165
|
+
const { taskCommand } = await import('./task.ts')
|
|
166
|
+
|
|
167
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
168
|
+
updateNode: { id: 'task_1' },
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
172
|
+
await updateCmd.parseAsync(
|
|
173
|
+
['task_1', '--title', 'New', '--description', 'Body', '--metadata', '{}'],
|
|
174
|
+
{ from: 'user' },
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
178
|
+
expect.stringContaining('updateNode'),
|
|
179
|
+
{
|
|
180
|
+
id: 'task_1',
|
|
181
|
+
title: 'New',
|
|
182
|
+
description: 'Body',
|
|
183
|
+
metadata: '{}',
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('update surfaces NOT_FOUND via outputError with its code', async () => {
|
|
189
|
+
const { graphql } = await import('../util/client.ts')
|
|
190
|
+
const { outputError } = await import('../util/format.ts')
|
|
191
|
+
const { taskCommand } = await import('./task.ts')
|
|
192
|
+
|
|
193
|
+
const notFound = Object.assign(new Error('Node task_x not found'), {
|
|
194
|
+
code: 'NOT_FOUND',
|
|
195
|
+
})
|
|
196
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
197
|
+
|
|
198
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
199
|
+
await updateCmd.parseAsync(['task_x', '--title', 'New'], { from: 'user' })
|
|
200
|
+
|
|
201
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
202
|
+
expect.objectContaining({ code: 'NOT_FOUND' }),
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('delete sends deleteNode mutation', async () => {
|
|
207
|
+
const { graphql } = await import('../util/client.ts')
|
|
208
|
+
const { output } = await import('../util/format.ts')
|
|
209
|
+
const { taskCommand } = await import('./task.ts')
|
|
210
|
+
|
|
211
|
+
vi.mocked(graphql).mockResolvedValueOnce({ deleteNode: true })
|
|
212
|
+
|
|
213
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
214
|
+
await deleteCmd.parseAsync(['task_1'], { from: 'user' })
|
|
215
|
+
|
|
216
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
217
|
+
expect.stringContaining('deleteNode'),
|
|
218
|
+
{
|
|
219
|
+
id: 'task_1',
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
expect(output).toHaveBeenCalledWith({ deleted: true })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('delete surfaces CONFLICT via outputError with its code', async () => {
|
|
226
|
+
const { graphql } = await import('../util/client.ts')
|
|
227
|
+
const { outputError } = await import('../util/format.ts')
|
|
228
|
+
const { taskCommand } = await import('./task.ts')
|
|
229
|
+
|
|
230
|
+
const conflict = Object.assign(new Error('has children'), {
|
|
231
|
+
code: 'CONFLICT',
|
|
232
|
+
})
|
|
233
|
+
vi.mocked(graphql).mockRejectedValueOnce(conflict)
|
|
234
|
+
|
|
235
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
236
|
+
await deleteCmd.parseAsync(['task_1'], { from: 'user' })
|
|
237
|
+
|
|
238
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
239
|
+
expect.objectContaining({ code: 'CONFLICT' }),
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('delete surfaces NOT_FOUND via outputError with its code', async () => {
|
|
244
|
+
const { graphql } = await import('../util/client.ts')
|
|
245
|
+
const { outputError } = await import('../util/format.ts')
|
|
246
|
+
const { taskCommand } = await import('./task.ts')
|
|
247
|
+
|
|
248
|
+
const notFound = Object.assign(new Error('Node task_x not found'), {
|
|
249
|
+
code: 'NOT_FOUND',
|
|
250
|
+
})
|
|
251
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
252
|
+
|
|
253
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
254
|
+
await deleteCmd.parseAsync(['task_x'], { from: 'user' })
|
|
255
|
+
|
|
256
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
257
|
+
expect.objectContaining({ code: 'NOT_FOUND' }),
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('show includes blockedBy and blocks in its output', async () => {
|
|
262
|
+
const { graphql } = await import('../util/client.ts')
|
|
263
|
+
const { output } = await import('../util/format.ts')
|
|
264
|
+
const { taskCommand } = await import('./task.ts')
|
|
265
|
+
|
|
266
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
267
|
+
node: { id: 'task_a', title: 'A', status: 'draft' },
|
|
268
|
+
blockedBy: [{ id: 'task_b', title: 'B', status: 'in_progress' }],
|
|
269
|
+
blocks: [{ id: 'task_c', title: 'C', status: 'draft' }],
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
const showCmd = taskCommand.commands.find((c) => c.name() === 'show')!
|
|
273
|
+
await showCmd.parseAsync(['task_a'], { from: 'user' })
|
|
274
|
+
|
|
275
|
+
expect(output).toHaveBeenCalledWith(
|
|
276
|
+
expect.objectContaining({
|
|
277
|
+
id: 'task_a',
|
|
278
|
+
blockedBy: [{ id: 'task_b', title: 'B', status: 'in_progress' }],
|
|
279
|
+
blocks: [{ id: 'task_c', title: 'C', status: 'draft' }],
|
|
280
|
+
}),
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('show queries edges for both directions of blocks', async () => {
|
|
285
|
+
const { graphql } = await import('../util/client.ts')
|
|
286
|
+
const { taskCommand } = await import('./task.ts')
|
|
287
|
+
|
|
288
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
289
|
+
node: { id: 'task_a', title: 'A', status: 'draft' },
|
|
290
|
+
blockedBy: [],
|
|
291
|
+
blocks: [],
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const showCmd = taskCommand.commands.find((c) => c.name() === 'show')!
|
|
295
|
+
await showCmd.parseAsync(['task_a'], { from: 'user' })
|
|
296
|
+
|
|
297
|
+
const [query, variables] = vi.mocked(graphql).mock.calls[0]!
|
|
298
|
+
expect(query).toContain('blockedBy')
|
|
299
|
+
expect(query).toContain('blocks')
|
|
300
|
+
expect(variables).toMatchObject({ id: 'task_a' })
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('deps lists blockedBy and blocks for a task', async () => {
|
|
304
|
+
const { graphql } = await import('../util/client.ts')
|
|
305
|
+
const { output } = await import('../util/format.ts')
|
|
306
|
+
const { taskCommand } = await import('./task.ts')
|
|
307
|
+
|
|
308
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
309
|
+
blockedBy: [{ id: 'task_b', title: 'B', status: 'draft' }],
|
|
310
|
+
blocks: [{ id: 'task_c', title: 'C', status: 'done' }],
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const depsCmd = taskCommand.commands.find((c) => c.name() === 'deps')!
|
|
314
|
+
await depsCmd.parseAsync(['task_a'], { from: 'user' })
|
|
315
|
+
|
|
316
|
+
expect(output).toHaveBeenCalledWith({
|
|
317
|
+
id: 'task_a',
|
|
318
|
+
blockedBy: [{ id: 'task_b', title: 'B', status: 'draft' }],
|
|
319
|
+
blocks: [{ id: 'task_c', title: 'C', status: 'done' }],
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('deps surfaces errors via outputError', async () => {
|
|
324
|
+
const { graphql } = await import('../util/client.ts')
|
|
325
|
+
const { outputError } = await import('../util/format.ts')
|
|
326
|
+
const { taskCommand } = await import('./task.ts')
|
|
327
|
+
|
|
328
|
+
vi.mocked(graphql).mockRejectedValueOnce(new Error('Node task_a not found'))
|
|
329
|
+
|
|
330
|
+
const depsCmd = taskCommand.commands.find((c) => c.name() === 'deps')!
|
|
331
|
+
await depsCmd.parseAsync(['task_a'], { from: 'user' })
|
|
332
|
+
|
|
333
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
334
|
+
expect.objectContaining({ message: 'Node task_a not found' }),
|
|
335
|
+
)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('list --ready queries readyTasks and prints them', async () => {
|
|
339
|
+
const { graphql } = await import('../util/client.ts')
|
|
340
|
+
const { output } = await import('../util/format.ts')
|
|
341
|
+
const { taskCommand } = await import('./task.ts')
|
|
342
|
+
|
|
343
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
344
|
+
readyTasks: [{ id: 'task_x', title: 'X', status: 'draft' }],
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const listCmd = taskCommand.commands.find((c) => c.name() === 'list')!
|
|
348
|
+
await listCmd.parseAsync(['--ready'], { from: 'user' })
|
|
349
|
+
|
|
350
|
+
const [query] = vi.mocked(graphql).mock.calls[0]!
|
|
351
|
+
expect(query).toContain('readyTasks')
|
|
352
|
+
expect(output).toHaveBeenCalledWith([
|
|
353
|
+
{ id: 'task_x', title: 'X', status: 'draft' },
|
|
354
|
+
])
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('list --ready --project scopes readyTasks to the given project', async () => {
|
|
358
|
+
const { graphql } = await import('../util/client.ts')
|
|
359
|
+
const { taskCommand } = await import('./task.ts')
|
|
360
|
+
|
|
361
|
+
vi.mocked(graphql).mockResolvedValueOnce({ readyTasks: [] })
|
|
362
|
+
|
|
363
|
+
const listCmd = taskCommand.commands.find((c) => c.name() === 'list')!
|
|
364
|
+
await listCmd.parseAsync(['--ready', '--project', 'proj_42'], {
|
|
365
|
+
from: 'user',
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
const [query, variables] = vi.mocked(graphql).mock.calls[0]!
|
|
369
|
+
expect(query).toContain('readyTasks')
|
|
370
|
+
expect(variables).toMatchObject({ projectId: 'proj_42' })
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('list --all lists every task node', async () => {
|
|
374
|
+
const { graphql } = await import('../util/client.ts')
|
|
375
|
+
const { output } = await import('../util/format.ts')
|
|
376
|
+
const { taskCommand } = await import('./task.ts')
|
|
377
|
+
|
|
378
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
379
|
+
nodes: [
|
|
380
|
+
{ id: 'task_1', type: 'task', title: 'One', status: 'draft' },
|
|
381
|
+
{ id: 'task_2', type: 'task', title: 'Two', status: 'done' },
|
|
382
|
+
],
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
const listCmd = taskCommand.commands.find((c) => c.name() === 'list')!
|
|
386
|
+
await listCmd.parseAsync(['--all'], { from: 'user' })
|
|
387
|
+
|
|
388
|
+
const [query, variables] = vi.mocked(graphql).mock.calls[0]!
|
|
389
|
+
expect(query).toContain('nodes')
|
|
390
|
+
expect(variables).toMatchObject({ type: 'task' })
|
|
391
|
+
expect(output).toHaveBeenCalledWith([
|
|
392
|
+
{ id: 'task_1', type: 'task', title: 'One', status: 'draft' },
|
|
393
|
+
{ id: 'task_2', type: 'task', title: 'Two', status: 'done' },
|
|
394
|
+
])
|
|
395
|
+
})
|
|
91
396
|
})
|
package/src/commands/task.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
-
import { requireFeature } from '../util/config.ts'
|
|
3
|
+
import { requireFeature, resolveProject } from '../util/config.ts'
|
|
4
4
|
import { resolveDescription } from '../util/description.ts'
|
|
5
5
|
import { output, outputError } from '../util/format.ts'
|
|
6
6
|
|
|
@@ -53,8 +53,45 @@ taskCommand
|
|
|
53
53
|
taskCommand
|
|
54
54
|
.command('list')
|
|
55
55
|
.description('List tasks in the active feature')
|
|
56
|
-
.
|
|
56
|
+
.option(
|
|
57
|
+
'--ready',
|
|
58
|
+
'Only actionable tasks: not done/cancelled and with zero unfinished blockers',
|
|
59
|
+
)
|
|
60
|
+
.option('--all', 'List every task across the whole backlog')
|
|
61
|
+
.option(
|
|
62
|
+
'--project <id>',
|
|
63
|
+
'Scope --ready/--all to a project (defaults to the active project)',
|
|
64
|
+
)
|
|
65
|
+
.action(async (opts) => {
|
|
57
66
|
try {
|
|
67
|
+
if (opts.ready) {
|
|
68
|
+
const projectId =
|
|
69
|
+
opts.project ?? (opts.all ? undefined : resolveProject()?.id)
|
|
70
|
+
const data = await graphql<{ readyTasks: unknown[] }>(
|
|
71
|
+
`query ReadyTasks($projectId: String) {
|
|
72
|
+
readyTasks(projectId: $projectId) {
|
|
73
|
+
id type title status createdAt
|
|
74
|
+
}
|
|
75
|
+
}`,
|
|
76
|
+
{ projectId: projectId ?? null },
|
|
77
|
+
)
|
|
78
|
+
output(data.readyTasks)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (opts.all) {
|
|
83
|
+
const data = await graphql<{ nodes: unknown[] }>(
|
|
84
|
+
`query AllTasks($type: String!) {
|
|
85
|
+
nodes(type: $type) {
|
|
86
|
+
id type title status createdAt
|
|
87
|
+
}
|
|
88
|
+
}`,
|
|
89
|
+
{ type: 'task' },
|
|
90
|
+
)
|
|
91
|
+
output(data.nodes)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
const featureId = requireFeature()
|
|
59
96
|
const data = await graphql<{ descendants: unknown[] }>(
|
|
60
97
|
`query ListTasks($nodeId: String!, $relation: String!, $maxDepth: Int) {
|
|
@@ -75,19 +112,90 @@ taskCommand
|
|
|
75
112
|
|
|
76
113
|
taskCommand
|
|
77
114
|
.command('show')
|
|
78
|
-
.description('Show task details')
|
|
115
|
+
.description('Show task details, including its blockedBy/blocks dependencies')
|
|
79
116
|
.argument('<id>', 'Task ID')
|
|
80
117
|
.action(async (id: string) => {
|
|
81
118
|
try {
|
|
82
|
-
const data = await graphql<{
|
|
119
|
+
const data = await graphql<{
|
|
120
|
+
node: Record<string, unknown>
|
|
121
|
+
blockedBy: unknown[]
|
|
122
|
+
blocks: unknown[]
|
|
123
|
+
}>(
|
|
83
124
|
`query ShowTask($id: String!) {
|
|
84
125
|
node(id: $id) {
|
|
85
126
|
id type title description status metadata createdAt updatedAt
|
|
86
127
|
}
|
|
128
|
+
blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
|
|
129
|
+
id type title status
|
|
130
|
+
}
|
|
131
|
+
blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
|
|
132
|
+
id type title status
|
|
133
|
+
}
|
|
134
|
+
}`,
|
|
135
|
+
{ id },
|
|
136
|
+
)
|
|
137
|
+
output({
|
|
138
|
+
...data.node,
|
|
139
|
+
blockedBy: data.blockedBy,
|
|
140
|
+
blocks: data.blocks,
|
|
141
|
+
})
|
|
142
|
+
} catch (error) {
|
|
143
|
+
outputError(error)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
taskCommand
|
|
148
|
+
.command('update')
|
|
149
|
+
.description('Update a task')
|
|
150
|
+
.argument('<id>', 'Task ID')
|
|
151
|
+
.option('--title <title>', 'New title')
|
|
152
|
+
.option(
|
|
153
|
+
'--description <text>',
|
|
154
|
+
'New description, used verbatim (never read as a file path)',
|
|
155
|
+
)
|
|
156
|
+
.option(
|
|
157
|
+
'--description-file <path>',
|
|
158
|
+
'Read the new description from a file, or "-" for stdin',
|
|
159
|
+
)
|
|
160
|
+
.option('--metadata <json>', 'New metadata as a JSON string')
|
|
161
|
+
.action(async (id: string, opts) => {
|
|
162
|
+
try {
|
|
163
|
+
const variables: Record<string, unknown> = { id }
|
|
164
|
+
if (opts.title != null) variables.title = opts.title
|
|
165
|
+
if (opts.description != null || opts.descriptionFile != null) {
|
|
166
|
+
variables.description = await resolveDescription({
|
|
167
|
+
description: opts.description,
|
|
168
|
+
descriptionFile: opts.descriptionFile,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
172
|
+
const data = await graphql<{ updateNode: unknown }>(
|
|
173
|
+
`mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
|
|
174
|
+
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
175
|
+
id type title description status metadata createdAt updatedAt
|
|
176
|
+
}
|
|
177
|
+
}`,
|
|
178
|
+
variables,
|
|
179
|
+
)
|
|
180
|
+
output(data.updateNode)
|
|
181
|
+
} catch (error) {
|
|
182
|
+
outputError(error)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
taskCommand
|
|
187
|
+
.command('delete')
|
|
188
|
+
.description('Delete a task')
|
|
189
|
+
.argument('<id>', 'Task ID')
|
|
190
|
+
.action(async (id: string) => {
|
|
191
|
+
try {
|
|
192
|
+
const data = await graphql<{ deleteNode: boolean }>(
|
|
193
|
+
`mutation DeleteNode($id: String!) {
|
|
194
|
+
deleteNode(id: $id)
|
|
87
195
|
}`,
|
|
88
196
|
{ id },
|
|
89
197
|
)
|
|
90
|
-
output(data.
|
|
198
|
+
output({ deleted: data.deleteNode })
|
|
91
199
|
} catch (error) {
|
|
92
200
|
outputError(error)
|
|
93
201
|
}
|
|
@@ -132,3 +240,26 @@ taskCommand
|
|
|
132
240
|
outputError(error)
|
|
133
241
|
}
|
|
134
242
|
})
|
|
243
|
+
|
|
244
|
+
taskCommand
|
|
245
|
+
.command('deps')
|
|
246
|
+
.description('List a task’s dependencies: what blocks it and what it blocks')
|
|
247
|
+
.argument('<id>', 'Task ID')
|
|
248
|
+
.action(async (id: string) => {
|
|
249
|
+
try {
|
|
250
|
+
const data = await graphql<{ blockedBy: unknown[]; blocks: unknown[] }>(
|
|
251
|
+
`query TaskDeps($id: String!) {
|
|
252
|
+
blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
|
|
253
|
+
id type title status
|
|
254
|
+
}
|
|
255
|
+
blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
|
|
256
|
+
id type title status
|
|
257
|
+
}
|
|
258
|
+
}`,
|
|
259
|
+
{ id },
|
|
260
|
+
)
|
|
261
|
+
output({ id, blockedBy: data.blockedBy, blocks: data.blocks })
|
|
262
|
+
} catch (error) {
|
|
263
|
+
outputError(error)
|
|
264
|
+
}
|
|
265
|
+
})
|