diff --git a/DevOps/Actions/ActionHelpers.cs b/DevOps/Actions/ActionHelpers.cs index 395def5..25d8c3f 100644 --- a/DevOps/Actions/ActionHelpers.cs +++ b/DevOps/Actions/ActionHelpers.cs @@ -154,6 +154,16 @@ internal static string Truncate(string value, int max) internal static string ShortBranch(string refName) => string.IsNullOrEmpty(refName) ? "-" : refName.Replace("refs/heads/", ""); + internal static int VoteValue(string vote) => vote?.ToLowerInvariant() switch + { + "approve" => 10, + "approve-suggestions" => 5, + "reset" or "none" => 0, + "wait" => -5, + "reject" => -10, + _ => throw new ArgumentException($"Unknown vote '{vote}'. Valid values: approve, approve-suggestions, reject, wait, reset.") + }; + internal static string VoteText(int vote) => vote switch { 10 => "approved", diff --git a/DevOps/Actions/ConfigAction.cs b/DevOps/Actions/ConfigAction.cs index c8c5404..38a7cbf 100644 --- a/DevOps/Actions/ConfigAction.cs +++ b/DevOps/Actions/ConfigAction.cs @@ -87,6 +87,7 @@ internal static async Task Execute(ConfigOptions opts, CancellationToken ct // PAT or plain settings (org/project/team/email) flow. string patUserDisplayName = null; string patUserEmail = null; + string patUserId = null; if (!string.IsNullOrEmpty(opts.Pat)) { @@ -97,6 +98,7 @@ internal static async Task Execute(ConfigOptions opts, CancellationToken ct var user = await HttpService.GetCurrentUser(ct); patUserDisplayName = user.DisplayName; patUserEmail = user.Properties?.Account?.Value; + patUserId = user.Id; if (patUserEmail == null) ConsoleHelper.WriteError("Warning: could not detect your email automatically. Use 'config --email ' so '--assigned-to me' works."); @@ -107,7 +109,7 @@ internal static async Task Execute(ConfigOptions opts, CancellationToken ct } } - ConfigService.SaveConfig(opts, patUserDisplayName, patUserEmail); + ConfigService.SaveConfig(opts, patUserDisplayName, patUserEmail, userId: patUserId); if (patUserDisplayName != null && patUserEmail != null) ConsoleHelper.WriteSuccess($"Configuration saved. Logged in as: {patUserDisplayName} ({patUserEmail})"); @@ -128,11 +130,13 @@ private static async Task SignInFlow(ConfigOptions opts, CancellationToken string userDisplayName = null; string userEmail = null; + string userId = null; try { var user = await HttpService.GetCurrentUser(ct); userDisplayName = user.DisplayName; userEmail = user.Properties?.Account?.Value; + userId = user.Id; } catch (Exception ex) { @@ -140,7 +144,7 @@ private static async Task SignInFlow(ConfigOptions opts, CancellationToken } userEmail ??= result.Account?.Username; - ConfigService.SaveConfig(opts, userDisplayName, userEmail, AuthModes.Entra); + ConfigService.SaveConfig(opts, userDisplayName, userEmail, AuthModes.Entra, userId); ConsoleHelper.WriteSuccess($"Signed in as {userDisplayName ?? result.Account?.Username} ({userEmail})."); return 0; diff --git a/DevOps/Actions/PrAbandonAction.cs b/DevOps/Actions/PrAbandonAction.cs new file mode 100644 index 0000000..f5243db --- /dev/null +++ b/DevOps/Actions/PrAbandonAction.cs @@ -0,0 +1,32 @@ +using DevOps.Options; +using DevOps.Services; +using DevOps.Utils; + +namespace DevOps.Actions; + +internal static class PrAbandonAction +{ + internal static async Task Execute(PrAbandonOptions opts, CancellationToken ct) + { + try + { + var pr = await HttpService.GetPullRequest(opts.Id, ct); + var repo = pr.Repository?.Name; + var project = pr.Repository?.Project?.Name; + if (string.IsNullOrEmpty(repo) || string.IsNullOrEmpty(project)) + { + ConsoleHelper.WriteError($"Could not resolve the repository for pull request {opts.Id}."); + return 1; + } + + var updated = await HttpService.SetPullRequestStatus(project, repo, opts.Id, "abandoned", ct); + ConsoleHelper.WriteSuccess($"Pull request #{opts.Id} abandoned (status: {updated.Status})."); + return 0; + } + catch (Exception ex) + { + ConsoleHelper.WriteError($"Error: {ex.Message}"); + return 1; + } + } +} diff --git a/DevOps/Actions/PrCompleteAction.cs b/DevOps/Actions/PrCompleteAction.cs new file mode 100644 index 0000000..77e431e --- /dev/null +++ b/DevOps/Actions/PrCompleteAction.cs @@ -0,0 +1,39 @@ +using DevOps.Options; +using DevOps.Services; +using DevOps.Utils; + +namespace DevOps.Actions; + +internal static class PrCompleteAction +{ + internal static async Task Execute(PrCompleteOptions opts, CancellationToken ct) + { + try + { + var pr = await HttpService.GetPullRequest(opts.Id, ct); + var repo = pr.Repository?.Name; + var project = pr.Repository?.Project?.Name; + if (string.IsNullOrEmpty(repo) || string.IsNullOrEmpty(project)) + { + ConsoleHelper.WriteError($"Could not resolve the repository for pull request {opts.Id}."); + return 1; + } + + var commitId = pr.LastMergeSourceCommit?.CommitId; + if (string.IsNullOrEmpty(commitId)) + { + ConsoleHelper.WriteError($"Pull request {opts.Id} has no merge commit to complete (it may be a draft or have conflicts)."); + return 1; + } + + var updated = await HttpService.CompletePullRequest(project, repo, opts.Id, commitId, opts.DeleteSource, ct); + ConsoleHelper.WriteSuccess($"Pull request #{opts.Id} completed (status: {updated.Status})."); + return 0; + } + catch (Exception ex) + { + ConsoleHelper.WriteError($"Error: {ex.Message}"); + return 1; + } + } +} diff --git a/DevOps/Actions/PrListAction.cs b/DevOps/Actions/PrListAction.cs index 218b08a..2faf8f7 100644 --- a/DevOps/Actions/PrListAction.cs +++ b/DevOps/Actions/PrListAction.cs @@ -13,7 +13,8 @@ internal static async Task Execute(PrListOptions opts, CancellationToken ct { var project = ConfigService.ResolveProject(opts.Project); var status = string.Equals(opts.Status, "all", StringComparison.OrdinalIgnoreCase) ? null : opts.Status; - var prs = await HttpService.ListPullRequests(project, opts.Repo, status, opts.Target, opts.Top, ct); + var creatorId = opts.Mine ? ConfigService.ResolveUserId() : null; + var prs = await HttpService.ListPullRequests(project, opts.Repo, status, opts.Target, opts.Top, creatorId, ct); if (prs.Count == 0) { diff --git a/DevOps/Actions/PrVoteAction.cs b/DevOps/Actions/PrVoteAction.cs new file mode 100644 index 0000000..3f9c2fa --- /dev/null +++ b/DevOps/Actions/PrVoteAction.cs @@ -0,0 +1,35 @@ +using DevOps.Options; +using DevOps.Services; +using DevOps.Utils; + +namespace DevOps.Actions; + +internal static class PrVoteAction +{ + internal static async Task Execute(PrVoteOptions opts, CancellationToken ct) + { + try + { + var vote = ActionHelpers.VoteValue(opts.Vote); + var userId = ConfigService.ResolveUserId(); + + var pr = await HttpService.GetPullRequest(opts.Id, ct); + var repo = pr.Repository?.Name; + var project = pr.Repository?.Project?.Name; + if (string.IsNullOrEmpty(repo) || string.IsNullOrEmpty(project)) + { + ConsoleHelper.WriteError($"Could not resolve the repository for pull request {opts.Id}."); + return 1; + } + + await HttpService.VotePullRequest(project, repo, opts.Id, userId, vote, ct); + ConsoleHelper.WriteSuccess($"Voted '{ActionHelpers.VoteText(vote)}' on pull request #{opts.Id}."); + return 0; + } + catch (Exception ex) + { + ConsoleHelper.WriteError($"Error: {ex.Message}"); + return 1; + } + } +} diff --git a/DevOps/Options/PrAbandonOptions.cs b/DevOps/Options/PrAbandonOptions.cs new file mode 100644 index 0000000..7506187 --- /dev/null +++ b/DevOps/Options/PrAbandonOptions.cs @@ -0,0 +1,10 @@ +using CommandLine; + +namespace DevOps.Options; + +[Verb("pr-abandon", HelpText = "Abandon a pull request.")] +public class PrAbandonOptions +{ + [Option('i', "id", Required = true, HelpText = "Pull request ID.")] + public int Id { get; set; } +} diff --git a/DevOps/Options/PrCompleteOptions.cs b/DevOps/Options/PrCompleteOptions.cs new file mode 100644 index 0000000..f333c45 --- /dev/null +++ b/DevOps/Options/PrCompleteOptions.cs @@ -0,0 +1,13 @@ +using CommandLine; + +namespace DevOps.Options; + +[Verb("pr-complete", HelpText = "Complete (merge) a pull request.")] +public class PrCompleteOptions +{ + [Option('i', "id", Required = true, HelpText = "Pull request ID.")] + public int Id { get; set; } + + [Option("delete-source", Required = false, HelpText = "Delete the source branch after completing.")] + public bool DeleteSource { get; set; } +} diff --git a/DevOps/Options/PrListOptions.cs b/DevOps/Options/PrListOptions.cs index ce2dbc7..104d58d 100644 --- a/DevOps/Options/PrListOptions.cs +++ b/DevOps/Options/PrListOptions.cs @@ -19,4 +19,7 @@ public class PrListOptions [Option('n', "top", Required = false, Default = 25, HelpText = "Maximum number of pull requests to show (default: 25).")] public int Top { get; set; } + + [Option('m', "mine", Required = false, HelpText = "Only pull requests created by me.")] + public bool Mine { get; set; } } diff --git a/DevOps/Options/PrVoteOptions.cs b/DevOps/Options/PrVoteOptions.cs new file mode 100644 index 0000000..ff46620 --- /dev/null +++ b/DevOps/Options/PrVoteOptions.cs @@ -0,0 +1,13 @@ +using CommandLine; + +namespace DevOps.Options; + +[Verb("pr-vote", HelpText = "Cast your vote on a pull request.")] +public class PrVoteOptions +{ + [Option('i', "id", Required = true, HelpText = "Pull request ID.")] + public int Id { get; set; } + + [Option('v', "vote", Required = true, HelpText = "Vote: approve, approve-suggestions, reject, wait, or reset.")] + public string Vote { get; set; } +} diff --git a/DevOps/Program.cs b/DevOps/Program.cs index 1686142..0905498 100644 --- a/DevOps/Program.cs +++ b/DevOps/Program.cs @@ -26,7 +26,10 @@ typeof(PrListOptions), typeof(PrGetOptions), typeof(PrCreateOptions), - typeof(PrOpenOptions) + typeof(PrOpenOptions), + typeof(PrVoteOptions), + typeof(PrAbandonOptions), + typeof(PrCompleteOptions) }; var result = Parser.Default.ParseArguments(args, optionTypes); @@ -52,6 +55,9 @@ await result.MapResult( PrGetOptions o => PrGetAction.Execute(o, cts.Token), PrCreateOptions o => PrCreateAction.Execute(o, cts.Token), PrOpenOptions o => PrOpenAction.Execute(o, cts.Token), + PrVoteOptions o => PrVoteAction.Execute(o, cts.Token), + PrAbandonOptions o => PrAbandonAction.Execute(o, cts.Token), + PrCompleteOptions o => PrCompleteAction.Execute(o, cts.Token), _ => Task.FromResult(1) }, _ => Task.FromResult(1) diff --git a/DevOps/Responses/AzureDevOpsResponses.cs b/DevOps/Responses/AzureDevOpsResponses.cs index e6e0a3c..ed997e0 100644 --- a/DevOps/Responses/AzureDevOpsResponses.cs +++ b/DevOps/Responses/AzureDevOpsResponses.cs @@ -217,10 +217,19 @@ public class PullRequestResponse [JsonProperty("reviewers")] public List Reviewers { get; set; } + [JsonProperty("lastMergeSourceCommit")] + public GitCommitRef LastMergeSourceCommit { get; set; } + [JsonProperty("_links")] public PullRequestLinks Links { get; set; } } +public class GitCommitRef +{ + [JsonProperty("commitId")] + public string CommitId { get; set; } +} + public class PullRequestRepository { [JsonProperty("name")] @@ -274,6 +283,9 @@ public class ConnectionDataResponse public class AuthenticatedUser { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("providerDisplayName")] public string DisplayName { get; set; } diff --git a/DevOps/Services/ConfigService.cs b/DevOps/Services/ConfigService.cs index 2485e6b..11a6814 100644 --- a/DevOps/Services/ConfigService.cs +++ b/DevOps/Services/ConfigService.cs @@ -21,6 +21,7 @@ public record Config public string AuthMode { get; set; } public string DefaultProject { get; set; } public string DefaultTeam { get; set; } + public string UserId { get; set; } public string UserDisplayName { get; set; } public string UserEmail { get; set; } public bool PatEncrypted { get; set; } @@ -56,7 +57,7 @@ public static Config LoadConfig() return config; } - public static void SaveConfig(ConfigOptions opts, string userDisplayName = null, string userEmail = null, string authMode = null) + public static void SaveConfig(ConfigOptions opts, string userDisplayName = null, string userEmail = null, string authMode = null, string userId = null) { var configPath = GetConfigPath(); var folderPath = Path.GetDirectoryName(configPath); @@ -78,6 +79,7 @@ public static void SaveConfig(ConfigOptions opts, string userDisplayName = null, AuthMode = resolvedAuthMode, DefaultProject = opts.Project ?? existing.DefaultProject, DefaultTeam = opts.Team ?? existing.DefaultTeam, + UserId = userId ?? existing.UserId, UserDisplayName = userDisplayName ?? existing.UserDisplayName, UserEmail = opts.Email ?? userEmail ?? existing.UserEmail, TableBorder = opts.Border ?? existing.TableBorder @@ -119,6 +121,15 @@ public static string ResolveAssignedTo(string assignedTo) throw new InvalidOperationException("Cannot resolve 'me': user email not found in config. Run 'config --login' or set it with 'config --email '."); } + public static string ResolveUserId() + { + var config = LoadConfig(); + if (!string.IsNullOrEmpty(config.UserId)) + return config.UserId; + + throw new InvalidOperationException("Your user ID is not stored yet. Re-run 'config --login' (or 'config --pat ') to refresh it."); + } + public static string ResolveTeam(string project, string team = null) { if (!string.IsNullOrEmpty(team)) return team; diff --git a/DevOps/Services/HttpService.cs b/DevOps/Services/HttpService.cs index e1f8a75..6d2e9e1 100644 --- a/DevOps/Services/HttpService.cs +++ b/DevOps/Services/HttpService.cs @@ -234,7 +234,7 @@ public static async Task QueuePipelineRun(string project, i private static string NormalizeBranch(string branch) => branch.StartsWith("refs/", StringComparison.OrdinalIgnoreCase) ? branch : $"refs/heads/{branch}"; - public static async Task> ListPullRequests(string project, string repo, string status, string targetBranch, int top, CancellationToken cancellationToken = default) + public static async Task> ListPullRequests(string project, string repo, string status, string targetBranch, int top, string creatorId = null, CancellationToken cancellationToken = default) { using var client = await CreateClientAsync(cancellationToken); var path = string.IsNullOrEmpty(repo) @@ -248,6 +248,8 @@ public static async Task> ListPullRequests(string proj request.AddQueryParameter("searchCriteria.status", status); if (!string.IsNullOrEmpty(targetBranch)) request.AddQueryParameter("searchCriteria.targetRefName", NormalizeBranch(targetBranch)); + if (!string.IsNullOrEmpty(creatorId)) + request.AddQueryParameter("searchCriteria.creatorId", creatorId); var response = await client.ExecuteAsync(request, cancellationToken); @@ -295,6 +297,53 @@ public static async Task CreatePullRequest(string project, return JsonConvert.DeserializeObject(response.Content); } + /// Casts the current user's vote on a pull request (self-adds as a reviewer if needed). + public static async Task VotePullRequest(string project, string repo, int pullRequestId, string userId, int vote, CancellationToken cancellationToken = default) + { + using var client = await CreateClientAsync(cancellationToken); + var request = new RestRequest($"{project}/_apis/git/repositories/{Uri.EscapeDataString(repo)}/pullrequests/{pullRequestId}/reviewers/{userId}", Method.Put); + request.AddQueryParameter("api-version", API_VERSION); + request.AddStringBody(JsonConvert.SerializeObject(new { vote }), DataFormat.Json); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + throw new Exception($"Failed to vote on pull request {pullRequestId}. Status: {response.StatusCode}. {response.Content}"); + } + + /// Sets a pull request status (e.g. "abandoned"), returning the updated PR. + public static async Task SetPullRequestStatus(string project, string repo, int pullRequestId, string status, CancellationToken cancellationToken = default) + { + return await PatchPullRequest(project, repo, pullRequestId, new { status }, cancellationToken); + } + + /// Completes (merges) a pull request using its last merge source commit. + public static async Task CompletePullRequest(string project, string repo, int pullRequestId, string lastMergeSourceCommitId, bool deleteSourceBranch, CancellationToken cancellationToken = default) + { + var body = new + { + status = "completed", + lastMergeSourceCommit = new { commitId = lastMergeSourceCommitId }, + completionOptions = new { deleteSourceBranch } + }; + return await PatchPullRequest(project, repo, pullRequestId, body, cancellationToken); + } + + private static async Task PatchPullRequest(string project, string repo, int pullRequestId, object body, CancellationToken cancellationToken) + { + using var client = await CreateClientAsync(cancellationToken); + var request = new RestRequest($"{project}/_apis/git/repositories/{Uri.EscapeDataString(repo)}/pullrequests/{pullRequestId}", Method.Patch); + request.AddQueryParameter("api-version", API_VERSION); + request.AddStringBody(JsonConvert.SerializeObject(body), DataFormat.Json); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + throw new Exception($"Failed to update pull request {pullRequestId}. Status: {response.StatusCode}. {response.Content}"); + + return JsonConvert.DeserializeObject(response.Content); + } + public static async Task GetCurrentUser(CancellationToken cancellationToken = default) { using var client = await CreateClientAsync(cancellationToken); diff --git a/README.md b/README.md index 5f2d1ec..22061d4 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,9 @@ On success it prints the new run ID, its state, and a link to follow it in the b ## Pull Request Commands -Pull requests belong to a **repository**, specified with `--repo` (required for `pr-create`, optional filter for `pr-list`). `pr-get` and `pr-open` work by PR ID at the organization level, so they need neither project nor repo. +Pull requests belong to a **repository**, specified with `--repo` (required for `pr-create`, optional filter for `pr-list`). `pr-get`, `pr-open`, `pr-vote`, `pr-abandon` and `pr-complete` work by PR ID and resolve the repository automatically, so they need neither project nor repo. + +> `pr-list --mine` and `pr-vote` need your user ID, which is captured during `config --login` / `config --pat`. If they report a missing user ID, re-run `config` to refresh it. ### `pr-list` — List pull requests @@ -382,6 +384,7 @@ devops pr-list devops pr-list -r MyRepo -s active devops pr-list -r MyRepo -t main devops pr-list -s all -n 50 +devops pr-list --mine ``` | Option | Alias | Description | @@ -391,6 +394,7 @@ devops pr-list -s all -n 50 | `--status` | `-s` | `active` (default), `completed`, `abandoned`, or `all` | | `--target` | `-t` | Filter by target branch (e.g., `main`) | | `--top` | `-n` | Maximum number of PRs to show (default: 25) | +| `--mine` | `-m` | Only pull requests you created | --- @@ -441,6 +445,51 @@ devops pr-open -i 123 --- +### `pr-vote` — Vote on a pull request + +Casts your vote (self-adding as a reviewer if needed). Works by PR ID; the repository is resolved automatically. + +```powershell +devops pr-vote -i 123 -v approve +devops pr-vote -i 123 -v reject +devops pr-vote -i 123 -v reset +``` + +| Option | Alias | Description | +|---|---|---| +| `--id` | `-i` | Pull request ID (required) | +| `--vote` | `-v` | `approve`, `approve-suggestions`, `reject`, `wait`, or `reset` (required) | + +--- + +### `pr-abandon` — Abandon a pull request + +```powershell +devops pr-abandon -i 123 +``` + +| Option | Alias | Description | +|---|---|---| +| `--id` | `-i` | Pull request ID (required) | + +--- + +### `pr-complete` — Complete (merge) a pull request + +Merges the PR using its last merge source commit. Fails if the PR has no merge commit (e.g. a draft or with conflicts). + +```powershell +devops pr-complete -i 123 +devops pr-complete -i 123 --delete-source +``` + +| Option | Alias | Description | +|---|---|---| +| `--id` | `-i` | Pull request ID (required) | +| `--delete-source` | | Delete the source branch after completing | + +--- + ## Authentication The CLI supports two authentication methods. Your choice is stored in `config.json` as the active auth mode and switching is just a matter of re-running `config`.