Skip to content
2 changes: 1 addition & 1 deletion RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@

- Added `--migration-id` option to `download-logs` command to allow downloading logs directly by migration ID without requiring org/repo lookup
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,19 @@ public class DownloadLogsCommandBase : CommandBase<DownloadLogsCommandArgs, Down

public virtual Option<string> GithubOrg { get; } = new("--github-org")
{
IsRequired = true,
Description = "GitHub organization to download logs from."
};

public virtual Option<string> GithubRepo { get; } = new("--github-repo")
{
IsRequired = true,
Description = "Target repository to download latest log for."
};

public virtual Option<string> 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<string> GithubApiUrl { get; } = new("--github-api-url")
{
Description = "Target GitHub API URL if not targeting github.com (default: https://api.github.com)."
Expand Down Expand Up @@ -79,6 +82,7 @@ protected void AddOptions()
{
AddOption(GithubOrg);
AddOption(GithubRepo);
AddOption(MigrationId);
AddOption(GithubApiUrl);
AddOption(GithubPat);
AddOption(MigrationLogFile);
Expand Down
69 changes: 57 additions & 12 deletions src/Octoshift/Commands/DownloadLogs/DownloadLogsCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand All @@ -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))
{
Expand All @@ -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}.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,126 @@ await FluentActions
_mockGithubApi.Verify(m => m.GetMigrationLogUrl(githubOrg, repo), Times.Exactly(6));
_mockHttpDownloadService.Verify(m => m.DownloadToFile(It.IsAny<string>(), It.IsAny<string>()), 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<OctoshiftCliException>()
.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<OctoshiftCliException>()
.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<OctoshiftCliException>()
.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<string>(), It.IsAny<string>()));

// 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<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<string>()));

// 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<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<string>()));

// 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<string>()), Times.Never);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 0 additions & 2 deletions src/gei/Commands/DownloadLogs/DownloadLogsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,11 @@ public class DownloadLogsCommand : DownloadLogsCommandBase

public override Option<string> GithubRepo { get; } = new("--target-repo")
{
IsRequired = true,
Description = "Target repository to download latest log for."
};

public override Option<string> GithubOrg { get; } = new("--github-target-org")
{
IsRequired = true,
Description = "Target GitHub organization to download logs from."
};
}
Loading