azdo-cli 0.3.0 → 0.5.0-010-work-item-comments.149
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 +126 -10
- package/dist/index.js +742 -93
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -12,10 +12,13 @@ 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
|
|
15
|
+
- Create or update work items from markdown documents (`upsert`)
|
|
16
|
+
- Read and post work item comments (`comments`)
|
|
16
17
|
- Read rich-text fields as markdown (`get-md-field`)
|
|
17
18
|
- Set rich-text fields as markdown from inline text, file, or stdin (`set-md-field`)
|
|
19
|
+
- Check branch pull request status, open PRs to `develop`, and review active comments (`pr`)
|
|
18
20
|
- Persist org/project/default fields in local config (`config`)
|
|
21
|
+
- List all fields of a work item (`list-fields`)
|
|
19
22
|
- Store PAT in OS credential store (or use `AZDO_PAT`)
|
|
20
23
|
|
|
21
24
|
## Installation
|
|
@@ -24,6 +27,20 @@ Azure DevOps CLI focused on work item read/write workflows.
|
|
|
24
27
|
npm install -g azdo-cli
|
|
25
28
|
```
|
|
26
29
|
|
|
30
|
+
## Utility Scripts
|
|
31
|
+
|
|
32
|
+
The repository also includes a helper script for syncing local `.env` entries into GitHub Actions secrets for the current repository:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
./scripts/sync-env-to-gh-secrets.zsh
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
It walks upward from the current directory until it finds a `.env`, then sets each valid `KEY=VALUE` entry with `gh secret set`. You can also limit the sync to selected keys:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
./scripts/sync-env-to-gh-secrets.zsh FOO BAR
|
|
42
|
+
```
|
|
43
|
+
|
|
27
44
|
## Authentication and Context Resolution
|
|
28
45
|
|
|
29
46
|
PAT resolution order:
|
|
@@ -49,8 +66,12 @@ azdo get-item 12345
|
|
|
49
66
|
# 3) Update state
|
|
50
67
|
azdo set-state 12345 "Active"
|
|
51
68
|
|
|
52
|
-
# 4) Create or update a
|
|
53
|
-
azdo upsert --content $'---\nTitle: Improve markdown import UX\nState: New\n---'
|
|
69
|
+
# 4) Create or update a work item from markdown
|
|
70
|
+
azdo upsert --type "User Story" --content $'---\nTitle: Improve markdown import UX\nState: New\n---'
|
|
71
|
+
|
|
72
|
+
# 5) Review work item discussion and post an update
|
|
73
|
+
azdo comments list 12345
|
|
74
|
+
azdo comments add 12345 "Investigating the root cause now."
|
|
54
75
|
```
|
|
55
76
|
|
|
56
77
|
## Command Cheat Sheet
|
|
@@ -61,9 +82,12 @@ azdo upsert --content $'---\nTitle: Improve markdown import UX\nState: New\n---'
|
|
|
61
82
|
| `azdo set-state <id> <state>` | Change work item state | `--json`, `--org`, `--project` |
|
|
62
83
|
| `azdo assign <id> [name]` | Assign or unassign owner | `--unassign`, `--json`, `--org`, `--project` |
|
|
63
84
|
| `azdo set-field <id> <field> <value>` | Update any field | `--json`, `--org`, `--project` |
|
|
64
|
-
| `azdo upsert [id]` | Create or update a
|
|
85
|
+
| `azdo upsert [id]` | Create or update a work item from markdown | `--content`, `--file`, `--type`, `--json`, `--org`, `--project` |
|
|
86
|
+
| `azdo comments <subcommand>` | Read or add work item comments | `list`, `add`, `--json`, `--org`, `--project` |
|
|
65
87
|
| `azdo get-md-field <id> <field>` | Get field as markdown | `--org`, `--project` |
|
|
66
88
|
| `azdo set-md-field <id> <field> [content]` | Set markdown field | `--file`, `--json`, `--org`, `--project` |
|
|
89
|
+
| `azdo list-fields <id>` | List all fields of a work item | `--json`, `--org`, `--project` |
|
|
90
|
+
| `azdo pr <subcommand>` | Manage pull requests for the current branch | `status`, `open`, `comments`, `--json`, `--org`, `--project` |
|
|
67
91
|
| `azdo config <subcommand>` | Manage saved settings | `set`, `get`, `list`, `unset`, `wizard`, `--json` |
|
|
68
92
|
| `azdo clear-pat` | Remove stored PAT | none |
|
|
69
93
|
|
|
@@ -84,8 +108,6 @@ azdo get-item 12345 --fields "System.Tags,Microsoft.VSTS.Common.Priority"
|
|
|
84
108
|
# Convert rich text fields to markdown
|
|
85
109
|
azdo get-item 12345 --markdown
|
|
86
110
|
|
|
87
|
-
# Disable markdown even if config is on
|
|
88
|
-
azdo get-item 12345 --no-markdown
|
|
89
111
|
```
|
|
90
112
|
|
|
91
113
|
```bash
|
|
@@ -100,11 +122,21 @@ azdo assign 12345 --unassign
|
|
|
100
122
|
azdo set-field 12345 System.Title "Updated title"
|
|
101
123
|
```
|
|
102
124
|
|
|
125
|
+
### List Fields
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# List all fields with values (rich text fields preview first 5 lines)
|
|
129
|
+
azdo list-fields 12345
|
|
130
|
+
|
|
131
|
+
# JSON output
|
|
132
|
+
azdo list-fields 12345 --json
|
|
133
|
+
```
|
|
134
|
+
|
|
103
135
|
### Markdown Display
|
|
104
136
|
|
|
105
137
|
The `get-item` command can convert HTML rich-text fields to readable markdown. Resolution order:
|
|
106
138
|
|
|
107
|
-
1. `--markdown`
|
|
139
|
+
1. `--markdown` flag enables markdown for the current call
|
|
108
140
|
2. Config setting: `azdo config set markdown true`
|
|
109
141
|
3. Default: off (HTML stripped to plain text)
|
|
110
142
|
|
|
@@ -124,13 +156,85 @@ azdo set-md-field 12345 System.Description --file ./description.md
|
|
|
124
156
|
cat description.md | azdo set-md-field 12345 System.Description
|
|
125
157
|
```
|
|
126
158
|
|
|
159
|
+
### Pull Request Commands
|
|
160
|
+
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Check whether the current branch already has pull requests
|
|
165
|
+
azdo pr status
|
|
166
|
+
|
|
167
|
+
# Open a pull request to develop
|
|
168
|
+
azdo pr open --title "Add PR handling" --description "Implements pr status, pr open, pr comments commands"
|
|
169
|
+
|
|
170
|
+
# Review active comments for the current branch's active pull request
|
|
171
|
+
azdo pr comments
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`azdo pr status`
|
|
175
|
+
|
|
176
|
+
- Lists pull requests for the current branch
|
|
177
|
+
- Prints `No pull requests found for branch <branch>.` when no PRs exist
|
|
178
|
+
- Supports `--json` for machine-readable output
|
|
179
|
+
|
|
180
|
+
`azdo pr open`
|
|
181
|
+
|
|
182
|
+
- Requires both `--title <title>` and `--description <description>`
|
|
183
|
+
- Targets `develop` automatically
|
|
184
|
+
- Creates a new active pull request when none exists
|
|
185
|
+
- Reuses the existing active PR when one already matches the branch and target
|
|
186
|
+
- Fails with a clear error when run from `develop` or when multiple active PRs already exist
|
|
187
|
+
|
|
188
|
+
`azdo pr comments`
|
|
189
|
+
|
|
190
|
+
- Resolves the single active pull request for the current branch
|
|
191
|
+
- Returns only active or pending threads with visible, non-deleted comments
|
|
192
|
+
- Groups text output by thread and shows file context when available
|
|
193
|
+
- Prints `Pull request #<id> has no active comments.` when the PR has no active comment threads
|
|
194
|
+
- Fails instead of guessing when no active PR or multiple active PRs exist
|
|
195
|
+
|
|
196
|
+
### Work Item Comment Commands
|
|
197
|
+
|
|
198
|
+
The `comments` command group works on a specific work item ID. It requires a PAT with `Work Items (read)` scope for listing comments and `Work Items (Read & Write)` to add a new comment.
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# Read the visible comment history for a work item
|
|
202
|
+
azdo comments list 12345
|
|
203
|
+
|
|
204
|
+
# Read the same history as JSON
|
|
205
|
+
azdo comments list 12345 --json
|
|
206
|
+
|
|
207
|
+
# Post a progress update
|
|
208
|
+
azdo comments add 12345 "Investigation complete. Working on the fix next."
|
|
209
|
+
|
|
210
|
+
# Post the update and return JSON
|
|
211
|
+
azdo comments add 12345 "Queued validation run." --json
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`azdo comments list`
|
|
215
|
+
|
|
216
|
+
- Resolves the target work item directly from the provided ID
|
|
217
|
+
- Retrieves the full visible comment history and follows Azure DevOps pagination internally
|
|
218
|
+
- Prints comments newest first with comment ID, author, timestamp, and body text
|
|
219
|
+
- Prints `Work item #<id> has no comments.` when the work item has no visible comments
|
|
220
|
+
|
|
221
|
+
`azdo comments add`
|
|
222
|
+
|
|
223
|
+
- Requires a non-empty `<text>` positional argument
|
|
224
|
+
- Preserves the supplied comment text as the submitted body
|
|
225
|
+
- Prints `Added comment #<commentId> to work item #<id>` on success
|
|
226
|
+
- Fails locally before any API call when the text is empty or whitespace-only
|
|
227
|
+
|
|
127
228
|
## azdo upsert
|
|
128
229
|
|
|
129
|
-
`azdo upsert` accepts a single markdown
|
|
230
|
+
`azdo upsert` accepts a single markdown work-item document and either creates a new Azure DevOps work item or updates an existing one. Omit `[id]` to create; pass `[id]` to update that work item in place. Create mode defaults to `Task`, and `--type <work item type>` lets you create `Bug`, `User Story`, `Feature`, `Epic`, and other Azure DevOps work item types.
|
|
130
231
|
|
|
131
232
|
```bash
|
|
132
|
-
# Create from inline content
|
|
133
|
-
azdo upsert --content $'---\nTitle: Improve markdown import UX\nAssigned To: user@example.com\nState: New\n---'
|
|
233
|
+
# Create a Bug from inline content
|
|
234
|
+
azdo upsert --type Bug --content $'---\nTitle: Improve markdown import UX\nAssigned To: user@example.com\nState: New\n---'
|
|
235
|
+
|
|
236
|
+
# Preserve the default Task create behavior
|
|
237
|
+
azdo upsert --content $'---\nTitle: Follow-up task\nAssigned To: user@example.com\nState: New\n---'
|
|
134
238
|
|
|
135
239
|
# Update from a file
|
|
136
240
|
azdo upsert 12345 --file ./task-import.md
|
|
@@ -143,9 +247,16 @@ The command requires exactly one source flag:
|
|
|
143
247
|
|
|
144
248
|
- `azdo upsert [id] --content <markdown>`
|
|
145
249
|
- `azdo upsert [id] --file <path>`
|
|
250
|
+
- `azdo upsert --type <work-item-type> --content <markdown>`
|
|
146
251
|
|
|
147
252
|
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.
|
|
148
253
|
|
|
254
|
+
Type rules:
|
|
255
|
+
|
|
256
|
+
- `--type` is optional for create and defaults to `Task`.
|
|
257
|
+
- `--type` is only valid when creating a new work item.
|
|
258
|
+
- Human-readable and JSON success output include the resulting work item type.
|
|
259
|
+
|
|
149
260
|
### Task Document Format
|
|
150
261
|
|
|
151
262
|
The document starts with YAML front matter for scalar fields, followed by optional `##` heading sections for markdown rich-text fields.
|
|
@@ -194,6 +305,7 @@ Clear semantics:
|
|
|
194
305
|
{
|
|
195
306
|
"action": "created",
|
|
196
307
|
"id": 12345,
|
|
308
|
+
"workItemType": "User Story",
|
|
197
309
|
"fields": {
|
|
198
310
|
"System.Title": "Improve markdown import UX",
|
|
199
311
|
"System.Description": "Implement a single-command task import flow."
|
|
@@ -232,10 +344,14 @@ azdo clear-pat
|
|
|
232
344
|
## JSON Output
|
|
233
345
|
|
|
234
346
|
These commands support `--json` for machine-readable output:
|
|
347
|
+
- `list-fields`
|
|
235
348
|
- `set-state`
|
|
236
349
|
- `assign`
|
|
237
350
|
- `set-field`
|
|
238
351
|
- `set-md-field`
|
|
352
|
+
- `upsert`
|
|
353
|
+
- `comments list|add`
|
|
354
|
+
- `pr status|open|comments`
|
|
239
355
|
- `config set|get|list|unset`
|
|
240
356
|
|
|
241
357
|
## Development
|
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 Command13 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
7
|
import { readFileSync } from "fs";
|
|
@@ -39,7 +39,15 @@ async function fetchWithErrors(url, init) {
|
|
|
39
39
|
}
|
|
40
40
|
if (response.status === 401) throw new Error("AUTH_FAILED");
|
|
41
41
|
if (response.status === 403) throw new Error("PERMISSION_DENIED");
|
|
42
|
-
if (response.status === 404)
|
|
42
|
+
if (response.status === 404) {
|
|
43
|
+
let detail = "";
|
|
44
|
+
try {
|
|
45
|
+
const body = await response.text();
|
|
46
|
+
detail = ` | url=${url} | body=${body}`;
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`NOT_FOUND${detail}`);
|
|
50
|
+
}
|
|
43
51
|
return response;
|
|
44
52
|
}
|
|
45
53
|
async function readResponseMessage(response) {
|
|
@@ -55,12 +63,29 @@ async function readResponseMessage(response) {
|
|
|
55
63
|
function normalizeFieldList(fields) {
|
|
56
64
|
return Array.from(new Set(fields.map((f) => f.trim()).filter((f) => f.length > 0)));
|
|
57
65
|
}
|
|
66
|
+
function stringifyFieldValue(value) {
|
|
67
|
+
if (typeof value === "object" && value !== null) {
|
|
68
|
+
return JSON.stringify(value);
|
|
69
|
+
}
|
|
70
|
+
return String(value);
|
|
71
|
+
}
|
|
58
72
|
function buildExtraFields(fields, requested) {
|
|
59
73
|
const result = {};
|
|
60
74
|
for (const name of requested) {
|
|
61
|
-
|
|
75
|
+
let val = fields[name];
|
|
76
|
+
let resolvedName = name;
|
|
77
|
+
if (val === void 0) {
|
|
78
|
+
const nameSuffix = name.split(".").pop().toLowerCase();
|
|
79
|
+
const match = Object.keys(fields).find(
|
|
80
|
+
(k) => k.split(".").pop().toLowerCase() === nameSuffix
|
|
81
|
+
);
|
|
82
|
+
if (match !== void 0) {
|
|
83
|
+
val = fields[match];
|
|
84
|
+
resolvedName = match;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
62
87
|
if (val !== void 0 && val !== null) {
|
|
63
|
-
result[
|
|
88
|
+
result[resolvedName] = stringifyFieldValue(val);
|
|
64
89
|
}
|
|
65
90
|
}
|
|
66
91
|
return Object.keys(result).length > 0 ? result : null;
|
|
@@ -71,6 +96,42 @@ function writeHeaders(pat) {
|
|
|
71
96
|
"Content-Type": "application/json-patch+json"
|
|
72
97
|
};
|
|
73
98
|
}
|
|
99
|
+
function buildWorkItemCommentsListUrl(context, id, continuationToken) {
|
|
100
|
+
const url = new URL(
|
|
101
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workItems/${id}/comments`
|
|
102
|
+
);
|
|
103
|
+
url.searchParams.set("api-version", "7.1-preview.4");
|
|
104
|
+
url.searchParams.set("order", "desc");
|
|
105
|
+
if (continuationToken) {
|
|
106
|
+
url.searchParams.set("continuationToken", continuationToken);
|
|
107
|
+
}
|
|
108
|
+
return url;
|
|
109
|
+
}
|
|
110
|
+
function buildWorkItemCommentsUrl(context, id) {
|
|
111
|
+
const url = new URL(
|
|
112
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workItems/${id}/comments`
|
|
113
|
+
);
|
|
114
|
+
url.searchParams.set("api-version", "7.1-preview.4");
|
|
115
|
+
return url;
|
|
116
|
+
}
|
|
117
|
+
function mapWorkItemComment(comment, fallbackWorkItemId) {
|
|
118
|
+
return {
|
|
119
|
+
id: comment.id ?? comment.commentId ?? 0,
|
|
120
|
+
workItemId: comment.workItemId ?? fallbackWorkItemId,
|
|
121
|
+
text: typeof comment.text === "string" ? comment.text : "",
|
|
122
|
+
author: comment.createdBy?.displayName ?? null,
|
|
123
|
+
createdAt: comment.createdDate ?? null,
|
|
124
|
+
modifiedAt: comment.modifiedDate ?? null,
|
|
125
|
+
isDeleted: comment.isDeleted === true
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function readContinuationToken(response, data) {
|
|
129
|
+
if (typeof data.continuationToken === "string" && data.continuationToken.trim() !== "") {
|
|
130
|
+
return data.continuationToken;
|
|
131
|
+
}
|
|
132
|
+
const headerToken = response.headers?.get("x-ms-continuationtoken") ?? response.headers?.get("continuationtoken") ?? null;
|
|
133
|
+
return headerToken && headerToken.trim() !== "" ? headerToken : null;
|
|
134
|
+
}
|
|
74
135
|
async function readWriteResponse(response, errorCode) {
|
|
75
136
|
if (response.status === 400) {
|
|
76
137
|
const serverMessage = await readResponseMessage(response) ?? "Unknown error";
|
|
@@ -86,6 +147,25 @@ async function readWriteResponse(response, errorCode) {
|
|
|
86
147
|
fields: data.fields
|
|
87
148
|
};
|
|
88
149
|
}
|
|
150
|
+
async function getWorkItemFields(context, id, pat) {
|
|
151
|
+
const url = new URL(
|
|
152
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
153
|
+
);
|
|
154
|
+
url.searchParams.set("api-version", "7.1");
|
|
155
|
+
url.searchParams.set("$expand", "all");
|
|
156
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
157
|
+
if (response.status === 400) {
|
|
158
|
+
const serverMessage = await readResponseMessage(response);
|
|
159
|
+
if (serverMessage) {
|
|
160
|
+
throw new Error(`BAD_REQUEST: ${serverMessage}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
throw new Error(`HTTP_${response.status}`);
|
|
165
|
+
}
|
|
166
|
+
const data = await response.json();
|
|
167
|
+
return data.fields;
|
|
168
|
+
}
|
|
89
169
|
async function getWorkItem(context, id, pat, extraFields) {
|
|
90
170
|
const url = new URL(
|
|
91
171
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
@@ -119,7 +199,7 @@ async function getWorkItem(context, id, pat, extraFields) {
|
|
|
119
199
|
}
|
|
120
200
|
let combinedDescription = null;
|
|
121
201
|
if (descriptionParts.length === 1) {
|
|
122
|
-
combinedDescription = descriptionParts
|
|
202
|
+
combinedDescription = descriptionParts.at(0)?.value ?? null;
|
|
123
203
|
} else if (descriptionParts.length > 1) {
|
|
124
204
|
combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
|
|
125
205
|
}
|
|
@@ -158,13 +238,62 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
|
|
|
158
238
|
if (value === void 0 || value === null || value === "") {
|
|
159
239
|
return null;
|
|
160
240
|
}
|
|
161
|
-
return
|
|
241
|
+
return stringifyFieldValue(value);
|
|
242
|
+
}
|
|
243
|
+
async function listWorkItemComments(context, id, pat) {
|
|
244
|
+
const comments = [];
|
|
245
|
+
let continuationToken = null;
|
|
246
|
+
do {
|
|
247
|
+
const response = await fetchWithErrors(
|
|
248
|
+
buildWorkItemCommentsListUrl(context, id, continuationToken ?? void 0).toString(),
|
|
249
|
+
{ headers: authHeaders(pat) }
|
|
250
|
+
);
|
|
251
|
+
if (!response.ok) {
|
|
252
|
+
throw new Error(`HTTP_${response.status}`);
|
|
253
|
+
}
|
|
254
|
+
const data = await response.json();
|
|
255
|
+
comments.push(
|
|
256
|
+
...(data.comments ?? []).map((comment) => mapWorkItemComment(comment, id)).filter((comment) => !comment.isDeleted)
|
|
257
|
+
);
|
|
258
|
+
continuationToken = readContinuationToken(response, data);
|
|
259
|
+
} while (continuationToken !== null);
|
|
260
|
+
return {
|
|
261
|
+
workItemId: id,
|
|
262
|
+
count: comments.length,
|
|
263
|
+
comments
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
async function addWorkItemComment(context, id, pat, text) {
|
|
267
|
+
const response = await fetchWithErrors(buildWorkItemCommentsUrl(context, id).toString(), {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: {
|
|
270
|
+
...authHeaders(pat),
|
|
271
|
+
"Content-Type": "application/json"
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify({ text })
|
|
274
|
+
});
|
|
275
|
+
if (response.status === 400) {
|
|
276
|
+
const serverMessage = await readResponseMessage(response) ?? "Unknown error";
|
|
277
|
+
throw new Error(`BAD_REQUEST: ${serverMessage}`);
|
|
278
|
+
}
|
|
279
|
+
if (!response.ok) {
|
|
280
|
+
throw new Error(`HTTP_${response.status}`);
|
|
281
|
+
}
|
|
282
|
+
const data = await response.json();
|
|
283
|
+
return {
|
|
284
|
+
workItemId: data.workItemId ?? id,
|
|
285
|
+
commentId: data.commentId ?? data.id ?? 0,
|
|
286
|
+
text: typeof data.text === "string" ? data.text : text,
|
|
287
|
+
author: data.createdBy?.displayName ?? null,
|
|
288
|
+
createdAt: data.createdDate ?? null,
|
|
289
|
+
url: data.url ?? null
|
|
290
|
+
};
|
|
162
291
|
}
|
|
163
292
|
async function updateWorkItem(context, id, pat, fieldName, operations) {
|
|
164
293
|
const result = await applyWorkItemPatch(context, id, pat, operations);
|
|
165
294
|
const title = result.fields["System.Title"];
|
|
166
|
-
const lastOp = operations
|
|
167
|
-
const fieldValue = lastOp
|
|
295
|
+
const lastOp = operations.at(-1);
|
|
296
|
+
const fieldValue = lastOp?.value ?? null;
|
|
168
297
|
return {
|
|
169
298
|
id: result.id,
|
|
170
299
|
rev: result.rev,
|
|
@@ -301,19 +430,19 @@ async function resolvePat() {
|
|
|
301
430
|
import { execSync } from "child_process";
|
|
302
431
|
var patterns = [
|
|
303
432
|
// HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
|
|
304
|
-
/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git
|
|
433
|
+
/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)$/,
|
|
305
434
|
// HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
|
|
306
|
-
/^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git
|
|
435
|
+
/^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+)$/,
|
|
307
436
|
// HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
|
|
308
|
-
/^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git
|
|
437
|
+
/^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)$/,
|
|
309
438
|
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
|
|
310
|
-
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)
|
|
439
|
+
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/,
|
|
311
440
|
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
|
|
312
|
-
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)
|
|
441
|
+
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/
|
|
313
442
|
];
|
|
314
443
|
function parseAzdoRemote(url) {
|
|
315
444
|
for (const pattern of patterns) {
|
|
316
|
-
const match =
|
|
445
|
+
const match = pattern.exec(url);
|
|
317
446
|
if (match) {
|
|
318
447
|
const project = match[2];
|
|
319
448
|
if (/^DefaultCollection$/i.test(project)) {
|
|
@@ -337,6 +466,35 @@ function detectAzdoContext() {
|
|
|
337
466
|
}
|
|
338
467
|
return context;
|
|
339
468
|
}
|
|
469
|
+
function parseRepoName(url) {
|
|
470
|
+
for (const pattern of patterns) {
|
|
471
|
+
const match = pattern.exec(url);
|
|
472
|
+
if (match) {
|
|
473
|
+
return match[3];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
function detectRepoName() {
|
|
479
|
+
let remoteUrl;
|
|
480
|
+
try {
|
|
481
|
+
remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
|
|
482
|
+
} catch {
|
|
483
|
+
throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
|
|
484
|
+
}
|
|
485
|
+
const repo = parseRepoName(remoteUrl);
|
|
486
|
+
if (!repo) {
|
|
487
|
+
throw new Error('Git remote "origin" is not an Azure DevOps URL. Check that origin points to Azure DevOps and try again.');
|
|
488
|
+
}
|
|
489
|
+
return repo;
|
|
490
|
+
}
|
|
491
|
+
function getCurrentBranch() {
|
|
492
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
|
|
493
|
+
if (branch === "HEAD") {
|
|
494
|
+
throw new Error("Not on a named branch. Check out a named branch and try again.");
|
|
495
|
+
}
|
|
496
|
+
return branch;
|
|
497
|
+
}
|
|
340
498
|
|
|
341
499
|
// src/services/config-store.ts
|
|
342
500
|
import fs from "fs";
|
|
@@ -568,7 +726,7 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
|
|
|
568
726
|
`Error: Access denied. Your PAT may lack ${scope} permissions for project "${context?.project}".
|
|
569
727
|
`
|
|
570
728
|
);
|
|
571
|
-
} else if (msg
|
|
729
|
+
} else if (msg.startsWith("NOT_FOUND")) {
|
|
572
730
|
process.stderr.write(
|
|
573
731
|
`Error: Work item ${id} not found in ${context?.org}/${context?.project}.
|
|
574
732
|
`
|
|
@@ -609,18 +767,18 @@ function parseRequestedFields(raw) {
|
|
|
609
767
|
}
|
|
610
768
|
function stripHtml(html) {
|
|
611
769
|
let text = html;
|
|
612
|
-
text = text.
|
|
613
|
-
text = text.
|
|
614
|
-
text = text.
|
|
615
|
-
text = text.
|
|
616
|
-
text = text.
|
|
617
|
-
text = text.
|
|
618
|
-
text = text.
|
|
619
|
-
text = text.
|
|
620
|
-
text = text.
|
|
621
|
-
text = text.
|
|
622
|
-
text = text.
|
|
623
|
-
text = text.
|
|
770
|
+
text = text.replaceAll(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
|
|
771
|
+
text = text.replaceAll(/<br\s*\/?>/gi, "\n");
|
|
772
|
+
text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
|
|
773
|
+
text = text.replaceAll(/<li>/gi, "\n");
|
|
774
|
+
text = text.replaceAll(/<[^>]*>/g, "");
|
|
775
|
+
text = text.replaceAll("&", "&");
|
|
776
|
+
text = text.replaceAll("<", "<");
|
|
777
|
+
text = text.replaceAll(">", ">");
|
|
778
|
+
text = text.replaceAll(""", '"');
|
|
779
|
+
text = text.replaceAll("'", "'");
|
|
780
|
+
text = text.replaceAll(" ", " ");
|
|
781
|
+
text = text.replaceAll(/\n{3,}/g, "\n\n");
|
|
624
782
|
return text.trim();
|
|
625
783
|
}
|
|
626
784
|
function convertRichText(html, markdown) {
|
|
@@ -641,16 +799,19 @@ function summarizeDescription(text, label) {
|
|
|
641
799
|
return [`${label("Description:")}${firstThree.join("\n")}${suffix}`];
|
|
642
800
|
}
|
|
643
801
|
function formatWorkItem(workItem, short, markdown = false) {
|
|
644
|
-
const lines = [];
|
|
645
802
|
const label = (name) => name.padEnd(13);
|
|
646
|
-
lines
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
803
|
+
const lines = [
|
|
804
|
+
`${label("ID:")}${workItem.id}`,
|
|
805
|
+
`${label("Type:")}${workItem.type}`,
|
|
806
|
+
`${label("Title:")}${workItem.title}`,
|
|
807
|
+
`${label("State:")}${workItem.state}`,
|
|
808
|
+
`${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`
|
|
809
|
+
];
|
|
651
810
|
if (!short) {
|
|
652
|
-
lines.push(
|
|
653
|
-
|
|
811
|
+
lines.push(
|
|
812
|
+
`${label("Area:")}${workItem.areaPath}`,
|
|
813
|
+
`${label("Iteration:")}${workItem.iterationPath}`
|
|
814
|
+
);
|
|
654
815
|
}
|
|
655
816
|
lines.push(`${label("URL:")}${workItem.url}`);
|
|
656
817
|
if (workItem.extraFields) {
|
|
@@ -661,8 +822,7 @@ function formatWorkItem(workItem, short, markdown = false) {
|
|
|
661
822
|
if (short) {
|
|
662
823
|
lines.push(...summarizeDescription(descriptionText, label));
|
|
663
824
|
} else {
|
|
664
|
-
lines.push("Description:");
|
|
665
|
-
lines.push(descriptionText);
|
|
825
|
+
lines.push("Description:", descriptionText);
|
|
666
826
|
}
|
|
667
827
|
return lines.join("\n");
|
|
668
828
|
}
|
|
@@ -676,7 +836,7 @@ function createGetItemCommand() {
|
|
|
676
836
|
try {
|
|
677
837
|
context = resolveContext(options);
|
|
678
838
|
const credential = await resolvePat();
|
|
679
|
-
const fieldsList = options.fields
|
|
839
|
+
const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
|
|
680
840
|
const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
|
|
681
841
|
const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
|
|
682
842
|
const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
|
|
@@ -707,6 +867,63 @@ function createClearPatCommand() {
|
|
|
707
867
|
// src/commands/config.ts
|
|
708
868
|
import { Command as Command3 } from "commander";
|
|
709
869
|
import { createInterface as createInterface2 } from "readline";
|
|
870
|
+
function formatConfigValue(value, unsetFallback = "") {
|
|
871
|
+
if (value === void 0) {
|
|
872
|
+
return unsetFallback;
|
|
873
|
+
}
|
|
874
|
+
return Array.isArray(value) ? value.join(",") : value;
|
|
875
|
+
}
|
|
876
|
+
function writeConfigList(cfg) {
|
|
877
|
+
const keyWidth = 10;
|
|
878
|
+
const valueWidth = 30;
|
|
879
|
+
for (const setting of SETTINGS) {
|
|
880
|
+
const raw = cfg[setting.key];
|
|
881
|
+
const value = formatConfigValue(raw, "(not set)");
|
|
882
|
+
const marker = raw === void 0 && setting.required ? " *" : "";
|
|
883
|
+
process.stdout.write(
|
|
884
|
+
`${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
885
|
+
`
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
|
|
889
|
+
if (hasUnset) {
|
|
890
|
+
process.stdout.write(
|
|
891
|
+
'\n* = required but not configured. Run "azdo config wizard" to set up.\n'
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function createAsk(rl) {
|
|
896
|
+
return (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
|
|
897
|
+
}
|
|
898
|
+
async function promptForSetting(cfg, setting, ask) {
|
|
899
|
+
const currentDisplay = String(formatConfigValue(cfg[setting.key], ""));
|
|
900
|
+
const requiredTag = setting.required ? " (required)" : " (optional)";
|
|
901
|
+
process.stderr.write(`${setting.description}${requiredTag}
|
|
902
|
+
`);
|
|
903
|
+
if (setting.example) {
|
|
904
|
+
process.stderr.write(` Example: ${setting.example}
|
|
905
|
+
`);
|
|
906
|
+
}
|
|
907
|
+
const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
|
|
908
|
+
const answer = await ask(` ${setting.key}${defaultHint}: `);
|
|
909
|
+
const trimmed = answer.trim();
|
|
910
|
+
if (trimmed) {
|
|
911
|
+
setConfigValue(setting.key, trimmed);
|
|
912
|
+
process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
|
|
913
|
+
|
|
914
|
+
`);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (currentDisplay) {
|
|
918
|
+
process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
|
|
919
|
+
|
|
920
|
+
`);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
process.stderr.write(` -> Skipped "${setting.key}"
|
|
924
|
+
|
|
925
|
+
`);
|
|
926
|
+
}
|
|
710
927
|
function createConfigCommand() {
|
|
711
928
|
const config = new Command3("config");
|
|
712
929
|
config.description("Manage CLI settings");
|
|
@@ -759,27 +976,9 @@ function createConfigCommand() {
|
|
|
759
976
|
const cfg = loadConfig();
|
|
760
977
|
if (options.json) {
|
|
761
978
|
process.stdout.write(JSON.stringify(cfg) + "\n");
|
|
762
|
-
|
|
763
|
-
const keyWidth = 10;
|
|
764
|
-
const valueWidth = 30;
|
|
765
|
-
for (const setting of SETTINGS) {
|
|
766
|
-
const raw = cfg[setting.key];
|
|
767
|
-
const value = raw === void 0 ? "(not set)" : Array.isArray(raw) ? raw.join(",") : raw;
|
|
768
|
-
const marker = raw === void 0 && setting.required ? " *" : "";
|
|
769
|
-
process.stdout.write(
|
|
770
|
-
`${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
771
|
-
`
|
|
772
|
-
);
|
|
773
|
-
}
|
|
774
|
-
const hasUnset = SETTINGS.some(
|
|
775
|
-
(s) => s.required && cfg[s.key] === void 0
|
|
776
|
-
);
|
|
777
|
-
if (hasUnset) {
|
|
778
|
-
process.stdout.write(
|
|
779
|
-
'\n* = required but not configured. Run "azdo config wizard" to set up.\n'
|
|
780
|
-
);
|
|
781
|
-
}
|
|
979
|
+
return;
|
|
782
980
|
}
|
|
981
|
+
writeConfigList(cfg);
|
|
783
982
|
});
|
|
784
983
|
const unset = new Command3("unset");
|
|
785
984
|
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
@@ -811,36 +1010,11 @@ function createConfigCommand() {
|
|
|
811
1010
|
input: process.stdin,
|
|
812
1011
|
output: process.stderr
|
|
813
1012
|
});
|
|
814
|
-
const ask = (
|
|
1013
|
+
const ask = createAsk(rl);
|
|
815
1014
|
process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
|
|
816
1015
|
process.stderr.write("=======================================\n\n");
|
|
817
1016
|
for (const setting of SETTINGS) {
|
|
818
|
-
|
|
819
|
-
const currentDisplay = current === void 0 ? "" : Array.isArray(current) ? current.join(",") : current;
|
|
820
|
-
const requiredTag = setting.required ? " (required)" : " (optional)";
|
|
821
|
-
process.stderr.write(`${setting.description}${requiredTag}
|
|
822
|
-
`);
|
|
823
|
-
if (setting.example) {
|
|
824
|
-
process.stderr.write(` Example: ${setting.example}
|
|
825
|
-
`);
|
|
826
|
-
}
|
|
827
|
-
const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
|
|
828
|
-
const answer = await ask(` ${setting.key}${defaultHint}: `);
|
|
829
|
-
const trimmed = answer.trim();
|
|
830
|
-
if (trimmed) {
|
|
831
|
-
setConfigValue(setting.key, trimmed);
|
|
832
|
-
process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
|
|
833
|
-
|
|
834
|
-
`);
|
|
835
|
-
} else if (currentDisplay) {
|
|
836
|
-
process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
|
|
837
|
-
|
|
838
|
-
`);
|
|
839
|
-
} else {
|
|
840
|
-
process.stderr.write(` -> Skipped "${setting.key}"
|
|
841
|
-
|
|
842
|
-
`);
|
|
843
|
-
}
|
|
1017
|
+
await promptForSetting(cfg, setting, ask);
|
|
844
1018
|
}
|
|
845
1019
|
rl.close();
|
|
846
1020
|
process.stderr.write("Configuration complete!\n");
|
|
@@ -1302,7 +1476,7 @@ function buildAppliedFields(fields) {
|
|
|
1302
1476
|
function ensureTitleForCreate(fields) {
|
|
1303
1477
|
const titleField = fields.find((field) => field.refName === "System.Title");
|
|
1304
1478
|
if (!titleField || titleField.op === "clear" || titleField.value === null || titleField.value.trim() === "") {
|
|
1305
|
-
fail2("Title is required when creating a
|
|
1479
|
+
fail2("Title is required when creating a work item.");
|
|
1306
1480
|
}
|
|
1307
1481
|
}
|
|
1308
1482
|
function writeSuccess(result, options) {
|
|
@@ -1314,7 +1488,7 @@ function writeSuccess(result, options) {
|
|
|
1314
1488
|
const verb = result.action === "created" ? "Created" : "Updated";
|
|
1315
1489
|
const fields = Object.keys(result.fields).join(", ");
|
|
1316
1490
|
const suffix = fields ? ` (${fields})` : "";
|
|
1317
|
-
process.stdout.write(`${verb}
|
|
1491
|
+
process.stdout.write(`${verb} ${result.workItemType} #${result.id}${suffix}
|
|
1318
1492
|
`);
|
|
1319
1493
|
}
|
|
1320
1494
|
function cleanupSourceFile(sourceFile) {
|
|
@@ -1328,16 +1502,31 @@ function cleanupSourceFile(sourceFile) {
|
|
|
1328
1502
|
`);
|
|
1329
1503
|
}
|
|
1330
1504
|
}
|
|
1331
|
-
function
|
|
1505
|
+
function resolveCreateType(id, options) {
|
|
1506
|
+
if (options.type === void 0) {
|
|
1507
|
+
return "Task";
|
|
1508
|
+
}
|
|
1509
|
+
if (id !== void 0) {
|
|
1510
|
+
fail2("--type can only be used when creating a work item.");
|
|
1511
|
+
}
|
|
1512
|
+
const trimmedType = options.type.trim();
|
|
1513
|
+
if (trimmedType === "") {
|
|
1514
|
+
fail2("--type must be a non-empty work item type.");
|
|
1515
|
+
}
|
|
1516
|
+
return trimmedType;
|
|
1517
|
+
}
|
|
1518
|
+
function buildUpsertResult(action, writeResult, fields, fallbackWorkItemType) {
|
|
1332
1519
|
const appliedFields = buildAppliedFields(fields);
|
|
1520
|
+
const workItemType = writeResult.fields["System.WorkItemType"];
|
|
1333
1521
|
return {
|
|
1334
1522
|
action,
|
|
1335
1523
|
id: writeResult.id,
|
|
1524
|
+
workItemType: typeof workItemType === "string" && workItemType.trim() !== "" ? workItemType : fallbackWorkItemType,
|
|
1336
1525
|
fields: appliedFields
|
|
1337
1526
|
};
|
|
1338
1527
|
}
|
|
1339
1528
|
function isUpdateWriteError(err) {
|
|
1340
|
-
return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message
|
|
1529
|
+
return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message.startsWith("NOT_FOUND") || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("UPDATE_REJECTED:");
|
|
1341
1530
|
}
|
|
1342
1531
|
function isCreateWriteError(err) {
|
|
1343
1532
|
return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("HTTP_");
|
|
@@ -1367,10 +1556,11 @@ function handleUpsertError(err, id, context) {
|
|
|
1367
1556
|
}
|
|
1368
1557
|
function createUpsertCommand() {
|
|
1369
1558
|
const command = new Command9("upsert");
|
|
1370
|
-
command.description("Create or update a
|
|
1559
|
+
command.description("Create or update a work item from a markdown document").argument("[id]", "work item ID to update; omit to create a new work item").option("--content <markdown>", "task document content").option("--file <path>", "read task document from file").option("--type <workItemType>", "create mode work item type (defaults to Task)").option("--json", "output result as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(async (idStr, options) => {
|
|
1371
1560
|
validateOrgProjectPair(options);
|
|
1372
1561
|
const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
|
|
1373
1562
|
const { content, sourceFile } = loadSourceContent(options);
|
|
1563
|
+
const createType = resolveCreateType(id, options);
|
|
1374
1564
|
let context;
|
|
1375
1565
|
try {
|
|
1376
1566
|
context = resolveContext(options);
|
|
@@ -1383,14 +1573,19 @@ function createUpsertCommand() {
|
|
|
1383
1573
|
const credential = await resolvePat();
|
|
1384
1574
|
let writeResult;
|
|
1385
1575
|
if (action === "created") {
|
|
1386
|
-
writeResult = await createWorkItem(context,
|
|
1576
|
+
writeResult = await createWorkItem(context, createType, credential.pat, operations);
|
|
1387
1577
|
} else {
|
|
1388
1578
|
if (id === void 0) {
|
|
1389
1579
|
fail2("Work item ID is required for updates.");
|
|
1390
1580
|
}
|
|
1391
1581
|
writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
|
|
1392
1582
|
}
|
|
1393
|
-
const result = buildUpsertResult(
|
|
1583
|
+
const result = buildUpsertResult(
|
|
1584
|
+
action,
|
|
1585
|
+
writeResult,
|
|
1586
|
+
document.fields,
|
|
1587
|
+
action === "created" ? createType : "Work item"
|
|
1588
|
+
);
|
|
1394
1589
|
writeSuccess(result, options);
|
|
1395
1590
|
cleanupSourceFile(sourceFile);
|
|
1396
1591
|
} catch (err) {
|
|
@@ -1400,8 +1595,459 @@ function createUpsertCommand() {
|
|
|
1400
1595
|
return command;
|
|
1401
1596
|
}
|
|
1402
1597
|
|
|
1598
|
+
// src/commands/list-fields.ts
|
|
1599
|
+
import { Command as Command10 } from "commander";
|
|
1600
|
+
function stringifyValue(value) {
|
|
1601
|
+
if (value === null || value === void 0) return "";
|
|
1602
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1603
|
+
return String(value);
|
|
1604
|
+
}
|
|
1605
|
+
function formatRichValue(raw) {
|
|
1606
|
+
const md = htmlToMarkdown(raw);
|
|
1607
|
+
const lines = md.split("\n").filter((l) => l.trim() !== "");
|
|
1608
|
+
const preview = lines.slice(0, 5);
|
|
1609
|
+
const suffix = lines.length > 5 ? `
|
|
1610
|
+
\u2026 (${lines.length - 5} more lines)` : "";
|
|
1611
|
+
return preview.join("\n ") + suffix;
|
|
1612
|
+
}
|
|
1613
|
+
function formatFieldList(fields) {
|
|
1614
|
+
const entries = Object.entries(fields).sort(([a], [b]) => a.localeCompare(b));
|
|
1615
|
+
const maxKeyLen = Math.min(
|
|
1616
|
+
Math.max(...entries.map(([k]) => k.length)),
|
|
1617
|
+
50
|
|
1618
|
+
);
|
|
1619
|
+
return entries.map(([key, value]) => {
|
|
1620
|
+
const raw = stringifyValue(value);
|
|
1621
|
+
if (raw === "") return `${key.padEnd(maxKeyLen + 2)}(empty)`;
|
|
1622
|
+
if (typeof value === "string" && isHtml(value)) {
|
|
1623
|
+
const preview = formatRichValue(value);
|
|
1624
|
+
return `${key.padEnd(maxKeyLen + 2)}[rich text]
|
|
1625
|
+
${preview}`;
|
|
1626
|
+
}
|
|
1627
|
+
return `${key.padEnd(maxKeyLen + 2)}${raw}`;
|
|
1628
|
+
}).join("\n");
|
|
1629
|
+
}
|
|
1630
|
+
function createListFieldsCommand() {
|
|
1631
|
+
const command = new Command10("list-fields");
|
|
1632
|
+
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(
|
|
1633
|
+
async (idStr, options) => {
|
|
1634
|
+
const id = parseWorkItemId(idStr);
|
|
1635
|
+
validateOrgProjectPair(options);
|
|
1636
|
+
let context;
|
|
1637
|
+
try {
|
|
1638
|
+
context = resolveContext(options);
|
|
1639
|
+
const credential = await resolvePat();
|
|
1640
|
+
const fields = await getWorkItemFields(context, id, credential.pat);
|
|
1641
|
+
if (options.json) {
|
|
1642
|
+
process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
|
|
1643
|
+
} else {
|
|
1644
|
+
process.stdout.write(`Work Item ${id} \u2014 ${Object.keys(fields).length} fields
|
|
1645
|
+
|
|
1646
|
+
`);
|
|
1647
|
+
process.stdout.write(formatFieldList(fields) + "\n");
|
|
1648
|
+
}
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
handleCommandError(err, id, context, "read");
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
);
|
|
1654
|
+
return command;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/commands/pr.ts
|
|
1658
|
+
import { Command as Command11 } from "commander";
|
|
1659
|
+
|
|
1660
|
+
// src/services/pr-client.ts
|
|
1661
|
+
function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
|
|
1662
|
+
const url = new URL(
|
|
1663
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
|
|
1664
|
+
);
|
|
1665
|
+
url.searchParams.set("api-version", "7.1");
|
|
1666
|
+
url.searchParams.set("searchCriteria.sourceRefName", `refs/heads/${sourceBranch}`);
|
|
1667
|
+
if (opts?.status) {
|
|
1668
|
+
url.searchParams.set("searchCriteria.status", opts.status);
|
|
1669
|
+
}
|
|
1670
|
+
if (opts?.targetBranch) {
|
|
1671
|
+
url.searchParams.set("searchCriteria.targetRefName", `refs/heads/${opts.targetBranch}`);
|
|
1672
|
+
}
|
|
1673
|
+
return url;
|
|
1674
|
+
}
|
|
1675
|
+
function mapPullRequest(repo, pullRequest) {
|
|
1676
|
+
return {
|
|
1677
|
+
id: pullRequest.pullRequestId,
|
|
1678
|
+
title: pullRequest.title,
|
|
1679
|
+
repository: repo,
|
|
1680
|
+
sourceRefName: pullRequest.sourceRefName,
|
|
1681
|
+
targetRefName: pullRequest.targetRefName,
|
|
1682
|
+
status: pullRequest.status,
|
|
1683
|
+
createdBy: pullRequest.createdBy?.displayName ?? null,
|
|
1684
|
+
url: pullRequest._links.web.href
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
function mapComment(comment) {
|
|
1688
|
+
const content = comment.content?.trim();
|
|
1689
|
+
if (comment.isDeleted || !content) {
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
return {
|
|
1693
|
+
id: comment.id,
|
|
1694
|
+
author: comment.author?.displayName ?? null,
|
|
1695
|
+
content,
|
|
1696
|
+
publishedAt: comment.publishedDate ?? null
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
function mapThread(thread) {
|
|
1700
|
+
if (thread.status !== "active" && thread.status !== "pending") {
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
const comments = thread.comments.map(mapComment).filter((comment) => comment !== null);
|
|
1704
|
+
if (comments.length === 0) {
|
|
1705
|
+
return null;
|
|
1706
|
+
}
|
|
1707
|
+
return {
|
|
1708
|
+
id: thread.id,
|
|
1709
|
+
status: thread.status,
|
|
1710
|
+
threadContext: thread.threadContext?.filePath ?? null,
|
|
1711
|
+
comments
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
async function readJsonResponse(response) {
|
|
1715
|
+
if (!response.ok) {
|
|
1716
|
+
throw new Error(`HTTP_${response.status}`);
|
|
1717
|
+
}
|
|
1718
|
+
return response.json();
|
|
1719
|
+
}
|
|
1720
|
+
async function listPullRequests(context, repo, pat, sourceBranch, opts) {
|
|
1721
|
+
const response = await fetchWithErrors(
|
|
1722
|
+
buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
|
|
1723
|
+
{ headers: authHeaders(pat) }
|
|
1724
|
+
);
|
|
1725
|
+
const data = await readJsonResponse(response);
|
|
1726
|
+
return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
|
|
1727
|
+
}
|
|
1728
|
+
async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
|
|
1729
|
+
const existing = await listPullRequests(context, repo, pat, sourceBranch, {
|
|
1730
|
+
status: "active",
|
|
1731
|
+
targetBranch: "develop"
|
|
1732
|
+
});
|
|
1733
|
+
if (existing.length === 1) {
|
|
1734
|
+
return {
|
|
1735
|
+
branch: sourceBranch,
|
|
1736
|
+
targetBranch: "develop",
|
|
1737
|
+
created: false,
|
|
1738
|
+
pullRequest: existing[0]
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
if (existing.length > 1) {
|
|
1742
|
+
throw new Error(`AMBIGUOUS_PRS:${existing.map((pullRequest) => pullRequest.id).join(",")}`);
|
|
1743
|
+
}
|
|
1744
|
+
const payload = {
|
|
1745
|
+
sourceRefName: `refs/heads/${sourceBranch}`,
|
|
1746
|
+
targetRefName: "refs/heads/develop",
|
|
1747
|
+
title,
|
|
1748
|
+
description
|
|
1749
|
+
};
|
|
1750
|
+
const url = new URL(
|
|
1751
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
|
|
1752
|
+
);
|
|
1753
|
+
url.searchParams.set("api-version", "7.1");
|
|
1754
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
1755
|
+
method: "POST",
|
|
1756
|
+
headers: {
|
|
1757
|
+
...authHeaders(pat),
|
|
1758
|
+
"Content-Type": "application/json"
|
|
1759
|
+
},
|
|
1760
|
+
body: JSON.stringify(payload)
|
|
1761
|
+
});
|
|
1762
|
+
const data = await readJsonResponse(response);
|
|
1763
|
+
return {
|
|
1764
|
+
branch: sourceBranch,
|
|
1765
|
+
targetBranch: "develop",
|
|
1766
|
+
created: true,
|
|
1767
|
+
pullRequest: mapPullRequest(repo, data)
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
async function getPullRequestThreads(context, repo, pat, prId) {
|
|
1771
|
+
const url = new URL(
|
|
1772
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
|
|
1773
|
+
);
|
|
1774
|
+
url.searchParams.set("api-version", "7.1");
|
|
1775
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
1776
|
+
const data = await readJsonResponse(response);
|
|
1777
|
+
return data.value.map(mapThread).filter((thread) => thread !== null);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// src/commands/pr.ts
|
|
1781
|
+
function formatBranchName(refName) {
|
|
1782
|
+
return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
|
|
1783
|
+
}
|
|
1784
|
+
function writeError(message) {
|
|
1785
|
+
process.stderr.write(`Error: ${message}
|
|
1786
|
+
`);
|
|
1787
|
+
process.exit(1);
|
|
1788
|
+
}
|
|
1789
|
+
function handlePrCommandError(err, context, mode = "read") {
|
|
1790
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1791
|
+
if (error.message === "AUTH_FAILED") {
|
|
1792
|
+
const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
|
|
1793
|
+
writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`);
|
|
1794
|
+
}
|
|
1795
|
+
if (error.message === "PERMISSION_DENIED") {
|
|
1796
|
+
writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`);
|
|
1797
|
+
}
|
|
1798
|
+
if (error.message === "NETWORK_ERROR") {
|
|
1799
|
+
writeError("Could not connect to Azure DevOps. Check your network connection.");
|
|
1800
|
+
}
|
|
1801
|
+
if (error.message.startsWith("NOT_FOUND")) {
|
|
1802
|
+
writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
|
|
1803
|
+
}
|
|
1804
|
+
if (error.message.startsWith("HTTP_")) {
|
|
1805
|
+
writeError(`Azure DevOps request failed with ${error.message}.`);
|
|
1806
|
+
}
|
|
1807
|
+
writeError(error.message);
|
|
1808
|
+
}
|
|
1809
|
+
function formatPullRequestBlock(pullRequest) {
|
|
1810
|
+
return [
|
|
1811
|
+
`#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
|
|
1812
|
+
`${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
|
|
1813
|
+
pullRequest.url
|
|
1814
|
+
].join("\n");
|
|
1815
|
+
}
|
|
1816
|
+
function formatThreads(prId, title, threads) {
|
|
1817
|
+
const lines = [`Active comments for pull request #${prId}: ${title}`];
|
|
1818
|
+
for (const thread of threads) {
|
|
1819
|
+
lines.push("", `Thread #${thread.id} [${thread.status}] ${thread.threadContext ?? "(general)"}`);
|
|
1820
|
+
for (const comment of thread.comments) {
|
|
1821
|
+
lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return lines.join("\n");
|
|
1825
|
+
}
|
|
1826
|
+
async function resolvePrCommandContext(options) {
|
|
1827
|
+
const context = resolveContext(options);
|
|
1828
|
+
const repo = detectRepoName();
|
|
1829
|
+
const branch = getCurrentBranch();
|
|
1830
|
+
const credential = await resolvePat();
|
|
1831
|
+
return {
|
|
1832
|
+
context,
|
|
1833
|
+
repo,
|
|
1834
|
+
branch,
|
|
1835
|
+
pat: credential.pat
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
function createPrStatusCommand() {
|
|
1839
|
+
const command = new Command11("status");
|
|
1840
|
+
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) => {
|
|
1841
|
+
validateOrgProjectPair(options);
|
|
1842
|
+
let context;
|
|
1843
|
+
try {
|
|
1844
|
+
const resolved = await resolvePrCommandContext(options);
|
|
1845
|
+
context = resolved.context;
|
|
1846
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch);
|
|
1847
|
+
const { branch, repo } = resolved;
|
|
1848
|
+
const result = { branch, repository: repo, pullRequests };
|
|
1849
|
+
if (options.json) {
|
|
1850
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1851
|
+
`);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (pullRequests.length === 0) {
|
|
1855
|
+
process.stdout.write(`No pull requests found for branch ${branch}.
|
|
1856
|
+
`);
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
process.stdout.write(`${pullRequests.map(formatPullRequestBlock).join("\n\n")}
|
|
1860
|
+
`);
|
|
1861
|
+
} catch (err) {
|
|
1862
|
+
handlePrCommandError(err, context, "read");
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
return command;
|
|
1866
|
+
}
|
|
1867
|
+
function createPrOpenCommand() {
|
|
1868
|
+
const command = new Command11("open");
|
|
1869
|
+
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) => {
|
|
1870
|
+
validateOrgProjectPair(options);
|
|
1871
|
+
const title = options.title?.trim();
|
|
1872
|
+
if (!title) {
|
|
1873
|
+
writeError("--title is required for pull request creation.");
|
|
1874
|
+
}
|
|
1875
|
+
const description = options.description?.trim();
|
|
1876
|
+
if (!description) {
|
|
1877
|
+
writeError("--description is required for pull request creation.");
|
|
1878
|
+
}
|
|
1879
|
+
let context;
|
|
1880
|
+
try {
|
|
1881
|
+
const resolved = await resolvePrCommandContext(options);
|
|
1882
|
+
context = resolved.context;
|
|
1883
|
+
if (resolved.branch === "develop") {
|
|
1884
|
+
writeError("Pull request creation requires a source branch other than develop.");
|
|
1885
|
+
}
|
|
1886
|
+
const result = await openPullRequest(
|
|
1887
|
+
resolved.context,
|
|
1888
|
+
resolved.repo,
|
|
1889
|
+
resolved.pat,
|
|
1890
|
+
resolved.branch,
|
|
1891
|
+
title,
|
|
1892
|
+
description
|
|
1893
|
+
);
|
|
1894
|
+
if (options.json) {
|
|
1895
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1896
|
+
`);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
if (result.created) {
|
|
1900
|
+
process.stdout.write(`Created pull request #${result.pullRequest.id}: ${result.pullRequest.title}
|
|
1901
|
+
${result.pullRequest.url}
|
|
1902
|
+
`);
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
process.stdout.write(
|
|
1906
|
+
`Active pull request already exists for ${resolved.branch} -> develop: #${result.pullRequest.id}
|
|
1907
|
+
${result.pullRequest.url}
|
|
1908
|
+
`
|
|
1909
|
+
);
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
|
|
1912
|
+
const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
|
|
1913
|
+
writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
|
|
1914
|
+
}
|
|
1915
|
+
handlePrCommandError(err, context, "write");
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
return command;
|
|
1919
|
+
}
|
|
1920
|
+
function createPrCommentsCommand() {
|
|
1921
|
+
const command = new Command11("comments");
|
|
1922
|
+
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) => {
|
|
1923
|
+
validateOrgProjectPair(options);
|
|
1924
|
+
let context;
|
|
1925
|
+
try {
|
|
1926
|
+
const resolved = await resolvePrCommandContext(options);
|
|
1927
|
+
context = resolved.context;
|
|
1928
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
|
|
1929
|
+
status: "active"
|
|
1930
|
+
});
|
|
1931
|
+
if (pullRequests.length === 0) {
|
|
1932
|
+
writeError(`No active pull request found for branch ${resolved.branch}.`);
|
|
1933
|
+
}
|
|
1934
|
+
if (pullRequests.length > 1) {
|
|
1935
|
+
const ids = pullRequests.map((pullRequest2) => `#${pullRequest2.id}`).join(", ");
|
|
1936
|
+
writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
|
|
1937
|
+
}
|
|
1938
|
+
const pullRequest = pullRequests[0];
|
|
1939
|
+
const threads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
|
|
1940
|
+
const result = { branch: resolved.branch, pullRequest, threads };
|
|
1941
|
+
if (options.json) {
|
|
1942
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1943
|
+
`);
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
if (threads.length === 0) {
|
|
1947
|
+
process.stdout.write(`Pull request #${pullRequest.id} has no active comments.
|
|
1948
|
+
`);
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
process.stdout.write(`${formatThreads(pullRequest.id, pullRequest.title, threads)}
|
|
1952
|
+
`);
|
|
1953
|
+
} catch (err) {
|
|
1954
|
+
handlePrCommandError(err, context, "read");
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
return command;
|
|
1958
|
+
}
|
|
1959
|
+
function createPrCommand() {
|
|
1960
|
+
const command = new Command11("pr");
|
|
1961
|
+
command.description("Manage Azure DevOps pull requests");
|
|
1962
|
+
command.addCommand(createPrStatusCommand());
|
|
1963
|
+
command.addCommand(createPrOpenCommand());
|
|
1964
|
+
command.addCommand(createPrCommentsCommand());
|
|
1965
|
+
return command;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// src/commands/comments.ts
|
|
1969
|
+
import { Command as Command12 } from "commander";
|
|
1970
|
+
function writeError2(message) {
|
|
1971
|
+
process.stderr.write(`Error: ${message}
|
|
1972
|
+
`);
|
|
1973
|
+
process.exit(1);
|
|
1974
|
+
}
|
|
1975
|
+
function formatCommentHeader(comment) {
|
|
1976
|
+
const author = comment.author ?? "Unknown";
|
|
1977
|
+
const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
|
|
1978
|
+
return `Comment #${comment.id} by ${author} at ${timestamp}`;
|
|
1979
|
+
}
|
|
1980
|
+
function formatComments(result) {
|
|
1981
|
+
const lines = [`Comments for work item #${result.workItemId}`];
|
|
1982
|
+
for (const comment of result.comments) {
|
|
1983
|
+
lines.push("", formatCommentHeader(comment), comment.text);
|
|
1984
|
+
}
|
|
1985
|
+
return lines.join("\n");
|
|
1986
|
+
}
|
|
1987
|
+
function createCommentsListCommand() {
|
|
1988
|
+
const command = new Command12("list");
|
|
1989
|
+
command.description("List visible comments for a work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (idStr, options) => {
|
|
1990
|
+
validateOrgProjectPair(options);
|
|
1991
|
+
const id = parseWorkItemId(idStr);
|
|
1992
|
+
let context;
|
|
1993
|
+
try {
|
|
1994
|
+
context = resolveContext(options);
|
|
1995
|
+
const credential = await resolvePat();
|
|
1996
|
+
const result = await listWorkItemComments(context, id, credential.pat);
|
|
1997
|
+
if (options.json) {
|
|
1998
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1999
|
+
`);
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
if (result.comments.length === 0) {
|
|
2003
|
+
process.stdout.write(`Work item #${id} has no comments.
|
|
2004
|
+
`);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
process.stdout.write(`${formatComments(result)}
|
|
2008
|
+
`);
|
|
2009
|
+
} catch (err) {
|
|
2010
|
+
handleCommandError(err, id, context, "read");
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
return command;
|
|
2014
|
+
}
|
|
2015
|
+
function createCommentsAddCommand() {
|
|
2016
|
+
const command = new Command12("add");
|
|
2017
|
+
command.description("Add a comment to a work item").argument("<id>", "work item ID").argument("<text>", "comment text").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (idStr, text, options) => {
|
|
2018
|
+
validateOrgProjectPair(options);
|
|
2019
|
+
const id = parseWorkItemId(idStr);
|
|
2020
|
+
if (text.trim() === "") {
|
|
2021
|
+
writeError2("Comment text must be a non-empty string.");
|
|
2022
|
+
}
|
|
2023
|
+
let context;
|
|
2024
|
+
try {
|
|
2025
|
+
context = resolveContext(options);
|
|
2026
|
+
const credential = await resolvePat();
|
|
2027
|
+
const result = await addWorkItemComment(context, id, credential.pat, text);
|
|
2028
|
+
if (options.json) {
|
|
2029
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2030
|
+
`);
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
process.stdout.write(`Added comment #${result.commentId} to work item #${result.workItemId}
|
|
2034
|
+
`);
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
handleCommandError(err, id, context, "write");
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
return command;
|
|
2040
|
+
}
|
|
2041
|
+
function createCommentsCommand() {
|
|
2042
|
+
const command = new Command12("comments");
|
|
2043
|
+
command.description("Manage Azure DevOps work item comments");
|
|
2044
|
+
command.addCommand(createCommentsListCommand());
|
|
2045
|
+
command.addCommand(createCommentsAddCommand());
|
|
2046
|
+
return command;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
1403
2049
|
// src/index.ts
|
|
1404
|
-
var program = new
|
|
2050
|
+
var program = new Command13();
|
|
1405
2051
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
1406
2052
|
program.addCommand(createGetItemCommand());
|
|
1407
2053
|
program.addCommand(createClearPatCommand());
|
|
@@ -1412,6 +2058,9 @@ program.addCommand(createSetFieldCommand());
|
|
|
1412
2058
|
program.addCommand(createGetMdFieldCommand());
|
|
1413
2059
|
program.addCommand(createSetMdFieldCommand());
|
|
1414
2060
|
program.addCommand(createUpsertCommand());
|
|
2061
|
+
program.addCommand(createListFieldsCommand());
|
|
2062
|
+
program.addCommand(createPrCommand());
|
|
2063
|
+
program.addCommand(createCommentsCommand());
|
|
1415
2064
|
program.showHelpAfterError();
|
|
1416
2065
|
program.parse();
|
|
1417
2066
|
if (process.argv.length <= 2) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azdo-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-010-work-item-comments.149",
|
|
4
4
|
"description": "Azure DevOps CLI tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,9 @@
|
|
|
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",
|
|
19
|
+
"test:integration:full": "bash scripts/setup-keyring.sh && npm run test:integration"
|
|
18
20
|
},
|
|
19
21
|
"repository": {
|
|
20
22
|
"type": "git",
|