azdo-cli 0.4.0 → 0.5.0-011-pr-status-checks.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +79 -12
  2. package/dist/index.js +276 -16
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -12,7 +12,8 @@ Azure DevOps CLI focused on work item read/write workflows.
12
12
  - Update work item state (`set-state`)
13
13
  - Assign and unassign work items (`assign`)
14
14
  - Set any work item field by reference name (`set-field`)
15
- - Create or update Tasks from markdown documents (`upsert`)
15
+ - 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`)
18
19
  - Check branch pull request status, open PRs to `develop`, and review active comments (`pr`)
@@ -26,6 +27,20 @@ Azure DevOps CLI focused on work item read/write workflows.
26
27
  npm install -g azdo-cli
27
28
  ```
28
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
+
29
44
  ## Authentication and Context Resolution
30
45
 
31
46
  PAT resolution order:
@@ -51,8 +66,12 @@ azdo get-item 12345
51
66
  # 3) Update state
52
67
  azdo set-state 12345 "Active"
53
68
 
54
- # 4) Create or update a Task from markdown
55
- 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."
56
75
  ```
57
76
 
58
77
  ## Command Cheat Sheet
@@ -63,7 +82,8 @@ azdo upsert --content $'---\nTitle: Improve markdown import UX\nState: New\n---'
63
82
  | `azdo set-state <id> <state>` | Change work item state | `--json`, `--org`, `--project` |
64
83
  | `azdo assign <id> [name]` | Assign or unassign owner | `--unassign`, `--json`, `--org`, `--project` |
65
84
  | `azdo set-field <id> <field> <value>` | Update any field | `--json`, `--org`, `--project` |
66
- | `azdo upsert [id]` | Create or update a Task from markdown | `--content`, `--file`, `--json`, `--org`, `--project` |
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` |
67
87
  | `azdo get-md-field <id> <field>` | Get field as markdown | `--org`, `--project` |
68
88
  | `azdo set-md-field <id> <field> [content]` | Set markdown field | `--file`, `--json`, `--org`, `--project` |
69
89
  | `azdo list-fields <id>` | List all fields of a work item | `--json`, `--org`, `--project` |
@@ -88,8 +108,6 @@ azdo get-item 12345 --fields "System.Tags,Microsoft.VSTS.Common.Priority"
88
108
  # Convert rich text fields to markdown
89
109
  azdo get-item 12345 --markdown
90
110
 
91
- # Disable markdown even if config is on
92
- azdo get-item 12345 --no-markdown
93
111
  ```
94
112
 
95
113
  ```bash
@@ -118,7 +136,7 @@ azdo list-fields 12345 --json
118
136
 
119
137
  The `get-item` command can convert HTML rich-text fields to readable markdown. Resolution order:
120
138
 
121
- 1. `--markdown` / `--no-markdown` flag (highest priority)
139
+ 1. `--markdown` flag enables markdown for the current call
122
140
  2. Config setting: `azdo config set markdown true`
123
141
  3. Default: off (HTML stripped to plain text)
124
142
 
@@ -155,9 +173,12 @@ azdo pr comments
155
173
 
156
174
  `azdo pr status`
157
175
 
158
- - Lists all pull requests for the current branch, including active, completed, and abandoned PRs
176
+ - Lists pull requests for the current branch
177
+ - Includes Azure DevOps pull request checks under each returned pull request
178
+ - Prints `Checks: none reported by Azure DevOps` when a pull request has no returned checks
179
+ - Shows `Detail: ...` lines for failed or errored checks when Azure DevOps provides description text
159
180
  - Prints `No pull requests found for branch <branch>.` when no PRs exist
160
- - Supports `--json` for machine-readable output
181
+ - Supports `--json` for machine-readable output, including a `checks` array per pull request
161
182
 
162
183
  `azdo pr open`
163
184
 
@@ -175,13 +196,48 @@ azdo pr comments
175
196
  - Prints `Pull request #<id> has no active comments.` when the PR has no active comment threads
176
197
  - Fails instead of guessing when no active PR or multiple active PRs exist
177
198
 
199
+ ### Work Item Comment Commands
200
+
201
+ 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.
202
+
203
+ ```bash
204
+ # Read the visible comment history for a work item
205
+ azdo comments list 12345
206
+
207
+ # Read the same history as JSON
208
+ azdo comments list 12345 --json
209
+
210
+ # Post a progress update
211
+ azdo comments add 12345 "Investigation complete. Working on the fix next."
212
+
213
+ # Post the update and return JSON
214
+ azdo comments add 12345 "Queued validation run." --json
215
+ ```
216
+
217
+ `azdo comments list`
218
+
219
+ - Resolves the target work item directly from the provided ID
220
+ - Retrieves the full visible comment history and follows Azure DevOps pagination internally
221
+ - Prints comments newest first with comment ID, author, timestamp, and body text
222
+ - Prints `Work item #<id> has no comments.` when the work item has no visible comments
223
+
224
+ `azdo comments add`
225
+
226
+ - Requires a non-empty `<text>` positional argument
227
+ - Preserves the supplied comment text as the submitted body
228
+ - Prints `Added comment #<commentId> to work item #<id>` on success
229
+ - Fails locally before any API call when the text is empty or whitespace-only
230
+
178
231
  ## azdo upsert
179
232
 
180
- `azdo upsert` accepts a single markdown task document and either creates a new Azure DevOps Task or updates an existing one. Omit `[id]` to create; pass `[id]` to update that work item in place.
233
+ `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.
181
234
 
182
235
  ```bash
183
- # Create from inline content
184
- azdo upsert --content $'---\nTitle: Improve markdown import UX\nAssigned To: user@example.com\nState: New\n---'
236
+ # Create a Bug from inline content
237
+ azdo upsert --type Bug --content $'---\nTitle: Improve markdown import UX\nAssigned To: user@example.com\nState: New\n---'
238
+
239
+ # Preserve the default Task create behavior
240
+ azdo upsert --content $'---\nTitle: Follow-up task\nAssigned To: user@example.com\nState: New\n---'
185
241
 
186
242
  # Update from a file
187
243
  azdo upsert 12345 --file ./task-import.md
@@ -194,9 +250,16 @@ The command requires exactly one source flag:
194
250
 
195
251
  - `azdo upsert [id] --content <markdown>`
196
252
  - `azdo upsert [id] --file <path>`
253
+ - `azdo upsert --type <work-item-type> --content <markdown>`
197
254
 
198
255
  If `--file` succeeds, the source file is deleted after the Azure DevOps write completes. If parsing, validation, or the API call fails, the file is preserved. If deletion fails after a successful write, the command still succeeds and prints a warning.
199
256
 
257
+ Type rules:
258
+
259
+ - `--type` is optional for create and defaults to `Task`.
260
+ - `--type` is only valid when creating a new work item.
261
+ - Human-readable and JSON success output include the resulting work item type.
262
+
200
263
  ### Task Document Format
201
264
 
202
265
  The document starts with YAML front matter for scalar fields, followed by optional `##` heading sections for markdown rich-text fields.
@@ -245,6 +308,7 @@ Clear semantics:
245
308
  {
246
309
  "action": "created",
247
310
  "id": 12345,
311
+ "workItemType": "User Story",
248
312
  "fields": {
249
313
  "System.Title": "Improve markdown import UX",
250
314
  "System.Description": "Implement a single-command task import flow."
@@ -288,6 +352,9 @@ These commands support `--json` for machine-readable output:
288
352
  - `assign`
289
353
  - `set-field`
290
354
  - `set-md-field`
355
+ - `upsert`
356
+ - `comments list|add`
357
+ - `pr status|open|comments`
291
358
  - `config set|get|list|unset`
292
359
 
293
360
  ## 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 Command12 } from "commander";
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) throw new Error("NOT_FOUND");
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) {
@@ -88,6 +96,42 @@ function writeHeaders(pat) {
88
96
  "Content-Type": "application/json-patch+json"
89
97
  };
90
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
+ }
91
135
  async function readWriteResponse(response, errorCode) {
92
136
  if (response.status === 400) {
93
137
  const serverMessage = await readResponseMessage(response) ?? "Unknown error";
@@ -196,6 +240,55 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
196
240
  }
197
241
  return stringifyFieldValue(value);
198
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
+ };
291
+ }
199
292
  async function updateWorkItem(context, id, pat, fieldName, operations) {
200
293
  const result = await applyWorkItemPatch(context, id, pat, operations);
201
294
  const title = result.fields["System.Title"];
@@ -633,7 +726,7 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
633
726
  `Error: Access denied. Your PAT may lack ${scope} permissions for project "${context?.project}".
634
727
  `
635
728
  );
636
- } else if (msg === "NOT_FOUND") {
729
+ } else if (msg.startsWith("NOT_FOUND")) {
637
730
  process.stderr.write(
638
731
  `Error: Work item ${id} not found in ${context?.org}/${context?.project}.
639
732
  `
@@ -1383,7 +1476,7 @@ function buildAppliedFields(fields) {
1383
1476
  function ensureTitleForCreate(fields) {
1384
1477
  const titleField = fields.find((field) => field.refName === "System.Title");
1385
1478
  if (!titleField || titleField.op === "clear" || titleField.value === null || titleField.value.trim() === "") {
1386
- fail2("Title is required when creating a task.");
1479
+ fail2("Title is required when creating a work item.");
1387
1480
  }
1388
1481
  }
1389
1482
  function writeSuccess(result, options) {
@@ -1395,7 +1488,7 @@ function writeSuccess(result, options) {
1395
1488
  const verb = result.action === "created" ? "Created" : "Updated";
1396
1489
  const fields = Object.keys(result.fields).join(", ");
1397
1490
  const suffix = fields ? ` (${fields})` : "";
1398
- process.stdout.write(`${verb} task #${result.id}${suffix}
1491
+ process.stdout.write(`${verb} ${result.workItemType} #${result.id}${suffix}
1399
1492
  `);
1400
1493
  }
1401
1494
  function cleanupSourceFile(sourceFile) {
@@ -1409,16 +1502,31 @@ function cleanupSourceFile(sourceFile) {
1409
1502
  `);
1410
1503
  }
1411
1504
  }
1412
- function buildUpsertResult(action, writeResult, fields) {
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) {
1413
1519
  const appliedFields = buildAppliedFields(fields);
1520
+ const workItemType = writeResult.fields["System.WorkItemType"];
1414
1521
  return {
1415
1522
  action,
1416
1523
  id: writeResult.id,
1524
+ workItemType: typeof workItemType === "string" && workItemType.trim() !== "" ? workItemType : fallbackWorkItemType,
1417
1525
  fields: appliedFields
1418
1526
  };
1419
1527
  }
1420
1528
  function isUpdateWriteError(err) {
1421
- return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NOT_FOUND" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("UPDATE_REJECTED:");
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:");
1422
1530
  }
1423
1531
  function isCreateWriteError(err) {
1424
1532
  return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("HTTP_");
@@ -1448,10 +1556,11 @@ function handleUpsertError(err, id, context) {
1448
1556
  }
1449
1557
  function createUpsertCommand() {
1450
1558
  const command = new Command9("upsert");
1451
- command.description("Create or update a Task from a markdown document").argument("[id]", "work item ID to update; omit to create a new Task").option("--content <markdown>", "task document content").option("--file <path>", "read task document from file").option("--json", "output result as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(async (idStr, options) => {
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) => {
1452
1560
  validateOrgProjectPair(options);
1453
1561
  const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
1454
1562
  const { content, sourceFile } = loadSourceContent(options);
1563
+ const createType = resolveCreateType(id, options);
1455
1564
  let context;
1456
1565
  try {
1457
1566
  context = resolveContext(options);
@@ -1464,14 +1573,19 @@ function createUpsertCommand() {
1464
1573
  const credential = await resolvePat();
1465
1574
  let writeResult;
1466
1575
  if (action === "created") {
1467
- writeResult = await createWorkItem(context, "Task", credential.pat, operations);
1576
+ writeResult = await createWorkItem(context, createType, credential.pat, operations);
1468
1577
  } else {
1469
1578
  if (id === void 0) {
1470
1579
  fail2("Work item ID is required for updates.");
1471
1580
  }
1472
1581
  writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
1473
1582
  }
1474
- const result = buildUpsertResult(action, writeResult, document.fields);
1583
+ const result = buildUpsertResult(
1584
+ action,
1585
+ writeResult,
1586
+ document.fields,
1587
+ action === "created" ? createType : "Work item"
1588
+ );
1475
1589
  writeSuccess(result, options);
1476
1590
  cleanupSourceFile(sourceFile);
1477
1591
  } catch (err) {
@@ -1558,6 +1672,13 @@ function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
1558
1672
  }
1559
1673
  return url;
1560
1674
  }
1675
+ function buildPullRequestStatusesUrl(context, repo, prId) {
1676
+ const url = new URL(
1677
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/statuses`
1678
+ );
1679
+ url.searchParams.set("api-version", "7.1");
1680
+ return url;
1681
+ }
1561
1682
  function mapPullRequest(repo, pullRequest) {
1562
1683
  return {
1563
1684
  id: pullRequest.pullRequestId,
@@ -1570,6 +1691,35 @@ function mapPullRequest(repo, pullRequest) {
1570
1691
  url: pullRequest._links.web.href
1571
1692
  };
1572
1693
  }
1694
+ function mapPullRequestCheckName(status) {
1695
+ const genre = status.context?.genre?.trim();
1696
+ const name = status.context?.name?.trim();
1697
+ if (genre && name) {
1698
+ return `${genre}/${name}`;
1699
+ }
1700
+ if (name) {
1701
+ return name;
1702
+ }
1703
+ if (genre) {
1704
+ return genre;
1705
+ }
1706
+ return `Status #${status.id}`;
1707
+ }
1708
+ function mapPullRequestCheck(status) {
1709
+ if (status.state === "notApplicable" || status.state === "notSet") {
1710
+ return null;
1711
+ }
1712
+ return {
1713
+ id: status.id,
1714
+ state: status.state,
1715
+ name: mapPullRequestCheckName(status),
1716
+ description: status.description ?? null,
1717
+ targetUrl: status.targetUrl ?? null,
1718
+ createdBy: status.createdBy?.displayName ?? null,
1719
+ createdAt: status.creationDate ?? null,
1720
+ updatedAt: status.updatedDate ?? null
1721
+ };
1722
+ }
1573
1723
  function mapComment(comment) {
1574
1724
  const content = comment.content?.trim();
1575
1725
  if (comment.isDeleted || !content) {
@@ -1611,6 +1761,14 @@ async function listPullRequests(context, repo, pat, sourceBranch, opts) {
1611
1761
  const data = await readJsonResponse(response);
1612
1762
  return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
1613
1763
  }
1764
+ async function getPullRequestChecks(context, repo, pat, prId) {
1765
+ const response = await fetchWithErrors(
1766
+ buildPullRequestStatusesUrl(context, repo, prId).toString(),
1767
+ { headers: authHeaders(pat) }
1768
+ );
1769
+ const data = await readJsonResponse(response);
1770
+ return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
1771
+ }
1614
1772
  async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
1615
1773
  const existing = await listPullRequests(context, repo, pat, sourceBranch, {
1616
1774
  status: "active",
@@ -1684,7 +1842,7 @@ function handlePrCommandError(err, context, mode = "read") {
1684
1842
  if (error.message === "NETWORK_ERROR") {
1685
1843
  writeError("Could not connect to Azure DevOps. Check your network connection.");
1686
1844
  }
1687
- if (error.message === "NOT_FOUND") {
1845
+ if (error.message.startsWith("NOT_FOUND")) {
1688
1846
  writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
1689
1847
  }
1690
1848
  if (error.message.startsWith("HTTP_")) {
@@ -1692,11 +1850,25 @@ function handlePrCommandError(err, context, mode = "read") {
1692
1850
  }
1693
1851
  writeError(error.message);
1694
1852
  }
1853
+ function formatPullRequestChecks(checks) {
1854
+ if (checks.length === 0) {
1855
+ return ["Checks: none reported by Azure DevOps"];
1856
+ }
1857
+ const lines = ["Checks:"];
1858
+ for (const check of checks) {
1859
+ lines.push(`- [${check.state}] ${check.name}`);
1860
+ if ((check.state === "failed" || check.state === "error") && check.description) {
1861
+ lines.push(` Detail: ${check.description}`);
1862
+ }
1863
+ }
1864
+ return lines;
1865
+ }
1695
1866
  function formatPullRequestBlock(pullRequest) {
1696
1867
  return [
1697
1868
  `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
1698
1869
  `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
1699
- pullRequest.url
1870
+ pullRequest.url,
1871
+ ...formatPullRequestChecks(pullRequest.checks)
1700
1872
  ].join("\n");
1701
1873
  }
1702
1874
  function formatThreads(prId, title, threads) {
@@ -1730,19 +1902,25 @@ function createPrStatusCommand() {
1730
1902
  const resolved = await resolvePrCommandContext(options);
1731
1903
  context = resolved.context;
1732
1904
  const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch);
1905
+ const pullRequestsWithChecks = await Promise.all(
1906
+ pullRequests.map(async (pullRequest) => ({
1907
+ ...pullRequest,
1908
+ checks: await getPullRequestChecks(resolved.context, resolved.repo, resolved.pat, pullRequest.id)
1909
+ }))
1910
+ );
1733
1911
  const { branch, repo } = resolved;
1734
- const result = { branch, repository: repo, pullRequests };
1912
+ const result = { branch, repository: repo, pullRequests: pullRequestsWithChecks };
1735
1913
  if (options.json) {
1736
1914
  process.stdout.write(`${JSON.stringify(result, null, 2)}
1737
1915
  `);
1738
1916
  return;
1739
1917
  }
1740
- if (pullRequests.length === 0) {
1918
+ if (pullRequestsWithChecks.length === 0) {
1741
1919
  process.stdout.write(`No pull requests found for branch ${branch}.
1742
1920
  `);
1743
1921
  return;
1744
1922
  }
1745
- process.stdout.write(`${pullRequests.map(formatPullRequestBlock).join("\n\n")}
1923
+ process.stdout.write(`${pullRequestsWithChecks.map(formatPullRequestBlock).join("\n\n")}
1746
1924
  `);
1747
1925
  } catch (err) {
1748
1926
  handlePrCommandError(err, context, "read");
@@ -1851,8 +2029,89 @@ function createPrCommand() {
1851
2029
  return command;
1852
2030
  }
1853
2031
 
2032
+ // src/commands/comments.ts
2033
+ import { Command as Command12 } from "commander";
2034
+ function writeError2(message) {
2035
+ process.stderr.write(`Error: ${message}
2036
+ `);
2037
+ process.exit(1);
2038
+ }
2039
+ function formatCommentHeader(comment) {
2040
+ const author = comment.author ?? "Unknown";
2041
+ const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
2042
+ return `Comment #${comment.id} by ${author} at ${timestamp}`;
2043
+ }
2044
+ function formatComments(result) {
2045
+ const lines = [`Comments for work item #${result.workItemId}`];
2046
+ for (const comment of result.comments) {
2047
+ lines.push("", formatCommentHeader(comment), comment.text);
2048
+ }
2049
+ return lines.join("\n");
2050
+ }
2051
+ function createCommentsListCommand() {
2052
+ const command = new Command12("list");
2053
+ 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) => {
2054
+ validateOrgProjectPair(options);
2055
+ const id = parseWorkItemId(idStr);
2056
+ let context;
2057
+ try {
2058
+ context = resolveContext(options);
2059
+ const credential = await resolvePat();
2060
+ const result = await listWorkItemComments(context, id, credential.pat);
2061
+ if (options.json) {
2062
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
2063
+ `);
2064
+ return;
2065
+ }
2066
+ if (result.comments.length === 0) {
2067
+ process.stdout.write(`Work item #${id} has no comments.
2068
+ `);
2069
+ return;
2070
+ }
2071
+ process.stdout.write(`${formatComments(result)}
2072
+ `);
2073
+ } catch (err) {
2074
+ handleCommandError(err, id, context, "read");
2075
+ }
2076
+ });
2077
+ return command;
2078
+ }
2079
+ function createCommentsAddCommand() {
2080
+ const command = new Command12("add");
2081
+ 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) => {
2082
+ validateOrgProjectPair(options);
2083
+ const id = parseWorkItemId(idStr);
2084
+ if (text.trim() === "") {
2085
+ writeError2("Comment text must be a non-empty string.");
2086
+ }
2087
+ let context;
2088
+ try {
2089
+ context = resolveContext(options);
2090
+ const credential = await resolvePat();
2091
+ const result = await addWorkItemComment(context, id, credential.pat, text);
2092
+ if (options.json) {
2093
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
2094
+ `);
2095
+ return;
2096
+ }
2097
+ process.stdout.write(`Added comment #${result.commentId} to work item #${result.workItemId}
2098
+ `);
2099
+ } catch (err) {
2100
+ handleCommandError(err, id, context, "write");
2101
+ }
2102
+ });
2103
+ return command;
2104
+ }
2105
+ function createCommentsCommand() {
2106
+ const command = new Command12("comments");
2107
+ command.description("Manage Azure DevOps work item comments");
2108
+ command.addCommand(createCommentsListCommand());
2109
+ command.addCommand(createCommentsAddCommand());
2110
+ return command;
2111
+ }
2112
+
1854
2113
  // src/index.ts
1855
- var program = new Command12();
2114
+ var program = new Command13();
1856
2115
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
1857
2116
  program.addCommand(createGetItemCommand());
1858
2117
  program.addCommand(createClearPatCommand());
@@ -1865,6 +2124,7 @@ program.addCommand(createSetMdFieldCommand());
1865
2124
  program.addCommand(createUpsertCommand());
1866
2125
  program.addCommand(createListFieldsCommand());
1867
2126
  program.addCommand(createPrCommand());
2127
+ program.addCommand(createCommentsCommand());
1868
2128
  program.showHelpAfterError();
1869
2129
  program.parse();
1870
2130
  if (process.argv.length <= 2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-011-pr-status-checks.158",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "typecheck": "tsc --noEmit",
16
16
  "format": "prettier --check src/",
17
17
  "test": "npm run build && vitest run tests/unit",
18
- "test:integration": "npm run build && vitest run tests/integration"
18
+ "test:integration": "npm run build && vitest run tests/integration",
19
+ "test:integration:full": "bash scripts/setup-keyring.sh && npm run test:integration"
19
20
  },
20
21
  "repository": {
21
22
  "type": "git",