azdo-cli 0.2.0-007-work-item-upsert.86 → 0.2.0-008-pull-request-handling.99

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 +39 -0
  2. package/dist/index.js +431 -83
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -15,6 +15,7 @@ Azure DevOps CLI focused on work item read/write workflows.
15
15
  - Create or update Tasks from markdown documents (`upsert`)
16
16
  - Read rich-text fields as markdown (`get-md-field`)
17
17
  - Set rich-text fields as markdown from inline text, file, or stdin (`set-md-field`)
18
+ - Check branch pull request status, open PRs to `develop`, and review active comments (`pr`)
18
19
  - Persist org/project/default fields in local config (`config`)
19
20
  - Store PAT in OS credential store (or use `AZDO_PAT`)
20
21
 
@@ -64,6 +65,7 @@ azdo upsert --content $'---\nTitle: Improve markdown import UX\nState: New\n---'
64
65
  | `azdo upsert [id]` | Create or update a Task from markdown | `--content`, `--file`, `--json`, `--org`, `--project` |
65
66
  | `azdo get-md-field <id> <field>` | Get field as markdown | `--org`, `--project` |
66
67
  | `azdo set-md-field <id> <field> [content]` | Set markdown field | `--file`, `--json`, `--org`, `--project` |
68
+ | `azdo pr <subcommand>` | Manage pull requests for the current branch | `status`, `open`, `comments`, `--json`, `--org`, `--project` |
67
69
  | `azdo config <subcommand>` | Manage saved settings | `set`, `get`, `list`, `unset`, `wizard`, `--json` |
68
70
  | `azdo clear-pat` | Remove stored PAT | none |
69
71
 
@@ -124,6 +126,43 @@ azdo set-md-field 12345 System.Description --file ./description.md
124
126
  cat description.md | azdo set-md-field 12345 System.Description
125
127
  ```
126
128
 
129
+ ### Pull Request Commands
130
+
131
+ 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.
132
+
133
+ ```bash
134
+ # Check whether the current branch already has pull requests
135
+ azdo pr status
136
+
137
+ # Open a pull request to develop
138
+ azdo pr open --title "Add PR handling" --description "Implements pr status, pr open, pr comments commands"
139
+
140
+ # Review active comments for the current branch's active pull request
141
+ azdo pr comments
142
+ ```
143
+
144
+ `azdo pr status`
145
+
146
+ - Lists all pull requests for the current branch, including active, completed, and abandoned PRs
147
+ - Prints `No pull requests found for branch <branch>.` when no PRs exist
148
+ - Supports `--json` for machine-readable output
149
+
150
+ `azdo pr open`
151
+
152
+ - Requires both `--title <title>` and `--description <description>`
153
+ - Targets `develop` automatically
154
+ - Creates a new active pull request when none exists
155
+ - Reuses the existing active PR when one already matches the branch and target
156
+ - Fails with a clear error when run from `develop` or when multiple active PRs already exist
157
+
158
+ `azdo pr comments`
159
+
160
+ - Resolves the single active pull request for the current branch
161
+ - Returns only active or pending threads with visible, non-deleted comments
162
+ - Groups text output by thread and shows file context when available
163
+ - Prints `Pull request #<id> has no active comments.` when the PR has no active comment threads
164
+ - Fails instead of guessing when no active PR or multiple active PRs exist
165
+
127
166
  ## azdo upsert
128
167
 
129
168
  `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.
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 Command10 } from "commander";
4
+ import { Command as Command11 } from "commander";
5
5
 
6
6
  // src/version.ts
7
7
  import { readFileSync } from "fs";
@@ -55,12 +55,18 @@ async function readResponseMessage(response) {
55
55
  function normalizeFieldList(fields) {
56
56
  return Array.from(new Set(fields.map((f) => f.trim()).filter((f) => f.length > 0)));
57
57
  }
58
+ function stringifyFieldValue(value) {
59
+ if (typeof value === "object" && value !== null) {
60
+ return JSON.stringify(value);
61
+ }
62
+ return String(value);
63
+ }
58
64
  function buildExtraFields(fields, requested) {
59
65
  const result = {};
60
66
  for (const name of requested) {
61
67
  const val = fields[name];
62
68
  if (val !== void 0 && val !== null) {
63
- result[name] = String(val);
69
+ result[name] = stringifyFieldValue(val);
64
70
  }
65
71
  }
66
72
  return Object.keys(result).length > 0 ? result : null;
@@ -119,7 +125,7 @@ async function getWorkItem(context, id, pat, extraFields) {
119
125
  }
120
126
  let combinedDescription = null;
121
127
  if (descriptionParts.length === 1) {
122
- combinedDescription = descriptionParts[0].value;
128
+ combinedDescription = descriptionParts.at(0)?.value ?? null;
123
129
  } else if (descriptionParts.length > 1) {
124
130
  combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
125
131
  }
@@ -158,13 +164,13 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
158
164
  if (value === void 0 || value === null || value === "") {
159
165
  return null;
160
166
  }
161
- return typeof value === "object" ? JSON.stringify(value) : `${value}`;
167
+ return stringifyFieldValue(value);
162
168
  }
163
169
  async function updateWorkItem(context, id, pat, fieldName, operations) {
164
170
  const result = await applyWorkItemPatch(context, id, pat, operations);
165
171
  const title = result.fields["System.Title"];
166
- const lastOp = operations[operations.length - 1];
167
- const fieldValue = lastOp.value ?? null;
172
+ const lastOp = operations.at(-1);
173
+ const fieldValue = lastOp?.value ?? null;
168
174
  return {
169
175
  id: result.id,
170
176
  rev: result.rev,
@@ -301,19 +307,19 @@ async function resolvePat() {
301
307
  import { execSync } from "child_process";
302
308
  var patterns = [
303
309
  // HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
304
- /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/.+$/,
310
+ /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)$/,
305
311
  // HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
306
- /^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/.+$/,
312
+ /^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+)$/,
307
313
  // HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
308
- /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/.+$/,
314
+ /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)$/,
309
315
  // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
310
- /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/.+$/,
316
+ /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/,
311
317
  // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
312
- /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/.+$/
318
+ /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/
313
319
  ];
314
320
  function parseAzdoRemote(url) {
315
321
  for (const pattern of patterns) {
316
- const match = url.match(pattern);
322
+ const match = pattern.exec(url);
317
323
  if (match) {
318
324
  const project = match[2];
319
325
  if (/^DefaultCollection$/i.test(project)) {
@@ -337,6 +343,35 @@ function detectAzdoContext() {
337
343
  }
338
344
  return context;
339
345
  }
346
+ function parseRepoName(url) {
347
+ for (const pattern of patterns) {
348
+ const match = pattern.exec(url);
349
+ if (match) {
350
+ return match[3];
351
+ }
352
+ }
353
+ return null;
354
+ }
355
+ function detectRepoName() {
356
+ let remoteUrl;
357
+ try {
358
+ remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
359
+ } catch {
360
+ throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
361
+ }
362
+ const repo = parseRepoName(remoteUrl);
363
+ if (!repo) {
364
+ throw new Error('Git remote "origin" is not an Azure DevOps URL. Check that origin points to Azure DevOps and try again.');
365
+ }
366
+ return repo;
367
+ }
368
+ function getCurrentBranch() {
369
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
370
+ if (branch === "HEAD") {
371
+ throw new Error("Not on a named branch. Check out a named branch and try again.");
372
+ }
373
+ return branch;
374
+ }
340
375
 
341
376
  // src/services/config-store.ts
342
377
  import fs from "fs";
@@ -609,18 +644,18 @@ function parseRequestedFields(raw) {
609
644
  }
610
645
  function stripHtml(html) {
611
646
  let text = html;
612
- text = text.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
613
- text = text.replace(/<br\s*\/?>/gi, "\n");
614
- text = text.replace(/<\/?(p|div)>/gi, "\n");
615
- text = text.replace(/<li>/gi, "\n");
616
- text = text.replace(/<[^>]*>/g, "");
617
- text = text.replace(/&amp;/g, "&");
618
- text = text.replace(/&lt;/g, "<");
619
- text = text.replace(/&gt;/g, ">");
620
- text = text.replace(/&quot;/g, '"');
621
- text = text.replace(/&#39;/g, "'");
622
- text = text.replace(/&nbsp;/g, " ");
623
- text = text.replace(/\n{3,}/g, "\n\n");
647
+ text = text.replaceAll(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
648
+ text = text.replaceAll(/<br\s*\/?>/gi, "\n");
649
+ text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
650
+ text = text.replaceAll(/<li>/gi, "\n");
651
+ text = text.replaceAll(/<[^>]*>/g, "");
652
+ text = text.replaceAll("&amp;", "&");
653
+ text = text.replaceAll("&lt;", "<");
654
+ text = text.replaceAll("&gt;", ">");
655
+ text = text.replaceAll("&quot;", '"');
656
+ text = text.replaceAll("&#39;", "'");
657
+ text = text.replaceAll("&nbsp;", " ");
658
+ text = text.replaceAll(/\n{3,}/g, "\n\n");
624
659
  return text.trim();
625
660
  }
626
661
  function convertRichText(html, markdown) {
@@ -641,16 +676,19 @@ function summarizeDescription(text, label) {
641
676
  return [`${label("Description:")}${firstThree.join("\n")}${suffix}`];
642
677
  }
643
678
  function formatWorkItem(workItem, short, markdown = false) {
644
- const lines = [];
645
679
  const label = (name) => name.padEnd(13);
646
- lines.push(`${label("ID:")}${workItem.id}`);
647
- lines.push(`${label("Type:")}${workItem.type}`);
648
- lines.push(`${label("Title:")}${workItem.title}`);
649
- lines.push(`${label("State:")}${workItem.state}`);
650
- lines.push(`${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`);
680
+ const lines = [
681
+ `${label("ID:")}${workItem.id}`,
682
+ `${label("Type:")}${workItem.type}`,
683
+ `${label("Title:")}${workItem.title}`,
684
+ `${label("State:")}${workItem.state}`,
685
+ `${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`
686
+ ];
651
687
  if (!short) {
652
- lines.push(`${label("Area:")}${workItem.areaPath}`);
653
- lines.push(`${label("Iteration:")}${workItem.iterationPath}`);
688
+ lines.push(
689
+ `${label("Area:")}${workItem.areaPath}`,
690
+ `${label("Iteration:")}${workItem.iterationPath}`
691
+ );
654
692
  }
655
693
  lines.push(`${label("URL:")}${workItem.url}`);
656
694
  if (workItem.extraFields) {
@@ -661,8 +699,7 @@ function formatWorkItem(workItem, short, markdown = false) {
661
699
  if (short) {
662
700
  lines.push(...summarizeDescription(descriptionText, label));
663
701
  } else {
664
- lines.push("Description:");
665
- lines.push(descriptionText);
702
+ lines.push("Description:", descriptionText);
666
703
  }
667
704
  return lines.join("\n");
668
705
  }
@@ -676,7 +713,7 @@ function createGetItemCommand() {
676
713
  try {
677
714
  context = resolveContext(options);
678
715
  const credential = await resolvePat();
679
- const fieldsList = options.fields !== void 0 ? parseRequestedFields(options.fields) : parseRequestedFields(loadConfig().fields);
716
+ const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
680
717
  const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
681
718
  const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
682
719
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
@@ -707,6 +744,63 @@ function createClearPatCommand() {
707
744
  // src/commands/config.ts
708
745
  import { Command as Command3 } from "commander";
709
746
  import { createInterface as createInterface2 } from "readline";
747
+ function formatConfigValue(value, unsetFallback = "") {
748
+ if (value === void 0) {
749
+ return unsetFallback;
750
+ }
751
+ return Array.isArray(value) ? value.join(",") : value;
752
+ }
753
+ function writeConfigList(cfg) {
754
+ const keyWidth = 10;
755
+ const valueWidth = 30;
756
+ for (const setting of SETTINGS) {
757
+ const raw = cfg[setting.key];
758
+ const value = formatConfigValue(raw, "(not set)");
759
+ const marker = raw === void 0 && setting.required ? " *" : "";
760
+ process.stdout.write(
761
+ `${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
762
+ `
763
+ );
764
+ }
765
+ const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
766
+ if (hasUnset) {
767
+ process.stdout.write(
768
+ '\n* = required but not configured. Run "azdo config wizard" to set up.\n'
769
+ );
770
+ }
771
+ }
772
+ function createAsk(rl) {
773
+ return (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
774
+ }
775
+ async function promptForSetting(cfg, setting, ask) {
776
+ const currentDisplay = String(formatConfigValue(cfg[setting.key], ""));
777
+ const requiredTag = setting.required ? " (required)" : " (optional)";
778
+ process.stderr.write(`${setting.description}${requiredTag}
779
+ `);
780
+ if (setting.example) {
781
+ process.stderr.write(` Example: ${setting.example}
782
+ `);
783
+ }
784
+ const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
785
+ const answer = await ask(` ${setting.key}${defaultHint}: `);
786
+ const trimmed = answer.trim();
787
+ if (trimmed) {
788
+ setConfigValue(setting.key, trimmed);
789
+ process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
790
+
791
+ `);
792
+ return;
793
+ }
794
+ if (currentDisplay) {
795
+ process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
796
+
797
+ `);
798
+ return;
799
+ }
800
+ process.stderr.write(` -> Skipped "${setting.key}"
801
+
802
+ `);
803
+ }
710
804
  function createConfigCommand() {
711
805
  const config = new Command3("config");
712
806
  config.description("Manage CLI settings");
@@ -759,27 +853,9 @@ function createConfigCommand() {
759
853
  const cfg = loadConfig();
760
854
  if (options.json) {
761
855
  process.stdout.write(JSON.stringify(cfg) + "\n");
762
- } else {
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
- }
856
+ return;
782
857
  }
858
+ writeConfigList(cfg);
783
859
  });
784
860
  const unset = new Command3("unset");
785
861
  unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
@@ -811,36 +887,11 @@ function createConfigCommand() {
811
887
  input: process.stdin,
812
888
  output: process.stderr
813
889
  });
814
- const ask = (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
890
+ const ask = createAsk(rl);
815
891
  process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
816
892
  process.stderr.write("=======================================\n\n");
817
893
  for (const setting of SETTINGS) {
818
- const current = cfg[setting.key];
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
- }
894
+ await promptForSetting(cfg, setting, ask);
844
895
  }
845
896
  rl.close();
846
897
  process.stderr.write("Configuration complete!\n");
@@ -1400,8 +1451,304 @@ function createUpsertCommand() {
1400
1451
  return command;
1401
1452
  }
1402
1453
 
1454
+ // src/commands/pr.ts
1455
+ import { Command as Command10 } from "commander";
1456
+
1457
+ // src/services/pr-client.ts
1458
+ function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
1459
+ const url = new URL(
1460
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
1461
+ );
1462
+ url.searchParams.set("api-version", "7.1");
1463
+ url.searchParams.set("searchCriteria.sourceRefName", `refs/heads/${sourceBranch}`);
1464
+ if (opts?.status) {
1465
+ url.searchParams.set("searchCriteria.status", opts.status);
1466
+ }
1467
+ if (opts?.targetBranch) {
1468
+ url.searchParams.set("searchCriteria.targetRefName", `refs/heads/${opts.targetBranch}`);
1469
+ }
1470
+ return url;
1471
+ }
1472
+ function mapPullRequest(repo, pullRequest) {
1473
+ return {
1474
+ id: pullRequest.pullRequestId,
1475
+ title: pullRequest.title,
1476
+ repository: repo,
1477
+ sourceRefName: pullRequest.sourceRefName,
1478
+ targetRefName: pullRequest.targetRefName,
1479
+ status: pullRequest.status,
1480
+ createdBy: pullRequest.createdBy?.displayName ?? null,
1481
+ url: pullRequest._links.web.href
1482
+ };
1483
+ }
1484
+ function mapComment(comment) {
1485
+ const content = comment.content?.trim();
1486
+ if (comment.isDeleted || !content) {
1487
+ return null;
1488
+ }
1489
+ return {
1490
+ id: comment.id,
1491
+ author: comment.author?.displayName ?? null,
1492
+ content,
1493
+ publishedAt: comment.publishedDate ?? null
1494
+ };
1495
+ }
1496
+ function mapThread(thread) {
1497
+ if (thread.status !== "active" && thread.status !== "pending") {
1498
+ return null;
1499
+ }
1500
+ const comments = thread.comments.map(mapComment).filter((comment) => comment !== null);
1501
+ if (comments.length === 0) {
1502
+ return null;
1503
+ }
1504
+ return {
1505
+ id: thread.id,
1506
+ status: thread.status,
1507
+ threadContext: thread.threadContext?.filePath ?? null,
1508
+ comments
1509
+ };
1510
+ }
1511
+ async function readJsonResponse(response) {
1512
+ if (!response.ok) {
1513
+ throw new Error(`HTTP_${response.status}`);
1514
+ }
1515
+ return response.json();
1516
+ }
1517
+ async function listPullRequests(context, repo, pat, sourceBranch, opts) {
1518
+ const response = await fetchWithErrors(
1519
+ buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
1520
+ { headers: authHeaders(pat) }
1521
+ );
1522
+ const data = await readJsonResponse(response);
1523
+ return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
1524
+ }
1525
+ async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
1526
+ const existing = await listPullRequests(context, repo, pat, sourceBranch, {
1527
+ status: "active",
1528
+ targetBranch: "develop"
1529
+ });
1530
+ if (existing.length === 1) {
1531
+ return {
1532
+ branch: sourceBranch,
1533
+ targetBranch: "develop",
1534
+ created: false,
1535
+ pullRequest: existing[0]
1536
+ };
1537
+ }
1538
+ if (existing.length > 1) {
1539
+ throw new Error(`AMBIGUOUS_PRS:${existing.map((pullRequest) => pullRequest.id).join(",")}`);
1540
+ }
1541
+ const payload = {
1542
+ sourceRefName: `refs/heads/${sourceBranch}`,
1543
+ targetRefName: "refs/heads/develop",
1544
+ title,
1545
+ description
1546
+ };
1547
+ const url = new URL(
1548
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
1549
+ );
1550
+ url.searchParams.set("api-version", "7.1");
1551
+ const response = await fetchWithErrors(url.toString(), {
1552
+ method: "POST",
1553
+ headers: {
1554
+ ...authHeaders(pat),
1555
+ "Content-Type": "application/json"
1556
+ },
1557
+ body: JSON.stringify(payload)
1558
+ });
1559
+ const data = await readJsonResponse(response);
1560
+ return {
1561
+ branch: sourceBranch,
1562
+ targetBranch: "develop",
1563
+ created: true,
1564
+ pullRequest: mapPullRequest(repo, data)
1565
+ };
1566
+ }
1567
+ async function getPullRequestThreads(context, repo, pat, prId) {
1568
+ const url = new URL(
1569
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
1570
+ );
1571
+ url.searchParams.set("api-version", "7.1");
1572
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
1573
+ const data = await readJsonResponse(response);
1574
+ return data.value.map(mapThread).filter((thread) => thread !== null);
1575
+ }
1576
+
1577
+ // src/commands/pr.ts
1578
+ function formatBranchName(refName) {
1579
+ return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
1580
+ }
1581
+ function writeError(message) {
1582
+ process.stderr.write(`Error: ${message}
1583
+ `);
1584
+ process.exit(1);
1585
+ }
1586
+ function handlePrCommandError(err, context, mode = "read") {
1587
+ const error = err instanceof Error ? err : new Error(String(err));
1588
+ if (error.message === "AUTH_FAILED") {
1589
+ const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
1590
+ writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`);
1591
+ }
1592
+ if (error.message === "PERMISSION_DENIED") {
1593
+ writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`);
1594
+ }
1595
+ if (error.message === "NETWORK_ERROR") {
1596
+ writeError("Could not connect to Azure DevOps. Check your network connection.");
1597
+ }
1598
+ if (error.message === "NOT_FOUND") {
1599
+ writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
1600
+ }
1601
+ if (error.message.startsWith("HTTP_")) {
1602
+ writeError(`Azure DevOps request failed with ${error.message}.`);
1603
+ }
1604
+ writeError(error.message);
1605
+ }
1606
+ function formatPullRequestBlock(pullRequest) {
1607
+ return [
1608
+ `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
1609
+ `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
1610
+ pullRequest.url
1611
+ ].join("\n");
1612
+ }
1613
+ function formatThreads(prId, title, threads) {
1614
+ const lines = [`Active comments for pull request #${prId}: ${title}`];
1615
+ for (const thread of threads) {
1616
+ lines.push("");
1617
+ lines.push(`Thread #${thread.id} [${thread.status}] ${thread.threadContext ?? "(general)"}`);
1618
+ for (const comment of thread.comments) {
1619
+ lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
1620
+ }
1621
+ }
1622
+ return lines.join("\n");
1623
+ }
1624
+ function createPrStatusCommand() {
1625
+ const command = new Command10("status");
1626
+ 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) => {
1627
+ validateOrgProjectPair(options);
1628
+ let context;
1629
+ try {
1630
+ context = resolveContext(options);
1631
+ const repo = detectRepoName();
1632
+ const branch = getCurrentBranch();
1633
+ const credential = await resolvePat();
1634
+ const pullRequests = await listPullRequests(context, repo, credential.pat, branch);
1635
+ const result = { branch, repository: repo, pullRequests };
1636
+ if (options.json) {
1637
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1638
+ `);
1639
+ return;
1640
+ }
1641
+ if (pullRequests.length === 0) {
1642
+ process.stdout.write(`No pull requests found for branch ${branch}.
1643
+ `);
1644
+ return;
1645
+ }
1646
+ process.stdout.write(`${pullRequests.map(formatPullRequestBlock).join("\n\n")}
1647
+ `);
1648
+ } catch (err) {
1649
+ handlePrCommandError(err, context, "read");
1650
+ }
1651
+ });
1652
+ return command;
1653
+ }
1654
+ function createPrOpenCommand() {
1655
+ const command = new Command10("open");
1656
+ 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) => {
1657
+ validateOrgProjectPair(options);
1658
+ const title = options.title?.trim();
1659
+ if (!title) {
1660
+ writeError("--title is required for pull request creation.");
1661
+ }
1662
+ const description = options.description?.trim();
1663
+ if (!description) {
1664
+ writeError("--description is required for pull request creation.");
1665
+ }
1666
+ let context;
1667
+ try {
1668
+ context = resolveContext(options);
1669
+ const repo = detectRepoName();
1670
+ const branch = getCurrentBranch();
1671
+ if (branch === "develop") {
1672
+ writeError("Pull request creation requires a source branch other than develop.");
1673
+ }
1674
+ const credential = await resolvePat();
1675
+ const result = await openPullRequest(context, repo, credential.pat, branch, title, description);
1676
+ if (options.json) {
1677
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1678
+ `);
1679
+ return;
1680
+ }
1681
+ if (result.created) {
1682
+ process.stdout.write(`Created pull request #${result.pullRequest.id}: ${result.pullRequest.title}
1683
+ ${result.pullRequest.url}
1684
+ `);
1685
+ return;
1686
+ }
1687
+ process.stdout.write(
1688
+ `Active pull request already exists for ${branch} -> develop: #${result.pullRequest.id}
1689
+ ${result.pullRequest.url}
1690
+ `
1691
+ );
1692
+ } catch (err) {
1693
+ if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
1694
+ const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
1695
+ writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
1696
+ }
1697
+ handlePrCommandError(err, context, "write");
1698
+ }
1699
+ });
1700
+ return command;
1701
+ }
1702
+ function createPrCommentsCommand() {
1703
+ const command = new Command10("comments");
1704
+ 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) => {
1705
+ validateOrgProjectPair(options);
1706
+ let context;
1707
+ try {
1708
+ context = resolveContext(options);
1709
+ const repo = detectRepoName();
1710
+ const branch = getCurrentBranch();
1711
+ const credential = await resolvePat();
1712
+ const pullRequests = await listPullRequests(context, repo, credential.pat, branch, { status: "active" });
1713
+ if (pullRequests.length === 0) {
1714
+ writeError(`No active pull request found for branch ${branch}.`);
1715
+ }
1716
+ if (pullRequests.length > 1) {
1717
+ const ids = pullRequests.map((pullRequest2) => `#${pullRequest2.id}`).join(", ");
1718
+ writeError(`Multiple active pull requests found for branch ${branch}: ${ids}. Use pr status to review them.`);
1719
+ }
1720
+ const pullRequest = pullRequests[0];
1721
+ const threads = await getPullRequestThreads(context, repo, credential.pat, pullRequest.id);
1722
+ const result = { branch, pullRequest, threads };
1723
+ if (options.json) {
1724
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1725
+ `);
1726
+ return;
1727
+ }
1728
+ if (threads.length === 0) {
1729
+ process.stdout.write(`Pull request #${pullRequest.id} has no active comments.
1730
+ `);
1731
+ return;
1732
+ }
1733
+ process.stdout.write(`${formatThreads(pullRequest.id, pullRequest.title, threads)}
1734
+ `);
1735
+ } catch (err) {
1736
+ handlePrCommandError(err, context, "read");
1737
+ }
1738
+ });
1739
+ return command;
1740
+ }
1741
+ function createPrCommand() {
1742
+ const command = new Command10("pr");
1743
+ command.description("Manage Azure DevOps pull requests");
1744
+ command.addCommand(createPrStatusCommand());
1745
+ command.addCommand(createPrOpenCommand());
1746
+ command.addCommand(createPrCommentsCommand());
1747
+ return command;
1748
+ }
1749
+
1403
1750
  // src/index.ts
1404
- var program = new Command10();
1751
+ var program = new Command11();
1405
1752
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
1406
1753
  program.addCommand(createGetItemCommand());
1407
1754
  program.addCommand(createClearPatCommand());
@@ -1412,6 +1759,7 @@ program.addCommand(createSetFieldCommand());
1412
1759
  program.addCommand(createGetMdFieldCommand());
1413
1760
  program.addCommand(createSetMdFieldCommand());
1414
1761
  program.addCommand(createUpsertCommand());
1762
+ program.addCommand(createPrCommand());
1415
1763
  program.showHelpAfterError();
1416
1764
  program.parse();
1417
1765
  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-007-work-item-upsert.86",
3
+ "version": "0.2.0-008-pull-request-handling.99",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,12 +28,12 @@
28
28
  },
29
29
  "devDependencies": {
30
30
  "@eslint/js": "^10.0.1",
31
- "@types/node": "^25.3.3",
32
- "eslint": "^10.0.2",
31
+ "@types/node": "^25.5.0",
32
+ "eslint": "^10.1.0",
33
33
  "prettier": "^3.8.1",
34
34
  "tsup": "^8.5.1",
35
35
  "typescript": "^5.9.3",
36
- "typescript-eslint": "^8.56.1",
37
- "vitest": "^4.0.18"
36
+ "typescript-eslint": "^8.57.2",
37
+ "vitest": "^4.1.2"
38
38
  }
39
39
  }