From 1dd5f2346760c7853ff1b4c7de5a63111cdd371a Mon Sep 17 00:00:00 2001 From: CrispyDrone Date: Sat, 17 Oct 2020 20:20:15 +0200 Subject: [PATCH] feat: Support custom templates for commits + PRs This commit introduces mustache templates for commit messages, and pull request titles and bodies. The design consists of templates with predefined properties. A custom template can be provided. Stubble has been chosen as the template renderer. It is possible to define additional context for the templates, this also includes delegates. These can be provided as the value of a "_delegates" key in the context. --- .../Configuration/FileSettingsReaderTests.cs | 35 ++- .../CommitUpdateMessageTemplate.cs | 47 ++++ .../DefaultPullRequestBodyTemplate.cs | 45 +++ .../DefaultPullRequestTitleTemplate.cs | 17 ++ .../CollaborationModels/FooterTemplate.cs | 10 + .../LatestPackageTemplate.cs | 11 + .../CollaborationModels/PackageTemplate.cs | 32 +++ .../PublicationTemplate.cs | 8 + .../UpdateMessageTemplate.cs | 157 +++++++++++ .../CollaborationModels/UpdateTemplate.cs | 12 + .../ICollaborationFactory.cs | 15 +- .../CollaborationPlatform/ICommitWorder.cs | 2 - .../CollaborationPlatform/IEnrichContext`2.cs | 11 + .../ITemplateRenderer.cs | 9 + .../ITemplateValidator.cs | 12 + .../PackageUpdateSetEnricher.cs | 99 +++++++ .../PackageUpdateSetsEnricher.cs | 30 ++ .../StubbleTemplateRenderer.cs | 26 ++ .../Configuration/FileSettings.cs | 15 +- .../Configuration/IProvideConfiguration.cs | 9 + .../Configuration/UserSettings.cs | 13 +- NuKeeper.Abstractions/Constants.cs | 27 ++ .../NuGetApi/PackageLookupResult.cs | 1 - .../NuGetApi/PackageSearchMetadata.cs | 22 +- .../NuKeeper.Abstractions.csproj | 3 +- .../RepositoryInspection/PackageUpdateSet.cs | 35 +++ .../AzureDevOpsCommitWorder.cs | 253 ----------------- .../AzureDevOpsPullRequestBodyTemplate.cs | 62 +++++ .../AzureDevOpsPullRequestTitleTemplate.cs | 43 +++ .../NuKeeper.AzureDevOps.csproj | 5 + NuKeeper.BitBucket/BitbucketCommitWorder.cs | 239 ---------------- NuKeeper.BitBucket/BitbucketPlatform.cs | 10 +- .../BitbucketPullRequestBodyTemplate.cs | 45 +++ NuKeeper.BitBucket/NuKeeper.BitBucket.csproj | 4 + NuKeeper.Gitea/GiteaSettingsReader.cs | 8 +- NuKeeper.Gitea/NuKeeper.Gitea.csproj | 4 + NuKeeper.Gitlab/GitlabPlatform.cs | 11 +- NuKeeper.Gitlab/NuKeeper.Gitlab.csproj | 4 + NuKeeper.Tests/Commands/GlobalCommandTests.cs | 13 +- .../Commands/OrganisationCommandTests.cs | 11 +- .../Commands/RepositoryCommandTests.cs | 34 ++- .../RepositoryCommand_CustomTemplates.cs | 163 +++++++++++ .../ProvideContextTests.cs | 261 ++++++++++++++++++ .../Engine/CollaborationFactoryTests.cs | 213 ++++++++++++-- .../CommitUpdateMessageTemplateTests.cs | 206 ++++++++++++++ ...mitWorderTests.cs => CommitWorderTests.cs} | 145 ++++++---- .../Engine/Packages/PackageUpdaterTests.cs | 2 + .../Engine/RepositoryUpdaterTests.cs | 5 +- NuKeeper.Tests/PackageUpdates.cs | 14 +- .../StubbleMustacheTemplateValidatorTests.cs | 33 +++ .../Collaboration/CollaborationFactory.cs | 135 +++++++-- .../Commands/CollaborationPlatformCommand.cs | 58 +++- NuKeeper/Commands/CommandBase.cs | 121 ++++---- .../ConfigurationProviders/ProvideContext.cs | 89 ++++++ NuKeeper/ContainerRegistration.cs | 17 +- NuKeeper/Engine/CommitWorder.cs | 78 ++++++ NuKeeper/Engine/DefaultCommitWorder.cs | 251 ----------------- .../Engine/Packages/ExistingCommitFilter.cs | 5 +- NuKeeper/Engine/Packages/PackageUpdater.cs | 9 +- NuKeeper/NuKeeper.csproj | 12 +- .../StubbleMustacheTemplateValidator.cs | 25 ++ .../AzureDevOpsCommitWorderTests.cs | 144 +++++++--- .../BitbucketLocalRestClient.cs | 12 +- .../NuKeeper.BitBucketLocal.csproj | 4 + site/content/basics/configuration.md | 136 ++++++++- 65 files changed, 2553 insertions(+), 1034 deletions(-) create mode 100644 NuKeeper.Abstractions/CollaborationModels/CommitUpdateMessageTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationModels/DefaultPullRequestBodyTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationModels/DefaultPullRequestTitleTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationModels/FooterTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationModels/LatestPackageTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationModels/PackageTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationModels/PublicationTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationModels/UpdateMessageTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationModels/UpdateTemplate.cs create mode 100644 NuKeeper.Abstractions/CollaborationPlatform/IEnrichContext`2.cs create mode 100644 NuKeeper.Abstractions/CollaborationPlatform/ITemplateRenderer.cs create mode 100644 NuKeeper.Abstractions/CollaborationPlatform/ITemplateValidator.cs create mode 100644 NuKeeper.Abstractions/CollaborationPlatform/PackageUpdateSetEnricher.cs create mode 100644 NuKeeper.Abstractions/CollaborationPlatform/PackageUpdateSetsEnricher.cs create mode 100644 NuKeeper.Abstractions/CollaborationPlatform/StubbleTemplateRenderer.cs create mode 100644 NuKeeper.Abstractions/Configuration/IProvideConfiguration.cs create mode 100644 NuKeeper.Abstractions/Constants.cs delete mode 100644 NuKeeper.AzureDevOps/AzureDevOpsCommitWorder.cs create mode 100644 NuKeeper.AzureDevOps/AzureDevOpsPullRequestBodyTemplate.cs create mode 100644 NuKeeper.AzureDevOps/AzureDevOpsPullRequestTitleTemplate.cs delete mode 100644 NuKeeper.BitBucket/BitbucketCommitWorder.cs create mode 100644 NuKeeper.BitBucket/BitbucketPullRequestBodyTemplate.cs create mode 100644 NuKeeper.Tests/Commands/RepositoryCommand_CustomTemplates.cs create mode 100644 NuKeeper.Tests/ConfigurationProviders/ProvideContextTests.cs create mode 100644 NuKeeper.Tests/Engine/CommitUpdateMessageTemplateTests.cs rename NuKeeper.Tests/Engine/{DefaultCommitWorderTests.cs => CommitWorderTests.cs} (70%) create mode 100644 NuKeeper.Tests/Validators/StubbleMustacheTemplateValidatorTests.cs create mode 100644 NuKeeper/ConfigurationProviders/ProvideContext.cs create mode 100644 NuKeeper/Engine/CommitWorder.cs delete mode 100644 NuKeeper/Engine/DefaultCommitWorder.cs create mode 100644 NuKeeper/Validators/StubbleMustacheTemplateValidator.cs diff --git a/NuKeeper.Abstractions.Tests/Configuration/FileSettingsReaderTests.cs b/NuKeeper.Abstractions.Tests/Configuration/FileSettingsReaderTests.cs index c876dc6b8..bb7df80df 100644 --- a/NuKeeper.Abstractions.Tests/Configuration/FileSettingsReaderTests.cs +++ b/NuKeeper.Abstractions.Tests/Configuration/FileSettingsReaderTests.cs @@ -5,6 +5,7 @@ using NuKeeper.Abstractions.Output; using NUnit.Framework; using System; +using System.Collections.Generic; using System.Globalization; using System.IO; @@ -60,6 +61,8 @@ public void MissingFileReturnsNoSettings() Assert.That(data.Platform, Is.Null); Assert.That(data.BranchNameTemplate, Is.Null); Assert.That(data.DeleteBranchAfterMerge, Is.Null); + Assert.That(data.CommitMessageTemplate, Is.Null); + Assert.That(data.Context, Is.Null); } [Test] @@ -92,6 +95,8 @@ public void EmptyConfigReturnsNoSettings() Assert.That(data.Platform, Is.Null); Assert.That(data.BranchNameTemplate, Is.Null); Assert.That(data.DeleteBranchAfterMerge, Is.Null); + Assert.That(data.CommitMessageTemplate, Is.Null); + Assert.That(data.Context, Is.Null); } private const string FullFileData = @"{ @@ -116,7 +121,9 @@ public void EmptyConfigReturnsNoSettings() ""OutputFileName"" : ""out_42.txt"", ""LogDestination"" : ""file"", ""Platform"" : ""Bitbucket"", - ""DeleteBranchAfterMerge"": ""true"" + ""DeleteBranchAfterMerge"": ""true"", + ""CommitMessageTemplate"": ""📦 Automatic update from {{packageName}} to {{packageVersion}}"", + ""Context"": { ""company"": ""NuKeeper"", ""issue"": ""JIRA-001"" } }"; [Test] @@ -139,6 +146,17 @@ public void PopulatedConfigReturnsAllStringSettings() Assert.That(data.OutputFileName, Is.EqualTo("out_42.txt")); Assert.That(data.BranchNameTemplate, Is.EqualTo("nukeeper/MyBranch")); Assert.That(data.DeleteBranchAfterMerge, Is.EqualTo(true)); + Assert.That(data.CommitMessageTemplate, Is.EqualTo("📦 Automatic update from {{packageName}} to {{packageVersion}}")); + Assert.That( + data.Context, + Is.EquivalentTo( + new Dictionary + { + { "company", "NuKeeper" }, + { "issue", "JIRA-001" } + } + ) + ); } [Test] @@ -206,7 +224,9 @@ public void ConfigKeysAreCaseInsensitive() ""vErBoSiTy"": ""Q"", ""CHANGE"": ""PATCH"", ""bRanCHNamETempLATe"": ""nukeeper/MyBranch"", - ""deLeTEBranCHafTERMerge"": ""true"" + ""deLeTEBranCHafTERMerge"": ""true"", + ""coMmItmESsageTeMpLate"": ""📦 Automatic update from {{packageName}} to {{packageVersion}}"", + ""cOntExT"": { ""company"": ""NuKeeper"", ""issue"": ""JIRA-001"" } }"; var path = MakeTestFile(configData); @@ -230,6 +250,17 @@ public void ConfigKeysAreCaseInsensitive() Assert.That(data.Change, Is.EqualTo(VersionChange.Patch)); Assert.That(data.BranchNameTemplate, Is.EqualTo("nukeeper/MyBranch")); Assert.That(data.DeleteBranchAfterMerge, Is.EqualTo(true)); + Assert.That(data.CommitMessageTemplate, Is.EqualTo("📦 Automatic update from {{packageName}} to {{packageVersion}}")); + Assert.That( + data.Context, + Is.EquivalentTo( + new Dictionary + { + { "company", "NuKeeper" }, + { "issue", "JIRA-001" } + } + ) + ); } [Test] diff --git a/NuKeeper.Abstractions/CollaborationModels/CommitUpdateMessageTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/CommitUpdateMessageTemplate.cs new file mode 100644 index 000000000..dfdb81e02 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/CommitUpdateMessageTemplate.cs @@ -0,0 +1,47 @@ +using NuKeeper.Abstractions.CollaborationPlatform; + +namespace NuKeeper.Abstractions.CollaborationModels +{ + public class CommitUpdateMessageTemplate : UpdateMessageTemplate + { + private const string CommitEmoji = "📦"; + + public CommitUpdateMessageTemplate() + : base(new StubbleTemplateRenderer()) + { + PackageEmoji = CommitEmoji; + } + + public static string DefaultTemplate { get; } = + "{{#packageEmoji}}{{packageEmoji}} {{/packageEmoji}}Automatic update of {{^multipleChanges}}{{#packages}}{{Name}} to {{Version}}{{/packages}}{{/multipleChanges}}{{#multipleChanges}}{{packageCount}} packages{{/multipleChanges}}"; + + public override string Value + { + get + { + return CustomTemplate ?? DefaultTemplate; + } + } + + public string CustomTemplate { get; set; } + + public object PackageEmoji + { + get + { + Context.TryGetValue(Constants.Template.PackageEmoji, out var packageEmoji); + return packageEmoji; + } + set + { + Context[Constants.Template.PackageEmoji] = value; + } + } + + public override void Clear() + { + base.Clear(); + PackageEmoji = CommitEmoji; + } + } +} diff --git a/NuKeeper.Abstractions/CollaborationModels/DefaultPullRequestBodyTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/DefaultPullRequestBodyTemplate.cs new file mode 100644 index 000000000..87a1cadca --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/DefaultPullRequestBodyTemplate.cs @@ -0,0 +1,45 @@ +using NuKeeper.Abstractions.CollaborationPlatform; + +namespace NuKeeper.Abstractions.CollaborationModels +{ + public class DefaultPullRequestBodyTemplate : UpdateMessageTemplate + { + public DefaultPullRequestBodyTemplate() + : base(new StubbleTemplateRenderer()) { } + + public static string DefaultTemplate { get; } = +@"{{#multipleChanges}}{{packageCount}} packages were updated in {{projectsUpdated}} project{{#multipleProjects}}s{{/multipleProjects}}: +{{#packages}}`{{Name}}`{{^Last}}, {{/Last}}{{/packages}} +
+Details of updated packages + +{{/multipleChanges}} +{{#packages}}NuKeeper has generated a {{ActualChange}} update of `{{Name}}` to `{{Version}}`{{^MultipleUpdates}} from `{{FromVersion}}`{{/MultipleUpdates}} +{{#MultipleUpdates}}{{ProjectsUpdated}} versions of `{{Name}}` were found in use: {{#Updates}}`{{FromVersion}}`{{^Last}}, {{/Last}}{{/Updates}}{{/MultipleUpdates}} +{{#Publication}}`{{Name}} {{Version}}` was published at `{{Date}}`, {{Ago}}{{/Publication}} +{{#LatestVersion}}There is also a higher version, `{{Name}} {{Version}}`{{#Publication}} published at `{{Date}}`, {{Ago}}{{/Publication}}, but this was not applied as only `{{AllowedChange}}` version changes are allowed. +{{/LatestVersion}} +{{ProjectsUpdated}} project update{{#MultipleProjectsUpdated}}s{{/MultipleProjectsUpdated}}: +{{#Updates}} +Updated `{{SourceFilePath}}` to `{{Name}}` `{{ToVersion}}` from `{{FromVersion}}` +{{/Updates}} +{{#IsFromNuget}} + +[{{Name}} {{Version}} on NuGet.org]({{Url}}) +{{/IsFromNuget}} +{{/packages}} +{{#multipleChanges}} +
+ +{{/multipleChanges}} +{{#footer}} +{{WarningMessage}} +**NuKeeper**: {{NuKeeperUrl}} +{{/footer}} +"; + + public string CustomTemplate { get; set; } + + public override string Value => CustomTemplate ?? DefaultTemplate; + } +} diff --git a/NuKeeper.Abstractions/CollaborationModels/DefaultPullRequestTitleTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/DefaultPullRequestTitleTemplate.cs new file mode 100644 index 000000000..be11845be --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/DefaultPullRequestTitleTemplate.cs @@ -0,0 +1,17 @@ +using NuKeeper.Abstractions.CollaborationPlatform; + +namespace NuKeeper.Abstractions.CollaborationModels +{ + public class DefaultPullRequestTitleTemplate : UpdateMessageTemplate + { + public DefaultPullRequestTitleTemplate() + : base(new StubbleTemplateRenderer()) { } + + public static string DefaultTemplate { get; } = + "Automatic update of {{^multipleChanges}}{{#packages}}{{Name}} to {{Version}}{{/packages}}{{/multipleChanges}}{{#multipleChanges}}{{packageCount}} packages{{/multipleChanges}}"; + + public string CustomTemplate { get; set; } + + public override string Value => CustomTemplate ?? DefaultTemplate; + } +} diff --git a/NuKeeper.Abstractions/CollaborationModels/FooterTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/FooterTemplate.cs new file mode 100644 index 000000000..07d774c41 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/FooterTemplate.cs @@ -0,0 +1,10 @@ +namespace NuKeeper.Abstractions.CollaborationModels +{ + public class FooterTemplate + { +#pragma warning disable CA1056 // Uri properties should not be strings + public string NuKeeperUrl { get; set; } +#pragma warning restore CA1056 // Uri properties should not be strings + public string WarningMessage { get; set; } + } +} diff --git a/NuKeeper.Abstractions/CollaborationModels/LatestPackageTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/LatestPackageTemplate.cs new file mode 100644 index 000000000..930fc0eca --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/LatestPackageTemplate.cs @@ -0,0 +1,11 @@ +namespace NuKeeper.Abstractions.CollaborationModels +{ + public class LatestPackageTemplate + { + public string Version { get; set; } +#pragma warning disable CA1056 // Uri properties should not be strings + public string Url { get; set; } +#pragma warning restore CA1056 // Uri properties should not be strings + public PublicationTemplate Publication { get; set; } + } +} diff --git a/NuKeeper.Abstractions/CollaborationModels/PackageTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/PackageTemplate.cs new file mode 100644 index 000000000..282881877 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/PackageTemplate.cs @@ -0,0 +1,32 @@ +#pragma warning disable CA1819 // Properties should not return arrays +#pragma warning disable CA1056 // Uri properties should not be strings + +using System; +using System.Linq; + +namespace NuKeeper.Abstractions.CollaborationModels +{ + public class PackageTemplate + { + public string Name { get; set; } + public string Version { get; set; } + public string FromVersion => MultipleUpdates ? + string.Empty + : Updates.FirstOrDefault()?.FromVersion; + public string AllowedChange { get; set; } + public string ActualChange { get; set; } + public PublicationTemplate Publication { get; set; } + public int ProjectsUpdated => + Updates?.Length ?? 0; + public LatestPackageTemplate LatestVersion { get; set; } + public string Url { get; set; } + public string SourceUrl { get; set; } + public bool IsFromNuget { get; set; } + public UpdateTemplate[] Updates { get; set; } + public bool MultipleProjectsUpdated => Updates?.Length > 1; + public bool MultipleUpdates => Updates? + .Select(u => u.FromVersion) + .Distinct(StringComparer.InvariantCultureIgnoreCase) + .Count() > 1; + } +} diff --git a/NuKeeper.Abstractions/CollaborationModels/PublicationTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/PublicationTemplate.cs new file mode 100644 index 000000000..a439977a8 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/PublicationTemplate.cs @@ -0,0 +1,8 @@ +namespace NuKeeper.Abstractions.CollaborationModels +{ + public class PublicationTemplate + { + public string Date { get; set; } + public string Ago { get; set; } + } +} diff --git a/NuKeeper.Abstractions/CollaborationModels/UpdateMessageTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/UpdateMessageTemplate.cs new file mode 100644 index 000000000..5af95b73d --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/UpdateMessageTemplate.cs @@ -0,0 +1,157 @@ +using NuKeeper.Abstractions.CollaborationPlatform; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NuKeeper.Abstractions.CollaborationModels +{ + public abstract class UpdateMessageTemplate + { + private IDictionary _persistedContext { get; } = new Dictionary(); + + protected ITemplateRenderer Renderer { get; } + + protected UpdateMessageTemplate(ITemplateRenderer renderer) + { + Renderer = renderer; + InitializeContext(); + } + + /// + /// Container for all placeholder replacement values, which will be passed to the as the view. + /// + protected IDictionary Context { get; } = new Dictionary(); + + /// + /// The template proper containing placeholders. + /// + public abstract string Value { get; } + + public IList Packages + { + get + { + Context.TryGetValue(Constants.Template.Packages, out var packages); + return packages as IList; + } + } + + public FooterTemplate Footer + { + get + { + Context.TryGetValue(Constants.Template.Footer, out var footer); + return footer as FooterTemplate; + } + set + { + Context[Constants.Template.Footer] = value; + } + } + + public bool MultipleChanges => Packages?.Count > 1; + public int PackageCount => Packages?.Count ?? 0; + public int ProjectsUpdated => Packages? + .SelectMany(p => p.Updates) + .Select(u => u.SourceFilePath) + .Distinct(StringComparer.InvariantCultureIgnoreCase) + .Count() ?? 0; + + public bool MultipleProjects => ProjectsUpdated > 1; + + /// + /// Clear all current values for placeholders in the template. + /// + public virtual void Clear() + { + Context.Clear(); + InitializeContext(); + } + + /// + /// Add a new value for a placeholder to the template. This is only useful in case you define your own custom template. + /// + /// + /// + /// + /// Persist the value after + public void AddPlaceholderValue(string key, T value, bool persist = false) + { + Context[key] = value; + if (persist) _persistedContext[key] = value; + } + + /// + /// Get the value from a placeholder in the template. + /// + /// + /// + /// + public T GetPlaceholderValue(string key) + { + if (Context.TryGetValue(key, out var value)) + return (T)value; + else + return default; + } + + /// + /// Output the template with all its placeholders replaced by the current values. + /// + /// + public virtual string Output() + { + var packages = Packages + .Select( + (p, i) => new + { + p.ActualChange, + p.AllowedChange, + p.LatestVersion, + p.Name, + p.ProjectsUpdated, + p.Publication, + Updates = p.Updates.Select( + (u, j) => new + { + u.SourceFilePath, + u.FromVersion, + u.FromUrl, + u.ToVersion, + Last = j == p.Updates.Length + } + ).ToArray(), + p.SourceUrl, + p.Url, + p.IsFromNuget, + p.Version, + p.FromVersion, + p.MultipleProjectsUpdated, + p.MultipleUpdates, + Last = i == Packages.Count + } + ) + .ToArray(); + + var context = new Dictionary(Context) + { + [Constants.Template.MultipleChanges] = MultipleChanges, + [Constants.Template.PackageCount] = PackageCount, + [Constants.Template.Packages] = packages, + [Constants.Template.ProjectsUpdated] = ProjectsUpdated, + [Constants.Template.MultipleProjects] = MultipleProjects + }; + + return Renderer.Render(Value, context); + } + + private void InitializeContext() + { + Context[Constants.Template.Packages] = new List(); + foreach (var kvp in _persistedContext) + { + Context[kvp.Key] = kvp.Value; + } + } + } +} diff --git a/NuKeeper.Abstractions/CollaborationModels/UpdateTemplate.cs b/NuKeeper.Abstractions/CollaborationModels/UpdateTemplate.cs new file mode 100644 index 000000000..e3d04d5e7 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationModels/UpdateTemplate.cs @@ -0,0 +1,12 @@ +namespace NuKeeper.Abstractions.CollaborationModels +{ + public class UpdateTemplate + { + public string SourceFilePath { get; set; } + public string FromVersion { get; set; } +#pragma warning disable CA1056 // Uri properties should not be strings + public string FromUrl { get; set; } +#pragma warning restore CA1056 // Uri properties should not be strings + public string ToVersion { get; set; } + } +} diff --git a/NuKeeper.Abstractions/CollaborationPlatform/ICollaborationFactory.cs b/NuKeeper.Abstractions/CollaborationPlatform/ICollaborationFactory.cs index 582d554a7..ff95ff8e0 100644 --- a/NuKeeper.Abstractions/CollaborationPlatform/ICollaborationFactory.cs +++ b/NuKeeper.Abstractions/CollaborationPlatform/ICollaborationFactory.cs @@ -1,13 +1,22 @@ +using NuKeeper.Abstractions.Configuration; using System; +using System.Collections.Generic; using System.Threading.Tasks; -using NuKeeper.Abstractions.Configuration; namespace NuKeeper.Abstractions.CollaborationPlatform { public interface ICollaborationFactory { - Task Initialise(Uri apiUri, string token, - ForkMode? forkModeFromSettings, Platform? platformFromSettings); + Task Initialise( + Uri apiUri, + string token, + ForkMode? forkModeFromSettings, + Platform? platformFromSettings, + string commitTemplate = null, + string pullrequestTitleTemplate = null, + string pullrequestBodyTemplate = null, + IDictionary templateContext = null + ); ICommitWorder CommitWorder { get; } CollaborationPlatformSettings Settings { get; } diff --git a/NuKeeper.Abstractions/CollaborationPlatform/ICommitWorder.cs b/NuKeeper.Abstractions/CollaborationPlatform/ICommitWorder.cs index ac4c9ec85..6e4709a99 100644 --- a/NuKeeper.Abstractions/CollaborationPlatform/ICommitWorder.cs +++ b/NuKeeper.Abstractions/CollaborationPlatform/ICommitWorder.cs @@ -6,9 +6,7 @@ namespace NuKeeper.Abstractions.CollaborationPlatform public interface ICommitWorder { string MakePullRequestTitle(IReadOnlyCollection updates); - string MakeCommitMessage(PackageUpdateSet updates); - string MakeCommitDetails(IReadOnlyCollection updates); } } diff --git a/NuKeeper.Abstractions/CollaborationPlatform/IEnrichContext`2.cs b/NuKeeper.Abstractions/CollaborationPlatform/IEnrichContext`2.cs new file mode 100644 index 000000000..d13e99710 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationPlatform/IEnrichContext`2.cs @@ -0,0 +1,11 @@ +#pragma warning disable CA1716 // Identifiers should not match keywords +using NuKeeper.Abstractions.CollaborationModels; + +namespace NuKeeper.Abstractions.CollaborationPlatform +{ + public interface IEnrichContext + where TTemplate : UpdateMessageTemplate + { + void Enrich(TSource source, TTemplate template); + } +} diff --git a/NuKeeper.Abstractions/CollaborationPlatform/ITemplateRenderer.cs b/NuKeeper.Abstractions/CollaborationPlatform/ITemplateRenderer.cs new file mode 100644 index 000000000..aac228706 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationPlatform/ITemplateRenderer.cs @@ -0,0 +1,9 @@ +namespace NuKeeper.Abstractions.CollaborationPlatform +{ + public interface ITemplateRenderer + { +#pragma warning disable CA1716 // Identifiers should not match keywords + string Render(string template, object view); +#pragma warning restore CA1716 // Identifiers should not match keywords + } +} diff --git a/NuKeeper.Abstractions/CollaborationPlatform/ITemplateValidator.cs b/NuKeeper.Abstractions/CollaborationPlatform/ITemplateValidator.cs new file mode 100644 index 000000000..e9c95fe33 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationPlatform/ITemplateValidator.cs @@ -0,0 +1,12 @@ +using NuKeeper.Abstractions.Configuration; +using System.Threading.Tasks; + +namespace NuKeeper.Abstractions.CollaborationPlatform +{ + public interface ITemplateValidator + { +#pragma warning disable CA1716 // Identifiers should not match keywords + Task ValidateAsync(string template); +#pragma warning restore CA1716 // Identifiers should not match keywords + } +} diff --git a/NuKeeper.Abstractions/CollaborationPlatform/PackageUpdateSetEnricher.cs b/NuKeeper.Abstractions/CollaborationPlatform/PackageUpdateSetEnricher.cs new file mode 100644 index 000000000..52f4f140d --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationPlatform/PackageUpdateSetEnricher.cs @@ -0,0 +1,99 @@ +using NuGet.Packaging.Core; +using NuKeeper.Abstractions.CollaborationModels; +using NuKeeper.Abstractions.Formats; +using NuKeeper.Abstractions.NuGetApi; +using NuKeeper.Abstractions.RepositoryInspection; +using System; +using System.Linq; + +namespace NuKeeper.Abstractions.CollaborationPlatform +{ + public class PackageUpdateSetEnricher : IEnrichContext + { + public void Enrich(PackageUpdateSet source, UpdateMessageTemplate template) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (template == null) throw new ArgumentNullException(nameof(template)); + + var package = new PackageTemplate + { + Name = source.SelectedId, + Version = source.SelectedVersion.ToNormalizedString(), + AllowedChange = source.AllowedChange.ToString(), + ActualChange = source.ActualChange.ToString(), + Publication = GetPublicationTemplate(source.Selected), + Url = source.Selected.Url?.ToString(), + SourceUrl = source.Selected.Source.SourceUri?.ToString(), + IsFromNuget = SourceIsPublicNuget(source.Selected.Source.SourceUri), + Updates = GetUpdates(source) + }; + + if (source.HigherVersionAvailable) + { + package.LatestVersion = new LatestPackageTemplate + { + Version = source.HighestVersion?.ToNormalizedString(), + Publication = GetPublicationTemplate(source.Highest), + Url = source.Highest?.Url?.ToString() + }; + } + + template.Packages.Add(package); + + template.Footer = new FooterTemplate + { + WarningMessage = "This is an automated update. Merge only if it passes tests", + NuKeeperUrl = "https://github.com/NuKeeperDotNet/NuKeeper" + }; + } + + private static PublicationTemplate GetPublicationTemplate( + PackageSearchMetadata metadata + ) + { + if (metadata == null) return null; + if (metadata.Published == null) return null; + + return new PublicationTemplate + { + Date = DateFormat.AsUtcIso8601(metadata.Published), + Ago = TimeSpanFormat.Ago( + metadata.Published.Value.UtcDateTime, + DateTime.UtcNow + ) + }; + } + + private static UpdateTemplate[] GetUpdates( + PackageUpdateSet source + ) + { + return source.CurrentPackages + .Select(p => + { + return new UpdateTemplate + { + SourceFilePath = p.Path.RelativePath, + ToVersion = source.SelectedVersion.ToNormalizedString(), + FromVersion = p.Version.ToNormalizedString(), + FromUrl = SourceIsPublicNuget(source.Selected.Source.SourceUri) ? + NuGetVersionPackageLink(p.Identity) + : "" + }; + } + ).ToArray(); + } + + private static bool SourceIsPublicNuget(Uri sourceUrl) + { + return + sourceUrl != null && + sourceUrl.ToString().StartsWith("https://api.nuget.org/", StringComparison.OrdinalIgnoreCase); + } + + private static string NuGetVersionPackageLink(PackageIdentity package) + { + return $"https://www.nuget.org/packages/{package.Id}/{package.Version}"; + } + } +} diff --git a/NuKeeper.Abstractions/CollaborationPlatform/PackageUpdateSetsEnricher.cs b/NuKeeper.Abstractions/CollaborationPlatform/PackageUpdateSetsEnricher.cs new file mode 100644 index 000000000..d18b38c13 --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationPlatform/PackageUpdateSetsEnricher.cs @@ -0,0 +1,30 @@ +using NuKeeper.Abstractions.CollaborationModels; +using NuKeeper.Abstractions.RepositoryInspection; +using System.Collections.Generic; +using System; + +namespace NuKeeper.Abstractions.CollaborationPlatform +{ + public class PackageUpdateSetsEnricher + : IEnrichContext, UpdateMessageTemplate> + { + private readonly IEnrichContext _enricher; + + public PackageUpdateSetsEnricher(IEnrichContext enricher) + { + _enricher = enricher; + } + + public void Enrich(IReadOnlyCollection source, UpdateMessageTemplate template) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (source.Count == 0) throw new ArgumentException("Update set must contain at least one update.", nameof(source)); + if (template == null) throw new ArgumentNullException(nameof(template)); + + foreach (var update in source) + { + _enricher.Enrich(update, template); + } + } + } +} diff --git a/NuKeeper.Abstractions/CollaborationPlatform/StubbleTemplateRenderer.cs b/NuKeeper.Abstractions/CollaborationPlatform/StubbleTemplateRenderer.cs new file mode 100644 index 000000000..a5cd85fdc --- /dev/null +++ b/NuKeeper.Abstractions/CollaborationPlatform/StubbleTemplateRenderer.cs @@ -0,0 +1,26 @@ +using Stubble.Core; +using Stubble.Core.Builders; +using Stubble.Core.Settings; + +namespace NuKeeper.Abstractions.CollaborationPlatform +{ + public class StubbleTemplateRenderer : ITemplateRenderer + { + private readonly StubbleVisitorRenderer _renderer = new StubbleBuilder() + .Build(); + + private readonly RenderSettings _settings = new RenderSettings + { + SkipHtmlEncoding = true + }; + + public string Render(string template, object view) + { + return _renderer.Render( + template, + view, + _settings + ); + } + } +} diff --git a/NuKeeper.Abstractions/Configuration/FileSettings.cs b/NuKeeper.Abstractions/Configuration/FileSettings.cs index d750f3ae9..eb94b833d 100644 --- a/NuKeeper.Abstractions/Configuration/FileSettings.cs +++ b/NuKeeper.Abstractions/Configuration/FileSettings.cs @@ -15,33 +15,26 @@ public class FileSettings public string Exclude { get; set; } public LogLevel? Verbosity { get; set; } public VersionChange? Change { get; set; } - public UsePrerelease? UsePrerelease { get; set; } - public ForkMode? ForkMode { get; set; } - public string IncludeRepos { get; set; } public string ExcludeRepos { get; set; } - public List Label { get; set; } - public string LogFile { get; set; } - public int? MaxPackageUpdates { get; set; } public int? MaxRepo { get; set; } - public bool? Consolidate { get; set; } - public OutputFormat? OutputFormat { get; set; } public OutputDestination? OutputDestination { get; set; } public string OutputFileName { get; set; } - public LogDestination? LogDestination { get; set; } public Platform? Platform { get; set; } - public string BranchNameTemplate { get; set; } + public string CommitMessageTemplate { get; set; } + public string PullRequestTitleTemplate { get; set; } + public string PullRequestBodyTemplate { get; set; } + public IDictionary Context { get; set; } public bool? DeleteBranchAfterMerge { get; set; } - public string GitCliPath { get; set; } public int? MaxOpenPullRequests { get; set; } diff --git a/NuKeeper.Abstractions/Configuration/IProvideConfiguration.cs b/NuKeeper.Abstractions/Configuration/IProvideConfiguration.cs new file mode 100644 index 000000000..8ea1ea5ef --- /dev/null +++ b/NuKeeper.Abstractions/Configuration/IProvideConfiguration.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace NuKeeper.Abstractions.Configuration +{ + public interface IProvideConfiguration + { + Task ProvideAsync(SettingsContainer settings); + } +} diff --git a/NuKeeper.Abstractions/Configuration/UserSettings.cs b/NuKeeper.Abstractions/Configuration/UserSettings.cs index 04b411bb0..f6f99d845 100644 --- a/NuKeeper.Abstractions/Configuration/UserSettings.cs +++ b/NuKeeper.Abstractions/Configuration/UserSettings.cs @@ -1,28 +1,25 @@ using NuKeeper.Abstractions.NuGet; using NuKeeper.Abstractions.Output; +using System.Collections.Generic; namespace NuKeeper.Abstractions.Configuration { public class UserSettings { public NuGetSources NuGetSources { get; set; } - public int MaxRepositoriesChanged { get; set; } public int MaxOpenPullRequests { get; set; } - public bool ConsolidateUpdatesInSinglePullRequest { get; set; } - public VersionChange AllowedChange { get; set; } - public UsePrerelease UsePrerelease { get; set; } - - public OutputFormat OutputFormat { get; set; } public OutputDestination OutputDestination { get; set; } public string OutputFileName { get; set; } - public string Directory { get; set; } - public string GitPath { get; set; } + public string CommitMessageTemplate { get; set; } + public string PullRequestTitleTemplate { get; set; } + public string PullRequestBodyTemplate { get; set; } + public IDictionary Context { get; } = new Dictionary(); } } diff --git a/NuKeeper.Abstractions/Constants.cs b/NuKeeper.Abstractions/Constants.cs new file mode 100644 index 000000000..68c3529a8 --- /dev/null +++ b/NuKeeper.Abstractions/Constants.cs @@ -0,0 +1,27 @@ +#pragma warning disable CA1034 // Nested types should not be visible + +namespace NuKeeper.Abstractions +{ + public static class Constants + { + public static class Template + { + public const string Packages = "packages"; + public const string PackageName = "packageName"; + public const string PackageVersion = "packageVersion"; + public const string PackageCount = "packageCount"; + public const string PackageEmoji = "packageEmoji"; + public const string PackageChangeLevel = "packageChangeLevel"; + public const string PackageOldCount = "packageOldCount"; + public const string PackageOldVersion = "packageOldVersion"; + public const string PackageOldVersions = "packageOldVersions"; + public const string PackagePublication = "packagePublication"; + public const string PackageHighestVersion = "packageHighestVersion"; + public const string Footer = "footer"; + public const string PackageUrl = "packageUrl"; + public const string MultipleChanges = "multipleChanges"; + public const string ProjectsUpdated = "projectsUpdated"; + public const string MultipleProjects = "multipleProjects"; + } + } +} diff --git a/NuKeeper.Abstractions/NuGetApi/PackageLookupResult.cs b/NuKeeper.Abstractions/NuGetApi/PackageLookupResult.cs index 4b2ebb4a2..bac6d61ee 100644 --- a/NuKeeper.Abstractions/NuGetApi/PackageLookupResult.cs +++ b/NuKeeper.Abstractions/NuGetApi/PackageLookupResult.cs @@ -18,7 +18,6 @@ public PackageLookupResult( } public VersionChange AllowedChange { get; } - public PackageSearchMetadata Major { get; } public PackageSearchMetadata Minor { get; } public PackageSearchMetadata Patch { get; } diff --git a/NuKeeper.Abstractions/NuGetApi/PackageSearchMetadata.cs b/NuKeeper.Abstractions/NuGetApi/PackageSearchMetadata.cs index 82f00411b..47644e200 100644 --- a/NuKeeper.Abstractions/NuGetApi/PackageSearchMetadata.cs +++ b/NuKeeper.Abstractions/NuGetApi/PackageSearchMetadata.cs @@ -1,9 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Linq; using NuGet.Configuration; using NuGet.Packaging.Core; using NuKeeper.Abstractions.Formats; +using System; +using System.Collections.Generic; +using System.Linq; namespace NuKeeper.Abstractions.NuGetApi { @@ -24,6 +24,8 @@ public PackageSearchMetadata( public PackageIdentity Identity { get; } public PackageSource Source { get; } public DateTimeOffset? Published { get; } + // TODO: how to get url for package hosted on private feeds or public feeds different from nuget? + public Uri Url => GetPackageUrl(); public IReadOnlyCollection Dependencies { get; } @@ -36,5 +38,19 @@ public override string ToString() return $"{Identity} from {Source}, no published date"; } + + private static bool IsNugetUrl(Uri sourceUrl) + { + return + sourceUrl != null && + sourceUrl.ToString().StartsWith("https://api.nuget.org/", StringComparison.OrdinalIgnoreCase); + } + + private Uri GetPackageUrl() + { + if (!IsNugetUrl(Source.SourceUri)) return null; + + return new Uri($"https://www.nuget.org/packages/{Identity.Id}/{Identity.Version}"); + } } } diff --git a/NuKeeper.Abstractions/NuKeeper.Abstractions.csproj b/NuKeeper.Abstractions/NuKeeper.Abstractions.csproj index cb0504778..98d9fda0f 100644 --- a/NuKeeper.Abstractions/NuKeeper.Abstractions.csproj +++ b/NuKeeper.Abstractions/NuKeeper.Abstractions.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -12,6 +12,7 @@ + diff --git a/NuKeeper.Abstractions/RepositoryInspection/PackageUpdateSet.cs b/NuKeeper.Abstractions/RepositoryInspection/PackageUpdateSet.cs index 6e6514209..528cc7ce3 100644 --- a/NuKeeper.Abstractions/RepositoryInspection/PackageUpdateSet.cs +++ b/NuKeeper.Abstractions/RepositoryInspection/PackageUpdateSet.cs @@ -10,6 +10,8 @@ namespace NuKeeper.Abstractions.RepositoryInspection { public class PackageUpdateSet { + //TODO: should verify all currentPackages have a version lower than the Selected() + // otherwise, it isn't an update... public PackageUpdateSet(PackageLookupResult packages, IEnumerable currentPackages) { if (packages == null) @@ -44,10 +46,16 @@ public PackageUpdateSet(PackageLookupResult packages, IEnumerable CurrentPackages { get; } public VersionChange AllowedChange => Packages.AllowedChange; + public VersionChange ActualChange => GetActualChange(); + public NuGetVersion HighestVersion => GetHighestVersion(); + public PackageSearchMetadata Selected => Packages.Selected(); + public PackageSearchMetadata Highest => GetHighest(); public string SelectedId => Selected.Identity.Id; public NuGetVersion SelectedVersion => Selected.Identity.Version; + public bool HigherVersionAvailable => + VersionComparer.Compare(HighestVersion, SelectedVersion, VersionComparison.Version) > 0; public int CountCurrentVersions() { @@ -70,6 +78,33 @@ private void CheckIdConsistency() } } + private VersionChange GetActualChange() + { + var newVersion = SelectedVersion; + var minVersion = CurrentPackages + .Select(p => p.Version) + .Min(); + + if (newVersion.Major > minVersion.Major) + return VersionChange.Major; + else if (newVersion.Minor > minVersion.Minor) + return VersionChange.Minor; + else if (newVersion.Patch > minVersion.Patch) + return VersionChange.Patch; + else + return VersionChange.None; + } + + private PackageSearchMetadata GetHighest() + { + return Packages.Major ?? Packages.Minor ?? Packages.Patch; + } + + private NuGetVersion GetHighestVersion() + { + return GetHighest().Identity.Version; + } + public override string ToString() { return $"{SelectedId} to {SelectedVersion} in {CurrentPackages.Count} places"; diff --git a/NuKeeper.AzureDevOps/AzureDevOpsCommitWorder.cs b/NuKeeper.AzureDevOps/AzureDevOpsCommitWorder.cs deleted file mode 100644 index 502b08ad5..000000000 --- a/NuKeeper.AzureDevOps/AzureDevOpsCommitWorder.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using NuGet.Packaging.Core; -using NuGet.Versioning; -using NuKeeper.Abstractions.CollaborationPlatform; -using NuKeeper.Abstractions.Formats; -using NuKeeper.Abstractions.RepositoryInspection; - -namespace NuKeeper.AzureDevOps -{ - public class AzureDevOpsCommitWorder : ICommitWorder - { - private const string CommitEmoji = "📦"; - - // Azure DevOps allows a maximum of 4000 characters to be used in a pull request description: - // https://visualstudio.uservoice.com/forums/330519-azure-devops-formerly-visual-studio-team-services/suggestions/20217283-raise-the-character-limit-for-pull-request-descrip - private const int MaxCharacterCount = 4000; - - public string MakePullRequestTitle(IReadOnlyCollection updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - if (updates.Count == 1) - { - return PackageTitle(updates.First()); - } - - return $"{CommitEmoji} Automatic update of {updates.Count} packages"; - } - - private static string PackageTitle(PackageUpdateSet updates) - { - return $"{CommitEmoji} Automatic update of {updates.SelectedId} to {updates.SelectedVersion}"; - } - - public string MakeCommitMessage(PackageUpdateSet updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - return $"{PackageTitle(updates)}"; - } - - public string MakeCommitDetails(IReadOnlyCollection updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - var builder = new StringBuilder(); - - if (updates.Count > 1) - { - MultiPackage(updates, builder); - } - - foreach (var update in updates) - { - builder.AppendLine(MakeCommitVersionDetails(update)); - } - - AddCommitFooter(builder); - - if (builder.Length > MaxCharacterCount) - { - // Strip end of commit details since Azure DevOps can't handle a bigger pull request description. - return $"{builder.ToString(0, MaxCharacterCount - 3)}..."; - } - - return builder.ToString(); - } - - private static void MultiPackage(IReadOnlyCollection updates, StringBuilder builder) - { - var packageNames = updates - .Select(p => p.SelectedId); - - var projects = updates.SelectMany( - u => u.CurrentPackages) - .Select(p => p.Path.FullName) - .Distinct() - .ToList(); - - var projectOptS = (projects.Count > 1) ? "s" : string.Empty; - - builder.AppendLine($"{updates.Count} packages were updated in {projects.Count} project{projectOptS}:"); - string updatedPackageNames = "|"; - foreach (var packageName in packageNames) - { - updatedPackageNames += $" {packageName} |"; - } - - builder.AppendLine(updatedPackageNames); - builder.AppendLine(""); - builder.AppendLine("## Details of updated packages"); - builder.AppendLine(""); - } - - private static string MakeCommitVersionDetails(PackageUpdateSet updates) - { - var versionsInUse = updates.CurrentPackages - .Select(u => u.Version) - .Distinct() - .ToList(); - - var oldVersions = versionsInUse - .Select(v => CodeQuote(v.ToString())) - .ToList(); - - var minOldVersion = versionsInUse.Min(); - - var newVersion = CodeQuote(updates.SelectedVersion.ToString()); - var packageId = CodeQuote(updates.SelectedId); - - var changeLevel = ChangeLevel(minOldVersion, updates.SelectedVersion); - - var builder = new StringBuilder(); - - if (oldVersions.Count == 1) - { - builder.AppendLine($"NuKeeper has generated a {changeLevel} update of {packageId} to {newVersion} from {oldVersions.JoinWithCommas()}"); - } - else - { - builder.AppendLine($"NuKeeper has generated a {changeLevel} update of {packageId} to {newVersion}"); - builder.AppendLine($"{oldVersions.Count} versions of {packageId} were found in use: {oldVersions.JoinWithCommas()}"); - } - - if (updates.Selected.Published.HasValue) - { - var packageWithVersion = CodeQuote(updates.SelectedId + " " + updates.SelectedVersion); - var pubDateString = CodeQuote(DateFormat.AsUtcIso8601(updates.Selected.Published)); - var pubDate = updates.Selected.Published.Value.UtcDateTime; - var ago = TimeSpanFormat.Ago(pubDate, DateTime.UtcNow); - - builder.AppendLine($"{packageWithVersion} was published at {pubDateString}, {ago}"); - } - - var highestVersion = updates.Packages.Major?.Identity.Version; - if (highestVersion != null && (highestVersion > updates.SelectedVersion)) - { - LogHighestVersion(updates, highestVersion, builder); - } - - builder.AppendLine(); - - var updateOptS = (updates.CurrentPackages.Count > 1) ? "s" : string.Empty; - builder.AppendLine($"### {updates.CurrentPackages.Count} project update{updateOptS}:"); - - builder.AppendLine("| Project | Package | From | To |"); - builder.AppendLine("|:----------|:----------|-------:|-----:|"); - - foreach (var current in updates.CurrentPackages) - { - string line; - if (SourceIsPublicNuget(updates.Selected.Source.SourceUri)) - { - line = $"| {CodeQuote(current.Path.RelativePath)} | {CodeQuote(updates.SelectedId)} | {NuGetVersionPackageLink(current.Identity)} | {NuGetVersionPackageLink(updates.Selected.Identity)} |"; - builder.AppendLine(line); - - continue; - } - - line = $"| {CodeQuote(current.Path.RelativePath)} | {CodeQuote(updates.SelectedId)} | {current.Version.ToString()} | {updates.SelectedVersion.ToString()} |"; - builder.AppendLine(line); - } - - return builder.ToString(); - } - - private static void AddCommitFooter(StringBuilder builder) - { - builder.AppendLine("This is an automated update. Merge only if it passes tests"); - builder.AppendLine("**NuKeeper**: https://github.com/NuKeeperDotNet/NuKeeper"); - } - - private static string ChangeLevel(NuGetVersion oldVersion, NuGetVersion newVersion) - { - if (newVersion.Major > oldVersion.Major) - { - return "major"; - } - - if (newVersion.Minor > oldVersion.Minor) - { - return "minor"; - } - - if (newVersion.Patch > oldVersion.Patch) - { - return "patch"; - } - - if (!newVersion.IsPrerelease && oldVersion.IsPrerelease) - { - return "out of beta"; - } - - return string.Empty; - } - - private static void LogHighestVersion(PackageUpdateSet updates, NuGetVersion highestVersion, StringBuilder builder) - { - var allowedChange = CodeQuote(updates.AllowedChange.ToString()); - var highest = CodeQuote(updates.SelectedId + " " + highestVersion); - var highestPublishedAt = HighestPublishedAt(updates.Packages.Major.Published); - - builder.AppendLine( - $"There is also a higher version, {highest}{highestPublishedAt}, " + - $"but this was not applied as only {allowedChange} version changes are allowed."); - } - - private static string HighestPublishedAt(DateTimeOffset? highestPublishedAt) - { - if (!highestPublishedAt.HasValue) - { - return string.Empty; - } - - var highestPubDate = highestPublishedAt.Value; - var formattedPubDate = CodeQuote(DateFormat.AsUtcIso8601(highestPubDate)); - var highestAgo = TimeSpanFormat.Ago(highestPubDate.UtcDateTime, DateTime.UtcNow); - - return $" published at {formattedPubDate}, {highestAgo}"; - } - - private static string CodeQuote(string value) - { - return "`" + value + "`"; - } - - private static bool SourceIsPublicNuget(Uri sourceUrl) - { - return - sourceUrl != null && - sourceUrl.ToString().StartsWith("https://api.nuget.org/", StringComparison.OrdinalIgnoreCase); - } - - private static string NuGetVersionPackageLink(PackageIdentity package) - { - var url = $"https://www.nuget.org/packages/{package.Id}/{package.Version}"; - return $"[{package.Version}]({url})"; - } - } -} diff --git a/NuKeeper.AzureDevOps/AzureDevOpsPullRequestBodyTemplate.cs b/NuKeeper.AzureDevOps/AzureDevOpsPullRequestBodyTemplate.cs new file mode 100644 index 000000000..ab6683f3c --- /dev/null +++ b/NuKeeper.AzureDevOps/AzureDevOpsPullRequestBodyTemplate.cs @@ -0,0 +1,62 @@ +using NuKeeper.Abstractions.CollaborationModels; +using NuKeeper.Abstractions.CollaborationPlatform; + +namespace NuKeeper.AzureDevOps +{ + public class AzureDevOpsPullRequestBodyTemplate : UpdateMessageTemplate + { + // Azure DevOps allows a maximum of 4000 characters to be used in a pull request description: + // https://visualstudio.uservoice.com/forums/330519-azure-devops-formerly-visual-studio-team-services/suggestions/20217283-raise-the-character-limit-for-pull-request-descrip + private const int MaxCharacterCount = 4000; + + public AzureDevOpsPullRequestBodyTemplate() + : base(new StubbleTemplateRenderer()) { } + + public static string DefaultTemplate { get; } = +@"{{#multipleChanges}}{{packageCount}} packages were updated in {{projectsUpdated}} project{{#multipleProjects}}s{{/multipleProjects}}: +{{#packages}}| {{Name}} {{/packages}}| + +## Details of updated packages + +{{/multipleChanges}} +{{#packages}}NuKeeper has generated a {{ActualChange}} update of `{{Name}}` to `{{Version}}`{{^MultipleUpdates}} from `{{FromVersion}}`{{/MultipleUpdates}} +{{#MultipleUpdates}}{{ProjectsUpdated}} versions of `{{Name}}` were found in use: {{#Updates}}`{{FromVersion}}`{{^Last}}, {{/Last}}{{/Updates}}{{/MultipleUpdates}} +{{#Publication}}`{{Name}} {{Version}}` was published at `{{Date}}`, {{Ago}}{{/Publication}} +{{#LatestVersion}}There is also a higher version, `{{Name}} {{Version}}`{{#Publication}} published at `{{Date}}`, {{Ago}}{{/Publication}}, but this was not applied as only `{{AllowedChange}}` version changes are allowed. +{{/LatestVersion}} +### {{ProjectsUpdated}} project update{{#MultipleProjectsUpdated}}s{{/MultipleProjectsUpdated}}: +| Project | Package | From | To | +|:----------|:----------|-------:|-----:| +{{#Updates}} +| `{{SourceFilePath}}` | `{{Name}}` | {{#IsFromNuget}}[{{FromVersion}}]({{FromUrl}}) | [{{ToVersion}}]({{Url}}) |{{/IsFromNuget}}{{^IsFromNuget}}{{FromVersion}} | {{ToVersion}} |{{/IsFromNuget}} +{{/Updates}} +{{#IsFromNuget}} + +[{{Name}} {{Version}} on NuGet.org]({{Url}}) +{{/IsFromNuget}} +{{/packages}} + +{{#footer}} +{{WarningMessage}} +**NuKeeper**: {{NuKeeperUrl}} +{{/footer}} +"; + + public string CustomTemplate { get; set; } + + public override string Value => CustomTemplate ?? DefaultTemplate; + + public override string Output() + { + var output = base.Output(); + + if (output.Length > MaxCharacterCount) + { + //todo: improve + return output.Substring(0, MaxCharacterCount - 3) + "..."; + } + + return output; + } + } +} diff --git a/NuKeeper.AzureDevOps/AzureDevOpsPullRequestTitleTemplate.cs b/NuKeeper.AzureDevOps/AzureDevOpsPullRequestTitleTemplate.cs new file mode 100644 index 000000000..a384fecd4 --- /dev/null +++ b/NuKeeper.AzureDevOps/AzureDevOpsPullRequestTitleTemplate.cs @@ -0,0 +1,43 @@ +using NuKeeper.Abstractions; +using NuKeeper.Abstractions.CollaborationModels; +using NuKeeper.Abstractions.CollaborationPlatform; + +namespace NuKeeper.AzureDevOps +{ + public class AzureDevOpsPullRequestTitleTemplate : UpdateMessageTemplate + { + private const string CommitEmoji = "📦"; + + public AzureDevOpsPullRequestTitleTemplate() + : base(new StubbleTemplateRenderer()) + { + PackageEmoji = CommitEmoji; + } + + public static string DefaultTemplate { get; } = + "{{packageEmoji}} Automatic update of {{^multipleChanges}}{{#packages}}{{Name}} to {{Version}}{{/packages}}{{/multipleChanges}}{{#multipleChanges}}{{packageCount}} packages{{/multipleChanges}}"; + + public string CustomTemplate { get; set; } + + public override string Value => CustomTemplate ?? DefaultTemplate; + + public object PackageEmoji + { + get + { + Context.TryGetValue(Constants.Template.PackageEmoji, out var packageEmoji); + return packageEmoji; + } + set + { + Context[Constants.Template.PackageEmoji] = value; + } + } + + public override void Clear() + { + base.Clear(); + PackageEmoji = CommitEmoji; + } + } +} diff --git a/NuKeeper.AzureDevOps/NuKeeper.AzureDevOps.csproj b/NuKeeper.AzureDevOps/NuKeeper.AzureDevOps.csproj index 9fe632c31..d20633deb 100644 --- a/NuKeeper.AzureDevOps/NuKeeper.AzureDevOps.csproj +++ b/NuKeeper.AzureDevOps/NuKeeper.AzureDevOps.csproj @@ -8,6 +8,11 @@ ..\CodeAnalysisRules.ruleset + + + + + diff --git a/NuKeeper.BitBucket/BitbucketCommitWorder.cs b/NuKeeper.BitBucket/BitbucketCommitWorder.cs deleted file mode 100644 index 3463f7dd9..000000000 --- a/NuKeeper.BitBucket/BitbucketCommitWorder.cs +++ /dev/null @@ -1,239 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using NuGet.Packaging.Core; -using NuGet.Versioning; -using NuKeeper.Abstractions.CollaborationPlatform; -using NuKeeper.Abstractions.Formats; -using NuKeeper.Abstractions.RepositoryInspection; - -namespace NuKeeper.BitBucket -{ - public class BitbucketCommitWorder : ICommitWorder - { - private const string CommitEmoji = "📦"; - - public string MakePullRequestTitle(IReadOnlyCollection updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - if (updates.Count == 1) - { - return PackageTitle(updates.First()); - } - - return $"Automatic update of {updates.Count} packages"; - } - - private static string PackageTitle(PackageUpdateSet updates) - { - return $"Automatic update of {updates.SelectedId} to {updates.SelectedVersion}"; - } - - public string MakeCommitMessage(PackageUpdateSet updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - return $":{CommitEmoji}: {PackageTitle(updates)}"; - } - - public string MakeCommitDetails(IReadOnlyCollection updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - var builder = new StringBuilder(); - - if (updates.Count > 1) - { - MultiPackagePrefix(updates, builder); - } - - foreach (var update in updates) - { - builder.AppendLine(MakeCommitVersionDetails(update)); - } - - AddCommitFooter(builder); - - return builder.ToString(); - } - - private static void MultiPackagePrefix(IReadOnlyCollection updates, StringBuilder builder) - { - var packageNames = updates - .Select(p => CodeQuote(p.SelectedId)) - .JoinWithCommas(); - - var projects = updates.SelectMany( - u => u.CurrentPackages) - .Select(p => p.Path.FullName) - .Distinct() - .ToList(); - - var projectOptS = (projects.Count > 1) ? "s" : string.Empty; - - builder.AppendLine($"{updates.Count} packages were updated in {projects.Count} project{projectOptS}:"); - builder.AppendLine(packageNames); - builder.AppendLine(""); - builder.AppendLine("**Details of updated packages**"); - builder.AppendLine(""); - } - - private static string MakeCommitVersionDetails(PackageUpdateSet updates) - { - var versionsInUse = updates.CurrentPackages - .Select(u => u.Version) - .Distinct() - .ToList(); - - var oldVersions = versionsInUse - .Select(v => CodeQuote(v.ToString())) - .ToList(); - - var minOldVersion = versionsInUse.Min(); - - var newVersion = CodeQuote(updates.SelectedVersion.ToString()); - var packageId = CodeQuote(updates.SelectedId); - - var changeLevel = ChangeLevel(minOldVersion, updates.SelectedVersion); - - var builder = new StringBuilder(); - - if (oldVersions.Count == 1) - { - builder.AppendLine($"NuKeeper has generated a {changeLevel} update of {packageId} to {newVersion} from {oldVersions.JoinWithCommas()}"); - } - else - { - builder.AppendLine($"NuKeeper has generated a {changeLevel} update of {packageId} to {newVersion}"); - builder.AppendLine($"{oldVersions.Count} versions of {packageId} were found in use: {oldVersions.JoinWithCommas()}"); - } - - if (updates.Selected.Published.HasValue) - { - var packageWithVersion = CodeQuote(updates.SelectedId + " " + updates.SelectedVersion); - var pubDateString = CodeQuote(DateFormat.AsUtcIso8601(updates.Selected.Published)); - var pubDate = updates.Selected.Published.Value.UtcDateTime; - var ago = TimeSpanFormat.Ago(pubDate, DateTime.UtcNow); - - builder.AppendLine($"{packageWithVersion} was published at {pubDateString}, {ago}"); - } - - var highestVersion = updates.Packages.Major?.Identity.Version; - if (highestVersion != null && (highestVersion > updates.SelectedVersion)) - { - LogHighestVersion(updates, highestVersion, builder); - } - - builder.AppendLine(); - - if (updates.CurrentPackages.Count == 1) - { - builder.AppendLine("1 project update:"); - } - else - { - builder.AppendLine($"{updates.CurrentPackages.Count} project updates:"); - } - - foreach (var current in updates.CurrentPackages) - { - var line = $"Updated {CodeQuote(current.Path.RelativePath)} to {packageId} {CodeQuote(updates.SelectedVersion.ToString())} from {CodeQuote(current.Version.ToString())}"; - builder.AppendLine(line); - } - - if (SourceIsPublicNuget(updates.Selected.Source.SourceUri)) - { - builder.AppendLine(NugetPackageLink(updates.Selected.Identity)); - } - - return builder.ToString(); - } - - private static void AddCommitFooter(StringBuilder builder) - { - builder.AppendLine(); - builder.AppendLine("This is an automated update. Merge only if it passes tests"); - builder.AppendLine("**NuKeeper**: https://github.com/NuKeeperDotNet/NuKeeper"); - } - - private static string ChangeLevel(NuGetVersion oldVersion, NuGetVersion newVersion) - { - if (newVersion.Major > oldVersion.Major) - { - return "major"; - } - - if (newVersion.Minor > oldVersion.Minor) - { - return "minor"; - } - - if (newVersion.Patch > oldVersion.Patch) - { - return "patch"; - } - - if (!newVersion.IsPrerelease && oldVersion.IsPrerelease) - { - return "out of beta"; - } - - return string.Empty; - } - - private static void LogHighestVersion(PackageUpdateSet updates, NuGetVersion highestVersion, StringBuilder builder) - { - var allowedChange = CodeQuote(updates.AllowedChange.ToString()); - var highest = CodeQuote(updates.SelectedId + " " + highestVersion); - - var highestPublishedAt = HighestPublishedAt(updates.Packages.Major.Published); - - builder.AppendLine( - $"There is also a higher version, {highest}{highestPublishedAt}, " + - $"but this was not applied as only {allowedChange} version changes are allowed."); - } - - private static string HighestPublishedAt(DateTimeOffset? highestPublishedAt) - { - if (!highestPublishedAt.HasValue) - { - return string.Empty; - } - - var highestPubDate = highestPublishedAt.Value; - var formattedPubDate = CodeQuote(DateFormat.AsUtcIso8601(highestPubDate)); - var highestAgo = TimeSpanFormat.Ago(highestPubDate.UtcDateTime, DateTime.UtcNow); - - return $" published at {formattedPubDate}, {highestAgo}"; - } - - private static string CodeQuote(string value) - { - return "`" + value + "`"; - } - - private static bool SourceIsPublicNuget(Uri sourceUrl) - { - return - sourceUrl != null && - sourceUrl.ToString().StartsWith("https://api.nuget.org/", StringComparison.OrdinalIgnoreCase); - } - - private static string NugetPackageLink(PackageIdentity package) - { - var url = $"https://www.nuget.org/packages/{package.Id}/{package.Version}"; - return $"[{package.Id} {package.Version} on NuGet.org]({url})"; - } - } -} diff --git a/NuKeeper.BitBucket/BitbucketPlatform.cs b/NuKeeper.BitBucket/BitbucketPlatform.cs index cf223ad20..c2ff4d817 100644 --- a/NuKeeper.BitBucket/BitbucketPlatform.cs +++ b/NuKeeper.BitBucket/BitbucketPlatform.cs @@ -1,13 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; using NuKeeper.Abstractions.Logging; using NuKeeper.BitBucket.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; using Repository = NuKeeper.Abstractions.CollaborationModels.Repository; using User = NuKeeper.Abstractions.CollaborationModels.User; diff --git a/NuKeeper.BitBucket/BitbucketPullRequestBodyTemplate.cs b/NuKeeper.BitBucket/BitbucketPullRequestBodyTemplate.cs new file mode 100644 index 000000000..4b60e0e6d --- /dev/null +++ b/NuKeeper.BitBucket/BitbucketPullRequestBodyTemplate.cs @@ -0,0 +1,45 @@ +using NuKeeper.Abstractions.CollaborationModels; +using NuKeeper.Abstractions.CollaborationPlatform; + +namespace NuKeeper.BitBucket +{ + public class BitbucketPullRequestBodyTemplate : UpdateMessageTemplate + { + public BitbucketPullRequestBodyTemplate() + : base(new StubbleTemplateRenderer()) { } + + public static string DefaultTemplate { get; } = +@"{{#multipleChanges}}{{packageCount}} packages were updated in {{projectsUpdated}} project{{#multipleProjects}}s{{/multipleProjects}}: +{{#packages}}`{{Name}}`{{^Last}}, {{/Last}}{{/packages}} + +**Details of updated packages** + +{{/multipleChanges}} +{{#packages}}NuKeeper has generated a {{ActualChange}} update of `{{Name}}` to `{{Version}}`{{^MultipleUpdates}} from `{{FromVersion}}`{{/MultipleUpdates}} +{{#MultipleUpdates}}{{ProjectsUpdated}} versions of `{{Name}}` were found in use: {{#Updates}}`{{FromVersion}}`{{^Last}}, {{/Last}}{{/Updates}}{{/MultipleUpdates}} +{{#Publication}}`{{Name}} {{Version}}` was published at `{{Date}}`, {{Ago}}{{/Publication}} +{{#LatestVersion}}There is also a higher version, `{{Name}} {{Version}}`{{#Publication}} published at `{{Date}}`, {{Ago}}{{/Publication}}, but this was not applied as only `{{AllowedChange}}` version changes are allowed. +{{/LatestVersion}} +{{ProjectsUpdated}} project update{{#MultipleProjectsUpdated}}s{{/MultipleProjectsUpdated}}: +{{#Updates}} +Updated `{{SourceFilePath}}` to `{{Name}}` `{{ToVersion}}` from `{{FromVersion}}` +{{/Updates}} +{{#IsFromNuget}} + +[{{Name}} {{Version}} on NuGet.org]({{Url}}) +{{/IsFromNuget}} +{{/packages}} +{{#multipleChanges}} + +{{/multipleChanges}} +{{#footer}} +{{WarningMessage}} +**NuKeeper**: {{NuKeeperUrl}} +{{/footer}} +"; + + public string CustomTemplate { get; set; } + + public override string Value => CustomTemplate ?? DefaultTemplate; + } +} diff --git a/NuKeeper.BitBucket/NuKeeper.BitBucket.csproj b/NuKeeper.BitBucket/NuKeeper.BitBucket.csproj index 2590e31b4..c64f69886 100644 --- a/NuKeeper.BitBucket/NuKeeper.BitBucket.csproj +++ b/NuKeeper.BitBucket/NuKeeper.BitBucket.csproj @@ -11,6 +11,10 @@ ..\CodeAnalysisRules.ruleset + + + + diff --git a/NuKeeper.Gitea/GiteaSettingsReader.cs b/NuKeeper.Gitea/GiteaSettingsReader.cs index fe68346d6..322c692b7 100644 --- a/NuKeeper.Gitea/GiteaSettingsReader.cs +++ b/NuKeeper.Gitea/GiteaSettingsReader.cs @@ -1,12 +1,12 @@ +using NuKeeper.Abstractions; +using NuKeeper.Abstractions.CollaborationPlatform; +using NuKeeper.Abstractions.Configuration; +using NuKeeper.Abstractions.Git; using System; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using NuKeeper.Abstractions; -using NuKeeper.Abstractions.CollaborationPlatform; -using NuKeeper.Abstractions.Configuration; -using NuKeeper.Abstractions.Git; namespace NuKeeper.Gitea { diff --git a/NuKeeper.Gitea/NuKeeper.Gitea.csproj b/NuKeeper.Gitea/NuKeeper.Gitea.csproj index b61b3b243..6574ead79 100644 --- a/NuKeeper.Gitea/NuKeeper.Gitea.csproj +++ b/NuKeeper.Gitea/NuKeeper.Gitea.csproj @@ -12,6 +12,10 @@ 1701;1702;CA1051;CA1707;CA1724,CA2227;CA1056;CA2007;CA1031 + + + + diff --git a/NuKeeper.Gitlab/GitlabPlatform.cs b/NuKeeper.Gitlab/GitlabPlatform.cs index 891be9799..855679127 100644 --- a/NuKeeper.Gitlab/GitlabPlatform.cs +++ b/NuKeeper.Gitlab/GitlabPlatform.cs @@ -1,14 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; using NuKeeper.Abstractions.Logging; using NuKeeper.Gitlab.Model; - +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; using User = NuKeeper.Abstractions.CollaborationModels.User; namespace NuKeeper.Gitlab diff --git a/NuKeeper.Gitlab/NuKeeper.Gitlab.csproj b/NuKeeper.Gitlab/NuKeeper.Gitlab.csproj index 1ac0ba7fb..6839b27b7 100644 --- a/NuKeeper.Gitlab/NuKeeper.Gitlab.csproj +++ b/NuKeeper.Gitlab/NuKeeper.Gitlab.csproj @@ -12,6 +12,10 @@ 1701;1702;CA1051;CA1707;CA1724,CA2227;CA1056;CA2007 + + + + diff --git a/NuKeeper.Tests/Commands/GlobalCommandTests.cs b/NuKeeper.Tests/Commands/GlobalCommandTests.cs index 79f984376..8f0c08eb2 100644 --- a/NuKeeper.Tests/Commands/GlobalCommandTests.cs +++ b/NuKeeper.Tests/Commands/GlobalCommandTests.cs @@ -1,8 +1,12 @@ using NSubstitute; +using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; +using NuKeeper.Abstractions.Git; using NuKeeper.Abstractions.Logging; using NuKeeper.Abstractions.Output; +using NuKeeper.Abstractions.RepositoryInspection; +using NuKeeper.AzureDevOps; using NuKeeper.Collaboration; using NuKeeper.Commands; using NuKeeper.GitHub; @@ -12,8 +16,6 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; -using NuKeeper.Abstractions.Git; -using NuKeeper.AzureDevOps; namespace NuKeeper.Tests.Commands { @@ -30,7 +32,10 @@ private static CollaborationFactory GetCollaborationFactory(Func(), - httpClientFactory + httpClientFactory, + Substitute.For(), + Substitute.For>(), + Substitute.For, UpdateMessageTemplate>>() ); } @@ -158,6 +163,8 @@ public async Task EmptyFileResultsInDefaultSettings() Assert.That(settings.UserSettings.NuGetSources, Is.Null); Assert.That(settings.UserSettings.OutputDestination, Is.EqualTo(OutputDestination.Console)); Assert.That(settings.UserSettings.OutputFormat, Is.EqualTo(OutputFormat.Text)); + Assert.That(settings.UserSettings.CommitMessageTemplate, Is.Null); + Assert.That(settings.UserSettings.Context, Is.Empty); Assert.That(settings.BranchSettings.BranchNameTemplate, Is.Null); Assert.That(settings.BranchSettings.DeleteBranchAfterMerge, Is.EqualTo(true)); diff --git a/NuKeeper.Tests/Commands/OrganisationCommandTests.cs b/NuKeeper.Tests/Commands/OrganisationCommandTests.cs index e8ce2ed93..34a0d312d 100644 --- a/NuKeeper.Tests/Commands/OrganisationCommandTests.cs +++ b/NuKeeper.Tests/Commands/OrganisationCommandTests.cs @@ -1,8 +1,12 @@ using NSubstitute; +using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; +using NuKeeper.Abstractions.Git; using NuKeeper.Abstractions.Logging; using NuKeeper.Abstractions.Output; +using NuKeeper.Abstractions.RepositoryInspection; +using NuKeeper.AzureDevOps; using NuKeeper.Collaboration; using NuKeeper.Commands; using NuKeeper.GitHub; @@ -12,8 +16,6 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; -using NuKeeper.Abstractions.Git; -using NuKeeper.AzureDevOps; namespace NuKeeper.Tests.Commands { @@ -30,7 +32,10 @@ private static CollaborationFactory GetCollaborationFactory(Func(), - httpClientFactory + httpClientFactory, + Substitute.For(), + Substitute.For>(), + Substitute.For, UpdateMessageTemplate>>() ); } diff --git a/NuKeeper.Tests/Commands/RepositoryCommandTests.cs b/NuKeeper.Tests/Commands/RepositoryCommandTests.cs index a2e466448..c15df9b13 100644 --- a/NuKeeper.Tests/Commands/RepositoryCommandTests.cs +++ b/NuKeeper.Tests/Commands/RepositoryCommandTests.cs @@ -5,6 +5,7 @@ using NuKeeper.Abstractions.Git; using NuKeeper.Abstractions.Logging; using NuKeeper.Abstractions.Output; +using NuKeeper.Abstractions.RepositoryInspection; using NuKeeper.BitBucketLocal; using NuKeeper.Collaboration; using NuKeeper.Commands; @@ -222,7 +223,12 @@ await collaborationFactory Arg.Is(new Uri("https://api.github.com")), Arg.Is("abc"), Arg.Is(ForkMode.PreferSingleRepository), - Arg.Is((Platform?)null)); + Arg.Is((Platform?)null), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); } [Test] @@ -257,7 +263,12 @@ await collaborationFactory Arg.Is(new Uri("https://api.github.com")), Arg.Is("abc"), Arg.Is(ForkMode.PreferFork), - Arg.Is((Platform?)null)); + Arg.Is((Platform?)null), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); } [Test] @@ -292,7 +303,12 @@ await collaborationFactory Arg.Is(new Uri("https://api.github.com")), Arg.Is("abc"), Arg.Is((ForkMode?)null), - Arg.Is((Platform?)Platform.BitbucketLocal)); + Arg.Is((Platform?)Platform.BitbucketLocal), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); } [TestCase(Platform.BitbucketLocal, "https://myRepo.ch/")] @@ -325,7 +341,12 @@ await collaborationFactory Arg.Is(new Uri(expectedApi)), // Is populated by the settings reader. Thus, can be used to check if the correct one was selected. Arg.Is((string)null), Arg.Is((ForkMode?)null), - Arg.Is((Platform?)platform)); + Arg.Is((Platform?)platform), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); } [Test] @@ -603,7 +624,10 @@ private static ICollaborationFactory GetCollaborationFactory(IEnvironmentVariabl return new CollaborationFactory( settingReaders ?? new ISettingsReader[] { new GitHubSettingsReader(new MockedGitDiscoveryDriver(), environmentVariablesProvider) }, Substitute.For(), - null + null, + Substitute.For(), + Substitute.For>(), + Substitute.For, UpdateMessageTemplate>>() ); } } diff --git a/NuKeeper.Tests/Commands/RepositoryCommand_CustomTemplates.cs b/NuKeeper.Tests/Commands/RepositoryCommand_CustomTemplates.cs new file mode 100644 index 000000000..7e4a1920b --- /dev/null +++ b/NuKeeper.Tests/Commands/RepositoryCommand_CustomTemplates.cs @@ -0,0 +1,163 @@ +using NSubstitute; +using NuKeeper.Abstractions.CollaborationPlatform; +using NuKeeper.Abstractions.Configuration; +using NuKeeper.AzureDevOps; +using NuKeeper.Collaboration; +using NuKeeper.Commands; +using NuKeeper.Inspection.Logging; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace NuKeeper.Tests.Commands +{ + public class RepositoryCommand_CustomTemplates + { + private IFileSettingsCache _fileSettings; + private ICollaborationFactory _collaborationFactory; + + [SetUp] + public void Initialize() + { + _fileSettings = Substitute.For(); + _collaborationFactory = Substitute.For(); + _fileSettings + .GetSettings() + .Returns(FileSettings.Empty()); + _collaborationFactory + .Initialise( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ) + .Returns(ValidationResult.Success); + _collaborationFactory + .Settings + .Returns(new CollaborationPlatformSettings { Token = "mytoken" }); + } + + [Test] + public async Task OnExecute_CustomTemplatesFromFile_CallsInitialiseCollaborationFactoryWithCustomTemplates() + { + var commitTemplate = "commit template"; + var prTitleTemplate = "pr title template"; + var prBodyTemplate = "pr body template"; + _fileSettings + .GetSettings() + .Returns( + new FileSettings + { + CommitMessageTemplate = commitTemplate, + PullRequestTitleTemplate = prTitleTemplate, + PullRequestBodyTemplate = prBodyTemplate + } + ); + var command = MakeCommand(); + + await command.OnExecute(); + + await _collaborationFactory + .Received() + .Initialise( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + commitTemplate, + prTitleTemplate, + prBodyTemplate, + Arg.Any>() + ); + } + + [Test] + public async Task OnExecute_CustomTemplatesFromCli_CallsInitialiseCollaborationFactoryWithCustomTemplates() + { + var commitTemplate = "commit template"; + var prTitleTemplate = "pr title template"; + var prBodyTemplate = "pr body template"; + var command = MakeCommand(); + command.CommitMessageTemplate = commitTemplate; + command.PullRequestTitleTemplate = prTitleTemplate; + command.PullRequestBodyTemplate = prBodyTemplate; + + await command.OnExecute(); + + await _collaborationFactory + .Received() + .Initialise( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + commitTemplate, + prTitleTemplate, + prBodyTemplate, + Arg.Any>() + ); + } + + [Test] + public async Task OnExecute_CustomTemplatesFromCliAndFile_CallsInitialiseCollaborationFactoryWithCustomTemplatesFromCli() + { + var commitTemplate = "commit template"; + var prTitleTemplate = "pr title template"; + var prBodyTemplate = "pr body template"; + _fileSettings + .GetSettings() + .Returns( + new FileSettings + { + CommitMessageTemplate = "commit template from file", + PullRequestTitleTemplate = "pr title template from file", + PullRequestBodyTemplate = "pr body template from file" + } + ); + var command = MakeCommand(); + command.CommitMessageTemplate = commitTemplate; + command.PullRequestTitleTemplate = prTitleTemplate; + command.PullRequestBodyTemplate = prBodyTemplate; + + await command.OnExecute(); + + await _collaborationFactory + .Received() + .Initialise( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + commitTemplate, + prTitleTemplate, + prBodyTemplate, + Arg.Any>() + ); + } + + private RepositoryCommand MakeCommand() + { + var engine = Substitute.For(); + var logger = Substitute.For(); + var settingReader = new TfsSettingsReader(new MockedGitDiscoveryDriver(), Substitute.For()); + var settingsReaders = new List { settingReader }; + + return new RepositoryCommand( + engine, + logger, + _fileSettings, + _collaborationFactory, + settingsReaders + ) + { + RepositoryUri = "http://tfs.myorganization.com/tfs/DefaultCollection/MyProject/_git/MyRepository", + PersonalAccessToken = "mytoken" + }; + } + } +} diff --git a/NuKeeper.Tests/ConfigurationProviders/ProvideContextTests.cs b/NuKeeper.Tests/ConfigurationProviders/ProvideContextTests.cs new file mode 100644 index 000000000..f317fc6f9 --- /dev/null +++ b/NuKeeper.Tests/ConfigurationProviders/ProvideContextTests.cs @@ -0,0 +1,261 @@ +using NSubstitute; +using NuKeeper.Abstractions.Configuration; +using NuKeeper.Commands; +using NuKeeper.ConfigurationProviders; +using NuKeeper.Inspection.Logging; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace NuKeeper.Tests.ConfigurationProviders +{ + [TestFixture] + public class ProvideContextTests + { + private IConfigureLogger _logger; + private IFileSettingsCache _fileSettings; + + [SetUp] + public void Initialize() + { + _logger = Substitute.For(); + _fileSettings = Substitute.For(); + + _fileSettings.GetSettings().Returns(FileSettings.Empty()); + } + + [Test] + public async Task ProvideAsync_NoContext_ReturnsSuccessWithEmptyContext() + { + var command = MakeCommand(); + var configProvider = MakeProvideCommitContext(command); + var settingsContainer = MakeEmptySettingsContainer(); + + var result = await configProvider.ProvideAsync(settingsContainer); + + Assert.IsTrue(result.IsSuccess); + Assert.AreEqual(0, settingsContainer.UserSettings.Context.Count); + } + + [Test] + public async Task ProvideAsync_ContextFromCli_CorrectlyPopulatesSettingsContainerWithContext() + { + var command = MakeCommand(); + command.Context = new string[] { "issue=JIRA-001" }; + var configProvider = MakeProvideCommitContext(command); + var settingsContainer = MakeEmptySettingsContainer(); + + var result = await configProvider.ProvideAsync(settingsContainer); + + Assert.IsTrue(result.IsSuccess); + Assert.AreEqual( + settingsContainer.UserSettings.Context, + new Dictionary() + { + { "issue", "JIRA-001" } + } + ); + } + + [Test] + public async Task ProvideAsync_ContextFromFile_CorrectlyPopulatesSettingsContainerWithContext() + { + _fileSettings + .GetSettings() + .Returns( + new FileSettings + { + Context = new Dictionary { { "issue", "JIRA-001" } } + } + ); + var command = MakeCommand(); + var configProvider = MakeProvideCommitContext(command); + var settingsContainer = MakeEmptySettingsContainer(); + + var result = await configProvider.ProvideAsync(settingsContainer); + + Assert.IsTrue(result.IsSuccess); + Assert.AreEqual( + settingsContainer.UserSettings.Context, + new Dictionary() + { + { "issue", "JIRA-001" } + } + ); + } + + [Test] + public async Task ProvideAsync_ContextFromCliAndFile_CorrectlyPopulatesSettingsContainerWithContextFromCli() + { + _fileSettings + .GetSettings() + .Returns( + new FileSettings + { + Context = new Dictionary { { "notwhatiwant", "tosee" } } + } + ); + var command = MakeCommand(); + command.Context = new string[] { "issue=JIRA-001" }; + var configProvider = MakeProvideCommitContext(command); + var settingsContainer = MakeEmptySettingsContainer(); + + var result = await configProvider.ProvideAsync(settingsContainer); + + Assert.IsTrue(result.IsSuccess); + Assert.AreEqual( + settingsContainer.UserSettings.Context, + new Dictionary() + { + { "issue", "JIRA-001" } + } + ); + } + + [TestCase("valid=property", true)] + [TestCase("my:invalidproperty", false)] + [TestCase("my invalidproperty", false)] + public async Task ProvideAsync_ValidAndInvalidKeyValuePairs_AreAcceptedAndRejectedAsExpected( + string setting, + bool expectedOutcome + ) + { + var command = MakeCommand(); + command.Context = new string[] { setting }; + var configProvider = MakeProvideCommitContext(command); + var settingsContainer = MakeEmptySettingsContainer(); + + var result = await configProvider.ProvideAsync(settingsContainer); + + Assert.AreEqual(expectedOutcome, result.IsSuccess); + } + + [Test] + public async Task ProvideAsync_SpecialDelegatesDictionaryPropertyFromCli_ParsesDelegatesFromStrings() + { + var command = MakeCommand(); + command.Context = new string[] + { + @"_delegates={ ""50char"": + "" + using System; + new Func, object>( + (str, render) => + { + var rendering = render(str); + return rendering.Length > 50 ? + rendering.Substring(0, 47).PadRight(50, '.') + : rendering; + } + ) + "" + }" + }; + var configProvider = MakeProvideCommitContext(command); + var settingsContainer = MakeEmptySettingsContainer(); + + var result = await configProvider.ProvideAsync(settingsContainer); + + Assert.IsTrue(result.IsSuccess); + Assert.IsInstanceOf( + typeof(Func, object>), + settingsContainer.UserSettings.Context["50char"] + ); + } + + [Test] + public async Task ProvideAsync_SpecialDelegatesDictionaryPropertyFromFile_ParsesDelegatesFromStrings() + { + _fileSettings.GetSettings().Returns( + new FileSettings + { + Context = new Dictionary + { + { + "_delegates", @"{ ""50char"": + "" + using System; + new Func, object>( + (str, render) => + { + var rendering = render(str); + return rendering.Length > 50 ? + rendering.Substring(0, 47).PadRight(50, '.') + : rendering; + } + ) + "" + }" + } + } + } + ); + var command = MakeCommand(); + var configProvider = MakeProvideCommitContext(command); + var settingsContainer = MakeEmptySettingsContainer(); + + var result = await configProvider.ProvideAsync(settingsContainer); + + Assert.IsTrue(result.IsSuccess); + Assert.IsInstanceOf( + typeof(Func, object>), + settingsContainer.UserSettings.Context["50char"] + ); + } + + [Test] + public async Task ProvideAsync_InvalidCSharpExpressionForDelegate_ReturnsFailure() + { + var command = MakeCommand(); + command.Context = new string[] + { + @"_delegates={ ""50char"": + "" + This is not a csharp expression + "" + }" + }; + var configProvider = MakeProvideCommitContext(command); + var settingsContainer = MakeEmptySettingsContainer(); + + var result = await configProvider.ProvideAsync(settingsContainer); + + Assert.IsFalse(result.IsSuccess); + } + + private CommandBase MakeCommand() + { + return new CommandBaseStub(_logger, _fileSettings); + } + + private ProvideContext MakeProvideCommitContext(CommandBase command) + { + return new ProvideContext(_fileSettings, command); + } + + private static SettingsContainer MakeEmptySettingsContainer() + { + return new SettingsContainer() + { + BranchSettings = new BranchSettings(), + PackageFilters = new FilterSettings(), + SourceControlServerSettings = new SourceControlServerSettings(), + UserSettings = new UserSettings() + }; + } + + class CommandBaseStub : CommandBase + { + public CommandBaseStub( + IConfigureLogger logger, + IFileSettingsCache fileSettingsCache + ) : base(logger, fileSettingsCache) { } + + protected override Task Run(SettingsContainer settings) + { + return Task.FromResult(0); + } + } + } +} diff --git a/NuKeeper.Tests/Engine/CollaborationFactoryTests.cs b/NuKeeper.Tests/Engine/CollaborationFactoryTests.cs index 054b9260d..70ee239b4 100644 --- a/NuKeeper.Tests/Engine/CollaborationFactoryTests.cs +++ b/NuKeeper.Tests/Engine/CollaborationFactoryTests.cs @@ -1,41 +1,39 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; using NSubstitute; +using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; using NuKeeper.Abstractions.Logging; +using NuKeeper.Abstractions.RepositoryInspection; using NuKeeper.AzureDevOps; using NuKeeper.Collaboration; using NuKeeper.Engine; using NuKeeper.GitHub; using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; namespace NuKeeper.Tests.Engine { [TestFixture] public class CollaborationFactoryTests { - private static CollaborationFactory GetCollaborationFactory() + private const string _commitTemplate = "commit template"; + private const string _bodyTemplate = "body template"; + private const string _titleTemplate = "title template"; + private static readonly Dictionary _context = new Dictionary { - var azureUri = new Uri("https://dev.azure.com"); - var gitHubUri = new Uri("https://api.github.com"); + { "company", "nukeeper" } + }; - var settingReader1 = Substitute.For(); - settingReader1.CanRead(azureUri).Returns(true); - settingReader1.Platform.Returns(Platform.AzureDevOps); - - var settingReader2 = Substitute.For(); - settingReader2.CanRead(gitHubUri).Returns(true); - settingReader2.Platform.Returns(Platform.GitHub); + ITemplateValidator _templateValidator; - var readers = new List { settingReader1, settingReader2 }; - var logger = Substitute.For(); - var httpClientFactory = Substitute.For(); - httpClientFactory.CreateClient().Returns(new HttpClient()); - - return new CollaborationFactory(readers, logger, httpClientFactory); + [SetUp] + public void Initialize() + { + _templateValidator = Substitute.For(); + _templateValidator.ValidateAsync(Arg.Any()).Returns(ValidationResult.Success); } [Test] @@ -96,11 +94,20 @@ public async Task AzureDevOpsUrlReturnsAzureDevOps() { var collaborationFactory = GetCollaborationFactory(); - var result = await collaborationFactory.Initialise(new Uri("https://dev.azure.com"), "token", - ForkMode.SingleRepositoryOnly, null); + var result = await collaborationFactory.Initialise( + new Uri("https://dev.azure.com"), + "token", + ForkMode.SingleRepositoryOnly, + null, + _commitTemplate, + _titleTemplate, + _bodyTemplate, + _context + ); Assert.That(result.IsSuccess); AssertAzureDevOps(collaborationFactory); + AssertCommitTempaltes(collaborationFactory); AssertAreSameObject(collaborationFactory); } @@ -109,14 +116,147 @@ public async Task GithubUrlReturnsGitHub() { var collaborationFactory = GetCollaborationFactory(); - var result = await collaborationFactory.Initialise(new Uri("https://api.github.com"), "token", - ForkMode.PreferFork, null); + var result = await collaborationFactory.Initialise( + new Uri("https://api.github.com"), + "token", + ForkMode.PreferFork, + null, + _commitTemplate, + _titleTemplate, + _bodyTemplate, + _context + ); Assert.That(result.IsSuccess); AssertGithub(collaborationFactory); + AssertCommitTempaltes(collaborationFactory); AssertAreSameObject(collaborationFactory); } + [Test] + public async Task Initialise_ValidTemplates_ReturnsSuccessValidationResult() + { + _templateValidator + .ValidateAsync(Arg.Any()) + .Returns(ValidationResult.Success); + var collaborationFactory = GetCollaborationFactory(); + + var result = await collaborationFactory.Initialise( + new Uri("https://api.github.com"), + "token", + ForkMode.SingleRepositoryOnly, + Platform.GitHub, + "commit template", + "pull request title template", + "pull request body template" + ); + + Assert.That(result.IsSuccess, Is.True); + } + + [Test] + public async Task Initialise_InvalidCommitTemplate_ReturnsFailedValidationResult() + { + var commitTemplate = "invalid commit template"; + _templateValidator + .ValidateAsync(commitTemplate) + .Returns(ValidationResult.Failure("invalid template")); + _templateValidator + .ValidateAsync(Arg.Is(s => s != commitTemplate)) + .Returns(ValidationResult.Success); + var collaborationFactory = GetCollaborationFactory(); + + var result = await collaborationFactory.Initialise( + new Uri("https://api.github.com"), + "token", + ForkMode.SingleRepositoryOnly, + Platform.GitHub, + commitTemplate, + "pull request title template", + "pull request body template" + ); + + Assert.That(result.IsSuccess, Is.False); + } + + [Test] + public async Task Initialise_InvalidPullRequestTitleTemplate_ReturnsFailedValidationResult() + { + var pullRequestTitleTemplate = "invalid pull request title template"; + _templateValidator + .ValidateAsync(pullRequestTitleTemplate) + .Returns(ValidationResult.Failure("invalid template")); + _templateValidator + .ValidateAsync(Arg.Is(s => s != pullRequestTitleTemplate)) + .Returns(ValidationResult.Success); + var collaborationFactory = GetCollaborationFactory(); + + var result = await collaborationFactory.Initialise( + new Uri("https://api.github.com"), + "token", + ForkMode.SingleRepositoryOnly, + Platform.GitHub, + "commit template", + pullRequestTitleTemplate, + "invalid pull request body template" + ); + + Assert.That(result.IsSuccess, Is.False); + } + + [Test] + public async Task Initialise_InvalidPullRequestBodyTemplate_ReturnsFailedValidationResult() + { + var pullRequestBodyTemplate = "invalid pull request body template"; + _templateValidator + .ValidateAsync(pullRequestBodyTemplate) + .Returns(ValidationResult.Failure("invalid template")); + _templateValidator + .ValidateAsync(Arg.Is(s => s != pullRequestBodyTemplate)) + .Returns(ValidationResult.Success); + var collaborationFactory = GetCollaborationFactory(); + + var result = await collaborationFactory.Initialise( + new Uri("https://api.github.com"), + "token", + ForkMode.SingleRepositoryOnly, + Platform.GitHub, + "commit template", + "pull request title template", + pullRequestBodyTemplate + ); + + Assert.That(result.IsSuccess, Is.False); + } + + private CollaborationFactory GetCollaborationFactory() + { + var azureUri = new Uri("https://dev.azure.com"); + var gitHubUri = new Uri("https://api.github.com"); + + var settingReader1 = Substitute.For(); + settingReader1.CanRead(azureUri).Returns(true); + settingReader1.Platform.Returns(Platform.AzureDevOps); + + var settingReader2 = Substitute.For(); + settingReader2.CanRead(gitHubUri).Returns(true); + settingReader2.Platform.Returns(Platform.GitHub); + + var readers = new List { settingReader1, settingReader2 }; + var logger = Substitute.For(); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient().Returns(new HttpClient()); + + return new CollaborationFactory( + readers, + logger, + httpClientFactory, + _templateValidator, + Substitute.For>(), + Substitute.For, UpdateMessageTemplate>>() + ); + } + private static void AssertAreSameObject(ICollaborationFactory collaborationFactory) { var collaborationPlatform = collaborationFactory.CollaborationPlatform; @@ -138,7 +278,12 @@ private static void AssertGithub(ICollaborationFactory collaborationFactory) Assert.IsInstanceOf(collaborationFactory.RepositoryDiscovery); Assert.IsInstanceOf(collaborationFactory.CollaborationPlatform); Assert.IsInstanceOf(collaborationFactory.Settings); - Assert.IsInstanceOf(collaborationFactory.CommitWorder); + Assert.IsInstanceOf(collaborationFactory.CommitWorder); + + var commitWorder = (CommitWorder)collaborationFactory.CommitWorder; + Assert.IsInstanceOf(commitWorder.PullrequestTitleTemplate); + Assert.IsInstanceOf(commitWorder.PullrequestBodyTemplate); + Assert.IsInstanceOf(commitWorder.CommitTemplate); } private static void AssertAzureDevOps(ICollaborationFactory collaborationFactory) @@ -147,7 +292,23 @@ private static void AssertAzureDevOps(ICollaborationFactory collaborationFactory Assert.IsInstanceOf(collaborationFactory.RepositoryDiscovery); Assert.IsInstanceOf(collaborationFactory.CollaborationPlatform); Assert.IsInstanceOf(collaborationFactory.Settings); - Assert.IsInstanceOf(collaborationFactory.CommitWorder); + Assert.IsInstanceOf(collaborationFactory.CommitWorder); + + var commitWorder = (CommitWorder)collaborationFactory.CommitWorder; + Assert.IsInstanceOf(commitWorder.PullrequestTitleTemplate); + Assert.IsInstanceOf(commitWorder.PullrequestBodyTemplate); + Assert.IsInstanceOf(commitWorder.CommitTemplate); + } + + private static void AssertCommitTempaltes(ICollaborationFactory collaborationFactory) + { + var commitWorder = (CommitWorder)collaborationFactory.CommitWorder; + Assert.AreEqual(_titleTemplate, commitWorder.PullrequestTitleTemplate.Value); + Assert.AreEqual(_bodyTemplate, commitWorder.PullrequestBodyTemplate.Value); + Assert.AreEqual(_commitTemplate, commitWorder.CommitTemplate.Value); + Assert.AreEqual("nukeeper", commitWorder.PullrequestTitleTemplate.GetPlaceholderValue("company")); + Assert.AreEqual("nukeeper", commitWorder.PullrequestBodyTemplate.GetPlaceholderValue("company")); + Assert.AreEqual("nukeeper", commitWorder.CommitTemplate.GetPlaceholderValue("company")); } } } diff --git a/NuKeeper.Tests/Engine/CommitUpdateMessageTemplateTests.cs b/NuKeeper.Tests/Engine/CommitUpdateMessageTemplateTests.cs new file mode 100644 index 000000000..fe8428178 --- /dev/null +++ b/NuKeeper.Tests/Engine/CommitUpdateMessageTemplateTests.cs @@ -0,0 +1,206 @@ +using NuKeeper.Abstractions.CollaborationModels; +using NuKeeper.Abstractions.CollaborationPlatform; +using NuKeeper.Abstractions.RepositoryInspection; +using NUnit.Framework; +using System; +using System.Collections.Generic; + +namespace NuKeeper.Tests.Engine +{ + public class CommitUpdateMessageTemplateTests + { + private CommitUpdateMessageTemplate _sut; + private IEnrichContext _enricher; + private IEnrichContext, UpdateMessageTemplate> _multiEnricher; + + [SetUp] + public void TestInitialize() + { + _sut = new CommitUpdateMessageTemplate(); + _enricher = new PackageUpdateSetEnricher(); + _multiEnricher = new PackageUpdateSetsEnricher(_enricher); + } + + [Test] + public void Write_OneUpdate_ReturnsMessageIndicatingPackageAndVersion() + { + var updates = PackageUpdates.For(MakePackageForV110()); + + _enricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That(report, Is.Not.Null); + Assert.That(report, Is.Not.Empty); + Assert.That(report, Is.EqualTo("📦 Automatic update of foo.bar to 1.2.3")); + } + + [Test] + public void Write_TwoUpdates_ReturnsMessageIndicatingTheActuallySelectedPackageAndVersion() + { + var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()); + + _enricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That(report, Is.Not.Null); + Assert.That(report, Is.Not.Empty); + Assert.That(report, Is.EqualTo("📦 Automatic update of foo.bar to 1.2.3")); + } + + [Test] + public void Write_TwoUpdatesSameVersion_ReturnsMessageIndicatingPackageAndVersion() + { + var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()); + + _enricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That(report, Is.Not.Null); + Assert.That(report, Is.Not.Empty); + Assert.That(report, Is.EqualTo("📦 Automatic update of foo.bar to 1.2.3")); + } + + [Test] + public void Write_MultiplePackages_ReturnsMessageIndicatingNumberOfPackagesUpdated() + { + const string packageOne = "foo.bar"; + const string packageTwo = "notfoo.bar"; + var updates = new[] { + PackageUpdates.For(packageOne, MakePackageForV110(packageOne), MakePackageForV100(packageOne)), + PackageUpdates.For(packageTwo, MakePackageForV110(packageTwo), MakePackageForV100(packageTwo)) + }; + + _multiEnricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That(report, Is.Not.Null); + Assert.That(report, Is.Not.Empty); + Assert.That(report, Is.EqualTo("📦 Automatic update of 2 packages")); + } + + [Test] + public void Write_CustomTemplate_UsesCustomTemplate() + { + _sut.CustomTemplate = "chore: Update {{^multipleChanges}}{{#packages}}{{Name}} to {{Version}}{{/packages}}{{/multipleChanges}}"; + var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()); + + _enricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That(report, Is.EqualTo("chore: Update foo.bar to 1.2.3")); + } + + [Test] + public void Write_CustomTemplateAndContext_UsesCustomTemplateAndContext() + { + _sut.AddPlaceholderValue("company", "NuKeeper"); + _sut.CustomTemplate = +@" +chore: Update {{^multipleChanges}}{{#packages}}{{Name}} to {{Version}}{{/packages}}{{/multipleChanges}} + +We at {{company}} are committed to keeping your software's dependencies up-to-date! +"; + var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()); + + _enricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That( + report, + Is.EqualTo( +@" +chore: Update foo.bar to 1.2.3 + +We at NuKeeper are committed to keeping your software's dependencies up-to-date! +" + ) + ); + } + + [Test] + public void Write_CustomTemplateAndContext_SupportsLambdas() + { + _sut.AddPlaceholderValue( + "50char", + new Func, object>( + (str, render) => + { + var rendering = render(str); + return rendering.Length > 50 ? + rendering.Substring(0, 47).PadRight(50, '.') + : rendering; + } + ) + ); + _sut.CustomTemplate = "{{#50char}}chore: Update {{^multipleChanges}}{{#packages}}{{Name}} to {{Version}}{{/packages}}{{/multipleChanges}}{{/50char}}"; + var longPackageName = "ExtremelyLongCompanyName.Package"; + var updates = PackageUpdates.For(longPackageName, MakePackageForV110(longPackageName), MakePackageForV100(longPackageName)); + + _enricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That(report, Is.EqualTo("chore: Update ExtremelyLongCompanyName.Package ...")); + } + + [TestCase("{{#packages}}{{Name}} to {{Version}}{{/packages}}", "foo.bar to 1.2.3")] + [TestCase("{{packageEmoji}}", "📦")] + [TestCase("{{packageCount}}", "1")] + public void Write_CustomTemplateDefaultContextSinglePackage_ReturnsMessageWithTheExpectedReplacements(string placeHolder, string expectedOutput) + { + const string packageName = "foo.bar"; + _sut.CustomTemplate = placeHolder; + var updates = PackageUpdates.For( + packageName, + MakePackageForV110(packageName), + MakePackageForV100(packageName) + ); + + _enricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That(report, Is.EqualTo(expectedOutput)); + } + + [TestCase("{{packageEmoji}}", "📦")] + [TestCase("{{packageCount}}", "2")] + [TestCase("{{#packages}}{{Name}} to {{Version}}\r\n{{/packages}}", "foo.bar to 1.2.3\r\nnotfoo.bar to 1.2.3\r\n")] + public void Write_CustomTemplateDefaultContextTwoPackages_ReturnsMessageWithTheExpectedReplacements(string placeHolder, string expectedOutput) + { + _sut.CustomTemplate = placeHolder; + const string packageNameOne = "foo.bar"; + const string packageNameTwo = "notfoo.bar"; + var updates = new[] + { + PackageUpdates.For(packageNameOne, MakePackageForV110(packageNameOne), MakePackageForV100(packageNameOne)), + PackageUpdates.For(packageNameTwo, MakePackageForV110(packageNameTwo), MakePackageForV100(packageNameTwo)) + }; + + _multiEnricher.Enrich(updates, _sut); + var report = _sut.Output(); + + Assert.That(report, Is.EqualTo(expectedOutput)); + } + + private static PackageInProject MakePackageForV110(string packageName = "foo.bar") + { + var path = new PackagePath("c:\\temp", "folder\\src\\project1\\packages.config", + PackageReferenceType.PackagesConfig); + return new PackageInProject(packageName, "1.1.0", path); + } + + private static PackageInProject MakePackageForV100(string packageName = "foo.bar") + { + var path2 = new PackagePath("c:\\temp", "folder\\src\\project2\\packages.config", + PackageReferenceType.PackagesConfig); + var currentPackage2 = new PackageInProject(packageName, "1.0.0", path2); + return currentPackage2; + } + + private static PackageInProject MakePackageForV110InProject3() + { + var path = new PackagePath("c:\\temp", "folder\\src\\project3\\packages.config", PackageReferenceType.PackagesConfig); + + return new PackageInProject("foo.bar", "1.1.0", path); + } + } +} diff --git a/NuKeeper.Tests/Engine/DefaultCommitWorderTests.cs b/NuKeeper.Tests/Engine/CommitWorderTests.cs similarity index 70% rename from NuKeeper.Tests/Engine/DefaultCommitWorderTests.cs rename to NuKeeper.Tests/Engine/CommitWorderTests.cs index 52e53da48..4ecbdad14 100644 --- a/NuKeeper.Tests/Engine/DefaultCommitWorderTests.cs +++ b/NuKeeper.Tests/Engine/CommitWorderTests.cs @@ -1,73 +1,85 @@ -using System; -using System.Collections.Generic; using NuGet.Packaging.Core; using NuGet.Versioning; using NuKeeper.Abstractions; +using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.RepositoryInspection; using NuKeeper.Engine; using NUnit.Framework; +using System; +using System.Collections.Generic; namespace NuKeeper.Tests.Engine { [TestFixture] - public class DefaultCommitWorderTests + public class CommitWorderTests { - private ICommitWorder _sut; - - [SetUp] - public void TestInitialize() - { - _sut = new DefaultCommitWorder(); - } - [Test] public void MarkPullRequestTitle_UpdateIsCorrect() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakePullRequestTitle(updates); + var report = sut.MakePullRequestTitle(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); Assert.That(report, Is.EqualTo("Automatic update of foo.bar to 1.2.3")); } + [Test] + public void MakePullRequestTitle_MultipleUpdates_ReturnsNumberOfPackagesInTitle() + { + var updates = new List + { + PackageUpdates.For(MakePackageFor("foo.bar", "1.1.0")), + PackageUpdates.For(MakePackageFor("notfoo.bar", "1.1.5")) + }; + var sut = MakeDefaultCommitWorder(); + + var report = sut.MakePullRequestTitle(updates); + + Assert.That(report, Is.EqualTo("Automatic update of 2 packages")); + } + [Test] public void MakeCommitMessage_OneUpdateIsCorrect() { var updates = PackageUpdates.For(MakePackageForV110()); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitMessage(updates); + var report = sut.MakeCommitMessage(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); - Assert.That(report, Is.EqualTo(":package: Automatic update of foo.bar to 1.2.3")); + Assert.That(report, Is.EqualTo("📦 Automatic update of foo.bar to 1.2.3")); } [Test] public void MakeCommitMessage_TwoUpdatesIsCorrect() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitMessage(updates); + var report = sut.MakeCommitMessage(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); - Assert.That(report, Is.EqualTo(":package: Automatic update of foo.bar to 1.2.3")); + Assert.That(report, Is.EqualTo("📦 Automatic update of foo.bar to 1.2.3")); } [Test] public void MakeCommitMessage_TwoUpdatesSameVersionIsCorrect() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitMessage(updates); + var report = sut.MakeCommitMessage(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); - Assert.That(report, Is.EqualTo(":package: Automatic update of foo.bar to 1.2.3")); + Assert.That(report, Is.EqualTo("📦 Automatic update of foo.bar to 1.2.3")); } @@ -76,8 +88,9 @@ public void OneUpdate_MakeCommitDetails_IsNotEmpty() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -88,8 +101,9 @@ public void OneUpdate_MakeCommitDetails_HasStandardTexts() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); AssertContainsStandardText(report); } @@ -99,10 +113,11 @@ public void OneUpdate_MakeCommitDetails_HasVersionInfo() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3` from `1.1.0`")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3` from `1.1.0`").IgnoreCase); } [Test] @@ -110,8 +125,9 @@ public void OneUpdate_MakeCommitDetails_HasPublishedDate() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("`foo.bar 1.2.3` was published at `2018-02-19T11:12:07Z`")); } @@ -122,8 +138,9 @@ public void OneUpdate_MakeCommitDetails_HasProjectDetails() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("1 project update:")); Assert.That(report, Does.Contain("Updated `folder\\src\\project1\\packages.config` to `foo.bar` `1.2.3` from `1.1.0`")); @@ -134,8 +151,9 @@ public void TwoUpdates_MakeCommitDetails_NotEmpty() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -146,8 +164,9 @@ public void TwoUpdates_MakeCommitDetails_HasStandardTexts() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); AssertContainsStandardText(report); Assert.That(report, Does.Contain("1.0.0")); @@ -158,10 +177,11 @@ public void TwoUpdates_MakeCommitDetails_HasVersionInfo() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3`")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3`").IgnoreCase); Assert.That(report, Does.Contain("2 versions of `foo.bar` were found in use: `1.1.0`, `1.0.0`")); } @@ -170,8 +190,9 @@ public void TwoUpdates_MakeCommitDetails_HasProjectList() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("2 project updates:")); Assert.That(report, Does.Contain("Updated `folder\\src\\project1\\packages.config` to `foo.bar` `1.2.3` from `1.1.0`")); @@ -183,8 +204,9 @@ public void TwoUpdatesSameVersion_MakeCommitDetails_NotEmpty() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -195,8 +217,9 @@ public void TwoUpdatesSameVersion_MakeCommitDetails_HasStandardTexts() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); AssertContainsStandardText(report); } @@ -206,10 +229,11 @@ public void TwoUpdatesSameVersion_MakeCommitDetails_HasVersionInfo() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3` from `1.1.0`")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3` from `1.1.0`").IgnoreCase); } [Test] @@ -217,8 +241,9 @@ public void TwoUpdatesSameVersion_MakeCommitDetails_HasProjectList() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("2 project updates:")); Assert.That(report, Does.Contain("Updated `folder\\src\\project1\\packages.config` to `foo.bar` `1.2.3` from `1.1.0`")); @@ -230,8 +255,9 @@ public void OneUpdate_MakeCommitDetails_HasVersionLimitData() { var updates = PackageUpdates.LimitedToMinor(MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("There is also a higher version, `foo.bar 2.3.4`, but this was not applied as only `Minor` version changes are allowed.")); } @@ -243,8 +269,9 @@ public void OneUpdateWithDate_MakeCommitDetails_HasVersionLimitDataWithDate() var updates = PackageUpdates.LimitedToMinor(publishedAt, MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("There is also a higher version, `foo.bar 2.3.4` published at `2018-02-20T11:32:45Z`,")); Assert.That(report, Does.Contain(" ago, but this was not applied as only `Minor` version changes are allowed.")); @@ -255,10 +282,11 @@ public void OneUpdateWithMajorVersionChange() { var updates = PackageUpdates.ForNewVersion(new PackageIdentity("foo.bar", new NuGetVersion("2.1.1")), MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a major update of `foo.bar` to `2.1.1` from `1.1.0")); + Assert.That(report, Does.StartWith("NuKeeper has generated a major update of `foo.bar` to `2.1.1` from `1.1.0").IgnoreCase); } [Test] @@ -266,10 +294,11 @@ public void OneUpdateWithMinorVersionChange() { var updates = PackageUpdates.ForNewVersion(new PackageIdentity("foo.bar", new NuGetVersion("1.2.1")), MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.1` from `1.1.0")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.1` from `1.1.0").IgnoreCase); } [Test] @@ -277,10 +306,11 @@ public void OneUpdateWithPatchVersionChange() { var updates = PackageUpdates.ForNewVersion(new PackageIdentity("foo.bar", new NuGetVersion("1.1.9")), MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a patch update of `foo.bar` to `1.1.9` from `1.1.0")); + Assert.That(report, Does.StartWith("NuKeeper has generated a patch update of `foo.bar` to `1.1.9` from `1.1.0").IgnoreCase); } [Test] @@ -288,8 +318,9 @@ public void OneUpdateWithInternalPackageSource() { var updates = PackageUpdates.ForInternalSource(MakePackageForV110()) .InList(); + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Not.Contain("on NuGet.org")); Assert.That(report, Does.Not.Contain("www.nuget.org")); @@ -299,14 +330,14 @@ public void OneUpdateWithInternalPackageSource() public void TwoUpdateSets() { var packageTwo = new PackageIdentity("packageTwo", new NuGetVersion("3.4.5")); - var updates = new List { PackageUpdates.ForNewVersion(new PackageIdentity("foo.bar", new NuGetVersion("2.1.1")), MakePackageForV110()), PackageUpdates.ForNewVersion(packageTwo, MakePackageForV110("packageTwo")) }; + var sut = MakeDefaultCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.StartWith("2 packages were updated in 1 project:")); Assert.That(report, Does.Contain("`foo.bar`, `packageTwo`")); @@ -314,13 +345,25 @@ public void TwoUpdateSets() Assert.That(report, Does.Contain("")); Assert.That(report, Does.Contain("")); Assert.That(report, Does.Contain("")); - Assert.That(report, Does.Contain("NuKeeper has generated a major update of `foo.bar` to `2.1.1` from `1.1.0`")); - Assert.That(report, Does.Contain("NuKeeper has generated a major update of `packageTwo` to `3.4.5` from `1.1.0`")); + Assert.That(report, Does.Contain("NuKeeper has generated a major update of `foo.bar` to `2.1.1` from `1.1.0`").IgnoreCase); + Assert.That(report, Does.Contain("NuKeeper has generated a major update of `packageTwo` to `3.4.5` from `1.1.0`").IgnoreCase); + } + + private static CommitWorder MakeDefaultCommitWorder() + { + var updateSetEnricher = new PackageUpdateSetEnricher(); + return new CommitWorder( + new CommitUpdateMessageTemplate(), + new DefaultPullRequestTitleTemplate(), + new DefaultPullRequestBodyTemplate(), + updateSetEnricher, + new PackageUpdateSetsEnricher(updateSetEnricher) + ); } private static void AssertContainsStandardText(string report) { - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3`")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3`").IgnoreCase); Assert.That(report, Does.Contain("This is an automated update. Merge only if it passes tests")); Assert.That(report, Does.EndWith("**NuKeeper**: https://github.com/NuKeeperDotNet/NuKeeper" + Environment.NewLine)); Assert.That(report, Does.Contain("1.1.0")); @@ -355,5 +398,11 @@ private static PackageInProject MakePackageForV110InProject3() return new PackageInProject("foo.bar", "1.1.0", path); } + private static PackageInProject MakePackageFor(string packageName, string version) + { + var path = new PackagePath("c:\\temp", "folder\\src\\project1\\packages.config", + PackageReferenceType.PackagesConfig); + return new PackageInProject(packageName, version, path); + } } } diff --git a/NuKeeper.Tests/Engine/Packages/PackageUpdaterTests.cs b/NuKeeper.Tests/Engine/Packages/PackageUpdaterTests.cs index d56f621ef..e05ee6ad3 100644 --- a/NuKeeper.Tests/Engine/Packages/PackageUpdaterTests.cs +++ b/NuKeeper.Tests/Engine/Packages/PackageUpdaterTests.cs @@ -199,6 +199,8 @@ private PackageUpdater MakePackageUpdater() _collaborationFactory, _existingCommitFilter, _updateRunner, + Substitute.For>(), + Substitute.For, UpdateMessageTemplate>>(), Substitute.For() ); } diff --git a/NuKeeper.Tests/Engine/RepositoryUpdaterTests.cs b/NuKeeper.Tests/Engine/RepositoryUpdaterTests.cs index 746615626..ddda795f1 100644 --- a/NuKeeper.Tests/Engine/RepositoryUpdaterTests.cs +++ b/NuKeeper.Tests/Engine/RepositoryUpdaterTests.cs @@ -144,7 +144,10 @@ int expectedPrs var packageUpdater = new PackageUpdater(collaborationFactory, existingCommitFilder, Substitute.For(), - Substitute.For()); + Substitute.For>(), + Substitute.For, UpdateMessageTemplate>>(), + Substitute.For() + ); var updates = Enumerable.Range(1, numberOfUpdates) .Select(_ => PackageUpdates.UpdateSet()) diff --git a/NuKeeper.Tests/PackageUpdates.cs b/NuKeeper.Tests/PackageUpdates.cs index 0500adea0..0c1562ddc 100644 --- a/NuKeeper.Tests/PackageUpdates.cs +++ b/NuKeeper.Tests/PackageUpdates.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using NuGet.Configuration; using NuGet.Packaging.Core; using NuGet.Versioning; @@ -9,6 +6,9 @@ using NuKeeper.Abstractions.NuGet; using NuKeeper.Abstractions.NuGetApi; using NuKeeper.Abstractions.RepositoryInspection; +using System; +using System.Collections.Generic; +using System.Linq; namespace NuKeeper.Tests { @@ -58,7 +58,13 @@ public static PackageUpdateSet ForPackageRefType(PackageReferenceType refType) public static PackageUpdateSet For(params PackageInProject[] packages) { - var newPackage = new PackageIdentity("foo.bar", new NuGetVersion("1.2.3")); + var newPackage = new PackageIdentity(packages?.First()?.Id ?? "foo.bar", new NuGetVersion("1.2.3")); + return ForNewVersion(newPackage, packages); + } + + public static PackageUpdateSet For(string packageName, params PackageInProject[] packages) + { + var newPackage = new PackageIdentity(packageName, new NuGetVersion("1.2.3")); return ForNewVersion(newPackage, packages); } diff --git a/NuKeeper.Tests/Validators/StubbleMustacheTemplateValidatorTests.cs b/NuKeeper.Tests/Validators/StubbleMustacheTemplateValidatorTests.cs new file mode 100644 index 000000000..98a78c62c --- /dev/null +++ b/NuKeeper.Tests/Validators/StubbleMustacheTemplateValidatorTests.cs @@ -0,0 +1,33 @@ +using NUnit.Framework; +using NuKeeper.Abstractions.CollaborationPlatform; +using NuKeeper.Validators; +using System.Threading.Tasks; + +namespace NuKeeper.Tests.Validators +{ + [TestFixture] + public class StubbleMustacheTemplateValidatorTests + { + ITemplateValidator _sut; + + [SetUp] + public void Initialize() + { + _sut = new StubbleMustacheTemplateValidator(); + } + + [TestCase("{{#Invalid}} template", false)] + [TestCase("{{#InValid}} template {{InValid}}", false)] + [TestCase("{{Valid}} template", true)] + [TestCase("{{#Valid}} template {{/Valid}}", true)] + public async Task ValidateAsync_InvalidAndValidMustacheTemplates_ReturnsExpectedFailureAndSuccessValidationResults( + string template, + bool valid + ) + { + var result = await _sut.ValidateAsync(template); + + Assert.That(result.IsSuccess, Is.EqualTo(valid)); + } + } +} diff --git a/NuKeeper/Collaboration/CollaborationFactory.cs b/NuKeeper/Collaboration/CollaborationFactory.cs index 5443acce4..39e660c7e 100644 --- a/NuKeeper/Collaboration/CollaborationFactory.cs +++ b/NuKeeper/Collaboration/CollaborationFactory.cs @@ -1,13 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; using NuKeeper.Abstractions; +using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; using NuKeeper.Abstractions.Formats; using NuKeeper.Abstractions.Logging; +using NuKeeper.Abstractions.RepositoryInspection; using NuKeeper.AzureDevOps; using NuKeeper.BitBucket; using NuKeeper.BitBucketLocal; @@ -15,6 +12,11 @@ using NuKeeper.Gitea; using NuKeeper.GitHub; using NuKeeper.Gitlab; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; namespace NuKeeper.Collaboration { @@ -23,30 +25,56 @@ public class CollaborationFactory : ICollaborationFactory private readonly IEnumerable _settingReaders; private readonly INuKeeperLogger _nuKeeperLogger; private readonly IHttpClientFactory _httpClientFactory; + private readonly ITemplateValidator _templateValidator; + private readonly IEnrichContext _enricher; + private readonly IEnrichContext, UpdateMessageTemplate> _multiEnricher; + private Platform? _platform; + private string _commitTemplate; + private string _pullrequestTitleTemplate; + private string _pullrequestBodyTemplate; + private IDictionary _templateContext; + + public CollaborationFactory( + IEnumerable settingReaders, + INuKeeperLogger nuKeeperLogger, + IHttpClientFactory httpClientFactory, + ITemplateValidator templateValidator, + IEnrichContext enricher, + IEnrichContext, UpdateMessageTemplate> multiEnricher + ) + { + _settingReaders = settingReaders; + _nuKeeperLogger = nuKeeperLogger; + _httpClientFactory = httpClientFactory; + _templateValidator = templateValidator; + _enricher = enricher; + _multiEnricher = multiEnricher; + Settings = new CollaborationPlatformSettings(); + } public IForkFinder ForkFinder { get; private set; } - public ICommitWorder CommitWorder { get; private set; } - public IRepositoryDiscovery RepositoryDiscovery { get; private set; } - public ICollaborationPlatform CollaborationPlatform { get; private set; } - public CollaborationPlatformSettings Settings { get; } - public CollaborationFactory(IEnumerable settingReaders, - INuKeeperLogger nuKeeperLogger, IHttpClientFactory httpClientFactory) + public async Task Initialise( + Uri apiEndpoint, + string token, + ForkMode? forkModeFromSettings, + Platform? platformFromSettings, + string commitTemplate = null, + string pullrequestTitleTemplate = null, + string pullrequestBodyTemplate = null, + IDictionary templateContext = null + ) { - _settingReaders = settingReaders; - _nuKeeperLogger = nuKeeperLogger; - _httpClientFactory = httpClientFactory; - Settings = new CollaborationPlatformSettings(); - } + _commitTemplate = commitTemplate; + _pullrequestTitleTemplate = pullrequestTitleTemplate; + _pullrequestBodyTemplate = pullrequestBodyTemplate; + _templateContext = templateContext; - public async Task Initialise(Uri apiEndpoint, string token, - ForkMode? forkModeFromSettings, Platform? platformFromSettings) - { var platformSettingsReader = await FindPlatformSettingsReader(platformFromSettings, apiEndpoint); if (platformSettingsReader != null) { @@ -62,7 +90,7 @@ public async Task Initialise(Uri apiEndpoint, string token, Settings.ForkMode = forkModeFromSettings; platformSettingsReader.UpdateCollaborationPlatformSettings(Settings); - var result = ValidateSettings(); + var result = await ValidateSettings(); if (!result.IsSuccess) { return result; @@ -102,7 +130,7 @@ private async Task FindPlatformSettingsReader( } } - private ValidationResult ValidateSettings() + private async Task ValidateSettings() { if (!Settings.BaseApiUrl.IsWellFormedOriginalString() || (Settings.BaseApiUrl.Scheme != "http" && Settings.BaseApiUrl.Scheme != "https")) @@ -126,6 +154,27 @@ private ValidationResult ValidateSettings() return ValidationResult.Failure("Platform was not set"); } + if (!string.IsNullOrEmpty(_commitTemplate)) + { + var validationResult = await _templateValidator.ValidateAsync(_commitTemplate); + if (!validationResult.IsSuccess) + return validationResult; + } + + if (!string.IsNullOrEmpty(_pullrequestTitleTemplate)) + { + var validationResult = await _templateValidator.ValidateAsync(_pullrequestTitleTemplate); + if (!validationResult.IsSuccess) + return validationResult; + } + + if (!string.IsNullOrEmpty(_pullrequestBodyTemplate)) + { + var validationResult = await _templateValidator.ValidateAsync(_pullrequestBodyTemplate); + if (!validationResult.IsSuccess) + return validationResult; + } + return ValidationResult.Success; } @@ -133,57 +182,70 @@ private void CreateForPlatform() { var forkMode = Settings.ForkMode.Value; + UpdateMessageTemplate commitTemplate = new CommitUpdateMessageTemplate { CustomTemplate = _commitTemplate }; + UpdateMessageTemplate titleTemplate = null; + UpdateMessageTemplate bodyTemplate = null; + switch (_platform.Value) { case Platform.AzureDevOps: CollaborationPlatform = new AzureDevOpsPlatform(_nuKeeperLogger, _httpClientFactory); RepositoryDiscovery = new AzureDevOpsRepositoryDiscovery(_nuKeeperLogger, CollaborationPlatform, Settings.Token); ForkFinder = new AzureDevOpsForkFinder(CollaborationPlatform, _nuKeeperLogger, forkMode); - - // We go for the specific platform version of ICommitWorder - // here since Azure DevOps has different commit message limits compared to other platforms. - CommitWorder = new AzureDevOpsCommitWorder(); + titleTemplate = new AzureDevOpsPullRequestTitleTemplate { CustomTemplate = _pullrequestTitleTemplate }; + bodyTemplate = new AzureDevOpsPullRequestBodyTemplate { CustomTemplate = _pullrequestBodyTemplate }; break; case Platform.GitHub: CollaborationPlatform = new OctokitClient(_nuKeeperLogger); RepositoryDiscovery = new GitHubRepositoryDiscovery(_nuKeeperLogger, CollaborationPlatform); ForkFinder = new GitHubForkFinder(CollaborationPlatform, _nuKeeperLogger, forkMode); - CommitWorder = new DefaultCommitWorder(); break; case Platform.Bitbucket: CollaborationPlatform = new BitbucketPlatform(_nuKeeperLogger, _httpClientFactory); RepositoryDiscovery = new BitbucketRepositoryDiscovery(_nuKeeperLogger); ForkFinder = new BitbucketForkFinder(CollaborationPlatform, _nuKeeperLogger, forkMode); - CommitWorder = new BitbucketCommitWorder(); + bodyTemplate = new BitbucketPullRequestBodyTemplate { CustomTemplate = _pullrequestBodyTemplate }; break; case Platform.BitbucketLocal: CollaborationPlatform = new BitBucketLocalPlatform(_nuKeeperLogger, _httpClientFactory); RepositoryDiscovery = new BitbucketLocalRepositoryDiscovery(_nuKeeperLogger, CollaborationPlatform, Settings); ForkFinder = new BitbucketForkFinder(CollaborationPlatform, _nuKeeperLogger, forkMode); - CommitWorder = new DefaultCommitWorder(); break; case Platform.GitLab: CollaborationPlatform = new GitlabPlatform(_nuKeeperLogger, _httpClientFactory); RepositoryDiscovery = new GitlabRepositoryDiscovery(_nuKeeperLogger, CollaborationPlatform); ForkFinder = new GitlabForkFinder(CollaborationPlatform, _nuKeeperLogger, forkMode); - CommitWorder = new DefaultCommitWorder(); break; case Platform.Gitea: CollaborationPlatform = new GiteaPlatform(_nuKeeperLogger, _httpClientFactory); RepositoryDiscovery = new GiteaRepositoryDiscovery(_nuKeeperLogger, CollaborationPlatform); ForkFinder = new GiteaForkFinder(CollaborationPlatform, _nuKeeperLogger, forkMode); - CommitWorder = new DefaultCommitWorder(); break; default: throw new NuKeeperException($"Unknown platform: {_platform}"); } + titleTemplate ??= new DefaultPullRequestTitleTemplate { CustomTemplate = _pullrequestTitleTemplate }; + bodyTemplate ??= new DefaultPullRequestBodyTemplate { CustomTemplate = _pullrequestBodyTemplate }; + + InitializeTemplateContext(commitTemplate, _templateContext); + InitializeTemplateContext(titleTemplate, _templateContext); + InitializeTemplateContext(bodyTemplate, _templateContext); + + CommitWorder = new CommitWorder( + commitTemplate, + titleTemplate, + bodyTemplate, + _enricher, + _multiEnricher + ); + var auth = new AuthSettings(Settings.BaseApiUrl, Settings.Token, Settings.Username); CollaborationPlatform.Initialise(auth); @@ -194,5 +256,18 @@ private void CreateForPlatform() throw new NuKeeperException($"Platform {_platform} could not be initialised"); } } + + private static void InitializeTemplateContext(UpdateMessageTemplate template, IDictionary context) + { + template.Clear(); + + if (context != null) + { + foreach (var property in context.Keys) + { + template.AddPlaceholderValue(property, context[property], persist: true); + } + } + } } } diff --git a/NuKeeper/Commands/CollaborationPlatformCommand.cs b/NuKeeper/Commands/CollaborationPlatformCommand.cs index c4b0f3a77..da635b79b 100644 --- a/NuKeeper/Commands/CollaborationPlatformCommand.cs +++ b/NuKeeper/Commands/CollaborationPlatformCommand.cs @@ -2,11 +2,11 @@ using NuKeeper.Abstractions; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; +using NuKeeper.Collaboration; using NuKeeper.Inspection.Logging; using System; using System.Collections.Generic; using System.Threading.Tasks; -using NuKeeper.Collaboration; namespace NuKeeper.Commands { @@ -54,11 +54,22 @@ internal abstract class CollaborationPlatformCommand : CommandBase Description = "Deletes branch created by NuKeeper after merge. Defaults to true.")] public bool? DeleteBranchAfterMerge { get; set; } + [Option(CommandOptionType.SingleValue, ShortName = "prtt", LongName = "pullrequesttitletemplate", + Description = "Mustache template used for creating the pull request title.")] + public string PullRequestTitleTemplate { get; set; } + + [Option(CommandOptionType.SingleValue, ShortName = "prbt", LongName = "pullrequestbodytemplate", + Description = "Mustache template used for creating the pull request body.")] + public string PullRequestBodyTemplate { get; set; } + private HashSet _platformsSupportingDeleteBranchAfterMerge = new HashSet(); - protected CollaborationPlatformCommand(ICollaborationEngine engine, IConfigureLogger logger, - IFileSettingsCache fileSettingsCache, ICollaborationFactory collaborationFactory) : - base(logger, fileSettingsCache) + protected CollaborationPlatformCommand( + ICollaborationEngine engine, + IConfigureLogger logger, + IFileSettingsCache fileSettingsCache, + ICollaborationFactory collaborationFactory + ) : base(logger, fileSettingsCache) { _engine = engine; CollaborationFactory = collaborationFactory; @@ -68,6 +79,12 @@ protected CollaborationPlatformCommand(ICollaborationEngine engine, IConfigureLo _platformsSupportingDeleteBranchAfterMerge.Add(Abstractions.CollaborationPlatform.Platform.Gitea); } + protected override async Task Run(SettingsContainer settings) + { + await _engine.Run(settings); + return 0; + } + protected override async Task PopulateSettings(SettingsContainer settings) { var baseResult = await base.PopulateSettings(settings); @@ -82,6 +99,18 @@ protected override async Task PopulateSettings(SettingsContain var forkMode = ForkMode ?? fileSettings.ForkMode; var platform = Platform ?? fileSettings.Platform; + var pullRequestTitleTemplate = Concat.FirstValue( + PullRequestTitleTemplate, + fileSettings.PullRequestTitleTemplate + ); + settings.UserSettings.PullRequestTitleTemplate = pullRequestTitleTemplate; + + var pullRequestBodyTemplate = Concat.FirstValue( + PullRequestBodyTemplate, + fileSettings.PullRequestBodyTemplate + ); + settings.UserSettings.PullRequestBodyTemplate = pullRequestBodyTemplate; + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var baseUri)) { return ValidationResult.Failure($"Bad Api Base '{endpoint}'"); @@ -90,8 +119,15 @@ protected override async Task PopulateSettings(SettingsContain try { var collaborationResult = await CollaborationFactory.Initialise( - baseUri, PersonalAccessToken, - forkMode, platform); + baseUri, + PersonalAccessToken, + forkMode, + platform, + settings.UserSettings.CommitMessageTemplate, + settings.UserSettings.PullRequestTitleTemplate, + settings.UserSettings.PullRequestBodyTemplate, + settings.UserSettings.Context + ); if (!collaborationResult.IsSuccess) { @@ -110,13 +146,13 @@ protected override async Task PopulateSettings(SettingsContain return ValidationResult.Failure("The required access token was not found"); } - var consolidate = + var consolidate = Concat.FirstValue(Consolidate, fileSettings.Consolidate, false); settings.UserSettings.ConsolidateUpdatesInSinglePullRequest = consolidate; const int defaultMaxPackageUpdates = 3; - var maxPackageUpdates = + var maxPackageUpdates = Concat.FirstValue(MaxPackageUpdates, fileSettings.MaxPackageUpdates, defaultMaxPackageUpdates); settings.PackageFilters.MaxPackageUpdates = maxPackageUpdates; @@ -144,12 +180,6 @@ protected override async Task PopulateSettings(SettingsContain return ValidationResult.Success; } - protected override async Task Run(SettingsContainer settings) - { - await _engine.Run(settings); - return 0; - } - private ValidationResult PopulateDeleteBranchAfterMerge( SettingsContainer settings) { diff --git a/NuKeeper/Commands/CommandBase.cs b/NuKeeper/Commands/CommandBase.cs index cc79836df..283f670df 100644 --- a/NuKeeper/Commands/CommandBase.cs +++ b/NuKeeper/Commands/CommandBase.cs @@ -5,6 +5,7 @@ using NuKeeper.Abstractions.Logging; using NuKeeper.Abstractions.NuGet; using NuKeeper.Abstractions.Output; +using NuKeeper.ConfigurationProviders; using NuKeeper.Engine; using NuKeeper.Inspection.Logging; using System; @@ -81,11 +82,25 @@ internal abstract class CommandBase Description = "Template used for creating the branch name.")] public string BranchNameTemplate { get; set; } + [Option(CommandOptionType.SingleValue, ShortName = "cmt", LongName = "commitmessagetemplate", + Description = "Mustache template used for creating commit messages.")] + public string CommitMessageTemplate { get; set; } + + [Option(CommandOptionType.MultipleValue, ShortName = "", LongName = "context", + Description = @" +Context used when creating commit messages as key=value pairs. +Currently only supports simple values so no arrays, objects, or delegates. +")] + public string[] Context { get; set; } + [Option(CommandOptionType.SingleValue, ShortName = "git", LongName = "gitclipath", Description = "Path to git to use instead of lib2gitsharp implementation")] public string GitCliPath { get; set; } - protected CommandBase(IConfigureLogger logger, IFileSettingsCache fileSettingsCache) + protected CommandBase( + IConfigureLogger logger, + IFileSettingsCache fileSettingsCache + ) { _configureLogger = logger; FileSettingsCache = fileSettingsCache; @@ -108,50 +123,7 @@ public async Task OnExecute() return await Run(settings); } - private void InitialiseLogging() - { - var settingsFromFile = FileSettingsCache.GetSettings(); - - var defaultLogDestination = string.IsNullOrWhiteSpace(LogFile) - ? Abstractions.Logging.LogDestination.Console - : Abstractions.Logging.LogDestination.File; - - var logDest = Concat.FirstValue(LogDestination, settingsFromFile.LogDestination, - defaultLogDestination); - - var logLevel = Concat.FirstValue(Verbosity, settingsFromFile.Verbosity, LogLevel.Normal); - var logFile = Concat.FirstValue(LogFile, settingsFromFile.LogFile, "nukeeper.log"); - - _configureLogger.Initialise(logLevel, logDest, logFile); - } - - private SettingsContainer MakeSettings() - { - var fileSettings = FileSettingsCache.GetSettings(); - var allowedChange = Concat.FirstValue(AllowedChange, fileSettings.Change, VersionChange.Major); - var usePrerelease = Concat.FirstValue(UsePrerelease, fileSettings.UsePrerelease, Abstractions.Configuration.UsePrerelease.FromPrerelease); - var branchNameTemplate = Concat.FirstValue(BranchNameTemplate, fileSettings.BranchNameTemplate); - var gitpath = Concat.FirstValue(GitCliPath, fileSettings.GitCliPath); - - var settings = new SettingsContainer - { - SourceControlServerSettings = new SourceControlServerSettings(), - PackageFilters = new FilterSettings(), - UserSettings = new UserSettings - { - AllowedChange = allowedChange, - UsePrerelease = usePrerelease, - NuGetSources = NuGetSources, - GitPath = gitpath - }, - BranchSettings = new BranchSettings - { - BranchNameTemplate = branchNameTemplate - } - }; - - return settings; - } + protected abstract Task Run(SettingsContainer settings); protected virtual async Task PopulateSettings(SettingsContainer settings) { @@ -199,9 +171,66 @@ protected virtual async Task PopulateSettings(SettingsContaine return branchNameTemplateValid; } + var commitMessageTemplate = Concat.FirstValue( + CommitMessageTemplate, + settingsFromFile.CommitMessageTemplate + ); + settings.UserSettings.CommitMessageTemplate = commitMessageTemplate; + + var context = await new ProvideContext(FileSettingsCache, this).ProvideAsync(settings); + if (!context.IsSuccess) + { + return context; + } + return await Task.FromResult(ValidationResult.Success); } + private void InitialiseLogging() + { + var settingsFromFile = FileSettingsCache.GetSettings(); + + var defaultLogDestination = string.IsNullOrWhiteSpace(LogFile) + ? Abstractions.Logging.LogDestination.Console + : Abstractions.Logging.LogDestination.File; + + var logDest = Concat.FirstValue(LogDestination, settingsFromFile.LogDestination, + defaultLogDestination); + + var logLevel = Concat.FirstValue(Verbosity, settingsFromFile.Verbosity, LogLevel.Normal); + var logFile = Concat.FirstValue(LogFile, settingsFromFile.LogFile, "nukeeper.log"); + + _configureLogger.Initialise(logLevel, logDest, logFile); + } + + private SettingsContainer MakeSettings() + { + var fileSettings = FileSettingsCache.GetSettings(); + var allowedChange = Concat.FirstValue(AllowedChange, fileSettings.Change, VersionChange.Major); + var usePrerelease = Concat.FirstValue(UsePrerelease, fileSettings.UsePrerelease, Abstractions.Configuration.UsePrerelease.FromPrerelease); + var branchNameTemplate = Concat.FirstValue(BranchNameTemplate, fileSettings.BranchNameTemplate); + var gitpath = Concat.FirstValue(GitCliPath, fileSettings.GitCliPath); + + var settings = new SettingsContainer + { + SourceControlServerSettings = new SourceControlServerSettings(), + PackageFilters = new FilterSettings(), + UserSettings = new UserSettings + { + AllowedChange = allowedChange, + UsePrerelease = usePrerelease, + NuGetSources = NuGetSources, + GitPath = gitpath + }, + BranchSettings = new BranchSettings + { + BranchNameTemplate = branchNameTemplate + } + }; + + return settings; + } + private TimeSpan? ReadMinPackageAge() { const string defaultMinPackageAge = "7d"; @@ -316,7 +345,5 @@ private ValidationResult PopulateBranchNameTemplate( settings.BranchSettings.BranchNameTemplate = value; return ValidationResult.Success; } - - protected abstract Task Run(SettingsContainer settings); } } diff --git a/NuKeeper/ConfigurationProviders/ProvideContext.cs b/NuKeeper/ConfigurationProviders/ProvideContext.cs new file mode 100644 index 000000000..b9ecf84a7 --- /dev/null +++ b/NuKeeper/ConfigurationProviders/ProvideContext.cs @@ -0,0 +1,89 @@ +#pragma warning disable CA2208 // Instantiate argument exceptions correctly +#pragma warning disable CA1307 // Specify StringComparison +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Newtonsoft.Json; +using NuKeeper.Abstractions.Configuration; +using NuKeeper.Commands; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace NuKeeper.ConfigurationProviders +{ + internal class ProvideContext : IProvideConfiguration + { + private readonly IFileSettingsCache _fileSettings; + private readonly CommandBase _command; + + public ProvideContext(IFileSettingsCache fileSettings, CommandBase command) + { + _fileSettings = fileSettings; + _command = command; + } + + public async Task ProvideAsync(SettingsContainer settings) + { + if (settings == null) throw new ArgumentNullException(nameof(settings)); + + try + { + var commitTemplateContext = _command.Context?.ToDictionary( + kvp => kvp.Substring(0, kvp.IndexOf('=', 0)), + kvp => (object)kvp.Substring(kvp.IndexOf('=', 0) + 1) + ) ?? _fileSettings.GetSettings().Context; + + foreach (var property in commitTemplateContext?.Keys ?? Enumerable.Empty()) + { + settings.UserSettings.Context.Add(property, commitTemplateContext[property]); + } + + return await ParseDelegatesAsync(settings.UserSettings.Context); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + return ValidationResult.Failure(ex.Message); + } + } + + private static async Task ParseDelegatesAsync(IDictionary context) + { + if (context == null) return ValidationResult.Success; + + const string delegateKey = "_delegates"; + + if (context.ContainsKey(delegateKey)) + { + var delegates = context[delegateKey] as IDictionary + ?? JsonConvert.DeserializeObject>( + context[delegateKey].ToString() + ); + + foreach (var property in delegates.Keys) + { + try + { + var @delegate = await ParseDelegateAsync(delegates[property]); + context.Add(property, @delegate); + } + catch (CompilationErrorException ex) + { + return ValidationResult.Failure(ex.Message); + } + } + + context.Remove(delegateKey); + } + + return ValidationResult.Success; + } + + private static Task ParseDelegateAsync(string delegateString) + { + return CSharpScript.EvaluateAsync(delegateString); + } + } +} diff --git a/NuKeeper/ContainerRegistration.cs b/NuKeeper/ContainerRegistration.cs index 1167a49bf..ea9ec7cc1 100644 --- a/NuKeeper/ContainerRegistration.cs +++ b/NuKeeper/ContainerRegistration.cs @@ -1,10 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; using NuKeeper.Abstractions.Git; +using NuKeeper.Abstractions.RepositoryInspection; using NuKeeper.AzureDevOps; using NuKeeper.BitBucket; using NuKeeper.BitBucketLocal; using NuKeeper.Collaboration; +using NuKeeper.Commands; using NuKeeper.Engine; using NuKeeper.Engine.Packages; using NuKeeper.Git; @@ -12,16 +17,15 @@ using NuKeeper.GitHub; using NuKeeper.Gitlab; using NuKeeper.Local; +using NuKeeper.Update.Process; using NuKeeper.Update.Selection; +using NuKeeper.Validators; using SimpleInjector; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Reflection; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using NuKeeper.Commands; -using NuKeeper.Update.Process; namespace NuKeeper { @@ -96,6 +100,10 @@ private static void Register(Container container) container.RegisterSingleton(); container.RegisterSingleton(); + container.RegisterSingleton(); + container.RegisterSingleton, PackageUpdateSetEnricher>(); + container.RegisterSingleton, UpdateMessageTemplate>, PackageUpdateSetsEnricher>(); + container.RegisterSingleton(); var settingsRegistration = RegisterMultipleSingletons(container, new[] { @@ -108,6 +116,7 @@ private static void Register(Container container) typeof(GiteaSettingsReader).Assembly }); + container.Collection.Register(settingsRegistration); } diff --git a/NuKeeper/Engine/CommitWorder.cs b/NuKeeper/Engine/CommitWorder.cs new file mode 100644 index 000000000..5d472c34a --- /dev/null +++ b/NuKeeper/Engine/CommitWorder.cs @@ -0,0 +1,78 @@ +using NuKeeper.Abstractions.CollaborationModels; +using NuKeeper.Abstractions.CollaborationPlatform; +using NuKeeper.Abstractions.RepositoryInspection; +using System; +using System.Collections.Generic; + +namespace NuKeeper.Engine +{ + public class CommitWorder : ICommitWorder + { + private readonly IEnrichContext _enricher; + private readonly IEnrichContext, UpdateMessageTemplate> _multiEnricher; + + public CommitWorder( + UpdateMessageTemplate commitTemplate, + UpdateMessageTemplate pullrequestTitleTemplate, + UpdateMessageTemplate pullrequestBodyTemplate, + IEnrichContext enricher, + IEnrichContext, UpdateMessageTemplate> multiEnricher + ) + { + CommitTemplate = commitTemplate; + PullrequestTitleTemplate = pullrequestTitleTemplate; + PullrequestBodyTemplate = pullrequestBodyTemplate; + _enricher = enricher; + _multiEnricher = multiEnricher; + } + + public UpdateMessageTemplate CommitTemplate { get; } + public UpdateMessageTemplate PullrequestTitleTemplate { get; } + public UpdateMessageTemplate PullrequestBodyTemplate { get; } + + public string MakePullRequestTitle(IReadOnlyCollection updates) + { + if (updates == null) + { + throw new ArgumentNullException(nameof(updates)); + } + + var template = PullrequestTitleTemplate; + template.Clear(); + _multiEnricher.Enrich(updates, template); + var title = template.Output(); + template.Clear(); + return title; + } + + public string MakeCommitMessage(PackageUpdateSet updates) + { + if (updates == null) + { + throw new ArgumentNullException(nameof(updates)); + } + + var template = CommitTemplate; + template.Clear(); + _enricher.Enrich(updates, template); + var commitMessage = template.Output(); + template.Clear(); + return commitMessage; + } + + public string MakeCommitDetails(IReadOnlyCollection updates) + { + if (updates == null) + { + throw new ArgumentNullException(nameof(updates)); + } + + var template = PullrequestBodyTemplate; + template.Clear(); + _multiEnricher.Enrich(updates, template); + var body = template.Output(); + template.Clear(); + return body; + } + } +} diff --git a/NuKeeper/Engine/DefaultCommitWorder.cs b/NuKeeper/Engine/DefaultCommitWorder.cs deleted file mode 100644 index c67d251b2..000000000 --- a/NuKeeper/Engine/DefaultCommitWorder.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using NuGet.Packaging.Core; -using NuGet.Versioning; -using NuKeeper.Abstractions.CollaborationPlatform; -using NuKeeper.Abstractions.Formats; -using NuKeeper.Abstractions.RepositoryInspection; - -namespace NuKeeper.Engine -{ - public class DefaultCommitWorder : ICommitWorder - { - private const string CommitEmoji = "package"; - - public string MakePullRequestTitle(IReadOnlyCollection updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - if (updates.Count == 1) - { - return PackageTitle(updates.First()); - } - - return $"Automatic update of {updates.Count} packages"; - } - - private static string PackageTitle(PackageUpdateSet updates) - { - return $"Automatic update of {updates.SelectedId} to {updates.SelectedVersion}"; - } - - public string MakeCommitMessage(PackageUpdateSet updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - return $":{CommitEmoji}: {PackageTitle(updates)}"; - } - - public string MakeCommitDetails(IReadOnlyCollection updates) - { - if (updates == null) - { - throw new ArgumentNullException(nameof(updates)); - } - - var builder = new StringBuilder(); - - if (updates.Count > 1) - { - MultiPackagePrefix(updates, builder); - } - - foreach (var update in updates) - { - builder.AppendLine(MakeCommitVersionDetails(update)); - } - - if (updates.Count > 1) - { - MultiPackageFooter(builder); - } - - AddCommitFooter(builder); - - return builder.ToString(); - } - - private static void MultiPackagePrefix(IReadOnlyCollection updates, StringBuilder builder) - { - var packageNames = updates - .Select(p => CodeQuote(p.SelectedId)) - .JoinWithCommas(); - - var projects = updates.SelectMany( - u => u.CurrentPackages) - .Select(p => p.Path.FullName) - .Distinct() - .ToList(); - - var projectOptS = (projects.Count > 1) ? "s" : string.Empty; - - builder.AppendLine($"{updates.Count} packages were updated in {projects.Count} project{projectOptS}:"); - builder.AppendLine(packageNames); - builder.AppendLine("
"); - builder.AppendLine("Details of updated packages"); - builder.AppendLine(""); - } - - private static void MultiPackageFooter(StringBuilder builder) - { - builder.AppendLine("
"); - builder.AppendLine(""); - } - - private static string MakeCommitVersionDetails(PackageUpdateSet updates) - { - var versionsInUse = updates.CurrentPackages - .Select(u => u.Version) - .Distinct() - .ToList(); - - var oldVersions = versionsInUse - .Select(v => CodeQuote(v.ToString())) - .ToList(); - - var minOldVersion = versionsInUse.Min(); - - var newVersion = CodeQuote(updates.SelectedVersion.ToString()); - var packageId = CodeQuote(updates.SelectedId); - - var changeLevel = ChangeLevel(minOldVersion, updates.SelectedVersion); - - var builder = new StringBuilder(); - - if (oldVersions.Count == 1) - { - builder.AppendLine($"NuKeeper has generated a {changeLevel} update of {packageId} to {newVersion} from {oldVersions.JoinWithCommas()}"); - } - else - { - builder.AppendLine($"NuKeeper has generated a {changeLevel} update of {packageId} to {newVersion}"); - builder.AppendLine($"{oldVersions.Count} versions of {packageId} were found in use: {oldVersions.JoinWithCommas()}"); - } - - if (updates.Selected.Published.HasValue) - { - var packageWithVersion = CodeQuote(updates.SelectedId + " " + updates.SelectedVersion); - var pubDateString = CodeQuote(DateFormat.AsUtcIso8601(updates.Selected.Published)); - var pubDate = updates.Selected.Published.Value.UtcDateTime; - var ago = TimeSpanFormat.Ago(pubDate, DateTime.UtcNow); - - builder.AppendLine($"{packageWithVersion} was published at {pubDateString}, {ago}"); - } - - var highestVersion = updates.Packages.Major?.Identity.Version; - if (highestVersion != null && (highestVersion > updates.SelectedVersion)) - { - LogHighestVersion(updates, highestVersion, builder); - } - - builder.AppendLine(); - - if (updates.CurrentPackages.Count == 1) - { - builder.AppendLine("1 project update:"); - } - else - { - builder.AppendLine($"{updates.CurrentPackages.Count} project updates:"); - } - - foreach (var current in updates.CurrentPackages) - { - var line = $"Updated {CodeQuote(current.Path.RelativePath)} to {packageId} {CodeQuote(updates.SelectedVersion.ToString())} from {CodeQuote(current.Version.ToString())}"; - builder.AppendLine(line); - } - - if (SourceIsPublicNuget(updates.Selected.Source.SourceUri)) - { - builder.AppendLine(); - builder.AppendLine(NugetPackageLink(updates.Selected.Identity)); - } - - return builder.ToString(); - } - - private static void AddCommitFooter(StringBuilder builder) - { - builder.AppendLine(); - builder.AppendLine("This is an automated update. Merge only if it passes tests"); - builder.AppendLine("**NuKeeper**: https://github.com/NuKeeperDotNet/NuKeeper"); - } - - private static string ChangeLevel(NuGetVersion oldVersion, NuGetVersion newVersion) - { - if (newVersion.Major > oldVersion.Major) - { - return "major"; - } - - if (newVersion.Minor > oldVersion.Minor) - { - return "minor"; - } - - if (newVersion.Patch > oldVersion.Patch) - { - return "patch"; - } - - if (!newVersion.IsPrerelease && oldVersion.IsPrerelease) - { - return "out of beta"; - } - - return string.Empty; - } - - private static void LogHighestVersion(PackageUpdateSet updates, NuGetVersion highestVersion, StringBuilder builder) - { - var allowedChange = CodeQuote(updates.AllowedChange.ToString()); - var highest = CodeQuote(updates.SelectedId + " " + highestVersion); - - var highestPublishedAt = HighestPublishedAt(updates.Packages.Major.Published); - - builder.AppendLine( - $"There is also a higher version, {highest}{highestPublishedAt}, " + - $"but this was not applied as only {allowedChange} version changes are allowed."); - } - - private static string HighestPublishedAt(DateTimeOffset? highestPublishedAt) - { - if (!highestPublishedAt.HasValue) - { - return string.Empty; - } - - var highestPubDate = highestPublishedAt.Value; - var formattedPubDate = CodeQuote(DateFormat.AsUtcIso8601(highestPubDate)); - var highestAgo = TimeSpanFormat.Ago(highestPubDate.UtcDateTime, DateTime.UtcNow); - - return $" published at {formattedPubDate}, {highestAgo}"; - } - - private static string CodeQuote(string value) - { - return "`" + value + "`"; - } - - private static bool SourceIsPublicNuget(Uri sourceUrl) - { - return - sourceUrl != null && - sourceUrl.ToString().StartsWith("https://api.nuget.org/", StringComparison.OrdinalIgnoreCase); - } - - private static string NugetPackageLink(PackageIdentity package) - { - var url = $"https://www.nuget.org/packages/{package.Id}/{package.Version}"; - return $"[{package.Id} {package.Version} on NuGet.org]({url})"; - } - } -} diff --git a/NuKeeper/Engine/Packages/ExistingCommitFilter.cs b/NuKeeper/Engine/Packages/ExistingCommitFilter.cs index 633ece708..b3e195687 100644 --- a/NuKeeper/Engine/Packages/ExistingCommitFilter.cs +++ b/NuKeeper/Engine/Packages/ExistingCommitFilter.cs @@ -14,7 +14,10 @@ public class ExistingCommitFilter : IExistingCommitFilter private readonly ICollaborationFactory _collaborationFactory; private readonly INuKeeperLogger _logger; - public ExistingCommitFilter(ICollaborationFactory collaborationFactory, INuKeeperLogger logger) + public ExistingCommitFilter( + ICollaborationFactory collaborationFactory, + INuKeeperLogger logger + ) { _collaborationFactory = collaborationFactory; _logger = logger; diff --git a/NuKeeper/Engine/Packages/PackageUpdater.cs b/NuKeeper/Engine/Packages/PackageUpdater.cs index 2f18eb556..a2692201b 100644 --- a/NuKeeper/Engine/Packages/PackageUpdater.cs +++ b/NuKeeper/Engine/Packages/PackageUpdater.cs @@ -19,16 +19,23 @@ public class PackageUpdater : IPackageUpdater private readonly IExistingCommitFilter _existingCommitFilter; private readonly INuKeeperLogger _logger; private readonly IUpdateRunner _updateRunner; + private readonly IEnrichContext _enricher; + private readonly IEnrichContext, UpdateMessageTemplate> _multiEnricher; public PackageUpdater( ICollaborationFactory collaborationFactory, IExistingCommitFilter existingCommitFilter, IUpdateRunner localUpdater, - INuKeeperLogger logger) + IEnrichContext enricher, + IEnrichContext, UpdateMessageTemplate> multiEnricher, + INuKeeperLogger logger + ) { _collaborationFactory = collaborationFactory; _existingCommitFilter = existingCommitFilter; _updateRunner = localUpdater; + _enricher = enricher; + _multiEnricher = multiEnricher; _logger = logger; } diff --git a/NuKeeper/NuKeeper.csproj b/NuKeeper/NuKeeper.csproj index c344610a8..3115dee1d 100644 --- a/NuKeeper/NuKeeper.csproj +++ b/NuKeeper/NuKeeper.csproj @@ -6,20 +6,21 @@ nukeeper nukeeper Automagically update nuget packages in .NET projects - icon.png ..\CodeAnalysisRules.ruleset + + - + runtime; build; native; contentfiles; analyzers all - - + + @@ -38,10 +39,9 @@ Always - - + true tools\$(TargetFramework)\any\NuGet.exe diff --git a/NuKeeper/Validators/StubbleMustacheTemplateValidator.cs b/NuKeeper/Validators/StubbleMustacheTemplateValidator.cs new file mode 100644 index 000000000..173b17889 --- /dev/null +++ b/NuKeeper/Validators/StubbleMustacheTemplateValidator.cs @@ -0,0 +1,25 @@ +using NuKeeper.Abstractions.CollaborationPlatform; +using NuKeeper.Abstractions.Configuration; +using Stubble.Core.Exceptions; +using Stubble.Core.Parser; +using System.Threading.Tasks; + +namespace NuKeeper.Validators +{ + public class StubbleMustacheTemplateValidator : ITemplateValidator + { + public Task ValidateAsync(string template) + { + try + { + MustacheParser.Parse(template); + } + catch (StubbleException ex) + { + return Task.FromResult(ValidationResult.Failure(ex.Message)); + } + + return Task.FromResult(ValidationResult.Success); + } + } +} diff --git a/Nukeeper.AzureDevOps.Tests/AzureDevOpsCommitWorderTests.cs b/Nukeeper.AzureDevOps.Tests/AzureDevOpsCommitWorderTests.cs index 171d73f7f..daf4c4d9b 100644 --- a/Nukeeper.AzureDevOps.Tests/AzureDevOpsCommitWorderTests.cs +++ b/Nukeeper.AzureDevOps.Tests/AzureDevOpsCommitWorderTests.cs @@ -1,12 +1,14 @@ -using System; -using System.Collections.Generic; using NuGet.Packaging.Core; using NuGet.Versioning; using NuKeeper.Abstractions; +using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.RepositoryInspection; +using NuKeeper.Engine; using NuKeeper.Tests; using NUnit.Framework; +using System; +using System.Collections.Generic; namespace NuKeeper.AzureDevOps.Tests { @@ -14,38 +16,47 @@ namespace NuKeeper.AzureDevOps.Tests public class AzureDevOpsCommitWorderTests { private const string CommitEmoji = "📦"; - + // Azure DevOps allows a maximum of 4000 characters to be used in a pull request description: // https://visualstudio.uservoice.com/forums/330519-azure-devops-formerly-visual-studio-team-services/suggestions/20217283-raise-the-character-limit-for-pull-request-descrip private const int MaxCharacterCount = 4000; - private ICommitWorder _sut; - - [SetUp] - public void TestInitialize() - { - _sut = new AzureDevOpsCommitWorder(); - } - [Test] public void MarkPullRequestTitle_UpdateIsCorrect() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakePullRequestTitle(updates); + var report = sut.MakePullRequestTitle(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); Assert.That(report, Is.EqualTo($"{CommitEmoji} Automatic update of foo.bar to 1.2.3")); } + [Test] + public void MakePullRequestTitle_MultipleUpdates_ReturnsNumberOfPackagesInTitle() + { + var updates = new List + { + PackageUpdates.For(MakePackageFor("foo.bar", "1.1.0")), + PackageUpdates.For(MakePackageFor("notfoo.bar", "1.1.5")) + }; + var sut = MakeAzureDevOpsCommitWorder(); + + var report = sut.MakePullRequestTitle(updates); + + Assert.That(report, Is.EqualTo($"{CommitEmoji} Automatic update of 2 packages")); + } + [Test] public void MakeCommitMessage_OneUpdateIsCorrect() { var updates = PackageUpdates.For(MakePackageForV110()); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitMessage(updates); + var report = sut.MakeCommitMessage(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -56,8 +67,9 @@ public void MakeCommitMessage_OneUpdateIsCorrect() public void MakeCommitMessage_TwoUpdatesIsCorrect() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitMessage(updates); + var report = sut.MakeCommitMessage(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -68,8 +80,9 @@ public void MakeCommitMessage_TwoUpdatesIsCorrect() public void MakeCommitMessage_TwoUpdatesSameVersionIsCorrect() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitMessage(updates); + var report = sut.MakeCommitMessage(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -81,8 +94,9 @@ public void OneUpdate_MakeCommitDetails_IsNotEmpty() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -93,8 +107,9 @@ public void OneUpdate_MakeCommitDetails_HasStandardTexts() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); AssertContainsStandardText(report); } @@ -104,10 +119,11 @@ public void OneUpdate_MakeCommitDetails_HasVersionInfo() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3` from `1.1.0`")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3` from `1.1.0`").IgnoreCase); } [Test] @@ -115,8 +131,9 @@ public void OneUpdate_MakeCommitDetails_HasPublishedDate() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("`foo.bar 1.2.3` was published at `2018-02-19T11:12:07Z`")); } @@ -127,8 +144,9 @@ public void OneUpdate_MakeCommitDetails_HasProjectDetailsAsMarkdownTable() { var updates = PackageUpdates.For(MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("### 1 project update:")); Assert.That(report, Does.Contain("| Project | Package | From | To |")); @@ -141,8 +159,9 @@ public void TwoUpdates_MakeCommitDetails_NotEmpty() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -153,8 +172,9 @@ public void TwoUpdates_MakeCommitDetails_HasStandardTexts() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); AssertContainsStandardText(report); Assert.That(report, Does.Contain("1.0.0")); @@ -165,10 +185,11 @@ public void TwoUpdates_MakeCommitDetails_HasVersionInfo() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3`")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3`").IgnoreCase); Assert.That(report, Does.Contain("2 versions of `foo.bar` were found in use: `1.1.0`, `1.0.0`")); } @@ -177,8 +198,9 @@ public void TwoUpdates_MakeCommitDetails_HasProjectList() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV100()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("2 project updates:")); Assert.That(report, Does.Contain("| Project | Package | From | To |")); @@ -192,8 +214,9 @@ public void TwoUpdatesSameVersion_MakeCommitDetails_NotEmpty() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -204,8 +227,9 @@ public void TwoUpdatesSameVersion_MakeCommitDetails_HasStandardTexts() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); AssertContainsStandardText(report); } @@ -215,10 +239,11 @@ public void TwoUpdatesSameVersion_MakeCommitDetails_HasVersionInfo() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3` from `1.1.0`")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3` from `1.1.0`").IgnoreCase); } [Test] @@ -226,8 +251,9 @@ public void TwoUpdatesSameVersion_MakeCommitDetails_HasProjectList() { var updates = PackageUpdates.For(MakePackageForV110(), MakePackageForV110InProject3()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain("2 project updates:")); Assert.That(report, Does.Contain("| Project | Package | From | To |")); @@ -241,8 +267,9 @@ public void OneUpdate_MakeCommitDetails_HasVersionLimitData() { var updates = PackageUpdates.LimitedToMinor(MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain($"There is also a higher version, `foo.bar 2.3.4`, but this was not applied as only `Minor` version changes are allowed.")); } @@ -254,8 +281,9 @@ public void OneUpdateWithDate_MakeCommitDetails_HasVersionLimitDataWithDate() var updates = PackageUpdates.LimitedToMinor(publishedAt, MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Contain($"There is also a higher version, `foo.bar 2.3.4` published at `2018-02-20T11:32:45Z`,")); Assert.That(report, Does.Contain(" ago, but this was not applied as only `Minor` version changes are allowed.")); @@ -267,8 +295,9 @@ public void MakeCommitDetails_DoesNotExceedPullRequestBodyLimit() var packageNameExceedingPullRequestBodyLimit = new string('a', MaxCharacterCount + 1); var updateSet = PackageUpdates.MakeUpdateSet(packageNameExceedingPullRequestBodyLimit) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updateSet); + var report = sut.MakeCommitDetails(updateSet); Assert.That(report, Is.Not.Null); Assert.That(report, Is.Not.Empty); @@ -281,10 +310,11 @@ public void OneUpdateWithMajorVersionChange() { var updates = PackageUpdates.ForNewVersion(new PackageIdentity("foo.bar", new NuGetVersion("2.1.1")), MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a major update of `foo.bar` to `2.1.1` from `1.1.0")); + Assert.That(report, Does.StartWith("NuKeeper has generated a major update of `foo.bar` to `2.1.1` from `1.1.0").IgnoreCase); } [Test] @@ -292,10 +322,11 @@ public void OneUpdateWithMinorVersionChange() { var updates = PackageUpdates.ForNewVersion(new PackageIdentity("foo.bar", new NuGetVersion("1.2.1")), MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.1` from `1.1.0")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.1` from `1.1.0").IgnoreCase); } [Test] @@ -303,10 +334,11 @@ public void OneUpdateWithPatchVersionChange() { var updates = PackageUpdates.ForNewVersion(new PackageIdentity("foo.bar", new NuGetVersion("1.1.9")), MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); - Assert.That(report, Does.StartWith("NuKeeper has generated a patch update of `foo.bar` to `1.1.9` from `1.1.0")); + Assert.That(report, Does.StartWith("NuKeeper has generated a patch update of `foo.bar` to `1.1.9` from `1.1.0").IgnoreCase); } [Test] @@ -314,8 +346,9 @@ public void OneUpdateWithInternalPackageSource() { var updates = PackageUpdates.ForInternalSource(MakePackageForV110()) .InList(); + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.Not.Contain("on NuGet.org")); Assert.That(report, Does.Not.Contain("www.nuget.org")); @@ -331,18 +364,32 @@ public void TwoUpdateSets() PackageUpdates.ForNewVersion(new PackageIdentity("foo.bar", new NuGetVersion("2.1.1")), MakePackageForV110()), PackageUpdates.ForNewVersion(packageTwo, MakePackageForV110("packageTwo")) }; + var sut = MakeAzureDevOpsCommitWorder(); - var report = _sut.MakeCommitDetails(updates); + var report = sut.MakeCommitDetails(updates); Assert.That(report, Does.StartWith("2 packages were updated in 1 project:")); Assert.That(report, Does.Contain("| foo.bar | packageTwo |")); - Assert.That(report, Does.Contain("NuKeeper has generated a major update of `foo.bar` to `2.1.1` from `1.1.0`")); - Assert.That(report, Does.Contain("NuKeeper has generated a major update of `packageTwo` to `3.4.5` from `1.1.0`")); + Assert.That(report, Does.Contain("NuKeeper has generated a major update of `foo.bar` to `2.1.1` from `1.1.0`").IgnoreCase); + Assert.That(report, Does.Contain("NuKeeper has generated a major update of `packageTwo` to `3.4.5` from `1.1.0`").IgnoreCase); + } + + private static CommitWorder MakeAzureDevOpsCommitWorder() + { + var enricher = new PackageUpdateSetEnricher(); + + return new CommitWorder( + new CommitUpdateMessageTemplate(), + new AzureDevOpsPullRequestTitleTemplate(), + new AzureDevOpsPullRequestBodyTemplate(), + enricher, + new PackageUpdateSetsEnricher(enricher) + ); } private static void AssertContainsStandardText(string report) { - Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3`")); + Assert.That(report, Does.StartWith("NuKeeper has generated a minor update of `foo.bar` to `1.2.3`").IgnoreCase); Assert.That(report, Does.Contain("This is an automated update. Merge only if it passes tests")); Assert.That(report, Does.EndWith("**NuKeeper**: https://github.com/NuKeeperDotNet/NuKeeper" + Environment.NewLine)); Assert.That(report, Does.Contain("1.1.0")); @@ -382,5 +429,12 @@ private static string NuGetVersionPackageLink(string packageId, string version) var url = $"https://www.nuget.org/packages/{packageId}/{version}"; return $"[{version}]({url})"; } + + private static PackageInProject MakePackageFor(string packageName, string version) + { + var path = new PackagePath("c:\\temp", "folder\\src\\project1\\packages.config", + PackageReferenceType.PackagesConfig); + return new PackageInProject(packageName, version, path); + } } } diff --git a/Nukeeper.BitBucketLocal/BitbucketLocalRestClient.cs b/Nukeeper.BitBucketLocal/BitbucketLocalRestClient.cs index 98d3faeaa..1aa7eefcd 100644 --- a/Nukeeper.BitBucketLocal/BitbucketLocalRestClient.cs +++ b/Nukeeper.BitBucketLocal/BitbucketLocalRestClient.cs @@ -1,3 +1,7 @@ +using Newtonsoft.Json; +using NuKeeper.Abstractions; +using NuKeeper.Abstractions.Logging; +using NuKeeper.BitBucketLocal.Models; using System; using System.Collections.Generic; using System.Linq; @@ -7,10 +11,6 @@ using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; -using Newtonsoft.Json; -using NuKeeper.Abstractions; -using NuKeeper.Abstractions.Logging; -using NuKeeper.BitBucketLocal.Models; namespace NuKeeper.BitBucketLocal { @@ -124,8 +124,8 @@ public async Task> GetPullRequests( return response.Values .Where(p => p.Open - && p.FromRef.Id.Equals(headBranch,StringComparison.InvariantCultureIgnoreCase) - && p.ToRef.Id.Equals(baseBranch,StringComparison.InvariantCultureIgnoreCase)); + && p.FromRef.Id.Equals(headBranch, StringComparison.InvariantCultureIgnoreCase) + && p.ToRef.Id.Equals(baseBranch, StringComparison.InvariantCultureIgnoreCase)); } public async Task CreatePullRequest(PullRequest pullReq, string projectName, string repositoryName, [CallerMemberName] string caller = null) diff --git a/Nukeeper.BitBucketLocal/NuKeeper.BitBucketLocal.csproj b/Nukeeper.BitBucketLocal/NuKeeper.BitBucketLocal.csproj index accf004fa..5fee26784 100644 --- a/Nukeeper.BitBucketLocal/NuKeeper.BitBucketLocal.csproj +++ b/Nukeeper.BitBucketLocal/NuKeeper.BitBucketLocal.csproj @@ -8,6 +8,10 @@ 1701;1702;CA1051;CA1707;CA2227;CA1056; + + + + diff --git a/site/content/basics/configuration.md b/site/content/basics/configuration.md index b0eb8fdf4..285ba0495 100644 --- a/site/content/basics/configuration.md +++ b/site/content/basics/configuration.md @@ -36,9 +36,13 @@ title: "Configuration" | includerepos | | `org`, `global` | _null_ | | excluderepos | | `org`, `global` | _null_ | | | | | | -| branchnametemplate | | `repo`, `org`, `global`, `update`| _null_ | +| branchnametemplate | | `repo`, `org`, `global`, `update`| _null_ | | deletebranchaftermerge | d | _all_ | true | | setautomerge | | `repo` | false | +| commitmessagetemplate | cmt | `repo`, `org`, `global`, `update`| | +| pullrequesttitletemplate | prtt | `repo`, `org`, `global`, `update` | | +| pullrequestbodytemplate | prbt | `repo`, `org`, `global`, `update` | | +| context | | _all_ | _null_ | * *age* The minimum package age. In order to not consume packages immediately after they are released, exclude updates that do not meet a minimum age. The default is 7 days. This age is the duration between the published date of the selected package update and now. A value can be expressed in command options as an integer and a unit suffix, @@ -109,3 +113,133 @@ Examples: `0` = zero, `12h` = 12 hours, `3d` = 3 days, `2w` = two weeks. * *deletebranchaftermerge* Specifies whether a branch should be automatically deleted or not once the branch has been merged. Currently only works with `Platform` equal to `AzureDevOps`, `Gitlab` or `Bitbucket`. * *setautomerge* Specifies whether a pull request should be merged automatically after passing all checks. Currently only works with `Platform` equal to `AzureDevOps`. +* *commitmessagetemplate* A [mustache](https://mustache.github.io/) template to control the commit messages that are created. The following object structure is available for rendering, but you can define your own additional static properties (see context). + + ```json + { + "packageEmoji": "📦", + "multipleChanges": true, + "packageCount": 3, + "packages": [ + { + "name": "foo.bar", + "version": "1.2.3", + "allowedChange": "Minor", + "actualChange': "Patch", + "publication": { + "date": "2019-06-02", + "ago": "6 months ago" + }, + "projectsUpdated": 1, + "latestVersion": { + "version": "2.1.0", + "url": "https://nuget.com/foo.bar/2.1.0", + "publication": { + "date": "2020-01-09", + "ago": "5 weeks ago" + } + }, + "updates": [ + { + "sourceFilePath": "project_x/packages.config", + "fromVersion": "1.2.1", + "fromUrl": "https://nuget.com/foo.bar/1.2.1", + "toVersion": "1.2.3", + "last": true + } + ], + "sourceUrl": "https://nuget.com/", + "url": "https://nuget.com/foo.bar/1.2.3", + "isFromNuget": true, + "fromVersion": "1.2.1", + "multipleProjectsUpdated": false, + "multipleUpdates": false, + "last": false + }, + { + "name": "notfoo.bar", + "version": "1.9.3", + "allowedChange": "Major", + "actualChange": "Minor", + "publication": { + "date": "2019-08-02", + "ago": "5 1/2 months ago" + }, + "projectsUpdated": 2, + "latestVersion": { + "version": "1.9.3", + "url": "https://nuget.com/notfoo.bar/1.9.3", + "publication": { + "date": "2020-02-01", + "ago": "2 weeks ago" + } + }, + "updates": [ + { + "sourceFilePath": "project_x/packages.config", + "fromVersion": "1.1.0", + "fromUrl": "https://nuget.com/notfoo.bar/1.1.0", + "toVersion": "1.9.3", + "last": false + }, + { + "sourceFilePath": "project_y/packages.config", + "fromVersion": "1.2.9", + "fromUrl": "https://nuget.com/notfoo.bar/1.2.9", + "toVersion": "1.9.3", + "last": true + } + ], + "sourceUrl": "https://nuget.com", + "url": "https://nuget.com/notfoo.bar/1.9.3", + "isFromNuget": true, + "fromVersion": "", + "multipleProjectsUpdated": true, + "multipleUpdates": true + "last": true + } + ], + "projectsUpdated": 2, + "multipleProjects": true, + "footer": { + "nukeeperUrl": "https://github.com/NuKeeper/NuKeeper", + "warningMsg": "**NuKeeper**: https://github.com/NuKeeperDotNet/NuKeeper" + } + } + ``` + + The default template is `{{#packageEmoji}}{{packageEmoji}} {{/packageEmoji}}Automatic update of {{^multipleChanges}}{{#packages}}{{Name}} to {{Version}}{{/packages}}{{/multipleChanges}}{{#multipleChanges}}{{packageCount}} packages{{/multipleChanges}}` + +* *pullrequestbodytemplate* A [mustache](https://mustache.github.io/) template to control the body of the pull request that gets created. The same object structure is available as for commit messages. You can similary define your own additional static properties (see context). The default template depends on the platform. + +* *pullrequesttitletemplate* A [mustache](https://mustache.github.io/) template to control the title of the pull request that gets created. The same object structure is available as for commit messages. You can similary define your own additional static properties (see context). + +* *context* A `key=value` pair that results in the replacement of a `tag` named `key` by the provided `value` in the commit message template. You can provide a custom value for `packageEmoji`, but not for `packageName` or `packageVersion`. + + On the command line you can specify `--context` multiple times. In the json configuration file you only need to add a json object as a property called `context`. + + The context supports delegates in a special property `_delegates`. Here you can provide another json object where each property value can be a `new` expression for a delegate of any of the following types: + + + `Func` + + `Func` + + `Func, object>` + + `Func, object>` + + Note that the required `using` statements need to be present. Here is an example of a template and delegate that can be used to keep the first line of the commit message to <= 50 characters: + + Delegate: + + ```json + { + "Context": { + "_delegates": { + "50char": "using System; new Func, object>((str, render) => { var rendering = render(str); return rendering.Length > 50 ? rendering.Substring(0, 47).PadRight(50, '.') : rendering; })" + } + } + } + ``` + + Template: + + `{{#50char}}chore: Update {{packageName}} to {{packageVersion}}{{/50char}}` +