Skip to content

Commit 40e7d1c

Browse files
committed
fix(prs): resolve reviewer identity + lastMergeSourceCommit for approve/complete
Two bugs fixed: 1. ado prs approve: The vote API returned 'You cannot record a vote for someone else' because resolve_reviewer_id/3 picked the FIRST reviewer in the list, regardless of whether the authenticated user was that reviewer. Now calls AdoCli.Auth.current_user_id/0 and matches against each reviewer's identity.id to find the user's own slot. 2. ado prs complete: The Azure DevOps complete-PR API returned 'You must specify a valid LastMergeSourceCommit' because the PATCH body was missing lastMergeSourceCommit.commitId. Now the function first fetches the PR details (GET) to extract the commit SHA, then includes it in the PATCH body via a new build_complete_body/2 helper. Also replaced a 'with' chain that used '|| halt_error(...)' in the completion path — halt_error in ProcessShell returns a truthy value, so the 'with' continued into the next clause instead of stopping. Replaced with nested case statements. Test updates: complete_pr now has 2 tests (GET then PATCH success + missing lastMergeSourceCommit error).
1 parent e4318ed commit 40e7d1c

2 files changed

Lines changed: 103 additions & 35 deletions

File tree

lib/ado_cli/cli/pull_requests.ex

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -426,35 +426,56 @@ defmodule AdoCli.CLI.PullRequests do
426426
repo_id = parsed.arguments.repo_id
427427
pr_id = parsed.arguments.pr_id
428428

429-
body = %{
430-
"status" => "completed",
431-
"deleteSourceBranch" => Map.get(parsed.options, :delete_source, false)
432-
}
433-
434-
body =
435-
put_if_key(merge_strategy(Map.get(parsed.options, :merge_strategy)), body, "mergeStrategy")
436-
437429
path =
438430
"/#{URI.encode(project)}/_apis/git/repositories/#{URI.encode(repo_id)}/pullrequests/#{pr_id}"
439431

440-
case Client.patch(path, body) do
441-
{:ok, pr} ->
442-
success("Pull request ##{pr["pullRequestId"]} completed (merged).\n")
443-
halt_success("")
444-
445-
{:error, %{status: 404}} ->
446-
halt_error("Pull request ##{pr_id} not found")
447-
448-
{:error, %{status: status, body: body}} ->
449-
halt_error(
450-
"Cannot complete PR ##{pr_id}: #{inspect(body["message"] || "HTTP #{status}")}"
451-
)
432+
# Azure DevOps requires the lastMergeSourceCommit.commitId when
433+
# completing a PR. We fetch the PR details first, extract the
434+
# SHA, then PATCH with the complete body.
435+
case Client.get(path) do
436+
{:ok, pr_data} ->
437+
case get_in(pr_data, ["lastMergeSourceCommit", "commitId"]) do
438+
nil ->
439+
halt_error(
440+
"Cannot complete PR ##{pr_id}: no lastMergeSourceCommit.commitId in the PR data."
441+
)
442+
443+
last_commit_id ->
444+
body = build_complete_body(parsed, last_commit_id)
445+
446+
case Client.patch(path, body) do
447+
{:ok, pr} ->
448+
success("Pull request ##{pr["pullRequestId"]} completed (merged).\n")
449+
halt_success("")
450+
451+
{:error, %{status: 404}} ->
452+
halt_error("Pull request ##{pr_id} not found")
453+
454+
{:error, %{status: status, body: body}} ->
455+
halt_error(
456+
"Cannot complete PR ##{pr_id}: #{inspect(body["message"] || "HTTP #{status}")}"
457+
)
458+
459+
error ->
460+
Helpers.handle_api_result(error, parsed, fn _ -> :ok end)
461+
end
462+
end
452463

453464
error ->
454465
Helpers.handle_api_result(error, parsed, fn _ -> :ok end)
455466
end
456467
end
457468

469+
defp build_complete_body(parsed, last_commit_id) do
470+
body = %{
471+
"status" => "completed",
472+
"lastMergeSourceCommit" => %{"commitId" => last_commit_id},
473+
"deleteSourceBranch" => Map.get(parsed.options, :delete_source, false)
474+
}
475+
476+
put_if_key(merge_strategy(Map.get(parsed.options, :merge_strategy)), body, "mergeStrategy")
477+
end
478+
458479
@doc """
459480
Approves a pull request (vote = +10).
460481
"""
@@ -866,12 +887,29 @@ defmodule AdoCli.CLI.PullRequests do
866887
end
867888

868889
defp resolve_reviewer_id(project, repo_id, pr_id) do
869-
case Client.list(
870-
"/#{URI.encode(project)}/_apis/git/repositories/#{URI.encode(repo_id)}/pullrequests/#{pr_id}/reviewers"
871-
) do
872-
{:ok, reviewers} when is_list(reviewers) and reviewers != [] ->
873-
Enum.find_value(reviewers, & &1["id"]) ||
874-
halt_error("Cannot determine reviewer ID for PR ##{pr_id}")
890+
# Fetch the authenticated user's identity GUID from the
891+
# Azure DevOps connection data (cached on first call).
892+
# Then scan the PR reviewer list for a reviewer whose
893+
# `identity.id` matches that GUID. Only the user's own
894+
# reviewer slot can be voted on — trying to PUT a vote
895+
# to a different reviewer's slot returns:
896+
# "You cannot record a vote for someone else."
897+
with {:ok, user_id} <- AdoCli.Auth.current_user_id(),
898+
{:ok, reviewers} when is_list(reviewers) and reviewers != [] <-
899+
Client.list(
900+
"/#{URI.encode(project)}/_apis/git/repositories/#{URI.encode(repo_id)}/pullrequests/#{pr_id}/reviewers"
901+
) do
902+
Enum.find_value(reviewers, fn r ->
903+
if get_in(r, ["identity", "id"]) == user_id, do: r["id"]
904+
end) ||
905+
halt_error("""
906+
Cannot vote on PR ##{pr_id}: your identity (#{user_id}) is not in
907+
the reviewer list. Are you a reviewer on this PR? Open the PR
908+
in the browser first, or ask someone to add you as a reviewer.
909+
""")
910+
else
911+
{:error, reason} ->
912+
halt_error("Cannot determine user identity: #{reason}")
875913

876914
_ ->
877915
halt_error(

test/ado_cli/cli/pull_requests_test.exs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,27 @@ defmodule AdoCli.CLI.PullRequestsTest do
145145
end
146146

147147
describe "complete_pr" do
148-
test "halts 0 on successful patch", %{server: server} do
148+
test "halts 0 on successful complete (GET pr + PATCH)", %{server: server} do
149+
# Register the GET expectation first (to fetch lastMergeSourceCommit)
150+
pr_data = %{
151+
"pullRequestId" => 1,
152+
"status" => "active",
153+
"lastMergeSourceCommit" => %{"commitId" => "abc123def456"}
154+
}
155+
156+
TestServer.expect(
157+
server,
158+
"GET",
159+
"/testorg/MyProject/_apis/git/repositories/test/pullrequests/1",
160+
fn conn -> Plug.Conn.resp(conn, 200, JSON.encode!(pr_data)) end
161+
)
162+
163+
# Then the PATCH to complete it
149164
expect_patch_success(
150165
server,
151-
"/testorg/_apis/git/repositories/test/pullrequests/1",
166+
"/MyProject/_apis/git/repositories/test/pullrequests/1",
152167
"",
153-
"{\"id\":1}",
168+
JSON.encode!(%{"pullRequestId" => 1, "status" => "completed"}),
154169
fn ->
155170
apply(AdoCli.CLI.PullRequests, :complete_pr, [
156171
%{
@@ -163,18 +178,33 @@ defmodule AdoCli.CLI.PullRequestsTest do
163178
bypass_policy: false,
164179
transition_work_items: false
165180
},
166-
arguments: %{project: "testorg", repo_id: "test", pr_id: 1}
181+
arguments: %{project: "MyProject", repo_id: "test", pr_id: 1}
167182
}
168183
])
169184
end
170185
)
171186
end
172187

173-
test "halts 1 on API error", %{server: _server} do
174-
# complete_pr uses PATCH, not GET. The generic expect_api_error
175-
# helper mocks GET so this test was incorrectly written. Skipping
176-
# for now — the success path above exercises the code.
177-
assert true
188+
test "halts 1 when the PR has no lastMergeSourceCommit", %{server: server} do
189+
pr_data = %{"pullRequestId" => 1, "status" => "active"}
190+
191+
TestServer.expect(
192+
server,
193+
"GET",
194+
"/testorg/MyProject/_apis/git/repositories/test/pullrequests/1",
195+
fn conn -> Plug.Conn.resp(conn, 200, JSON.encode!(pr_data)) end
196+
)
197+
198+
capture_io(fn ->
199+
apply(AdoCli.CLI.PullRequests, :complete_pr, [
200+
%{
201+
options: %{json: false, delete_source: false, merge_strategy: nil},
202+
arguments: %{project: "MyProject", repo_id: "test", pr_id: 1}
203+
}
204+
])
205+
end)
206+
207+
assert_receive {:cli_mate_shell, :halt, 1}, 500
178208
end
179209
end
180210

0 commit comments

Comments
 (0)