azdo-cli 0.2.0-develop.8 → 0.2.0-hotfix-0-2-1.54
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 +48 -54
- package/dist/index.js +989 -2
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,79 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# azdo-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A command-line interface for Azure DevOps.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/azdo-cli)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://sonarcloud.io/summary/new_code?id=alkampfergit_azdo-cli)
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
[](https://goreportcard.com/report/github.com/steveyegge/beads)
|
|
9
|
-
[](https://github.com/steveyegge/beads/releases)
|
|
10
|
-
[](https://www.npmjs.com/package/@beads/bd)
|
|
11
|
-
[](https://pypi.org/project/beads-mcp/)
|
|
9
|
+
## Installation
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g azdo-cli
|
|
13
|
+
```
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Usage
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# Initialize in YOUR project
|
|
22
|
-
cd your-project
|
|
23
|
-
bd init
|
|
18
|
+
# Show help
|
|
19
|
+
azdo --help
|
|
24
20
|
|
|
25
|
-
#
|
|
26
|
-
|
|
21
|
+
# Show version
|
|
22
|
+
azdo --version
|
|
23
|
+
azdo -v
|
|
27
24
|
```
|
|
28
25
|
|
|
29
|
-
|
|
26
|
+
## Current Status
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
This project is in early development (v0.2.0). The base CLI scaffold is in place with support for `--help` and `--version`. Azure DevOps commands (work items, pipelines, repos, etc.) will be added in future releases.
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
* **Agent-Optimized:** JSON output, dependency tracking, and auto-ready task detection.
|
|
35
|
-
* **Zero Conflict:** Hash-based IDs (`bd-a1b2`) prevent merge collisions in multi-agent/multi-branch workflows.
|
|
36
|
-
* **Invisible Infrastructure:** SQLite local cache for speed; background daemon for auto-sync.
|
|
37
|
-
* **Compaction:** Semantic "memory decay" summarizes old closed tasks to save context window.
|
|
30
|
+
## Development
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
| Command | Action |
|
|
42
|
-
| --- | --- |
|
|
43
|
-
| `bd ready` | List tasks with no open blockers. |
|
|
44
|
-
| `bd create "Title" -p 0` | Create a P0 task. |
|
|
45
|
-
| `bd update <id> --claim` | Atomically claim a task (sets assignee + in_progress). |
|
|
46
|
-
| `bd dep add <child> <parent>` | Link tasks (blocks, related, parent-child). |
|
|
47
|
-
| `bd show <id>` | View task details and audit trail. |
|
|
32
|
+
### Prerequisites
|
|
48
33
|
|
|
49
|
-
|
|
34
|
+
- Node.js LTS (20+)
|
|
35
|
+
- npm
|
|
50
36
|
|
|
51
|
-
|
|
37
|
+
### Setup
|
|
52
38
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/alkampfergit/azdo-cli.git
|
|
41
|
+
cd azdo-cli
|
|
42
|
+
npm install
|
|
43
|
+
```
|
|
58
44
|
|
|
59
|
-
|
|
45
|
+
### Scripts
|
|
60
46
|
|
|
61
|
-
|
|
62
|
-
|
|
47
|
+
| Command | Description |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `npm run build` | Build the CLI with tsup |
|
|
50
|
+
| `npm test` | Run tests with vitest |
|
|
51
|
+
| `npm run lint` | Lint source files with ESLint |
|
|
52
|
+
| `npm run typecheck` | Type-check with tsc (no emit) |
|
|
53
|
+
| `npm run format` | Check formatting with Prettier |
|
|
63
54
|
|
|
64
|
-
|
|
55
|
+
### Tech Stack
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
57
|
+
- **TypeScript 5.x** (strict mode, ES modules)
|
|
58
|
+
- **commander.js** — CLI framework
|
|
59
|
+
- **tsup** — Bundler (single-file ESM output)
|
|
60
|
+
- **vitest** — Test runner
|
|
61
|
+
- **ESLint + Prettier** — Linting and formatting
|
|
69
62
|
|
|
70
|
-
|
|
63
|
+
### Branch Strategy
|
|
71
64
|
|
|
72
|
-
|
|
65
|
+
This project follows **GitFlow**:
|
|
73
66
|
|
|
74
|
-
|
|
67
|
+
- `master` — stable releases
|
|
68
|
+
- `develop` — integration branch
|
|
69
|
+
- `feature/*` — feature branches off `develop`
|
|
75
70
|
|
|
76
|
-
##
|
|
71
|
+
## License
|
|
77
72
|
|
|
78
|
-
|
|
79
|
-
* [](https://deepwiki.com/steveyegge/beads)
|
|
73
|
+
[MIT](LICENSE)
|
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 } from "commander";
|
|
4
|
+
import { Command as Command9 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
7
|
import { readFileSync } from "fs";
|
|
@@ -11,9 +11,996 @@ var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
11
11
|
var pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf-8"));
|
|
12
12
|
var version = pkg.version;
|
|
13
13
|
|
|
14
|
+
// src/commands/get-item.ts
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
|
|
17
|
+
// src/services/azdo-client.ts
|
|
18
|
+
var DEFAULT_FIELDS = [
|
|
19
|
+
"System.Title",
|
|
20
|
+
"System.State",
|
|
21
|
+
"System.WorkItemType",
|
|
22
|
+
"System.AssignedTo",
|
|
23
|
+
"System.Description",
|
|
24
|
+
"Microsoft.VSTS.Common.AcceptanceCriteria",
|
|
25
|
+
"Microsoft.VSTS.TCM.ReproSteps",
|
|
26
|
+
"System.AreaPath",
|
|
27
|
+
"System.IterationPath"
|
|
28
|
+
];
|
|
29
|
+
function authHeaders(pat) {
|
|
30
|
+
const token = Buffer.from(`:${pat}`).toString("base64");
|
|
31
|
+
return { Authorization: `Basic ${token}` };
|
|
32
|
+
}
|
|
33
|
+
async function fetchWithErrors(url, init) {
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(url, init);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("NETWORK_ERROR");
|
|
39
|
+
}
|
|
40
|
+
if (response.status === 401) throw new Error("AUTH_FAILED");
|
|
41
|
+
if (response.status === 403) throw new Error("PERMISSION_DENIED");
|
|
42
|
+
if (response.status === 404) throw new Error("NOT_FOUND");
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
function buildExtraFields(fields, requested) {
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const name of requested) {
|
|
48
|
+
const val = fields[name];
|
|
49
|
+
if (val !== void 0 && val !== null) {
|
|
50
|
+
result[name] = String(val);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
54
|
+
}
|
|
55
|
+
async function getWorkItem(context, id, pat, extraFields) {
|
|
56
|
+
const url = new URL(
|
|
57
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
58
|
+
);
|
|
59
|
+
url.searchParams.set("api-version", "7.1");
|
|
60
|
+
if (extraFields && extraFields.length > 0) {
|
|
61
|
+
const allFields = [...DEFAULT_FIELDS, ...extraFields];
|
|
62
|
+
url.searchParams.set("fields", allFields.join(","));
|
|
63
|
+
}
|
|
64
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new Error(`HTTP_${response.status}`);
|
|
67
|
+
}
|
|
68
|
+
const data = await response.json();
|
|
69
|
+
const descriptionParts = [];
|
|
70
|
+
if (data.fields["System.Description"]) {
|
|
71
|
+
descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
|
|
72
|
+
}
|
|
73
|
+
if (data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
|
|
74
|
+
descriptionParts.push({ label: "Acceptance Criteria", value: data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
|
|
75
|
+
}
|
|
76
|
+
if (data.fields["Microsoft.VSTS.TCM.ReproSteps"]) {
|
|
77
|
+
descriptionParts.push({ label: "Repro Steps", value: data.fields["Microsoft.VSTS.TCM.ReproSteps"] });
|
|
78
|
+
}
|
|
79
|
+
let combinedDescription = null;
|
|
80
|
+
if (descriptionParts.length === 1) {
|
|
81
|
+
combinedDescription = descriptionParts[0].value;
|
|
82
|
+
} else if (descriptionParts.length > 1) {
|
|
83
|
+
combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
id: data.id,
|
|
87
|
+
rev: data.rev,
|
|
88
|
+
title: data.fields["System.Title"],
|
|
89
|
+
state: data.fields["System.State"],
|
|
90
|
+
type: data.fields["System.WorkItemType"],
|
|
91
|
+
assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
|
|
92
|
+
description: combinedDescription,
|
|
93
|
+
areaPath: data.fields["System.AreaPath"],
|
|
94
|
+
iterationPath: data.fields["System.IterationPath"],
|
|
95
|
+
url: data._links.html.href,
|
|
96
|
+
extraFields: extraFields && extraFields.length > 0 ? buildExtraFields(data.fields, extraFields) : null
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async function getWorkItemFieldValue(context, id, pat, fieldName) {
|
|
100
|
+
const url = new URL(
|
|
101
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
102
|
+
);
|
|
103
|
+
url.searchParams.set("api-version", "7.1");
|
|
104
|
+
url.searchParams.set("fields", fieldName);
|
|
105
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`HTTP_${response.status}`);
|
|
108
|
+
}
|
|
109
|
+
const data = await response.json();
|
|
110
|
+
const value = data.fields[fieldName];
|
|
111
|
+
if (value === void 0 || value === null || value === "") {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return typeof value === "object" ? JSON.stringify(value) : `${value}`;
|
|
115
|
+
}
|
|
116
|
+
async function updateWorkItem(context, id, pat, fieldName, operations) {
|
|
117
|
+
const url = new URL(
|
|
118
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
119
|
+
);
|
|
120
|
+
url.searchParams.set("api-version", "7.1");
|
|
121
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
122
|
+
method: "PATCH",
|
|
123
|
+
headers: {
|
|
124
|
+
...authHeaders(pat),
|
|
125
|
+
"Content-Type": "application/json-patch+json"
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify(operations)
|
|
128
|
+
});
|
|
129
|
+
if (response.status === 400) {
|
|
130
|
+
let serverMessage = "Unknown error";
|
|
131
|
+
try {
|
|
132
|
+
const body = await response.json();
|
|
133
|
+
if (body.message) serverMessage = body.message;
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`UPDATE_REJECTED: ${serverMessage}`);
|
|
137
|
+
}
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`HTTP_${response.status}`);
|
|
140
|
+
}
|
|
141
|
+
const data = await response.json();
|
|
142
|
+
const lastOp = operations[operations.length - 1];
|
|
143
|
+
const fieldValue = lastOp.value ?? null;
|
|
144
|
+
return {
|
|
145
|
+
id: data.id,
|
|
146
|
+
rev: data.rev,
|
|
147
|
+
title: data.fields["System.Title"],
|
|
148
|
+
fieldName,
|
|
149
|
+
fieldValue
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/services/auth.ts
|
|
154
|
+
import { createInterface } from "readline";
|
|
155
|
+
|
|
156
|
+
// src/services/credential-store.ts
|
|
157
|
+
import { Entry } from "@napi-rs/keyring";
|
|
158
|
+
var SERVICE = "azdo-cli";
|
|
159
|
+
var ACCOUNT = "pat";
|
|
160
|
+
async function getPat() {
|
|
161
|
+
try {
|
|
162
|
+
const entry = new Entry(SERVICE, ACCOUNT);
|
|
163
|
+
return entry.getPassword();
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function storePat(pat) {
|
|
169
|
+
try {
|
|
170
|
+
const entry = new Entry(SERVICE, ACCOUNT);
|
|
171
|
+
entry.setPassword(pat);
|
|
172
|
+
} catch {
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function deletePat() {
|
|
176
|
+
try {
|
|
177
|
+
const entry = new Entry(SERVICE, ACCOUNT);
|
|
178
|
+
entry.deletePassword();
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/services/auth.ts
|
|
186
|
+
function normalizePat(rawPat) {
|
|
187
|
+
const trimmedPat = rawPat.trim();
|
|
188
|
+
return trimmedPat.length > 0 ? trimmedPat : null;
|
|
189
|
+
}
|
|
190
|
+
async function promptForPat() {
|
|
191
|
+
if (!process.stdin.isTTY) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return new Promise((resolve2) => {
|
|
195
|
+
const rl = createInterface({
|
|
196
|
+
input: process.stdin,
|
|
197
|
+
output: process.stderr
|
|
198
|
+
});
|
|
199
|
+
process.stderr.write("Enter your Azure DevOps PAT: ");
|
|
200
|
+
process.stdin.setRawMode(true);
|
|
201
|
+
process.stdin.resume();
|
|
202
|
+
let pat = "";
|
|
203
|
+
const onData = (key) => {
|
|
204
|
+
const ch = key.toString("utf8");
|
|
205
|
+
if (ch === "") {
|
|
206
|
+
process.stdin.setRawMode(false);
|
|
207
|
+
process.stdin.removeListener("data", onData);
|
|
208
|
+
rl.close();
|
|
209
|
+
process.stderr.write("\n");
|
|
210
|
+
resolve2(null);
|
|
211
|
+
} else if (ch === "\r" || ch === "\n") {
|
|
212
|
+
process.stdin.setRawMode(false);
|
|
213
|
+
process.stdin.removeListener("data", onData);
|
|
214
|
+
rl.close();
|
|
215
|
+
process.stderr.write("\n");
|
|
216
|
+
resolve2(pat);
|
|
217
|
+
} else if (ch === "\x7F" || ch === "\b") {
|
|
218
|
+
if (pat.length > 0) {
|
|
219
|
+
pat = pat.slice(0, -1);
|
|
220
|
+
process.stderr.write("\b \b");
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
pat += ch;
|
|
224
|
+
process.stderr.write("*".repeat(ch.length));
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
process.stdin.on("data", onData);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
async function resolvePat() {
|
|
231
|
+
const envPat = process.env.AZDO_PAT;
|
|
232
|
+
if (envPat) {
|
|
233
|
+
return { pat: envPat, source: "env" };
|
|
234
|
+
}
|
|
235
|
+
const storedPat = await getPat();
|
|
236
|
+
if (storedPat !== null) {
|
|
237
|
+
return { pat: storedPat, source: "credential-store" };
|
|
238
|
+
}
|
|
239
|
+
const promptedPat = await promptForPat();
|
|
240
|
+
if (promptedPat !== null) {
|
|
241
|
+
const normalizedPat = normalizePat(promptedPat);
|
|
242
|
+
if (normalizedPat !== null) {
|
|
243
|
+
await storePat(normalizedPat);
|
|
244
|
+
return { pat: normalizedPat, source: "prompt" };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
throw new Error(
|
|
248
|
+
"Authentication cancelled. Set AZDO_PAT environment variable or run again to enter a PAT."
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/services/git-remote.ts
|
|
253
|
+
import { execSync } from "child_process";
|
|
254
|
+
var patterns = [
|
|
255
|
+
// HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
|
|
256
|
+
/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/.+$/,
|
|
257
|
+
// HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
|
|
258
|
+
/^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/.+$/,
|
|
259
|
+
// HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
|
|
260
|
+
/^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/.+$/,
|
|
261
|
+
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
|
|
262
|
+
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/.+$/,
|
|
263
|
+
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
|
|
264
|
+
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/.+$/
|
|
265
|
+
];
|
|
266
|
+
function parseAzdoRemote(url) {
|
|
267
|
+
for (const pattern of patterns) {
|
|
268
|
+
const match = url.match(pattern);
|
|
269
|
+
if (match) {
|
|
270
|
+
const project = match[2];
|
|
271
|
+
if (/^DefaultCollection$/i.test(project)) {
|
|
272
|
+
return { org: match[1], project: "" };
|
|
273
|
+
}
|
|
274
|
+
return { org: match[1], project };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
function detectAzdoContext() {
|
|
280
|
+
let remoteUrl;
|
|
281
|
+
try {
|
|
282
|
+
remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
|
|
283
|
+
} catch {
|
|
284
|
+
throw new Error("Not in a git repository. Provide --org and --project explicitly.");
|
|
285
|
+
}
|
|
286
|
+
const context = parseAzdoRemote(remoteUrl);
|
|
287
|
+
if (!context || !context.org && !context.project) {
|
|
288
|
+
throw new Error('Git remote "origin" is not an Azure DevOps URL. Provide --org and --project explicitly.');
|
|
289
|
+
}
|
|
290
|
+
return context;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/services/config-store.ts
|
|
294
|
+
import fs from "fs";
|
|
295
|
+
import path from "path";
|
|
296
|
+
import os from "os";
|
|
297
|
+
var SETTINGS = [
|
|
298
|
+
{
|
|
299
|
+
key: "org",
|
|
300
|
+
description: "Azure DevOps organization name",
|
|
301
|
+
type: "string",
|
|
302
|
+
example: "mycompany",
|
|
303
|
+
required: true
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
key: "project",
|
|
307
|
+
description: "Azure DevOps project name",
|
|
308
|
+
type: "string",
|
|
309
|
+
example: "MyProject",
|
|
310
|
+
required: true
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
key: "fields",
|
|
314
|
+
description: "Extra work item fields to include (comma-separated reference names)",
|
|
315
|
+
type: "string[]",
|
|
316
|
+
example: "System.Tags,Custom.Priority",
|
|
317
|
+
required: false
|
|
318
|
+
}
|
|
319
|
+
];
|
|
320
|
+
var VALID_KEYS = SETTINGS.map((s) => s.key);
|
|
321
|
+
function getConfigPath() {
|
|
322
|
+
return path.join(os.homedir(), ".azdo", "config.json");
|
|
323
|
+
}
|
|
324
|
+
function loadConfig() {
|
|
325
|
+
const configPath = getConfigPath();
|
|
326
|
+
let raw;
|
|
327
|
+
try {
|
|
328
|
+
raw = fs.readFileSync(configPath, "utf-8");
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (err.code === "ENOENT") {
|
|
331
|
+
return {};
|
|
332
|
+
}
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(raw);
|
|
337
|
+
} catch {
|
|
338
|
+
process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
|
|
339
|
+
`);
|
|
340
|
+
return {};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function saveConfig(config) {
|
|
344
|
+
const configPath = getConfigPath();
|
|
345
|
+
const dir = path.dirname(configPath);
|
|
346
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
347
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
348
|
+
}
|
|
349
|
+
function validateKey(key) {
|
|
350
|
+
if (!VALID_KEYS.includes(key)) {
|
|
351
|
+
throw new Error(`Unknown setting key "${key}". Valid keys: org, project, fields`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function getConfigValue(key) {
|
|
355
|
+
validateKey(key);
|
|
356
|
+
const config = loadConfig();
|
|
357
|
+
return config[key];
|
|
358
|
+
}
|
|
359
|
+
function setConfigValue(key, value) {
|
|
360
|
+
validateKey(key);
|
|
361
|
+
const config = loadConfig();
|
|
362
|
+
if (value === "") {
|
|
363
|
+
delete config[key];
|
|
364
|
+
} else if (key === "fields") {
|
|
365
|
+
config.fields = value.split(",").map((s) => s.trim());
|
|
366
|
+
} else {
|
|
367
|
+
config[key] = value;
|
|
368
|
+
}
|
|
369
|
+
saveConfig(config);
|
|
370
|
+
}
|
|
371
|
+
function unsetConfigValue(key) {
|
|
372
|
+
validateKey(key);
|
|
373
|
+
const config = loadConfig();
|
|
374
|
+
delete config[key];
|
|
375
|
+
saveConfig(config);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/services/context.ts
|
|
379
|
+
function resolveContext(options) {
|
|
380
|
+
if (options.org && options.project) {
|
|
381
|
+
return { org: options.org, project: options.project };
|
|
382
|
+
}
|
|
383
|
+
const config = loadConfig();
|
|
384
|
+
if (config.org && config.project) {
|
|
385
|
+
return { org: config.org, project: config.project };
|
|
386
|
+
}
|
|
387
|
+
let gitContext = null;
|
|
388
|
+
try {
|
|
389
|
+
gitContext = detectAzdoContext();
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
const org = config.org || gitContext?.org;
|
|
393
|
+
const project = config.project || gitContext?.project;
|
|
394
|
+
if (org && project) {
|
|
395
|
+
return { org, project };
|
|
396
|
+
}
|
|
397
|
+
throw new Error(
|
|
398
|
+
'Could not determine org/project. Use --org and --project flags, work from an Azure DevOps git repo, or run "azdo config set org/project".'
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/services/command-helpers.ts
|
|
403
|
+
function parseWorkItemId(idStr) {
|
|
404
|
+
const id = Number.parseInt(idStr, 10);
|
|
405
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
406
|
+
process.stderr.write(
|
|
407
|
+
`Error: Work item ID must be a positive integer. Got: "${idStr}"
|
|
408
|
+
`
|
|
409
|
+
);
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
return id;
|
|
413
|
+
}
|
|
414
|
+
function validateOrgProjectPair(options) {
|
|
415
|
+
const hasOrg = options.org !== void 0;
|
|
416
|
+
const hasProject = options.project !== void 0;
|
|
417
|
+
if (hasOrg !== hasProject) {
|
|
418
|
+
process.stderr.write(
|
|
419
|
+
"Error: --org and --project must both be provided, or both omitted.\n"
|
|
420
|
+
);
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function handleCommandError(err, id, context, scope = "write") {
|
|
425
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
426
|
+
const msg = error.message;
|
|
427
|
+
const scopeLabel = scope === "read" ? "Work Items (read)" : "Work Items (Read & Write)";
|
|
428
|
+
if (msg === "AUTH_FAILED") {
|
|
429
|
+
process.stderr.write(
|
|
430
|
+
`Error: Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.
|
|
431
|
+
`
|
|
432
|
+
);
|
|
433
|
+
} else if (msg === "PERMISSION_DENIED") {
|
|
434
|
+
process.stderr.write(
|
|
435
|
+
`Error: Access denied. Your PAT may lack ${scope} permissions for project "${context?.project}".
|
|
436
|
+
`
|
|
437
|
+
);
|
|
438
|
+
} else if (msg === "NOT_FOUND") {
|
|
439
|
+
process.stderr.write(
|
|
440
|
+
`Error: Work item ${id} not found in ${context?.org}/${context?.project}.
|
|
441
|
+
`
|
|
442
|
+
);
|
|
443
|
+
} else if (msg === "NETWORK_ERROR") {
|
|
444
|
+
process.stderr.write(
|
|
445
|
+
"Error: Could not connect to Azure DevOps. Check your network connection.\n"
|
|
446
|
+
);
|
|
447
|
+
} else if (msg.startsWith("UPDATE_REJECTED:")) {
|
|
448
|
+
const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
|
|
449
|
+
process.stderr.write(`Error: Update rejected: ${serverMsg}
|
|
450
|
+
`);
|
|
451
|
+
} else {
|
|
452
|
+
process.stderr.write(`Error: ${msg}
|
|
453
|
+
`);
|
|
454
|
+
}
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/commands/get-item.ts
|
|
459
|
+
function stripHtml(html) {
|
|
460
|
+
let text = html;
|
|
461
|
+
text = text.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
|
|
462
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
463
|
+
text = text.replace(/<\/?(p|div)>/gi, "\n");
|
|
464
|
+
text = text.replace(/<li>/gi, "\n");
|
|
465
|
+
text = text.replace(/<[^>]*>/g, "");
|
|
466
|
+
text = text.replace(/&/g, "&");
|
|
467
|
+
text = text.replace(/</g, "<");
|
|
468
|
+
text = text.replace(/>/g, ">");
|
|
469
|
+
text = text.replace(/"/g, '"');
|
|
470
|
+
text = text.replace(/'/g, "'");
|
|
471
|
+
text = text.replace(/ /g, " ");
|
|
472
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
473
|
+
return text.trim();
|
|
474
|
+
}
|
|
475
|
+
function formatWorkItem(workItem, short) {
|
|
476
|
+
const lines = [];
|
|
477
|
+
const label = (name) => name.padEnd(13);
|
|
478
|
+
lines.push(`${label("ID:")}${workItem.id}`);
|
|
479
|
+
lines.push(`${label("Type:")}${workItem.type}`);
|
|
480
|
+
lines.push(`${label("Title:")}${workItem.title}`);
|
|
481
|
+
lines.push(`${label("State:")}${workItem.state}`);
|
|
482
|
+
lines.push(`${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`);
|
|
483
|
+
if (!short) {
|
|
484
|
+
lines.push(`${label("Area:")}${workItem.areaPath}`);
|
|
485
|
+
lines.push(`${label("Iteration:")}${workItem.iterationPath}`);
|
|
486
|
+
}
|
|
487
|
+
lines.push(`${label("URL:")}${workItem.url}`);
|
|
488
|
+
if (workItem.extraFields) {
|
|
489
|
+
for (const [refName, value] of Object.entries(workItem.extraFields)) {
|
|
490
|
+
const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
|
|
491
|
+
lines.push(`${fieldLabel.padEnd(13)}${value}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
lines.push("");
|
|
495
|
+
const descriptionText = workItem.description ? stripHtml(workItem.description) : "";
|
|
496
|
+
if (short) {
|
|
497
|
+
const descLines = descriptionText.split("\n").filter((l) => l.trim() !== "");
|
|
498
|
+
const firstThree = descLines.slice(0, 3);
|
|
499
|
+
const truncated = descLines.length > 3;
|
|
500
|
+
const descSummary = firstThree.join("\n") + (truncated ? "\n..." : "");
|
|
501
|
+
lines.push(`${label("Description:")}${descSummary}`);
|
|
502
|
+
} else {
|
|
503
|
+
lines.push("Description:");
|
|
504
|
+
lines.push(descriptionText);
|
|
505
|
+
}
|
|
506
|
+
return lines.join("\n");
|
|
507
|
+
}
|
|
508
|
+
function createGetItemCommand() {
|
|
509
|
+
const command = new Command("get-item");
|
|
510
|
+
command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").action(
|
|
511
|
+
async (idStr, options) => {
|
|
512
|
+
const id = parseWorkItemId(idStr);
|
|
513
|
+
validateOrgProjectPair(options);
|
|
514
|
+
let context;
|
|
515
|
+
try {
|
|
516
|
+
context = resolveContext(options);
|
|
517
|
+
const credential = await resolvePat();
|
|
518
|
+
const fieldsList = options.fields ? options.fields.split(",").map((f) => f.trim()) : loadConfig().fields;
|
|
519
|
+
const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
|
|
520
|
+
const output = formatWorkItem(workItem, options.short ?? false);
|
|
521
|
+
process.stdout.write(output + "\n");
|
|
522
|
+
} catch (err) {
|
|
523
|
+
handleCommandError(err, id, context, "read");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
);
|
|
527
|
+
return command;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/commands/clear-pat.ts
|
|
531
|
+
import { Command as Command2 } from "commander";
|
|
532
|
+
function createClearPatCommand() {
|
|
533
|
+
const command = new Command2("clear-pat");
|
|
534
|
+
command.description("Remove the stored Azure DevOps PAT from the credential store").action(async () => {
|
|
535
|
+
const deleted = await deletePat();
|
|
536
|
+
if (deleted) {
|
|
537
|
+
process.stdout.write("PAT removed from credential store.\n");
|
|
538
|
+
} else {
|
|
539
|
+
process.stdout.write("No stored PAT found.\n");
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
return command;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/commands/config.ts
|
|
546
|
+
import { Command as Command3 } from "commander";
|
|
547
|
+
import { createInterface as createInterface2 } from "readline";
|
|
548
|
+
function createConfigCommand() {
|
|
549
|
+
const config = new Command3("config");
|
|
550
|
+
config.description("Manage CLI settings");
|
|
551
|
+
const set = new Command3("set");
|
|
552
|
+
set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields)").argument("<value>", "setting value").option("--json", "output in JSON format").action((key, value, options) => {
|
|
553
|
+
try {
|
|
554
|
+
setConfigValue(key, value);
|
|
555
|
+
if (options.json) {
|
|
556
|
+
const output = { key, value };
|
|
557
|
+
if (key === "fields") {
|
|
558
|
+
output.value = value.split(",").map((s) => s.trim());
|
|
559
|
+
}
|
|
560
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
561
|
+
} else {
|
|
562
|
+
process.stdout.write(`Set "${key}" to "${value}"
|
|
563
|
+
`);
|
|
564
|
+
}
|
|
565
|
+
} catch (err) {
|
|
566
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
567
|
+
process.stderr.write(`Error: ${message}
|
|
568
|
+
`);
|
|
569
|
+
process.exit(1);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
const get = new Command3("get");
|
|
573
|
+
get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
574
|
+
try {
|
|
575
|
+
const value = getConfigValue(key);
|
|
576
|
+
if (options.json) {
|
|
577
|
+
process.stdout.write(
|
|
578
|
+
JSON.stringify({ key, value: value ?? null }) + "\n"
|
|
579
|
+
);
|
|
580
|
+
} else if (value === void 0) {
|
|
581
|
+
process.stdout.write(`Setting "${key}" is not configured.
|
|
582
|
+
`);
|
|
583
|
+
} else if (Array.isArray(value)) {
|
|
584
|
+
process.stdout.write(value.join(",") + "\n");
|
|
585
|
+
} else {
|
|
586
|
+
process.stdout.write(value + "\n");
|
|
587
|
+
}
|
|
588
|
+
} catch (err) {
|
|
589
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
590
|
+
process.stderr.write(`Error: ${message}
|
|
591
|
+
`);
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
const list = new Command3("list");
|
|
596
|
+
list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
|
|
597
|
+
const cfg = loadConfig();
|
|
598
|
+
if (options.json) {
|
|
599
|
+
process.stdout.write(JSON.stringify(cfg) + "\n");
|
|
600
|
+
} else {
|
|
601
|
+
const keyWidth = 10;
|
|
602
|
+
const valueWidth = 30;
|
|
603
|
+
for (const setting of SETTINGS) {
|
|
604
|
+
const raw = cfg[setting.key];
|
|
605
|
+
const value = raw === void 0 ? "(not set)" : Array.isArray(raw) ? raw.join(",") : raw;
|
|
606
|
+
const marker = raw === void 0 && setting.required ? " *" : "";
|
|
607
|
+
process.stdout.write(
|
|
608
|
+
`${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
609
|
+
`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
const hasUnset = SETTINGS.some(
|
|
613
|
+
(s) => s.required && cfg[s.key] === void 0
|
|
614
|
+
);
|
|
615
|
+
if (hasUnset) {
|
|
616
|
+
process.stdout.write(
|
|
617
|
+
'\n* = required but not configured. Run "azdo config wizard" to set up.\n'
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
const unset = new Command3("unset");
|
|
623
|
+
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
624
|
+
try {
|
|
625
|
+
unsetConfigValue(key);
|
|
626
|
+
if (options.json) {
|
|
627
|
+
process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
|
|
628
|
+
} else {
|
|
629
|
+
process.stdout.write(`Unset "${key}"
|
|
630
|
+
`);
|
|
631
|
+
}
|
|
632
|
+
} catch (err) {
|
|
633
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
634
|
+
process.stderr.write(`Error: ${message}
|
|
635
|
+
`);
|
|
636
|
+
process.exit(1);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
const wizard = new Command3("wizard");
|
|
640
|
+
wizard.description("Interactive wizard to configure all settings").action(async () => {
|
|
641
|
+
if (!process.stdin.isTTY) {
|
|
642
|
+
process.stderr.write(
|
|
643
|
+
"Error: Wizard requires an interactive terminal.\n"
|
|
644
|
+
);
|
|
645
|
+
process.exit(1);
|
|
646
|
+
}
|
|
647
|
+
const cfg = loadConfig();
|
|
648
|
+
const rl = createInterface2({
|
|
649
|
+
input: process.stdin,
|
|
650
|
+
output: process.stderr
|
|
651
|
+
});
|
|
652
|
+
const ask = (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
|
|
653
|
+
process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
|
|
654
|
+
process.stderr.write("=======================================\n\n");
|
|
655
|
+
for (const setting of SETTINGS) {
|
|
656
|
+
const current = cfg[setting.key];
|
|
657
|
+
const currentDisplay = current === void 0 ? "" : Array.isArray(current) ? current.join(",") : current;
|
|
658
|
+
const requiredTag = setting.required ? " (required)" : " (optional)";
|
|
659
|
+
process.stderr.write(`${setting.description}${requiredTag}
|
|
660
|
+
`);
|
|
661
|
+
if (setting.example) {
|
|
662
|
+
process.stderr.write(` Example: ${setting.example}
|
|
663
|
+
`);
|
|
664
|
+
}
|
|
665
|
+
const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
|
|
666
|
+
const answer = await ask(` ${setting.key}${defaultHint}: `);
|
|
667
|
+
const trimmed = answer.trim();
|
|
668
|
+
if (trimmed) {
|
|
669
|
+
setConfigValue(setting.key, trimmed);
|
|
670
|
+
process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
|
|
671
|
+
|
|
672
|
+
`);
|
|
673
|
+
} else if (currentDisplay) {
|
|
674
|
+
process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
|
|
675
|
+
|
|
676
|
+
`);
|
|
677
|
+
} else {
|
|
678
|
+
process.stderr.write(` -> Skipped "${setting.key}"
|
|
679
|
+
|
|
680
|
+
`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
rl.close();
|
|
684
|
+
process.stderr.write("Configuration complete!\n");
|
|
685
|
+
});
|
|
686
|
+
config.addCommand(set);
|
|
687
|
+
config.addCommand(get);
|
|
688
|
+
config.addCommand(list);
|
|
689
|
+
config.addCommand(unset);
|
|
690
|
+
config.addCommand(wizard);
|
|
691
|
+
return config;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/commands/set-state.ts
|
|
695
|
+
import { Command as Command4 } from "commander";
|
|
696
|
+
function createSetStateCommand() {
|
|
697
|
+
const command = new Command4("set-state");
|
|
698
|
+
command.description("Change the state of a work item").argument("<id>", "work item ID").argument("<state>", 'target state (e.g., "Active", "Closed")').option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
699
|
+
async (idStr, state, options) => {
|
|
700
|
+
const id = parseWorkItemId(idStr);
|
|
701
|
+
validateOrgProjectPair(options);
|
|
702
|
+
let context;
|
|
703
|
+
try {
|
|
704
|
+
context = resolveContext(options);
|
|
705
|
+
const credential = await resolvePat();
|
|
706
|
+
const operations = [
|
|
707
|
+
{ op: "add", path: "/fields/System.State", value: state }
|
|
708
|
+
];
|
|
709
|
+
const result = await updateWorkItem(context, id, credential.pat, "System.State", operations);
|
|
710
|
+
if (options.json) {
|
|
711
|
+
process.stdout.write(
|
|
712
|
+
JSON.stringify({
|
|
713
|
+
id: result.id,
|
|
714
|
+
rev: result.rev,
|
|
715
|
+
title: result.title,
|
|
716
|
+
field: result.fieldName,
|
|
717
|
+
value: result.fieldValue
|
|
718
|
+
}) + "\n"
|
|
719
|
+
);
|
|
720
|
+
} else {
|
|
721
|
+
process.stdout.write(`Updated work item ${result.id}: State -> ${state}
|
|
722
|
+
`);
|
|
723
|
+
}
|
|
724
|
+
} catch (err) {
|
|
725
|
+
handleCommandError(err, id, context, "write");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
);
|
|
729
|
+
return command;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/commands/assign.ts
|
|
733
|
+
import { Command as Command5 } from "commander";
|
|
734
|
+
function createAssignCommand() {
|
|
735
|
+
const command = new Command5("assign");
|
|
736
|
+
command.description("Assign a work item to a user, or unassign it").argument("<id>", "work item ID").argument("[name]", "user display name or email").option("--unassign", "clear the Assigned To field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
737
|
+
async (idStr, name, options) => {
|
|
738
|
+
const id = parseWorkItemId(idStr);
|
|
739
|
+
if (!name && !options.unassign) {
|
|
740
|
+
process.stderr.write(
|
|
741
|
+
"Error: Either provide a user name or use --unassign.\n"
|
|
742
|
+
);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
if (name && options.unassign) {
|
|
746
|
+
process.stderr.write(
|
|
747
|
+
"Error: Cannot provide both a user name and --unassign.\n"
|
|
748
|
+
);
|
|
749
|
+
process.exit(1);
|
|
750
|
+
}
|
|
751
|
+
validateOrgProjectPair(options);
|
|
752
|
+
let context;
|
|
753
|
+
try {
|
|
754
|
+
context = resolveContext(options);
|
|
755
|
+
const credential = await resolvePat();
|
|
756
|
+
const value = options.unassign ? "" : name;
|
|
757
|
+
const operations = [
|
|
758
|
+
{ op: "add", path: "/fields/System.AssignedTo", value }
|
|
759
|
+
];
|
|
760
|
+
const result = await updateWorkItem(context, id, credential.pat, "System.AssignedTo", operations);
|
|
761
|
+
if (options.json) {
|
|
762
|
+
process.stdout.write(
|
|
763
|
+
JSON.stringify({
|
|
764
|
+
id: result.id,
|
|
765
|
+
rev: result.rev,
|
|
766
|
+
title: result.title,
|
|
767
|
+
field: result.fieldName,
|
|
768
|
+
value: result.fieldValue
|
|
769
|
+
}) + "\n"
|
|
770
|
+
);
|
|
771
|
+
} else {
|
|
772
|
+
const displayValue = options.unassign ? "(unassigned)" : name;
|
|
773
|
+
process.stdout.write(`Updated work item ${result.id}: Assigned To -> ${displayValue}
|
|
774
|
+
`);
|
|
775
|
+
}
|
|
776
|
+
} catch (err) {
|
|
777
|
+
handleCommandError(err, id, context, "write");
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
);
|
|
781
|
+
return command;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/commands/set-field.ts
|
|
785
|
+
import { Command as Command6 } from "commander";
|
|
786
|
+
function createSetFieldCommand() {
|
|
787
|
+
const command = new Command6("set-field");
|
|
788
|
+
command.description("Set any work item field by its reference name").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Title)").argument("<value>", "new value for the field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
789
|
+
async (idStr, field, value, options) => {
|
|
790
|
+
const id = parseWorkItemId(idStr);
|
|
791
|
+
validateOrgProjectPair(options);
|
|
792
|
+
let context;
|
|
793
|
+
try {
|
|
794
|
+
context = resolveContext(options);
|
|
795
|
+
const credential = await resolvePat();
|
|
796
|
+
const operations = [
|
|
797
|
+
{ op: "add", path: `/fields/${field}`, value }
|
|
798
|
+
];
|
|
799
|
+
const result = await updateWorkItem(context, id, credential.pat, field, operations);
|
|
800
|
+
if (options.json) {
|
|
801
|
+
process.stdout.write(
|
|
802
|
+
JSON.stringify({
|
|
803
|
+
id: result.id,
|
|
804
|
+
rev: result.rev,
|
|
805
|
+
title: result.title,
|
|
806
|
+
field: result.fieldName,
|
|
807
|
+
value: result.fieldValue
|
|
808
|
+
}) + "\n"
|
|
809
|
+
);
|
|
810
|
+
} else {
|
|
811
|
+
process.stdout.write(`Updated work item ${result.id}: ${field} -> ${value}
|
|
812
|
+
`);
|
|
813
|
+
}
|
|
814
|
+
} catch (err) {
|
|
815
|
+
handleCommandError(err, id, context, "write");
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
);
|
|
819
|
+
return command;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// src/commands/get-md-field.ts
|
|
823
|
+
import { Command as Command7 } from "commander";
|
|
824
|
+
|
|
825
|
+
// src/services/md-convert.ts
|
|
826
|
+
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
827
|
+
|
|
828
|
+
// src/services/html-detect.ts
|
|
829
|
+
var HTML_TAG_REGEX = /<\/?([a-z][a-z0-9]*)\b/gi;
|
|
830
|
+
var HTML_TAGS = /* @__PURE__ */ new Set([
|
|
831
|
+
"p",
|
|
832
|
+
"br",
|
|
833
|
+
"div",
|
|
834
|
+
"span",
|
|
835
|
+
"strong",
|
|
836
|
+
"em",
|
|
837
|
+
"b",
|
|
838
|
+
"i",
|
|
839
|
+
"u",
|
|
840
|
+
"a",
|
|
841
|
+
"ul",
|
|
842
|
+
"ol",
|
|
843
|
+
"li",
|
|
844
|
+
"h1",
|
|
845
|
+
"h2",
|
|
846
|
+
"h3",
|
|
847
|
+
"h4",
|
|
848
|
+
"h5",
|
|
849
|
+
"h6",
|
|
850
|
+
"table",
|
|
851
|
+
"tr",
|
|
852
|
+
"td",
|
|
853
|
+
"th",
|
|
854
|
+
"img",
|
|
855
|
+
"pre",
|
|
856
|
+
"code"
|
|
857
|
+
]);
|
|
858
|
+
function isHtml(content) {
|
|
859
|
+
let match;
|
|
860
|
+
HTML_TAG_REGEX.lastIndex = 0;
|
|
861
|
+
while ((match = HTML_TAG_REGEX.exec(content)) !== null) {
|
|
862
|
+
if (HTML_TAGS.has(match[1].toLowerCase())) {
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/services/md-convert.ts
|
|
870
|
+
function htmlToMarkdown(html) {
|
|
871
|
+
return NodeHtmlMarkdown.translate(html);
|
|
872
|
+
}
|
|
873
|
+
function toMarkdown(content) {
|
|
874
|
+
if (isHtml(content)) {
|
|
875
|
+
return htmlToMarkdown(content);
|
|
876
|
+
}
|
|
877
|
+
return content;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/commands/get-md-field.ts
|
|
881
|
+
function createGetMdFieldCommand() {
|
|
882
|
+
const command = new Command7("get-md-field");
|
|
883
|
+
command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
|
|
884
|
+
async (idStr, field, options) => {
|
|
885
|
+
const id = parseWorkItemId(idStr);
|
|
886
|
+
validateOrgProjectPair(options);
|
|
887
|
+
let context;
|
|
888
|
+
try {
|
|
889
|
+
context = resolveContext(options);
|
|
890
|
+
const credential = await resolvePat();
|
|
891
|
+
const value = await getWorkItemFieldValue(context, id, credential.pat, field);
|
|
892
|
+
if (value === null) {
|
|
893
|
+
process.stdout.write("\n");
|
|
894
|
+
} else {
|
|
895
|
+
process.stdout.write(toMarkdown(value) + "\n");
|
|
896
|
+
}
|
|
897
|
+
} catch (err) {
|
|
898
|
+
handleCommandError(err, id, context, "read");
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
);
|
|
902
|
+
return command;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/commands/set-md-field.ts
|
|
906
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
907
|
+
import { Command as Command8 } from "commander";
|
|
908
|
+
function fail(message) {
|
|
909
|
+
process.stderr.write(`Error: ${message}
|
|
910
|
+
`);
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
function resolveContent(inlineContent, options) {
|
|
914
|
+
if (inlineContent && options.file) {
|
|
915
|
+
fail("Cannot specify both inline content and --file.");
|
|
916
|
+
}
|
|
917
|
+
if (options.file) {
|
|
918
|
+
return readFileContent(options.file);
|
|
919
|
+
}
|
|
920
|
+
if (inlineContent) {
|
|
921
|
+
return inlineContent;
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
function readFileContent(filePath) {
|
|
926
|
+
if (!existsSync(filePath)) {
|
|
927
|
+
fail(`File not found: ${filePath}`);
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
return readFileSync2(filePath, "utf-8");
|
|
931
|
+
} catch {
|
|
932
|
+
fail(`Cannot read file: ${filePath}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
async function readStdinContent() {
|
|
936
|
+
if (process.stdin.isTTY) {
|
|
937
|
+
fail(
|
|
938
|
+
"No content provided. Pass markdown content as the third argument, use --file, or pipe via stdin."
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
const chunks = [];
|
|
942
|
+
for await (const chunk of process.stdin) {
|
|
943
|
+
chunks.push(chunk);
|
|
944
|
+
}
|
|
945
|
+
const stdinContent = Buffer.concat(chunks).toString("utf-8").trimEnd();
|
|
946
|
+
if (!stdinContent) {
|
|
947
|
+
fail(
|
|
948
|
+
"No content provided via stdin. Pipe markdown content or use inline content or --file."
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
return stdinContent;
|
|
952
|
+
}
|
|
953
|
+
function formatOutput(result, options, field) {
|
|
954
|
+
if (options.json) {
|
|
955
|
+
process.stdout.write(
|
|
956
|
+
JSON.stringify({
|
|
957
|
+
id: result.id,
|
|
958
|
+
rev: result.rev,
|
|
959
|
+
field: result.fieldName,
|
|
960
|
+
value: result.fieldValue
|
|
961
|
+
}) + "\n"
|
|
962
|
+
);
|
|
963
|
+
} else {
|
|
964
|
+
process.stdout.write(`Updated work item ${result.id}: ${field} set with markdown content
|
|
965
|
+
`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function createSetMdFieldCommand() {
|
|
969
|
+
const command = new Command8("set-md-field");
|
|
970
|
+
command.description("Set a work item field with markdown content").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").argument("[content]", "markdown content to set").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").option("--file <path>", "read markdown content from file").action(
|
|
971
|
+
async (idStr, field, inlineContent, options) => {
|
|
972
|
+
const id = parseWorkItemId(idStr);
|
|
973
|
+
validateOrgProjectPair(options);
|
|
974
|
+
const content = resolveContent(inlineContent, options) ?? await readStdinContent();
|
|
975
|
+
let context;
|
|
976
|
+
try {
|
|
977
|
+
context = resolveContext(options);
|
|
978
|
+
const credential = await resolvePat();
|
|
979
|
+
const operations = [
|
|
980
|
+
{ op: "add", path: `/fields/${field}`, value: content },
|
|
981
|
+
{ op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
|
|
982
|
+
];
|
|
983
|
+
const result = await updateWorkItem(context, id, credential.pat, field, operations);
|
|
984
|
+
formatOutput(result, options, field);
|
|
985
|
+
} catch (err) {
|
|
986
|
+
handleCommandError(err, id, context, "write");
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
);
|
|
990
|
+
return command;
|
|
991
|
+
}
|
|
992
|
+
|
|
14
993
|
// src/index.ts
|
|
15
|
-
var program = new
|
|
994
|
+
var program = new Command9();
|
|
16
995
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
996
|
+
program.addCommand(createGetItemCommand());
|
|
997
|
+
program.addCommand(createClearPatCommand());
|
|
998
|
+
program.addCommand(createConfigCommand());
|
|
999
|
+
program.addCommand(createSetStateCommand());
|
|
1000
|
+
program.addCommand(createAssignCommand());
|
|
1001
|
+
program.addCommand(createSetFieldCommand());
|
|
1002
|
+
program.addCommand(createGetMdFieldCommand());
|
|
1003
|
+
program.addCommand(createSetMdFieldCommand());
|
|
17
1004
|
program.showHelpAfterError();
|
|
18
1005
|
program.parse();
|
|
19
1006
|
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-
|
|
3
|
+
"version": "0.2.0-hotfix-0-2-1.54",
|
|
4
4
|
"description": "Azure DevOps CLI tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"lint": "eslint src/",
|
|
15
15
|
"typecheck": "tsc --noEmit",
|
|
16
16
|
"format": "prettier --check src/",
|
|
17
|
-
"test": "vitest run"
|
|
17
|
+
"test": "npm run build && vitest run"
|
|
18
18
|
},
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
},
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"
|
|
25
|
+
"@napi-rs/keyring": "^1.2.0",
|
|
26
|
+
"commander": "^14.0.3",
|
|
27
|
+
"node-html-markdown": "^2.0.0"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
30
|
"@eslint/js": "^10.0.1",
|