@sascha384/tic 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +32 -17
  3. package/dist/app.js +7 -2
  4. package/dist/app.js.map +1 -1
  5. package/dist/auth/ado.d.ts +31 -0
  6. package/dist/auth/ado.js +136 -0
  7. package/dist/auth/ado.js.map +1 -0
  8. package/dist/auth/index.d.ts +2 -0
  9. package/dist/auth/index.js +1 -0
  10. package/dist/auth/index.js.map +1 -1
  11. package/dist/backends/ado/api.d.ts +19 -0
  12. package/dist/backends/ado/api.js +110 -0
  13. package/dist/backends/ado/api.js.map +1 -0
  14. package/dist/backends/ado/index.d.ts +6 -12
  15. package/dist/backends/ado/index.js +201 -349
  16. package/dist/backends/ado/index.js.map +1 -1
  17. package/dist/backends/factory.js +1 -1
  18. package/dist/backends/factory.js.map +1 -1
  19. package/dist/cli/commands/auth.d.ts +5 -3
  20. package/dist/cli/commands/auth.js +39 -4
  21. package/dist/cli/commands/auth.js.map +1 -1
  22. package/dist/cli/commands/mcp.js +17 -17
  23. package/dist/cli/commands/mcp.js.map +1 -1
  24. package/dist/cli/index.js +5 -4
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/components/AuthPrompt.d.ts +1 -0
  27. package/dist/components/AuthPrompt.js +37 -0
  28. package/dist/components/AuthPrompt.js.map +1 -0
  29. package/dist/components/Header.js +10 -2
  30. package/dist/components/Header.js.map +1 -1
  31. package/dist/components/StatusScreen.js +2 -1
  32. package/dist/components/StatusScreen.js.map +1 -1
  33. package/dist/stores/backendDataStore.d.ts +15 -0
  34. package/dist/stores/backendDataStore.js +81 -5
  35. package/dist/stores/backendDataStore.js.map +1 -1
  36. package/package.json +1 -1
  37. package/dist/backends/ado/az.d.ts +0 -28
  38. package/dist/backends/ado/az.js +0 -162
  39. package/dist/backends/ado/az.js.map +0 -1
@@ -1,26 +1,49 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { BaseBackend, UnsupportedOperationError } from '../types.js';
3
- import { az, azExec, azInvoke, azRest, azExecSync, azInvokeSync, } from './az.js';
3
+ import { getAdoToken, getAdoPat, authenticateAdo } from '../../auth/ado.js';
4
+ import { AuthError } from '../shared/api-client.js';
5
+ import { AdoApiClient } from './api.js';
4
6
  import { parseAdoRemote } from './remote.js';
5
7
  import { mapWorkItemToWorkItem, mapCommentToComment, mapPriorityToAdo, formatTags, extractParent, extractPredecessors, } from './mappers.js';
6
8
  export class AzureDevOpsBackend extends BaseBackend {
7
- cwd;
9
+ api;
8
10
  org;
9
11
  project;
10
12
  types;
11
- constructor(cwd) {
13
+ constructor(api, org, project, types) {
12
14
  super(60_000);
13
- this.cwd = cwd;
14
- azExecSync(['account', 'show'], cwd);
15
+ this.api = api;
16
+ this.org = org;
17
+ this.project = project;
18
+ this.types = types;
19
+ }
20
+ static async create(cwd, options) {
15
21
  const remote = parseAdoRemote(cwd);
16
- this.org = remote.org;
17
- this.project = remote.project;
18
- this.types = azInvokeSync({
19
- area: 'wit',
20
- resource: 'workitemtypes',
21
- routeParameters: `project=${this.project}`,
22
- apiVersion: '7.1',
23
- }, cwd).value;
22
+ let auth = null;
23
+ const token = getAdoToken();
24
+ const pat = getAdoPat();
25
+ if (token) {
26
+ auth = { type: 'bearer', token };
27
+ }
28
+ else if (pat) {
29
+ auth = { type: 'basic', pat };
30
+ }
31
+ if (!auth) {
32
+ if (options?.skipAuth) {
33
+ throw new AuthError('Azure DevOps authentication required. Run "tic auth login azure" to authenticate.');
34
+ }
35
+ const accessToken = await authenticateAdo({
36
+ onCode: (code, url) => {
37
+ console.log(`\nAzure DevOps authentication required.`);
38
+ console.log(`Visit ${url} and enter code: ${code}\n`);
39
+ },
40
+ });
41
+ auth = { type: 'bearer', token: accessToken };
42
+ }
43
+ const api = new AdoApiClient(auth, remote.org);
44
+ // Fetch work item types to verify auth and cache type metadata
45
+ const typesResult = await api.rest('GET', `/${remote.project}/_apis/wit/workitemtypes`);
46
+ return new AzureDevOpsBackend(api, remote.org, remote.project, typesResult.value);
24
47
  }
25
48
  getCapabilities() {
26
49
  return {
@@ -66,18 +89,8 @@ export class AzureDevOpsBackend extends BaseBackend {
66
89
  }
67
90
  async getAssignees() {
68
91
  try {
69
- const members = await az([
70
- 'devops',
71
- 'team',
72
- 'list-members',
73
- '--team',
74
- `${this.project} Team`,
75
- '--org',
76
- `https://dev.azure.com/${this.org}`,
77
- '--project',
78
- this.project,
79
- ], this.cwd);
80
- return members.map((m) => m.identity.displayName);
92
+ const result = await this.api.rest('GET', `/_apis/projects/${encodeURIComponent(this.project)}/teams/${encodeURIComponent(this.project + ' Team')}/members`);
93
+ return result.value.map((m) => m.identity.displayName);
81
94
  }
82
95
  catch {
83
96
  return [];
@@ -87,36 +100,12 @@ export class AzureDevOpsBackend extends BaseBackend {
87
100
  return this.getLabelsFromCache();
88
101
  }
89
102
  async getIterations() {
90
- const iterations = await az([
91
- 'boards',
92
- 'iteration',
93
- 'team',
94
- 'list',
95
- '--team',
96
- `${this.project} Team`,
97
- '--org',
98
- `https://dev.azure.com/${this.org}`,
99
- '--project',
100
- this.project,
101
- ], this.cwd);
102
- return iterations.map((i) => i.path);
103
+ const result = await this.api.rest('GET', `/${encodeURIComponent(this.project)}/${encodeURIComponent(this.project + ' Team')}/_apis/work/teamsettings/iterations`);
104
+ return result.value.map((i) => i.path);
103
105
  }
104
106
  async getCurrentIteration() {
105
- const iterations = await az([
106
- 'boards',
107
- 'iteration',
108
- 'team',
109
- 'list',
110
- '--team',
111
- `${this.project} Team`,
112
- '--timeframe',
113
- 'current',
114
- '--org',
115
- `https://dev.azure.com/${this.org}`,
116
- '--project',
117
- this.project,
118
- ], this.cwd);
119
- return iterations[0]?.path ?? '';
107
+ const result = await this.api.rest('GET', `/${encodeURIComponent(this.project)}/${encodeURIComponent(this.project + ' Team')}/_apis/work/teamsettings/iterations?$timeframe=current`);
108
+ return result.value[0]?.path ?? '';
120
109
  }
121
110
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
122
111
  async setCurrentIteration(_name) {
@@ -126,37 +115,16 @@ export class AzureDevOpsBackend extends BaseBackend {
126
115
  return value.replace(/'/g, "''");
127
116
  }
128
117
  async batchFetchWorkItems(ids) {
129
- const CHUNK_SIZE = 200;
130
- const items = [];
131
- for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
132
- const chunk = ids.slice(i, i + CHUNK_SIZE);
133
- const batchResult = await azInvoke({
134
- area: 'wit',
135
- resource: 'workitemsbatch',
136
- httpMethod: 'POST',
137
- body: { ids: chunk, $expand: 4 },
138
- apiVersion: '7.1',
139
- }, this.cwd);
140
- items.push(...batchResult.value.map(mapWorkItemToWorkItem));
141
- }
142
- return items;
118
+ const result = await this.api.batchGetWorkItems(ids);
119
+ return result.value.map(mapWorkItemToWorkItem);
143
120
  }
144
121
  async listWorkItems(iteration) {
145
122
  let wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '${this.escapeWiql(this.project)}'`;
146
123
  if (iteration) {
147
124
  wiql += ` AND [System.IterationPath] = '${this.escapeWiql(iteration)}'`;
148
125
  }
149
- const queryResult = await az([
150
- 'boards',
151
- 'query',
152
- '--wiql',
153
- wiql,
154
- '--org',
155
- `https://dev.azure.com/${this.org}`,
156
- '--project',
157
- this.project,
158
- ], this.cwd);
159
- const ids = queryResult.map((w) => w.id);
126
+ const queryResult = await this.api.wiql(this.project, wiql);
127
+ const ids = queryResult.workItems.map((w) => w.id);
160
128
  if (ids.length === 0)
161
129
  return [];
162
130
  const items = await this.batchFetchWorkItems(ids);
@@ -164,25 +132,11 @@ export class AzureDevOpsBackend extends BaseBackend {
164
132
  return items;
165
133
  }
166
134
  async getWorkItem(id) {
167
- // Fetch work item and comments in parallel.
168
- // Comments use `az rest` which requires AAD auth (`az login`).
169
- // If only PAT auth is available (`az devops login`), comments
170
- // gracefully degrade to an empty array.
171
135
  const [ado, commentResult] = await Promise.all([
172
- az([
173
- 'boards',
174
- 'work-item',
175
- 'show',
176
- '--id',
177
- id,
178
- '--expand',
179
- 'relations',
180
- '--org',
181
- `https://dev.azure.com/${this.org}`,
182
- ], this.cwd),
183
- azRest({
184
- url: `https://dev.azure.com/${this.org}/${encodeURIComponent(this.project)}/_apis/wit/workItems/${id}/comments?api-version=7.1-preview.4`,
185
- }, this.cwd).catch(() => ({ comments: [] })),
136
+ this.api.rest('GET', `/${encodeURIComponent(this.project)}/_apis/wit/workitems/${id}?$expand=relations`),
137
+ this.api
138
+ .rest('GET', `/${encodeURIComponent(this.project)}/_apis/wit/workItems/${id}/comments?api-version=7.1-preview.4`)
139
+ .catch(() => ({ comments: [] })),
186
140
  ]);
187
141
  const item = mapWorkItemToWorkItem(ado);
188
142
  item.comments = (commentResult.comments ?? []).map(mapCommentToComment);
@@ -190,268 +144,182 @@ export class AzureDevOpsBackend extends BaseBackend {
190
144
  }
191
145
  async createWorkItem(data) {
192
146
  this.validateFields(data);
193
- const args = [
194
- 'boards',
195
- 'work-item',
196
- 'create',
197
- '--type',
198
- data.type,
199
- '--title',
200
- data.title,
201
- '--org',
202
- `https://dev.azure.com/${this.org}`,
203
- '--project',
204
- this.project,
147
+ const patch = [
148
+ { op: 'add', path: '/fields/System.Title', value: data.title },
205
149
  ];
206
- const fields = [];
207
150
  if (data.status)
208
- fields.push(`System.State=${data.status}`);
151
+ patch.push({
152
+ op: 'add',
153
+ path: '/fields/System.State',
154
+ value: data.status,
155
+ });
209
156
  if (data.iteration)
210
- fields.push(`System.IterationPath=${data.iteration}`);
157
+ patch.push({
158
+ op: 'add',
159
+ path: '/fields/System.IterationPath',
160
+ value: data.iteration,
161
+ });
211
162
  if (data.priority)
212
- fields.push(`Microsoft.VSTS.Common.Priority=${mapPriorityToAdo(data.priority)}`);
163
+ patch.push({
164
+ op: 'add',
165
+ path: '/fields/Microsoft.VSTS.Common.Priority',
166
+ value: mapPriorityToAdo(data.priority),
167
+ });
213
168
  if (data.assignee)
214
- fields.push(`System.AssignedTo=${data.assignee}`);
169
+ patch.push({
170
+ op: 'add',
171
+ path: '/fields/System.AssignedTo',
172
+ value: data.assignee,
173
+ });
215
174
  if (data.labels.length > 0)
216
- fields.push(`System.Tags=${formatTags(data.labels)}`);
175
+ patch.push({
176
+ op: 'add',
177
+ path: '/fields/System.Tags',
178
+ value: formatTags(data.labels),
179
+ });
217
180
  if (data.description)
218
- fields.push(`System.Description=${data.description}`);
219
- for (const field of fields) {
220
- args.push('--fields', field);
181
+ patch.push({
182
+ op: 'add',
183
+ path: '/fields/System.Description',
184
+ value: data.description,
185
+ });
186
+ // Add parent relation in same request
187
+ if (data.parent) {
188
+ patch.push({
189
+ op: 'add',
190
+ path: '/relations/-',
191
+ value: {
192
+ rel: 'System.LinkTypes.Hierarchy-Reverse',
193
+ url: `https://dev.azure.com/${this.org}/_apis/wit/workitems/${data.parent}`,
194
+ },
195
+ });
221
196
  }
222
- const created = await az(args, this.cwd);
223
- const createdId = String(created.id);
224
- // Add parent and dependency relations
225
- if (data.parent || data.dependsOn.length > 0) {
226
- try {
227
- if (data.parent) {
228
- await azExec([
229
- 'boards',
230
- 'work-item',
231
- 'relation',
232
- 'add',
233
- '--id',
234
- createdId,
235
- '--relation-type',
236
- 'System.LinkTypes.Hierarchy-Reverse',
237
- '--target-id',
238
- data.parent,
239
- '--org',
240
- `https://dev.azure.com/${this.org}`,
241
- ], this.cwd);
242
- }
243
- for (const depId of data.dependsOn) {
244
- await azExec([
245
- 'boards',
246
- 'work-item',
247
- 'relation',
248
- 'add',
249
- '--id',
250
- createdId,
251
- '--relation-type',
252
- 'System.LinkTypes.Dependency-Reverse',
253
- '--target-id',
254
- depId,
255
- '--org',
256
- `https://dev.azure.com/${this.org}`,
257
- ], this.cwd);
258
- }
259
- }
260
- catch (err) {
261
- try {
262
- await azExec([
263
- 'boards',
264
- 'work-item',
265
- 'delete',
266
- '--id',
267
- createdId,
268
- '--yes',
269
- '--org',
270
- `https://dev.azure.com/${this.org}`,
271
- '--project',
272
- this.project,
273
- ], this.cwd);
274
- }
275
- catch {
276
- // Best-effort cleanup
277
- }
278
- throw new Error(`Failed to link relationships for work item #${createdId}; item was rolled back: ${err instanceof Error ? err.message : String(err)}`);
279
- }
197
+ // Add dependency relations in same request
198
+ for (const depId of data.dependsOn) {
199
+ patch.push({
200
+ op: 'add',
201
+ path: '/relations/-',
202
+ value: {
203
+ rel: 'System.LinkTypes.Dependency-Reverse',
204
+ url: `https://dev.azure.com/${this.org}/_apis/wit/workitems/${depId}`,
205
+ },
206
+ });
280
207
  }
281
- return this.getWorkItem(createdId);
208
+ const created = await this.api.rest('POST', `/${encodeURIComponent(this.project)}/_apis/wit/workitems/$${encodeURIComponent(data.type)}`, patch, 'application/json-patch+json');
209
+ return this.getWorkItem(String(created.id));
282
210
  }
283
211
  async updateWorkItem(id, data) {
284
212
  this.validateFields(data);
285
- const args = [
286
- 'boards',
287
- 'work-item',
288
- 'update',
289
- '--id',
290
- id,
291
- '--org',
292
- `https://dev.azure.com/${this.org}`,
293
- ];
294
- const fields = [];
213
+ const patch = [];
295
214
  if (data.title !== undefined)
296
- fields.push(`System.Title=${data.title}`);
215
+ patch.push({
216
+ op: 'replace',
217
+ path: '/fields/System.Title',
218
+ value: data.title,
219
+ });
297
220
  if (data.status !== undefined)
298
- fields.push(`System.State=${data.status}`);
221
+ patch.push({
222
+ op: 'replace',
223
+ path: '/fields/System.State',
224
+ value: data.status,
225
+ });
299
226
  if (data.iteration !== undefined)
300
- fields.push(`System.IterationPath=${data.iteration}`);
227
+ patch.push({
228
+ op: 'replace',
229
+ path: '/fields/System.IterationPath',
230
+ value: data.iteration,
231
+ });
301
232
  if (data.priority !== undefined)
302
- fields.push(`Microsoft.VSTS.Common.Priority=${mapPriorityToAdo(data.priority)}`);
233
+ patch.push({
234
+ op: 'replace',
235
+ path: '/fields/Microsoft.VSTS.Common.Priority',
236
+ value: mapPriorityToAdo(data.priority),
237
+ });
303
238
  if (data.assignee !== undefined)
304
- fields.push(`System.AssignedTo=${data.assignee}`);
239
+ patch.push({
240
+ op: 'replace',
241
+ path: '/fields/System.AssignedTo',
242
+ value: data.assignee,
243
+ });
305
244
  if (data.labels !== undefined)
306
- fields.push(`System.Tags=${formatTags(data.labels)}`);
245
+ patch.push({
246
+ op: 'replace',
247
+ path: '/fields/System.Tags',
248
+ value: formatTags(data.labels),
249
+ });
307
250
  if (data.description !== undefined)
308
- fields.push(`System.Description=${data.description}`);
309
- for (const field of fields) {
310
- args.push('--fields', field);
311
- }
312
- if (fields.length > 0) {
313
- await az(args, this.cwd);
314
- }
315
- // Handle parent and dependency relation changes (single fetch)
251
+ patch.push({
252
+ op: 'replace',
253
+ path: '/fields/System.Description',
254
+ value: data.description,
255
+ });
256
+ // Handle relation changes — need to fetch current relations first
316
257
  if (data.parent !== undefined || data.dependsOn !== undefined) {
317
- const current = await az([
318
- 'boards',
319
- 'work-item',
320
- 'show',
321
- '--id',
322
- id,
323
- '--expand',
324
- 'relations',
325
- '--org',
326
- `https://dev.azure.com/${this.org}`,
327
- ], this.cwd);
328
- try {
329
- if (data.parent !== undefined) {
330
- const currentParent = extractParent(current.relations);
331
- if (currentParent && currentParent !== data.parent) {
332
- await azExec([
333
- 'boards',
334
- 'work-item',
335
- 'relation',
336
- 'remove',
337
- '--id',
338
- id,
339
- '--relation-type',
340
- 'System.LinkTypes.Hierarchy-Reverse',
341
- '--target-id',
342
- currentParent,
343
- '--org',
344
- `https://dev.azure.com/${this.org}`,
345
- '--yes',
346
- ], this.cwd);
347
- }
348
- if (data.parent && data.parent !== currentParent) {
349
- await azExec([
350
- 'boards',
351
- 'work-item',
352
- 'relation',
353
- 'add',
354
- '--id',
355
- id,
356
- '--relation-type',
357
- 'System.LinkTypes.Hierarchy-Reverse',
358
- '--target-id',
359
- data.parent,
360
- '--org',
361
- `https://dev.azure.com/${this.org}`,
362
- ], this.cwd);
258
+ const current = await this.api.rest('GET', `/${encodeURIComponent(this.project)}/_apis/wit/workitems/${id}?$expand=relations`);
259
+ if (data.parent !== undefined) {
260
+ const currentParent = extractParent(current.relations);
261
+ if (currentParent && currentParent !== data.parent) {
262
+ // Find the index of the parent relation to remove it
263
+ const parentIdx = current.relations?.findIndex((r) => r.rel === 'System.LinkTypes.Hierarchy-Reverse');
264
+ if (parentIdx !== undefined && parentIdx >= 0) {
265
+ patch.push({
266
+ op: 'remove',
267
+ path: `/relations/${parentIdx}`,
268
+ });
363
269
  }
364
270
  }
365
- if (data.dependsOn !== undefined) {
366
- const currentDeps = new Set(extractPredecessors(current.relations));
367
- const newDeps = new Set(data.dependsOn);
368
- // Remove deps that are no longer in the list
369
- for (const dep of currentDeps) {
370
- if (!newDeps.has(dep)) {
371
- await azExec([
372
- 'boards',
373
- 'work-item',
374
- 'relation',
375
- 'remove',
376
- '--id',
377
- id,
378
- '--relation-type',
379
- 'System.LinkTypes.Dependency-Reverse',
380
- '--target-id',
381
- dep,
382
- '--org',
383
- `https://dev.azure.com/${this.org}`,
384
- '--yes',
385
- ], this.cwd);
271
+ if (data.parent && data.parent !== currentParent) {
272
+ patch.push({
273
+ op: 'add',
274
+ path: '/relations/-',
275
+ value: {
276
+ rel: 'System.LinkTypes.Hierarchy-Reverse',
277
+ url: `https://dev.azure.com/${this.org}/_apis/wit/workitems/${data.parent}`,
278
+ },
279
+ });
280
+ }
281
+ }
282
+ if (data.dependsOn !== undefined) {
283
+ const currentDeps = new Set(extractPredecessors(current.relations));
284
+ const newDeps = new Set(data.dependsOn);
285
+ // Remove deps no longer in the list (iterate in reverse to preserve indices)
286
+ const removeIndices = [];
287
+ current.relations?.forEach((r, i) => {
288
+ if (r.rel === 'System.LinkTypes.Dependency-Reverse') {
289
+ const depId = r.url.match(/\/workitems\/(\d+)$/i)?.[1];
290
+ if (depId && !newDeps.has(depId)) {
291
+ removeIndices.push(i);
386
292
  }
387
293
  }
388
- // Add deps that are new
389
- for (const dep of newDeps) {
390
- if (!currentDeps.has(dep)) {
391
- await azExec([
392
- 'boards',
393
- 'work-item',
394
- 'relation',
395
- 'add',
396
- '--id',
397
- id,
398
- '--relation-type',
399
- 'System.LinkTypes.Dependency-Reverse',
400
- '--target-id',
401
- dep,
402
- '--org',
403
- `https://dev.azure.com/${this.org}`,
404
- ], this.cwd);
405
- }
294
+ });
295
+ for (const idx of removeIndices.reverse()) {
296
+ patch.push({ op: 'remove', path: `/relations/${idx}` });
297
+ }
298
+ // Add new deps
299
+ for (const dep of newDeps) {
300
+ if (!currentDeps.has(dep)) {
301
+ patch.push({
302
+ op: 'add',
303
+ path: '/relations/-',
304
+ value: {
305
+ rel: 'System.LinkTypes.Dependency-Reverse',
306
+ url: `https://dev.azure.com/${this.org}/_apis/wit/workitems/${dep}`,
307
+ },
308
+ });
406
309
  }
407
310
  }
408
311
  }
409
- catch (err) {
410
- throw new Error(`Failed to update relationships for work item #${id}: ${err instanceof Error ? err.message : String(err)}`);
411
- }
312
+ }
313
+ if (patch.length > 0) {
314
+ await this.api.rest('PATCH', `/_apis/wit/workitems/${id}`, patch, 'application/json-patch+json');
412
315
  }
413
316
  return this.getWorkItem(id);
414
317
  }
415
318
  async deleteWorkItem(id) {
416
- await azExec([
417
- 'boards',
418
- 'work-item',
419
- 'delete',
420
- '--id',
421
- id,
422
- '--yes',
423
- '--org',
424
- `https://dev.azure.com/${this.org}`,
425
- '--project',
426
- this.project,
427
- ], this.cwd);
319
+ await this.api.rest('DELETE', `/_apis/wit/workitems/${id}`);
428
320
  }
429
- /**
430
- * Add a comment to a work item.
431
- *
432
- * The Work Item Comments API is preview-only (7.1-preview.4) and cannot be
433
- * called via `az devops invoke` (it fails to parse preview version strings),
434
- * so we use `az rest` instead. Unlike `az devops` commands which honor PAT
435
- * auth from `az devops login`, `az rest --resource` requires an Azure AD
436
- * token obtained via `az login`. If only PAT auth is available, this method
437
- * throws a descriptive error directing the user to run `az login`.
438
- */
439
321
  async addComment(workItemId, comment) {
440
- try {
441
- await azRest({
442
- url: `https://dev.azure.com/${this.org}/${encodeURIComponent(this.project)}/_apis/wit/workItems/${workItemId}/comments?api-version=7.1-preview.4`,
443
- httpMethod: 'POST',
444
- body: { text: comment.body },
445
- }, this.cwd);
446
- }
447
- catch (err) {
448
- const msg = err instanceof Error ? err.message : String(err);
449
- if (msg.includes('not been materialized') ||
450
- msg.includes('not allowed')) {
451
- throw new Error('Adding comments requires Azure AD authentication. Run `az login` in addition to `az devops login`.');
452
- }
453
- throw err;
454
- }
322
+ await this.api.rest('POST', `/${encodeURIComponent(this.project)}/_apis/wit/workItems/${workItemId}/comments?api-version=7.1-preview.4`, { text: comment.body });
455
323
  return {
456
324
  author: comment.author,
457
325
  date: new Date().toISOString(),
@@ -463,18 +331,10 @@ export class AzureDevOpsBackend extends BaseBackend {
463
331
  if (isNaN(numericId))
464
332
  throw new Error(`Invalid work item ID: "${id}"`);
465
333
  const wiql = `SELECT [System.Id] FROM WorkItemLinks WHERE [Source].[System.Id] = ${numericId} AND [System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Forward' MODE (MustContain)`;
466
- const queryResult = await az([
467
- 'boards',
468
- 'query',
469
- '--wiql',
470
- wiql,
471
- '--org',
472
- `https://dev.azure.com/${this.org}`,
473
- '--project',
474
- this.project,
475
- ], this.cwd);
476
- // Filter out the source item (link queries include it)
477
- const ids = queryResult.map((w) => w.id).filter((wid) => wid !== numericId);
334
+ const queryResult = await this.api.wiql(this.project, wiql);
335
+ const ids = queryResult.workItemRelations
336
+ .map((r) => r.target.id)
337
+ .filter((wid) => wid !== numericId);
478
338
  if (ids.length === 0)
479
339
  return [];
480
340
  return this.batchFetchWorkItems(ids);
@@ -484,18 +344,10 @@ export class AzureDevOpsBackend extends BaseBackend {
484
344
  if (isNaN(numericId))
485
345
  throw new Error(`Invalid work item ID: "${id}"`);
486
346
  const wiql = `SELECT [System.Id] FROM WorkItemLinks WHERE [Source].[System.Id] = ${numericId} AND [System.Links.LinkType] = 'System.LinkTypes.Dependency-Forward' MODE (MustContain)`;
487
- const queryResult = await az([
488
- 'boards',
489
- 'query',
490
- '--wiql',
491
- wiql,
492
- '--org',
493
- `https://dev.azure.com/${this.org}`,
494
- '--project',
495
- this.project,
496
- ], this.cwd);
497
- // Filter out the source item (link queries include it)
498
- const ids = queryResult.map((w) => w.id).filter((wid) => wid !== numericId);
347
+ const queryResult = await this.api.wiql(this.project, wiql);
348
+ const ids = queryResult.workItemRelations
349
+ .map((r) => r.target.id)
350
+ .filter((wid) => wid !== numericId);
499
351
  if (ids.length === 0)
500
352
  return [];
501
353
  return this.batchFetchWorkItems(ids);