diff --git a/Gemfile.lock b/Gemfile.lock index 49998eb1cff..d80d35516c8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,20 +1,20 @@ PATH remote: bundler specs: - dependabot-bundler (0.282.0) - dependabot-common (= 0.282.0) + dependabot-bundler (0.283.0) + dependabot-common (= 0.283.0) parallel (~> 1.24) PATH remote: cargo specs: - dependabot-cargo (0.282.0) - dependabot-common (= 0.282.0) + dependabot-cargo (0.283.0) + dependabot-common (= 0.283.0) PATH remote: common specs: - dependabot-common (0.282.0) + dependabot-common (0.283.0) aws-sdk-codecommit (~> 1.28) aws-sdk-ecr (~> 1.5) bundler (>= 1.16, < 3.0.0) @@ -38,107 +38,107 @@ PATH PATH remote: composer specs: - dependabot-composer (0.282.0) - dependabot-common (= 0.282.0) + dependabot-composer (0.283.0) + dependabot-common (= 0.283.0) PATH remote: devcontainers specs: - dependabot-devcontainers (0.282.0) - dependabot-common (= 0.282.0) + dependabot-devcontainers (0.283.0) + dependabot-common (= 0.283.0) PATH remote: docker specs: - dependabot-docker (0.282.0) - dependabot-common (= 0.282.0) + dependabot-docker (0.283.0) + dependabot-common (= 0.283.0) PATH remote: elm specs: - dependabot-elm (0.282.0) - dependabot-common (= 0.282.0) + dependabot-elm (0.283.0) + dependabot-common (= 0.283.0) PATH remote: git_submodules specs: - dependabot-git_submodules (0.282.0) - dependabot-common (= 0.282.0) + dependabot-git_submodules (0.283.0) + dependabot-common (= 0.283.0) parseconfig (~> 1.0, < 1.1.0) PATH remote: github_actions specs: - dependabot-github_actions (0.282.0) - dependabot-common (= 0.282.0) + dependabot-github_actions (0.283.0) + dependabot-common (= 0.283.0) PATH remote: go_modules specs: - dependabot-go_modules (0.282.0) - dependabot-common (= 0.282.0) + dependabot-go_modules (0.283.0) + dependabot-common (= 0.283.0) PATH remote: gradle specs: - dependabot-gradle (0.282.0) - dependabot-common (= 0.282.0) - dependabot-maven (= 0.282.0) + dependabot-gradle (0.283.0) + dependabot-common (= 0.283.0) + dependabot-maven (= 0.283.0) PATH remote: hex specs: - dependabot-hex (0.282.0) - dependabot-common (= 0.282.0) + dependabot-hex (0.283.0) + dependabot-common (= 0.283.0) PATH remote: maven specs: - dependabot-maven (0.282.0) - dependabot-common (= 0.282.0) + dependabot-maven (0.283.0) + dependabot-common (= 0.283.0) PATH remote: npm_and_yarn specs: - dependabot-npm_and_yarn (0.282.0) - dependabot-common (= 0.282.0) + dependabot-npm_and_yarn (0.283.0) + dependabot-common (= 0.283.0) PATH remote: nuget specs: - dependabot-nuget (0.282.0) - dependabot-common (= 0.282.0) + dependabot-nuget (0.283.0) + dependabot-common (= 0.283.0) rubyzip (>= 2.3.2, < 3.0) PATH remote: pub specs: - dependabot-pub (0.282.0) - dependabot-common (= 0.282.0) + dependabot-pub (0.283.0) + dependabot-common (= 0.283.0) PATH remote: python specs: - dependabot-python (0.282.0) - dependabot-common (= 0.282.0) + dependabot-python (0.283.0) + dependabot-common (= 0.283.0) PATH remote: silent specs: - dependabot-silent (0.282.0) - dependabot-common (= 0.282.0) + dependabot-silent (0.283.0) + dependabot-common (= 0.283.0) PATH remote: swift specs: - dependabot-swift (0.282.0) - dependabot-common (= 0.282.0) + dependabot-swift (0.283.0) + dependabot-common (= 0.283.0) PATH remote: terraform specs: - dependabot-terraform (0.282.0) - dependabot-common (= 0.282.0) + dependabot-terraform (0.283.0) + dependabot-common (= 0.283.0) GEM remote: https://rubygems.org/ diff --git a/common/lib/dependabot.rb b/common/lib/dependabot.rb index d1339e58c5f..711e5c0ee8f 100644 --- a/common/lib/dependabot.rb +++ b/common/lib/dependabot.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Dependabot - VERSION = "0.282.0" + VERSION = "0.283.0" end diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Run.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Run.cs index fb82c7bd40c..f3ca520d8bf 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Run.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Run.cs @@ -41,7 +41,6 @@ await RunAsync( ], job: new Job() { - PackageManager = "nuget", AllowedUpdates = [ new() { diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/CloneCommand.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/CloneCommand.cs new file mode 100644 index 00000000000..581d95de0f9 --- /dev/null +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/CloneCommand.cs @@ -0,0 +1,40 @@ +using System.CommandLine; + +using NuGetUpdater.Core; +using NuGetUpdater.Core.Clone; +using NuGetUpdater.Core.Run; + +namespace NuGetUpdater.Cli.Commands; + +internal static class CloneCommand +{ + internal static readonly Option JobPathOption = new("--job-path") { IsRequired = true }; + internal static readonly Option RepoContentsPathOption = new("--repo-contents-path") { IsRequired = true }; + internal static readonly Option ApiUrlOption = new("--api-url") { IsRequired = true }; + internal static readonly Option JobIdOption = new("--job-id") { IsRequired = true }; + + internal static Command GetCommand(Action setExitCode) + { + var command = new Command("clone", "Clones a repository in preparation for a dependabot job.") + { + JobPathOption, + RepoContentsPathOption, + ApiUrlOption, + JobIdOption, + }; + + command.TreatUnmatchedTokensAsErrors = true; + + command.SetHandler(async (jobPath, repoContentsPath, apiUrl, jobId) => + { + var apiHandler = new HttpApiHandler(apiUrl.ToString(), jobId); + var logger = new ConsoleLogger(); + var gitCommandHandler = new ShellGitCommandHandler(logger); + var worker = new CloneWorker(apiHandler, gitCommandHandler, logger); + var exitCode = await worker.RunAsync(jobPath, repoContentsPath); + setExitCode(exitCode); + }, JobPathOption, RepoContentsPathOption, ApiUrlOption, JobIdOption); + + return command; + } +} diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Program.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Program.cs index 62561336f20..9fe04f18235 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Program.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Program.cs @@ -13,6 +13,7 @@ internal static async Task Main(string[] args) var command = new RootCommand { + CloneCommand.GetCommand(setExitCode), FrameworkCheckCommand.GetCommand(setExitCode), DiscoverCommand.GetCommand(setExitCode), AnalyzeCommand.GetCommand(setExitCode), diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Clone/CloneWorkerTests.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Clone/CloneWorkerTests.cs new file mode 100644 index 00000000000..71e6387bff9 --- /dev/null +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Clone/CloneWorkerTests.cs @@ -0,0 +1,183 @@ +using NuGetUpdater.Core.Clone; +using NuGetUpdater.Core.Run.ApiModel; +using NuGetUpdater.Core.Test.Run; + +using Xunit; + +namespace NuGetUpdater.Core.Test.Clone; + +public class CloneWorkerTests +{ + private const string TestRepoPath = "TEST/REPO/PATH"; + + [Fact] + public void CloneCommandsAreGenerated() + { + TestCommands( + provider: "github", + repoMoniker: "test/repo", + expectedCommands: + [ + (["clone", "--no-tags", "--depth", "1", "--recurse-submodules", "--shallow-submodules", "https://github.com/test/repo", TestRepoPath], null) + ] + ); + } + + [Fact] + public void CloneCommandsAreGeneratedWhenBranchIsSpecified() + { + TestCommands( + provider: "github", + repoMoniker: "test/repo", + branch: "some-branch", + expectedCommands: + [ + (["clone", "--no-tags", "--depth", "1", "--recurse-submodules", "--shallow-submodules", "--branch", "some-branch", "--single-branch", "https://github.com/test/repo", TestRepoPath], null) + ] + ); + } + + [Fact] + public void CloneCommandsAreGeneratedWhenCommitIsSpecified() + { + TestCommands( + provider: "github", + repoMoniker: "test/repo", + commit: "abc123", + expectedCommands: + [ + (["clone", "--no-tags", "--depth", "1", "--recurse-submodules", "--shallow-submodules", "https://github.com/test/repo", TestRepoPath], null), + (["fetch", "--depth", "1", "--recurse-submodules=on-demand", "origin", "abc123"], TestRepoPath), + (["reset", "--hard", "--recurse-submodules", "abc123"], TestRepoPath) + ] + ); + } + + [Fact] + public async Task SuccessfulCloneGeneratesNoApiMessages() + { + await TestCloneAsync( + provider: "github", + repoMoniker: "test/repo", + expectedApiMessages: [] + ); + } + + [Fact] + public async Task UnauthorizedCloneGeneratesTheExpectedApiMessagesFromGenericOutput() + { + await TestCloneAsync( + provider: "github", + repoMoniker: "test/repo", + testGitCommandHandler: new TestGitCommandHandlerWithOutputs("Authentication failed for repo", ""), + expectedApiMessages: + [ + new JobRepoNotFound("Authentication failed for repo"), + new MarkAsProcessed("unknown"), + ], + expectedExitCode: 1 + ); + } + + [Fact] + public async Task UnauthorizedCloneGeneratesTheExpectedApiMessagesFromGitCommandOutput() + { + await TestCloneAsync( + provider: "github", + repoMoniker: "test/repo", + testGitCommandHandler: new TestGitCommandHandlerWithOutputs("", "fatal: could not read Username for 'https://github.com': No such device or address"), + expectedApiMessages: + [ + new JobRepoNotFound("fatal: could not read Username for 'https://github.com': No such device or address"), + new MarkAsProcessed("unknown"), + ], + expectedExitCode: 1 + ); + } + + private class TestGitCommandHandlerWithOutputs : TestGitCommandHandler + { + private readonly string _stdout; + private readonly string _stderr; + + public TestGitCommandHandlerWithOutputs(string stdout, string stderr) + { + _stdout = stdout; + _stderr = stderr; + } + + public override async Task RunGitCommandAsync(IReadOnlyCollection args, string? workingDirectory = null) + { + await base.RunGitCommandAsync(args, workingDirectory); + ShellGitCommandHandler.HandleErrorsFromOutput(_stdout, _stderr); + } + } + + private static void TestCommands(string provider, string repoMoniker, (string[] Args, string? WorkingDirectory)[] expectedCommands, string? branch = null, string? commit = null) + { + var job = new Job() + { + Source = new() + { + Provider = provider, + Repo = repoMoniker, + Branch = branch, + Commit = commit, + } + }; + var actualCommands = CloneWorker.GetAllCommandArgs(job, TestRepoPath); + VerifyCommands(expectedCommands, actualCommands); + } + + private static async Task TestCloneAsync(string provider, string repoMoniker, object[] expectedApiMessages, string? branch = null, string? commit = null, TestGitCommandHandler? testGitCommandHandler = null, int expectedExitCode = 0) + { + // arrange + var testApiHandler = new TestApiHandler(); + testGitCommandHandler ??= new TestGitCommandHandler(); + var testLogger = new TestLogger(); + var worker = new CloneWorker(testApiHandler, testGitCommandHandler, testLogger); + + // act + var job = new Job() + { + Source = new() + { + Provider = provider, + Repo = repoMoniker, + Branch = branch, + Commit = commit, + } + }; + var exitCode = await worker.RunAsync(job, TestRepoPath); + + // assert + Assert.Equal(expectedExitCode, exitCode); + + var actualApiMessages = testApiHandler.ReceivedMessages.ToArray(); + if (actualApiMessages.Length > expectedApiMessages.Length) + { + var extraApiMessages = actualApiMessages.Skip(expectedApiMessages.Length).Select(m => RunWorkerTests.SerializeObjectAndType(m.Object)).ToArray(); + Assert.Fail($"Expected {expectedApiMessages.Length} API messages, but got {extraApiMessages.Length} extra:\n\t{string.Join("\n\t", extraApiMessages)}"); + } + if (expectedApiMessages.Length > actualApiMessages.Length) + { + var missingApiMessages = expectedApiMessages.Skip(actualApiMessages.Length).Select(m => RunWorkerTests.SerializeObjectAndType(m)).ToArray(); + Assert.Fail($"Expected {expectedApiMessages.Length} API messages, but only got {actualApiMessages.Length}; missing:\n\t{string.Join("\n\t", missingApiMessages)}"); + } + } + + private static void VerifyCommands((string[] Args, string? WorkingDirectory)[] expected, (string[] Args, string? WorkingDirectory)[] actual) + { + var expectedCommands = StringifyCommands(expected); + var actualCommands = StringifyCommands(actual); + Assert.True(expectedCommands.Length == actualCommands.Length, $"Expected {expectedCommands.Length} messages:\n\t{string.Join("\n\t", expectedCommands)}\ngot {actualCommands.Length}:\n\t{string.Join("\n\t", actualCommands)}"); + foreach (var (expectedCommand, actualCommand) in expectedCommands.Zip(actualCommands)) + { + Assert.Equal(expectedCommand, actualCommand); + } + } + + private static string[] StringifyCommands((string[] Args, string? WorkingDirectory)[] commandArgs) => commandArgs.Select(a => StringifyCommand(a.Args, a.WorkingDirectory)).ToArray(); + private static string StringifyCommand(string[] args, string? workingDirectory) => $"args=[{string.Join(", ", args)}], workingDirectory={ReplaceWorkingDirectory(workingDirectory)}"; + private static string ReplaceWorkingDirectory(string? arg) => arg ?? "NULL"; +} diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Clone/TestGitCommandHandler.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Clone/TestGitCommandHandler.cs new file mode 100644 index 00000000000..440c921e271 --- /dev/null +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Clone/TestGitCommandHandler.cs @@ -0,0 +1,16 @@ +using NuGetUpdater.Core.Clone; + +namespace NuGetUpdater.Core.Test.Clone; + +internal class TestGitCommandHandler : IGitCommandHandler +{ + private readonly List<(string[] Args, string? WorkingDirectory)> _seenCommands = new(); + + public IReadOnlyCollection<(string[] Args, string? WorkingDirectory)> SeenCommands => _seenCommands; + + public virtual Task RunGitCommandAsync(IReadOnlyCollection args, string? workingDirectory = null) + { + _seenCommands.Add((args.ToArray(), workingDirectory)); + return Task.CompletedTask; + } +} diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Run/RunWorkerTests.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Run/RunWorkerTests.cs index 5fbb358665c..fe6a1f851ee 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Run/RunWorkerTests.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Run/RunWorkerTests.cs @@ -26,7 +26,6 @@ await RunAsync( ], job: new Job() { - PackageManager = "nuget", Source = new() { Provider = "github", @@ -161,10 +160,7 @@ await RunAsync( PrTitle = "TODO: title", PrBody = "TODO: body", }, - new MarkAsProcessed() - { - BaseCommitSha = "TEST-COMMIT-SHA", - } + new MarkAsProcessed("TEST-COMMIT-SHA") ] ); } @@ -209,7 +205,6 @@ await RunAsync( ], job: new Job() { - PackageManager = "nuget", Source = new() { Provider = "github", @@ -249,14 +244,8 @@ await RunAsync( }, expectedApiMessages: [ - new PrivateSourceAuthenticationFailure() - { - Details = $"({http.BaseUrl.TrimEnd('/')}/index.json)" - }, - new MarkAsProcessed() - { - BaseCommitSha = "TEST-COMMIT-SHA", - } + new PrivateSourceAuthenticationFailure([$"{http.BaseUrl.TrimEnd('/')}/index.json"]), + new MarkAsProcessed("TEST-COMMIT-SHA") ] ); } @@ -308,7 +297,7 @@ private static async Task RunAsync(Job job, TestFile[] files, RunResult expected } } - private static string SerializeObjectAndType(object obj) + internal static string SerializeObjectAndType(object obj) { return $"{obj.GetType().Name}:{JsonSerializer.Serialize(obj)}"; } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Run/SerializationTests.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Run/SerializationTests.cs index 048912ec0dd..ff7f7dd88d5 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Run/SerializationTests.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Run/SerializationTests.cs @@ -1,4 +1,5 @@ using NuGetUpdater.Core.Run; +using NuGetUpdater.Core.Run.ApiModel; using Xunit; @@ -57,4 +58,13 @@ public void DeserializeJob() Assert.Equal("some-org/some-repo", jobWrapper.Job.Source.Repo); Assert.Equal("specific-sdk", jobWrapper.Job.Source.Directory); } + + [Fact] + public void SerializeError() + { + var error = new JobRepoNotFound("some message"); + var actual = HttpApiHandler.Serialize(error); + var expected = """{"data":{"error-type":"job_repo_not_found","error-details":{"message":"some message"}}}"""; + Assert.Equal(expected, actual); + } } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/BindingRedirectsTests.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/BindingRedirectsTests.cs new file mode 100644 index 00000000000..6914ff07685 --- /dev/null +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/BindingRedirectsTests.cs @@ -0,0 +1,225 @@ +using Xunit; + +namespace NuGetUpdater.Core.Test.Update; + +public class BindingRedirectsTests +{ + [Fact] + public async Task SimpleBindingRedirectIsPerformed() + { + await VerifyBindingRedirectsAsync( + projectContents: """ + + + + v4.5 + + + + + + + packages\Some.Package.2.0.0\lib\net45\Some.Package.dll + True + + + + + """, + configContents: """ + + + + + + + + + + + """, + updatedPackageName: "Some.Package", + updatedPackageVersion: "2.0.0", + expectedConfigContents: """ + + + + + + + + + + + """ + ); + } + + [Fact] + public async Task ConfigFileIndentationIsPreserved() + { + await VerifyBindingRedirectsAsync( + projectContents: """ + + + + v4.5 + + + + + + + packages\Some.Package.2.0.0\lib\net45\Some.Package.dll + True + + + + + """, + configContents: """ + + + + + + + + + + + """, + updatedPackageName: "Some.Package", + updatedPackageVersion: "2.0.0", + expectedConfigContents: """ + + + + + + + + + + + """ + ); + } + + [Fact] + public async Task NoExtraBindingsAreAdded() + { + await VerifyBindingRedirectsAsync( + projectContents: """ + + + + v4.5 + + + + + + + packages\Some.Package.2.0.0\lib\net45\Some.Package.dll + True + + + packages\Some.Unrelated.Package.3.0.0\lib\net45\Some.Package.dll + True + + + + + """, + configContents: """ + + + + + + + + + + + """, + updatedPackageName: "Some.Package", + updatedPackageVersion: "2.0.0", + expectedConfigContents: """ + + + + + + + + + + + """ + ); + } + + [Fact] + public async Task NewBindingIsAdded() + { + await VerifyBindingRedirectsAsync( + projectContents: """ + + + + v4.5 + + + + + + + packages\Some.Package.2.0.0\lib\net45\Some.Package.dll + True + + + + + """, + configContents: """ + + + + """, + updatedPackageName: "Some.Package", + updatedPackageVersion: "2.0.0", + expectedConfigContents: """ + + + + + + + + + + + """ + ); + } + + private static async Task VerifyBindingRedirectsAsync(string projectContents, string configContents, string expectedConfigContents, string updatedPackageName, string updatedPackageVersion, string configFileName = "app.config") + { + using var tempDir = new TemporaryDirectory(); + var projectFileName = "project.csproj"; + var projectFilePath = Path.Combine(tempDir.DirectoryPath, projectFileName); + var configFilePath = Path.Combine(tempDir.DirectoryPath, configFileName); + + await File.WriteAllTextAsync(projectFilePath, projectContents); + await File.WriteAllTextAsync(configFilePath, configContents); + + var projectBuildFile = ProjectBuildFile.Open(tempDir.DirectoryPath, projectFilePath); + await BindingRedirectManager.UpdateBindingRedirectsAsync(projectBuildFile, updatedPackageName, updatedPackageVersion); + + var actualConfigContents = (await File.ReadAllTextAsync(configFilePath)).Replace("\r", ""); + expectedConfigContents = expectedConfigContents.Replace("\r", ""); + Assert.Equal(expectedConfigContents, actualConfigContents); + } +} diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/UpdateWorkerTests.PackagesConfig.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/UpdateWorkerTests.PackagesConfig.cs index e02bb35fcab..6f5117ab8f6 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/UpdateWorkerTests.PackagesConfig.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/UpdateWorkerTests.PackagesConfig.cs @@ -594,6 +594,7 @@ await TestUpdateForProject("Some.Package", "7.0.1", "13.0.1", [ MockNuGetPackage.CreatePackageWithAssembly("Some.Package", "7.0.1", "net45", "7.0.0.0"), MockNuGetPackage.CreatePackageWithAssembly("Some.Package", "13.0.1", "net45", "13.0.0.0"), + MockNuGetPackage.CreatePackageWithAssembly("Unrelated.Package", "1.2.3", "net45","1.2.0.0"), ], projectContents: """ @@ -612,6 +613,10 @@ await TestUpdateForProject("Some.Package", "7.0.1", "13.0.1", packages\Some.Package.7.0.1\lib\net45\Some.Package.dll True + + packages\Unrelated.Package.1.2.3\lib\net45\Unrelated.Package.dll + True + @@ -653,6 +658,107 @@ await TestUpdateForProject("Some.Package", "7.0.1", "13.0.1", packages\Some.Package.13.0.1\lib\net45\Some.Package.dll True + + packages\Unrelated.Package.1.2.3\lib\net45\Unrelated.Package.dll + True + + + + + """, + expectedPackagesConfigContents: """ + + + + + """, + additionalFilesExpected: + [ + ("app.config", """ + + + + + + + + + + + """) + ] + ); + } + + [Fact] + public async Task BindingRedirectIsAddedForUpdatedPackage() + { + await TestUpdateForProject("Some.Package", "7.0.1", "13.0.1", + packages: + [ + MockNuGetPackage.CreatePackageWithAssembly("Some.Package", "7.0.1", "net45", "7.0.0.0"), + MockNuGetPackage.CreatePackageWithAssembly("Some.Package", "13.0.1", "net45", "13.0.0.0"), + MockNuGetPackage.CreatePackageWithAssembly("Unrelated.Package", "1.2.3", "net45","1.2.0.0"), + ], + projectContents: """ + + + + v4.5 + + + + + + + + + + packages\Some.Package.7.0.1\lib\net45\Some.Package.dll + True + + + packages\Unrelated.Package.1.2.3\lib\net45\Unrelated.Package.dll + True + + + + + """, + packagesConfigContents: """ + + + + """, + additionalFiles: + [ + ("app.config", """ + + + + """) + ], + expectedProjectContents: """ + + + + v4.5 + + + + + + + + + + packages\Some.Package.13.0.1\lib\net45\Some.Package.dll + True + + + packages\Unrelated.Package.1.2.3\lib\net45\Unrelated.Package.dll + True + diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/CloneWorker.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/CloneWorker.cs new file mode 100644 index 00000000000..761a364f777 --- /dev/null +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/CloneWorker.cs @@ -0,0 +1,144 @@ +using System.Net; + +using NuGetUpdater.Core.Run; +using NuGetUpdater.Core.Run.ApiModel; + +using CommandArguments = (string[] Args, string? WorkingDirectory); + +namespace NuGetUpdater.Core.Clone; + +public class CloneWorker +{ + private readonly IApiHandler _apiHandler; + private readonly IGitCommandHandler _gitCommandHandler; + private readonly ILogger _logger; + + public CloneWorker(IApiHandler apiHandler, IGitCommandHandler gitCommandHandler, ILogger logger) + { + _apiHandler = apiHandler; + _gitCommandHandler = gitCommandHandler; + _logger = logger; + } + + // entrypoint for cli + public async Task RunAsync(FileInfo jobFilePath, DirectoryInfo repoContentsPath) + { + var jobFileContent = await File.ReadAllTextAsync(jobFilePath.FullName); + var jobWrapper = RunWorker.Deserialize(jobFileContent); + var result = await RunAsync(jobWrapper.Job, repoContentsPath.FullName); + return result; + } + + // object model entry point + public async Task RunAsync(Job job, string repoContentsPath) + { + JobErrorBase? error = null; + try + { + var commandArgs = GetAllCommandArgs(job, repoContentsPath); + foreach (var (args, workingDirectory) in commandArgs) + { + await _gitCommandHandler.RunGitCommandAsync(args, workingDirectory); + } + } + catch (HttpRequestException ex) + when (ex.StatusCode == HttpStatusCode.Unauthorized || ex.StatusCode == HttpStatusCode.Forbidden) + { + error = new JobRepoNotFound(ex.Message); + } + catch (Exception ex) + { + error = new UnknownError(ex.ToString()); + } + + if (error is not null) + { + await _apiHandler.RecordUpdateJobError(error); + await _apiHandler.MarkAsProcessed(new("unknown")); + return 1; + } + + return 0; + } + + internal static CommandArguments[] GetAllCommandArgs(Job job, string repoContentsPath) + { + var commandArgs = new List() + { + GetCloneArgs(job, repoContentsPath) + }; + + if (job.Source.Commit is { } commit) + { + commandArgs.Add(GetFetchArgs(commit, repoContentsPath)); + commandArgs.Add(GetResetArgs(commit, repoContentsPath)); + } + + return commandArgs.ToArray(); + } + + internal static CommandArguments GetCloneArgs(Job job, string repoContentsPath) + { + var url = GetRepoUrl(job); + var args = new List() + { + "clone", + "--no-tags", + "--depth", + "1", + "--recurse-submodules", + "--shallow-submodules", + }; + + if (job.Source.Branch is { } branch) + { + args.Add("--branch"); + args.Add(branch); + args.Add("--single-branch"); + } + + args.Add(url); + args.Add(repoContentsPath); + return (args.ToArray(), null); + } + + internal static CommandArguments GetFetchArgs(string commit, string repoContentsPath) + { + return + ( + [ + "fetch", + "--depth", + "1", + "--recurse-submodules=on-demand", + "origin", + commit + ], + repoContentsPath + ); + } + + internal static CommandArguments GetResetArgs(string commit, string repoContentsPath) + { + return + ( + [ + "reset", + "--hard", + "--recurse-submodules", + commit + ], + repoContentsPath + ); + } + + private static string GetRepoUrl(Job job) + { + return job.Source.Provider switch + { + "azure" => $"https://dev.azure.com/{job.Source.Repo}", + "github" => $"https://github.com/{job.Source.Repo}", + _ => throw new ArgumentException($"Unknown provider: {job.Source.Provider}") + }; + } +} diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/IGitCommandHandler.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/IGitCommandHandler.cs new file mode 100644 index 00000000000..f43f43f9c32 --- /dev/null +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/IGitCommandHandler.cs @@ -0,0 +1,6 @@ +namespace NuGetUpdater.Core.Clone; + +public interface IGitCommandHandler +{ + Task RunGitCommandAsync(IReadOnlyCollection args, string? workingDirectory = null); +} diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/ShellGitCommandHandler.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/ShellGitCommandHandler.cs new file mode 100644 index 00000000000..d61bbf58d47 --- /dev/null +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Clone/ShellGitCommandHandler.cs @@ -0,0 +1,37 @@ +using System.Net; + +namespace NuGetUpdater.Core.Clone; + +public class ShellGitCommandHandler : IGitCommandHandler +{ + private readonly ILogger _logger; + + public ShellGitCommandHandler(ILogger logger) + { + _logger = logger; + } + + public async Task RunGitCommandAsync(IReadOnlyCollection args, string? workingDirectory = null) + { + _logger.Log($"Running command: git {string.Join(" ", args)}{(workingDirectory is null ? "" : $" in directory {workingDirectory}")}"); + var (exitCode, stdout, stderr) = await ProcessEx.RunAsync("git", args, workingDirectory); + HandleErrorsFromOutput(stdout, stderr); + } + + internal static void HandleErrorsFromOutput(string stdout, string stderr) + { + foreach (var output in new[] { stdout, stderr }) + { + ThrowOnUnauthenticated(output); + } + } + + private static void ThrowOnUnauthenticated(string output) + { + if (output.Contains("Authentication failed for") || + output.Contains("could not read Username for")) + { + throw new HttpRequestException(output, inner: null, statusCode: HttpStatusCode.Unauthorized); + } + } +} diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/DependencyFileNotFound.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/DependencyFileNotFound.cs index 8188266a597..94c1b71f08a 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/DependencyFileNotFound.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/DependencyFileNotFound.cs @@ -2,5 +2,9 @@ namespace NuGetUpdater.Core.Run.ApiModel; public record DependencyFileNotFound : JobErrorBase { - public override string Type => "dependency_file_not_found"; + public DependencyFileNotFound(string filePath) + : base("dependency_file_not_found") + { + Details = filePath; + } } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/Job.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/Job.cs index b612717df82..d9e107ed3bb 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/Job.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/Job.cs @@ -2,7 +2,7 @@ namespace NuGetUpdater.Core.Run.ApiModel; public sealed record Job { - public required string PackageManager { get; init; } + public string PackageManager { get; init; } = "nuget"; public AllowedUpdate[]? AllowedUpdates { get; init; } = null; public bool Debug { get; init; } = false; public object[]? DependencyGroups { get; init; } = null; diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobErrorBase.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobErrorBase.cs index 844f343bb61..7507ba583d9 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobErrorBase.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobErrorBase.cs @@ -4,8 +4,15 @@ namespace NuGetUpdater.Core.Run.ApiModel; public abstract record JobErrorBase { + public JobErrorBase(string type) + { + Type = type; + } + [JsonPropertyName("error-type")] - public abstract string Type { get; } + public string Type { get; } + [JsonPropertyName("error-details")] - public required object Details { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Details { get; init; } = null; } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobRepoNotFound.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobRepoNotFound.cs new file mode 100644 index 00000000000..12d9f229d76 --- /dev/null +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobRepoNotFound.cs @@ -0,0 +1,13 @@ +namespace NuGetUpdater.Core.Run.ApiModel; + +public record JobRepoNotFound : JobErrorBase +{ + public JobRepoNotFound(string message) + : base("job_repo_not_found") + { + Details = new Dictionary() + { + ["message"] = message + }; + } +} diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobSource.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobSource.cs index 21f8c17db2d..a29056e606e 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobSource.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/JobSource.cs @@ -4,6 +4,8 @@ public sealed class JobSource { public required string Provider { get; init; } public required string Repo { get; init; } + public string? Branch { get; init; } = null; + public string? Commit { get; init; } = null; public string? Directory { get; init; } = null; public string[]? Directories { get; init; } = null; public string? Hostname { get; init; } = null; diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/MarkAsProcessed.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/MarkAsProcessed.cs index b29b0be0ab1..2c6c9e75cff 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/MarkAsProcessed.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/MarkAsProcessed.cs @@ -4,6 +4,11 @@ namespace NuGetUpdater.Core.Run.ApiModel; public sealed record MarkAsProcessed { + public MarkAsProcessed(string baseCommitSha) + { + BaseCommitSha = baseCommitSha; + } + [JsonPropertyName("base-commit-sha")] - public required string BaseCommitSha { get; init; } + public string BaseCommitSha { get; } } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/PrivateSourceAuthenticationFailure.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/PrivateSourceAuthenticationFailure.cs index 616cb9bd62f..35a39894fd4 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/PrivateSourceAuthenticationFailure.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/PrivateSourceAuthenticationFailure.cs @@ -2,5 +2,9 @@ namespace NuGetUpdater.Core.Run.ApiModel; public record PrivateSourceAuthenticationFailure : JobErrorBase { - public override string Type => "private_source_authentication_failure"; + public PrivateSourceAuthenticationFailure(string[] urls) + : base("private_source_authentication_failure") + { + Details = $"({string.Join("|", urls)})"; + } } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/UnknownError.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/UnknownError.cs index 7ab4b455fcd..90517a8f1ea 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/UnknownError.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/UnknownError.cs @@ -2,5 +2,9 @@ namespace NuGetUpdater.Core.Run.ApiModel; public record UnknownError : JobErrorBase { - public override string Type => "unknown_error"; + public UnknownError(string details) + : base("unknown_error") + { + Details = details; + } } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/UpdateNotPossible.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/UpdateNotPossible.cs index 7a534ff854b..cdf78540631 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/UpdateNotPossible.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/ApiModel/UpdateNotPossible.cs @@ -2,5 +2,9 @@ namespace NuGetUpdater.Core.Run.ApiModel; public record UpdateNotPossible : JobErrorBase { - public override string Type => "update_not_possible"; + public UpdateNotPossible(string[] dependencies) + : base("update_not_possible") + { + Details = dependencies; + } } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/HttpApiHandler.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/HttpApiHandler.cs index bcad298776c..731e9e80542 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/HttpApiHandler.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/HttpApiHandler.cs @@ -50,13 +50,19 @@ public async Task MarkAsProcessed(MarkAsProcessed markAsProcessed) await PostAsJson("mark_as_processed", markAsProcessed); } - private async Task PostAsJson(string endpoint, object body) + internal static string Serialize(object body) { var wrappedBody = new { - Data = body, + Data = body }; var payload = JsonSerializer.Serialize(wrappedBody, SerializerOptions); + return payload; + } + + private async Task PostAsJson(string endpoint, object body) + { + var payload = Serialize(body); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var response = await HttpClient.PostAsync($"{_apiUrl}/update_jobs/{_jobId}/{endpoint}", content); var _ = response.EnsureSuccessStatusCode(); diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/RunWorker.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/RunWorker.cs index 7713071c129..1089101e805 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/RunWorker.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Run/RunWorker.cs @@ -76,31 +76,19 @@ private async Task RunWithErrorHandlingAsync(Job job, DirectoryInfo r catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized || ex.StatusCode == HttpStatusCode.Forbidden) { - error = new PrivateSourceAuthenticationFailure() - { - Details = $"({string.Join("|", lastUsedPackageSourceUrls)})", - }; + error = new PrivateSourceAuthenticationFailure(lastUsedPackageSourceUrls); } catch (MissingFileException ex) { - error = new DependencyFileNotFound() - { - Details = ex.FilePath, - }; + error = new DependencyFileNotFound(ex.FilePath); } catch (UpdateNotPossibleException ex) { - error = new UpdateNotPossible() - { - Details = ex.Dependencies, - }; + error = new UpdateNotPossible(ex.Dependencies); } catch (Exception ex) { - error = new UnknownError() - { - Details = ex.ToString(), - }; + error = new UnknownError(ex.ToString()); } if (error is not null) @@ -108,7 +96,7 @@ private async Task RunWithErrorHandlingAsync(Job job, DirectoryInfo r await _apiHandler.RecordUpdateJobError(error); } - await _apiHandler.MarkAsProcessed(new() { BaseCommitSha = baseCommitSha }); + await _apiHandler.MarkAsProcessed(new(baseCommitSha)); return runResult; } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/BindingRedirectManager.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/BindingRedirectManager.cs index be0cf0ed8f7..c27c93cfac0 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/BindingRedirectManager.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/BindingRedirectManager.cs @@ -28,7 +28,9 @@ internal static class BindingRedirectManager /// https://learn.microsoft.com/en-us/nuget/resources/check-project-format /// /// The project build file (*.xproj) to be updated - public static async ValueTask UpdateBindingRedirectsAsync(ProjectBuildFile projectBuildFile) + /// The name of the package that was updated + /// The version of the package that was updated + public static async ValueTask UpdateBindingRedirectsAsync(ProjectBuildFile projectBuildFile, string updatedPackageName, string updatedPackageVersion) { var configFile = await TryGetRuntimeConfigurationFile(projectBuildFile); if (configFile is null) @@ -47,7 +49,20 @@ public static async ValueTask UpdateBindingRedirectsAsync(ProjectBuildFile proje return; } - var fileContent = AddBindingRedirects(configFile, bindings); + // we need to detect what assembly references come from the newly updated package; the `HintPath` will look like + // ..\packages\Some.Package.1.2.3\lib\net45\Some.Package.dll + // so we first pull out the packages sub-path, e.g., `..\packages` + // then we add the updated package name, version, and a trailing directory separator and ensure it's a unix-style path + // e.g., ../packages/Some.Package/1.2.3/ + // at this point any assembly in that directory is from the updated package and will need a binding redirect + // finally we pull out the assembly `HintPath` values for _all_ references relative to the project file in a unix-style value + // e.g., ../packages/Some.Other.Package/4.5.6/lib/net45/Some.Other.Package.dll + // all of that is passed to `AddBindingRedirects()` so we can ensure binding redirects for the relevant assemblies + var packagesDirectory = PackagesConfigUpdater.GetPathToPackagesDirectory(projectBuildFile, updatedPackageName, updatedPackageVersion, packagesConfigPath: null)!; + var assemblyPathPrefix = Path.Combine(packagesDirectory, $"{updatedPackageName}.{updatedPackageVersion}").NormalizePathToUnix().EnsureSuffix("/"); + var assemblyPaths = references.Select(static x => x.HintPath).Select(x => Path.GetRelativePath(Path.GetDirectoryName(projectBuildFile.Path)!, x).NormalizePathToUnix()).ToList(); + var bindingsAndAssemblyPaths = bindings.Zip(assemblyPaths); + var fileContent = AddBindingRedirects(configFile, bindingsAndAssemblyPaths, assemblyPathPrefix); configFile = configFile with { Content = fileContent }; await File.WriteAllTextAsync(configFile.Path, configFile.Content); @@ -161,10 +176,10 @@ static bool IsConfigFile(IXmlElementSyntax element) } } - private static string AddBindingRedirects(ConfigurationFile configFile, IEnumerable bindingRedirects) + private static string AddBindingRedirects(ConfigurationFile configFile, IEnumerable<(Runtime_AssemblyBinding Binding, string AssemblyPath)> bindingRedirectsAndAssemblyPaths, string assemblyPathPrefix) { // Do nothing if there are no binding redirects to add, bail out - if (!bindingRedirects.Any()) + if (!bindingRedirectsAndAssemblyPaths.Any()) { return configFile.Content; } @@ -185,7 +200,7 @@ private static string AddBindingRedirects(ConfigurationFile configFile, IEnumera // Get all of the current bindings in config var currentBindings = GetAssemblyBindings(runtime); - foreach (var bindingRedirect in bindingRedirects) + foreach (var (bindingRedirect, assemblyPath) in bindingRedirectsAndAssemblyPaths) { // If the binding redirect already exists in config, update it. Otherwise, add it. var bindingAssemblyIdentity = new AssemblyIdentity(bindingRedirect.Name, bindingRedirect.PublicKeyToken); @@ -208,11 +223,17 @@ private static string AddBindingRedirects(ConfigurationFile configFile, IEnumera } else { - // Get an assembly binding element to use - var assemblyBindingElement = GetAssemblyBindingElement(runtime); + // only add a previously missing binding redirect if it's related to the package that caused the whole update + // this isn't strictly necessary, but can be helpful to the end user and it's easy for them to revert if they + // don't like this particular change + if (assemblyPath.StartsWith(assemblyPathPrefix, StringComparison.OrdinalIgnoreCase)) + { + // Get an assembly binding element to use + var assemblyBindingElement = GetAssemblyBindingElement(runtime); - // Add the binding to that element - assemblyBindingElement.AddIndented(bindingRedirect.ToXElement()); + // Add the binding to that element + assemblyBindingElement.AddIndented(bindingRedirect.ToXElement()); + } } } diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/PackagesConfigUpdater.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/PackagesConfigUpdater.cs index 98720390d53..4c0b1290115 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/PackagesConfigUpdater.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/PackagesConfigUpdater.cs @@ -93,7 +93,7 @@ ILogger logger projectBuildFile.NormalizeDirectorySeparatorsInProject(); // Update binding redirects - await BindingRedirectManager.UpdateBindingRedirectsAsync(projectBuildFile); + await BindingRedirectManager.UpdateBindingRedirectsAsync(projectBuildFile, dependencyName, newDependencyVersion); logger.Log(" Writing project file back to disk"); await projectBuildFile.SaveAsync(); @@ -196,7 +196,7 @@ private static Process[] GetLikelyNuGetSpawnedProcesses() return processes; } - internal static string? GetPathToPackagesDirectory(ProjectBuildFile projectBuildFile, string dependencyName, string dependencyVersion, string packagesConfigPath) + internal static string? GetPathToPackagesDirectory(ProjectBuildFile projectBuildFile, string dependencyName, string dependencyVersion, string? packagesConfigPath) { // the packages directory can be found from the hint path of the matching dependency, e.g., when given "Newtonsoft.Json", "7.0.1", and a project like this: // @@ -242,7 +242,7 @@ private static Process[] GetLikelyNuGetSpawnedProcesses() } } - if (partialPathMatch is null) + if (partialPathMatch is null && packagesConfigPath is not null) { // if we got this far, we couldn't find the packages directory for the specified dependency and there are 2 possibilities: // 1. the dependency doesn't actually exist in this project diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Utilities/PathHelper.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Utilities/PathHelper.cs index 8d680cb9913..59ff5fa5e95 100644 --- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Utilities/PathHelper.cs +++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Utilities/PathHelper.cs @@ -27,6 +27,8 @@ public static string JoinPath(string? path1, string path2) public static string EnsurePrefix(this string s, string prefix) => s.StartsWith(prefix) ? s : prefix + s; + public static string EnsureSuffix(this string s, string suffix) => s.EndsWith(suffix) ? s : s + suffix; + public static string NormalizePathToUnix(this string path) => path.Replace("\\", "/"); public static string NormalizeUnixPathParts(this string path) diff --git a/nuget/updater/main.ps1 b/nuget/updater/main.ps1 index 7c124062a67..c25197a2f52 100644 --- a/nuget/updater/main.ps1 +++ b/nuget/updater/main.ps1 @@ -1,37 +1,28 @@ Set-StrictMode -version 2.0 $ErrorActionPreference = "Stop" +$updaterTool = "$env:DEPENDABOT_NATIVE_HELPERS_PATH/nuget/NuGetUpdater/NuGetUpdater.Cli" $jobString = Get-Content -Path $env:DEPENDABOT_JOB_PATH $job = (ConvertFrom-Json -InputObject $jobString).job +# Function return values in PowerShell are wacky and contain all of the output produced during the function call. +# Because of this, we need a reliable way to communicate _only_ the result of executing a single command, not its +# output. To accomplish this the value `$operationExitCode` is introduced and explicitly tracked. The value +# cannot be directly set, however, because it would be scoped locally to the function, so the `script:` prefix +# is added when setting the value. +$operationExitCode = 0 + function Get-Files { Write-Host "Job: $($job | ConvertTo-Json)" - $sourceRepo = $job.source.repo - # TODO: handle other values from $job.source.provider - $url = "https://github.com/$sourceRepo" - $path = $env:DEPENDABOT_REPO_CONTENTS_PATH - $cloneOptions = "--no-tags --depth 1 --recurse-submodules --shallow-submodules" - if ("branch" -in $job.source.PSobject.Properties.Name) { - $cloneOptions += " --branch $($job.source.branch) --single-branch" - } - - Invoke-Expression "git clone $cloneOptions $url $path" - - if ("commit" -in $job.source.PSobject.Properties.Name) { - # this is only called for testing; production will never pass a commit - Push-Location $path - - $fetchOptions = "--depth 1 --recurse-submodules=on-demand" - Invoke-Expression "git fetch $fetchOptions origin $($job.source.commit)" - - $resetOptions = "--hard --recurse-submodules" - Invoke-Expression "git reset $resetOptions $($job.source.commit)" - - Pop-Location - } + & $updaterTool clone ` + --job-path $env:DEPENDABOT_JOB_PATH ` + --repo-contents-path $env:DEPENDABOT_REPO_CONTENTS_PATH ` + --api-url $env:DEPENDABOT_API_URL ` + --job-id $env:DEPENDABOT_JOB_ID + $script:operationExitCode = $LASTEXITCODE } -function Install-Sdks([string] $directory) { +function Install-Sdks { $installedSdks = dotnet --list-sdks | ForEach-Object { $_.Split(' ')[0] } if ($installedSdks.GetType().Name -eq "String") { # if only a single value was returned (expected in the container), then force it to an array @@ -39,22 +30,36 @@ function Install-Sdks([string] $directory) { } Write-Host "Currently installed SDKs: $installedSdks" $rootDir = Convert-Path $env:DEPENDABOT_REPO_CONTENTS_PATH - $candidateDir = Convert-Path "$rootDir/$directory" - while ($true) { - $globalJsonPath = Join-Path $candidateDir "global.json" - if (Test-Path $globalJsonPath) { - $globalJson = Get-Content $globalJsonPath | ConvertFrom-Json - $sdkVersion = $globalJson.sdk.version - if (-Not ($sdkVersion -in $installedSdks)) { - $installedSdks += $sdkVersion - Write-Host "Installing SDK $sdkVersion as specified in $globalJsonPath" - & $env:DOTNET_INSTALL_SCRIPT_PATH --version $sdkVersion --install-dir $env:DOTNET_INSTALL_DIR - } - } - $candidateDir = Split-Path -Parent $candidateDir - if ($candidateDir -eq $rootDir) { - break + $candidateDirectories = @() + if ("directory" -in $job.source.PSobject.Properties.Name) { + $candidateDirectories += $job.source.directory + } + if ("directories" -in $job.source.PSobject.Properties.Name) { + $candidateDirectories += $job.source.directories + } + + foreach ($candidateDirName in $candidateDirectories) { + $candidateFullPath = "$rootDir/$candidateDirName" + if (Test-Path $candidateFullPath) { + $candidateDir = Convert-Path $candidateFullPath + while ($true) { + $globalJsonPath = Join-Path $candidateDir "global.json" + if (Test-Path $globalJsonPath) { + $globalJson = Get-Content $globalJsonPath | ConvertFrom-Json + $sdkVersion = $globalJson.sdk.version + if (-Not ($sdkVersion -in $installedSdks)) { + $installedSdks += $sdkVersion + Write-Host "Installing SDK $sdkVersion as specified in $globalJsonPath" + & $env:DOTNET_INSTALL_SCRIPT_PATH --version $sdkVersion --install-dir $env:DOTNET_INSTALL_DIR + } + } + + $candidateDir = Split-Path -Parent $candidateDir + if ($candidateDir -eq $rootDir) { + break + } + } } } @@ -64,14 +69,13 @@ function Install-Sdks([string] $directory) { function Update-Files { # install relevant SDKs - Install-Sdks $job.source.directory + Install-Sdks # TODO: install workloads? Push-Location $env:DEPENDABOT_REPO_CONTENTS_PATH $baseCommitSha = git rev-parse HEAD Pop-Location - $updaterTool = "$env:DEPENDABOT_NATIVE_HELPERS_PATH/nuget/NuGetUpdater/NuGetUpdater.Cli" & $updaterTool run ` --job-path $env:DEPENDABOT_JOB_PATH ` --repo-contents-path $env:DEPENDABOT_REPO_CONTENTS_PATH ` @@ -79,13 +83,16 @@ function Update-Files { --job-id $env:DEPENDABOT_JOB_ID ` --output-path $env:DEPENDABOT_OUTPUT_PATH ` --base-commit-sha $baseCommitSha + $script:operationExitCode = $LASTEXITCODE } try { Switch ($args[0]) { "fetch_files" { Get-Files } "update_files" { Update-Files } + default { throw "unknown command: $args[0]" } } + exit $operationExitCode } catch { Write-Host $_ diff --git a/updater/Gemfile.lock b/updater/Gemfile.lock index b5bfd441b3b..ce69ec59ae5 100644 --- a/updater/Gemfile.lock +++ b/updater/Gemfile.lock @@ -1,20 +1,20 @@ PATH remote: ../bundler specs: - dependabot-bundler (0.282.0) - dependabot-common (= 0.282.0) + dependabot-bundler (0.283.0) + dependabot-common (= 0.283.0) parallel (~> 1.24) PATH remote: ../cargo specs: - dependabot-cargo (0.282.0) - dependabot-common (= 0.282.0) + dependabot-cargo (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../common specs: - dependabot-common (0.282.0) + dependabot-common (0.283.0) aws-sdk-codecommit (~> 1.28) aws-sdk-ecr (~> 1.5) bundler (>= 1.16, < 3.0.0) @@ -38,107 +38,107 @@ PATH PATH remote: ../composer specs: - dependabot-composer (0.282.0) - dependabot-common (= 0.282.0) + dependabot-composer (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../devcontainers specs: - dependabot-devcontainers (0.282.0) - dependabot-common (= 0.282.0) + dependabot-devcontainers (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../docker specs: - dependabot-docker (0.282.0) - dependabot-common (= 0.282.0) + dependabot-docker (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../elm specs: - dependabot-elm (0.282.0) - dependabot-common (= 0.282.0) + dependabot-elm (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../git_submodules specs: - dependabot-git_submodules (0.282.0) - dependabot-common (= 0.282.0) + dependabot-git_submodules (0.283.0) + dependabot-common (= 0.283.0) parseconfig (~> 1.0, < 1.1.0) PATH remote: ../github_actions specs: - dependabot-github_actions (0.282.0) - dependabot-common (= 0.282.0) + dependabot-github_actions (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../go_modules specs: - dependabot-go_modules (0.282.0) - dependabot-common (= 0.282.0) + dependabot-go_modules (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../gradle specs: - dependabot-gradle (0.282.0) - dependabot-common (= 0.282.0) - dependabot-maven (= 0.282.0) + dependabot-gradle (0.283.0) + dependabot-common (= 0.283.0) + dependabot-maven (= 0.283.0) PATH remote: ../hex specs: - dependabot-hex (0.282.0) - dependabot-common (= 0.282.0) + dependabot-hex (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../maven specs: - dependabot-maven (0.282.0) - dependabot-common (= 0.282.0) + dependabot-maven (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../npm_and_yarn specs: - dependabot-npm_and_yarn (0.282.0) - dependabot-common (= 0.282.0) + dependabot-npm_and_yarn (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../nuget specs: - dependabot-nuget (0.282.0) - dependabot-common (= 0.282.0) + dependabot-nuget (0.283.0) + dependabot-common (= 0.283.0) rubyzip (>= 2.3.2, < 3.0) PATH remote: ../pub specs: - dependabot-pub (0.282.0) - dependabot-common (= 0.282.0) + dependabot-pub (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../python specs: - dependabot-python (0.282.0) - dependabot-common (= 0.282.0) + dependabot-python (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../silent specs: - dependabot-silent (0.282.0) - dependabot-common (= 0.282.0) + dependabot-silent (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../swift specs: - dependabot-swift (0.282.0) - dependabot-common (= 0.282.0) + dependabot-swift (0.283.0) + dependabot-common (= 0.283.0) PATH remote: ../terraform specs: - dependabot-terraform (0.282.0) - dependabot-common (= 0.282.0) + dependabot-terraform (0.283.0) + dependabot-common (= 0.283.0) GEM remote: https://rubygems.org/ diff --git a/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb b/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb index 9acbffc8ba0..ab94b2fad2f 100644 --- a/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb +++ b/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb @@ -132,7 +132,7 @@ def check_and_update_pull_request(dependencies) # Dependabot::Experiments.register(:lead_security_dependency, true) if Dependabot::Experiments.enabled?(:lead_security_dependency) - lead_dep_name = security_advisory_dependency + lead_dep_name = security_advisory_dependency.downcase # telemetry data collection Dependabot.logger.info( diff --git a/updater/spec/dependabot/updater/operations/refresh_security_update_pull_request_spec.rb b/updater/spec/dependabot/updater/operations/refresh_security_update_pull_request_spec.rb index a2df8f7bcc8..fbc8aa9bc12 100644 --- a/updater/spec/dependabot/updater/operations/refresh_security_update_pull_request_spec.rb +++ b/updater/spec/dependabot/updater/operations/refresh_security_update_pull_request_spec.rb @@ -310,5 +310,51 @@ [dependency]) end end + + context "when the dependency name has upper-case characters" do + before do + allow(Dependabot::Experiments).to receive(:enabled?).with(:lead_security_dependency).and_return(true) + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: true + ) + allow(job).to receive_messages(allowed_update?: true, + security_advisories: [{ "dependency-name" => "Dummy-Pkg-A" }]) + end + + after do + allow(Dependabot::Experiments).to receive(:enabled?).with(:lead_security_dependency).and_return(false) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "Dummy-Pkg-A", + version: "4.0.0", + requirements: [{ + file: "Gemfile", + requirement: "~> 4.0.0", + groups: ["default"], + source: nil + }], + package_manager: "bundler", + metadata: { all_versions: ["4.0.0"] } + ) + end + + it "checks if a pull request already exists" do + allow(job).to receive(:dependencies).and_return(%w(dummy-pkg-a)) + allow(refresh_security_update_pull_request).to receive(:existing_pull_request).and_return(true) + allow(Dependabot.logger).to receive(:info).and_call_original + + expect(refresh_security_update_pull_request).to receive(:update_pull_request) + + expect(Dependabot.logger) + .to receive(:info) + .with matching(/Security advisory dependency: dummy-pkg-a/) + + refresh_security_update_pull_request.send(:check_and_update_pull_request, + [dependency]) + end + end end end