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
10 changes: 10 additions & 0 deletions DevOps/Actions/ActionHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions DevOps/Actions/ConfigAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ internal static async Task<int> 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))
{
Expand All @@ -97,6 +98,7 @@ internal static async Task<int> 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 <your@email.com>' so '--assigned-to me' works.");
Expand All @@ -107,7 +109,7 @@ internal static async Task<int> 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})");
Expand All @@ -128,19 +130,21 @@ private static async Task<int> 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)
{
ConsoleHelper.WriteError($"Warning: signed in, but could not fetch user info ({ex.Message}). Ensure --org is set.");
}

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;
Expand Down
32 changes: 32 additions & 0 deletions DevOps/Actions/PrAbandonAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using DevOps.Options;
using DevOps.Services;
using DevOps.Utils;

namespace DevOps.Actions;

internal static class PrAbandonAction
{
internal static async Task<int> 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;
}
}
}
39 changes: 39 additions & 0 deletions DevOps/Actions/PrCompleteAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using DevOps.Options;
using DevOps.Services;
using DevOps.Utils;

namespace DevOps.Actions;

internal static class PrCompleteAction
{
internal static async Task<int> 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;
}
}
}
3 changes: 2 additions & 1 deletion DevOps/Actions/PrListAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ internal static async Task<int> 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)
{
Expand Down
35 changes: 35 additions & 0 deletions DevOps/Actions/PrVoteAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using DevOps.Options;
using DevOps.Services;
using DevOps.Utils;

namespace DevOps.Actions;

internal static class PrVoteAction
{
internal static async Task<int> 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;
}
}
}
10 changes: 10 additions & 0 deletions DevOps/Options/PrAbandonOptions.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
13 changes: 13 additions & 0 deletions DevOps/Options/PrCompleteOptions.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
3 changes: 3 additions & 0 deletions DevOps/Options/PrListOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
13 changes: 13 additions & 0 deletions DevOps/Options/PrVoteOptions.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
8 changes: 7 additions & 1 deletion DevOps/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions DevOps/Responses/AzureDevOpsResponses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,19 @@ public class PullRequestResponse
[JsonProperty("reviewers")]
public List<PullRequestReviewer> 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")]
Expand Down Expand Up @@ -274,6 +283,9 @@ public class ConnectionDataResponse

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

[JsonProperty("providerDisplayName")]
public string DisplayName { get; set; }

Expand Down
13 changes: 12 additions & 1 deletion DevOps/Services/ConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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 <your@email.com>'.");
}

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 <token>') to refresh it.");
}

public static string ResolveTeam(string project, string team = null)
{
if (!string.IsNullOrEmpty(team)) return team;
Expand Down
51 changes: 50 additions & 1 deletion DevOps/Services/HttpService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ public static async Task<PipelineRunResponse> QueuePipelineRun(string project, i
private static string NormalizeBranch(string branch) =>
branch.StartsWith("refs/", StringComparison.OrdinalIgnoreCase) ? branch : $"refs/heads/{branch}";

public static async Task<List<PullRequestResponse>> ListPullRequests(string project, string repo, string status, string targetBranch, int top, CancellationToken cancellationToken = default)
public static async Task<List<PullRequestResponse>> 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)
Expand All @@ -248,6 +248,8 @@ public static async Task<List<PullRequestResponse>> 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);

Expand Down Expand Up @@ -295,6 +297,53 @@ public static async Task<PullRequestResponse> CreatePullRequest(string project,
return JsonConvert.DeserializeObject<PullRequestResponse>(response.Content);
}

/// <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)
{
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}");
}

/// <summary>Sets a pull request status (e.g. "abandoned"), returning the updated PR.</summary>
public static async Task<PullRequestResponse> SetPullRequestStatus(string project, string repo, int pullRequestId, string status, CancellationToken cancellationToken = default)
{
return await PatchPullRequest(project, repo, pullRequestId, new { status }, cancellationToken);
}

/// <summary>Completes (merges) a pull request using its last merge source commit.</summary>
public static async Task<PullRequestResponse> 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<PullRequestResponse> 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<PullRequestResponse>(response.Content);
}

public static async Task<AuthenticatedUser> GetCurrentUser(CancellationToken cancellationToken = default)
{
using var client = await CreateClientAsync(cancellationToken);
Expand Down
Loading
Loading