From 0ca3693a6e8521332ea12c7b1f1db519ef29302d Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Mon, 9 Dec 2024 09:04:08 +1100 Subject: [PATCH] Improve pull request creation (#123) --- .../CreatePullRequestsCommandHandlerTests.cs | 78 +++++- src/Stack/Commands/Helpers/Questions.cs | 6 +- .../Commands/Helpers/StackStatusHelpers.cs | 256 ++++++++++++++++++ .../PullRequests/CreatePullRequestsCommand.cs | 231 ++++++++++------ .../Commands/Stack/StackStatusCommand.cs | 208 +------------- src/Stack/Git/GitHubOperations.cs | 4 +- 6 files changed, 485 insertions(+), 298 deletions(-) create mode 100644 src/Stack/Commands/Helpers/StackStatusHelpers.cs diff --git a/src/Stack.Tests/Commands/PullRequests/CreatePullRequestsCommandHandlerTests.cs b/src/Stack.Tests/Commands/PullRequests/CreatePullRequestsCommandHandlerTests.cs index 849e78f..aa05188 100644 --- a/src/Stack.Tests/Commands/PullRequests/CreatePullRequestsCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/PullRequests/CreatePullRequestsCommandHandlerTests.cs @@ -23,11 +23,19 @@ public async Task WhenNoPullRequestsExistForAStackWithMultipleBranches_CreatesPu var handler = new CreatePullRequestsCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); var remoteUri = Some.HttpsUri().ToString(); + outputProvider + .WhenForAnyArgs(o => o.Status(Arg.Any(), Arg.Any())) + .Do(ci => ci.ArgAt(1)()); gitOperations.GetRemoteUri().Returns(remoteUri); gitOperations.GetCurrentBranch().Returns("branch-1"); - gitOperations.DoesRemoteBranchExist("branch-3").Returns(true); - gitOperations.DoesRemoteBranchExist("branch-5").Returns(true); + gitOperations + .GetBranchesThatExistInRemote(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); + + gitOperations + .GetBranchesThatExistLocally(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); var stacks = new List( [ @@ -40,6 +48,7 @@ public async Task WhenNoPullRequestsExistForAStackWithMultipleBranches_CreatesPu .Do(ci => stacks = ci.ArgAt>(0)); inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Confirm(Questions.ConfirmStartCreatePullRequests(2)).Returns(true); inputProvider.Confirm(Questions.ConfirmCreatePullRequests).Returns(true); inputProvider.Text(Questions.PullRequestTitle("branch-3", "branch-1")).Returns("PR Title for branch-3"); inputProvider.Text(Questions.PullRequestTitle("branch-5", "branch-3")).Returns("PR Title for branch-5"); @@ -74,11 +83,19 @@ public async Task WhenCreatingPullRequestsForAStackWithMultipleBranches_EachPull var handler = new CreatePullRequestsCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); var remoteUri = Some.HttpsUri().ToString(); + outputProvider + .WhenForAnyArgs(o => o.Status(Arg.Any(), Arg.Any())) + .Do(ci => ci.ArgAt(1)()); gitOperations.GetRemoteUri().Returns(remoteUri); gitOperations.GetCurrentBranch().Returns("branch-1"); - gitOperations.DoesRemoteBranchExist("branch-3").Returns(true); - gitOperations.DoesRemoteBranchExist("branch-5").Returns(true); + gitOperations + .GetBranchesThatExistInRemote(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); + + gitOperations + .GetBranchesThatExistLocally(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); var stacks = new List( [ @@ -91,6 +108,7 @@ public async Task WhenCreatingPullRequestsForAStackWithMultipleBranches_EachPull .Do(ci => stacks = ci.ArgAt>(0)); inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Confirm(Questions.ConfirmStartCreatePullRequests(2)).Returns(true); inputProvider.Confirm(Questions.ConfirmCreatePullRequests).Returns(true); inputProvider.Text(Questions.PullRequestTitle("branch-3", "branch-1")).Returns("PR Title for branch-3"); inputProvider.Text(Questions.PullRequestTitle("branch-5", "branch-3")).Returns("PR Title for branch-5"); @@ -141,11 +159,19 @@ public async Task WhenAPullRequestExistForABranch_AndNoneForAnotherBranch_Create var handler = new CreatePullRequestsCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); var remoteUri = Some.HttpsUri().ToString(); + outputProvider + .WhenForAnyArgs(o => o.Status(Arg.Any(), Arg.Any())) + .Do(ci => ci.ArgAt(1)()); gitOperations.GetRemoteUri().Returns(remoteUri); gitOperations.GetCurrentBranch().Returns("branch-1"); - gitOperations.DoesRemoteBranchExist("branch-3").Returns(true); - gitOperations.DoesRemoteBranchExist("branch-5").Returns(true); + gitOperations + .GetBranchesThatExistInRemote(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); + + gitOperations + .GetBranchesThatExistLocally(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); var stacks = new List( [ @@ -158,6 +184,7 @@ public async Task WhenAPullRequestExistForABranch_AndNoneForAnotherBranch_Create .Do(ci => stacks = ci.ArgAt>(0)); inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Confirm(Questions.ConfirmStartCreatePullRequests(1)).Returns(true); inputProvider.Confirm(Questions.ConfirmCreatePullRequests).Returns(true); inputProvider.Text(Questions.PullRequestTitle("branch-5", "branch-3")).Returns("PR Title for branch-5"); inputProvider.Text(Questions.PullRequestStackDescription, Arg.Any()).Returns("A custom description"); @@ -207,11 +234,19 @@ public async Task WhenStackNameIsProvided_PullRequestsAreCreatedForThatStack() var handler = new CreatePullRequestsCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); var remoteUri = Some.HttpsUri().ToString(); + outputProvider + .WhenForAnyArgs(o => o.Status(Arg.Any(), Arg.Any())) + .Do(ci => ci.ArgAt(1)()); gitOperations.GetRemoteUri().Returns(remoteUri); gitOperations.GetCurrentBranch().Returns("branch-1"); - gitOperations.DoesRemoteBranchExist("branch-3").Returns(true); - gitOperations.DoesRemoteBranchExist("branch-5").Returns(true); + gitOperations + .GetBranchesThatExistInRemote(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); + + gitOperations + .GetBranchesThatExistLocally(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); var stacks = new List( [ @@ -224,6 +259,7 @@ public async Task WhenStackNameIsProvided_PullRequestsAreCreatedForThatStack() .Do(ci => stacks = ci.ArgAt>(0)); inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Confirm(Questions.ConfirmStartCreatePullRequests(2)).Returns(true); inputProvider.Confirm(Questions.ConfirmCreatePullRequests).Returns(true); inputProvider.Text(Questions.PullRequestTitle("branch-3", "branch-1")).Returns("PR Title for branch-3"); inputProvider.Text(Questions.PullRequestTitle("branch-5", "branch-3")).Returns("PR Title for branch-5"); @@ -258,11 +294,19 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName_PullRequestsAreC var handler = new CreatePullRequestsCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); var remoteUri = Some.HttpsUri().ToString(); + outputProvider + .WhenForAnyArgs(o => o.Status(Arg.Any(), Arg.Any())) + .Do(ci => ci.ArgAt(1)()); gitOperations.GetRemoteUri().Returns(remoteUri); gitOperations.GetCurrentBranch().Returns("branch-1"); - gitOperations.DoesRemoteBranchExist("branch-3").Returns(true); - gitOperations.DoesRemoteBranchExist("branch-5").Returns(true); + gitOperations + .GetBranchesThatExistInRemote(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); + + gitOperations + .GetBranchesThatExistLocally(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); var stacks = new List( [ @@ -270,6 +314,7 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName_PullRequestsAreC ]); stackConfig.Load().Returns(stacks); + inputProvider.Confirm(Questions.ConfirmStartCreatePullRequests(2)).Returns(true); inputProvider.Confirm(Questions.ConfirmCreatePullRequests).Returns(true); inputProvider.Text(Questions.PullRequestTitle("branch-3", "branch-1")).Returns("PR Title for branch-3"); inputProvider.Text(Questions.PullRequestTitle("branch-5", "branch-3")).Returns("PR Title for branch-5"); @@ -335,11 +380,19 @@ public async Task WhenAPullRequestExistForABranch_AndHasBeenMerged_AndNoneForAno var handler = new CreatePullRequestsCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); var remoteUri = Some.HttpsUri().ToString(); + outputProvider + .WhenForAnyArgs(o => o.Status(Arg.Any(), Arg.Any())) + .Do(ci => ci.ArgAt(1)()); gitOperations.GetRemoteUri().Returns(remoteUri); gitOperations.GetCurrentBranch().Returns("branch-1"); - gitOperations.DoesRemoteBranchExist("branch-3").Returns(true); - gitOperations.DoesRemoteBranchExist("branch-5").Returns(true); + gitOperations + .GetBranchesThatExistInRemote(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); + + gitOperations + .GetBranchesThatExistLocally(Arg.Any()) + .Returns(["branch-1", "branch-3", "branch-5"]); var stacks = new List( [ @@ -352,6 +405,7 @@ public async Task WhenAPullRequestExistForABranch_AndHasBeenMerged_AndNoneForAno .Do(ci => stacks = ci.ArgAt>(0)); inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Confirm(Questions.ConfirmStartCreatePullRequests(1)).Returns(true); inputProvider.Confirm(Questions.ConfirmCreatePullRequests).Returns(true); inputProvider.Text(Questions.PullRequestTitle("branch-5", "branch-1")).Returns("PR Title for branch-5"); inputProvider.Text(Questions.PullRequestStackDescription, Arg.Any()).Returns("A custom description"); diff --git a/src/Stack/Commands/Helpers/Questions.cs b/src/Stack/Commands/Helpers/Questions.cs index fc16a1c..e034a85 100644 --- a/src/Stack/Commands/Helpers/Questions.cs +++ b/src/Stack/Commands/Helpers/Questions.cs @@ -1,3 +1,4 @@ +using Humanizer; using Stack.Infrastructure; namespace Stack.Commands.Helpers; @@ -16,8 +17,9 @@ public static class Questions public const string ConfirmAddOrCreateBranch = "Do you want to add an existing branch or create a new branch and add it to the stack?"; public const string AddOrCreateBranch = "Add or create a branch:"; public const string ConfirmSwitchToBranch = "Do you want to switch to the new branch?"; - public const string ConfirmCreatePullRequests = "Are you sure you want to create/update pull requests for branches in this stack?"; - public static string PullRequestTitle(string sourceBranch, string targetBranch) => $"Pull request title for branch {sourceBranch.Branch()} to {targetBranch.Branch()}:"; + public static string ConfirmStartCreatePullRequests(int numberOfBranchesWithoutPullRequests) => $"There {"are".ToQuantity(numberOfBranchesWithoutPullRequests, ShowQuantityAs.None)} {"branch".ToQuantity(numberOfBranchesWithoutPullRequests)} to create pull requests for. Do you want to continue?"; + public const string ConfirmCreatePullRequests = "Are you sure you want to create pull requests for branches in this stack?"; + public static string PullRequestTitle(string sourceBranch, string targetBranch) => $"Title for pull request from {sourceBranch.Branch()} -> {targetBranch.Branch()}:"; public const string PullRequestStackDescription = "Stack description for pull request:"; public const string OpenPullRequests = "Open the pull requests in the browser?"; } diff --git a/src/Stack/Commands/Helpers/StackStatusHelpers.cs b/src/Stack/Commands/Helpers/StackStatusHelpers.cs new file mode 100644 index 0000000..232477f --- /dev/null +++ b/src/Stack/Commands/Helpers/StackStatusHelpers.cs @@ -0,0 +1,256 @@ +using System.Text; +using Spectre.Console; +using Stack.Config; +using Stack.Git; +using Stack.Infrastructure; + +namespace Stack.Commands.Helpers; + +public class BranchDetail +{ + public BranchStatus Status { get; set; } = new(false, false, 0, 0); + public GitHubPullRequest? PullRequest { get; set; } + + public bool IsActive => Status.ExistsLocally && Status.ExistsInRemote && (PullRequest is null || PullRequest.State != GitHubPullRequestStates.Merged); + public bool CouldBeCleanedUp => Status.ExistsLocally && (!Status.ExistsInRemote || PullRequest is not null && PullRequest.State == GitHubPullRequestStates.Merged); + public bool HasPullRequest => PullRequest is not null && PullRequest.State != GitHubPullRequestStates.Closed; +} +public record BranchStatus(bool ExistsLocally, bool ExistsInRemote, int Ahead, int Behind); +public record StackStatus(Dictionary Branches) +{ + public string[] GetActiveBranches() => Branches.Where(b => b.Value.IsActive).Select(b => b.Key).ToArray(); +} + +public static class StackStatusHelpers +{ + public static Dictionary GetStackStatus( + List stacks, + string currentBranch, + IOutputProvider outputProvider, + IGitOperations gitOperations, + IGitHubOperations gitHubOperations) + { + var stacksToCheckStatusFor = new Dictionary(); + + stacks + .OrderByCurrentStackThenByName(currentBranch) + .ToList() + .ForEach(stack => stacksToCheckStatusFor.Add(stack, new StackStatus([]))); + + outputProvider.Status("Checking status of remote branches...", () => + { + foreach (var (stack, status) in stacksToCheckStatusFor) + { + var allBranchesInStack = new List([stack.SourceBranch]).Concat(stack.Branches).Distinct().ToArray(); + var branchesThatExistInRemote = gitOperations.GetBranchesThatExistInRemote(allBranchesInStack); + var branchesThatExistLocally = gitOperations.GetBranchesThatExistLocally(allBranchesInStack); + + gitOperations.FetchBranches(branchesThatExistInRemote); + + void CheckRemoteBranch(string branch, string sourceBranch) + { + var branchExistsLocally = branchesThatExistLocally.Contains(branch); + var (ahead, behind) = gitOperations.GetStatusOfRemoteBranch(branch, sourceBranch); + var branchStatus = new BranchStatus(branchExistsLocally, true, ahead, behind); + status.Branches[branch].Status = branchStatus; + } + + var parentBranch = stack.SourceBranch; + + foreach (var branch in stack.Branches) + { + status.Branches.Add(branch, new BranchDetail()); + + if (branchesThatExistInRemote.Contains(branch)) + { + CheckRemoteBranch(branch, parentBranch); + parentBranch = branch; + } + else + { + status.Branches[branch].Status = new BranchStatus(branchesThatExistLocally.Contains(branch), false, 0, 0); + } + } + } + }); + + outputProvider.Status("Checking status of GitHub pull requests...", () => + { + foreach (var (stack, status) in stacksToCheckStatusFor) + { + try + { + foreach (var branch in stack.Branches) + { + var pr = gitHubOperations.GetPullRequest(branch); + + if (pr is not null) + { + status.Branches[branch].PullRequest = pr; + } + } + } + catch (Exception ex) + { + outputProvider.Warning($"Error checking GitHub pull requests: {ex.Message}"); + } + } + }); + + return stacksToCheckStatusFor; + } + + public static StackStatus GetStackStatus( + Config.Stack stack, + string currentBranch, + IOutputProvider outputProvider, + IGitOperations gitOperations, + IGitHubOperations gitHubOperations) + { + var statues = GetStackStatus([stack], currentBranch, outputProvider, gitOperations, gitHubOperations); + + return statues[stack]; + } + + public static void OutputStackStatus( + Dictionary stackStatuses, + IGitOperations gitOperations, + IOutputProvider outputProvider) + { + foreach (var (stack, status) in stackStatuses) + { + OutputStackStatus(stack, status, gitOperations, outputProvider); + } + } + + public static void OutputStackStatus( + Config.Stack stack, + StackStatus status, + IGitOperations gitOperations, + IOutputProvider outputProvider) + { + var header = $"{stack.Name.Stack()}: {stack.SourceBranch.Muted()}"; + var items = new List(); + + string parentBranch = stack.SourceBranch; + + foreach (var branch in stack.Branches) + { + if (status.Branches.TryGetValue(branch, out var branchDetail)) + { + items.Add(GetBranchAndPullRequestStatusOutput(branch, parentBranch, branchDetail, gitOperations)); + + if (branchDetail.IsActive) + { + parentBranch = branch; + } + } + } + outputProvider.Tree(header, [.. items]); + } + + public static string GetBranchAndPullRequestStatusOutput( + string branch, + string parentBranch, + BranchDetail branchDetail, + IGitOperations gitOperations) + { + var branchNameBuilder = new StringBuilder(); + branchNameBuilder.Append(GetBranchStatusOutput(branch, parentBranch, branchDetail, gitOperations)); + + if (branchDetail.PullRequest is not null) + { + branchNameBuilder.Append($" {branchDetail.PullRequest.GetPullRequestDisplay()}"); + } + + return branchNameBuilder.ToString(); + } + + public static string GetBranchStatusOutput( + string branch, + string parentBranch, + BranchDetail branchDetail, + IGitOperations gitOperations) + { + var branchNameBuilder = new StringBuilder(); + var currentBranch = gitOperations.GetCurrentBranch(); + var branchIsMerged = + branchDetail.Status.ExistsInRemote == false || + branchDetail.Status.ExistsLocally == false || + branchDetail.PullRequest is not null && branchDetail.PullRequest.State == GitHubPullRequestStates.Merged; + + var color = !branchDetail.IsActive ? "grey" : branch.Equals(currentBranch, StringComparison.OrdinalIgnoreCase) ? "blue" : null; + Decoration? decoration = !branchDetail.IsActive ? Decoration.Strikethrough : null; + + if (color is not null && decoration is not null) + { + branchNameBuilder.Append($"[{decoration} {color}]{branch}[/]"); + } + else if (color is not null) + { + branchNameBuilder.Append($"[{color}]{branch}[/]"); + } + else if (decoration is not null) + { + branchNameBuilder.Append($"[{decoration}]{branch}[/]"); + } + else + { + branchNameBuilder.Append(branch); + } + + if (branchDetail.IsActive) + { + if (branchDetail.Status.Ahead > 0 && branchDetail.Status.Behind > 0) + { + branchNameBuilder.Append($" [grey]({branchDetail.Status.Ahead} ahead, {branchDetail.Status.Behind} behind {parentBranch})[/]"); + } + else if (branchDetail.Status.Ahead > 0) + { + branchNameBuilder.Append($" [grey]({branchDetail.Status.Ahead} ahead of {parentBranch})[/]"); + } + else if (branchDetail.Status.Behind > 0) + { + branchNameBuilder.Append($" [grey]({branchDetail.Status.Behind} behind {parentBranch})[/]"); + } + } + + return branchNameBuilder.ToString(); + } + + public static void OutputBranchAndStackCleanup( + Config.Stack stack, + StackStatus status, + IOutputProvider outputProvider) + { + if (status.Branches.Values.All(branch => branch.CouldBeCleanedUp)) + { + outputProvider.NewLine(); + outputProvider.Information("All branches exist locally but are either not in the remote repository or the pull request associated with the branch is no longer open. This stack might be able to be deleted."); + outputProvider.NewLine(); + outputProvider.Information($"Run {$"stack delete --name \"{stack.Name}\"".Example()} to delete the stack if it's no longer needed."); + } + else if (status.Branches.Values.Any(branch => branch.CouldBeCleanedUp)) + { + outputProvider.NewLine(); + outputProvider.Information("Some branches exist locally but are either not in the remote repository or the pull request associated with the branch is no longer open."); + outputProvider.NewLine(); + outputProvider.Information($"Run {$"stack cleanup --name \"{stack.Name}\"".Example()} to clean up local branches."); + } + else if (status.Branches.Values.All(branch => !branch.Status.ExistsLocally)) + { + outputProvider.NewLine(); + outputProvider.Information("No branches exist locally. This stack might be able to be deleted."); + outputProvider.NewLine(); + outputProvider.Information($"Run {$"stack delete --name \"{stack.Name}\"".Example()} to delete the stack."); + } + + if (status.Branches.Values.Any(branch => branch.Status.ExistsInRemote && branch.Status.ExistsLocally && branch.Status.Behind > 0)) + { + outputProvider.NewLine(); + outputProvider.Information("There are changes in source branches that have not been applied to the stack."); + outputProvider.NewLine(); + outputProvider.Information($"Run {$"stack update --name \"{stack.Name}\"".Example()} to update the stack."); + } + } +} diff --git a/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs b/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs index 9b3cad6..2bae1c5 100644 --- a/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs +++ b/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.ComponentModel; using System.Diagnostics; using Spectre.Console; @@ -73,122 +74,188 @@ public async Task Handle(CreatePullRequestsCo throw new InvalidOperationException($"Stack '{inputs.StackName}' not found."); } - StackStatusHelpers.CheckStackStatus( - [stack], + var status = StackStatusHelpers.GetStackStatus( + stack, currentBranch, outputProvider, gitOperations, - gitHubOperations, - false); + gitHubOperations); - outputProvider.NewLine(); + var sourceBranch = stack.SourceBranch; + var pullRequestCreateActions = new List(); - if (inputProvider.Confirm(Questions.ConfirmCreatePullRequests)) + foreach (var branch in stack.Branches) { - var sourceBranch = stack.SourceBranch; - var pullRequestsInStack = new List(); + var branchDetail = status.Branches[branch]; - foreach (var branch in stack.Branches) + if (branchDetail.IsActive) { - var existingPullRequest = gitHubOperations.GetPullRequest(branch); - - if (existingPullRequest is not null && existingPullRequest.State != GitHubPullRequestStates.Closed) + if (!branchDetail.HasPullRequest) { - outputProvider.Information($"Pull request {existingPullRequest.GetPullRequestDisplay()} already exists for branch {branch.Branch()} to {sourceBranch.Branch()}. Skipping..."); - pullRequestsInStack.Add(existingPullRequest); + pullRequestCreateActions.Add(new GitHubPullRequestCreateAction(branch, sourceBranch)); } - // If the source branch still exists and there is either no PR or the PR isn't merged - // then we consider this branch to be the source branch for the next PR in the stack - if (gitOperations.DoesRemoteBranchExist(branch) && (existingPullRequest is null || existingPullRequest.State != GitHubPullRequestStates.Merged)) - { - if (existingPullRequest is null || existingPullRequest.State == GitHubPullRequestStates.Closed) - { - var prTitle = inputProvider.Text(Questions.PullRequestTitle(branch, sourceBranch)); - outputProvider.Information($"Creating pull request for branch {branch.Branch()} to {sourceBranch.Branch()}"); - var pullRequest = gitHubOperations.CreatePullRequest(branch, sourceBranch, prTitle, ""); + sourceBranch = branch; + } + } - if (pullRequest is not null) - { - outputProvider.Information($"Pull request {pullRequest.GetPullRequestDisplay()} created for branch {branch.Branch()} to {sourceBranch.Branch()}"); - pullRequestsInStack.Add(pullRequest); - } - } + StackStatusHelpers.OutputStackStatus(stack, status, gitOperations, outputProvider); - sourceBranch = branch; - } - } + outputProvider.NewLine(); - if (pullRequestsInStack.Count > 1) + if (pullRequestCreateActions.Count > 0) + { + if (inputProvider.Confirm(Questions.ConfirmStartCreatePullRequests(pullRequestCreateActions.Count))) { - var defaultStackDescription = stack.PullRequestDescription ?? $"This PR is part of a stack **{stack.Name}**:"; - var stackDescription = inputProvider.Text(Questions.PullRequestStackDescription, defaultStackDescription); + GetPullRequestTitles(inputProvider, pullRequestCreateActions); - if (stackDescription != stack.PullRequestDescription) - { - stack.SetPullRequestDescription(stackDescription); - stackConfig.Save(stacks); - } + outputProvider.NewLine(); - // Edit each PR and add to the top of the description - // the details of each PR in the stack - var stackMarkerStart = ""; - var stackMarkerEnd = ""; - var prList = pullRequestsInStack - .Select(pr => $"- {pr.Url}") - .ToList(); - var prListMarkdown = string.Join(Environment.NewLine, prList); - var prBodyMarkdown = $"{stackMarkerStart}{Environment.NewLine}{stackDescription}{Environment.NewLine}{Environment.NewLine}{prListMarkdown}{Environment.NewLine}{stackMarkerEnd}"; - - foreach (var pullRequest in pullRequestsInStack) + OutputUpdatedStackStatus(outputProvider, gitOperations, stack, status, pullRequestCreateActions); + + outputProvider.NewLine(); + + if (inputProvider.Confirm(Questions.ConfirmCreatePullRequests)) { - // 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; + CreatePullRequests(outputProvider, gitHubOperations, status, pullRequestCreateActions); - var prListStart = prBody.IndexOf(stackMarkerStart, StringComparison.OrdinalIgnoreCase); - var prListEnd = prBody.IndexOf(stackMarkerEnd, StringComparison.OrdinalIgnoreCase); + var pullRequestsInStack = status.Branches.Values + .Where(branch => branch.HasPullRequest) + .Select(branch => branch.PullRequest!) + .ToList(); - if (prListStart >= 0 && prListEnd >= 0) + if (pullRequestsInStack.Count > 1) { - prBody = prBody.Remove(prListStart, prListEnd - prListStart + stackMarkerEnd.Length); + UpdatePullRequestStackDescriptions(inputProvider, outputProvider, gitHubOperations, stackConfig, stacks, stack, pullRequestsInStack); } - if (prListStart == -1) + if (inputProvider.Confirm(Questions.OpenPullRequests)) { - prListStart = 0; + foreach (var pullRequest in pullRequestsInStack) + { + gitHubOperations.OpenPullRequest(pullRequest); + } } + } + } + } + else + { + outputProvider.Information("No new pull requests to create."); + } - if (prBody.Length > 0 && prListStart == 0) - { - // Add some newlines so that the PR list is separated from the rest of the PR body - prBody = prBody.Insert(prListStart, prBodyMarkdown + "\n\n"); - } - else - { - prBody = prBody.Insert(prListStart, prBodyMarkdown); - } + return new CreatePullRequestsCommandResponse(); + } - gitHubOperations.EditPullRequest(pullRequest.Number, prBody); - } + private static void UpdatePullRequestStackDescriptions(IInputProvider inputProvider, IOutputProvider outputProvider, IGitHubOperations gitHubOperations, IStackConfig stackConfig, List stacks, Config.Stack stack, List pullRequestsInStack) + { + var defaultStackDescription = stack.PullRequestDescription ?? $"This PR is part of a stack **{stack.Name}**:"; + var stackDescription = inputProvider.Text(Questions.PullRequestStackDescription, defaultStackDescription); + + if (stackDescription != stack.PullRequestDescription) + { + stack.SetPullRequestDescription(stackDescription); + stackConfig.Save(stacks); + } + + // Edit each PR and add to the top of the description + // the details of each PR in the stack + var stackMarkerStart = ""; + var stackMarkerEnd = ""; + + var prList = pullRequestsInStack + .Select(pr => $"- {pr.Url}") + .ToList(); + var prListMarkdown = string.Join(Environment.NewLine, prList); + var prBodyMarkdown = $"{stackMarkerStart}{Environment.NewLine}{stack.PullRequestDescription}{Environment.NewLine}{Environment.NewLine}{prListMarkdown}{Environment.NewLine}{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; + } + + if (prBody.Length > 0 && prListStart == 0) + { + // Add some newlines so that the PR list is separated from the rest of the PR body + prBody = prBody.Insert(prListStart, prBodyMarkdown + "\n\n"); } else { - outputProvider.Information("Only one pull request in stack, not adding PR list to description."); + prBody = prBody.Insert(prListStart, prBodyMarkdown); } - if (inputProvider.Confirm(Questions.OpenPullRequests)) + outputProvider.Information($"Updating pull request {pullRequest.GetPullRequestDisplay()} with stack details"); + + gitHubOperations.EditPullRequest(pullRequest.Number, prBody); + } + } + + private static void CreatePullRequests(IOutputProvider outputProvider, IGitHubOperations gitHubOperations, StackStatus status, List pullRequestCreateActions) + { + foreach (var action in pullRequestCreateActions) + { + var branchDetail = status.Branches[action.HeadBranch]; + outputProvider.Information($"Creating pull request for branch {action.HeadBranch.Branch()} to {action.BaseBranch.Branch()}"); + var pullRequest = gitHubOperations.CreatePullRequest(action.HeadBranch, action.BaseBranch, action.Title!, ""); + + if (pullRequest is not null) { - foreach (var pullRequest in pullRequestsInStack) - { - Process.Start(new ProcessStartInfo(pullRequest.Url.ToString()) - { - UseShellExecute = true - }); - } + outputProvider.Information($"Pull request {pullRequest.GetPullRequestDisplay()} created for branch {action.HeadBranch.Branch()} to {action.BaseBranch.Branch()}"); + branchDetail.PullRequest = pullRequest; } } + } - return new CreatePullRequestsCommandResponse(); + private static void OutputUpdatedStackStatus(IOutputProvider outputProvider, IGitOperations gitOperations, Config.Stack stack, StackStatus status, List pullRequestCreateActions) + { + var branchDisplayItems = new List(); + var parentBranch = stack.SourceBranch; + + foreach (var branch in stack.Branches) + { + var branchDetail = status.Branches[branch]; + if (branchDetail.PullRequest is not null) + { + branchDisplayItems.Add(StackStatusHelpers.GetBranchAndPullRequestStatusOutput(branch, parentBranch, branchDetail, gitOperations)); + } + else + { + var action = pullRequestCreateActions.FirstOrDefault(a => a.HeadBranch == branch); + branchDisplayItems.Add($"{StackStatusHelpers.GetBranchStatusOutput(branch, parentBranch, branchDetail, gitOperations)} *NEW* {action?.Title}"); + } + parentBranch = branch; + } + + outputProvider.Tree( + $"{stack.Name.Stack()}: {stack.SourceBranch.Muted()}", + [.. branchDisplayItems]); } -} \ No newline at end of file + + private static void GetPullRequestTitles(IInputProvider inputProvider, List pullRequestCreateActions) + { + foreach (var action in pullRequestCreateActions) + { + action.Title = inputProvider.Text(Questions.PullRequestTitle(action.HeadBranch, action.BaseBranch)); + } + } + + record GitHubPullRequestCreateAction(string HeadBranch, string BaseBranch) + { + public string? Title { get; set; } + } +} + diff --git a/src/Stack/Commands/Stack/StackStatusCommand.cs b/src/Stack/Commands/Stack/StackStatusCommand.cs index 61d7cca..470c77e 100644 --- a/src/Stack/Commands/Stack/StackStatusCommand.cs +++ b/src/Stack/Commands/Stack/StackStatusCommand.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Text; using Spectre.Console; using Spectre.Console.Cli; using Stack.Commands.Helpers; @@ -20,14 +19,6 @@ public class StackStatusCommandSettings : CommandSettingsBase public bool All { get; init; } } -public class BranchDetail -{ - public BranchStatus Status { get; set; } = new(false, false, 0, 0); - public GitHubPullRequest? PullRequest { get; set; } -} -public record BranchStatus(bool ExistsLocally, bool ExistsInRemote, int Ahead, int Behind); -public record StackStatus(Dictionary Branches); - public class StackStatusCommand : AsyncCommand { public override async Task ExecuteAsync(CommandContext context, StackStatusCommandSettings settings) @@ -84,204 +75,21 @@ public async Task Handle(StackStatusCommandInputs in stacksToCheckStatusFor.Add(stack); } - var stackStatusResults = StackStatusHelpers.CheckStackStatus( + var stackStatusResults = StackStatusHelpers.GetStackStatus( stacksToCheckStatusFor, currentBranch, outputProvider, gitOperations, - gitHubOperations, - true); - - return new StackStatusCommandResponse(stackStatusResults); - } -} - -public static class StackStatusHelpers -{ - public static Dictionary CheckStackStatus( - List stacks, - string currentBranch, - IOutputProvider outputProvider, - IGitOperations gitOperations, - IGitHubOperations gitHubOperations, - bool checkBranchAndStackCleanup) - { - var stacksToCheckStatusFor = new Dictionary(); - - stacks - .OrderByCurrentStackThenByName(currentBranch) - .ToList() - .ForEach(stack => stacksToCheckStatusFor.Add(stack, new StackStatus([]))); - - outputProvider.Status("Checking status of remote branches...", () => - { - foreach (var (stack, status) in stacksToCheckStatusFor) - { - var allBranchesInStack = new List([stack.SourceBranch]).Concat(stack.Branches).Distinct().ToArray(); - var branchesThatExistInRemote = gitOperations.GetBranchesThatExistInRemote(allBranchesInStack); - var branchesThatExistLocally = gitOperations.GetBranchesThatExistLocally(allBranchesInStack); - - gitOperations.FetchBranches(branchesThatExistInRemote); - - void CheckRemoteBranch(string branch, string sourceBranch) - { - var branchExistsLocally = branchesThatExistLocally.Contains(branch); - var (ahead, behind) = gitOperations.GetStatusOfRemoteBranch(branch, sourceBranch); - var branchStatus = new BranchStatus(branchExistsLocally, true, ahead, behind); - status.Branches[branch].Status = branchStatus; - } - - var parentBranch = stack.SourceBranch; - - foreach (var branch in stack.Branches) - { - status.Branches.Add(branch, new BranchDetail()); - - if (branchesThatExistInRemote.Contains(branch)) - { - CheckRemoteBranch(branch, parentBranch); - parentBranch = branch; - } - else - { - status.Branches[branch].Status = new BranchStatus(branchesThatExistLocally.Contains(branch), false, 0, 0); - } - } - } - }); - - outputProvider.Status("Checking status of GitHub pull requests...", () => - { - foreach (var (stack, status) in stacksToCheckStatusFor) - { - try - { - foreach (var branch in stack.Branches) - { - var pr = gitHubOperations.GetPullRequest(branch); - - if (pr is not null) - { - status.Branches[branch].PullRequest = pr; - } - } - } - catch (Exception ex) - { - outputProvider.Warning($"Error checking GitHub pull requests: {ex.Message}"); - } - } - }); - - foreach (var (stack, status) in stacksToCheckStatusFor) - { - var header = $"{stack.Name.Stack()}: {stack.SourceBranch.Muted()}"; - var items = new List(); - var stackRoot = new Tree($"{stack.Name.Stack()}: [grey]{stack.SourceBranch.Muted()}[/]"); - - string BuildBranchName(string branch, string? parentBranch, bool isSourceBranchForStack) - { - var branchDetail = status.Branches.GetValueOrDefault(branch); - var branchNameBuilder = new StringBuilder(); - - var color = branchDetail?.Status.ExistsInRemote == false ? "grey" : isSourceBranchForStack ? "grey" : branch.Equals(currentBranch, StringComparison.OrdinalIgnoreCase) ? "blue" : null; - Decoration? decoration = branchDetail?.Status.ExistsInRemote == false || branchDetail?.Status.ExistsLocally == false ? Decoration.Strikethrough : null; - - if (color is not null && decoration is not null) - { - branchNameBuilder.Append($"[{decoration} {color}]{branch}[/]"); - } - else if (color is not null) - { - branchNameBuilder.Append($"[{color}]{branch}[/]"); - } - else if (decoration is not null) - { - branchNameBuilder.Append($"[{decoration}]{branch}[/]"); - } - else - { - branchNameBuilder.Append(branch); - } - - if (branchDetail?.Status.Ahead > 0 && branchDetail?.Status.Behind > 0) - { - branchNameBuilder.Append($" [grey]({branchDetail.Status.Ahead} ahead, {branchDetail.Status.Behind} behind {parentBranch})[/]"); - } - else if (branchDetail?.Status.Ahead > 0) - { - branchNameBuilder.Append($" [grey]({branchDetail.Status.Ahead} ahead of {parentBranch})[/]"); - } - else if (branchDetail?.Status.Behind > 0) - { - branchNameBuilder.Append($" [grey]({branchDetail.Status.Behind} behind {parentBranch})[/]"); - } - - if (branchDetail?.PullRequest is not null) - { - branchNameBuilder.Append($" {branchDetail.PullRequest.GetPullRequestDisplay()}"); - } - - return branchNameBuilder.ToString(); - } + gitHubOperations); - string parentBranch = stack.SourceBranch; + StackStatusHelpers.OutputStackStatus(stackStatusResults, gitOperations, outputProvider); - foreach (var branch in stack.Branches) - { - items.Add(BuildBranchName(branch, parentBranch, false)); - - if (status.Branches.TryGetValue(branch, out var branchDetail) && branchDetail.Status.ExistsInRemote) - { - parentBranch = branch; - } - } - - outputProvider.Tree(header, items.ToArray()); - } - - if (checkBranchAndStackCleanup && stacksToCheckStatusFor.Count == 1) + if (stacksToCheckStatusFor.Count == 1) { - var (stack, status) = stacksToCheckStatusFor.First(); - - bool BranchCouldBeCleanedUp(BranchDetail branchDetail) - { - return branchDetail.Status.ExistsLocally && - (!branchDetail.Status.ExistsInRemote || - branchDetail.PullRequest is not null && branchDetail.PullRequest.State != GitHubPullRequestStates.Open); - } - - if (status.Branches.Values.All(branch => BranchCouldBeCleanedUp(branch))) - { - outputProvider.NewLine(); - outputProvider.Information("All branches exist locally but are either not in the remote repository or the pull request associated with the branch is no longer open. This stack might be able to be deleted."); - outputProvider.NewLine(); - outputProvider.Information($"Run {$"stack delete --name \"{stack.Name}\"".Example()} to delete the stack if it's no longer needed."); - } - else if (status.Branches.Values.Any(branch => BranchCouldBeCleanedUp(branch))) - { - outputProvider.NewLine(); - outputProvider.Information("Some branches exist locally but are either not in the remote repository or the pull request associated with the branch is no longer open."); - outputProvider.NewLine(); - outputProvider.Information($"Run {$"stack cleanup --name \"{stack.Name}\"".Example()} to clean up local branches."); - } - else if (status.Branches.Values.All(branch => !branch.Status.ExistsLocally)) - { - outputProvider.NewLine(); - outputProvider.Information("No branches exist locally. This stack might be able to be deleted."); - outputProvider.NewLine(); - outputProvider.Information($"Run {$"stack delete --name \"{stack.Name}\"".Example()} to delete the stack."); - } - - if (status.Branches.Values.Any(branch => branch.Status.ExistsInRemote && branch.Status.ExistsLocally && branch.Status.Behind > 0)) - { - outputProvider.NewLine(); - outputProvider.Information("There are changes in source branches that have not been applied to the stack."); - outputProvider.NewLine(); - outputProvider.Information($"Run {$"stack update --name \"{stack.Name}\"".Example()} to update the stack."); - } + var (stack, status) = stackStatusResults.First(); + StackStatusHelpers.OutputBranchAndStackCleanup(stack, status, outputProvider); } - return stacksToCheckStatusFor; + return new StackStatusCommandResponse(stackStatusResults); } -} \ No newline at end of file +} diff --git a/src/Stack/Git/GitHubOperations.cs b/src/Stack/Git/GitHubOperations.cs index 6e9aca1..9bf757c 100644 --- a/src/Stack/Git/GitHubOperations.cs +++ b/src/Stack/Git/GitHubOperations.cs @@ -81,7 +81,7 @@ public void OpenPullRequest(GitHubPullRequest pullRequest) private string ExecuteGitHubCommandAndReturnOutput(string command) { if (settings.Verbose) - console.MarkupLine($"[grey]git {command}[/]"); + console.MarkupLine($"[grey]gh {command}[/]"); var infoBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); @@ -127,7 +127,7 @@ private void ExecuteGitHubCommandInternal(string command) var result = ShellExecutor.ExecuteCommand( "gh", command, - ".", + settings.WorkingDirectory ?? ".", (_) => { }, (info) => infoBuilder.AppendLine(info), (error) => errorBuilder.AppendLine(error));