From 18d6cdba385de5ddab9e613c1b7d373eb719def9 Mon Sep 17 00:00:00 2001 From: CrispyDrone <37639058+CrispyDrone@users.noreply.github.com> Date: Tue, 12 Jan 2021 11:09:37 +0100 Subject: [PATCH] feat: Support specifying threshold for open PRs (#1043) Before, NuKeeper would only generate a single pull request as it would reselect the same updateset every single time, unless some outside factors would influence the result of its prioritization algorithm. Now, you can specify the max number of open pull requests on a per-repository basis. This is currently only supported for Azure Devops/TFS, however only Azure Devops Server has been tested. Since there are no straightforward APIs for figuring out the number of open pull requests, especially when using a PAT with `Code (Read & Write)`, heuristics are used to figure out the number of open pull requests as best as possible. First, the current user is fetched. If this fails, a user by the name of `nukeeper@bot.com` will be fetched. If this fails, all pull requests in the repository will be considered that have the label `nukeeper`. If none of these things were possible, it's assumed that there are 0 open pull requests. When the parameter `--consolidate` is specified, the default value for `--maxopenpullrequests` is 1, otherwise it is `maxpackageupdates`. --- .../Configuration/FileSettingsReaderTests.cs | 6 + .../CollaborationModels/User.cs | 2 + .../ICollaborationPlatform.cs | 1 + .../Configuration/FileSettings.cs | 1 + .../Configuration/UserSettings.cs | 1 + NuKeeper.AzureDevOps/AzureDevOpsRestClient.cs | 22 ++ NuKeeper.AzureDevOps/AzureDevopsPlatform.cs | 91 +++++- NuKeeper.AzureDevOps/AzureDevopsRestTypes.cs | 47 +++ NuKeeper.BitBucket/BitbucketPlatform.cs | 5 + NuKeeper.GitHub/OctokitClient.cs | 5 + NuKeeper.Gitea/GiteaPlatform.cs | 5 + NuKeeper.Gitlab/GitlabPlatform.cs | 5 + .../Commands/RepositoryCommandTests.cs | 59 +++- .../Engine/Packages/PackageUpdaterTests.cs | 280 ++++++++++++++++++ .../Engine/RepositoryUpdaterTests.cs | 188 +++++++++++- .../Commands/CollaborationPlatformCommand.cs | 21 +- NuKeeper/Engine/Packages/IPackageUpdater.cs | 5 +- NuKeeper/Engine/Packages/PackageUpdater.cs | 39 ++- NuKeeper/Engine/RepositoryUpdater.cs | 53 +++- .../BitBucketLocalPlatform.cs | 5 + site/content/basics/configuration.md | 9 + site/content/commands/repository.md | 2 + site/content/platform/azure-devops.md | 9 + 23 files changed, 823 insertions(+), 38 deletions(-) create mode 100644 NuKeeper.Tests/Engine/Packages/PackageUpdaterTests.cs diff --git a/NuKeeper.Abstractions.Tests/Configuration/FileSettingsReaderTests.cs b/NuKeeper.Abstractions.Tests/Configuration/FileSettingsReaderTests.cs index 01a5aaa1d..c876dc6b8 100644 --- a/NuKeeper.Abstractions.Tests/Configuration/FileSettingsReaderTests.cs +++ b/NuKeeper.Abstractions.Tests/Configuration/FileSettingsReaderTests.cs @@ -46,6 +46,7 @@ public void MissingFileReturnsNoSettings() Assert.That(data.Exclude, Is.Null); Assert.That(data.Label, Is.Null); Assert.That(data.MaxPackageUpdates, Is.Null); + Assert.That(data.MaxOpenPullRequests, Is.Null); Assert.That(data.MaxRepo, Is.Null); Assert.That(data.Verbosity, Is.Null); Assert.That(data.Change, Is.Null); @@ -77,6 +78,7 @@ public void EmptyConfigReturnsNoSettings() Assert.That(data.Exclude, Is.Null); Assert.That(data.Label, Is.Null); Assert.That(data.MaxPackageUpdates, Is.Null); + Assert.That(data.MaxOpenPullRequests, Is.Null); Assert.That(data.MaxRepo, Is.Null); Assert.That(data.Verbosity, Is.Null); Assert.That(data.Change, Is.Null); @@ -103,6 +105,7 @@ public void EmptyConfigReturnsNoSettings() ""logFile"":""somefile.log"", ""branchNameTemplate"": ""nukeeper/MyBranch"", ""maxPackageUpdates"": 42, + ""maxOpenPullRequests"": 10, ""maxRepo"": 12, ""verbosity"": ""Detailed"", ""Change"": ""Minor"", @@ -162,6 +165,7 @@ public void PopulatedConfigReturnsNumericSettings() var data = fsr.Read(path); Assert.That(data.MaxPackageUpdates, Is.EqualTo(42)); + Assert.That(data.MaxOpenPullRequests, Is.EqualTo(10)); Assert.That(data.MaxRepo, Is.EqualTo(12)); } @@ -197,6 +201,7 @@ public void ConfigKeysAreCaseInsensitive() ""IncluDeRepoS"":""repo2"", ""label"": [""mark"" ], ""MaxPackageUpdates"":4, + ""MaxOpenPUllrequests"":10, ""MAXrepo"":3, ""vErBoSiTy"": ""Q"", ""CHANGE"": ""PATCH"", @@ -219,6 +224,7 @@ public void ConfigKeysAreCaseInsensitive() Assert.That(data.Label.Count, Is.EqualTo(1)); Assert.That(data.Label, Does.Contain("mark")); Assert.That(data.MaxPackageUpdates, Is.EqualTo(4)); + Assert.That(data.MaxOpenPullRequests, Is.EqualTo(10)); Assert.That(data.MaxRepo, Is.EqualTo(3)); Assert.That(data.Verbosity, Is.EqualTo(LogLevel.Quiet)); Assert.That(data.Change, Is.EqualTo(VersionChange.Patch)); diff --git a/NuKeeper.Abstractions/CollaborationModels/User.cs b/NuKeeper.Abstractions/CollaborationModels/User.cs index 1608bea00..591b51a8b 100644 --- a/NuKeeper.Abstractions/CollaborationModels/User.cs +++ b/NuKeeper.Abstractions/CollaborationModels/User.cs @@ -2,6 +2,8 @@ namespace NuKeeper.Abstractions.CollaborationModels { public class User { + public static readonly User Default = new User("user@email.com", "", ""); + public User(string login, string name, string email) { Login = login; diff --git a/NuKeeper.Abstractions/CollaborationPlatform/ICollaborationPlatform.cs b/NuKeeper.Abstractions/CollaborationPlatform/ICollaborationPlatform.cs index 4fa2a0476..fbb8b6c7a 100644 --- a/NuKeeper.Abstractions/CollaborationPlatform/ICollaborationPlatform.cs +++ b/NuKeeper.Abstractions/CollaborationPlatform/ICollaborationPlatform.cs @@ -26,5 +26,6 @@ public interface ICollaborationPlatform Task RepositoryBranchExists(string userName, string repositoryName, string branchName); Task Search(SearchCodeRequest search); + Task GetNumberOfOpenPullRequests(string projectName, string repositoryName); } } diff --git a/NuKeeper.Abstractions/Configuration/FileSettings.cs b/NuKeeper.Abstractions/Configuration/FileSettings.cs index 45893d930..d750f3ae9 100644 --- a/NuKeeper.Abstractions/Configuration/FileSettings.cs +++ b/NuKeeper.Abstractions/Configuration/FileSettings.cs @@ -43,6 +43,7 @@ public class FileSettings public bool? DeleteBranchAfterMerge { get; set; } public string GitCliPath { get; set; } + public int? MaxOpenPullRequests { get; set; } public static FileSettings Empty() { diff --git a/NuKeeper.Abstractions/Configuration/UserSettings.cs b/NuKeeper.Abstractions/Configuration/UserSettings.cs index 7cd139b32..04b411bb0 100644 --- a/NuKeeper.Abstractions/Configuration/UserSettings.cs +++ b/NuKeeper.Abstractions/Configuration/UserSettings.cs @@ -8,6 +8,7 @@ public class UserSettings public NuGetSources NuGetSources { get; set; } public int MaxRepositoriesChanged { get; set; } + public int MaxOpenPullRequests { get; set; } public bool ConsolidateUpdatesInSinglePullRequest { get; set; } diff --git a/NuKeeper.AzureDevOps/AzureDevOpsRestClient.cs b/NuKeeper.AzureDevOps/AzureDevOpsRestClient.cs index 6c4989ae0..29b730f8a 100644 --- a/NuKeeper.AzureDevOps/AzureDevOpsRestClient.cs +++ b/NuKeeper.AzureDevOps/AzureDevOpsRestClient.cs @@ -115,6 +115,19 @@ public static Uri BuildAzureDevOpsUri(string relativePath, bool previewApi = fal : new Uri($"{relativePath}{separator}api-version=4.1", UriKind.Relative); } + // documentation is confusing, I think this won't work without memberId or ownerId + // https://docs.microsoft.com/en-us/rest/api/azure/devops/account/accounts/list?view=azure-devops-rest-6.0 + public Task> GetCurrentUser() + { + return GetResource>("/_apis/accounts"); + } + + public Task> GetUserByMail(string email) + { + var encodedEmail = HttpUtility.UrlEncode(email); + return GetResource>($"/_apis/identities?searchFilter=MailAddress&filterValue={encodedEmail}"); + } + public async Task> GetProjects() { var response = await GetResource("/_apis/projects"); @@ -148,6 +161,15 @@ public async Task> GetPullRequests( return response?.value.AsEnumerable(); } + public async Task> GetPullRequests(string projectName, string repositoryName, string user) + { + var response = await GetResource( + $"{projectName}/_apis/git/repositories/{repositoryName}/pullrequests?searchCriteria.creatorId={user}" + ); + + return response?.value.AsEnumerable(); + } + public async Task CreatePullRequest(PRRequest request, string projectName, string azureRepositoryId) { var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); diff --git a/NuKeeper.AzureDevOps/AzureDevopsPlatform.cs b/NuKeeper.AzureDevOps/AzureDevopsPlatform.cs index bd3b0f420..59f49d4a6 100644 --- a/NuKeeper.AzureDevOps/AzureDevopsPlatform.cs +++ b/NuKeeper.AzureDevOps/AzureDevopsPlatform.cs @@ -1,3 +1,4 @@ +using NuKeeper.Abstractions; using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; @@ -32,9 +33,42 @@ public void Initialise(AuthSettings settings) _client = new AzureDevOpsRestClient(_clientFactory, _logger, settings.Token, settings.ApiBase); } - public Task GetCurrentUser() + public async Task GetCurrentUser() { - return Task.FromResult(new User("user@email.com", "", "")); + try + { + var currentAccounts = await _client.GetCurrentUser(); + var account = currentAccounts.value.FirstOrDefault(); + + if (account == null) + return User.Default; + + return new User(account.accountId, account.accountName, account.Mail); + + } + catch (NuKeeperException) + { + return User.Default; + } + } + + public async Task GetUserByMail(string email) + { + try + { + var currentAccounts = await _client.GetUserByMail(email); + var account = currentAccounts.value.FirstOrDefault(); + + if (account == null) + return User.Default; + + return new User(account.accountId, account.accountName, account.Mail); + + } + catch (NuKeeperException) + { + return User.Default; + } } public async Task PullRequestExists(ForkData target, string headBranch, string baseBranch) @@ -180,5 +214,58 @@ public async Task Search(SearchCodeRequest searchRequest) return new SearchCodeResult(totalCount); } + + public async Task GetNumberOfOpenPullRequests(string projectName, string repositoryName) + { + var user = await GetCurrentUser(); + + if (user == User.Default) + { + // TODO: allow this to be configurable + user = await GetUserByMail("bot@nukeeper.com"); + } + + var prs = await GetPullRequestsForUser( + projectName, + repositoryName, + user == User.Default ? + string.Empty + : user.Login + ); + + if (user == User.Default) + { + var relevantPrs = prs? + .Where( + pr => pr.labels + ?.FirstOrDefault( + l => l.name.Equals( + "nukeeper", + StringComparison.InvariantCultureIgnoreCase + ) + )?.active ?? false + ); + + return relevantPrs?.Count() ?? 0; + } + else + { + return prs?.Count() ?? 0; + } + } + + private async Task> GetPullRequestsForUser(string projectName, string repositoryName, string userName) + { + try + { + return await _client.GetPullRequests(projectName, repositoryName, userName); + + } + catch (NuKeeperException ex) + { + _logger.Error($"Failed to get pull requests for name {userName}", ex); + return Enumerable.Empty(); + } + } } } diff --git a/NuKeeper.AzureDevOps/AzureDevopsRestTypes.cs b/NuKeeper.AzureDevOps/AzureDevopsRestTypes.cs index 7f7f0d176..0252e44bf 100644 --- a/NuKeeper.AzureDevOps/AzureDevopsRestTypes.cs +++ b/NuKeeper.AzureDevOps/AzureDevopsRestTypes.cs @@ -1,3 +1,4 @@ +using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; @@ -7,6 +8,42 @@ namespace NuKeeper.AzureDevOps #pragma warning disable CA1707 // Identifiers should not contain underscores #pragma warning disable CA2227 // Collection properties should be read only + public class Resource + { + public int count { get; set; } + public IEnumerable value { get; set; } + } + + public class Account + { + public string accountId { get; set; } + public string accountName { get; set; } + public string accountOwner { get; set; } + public Dictionary properties { get; set; } + public string Mail + { + get + { + if (properties.ContainsKey("Mail")) + { + switch (properties["Mail"]) + { + case JObject mailObject: + return mailObject.Property("$value").Value.ToString(); + + case JProperty mailProp: + return mailProp.Value.ToString(); + + case string mailString: + return mailString; + } + } + + return string.Empty; + } + } + } + public class Avatar { public string href { get; set; } @@ -75,6 +112,7 @@ public class PullRequest public string Url { get; set; } public bool SupportsIterations { get; set; } public Creator CreatedBy { get; set; } + public IEnumerable labels { get; set; } // public CreatedBy CreatedBy { get; set; } // public Lastmergesourcecommit LastMergeSourceCommit { get; set; } @@ -82,6 +120,15 @@ public class PullRequest // public Lastmergecommit LastMergeCommit { get; set; } // public IEnumerable Reviewers { get; set; } } + + public class WebApiTagDefinition + { + public bool active { get; set; } + public string id { get; set; } + public string name { get; set; } + public string url { get; set; } + } + public class ProjectResource { public int Count { get; set; } diff --git a/NuKeeper.BitBucket/BitbucketPlatform.cs b/NuKeeper.BitBucket/BitbucketPlatform.cs index 40a98834a..cf223ad20 100644 --- a/NuKeeper.BitBucket/BitbucketPlatform.cs +++ b/NuKeeper.BitBucket/BitbucketPlatform.cs @@ -139,5 +139,10 @@ private static Repository MapRepository(BitBucket.Models.Repository repo) new Uri(repo.links.html.href), null, false, null); } + + public Task GetNumberOfOpenPullRequests(string projectName, string repositoryName) + { + return Task.FromResult(0); + } } } diff --git a/NuKeeper.GitHub/OctokitClient.cs b/NuKeeper.GitHub/OctokitClient.cs index 675a81df5..208cc55c7 100644 --- a/NuKeeper.GitHub/OctokitClient.cs +++ b/NuKeeper.GitHub/OctokitClient.cs @@ -263,5 +263,10 @@ private static async Task ExceptionHandler(Func> funcToCheck) throw new NuKeeperException(ex.Message, ex); } } + + public Task GetNumberOfOpenPullRequests(string projectName, string repositoryName) + { + return Task.FromResult(0); + } } } diff --git a/NuKeeper.Gitea/GiteaPlatform.cs b/NuKeeper.Gitea/GiteaPlatform.cs index 7ef4946ea..7efcf1faa 100644 --- a/NuKeeper.Gitea/GiteaPlatform.cs +++ b/NuKeeper.Gitea/GiteaPlatform.cs @@ -148,5 +148,10 @@ private Repository MapRepository(Gitea.Model.Repository repo) repo.IsFork, repo.Parent != null ? MapRepository(repo.Parent) : null); } + + public Task GetNumberOfOpenPullRequests(string projectName, string repositoryName) + { + return Task.FromResult(0); + } } } diff --git a/NuKeeper.Gitlab/GitlabPlatform.cs b/NuKeeper.Gitlab/GitlabPlatform.cs index bc6e20399..891be9799 100644 --- a/NuKeeper.Gitlab/GitlabPlatform.cs +++ b/NuKeeper.Gitlab/GitlabPlatform.cs @@ -126,5 +126,10 @@ public Task Search(SearchCodeRequest search) _logger.Error($"Search has not yet been implemented for GitLab."); throw new NotImplementedException(); } + + public Task GetNumberOfOpenPullRequests(string projectName, string repositoryName) + { + return Task.FromResult(0); + } } } diff --git a/NuKeeper.Tests/Commands/RepositoryCommandTests.cs b/NuKeeper.Tests/Commands/RepositoryCommandTests.cs index d763f9385..a2e466448 100644 --- a/NuKeeper.Tests/Commands/RepositoryCommandTests.cs +++ b/NuKeeper.Tests/Commands/RepositoryCommandTests.cs @@ -28,7 +28,9 @@ public class RepositoryCommandTests public static async Task<(SettingsContainer settingsContainer, CollaborationPlatformSettings platformSettings)> CaptureSettings( FileSettings settingsIn, bool addLabels = false, - int? maxPackageUpdates = null) + int? maxPackageUpdates = null, + int? maxOpenPullRequests = null + ) { var logger = Substitute.For(); var fileSettings = Substitute.For(); @@ -53,6 +55,7 @@ public class RepositoryCommandTests } command.MaxPackageUpdates = maxPackageUpdates; + command.MaxOpenPullRequests = maxOpenPullRequests; await command.OnExecute(); @@ -124,6 +127,19 @@ public async Task MaxPackageUpdatesFromCommandLineOverridesFiles() Assert.That(settings.PackageFilters.MaxPackageUpdates, Is.EqualTo(101)); } + [Test] + public async Task MaxOpenPullRequestsFromCommandLineOverridesFiles() + { + var fileSettings = new FileSettings + { + MaxOpenPullRequests = 10 + }; + + var (settings, _) = await CaptureSettings(fileSettings, false, null, 15); + + Assert.That(settings.UserSettings.MaxOpenPullRequests, Is.EqualTo(15)); + } + [SetUp] public void Setup() { @@ -540,6 +556,47 @@ public async Task WillReadMaxPackageUpdatesFromFile() Assert.That(settings.PackageFilters.MaxPackageUpdates, Is.EqualTo(42)); } + [Test] + public async Task WillReadMaxOpenPullRequestsFromFile() + { + var fileSettings = new FileSettings + { + MaxOpenPullRequests = 202 + }; + + var (settings, _) = await CaptureSettings(fileSettings); + + Assert.That(settings.UserSettings.MaxOpenPullRequests, Is.EqualTo(202)); + } + + [Test] + public async Task MaxOpenPullRequestsIsOneIfConsolidatedIsTrue() + { + var fileSettings = new FileSettings + { + Consolidate = true, + MaxPackageUpdates = 20 + }; + + var (settings, _) = await CaptureSettings(fileSettings); + + Assert.That(settings.UserSettings.MaxOpenPullRequests, Is.EqualTo(1)); + } + + [Test] + public async Task MaxOpenPullRequestsIsMaxPackageUpdatesIfConsolidatedIsFalse() + { + var fileSettings = new FileSettings + { + Consolidate = false, + MaxPackageUpdates = 20 + }; + + var (settings, _) = await CaptureSettings(fileSettings); + + Assert.That(settings.UserSettings.MaxOpenPullRequests, Is.EqualTo(20)); + } + private static ICollaborationFactory GetCollaborationFactory(IEnvironmentVariablesProvider environmentVariablesProvider, IEnumerable settingReaders = null) { diff --git a/NuKeeper.Tests/Engine/Packages/PackageUpdaterTests.cs b/NuKeeper.Tests/Engine/Packages/PackageUpdaterTests.cs new file mode 100644 index 000000000..d56f621ef --- /dev/null +++ b/NuKeeper.Tests/Engine/Packages/PackageUpdaterTests.cs @@ -0,0 +1,280 @@ +using NSubstitute; +using NuGet.Configuration; +using NuGet.Packaging.Core; +using NuGet.Versioning; +using NuKeeper.Abstractions.CollaborationModels; +using NuKeeper.Abstractions.CollaborationPlatform; +using NuKeeper.Abstractions.Configuration; +using NuKeeper.Abstractions.Git; +using NuKeeper.Abstractions.Logging; +using NuKeeper.Abstractions.NuGet; +using NuKeeper.Abstractions.NuGetApi; +using NuKeeper.Abstractions.RepositoryInspection; +using NuKeeper.Engine; +using NuKeeper.Engine.Packages; +using NuKeeper.Update; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace NuKeeper.Tests.Engine.Packages +{ + [TestFixture] + public class PackageUpdaterTests + { + private ICollaborationFactory _collaborationFactory; + private IExistingCommitFilter _existingCommitFilter; + private IUpdateRunner _updateRunner; + + [SetUp] + public void Initialize() + { + _collaborationFactory = Substitute.For(); + _existingCommitFilter = Substitute.For(); + _updateRunner = Substitute.For(); + + _existingCommitFilter + .Filter( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any() + ) + .Returns(ci => ((IReadOnlyCollection)ci[1])); + } + + [Test] + public async Task MakeUpdatePullRequests_TwoUpdatesOneExistingPrAndMaxOpenPrIsTwo_CreatesOnlyOnePr() + { + _collaborationFactory + .CollaborationPlatform + .GetNumberOfOpenPullRequests(Arg.Any(), Arg.Any()) + .Returns(1); + var packages = new List + { + MakePackageUpdateSet("foo.bar", "1.0.0"), + MakePackageUpdateSet("notfoo.bar", "2.0.0") + }; + var repoData = MakeRepositoryData(); + var settings = MakeSettings(); + settings.UserSettings.MaxOpenPullRequests = 2; + var sut = MakePackageUpdater(); + + var (updatesDone, thresholdReached) = await sut.MakeUpdatePullRequests( + Substitute.For(), + repoData, + packages, + new NuGetSources(""), + settings + ); + + Assert.That(updatesDone, Is.EqualTo(1)); + Assert.That(thresholdReached, Is.True); + } + + [Test] + public async Task MakeUpdatePullRequest_OpenPrsEqualsMaxOpenPrs_DoesNotCreateNewPr() + { + _collaborationFactory + .CollaborationPlatform + .GetNumberOfOpenPullRequests(Arg.Any(), Arg.Any()) + .Returns(2); + var packages = new List + { + MakePackageUpdateSet("foo.bar", "1.0.0") + }; + var repoData = MakeRepositoryData(); + var settings = MakeSettings(); + settings.UserSettings.MaxOpenPullRequests = 2; + var sut = MakePackageUpdater(); + + var (updatesDone, thresHoldReached) = await sut.MakeUpdatePullRequests( + Substitute.For(), + repoData, + packages, + new NuGetSources(""), + settings + ); + + Assert.That(updatesDone, Is.EqualTo(0)); + Assert.That(thresHoldReached, Is.True); + } + + [Test] + public async Task MakeUpdatePullRequest_UpdateDoesNotCreatePrDueToExistingCommits_DoesNotPreventNewUpdates() + { + var packageSetOne = MakePackageUpdateSet("foo.bar", "1.0.0"); + var packageSetTwo = MakePackageUpdateSet("notfoo.bar", "2.0.0"); + _collaborationFactory + .CollaborationPlatform + .GetNumberOfOpenPullRequests(Arg.Any(), Arg.Any()) + .Returns(1); + _existingCommitFilter + .Filter( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any() + ) + .Returns(new List(), new List { packageSetTwo }); + var packages = new List { packageSetOne, packageSetTwo }; + var repoData = MakeRepositoryData(); + var settings = MakeSettings(); + settings.UserSettings.MaxOpenPullRequests = 2; + var sut = MakePackageUpdater(); + + var (updatesDone, _) = await sut.MakeUpdatePullRequests( + Substitute.For(), + repoData, + packages, + new NuGetSources(""), + settings + ); + + Assert.That(updatesDone, Is.EqualTo(1)); + } + + [Test] + public async Task MakeUpdatePullRequest_UpdateDoesNotCreatePrDueToExistingPr_DoesNotPreventNewUpdates() + { + var packageSetOne = MakePackageUpdateSet("foo.bar", "1.0.0"); + var packageSetTwo = MakePackageUpdateSet("notfoo.bar", "2.0.0"); + _collaborationFactory + .CollaborationPlatform + .GetNumberOfOpenPullRequests(Arg.Any(), Arg.Any()) + .Returns(1); + _collaborationFactory + .CollaborationPlatform + .PullRequestExists(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true, false); + var packages = new List { packageSetOne, packageSetTwo }; + var repoData = MakeRepositoryData(); + var settings = MakeSettings(); + settings.UserSettings.MaxOpenPullRequests = 2; + var sut = MakePackageUpdater(); + + var (updatesDone, _) = await sut.MakeUpdatePullRequests( + Substitute.For(), + repoData, + packages, + new NuGetSources(""), + settings + ); + + Assert.That(updatesDone, Is.EqualTo(2)); + } + + [Test] + public async Task MakeUpdatePullRequests_LessPrsThanMaxOpenPrs_ReturnsNotThresholdReached() + { + _collaborationFactory + .CollaborationPlatform + .GetNumberOfOpenPullRequests(Arg.Any(), Arg.Any()) + .Returns(1); + var packages = new List + { + MakePackageUpdateSet("foo.bar", "1.0.0"), + MakePackageUpdateSet("notfoo.bar", "2.0.0") + }; + var repoData = MakeRepositoryData(); + var settings = MakeSettings(); + settings.UserSettings.MaxOpenPullRequests = 10; + var sut = MakePackageUpdater(); + + var (updatesDone, thresholdReached) = await sut.MakeUpdatePullRequests( + Substitute.For(), + repoData, + packages, + new NuGetSources(""), + settings + ); + + Assert.That(thresholdReached, Is.False); + } + + private PackageUpdater MakePackageUpdater() + { + return new PackageUpdater( + _collaborationFactory, + _existingCommitFilter, + _updateRunner, + Substitute.For() + ); + } + + private static RepositoryData MakeRepositoryData() + { + return new RepositoryData( + new ForkData(new Uri("http://foo.com"), "me", "test"), + new ForkData(new Uri("http://foo.com"), "me", "test")); + } + + private static PackageUpdateSet MakePackageUpdateSet(string packageName, string version) + { + return new PackageUpdateSet( + new PackageLookupResult( + VersionChange.Major, + MakePackageSearchMetadata(packageName, version), + null, + null + ), + new List + { + MakePackageInProject(packageName, version) + } + ); + } + + private static SettingsContainer MakeSettings( + bool consolidateUpdates = false + ) + { + return new SettingsContainer + { + SourceControlServerSettings = new SourceControlServerSettings + { + Repository = new RepositorySettings() + }, + UserSettings = new UserSettings + { + ConsolidateUpdatesInSinglePullRequest = consolidateUpdates + }, + BranchSettings = new BranchSettings(), + PackageFilters = new FilterSettings + { + MaxPackageUpdates = 3, + MinimumAge = new TimeSpan(7, 0, 0, 0), + } + }; + } + + private static PackageInProject MakePackageInProject(string packageName, string version) + { + return new PackageInProject( + new PackageVersionRange( + packageName, + VersionRange.Parse(version) + ), + new PackagePath( + "projectA", + "MyFolder", + PackageReferenceType.PackagesConfig + ) + ); + } + + private static PackageSearchMetadata MakePackageSearchMetadata(string packageName, string version) + { + return new PackageSearchMetadata( + new PackageIdentity( + packageName, + NuGetVersion.Parse(version) + ), + new PackageSource("https://api.nuget.com/v3/"), + new DateTimeOffset(2019, 1, 12, 0, 0, 0, TimeSpan.Zero), + null + ); + } + } +} diff --git a/NuKeeper.Tests/Engine/RepositoryUpdaterTests.cs b/NuKeeper.Tests/Engine/RepositoryUpdaterTests.cs index 97c7eb6a4..746615626 100644 --- a/NuKeeper.Tests/Engine/RepositoryUpdaterTests.cs +++ b/NuKeeper.Tests/Engine/RepositoryUpdaterTests.cs @@ -1,5 +1,8 @@ using NSubstitute; -using NuKeeper.Abstractions; +using NUnit.Framework; +using NuGet.Configuration; +using NuGet.Packaging.Core; +using NuGet.Versioning; using NuKeeper.Abstractions.CollaborationModels; using NuKeeper.Abstractions.CollaborationPlatform; using NuKeeper.Abstractions.Configuration; @@ -7,25 +10,55 @@ using NuKeeper.Abstractions.Inspections.Files; using NuKeeper.Abstractions.Logging; using NuKeeper.Abstractions.NuGet; +using NuKeeper.Abstractions.NuGetApi; using NuKeeper.Abstractions.RepositoryInspection; -using NuKeeper.Engine; +using NuKeeper.Abstractions; using NuKeeper.Engine.Packages; -using NuKeeper.Inspection; +using NuKeeper.Engine; using NuKeeper.Inspection.Report; +using NuKeeper.Inspection.Sort; using NuKeeper.Inspection.Sources; -using NuKeeper.Update; +using NuKeeper.Inspection; using NuKeeper.Update.Process; -using NUnit.Framework; -using System; +using NuKeeper.Update.Selection; +using NuKeeper.Update; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using System; namespace NuKeeper.Tests.Engine { [TestFixture] public class RepositoryUpdaterTests { + private INuGetSourcesReader _sourcesReader; + private INuKeeperLogger _nukeeperLogger; + private IUpdateFinder _updateFinder; + private IPackageUpdater _packageUpdater; + private List _packagesToReturn; + + [SetUp] + public void Initialize() + { + _packagesToReturn = new List(); + + _sourcesReader = Substitute.For(); + _nukeeperLogger = Substitute.For(); + _updateFinder = Substitute.For(); + _packageUpdater = Substitute.For(); + _updateFinder + .FindPackageUpdateSets( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(_packagesToReturn); + } + [Test] public async Task WhenThereAreNoUpdates_CountIsZero() { @@ -85,7 +118,14 @@ public async Task WhenThereIsAnUpdate_CountIsOne() [TestCase(1, 0, false, false, 1, 1)] [TestCase(1, 1, false, false, 0, 0)] - public async Task WhenThereAreUpdates_CountIsAsExpected(int numberOfUpdates, int existingCommitsPerBranch, bool consolidateUpdates, bool pullRequestExists, int expectedUpdates, int expectedPrs) + public async Task WhenThereAreUpdates_CountIsAsExpected( + int numberOfUpdates, + int existingCommitsPerBranch, + bool consolidateUpdates, + bool pullRequestExists, + int expectedUpdates, + int expectedPrs + ) { var updateSelection = Substitute.For(); var collaborationFactory = Substitute.For(); @@ -160,6 +200,101 @@ public async Task WhenUpdatesAreFilteredOut_CountIsZero() await AssertDidNotReceiveMakeUpdate(packageUpdater); } + [Test] + public async Task Run_TwoUpdatesOneExistingPrAndMaxOpenPrIsGreaterThanOne_CreatesPrForSecondUpdate() + { + _packagesToReturn.Add(MakePackageUpdateSet("foo.bar", "1.0.0")); + _packagesToReturn.Add(MakePackageUpdateSet("notfoo.bar", "2.0.0")); + _packageUpdater + .MakeUpdatePullRequests( + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any() + ) + .Returns((0, false), (1, false)); + var repoData = MakeRepositoryData(); + var settings = MakeSettings(); + settings.UserSettings.MaxOpenPullRequests = 2; + settings.PackageFilters.MaxPackageUpdates = 1; + var sut = MakeRepositoryUpdater(); + + var result = await sut.Run(Substitute.For(), repoData, settings); + + Assert.That(result, Is.EqualTo(1)); + } + + [Test] + public async Task Run_MultipleUpdatesMaxPackageUpdatesIsOne_StillOnlyCreatesOnePr() + { + _packagesToReturn.Add(MakePackageUpdateSet("foo.bar", "1.0.0")); + _packagesToReturn.Add(MakePackageUpdateSet("notfoo.bar", "2.0.0")); + _packagesToReturn.Add(MakePackageUpdateSet("baz.bar", "3.0.0")); + _packageUpdater + .MakeUpdatePullRequests( + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any() + ) + .Returns(ci => (((IReadOnlyCollection)ci[2]).Count, false)); + var repoData = MakeRepositoryData(); + var settings = MakeSettings(); + settings.UserSettings.MaxOpenPullRequests = 10; + settings.PackageFilters.MaxPackageUpdates = 1; + var sut = MakeRepositoryUpdater(); + + var result = await sut.Run(Substitute.For(), repoData, settings); + + Assert.That(result, Is.EqualTo(1)); + } + + private static PackageUpdateSet MakePackageUpdateSet(string packageName, string version) + { + return new PackageUpdateSet( + new PackageLookupResult( + VersionChange.Major, + MakePackageSearchMetadata(packageName, version), + null, + null + ), + new List + { + MakePackageInProject(packageName, version) + } + ); + } + + private static PackageInProject MakePackageInProject(string packageName, string version) + { + return new PackageInProject( + new PackageVersionRange( + packageName, + VersionRange.Parse(version) + ), + new PackagePath( + "projectA", + "MyFolder", + PackageReferenceType.PackagesConfig + ) + ); + } + + private static PackageSearchMetadata MakePackageSearchMetadata(string packageName, string version) + { + return new PackageSearchMetadata( + new PackageIdentity( + packageName, + NuGetVersion.Parse(version) + ), + new PackageSource("https://api.nuget.com/v3/"), + new DateTimeOffset(2019, 1, 12, 0, 0, 0, TimeSpan.Zero), + null + ); + } + private static async Task AssertReceivedMakeUpdate( IPackageUpdater packageUpdater, int count) @@ -203,7 +338,9 @@ private static void UpdateSelectionNone(IPackageUpdateSelection updateSelection) .Returns(new List()); } - private static SettingsContainer MakeSettings(bool consolidateUpdates = false) + private static SettingsContainer MakeSettings( + bool consolidateUpdates = false + ) { return new SettingsContainer { @@ -213,14 +350,22 @@ private static SettingsContainer MakeSettings(bool consolidateUpdates = false) }, UserSettings = new UserSettings { + MaxOpenPullRequests = int.MaxValue, ConsolidateUpdatesInSinglePullRequest = consolidateUpdates }, - BranchSettings = new BranchSettings() + BranchSettings = new BranchSettings(), + PackageFilters = new FilterSettings + { + MaxPackageUpdates = 3, + MinimumAge = new TimeSpan(7, 0, 0, 0), + } }; } - private static - (IRepositoryUpdater repositoryUpdater, IPackageUpdater packageUpdater) MakeRepositoryUpdater( + private static ( + IRepositoryUpdater repositoryUpdater, + IPackageUpdater packageUpdater + ) MakeRepositoryUpdater( IPackageUpdateSelection updateSelection, List updates, IPackageUpdater packageUpdater = null) @@ -246,7 +391,7 @@ private static Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(1); + .Returns((1, false)); } var repoUpdater = new RepositoryUpdater( @@ -257,6 +402,25 @@ private static return (repoUpdater, packageUpdater); } + private RepositoryUpdater MakeRepositoryUpdater() + { + var packageUpdateSelector = new PackageUpdateSelection( + new PackageUpdateSetSort(_nukeeperLogger), + new UpdateSelection(_nukeeperLogger), + _nukeeperLogger + ); + + return new RepositoryUpdater( + _sourcesReader, + _updateFinder, + packageUpdateSelector, + _packageUpdater, + _nukeeperLogger, + Substitute.For(), + Substitute.For() + ); + } + private static RepositoryData MakeRepositoryData() { return new RepositoryData( diff --git a/NuKeeper/Commands/CollaborationPlatformCommand.cs b/NuKeeper/Commands/CollaborationPlatformCommand.cs index 2af4e9198..c4b0f3a77 100644 --- a/NuKeeper/Commands/CollaborationPlatformCommand.cs +++ b/NuKeeper/Commands/CollaborationPlatformCommand.cs @@ -28,6 +28,10 @@ internal abstract class CollaborationPlatformCommand : CommandBase Description = "The maximum number of package updates to apply on one repository. Defaults to 3.")] public int? MaxPackageUpdates { get; set; } + [Option(CommandOptionType.SingleValue, ShortName = "", LongName = "maxopenpullrequests", + Description = "The maximum number of open pull requests for one repository. Defaults to 1 if `--consolidate` is specified, otherwise defaults to `--maxpackageupdates`.")] + public int? MaxOpenPullRequests { get; set; } + [Option(CommandOptionType.NoValue, ShortName = "n", LongName = "consolidate", Description = "Consolidate updates into a single pull request. Defaults to false.")] public bool? Consolidate { get; set; } @@ -106,13 +110,26 @@ protected override async Task PopulateSettings(SettingsContain return ValidationResult.Failure("The required access token was not found"); } - settings.UserSettings.ConsolidateUpdatesInSinglePullRequest = + var consolidate = Concat.FirstValue(Consolidate, fileSettings.Consolidate, false); + settings.UserSettings.ConsolidateUpdatesInSinglePullRequest = consolidate; + const int defaultMaxPackageUpdates = 3; - settings.PackageFilters.MaxPackageUpdates = + var maxPackageUpdates = Concat.FirstValue(MaxPackageUpdates, fileSettings.MaxPackageUpdates, defaultMaxPackageUpdates); + settings.PackageFilters.MaxPackageUpdates = maxPackageUpdates; + + const int defaultMaxOpenPullRequests = 1; + settings.UserSettings.MaxOpenPullRequests = Concat.FirstValue( + MaxOpenPullRequests, + fileSettings.MaxOpenPullRequests, + consolidate ? + defaultMaxOpenPullRequests + : maxPackageUpdates + ); + var defaultLabels = new List { "nukeeper" }; settings.SourceControlServerSettings.Labels = diff --git a/NuKeeper/Engine/Packages/IPackageUpdater.cs b/NuKeeper/Engine/Packages/IPackageUpdater.cs index 26e956d97..9b10d3cfa 100644 --- a/NuKeeper/Engine/Packages/IPackageUpdater.cs +++ b/NuKeeper/Engine/Packages/IPackageUpdater.cs @@ -9,11 +9,12 @@ namespace NuKeeper.Engine.Packages { public interface IPackageUpdater { - Task MakeUpdatePullRequests( + Task<(int UpdatesMade, bool ThresholdReached)> MakeUpdatePullRequests( IGitDriver git, RepositoryData repository, IReadOnlyCollection updates, NuGetSources sources, - SettingsContainer settings); + SettingsContainer settings + ); } } diff --git a/NuKeeper/Engine/Packages/PackageUpdater.cs b/NuKeeper/Engine/Packages/PackageUpdater.cs index c0ec80ce8..2f18eb556 100644 --- a/NuKeeper/Engine/Packages/PackageUpdater.cs +++ b/NuKeeper/Engine/Packages/PackageUpdater.cs @@ -32,12 +32,13 @@ public PackageUpdater( _logger = logger; } - public async Task MakeUpdatePullRequests( + public async Task<(int UpdatesMade, bool ThresholdReached)> MakeUpdatePullRequests( IGitDriver git, RepositoryData repository, IReadOnlyCollection updates, NuGetSources sources, - SettingsContainer settings) + SettingsContainer settings + ) { if (settings == null) { @@ -54,6 +55,19 @@ public async Task MakeUpdatePullRequests( throw new ArgumentNullException(nameof(repository)); } + var openPrs = await _collaborationFactory.CollaborationPlatform.GetNumberOfOpenPullRequests( + repository.Pull.Owner, + repository.Pull.Name + ); + + var allowedPrs = settings.UserSettings.MaxOpenPullRequests; + + if (openPrs >= allowedPrs) + { + _logger.Normal("Number of open pull requests equals or exceeds allowed number of open pull requests."); + return (0, true); + } + int totalCount = 0; var groups = UpdateConsolidator.Consolidate(updates, @@ -61,17 +75,25 @@ public async Task MakeUpdatePullRequests( foreach (var updateSets in groups) { - var updatesMade = await MakeUpdatePullRequests( + var (updatesMade, pullRequestCreated) = await MakeUpdatePullRequests( git, repository, sources, settings, updateSets); totalCount += updatesMade; + + if (pullRequestCreated) + openPrs++; + + if (openPrs == allowedPrs) + { + return (totalCount, true); + } } - return totalCount; + return (totalCount, false); } - private async Task MakeUpdatePullRequests( + private async Task<(int UpdatesMade, bool PullRequestCreated)> MakeUpdatePullRequests( IGitDriver git, RepositoryData repository, NuGetSources sources, SettingsContainer settings, IReadOnlyCollection updates) @@ -108,6 +130,9 @@ private async Task MakeUpdatePullRequests( await git.Commit(commitMessage); } + bool pullRequestCreated = false; + + // bug: pr might not have been created yet if (haveUpdates) { await git.Push(repository.Remote, branchWithChanges); @@ -132,6 +157,8 @@ private async Task MakeUpdatePullRequests( var pullRequestRequest = new PullRequestRequest(qualifiedBranch, title, repository.DefaultBranch, settings.BranchSettings.DeleteBranchAfterMerge, settings.SourceControlServerSettings.Repository.SetAutoMerge) { Body = body }; await _collaborationFactory.CollaborationPlatform.OpenPullRequest(repository.Pull, pullRequestRequest, settings.SourceControlServerSettings.Labels); + + pullRequestCreated = true; } else { @@ -139,7 +166,7 @@ private async Task MakeUpdatePullRequests( } } await git.Checkout(repository.DefaultBranch); - return filteredUpdates.Count; + return (filteredUpdates.Count, pullRequestCreated); } } } diff --git a/NuKeeper/Engine/RepositoryUpdater.cs b/NuKeeper/Engine/RepositoryUpdater.cs index 4d6be81a6..597b6a452 100644 --- a/NuKeeper/Engine/RepositoryUpdater.cs +++ b/NuKeeper/Engine/RepositoryUpdater.cs @@ -11,6 +11,8 @@ using NuKeeper.Update.Process; using System.Collections.Generic; using System.Threading.Tasks; +using System.Linq; +using System.Collections.ObjectModel; namespace NuKeeper.Engine { @@ -31,7 +33,8 @@ public RepositoryUpdater( IPackageUpdater packageUpdater, INuKeeperLogger logger, ISolutionRestore solutionRestore, - IReporter reporter) + IReporter reporter + ) { _nugetSourcesReader = nugetSourcesReader; _updateFinder = updateFinder; @@ -92,30 +95,54 @@ public async Task Run( return 0; } - var targetUpdates = _updateSelection.SelectTargets( - repository.Push, - updates, - settings.PackageFilters); + while (updates.Any()) + { + var targetUpdates = _updateSelection.SelectTargets( + repository.Push, + updates, + settings.PackageFilters + ); + + if (!targetUpdates.Any()) + { + _logger.Minimal("No updates can be applied. Exiting."); + return 0; + } + + var (updatesDone, thresholdReached) = await DoTargetUpdates(git, repository, targetUpdates, + sources, settings); + + if (updatesDone != 0) + { + return updatesDone; + } + + if (thresholdReached.GetValueOrDefault()) + return 0; + + updates = new ReadOnlyCollection( + updates.Except(targetUpdates).ToList() + ); + } - return await DoTargetUpdates(git, repository, targetUpdates, - sources, settings); + return 0; } - private async Task DoTargetUpdates( + private async Task<(int UpdatesMade, bool? ThresholdReached)> DoTargetUpdates( IGitDriver git, RepositoryData repository, IReadOnlyCollection targetUpdates, NuGetSources sources, - SettingsContainer settings) + SettingsContainer settings + ) { if (targetUpdates.Count == 0) { - _logger.Minimal("No updates can be applied. Exiting."); - return 0; + return (0, null); } await _solutionRestore.CheckRestore(targetUpdates, settings.WorkingFolder ?? git.WorkingFolder, sources); - var updatesDone = await _packageUpdater.MakeUpdatePullRequests(git, repository, targetUpdates, sources, settings); + var (updatesDone, thresholdReached) = await _packageUpdater.MakeUpdatePullRequests(git, repository, targetUpdates, sources, settings); if (updatesDone < targetUpdates.Count) { @@ -126,7 +153,7 @@ private async Task DoTargetUpdates( _logger.Normal($"Done {updatesDone} updates"); } - return updatesDone; + return (updatesDone, thresholdReached); } private static async Task GitInit(IGitDriver git, RepositoryData repository) diff --git a/Nukeeper.BitBucketLocal/BitBucketLocalPlatform.cs b/Nukeeper.BitBucketLocal/BitBucketLocalPlatform.cs index b2e8f271b..84c179981 100644 --- a/Nukeeper.BitBucketLocal/BitBucketLocalPlatform.cs +++ b/Nukeeper.BitBucketLocal/BitBucketLocalPlatform.cs @@ -167,5 +167,10 @@ public async Task Search(SearchCodeRequest searchRequest) return new SearchCodeResult(totalCount); } + + public Task GetNumberOfOpenPullRequests(string projectName, string repositoryName) + { + return Task.FromResult(0); + } } } diff --git a/site/content/basics/configuration.md b/site/content/basics/configuration.md index 1ee58e005..b0eb8fdf4 100644 --- a/site/content/basics/configuration.md +++ b/site/content/basics/configuration.md @@ -27,6 +27,7 @@ title: "Configuration" | fork | f | `repo`, `org`, `global` | PreferFork | | label | l | `repo`, `org`, `global` | 'nukeeper' | | maxpackageupdates| m | `repo`, `org`, `global`, `update`| 3, or when the command is `update`, 1 | +| maxopenpullrequests | | `repo`, `org`, `global`| 1 if `consolidate`, else `maxpackageupdates` | | consolidate | n | `repo`, `org`, `global` | false | | platform | | `repo`, `org`, `global` | _null_ | | gitclipath | git | `repo`, `org`, `global` | _null_ (use default Lib2Git-Implementation) | @@ -70,6 +71,14 @@ Examples: `0` = zero, `12h` = 12 hours, `3d` = 3 days, `2w` = two weeks. * *fork* Values are `PreferFork`, `PreferSingleRepository` and `SingleRepositoryOnly`. Prefer to make branches on a fork of the target repository, or on that repository itself. See the section "Branches, forks and pull requests" below. * *label* Label to apply to GitHub pull requests. Can be specified multiple times. * *maxpackageupdates* The maximum number of package updates to apply. In `repo`,`org` and `global` commands, this limits the number of updates per repository. If the `--consolidate` flag is used, these wll be consolidated into one Pull Request. If not, then there will be one Pull Request per update applied. In the `update` command, The default value is 1. When changed, multiple updates can be applied in a single `update` run, up to this number. +* *maxopenpullrequests* The maximum number of pull requests that can be active in one repository at a time. If the `--consolidate` flag is used, the default value is 1. If not, then the default will be `maxpackageupdates`. This currently only works for AzureDevops/TFS. + + To be able to figure out the number of active pull requests, different strategies are used: + + 1. First, only pull requests created by the identity linked to the PAT are considered, however this is only possible for Azure Devops Services, and not the on-premise version, as it does not provide any (straightforward) API to be able to figure this out. The cloud version does provide an API that should allow us to figure out the current account, but this requires a token with a scope that can read identities/account information. + 2. If no account or identity can be determined based on the PAT. Then, currently a hardcoded, user `nukeeper@bot.com` is considered. Again this requires a broader scope. + 3. If no user identity was able to be fetched, then it will fetch all active PRs and consider only those with labels `nukeeper` attached to it. + * *maxrepo* The maximum number of repositories to change. Used in Organisation and Global mode. * *consolidate* Consolidate updates into a single pull request, instead of the default of 1 pull request per package update applied. * *platform* One of `GitHub`, `AzureDevOps`, `Bitbucket`, `BitbucketLocal`, `Gitlab`, `Gitea`. Determines which kind of source control api will be used. This is typicaly infered from the api url structure, but since this does not always work, it can be specified here if neccessary. diff --git a/site/content/commands/repository.md b/site/content/commands/repository.md index 3d6cedb45..b0d767bcf 100644 --- a/site/content/commands/repository.md +++ b/site/content/commands/repository.md @@ -93,3 +93,5 @@ NuKeeper sorts the pull requests, using heuristics so that more impactful update `--change minor` Do not allow major version changes. `--age 10d` Do not apply any version change until it has been available for 10 days. The default is 7 days. You can also specify this value in weeks with e.g. `age=6w` or hours with e.g. `age=12h`. + +`--maxopenpullrequests 10` will ensure that no more than 10 pull requests can be active in a single repository at the same time. The default value is 1 if `consolidate` is specified, otherwise `maxpackageupdates`. diff --git a/site/content/platform/azure-devops.md b/site/content/platform/azure-devops.md index 02f511057..3248af361 100644 --- a/site/content/platform/azure-devops.md +++ b/site/content/platform/azure-devops.md @@ -105,3 +105,12 @@ Add any additional arguments that are available for the repo command nukeeper repo "https://dev.azure.com/{org}/{project}/_git/{repo}/" -m 10 ``` The `-m 10` tells NuKeeper that it may update 10 packages. For more parameters checkout the [Configuration](/basics/configuration/) page. + +#### Setting a custom limit on the number of open pull requests + +You can instruct nukeeper to not create more pull requests than allowed by specifying the `--maxopenpullrequests` parameter. The strategy for figuring out how many active pull requests there are is explained in the [configuration page](/basics/configuration/). + +```sh +nukeeper repo "https://dev.azure.com/{org}/{project}/_git/{repo}" --maxopenpullrequests 10 +``` +