diff --git a/README.md b/README.md index fe0dfe7cf..288f63330 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,27 @@ Refer to the [official documentation](https://docs.github.com/en/migrations/usin Refer to the [official documentation](https://docs.github.com/en/migrations/using-github-enterprise-importer/migrating-repositories-with-github-enterprise-importer/migrating-repositories-from-bitbucket-server-to-github-enterprise-cloud) for more details. +#### Setting custom properties during a Bitbucket migration + +Use `--custom-properties` with `gh bbs2gh migrate-repo` to set custom property values on the destination repository. Pass a JSON object mapping property names to strings, arrays of strings (for multi-select properties), or `null` (to unset a value). Boolean properties use the strings `"true"` and `"false"`, not JSON booleans. + +For example, when importing an archive that you have already uploaded: + +```bash +gh bbs2gh migrate-repo \ + --bbs-server-url https://bitbucket.example.com \ + --bbs-project PROJECT --bbs-repo source-repo \ + --archive-url "https://example.com/migration-archive.tar" \ + --github-org DESTINATION --github-repo target-repo \ + --custom-properties '{"environment":"production","teams":["platform","security"],"reviewed":"true"}' +``` + +The option also works when `migrate-repo` generates and uploads the archive. The properties must already be defined in the destination organization, and the target GitHub token must have permission to edit their values, in addition to the usual migration permissions. See the [custom property API documentation](https://docs.github.com/en/rest/repos/custom-properties#create-or-update-custom-property-values-for-a-repository). + +Properties are applied **after the migration succeeds**, not atomically when the repository is created. This option cannot satisfy policies that require property values at repository creation time. It cannot be combined with `--queue-only` or used for export-only operations. If you edit a generated migration script, add the option to the relevant `migrate-repo` calls and remove `--queue-only` from those calls; `generate-script` does not accept this option. + +If applying the properties fails, the command returns an error, but the migrated repository remains in place. Check the reported error, network connectivity, property definitions, and permissions, then verify and set the values through GitHub's UI or API instead of rerunning the migration. A timeout or cancellation may occur after GitHub has applied the values, so check the current values first. This option is available only in `bbs2gh migrate-repo`. + ### GitLab to GitHub Usage 1. Create a Personal Access Token for the source GitLab instance (with `api` and `read_repository` scopes) and one for the target GitHub org (for more details on scopes needed refer to our [official documentation](https://docs.github.com/en/migrations/using-github-enterprise-importer/preparing-to-migrate-with-github-enterprise-importer/managing-access-for-github-enterprise-importer)). diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8b1378917..e8884e7f0 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1 +1,3 @@ +- Added `--custom-properties` to `bbs2gh migrate-repo` to apply custom property values from a JSON object after a successful migration. +- Custom property update timeouts, cancellations, and rate-limit errors now include recovery guidance without requiring the completed migration to be rerun. diff --git a/src/Octoshift/Services/GithubApi.cs b/src/Octoshift/Services/GithubApi.cs index 55b969a89..c049c7bd1 100644 --- a/src/Octoshift/Services/GithubApi.cs +++ b/src/Octoshift/Services/GithubApi.cs @@ -185,6 +185,26 @@ public virtual async Task DoesRepoExist(string org, string repo) } } + public virtual async Task SetRepositoryCustomProperties(string org, string repo, JObject customProperties) + { + if (customProperties is null) + { + throw new ArgumentNullException(nameof(customProperties)); + } + + var url = $"{_apiUrl}/repos/{org.EscapeDataString()}/{repo.EscapeDataString()}/properties/values"; + var payload = new + { + properties = customProperties.Properties().Select(property => new + { + property_name = property.Name, + value = property.Value + }) + }; + + await _client.PatchAsync(url, payload); + } + public virtual async Task DoesOrgExist(string org) { var url = $"{_apiUrl}/orgs/{org.EscapeDataString()}"; diff --git a/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs b/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs index 949f3300a..331267c8a 100644 --- a/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs +++ b/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs @@ -57,6 +57,54 @@ public GithubApiTests() _githubApi = new GithubApi(_githubClientMock.Object, API_URL, _retryPolicy, _archiveUploader.Object); } + [Theory] + [InlineData(API_URL, GITHUB_ORG, GITHUB_REPO, "ORG_LOGIN/REPOSITORY_NAME")] + [InlineData("https://api.example.ghe.com", "org/name", "repo name", "org%2Fname/repo%20name")] + public async Task SetRepositoryCustomProperties_Calls_The_Right_Endpoint_With_Payload(string apiUrl, string org, string repo, string escapedPath) + { + var githubApi = new GithubApi(_githubClientMock.Object, apiUrl, _retryPolicy, _archiveUploader.Object); + var properties = JObject.Parse(""" + {"environment":"production","teams":["platform","security"],"reviewed":"true","unset":null,"text":"quotes: \" and newline: \n"} + """); + var payload = JObject.Parse(""" + { + "properties": [ + {"property_name":"environment","value":"production"}, + {"property_name":"teams","value":["platform","security"]}, + {"property_name":"reviewed","value":"true"}, + {"property_name":"unset","value":null}, + {"property_name":"text","value":"quotes: \" and newline: \n"} + ] + } + """); + + await githubApi.SetRepositoryCustomProperties(org, repo, properties); + + _githubClientMock.Verify(x => x.PatchAsync($"{apiUrl}/repos/{escapedPath}/properties/values", + It.Is(body => JToken.DeepEquals(JObject.FromObject(body), payload)), null), Times.Once); + } + + [Fact] + public async Task SetRepositoryCustomProperties_Rejects_Null_Properties() + { + await _githubApi.Invoking(x => x.SetRepositoryCustomProperties(GITHUB_ORG, GITHUB_REPO, null)) + .Should().ThrowExactlyAsync(); + + _githubClientMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SetRepositoryCustomProperties_Propagates_Api_Errors() + { + var exception = new HttpRequestException("Permission denied", null, HttpStatusCode.Forbidden); + _githubClientMock.Setup(x => x.PatchAsync(It.IsAny(), It.IsAny(), null)).ThrowsAsync(exception); + + var result = await _githubApi.Invoking(x => x.SetRepositoryCustomProperties(GITHUB_ORG, GITHUB_REPO, JObject.Parse("{\"environment\":\"production\"}"))) + .Should().ThrowExactlyAsync(); + + result.Which.Should().BeSameAs(exception); + } + [Fact] public async Task AddAutoLink_Calls_The_Right_Endpoint_With_Payload() { diff --git a/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandArgsTests.cs b/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandArgsTests.cs index b87415ce9..14d533137 100644 --- a/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandArgsTests.cs +++ b/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandArgsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Moq; +using Newtonsoft.Json.Linq; using OctoshiftCLI.BbsToGithub.Commands.MigrateRepo; using OctoshiftCLI.Services; using Xunit; @@ -32,6 +33,118 @@ public class MigrateRepoCommandArgsTests private const string SMB_USER = "smb-user"; private const string SMB_PASSWORD = "smb-password"; + [Fact] + public void Custom_Properties_Are_Optional() + { + var args = new MigrateRepoCommandArgs + { + ArchiveUrl = ARCHIVE_URL, + GithubOrg = GITHUB_ORG, + GithubRepo = GITHUB_REPO, + QueueOnly = true + }; + + args.Invoking(x => x.Validate(_mockOctoLogger.Object)).Should().NotThrow(); + args.ParseCustomProperties().Should().BeNull(); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"environment\":\"production\",\"teams\":[\"platform\",\"security\"],\"reviewed\":\"true\",\"unset\":null}")] + [InlineData("{\"empty\":\"\",\"teams\":[]}")] + [InlineData("{\"date\":\"2026-10-08T10:00:00Z\",\"text\":\"quotes: \\\" and newline: \\n\"}")] + public void Custom_Properties_Accepts_Valid_Values(string customProperties) + { + var args = new MigrateRepoCommandArgs + { + ArchiveUrl = ARCHIVE_URL, + GithubOrg = GITHUB_ORG, + GithubRepo = GITHUB_REPO, + CustomProperties = customProperties + }; + + args.Invoking(x => x.Validate(_mockOctoLogger.Object)).Should().NotThrow(); + var properties = args.ParseCustomProperties(); + properties.Should().NotBeNull(); + if (properties["date"] is { } date) + { + date.Type.Should().Be(JTokenType.String); + date.Value().Should().Be("2026-10-08T10:00:00Z"); + } + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-json")] + [InlineData("null")] + [InlineData("[]")] + [InlineData("\"production\"")] + [InlineData("{\"environment\":")] + [InlineData("{\"environment\":\"production\",\"environment\":\"test\"}")] + [InlineData("{} {}")] + [InlineData("{} trailing-content")] + [InlineData("{\"\": \"production\"}")] + [InlineData("{\" \": \"production\"}")] + [InlineData("{\"environment\":true}")] + [InlineData("{\"environment\":42}")] + [InlineData("{\"environment\":1.5}")] + [InlineData("{\"environment\":{\"nested\":\"value\"}}")] + [InlineData("{\"environment\":[\"production\",42]}")] + [InlineData("{\"environment\":[null]}")] + [InlineData("{\"environment\":[[\"production\"]]}")] + public void Custom_Properties_Rejects_Invalid_Values(string customProperties) + { + var args = new MigrateRepoCommandArgs + { + ArchiveUrl = ARCHIVE_URL, + GithubOrg = GITHUB_ORG, + GithubRepo = GITHUB_REPO, + CustomProperties = customProperties + }; + + args.Invoking(x => x.Validate(_mockOctoLogger.Object)) + .Should().ThrowExactly(); + } + + [Fact] + public void Custom_Properties_Rejects_Queue_Only() + { + var args = new MigrateRepoCommandArgs + { + ArchiveUrl = ARCHIVE_URL, + GithubOrg = GITHUB_ORG, + GithubRepo = GITHUB_REPO, + CustomProperties = "{\"environment\":\"production\"}", + QueueOnly = true + }; + + args.Invoking(x => x.Validate(_mockOctoLogger.Object)) + .Should().ThrowExactly() + .WithMessage("*--custom-properties*--queue-only*"); + } + + [Theory] + [InlineData(null, null)] + [InlineData(GITHUB_ORG, null)] + [InlineData(null, GITHUB_REPO)] + public void Custom_Properties_Requires_A_Destination_Repository(string githubOrg, string githubRepo) + { + var args = new MigrateRepoCommandArgs + { + BbsServerUrl = BBS_SERVER_URL, + BbsProject = BBS_PROJECT, + BbsRepo = BBS_REPO, + GithubOrg = githubOrg, + GithubRepo = githubRepo, + CustomProperties = "{\"environment\":\"production\"}" + }; + + args.Invoking(x => x.Validate(_mockOctoLogger.Object)) + .Should().ThrowExactly() + .WithMessage("*--custom-properties*--github-org*--github-repo*export-only*"); + } + [Fact] public void It_Throws_When_Kerberos_Is_Set_And_Bbs_Password_Is_Provided() { diff --git a/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandHandlerTests.cs b/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandHandlerTests.cs index 7661090c6..12b17b161 100644 --- a/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandHandlerTests.cs +++ b/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandHandlerTests.cs @@ -1,9 +1,12 @@ using System; using System.IO; +using System.Net; +using System.Net.Http; using System.Text; using System.Threading.Tasks; using FluentAssertions; using Moq; +using Newtonsoft.Json.Linq; using OctoshiftCLI.BbsToGithub.Commands.MigrateRepo; using OctoshiftCLI.BbsToGithub.Services; using OctoshiftCLI.Extensions; @@ -77,6 +80,199 @@ public MigrateRepoCommandHandlerTests() _mockFileSystemProvider.Setup(m => m.DirectoryExists(It.IsAny())).Returns(true); } + private MigrateRepoCommandArgs CreateCustomPropertiesMigration(string state = RepositoryMigrationStatus.Succeeded) + { + _mockGithubApi.Setup(x => x.GetOrganizationId(GITHUB_ORG)).ReturnsAsync(GITHUB_ORG_ID); + _mockGithubApi.Setup(x => x.CreateBbsMigrationSource(GITHUB_ORG_ID)).ReturnsAsync(MIGRATION_SOURCE_ID); + _mockGithubApi.Setup(x => x.StartBbsMigration(MIGRATION_SOURCE_ID, UNUSED_REPO_URL, GITHUB_ORG_ID, GITHUB_REPO, GITHUB_PAT, ARCHIVE_URL, null)) + .ReturnsAsync(MIGRATION_ID); + _mockGithubApi.Setup(x => x.GetMigration(MIGRATION_ID)) + .ReturnsAsync((state, GITHUB_REPO, 0, "Migration failed", "https://example.com/migration.log")); + + return new MigrateRepoCommandArgs + { + ArchiveUrl = ARCHIVE_URL, + GithubOrg = GITHUB_ORG, + GithubRepo = GITHUB_REPO, + GithubPat = GITHUB_PAT, + CustomProperties = "{\"environment\":\"production\",\"teams\":[\"platform\",\"security\"],\"unset\":null}" + }; + } + + [Fact] + public async Task It_Applies_Custom_Properties_Only_After_Migration_Succeeds() + { + var args = CreateCustomPropertiesMigration(); + var sequence = new MockSequence(); + _mockGithubApi.InSequence(sequence).Setup(x => x.GetMigration(MIGRATION_ID)) + .ReturnsAsync((RepositoryMigrationStatus.Succeeded, GITHUB_REPO, 0, null, "https://example.com/migration.log")); + _mockGithubApi.InSequence(sequence) + .Setup(x => x.SetRepositoryCustomProperties(GITHUB_ORG, GITHUB_REPO, It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + await _handler.Handle(args); + + _mockGithubApi.Verify(x => x.SetRepositoryCustomProperties(GITHUB_ORG, GITHUB_REPO, + It.Is(properties => JToken.DeepEquals(properties, JObject.Parse(args.CustomProperties)))), Times.Once); + _mockGithubApi.Verify(); + _mockOctoLogger.Verify(x => x.LogSuccess("Custom properties applied successfully."), Times.Once); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task It_Applies_Custom_Properties_When_Uploading_An_Archive(bool generateArchive) + { + var args = CreateCustomPropertiesMigration(); + args.ArchiveUrl = null; + args.AwsBucketName = AWS_BUCKET_NAME; + args.AwsAccessKey = AWS_ACCESS_KEY_ID; + args.AwsSecretKey = AWS_SECRET_ACCESS_KEY; + args.AwsRegion = AWS_REGION; + _mockAwsApi.Setup(x => x.UploadToBucket(AWS_BUCKET_NAME, ARCHIVE_PATH, It.IsAny())).ReturnsAsync(ARCHIVE_URL); + + if (generateArchive) + { + args.BbsServerUrl = BBS_SERVER_URL; + args.BbsProject = BBS_PROJECT; + args.BbsRepo = BBS_REPO; + args.BbsUsername = BBS_USERNAME; + args.BbsPassword = BBS_PASSWORD; + args.SshUser = SSH_USER; + args.SshPrivateKey = PRIVATE_KEY; + _mockBbsApi.Setup(x => x.StartExport(BBS_PROJECT, BBS_REPO)).ReturnsAsync(BBS_EXPORT_ID); + _mockBbsApi.Setup(x => x.GetExport(BBS_EXPORT_ID)).ReturnsAsync(("COMPLETED", "The export is complete", 100)); + _mockBbsArchiveDownloader.Setup(x => x.Download(BBS_EXPORT_ID, It.IsAny())).ReturnsAsync(ARCHIVE_PATH); + _mockGithubApi.Setup(x => x.StartBbsMigration(MIGRATION_SOURCE_ID, BBS_REPO_URL, GITHUB_ORG_ID, GITHUB_REPO, GITHUB_PAT, ARCHIVE_URL, null)) + .ReturnsAsync(MIGRATION_ID); + } + else + { + args.ArchivePath = ARCHIVE_PATH; + } + + args.Validate(_mockOctoLogger.Object); + await _handler.Handle(args); + + _mockAwsApi.Verify(x => x.UploadToBucket(AWS_BUCKET_NAME, ARCHIVE_PATH, It.IsAny()), Times.Once); + _mockGithubApi.Verify(x => x.SetRepositoryCustomProperties(GITHUB_ORG, GITHUB_REPO, + It.Is(properties => JToken.DeepEquals(properties, JObject.Parse(args.CustomProperties)))), Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("{}")] + public async Task It_Does_Not_Update_Custom_Properties_When_None_Are_Requested(string customProperties) + { + var args = CreateCustomPropertiesMigration(); + args.CustomProperties = customProperties; + + await _handler.Handle(args); + + _mockGithubApi.Verify(x => x.SetRepositoryCustomProperties(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(RepositoryMigrationStatus.Failed)] + [InlineData(RepositoryMigrationStatus.FailedValidation)] + [InlineData("UNKNOWN")] + public async Task It_Does_Not_Update_Custom_Properties_When_Migration_Fails(string state) + { + var args = CreateCustomPropertiesMigration(state); + + await _handler.Invoking(x => x.Handle(args)).Should().ThrowExactlyAsync() + .WithMessage("Migration failed"); + + _mockGithubApi.Verify(x => x.SetRepositoryCustomProperties(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task It_Does_Not_Update_Custom_Properties_When_Target_Repository_Already_Exists(bool existsBeforeMigration) + { + var args = CreateCustomPropertiesMigration(); + _mockGithubApi.Setup(x => x.DoesRepoExist(GITHUB_ORG, GITHUB_REPO)).ReturnsAsync(existsBeforeMigration); + _mockGithubApi.Setup(x => x.StartBbsMigration(MIGRATION_SOURCE_ID, UNUSED_REPO_URL, GITHUB_ORG_ID, GITHUB_REPO, GITHUB_PAT, ARCHIVE_URL, null)) + .ThrowsAsync(new OctoshiftCliException($"A repository called {GITHUB_ORG}/{GITHUB_REPO} already exists")); + + if (existsBeforeMigration) + { + await _handler.Invoking(x => x.Handle(args)).Should().ThrowExactlyAsync(); + } + else + { + await _handler.Handle(args); + } + + _mockGithubApi.Verify(x => x.SetRepositoryCustomProperties(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.UnprocessableEntity)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task It_Reports_A_Property_Update_Failure_Without_Hiding_The_Successful_Migration(HttpStatusCode statusCode) => + await AssertPropertyUpdateFailure(new HttpRequestException("API error", null, statusCode)); + + [Fact] + public async Task It_Reports_A_Property_Update_Timeout_Without_Hiding_The_Successful_Migration() => + await AssertPropertyUpdateFailure(new TaskCanceledException("The request timed out.")); + + [Fact] + public async Task It_Reports_A_Property_Update_Cancellation_Without_Hiding_The_Successful_Migration() => + await AssertPropertyUpdateFailure(new OperationCanceledException("The request was canceled.")); + + [Fact] + public async Task It_Reports_A_Property_Update_Rate_Limit_Without_Hiding_The_Successful_Migration() => + await AssertPropertyUpdateFailure(new OctoshiftCliException("Secondary rate limit exceeded. Maximum retries reached.")); + + private async Task AssertPropertyUpdateFailure(Exception apiException) + { + var args = CreateCustomPropertiesMigration(); + _mockGithubApi.Setup(x => x.SetRepositoryCustomProperties(GITHUB_ORG, GITHUB_REPO, It.IsAny())) + .ThrowsAsync(apiException); + + var exception = await _handler.Invoking(x => x.Handle(args)).Should().ThrowExactlyAsync() + .WithMessage($"Migration completed (ID: {MIGRATION_ID}), but custom properties could not be applied to {GITHUB_ORG}/{GITHUB_REPO}.*do not rerun the migration*{apiException.Message}"); + + exception.Which.InnerException.Should().BeSameAs(apiException); + _mockOctoLogger.Verify(x => x.LogSuccess("Custom properties applied successfully."), Times.Never); + } + + [Fact] + public async Task It_Does_Not_Wrap_Unexpected_Property_Update_Errors() + { + var args = CreateCustomPropertiesMigration(); + var unexpectedException = new InvalidOperationException("Unexpected error"); + _mockGithubApi.Setup(x => x.SetRepositoryCustomProperties(GITHUB_ORG, GITHUB_REPO, It.IsAny())) + .ThrowsAsync(unexpectedException); + + var exception = await _handler.Invoking(x => x.Handle(args)).Should().ThrowExactlyAsync(); + + exception.Which.Should().BeSameAs(unexpectedException); + _mockOctoLogger.Verify(x => x.LogSuccess("Custom properties applied successfully."), Times.Never); + } + + [Theory] + [InlineData("invalid-json", false)] + [InlineData("{\"environment\":\"production\"}", true)] + public async Task It_Rejects_Invalid_Custom_Property_Options_Before_Any_Api_Calls(string customProperties, bool queueOnly) + { + var args = CreateCustomPropertiesMigration(); + args.CustomProperties = customProperties; + args.QueueOnly = queueOnly; + + await _handler.Invoking(x => x.Handle(args)).Should().ThrowExactlyAsync(); + + _mockGithubApi.VerifyNoOtherCalls(); + _mockBbsApi.VerifyNoOtherCalls(); + _mockAzureApi.VerifyNoOtherCalls(); + _mockAwsApi.VerifyNoOtherCalls(); + } + [Fact] public async Task Happy_Path_Generate_Only() { diff --git a/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandTests.cs b/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandTests.cs index 5d163d61c..8812868b9 100644 --- a/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandTests.cs +++ b/src/OctoshiftCLI.Tests/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandTests.cs @@ -1,4 +1,6 @@ using System; +using System.CommandLine; +using System.Threading.Tasks; using FluentAssertions; using Moq; using OctoshiftCLI.BbsToGithub.Commands.MigrateRepo; @@ -58,7 +60,7 @@ public void Should_Have_Options() var command = new MigrateRepoCommand(); command.Should().NotBeNull(); command.Name.Should().Be("migrate-repo"); - command.Options.Count.Should().Be(33); + command.Options.Count.Should().Be(34); TestHelpers.VerifyCommandOption(command.Options, "bbs-server-url", true); TestHelpers.VerifyCommandOption(command.Options, "bbs-project", true); @@ -85,6 +87,7 @@ public void Should_Have_Options() TestHelpers.VerifyCommandOption(command.Options, "smb-domain", false); TestHelpers.VerifyCommandOption(command.Options, "queue-only", false); TestHelpers.VerifyCommandOption(command.Options, "target-repo-visibility", false); + TestHelpers.VerifyCommandOption(command.Options, "custom-properties", false); TestHelpers.VerifyCommandOption(command.Options, "kerberos", false, true); TestHelpers.VerifyCommandOption(command.Options, "verbose", false); TestHelpers.VerifyCommandOption(command.Options, "keep-archive", false); @@ -94,6 +97,29 @@ public void Should_Have_Options() TestHelpers.VerifyCommandOption(command.Options, "use-github-storage", false); } + [Fact] + public async Task It_Binds_Custom_Properties_Json_To_Command_Args() + { + const string customProperties = "{\"environment\":\"production\",\"teams\":[\"platform\",\"security\"]}"; + MigrateRepoCommandArgs boundArgs = null; + var argsBinder = new GenericArgsBinder(_command); + _command.SetHandler(args => { boundArgs = args; }, argsBinder); + + var exitCode = await _command.InvokeAsync(new[] + { + "--bbs-server-url", BBS_SERVER_URL, + "--bbs-project", "project", + "--bbs-repo", "repo", + "--github-org", GITHUB_ORG, + "--github-repo", "target-repo", + "--custom-properties", customProperties + }); + + exitCode.Should().Be(0); + boundArgs.Should().NotBeNull(); + boundArgs.CustomProperties.Should().Be(customProperties); + } + [Fact] public void BuildHandler_Creates_Bbs_Ssh_Archive_Downloader_Based_On_Server_Url_When_Ssh_User_Is_Provided() { diff --git a/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommand.cs b/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommand.cs index 6b097af9f..c528b6bce 100644 --- a/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommand.cs +++ b/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommand.cs @@ -45,6 +45,7 @@ public MigrateRepoCommand() : base( AddOption(AwsRegion); AddOption(QueueOnly); AddOption(TargetRepoVisibility.FromAmong("public", "private", "internal")); + AddOption(CustomProperties); AddOption(Kerberos); AddOption(Verbose); AddOption(KeepArchive); @@ -177,6 +178,12 @@ public MigrateRepoCommand() : base( name: "--target-repo-visibility", description: "The visibility of the target repo. Defaults to private. Valid values are public, private, or internal."); + public Option CustomProperties { get; } = new( + name: "--custom-properties", + description: "A JSON object of custom property names and values to apply after the migration succeeds. " + + "Values must be strings, arrays of strings, or null. Properties must already be defined in the target organization. " + + "Cannot be used with --queue-only."); + public Option Kerberos { get; } = new( name: "--kerberos", description: "Use Kerberos authentication for downloading the export archive off of the Bitbucket server.") diff --git a/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandArgs.cs b/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandArgs.cs index 6db495022..4691e8305 100644 --- a/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandArgs.cs +++ b/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandArgs.cs @@ -1,4 +1,7 @@ -using System.Linq; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using OctoshiftCLI.Commands; using OctoshiftCLI.Extensions; using OctoshiftCLI.Services; @@ -28,6 +31,7 @@ public class MigrateRepoCommandArgs : CommandArgs public string GithubPat { get; set; } public bool QueueOnly { get; set; } public string TargetRepoVisibility { get; set; } + public string CustomProperties { get; set; } public string TargetApiUrl { get; set; } public string TargetUploadsUrl { get; set; } public bool Kerberos { get; set; } @@ -56,6 +60,8 @@ public class MigrateRepoCommandArgs : CommandArgs public override void Validate(OctoLogger log) { + _ = ParseCustomProperties(); + if (!BbsServerUrl.HasValue() && !ArchiveUrl.HasValue() && !ArchivePath.HasValue()) { throw new OctoshiftCliException("Either --bbs-server-url, --archive-path, or --archive-url must be specified."); @@ -92,6 +98,56 @@ public override void Validate(OctoLogger log) } } + public JObject ParseCustomProperties() + { + if (CustomProperties is null) + { + return null; + } + + if (QueueOnly) + { + throw new OctoshiftCliException("--custom-properties cannot be used with --queue-only because custom properties are applied after the migration succeeds."); + } + + if (GithubOrg.IsNullOrWhiteSpace() || GithubRepo.IsNullOrWhiteSpace()) + { + throw new OctoshiftCliException("--custom-properties requires --github-org and --github-repo and cannot be used for an export-only migration."); + } + + JObject properties; + try + { + using var stringReader = new StringReader(CustomProperties); + using var jsonReader = new JsonTextReader(stringReader) { DateParseHandling = DateParseHandling.None }; + properties = JObject.Load(jsonReader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); + if (jsonReader.Read()) + { + throw new OctoshiftCliException("--custom-properties must contain a single JSON object."); + } + } + catch (JsonException ex) + { + throw new OctoshiftCliException("--custom-properties must be a valid JSON object with unique property names, for example: '{\"environment\":\"production\"}'.", ex); + } + + foreach (var property in properties.Properties()) + { + if (property.Name.IsNullOrWhiteSpace()) + { + throw new OctoshiftCliException("--custom-properties cannot contain an empty property name."); + } + + if (property.Value.Type is not (JTokenType.String or JTokenType.Null) && + !(property.Value is JArray values && values.All(value => value.Type == JTokenType.String))) + { + throw new OctoshiftCliException($"The custom property '{property.Name}' must have a string, an array of strings, or null as its value. Use \"true\" or \"false\" strings for boolean properties."); + } + } + + return properties; + } + private void ValidateNoGenerateOptions() { if (BbsUsername.HasValue() || BbsPassword.HasValue()) diff --git a/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandHandler.cs b/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandHandler.cs index 92abc0a8d..d84c2c9c8 100644 --- a/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandHandler.cs +++ b/src/bbs2gh/Commands/MigrateRepo/MigrateRepoCommandHandler.cs @@ -1,6 +1,8 @@ using System; +using System.Net.Http; using System.Runtime.InteropServices; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using OctoshiftCLI.BbsToGithub.Services; using OctoshiftCLI.Commands; using OctoshiftCLI.Extensions; @@ -51,6 +53,7 @@ public async Task Handle(MigrateRepoCommandArgs args) throw new ArgumentNullException(nameof(args)); } + var customProperties = args.ParseCustomProperties(); ValidateOptions(args); var exportId = 0L; @@ -122,7 +125,7 @@ public async Task Handle(MigrateRepoCommandArgs args) if (args.ShouldImportArchive()) { - await ImportArchive(args, migrationSourceId, args.ArchiveUrl); + await ImportArchive(args, migrationSourceId, customProperties, args.ArchiveUrl); } } @@ -244,7 +247,7 @@ private async Task CreateMigrationSource(MigrateRepoCommandArgs args) } } - private async Task ImportArchive(MigrateRepoCommandArgs args, string migrationSourceId, string archiveUrl = null) + private async Task ImportArchive(MigrateRepoCommandArgs args, string migrationSourceId, JObject customProperties, string archiveUrl = null) { _log.LogInformation("Importing Archive..."); @@ -295,6 +298,23 @@ private async Task ImportArchive(MigrateRepoCommandArgs args, string migrationSo _log.LogSuccess($"Migration completed (ID: {migrationId})! State: {migrationState}"); _warningsCountLogger.LogWarningsCount(warningsCount); _log.LogInformation(migrationLogAvailableMessage); + + if (customProperties?.Count > 0) + { + _log.LogInformation($"Applying custom properties to {args.GithubOrg}/{args.GithubRepo}..."); + try + { + await _githubApi.SetRepositoryCustomProperties(args.GithubOrg, args.GithubRepo, customProperties); + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or OctoshiftCliException) + { + throw new OctoshiftCliException( + $"Migration completed (ID: {migrationId}), but custom properties could not be applied to {args.GithubOrg}/{args.GithubRepo}. " + + "The migrated repository already exists; do not rerun the migration. Check the error, network connectivity, property definitions, and your permission to edit custom property values, " + + $"then verify and apply the properties manually. {ex.Message}", ex); + } + _log.LogSuccess("Custom properties applied successfully."); + } } private string GetAwsAccessKey(MigrateRepoCommandArgs args) => args.AwsAccessKey.HasValue() ? args.AwsAccessKey : _environmentVariableProvider.AwsAccessKeyId(false);