Skip to content

Commit

Permalink
Adds support for creating PRs from a stack (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
geofflamrock authored Nov 13, 2024
1 parent 985a394 commit 2493b99
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 15 deletions.
2 changes: 2 additions & 0 deletions src/Stack/Commands/Helpers/DryRunCommandSettingsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ internal class DryRunCommandSettingsBase : CommandSettingsBase
public bool DryRun { get; init; }

public override GitOperationSettings GetGitOperationSettings() => new(DryRun, Verbose, WorkingDirectory);

public override GitHubOperationSettings GetGitHubOperationSettings() => new(DryRun, Verbose, WorkingDirectory);
}
139 changes: 139 additions & 0 deletions src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
using System.ComponentModel;
using System.Diagnostics;
using Spectre.Console;
using Spectre.Console.Cli;
using Stack.Config;
using Stack.Git;

namespace Stack.Commands;

internal class CreatePullRequestsCommandSettings : DryRunCommandSettingsBase
{
[Description("The name of the stack to create pull requests for.")]
[CommandOption("-n|--name")]
public string? Name { get; init; }
}

internal class CreatePullRequestsCommand(
IAnsiConsole console,
IGitOperations gitOperations,
IGitHubOperations gitHubOperations,
IStackConfig stackConfig) : AsyncCommand<CreatePullRequestsCommandSettings>
{
public override async Task<int> ExecuteAsync(CommandContext context, CreatePullRequestsCommandSettings settings)
{
await Task.CompletedTask;

var stacks = stackConfig.Load();

var remoteUri = gitOperations.GetRemoteUri(settings.GetGitOperationSettings());

var stacksForRemote = stacks.Where(s => s.RemoteUri.Equals(remoteUri, StringComparison.OrdinalIgnoreCase)).ToList();

if (stacksForRemote.Count == 0)
{
console.WriteLine("No stacks found for current repository.");
return 0;
}

var currentBranch = gitOperations.GetCurrentBranch(settings.GetGitOperationSettings());
var stackSelection = settings.Name ?? console.Prompt(Prompts.Stack(stacksForRemote, currentBranch));
var stack = stacksForRemote.First(s => s.Name.Equals(stackSelection, StringComparison.OrdinalIgnoreCase));

console.MarkupLine($"Stack: {stack.Name}");

if (console.Prompt(new ConfirmationPrompt("Are you sure you want to create pull requests for branches in this stack?")))
{
var sourceBranch = stack.SourceBranch;
var pullRequestsInStack = new List<GitHubPullRequest>();

foreach (var branch in stack.Branches)
{
if (gitOperations.DoesRemoteBranchExist(branch, settings.GetGitOperationSettings()))
{
var existingPullRequest = gitHubOperations.GetPullRequest(branch, settings.GetGitHubOperationSettings());

if (existingPullRequest is not null && existingPullRequest.State != GitHubPullRequestStates.Closed)
{
console.MarkupLine($"Pull request {existingPullRequest.GetPullRequestDisplay()} already exists for branch [blue]{branch}[/] to [blue]{sourceBranch}[/]. Skipping...");
pullRequestsInStack.Add(existingPullRequest);
}
else
{
var prTitle = console.Prompt(new TextPrompt<string>($"Pull request title for branch [blue]{branch}[/] to [blue]{sourceBranch}[/]:"));
console.MarkupLine($"Creating pull request for branch [blue]{branch}[/] to [blue]{sourceBranch}[/]");
var pullRequest = gitHubOperations.CreatePullRequest(branch, sourceBranch, prTitle, "", settings.GetGitHubOperationSettings());

if (pullRequest is not null)
{
console.MarkupLine($"Pull request {pullRequest.GetPullRequestDisplay()} created for branch [blue]{branch}[/] to [blue]{sourceBranch}[/]");
pullRequestsInStack.Add(pullRequest);
}
}

sourceBranch = branch;
}
else
{
// Remote branch no longer exists, skip over
console.MarkupLine($"[red]Branch '{branch}' no longer exists on the remote repository. Skipping...[/]");
}
}

if (pullRequestsInStack.Count > 1)
{
// Edit each PR and add to the top of the description
// the details of each PR in the stack
var stackMarkerStart = "<!-- stack-pr-list -->";
var stackMarkerEnd = "<!-- /stack-pr-list -->";
var prList = pullRequestsInStack
.Select(pr => $"- {pr.Url}")
.ToList();
var prListMarkdown = string.Join("\n", prList);
var prListHeader = $"This PR is part of a stack **{stack.Name}**:";
var prBodyMarkdown = $"{stackMarkerStart}\n{prListHeader}\n\n{prListMarkdown}\n{stackMarkerEnd}";

foreach (var pullRequest in pullRequestsInStack)
{
// Find the existing part of the PR body that has the PR list
// and replace it with the updated PR list
var prBody = pullRequest.Body;

var prListStart = prBody.IndexOf(stackMarkerStart, StringComparison.OrdinalIgnoreCase);
var prListEnd = prBody.IndexOf(stackMarkerEnd, StringComparison.OrdinalIgnoreCase);

if (prListStart >= 0 && prListEnd >= 0)
{
prBody = prBody.Remove(prListStart, prListEnd - prListStart + stackMarkerEnd.Length);
}

if (prListStart == -1)
{
prListStart = 0;
}

prBody = prBody.Insert(prListStart, prBodyMarkdown);

gitHubOperations.EditPullRequest(pullRequest.Number, prBody, settings.GetGitHubOperationSettings());
}
}
else
{
console.MarkupLine("Only one pull request in stack, not adding PR list to description.");
}

if (console.Prompt(new ConfirmationPrompt("Open the pull requests in the browser?")))
{
foreach (var pullRequest in pullRequestsInStack)
{
Process.Start(new ProcessStartInfo(pullRequest.Url.ToString())
{
UseShellExecute = true
});
}
}
}

return 0;
}
}
11 changes: 1 addition & 10 deletions src/Stack/Commands/Stack/StackStatusCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,7 @@ string BuildBranchName(string branch, string? parentBranch, bool isSourceBranchF

if (status.PullRequests.TryGetValue(branch, out var pr))
{
var prStatusColor = Color.Green;
if (pr.State == GitHubPullRequestStates.Merged)
{
prStatusColor = Color.Purple;
}
else if (pr.State == GitHubPullRequestStates.Closed)
{
prStatusColor = Color.Red;
}
branchNameBuilder.Append($" [{prStatusColor} link={pr.Url}]#{pr.Number}: {pr.Title}[/]");
branchNameBuilder.Append($" {pr.GetPullRequestDisplay()}");
}

return branchNameBuilder.ToString();
Expand Down
48 changes: 43 additions & 5 deletions src/Stack/Git/GitHubOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,67 @@ internal record GitHubOperationSettings(bool DryRun, bool Verbose, string? Worki

internal static class GitHubPullRequestStates
{
public static string Open = "OPEN";
public static string Closed = "CLOSED";
public static string Merged = "MERGED";
public const string Open = "OPEN";
public const string Closed = "CLOSED";
public const string Merged = "MERGED";
}

internal record GitHubPullRequest(int Number, string Title, string State, Uri Url);
internal record GitHubPullRequest(int Number, string Title, string Body, string State, Uri Url);

internal static class GitHubPullRequestExtensionMethods
{
public static Color GetPullRequestColor(this GitHubPullRequest pullRequest)
{
return pullRequest.State switch
{
GitHubPullRequestStates.Open => Color.Green,
GitHubPullRequestStates.Closed => Color.Red,
GitHubPullRequestStates.Merged => Color.Purple,
_ => Color.Default
};
}

public static string GetPullRequestDisplay(this GitHubPullRequest pullRequest)
{
return $"[{pullRequest.GetPullRequestColor()} link={pullRequest.Url}]#{pullRequest.Number}: {pullRequest.Title}[/]";
}
}

internal interface IGitHubOperations
{
GitHubPullRequest? GetPullRequest(string branch, GitHubOperationSettings settings);
GitHubPullRequest? CreatePullRequest(string headBranch, string baseBranch, string title, string body, GitHubOperationSettings settings);
void EditPullRequest(int number, string body, GitHubOperationSettings settings);
}

internal class GitHubOperations(IAnsiConsole console) : IGitHubOperations
{
public GitHubPullRequest? GetPullRequest(string branch, GitHubOperationSettings settings)
{
var output = ExecuteGitHubCommandAndReturnOutput($"pr list --json title,number,state,url --head {branch} --state all", settings);
var output = ExecuteGitHubCommandAndReturnOutput($"pr list --json title,number,body,state,url --head {branch} --state all", settings);
var pullRequests = System.Text.Json.JsonSerializer.Deserialize<List<GitHubPullRequest>>(output,
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;

return pullRequests.FirstOrDefault();
}

public GitHubPullRequest? CreatePullRequest(string headBranch, string baseBranch, string title, string body, GitHubOperationSettings settings)
{
ExecuteGitHubCommand($"pr create --title \"{title}\" --body \"{body}\" --base {baseBranch} --head {headBranch}", settings);

if (settings.DryRun)
{
return null;
}

return GetPullRequest(headBranch, settings);
}

public void EditPullRequest(int number, string body, GitHubOperationSettings settings)
{
ExecuteGitHubCommand($"pr edit {number} --body \"{body}\"", settings);
}

private string ExecuteGitHubCommandAndReturnOutput(string command, GitHubOperationSettings settings)
{
if (settings.Verbose)
Expand Down
7 changes: 7 additions & 0 deletions src/Stack/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@

// Config commands
configure.AddCommand<OpenConfigCommand>("config").WithDescription("Opens the configuration file in the default editor.");

// Pull request commands
configure.AddBranch("pr", pr =>
{
pr.SetDescription("Manages pull requests for a stack. [[EXPERIMENTAL]]");
pr.AddCommand<CreatePullRequestsCommand>("create").WithDescription("Creates pull requests for a stack.");
});
});

await app.RunAsync(args);
Expand Down

0 comments on commit 2493b99

Please sign in to comment.