From bb0cecb530b7d143d642947f72883c6a8bb637d1 Mon Sep 17 00:00:00 2001 From: Jonas Lima de Amorim Date: Thu, 23 Jul 2026 19:26:32 -0300 Subject: [PATCH] feat(pr-comment): add command to comment on a pull request --- DevOps/Actions/PrCommentAction.cs | 32 ++++++++++++++++++++++++ DevOps/Options/PrCommentOptions.cs | 13 ++++++++++ DevOps/Program.cs | 4 ++- DevOps/Responses/AzureDevOpsResponses.cs | 6 +++++ DevOps/Services/HttpService.cs | 22 ++++++++++++++++ README.md | 15 +++++++++++ 6 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 DevOps/Actions/PrCommentAction.cs create mode 100644 DevOps/Options/PrCommentOptions.cs diff --git a/DevOps/Actions/PrCommentAction.cs b/DevOps/Actions/PrCommentAction.cs new file mode 100644 index 0000000..99b2799 --- /dev/null +++ b/DevOps/Actions/PrCommentAction.cs @@ -0,0 +1,32 @@ +using DevOps.Options; +using DevOps.Services; +using DevOps.Utils; + +namespace DevOps.Actions; + +internal static class PrCommentAction +{ + internal static async Task Execute(PrCommentOptions 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; + } + + await HttpService.AddPullRequestComment(project, repo, opts.Id, opts.Message, ct); + ConsoleHelper.WriteSuccess($"Comment added to pull request #{opts.Id}."); + return 0; + } + catch (Exception ex) + { + ConsoleHelper.WriteError($"Error: {ex.Message}"); + return 1; + } + } +} diff --git a/DevOps/Options/PrCommentOptions.cs b/DevOps/Options/PrCommentOptions.cs new file mode 100644 index 0000000..e2c44b2 --- /dev/null +++ b/DevOps/Options/PrCommentOptions.cs @@ -0,0 +1,13 @@ +using CommandLine; + +namespace DevOps.Options; + +[Verb("pr-comment", HelpText = "Add a comment to a pull request.")] +public class PrCommentOptions +{ + [Option('i', "id", Required = true, HelpText = "Pull request ID.")] + public int Id { get; set; } + + [Option('m', "message", Required = true, HelpText = "Comment text.")] + public string Message { get; set; } +} diff --git a/DevOps/Program.cs b/DevOps/Program.cs index 0905498..bc31ae4 100644 --- a/DevOps/Program.cs +++ b/DevOps/Program.cs @@ -29,7 +29,8 @@ typeof(PrOpenOptions), typeof(PrVoteOptions), typeof(PrAbandonOptions), - typeof(PrCompleteOptions) + typeof(PrCompleteOptions), + typeof(PrCommentOptions) }; var result = Parser.Default.ParseArguments(args, optionTypes); @@ -58,6 +59,7 @@ await result.MapResult( PrVoteOptions o => PrVoteAction.Execute(o, cts.Token), PrAbandonOptions o => PrAbandonAction.Execute(o, cts.Token), PrCompleteOptions o => PrCompleteAction.Execute(o, cts.Token), + PrCommentOptions o => PrCommentAction.Execute(o, cts.Token), _ => Task.FromResult(1) }, _ => Task.FromResult(1) diff --git a/DevOps/Responses/AzureDevOpsResponses.cs b/DevOps/Responses/AzureDevOpsResponses.cs index ed997e0..7e458fa 100644 --- a/DevOps/Responses/AzureDevOpsResponses.cs +++ b/DevOps/Responses/AzureDevOpsResponses.cs @@ -245,6 +245,12 @@ public class PullRequestProject public string Name { get; set; } } +public class PullRequestThreadResponse +{ + [JsonProperty("id")] + public int Id { get; set; } +} + public class PullRequestReviewer { [JsonProperty("displayName")] diff --git a/DevOps/Services/HttpService.cs b/DevOps/Services/HttpService.cs index 6d2e9e1..5f05991 100644 --- a/DevOps/Services/HttpService.cs +++ b/DevOps/Services/HttpService.cs @@ -311,6 +311,28 @@ public static async Task VotePullRequest(string project, string repo, int pullRe throw new Exception($"Failed to vote on pull request {pullRequestId}. Status: {response.StatusCode}. {response.Content}"); } + /// Adds a top-level comment thread to a pull request, returning the created thread id. + public static async Task AddPullRequestComment(string project, string repo, int pullRequestId, string content, CancellationToken cancellationToken = default) + { + using var client = await CreateClientAsync(cancellationToken); + var request = new RestRequest($"{project}/_apis/git/repositories/{Uri.EscapeDataString(repo)}/pullrequests/{pullRequestId}/threads", Method.Post); + request.AddQueryParameter("api-version", API_VERSION); + + var body = new + { + comments = new[] { new { parentCommentId = 0, content, commentType = 1 } }, + status = 1 + }; + request.AddStringBody(JsonConvert.SerializeObject(body), DataFormat.Json); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + throw new Exception($"Failed to comment on pull request {pullRequestId}. Status: {response.StatusCode}. {response.Content}"); + + return JsonConvert.DeserializeObject(response.Content)?.Id ?? 0; + } + /// 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) { diff --git a/README.md b/README.md index 22061d4..fbe1a2d 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,21 @@ devops pr-vote -i 123 -v reset --- +### `pr-comment` — Add a comment to a pull request + +Posts a top-level comment thread. Works by PR ID; the repository is resolved automatically. + +```powershell +devops pr-comment -i 123 -m "Looks good, one nit on the naming." +``` + +| Option | Alias | Description | +|---|---|---| +| `--id` | `-i` | Pull request ID (required) | +| `--message` | `-m` | Comment text (required) | + +--- + ### `pr-abandon` — Abandon a pull request ```powershell