diff --git a/DevOps/Actions/PrCreateAction.cs b/DevOps/Actions/PrCreateAction.cs index e5105f0..13a4bc5 100644 --- a/DevOps/Actions/PrCreateAction.cs +++ b/DevOps/Actions/PrCreateAction.cs @@ -11,7 +11,25 @@ internal static async Task Execute(PrCreateOptions opts, CancellationToken try { var project = ConfigService.ResolveProject(opts.Project); - var pr = await HttpService.CreatePullRequest(project, opts.Repo, opts.Source, opts.Target, opts.Title, opts.Description, opts.Draft, ct); + + var source = opts.Source; + if (string.IsNullOrWhiteSpace(source)) + { + source = GitHelper.CurrentBranch(); + if (string.IsNullOrWhiteSpace(source)) + { + ConsoleHelper.WriteError("Could not detect the current git branch. Provide --source explicitly."); + return 1; + } + + ActionHelpers.WriteMuted($"Using current branch as source: {source}"); + } + + List reviewerIds = null; + if (opts.Reviewers != null && opts.Reviewers.Any()) + reviewerIds = await HttpService.ResolveReviewerIds(opts.Reviewers, ct); + + var pr = await HttpService.CreatePullRequest(project, opts.Repo, source, opts.Target, opts.Title, opts.Description, opts.Draft, reviewerIds, ct); ConsoleHelper.WriteSuccess($"Pull request #{pr.PullRequestId} created{(pr.IsDraft ? " (draft)" : "")}: {pr.Title}"); @@ -19,6 +37,13 @@ internal static async Task Execute(PrCreateOptions opts, CancellationToken if (!string.IsNullOrEmpty(url)) Console.WriteLine($"URL: {url}"); + var workItems = opts.WorkItems?.ToList(); + if (workItems is { Count: > 0 }) + { + await HttpService.LinkWorkItemsToPullRequest(pr.Repository.Project.Id, pr.Repository.Id, pr.PullRequestId, workItems, project, ct); + ActionHelpers.WriteMuted($"Linked work item(s): {string.Join(", ", workItems.Select(id => $"#{id}"))}"); + } + return 0; } catch (Exception ex) diff --git a/DevOps/Options/PrCreateOptions.cs b/DevOps/Options/PrCreateOptions.cs index 7459381..a3461f5 100644 --- a/DevOps/Options/PrCreateOptions.cs +++ b/DevOps/Options/PrCreateOptions.cs @@ -11,7 +11,7 @@ public class PrCreateOptions [Option('r', "repo", Required = true, HelpText = "Repository name.")] public string Repo { get; set; } - [Option('s', "source", Required = true, HelpText = "Source branch (e.g., feature/x or refs/heads/feature/x).")] + [Option('s', "source", Required = false, HelpText = "Source branch (e.g., feature/x). Defaults to the current git branch.")] public string Source { get; set; } [Option('t', "target", Required = true, HelpText = "Target branch (e.g., main).")] @@ -25,4 +25,10 @@ public class PrCreateOptions [Option("draft", Required = false, HelpText = "Create the pull request as a draft.")] public bool Draft { get; set; } + + [Option("reviewers", Required = false, Separator = ',', HelpText = "Reviewers to add: 'me', a GUID, or an email/display name (comma-separated).")] + public IEnumerable Reviewers { get; set; } + + [Option('w', "work-item", Required = false, Separator = ',', HelpText = "Work item IDs to link to the pull request (comma-separated).")] + public IEnumerable WorkItems { get; set; } } diff --git a/DevOps/Requests/AzureDevOpsRequests.cs b/DevOps/Requests/AzureDevOpsRequests.cs index 9df40c9..ee9e7fc 100644 --- a/DevOps/Requests/AzureDevOpsRequests.cs +++ b/DevOps/Requests/AzureDevOpsRequests.cs @@ -63,4 +63,13 @@ public class PullRequestCreateRequest [JsonProperty("isDraft")] public bool IsDraft { get; set; } + + [JsonProperty("reviewers", NullValueHandling = NullValueHandling.Ignore)] + public List Reviewers { get; set; } +} + +public class PullRequestReviewerRef +{ + [JsonProperty("id")] + public string Id { get; set; } } diff --git a/DevOps/Responses/AzureDevOpsResponses.cs b/DevOps/Responses/AzureDevOpsResponses.cs index ed997e0..16e9e92 100644 --- a/DevOps/Responses/AzureDevOpsResponses.cs +++ b/DevOps/Responses/AzureDevOpsResponses.cs @@ -232,6 +232,9 @@ public class GitCommitRef public class PullRequestRepository { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("name")] public string Name { get; set; } @@ -241,10 +244,25 @@ public class PullRequestRepository public class PullRequestProject { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("name")] public string Name { get; set; } } +public class IdentityListResponse +{ + [JsonProperty("value")] + public List Value { get; set; } +} + +public class IdentityRef +{ + [JsonProperty("id")] + public string Id { get; set; } +} + public class PullRequestReviewer { [JsonProperty("displayName")] diff --git a/DevOps/Services/HttpService.cs b/DevOps/Services/HttpService.cs index 6d2e9e1..01c6277 100644 --- a/DevOps/Services/HttpService.cs +++ b/DevOps/Services/HttpService.cs @@ -21,24 +21,27 @@ public static class HttpService private static async Task CreateClientAsync(CancellationToken cancellationToken) { var config = ConfigService.LoadConfig(); + return new RestClient(new RestClientOptions(config.OrgUrl) { Authenticator = await ResolveAuthenticatorAsync(config, cancellationToken) }); + } + + private static async Task CreateClientAsync(string baseUrl, CancellationToken cancellationToken) + { + var config = ConfigService.LoadConfig(); + return new RestClient(new RestClientOptions(baseUrl) { Authenticator = await ResolveAuthenticatorAsync(config, cancellationToken) }); + } - IAuthenticator authenticator; + private static async Task ResolveAuthenticatorAsync(Config config, CancellationToken cancellationToken) + { if (config.AuthMode == AuthModes.Entra) { var token = await AuthService.GetAccessTokenAsync(config.TenantId, cancellationToken); - authenticator = new JwtAuthenticator(token); - } - else if (!string.IsNullOrEmpty(config.Pat)) - { - authenticator = new HttpBasicAuthenticator(string.Empty, config.Pat); - } - else - { - throw new InvalidOperationException("No authentication configured. Run 'config --pat ' or 'config --login'."); + return new JwtAuthenticator(token); } - var options = new RestClientOptions(config.OrgUrl) { Authenticator = authenticator }; - return new RestClient(options); + if (!string.IsNullOrEmpty(config.Pat)) + return new HttpBasicAuthenticator(string.Empty, config.Pat); + + throw new InvalidOperationException("No authentication configured. Run 'config --pat ' or 'config --login'."); } public static async Task GetWorkItem(int id, string project, CancellationToken cancellationToken = default) @@ -273,7 +276,7 @@ public static async Task GetPullRequest(int pullRequestId, return JsonConvert.DeserializeObject(response.Content); } - public static async Task CreatePullRequest(string project, string repo, string sourceBranch, string targetBranch, string title, string description, bool isDraft, CancellationToken cancellationToken = default) + public static async Task CreatePullRequest(string project, string repo, string sourceBranch, string targetBranch, string title, string description, bool isDraft, List reviewerIds = null, CancellationToken cancellationToken = default) { using var client = await CreateClientAsync(cancellationToken); var request = new RestRequest($"{project}/_apis/git/repositories/{Uri.EscapeDataString(repo)}/pullrequests", Method.Post); @@ -285,7 +288,8 @@ public static async Task CreatePullRequest(string project, TargetRefName = NormalizeBranch(targetBranch), Title = title, Description = description, - IsDraft = isDraft + IsDraft = isDraft, + Reviewers = reviewerIds?.Select(id => new PullRequestReviewerRef { Id = id }).ToList() }; request.AddStringBody(JsonConvert.SerializeObject(body), DataFormat.Json); @@ -297,6 +301,83 @@ public static async Task CreatePullRequest(string project, return JsonConvert.DeserializeObject(response.Content); } + /// + /// Resolves reviewer identifiers to their IDs. Accepts "me" (current user), a raw GUID, + /// or an email/name resolved through the Identities API. + /// + public static async Task> ResolveReviewerIds(IEnumerable reviewers, CancellationToken cancellationToken = default) + { + var config = ConfigService.LoadConfig(); + var vsspsBase = config.OrgUrl.Replace("://dev.azure.com", "://vssps.dev.azure.com", StringComparison.OrdinalIgnoreCase); + + var ids = new List(); + RestClient identityClient = null; + try + { + foreach (var raw in reviewers) + { + var value = raw?.Trim(); + if (string.IsNullOrEmpty(value)) + continue; + + if (value.Equals("me", StringComparison.OrdinalIgnoreCase)) + { + ids.Add(ConfigService.ResolveUserId()); + continue; + } + + if (Guid.TryParse(value, out _)) + { + ids.Add(value); + continue; + } + + identityClient ??= await CreateClientAsync(vsspsBase, cancellationToken); + var request = new RestRequest("_apis/identities", Method.Get); + request.AddQueryParameter("api-version", API_VERSION); + request.AddQueryParameter("searchFilter", "General"); + request.AddQueryParameter("filterValue", value); + + var response = await identityClient.ExecuteAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + throw new Exception($"Failed to resolve reviewer '{value}'. Status: {response.StatusCode}. {response.Content}"); + + var found = JsonConvert.DeserializeObject(response.Content)?.Value?.FirstOrDefault(); + if (string.IsNullOrEmpty(found?.Id)) + throw new Exception($"Reviewer '{value}' not found."); + + ids.Add(found.Id); + } + } + finally + { + identityClient?.Dispose(); + } + + return ids; + } + + /// Links work items to a pull request via an ArtifactLink relation on each work item. + public static async Task LinkWorkItemsToPullRequest(string projectId, string repositoryId, int pullRequestId, IEnumerable workItemIds, string project, CancellationToken cancellationToken = default) + { + var artifactUri = $"vstfs:///Git/PullRequestId/{projectId}%2F{repositoryId}%2F{pullRequestId}"; + + foreach (var workItemId in workItemIds) + { + var operations = new List + { + new() + { + Op = "add", + Path = "/relations/-", + Value = new { rel = "ArtifactLink", url = artifactUri, attributes = new { name = "Pull Request" } } + } + }; + + await UpdateWorkItem(workItemId, project, operations, cancellationToken); + } + } + /// 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) { diff --git a/DevOps/Utils/GitHelper.cs b/DevOps/Utils/GitHelper.cs new file mode 100644 index 0000000..256ad06 --- /dev/null +++ b/DevOps/Utils/GitHelper.cs @@ -0,0 +1,33 @@ +namespace DevOps.Utils; + +public static class GitHelper +{ + /// + /// Returns the current git branch by reading .git/HEAD, searching from the working + /// directory upward. Returns null when not in a git repository or in a detached HEAD. + /// + public static string CurrentBranch() + { + try + { + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null) + { + var head = Path.Combine(dir.FullName, ".git", "HEAD"); + if (File.Exists(head)) + { + var content = File.ReadAllText(head).Trim(); + const string prefix = "ref: refs/heads/"; + return content.StartsWith(prefix, StringComparison.Ordinal) ? content[prefix.Length..] : null; + } + dir = dir.Parent; + } + } + catch + { + // best-effort only + } + + return null; + } +} diff --git a/README.md b/README.md index 22061d4..b74c035 100644 --- a/README.md +++ b/README.md @@ -416,19 +416,26 @@ Shows status, source/target branches, author, reviewers with their votes, the we ```powershell devops pr-create -r MyRepo -s feature/login -t main --title "Add login" -devops pr-create -r MyRepo -s feature/x -t main --title "WIP" -d "Details..." --draft +devops pr-create -r MyRepo -t main --title "Add login" # source = current git branch +devops pr-create -r MyRepo -t main --title "WIP" -d "Details..." --draft +devops pr-create -r MyRepo -t main --title "Add login" --reviewers me,jane@contoso.com +devops pr-create -r MyRepo -t main --title "Add login" -w 1234,1235 ``` -Branches accept either the short name (`main`) or the full ref (`refs/heads/main`). +Branches accept either the short name (`main`) or the full ref (`refs/heads/main`). When `--source` is omitted, the current git branch is detected automatically from `.git/HEAD`. + +`--reviewers` accepts `me` (you), a reviewer GUID, or an email / display name (resolved through the Identities API). `--work-item` links existing work items to the new PR. | Option | Alias | Description | |---|---|---| | `--repo` | `-r` | Repository name (required) | -| `--source` | `-s` | Source branch (required) | +| `--source` | `-s` | Source branch (defaults to the current git branch) | | `--target` | `-t` | Target branch (required) | | `--title` | | Pull request title (required) | | `--description` | `-d` | Pull request description | | `--draft` | | Create as a draft | +| `--reviewers` | | Reviewers to add: `me`, a GUID, or email/display name (comma-separated) | +| `--work-item` | `-w` | Work item IDs to link to the pull request (comma-separated) | | `--project` | `-p` | Project name (uses default if configured) | ---