@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +32 -17
- package/dist/app.js +7 -2
- package/dist/app.js.map +1 -1
- package/dist/auth/ado.d.ts +31 -0
- package/dist/auth/ado.js +136 -0
- package/dist/auth/ado.js.map +1 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/backends/ado/api.d.ts +19 -0
- package/dist/backends/ado/api.js +110 -0
- package/dist/backends/ado/api.js.map +1 -0
- package/dist/backends/ado/index.d.ts +6 -12
- package/dist/backends/ado/index.js +201 -349
- package/dist/backends/ado/index.js.map +1 -1
- package/dist/backends/factory.js +1 -1
- package/dist/backends/factory.js.map +1 -1
- package/dist/cli/commands/auth.d.ts +5 -3
- package/dist/cli/commands/auth.js +39 -4
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/cli/commands/mcp.js +17 -17
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/index.js +5 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/components/AuthPrompt.d.ts +1 -0
- package/dist/components/AuthPrompt.js +37 -0
- package/dist/components/AuthPrompt.js.map +1 -0
- package/dist/components/Header.js +10 -2
- package/dist/components/Header.js.map +1 -1
- package/dist/components/StatusScreen.js +2 -1
- package/dist/components/StatusScreen.js.map +1 -1
- package/dist/stores/backendDataStore.d.ts +15 -0
- package/dist/stores/backendDataStore.js +81 -5
- package/dist/stores/backendDataStore.js.map +1 -1
- package/package.json +1 -1
- package/dist/backends/ado/az.d.ts +0 -28
- package/dist/backends/ado/az.js +0 -162
- 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 {
|
|
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
|
-
|
|
9
|
+
api;
|
|
8
10
|
org;
|
|
9
11
|
project;
|
|
10
12
|
types;
|
|
11
|
-
constructor(
|
|
13
|
+
constructor(api, org, project, types) {
|
|
12
14
|
super(60_000);
|
|
13
|
-
this.
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
70
|
-
|
|
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
|
|
91
|
-
|
|
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
|
|
106
|
-
|
|
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
|
|
130
|
-
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
'
|
|
175
|
-
|
|
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
|
|
194
|
-
'
|
|
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
|
-
|
|
151
|
+
patch.push({
|
|
152
|
+
op: 'add',
|
|
153
|
+
path: '/fields/System.State',
|
|
154
|
+
value: data.status,
|
|
155
|
+
});
|
|
209
156
|
if (data.iteration)
|
|
210
|
-
|
|
157
|
+
patch.push({
|
|
158
|
+
op: 'add',
|
|
159
|
+
path: '/fields/System.IterationPath',
|
|
160
|
+
value: data.iteration,
|
|
161
|
+
});
|
|
211
162
|
if (data.priority)
|
|
212
|
-
|
|
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
|
-
|
|
169
|
+
patch.push({
|
|
170
|
+
op: 'add',
|
|
171
|
+
path: '/fields/System.AssignedTo',
|
|
172
|
+
value: data.assignee,
|
|
173
|
+
});
|
|
215
174
|
if (data.labels.length > 0)
|
|
216
|
-
|
|
175
|
+
patch.push({
|
|
176
|
+
op: 'add',
|
|
177
|
+
path: '/fields/System.Tags',
|
|
178
|
+
value: formatTags(data.labels),
|
|
179
|
+
});
|
|
217
180
|
if (data.description)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
215
|
+
patch.push({
|
|
216
|
+
op: 'replace',
|
|
217
|
+
path: '/fields/System.Title',
|
|
218
|
+
value: data.title,
|
|
219
|
+
});
|
|
297
220
|
if (data.status !== undefined)
|
|
298
|
-
|
|
221
|
+
patch.push({
|
|
222
|
+
op: 'replace',
|
|
223
|
+
path: '/fields/System.State',
|
|
224
|
+
value: data.status,
|
|
225
|
+
});
|
|
299
226
|
if (data.iteration !== undefined)
|
|
300
|
-
|
|
227
|
+
patch.push({
|
|
228
|
+
op: 'replace',
|
|
229
|
+
path: '/fields/System.IterationPath',
|
|
230
|
+
value: data.iteration,
|
|
231
|
+
});
|
|
301
232
|
if (data.priority !== undefined)
|
|
302
|
-
|
|
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
|
-
|
|
239
|
+
patch.push({
|
|
240
|
+
op: 'replace',
|
|
241
|
+
path: '/fields/System.AssignedTo',
|
|
242
|
+
value: data.assignee,
|
|
243
|
+
});
|
|
305
244
|
if (data.labels !== undefined)
|
|
306
|
-
|
|
245
|
+
patch.push({
|
|
246
|
+
op: 'replace',
|
|
247
|
+
path: '/fields/System.Tags',
|
|
248
|
+
value: formatTags(data.labels),
|
|
249
|
+
});
|
|
307
250
|
if (data.description !== undefined)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
'System.LinkTypes.Dependency-Reverse',
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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);
|