azdo-cli 0.2.0-feature-devcontainer.72 → 0.2.0-feature-more-resilient-fields.105
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/README.md +134 -0
- package/dist/index.js +910 -103
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -12,9 +12,12 @@ Azure DevOps CLI focused on work item read/write workflows.
|
|
|
12
12
|
- Update work item state (`set-state`)
|
|
13
13
|
- Assign and unassign work items (`assign`)
|
|
14
14
|
- Set any work item field by reference name (`set-field`)
|
|
15
|
+
- Create or update Tasks from markdown documents (`upsert`)
|
|
15
16
|
- Read rich-text fields as markdown (`get-md-field`)
|
|
16
17
|
- Set rich-text fields as markdown from inline text, file, or stdin (`set-md-field`)
|
|
18
|
+
- Check branch pull request status, open PRs to `develop`, and review active comments (`pr`)
|
|
17
19
|
- Persist org/project/default fields in local config (`config`)
|
|
20
|
+
- List all fields of a work item (`list-fields`)
|
|
18
21
|
- Store PAT in OS credential store (or use `AZDO_PAT`)
|
|
19
22
|
|
|
20
23
|
## Installation
|
|
@@ -47,6 +50,9 @@ azdo get-item 12345
|
|
|
47
50
|
|
|
48
51
|
# 3) Update state
|
|
49
52
|
azdo set-state 12345 "Active"
|
|
53
|
+
|
|
54
|
+
# 4) Create or update a Task from markdown
|
|
55
|
+
azdo upsert --content $'---\nTitle: Improve markdown import UX\nState: New\n---'
|
|
50
56
|
```
|
|
51
57
|
|
|
52
58
|
## Command Cheat Sheet
|
|
@@ -57,8 +63,11 @@ azdo set-state 12345 "Active"
|
|
|
57
63
|
| `azdo set-state <id> <state>` | Change work item state | `--json`, `--org`, `--project` |
|
|
58
64
|
| `azdo assign <id> [name]` | Assign or unassign owner | `--unassign`, `--json`, `--org`, `--project` |
|
|
59
65
|
| `azdo set-field <id> <field> <value>` | Update any field | `--json`, `--org`, `--project` |
|
|
66
|
+
| `azdo upsert [id]` | Create or update a Task from markdown | `--content`, `--file`, `--json`, `--org`, `--project` |
|
|
60
67
|
| `azdo get-md-field <id> <field>` | Get field as markdown | `--org`, `--project` |
|
|
61
68
|
| `azdo set-md-field <id> <field> [content]` | Set markdown field | `--file`, `--json`, `--org`, `--project` |
|
|
69
|
+
| `azdo list-fields <id>` | List all fields of a work item | `--json`, `--org`, `--project` |
|
|
70
|
+
| `azdo pr <subcommand>` | Manage pull requests for the current branch | `status`, `open`, `comments`, `--json`, `--org`, `--project` |
|
|
62
71
|
| `azdo config <subcommand>` | Manage saved settings | `set`, `get`, `list`, `unset`, `wizard`, `--json` |
|
|
63
72
|
| `azdo clear-pat` | Remove stored PAT | none |
|
|
64
73
|
|
|
@@ -95,6 +104,16 @@ azdo assign 12345 --unassign
|
|
|
95
104
|
azdo set-field 12345 System.Title "Updated title"
|
|
96
105
|
```
|
|
97
106
|
|
|
107
|
+
### List Fields
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# List all fields with values (rich text fields preview first 5 lines)
|
|
111
|
+
azdo list-fields 12345
|
|
112
|
+
|
|
113
|
+
# JSON output
|
|
114
|
+
azdo list-fields 12345 --json
|
|
115
|
+
```
|
|
116
|
+
|
|
98
117
|
### Markdown Display
|
|
99
118
|
|
|
100
119
|
The `get-item` command can convert HTML rich-text fields to readable markdown. Resolution order:
|
|
@@ -119,6 +138,120 @@ azdo set-md-field 12345 System.Description --file ./description.md
|
|
|
119
138
|
cat description.md | azdo set-md-field 12345 System.Description
|
|
120
139
|
```
|
|
121
140
|
|
|
141
|
+
### Pull Request Commands
|
|
142
|
+
|
|
143
|
+
The `pr` command group uses the current git branch and the Azure DevOps `origin` remote automatically. It requires a PAT with `Code (Read)` scope for read operations and `Code (Read & Write)` for pull request creation.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# Check whether the current branch already has pull requests
|
|
147
|
+
azdo pr status
|
|
148
|
+
|
|
149
|
+
# Open a pull request to develop
|
|
150
|
+
azdo pr open --title "Add PR handling" --description "Implements pr status, pr open, pr comments commands"
|
|
151
|
+
|
|
152
|
+
# Review active comments for the current branch's active pull request
|
|
153
|
+
azdo pr comments
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`azdo pr status`
|
|
157
|
+
|
|
158
|
+
- Lists all pull requests for the current branch, including active, completed, and abandoned PRs
|
|
159
|
+
- Prints `No pull requests found for branch <branch>.` when no PRs exist
|
|
160
|
+
- Supports `--json` for machine-readable output
|
|
161
|
+
|
|
162
|
+
`azdo pr open`
|
|
163
|
+
|
|
164
|
+
- Requires both `--title <title>` and `--description <description>`
|
|
165
|
+
- Targets `develop` automatically
|
|
166
|
+
- Creates a new active pull request when none exists
|
|
167
|
+
- Reuses the existing active PR when one already matches the branch and target
|
|
168
|
+
- Fails with a clear error when run from `develop` or when multiple active PRs already exist
|
|
169
|
+
|
|
170
|
+
`azdo pr comments`
|
|
171
|
+
|
|
172
|
+
- Resolves the single active pull request for the current branch
|
|
173
|
+
- Returns only active or pending threads with visible, non-deleted comments
|
|
174
|
+
- Groups text output by thread and shows file context when available
|
|
175
|
+
- Prints `Pull request #<id> has no active comments.` when the PR has no active comment threads
|
|
176
|
+
- Fails instead of guessing when no active PR or multiple active PRs exist
|
|
177
|
+
|
|
178
|
+
## azdo upsert
|
|
179
|
+
|
|
180
|
+
`azdo upsert` accepts a single markdown task document and either creates a new Azure DevOps Task or updates an existing one. Omit `[id]` to create; pass `[id]` to update that work item in place.
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# Create from inline content
|
|
184
|
+
azdo upsert --content $'---\nTitle: Improve markdown import UX\nAssigned To: user@example.com\nState: New\n---'
|
|
185
|
+
|
|
186
|
+
# Update from a file
|
|
187
|
+
azdo upsert 12345 --file ./task-import.md
|
|
188
|
+
|
|
189
|
+
# JSON output
|
|
190
|
+
azdo upsert 12345 --content $'---\nSystem.Title: Improve markdown import UX\n---' --json
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The command requires exactly one source flag:
|
|
194
|
+
|
|
195
|
+
- `azdo upsert [id] --content <markdown>`
|
|
196
|
+
- `azdo upsert [id] --file <path>`
|
|
197
|
+
|
|
198
|
+
If `--file` succeeds, the source file is deleted after the Azure DevOps write completes. If parsing, validation, or the API call fails, the file is preserved. If deletion fails after a successful write, the command still succeeds and prints a warning.
|
|
199
|
+
|
|
200
|
+
### Task Document Format
|
|
201
|
+
|
|
202
|
+
The document starts with YAML front matter for scalar fields, followed by optional `##` heading sections for markdown rich-text fields.
|
|
203
|
+
|
|
204
|
+
```md
|
|
205
|
+
---
|
|
206
|
+
Title: Improve markdown import UX
|
|
207
|
+
Assigned To: user@example.com
|
|
208
|
+
State: New
|
|
209
|
+
Tags: cli; markdown
|
|
210
|
+
Priority: null
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Description
|
|
214
|
+
|
|
215
|
+
Implement a single-command task import flow.
|
|
216
|
+
|
|
217
|
+
## Acceptance Criteria
|
|
218
|
+
|
|
219
|
+
- Supports create when no ID is passed
|
|
220
|
+
- Supports update when an ID is passed
|
|
221
|
+
- Deletes imported files only after success
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Supported friendly field names:
|
|
225
|
+
|
|
226
|
+
- `Title`
|
|
227
|
+
- `Assigned To` / `assignedTo`
|
|
228
|
+
- `State`
|
|
229
|
+
- `Description`
|
|
230
|
+
- `Acceptance Criteria` / `acceptanceCriteria`
|
|
231
|
+
- `Tags`
|
|
232
|
+
- `Priority`
|
|
233
|
+
|
|
234
|
+
Raw Azure DevOps reference names are also accepted anywhere a field name is expected, for example `System.Title` or `Microsoft.VSTS.Common.AcceptanceCriteria`.
|
|
235
|
+
|
|
236
|
+
Clear semantics:
|
|
237
|
+
|
|
238
|
+
- Scalar YAML fields with `null` or an empty value are treated as clears on update.
|
|
239
|
+
- Rich-text heading sections with an empty body are treated as clears on update.
|
|
240
|
+
- Omitted fields are untouched on update.
|
|
241
|
+
|
|
242
|
+
`--json` output shape:
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"action": "created",
|
|
247
|
+
"id": 12345,
|
|
248
|
+
"fields": {
|
|
249
|
+
"System.Title": "Improve markdown import UX",
|
|
250
|
+
"System.Description": "Implement a single-command task import flow."
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
122
255
|
### Configuration
|
|
123
256
|
|
|
124
257
|
```bash
|
|
@@ -150,6 +283,7 @@ azdo clear-pat
|
|
|
150
283
|
## JSON Output
|
|
151
284
|
|
|
152
285
|
These commands support `--json` for machine-readable output:
|
|
286
|
+
- `list-fields`
|
|
153
287
|
- `set-state`
|
|
154
288
|
- `assign`
|
|
155
289
|
- `set-field`
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command12 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
7
|
import { readFileSync } from "fs";
|
|
@@ -55,16 +55,73 @@ async function readResponseMessage(response) {
|
|
|
55
55
|
function normalizeFieldList(fields) {
|
|
56
56
|
return Array.from(new Set(fields.map((f) => f.trim()).filter((f) => f.length > 0)));
|
|
57
57
|
}
|
|
58
|
+
function stringifyFieldValue(value) {
|
|
59
|
+
if (typeof value === "object" && value !== null) {
|
|
60
|
+
return JSON.stringify(value);
|
|
61
|
+
}
|
|
62
|
+
return String(value);
|
|
63
|
+
}
|
|
58
64
|
function buildExtraFields(fields, requested) {
|
|
59
65
|
const result = {};
|
|
60
66
|
for (const name of requested) {
|
|
61
|
-
|
|
67
|
+
let val = fields[name];
|
|
68
|
+
let resolvedName = name;
|
|
69
|
+
if (val === void 0) {
|
|
70
|
+
const nameSuffix = name.split(".").pop().toLowerCase();
|
|
71
|
+
const match = Object.keys(fields).find(
|
|
72
|
+
(k) => k.split(".").pop().toLowerCase() === nameSuffix
|
|
73
|
+
);
|
|
74
|
+
if (match !== void 0) {
|
|
75
|
+
val = fields[match];
|
|
76
|
+
resolvedName = match;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
62
79
|
if (val !== void 0 && val !== null) {
|
|
63
|
-
result[
|
|
80
|
+
result[resolvedName] = stringifyFieldValue(val);
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
83
|
return Object.keys(result).length > 0 ? result : null;
|
|
67
84
|
}
|
|
85
|
+
function writeHeaders(pat) {
|
|
86
|
+
return {
|
|
87
|
+
...authHeaders(pat),
|
|
88
|
+
"Content-Type": "application/json-patch+json"
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function readWriteResponse(response, errorCode) {
|
|
92
|
+
if (response.status === 400) {
|
|
93
|
+
const serverMessage = await readResponseMessage(response) ?? "Unknown error";
|
|
94
|
+
throw new Error(`${errorCode}: ${serverMessage}`);
|
|
95
|
+
}
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
throw new Error(`HTTP_${response.status}`);
|
|
98
|
+
}
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
return {
|
|
101
|
+
id: data.id,
|
|
102
|
+
rev: data.rev,
|
|
103
|
+
fields: data.fields
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function getWorkItemFields(context, id, pat) {
|
|
107
|
+
const url = new URL(
|
|
108
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
109
|
+
);
|
|
110
|
+
url.searchParams.set("api-version", "7.1");
|
|
111
|
+
url.searchParams.set("$expand", "all");
|
|
112
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
113
|
+
if (response.status === 400) {
|
|
114
|
+
const serverMessage = await readResponseMessage(response);
|
|
115
|
+
if (serverMessage) {
|
|
116
|
+
throw new Error(`BAD_REQUEST: ${serverMessage}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(`HTTP_${response.status}`);
|
|
121
|
+
}
|
|
122
|
+
const data = await response.json();
|
|
123
|
+
return data.fields;
|
|
124
|
+
}
|
|
68
125
|
async function getWorkItem(context, id, pat, extraFields) {
|
|
69
126
|
const url = new URL(
|
|
70
127
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
@@ -98,7 +155,7 @@ async function getWorkItem(context, id, pat, extraFields) {
|
|
|
98
155
|
}
|
|
99
156
|
let combinedDescription = null;
|
|
100
157
|
if (descriptionParts.length === 1) {
|
|
101
|
-
combinedDescription = descriptionParts
|
|
158
|
+
combinedDescription = descriptionParts.at(0)?.value ?? null;
|
|
102
159
|
} else if (descriptionParts.length > 1) {
|
|
103
160
|
combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
|
|
104
161
|
}
|
|
@@ -137,38 +194,44 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
|
|
|
137
194
|
if (value === void 0 || value === null || value === "") {
|
|
138
195
|
return null;
|
|
139
196
|
}
|
|
140
|
-
return
|
|
197
|
+
return stringifyFieldValue(value);
|
|
141
198
|
}
|
|
142
199
|
async function updateWorkItem(context, id, pat, fieldName, operations) {
|
|
200
|
+
const result = await applyWorkItemPatch(context, id, pat, operations);
|
|
201
|
+
const title = result.fields["System.Title"];
|
|
202
|
+
const lastOp = operations.at(-1);
|
|
203
|
+
const fieldValue = lastOp?.value ?? null;
|
|
204
|
+
return {
|
|
205
|
+
id: result.id,
|
|
206
|
+
rev: result.rev,
|
|
207
|
+
title: typeof title === "string" ? title : "",
|
|
208
|
+
fieldName,
|
|
209
|
+
fieldValue
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async function createWorkItem(context, workItemType, pat, operations) {
|
|
213
|
+
const url = new URL(
|
|
214
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
|
|
215
|
+
);
|
|
216
|
+
url.searchParams.set("api-version", "7.1");
|
|
217
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: writeHeaders(pat),
|
|
220
|
+
body: JSON.stringify(operations)
|
|
221
|
+
});
|
|
222
|
+
return readWriteResponse(response, "CREATE_REJECTED");
|
|
223
|
+
}
|
|
224
|
+
async function applyWorkItemPatch(context, id, pat, operations) {
|
|
143
225
|
const url = new URL(
|
|
144
226
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
145
227
|
);
|
|
146
228
|
url.searchParams.set("api-version", "7.1");
|
|
147
229
|
const response = await fetchWithErrors(url.toString(), {
|
|
148
230
|
method: "PATCH",
|
|
149
|
-
headers:
|
|
150
|
-
...authHeaders(pat),
|
|
151
|
-
"Content-Type": "application/json-patch+json"
|
|
152
|
-
},
|
|
231
|
+
headers: writeHeaders(pat),
|
|
153
232
|
body: JSON.stringify(operations)
|
|
154
233
|
});
|
|
155
|
-
|
|
156
|
-
const serverMessage = await readResponseMessage(response) ?? "Unknown error";
|
|
157
|
-
throw new Error(`UPDATE_REJECTED: ${serverMessage}`);
|
|
158
|
-
}
|
|
159
|
-
if (!response.ok) {
|
|
160
|
-
throw new Error(`HTTP_${response.status}`);
|
|
161
|
-
}
|
|
162
|
-
const data = await response.json();
|
|
163
|
-
const lastOp = operations[operations.length - 1];
|
|
164
|
-
const fieldValue = lastOp.value ?? null;
|
|
165
|
-
return {
|
|
166
|
-
id: data.id,
|
|
167
|
-
rev: data.rev,
|
|
168
|
-
title: data.fields["System.Title"],
|
|
169
|
-
fieldName,
|
|
170
|
-
fieldValue
|
|
171
|
-
};
|
|
234
|
+
return readWriteResponse(response, "UPDATE_REJECTED");
|
|
172
235
|
}
|
|
173
236
|
|
|
174
237
|
// src/services/auth.ts
|
|
@@ -274,19 +337,19 @@ async function resolvePat() {
|
|
|
274
337
|
import { execSync } from "child_process";
|
|
275
338
|
var patterns = [
|
|
276
339
|
// HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
|
|
277
|
-
/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git
|
|
340
|
+
/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)$/,
|
|
278
341
|
// HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
|
|
279
|
-
/^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git
|
|
342
|
+
/^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+)$/,
|
|
280
343
|
// HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
|
|
281
|
-
/^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git
|
|
344
|
+
/^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)$/,
|
|
282
345
|
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
|
|
283
|
-
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)
|
|
346
|
+
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/,
|
|
284
347
|
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
|
|
285
|
-
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)
|
|
348
|
+
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/
|
|
286
349
|
];
|
|
287
350
|
function parseAzdoRemote(url) {
|
|
288
351
|
for (const pattern of patterns) {
|
|
289
|
-
const match =
|
|
352
|
+
const match = pattern.exec(url);
|
|
290
353
|
if (match) {
|
|
291
354
|
const project = match[2];
|
|
292
355
|
if (/^DefaultCollection$/i.test(project)) {
|
|
@@ -310,6 +373,35 @@ function detectAzdoContext() {
|
|
|
310
373
|
}
|
|
311
374
|
return context;
|
|
312
375
|
}
|
|
376
|
+
function parseRepoName(url) {
|
|
377
|
+
for (const pattern of patterns) {
|
|
378
|
+
const match = pattern.exec(url);
|
|
379
|
+
if (match) {
|
|
380
|
+
return match[3];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
function detectRepoName() {
|
|
386
|
+
let remoteUrl;
|
|
387
|
+
try {
|
|
388
|
+
remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
|
|
389
|
+
} catch {
|
|
390
|
+
throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
|
|
391
|
+
}
|
|
392
|
+
const repo = parseRepoName(remoteUrl);
|
|
393
|
+
if (!repo) {
|
|
394
|
+
throw new Error('Git remote "origin" is not an Azure DevOps URL. Check that origin points to Azure DevOps and try again.');
|
|
395
|
+
}
|
|
396
|
+
return repo;
|
|
397
|
+
}
|
|
398
|
+
function getCurrentBranch() {
|
|
399
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
|
|
400
|
+
if (branch === "HEAD") {
|
|
401
|
+
throw new Error("Not on a named branch. Check out a named branch and try again.");
|
|
402
|
+
}
|
|
403
|
+
return branch;
|
|
404
|
+
}
|
|
313
405
|
|
|
314
406
|
// src/services/config-store.ts
|
|
315
407
|
import fs from "fs";
|
|
@@ -509,6 +601,24 @@ function validateOrgProjectPair(options) {
|
|
|
509
601
|
process.exit(1);
|
|
510
602
|
}
|
|
511
603
|
}
|
|
604
|
+
function validateSource(options) {
|
|
605
|
+
const hasContent = options.content !== void 0;
|
|
606
|
+
const hasFile = options.file !== void 0;
|
|
607
|
+
if (hasContent === hasFile) {
|
|
608
|
+
process.stderr.write("Error: provide exactly one of --content or --file\n");
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function formatCreateError(err) {
|
|
613
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
614
|
+
const message = error.message.startsWith("CREATE_REJECTED:") ? error.message.replace("CREATE_REJECTED:", "").trim() : error.message;
|
|
615
|
+
const requiredMatches = [...message.matchAll(/field ['"]([^'"]+)['"]/gi)];
|
|
616
|
+
if (requiredMatches.length > 0) {
|
|
617
|
+
const fields = Array.from(new Set(requiredMatches.map((match) => match[1])));
|
|
618
|
+
return `Create rejected: ${message} (fields: ${fields.join(", ")})`;
|
|
619
|
+
}
|
|
620
|
+
return `Create rejected: ${message}`;
|
|
621
|
+
}
|
|
512
622
|
function handleCommandError(err, id, context, scope = "write", exit = true) {
|
|
513
623
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
514
624
|
const msg = error.message;
|
|
@@ -535,6 +645,9 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
|
|
|
535
645
|
} else if (msg.startsWith("BAD_REQUEST:")) {
|
|
536
646
|
const serverMsg = msg.replace("BAD_REQUEST: ", "");
|
|
537
647
|
process.stderr.write(`Error: Request rejected: ${serverMsg}
|
|
648
|
+
`);
|
|
649
|
+
} else if (msg.startsWith("CREATE_REJECTED:")) {
|
|
650
|
+
process.stderr.write(`Error: ${formatCreateError(error)}
|
|
538
651
|
`);
|
|
539
652
|
} else if (msg.startsWith("UPDATE_REJECTED:")) {
|
|
540
653
|
const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
|
|
@@ -561,18 +674,18 @@ function parseRequestedFields(raw) {
|
|
|
561
674
|
}
|
|
562
675
|
function stripHtml(html) {
|
|
563
676
|
let text = html;
|
|
564
|
-
text = text.
|
|
565
|
-
text = text.
|
|
566
|
-
text = text.
|
|
567
|
-
text = text.
|
|
568
|
-
text = text.
|
|
569
|
-
text = text.
|
|
570
|
-
text = text.
|
|
571
|
-
text = text.
|
|
572
|
-
text = text.
|
|
573
|
-
text = text.
|
|
574
|
-
text = text.
|
|
575
|
-
text = text.
|
|
677
|
+
text = text.replaceAll(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
|
|
678
|
+
text = text.replaceAll(/<br\s*\/?>/gi, "\n");
|
|
679
|
+
text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
|
|
680
|
+
text = text.replaceAll(/<li>/gi, "\n");
|
|
681
|
+
text = text.replaceAll(/<[^>]*>/g, "");
|
|
682
|
+
text = text.replaceAll("&", "&");
|
|
683
|
+
text = text.replaceAll("<", "<");
|
|
684
|
+
text = text.replaceAll(">", ">");
|
|
685
|
+
text = text.replaceAll(""", '"');
|
|
686
|
+
text = text.replaceAll("'", "'");
|
|
687
|
+
text = text.replaceAll(" ", " ");
|
|
688
|
+
text = text.replaceAll(/\n{3,}/g, "\n\n");
|
|
576
689
|
return text.trim();
|
|
577
690
|
}
|
|
578
691
|
function convertRichText(html, markdown) {
|
|
@@ -593,16 +706,19 @@ function summarizeDescription(text, label) {
|
|
|
593
706
|
return [`${label("Description:")}${firstThree.join("\n")}${suffix}`];
|
|
594
707
|
}
|
|
595
708
|
function formatWorkItem(workItem, short, markdown = false) {
|
|
596
|
-
const lines = [];
|
|
597
709
|
const label = (name) => name.padEnd(13);
|
|
598
|
-
lines
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
710
|
+
const lines = [
|
|
711
|
+
`${label("ID:")}${workItem.id}`,
|
|
712
|
+
`${label("Type:")}${workItem.type}`,
|
|
713
|
+
`${label("Title:")}${workItem.title}`,
|
|
714
|
+
`${label("State:")}${workItem.state}`,
|
|
715
|
+
`${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`
|
|
716
|
+
];
|
|
603
717
|
if (!short) {
|
|
604
|
-
lines.push(
|
|
605
|
-
|
|
718
|
+
lines.push(
|
|
719
|
+
`${label("Area:")}${workItem.areaPath}`,
|
|
720
|
+
`${label("Iteration:")}${workItem.iterationPath}`
|
|
721
|
+
);
|
|
606
722
|
}
|
|
607
723
|
lines.push(`${label("URL:")}${workItem.url}`);
|
|
608
724
|
if (workItem.extraFields) {
|
|
@@ -613,8 +729,7 @@ function formatWorkItem(workItem, short, markdown = false) {
|
|
|
613
729
|
if (short) {
|
|
614
730
|
lines.push(...summarizeDescription(descriptionText, label));
|
|
615
731
|
} else {
|
|
616
|
-
lines.push("Description:");
|
|
617
|
-
lines.push(descriptionText);
|
|
732
|
+
lines.push("Description:", descriptionText);
|
|
618
733
|
}
|
|
619
734
|
return lines.join("\n");
|
|
620
735
|
}
|
|
@@ -628,7 +743,7 @@ function createGetItemCommand() {
|
|
|
628
743
|
try {
|
|
629
744
|
context = resolveContext(options);
|
|
630
745
|
const credential = await resolvePat();
|
|
631
|
-
const fieldsList = options.fields
|
|
746
|
+
const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
|
|
632
747
|
const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
|
|
633
748
|
const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
|
|
634
749
|
const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
|
|
@@ -659,6 +774,63 @@ function createClearPatCommand() {
|
|
|
659
774
|
// src/commands/config.ts
|
|
660
775
|
import { Command as Command3 } from "commander";
|
|
661
776
|
import { createInterface as createInterface2 } from "readline";
|
|
777
|
+
function formatConfigValue(value, unsetFallback = "") {
|
|
778
|
+
if (value === void 0) {
|
|
779
|
+
return unsetFallback;
|
|
780
|
+
}
|
|
781
|
+
return Array.isArray(value) ? value.join(",") : value;
|
|
782
|
+
}
|
|
783
|
+
function writeConfigList(cfg) {
|
|
784
|
+
const keyWidth = 10;
|
|
785
|
+
const valueWidth = 30;
|
|
786
|
+
for (const setting of SETTINGS) {
|
|
787
|
+
const raw = cfg[setting.key];
|
|
788
|
+
const value = formatConfigValue(raw, "(not set)");
|
|
789
|
+
const marker = raw === void 0 && setting.required ? " *" : "";
|
|
790
|
+
process.stdout.write(
|
|
791
|
+
`${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
792
|
+
`
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
|
|
796
|
+
if (hasUnset) {
|
|
797
|
+
process.stdout.write(
|
|
798
|
+
'\n* = required but not configured. Run "azdo config wizard" to set up.\n'
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function createAsk(rl) {
|
|
803
|
+
return (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
|
|
804
|
+
}
|
|
805
|
+
async function promptForSetting(cfg, setting, ask) {
|
|
806
|
+
const currentDisplay = String(formatConfigValue(cfg[setting.key], ""));
|
|
807
|
+
const requiredTag = setting.required ? " (required)" : " (optional)";
|
|
808
|
+
process.stderr.write(`${setting.description}${requiredTag}
|
|
809
|
+
`);
|
|
810
|
+
if (setting.example) {
|
|
811
|
+
process.stderr.write(` Example: ${setting.example}
|
|
812
|
+
`);
|
|
813
|
+
}
|
|
814
|
+
const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
|
|
815
|
+
const answer = await ask(` ${setting.key}${defaultHint}: `);
|
|
816
|
+
const trimmed = answer.trim();
|
|
817
|
+
if (trimmed) {
|
|
818
|
+
setConfigValue(setting.key, trimmed);
|
|
819
|
+
process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
|
|
820
|
+
|
|
821
|
+
`);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (currentDisplay) {
|
|
825
|
+
process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
|
|
826
|
+
|
|
827
|
+
`);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
process.stderr.write(` -> Skipped "${setting.key}"
|
|
831
|
+
|
|
832
|
+
`);
|
|
833
|
+
}
|
|
662
834
|
function createConfigCommand() {
|
|
663
835
|
const config = new Command3("config");
|
|
664
836
|
config.description("Manage CLI settings");
|
|
@@ -711,27 +883,9 @@ function createConfigCommand() {
|
|
|
711
883
|
const cfg = loadConfig();
|
|
712
884
|
if (options.json) {
|
|
713
885
|
process.stdout.write(JSON.stringify(cfg) + "\n");
|
|
714
|
-
|
|
715
|
-
const keyWidth = 10;
|
|
716
|
-
const valueWidth = 30;
|
|
717
|
-
for (const setting of SETTINGS) {
|
|
718
|
-
const raw = cfg[setting.key];
|
|
719
|
-
const value = raw === void 0 ? "(not set)" : Array.isArray(raw) ? raw.join(",") : raw;
|
|
720
|
-
const marker = raw === void 0 && setting.required ? " *" : "";
|
|
721
|
-
process.stdout.write(
|
|
722
|
-
`${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
723
|
-
`
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
const hasUnset = SETTINGS.some(
|
|
727
|
-
(s) => s.required && cfg[s.key] === void 0
|
|
728
|
-
);
|
|
729
|
-
if (hasUnset) {
|
|
730
|
-
process.stdout.write(
|
|
731
|
-
'\n* = required but not configured. Run "azdo config wizard" to set up.\n'
|
|
732
|
-
);
|
|
733
|
-
}
|
|
886
|
+
return;
|
|
734
887
|
}
|
|
888
|
+
writeConfigList(cfg);
|
|
735
889
|
});
|
|
736
890
|
const unset = new Command3("unset");
|
|
737
891
|
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
@@ -763,36 +917,11 @@ function createConfigCommand() {
|
|
|
763
917
|
input: process.stdin,
|
|
764
918
|
output: process.stderr
|
|
765
919
|
});
|
|
766
|
-
const ask = (
|
|
920
|
+
const ask = createAsk(rl);
|
|
767
921
|
process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
|
|
768
922
|
process.stderr.write("=======================================\n\n");
|
|
769
923
|
for (const setting of SETTINGS) {
|
|
770
|
-
|
|
771
|
-
const currentDisplay = current === void 0 ? "" : Array.isArray(current) ? current.join(",") : current;
|
|
772
|
-
const requiredTag = setting.required ? " (required)" : " (optional)";
|
|
773
|
-
process.stderr.write(`${setting.description}${requiredTag}
|
|
774
|
-
`);
|
|
775
|
-
if (setting.example) {
|
|
776
|
-
process.stderr.write(` Example: ${setting.example}
|
|
777
|
-
`);
|
|
778
|
-
}
|
|
779
|
-
const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
|
|
780
|
-
const answer = await ask(` ${setting.key}${defaultHint}: `);
|
|
781
|
-
const trimmed = answer.trim();
|
|
782
|
-
if (trimmed) {
|
|
783
|
-
setConfigValue(setting.key, trimmed);
|
|
784
|
-
process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
|
|
785
|
-
|
|
786
|
-
`);
|
|
787
|
-
} else if (currentDisplay) {
|
|
788
|
-
process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
|
|
789
|
-
|
|
790
|
-
`);
|
|
791
|
-
} else {
|
|
792
|
-
process.stderr.write(` -> Skipped "${setting.key}"
|
|
793
|
-
|
|
794
|
-
`);
|
|
795
|
-
}
|
|
924
|
+
await promptForSetting(cfg, setting, ask);
|
|
796
925
|
}
|
|
797
926
|
rl.close();
|
|
798
927
|
process.stderr.write("Configuration complete!\n");
|
|
@@ -1047,8 +1176,683 @@ function createSetMdFieldCommand() {
|
|
|
1047
1176
|
return command;
|
|
1048
1177
|
}
|
|
1049
1178
|
|
|
1179
|
+
// src/commands/upsert.ts
|
|
1180
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, unlinkSync } from "fs";
|
|
1181
|
+
import { Command as Command9 } from "commander";
|
|
1182
|
+
|
|
1183
|
+
// src/services/task-document.ts
|
|
1184
|
+
var FIELD_ALIASES = /* @__PURE__ */ new Map([
|
|
1185
|
+
["title", "System.Title"],
|
|
1186
|
+
["assignedto", "System.AssignedTo"],
|
|
1187
|
+
["assigned to", "System.AssignedTo"],
|
|
1188
|
+
["state", "System.State"],
|
|
1189
|
+
["description", "System.Description"],
|
|
1190
|
+
["acceptancecriteria", "Microsoft.VSTS.Common.AcceptanceCriteria"],
|
|
1191
|
+
["acceptance criteria", "Microsoft.VSTS.Common.AcceptanceCriteria"],
|
|
1192
|
+
["tags", "System.Tags"],
|
|
1193
|
+
["priority", "Microsoft.VSTS.Common.Priority"]
|
|
1194
|
+
]);
|
|
1195
|
+
var RICH_TEXT_FIELDS = /* @__PURE__ */ new Set([
|
|
1196
|
+
"System.Description",
|
|
1197
|
+
"Microsoft.VSTS.Common.AcceptanceCriteria"
|
|
1198
|
+
]);
|
|
1199
|
+
var REFERENCE_NAME_PATTERN = /^[A-Z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$/;
|
|
1200
|
+
function normalizeAlias(name) {
|
|
1201
|
+
return name.trim().replaceAll(/\s+/g, " ").toLowerCase();
|
|
1202
|
+
}
|
|
1203
|
+
function parseScalarValue(rawValue, fieldName) {
|
|
1204
|
+
if (rawValue === void 0) {
|
|
1205
|
+
throw new Error(`Malformed YAML front matter: missing value for "${fieldName}"`);
|
|
1206
|
+
}
|
|
1207
|
+
const trimmed = rawValue.trim();
|
|
1208
|
+
if (trimmed === "" || trimmed === "null" || trimmed === "~") {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
1212
|
+
return trimmed.slice(1, -1);
|
|
1213
|
+
}
|
|
1214
|
+
if (/^[[{]|^[>|]-?$/.test(trimmed)) {
|
|
1215
|
+
throw new Error(`Malformed YAML front matter: unsupported value for "${fieldName}"`);
|
|
1216
|
+
}
|
|
1217
|
+
return trimmed;
|
|
1218
|
+
}
|
|
1219
|
+
function parseFrontMatter(content) {
|
|
1220
|
+
if (!content.startsWith("---")) {
|
|
1221
|
+
return { frontMatter: "", remainder: content };
|
|
1222
|
+
}
|
|
1223
|
+
const frontMatterPattern = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
1224
|
+
const match = frontMatterPattern.exec(content);
|
|
1225
|
+
if (!match) {
|
|
1226
|
+
throw new Error('Malformed YAML front matter: missing closing "---"');
|
|
1227
|
+
}
|
|
1228
|
+
return {
|
|
1229
|
+
frontMatter: match[1],
|
|
1230
|
+
remainder: content.slice(match[0].length)
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function assertKnownField(name, kind) {
|
|
1234
|
+
const resolved = resolveFieldName(name);
|
|
1235
|
+
if (!resolved) {
|
|
1236
|
+
const prefix = kind === "rich-text" ? "Unknown rich-text field" : "Unknown field";
|
|
1237
|
+
throw new Error(`${prefix}: ${name}`);
|
|
1238
|
+
}
|
|
1239
|
+
if (kind === "rich-text" && !RICH_TEXT_FIELDS.has(resolved)) {
|
|
1240
|
+
throw new Error(`Unknown rich-text field: ${name}`);
|
|
1241
|
+
}
|
|
1242
|
+
return resolved;
|
|
1243
|
+
}
|
|
1244
|
+
function pushField(fields, seen, refName, value, kind) {
|
|
1245
|
+
if (seen.has(refName)) {
|
|
1246
|
+
throw new Error(`Duplicate field: ${refName}`);
|
|
1247
|
+
}
|
|
1248
|
+
seen.add(refName);
|
|
1249
|
+
fields.push({
|
|
1250
|
+
refName,
|
|
1251
|
+
value,
|
|
1252
|
+
op: value === null ? "clear" : "set",
|
|
1253
|
+
kind
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
function parseScalarFields(frontMatter, fields, seen) {
|
|
1257
|
+
if (frontMatter.trim() === "") {
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
for (const rawLine of frontMatter.split(/\r?\n/)) {
|
|
1261
|
+
const line = rawLine.trim();
|
|
1262
|
+
if (line === "") {
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
const separatorIndex = rawLine.indexOf(":");
|
|
1266
|
+
if (separatorIndex <= 0) {
|
|
1267
|
+
throw new Error(`Malformed YAML front matter: ${rawLine.trim()}`);
|
|
1268
|
+
}
|
|
1269
|
+
const rawName = rawLine.slice(0, separatorIndex).trim();
|
|
1270
|
+
const rawValue = rawLine.slice(separatorIndex + 1);
|
|
1271
|
+
const refName = assertKnownField(rawName, "scalar");
|
|
1272
|
+
const value = parseScalarValue(rawValue, rawName);
|
|
1273
|
+
pushField(fields, seen, refName, value, "scalar");
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
function parseRichTextSections(content, fields, seen) {
|
|
1277
|
+
const normalizedContent = content.replaceAll("\r\n", "\n");
|
|
1278
|
+
const lines = normalizedContent.split("\n");
|
|
1279
|
+
const headings = [];
|
|
1280
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1281
|
+
const line = lines[index];
|
|
1282
|
+
if (!line.startsWith("##")) {
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
const headingBody = line.slice(2);
|
|
1286
|
+
if (headingBody.trim() === "" || !headingBody.startsWith(" ") && !headingBody.startsWith(" ")) {
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
headings.push({
|
|
1290
|
+
lineIndex: index,
|
|
1291
|
+
rawName: headingBody.trim()
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
if (headings.length === 0) {
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
for (let index = 0; index < headings[0].lineIndex; index += 1) {
|
|
1298
|
+
if (lines[index].trim() !== "") {
|
|
1299
|
+
throw new Error("Unexpected content before the first markdown heading section");
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
for (let index = 0; index < headings.length; index += 1) {
|
|
1303
|
+
const { lineIndex, rawName } = headings[index];
|
|
1304
|
+
const refName = assertKnownField(rawName, "rich-text");
|
|
1305
|
+
const bodyStart = lineIndex + 1;
|
|
1306
|
+
const bodyEnd = index + 1 < headings.length ? headings[index + 1].lineIndex : lines.length;
|
|
1307
|
+
const rawBody = lines.slice(bodyStart, bodyEnd).join("\n");
|
|
1308
|
+
const value = rawBody.trim() === "" ? null : rawBody.trimEnd();
|
|
1309
|
+
pushField(fields, seen, refName, value, "rich-text");
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
function resolveFieldName(name) {
|
|
1313
|
+
const trimmed = name.trim();
|
|
1314
|
+
if (trimmed === "") {
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1317
|
+
const alias = FIELD_ALIASES.get(normalizeAlias(trimmed));
|
|
1318
|
+
if (alias) {
|
|
1319
|
+
return alias;
|
|
1320
|
+
}
|
|
1321
|
+
return REFERENCE_NAME_PATTERN.test(trimmed) ? trimmed : null;
|
|
1322
|
+
}
|
|
1323
|
+
function parseTaskDocument(content) {
|
|
1324
|
+
const { frontMatter, remainder } = parseFrontMatter(content);
|
|
1325
|
+
const fields = [];
|
|
1326
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1327
|
+
parseScalarFields(frontMatter, fields, seen);
|
|
1328
|
+
parseRichTextSections(remainder, fields, seen);
|
|
1329
|
+
return { fields };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// src/commands/upsert.ts
|
|
1333
|
+
function fail2(message) {
|
|
1334
|
+
process.stderr.write(`Error: ${message}
|
|
1335
|
+
`);
|
|
1336
|
+
process.exit(1);
|
|
1337
|
+
}
|
|
1338
|
+
function loadSourceContent(options) {
|
|
1339
|
+
validateSource(options);
|
|
1340
|
+
if (options.content !== void 0) {
|
|
1341
|
+
return { content: options.content };
|
|
1342
|
+
}
|
|
1343
|
+
const filePath = options.file;
|
|
1344
|
+
if (!existsSync2(filePath)) {
|
|
1345
|
+
fail2(`File not found: ${filePath}`);
|
|
1346
|
+
}
|
|
1347
|
+
try {
|
|
1348
|
+
return {
|
|
1349
|
+
content: readFileSync3(filePath, "utf-8"),
|
|
1350
|
+
sourceFile: filePath
|
|
1351
|
+
};
|
|
1352
|
+
} catch {
|
|
1353
|
+
fail2(`Cannot read file: ${filePath}`);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
function toPatchOperations(fields, action) {
|
|
1357
|
+
const operations = [];
|
|
1358
|
+
for (const field of fields) {
|
|
1359
|
+
if (field.op === "clear") {
|
|
1360
|
+
if (action === "updated") {
|
|
1361
|
+
operations.push({ op: "remove", path: `/fields/${field.refName}` });
|
|
1362
|
+
}
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
operations.push({ op: "add", path: `/fields/${field.refName}`, value: field.value ?? "" });
|
|
1366
|
+
if (field.kind === "rich-text") {
|
|
1367
|
+
operations.push({
|
|
1368
|
+
op: "add",
|
|
1369
|
+
path: `/multilineFieldsFormat/${field.refName}`,
|
|
1370
|
+
value: "Markdown"
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return operations;
|
|
1375
|
+
}
|
|
1376
|
+
function buildAppliedFields(fields) {
|
|
1377
|
+
const applied = {};
|
|
1378
|
+
for (const field of fields) {
|
|
1379
|
+
applied[field.refName] = field.value;
|
|
1380
|
+
}
|
|
1381
|
+
return applied;
|
|
1382
|
+
}
|
|
1383
|
+
function ensureTitleForCreate(fields) {
|
|
1384
|
+
const titleField = fields.find((field) => field.refName === "System.Title");
|
|
1385
|
+
if (!titleField || titleField.op === "clear" || titleField.value === null || titleField.value.trim() === "") {
|
|
1386
|
+
fail2("Title is required when creating a task.");
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
function writeSuccess(result, options) {
|
|
1390
|
+
if (options.json) {
|
|
1391
|
+
process.stdout.write(`${JSON.stringify(result)}
|
|
1392
|
+
`);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const verb = result.action === "created" ? "Created" : "Updated";
|
|
1396
|
+
const fields = Object.keys(result.fields).join(", ");
|
|
1397
|
+
const suffix = fields ? ` (${fields})` : "";
|
|
1398
|
+
process.stdout.write(`${verb} task #${result.id}${suffix}
|
|
1399
|
+
`);
|
|
1400
|
+
}
|
|
1401
|
+
function cleanupSourceFile(sourceFile) {
|
|
1402
|
+
if (!sourceFile) {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
try {
|
|
1406
|
+
unlinkSync(sourceFile);
|
|
1407
|
+
} catch {
|
|
1408
|
+
process.stderr.write(`Warning: upsert succeeded but could not delete source file: ${sourceFile}
|
|
1409
|
+
`);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
function buildUpsertResult(action, writeResult, fields) {
|
|
1413
|
+
const appliedFields = buildAppliedFields(fields);
|
|
1414
|
+
return {
|
|
1415
|
+
action,
|
|
1416
|
+
id: writeResult.id,
|
|
1417
|
+
fields: appliedFields
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
function isUpdateWriteError(err) {
|
|
1421
|
+
return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NOT_FOUND" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("UPDATE_REJECTED:");
|
|
1422
|
+
}
|
|
1423
|
+
function isCreateWriteError(err) {
|
|
1424
|
+
return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("HTTP_");
|
|
1425
|
+
}
|
|
1426
|
+
function handleUpsertError(err, id, context) {
|
|
1427
|
+
if (!(err instanceof Error)) {
|
|
1428
|
+
process.stderr.write(`Error: ${String(err)}
|
|
1429
|
+
`);
|
|
1430
|
+
process.exit(1);
|
|
1431
|
+
}
|
|
1432
|
+
if (id === void 0 && err.message.startsWith("CREATE_REJECTED:")) {
|
|
1433
|
+
process.stderr.write(`Error: ${formatCreateError(err)}
|
|
1434
|
+
`);
|
|
1435
|
+
process.exit(1);
|
|
1436
|
+
}
|
|
1437
|
+
if (id !== void 0 && isUpdateWriteError(err)) {
|
|
1438
|
+
handleCommandError(err, id, context, "write");
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
if (id === void 0 && isCreateWriteError(err)) {
|
|
1442
|
+
handleCommandError(err, 0, context, "write");
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
process.stderr.write(`Error: ${err.message}
|
|
1446
|
+
`);
|
|
1447
|
+
process.exit(1);
|
|
1448
|
+
}
|
|
1449
|
+
function createUpsertCommand() {
|
|
1450
|
+
const command = new Command9("upsert");
|
|
1451
|
+
command.description("Create or update a Task from a markdown document").argument("[id]", "work item ID to update; omit to create a new Task").option("--content <markdown>", "task document content").option("--file <path>", "read task document from file").option("--json", "output result as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(async (idStr, options) => {
|
|
1452
|
+
validateOrgProjectPair(options);
|
|
1453
|
+
const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
|
|
1454
|
+
const { content, sourceFile } = loadSourceContent(options);
|
|
1455
|
+
let context;
|
|
1456
|
+
try {
|
|
1457
|
+
context = resolveContext(options);
|
|
1458
|
+
const document = parseTaskDocument(content);
|
|
1459
|
+
const action = id === void 0 ? "created" : "updated";
|
|
1460
|
+
if (action === "created") {
|
|
1461
|
+
ensureTitleForCreate(document.fields);
|
|
1462
|
+
}
|
|
1463
|
+
const operations = toPatchOperations(document.fields, action);
|
|
1464
|
+
const credential = await resolvePat();
|
|
1465
|
+
let writeResult;
|
|
1466
|
+
if (action === "created") {
|
|
1467
|
+
writeResult = await createWorkItem(context, "Task", credential.pat, operations);
|
|
1468
|
+
} else {
|
|
1469
|
+
if (id === void 0) {
|
|
1470
|
+
fail2("Work item ID is required for updates.");
|
|
1471
|
+
}
|
|
1472
|
+
writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
|
|
1473
|
+
}
|
|
1474
|
+
const result = buildUpsertResult(action, writeResult, document.fields);
|
|
1475
|
+
writeSuccess(result, options);
|
|
1476
|
+
cleanupSourceFile(sourceFile);
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
handleUpsertError(err, id, context);
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
return command;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// src/commands/list-fields.ts
|
|
1485
|
+
import { Command as Command10 } from "commander";
|
|
1486
|
+
function stringifyValue(value) {
|
|
1487
|
+
if (value === null || value === void 0) return "";
|
|
1488
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1489
|
+
return String(value);
|
|
1490
|
+
}
|
|
1491
|
+
function formatRichValue(raw) {
|
|
1492
|
+
const md = htmlToMarkdown(raw);
|
|
1493
|
+
const lines = md.split("\n").filter((l) => l.trim() !== "");
|
|
1494
|
+
const preview = lines.slice(0, 5);
|
|
1495
|
+
const suffix = lines.length > 5 ? `
|
|
1496
|
+
\u2026 (${lines.length - 5} more lines)` : "";
|
|
1497
|
+
return preview.join("\n ") + suffix;
|
|
1498
|
+
}
|
|
1499
|
+
function formatFieldList(fields) {
|
|
1500
|
+
const entries = Object.entries(fields).sort(([a], [b]) => a.localeCompare(b));
|
|
1501
|
+
const maxKeyLen = Math.min(
|
|
1502
|
+
Math.max(...entries.map(([k]) => k.length)),
|
|
1503
|
+
50
|
|
1504
|
+
);
|
|
1505
|
+
return entries.map(([key, value]) => {
|
|
1506
|
+
const raw = stringifyValue(value);
|
|
1507
|
+
if (raw === "") return `${key.padEnd(maxKeyLen + 2)}(empty)`;
|
|
1508
|
+
if (typeof value === "string" && isHtml(value)) {
|
|
1509
|
+
const preview = formatRichValue(value);
|
|
1510
|
+
return `${key.padEnd(maxKeyLen + 2)}[rich text]
|
|
1511
|
+
${preview}`;
|
|
1512
|
+
}
|
|
1513
|
+
return `${key.padEnd(maxKeyLen + 2)}${raw}`;
|
|
1514
|
+
}).join("\n");
|
|
1515
|
+
}
|
|
1516
|
+
function createListFieldsCommand() {
|
|
1517
|
+
const command = new Command10("list-fields");
|
|
1518
|
+
command.description("List all fields of an Azure DevOps work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
1519
|
+
async (idStr, options) => {
|
|
1520
|
+
const id = parseWorkItemId(idStr);
|
|
1521
|
+
validateOrgProjectPair(options);
|
|
1522
|
+
let context;
|
|
1523
|
+
try {
|
|
1524
|
+
context = resolveContext(options);
|
|
1525
|
+
const credential = await resolvePat();
|
|
1526
|
+
const fields = await getWorkItemFields(context, id, credential.pat);
|
|
1527
|
+
if (options.json) {
|
|
1528
|
+
process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
|
|
1529
|
+
} else {
|
|
1530
|
+
process.stdout.write(`Work Item ${id} \u2014 ${Object.keys(fields).length} fields
|
|
1531
|
+
|
|
1532
|
+
`);
|
|
1533
|
+
process.stdout.write(formatFieldList(fields) + "\n");
|
|
1534
|
+
}
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
handleCommandError(err, id, context, "read");
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
);
|
|
1540
|
+
return command;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// src/commands/pr.ts
|
|
1544
|
+
import { Command as Command11 } from "commander";
|
|
1545
|
+
|
|
1546
|
+
// src/services/pr-client.ts
|
|
1547
|
+
function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
|
|
1548
|
+
const url = new URL(
|
|
1549
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
|
|
1550
|
+
);
|
|
1551
|
+
url.searchParams.set("api-version", "7.1");
|
|
1552
|
+
url.searchParams.set("searchCriteria.sourceRefName", `refs/heads/${sourceBranch}`);
|
|
1553
|
+
if (opts?.status) {
|
|
1554
|
+
url.searchParams.set("searchCriteria.status", opts.status);
|
|
1555
|
+
}
|
|
1556
|
+
if (opts?.targetBranch) {
|
|
1557
|
+
url.searchParams.set("searchCriteria.targetRefName", `refs/heads/${opts.targetBranch}`);
|
|
1558
|
+
}
|
|
1559
|
+
return url;
|
|
1560
|
+
}
|
|
1561
|
+
function mapPullRequest(repo, pullRequest) {
|
|
1562
|
+
return {
|
|
1563
|
+
id: pullRequest.pullRequestId,
|
|
1564
|
+
title: pullRequest.title,
|
|
1565
|
+
repository: repo,
|
|
1566
|
+
sourceRefName: pullRequest.sourceRefName,
|
|
1567
|
+
targetRefName: pullRequest.targetRefName,
|
|
1568
|
+
status: pullRequest.status,
|
|
1569
|
+
createdBy: pullRequest.createdBy?.displayName ?? null,
|
|
1570
|
+
url: pullRequest._links.web.href
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
function mapComment(comment) {
|
|
1574
|
+
const content = comment.content?.trim();
|
|
1575
|
+
if (comment.isDeleted || !content) {
|
|
1576
|
+
return null;
|
|
1577
|
+
}
|
|
1578
|
+
return {
|
|
1579
|
+
id: comment.id,
|
|
1580
|
+
author: comment.author?.displayName ?? null,
|
|
1581
|
+
content,
|
|
1582
|
+
publishedAt: comment.publishedDate ?? null
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
function mapThread(thread) {
|
|
1586
|
+
if (thread.status !== "active" && thread.status !== "pending") {
|
|
1587
|
+
return null;
|
|
1588
|
+
}
|
|
1589
|
+
const comments = thread.comments.map(mapComment).filter((comment) => comment !== null);
|
|
1590
|
+
if (comments.length === 0) {
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
return {
|
|
1594
|
+
id: thread.id,
|
|
1595
|
+
status: thread.status,
|
|
1596
|
+
threadContext: thread.threadContext?.filePath ?? null,
|
|
1597
|
+
comments
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
async function readJsonResponse(response) {
|
|
1601
|
+
if (!response.ok) {
|
|
1602
|
+
throw new Error(`HTTP_${response.status}`);
|
|
1603
|
+
}
|
|
1604
|
+
return response.json();
|
|
1605
|
+
}
|
|
1606
|
+
async function listPullRequests(context, repo, pat, sourceBranch, opts) {
|
|
1607
|
+
const response = await fetchWithErrors(
|
|
1608
|
+
buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
|
|
1609
|
+
{ headers: authHeaders(pat) }
|
|
1610
|
+
);
|
|
1611
|
+
const data = await readJsonResponse(response);
|
|
1612
|
+
return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
|
|
1613
|
+
}
|
|
1614
|
+
async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
|
|
1615
|
+
const existing = await listPullRequests(context, repo, pat, sourceBranch, {
|
|
1616
|
+
status: "active",
|
|
1617
|
+
targetBranch: "develop"
|
|
1618
|
+
});
|
|
1619
|
+
if (existing.length === 1) {
|
|
1620
|
+
return {
|
|
1621
|
+
branch: sourceBranch,
|
|
1622
|
+
targetBranch: "develop",
|
|
1623
|
+
created: false,
|
|
1624
|
+
pullRequest: existing[0]
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
if (existing.length > 1) {
|
|
1628
|
+
throw new Error(`AMBIGUOUS_PRS:${existing.map((pullRequest) => pullRequest.id).join(",")}`);
|
|
1629
|
+
}
|
|
1630
|
+
const payload = {
|
|
1631
|
+
sourceRefName: `refs/heads/${sourceBranch}`,
|
|
1632
|
+
targetRefName: "refs/heads/develop",
|
|
1633
|
+
title,
|
|
1634
|
+
description
|
|
1635
|
+
};
|
|
1636
|
+
const url = new URL(
|
|
1637
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
|
|
1638
|
+
);
|
|
1639
|
+
url.searchParams.set("api-version", "7.1");
|
|
1640
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
1641
|
+
method: "POST",
|
|
1642
|
+
headers: {
|
|
1643
|
+
...authHeaders(pat),
|
|
1644
|
+
"Content-Type": "application/json"
|
|
1645
|
+
},
|
|
1646
|
+
body: JSON.stringify(payload)
|
|
1647
|
+
});
|
|
1648
|
+
const data = await readJsonResponse(response);
|
|
1649
|
+
return {
|
|
1650
|
+
branch: sourceBranch,
|
|
1651
|
+
targetBranch: "develop",
|
|
1652
|
+
created: true,
|
|
1653
|
+
pullRequest: mapPullRequest(repo, data)
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
async function getPullRequestThreads(context, repo, pat, prId) {
|
|
1657
|
+
const url = new URL(
|
|
1658
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
|
|
1659
|
+
);
|
|
1660
|
+
url.searchParams.set("api-version", "7.1");
|
|
1661
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
1662
|
+
const data = await readJsonResponse(response);
|
|
1663
|
+
return data.value.map(mapThread).filter((thread) => thread !== null);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/commands/pr.ts
|
|
1667
|
+
function formatBranchName(refName) {
|
|
1668
|
+
return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
|
|
1669
|
+
}
|
|
1670
|
+
function writeError(message) {
|
|
1671
|
+
process.stderr.write(`Error: ${message}
|
|
1672
|
+
`);
|
|
1673
|
+
process.exit(1);
|
|
1674
|
+
}
|
|
1675
|
+
function handlePrCommandError(err, context, mode = "read") {
|
|
1676
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1677
|
+
if (error.message === "AUTH_FAILED") {
|
|
1678
|
+
const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
|
|
1679
|
+
writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`);
|
|
1680
|
+
}
|
|
1681
|
+
if (error.message === "PERMISSION_DENIED") {
|
|
1682
|
+
writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`);
|
|
1683
|
+
}
|
|
1684
|
+
if (error.message === "NETWORK_ERROR") {
|
|
1685
|
+
writeError("Could not connect to Azure DevOps. Check your network connection.");
|
|
1686
|
+
}
|
|
1687
|
+
if (error.message === "NOT_FOUND") {
|
|
1688
|
+
writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
|
|
1689
|
+
}
|
|
1690
|
+
if (error.message.startsWith("HTTP_")) {
|
|
1691
|
+
writeError(`Azure DevOps request failed with ${error.message}.`);
|
|
1692
|
+
}
|
|
1693
|
+
writeError(error.message);
|
|
1694
|
+
}
|
|
1695
|
+
function formatPullRequestBlock(pullRequest) {
|
|
1696
|
+
return [
|
|
1697
|
+
`#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
|
|
1698
|
+
`${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
|
|
1699
|
+
pullRequest.url
|
|
1700
|
+
].join("\n");
|
|
1701
|
+
}
|
|
1702
|
+
function formatThreads(prId, title, threads) {
|
|
1703
|
+
const lines = [`Active comments for pull request #${prId}: ${title}`];
|
|
1704
|
+
for (const thread of threads) {
|
|
1705
|
+
lines.push("", `Thread #${thread.id} [${thread.status}] ${thread.threadContext ?? "(general)"}`);
|
|
1706
|
+
for (const comment of thread.comments) {
|
|
1707
|
+
lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
return lines.join("\n");
|
|
1711
|
+
}
|
|
1712
|
+
async function resolvePrCommandContext(options) {
|
|
1713
|
+
const context = resolveContext(options);
|
|
1714
|
+
const repo = detectRepoName();
|
|
1715
|
+
const branch = getCurrentBranch();
|
|
1716
|
+
const credential = await resolvePat();
|
|
1717
|
+
return {
|
|
1718
|
+
context,
|
|
1719
|
+
repo,
|
|
1720
|
+
branch,
|
|
1721
|
+
pat: credential.pat
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
function createPrStatusCommand() {
|
|
1725
|
+
const command = new Command11("status");
|
|
1726
|
+
command.description("Check pull requests for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
|
|
1727
|
+
validateOrgProjectPair(options);
|
|
1728
|
+
let context;
|
|
1729
|
+
try {
|
|
1730
|
+
const resolved = await resolvePrCommandContext(options);
|
|
1731
|
+
context = resolved.context;
|
|
1732
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch);
|
|
1733
|
+
const { branch, repo } = resolved;
|
|
1734
|
+
const result = { branch, repository: repo, pullRequests };
|
|
1735
|
+
if (options.json) {
|
|
1736
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1737
|
+
`);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (pullRequests.length === 0) {
|
|
1741
|
+
process.stdout.write(`No pull requests found for branch ${branch}.
|
|
1742
|
+
`);
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
process.stdout.write(`${pullRequests.map(formatPullRequestBlock).join("\n\n")}
|
|
1746
|
+
`);
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
handlePrCommandError(err, context, "read");
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
return command;
|
|
1752
|
+
}
|
|
1753
|
+
function createPrOpenCommand() {
|
|
1754
|
+
const command = new Command11("open");
|
|
1755
|
+
command.description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option("--description <description>", "pull request description").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
|
|
1756
|
+
validateOrgProjectPair(options);
|
|
1757
|
+
const title = options.title?.trim();
|
|
1758
|
+
if (!title) {
|
|
1759
|
+
writeError("--title is required for pull request creation.");
|
|
1760
|
+
}
|
|
1761
|
+
const description = options.description?.trim();
|
|
1762
|
+
if (!description) {
|
|
1763
|
+
writeError("--description is required for pull request creation.");
|
|
1764
|
+
}
|
|
1765
|
+
let context;
|
|
1766
|
+
try {
|
|
1767
|
+
const resolved = await resolvePrCommandContext(options);
|
|
1768
|
+
context = resolved.context;
|
|
1769
|
+
if (resolved.branch === "develop") {
|
|
1770
|
+
writeError("Pull request creation requires a source branch other than develop.");
|
|
1771
|
+
}
|
|
1772
|
+
const result = await openPullRequest(
|
|
1773
|
+
resolved.context,
|
|
1774
|
+
resolved.repo,
|
|
1775
|
+
resolved.pat,
|
|
1776
|
+
resolved.branch,
|
|
1777
|
+
title,
|
|
1778
|
+
description
|
|
1779
|
+
);
|
|
1780
|
+
if (options.json) {
|
|
1781
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1782
|
+
`);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (result.created) {
|
|
1786
|
+
process.stdout.write(`Created pull request #${result.pullRequest.id}: ${result.pullRequest.title}
|
|
1787
|
+
${result.pullRequest.url}
|
|
1788
|
+
`);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
process.stdout.write(
|
|
1792
|
+
`Active pull request already exists for ${resolved.branch} -> develop: #${result.pullRequest.id}
|
|
1793
|
+
${result.pullRequest.url}
|
|
1794
|
+
`
|
|
1795
|
+
);
|
|
1796
|
+
} catch (err) {
|
|
1797
|
+
if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
|
|
1798
|
+
const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
|
|
1799
|
+
writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
|
|
1800
|
+
}
|
|
1801
|
+
handlePrCommandError(err, context, "write");
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
return command;
|
|
1805
|
+
}
|
|
1806
|
+
function createPrCommentsCommand() {
|
|
1807
|
+
const command = new Command11("comments");
|
|
1808
|
+
command.description("List active pull request comments for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
|
|
1809
|
+
validateOrgProjectPair(options);
|
|
1810
|
+
let context;
|
|
1811
|
+
try {
|
|
1812
|
+
const resolved = await resolvePrCommandContext(options);
|
|
1813
|
+
context = resolved.context;
|
|
1814
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
|
|
1815
|
+
status: "active"
|
|
1816
|
+
});
|
|
1817
|
+
if (pullRequests.length === 0) {
|
|
1818
|
+
writeError(`No active pull request found for branch ${resolved.branch}.`);
|
|
1819
|
+
}
|
|
1820
|
+
if (pullRequests.length > 1) {
|
|
1821
|
+
const ids = pullRequests.map((pullRequest2) => `#${pullRequest2.id}`).join(", ");
|
|
1822
|
+
writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
|
|
1823
|
+
}
|
|
1824
|
+
const pullRequest = pullRequests[0];
|
|
1825
|
+
const threads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
|
|
1826
|
+
const result = { branch: resolved.branch, pullRequest, threads };
|
|
1827
|
+
if (options.json) {
|
|
1828
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1829
|
+
`);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (threads.length === 0) {
|
|
1833
|
+
process.stdout.write(`Pull request #${pullRequest.id} has no active comments.
|
|
1834
|
+
`);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
process.stdout.write(`${formatThreads(pullRequest.id, pullRequest.title, threads)}
|
|
1838
|
+
`);
|
|
1839
|
+
} catch (err) {
|
|
1840
|
+
handlePrCommandError(err, context, "read");
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1843
|
+
return command;
|
|
1844
|
+
}
|
|
1845
|
+
function createPrCommand() {
|
|
1846
|
+
const command = new Command11("pr");
|
|
1847
|
+
command.description("Manage Azure DevOps pull requests");
|
|
1848
|
+
command.addCommand(createPrStatusCommand());
|
|
1849
|
+
command.addCommand(createPrOpenCommand());
|
|
1850
|
+
command.addCommand(createPrCommentsCommand());
|
|
1851
|
+
return command;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1050
1854
|
// src/index.ts
|
|
1051
|
-
var program = new
|
|
1855
|
+
var program = new Command12();
|
|
1052
1856
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
1053
1857
|
program.addCommand(createGetItemCommand());
|
|
1054
1858
|
program.addCommand(createClearPatCommand());
|
|
@@ -1058,6 +1862,9 @@ program.addCommand(createAssignCommand());
|
|
|
1058
1862
|
program.addCommand(createSetFieldCommand());
|
|
1059
1863
|
program.addCommand(createGetMdFieldCommand());
|
|
1060
1864
|
program.addCommand(createSetMdFieldCommand());
|
|
1865
|
+
program.addCommand(createUpsertCommand());
|
|
1866
|
+
program.addCommand(createListFieldsCommand());
|
|
1867
|
+
program.addCommand(createPrCommand());
|
|
1061
1868
|
program.showHelpAfterError();
|
|
1062
1869
|
program.parse();
|
|
1063
1870
|
if (process.argv.length <= 2) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azdo-cli",
|
|
3
|
-
"version": "0.2.0-feature-
|
|
3
|
+
"version": "0.2.0-feature-more-resilient-fields.105",
|
|
4
4
|
"description": "Azure DevOps CLI tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"lint": "eslint src/",
|
|
15
15
|
"typecheck": "tsc --noEmit",
|
|
16
16
|
"format": "prettier --check src/",
|
|
17
|
-
"test": "npm run build && vitest run"
|
|
17
|
+
"test": "npm run build && vitest run tests/unit",
|
|
18
|
+
"test:integration": "npm run build && vitest run tests/integration"
|
|
18
19
|
},
|
|
19
20
|
"repository": {
|
|
20
21
|
"type": "git",
|
|
@@ -28,12 +29,12 @@
|
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@eslint/js": "^10.0.1",
|
|
31
|
-
"@types/node": "^25.
|
|
32
|
-
"eslint": "^10.0
|
|
32
|
+
"@types/node": "^25.5.0",
|
|
33
|
+
"eslint": "^10.1.0",
|
|
33
34
|
"prettier": "^3.8.1",
|
|
34
35
|
"tsup": "^8.5.1",
|
|
35
36
|
"typescript": "^5.9.3",
|
|
36
|
-
"typescript-eslint": "^8.
|
|
37
|
-
"vitest": "^4.
|
|
37
|
+
"typescript-eslint": "^8.57.2",
|
|
38
|
+
"vitest": "^4.1.2"
|
|
38
39
|
}
|
|
39
40
|
}
|