Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions src/Octoshift/Services/GithubApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,26 @@ public virtual async Task<bool> 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<bool> DoesOrgExist(string org)
{
var url = $"{_apiUrl}/orgs/{org.EscapeDataString()}";
Expand Down
48 changes: 48 additions & 0 deletions src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>(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<ArgumentNullException>();

_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<string>(), It.IsAny<object>(), null)).ThrowsAsync(exception);

var result = await _githubApi.Invoking(x => x.SetRepositoryCustomProperties(GITHUB_ORG, GITHUB_REPO, JObject.Parse("{\"environment\":\"production\"}")))
.Should().ThrowExactlyAsync<HttpRequestException>();

result.Which.Should().BeSameAs(exception);
}

[Fact]
public async Task AddAutoLink_Calls_The_Right_Endpoint_With_Payload()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentAssertions;
using Moq;
using Newtonsoft.Json.Linq;
using OctoshiftCLI.BbsToGithub.Commands.MigrateRepo;
using OctoshiftCLI.Services;
using Xunit;
Expand Down Expand Up @@ -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<string>().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<OctoshiftCliException>();
}

[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<OctoshiftCliException>()
.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<OctoshiftCliException>()
.WithMessage("*--custom-properties*--github-org*--github-repo*export-only*");
}

[Fact]
public void It_Throws_When_Kerberos_Is_Set_And_Bbs_Password_Is_Provided()
{
Expand Down
Loading
Loading