azdo-cli 0.19.0-feature-037-api-error-surfacing.701 → 0.19.0-feature-038-pr-update.706

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 +11 -1
  2. package/dist/index.js +218 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,8 @@ Azure DevOps CLI focused on work item read/write workflows.
15
15
  - Attach a local file to a work item, or remove a named attachment (`add-attachment`, `delete-attachment`)
16
16
  - Read/write rich-text fields as markdown (`get-md-field`, `set-md-field`)
17
17
  - Download images embedded in rich-text fields, optionally resized for LLM use (`get-item`/`get-md-field` `--download-images`, `--resize-images`)
18
- - Check branch pull request status, open PRs to `develop` (optionally pre-filled from a repository-defined template), list PR comment threads for any PR (`--pr-number`), resolve/reopen threads, link/unlink work items, and add/remove required or optional reviewers — all from the CLI (`pr`)
18
+ - Check branch pull request status, open PRs to `develop` (optionally pre-filled from a repository-defined template), update an existing PR's title or description (`pr update`), list PR comment threads for any PR (`--pr-number`), resolve/reopen threads, link/unlink work items, and add/remove required or optional reviewers — all from the CLI (`pr`)
19
+ - Feed long PR titles, descriptions and comment bodies from a file or a pipe instead of the shell (`--title-file`, `--description-file`, `--file`; `-` reads standard input)
19
20
  - Persist org/project/default fields in local config (`config`)
20
21
  - List all fields of a work item (`list-fields`)
21
22
  - Authenticate per Azure DevOps organization with `azdo auth login` — OAuth (Microsoft Entra) by default, or a Personal Access Token via `--use-pat` (or the `AZDO_PAT` env var). Credentials are stored in the OS credential store. Inspect with `azdo auth status`, remove with `azdo auth logout`. Diagnose auth problems with `azdo auth diagnose`. See [docs/authentication.md](docs/authentication.md).
@@ -85,13 +86,22 @@ azdo pr comments edit 148 --file plan.md --pr-number 64 # rewrite it in p
85
86
  azdo pr comments reply 148 "Great suggestion, I'll address it." # human-readable output
86
87
  azdo pr comments reply 148 "Done." --pr-number 64 --json # JSON: { pullRequestId, threadId, commentId, content }
87
88
  azdo pr comment-reply 148 "Done." --pr-number 64 # flat alias, identical behaviour
89
+ git log -1 --format=%B | azdo pr comments add --file - --pr-number 64 # "-" reads standard input
88
90
 
89
91
  # Open a pull request — description from a repo template when you don't pass one
90
92
  azdo pr open --title "Fix the thing" --description "Because X was broken"
91
93
  azdo pr open --title "Fix the thing" # uses docs/pull_request_template[/branches/<branch>].md if present
94
+ azdo pr open --title "Fix the thing" --description-file body.md # or --description-file - to pipe it in
92
95
  # The description plus the template must stay within Azure DevOps' 4000-character cap; over it,
93
96
  # the command says by how much and creates nothing. See docs/commands.md.
94
97
 
98
+ # Fix a title or description after the fact — a PR is no longer write-once
99
+ azdo pr update --pr-number 64 --title "A real title" # description untouched
100
+ azdo pr update --pr-number 64 --description-file body.md # replaces literally; no template is prepended
101
+ cat body.md | azdo pr edit --pr-number 64 --description-file - # "edit" is an alias for "update"
102
+ azdo pr update --pr-number 64 --title "A real title" --json # { pullRequestId, title, description, url, noop, updatedFields }
103
+ # Re-running with the values it already has writes nothing and reports "noop": true.
104
+
95
105
  # Link/unlink a work item, add/remove reviewers
96
106
  azdo pr work-items link 1234 --pr-number 64
97
107
  azdo pr work-items unlink 1234 --pr-number 64
package/dist/index.js CHANGED
@@ -3750,6 +3750,28 @@ async function openPullRequest(context, repo, cred, sourceBranch, title, descrip
3750
3750
  pullRequest: mapPullRequest(context, repo, data)
3751
3751
  };
3752
3752
  }
3753
+ async function updatePullRequest(context, repo, cred, prId, fields) {
3754
+ if (fields.description !== void 0 && fields.description.length > MAX_PR_DESCRIPTION_CHARS) {
3755
+ const overflow = fields.description.length - MAX_PR_DESCRIPTION_CHARS;
3756
+ throw new Error(
3757
+ `DESCRIPTION_TOO_LONG: description is ${fields.description.length} characters, exceeding the Azure DevOps limit of ${MAX_PR_DESCRIPTION_CHARS} characters. Shorten the description by at least ${overflow} characters.`
3758
+ );
3759
+ }
3760
+ const url = new URL(
3761
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests/${prId}`
3762
+ );
3763
+ url.searchParams.set("api-version", "7.1");
3764
+ const response = await fetchWithErrors(url.toString(), {
3765
+ method: "PATCH",
3766
+ headers: {
3767
+ ...authHeaders(cred),
3768
+ "Content-Type": "application/json"
3769
+ },
3770
+ body: JSON.stringify(fields)
3771
+ });
3772
+ const data = await readJsonResponse(response);
3773
+ return mapPullRequest(context, repo, data);
3774
+ }
3753
3775
  async function getPullRequestThreads(context, repo, cred, prId) {
3754
3776
  const url = new URL(
3755
3777
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
@@ -4107,6 +4129,27 @@ function writeContractError(line) {
4107
4129
  process.exitCode = 1;
4108
4130
  }
4109
4131
  var EMPTY_BODY_ERROR = "Comment text must not be empty. Pass the text inline or use --file <path>.";
4132
+ var STDIN_PATH = "-";
4133
+ function readTextSource(file) {
4134
+ if (file === STDIN_PATH) {
4135
+ try {
4136
+ return readFileSync5(0, "utf-8");
4137
+ } catch {
4138
+ writeError("Cannot read standard input.");
4139
+ return null;
4140
+ }
4141
+ }
4142
+ if (!existsSync5(file)) {
4143
+ writeError(`File not found: ${file}`);
4144
+ return null;
4145
+ }
4146
+ try {
4147
+ return readFileSync5(file, "utf-8");
4148
+ } catch {
4149
+ writeError(`Cannot read file: ${file}`);
4150
+ return null;
4151
+ }
4152
+ }
4110
4153
  function resolveCommentBody(inline, file, emptyMessage = EMPTY_BODY_ERROR) {
4111
4154
  if (inline !== void 0 && file !== void 0) {
4112
4155
  writeError("Cannot specify both inline text and --file.");
@@ -4114,16 +4157,11 @@ function resolveCommentBody(inline, file, emptyMessage = EMPTY_BODY_ERROR) {
4114
4157
  }
4115
4158
  let body;
4116
4159
  if (file !== void 0) {
4117
- if (!existsSync5(file)) {
4118
- writeError(`File not found: ${file}`);
4119
- return null;
4120
- }
4121
- try {
4122
- body = readFileSync5(file, "utf-8");
4123
- } catch {
4124
- writeError(`Cannot read file: ${file}`);
4160
+ const read = readTextSource(file);
4161
+ if (read === null) {
4125
4162
  return null;
4126
4163
  }
4164
+ body = read;
4127
4165
  } else if (inline !== void 0) {
4128
4166
  body = inline;
4129
4167
  } else {
@@ -4137,6 +4175,33 @@ function resolveCommentBody(inline, file, emptyMessage = EMPTY_BODY_ERROR) {
4137
4175
  }
4138
4176
  return trimmed;
4139
4177
  }
4178
+ function resolveOptionalTextInput(inline, file, flag) {
4179
+ if (inline !== void 0 && file !== void 0) {
4180
+ writeError(`Cannot specify both --${flag} and --${flag}-file.`);
4181
+ return null;
4182
+ }
4183
+ if (inline === void 0 && file === void 0) {
4184
+ return void 0;
4185
+ }
4186
+ const label = flag === "title" ? "Title" : "Description";
4187
+ const emptyMessage = `${label} must not be empty. Pass the text inline or use --${flag}-file <path>.`;
4188
+ let value;
4189
+ if (file !== void 0) {
4190
+ const read = readTextSource(file);
4191
+ if (read === null) {
4192
+ return null;
4193
+ }
4194
+ value = read;
4195
+ } else {
4196
+ value = inline;
4197
+ }
4198
+ const trimmed = value.trim();
4199
+ if (trimmed === "") {
4200
+ writeError(emptyMessage);
4201
+ return null;
4202
+ }
4203
+ return trimmed;
4204
+ }
4140
4205
  function parseNonNegativeInt(raw) {
4141
4206
  if (!/^\d+$/.test(raw)) {
4142
4207
  return null;
@@ -4392,11 +4457,34 @@ function handlePrOpenError(err, context) {
4392
4457
  }
4393
4458
  handlePrCommandError(err, context, "write");
4394
4459
  }
4460
+ function resolveOpenDescription(inline, file) {
4461
+ if (inline !== void 0 && file !== void 0) {
4462
+ writeError("Cannot specify both --description and --description-file.");
4463
+ return null;
4464
+ }
4465
+ if (file !== void 0) {
4466
+ const fromFile = readTextSource(file);
4467
+ if (fromFile === null) {
4468
+ return null;
4469
+ }
4470
+ const trimmed = fromFile.trim();
4471
+ if (trimmed === "") {
4472
+ writeError("Description must not be empty. Pass the text inline or use --description-file <path>.");
4473
+ return null;
4474
+ }
4475
+ return trimmed;
4476
+ }
4477
+ const trimmedInline = inline?.trim();
4478
+ return trimmedInline ? trimmedInline : void 0;
4479
+ }
4395
4480
  function createPrOpenCommand() {
4396
4481
  const command = new Command12("open");
4397
4482
  withCommonPrOptions(command).description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option(
4398
4483
  "--description <description>",
4399
4484
  "pull request description; when omitted, a repository-defined pull request template is used if one exists (prepended by this text when both are present)"
4485
+ ).option(
4486
+ "--description-file <path>",
4487
+ 'read the description from a UTF-8 file instead of --description; "-" reads standard input'
4400
4488
  ).option("--json", "output JSON").action(async (options) => {
4401
4489
  validateOrgProjectPair(options);
4402
4490
  const title = options.title?.trim();
@@ -4404,8 +4492,10 @@ function createPrOpenCommand() {
4404
4492
  writeError("--title is required for pull request creation.");
4405
4493
  return;
4406
4494
  }
4407
- const trimmedDescription = options.description?.trim();
4408
- const description = trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription : void 0;
4495
+ const description = resolveOpenDescription(options.description, options.descriptionFile);
4496
+ if (description === null) {
4497
+ return;
4498
+ }
4409
4499
  let context;
4410
4500
  try {
4411
4501
  const resolved = await resolvePrCommandContext(options);
@@ -4445,6 +4535,123 @@ ${result.pullRequest.url ?? "\u2014"}
4445
4535
  });
4446
4536
  return command;
4447
4537
  }
4538
+ function handlePrUpdateError(err, context) {
4539
+ const message = err instanceof Error ? err.message : "";
4540
+ if (message.startsWith("DESCRIPTION_TOO_LONG: ")) {
4541
+ writeError(message.slice("DESCRIPTION_TOO_LONG: ".length));
4542
+ return;
4543
+ }
4544
+ handlePrCommandError(err, context, "write");
4545
+ }
4546
+ function joinFieldNames(fields) {
4547
+ return fields.join(" and ");
4548
+ }
4549
+ function resolveRequestedFields(options) {
4550
+ if (options.titleFile === STDIN_PATH && options.descriptionFile === STDIN_PATH) {
4551
+ writeError('Cannot read standard input twice; only one of --title-file and --description-file may be "-".');
4552
+ return null;
4553
+ }
4554
+ const title = resolveOptionalTextInput(options.title, options.titleFile, "title");
4555
+ if (title === null) {
4556
+ return null;
4557
+ }
4558
+ const description = resolveOptionalTextInput(options.description, options.descriptionFile, "description");
4559
+ if (description === null) {
4560
+ return null;
4561
+ }
4562
+ if (title === void 0 && description === void 0) {
4563
+ writeError("pr update requires at least one of --title, --title-file, --description or --description-file.");
4564
+ return null;
4565
+ }
4566
+ return { title, description };
4567
+ }
4568
+ function diffRequestedFields(requested, current) {
4569
+ const fields = {};
4570
+ const updatedFields = [];
4571
+ const requestedFields = [];
4572
+ if (requested.title !== void 0) {
4573
+ requestedFields.push("title");
4574
+ if (requested.title !== current.title) {
4575
+ fields.title = requested.title;
4576
+ updatedFields.push("title");
4577
+ }
4578
+ }
4579
+ if (requested.description !== void 0) {
4580
+ requestedFields.push("description");
4581
+ if (requested.description !== (current.description ?? "")) {
4582
+ fields.description = requested.description;
4583
+ updatedFields.push("description");
4584
+ }
4585
+ }
4586
+ return { fields, updatedFields, requestedFields };
4587
+ }
4588
+ async function runPrUpdate(options) {
4589
+ const requested = resolveRequestedFields(options);
4590
+ if (requested === null) {
4591
+ return;
4592
+ }
4593
+ let context;
4594
+ try {
4595
+ const target = await resolvePullRequestTarget(options);
4596
+ if (target === null) {
4597
+ return;
4598
+ }
4599
+ context = target.context;
4600
+ const current = target.pullRequest;
4601
+ const { fields, updatedFields, requestedFields } = diffRequestedFields(requested, current);
4602
+ if (updatedFields.length === 0) {
4603
+ const noopResult = {
4604
+ pullRequestId: current.id,
4605
+ title: current.title,
4606
+ description: current.description ?? null,
4607
+ url: current.url,
4608
+ noop: true,
4609
+ updatedFields: []
4610
+ };
4611
+ if (options.json) {
4612
+ process.stdout.write(`${JSON.stringify(noopResult, null, 2)}
4613
+ `);
4614
+ return;
4615
+ }
4616
+ process.stdout.write(
4617
+ `Pull request #${current.id} already has the requested ${joinFieldNames(requestedFields)}; nothing to update.
4618
+ `
4619
+ );
4620
+ return;
4621
+ }
4622
+ const updated = await updatePullRequest(target.context, target.repo, target.pat, current.id, fields);
4623
+ const result = {
4624
+ pullRequestId: updated.id,
4625
+ title: updated.title,
4626
+ description: updated.description ?? null,
4627
+ url: updated.url,
4628
+ noop: false,
4629
+ updatedFields
4630
+ };
4631
+ if (options.json) {
4632
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4633
+ `);
4634
+ return;
4635
+ }
4636
+ process.stdout.write(
4637
+ `Updated pull request #${updated.id} (${updatedFields.join(", ")}).
4638
+ ${updated.url ?? "\u2014"}
4639
+ `
4640
+ );
4641
+ } catch (err) {
4642
+ handlePrUpdateError(err, context);
4643
+ }
4644
+ }
4645
+ function createPrUpdateCommand() {
4646
+ const command = new Command12("update");
4647
+ withCommonPrOptions(configureUnwrappedHelp(command)).alias("edit").description("Update a pull request's title and/or description").option("--pr-number <N>", PR_NUMBER_HELP).option("--title <title>", "new pull request title").option("--title-file <path>", 'read the new title from a UTF-8 file; "-" reads standard input').option(
4648
+ "--description <description>",
4649
+ 'new pull request description; REPLACES the existing description literally \u2014 no repository pull request template is looked up or prepended, unlike "azdo pr open"'
4650
+ ).option("--description-file <path>", 'read the new description from a UTF-8 file; "-" reads standard input').option("--json", "output JSON").action(async (_options, command2) => {
4651
+ await runPrUpdate(mergedPrOptions(command2));
4652
+ });
4653
+ return command;
4654
+ }
4448
4655
  function createPrCommentsCommand() {
4449
4656
  const command = new Command12("comments");
4450
4657
  withCommonPrOptions(configureUnwrappedHelp(command)).description("List pull request comment threads for the current branch").option("--pr-number <N>", PR_NUMBER_HELP).option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--exclude-resolved", "alias of --hide-resolved: exclude resolved / won't fix / closed / by design threads").option("--code-related-only", "show only threads anchored to a file/line; omit general discussion threads").option("--exclude-system", "omit Azure DevOps system comments (branch updates, reviewer votes, build events)").option("--max-chars <N>", "truncate each comment body to N characters (0 = no limit, the default)").option("--thread <id>", "show only the thread with this numeric id; fails when the pull request has no such thread").option("--contains <text>", "show only threads holding a comment that contains this literal, case-sensitive substring (matched before --max-chars truncates)").option("--json", "output JSON").action(async (options) => {
@@ -5224,6 +5431,7 @@ function createPrCommand() {
5224
5431
  command.addCommand(createPrListCommand());
5225
5432
  command.addCommand(createPrStatusCommand());
5226
5433
  command.addCommand(createPrOpenCommand());
5434
+ command.addCommand(createPrUpdateCommand());
5227
5435
  command.addCommand(createPrCommentsCommand());
5228
5436
  command.addCommand(createPrCommentResolveCommand());
5229
5437
  command.addCommand(createPrCommentReopenCommand());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.19.0-feature-037-api-error-surfacing.701",
3
+ "version": "0.19.0-feature-038-pr-update.706",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {