diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8b1378917..e88df6a06 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1 +1 @@ - +- Added `--migration-id` option to `download-logs` command to allow downloading logs directly by migration ID without requiring org/repo lookup diff --git a/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandArgs.cs b/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandArgs.cs index 74897d4aa..8795ca52d 100644 --- a/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandArgs.cs +++ b/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandArgs.cs @@ -5,6 +5,7 @@ public class DownloadLogsCommandArgs : CommandArgs { public string GithubOrg { get; set; } public string GithubRepo { get; set; } + public string MigrationId { get; set; } public string GithubApiUrl { get; set; } [Secret] public string GithubPat { get; set; } diff --git a/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandBase.cs b/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandBase.cs index cd551ca96..70b031c4c 100644 --- a/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandBase.cs +++ b/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandBase.cs @@ -18,16 +18,19 @@ public class DownloadLogsCommandBase : CommandBase GithubOrg { get; } = new("--github-org") { - IsRequired = true, Description = "GitHub organization to download logs from." }; public virtual Option GithubRepo { get; } = new("--github-repo") { - IsRequired = true, Description = "Target repository to download latest log for." }; + public virtual Option MigrationId { get; } = new("--migration-id") + { + Description = "Migration ID to download logs for. If specified, --github-org and --github-repo are not required." + }; + public virtual Option GithubApiUrl { get; } = new("--github-api-url") { Description = "Target GitHub API URL if not targeting github.com (default: https://api.github.com)." @@ -79,6 +82,7 @@ protected void AddOptions() { AddOption(GithubOrg); AddOption(GithubRepo); + AddOption(MigrationId); AddOption(GithubApiUrl); AddOption(GithubPat); AddOption(MigrationLogFile); diff --git a/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandler.cs b/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandler.cs index bccf84625..60a64a548 100644 --- a/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandler.cs +++ b/src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandler.cs @@ -38,6 +38,21 @@ public async Task Handle(DownloadLogsCommandArgs args) throw new ArgumentNullException(nameof(args)); } + if (args.MigrationId.HasValue()) + { + if (args.GithubOrg.HasValue() || args.GithubRepo.HasValue()) + { + _log.LogWarning("--github-org and --github-repo are ignored when --migration-id is specified."); + } + } + else + { + if (!args.GithubOrg.HasValue() || !args.GithubRepo.HasValue()) + { + throw new OctoshiftCliException("Either --migration-id (GraphQL migration ID) or both --github-org and --github-repo must be specified."); + } + } + _log.LogWarning("Migration logs are only available for 24 hours after a migration finishes!"); _log.LogInformation("Downloading migration logs..."); @@ -49,22 +64,52 @@ public async Task Handle(DownloadLogsCommandArgs args) throw new OctoshiftCliException($"File {args.MigrationLogFile} already exists! Use --overwrite to overwrite this file."); } - var result = await _retryPolicy.RetryOnResult(async () => await _githubApi.GetMigrationLogUrl(args.GithubOrg, args.GithubRepo), r => r?.MigrationLogUrl.IsNullOrWhiteSpace() ?? false, - "Waiting for migration log to populate..."); + string logUrl; + string migrationId; + string repositoryName; - if (result.Outcome == OutcomeType.Successful && result.Result is null) + if (args.MigrationId.HasValue()) { - throw new OctoshiftCliException($"Migration for repository {args.GithubRepo} not found!"); - } + // Use migration ID directly + migrationId = args.MigrationId; + var migrationResult = await _retryPolicy.RetryOnResult( + async () => await _githubApi.GetMigration(migrationId), + r => string.IsNullOrWhiteSpace(r.MigrationLogUrl), + "Waiting for migration log to populate..."); + + if (migrationResult.Outcome == OutcomeType.Failure) + { + throw new OctoshiftCliException($"Migration log for migration {migrationId} is currently unavailable. Migration logs are only available for 24 hours after a migration finishes. Please ensure the migration ID is correct and the migration has completed recently."); + } - if (result.Outcome == OutcomeType.Failure) - { - throw new OctoshiftCliException($"Migration log for repository {args.GithubRepo} unavailable!"); + var (_, RepositoryName, _, _, MigrationLogUrl) = migrationResult.Result; + logUrl = MigrationLogUrl; + repositoryName = RepositoryName; } + else + { + // Use org/repo to find migration + var result = await _retryPolicy.RetryOnResult(async () => await _githubApi.GetMigrationLogUrl(args.GithubOrg, args.GithubRepo), r => r?.MigrationLogUrl.IsNullOrWhiteSpace() ?? false, + "Waiting for migration log to populate..."); + + if (result.Outcome == OutcomeType.Successful && result.Result is null) + { + throw new OctoshiftCliException($"Migration for repository {args.GithubRepo} not found!"); + } - var (logUrl, migrationId) = result.Result.Value; + if (result.Outcome == OutcomeType.Failure) + { + throw new OctoshiftCliException($"Migration log for repository {args.GithubRepo} unavailable!"); + } + + (logUrl, migrationId) = result.Result.Value; + repositoryName = args.GithubRepo; + } - args.MigrationLogFile ??= $"migration-log-{args.GithubOrg}-{args.GithubRepo}-{migrationId}.log"; + var defaultFileName = args.MigrationId.HasValue() + ? $"migration-log-{repositoryName}-{migrationId}.log" + : $"migration-log-{args.GithubOrg}-{repositoryName}-{migrationId}.log"; + args.MigrationLogFile ??= defaultFileName; if (FileExists(args.MigrationLogFile)) { @@ -76,9 +121,9 @@ public async Task Handle(DownloadLogsCommandArgs args) _log.LogWarning($"Overwriting {args.MigrationLogFile} due to --overwrite option."); } - _log.LogInformation($"Downloading log for repository {args.GithubRepo} to {args.MigrationLogFile}..."); + _log.LogInformation($"Downloading log for repository {repositoryName} to {args.MigrationLogFile}..."); await _httpDownloadService.DownloadToFile(logUrl, args.MigrationLogFile); - _log.LogSuccess($"Downloaded {args.GithubRepo} log to {args.MigrationLogFile}."); + _log.LogSuccess($"Downloaded {repositoryName} log to {args.MigrationLogFile}."); } } diff --git a/src/OctoshiftCLI.Tests/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandlerTests.cs b/src/OctoshiftCLI.Tests/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandlerTests.cs index ec2a06413..5e1614731 100644 --- a/src/OctoshiftCLI.Tests/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandlerTests.cs +++ b/src/OctoshiftCLI.Tests/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandlerTests.cs @@ -299,4 +299,126 @@ await FluentActions _mockGithubApi.Verify(m => m.GetMigrationLogUrl(githubOrg, repo), Times.Exactly(6)); _mockHttpDownloadService.Verify(m => m.DownloadToFile(It.IsAny(), It.IsAny()), Times.Never()); } + + [Fact] + public async Task Should_Throw_When_Neither_MigrationId_Nor_OrgRepo_Provided() + { + // Act & Assert + var args = new DownloadLogsCommandArgs(); + await FluentAssertions.FluentActions + .Invoking(async () => await _handler.Handle(args)) + .Should().ThrowAsync() + .WithMessage("Either --migration-id (GraphQL migration ID) or both --github-org and --github-repo must be specified."); + } + + [Fact] + public async Task Should_Throw_When_Only_GithubOrg_Provided() + { + // Act & Assert + var args = new DownloadLogsCommandArgs + { + GithubOrg = "test-org" + }; + await FluentAssertions.FluentActions + .Invoking(async () => await _handler.Handle(args)) + .Should().ThrowAsync() + .WithMessage("Either --migration-id (GraphQL migration ID) or both --github-org and --github-repo must be specified."); + } + + [Fact] + public async Task Should_Throw_When_Only_GithubRepo_Provided() + { + // Act & Assert + var args = new DownloadLogsCommandArgs + { + GithubRepo = "test-repo" + }; + await FluentAssertions.FluentActions + .Invoking(async () => await _handler.Handle(args)) + .Should().ThrowAsync() + .WithMessage("Either --migration-id (GraphQL migration ID) or both --github-org and --github-repo must be specified."); + } + + [Fact] + public async Task Should_Log_Warning_When_MigrationId_And_OrgRepo_Both_Provided() + { + // Arrange + const string migrationId = "RM_test123"; + const string githubOrg = "test-org"; + const string githubRepo = "test-repo"; + const string logUrl = "some-url"; + const string repoName = "test-repo-name"; + + _mockGithubApi.Setup(m => m.GetMigration(migrationId)) + .ReturnsAsync((State: "SUCCEEDED", RepositoryName: repoName, WarningsCount: 0, FailureReason: "", MigrationLogUrl: logUrl)); + _mockHttpDownloadService.Setup(m => m.DownloadToFile(It.IsAny(), It.IsAny())); + + // Act + var args = new DownloadLogsCommandArgs + { + MigrationId = migrationId, + GithubOrg = githubOrg, + GithubRepo = githubRepo + }; + await _handler.Handle(args); + + // Assert + _mockLogger.Verify(m => m.LogWarning("--github-org and --github-repo are ignored when --migration-id is specified."), Times.Once); + _mockGithubApi.Verify(m => m.GetMigration(migrationId), Times.Once); + _mockGithubApi.Verify(m => m.GetMigrationLogUrl(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Should_Succeed_When_Only_MigrationId_Provided() + { + // Arrange + const string migrationId = "RM_test123"; + const string logUrl = "some-url"; + const string repoName = "test-repo-name"; + const string expectedFileName = $"migration-log-{repoName}-{migrationId}.log"; + + _mockGithubApi.Setup(m => m.GetMigration(migrationId)) + .ReturnsAsync((State: "SUCCEEDED", RepositoryName: repoName, WarningsCount: 0, FailureReason: "", MigrationLogUrl: logUrl)); + _mockHttpDownloadService.Setup(m => m.DownloadToFile(It.IsAny(), It.IsAny())); + + // Act + var args = new DownloadLogsCommandArgs + { + MigrationId = migrationId + }; + await _handler.Handle(args); + + // Assert + _mockGithubApi.Verify(m => m.GetMigration(migrationId), Times.Once); + _mockHttpDownloadService.Verify(m => m.DownloadToFile(logUrl, expectedFileName), Times.Once); + _mockGithubApi.Verify(m => m.GetMigrationLogUrl(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Should_Succeed_When_Only_OrgRepo_Provided() + { + // Arrange + const string githubOrg = "test-org"; + const string githubRepo = "test-repo"; + const string logUrl = "some-url"; + const string migrationId = "RM_test123"; + const string expectedFileName = $"migration-log-{githubOrg}-{githubRepo}-{migrationId}.log"; + + _mockGithubApi.Setup(m => m.GetMigrationLogUrl(githubOrg, githubRepo)) + .ReturnsAsync((logUrl, migrationId)); + _mockHttpDownloadService.Setup(m => m.DownloadToFile(It.IsAny(), It.IsAny())); + + // Act + var args = new DownloadLogsCommandArgs + { + GithubOrg = githubOrg, + GithubRepo = githubRepo + }; + await _handler.Handle(args); + + // Assert + _mockGithubApi.Verify(m => m.GetMigrationLogUrl(githubOrg, githubRepo), Times.Once); + _mockHttpDownloadService.Verify(m => m.DownloadToFile(logUrl, expectedFileName), Times.Once); + _mockGithubApi.Verify(m => m.GetMigration(It.IsAny()), Times.Never); + } } diff --git a/src/OctoshiftCLI.Tests/ado2gh/Commands/DownloadLogs/DownloadLogsCommandTests.cs b/src/OctoshiftCLI.Tests/ado2gh/Commands/DownloadLogs/DownloadLogsCommandTests.cs index 7d8e52a65..7b371833f 100644 --- a/src/OctoshiftCLI.Tests/ado2gh/Commands/DownloadLogs/DownloadLogsCommandTests.cs +++ b/src/OctoshiftCLI.Tests/ado2gh/Commands/DownloadLogs/DownloadLogsCommandTests.cs @@ -11,10 +11,11 @@ public void Should_Have_Options() var command = new DownloadLogsCommand(); Assert.NotNull(command); Assert.Equal("download-logs", command.Name); - Assert.Equal(7, command.Options.Count); + Assert.Equal(8, command.Options.Count); - TestHelpers.VerifyCommandOption(command.Options, "github-org", true); - TestHelpers.VerifyCommandOption(command.Options, "github-repo", true); + TestHelpers.VerifyCommandOption(command.Options, "github-org", false); + TestHelpers.VerifyCommandOption(command.Options, "github-repo", false); + TestHelpers.VerifyCommandOption(command.Options, "migration-id", false); TestHelpers.VerifyCommandOption(command.Options, "github-api-url", false); TestHelpers.VerifyCommandOption(command.Options, "github-pat", false); TestHelpers.VerifyCommandOption(command.Options, "migration-log-file", false); diff --git a/src/OctoshiftCLI.Tests/bbs2gh/Commands/DownloadLogs/DownloadLogsCommandTests.cs b/src/OctoshiftCLI.Tests/bbs2gh/Commands/DownloadLogs/DownloadLogsCommandTests.cs index 0ed0a634d..dc99030b2 100644 --- a/src/OctoshiftCLI.Tests/bbs2gh/Commands/DownloadLogs/DownloadLogsCommandTests.cs +++ b/src/OctoshiftCLI.Tests/bbs2gh/Commands/DownloadLogs/DownloadLogsCommandTests.cs @@ -11,10 +11,11 @@ public void Should_Have_Options() var command = new DownloadLogsCommand(); Assert.NotNull(command); Assert.Equal("download-logs", command.Name); - Assert.Equal(7, command.Options.Count); + Assert.Equal(8, command.Options.Count); - TestHelpers.VerifyCommandOption(command.Options, "github-org", true); - TestHelpers.VerifyCommandOption(command.Options, "github-repo", true); + TestHelpers.VerifyCommandOption(command.Options, "github-org", false); + TestHelpers.VerifyCommandOption(command.Options, "github-repo", false); + TestHelpers.VerifyCommandOption(command.Options, "migration-id", false); TestHelpers.VerifyCommandOption(command.Options, "github-api-url", false); TestHelpers.VerifyCommandOption(command.Options, "github-pat", false); TestHelpers.VerifyCommandOption(command.Options, "migration-log-file", false); diff --git a/src/OctoshiftCLI.Tests/gei/Commands/DownloadLogs/DownloadLogsCommandTests.cs b/src/OctoshiftCLI.Tests/gei/Commands/DownloadLogs/DownloadLogsCommandTests.cs index 890eff88b..5a98d94d7 100644 --- a/src/OctoshiftCLI.Tests/gei/Commands/DownloadLogs/DownloadLogsCommandTests.cs +++ b/src/OctoshiftCLI.Tests/gei/Commands/DownloadLogs/DownloadLogsCommandTests.cs @@ -11,10 +11,11 @@ public void Should_Have_Options() var command = new DownloadLogsCommand(); Assert.NotNull(command); Assert.Equal("download-logs", command.Name); - Assert.Equal(7, command.Options.Count); + Assert.Equal(8, command.Options.Count); - TestHelpers.VerifyCommandOption(command.Options, "github-target-org", true); - TestHelpers.VerifyCommandOption(command.Options, "target-repo", true); + TestHelpers.VerifyCommandOption(command.Options, "github-target-org", false); + TestHelpers.VerifyCommandOption(command.Options, "target-repo", false); + TestHelpers.VerifyCommandOption(command.Options, "migration-id", false); TestHelpers.VerifyCommandOption(command.Options, "target-api-url", false); TestHelpers.VerifyCommandOption(command.Options, "github-target-pat", false); TestHelpers.VerifyCommandOption(command.Options, "migration-log-file", false); diff --git a/src/gei/Commands/DownloadLogs/DownloadLogsCommand.cs b/src/gei/Commands/DownloadLogs/DownloadLogsCommand.cs index 83661c45b..6d7c36469 100644 --- a/src/gei/Commands/DownloadLogs/DownloadLogsCommand.cs +++ b/src/gei/Commands/DownloadLogs/DownloadLogsCommand.cs @@ -22,13 +22,11 @@ public class DownloadLogsCommand : DownloadLogsCommandBase public override Option GithubRepo { get; } = new("--target-repo") { - IsRequired = true, Description = "Target repository to download latest log for." }; public override Option GithubOrg { get; } = new("--github-target-org") { - IsRequired = true, Description = "Target GitHub organization to download logs from." }; }