diff --git a/nuget/Dockerfile b/nuget/Dockerfile
index 0427b2e766f..dde3618f5d2 100644
--- a/nuget/Dockerfile
+++ b/nuget/Dockerfile
@@ -87,5 +87,8 @@ RUN chmod +x $DEPENDABOT_HOME/dependabot-updater/bin/run
# .NET install targeting packs
RUN pwsh $DEPENDABOT_HOME/dependabot-updater/bin/install-targeting-packs.ps1
+# Extract the Dependabot gem version for runtime use
+RUN sed -n 's/.*VERSION = "\(.*\)"/\1/p' $DEPENDABOT_HOME/common/lib/dependabot.rb > $DEPENDABOT_HOME/.dependabot-version
+
# Enable MSBuild operations with a shallow clone for repos that use the Nerdbank.GitVersioning package
ENV NBGV_GitEngine=Disabled
diff --git a/nuget/helpers/lib/NuGetUpdater/Directory.Packages.props b/nuget/helpers/lib/NuGetUpdater/Directory.Packages.props
index f7f4157cb30..4151acdf36b 100644
--- a/nuget/helpers/lib/NuGetUpdater/Directory.Packages.props
+++ b/nuget/helpers/lib/NuGetUpdater/Directory.Packages.props
@@ -27,6 +27,7 @@
+
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTestHelper.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTestHelper.cs
new file mode 100644
index 00000000000..3a64c57810a
--- /dev/null
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTestHelper.cs
@@ -0,0 +1,84 @@
+using System.Text;
+using System.Text.Json;
+
+using NuGetUpdater.Core.Run;
+using NuGetUpdater.Core.Run.ApiModel;
+using NuGetUpdater.Core.Test;
+using NuGetUpdater.Core.Test.Update;
+
+using Xunit;
+
+namespace NuGetUpdater.Cli.Test;
+
+using TestFile = (string Path, string Content);
+
+internal static class EntryPointTestHelper
+{
+ internal static async Task RunAsync(
+ string commandName,
+ TestFile[] files,
+ Job job,
+ string[] expectedUrls,
+ MockNuGetPackage[]? packages = null,
+ string? repoContentsPath = null,
+ int expectedExitCode = 0)
+ {
+ using var tempDirectory = new TemporaryDirectory();
+
+ // write test files
+ foreach (var testFile in files)
+ {
+ var fullPath = Path.Join(tempDirectory.DirectoryPath, testFile.Path);
+ var directory = Path.GetDirectoryName(fullPath)!;
+ Directory.CreateDirectory(directory);
+ await File.WriteAllTextAsync(fullPath, testFile.Content);
+ }
+
+ // write job file
+ var jobPath = Path.Combine(tempDirectory.DirectoryPath, "job.json");
+ await File.WriteAllTextAsync(jobPath, JsonSerializer.Serialize(new { Job = job }, RunWorker.SerializerOptions));
+
+ // save packages
+ await UpdateWorkerTestBase.MockNuGetPackagesInDirectory(packages, tempDirectory.DirectoryPath);
+
+ var actualUrls = new List();
+ using var http = TestHttpServer.CreateTestStringServer((method, url) =>
+ {
+ actualUrls.Add($"{method} {new Uri(url).PathAndQuery}");
+ return (200, "ok");
+ });
+ var args = new List()
+ {
+ commandName,
+ "--job-path",
+ jobPath,
+ "--repo-contents-path",
+ repoContentsPath ?? tempDirectory.DirectoryPath,
+ "--api-url",
+ http.BaseUrl,
+ "--job-id",
+ "TEST-ID",
+ "--base-commit-sha",
+ "BASE-COMMIT-SHA"
+ };
+
+ var output = new StringBuilder();
+ // redirect stdout
+ var originalOut = Console.Out;
+ Console.SetOut(new StringWriter(output));
+ int result = -1;
+ try
+ {
+ result = await Program.Main(args.ToArray());
+ }
+ catch
+ {
+ // restore stdout
+ Console.SetOut(originalOut);
+ throw;
+ }
+
+ Assert.True(result == expectedExitCode, $"Expected exit code {expectedExitCode} but got {result}.\nSTDOUT:\n" + output.ToString());
+ Assert.Equal(expectedUrls, actualUrls);
+ }
+}
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Graph.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Graph.cs
new file mode 100644
index 00000000000..fa1ab6cfcd8
--- /dev/null
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Graph.cs
@@ -0,0 +1,65 @@
+using NuGetUpdater.Core.Run.ApiModel;
+using NuGetUpdater.Core.Test;
+
+using Xunit;
+
+namespace NuGetUpdater.Cli.Test;
+
+using TestFile = (string Path, string Content);
+
+public partial class EntryPointTests
+{
+ public class Graph
+ {
+ [Fact]
+ public async Task Graph_Simple()
+ {
+ // verify we can pass command line arguments for graph command
+ await RunAsync(
+ packages:
+ [
+ MockNuGetPackage.CreateSimplePackage("Some.Package", "1.0.0", "net8.0"),
+ ],
+ files:
+ [
+ ("Directory.Build.props", ""),
+ ("Directory.Build.targets", ""),
+ ("Directory.Packages.props", """
+
+
+ false
+
+
+ """),
+ ("src/project.csproj", """
+
+
+ net8.0
+
+
+
+
+
+ """)
+ ],
+ job: new Job()
+ {
+ Source = new()
+ {
+ Provider = "github",
+ Repo = "test",
+ Directory = "src",
+ }
+ },
+ expectedUrls:
+ [
+ "POST /update_jobs/TEST-ID/create_dependency_submission",
+ "PATCH /update_jobs/TEST-ID/mark_as_processed",
+ ]
+ );
+ }
+
+ private static Task RunAsync(TestFile[] files, Job job, string[] expectedUrls, MockNuGetPackage[]? packages = null, string? repoContentsPath = null, int expectedExitCode = 0)
+ => EntryPointTestHelper.RunAsync("graph", files, job, expectedUrls, packages, repoContentsPath, expectedExitCode);
+ }
+}
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 d678c482ee0..fffb2781c42 100644
--- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Run.cs
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Run.cs
@@ -143,65 +143,7 @@ await RunAsync(
);
}
- private static async Task RunAsync(TestFile[] files, Job job, string[] expectedUrls, MockNuGetPackage[]? packages = null, string? repoContentsPath = null, int expectedExitCode = 0)
- {
- using var tempDirectory = new TemporaryDirectory();
-
- // write test files
- foreach (var testFile in files)
- {
- var fullPath = Path.Join(tempDirectory.DirectoryPath, testFile.Path);
- var directory = Path.GetDirectoryName(fullPath)!;
- Directory.CreateDirectory(directory);
- await File.WriteAllTextAsync(fullPath, testFile.Content);
- }
-
- // write job file
- var jobPath = Path.Combine(tempDirectory.DirectoryPath, "job.json");
- await File.WriteAllTextAsync(jobPath, JsonSerializer.Serialize(new { Job = job }, RunWorker.SerializerOptions));
-
- // save packages
- await UpdateWorkerTestBase.MockNuGetPackagesInDirectory(packages, tempDirectory.DirectoryPath);
-
- var actualUrls = new List();
- using var http = TestHttpServer.CreateTestStringServer((method, url) =>
- {
- actualUrls.Add($"{method} {new Uri(url).PathAndQuery}");
- return (200, "ok");
- });
- var args = new List()
- {
- "run",
- "--job-path",
- jobPath,
- "--repo-contents-path",
- repoContentsPath ?? tempDirectory.DirectoryPath,
- "--api-url",
- http.BaseUrl,
- "--job-id",
- "TEST-ID",
- "--base-commit-sha",
- "BASE-COMMIT-SHA"
- };
-
- var output = new StringBuilder();
- // redirect stdout
- var originalOut = Console.Out;
- Console.SetOut(new StringWriter(output));
- int result = -1;
- try
- {
- result = await Program.Main(args.ToArray());
- }
- catch
- {
- // restore stdout
- Console.SetOut(originalOut);
- throw;
- }
-
- Assert.True(result == expectedExitCode, $"Expected exit code {expectedExitCode} but got {result}.\nSTDOUT:\n" + output.ToString());
- Assert.Equal(expectedUrls, actualUrls);
- }
+ private static Task RunAsync(TestFile[] files, Job job, string[] expectedUrls, MockNuGetPackage[]? packages = null, string? repoContentsPath = null, int expectedExitCode = 0)
+ => EntryPointTestHelper.RunAsync("run", files, job, expectedUrls, packages, repoContentsPath, expectedExitCode);
}
}
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/CloneCommand.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/CloneCommand.cs
index 243e870678e..94205f026b9 100644
--- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/CloneCommand.cs
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/CloneCommand.cs
@@ -8,33 +8,24 @@ namespace NuGetUpdater.Cli.Commands;
internal static class CloneCommand
{
- internal static readonly Option JobPathOption = new("--job-path") { Required = true };
- internal static readonly Option RepoContentsPathOption = new("--repo-contents-path") { Required = true };
- internal static readonly Option ApiUrlOption = new("--api-url")
- {
- Required = true,
- CustomParser = (argumentResult) => Uri.TryCreate(argumentResult.Tokens.Single().Value, UriKind.Absolute, out var uri) ? uri : throw new ArgumentException("Invalid API URL format.")
- };
- internal static readonly Option JobIdOption = new("--job-id") { Required = 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,
+ SharedOptions.JobPathOption,
+ SharedOptions.RepoContentsPathOption,
+ SharedOptions.ApiUrlOption,
+ SharedOptions.JobIdOption,
};
command.TreatUnmatchedTokensAsErrors = true;
command.SetAction(async (parseResult, cancellationToken) =>
{
- var jobPath = parseResult.GetValue(JobPathOption);
- var repoContentsPath = parseResult.GetValue(RepoContentsPathOption);
- var apiUrl = parseResult.GetValue(ApiUrlOption);
- var jobId = parseResult.GetValue(JobIdOption);
+ var jobPath = parseResult.GetValue(SharedOptions.JobPathOption);
+ var repoContentsPath = parseResult.GetValue(SharedOptions.RepoContentsPathOption);
+ var apiUrl = parseResult.GetValue(SharedOptions.ApiUrlOption);
+ var jobId = parseResult.GetValue(SharedOptions.JobIdOption);
var apiHandler = new HttpApiHandler(apiUrl!.ToString(), jobId!);
var logger = new OpenTelemetryLogger();
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/GraphCommand.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/GraphCommand.cs
new file mode 100644
index 00000000000..40da88f521a
--- /dev/null
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/GraphCommand.cs
@@ -0,0 +1,47 @@
+using System.CommandLine;
+
+using NuGetUpdater.Core;
+using NuGetUpdater.Core.Discover;
+using NuGetUpdater.Core.Graph;
+using NuGetUpdater.Core.Run;
+
+namespace NuGetUpdater.Cli.Commands;
+
+internal static class GraphCommand
+{
+ internal static Command GetCommand(Action setExitCode)
+ {
+ Command command = new("graph", "Generates a dependency graph for a repository.")
+ {
+ SharedOptions.JobPathOption,
+ SharedOptions.RepoContentsPathOption,
+ SharedOptions.CaseInsensitiveRepoContentsPathOption,
+ SharedOptions.ApiUrlOption,
+ SharedOptions.JobIdOption,
+ SharedOptions.BaseCommitShaOption
+ };
+
+ command.TreatUnmatchedTokensAsErrors = true;
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var jobPath = parseResult.GetValue(SharedOptions.JobPathOption);
+ var repoContentsPath = parseResult.GetValue(SharedOptions.RepoContentsPathOption);
+ var caseInsensitiveRepoContentsPath = parseResult.GetValue(SharedOptions.CaseInsensitiveRepoContentsPathOption);
+ var apiUrl = parseResult.GetValue(SharedOptions.ApiUrlOption);
+ var jobId = parseResult.GetValue(SharedOptions.JobIdOption);
+ var baseCommitSha = parseResult.GetValue(SharedOptions.BaseCommitShaOption);
+
+ var apiHandler = new HttpApiHandler(apiUrl!.ToString(), jobId!);
+ var (experimentsManager, _errorResult) = await ExperimentsManager.FromJobFileAsync(jobId!, jobPath!.FullName);
+ var logger = new OpenTelemetryLogger();
+ var discoverWorker = new DiscoveryWorker(jobId!, experimentsManager, logger);
+ var worker = new GraphWorker(jobId!, apiHandler, discoverWorker, logger);
+ var result = await worker.RunAsync(jobPath!, repoContentsPath!, caseInsensitiveRepoContentsPath, baseCommitSha!);
+ setExitCode(result);
+ return 0;
+ });
+
+ return command;
+ }
+}
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/RunCommand.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/RunCommand.cs
index 4d6d76ecd87..4c990ad6ef4 100644
--- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/RunCommand.cs
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/RunCommand.cs
@@ -9,39 +9,28 @@ namespace NuGetUpdater.Cli.Commands;
internal static class RunCommand
{
- internal static readonly Option JobPathOption = new("--job-path") { Required = true };
- internal static readonly Option RepoContentsPathOption = new("--repo-contents-path") { Required = true };
- internal static readonly Option CaseInsensitiveRepoContentsPathOption = new("--case-insensitive-repo-contents-path") { Required = false };
- internal static readonly Option ApiUrlOption = new("--api-url")
- {
- Required = true,
- CustomParser = (argumentResult) => Uri.TryCreate(argumentResult.Tokens.Single().Value, UriKind.Absolute, out var uri) ? uri : throw new ArgumentException("Invalid API URL format.")
- };
- internal static readonly Option JobIdOption = new("--job-id") { Required = true };
- internal static readonly Option BaseCommitShaOption = new("--base-commit-sha") { Required = true };
-
internal static Command GetCommand(Action setExitCode)
{
Command command = new("run", "Runs a full dependabot job.")
{
- JobPathOption,
- RepoContentsPathOption,
- CaseInsensitiveRepoContentsPathOption,
- ApiUrlOption,
- JobIdOption,
- BaseCommitShaOption
+ SharedOptions.JobPathOption,
+ SharedOptions.RepoContentsPathOption,
+ SharedOptions.CaseInsensitiveRepoContentsPathOption,
+ SharedOptions.ApiUrlOption,
+ SharedOptions.JobIdOption,
+ SharedOptions.BaseCommitShaOption
};
command.TreatUnmatchedTokensAsErrors = true;
command.SetAction(async (parseResult, cancellationToken) =>
{
- var jobPath = parseResult.GetValue(JobPathOption);
- var repoContentsPath = parseResult.GetValue(RepoContentsPathOption);
- var caseInsensitiveRepoContentsPath = parseResult.GetValue(CaseInsensitiveRepoContentsPathOption);
- var apiUrl = parseResult.GetValue(ApiUrlOption);
- var jobId = parseResult.GetValue(JobIdOption);
- var baseCommitSha = parseResult.GetValue(BaseCommitShaOption);
+ var jobPath = parseResult.GetValue(SharedOptions.JobPathOption);
+ var repoContentsPath = parseResult.GetValue(SharedOptions.RepoContentsPathOption);
+ var caseInsensitiveRepoContentsPath = parseResult.GetValue(SharedOptions.CaseInsensitiveRepoContentsPathOption);
+ var apiUrl = parseResult.GetValue(SharedOptions.ApiUrlOption);
+ var jobId = parseResult.GetValue(SharedOptions.JobIdOption);
+ var baseCommitSha = parseResult.GetValue(SharedOptions.BaseCommitShaOption);
var apiHandler = new HttpApiHandler(apiUrl!.ToString(), jobId!);
var (experimentsManager, _errorResult) = await ExperimentsManager.FromJobFileAsync(jobId!, jobPath!.FullName);
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/SharedOptions.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/SharedOptions.cs
new file mode 100644
index 00000000000..25e6952c57d
--- /dev/null
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Commands/SharedOptions.cs
@@ -0,0 +1,17 @@
+using System.CommandLine;
+
+namespace NuGetUpdater.Cli.Commands;
+
+internal static class SharedOptions
+{
+ internal static readonly Option JobPathOption = new("--job-path") { Required = true };
+ internal static readonly Option RepoContentsPathOption = new("--repo-contents-path") { Required = true };
+ internal static readonly Option CaseInsensitiveRepoContentsPathOption = new("--case-insensitive-repo-contents-path") { Required = false };
+ internal static readonly Option ApiUrlOption = new("--api-url")
+ {
+ Required = true,
+ CustomParser = (argumentResult) => Uri.TryCreate(argumentResult.Tokens.Single().Value, UriKind.Absolute, out var uri) ? uri : throw new ArgumentException("Invalid API URL format.")
+ };
+ internal static readonly Option JobIdOption = new("--job-id") { Required = true };
+ internal static readonly Option BaseCommitShaOption = new("--base-commit-sha") { Required = true };
+}
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Program.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Program.cs
index 1553fef7e27..dce5cb1a334 100644
--- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Program.cs
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Cli/Program.cs
@@ -20,6 +20,7 @@ internal static async Task Main(string[] args)
{
CloneCommand.GetCommand(setExitCode),
RunCommand.GetCommand(setExitCode),
+ GraphCommand.GetCommand(setExitCode),
};
command.TreatUnmatchedTokensAsErrors = true;
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Discover/DiscoveryWorkerTestBase.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Discover/DiscoveryWorkerTestBase.cs
index 8399993e3b3..2444522ebc7 100644
--- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Discover/DiscoveryWorkerTestBase.cs
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Discover/DiscoveryWorkerTestBase.cs
@@ -117,6 +117,16 @@ internal static void ValidateProjectResults(ImmutableArray
+
+
+ v4.5
+
+
+
+
+
+
+ packages\Some.Package.1.0.0\lib\net45\Some.Package.dll
+ True
+
+
+
+
+ """),
+ ("src/packages.config", """
+
+
+
+ """),
+ ],
+ expectedResult: new()
+ {
+ Path = "src",
+ Projects =
+ [
+ new()
+ {
+ FilePath = "project.csproj",
+ TargetFrameworks = ["net45"],
+ Dependencies =
+ [
+ new Dependency("Some.Package", "1.0.0", DependencyType.PackagesConfig, TargetFrameworks: ["net45"]),
+ ],
+ ReferencedProjectPaths = [],
+ ImportedFiles = [],
+ AdditionalFiles = [
+ "packages.config",
+ ],
+ ExpectedDependencyGraph = new Dictionary>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Some.Package/1.0.0"] = [],
+ }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
+ }
+ ]
+ }
+ );
+ }
+
+ [Fact]
+ public async Task TestDependencyGraphIsPopulated()
+ {
+ await TestDiscoveryAsync(
+ packages:
+ [
+ MockNuGetPackage.CreateSimplePackage("Some.Package", "1.0.0", "net8.0",
+ dependencyGroups: [(null, [("Some.Dependency", "2.0.0")])]),
+ MockNuGetPackage.CreateSimplePackage("Some.Dependency", "2.0.0", "net8.0"),
+ ],
+ workspacePath: "src",
+ files:
+ [
+ ("src/project.csproj", """
+
+
+ net8.0
+
+
+
+
+
+ """)
+ ],
+ expectedResult: new()
+ {
+ Path = "src",
+ Projects =
+ [
+ new()
+ {
+ FilePath = "project.csproj",
+ TargetFrameworks = ["net8.0"],
+ Dependencies =
+ [
+ new Dependency("Some.Dependency", "2.0.0", DependencyType.Unknown, TargetFrameworks: ["net8.0"], IsTopLevel: false),
+ new Dependency("Some.Package", "1.0.0", DependencyType.PackageReference, TargetFrameworks: ["net8.0"]),
+ ],
+ ReferencedProjectPaths = [],
+ ImportedFiles = [],
+ AdditionalFiles = [],
+ ExpectedDependencyGraph = new Dictionary>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Some.Package/1.0.0"] = ["Some.Dependency/2.0.0"],
+ ["Some.Dependency/2.0.0"] = [],
+ }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
+ }
+ ]
+ }
+ );
+ }
}
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Discover/ExpectedDiscoveryResults.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Discover/ExpectedDiscoveryResults.cs
index aec0fe010ca..e63cdff048c 100644
--- a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Discover/ExpectedDiscoveryResults.cs
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Discover/ExpectedDiscoveryResults.cs
@@ -24,6 +24,7 @@ public record ExpectedSdkProjectDiscoveryResult : ExpectedDependencyDiscoveryRes
public string? ErrorDetails { get; init; }
public PackageManagementKind? ExpectedPackageManagementKind { get; init; } = null;
public string? ExpectedPackageManagementSpecialFileRelativePath { get; init; } = null;
+ public ImmutableDictionary>? ExpectedDependencyGraph { get; init; }
}
public record ExpectedDependencyDiscoveryResult : IDiscoveryResultWithDependencies
diff --git a/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Graph/GraphWorkerTests.cs b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Graph/GraphWorkerTests.cs
new file mode 100644
index 00000000000..40a98f0e7e6
--- /dev/null
+++ b/nuget/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Graph/GraphWorkerTests.cs
@@ -0,0 +1,465 @@
+using System.Collections.Immutable;
+
+using NuGetUpdater.Core.Discover;
+using NuGetUpdater.Core.Graph;
+using NuGetUpdater.Core.Run.ApiModel;
+
+using Xunit;
+
+namespace NuGetUpdater.Core.Test.Graph;
+
+public class GraphWorkerTests
+{
+ [Theory]
+ [MemberData(nameof(BuildDependencySubmissionTestData))]
+ public void BuildDependencySubmission_ConvertsDiscoveryResults(
+ WorkspaceDiscoveryResult discoveryResult,
+ Job job,
+ string baseCommitSha,
+ string repoRoot,
+ string directory,
+ string expectedStatus,
+ string? expectedReason,
+ int expectedManifestCount)
+ {
+ // Arrange
+ var logger = new TestLogger();
+ var apiHandler = new TestApiHandler();
+ var discoveryWorker = TestDiscoveryWorker.FromResults();
+ var worker = new GraphWorker("test-job-id", apiHandler, discoveryWorker, logger);
+
+ // Act
+ var result = worker.BuildDependencySubmission(discoveryResult, job, baseCommitSha, repoRoot, directory);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(1, result.Version);
+ Assert.Equal(baseCommitSha, result.Sha);
+ Assert.Equal(expectedStatus, result.Metadata.Status);
+ Assert.Equal(expectedReason, result.Metadata.Reason);
+ Assert.Equal(expectedManifestCount, result.Manifests.Count);
+ Assert.Equal($"nuget::{directory}", result.Metadata.ScannedManifestPath);
+ }
+
+ public static IEnumerable