diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index ee76ed7..2dd63b2 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -73,6 +73,7 @@ "type": "string", "enum": [ "BuildTemplate", + "CreateVersionChangeCommit", "GenerateDocumentationFeature", "GetFeatures", "GetTemplates", @@ -95,6 +96,7 @@ "type": "string", "enum": [ "BuildTemplate", + "CreateVersionChangeCommit", "GenerateDocumentationFeature", "GetFeatures", "GetTemplates", diff --git a/build.test/Fixture.cs b/build.test/Fixture.cs index f4de8ad..973d460 100644 --- a/build.test/Fixture.cs +++ b/build.test/Fixture.cs @@ -1,5 +1,6 @@ using System.Text.Json; using CliWrap; +using CliWrap.Builders; using Nuke.Common; using Nuke.Common.IO; @@ -7,9 +8,19 @@ namespace build.test; internal sealed class CustomFixture : IAsyncDisposable { - private readonly List tags = new(); + private readonly List tags = new(); private readonly List tempFiles = new(); - private int commitsCount = 0; + private string? commitToRestore; + + public async Task SaveCommit(string commit) + { + await Cli.Wrap("git") + .WithArguments(args => args + .Add("rev-parse") + .Add(commit)) + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => commitToRestore = x)) + .ExecuteAsync(); + } public bool KeepFiles { get; init; } @@ -28,7 +39,7 @@ public async ValueTask DisposeAsync() if (!KeepCommits) { - await RevertCommits(commitsCount); + await RevertCommits(commitToRestore); } if (!KeepFiles) @@ -52,14 +63,13 @@ await Cli.Wrap("git") .Add("--progress") .Add(files)) .WithStandardOutputPipe(PipeTarget.ToDelegate(Console.WriteLine)) + .WithValidation(CommandResultValidation.None) .ExecuteAsync(); } - public string GetTagForFeature(string feature) => $"feature_{feature}"; - - public async Task RevertCommits(int numberOfCommits) + public async Task RevertCommits(string? commit) { - if (numberOfCommits <= 0) + if (string.IsNullOrEmpty(commit)) { return; } @@ -69,7 +79,7 @@ await Cli.Wrap("git") .Add("reset") .Add("--no-refresh") .Add("--soft") - .Add($"HEAD~{numberOfCommits}")) + .Add(commit)) .WithStandardOutputPipe(PipeTarget.ToDelegate(Console.WriteLine)) .ExecuteAsync(); } @@ -107,55 +117,64 @@ public void CreateTempFile(string path) } public async Task CreateFeatureConfig( - string featureName, - int major, - int minor, - int build) + Feature featureName, + Version version) { - this.CreateTempDirectory(GetFeatureRoot(featureName)); + this.CreateTempDirectory(featureName.GetFeatureRoot(RootDirectory)); - await File.WriteAllTextAsync( - GetFeatureConfig(featureName), - $$"""{ "version": "{{major}}.{{minor}}.{{build}}" }"""); - } - - public AbsolutePath GetFeatureRoot(string featureName) - => NukeBuild.RootDirectory - / "features" - / "src" - / featureName; + var featureConfig = featureName.GetFeatureConfig(RootDirectory); + var json = $$""" + { + "version": "{{version}}", + "id": "{{featureName}}", + "name": "{{featureName}}" + } + """; - public AbsolutePath GetFeatureConfig(string featureName) - => GetFeatureRoot(featureName) - / "devcontainer-feature.json"; + await File.WriteAllTextAsync(featureConfig, json); + } - public async Task GetVersion(string feature) + public async Task GetVersion(Feature feature) { - var featureConfig = this.GetFeatureConfig(feature); + var featureConfig = feature.GetFeatureConfig(RootDirectory); using var fileStream = File.OpenRead(featureConfig); var document = await JsonDocument.ParseAsync(fileStream); return document.RootElement.GetProperty("version").GetString(); } - public async Task RunBuild(string feature) + public async Task RunBuild(Func configure) { await Cli.Wrap("dotnet") - .WithArguments(args => args - .Add("run") - .Add("--project") - .Add("/workspaces/devcontainers/build") - .Add("Version") - .Add("--feature") - .Add(feature) + .WithArguments(args => configure(args + .Add("run") + .Add("--project") + .Add("/workspaces/devcontainers/build")) .Add("--no-logo")) .WithStandardOutputPipe(PipeTarget.ToDelegate(Console.WriteLine)) .ExecuteAsync(); } + public async Task RunCreateReleaseCommitTarget(Feature feature) + { + await RunBuild(args => args + .Add("--target") + .Add("CreateVersionChangeCommit") + .Add("--feature") + .Add(feature)); + } + + public async Task RunVersionTarget(Feature feature) + { + await RunBuild(args => args + .Add("Version") + .Add("--feature") + .Add(feature)); + } + public async Task Commit( - string path, - string message) + CommitMessage message, + string path) { await Cli.Wrap("git") .WithArguments(args => args @@ -173,12 +192,10 @@ await Cli.Wrap("git") .Add(message)) .WithStandardOutputPipe(PipeTarget.ToDelegate(Console.WriteLine)) .ExecuteAsync(); - - this.commitsCount++; } public async Task AddGitTag( - string tag, + Tag tag, string commit = "HEAD") { await Cli.Wrap("git") @@ -191,7 +208,7 @@ await Cli.Wrap("git") this.tags.Add(tag); } - public async Task DeleteGitTags(IReadOnlyCollection tags) + public async Task DeleteGitTags(IReadOnlyCollection tags) { if (tags.Count is 0) { @@ -202,24 +219,99 @@ await Cli.Wrap("git") .WithArguments(args => args .Add("tag") .Add("--delete") - .Add(tags)) + .Add(tags.Select(x => x.ToString()))) .WithStandardOutputPipe(PipeTarget.ToDelegate(Console.WriteLine)) .ExecuteAsync(); } + + public async Task GetLatestCommitMessage() + { + var commitMessage = string.Empty; + + await Cli.Wrap("git") + .WithArguments(args => args + .Add("rev-list") + .Add("HEAD") + .Add("--pretty=%s") + .Add("--no-commit-header") + .Add("-n1")) + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => commitMessage = x)) + .ExecuteAsync(); + + return commitMessage; + } + + public async Task GetLatestTag(string feature) + { + var tag = string.Empty; + + var result = await Cli.Wrap("git") + .WithArguments(args => args + .Add("describe") + .Add("--abbrev=0") + .Add("--tags") + .Add("--match") + .Add($"feature_{feature}*")) + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => tag = x)) + .ExecuteAsync(); + + return tag; + } + + public async Task> GetModifiedFilesLatestCommit( + string commit = "HEAD") + { + var modifiedFiles = new List(); + + await Cli.Wrap("git") + .WithArguments(args => args + .Add("diff-tree") + .Add("--no-commit-id") + .Add("--name-only") + .Add("-r") + .Add(commit)) + .WithStandardOutputPipe(PipeTarget.ToDelegate(modifiedFiles.Add)) + .ExecuteAsync(); + + return modifiedFiles; + } } -public static class EnumerableExtenssions +public static class FeatureExtenssions { - public static void ForEach(this IEnumerable items, Action action) + public static Tag GetTag(this Feature feature) => new($"feature_{feature}"); + + public static AbsolutePath GetFeatureRoot( + this Feature feature, + AbsolutePath projectRoot) => projectRoot + / "features" + / "src" + / feature; + + public static AbsolutePath GetFeatureConfig( + this Feature featureName, + AbsolutePath projectRoot) + => featureName.GetFeatureRoot(projectRoot) + / "devcontainer-feature.json"; + + public static string GetRelativePathToConfig(this Feature feature) + => Path.Combine("features", "src", feature, "devcontainer-feature.json"); + + public static async Task GetVersion( + this Feature feature, + AbsolutePath projectRoot) { - foreach (var item in items) - { - action(item); - } + var featureConfig = feature.GetFeatureConfig(projectRoot); + using var fileStream = File.OpenRead(featureConfig); + var document = await JsonDocument.ParseAsync(fileStream); + + return document.RootElement.GetProperty("version").GetString(); } - public static void ForEach( - this IEnumerable items, - Func action) - => items.ForEach(x => { action(x); }); + public static void CreateTempFile( + this Feature feature, + AbsolutePath root) + { + using var _ = File.Create(feature.GetFeatureRoot(root) / $"tmp_{Guid.NewGuid():N}"); + } } diff --git a/build.test/VersioningTests.cs b/build.test/VersioningTests.cs index 367ae55..c84ab97 100644 --- a/build.test/VersioningTests.cs +++ b/build.test/VersioningTests.cs @@ -10,133 +10,186 @@ public VersioningTests() { KeepCommits = false, KeepFiles = false, - KeepTags = false + KeepTags = false, }; } [Theory, AutoData] public async Task TwoCommits_FeatAndChore_UpdatesMajor( - string feature, - int major, - int minor, - int build, - string message) + Feature feature, + Version version) { - feature = feature.Replace("-", string.Empty); - var gitTag = fixture.GetTagForFeature(feature); - (major, minor, build) = (Math.Abs(major), Math.Abs(minor), Math.Abs(build)); - var featureRoot = fixture.GetFeatureRoot(feature); - var featureFile = fixture.GetFeatureConfig(feature); + await fixture.AddGitTag(feature.GetTag()); + await fixture.CreateFeatureConfig(feature, version); + await fixture.Commit(CommitMessage.New("feat"), feature.GetFeatureRoot(fixture.RootDirectory)); + feature.CreateTempFile(fixture.RootDirectory); + await fixture.Commit(CommitMessage.New("chore"), feature.GetFeatureRoot(fixture.RootDirectory)); - await this.fixture.AddGitTag(gitTag); + await fixture.RunVersionTarget(feature); - await fixture.CreateFeatureConfig(feature, major, minor, build); - await fixture.Commit(featureRoot, $"feat: {message}"); - fixture.CreateTempFile(featureRoot / "foo"); - await fixture.Commit(featureRoot, $"chore: {message}"); + var newVersion = await feature.GetVersion(fixture.RootDirectory); - await fixture.RunBuild(feature); - - var version = await fixture.GetVersion(feature); - - version + newVersion .Should() - .Be($"{major + 1}.0.0"); + .Be(version.IncrementMajor()); } [Theory, AutoData] public async Task TwoCommits_ChoreAndChore_UpdatesChore( - string feature, - int major, - int minor, - int build, - string message) + Feature feature, + Version version) { - feature = feature.Replace("-", string.Empty); - var gitTag = fixture.GetTagForFeature(feature); - (major, minor, build) = (Math.Abs(major), Math.Abs(minor), Math.Abs(build)); - var featureRoot = fixture.GetFeatureRoot(feature); - var featureFile = fixture.GetFeatureConfig(feature); + await fixture.AddGitTag(feature.GetTag()); + await fixture.CreateFeatureConfig(feature, version); + await fixture.Commit(CommitMessage.New("chore"), feature.GetFeatureRoot(fixture.RootDirectory)); + feature.CreateTempFile(fixture.RootDirectory); + await fixture.Commit(CommitMessage.New("chore"), feature.GetFeatureRoot(fixture.RootDirectory)); - await fixture.AddGitTag(gitTag); - await fixture.CreateFeatureConfig(feature, major, minor, build); - await fixture.Commit(featureRoot, $"chore: {message}"); - fixture.CreateTempFile(featureRoot / "foo"); - await fixture.Commit(featureRoot, $"chore: {message}"); + await fixture.RunVersionTarget(feature); - await fixture.RunBuild(feature); + var newVersion = await fixture.GetVersion(feature); - var version = await fixture.GetVersion(feature); - - version + newVersion .Should() - .Be($"{major}.{minor + 1}.0"); + .Be(version.IncrementMinor()); } [Theory, AutoData] public async Task UseTheRightTag( - string feature, - int major, - int minor, - int build, - string wrongTag, - string message) + Feature feature, + Version version, + Tag wrongTag) { - feature = feature.Replace("-", string.Empty); - var featureRoot = fixture.GetFeatureRoot(feature); - var rightTag = fixture.GetTagForFeature(feature); - wrongTag = wrongTag.Replace("-", ""); - (major, minor, build) = (Math.Abs(major), Math.Abs(minor), Math.Abs(build)); - - await fixture.AddGitTag(rightTag); - await fixture.CreateFeatureConfig(feature, major, minor, build); - await fixture.Commit(featureRoot, $"feat: {message}"); + await fixture.AddGitTag(feature.GetTag()); + await fixture.CreateFeatureConfig(feature, version); + await fixture.Commit(CommitMessage.New("feat"), feature.GetFeatureRoot(fixture.RootDirectory)); await fixture.AddGitTag(wrongTag); - fixture.CreateTempFile(featureRoot / "foo"); - await fixture.Commit(featureRoot, $"chore: {message}"); + feature.CreateTempFile(fixture.RootDirectory); + await fixture.Commit(CommitMessage.New("chore"), feature.GetFeatureRoot(fixture.RootDirectory)); - await fixture.RunBuild(feature); + await fixture.RunVersionTarget(feature); - var version = await fixture.GetVersion(feature); + var newVersion = await fixture.GetVersion(feature); - version + newVersion .Should() - .Be($"{major + 1}.0.0"); + .Be(version.IncrementMajor()); } [Theory, AutoData] public async Task UseTheRightCommit( - string feature, + Feature feature, string tempFileName, - int major, - int minor, - int build, - string wrongTag, - string message) + Version version) { - feature = feature.Replace("-", string.Empty); - var featureRoot = fixture.GetFeatureRoot(feature); - var rightTag = fixture.GetTagForFeature(feature); - wrongTag = wrongTag.Replace("-", ""); - (major, minor, build) = (Math.Abs(major), Math.Abs(minor), Math.Abs(build)); - - await fixture.AddGitTag(rightTag); - await fixture.CreateFeatureConfig(feature, major, minor, build); - await fixture.Commit(featureRoot, $"chore: {message}"); - await fixture.AddGitTag(wrongTag); + await fixture.AddGitTag(feature.GetTag()); + await fixture.CreateFeatureConfig(feature, version); + await fixture.Commit(CommitMessage.New("chore"), feature.GetFeatureRoot(fixture.RootDirectory)); fixture.CreateTempFile(fixture.RootDirectory / tempFileName); - await fixture.Commit(fixture.RootDirectory / tempFileName, $"feat: {message}"); + await fixture.Commit(CommitMessage.New("feat"), fixture.RootDirectory / tempFileName); - await fixture.RunBuild(feature); + await fixture.RunVersionTarget(feature); - var version = await fixture.GetVersion(feature); + var newVersion = await fixture.GetVersion(feature); - version + newVersion .Should() - .Be($"{major}.{minor + 1}.0"); + .Be(version.IncrementMinor()); } - public async Task InitializeAsync() => await Task.CompletedTask; + [Theory, AutoData] + public async Task CreateVersionChangeCommitCreatesCommitWithRightMessage( + Feature feature, + Version version) + { + await fixture.CreateFeatureConfig(feature, version); + await fixture.RunCreateReleaseCommitTarget(feature); + + var expectedMessage = await fixture.GetLatestCommitMessage(); + + expectedMessage + .Should() + .Be($"chore(version): Release feature/{feature} {version}"); + } + + [Theory, AutoData] + public async Task CreateReleaseCommitContainsOnlyConfigFile( + Feature feature, + Version version) + { + await fixture.CreateFeatureConfig(feature, version); + await fixture.RunCreateReleaseCommitTarget(feature); + + var modifiedFiles = await fixture.GetModifiedFilesLatestCommit(); + + modifiedFiles + .Should() + .Equal(feature.GetRelativePathToConfig()); + } + + public async Task InitializeAsync() => await fixture.SaveCommit("HEAD"); public async Task DisposeAsync() => await fixture.DisposeAsync(); } + + +public sealed class Feature +{ + public Feature(string name) => Name = name.Replace("-", string.Empty); + + public string Name { get; } + + public override string ToString() => Name; + + public static implicit operator string(Feature feature) => feature.ToString(); +} + +public sealed record Version(int Major, int Minor, int Build) +{ + public override string ToString() => $"{Major}.{Minor}.{Build}"; + + public static implicit operator string(Version version) => version.ToString(); +} + +public static class VersionExtenssions +{ + public static Version IncrementMajor(this Version version) => version with + { + Major = version.Major + 1, + Minor = 0, + Build = 0 + }; + + public static Version IncrementMinor(this Version version) => version with + { + Minor = version.Minor + 1, + Build = 0 + }; +} + +public class CommitMessage +{ + public static CommitMessage New(string type) => new() + { + Type = type, + Message = Guid.NewGuid().ToString("N") + }; + + public required string Type { get; init; } + + public required string Message { get; init; } + + public override string ToString() => $"{Type}: {Message}"; + + public static implicit operator string(CommitMessage commitMessage) => commitMessage.ToString(); +} + +public sealed record Tag(string Name) +{ + public override string ToString() + { + return Name; + } + + public static implicit operator string(Tag tag) => tag.ToString(); +} diff --git a/build/Build.cs b/build/Build.cs index e515c74..24126b7 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -5,6 +5,7 @@ using CliWrap; using Serilog; using Nuke.Common.IO; +using System.Text.Json; public sealed partial class Build : NukeBuild { @@ -12,6 +13,34 @@ public sealed partial class Build : NukeBuild [PathExecutable("bash")] private readonly Tool Bash = null!; + public Target CreateVersionChangeCommit => _ => _ + .Executes(() => Feature) + .Executes(async () => + { + var json = File.ReadAllText(PathToFeatureDefinition); + var feature = JsonSerializer.Deserialize(json); + var version = System.Version.Parse(feature?.Version!); + + await Cli.Wrap("git") + .WithArguments(args => args + .Add("add") + .Add(PathToFeatureDefinition)) + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => Log.Information("{git_msg}", x))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(x => Log.Error("{git_msg}", x))) + .ExecuteAsync(); + + await Cli.Wrap("git") + .WithArguments(args => args + .Add("commit") + .Add("--include") + .Add(PathToFeatureDefinition) + .Add("--message") + .Add($"chore(version): Release feature/{Feature} {version}")) + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => Log.Information("{git_msg}", x))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(x => Log.Error("{git_msg}", x))) + .ExecuteAsync(); + }); + public Target Version => _ => _ .Requires(() => Feature) .Executes(async () => @@ -22,7 +51,7 @@ public sealed partial class Build : NukeBuild var versionJsonElement = document.Root["version"]; - var version = new Version(versionJsonElement!.GetValue()); + var version = new System.Version(versionJsonElement!.GetValue()); Log.Information("old version: {version}", version); var latestGitTag = await GetLatestTag(Feature); diff --git a/features/src/namee0675c918bcf41f08fdf21b526121733/devcontainer-feature.json b/features/src/namee0675c918bcf41f08fdf21b526121733/devcontainer-feature.json new file mode 100644 index 0000000..397e976 --- /dev/null +++ b/features/src/namee0675c918bcf41f08fdf21b526121733/devcontainer-feature.json @@ -0,0 +1,5 @@ +{ + "version": "79.60.100", + "id": "namee0675c918bcf41f08fdf21b526121733", + "name": "namee0675c918bcf41f08fdf21b526121733" +} \ No newline at end of file