Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion DevOps/Actions/PrCreateAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,39 @@ internal static async Task<int> 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<string> 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}");

var url = ActionHelpers.ResolvePullRequestUrl(pr);
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)
Expand Down
8 changes: 7 additions & 1 deletion DevOps/Options/PrCreateOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).")]
Expand All @@ -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<string> Reviewers { get; set; }

[Option('w', "work-item", Required = false, Separator = ',', HelpText = "Work item IDs to link to the pull request (comma-separated).")]
public IEnumerable<int> WorkItems { get; set; }
}
9 changes: 9 additions & 0 deletions DevOps/Requests/AzureDevOpsRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,13 @@ public class PullRequestCreateRequest

[JsonProperty("isDraft")]
public bool IsDraft { get; set; }

[JsonProperty("reviewers", NullValueHandling = NullValueHandling.Ignore)]
public List<PullRequestReviewerRef> Reviewers { get; set; }
}

public class PullRequestReviewerRef
{
[JsonProperty("id")]
public string Id { get; set; }
}
18 changes: 18 additions & 0 deletions DevOps/Responses/AzureDevOpsResponses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ public class GitCommitRef

public class PullRequestRepository
{
[JsonProperty("id")]
public string Id { get; set; }

[JsonProperty("name")]
public string Name { get; set; }

Expand All @@ -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<IdentityRef> Value { get; set; }
}

public class IdentityRef
{
[JsonProperty("id")]
public string Id { get; set; }
}

public class PullRequestReviewer
{
[JsonProperty("displayName")]
Expand Down
109 changes: 95 additions & 14 deletions DevOps/Services/HttpService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,27 @@ public static class HttpService
private static async Task<RestClient> CreateClientAsync(CancellationToken cancellationToken)
{
var config = ConfigService.LoadConfig();
return new RestClient(new RestClientOptions(config.OrgUrl) { Authenticator = await ResolveAuthenticatorAsync(config, cancellationToken) });
}

private static async Task<RestClient> 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<IAuthenticator> 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 <token>' 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 <token>' or 'config --login'.");
}

public static async Task<WorkItemResponse> GetWorkItem(int id, string project, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -273,7 +276,7 @@ public static async Task<PullRequestResponse> GetPullRequest(int pullRequestId,
return JsonConvert.DeserializeObject<PullRequestResponse>(response.Content);
}

public static async Task<PullRequestResponse> CreatePullRequest(string project, string repo, string sourceBranch, string targetBranch, string title, string description, bool isDraft, CancellationToken cancellationToken = default)
public static async Task<PullRequestResponse> CreatePullRequest(string project, string repo, string sourceBranch, string targetBranch, string title, string description, bool isDraft, List<string> 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);
Expand All @@ -285,7 +288,8 @@ public static async Task<PullRequestResponse> 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);

Expand All @@ -297,6 +301,83 @@ public static async Task<PullRequestResponse> CreatePullRequest(string project,
return JsonConvert.DeserializeObject<PullRequestResponse>(response.Content);
}

/// <summary>
/// Resolves reviewer identifiers to their IDs. Accepts "me" (current user), a raw GUID,
/// or an email/name resolved through the Identities API.
/// </summary>
public static async Task<List<string>> ResolveReviewerIds(IEnumerable<string> 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<string>();
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<IdentityListResponse>(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;
}

/// <summary>Links work items to a pull request via an ArtifactLink relation on each work item.</summary>
public static async Task LinkWorkItemsToPullRequest(string projectId, string repositoryId, int pullRequestId, IEnumerable<int> 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<JsonPatchOperation>
{
new()
{
Op = "add",
Path = "/relations/-",
Value = new { rel = "ArtifactLink", url = artifactUri, attributes = new { name = "Pull Request" } }
}
};

await UpdateWorkItem(workItemId, project, operations, cancellationToken);
}
}

/// <summary>Casts the current user's vote on a pull request (self-adds as a reviewer if needed).</summary>
public static async Task VotePullRequest(string project, string repo, int pullRequestId, string userId, int vote, CancellationToken cancellationToken = default)
{
Expand Down
33 changes: 33 additions & 0 deletions DevOps/Utils/GitHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace DevOps.Utils;

public static class GitHelper
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

---
Expand Down
Loading