@knowttl/ado-axi 0.0.0-stage → 0.4.8

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 (169) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +261 -2
  3. package/SKILL.md +334 -0
  4. package/assets/ado-axi-header.png +0 -0
  5. package/dist/bin/ado-axi.d.ts +3 -0
  6. package/dist/bin/ado-axi.d.ts.map +1 -0
  7. package/dist/bin/ado-axi.js +192 -0
  8. package/dist/bin/ado-axi.js.map +1 -0
  9. package/dist/commands/agent.d.ts +3 -0
  10. package/dist/commands/agent.d.ts.map +1 -0
  11. package/dist/commands/agent.js +26 -0
  12. package/dist/commands/agent.js.map +1 -0
  13. package/dist/commands/api.d.ts +2 -0
  14. package/dist/commands/api.d.ts.map +1 -0
  15. package/dist/commands/api.js +161 -0
  16. package/dist/commands/api.js.map +1 -0
  17. package/dist/commands/classification.d.ts +2 -0
  18. package/dist/commands/classification.d.ts.map +1 -0
  19. package/dist/commands/classification.js +54 -0
  20. package/dist/commands/classification.js.map +1 -0
  21. package/dist/commands/config.d.ts +2 -0
  22. package/dist/commands/config.d.ts.map +1 -0
  23. package/dist/commands/config.js +109 -0
  24. package/dist/commands/config.js.map +1 -0
  25. package/dist/commands/doctor.d.ts +2 -0
  26. package/dist/commands/doctor.d.ts.map +1 -0
  27. package/dist/commands/doctor.js +67 -0
  28. package/dist/commands/doctor.js.map +1 -0
  29. package/dist/commands/home.d.ts +2 -0
  30. package/dist/commands/home.d.ts.map +1 -0
  31. package/dist/commands/home.js +132 -0
  32. package/dist/commands/home.js.map +1 -0
  33. package/dist/commands/pipeline.d.ts +2 -0
  34. package/dist/commands/pipeline.d.ts.map +1 -0
  35. package/dist/commands/pipeline.js +572 -0
  36. package/dist/commands/pipeline.js.map +1 -0
  37. package/dist/commands/pipelineAdmin.d.ts +3 -0
  38. package/dist/commands/pipelineAdmin.d.ts.map +1 -0
  39. package/dist/commands/pipelineAdmin.js +152 -0
  40. package/dist/commands/pipelineAdmin.js.map +1 -0
  41. package/dist/commands/pipelineArtifact.d.ts +3 -0
  42. package/dist/commands/pipelineArtifact.d.ts.map +1 -0
  43. package/dist/commands/pipelineArtifact.js +68 -0
  44. package/dist/commands/pipelineArtifact.js.map +1 -0
  45. package/dist/commands/policy.d.ts +3 -0
  46. package/dist/commands/policy.d.ts.map +1 -0
  47. package/dist/commands/policy.js +31 -0
  48. package/dist/commands/policy.js.map +1 -0
  49. package/dist/commands/pool.d.ts +3 -0
  50. package/dist/commands/pool.d.ts.map +1 -0
  51. package/dist/commands/pool.js +19 -0
  52. package/dist/commands/pool.js.map +1 -0
  53. package/dist/commands/pr.d.ts +9 -0
  54. package/dist/commands/pr.d.ts.map +1 -0
  55. package/dist/commands/pr.js +1028 -0
  56. package/dist/commands/pr.js.map +1 -0
  57. package/dist/commands/query.d.ts +2 -0
  58. package/dist/commands/query.d.ts.map +1 -0
  59. package/dist/commands/query.js +29 -0
  60. package/dist/commands/query.js.map +1 -0
  61. package/dist/commands/queue.d.ts +3 -0
  62. package/dist/commands/queue.d.ts.map +1 -0
  63. package/dist/commands/queue.js +21 -0
  64. package/dist/commands/queue.js.map +1 -0
  65. package/dist/commands/ref.d.ts +3 -0
  66. package/dist/commands/ref.d.ts.map +1 -0
  67. package/dist/commands/ref.js +204 -0
  68. package/dist/commands/ref.js.map +1 -0
  69. package/dist/commands/release.d.ts +3 -0
  70. package/dist/commands/release.d.ts.map +1 -0
  71. package/dist/commands/release.js +91 -0
  72. package/dist/commands/release.js.map +1 -0
  73. package/dist/commands/repo.d.ts +3 -0
  74. package/dist/commands/repo.d.ts.map +1 -0
  75. package/dist/commands/repo.js +261 -0
  76. package/dist/commands/repo.js.map +1 -0
  77. package/dist/commands/repoAdmin.d.ts +3 -0
  78. package/dist/commands/repoAdmin.d.ts.map +1 -0
  79. package/dist/commands/repoAdmin.js +195 -0
  80. package/dist/commands/repoAdmin.js.map +1 -0
  81. package/dist/commands/security.d.ts +3 -0
  82. package/dist/commands/security.d.ts.map +1 -0
  83. package/dist/commands/security.js +57 -0
  84. package/dist/commands/security.js.map +1 -0
  85. package/dist/commands/serviceEndpoint.d.ts +3 -0
  86. package/dist/commands/serviceEndpoint.d.ts.map +1 -0
  87. package/dist/commands/serviceEndpoint.js +29 -0
  88. package/dist/commands/serviceEndpoint.js.map +1 -0
  89. package/dist/commands/team.d.ts +3 -0
  90. package/dist/commands/team.d.ts.map +1 -0
  91. package/dist/commands/team.js +33 -0
  92. package/dist/commands/team.js.map +1 -0
  93. package/dist/commands/test.d.ts +2 -0
  94. package/dist/commands/test.d.ts.map +1 -0
  95. package/dist/commands/test.js +183 -0
  96. package/dist/commands/test.js.map +1 -0
  97. package/dist/commands/user.d.ts +3 -0
  98. package/dist/commands/user.d.ts.map +1 -0
  99. package/dist/commands/user.js +20 -0
  100. package/dist/commands/user.js.map +1 -0
  101. package/dist/commands/variableGroup.d.ts +3 -0
  102. package/dist/commands/variableGroup.d.ts.map +1 -0
  103. package/dist/commands/variableGroup.js +23 -0
  104. package/dist/commands/variableGroup.js.map +1 -0
  105. package/dist/commands/workItem.d.ts +12 -0
  106. package/dist/commands/workItem.d.ts.map +1 -0
  107. package/dist/commands/workItem.js +940 -0
  108. package/dist/commands/workItem.js.map +1 -0
  109. package/dist/commands/workItemAttachment.d.ts +3 -0
  110. package/dist/commands/workItemAttachment.d.ts.map +1 -0
  111. package/dist/commands/workItemAttachment.js +136 -0
  112. package/dist/commands/workItemAttachment.js.map +1 -0
  113. package/dist/help.d.ts +4 -0
  114. package/dist/help.d.ts.map +1 -0
  115. package/dist/help.js +383 -0
  116. package/dist/help.js.map +1 -0
  117. package/dist/lib/adoUrl.d.ts +19 -0
  118. package/dist/lib/adoUrl.d.ts.map +1 -0
  119. package/dist/lib/adoUrl.js +104 -0
  120. package/dist/lib/adoUrl.js.map +1 -0
  121. package/dist/lib/args.d.ts +14 -0
  122. package/dist/lib/args.d.ts.map +1 -0
  123. package/dist/lib/args.js +94 -0
  124. package/dist/lib/args.js.map +1 -0
  125. package/dist/lib/argv.d.ts +8 -0
  126. package/dist/lib/argv.d.ts.map +1 -0
  127. package/dist/lib/argv.js +35 -0
  128. package/dist/lib/argv.js.map +1 -0
  129. package/dist/lib/audit.d.ts +21 -0
  130. package/dist/lib/audit.d.ts.map +1 -0
  131. package/dist/lib/audit.js +128 -0
  132. package/dist/lib/audit.js.map +1 -0
  133. package/dist/lib/auth.d.ts +8 -0
  134. package/dist/lib/auth.d.ts.map +1 -0
  135. package/dist/lib/auth.js +127 -0
  136. package/dist/lib/auth.js.map +1 -0
  137. package/dist/lib/client.d.ts +32 -0
  138. package/dist/lib/client.d.ts.map +1 -0
  139. package/dist/lib/client.js +181 -0
  140. package/dist/lib/client.js.map +1 -0
  141. package/dist/lib/config.d.ts +39 -0
  142. package/dist/lib/config.d.ts.map +1 -0
  143. package/dist/lib/config.js +150 -0
  144. package/dist/lib/config.js.map +1 -0
  145. package/dist/lib/context.d.ts +5 -0
  146. package/dist/lib/context.d.ts.map +1 -0
  147. package/dist/lib/context.js +20 -0
  148. package/dist/lib/context.js.map +1 -0
  149. package/dist/lib/format.d.ts +17 -0
  150. package/dist/lib/format.d.ts.map +1 -0
  151. package/dist/lib/format.js +65 -0
  152. package/dist/lib/format.js.map +1 -0
  153. package/dist/lib/paths.d.ts +3 -0
  154. package/dist/lib/paths.d.ts.map +1 -0
  155. package/dist/lib/paths.js +13 -0
  156. package/dist/lib/paths.js.map +1 -0
  157. package/dist/lib/repositoryPath.d.ts +2 -0
  158. package/dist/lib/repositoryPath.d.ts.map +1 -0
  159. package/dist/lib/repositoryPath.js +26 -0
  160. package/dist/lib/repositoryPath.js.map +1 -0
  161. package/dist/lib/stdin.d.ts +2 -0
  162. package/dist/lib/stdin.d.ts.map +1 -0
  163. package/dist/lib/stdin.js +11 -0
  164. package/dist/lib/stdin.js.map +1 -0
  165. package/dist/lib/writes.d.ts +3 -0
  166. package/dist/lib/writes.d.ts.map +1 -0
  167. package/dist/lib/writes.js +37 -0
  168. package/dist/lib/writes.js.map +1 -0
  169. package/package.json +65 -4
@@ -0,0 +1,940 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { assertKnownFlags, flagNumber, flagString, flagBool, flagList, parseArgs } from "../lib/args.js";
3
+ import { request, requestList } from "../lib/client.js";
4
+ import { requireProject } from "../lib/config.js";
5
+ import { profileFromArgs } from "../lib/context.js";
6
+ import { countLine, htmlToText, personName, pickFields, shortDate, truncate } from "../lib/format.js";
7
+ import { readStdinIfPiped } from "../lib/stdin.js";
8
+ import { workItemAttachment } from "./workItemAttachment.js";
9
+ const LIST_FLAGS = ["state", "type", "assigned-to", "iteration", "area", "tag", "search", "query", "limit"];
10
+ const GET_FLAGS = ["comments", "relations"];
11
+ const CREATE_FLAGS = ["type", "title", "description", "description-format", "assigned-to", "area", "iteration", "parent", "tags", "set"];
12
+ const UPDATE_FLAGS = [
13
+ "title",
14
+ "state",
15
+ "assigned-to",
16
+ "area",
17
+ "iteration",
18
+ "description",
19
+ "description-format",
20
+ "tags",
21
+ "add-tags",
22
+ "remove-tags",
23
+ "set",
24
+ "reason",
25
+ "if-rev",
26
+ ];
27
+ const COMMENT_FLAGS = ["body", "format"];
28
+ const DELETE_FLAGS = ["if-rev", "destroy", "confirm-destroy"];
29
+ const LINK_LIST_FLAGS = ["limit"];
30
+ const LINK_ADD_FLAGS = ["parent", "child", "related", "pr", "commit", "branch", "repo", "comment", "if-rev"];
31
+ const LINK_REMOVE_FLAGS = ["index", "if-rev"];
32
+ const HIERARCHY = {
33
+ parent: "System.LinkTypes.Hierarchy-Reverse",
34
+ child: "System.LinkTypes.Hierarchy-Forward",
35
+ related: "System.LinkTypes.Related",
36
+ };
37
+ const RELATION_LABELS = {
38
+ "System.LinkTypes.Hierarchy-Reverse": "parent",
39
+ "System.LinkTypes.Hierarchy-Forward": "child",
40
+ "System.LinkTypes.Related": "related",
41
+ "System.LinkTypes.Dependency-Forward": "successor",
42
+ "System.LinkTypes.Dependency-Reverse": "predecessor",
43
+ "System.LinkTypes.Duplicate-Forward": "duplicate",
44
+ "System.LinkTypes.Duplicate-Reverse": "duplicate-of",
45
+ AttachedFile: "attachment",
46
+ Hyperlink: "hyperlink",
47
+ };
48
+ const DEFAULT_COLUMNS = ["id", "type", "title", "state", "assignee", "changed"];
49
+ const FIELD_ALIASES = {
50
+ id: "System.Id",
51
+ type: "System.WorkItemType",
52
+ title: "System.Title",
53
+ state: "System.State",
54
+ assignee: "System.AssignedTo",
55
+ created: "System.CreatedDate",
56
+ changed: "System.ChangedDate",
57
+ };
58
+ const CLOSED_STATES = ["Closed", "Removed", "Done", "Resolved"];
59
+ export async function workItemCommand(argv) {
60
+ const args = parseArgs(argv);
61
+ const sub = args.positionals[0] ?? "list";
62
+ const rest = { ...args, positionals: args.positionals.slice(1) };
63
+ switch (sub) {
64
+ case "attach":
65
+ case "attachment":
66
+ return workItemAttachment(sub, rest);
67
+ case "list":
68
+ return listWorkItems(rest);
69
+ case "get":
70
+ case "view":
71
+ return getWorkItem(rest);
72
+ case "create":
73
+ return createWorkItem(rest);
74
+ case "update":
75
+ return updateWorkItem(rest);
76
+ case "delete":
77
+ return deleteWorkItem(rest);
78
+ case "comment":
79
+ return commentWorkItem(rest);
80
+ case "link":
81
+ case "links":
82
+ return linkWorkItem(rest);
83
+ default:
84
+ throw new AxiError(`unknown subcommand \`work-item ${sub}\``, "VALIDATION_ERROR", [
85
+ "Subcommands: list | get | create | update | delete | comment | link | attach | attachment",
86
+ "Run `ado-axi work-item --help` for the full reference",
87
+ ]);
88
+ }
89
+ }
90
+ function escapeWiql(value) {
91
+ return value.replace(/'/g, "''");
92
+ }
93
+ function buildWiql(args, project) {
94
+ const raw = flagString(args, "query");
95
+ if (raw)
96
+ return raw;
97
+ const where = [`[System.TeamProject] = '${escapeWiql(project)}'`];
98
+ const state = flagString(args, "state") ?? "open";
99
+ if (state === "open") {
100
+ where.push(`[System.State] NOT IN (${CLOSED_STATES.map((s) => `'${s}'`).join(", ")})`);
101
+ }
102
+ else if (state !== "all") {
103
+ where.push(`[System.State] = '${escapeWiql(state)}'`);
104
+ }
105
+ const type = flagString(args, "type");
106
+ if (type)
107
+ where.push(`[System.WorkItemType] = '${escapeWiql(type)}'`);
108
+ const assignee = flagString(args, "assigned-to");
109
+ if (assignee) {
110
+ where.push(assignee === "@me"
111
+ ? "[System.AssignedTo] = @me"
112
+ : `[System.AssignedTo] CONTAINS '${escapeWiql(assignee)}'`);
113
+ }
114
+ const iteration = flagString(args, "iteration");
115
+ if (iteration) {
116
+ where.push(iteration === "@current"
117
+ ? "[System.IterationPath] = @currentIteration"
118
+ : `[System.IterationPath] UNDER '${escapeWiql(iteration)}'`);
119
+ }
120
+ const area = flagString(args, "area");
121
+ if (area)
122
+ where.push(`[System.AreaPath] UNDER '${escapeWiql(area)}'`);
123
+ const tag = flagString(args, "tag");
124
+ if (tag)
125
+ where.push(`[System.Tags] CONTAINS '${escapeWiql(tag)}'`);
126
+ const search = flagString(args, "search");
127
+ if (search)
128
+ where.push(`[System.Title] CONTAINS '${escapeWiql(search)}'`);
129
+ return `SELECT [System.Id] FROM WorkItems WHERE ${where.join(" AND ")} ORDER BY [System.ChangedDate] DESC`;
130
+ }
131
+ async function fetchWorkItems(profile, ids, fields, getOnly = false, asOf) {
132
+ const out = [];
133
+ for (let i = 0; i < ids.length; i += 200) {
134
+ const chunk = ids.slice(i, i + 200);
135
+ const result = await request(profile, {
136
+ method: getOnly ? "GET" : "POST",
137
+ path: getOnly ? "_apis/wit/workitems" : "_apis/wit/workitemsbatch",
138
+ ...(getOnly ? { query: { ids: chunk.join(","), fields: fields.join(","), asOf } }
139
+ : { body: { ids: chunk, fields, asOf } }),
140
+ });
141
+ out.push(...(result.value ?? []));
142
+ }
143
+ return out;
144
+ }
145
+ export async function listWorkItems(args, savedQuery) {
146
+ assertKnownFlags(args, savedQuery ? ["limit"] : LIST_FLAGS, savedQuery ? "query run" : "work-item list");
147
+ const columns = flagList(args, "fields");
148
+ const selected = columns?.length ? columns : DEFAULT_COLUMNS;
149
+ const requestedFields = selected.map((name) => {
150
+ const reference = Object.hasOwn(FIELD_ALIASES, name) ? FIELD_ALIASES[name] : undefined;
151
+ if (reference)
152
+ return reference;
153
+ if (name.includes("."))
154
+ return name;
155
+ throw new AxiError(`unknown field '${name}' in --fields`, "VALIDATION_ERROR", [
156
+ `Use aliases: ${Object.keys(FIELD_ALIASES).join(", ")}, or a field reference name such as Custom.EventSeverity`,
157
+ ]);
158
+ });
159
+ const profile = profileFromArgs(args);
160
+ const project = requireProject(profile, "work-item list");
161
+ const limit = flagNumber(args, "limit") ?? 30;
162
+ if (!Number.isInteger(limit) || limit < 1) {
163
+ throw new AxiError("--limit must be a positive integer", "VALIDATION_ERROR");
164
+ }
165
+ const wiql = buildWiql(args, project);
166
+ const result = await request(profile, {
167
+ method: savedQuery ? "GET" : "POST",
168
+ path: savedQuery ? `_apis/wit/wiql/${encodeURIComponent(savedQuery)}` : "_apis/wit/wiql",
169
+ project,
170
+ query: { $top: limit },
171
+ ...(savedQuery ? {} : { body: { query: wiql } }),
172
+ });
173
+ const ids = result.workItems?.map((w) => w.id) ?? [...new Set((result.workItemRelations ?? []).flatMap((r) => [r.source?.id, r.target?.id])
174
+ .filter((id) => id !== undefined))];
175
+ if (ids.length === 0) {
176
+ return {
177
+ "work-items": `0 work items found in ${project} (${savedQuery ? "saved query" : describeFilter(args)})`,
178
+ help: [
179
+ "Run `ado-axi work-item list --state all` to include closed items",
180
+ 'Run `ado-axi work-item list --query "SELECT [System.Id] FROM WorkItems WHERE ..."` for a raw WIQL query',
181
+ 'Run `ado-axi work-item create --type Task --title "<title>"` to add one',
182
+ ],
183
+ };
184
+ }
185
+ const page = ids.slice(0, limit);
186
+ const items = await fetchWorkItems(profile, page, [...new Set(requestedFields)], savedQuery !== undefined, result.asOf);
187
+ const rows = items.map((item) => Object.fromEntries(selected.map((column, index) => {
188
+ const reference = requestedFields[index];
189
+ const value = reference === "System.Id" ? item.id : item.fields?.[reference];
190
+ if (column === "created" || column === "changed") {
191
+ return [column, shortDate(value)];
192
+ }
193
+ return [column, value && typeof value === "object" ? personName(value) : value ?? ""];
194
+ })));
195
+ const help = [`Run \`ado-axi work-item get <id>\` for full detail`];
196
+ help.push('Run `ado-axi work-item update <id> --state <state>` to change state');
197
+ return {
198
+ project,
199
+ org: profile.org,
200
+ count: `${rows.length} work items shown`,
201
+ "work-items": rows,
202
+ help,
203
+ };
204
+ }
205
+ function describeFilter(args) {
206
+ const parts = [];
207
+ const state = flagString(args, "state") ?? "open";
208
+ parts.push(`state=${state}`);
209
+ for (const key of ["type", "assigned-to", "iteration", "area", "tag", "search"]) {
210
+ const value = flagString(args, key);
211
+ if (value)
212
+ parts.push(`${key}=${value}`);
213
+ }
214
+ return parts.join(", ");
215
+ }
216
+ async function getWorkItem(args) {
217
+ assertKnownFlags(args, GET_FLAGS, "work-item get");
218
+ const profile = profileFromArgs(args);
219
+ const id = requireId(args, "work-item get <id>");
220
+ const item = await request(profile, {
221
+ path: `_apis/wit/workitems/${id}`,
222
+ query: { $expand: "all" },
223
+ });
224
+ const fields = item.fields ?? {};
225
+ const description = htmlToText(String(fields["System.Description"] ?? fields["Microsoft.VSTS.TCM.ReproSteps"] ?? ""));
226
+ const body = truncate(description, flagBool(args, "full") ? Number.MAX_SAFE_INTEGER : 1200);
227
+ const relations = (item.relations ?? []).filter((r) => r.rel?.startsWith("System.LinkTypes") || r.rel === "ArtifactLink");
228
+ const parent = (item.relations ?? []).find((r) => r.rel === "System.LinkTypes.Hierarchy-Reverse");
229
+ const out = {
230
+ "work-item": {
231
+ id: item.id,
232
+ rev: item.rev ?? "",
233
+ type: String(fields["System.WorkItemType"] ?? ""),
234
+ title: String(fields["System.Title"] ?? ""),
235
+ state: String(fields["System.State"] ?? ""),
236
+ reason: String(fields["System.Reason"] ?? ""),
237
+ assignee: personName(fields["System.AssignedTo"]),
238
+ area: String(fields["System.AreaPath"] ?? ""),
239
+ iteration: String(fields["System.IterationPath"] ?? ""),
240
+ tags: String(fields["System.Tags"] ?? ""),
241
+ created: shortDate(fields["System.CreatedDate"]),
242
+ changed: shortDate(fields["System.ChangedDate"]),
243
+ "changed-by": personName(fields["System.ChangedBy"]),
244
+ comments: Number(fields["System.CommentCount"] ?? 0),
245
+ links: relations.length,
246
+ parent: parent ? Number(parent.url?.split("/").pop()) : "",
247
+ url: item._links?.html?.href ?? "",
248
+ description: body.text,
249
+ },
250
+ };
251
+ if (flagBool(args, "full")) {
252
+ out.fields = Object.fromEntries(Object.entries(fields)
253
+ .filter(([reference]) => !reference.startsWith("System."))
254
+ .map(([reference, value]) => [reference,
255
+ typeof value === "string"
256
+ ? truncate(reference === "Microsoft.VSTS.TCM.ReproSteps" ? htmlToText(value) : value, 1200).text
257
+ : value,
258
+ ]));
259
+ }
260
+ if (flagBool(args, "relations") && relations.length > 0) {
261
+ out.links = relations.map((relation) => {
262
+ const described = describeRelation(relation);
263
+ return {
264
+ type: described.type,
265
+ target: described.target,
266
+ comment: relation.attributes?.comment ?? "",
267
+ };
268
+ });
269
+ }
270
+ if (flagBool(args, "comments") && Number(fields["System.CommentCount"] ?? 0) > 0) {
271
+ const comments = await request(profile, {
272
+ path: `_apis/wit/workItems/${id}/comments`,
273
+ project: profile.project,
274
+ query: { $top: 50 },
275
+ apiVersion: "7.1-preview.4",
276
+ });
277
+ out.comments = (comments.comments ?? []).map((c) => ({
278
+ id: c.id,
279
+ author: personName(c.createdBy),
280
+ date: shortDate(c.createdDate),
281
+ text: truncate(htmlToText(c.text ?? ""), 400).text,
282
+ }));
283
+ }
284
+ const help = [];
285
+ if (body.truncated)
286
+ help.push(`Run \`ado-axi work-item get ${id} --full\` for the complete description`);
287
+ if (!flagBool(args, "comments") && Number(fields["System.CommentCount"] ?? 0) > 0) {
288
+ help.push(`Run \`ado-axi work-item get ${id} --comments\` to include ${fields["System.CommentCount"]} comments`);
289
+ }
290
+ if (!flagBool(args, "relations") && relations.length > 0) {
291
+ help.push(`Run \`ado-axi work-item link list ${id}\` to see its ${relations.length} links`);
292
+ }
293
+ if (help.length > 0)
294
+ out.help = help;
295
+ return out;
296
+ }
297
+ function requireId(args, usage) {
298
+ const raw = args.positionals[0];
299
+ const id = Number(raw);
300
+ if (!raw || !Number.isFinite(id)) {
301
+ throw new AxiError("a numeric work item id is required", "VALIDATION_ERROR", [
302
+ `Usage: ado-axi ${usage}`,
303
+ "Run `ado-axi work-item list` to find ids",
304
+ ]);
305
+ }
306
+ return id;
307
+ }
308
+ function patch(op, path, value) {
309
+ return { op, path, value };
310
+ }
311
+ function setFlags(args) {
312
+ const raw = flagString(args, "set");
313
+ if (!raw)
314
+ return [];
315
+ let parsed;
316
+ try {
317
+ parsed = JSON.parse(raw);
318
+ }
319
+ catch {
320
+ throw new AxiError("--set expects a JSON object of field reference names", "VALIDATION_ERROR", [
321
+ `Example: --set '{"Microsoft.VSTS.Common.Priority": 1}'`,
322
+ ]);
323
+ }
324
+ return Object.entries(parsed).map(([key, value]) => patch("add", `/fields/${key}`, value));
325
+ }
326
+ function descriptionFormat(args, description) {
327
+ const value = flagString(args, "description-format");
328
+ if (value === undefined)
329
+ return undefined;
330
+ if (description === undefined) {
331
+ throw new AxiError("--description-format requires --description or a piped description", "VALIDATION_ERROR");
332
+ }
333
+ if (value.toLowerCase() === "markdown")
334
+ return "Markdown";
335
+ if (value.toLowerCase() === "html")
336
+ return "Html";
337
+ throw new AxiError("--description-format must be markdown or html", "VALIDATION_ERROR", [
338
+ "Use `--description-format markdown` for Markdown or `--description-format html` for HTML",
339
+ ]);
340
+ }
341
+ function commentFormat(args) {
342
+ const raw = args.flags.format;
343
+ if (raw === undefined)
344
+ return "markdown";
345
+ if (typeof raw !== "string") {
346
+ throw new AxiError("--format must be markdown or html", "VALIDATION_ERROR", [
347
+ "Use `--format markdown` for Markdown or `--format html` for HTML",
348
+ ]);
349
+ }
350
+ const value = raw.toLowerCase();
351
+ if (value === "markdown" || value === "html")
352
+ return value;
353
+ throw new AxiError("--format must be markdown or html", "VALIDATION_ERROR", [
354
+ "Use `--format markdown` for Markdown or `--format html` for HTML",
355
+ ]);
356
+ }
357
+ async function createWorkItem(args) {
358
+ assertKnownFlags(args, CREATE_FLAGS, "work-item create");
359
+ const profile = profileFromArgs(args);
360
+ const project = requireProject(profile, "work-item create");
361
+ const type = flagString(args, "type");
362
+ const title = flagString(args, "title");
363
+ if (!type || !title) {
364
+ throw new AxiError("--type and --title are required", "VALIDATION_ERROR", [
365
+ 'Usage: ado-axi work-item create --type Task --title "..." [--description "..."] [--assigned-to <user>]',
366
+ ]);
367
+ }
368
+ const ops = [patch("add", "/fields/System.Title", title)];
369
+ const description = flagString(args, "description") ?? (await readStdinIfPiped())?.toString("utf8");
370
+ if (description !== undefined) {
371
+ ops.push(patch("add", "/fields/System.Description", description));
372
+ const format = descriptionFormat(args, description);
373
+ if (format)
374
+ ops.push(patch("add", "/multilineFieldsFormat/System.Description", format));
375
+ }
376
+ else {
377
+ descriptionFormat(args, description);
378
+ }
379
+ const assignee = flagString(args, "assigned-to");
380
+ if (assignee)
381
+ ops.push(patch("add", "/fields/System.AssignedTo", assignee));
382
+ const area = flagString(args, "area");
383
+ if (area)
384
+ ops.push(patch("add", "/fields/System.AreaPath", area));
385
+ const iteration = flagString(args, "iteration");
386
+ if (iteration)
387
+ ops.push(patch("add", "/fields/System.IterationPath", iteration));
388
+ const tags = flagString(args, "tags");
389
+ if (tags)
390
+ ops.push(patch("add", "/fields/System.Tags", tags));
391
+ const parent = flagString(args, "parent");
392
+ if (parent) {
393
+ ops.push(patch("add", "/relations/-", {
394
+ rel: "System.LinkTypes.Hierarchy-Reverse",
395
+ url: `https://dev.azure.com/${profile.org}/_apis/wit/workItems/${parent}`,
396
+ }));
397
+ }
398
+ ops.push(...setFlags(args));
399
+ const created = await request(profile, {
400
+ method: "POST",
401
+ path: `_apis/wit/workitems/$${encodeURIComponent(type)}`,
402
+ project,
403
+ body: ops,
404
+ contentType: "application/json-patch+json",
405
+ });
406
+ return {
407
+ created: {
408
+ id: created.id,
409
+ type: String(created.fields?.["System.WorkItemType"] ?? type),
410
+ title: String(created.fields?.["System.Title"] ?? title),
411
+ state: String(created.fields?.["System.State"] ?? ""),
412
+ url: created._links?.html?.href ?? "",
413
+ },
414
+ help: [
415
+ `Run \`ado-axi work-item get ${created.id}\` to view it`,
416
+ `Run \`ado-axi work-item update ${created.id} --assigned-to <user>\` to assign it`,
417
+ ],
418
+ };
419
+ }
420
+ export function parseTags(value) {
421
+ return String(value ?? "")
422
+ .split(";")
423
+ .map((t) => t.trim())
424
+ .filter(Boolean);
425
+ }
426
+ export function mergeTags(current, add, remove) {
427
+ const removeSet = new Set(remove.map((t) => t.toLowerCase()));
428
+ const kept = current.filter((t) => !removeSet.has(t.toLowerCase()));
429
+ const removed = current.filter((t) => removeSet.has(t.toLowerCase()));
430
+ const seen = new Set(kept.map((t) => t.toLowerCase()));
431
+ const added = [];
432
+ for (const tag of add) {
433
+ if (seen.has(tag.toLowerCase()))
434
+ continue;
435
+ seen.add(tag.toLowerCase());
436
+ kept.push(tag);
437
+ added.push(tag);
438
+ }
439
+ return { tags: kept, added, removed };
440
+ }
441
+ async function updateWorkItem(args) {
442
+ assertKnownFlags(args, UPDATE_FLAGS, "work-item update");
443
+ const profile = profileFromArgs(args);
444
+ const id = requireId(args, "work-item update <id> --state <state>");
445
+ const ifRev = flagNumber(args, "if-rev");
446
+ const current = await request(profile, { path: `_apis/wit/workitems/${id}` });
447
+ const fields = current.fields ?? {};
448
+ if (ifRev !== undefined && current.rev !== undefined && current.rev !== ifRev) {
449
+ throw new AxiError(`#${id} is at rev ${current.rev}, expected ${ifRev} — update refused`, "PRECONDITION_FAILED", [
450
+ `Someone else changed the work item (last change by ${personName(fields["System.ChangedBy"]) || "unknown"})`,
451
+ `Run \`ado-axi work-item get ${id}\` to re-read the current rev, then retry with --if-rev ${current.rev}`,
452
+ ]);
453
+ }
454
+ const ops = [];
455
+ const mapping = [
456
+ ["title", "System.Title"],
457
+ ["state", "System.State"],
458
+ ["assigned-to", "System.AssignedTo"],
459
+ ["area", "System.AreaPath"],
460
+ ["iteration", "System.IterationPath"],
461
+ ["description", "System.Description"],
462
+ ["tags", "System.Tags"],
463
+ ["reason", "System.Reason"],
464
+ ];
465
+ const unchanged = [];
466
+ const stdinDescription = flagString(args, "description") === undefined
467
+ ? (await readStdinIfPiped())?.toString("utf8")
468
+ : undefined;
469
+ for (const [flag, field] of mapping) {
470
+ const value = flag === "description" && stdinDescription !== undefined
471
+ ? stdinDescription
472
+ : flagString(args, flag);
473
+ if (value === undefined)
474
+ continue;
475
+ const currentValue = field === "System.AssignedTo" ? personName(fields[field]) : String(fields[field] ?? "");
476
+ if (currentValue === value && ifRev === undefined) {
477
+ unchanged.push(flag);
478
+ continue;
479
+ }
480
+ // Azure DevOps merges tags on `add`; only `replace` can drop existing tags.
481
+ const op = field === "System.Tags" && currentValue !== "" ? "replace" : "add";
482
+ ops.push(patch(op, `/fields/${field}`, value));
483
+ }
484
+ const description = stdinDescription ?? flagString(args, "description");
485
+ const format = descriptionFormat(args, description);
486
+ if (format)
487
+ ops.push(patch("add", "/multilineFieldsFormat/System.Description", format));
488
+ const addTags = flagList(args, "add-tags") ?? [];
489
+ const removeTags = flagList(args, "remove-tags") ?? [];
490
+ let tagChange;
491
+ if (addTags.length > 0 || removeTags.length > 0) {
492
+ if (flagString(args, "tags") !== undefined) {
493
+ throw new AxiError("--tags cannot be combined with --add-tags/--remove-tags", "VALIDATION_ERROR", [
494
+ "--tags replaces the whole tag string; --add-tags/--remove-tags mutate it in place",
495
+ ]);
496
+ }
497
+ const currentTags = parseTags(fields["System.Tags"]);
498
+ const merged = mergeTags(currentTags, addTags, removeTags);
499
+ if (merged.added.length > 0 || merged.removed.length > 0) {
500
+ ops.push(patch(currentTags.length > 0 ? "replace" : "add", "/fields/System.Tags", merged.tags.join("; ")));
501
+ tagChange = { added: merged.added, removed: merged.removed };
502
+ }
503
+ else {
504
+ unchanged.push("tags");
505
+ }
506
+ }
507
+ ops.push(...setFlags(args));
508
+ if (ops.length === 0) {
509
+ if (unchanged.length > 0) {
510
+ return {
511
+ "work-item": `#${id} already has ${unchanged.join(", ")} set to the requested value (no-op)`,
512
+ };
513
+ }
514
+ throw new AxiError("no changes requested", "VALIDATION_ERROR", [
515
+ `Usage: ado-axi work-item update ${id} --state <state> [--title "..."] [--assigned-to <user>]`,
516
+ `Fields: ${mapping.map(([f]) => `--${f}`).join(", ")}, --add-tags a,b, --remove-tags c, --set '{"<Field.Ref>": <value>}'`,
517
+ ]);
518
+ }
519
+ const expectedRev = ifRev ?? (tagChange ? current.rev : undefined);
520
+ if (tagChange && expectedRev === undefined) {
521
+ throw new AxiError(`#${id} has no revision — tag update refused`, "PRECONDITION_FAILED", [
522
+ `Run \`ado-axi work-item get ${id}\` to read the current rev, then retry`,
523
+ ]);
524
+ }
525
+ if (expectedRev !== undefined)
526
+ ops.unshift({ op: "test", path: "/rev", value: expectedRev });
527
+ let updated;
528
+ try {
529
+ updated = await request(profile, {
530
+ method: "PATCH",
531
+ path: `_apis/wit/workitems/${id}`,
532
+ body: ops,
533
+ contentType: "application/json-patch+json",
534
+ });
535
+ }
536
+ catch (err) {
537
+ if (expectedRev !== undefined &&
538
+ err instanceof AxiError &&
539
+ (err.code === "PRECONDITION_FAILED" || /VS403351|Test Operation/i.test(err.message))) {
540
+ throw new AxiError(`#${id} changed during the update — the ${ifRev !== undefined ? `--if-rev ${ifRev}` : `automatic revision ${expectedRev}`} check failed`, "PRECONDITION_FAILED", [
541
+ err.message,
542
+ `Run \`ado-axi work-item get ${id}\` to read the current rev, then retry`,
543
+ ]);
544
+ }
545
+ throw err;
546
+ }
547
+ const result = {
548
+ id: updated.id,
549
+ rev: updated.rev ?? "",
550
+ title: String(updated.fields?.["System.Title"] ?? ""),
551
+ state: String(updated.fields?.["System.State"] ?? ""),
552
+ assignee: personName(updated.fields?.["System.AssignedTo"]),
553
+ changed: expectedRev === undefined ? ops.length : ops.length - 1,
554
+ skipped: unchanged.join(", "),
555
+ };
556
+ if (tagChange) {
557
+ result.tags = String(updated.fields?.["System.Tags"] ?? "");
558
+ result["tags-added"] = tagChange.added.join(", ");
559
+ result["tags-removed"] = tagChange.removed.join(", ");
560
+ }
561
+ return { updated: result };
562
+ }
563
+ async function commentWorkItem(args) {
564
+ assertKnownFlags(args, COMMENT_FLAGS, "work-item comment");
565
+ const profile = profileFromArgs(args);
566
+ const project = requireProject(profile, "work-item comment");
567
+ const id = requireId(args, 'work-item comment <id> --body "..." [--format markdown|html]');
568
+ const body = flagString(args, "body") ?? (await readStdinIfPiped())?.toString("utf8") ?? args.positionals[1];
569
+ if (!body) {
570
+ throw new AxiError("--body is required", "VALIDATION_ERROR", [
571
+ `Usage: ado-axi work-item comment ${id} --body "..." [--format markdown|html]`,
572
+ "Pipe the comment to stdin when omitting --body",
573
+ ]);
574
+ }
575
+ const format = commentFormat(args);
576
+ const created = await request(profile, {
577
+ method: "POST",
578
+ path: `_apis/wit/workItems/${id}/comments`,
579
+ project,
580
+ query: { format },
581
+ body: { text: body },
582
+ apiVersion: "7.1-preview.4",
583
+ });
584
+ return { comment: { "work-item": id, id: created.id ?? "", posted: true } };
585
+ }
586
+ export { requestList };
587
+ const ARTIFACT_LABELS = {
588
+ "pull request": "pull-request",
589
+ "fixed in commit": "commit",
590
+ "fixed in changeset": "changeset",
591
+ branch: "branch",
592
+ "integrated in build": "build",
593
+ "found in build": "build",
594
+ };
595
+ /** `vstfs:///Git/PullRequestId/<project>%2F<repo>%2F812` -> `812`, Ref links -> the branch name. */
596
+ function artifactTarget(url) {
597
+ const decoded = decodeURIComponent(url);
598
+ const branch = decoded.match(/^vstfs:\/{3}Git\/Ref\/[^/]+\/[^/]+\/GB(.+)$/i);
599
+ if (branch)
600
+ return branch[1];
601
+ return decoded.split("/").filter(Boolean).pop() ?? "";
602
+ }
603
+ function describeRelation(relation) {
604
+ const rel = relation.rel ?? "";
605
+ const url = relation.url ?? "";
606
+ if (rel === "ArtifactLink") {
607
+ const raw = (relation.attributes?.name ?? "artifact").toLowerCase();
608
+ const name = ARTIFACT_LABELS[raw] ?? raw.replace(/\s+/g, "-");
609
+ const target = artifactTarget(url);
610
+ return { type: name, target: /^[0-9a-f]{40}$/i.test(target) ? target.slice(0, 8) : target };
611
+ }
612
+ const label = RELATION_LABELS[rel] ?? rel;
613
+ if (rel === "AttachedFile" || rel === "Hyperlink") {
614
+ return { type: label, target: relation.attributes?.name ?? url };
615
+ }
616
+ const id = Number(url.split("/").pop());
617
+ return { type: label, target: Number.isFinite(id) ? id : url };
618
+ }
619
+ async function linkWorkItem(args) {
620
+ const first = args.positionals[0] ?? "list";
621
+ const sub = /^\d+$/.test(first) ? "list" : first;
622
+ const rest = sub === first ? { ...args, positionals: args.positionals.slice(1) } : args;
623
+ switch (sub) {
624
+ case "list":
625
+ return listLinks(rest);
626
+ case "types":
627
+ return listLinkTypes(rest);
628
+ case "add":
629
+ return addLink(rest);
630
+ case "remove":
631
+ return removeLink(rest);
632
+ default:
633
+ throw new AxiError(`unknown subcommand \`work-item link ${sub}\``, "VALIDATION_ERROR", [
634
+ "Subcommands: list | types | add | remove",
635
+ "Run `ado-axi work-item --help` for the full reference",
636
+ ]);
637
+ }
638
+ }
639
+ async function listLinkTypes(args) {
640
+ assertKnownFlags(args, ["limit"], "work-item link types");
641
+ if (args.positionals.length > 0) {
642
+ throw new AxiError("Usage: ado-axi work-item link types [--limit 50] [--full]", "VALIDATION_ERROR");
643
+ }
644
+ const limit = flagNumber(args, "limit") ?? 50;
645
+ if (!Number.isInteger(limit) || limit < 1) {
646
+ throw new AxiError("--limit must be a positive integer", "VALIDATION_ERROR");
647
+ }
648
+ const types = await requestList(profileFromArgs(args), { path: "_apis/wit/workitemrelationtypes" });
649
+ const shown = flagBool(args, "full") ? types : types.slice(0, limit);
650
+ return {
651
+ count: countLine(shown.length, types.length, "link types"),
652
+ "link-types": types.length === 0 ? "0 link types found" : pickFields(shown.map((type) => ({
653
+ reference: type.referenceName, name: type.name, usage: type.attributes?.usage ?? "",
654
+ enabled: type.attributes?.enabled ?? "",
655
+ })), flagList(args, "fields")),
656
+ ...(shown.length < types.length ? { "left-out": types.length - shown.length,
657
+ help: ["Run `ado-axi work-item link types --full` for all link types"] } : {}),
658
+ };
659
+ }
660
+ async function listLinks(args) {
661
+ assertKnownFlags(args, LINK_LIST_FLAGS, "work-item link list");
662
+ const profile = profileFromArgs(args);
663
+ const id = requireId(args, "work-item link list <id>");
664
+ const limit = flagNumber(args, "limit") ?? 50;
665
+ const item = await request(profile, {
666
+ path: `_apis/wit/workitems/${id}`,
667
+ query: { $expand: "relations" },
668
+ });
669
+ const relations = item.relations ?? [];
670
+ if (relations.length === 0) {
671
+ return {
672
+ links: `0 links on work item ${id}`,
673
+ help: [
674
+ `Run \`ado-axi work-item link add ${id} --parent <id>\` to place it in the hierarchy`,
675
+ `Run \`ado-axi work-item link add ${id} --pr <pull-request-id>\` to attach a pull request`,
676
+ ],
677
+ };
678
+ }
679
+ const shown = relations.slice(0, limit);
680
+ return {
681
+ "work-item": id,
682
+ rev: item.rev ?? "",
683
+ count: countLine(shown.length, relations.length, "links"),
684
+ links: pickFields(shown.map((relation, index) => {
685
+ const described = describeRelation(relation);
686
+ return {
687
+ index,
688
+ type: described.type,
689
+ target: described.target,
690
+ comment: relation.attributes?.comment ?? "",
691
+ };
692
+ }), flagList(args, "fields")),
693
+ help: [`Run \`ado-axi work-item link remove ${id} --index <index> --if-rev ${item.rev}\` to remove a relation`],
694
+ };
695
+ }
696
+ async function deleteWorkItem(args) {
697
+ assertKnownFlags(args, DELETE_FLAGS, "work-item delete");
698
+ const id = requireId(args, "work-item delete <id> --if-rev <n> [--destroy --confirm-destroy]");
699
+ const rev = requiredRevision(args, id);
700
+ if (args.positionals.length !== 1) {
701
+ throw new AxiError("exactly one work item id is required", "VALIDATION_ERROR");
702
+ }
703
+ const destroy = flagBool(args, "destroy");
704
+ if (destroy && !flagBool(args, "confirm-destroy")) {
705
+ throw new AxiError("permanent destruction requires --destroy --confirm-destroy and --if-rev <n>", "VALIDATION_ERROR", [
706
+ "Omit --destroy to move the work item to the recycle bin instead",
707
+ ]);
708
+ }
709
+ const profile = profileFromArgs(args);
710
+ const current = await request(profile, { path: `_apis/wit/workitems/${id}` });
711
+ checkRevision(current, rev);
712
+ // DELETE has no atomic revision predicate. This is a preflight check, not a JSON-Patch CAS.
713
+ await request(profile, {
714
+ method: "DELETE",
715
+ path: `_apis/wit/workitems/${id}`,
716
+ query: { destroy },
717
+ });
718
+ return {
719
+ deleted: { id, outcome: destroy ? "permanently destroyed" : "moved to recycle bin", "checked-rev": rev },
720
+ };
721
+ }
722
+ function requiredRevision(args, id) {
723
+ const rev = flagNumber(args, "if-rev");
724
+ if (rev === undefined || !Number.isInteger(rev) || rev < 1) {
725
+ throw new AxiError("--if-rev must be a positive integer", "VALIDATION_ERROR", [
726
+ `Run \`ado-axi work-item get ${id}\` to read the current rev`,
727
+ ]);
728
+ }
729
+ return rev;
730
+ }
731
+ function checkRevision(item, rev) {
732
+ if (item.rev !== rev) {
733
+ throw new AxiError(`#${item.id} is at rev ${item.rev ?? "unknown"}, expected ${rev} - write refused`, "PRECONDITION_FAILED", [
734
+ `Run \`ado-axi work-item get ${item.id}\` to re-read the current rev before retrying`,
735
+ ]);
736
+ }
737
+ }
738
+ async function removeLink(args) {
739
+ assertKnownFlags(args, LINK_REMOVE_FLAGS, "work-item link remove");
740
+ const id = requireId(args, "work-item link remove <id> --index <index> --if-rev <n>");
741
+ const rev = requiredRevision(args, id);
742
+ const index = flagNumber(args, "index");
743
+ if (args.positionals.length !== 1 || index === undefined || !Number.isInteger(index) || index < 0) {
744
+ throw new AxiError("--index must be a non-negative integer and exactly one work item id is required", "VALIDATION_ERROR", [
745
+ `Run \`ado-axi work-item link list ${id}\` to read relation indexes and rev`,
746
+ ]);
747
+ }
748
+ const profile = profileFromArgs(args);
749
+ const current = await request(profile, {
750
+ path: `_apis/wit/workitems/${id}`,
751
+ query: { $expand: "relations" },
752
+ });
753
+ checkRevision(current, rev);
754
+ const relation = current.relations?.[index];
755
+ if (!relation) {
756
+ throw new AxiError(`no relation at index ${index} on #${id} - removal refused`, "VALIDATION_ERROR", [
757
+ `Run \`ado-axi work-item link list ${id}\` to read relation indexes and rev`,
758
+ ]);
759
+ }
760
+ let updated;
761
+ try {
762
+ updated = await request(profile, {
763
+ method: "PATCH",
764
+ path: `_apis/wit/workitems/${id}`,
765
+ contentType: "application/json-patch+json",
766
+ body: [patch("test", "/rev", rev), { op: "remove", path: `/relations/${index}` }],
767
+ });
768
+ }
769
+ catch (err) {
770
+ if (err instanceof AxiError && (err.code === "PRECONDITION_FAILED" || /VS403351|Test Operation/i.test(err.message))) {
771
+ throw new AxiError(`#${id} changed during link removal - removal refused without retry`, "PRECONDITION_FAILED", [
772
+ `Run \`ado-axi work-item link list ${id}\` to re-read relation indexes and rev before retrying`,
773
+ ]);
774
+ }
775
+ throw err;
776
+ }
777
+ return {
778
+ removed: { "work-item": id, index, ...describeRelation(relation), rev: updated.rev ?? "" },
779
+ help: [`Run \`ado-axi work-item link list ${id}\` to see the remaining links`],
780
+ };
781
+ }
782
+ async function artifactLink(profile, args, project, kind, value) {
783
+ if (kind === "pr") {
784
+ const prId = Number(value);
785
+ if (!Number.isInteger(prId) || prId < 1) {
786
+ throw new AxiError("--pr expects a pull request id", "VALIDATION_ERROR", [
787
+ "Run `ado-axi pr list` to find pull request ids",
788
+ ]);
789
+ }
790
+ const repoFlag = flagString(args, "repo");
791
+ const pr = await request(profile, {
792
+ path: repoFlag
793
+ ? `_apis/git/repositories/${encodeURIComponent(repoFlag)}/pullrequests/${prId}`
794
+ : `_apis/git/pullrequests/${prId}`,
795
+ project: repoFlag ? project : undefined,
796
+ });
797
+ const repoId = pr.repository?.id;
798
+ const projectId = pr.repository?.project?.id;
799
+ if (!repoId || !projectId) {
800
+ throw new AxiError(`could not resolve the repository of pull request ${prId}`, "NOT_FOUND", [
801
+ "Pass --repo <repo> explicitly",
802
+ ]);
803
+ }
804
+ return {
805
+ rel: "ArtifactLink",
806
+ url: `vstfs:///Git/PullRequestId/${projectId}%2F${repoId}%2F${prId}`,
807
+ name: "Pull Request",
808
+ type: "pull-request",
809
+ target: String(prId),
810
+ };
811
+ }
812
+ const repo = flagString(args, "repo");
813
+ if (kind === "commit" && !/^[0-9a-f]{40}$/i.test(value)) {
814
+ throw new AxiError("--commit expects a 40-character commit id", "VALIDATION_ERROR", [
815
+ "Pass the full commit SHA, not an abbreviation",
816
+ ]);
817
+ }
818
+ if (!repo) {
819
+ throw new AxiError(`--repo <name> is required with --${kind}`, "VALIDATION_ERROR", [
820
+ `Usage: ado-axi work-item link add <id> --${kind} <value> --repo <name>`,
821
+ "Run `ado-axi repo list` to see repository names",
822
+ ]);
823
+ }
824
+ const repository = await request(profile, {
825
+ path: `_apis/git/repositories/${encodeURIComponent(repo)}`,
826
+ project,
827
+ });
828
+ const repoId = repository.id;
829
+ const projectId = repository.project?.id;
830
+ if (!repoId || !projectId) {
831
+ throw new AxiError(`could not resolve repository '${repo}'`, "NOT_FOUND", [
832
+ "Run `ado-axi repo list` to see repository names",
833
+ ]);
834
+ }
835
+ if (kind === "commit") {
836
+ return {
837
+ rel: "ArtifactLink",
838
+ url: `vstfs:///Git/Commit/${projectId}%2F${repoId}%2F${value}`,
839
+ name: "Fixed in Commit",
840
+ type: "commit",
841
+ target: value.slice(0, 8),
842
+ };
843
+ }
844
+ const branch = value.replace(/^refs\/heads\//, "");
845
+ return {
846
+ rel: "ArtifactLink",
847
+ url: `vstfs:///Git/Ref/${projectId}%2F${repoId}%2FGB${encodeURIComponent(branch)}`,
848
+ name: "Branch",
849
+ type: "branch",
850
+ target: branch,
851
+ };
852
+ }
853
+ /** Azure DevOps rewrites work item link URLs with the project GUID, so compare those by id. */
854
+ function sameLink(relation, link) {
855
+ if ((relation.rel ?? "") !== link.rel)
856
+ return false;
857
+ const stored = decodeURIComponent(relation.url ?? "").toLowerCase();
858
+ const wanted = decodeURIComponent(link.url).toLowerCase();
859
+ if (link.rel === "ArtifactLink")
860
+ return stored === wanted;
861
+ return stored.split("/").pop() === wanted.split("/").pop();
862
+ }
863
+ async function addLink(args) {
864
+ assertKnownFlags(args, LINK_ADD_FLAGS, "work-item link add");
865
+ const profile = profileFromArgs(args);
866
+ const project = requireProject(profile, "work-item link add");
867
+ const id = requireId(args, "work-item link add <id> --parent <id> | --pr <id>");
868
+ const selected = ["parent", "child", "related", "pr", "commit", "branch"].filter((flag) => flagString(args, flag) !== undefined);
869
+ if (selected.length !== 1) {
870
+ throw new AxiError(selected.length === 0
871
+ ? "a link target is required"
872
+ : `pass exactly one link target, got ${selected.join(", ")}`, "VALIDATION_ERROR", [
873
+ `Usage: ado-axi work-item link add ${id} (--parent <id> | --child <id> | --related <id> | --pr <id> | --commit <40-hex> --repo <name> | --branch <name> --repo <name>)`,
874
+ ]);
875
+ }
876
+ const kind = selected[0];
877
+ const value = flagString(args, kind);
878
+ let link;
879
+ if (kind in HIERARCHY) {
880
+ const targetId = Number(value);
881
+ if (!Number.isInteger(targetId) || targetId < 1) {
882
+ throw new AxiError(`--${kind} expects a work item id`, "VALIDATION_ERROR", [
883
+ "Run `ado-axi work-item list` to find ids",
884
+ ]);
885
+ }
886
+ if (targetId === id)
887
+ throw new AxiError("a work item cannot link to itself", "VALIDATION_ERROR");
888
+ link = {
889
+ rel: HIERARCHY[kind],
890
+ url: `https://dev.azure.com/${profile.org}/_apis/wit/workItems/${targetId}`,
891
+ type: kind,
892
+ target: String(targetId),
893
+ };
894
+ }
895
+ else {
896
+ link = await artifactLink(profile, args, project, kind, value);
897
+ }
898
+ const current = await request(profile, {
899
+ path: `_apis/wit/workitems/${id}`,
900
+ query: { $expand: "relations" },
901
+ });
902
+ const existing = (current.relations ?? []).find((relation) => sameLink(relation, link));
903
+ if (existing) {
904
+ return {
905
+ link: `#${id} already links to ${link.target} as ${link.type} (no-op)`,
906
+ rev: current.rev ?? "",
907
+ };
908
+ }
909
+ const ops = [];
910
+ const ifRev = flagNumber(args, "if-rev");
911
+ if (ifRev !== undefined)
912
+ ops.push(patch("test", "/rev", ifRev));
913
+ const comment = flagString(args, "comment");
914
+ ops.push(patch("add", "/relations/-", {
915
+ rel: link.rel,
916
+ url: link.url,
917
+ attributes: {
918
+ ...(link.name ? { name: link.name } : {}),
919
+ ...(comment ? { comment } : {}),
920
+ },
921
+ }));
922
+ const updated = await request(profile, {
923
+ method: "PATCH",
924
+ path: `_apis/wit/workitems/${id}`,
925
+ project,
926
+ body: ops,
927
+ contentType: "application/json-patch+json",
928
+ });
929
+ return {
930
+ linked: {
931
+ "work-item": id,
932
+ type: link.type,
933
+ target: link.target,
934
+ rel: link.rel,
935
+ rev: updated.rev ?? "",
936
+ },
937
+ help: [`Run \`ado-axi work-item link list ${id}\` to see every link`],
938
+ };
939
+ }
940
+ //# sourceMappingURL=workItem.js.map