azdo-cli 0.16.0-develop.606 → 0.16.0-develop.629

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 +12 -1
  2. package/dist/index.js +461 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,7 +14,7 @@ Azure DevOps CLI focused on work item read/write workflows.
14
14
  - Read and post work item comments (`comments`)
15
15
  - Read/write rich-text fields as markdown (`get-md-field`, `set-md-field`)
16
16
  - Download images embedded in rich-text fields, optionally resized for LLM use (`get-item`/`get-md-field` `--download-images`, `--resize-images`)
17
- - Check branch pull request status, open PRs to `develop`, list PR comment threads for any PR (`--pr-number`), and resolve/reopen threads from the CLI (`pr`)
17
+ - 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
18
  - Persist org/project/default fields in local config (`config`)
19
19
  - List all fields of a work item (`list-fields`)
20
20
  - 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).
@@ -79,6 +79,17 @@ azdo pr comments reply 148 "Great suggestion, I'll address it." # human
79
79
  azdo pr comments reply 148 "Done." --pr-number 64 --json # JSON: { pullRequestId, threadId, commentId, content }
80
80
  azdo pr comment-reply 148 "Done." --pr-number 64 # flat alias, identical behaviour
81
81
 
82
+ # Open a pull request — description from a repo template when you don't pass one
83
+ azdo pr open --title "Fix the thing" --description "Because X was broken"
84
+ azdo pr open --title "Fix the thing" # uses docs/pull_request_template[/branches/<branch>].md if present
85
+
86
+ # Link/unlink a work item, add/remove reviewers
87
+ azdo pr work-items link 1234 --pr-number 64
88
+ azdo pr work-items unlink 1234 --pr-number 64
89
+ azdo pr reviewers add jane@example.com --pr-number 64 # optional by default
90
+ azdo pr reviewers add jane@example.com --pr-number 64 --required # required (or promotes in place)
91
+ azdo pr reviewers remove jane@example.com --pr-number 64
92
+
82
93
  # Any pr subcommand can target another repository
83
94
  azdo pr comments --repo other-repo --pr-number 12
84
95
 
package/dist/index.js CHANGED
@@ -1798,7 +1798,7 @@ async function runConnectivityTest(org, cred) {
1798
1798
  return { status: "failed", error };
1799
1799
  }
1800
1800
  async function resolveCredentialIdentity(org, cred) {
1801
- const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/connectionData?api-version=7.1`;
1801
+ const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/connectionData?api-version=7.1-preview`;
1802
1802
  try {
1803
1803
  const result = await fetchRaw(url, { headers: authHeaders(cred) });
1804
1804
  if (result.status < 200 || result.status >= 300) {
@@ -3511,6 +3511,20 @@ async function getPullRequestBuilds(context, cred, prId) {
3511
3511
  isBlocking: null
3512
3512
  }));
3513
3513
  }
3514
+ function composeDescription(description, template) {
3515
+ if (description !== void 0 && template !== null) {
3516
+ return `${description}
3517
+
3518
+ ${template.content}`;
3519
+ }
3520
+ if (description !== void 0) {
3521
+ return description;
3522
+ }
3523
+ if (template !== null) {
3524
+ return template.content;
3525
+ }
3526
+ return null;
3527
+ }
3514
3528
  async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
3515
3529
  const existing = await listPullRequests(context, repo, cred, sourceBranch, {
3516
3530
  status: "active",
@@ -3527,11 +3541,18 @@ async function openPullRequest(context, repo, cred, sourceBranch, title, descrip
3527
3541
  if (existing.length > 1) {
3528
3542
  throw new Error(`AMBIGUOUS_PRS:${existing.map((pullRequest) => pullRequest.id).join(",")}`);
3529
3543
  }
3544
+ const repository = await getRepository(context, repo, cred);
3545
+ const defaultBranch = repository.defaultBranch ? repository.defaultBranch.replace(/^refs\/heads\//, "") : "develop";
3546
+ const template = await resolvePullRequestTemplate(context, repo, cred, defaultBranch, "develop");
3547
+ const finalDescription = composeDescription(description, template);
3548
+ if (finalDescription === null) {
3549
+ throw new Error("DESCRIPTION_REQUIRED");
3550
+ }
3530
3551
  const payload = {
3531
3552
  sourceRefName: `refs/heads/${sourceBranch}`,
3532
3553
  targetRefName: "refs/heads/develop",
3533
3554
  title,
3534
- description
3555
+ description: finalDescription
3535
3556
  };
3536
3557
  const url = new URL(
3537
3558
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
@@ -3638,6 +3659,245 @@ async function postThreadComment(context, repo, cred, prId, threadId, content) {
3638
3659
  publishedAt: data.publishedDate ?? null
3639
3660
  };
3640
3661
  }
3662
+ function buildRepositoryUrl(context, repo) {
3663
+ const url = new URL(
3664
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}`
3665
+ );
3666
+ url.searchParams.set("api-version", "7.1");
3667
+ return url;
3668
+ }
3669
+ async function getRepository(context, repo, cred) {
3670
+ const response = await fetchWithErrors(buildRepositoryUrl(context, repo).toString(), {
3671
+ headers: authHeaders(cred)
3672
+ });
3673
+ return readJsonResponse(response);
3674
+ }
3675
+ async function resolveRepositoryId(context, repo, cred) {
3676
+ const repository = await getRepository(context, repo, cred);
3677
+ return repository.id;
3678
+ }
3679
+ function buildWorkItemArtifactUri(projectId, repositoryId, prId) {
3680
+ return `vstfs:///Git/PullRequestId/${projectId}/${repositoryId}/${prId}`;
3681
+ }
3682
+ function buildWorkItemUrl2(context, workItemId) {
3683
+ const url = new URL(
3684
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${workItemId}`
3685
+ );
3686
+ url.searchParams.set("api-version", "7.1");
3687
+ return url;
3688
+ }
3689
+ async function getWorkItemRelations(context, cred, workItemId) {
3690
+ const url = buildWorkItemUrl2(context, workItemId);
3691
+ url.searchParams.set("$expand", "relations");
3692
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3693
+ const data = await readJsonResponse(response);
3694
+ return data.relations ?? [];
3695
+ }
3696
+ async function patchWorkItemRelations(context, cred, workItemId, operation) {
3697
+ const url = buildWorkItemUrl2(context, workItemId);
3698
+ const response = await fetchWithErrors(url.toString(), {
3699
+ method: "PATCH",
3700
+ headers: {
3701
+ ...authHeaders(cred),
3702
+ "Content-Type": "application/json-patch+json"
3703
+ },
3704
+ body: JSON.stringify([operation])
3705
+ });
3706
+ if (!response.ok) {
3707
+ throw new Error(`HTTP_${response.status}`);
3708
+ }
3709
+ }
3710
+ async function linkWorkItemToPullRequest(context, repo, cred, prId, workItemId) {
3711
+ const [projectId, repositoryId, relations] = await Promise.all([
3712
+ resolveProjectId(context, cred),
3713
+ resolveRepositoryId(context, repo, cred),
3714
+ getWorkItemRelations(context, cred, workItemId)
3715
+ ]);
3716
+ const uri = buildWorkItemArtifactUri(projectId, repositoryId, prId);
3717
+ const alreadyLinked = relations.some((relation) => relation.rel === "ArtifactLink" && relation.url === uri);
3718
+ if (alreadyLinked) {
3719
+ return { pullRequestId: prId, workItemId, url: uri, noop: true };
3720
+ }
3721
+ await patchWorkItemRelations(context, cred, workItemId, {
3722
+ op: "add",
3723
+ path: "/relations/-",
3724
+ value: { rel: "ArtifactLink", url: uri, attributes: { name: "Pull Request" } }
3725
+ });
3726
+ return { pullRequestId: prId, workItemId, url: uri, noop: false };
3727
+ }
3728
+ async function unlinkWorkItemFromPullRequest(context, repo, cred, prId, workItemId) {
3729
+ const [projectId, repositoryId, relations] = await Promise.all([
3730
+ resolveProjectId(context, cred),
3731
+ resolveRepositoryId(context, repo, cred),
3732
+ getWorkItemRelations(context, cred, workItemId)
3733
+ ]);
3734
+ const uri = buildWorkItemArtifactUri(projectId, repositoryId, prId);
3735
+ const index = relations.findIndex((relation) => relation.rel === "ArtifactLink" && relation.url === uri);
3736
+ if (index === -1) {
3737
+ return { pullRequestId: prId, workItemId, url: uri, noop: true };
3738
+ }
3739
+ await patchWorkItemRelations(context, cred, workItemId, { op: "remove", path: `/relations/${index}` });
3740
+ return { pullRequestId: prId, workItemId, url: uri, noop: false };
3741
+ }
3742
+ function buildIdentitiesUrl(org, filterValue) {
3743
+ const url = new URL(`https://vssps.dev.azure.com/${encodeURIComponent(org)}/_apis/identities`);
3744
+ url.searchParams.set("searchFilter", "General");
3745
+ url.searchParams.set("filterValue", filterValue);
3746
+ url.searchParams.set("api-version", "7.1");
3747
+ return url;
3748
+ }
3749
+ async function resolveReviewerIdentity(org, cred, input) {
3750
+ let response;
3751
+ try {
3752
+ response = await fetchWithErrors(buildIdentitiesUrl(org, input).toString(), {
3753
+ headers: authHeaders(cred)
3754
+ });
3755
+ } catch (err) {
3756
+ if (err instanceof Error && err.message === "AUTH_FAILED") {
3757
+ throw new Error("IDENTITY_SCOPE_MISSING", { cause: err });
3758
+ }
3759
+ throw err;
3760
+ }
3761
+ const data = await readJsonResponse(response);
3762
+ if (data.value.length !== 1) {
3763
+ throw new Error(`RESOLVE_FAILED:${input}`);
3764
+ }
3765
+ return data.value[0];
3766
+ }
3767
+ function buildPullRequestReviewerUrl(context, repo, prId, reviewerId) {
3768
+ const url = new URL(
3769
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/reviewers/${encodeURIComponent(reviewerId)}`
3770
+ );
3771
+ url.searchParams.set("api-version", "7.1");
3772
+ return url;
3773
+ }
3774
+ function mapReviewer(data) {
3775
+ return {
3776
+ id: data.id,
3777
+ displayName: data.displayName ?? null,
3778
+ uniqueName: data.uniqueName ?? null,
3779
+ isRequired: data.isRequired ?? false,
3780
+ vote: data.vote ?? 0
3781
+ };
3782
+ }
3783
+ async function getPullRequestReviewers(context, repo, cred, prId) {
3784
+ const url = new URL(
3785
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/reviewers`
3786
+ );
3787
+ url.searchParams.set("api-version", "7.1");
3788
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3789
+ const data = await readJsonResponse(response);
3790
+ return data.value.map(mapReviewer);
3791
+ }
3792
+ async function addOrUpdatePullRequestReviewer(context, repo, cred, prId, reviewerId, isRequired) {
3793
+ const response = await fetchWithErrors(buildPullRequestReviewerUrl(context, repo, prId, reviewerId).toString(), {
3794
+ method: "PUT",
3795
+ headers: {
3796
+ ...authHeaders(cred),
3797
+ "Content-Type": "application/json"
3798
+ },
3799
+ body: JSON.stringify({ vote: 0, isRequired })
3800
+ });
3801
+ const data = await readJsonResponse(response);
3802
+ return mapReviewer(data);
3803
+ }
3804
+ async function removePullRequestReviewer(context, repo, cred, prId, reviewerId) {
3805
+ const reviewers = await getPullRequestReviewers(context, repo, cred, prId);
3806
+ const existing = reviewers.find((reviewer) => reviewer.id === reviewerId);
3807
+ if (existing === void 0) {
3808
+ return { reviewer: null, noop: true };
3809
+ }
3810
+ const response = await fetchWithErrors(buildPullRequestReviewerUrl(context, repo, prId, reviewerId).toString(), {
3811
+ method: "DELETE",
3812
+ headers: authHeaders(cred)
3813
+ });
3814
+ if (!response.ok) {
3815
+ throw new Error(`HTTP_${response.status}`);
3816
+ }
3817
+ return { reviewer: existing, noop: false };
3818
+ }
3819
+ var TEMPLATE_ROOTS = [".azuredevops", ".vsts", "docs", ""];
3820
+ var TEMPLATE_EXTENSIONS = [".md", ".txt"];
3821
+ function joinTemplatePath(root, relative) {
3822
+ return root === "" ? relative : `${root}/${relative}`;
3823
+ }
3824
+ function branchTemplateSegments(branch) {
3825
+ const parts = branch.split("/").filter((part) => part.length > 0).slice(0, 10);
3826
+ const segments = [];
3827
+ for (let i = parts.length; i > 0; i -= 1) {
3828
+ segments.push(parts.slice(0, i).join("/"));
3829
+ }
3830
+ return segments;
3831
+ }
3832
+ async function fetchRepositoryItemContent(context, repo, cred, path3, branch) {
3833
+ const url = new URL(
3834
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/items`
3835
+ );
3836
+ url.searchParams.set("path", path3);
3837
+ url.searchParams.set("versionDescriptor.version", branch);
3838
+ url.searchParams.set("versionDescriptor.versionType", "branch");
3839
+ url.searchParams.set("includeContent", "true");
3840
+ url.searchParams.set("$format", "text");
3841
+ url.searchParams.set("api-version", "7.1");
3842
+ let response;
3843
+ try {
3844
+ response = await fetchWithErrors(url.toString(), {
3845
+ headers: { ...authHeaders(cred), Accept: "text/plain" }
3846
+ });
3847
+ } catch (err) {
3848
+ if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
3849
+ return null;
3850
+ }
3851
+ throw err;
3852
+ }
3853
+ if (!response.ok) {
3854
+ throw new Error(`HTTP_${response.status}`);
3855
+ }
3856
+ return response.text();
3857
+ }
3858
+ function branchTemplateCandidates(targetBranch) {
3859
+ const paths = [];
3860
+ for (const segment of branchTemplateSegments(targetBranch)) {
3861
+ for (const root of TEMPLATE_ROOTS) {
3862
+ for (const ext of TEMPLATE_EXTENSIONS) {
3863
+ paths.push(joinTemplatePath(root, `pull_request_template/branches/${segment}${ext}`));
3864
+ }
3865
+ }
3866
+ }
3867
+ return paths;
3868
+ }
3869
+ function defaultTemplateCandidates() {
3870
+ const paths = [];
3871
+ for (const root of TEMPLATE_ROOTS) {
3872
+ for (const ext of TEMPLATE_EXTENSIONS) {
3873
+ paths.push(joinTemplatePath(root, `pull_request_template${ext}`));
3874
+ }
3875
+ }
3876
+ return paths;
3877
+ }
3878
+ async function firstMatchingTemplate(context, repo, cred, defaultBranch, candidates, kind) {
3879
+ for (const path3 of candidates) {
3880
+ const content = await fetchRepositoryItemContent(context, repo, cred, path3, defaultBranch);
3881
+ if (content !== null) {
3882
+ return { path: path3, content, kind };
3883
+ }
3884
+ }
3885
+ return null;
3886
+ }
3887
+ async function resolvePullRequestTemplate(context, repo, cred, defaultBranch, targetBranch) {
3888
+ const branchMatch = await firstMatchingTemplate(
3889
+ context,
3890
+ repo,
3891
+ cred,
3892
+ defaultBranch,
3893
+ branchTemplateCandidates(targetBranch),
3894
+ "branch"
3895
+ );
3896
+ if (branchMatch !== null) {
3897
+ return branchMatch;
3898
+ }
3899
+ return firstMatchingTemplate(context, repo, cred, defaultBranch, defaultTemplateCandidates(), "default");
3900
+ }
3641
3901
 
3642
3902
  // src/commands/pr.ts
3643
3903
  function parsePositivePrNumber(raw) {
@@ -3736,6 +3996,13 @@ function handlePrCommandError(err, context, mode = "read") {
3736
3996
  }
3737
3997
  return;
3738
3998
  }
3999
+ if (error.message === "IDENTITY_SCOPE_MISSING") {
4000
+ writeError(
4001
+ 'Could not resolve reviewer identity: your PAT is missing the "Identity (Read)" scope required by the Azure DevOps identities API (separate from Code scope).',
4002
+ EXIT_NOT_PERMITTED
4003
+ );
4004
+ return;
4005
+ }
3739
4006
  if (error.message === "PERMISSION_DENIED") {
3740
4007
  writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`, EXIT_NOT_PERMITTED);
3741
4008
  return;
@@ -3926,18 +4193,18 @@ function createPrStatusCommand() {
3926
4193
  }
3927
4194
  function createPrOpenCommand() {
3928
4195
  const command = new Command12("open");
3929
- withCommonPrOptions(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("--json", "output JSON").action(async (options) => {
4196
+ withCommonPrOptions(command).description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option(
4197
+ "--description <description>",
4198
+ "pull request description; when omitted, a repository-defined pull request template is used if one exists (prepended by this text when both are present)"
4199
+ ).option("--json", "output JSON").action(async (options) => {
3930
4200
  validateOrgProjectPair(options);
3931
4201
  const title = options.title?.trim();
3932
4202
  if (!title) {
3933
4203
  writeError("--title is required for pull request creation.");
3934
4204
  return;
3935
4205
  }
3936
- const description = options.description?.trim();
3937
- if (!description) {
3938
- writeError("--description is required for pull request creation.");
3939
- return;
3940
- }
4206
+ const trimmedDescription = options.description?.trim();
4207
+ const description = trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription : void 0;
3941
4208
  let context;
3942
4209
  try {
3943
4210
  const resolved = await resolvePrCommandContext(options);
@@ -3977,6 +4244,10 @@ ${result.pullRequest.url ?? "\u2014"}
3977
4244
  writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
3978
4245
  return;
3979
4246
  }
4247
+ if (err instanceof Error && err.message === "DESCRIPTION_REQUIRED") {
4248
+ writeError("--description is required for pull request creation.");
4249
+ return;
4250
+ }
3980
4251
  handlePrCommandError(err, context, "write");
3981
4252
  }
3982
4253
  });
@@ -4579,6 +4850,182 @@ function createPrListCommand() {
4579
4850
  });
4580
4851
  return command;
4581
4852
  }
4853
+ async function runWorkItemLinkChange(workItemIdRaw, options, direction) {
4854
+ let context;
4855
+ try {
4856
+ const workItemId = parsePositivePrNumber(workItemIdRaw);
4857
+ if (workItemId === null) {
4858
+ validateOrgProjectPair(options);
4859
+ writeError(`Invalid work item id "${workItemIdRaw}"; expected a positive integer.`);
4860
+ return;
4861
+ }
4862
+ const target = await resolvePullRequestTarget(options);
4863
+ if (target === null) {
4864
+ return;
4865
+ }
4866
+ context = target.context;
4867
+ let outcome;
4868
+ try {
4869
+ outcome = direction === "link" ? await linkWorkItemToPullRequest(target.context, target.repo, target.pat, target.pullRequest.id, workItemId) : await unlinkWorkItemFromPullRequest(target.context, target.repo, target.pat, target.pullRequest.id, workItemId);
4870
+ } catch (err) {
4871
+ if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
4872
+ writeError(`Work item #${workItemId} not found in ${target.context.org}/${target.context.project}.`, EXIT_NOT_FOUND);
4873
+ return;
4874
+ }
4875
+ throw err;
4876
+ }
4877
+ const result = {
4878
+ pullRequestId: outcome.pullRequestId,
4879
+ workItemId: outcome.workItemId,
4880
+ noop: outcome.noop
4881
+ };
4882
+ if (options.json) {
4883
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4884
+ `);
4885
+ return;
4886
+ }
4887
+ if (direction === "link") {
4888
+ process.stdout.write(
4889
+ outcome.noop ? `Work item #${workItemId} is already linked to pull request #${outcome.pullRequestId}.
4890
+ ` : `Linked work item #${workItemId} to pull request #${outcome.pullRequestId}.
4891
+ `
4892
+ );
4893
+ } else {
4894
+ process.stdout.write(
4895
+ outcome.noop ? `Work item #${workItemId} was not linked to pull request #${outcome.pullRequestId}.
4896
+ ` : `Unlinked work item #${workItemId} from pull request #${outcome.pullRequestId}.
4897
+ `
4898
+ );
4899
+ }
4900
+ } catch (err) {
4901
+ handlePrCommandError(err, context, "write");
4902
+ }
4903
+ }
4904
+ function buildWorkItemLinkCommand(name, description, direction) {
4905
+ const command = new Command12(name);
4906
+ withCommonPrOptions(configureUnwrappedHelp(command)).description(description).argument("<workItemId>", "numeric id of the work item").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (workItemIdRaw, _options, command2) => {
4907
+ await runWorkItemLinkChange(workItemIdRaw, mergedPrOptions(command2), direction);
4908
+ });
4909
+ return command;
4910
+ }
4911
+ function createPrWorkItemsCommand() {
4912
+ const command = new Command12("work-items");
4913
+ command.description("Manage work items linked to a pull request");
4914
+ command.addCommand(buildWorkItemLinkCommand("link", "Link a work item to the pull request", "link"));
4915
+ command.addCommand(buildWorkItemLinkCommand("unlink", "Unlink a work item from the pull request", "unlink"));
4916
+ return command;
4917
+ }
4918
+ async function runReviewerAdd(reviewer, options) {
4919
+ let context;
4920
+ try {
4921
+ const target = await resolvePullRequestTarget(options);
4922
+ if (target === null) {
4923
+ return;
4924
+ }
4925
+ context = target.context;
4926
+ let identity;
4927
+ try {
4928
+ identity = await resolveReviewerIdentity(target.context.org, target.pat, reviewer);
4929
+ } catch (err) {
4930
+ if (err instanceof Error && err.message.startsWith("RESOLVE_FAILED:")) {
4931
+ writeError(`Reviewer "${reviewer}" could not be resolved to an Azure DevOps identity.`);
4932
+ return;
4933
+ }
4934
+ throw err;
4935
+ }
4936
+ const isRequired = options.required === true;
4937
+ const existingReviewers = await getPullRequestReviewers(target.context, target.repo, target.pat, target.pullRequest.id);
4938
+ const existing = existingReviewers.find((reviewer2) => reviewer2.id === identity.id);
4939
+ const noop = existing?.isRequired === isRequired;
4940
+ const added = noop ? existing : await addOrUpdatePullRequestReviewer(
4941
+ target.context,
4942
+ target.repo,
4943
+ target.pat,
4944
+ target.pullRequest.id,
4945
+ identity.id,
4946
+ isRequired
4947
+ );
4948
+ const result = {
4949
+ pullRequestId: target.pullRequest.id,
4950
+ reviewer: { id: added.id, displayName: added.displayName, uniqueName: added.uniqueName, isRequired: added.isRequired },
4951
+ noop
4952
+ };
4953
+ if (options.json) {
4954
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4955
+ `);
4956
+ return;
4957
+ }
4958
+ if (noop) {
4959
+ const label2 = added.displayName ?? added.uniqueName ?? reviewer;
4960
+ const kind2 = added.isRequired ? "required" : "optional";
4961
+ process.stdout.write(`${label2} is already a ${kind2} reviewer on pull request #${target.pullRequest.id}.
4962
+ `);
4963
+ return;
4964
+ }
4965
+ const label = added.displayName ?? added.uniqueName ?? reviewer;
4966
+ const kind = added.isRequired ? "required" : "optional";
4967
+ process.stdout.write(`Added ${label} as a ${kind} reviewer on pull request #${target.pullRequest.id}.
4968
+ `);
4969
+ } catch (err) {
4970
+ handlePrCommandError(err, context, "write");
4971
+ }
4972
+ }
4973
+ async function runReviewerRemove(reviewer, options) {
4974
+ let context;
4975
+ try {
4976
+ const target = await resolvePullRequestTarget(options);
4977
+ if (target === null) {
4978
+ return;
4979
+ }
4980
+ context = target.context;
4981
+ let identity;
4982
+ try {
4983
+ identity = await resolveReviewerIdentity(target.context.org, target.pat, reviewer);
4984
+ } catch (err) {
4985
+ if (err instanceof Error && err.message.startsWith("RESOLVE_FAILED:")) {
4986
+ writeError(`Reviewer "${reviewer}" could not be resolved to an Azure DevOps identity.`);
4987
+ return;
4988
+ }
4989
+ throw err;
4990
+ }
4991
+ const outcome = await removePullRequestReviewer(target.context, target.repo, target.pat, target.pullRequest.id, identity.id);
4992
+ const result = {
4993
+ pullRequestId: target.pullRequest.id,
4994
+ reviewer: outcome.reviewer ? { id: outcome.reviewer.id, displayName: outcome.reviewer.displayName, uniqueName: outcome.reviewer.uniqueName, isRequired: outcome.reviewer.isRequired } : null,
4995
+ noop: outcome.noop
4996
+ };
4997
+ if (options.json) {
4998
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4999
+ `);
5000
+ return;
5001
+ }
5002
+ if (outcome.noop) {
5003
+ process.stdout.write(`${reviewer} is not a reviewer on pull request #${target.pullRequest.id}.
5004
+ `);
5005
+ return;
5006
+ }
5007
+ const label = outcome.reviewer?.displayName ?? outcome.reviewer?.uniqueName ?? reviewer;
5008
+ process.stdout.write(`Removed ${label} from pull request #${target.pullRequest.id}.
5009
+ `);
5010
+ } catch (err) {
5011
+ handlePrCommandError(err, context, "write");
5012
+ }
5013
+ }
5014
+ function createPrReviewersCommand() {
5015
+ const command = new Command12("reviewers");
5016
+ command.description("Manage pull request reviewers");
5017
+ const add = new Command12("add");
5018
+ withCommonPrOptions(configureUnwrappedHelp(add)).description("Add a reviewer to the pull request (optional by default)").argument("<reviewer>", "reviewer email or unique name").option("--required", "mark the reviewer as required instead of optional").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (reviewer, _options, command2) => {
5019
+ await runReviewerAdd(reviewer, mergedPrOptions(command2));
5020
+ });
5021
+ command.addCommand(add);
5022
+ const remove = new Command12("remove");
5023
+ withCommonPrOptions(configureUnwrappedHelp(remove)).description("Remove a reviewer from the pull request").argument("<reviewer>", "reviewer email or unique name").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (reviewer, _options, command2) => {
5024
+ await runReviewerRemove(reviewer, mergedPrOptions(command2));
5025
+ });
5026
+ command.addCommand(remove);
5027
+ return command;
5028
+ }
4582
5029
  function createPrCommand() {
4583
5030
  const command = new Command12("pr");
4584
5031
  command.description("Manage Azure DevOps pull requests");
@@ -4590,6 +5037,8 @@ function createPrCommand() {
4590
5037
  command.addCommand(createPrCommentReopenCommand());
4591
5038
  command.addCommand(createPrCommentReplyCommand());
4592
5039
  command.addCommand(createPrCommentAddCommand());
5040
+ command.addCommand(createPrWorkItemsCommand());
5041
+ command.addCommand(createPrReviewersCommand());
4593
5042
  command.addCommand(createPrCommentEditCommand());
4594
5043
  return command;
4595
5044
  }
@@ -5648,7 +6097,7 @@ function buildRelationTypesUrl(context) {
5648
6097
  url.searchParams.set("api-version", API_VERSION2);
5649
6098
  return url;
5650
6099
  }
5651
- function buildWorkItemUrl2(context, id, expand) {
6100
+ function buildWorkItemUrl3(context, id, expand) {
5652
6101
  const url = new URL(
5653
6102
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
5654
6103
  );
@@ -5698,7 +6147,7 @@ async function resolveRelationType(context, cred, alias) {
5698
6147
  return match;
5699
6148
  }
5700
6149
  async function getWorkItemWithRelations(context, cred, id) {
5701
- const url = buildWorkItemUrl2(context, id, "relations");
6150
+ const url = buildWorkItemUrl3(context, id, "relations");
5702
6151
  const response = await fetchWithErrors(url.toString(), {
5703
6152
  headers: authHeaders(cred)
5704
6153
  });
@@ -5715,7 +6164,7 @@ async function addWorkItemRelation(context, cred, type, id1, id2) {
5715
6164
  if (exists) {
5716
6165
  return { status: "already_exists", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5717
6166
  }
5718
- const patchUrl = buildWorkItemUrl2(context, id1);
6167
+ const patchUrl = buildWorkItemUrl3(context, id1);
5719
6168
  const response = await fetchWithErrors(patchUrl.toString(), {
5720
6169
  method: "PATCH",
5721
6170
  headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
@@ -5737,7 +6186,7 @@ async function removeWorkItemRelation(context, cred, type, id1, id2) {
5737
6186
  if (index === -1) {
5738
6187
  return { status: "not_found", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5739
6188
  }
5740
- const patchUrl = buildWorkItemUrl2(context, id1);
6189
+ const patchUrl = buildWorkItemUrl3(context, id1);
5741
6190
  const response = await fetchWithErrors(patchUrl.toString(), {
5742
6191
  method: "PATCH",
5743
6192
  headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.16.0-develop.606",
3
+ "version": "0.16.0-develop.629",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {