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 16e9e92..35cb40b 100644 --- a/DevOps/Responses/AzureDevOpsResponses.cs +++ b/DevOps/Responses/AzureDevOpsResponses.cs @@ -251,6 +251,12 @@ public class PullRequestProject public string Name { get; set; } } +public class PullRequestThreadResponse +{ + [JsonProperty("id")] + public int Id { get; set; } +} + public class IdentityListResponse { [JsonProperty("value")] diff --git a/DevOps/Services/HttpService.cs b/DevOps/Services/HttpService.cs index 01c6277..4e83579 100644 --- a/DevOps/Services/HttpService.cs +++ b/DevOps/Services/HttpService.cs @@ -392,6 +392,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 b74c035..ec395f4 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,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