diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index bb1d61f..503efce 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -18,6 +18,10 @@ "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, + "Description": { + "type": "string", + "description": "Description of the pull request" + }, "GitHubToken": { "type": "string", "description": "Token used to create a new release in GitHub", @@ -101,6 +105,7 @@ "Compile", "Feature", "Hotfix", + "ManageSecrets", "Pack", "Publish", "Release", @@ -125,6 +130,7 @@ "Compile", "Feature", "Hotfix", + "ManageSecrets", "Pack", "Publish", "Release", @@ -132,6 +138,15 @@ ] } }, + "Title": { + "type": "string", + "description": "Title that will be used when creating a PR" + }, + "Token": { + "type": "string", + "description": "Token used to create a pull request", + "default": "Secrets must be entered via 'nuke :secrets [profile]'" + }, "Verbosity": { "type": "string", "description": "Logging verbosity during build execution. Default is 'Normal'", diff --git a/.nuke/parameters.json b/.nuke/parameters.json index c2f51c7..f67923a 100644 --- a/.nuke/parameters.json +++ b/.nuke/parameters.json @@ -1,4 +1,5 @@ { "$schema": "./build.schema.json", - "Solution": "Candoumbe.Pipelines.sln" + "Solution": "Candoumbe.Pipelines.sln", + "NoLogo": true } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 407480c..35bd085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking changes - Renamed `IBenchmarks` to `IBenchmark` - Renamed `IMutationTests` to `IMutationTest` +- Made `IGitFlow.FinishFeature` async +- Made `IGitFlow.FinishReleaseOrHotfix` async +- Made `IGitFlow.FinishColdfix` async +- Moved `GitHubPublishConfiguration` to `Candoumbe.Pipelines.Components.GitHub` namespace +- Moved `ICreateGitHubRelease` to `Candoumbe.Pipelines.Components.GitHub` namespace ### New features - Added execution of `IPublish.Publish` target on `integration` workflow -- Added `IHaveReport` interface that can be used by pipelines that output reports of any kind (code coverage, performance tests, ...) +- Added `IHaveReport` component that can be used by pipelines that output reports of any kind (code coverage, performance tests, ...) - Added `IUnitTest.UnitTestsResultsDirectory` which defines where to output unit test result files - Added `IMutationTest.MutationTestResultsDirectory` which defines where to output mutation test result files - Added `IBenchmark.BenchmarkTestResultsDirectory` which defines where to output benchmarks test result files +- Added `IPullRequest` component which extends `IGitFlow` and create pull requests instead or merging back to `develop` (respectiveley `main`) when finishing a feature / coldfix (resp. release / hotfix) branch. +- Added `IHaveGitHubRepository` which extends `IHaveGitRepository` and specific properties related to GitHub repositories. ### Fixes diff --git a/README.md b/README.md index 02c54a7..40b8edd 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,11 @@ [![GitHub raw issues](https://img.shields.io/github/issues-raw/candoumbe/pipelines)](https://github.com/candoumbe/pipelines/issues) [![Nuget](https://img.shields.io/nuget/vpre/candoumbe.pipelines)](https://nuget.org/packages/candoumbe.pipelines) -A Stater development kit to script your CI/CD using [Nuke](https://nuke.build). \ No newline at end of file +A Stater development kit to script your CI/CD using [Nuke]. + + +## Credits + +- [Nuke] as the engine behind the scene + +[Nuke]: https//nuke.build \ No newline at end of file diff --git a/build/Candoumbe.Pipelines.Build.csproj b/build/Candoumbe.Pipelines.Build.csproj index 20c157f..b81deb6 100644 --- a/build/Candoumbe.Pipelines.Build.csproj +++ b/build/Candoumbe.Pipelines.Build.csproj @@ -12,17 +12,15 @@ - - + - @@ -39,6 +37,11 @@ + + + + + diff --git a/build/Pipeline.cs b/build/Pipeline.cs index 7282f39..9714a0c 100644 --- a/build/Pipeline.cs +++ b/build/Pipeline.cs @@ -1,6 +1,7 @@ namespace Candoumbe.Pipelines.Build; using Components; +using Components.GitHub; using Nuke.Common; using Nuke.Common.CI; @@ -15,7 +16,7 @@ namespace Candoumbe.Pipelines.Build; [GitHubActions("integration", GitHubActionsImage.WindowsLatest, - AutoGenerate = true, + AutoGenerate = false, OnPushBranchesIgnore = new[] { IGitFlow.MainBranchName, IGitFlow.ReleaseBranch + "/*" }, FetchDepth = 0, InvokedTargets = new[] { nameof(ICompile.Compile), nameof(IPack.Pack), nameof(IPublish.Publish) }, @@ -35,7 +36,7 @@ namespace Candoumbe.Pipelines.Build; })] [GitHubActions("delivery", GitHubActionsImage.WindowsLatest, - AutoGenerate = true, + AutoGenerate = false, OnPushBranches = new[] { IGitFlow.MainBranchName, IGitFlow.ReleaseBranch + "/*" }, FetchDepth = 0, InvokedTargets = new[] { nameof(ICompile.Compile), nameof(IPack.Pack), nameof(IPublish.Publish) }, @@ -64,11 +65,12 @@ public class Pipeline : NukeBuild, ICompile, IPack, IHaveGitVersion, - IHaveGitRepository, + IHaveGitHubRepository, IHaveArtifacts, IPublish, ICreateGithubRelease, - IGitFlow + IPullRequest, + IHaveSecret { /// IEnumerable IClean.DirectoriesToDelete => SourceDirectory.GlobDirectories("**/bin", "**/obj"); @@ -76,8 +78,8 @@ public class Pipeline : NukeBuild, /// IEnumerable IClean.DirectoriesToEnsureExistance => new[] { - From().OutputDirectory, - From().ArtifactsDirectory, + this.Get().OutputDirectory, + this.Get().ArtifactsDirectory, }; [CI] @@ -93,7 +95,6 @@ public class Pipeline : NukeBuild, /// public AbsolutePath SourceDirectory => RootDirectory / "src"; - /// /// Token used to interact with GitHub API /// @@ -121,12 +122,9 @@ public class Pipeline : NukeBuild, canBeUsed: () => NugetApiKey is not null ), new GitHubPublishConfiguration( - githubToken: From()?.GitHubToken, + githubToken: this.Get()?.GitHubToken, source: new Uri($"https://nuget.pkg.github.com/{GitHubActions?.RepositoryOwner}/index.json"), canBeUsed: () => this is ICreateGithubRelease createRelease && createRelease.GitHubToken is not null ), }; - - private T From() where T : INukeBuild - => (T)(object)this; } diff --git a/src/Candoumbe.Pipelines/Components/Extensions.cs b/src/Candoumbe.Pipelines/Components/Extensions.cs index a98218e..867550e 100644 --- a/src/Candoumbe.Pipelines/Components/Extensions.cs +++ b/src/Candoumbe.Pipelines/Components/Extensions.cs @@ -1,4 +1,6 @@ -using System; +using Nuke.Common; + +using System; namespace Candoumbe.Pipelines.Components; @@ -21,4 +23,8 @@ public static class Extensions /// public static T WhenNotNull(this T settings, TObject obj, Func configurator) => obj is not null ? configurator.Invoke(settings, obj) : settings; + + + public static T Get(this INukeBuild nukeBuild) where T : INukeBuild + => (T)(object)nukeBuild; } \ No newline at end of file diff --git a/src/Candoumbe.Pipelines/Components/GitHubPublishConfiguration.cs b/src/Candoumbe.Pipelines/Components/GitHub/GitHubPublishConfiguration.cs similarity index 93% rename from src/Candoumbe.Pipelines/Components/GitHubPublishConfiguration.cs rename to src/Candoumbe.Pipelines/Components/GitHub/GitHubPublishConfiguration.cs index ac938bf..6daffaf 100644 --- a/src/Candoumbe.Pipelines/Components/GitHubPublishConfiguration.cs +++ b/src/Candoumbe.Pipelines/Components/GitHub/GitHubPublishConfiguration.cs @@ -1,6 +1,6 @@ using System; -namespace Candoumbe.Pipelines.Components; +namespace Candoumbe.Pipelines.Components.GitHub; /// /// Wraps configuration used to publish packages to nuget.org feed. diff --git a/src/Candoumbe.Pipelines/Components/ICreateGithubRelease.cs b/src/Candoumbe.Pipelines/Components/GitHub/ICreateGithubRelease.cs similarity index 87% rename from src/Candoumbe.Pipelines/Components/ICreateGithubRelease.cs rename to src/Candoumbe.Pipelines/Components/GitHub/ICreateGithubRelease.cs index 4dcb5fa..767e748 100644 --- a/src/Candoumbe.Pipelines/Components/ICreateGithubRelease.cs +++ b/src/Candoumbe.Pipelines/Components/GitHub/ICreateGithubRelease.cs @@ -10,20 +10,13 @@ using static Nuke.Common.ChangeLog.ChangelogTasks; using static Serilog.Log; -namespace Candoumbe.Pipelines.Components; +namespace Candoumbe.Pipelines.Components.GitHub; /// /// Marks a pipeline that can create a GitHub release /// -public interface ICreateGithubRelease : IHaveGitRepository, IHaveChangeLog, IHaveGitVersion +public interface ICreateGithubRelease : IHaveGitHubRepository, IHaveChangeLog, IHaveGitVersion { - /// - /// Token used to create a new GitHub release - /// - [Parameter("Token used to create a new release in GitHub")] - [Secret] - string GitHubToken => TryGetValue(() => GitHubToken) ?? GitHubActions.Instance.Token; - /// /// Collection of assets to add to the published release. /// diff --git a/src/Candoumbe.Pipelines/Components/GitHub/IHaveGitHubRepository.cs b/src/Candoumbe.Pipelines/Components/GitHub/IHaveGitHubRepository.cs new file mode 100644 index 0000000..c89f717 --- /dev/null +++ b/src/Candoumbe.Pipelines/Components/GitHub/IHaveGitHubRepository.cs @@ -0,0 +1,17 @@ +using Nuke.Common; +using Nuke.Common.CI.GitHubActions; + +namespace Candoumbe.Pipelines.Components.GitHub; + +/// +/// Marks a pipeline for repositories that are stored on GitHub. +/// +public interface IHaveGitHubRepository : IHaveGitRepository, IHaveSecret +{ + /// + /// Token used to create a new GitHub release + /// + [Parameter("Token used to create a new release in GitHub")] + [Secret] + string GitHubToken => TryGetValue(() => GitHubToken) ?? GitHubActions.Instance.Token; +} diff --git a/src/Candoumbe.Pipelines/Components/GitHub/IPullRequest.cs b/src/Candoumbe.Pipelines/Components/GitHub/IPullRequest.cs new file mode 100644 index 0000000..315ab69 --- /dev/null +++ b/src/Candoumbe.Pipelines/Components/GitHub/IPullRequest.cs @@ -0,0 +1,152 @@ +using Nuke.Common; +using Nuke.Common.Git; +using Nuke.Common.Tools.GitHub; + +using Octokit; + +using System; +using System.Threading.Tasks; + +using static Nuke.Common.Tools.Git.GitTasks; +using static Nuke.Common.Tools.GitHub.GitHubTasks; +using static Nuke.Common.Utilities.ConsoleUtility; +using static Serilog.Log; + +namespace Candoumbe.Pipelines.Components.GitHub +{ + /// + /// This interface adds a target to open a pull request + /// + public interface IPullRequest : IGitFlow, IHaveGitHubRepository + { + /// + /// The title of the PR that will be created + /// + [Parameter("Title that will be used when creating a PR")] + string Title => TryGetValue(() => Title) ?? ((GitRepository.IsOnFeatureBranch(), GitRepository.IsOnReleaseBranch(), GitRepository.IsOnHotfixBranch()) switch + { + (true, _, _) => $"[FEATURE] {GitRepository.Branch.ToTitleCase()}", + (_, _, true) => $"[HOTFIX] {GitRepository.Branch.ToTitleCase()}", + _ => GitRepository.Branch.ToTitleCase() + }).Replace('-', ' '); + + /// + /// Token that will be used to connect to GitHub + /// + [Parameter("Token used to create a pull request")] + [Secret] + string Token => TryGetValue(() => Token); + + /// + /// Description of the pull request + /// + [Parameter("Description of the pull request")] + string Description => TryGetValue(() => Description) ?? this.Get()?.ReleaseNotes; + + /// + /// Should the local branch be deleted after the pull request was created successfully ? + /// + public bool DeleteLocalOnSuccess => false; + + /// + /// Defines, when set to , to open the pull request as draft. + /// + [Parameter("Indicates to open the pull request as 'draft'")] + public bool Draft => false; + + /// + /// The issue ID for witch pull request will be created. + /// + [Parameter("The issue ID for witch pull request will be created.")] + uint? Issue => null; + + /// + async ValueTask IGitFlow.FinishFeature() + { + // Push to the remote branch + GitPushToRemote(); + + string repositoryName = GitRepository.GetGitHubName(); + string branchName = GitCurrentBranch(); + string owner = GitRepository.GetGitHubOwner(); + + Information("Creating a pull request for {Repository}", repositoryName); + string title = PromptForInput("Title of the pull request :", Title); + + Information("Creating {PullRequestName} for {Repository}", title, repositoryName); + string token = Token ?? PromptForInput("Token (leave empty to exit)", string.Empty); + + if (!string.IsNullOrWhiteSpace(token)) + { + Information("{SourceBranch} ==> {TargetBranch} on the behalf of {UserName}", branchName, DevelopBranch, token); + GitHubClient gitHubClient = new(new ProductHeaderValue(repositoryName)) + { + Credentials = new Credentials(token) + }; + + NewPullRequest newPullRequest = new(title, branchName, DevelopBranch) + { + Draft = Draft, + Body = Description + }; + + PullRequest pullRequest = await gitHubClient.PullRequest.Create(owner, repositoryName, newPullRequest); + + Information("PR {PullRequestUrl} created successfully", pullRequest.HtmlUrl); + DeleteLocalBranchIf(DeleteLocalOnSuccess, branchName, switchToBranchName: DevelopBranch); + } + } + + private static void GitPushToRemote() + { + Git($"push origin --set-upstream {GitCurrentBranch()}"); + } + + private static void DeleteLocalBranchIf(in bool condition, in string branchName, in string switchToBranchName) + { + if (condition) + { + if (PromptForChoice("Delete branch {BranchName} ? (Y/N)", BuildChoices()) == ConsoleKey.Y) + { + Git($"switch {switchToBranchName}"); + Git($"branch -D {branchName}"); + } + } + + static (ConsoleKey key, string description)[] BuildChoices() => new[] + { + (key: ConsoleKey.Y, "Delete the local branch"), + (key: ConsoleKey.N, "Keep the local branch"), + }; + } + + /// + async ValueTask IGitFlow.FinishReleaseOrHotfix() + { + GitPushToRemote(); + + string repositoryName = GitRepository.GetGitHubName(); + string branchName = GitCurrentBranch(); + + Information("Creating a pull request for {Repository}", repositoryName); + string title = PromptForInput("Title of the pull request :", Title); + + Information("Creating {PullRequestName} for {Repository}", title, repositoryName); + string token = Token ?? PromptForInput("Token (leave empty to exit)", string.Empty); + if (!string.IsNullOrWhiteSpace(token)) + { + Verbose("{SourceBranch} ==> {TargetBranch}", branchName, DevelopBranch, token); + GitHubClient gitHubClient = new(new ProductHeaderValue(repositoryName)) + { + Credentials = new Credentials(token) + }; + NewPullRequest newPullRequest = new(Title, GitRepository.Branch, MainBranchName); + + PullRequest pullRequest = await gitHubClient.PullRequest.Create(GitRepository.GetGitHubOwner(), repositoryName, newPullRequest); + + Information("PR {PullRequestUrl} created successfully", pullRequest.HtmlUrl); + DeleteLocalBranchIf(DeleteLocalOnSuccess, GitCurrentBranch(), switchToBranchName: DevelopBranch); + } + } + } +} diff --git a/src/Candoumbe.Pipelines/Components/IGitFlow.cs b/src/Candoumbe.Pipelines/Components/IGitFlow.cs index 07cbd4d..f2ebc6f 100644 --- a/src/Candoumbe.Pipelines/Components/IGitFlow.cs +++ b/src/Candoumbe.Pipelines/Components/IGitFlow.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using static Nuke.Common.ChangeLog.ChangelogTasks; using static Nuke.Common.Tools.Git.GitTasks; @@ -108,7 +109,7 @@ public interface IGitFlow : IHaveGitRepository, IHaveChangeLog, IHaveGitVersion .Description($"Starts a new feature development by creating the associated branch {FeatureBranchPrefix}/{{feature-name}} from {DevelopBranch}") .Requires(() => IsLocalBuild) .Requires(() => !GitRepository.IsOnFeatureBranch() || GitHasCleanWorkingCopy()) - .Executes(() => + .Executes(async () => { if (!GitRepository.IsOnFeatureBranch()) { @@ -118,7 +119,7 @@ public interface IGitFlow : IHaveGitRepository, IHaveChangeLog, IHaveGitVersion } else { - FinishFeature(); + await FinishFeature(); } }); @@ -178,8 +179,9 @@ private void AskBranchNameAndSwitchToIt(string branchNamePrefix, string sourceBr public Target Release => _ => _ .DependsOn(Changelog) .Description($"Starts a new {ReleaseBranchPrefix}/{{version}} from {DevelopBranch}") + .Requires(() => IsLocalBuild) .Requires(() => !GitRepository.IsOnReleaseBranch() || GitHasCleanWorkingCopy()) - .Executes(() => + .Executes(async () => { if (!GitRepository.IsOnReleaseBranch()) { @@ -187,7 +189,7 @@ private void AskBranchNameAndSwitchToIt(string branchNamePrefix, string sourceBr } else { - FinishReleaseOrHotfix(); + await FinishReleaseOrHotfix(); } }); @@ -200,8 +202,9 @@ private void AskBranchNameAndSwitchToIt(string branchNamePrefix, string sourceBr public Target Hotfix => _ => _ .DependsOn(Changelog) .Description($"Starts a new hotfix branch '{HotfixBranchPrefix}/*' from {MainBranchName}") + .Requires(() => IsLocalBuild) .Requires(() => !GitRepository.IsOnHotfixBranch() || GitHasCleanWorkingCopy()) - .Executes(() => + .Executes(async () => { (GitVersion mainBranchVersion, IReadOnlyCollection _) = GitVersion(s => s .SetFramework("net5.0") @@ -215,7 +218,7 @@ private void AskBranchNameAndSwitchToIt(string branchNamePrefix, string sourceBr } else { - FinishReleaseOrHotfix(); + await FinishReleaseOrHotfix(); } }); @@ -226,7 +229,7 @@ private void AskBranchNameAndSwitchToIt(string branchNamePrefix, string sourceBr .Description($"Starts a new coldfix development by creating the associated '{ColdfixBranchPrefix}/{{name}}' from {DevelopBranch}") .Requires(() => IsLocalBuild) .Requires(() => !GitRepository.Branch.Like($"{ColdfixBranchPrefix}/*", true) || GitHasCleanWorkingCopy()) - .Executes(() => + .Executes(async () => { if (!GitRepository.Branch.Like($"{ColdfixBranchPrefix}/*")) { @@ -236,14 +239,14 @@ private void AskBranchNameAndSwitchToIt(string branchNamePrefix, string sourceBr } else { - FinishColdfix(); + await FinishColdfix(); } }); /// - /// Merge a coldfix/* branch back to the develop branch + /// Merges a branch back to branch /// - private void FinishColdfix() => FinishFeature(); + async virtual ValueTask FinishColdfix() => await FinishFeature(); private void Checkout(string branch, string start) { @@ -261,7 +264,10 @@ private void Checkout(string branch, string start) } } - private void FinishReleaseOrHotfix() + /// + /// Merges the current or branch back to . + /// + virtual ValueTask FinishReleaseOrHotfix() { Git($"checkout {MainBranchName}"); Git("pull"); @@ -275,9 +281,14 @@ private void FinishReleaseOrHotfix() Git($"branch -D {GitRepository.Branch}"); Git($"push origin --follow-tags {MainBranchName} {DevelopBranch} {MajorMinorPatchVersion}"); + + return ValueTask.CompletedTask; } - private void FinishFeature() + /// + /// Merges the current feature branch back to . + /// + virtual ValueTask FinishFeature() { Git($"rebase {DevelopBranch}"); Git($"checkout {DevelopBranch}"); @@ -286,5 +297,7 @@ private void FinishFeature() Git($"branch -D {GitRepository.Branch}"); Git($"push origin {DevelopBranch}"); + + return ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/src/Candoumbe.Pipelines/Components/IHaveSecret.cs b/src/Candoumbe.Pipelines/Components/IHaveSecret.cs new file mode 100644 index 0000000..f04ad78 --- /dev/null +++ b/src/Candoumbe.Pipelines/Components/IHaveSecret.cs @@ -0,0 +1,32 @@ +using Nuke.Common; + +using static Nuke.Common.Tools.DotNet.DotNetTasks; +using static Nuke.Common.Utilities.ConsoleUtility; +using static Serilog.Log; + +namespace Candoumbe.Pipelines.Components; + +/// +/// Adds a target that manages secrets +/// +/// +/// The default implementation requires nuke to be install locally +/// +public interface IHaveSecret : INukeBuild +{ + /// + /// Manage secrets so that pipelines can be runned locally" + /// + public Target ManageSecrets => _ => _ + .Description("Manage secrets that can be used when running build locally") + .Requires(() => IsLocalBuild) + .Executes(() => + { + string profile = PromptForInput("Profile name", string.Empty); + if (string.IsNullOrWhiteSpace(profile)) + { + Information("No profile set. Parameters will be set for the default profile"); + } + DotNet($"tool run nuke :secrets{(string.IsNullOrWhiteSpace(profile) ? string.Empty : $" {profile}")}"); + }); +} diff --git a/src/Candoumbe.Pipelines/Components/IPublish.cs b/src/Candoumbe.Pipelines/Components/IPublish.cs index 396da3f..bc49d9f 100644 --- a/src/Candoumbe.Pipelines/Components/IPublish.cs +++ b/src/Candoumbe.Pipelines/Components/IPublish.cs @@ -15,7 +15,7 @@ namespace Candoumbe.Pipelines.Components; /// -/// Marks a pipeline that can specify a folder for source files +/// Marks a pipeline that can publish packages /// public interface IPublish : IPack {