feat(migrations): add repository migration from bitbucket.org (#38607)

Adds migration from bitbucket.org as the Cloud variant of the Bitbucket
entry, in the web UI and via `POST /repos/migrate` with
`service=bitbucket`. It migrates the repository and pull requests with
their comments and reviews. Bitbucket Cloud has no releases, and
Atlassian removed its issues and wikis on 2026-08-20
(https://community.atlassian.com/forums/Bitbucket-articles/Announcing-sunset-of-Bitbucket-Issues-and-Wikis/ba-p/3193882).

- Private repositories need an API or access token, which also raises
the rate limit.
- Rate-limited requests back off and retry, Bitbucket sends no
`Retry-After`.
- Tests replay recorded responses, set `BITBUCKET_READ_TOKEN` to
re-record.

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Lunny Xiaoandsilverwind authored and GitHub committed 2026-10-31 14:51:43 +00:00
1 parent cc20c4c11b
commit bd5fbcfb29
21 files changed
+874 -80

No files matched your search

+7 -12
View File
@@ -359,6 +359,7 @@ const (
CodeCommitService // 9 codecommit service
BitbucketServerService // 10 bitbucket server (data center) service
ForgejoService // 11 forgejo service
BitbucketService // 12 bitbucket.org service
)
// Name represents the service type's name
@@ -390,6 +391,8 @@ func (gt GitServiceType) Title() string {
return "Bitbucket Server"
case ForgejoService:
return "Forgejo"
case BitbucketService:
return "Bitbucket"
case PlainGitService:
return "Git"
}
@@ -399,21 +402,12 @@ func (gt GitServiceType) Title() string {
// Variants returns the hosting options of gt's provider, which share one entry on the migrate page
func (gt GitServiceType) Variants() []GitServiceType {
switch gt {
case BitbucketServerService:
return []GitServiceType{BitbucketServerService}
case BitbucketService, BitbucketServerService:
return []GitServiceType{BitbucketService, BitbucketServerService}
}
return nil
}
// ProviderTitle returns the title of gt's entry on the migrate page
func (gt GitServiceType) ProviderTitle() string {
switch gt {
case BitbucketServerService:
return "Bitbucket"
}
return gt.Title()
}
// MigrateRepoOptions options for migrating repository's
// this is used to interact with api v1
type MigrateRepoOptions struct {
@@ -426,7 +420,7 @@ type MigrateRepoOptions struct {
// required: true
RepoName string `json:"repo_name" binding:"Required;AlphaDashDot;MaxSize(100)"`
// enum: ["git","github","gitea","gitlab","gogs","onedev","gitbucket","codebase","codecommit","bitbucketserver","forgejo"]
// enum: ["git","github","gitea","gitlab","gogs","onedev","gitbucket","codebase","codecommit","bitbucketserver","forgejo","bitbucket"]
Service string `json:"service"`
AuthUsername string `json:"auth_username"`
AuthPassword string `json:"auth_password"`
@@ -464,6 +458,7 @@ var SupportedFullGitService = []GitServiceType{
GithubService,
GitlabService,
GiteaService,
BitbucketService,
ForgejoService,
GogsService,
OneDevService,
+3 -1
View File
@@ -1125,7 +1125,9 @@
"repo.migrate.onedev.description": "Migrate data from code.onedev.io or other OneDev instances.",
"repo.migrate.codebase.description": "Migrate data from codebasehq.com.",
"repo.migrate.gitbucket.description": "Migrate data from GitBucket instances.",
"repo.migrate.bitbucketserver.description": "Migrate data from Bitbucket Data Center or Server instances.",
"repo.migrate.bitbucket.description": "Migrate data from bitbucket.org or Bitbucket Data Center and Server instances.",
"repo.migrate.bitbucket.variant": "Cloud (bitbucket.org)",
"repo.migrate.bitbucket.token_desc": "An Atlassian API token or a repository or workspace access token with read access to repositories and pull requests. Required for private repositories and to raise the API rate limit.",
"repo.migrate.bitbucketserver.variant": "Data Center / Server",
"repo.migrate.hosting": "Hosting",
"repo.migrate.bitbucketserver.token_desc": "HTTP access token. If set, it is used instead of the password above. The username defaults to the owner of the token.",
+2
View File
@@ -44,6 +44,8 @@ func ToGitServiceType(value string) structs.GitServiceType {
return structs.BitbucketServerService
case "forgejo":
return structs.ForgejoService
case "bitbucket":
return structs.BitbucketService
default:
return structs.PlainGitService
}
+2
View File
@@ -42,6 +42,8 @@ func TestToGitServiceType(t *testing.T) {
typ: "bitbucketserver", enum: 10,
}, {
typ: "forgejo", enum: 11,
}, {
typ: "bitbucket", enum: 12,
}}
for _, test := range tc {
assert.EqualValues(t, test.enum, ToGitServiceType(test.typ))
@@ -0,0 +1,37 @@
Age: 0
Atl-Request-Id: 9e13d533-e742-492f-9491-fa70d6b2eeda
Atl-Traceid: 9e13d533e742492f9491fa70d6b2eeda
Cache-Control: max-age=900
Content-Type: application/json; charset=utf-8
Etag: W/"d60eb01065df32f229cf7996bf21f149"
Server-Timing: cdn-upstream-layer;desc="EDGE",cdn-upstream-dns;dur=0,cdn-upstream-connect;dur=0,cdn-upstream-fbl;dur=576,atl-edge;dur=469,atl-edge-internal;dur=8,atl-edge-upstream;dur=461,atl-edge-pop;desc="aws-us-east-1",cdn-cache-miss,cdn-pop;desc="VIE50-P1",cdn-rid;desc="G_Q7MMqPH7SRDgKt6XL84kKmqanLAXMocfBKJ_CNFWkF3gHAW_8SCw==",cdn-downstream-fbl;dur=580
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Vary: Authorization, origin
Vary: Accept-Encoding
Via: 1.1 9127bf22c332a88edd7d5939b5870d1e.cloudfront.net (CloudFront)
X-Accepted-Oauth-Scopes: pullrequest
X-Amz-Cf-Id: G_Q7MMqPH7SRDgKt6XL84kKmqanLAXMocfBKJ_CNFWkF3gHAW_8SCw==
X-Amz-Cf-Pop: VIE50-P1
X-Asap-Succeeded: True
X-B3-Spanid: 54f13123cfd4da4c
X-B3-Traceid: 9e13d533e742492f9491fa70d6b2eeda
X-Cache: Miss from cloudfront
X-Content-Type-Options: nosniff
X-Credential-Type: unauthenticated_identity
X-Dc-Location: Micros-3
X-Envoy-Attempt-Count: 1
X-Frame-Options: SAMEORIGIN
X-Render-Time: 0.43766093254089355
X-Request-Count: 2930
X-Served-By: 38e679aebd5f
X-Static-Version: 23055ebb54a0
X-Usage-Input-Ops: 256
X-Usage-Output-Ops: 0
X-Usage-System-Time: 0.012903
X-Usage-User-Time: 0.182562
X-Used-Mesh: False
X-Version: 23055ebb54a0
X-View-Name: bitbucket.apps.pullrequests.api.v20.handlers.RepositoryPullRequestsHandler
X-Xss-Protection: 1; mode=block
{"values": [{"comment_count": 25, "task_count": 0, "type": "pullrequest", "id": 1, "title": "Heading size change and split typography to separate resource", "description": "As discussed at FERM. Will put this on master first then backport as required.", "rendered": {}, "state": "MERGED", "draft": false, "merge_commit": {"type": "commit", "hash": "108d70a532dd8b98166237b54a961958e92f8a96", "date": "2011-11-02T05:23:30+00:00", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/108d70a532dd8b98166237b54a961958e92f8a96"}, "html": {"href": "https://bitbucket.org/atlassian/aui/commits/108d70a532dd8b98166237b54a961958e92f8a96"}}}, "diff_type": 1, "latest_excluded_files_version": "8d74170f-e08f-41d8-8d65-ce063fa86348", "close_source_branch": false, "closed_by": {"display_name": "Jaie Wilson", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7B4035f76b-7370-4a33-86e5-bc906d201811%7D"}, "avatar": {"href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/557058:5271fe35-ce1c-4d9e-bb7c-93d777bf230d/36a76baa-05bc-4e76-a42b-5f3ce2a3fd1c/128"}, "html": {"href": "https://bitbucket.org/%7B4035f76b-7370-4a33-86e5-bc906d201811%7D/"}}, "type": "user", "uuid": "{4035f76b-7370-4a33-86e5-bc906d201811}", "account_id": "557058:5271fe35-ce1c-4d9e-bb7c-93d777bf230d", "nickname": "Jaie Wilson"}, "author": {"display_name": "Ben Buchanan", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7B5881db19-c8eb-4cd0-af9b-1a5b3e378e17%7D"}, "avatar": {"href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/557058:6af744d8-ea74-40ff-9fbb-c7af27dc5351/259de5c8-61ca-4d67-b7fc-41deb8e8bbe9/128"}, "html": {"href": "https://bitbucket.org/%7B5881db19-c8eb-4cd0-af9b-1a5b3e378e17%7D/"}}, "type": "user", "uuid": "{5881db19-c8eb-4cd0-af9b-1a5b3e378e17}", "account_id": "557058:6af744d8-ea74-40ff-9fbb-c7af27dc5351", "nickname": "200ok"}, "reason": "", "created_on": "2011-11-02T05:10:57.655043+00:00", "updated_on": "2011-11-02T05:23:32.012180+00:00", "closed_on": "2011-11-02T05:23:31.438428+00:00", "destination": {"branch": {"name": "master"}, "commit": {"type": "commit", "hash": "3d83e9aa790ef44d2ed02eb6708bec7b28e410d3", "date": "2011-10-27T02:59:21+00:00", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/3d83e9aa790ef44d2ed02eb6708bec7b28e410d3"}, "html": {"href": "https://bitbucket.org/atlassian/aui/commits/3d83e9aa790ef44d2ed02eb6708bec7b28e410d3"}}}, "repository": {"type": "repository", "full_name": "atlassian/aui", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui"}, "html": {"href": "https://bitbucket.org/atlassian/aui"}, "avatar": {"href": "https://bytebucket.org/ravatar/%7B585074de-7b60-4fd1-81ed-e0bc7fafbda5%7D?ts=86317"}}, "name": "aui", "uuid": "{585074de-7b60-4fd1-81ed-e0bc7fafbda5}"}}, "source": {"branch": {"name": "master", "links": {}}, "commit": null, "repository": null}, "reviewers": [], "participants": [{"type": "participant", "user": {"display_name": "Jason Berry", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7B2a7482f5-ccf1-4c98-89f2-4b5c8434601d%7D"}, "avatar": {"href": "https://secure.gravatar.com/avatar/5664c95b41e5fc913d9c53861e6ea4c6?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FJB-6.png"}, "html": {"href": "https://bitbucket.org/%7B2a7482f5-ccf1-4c98-89f2-4b5c8434601d%7D/"}}, "type": "user", "uuid": "{2a7482f5-ccf1-4c98-89f2-4b5c8434601d}", "account_id": "557058:d42d7454-f9de-4bdf-9584-9abfc1c8102f", "nickname": "Jason Berry"}, "role": "PARTICIPANT", "approved": false, "state": null, "participated_on": "2011-11-03T02:54:51.194329+00:00"}, {"type": "participant", "user": {"display_name": "Ben Buchanan", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7B5881db19-c8eb-4cd0-af9b-1a5b3e378e17%7D"}, "avatar": {"href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/557058:6af744d8-ea74-40ff-9fbb-c7af27dc5351/259de5c8-61ca-4d67-b7fc-41deb8e8bbe9/128"}, "html": {"href": "https://bitbucket.org/%7B5881db19-c8eb-4cd0-af9b-1a5b3e378e17%7D/"}}, "type": "user", "uuid": "{5881db19-c8eb-4cd0-af9b-1a5b3e378e17}", "account_id": "557058:6af744d8-ea74-40ff-9fbb-c7af27dc5351", "nickname": "200ok"}, "role": "PARTICIPANT", "approved": false, "state": null, "participated_on": "2011-11-03T02:36:54.859631+00:00"}], "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1"}, "html": {"href": "https://bitbucket.org/atlassian/aui/pull-requests/1"}, "commits": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1/commits"}, "approve": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1/approve"}, "request-changes": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1/request-changes"}, "diff": {"href": "https://api.bitbucket.org/2.0/repoLine truncated
@@ -0,0 +1,35 @@
Age: 0
Atl-Request-Id: 754633c8-d49c-413e-a9dd-b6a319700346
Atl-Traceid: 754633c8d49c413ea9ddb6a319700346
Cache-Control: max-age=900
Content-Type: application/json; charset=utf-8
Server-Timing: cdn-upstream-layer;desc="EDGE",cdn-upstream-dns;dur=0,cdn-upstream-connect;dur=0,cdn-upstream-fbl;dur=863,atl-edge;dur=757,atl-edge-internal;dur=8,atl-edge-upstream;dur=749,atl-edge-pop;desc="aws-us-east-1",cdn-cache-miss,cdn-pop;desc="VIE50-P1",cdn-rid;desc="iJzf36UVx1dY6Cd0i0iUYl5PS470YK0TolLZy8YYoTl7QCLDqcc-Lw==",cdn-downstream-fbl;dur=866
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Vary: Authorization, origin, Accept-Encoding
Via: 1.1 9127bf22c332a88edd7d5939b5870d1e.cloudfront.net (CloudFront)
X-Accepted-Oauth-Scopes: pullrequest
X-Amz-Cf-Id: iJzf36UVx1dY6Cd0i0iUYl5PS470YK0TolLZy8YYoTl7QCLDqcc-Lw==
X-Amz-Cf-Pop: VIE50-P1
X-Asap-Succeeded: True
X-B3-Spanid: cec56d2b24e72a51
X-B3-Traceid: 754633c8d49c413ea9ddb6a319700346
X-Cache: Miss from cloudfront
X-Content-Type-Options: nosniff
X-Credential-Type: unauthenticated_identity
X-Dc-Location: Micros-3
X-Envoy-Attempt-Count: 1
X-Frame-Options: SAMEORIGIN
X-Render-Time: 0.7233684062957764
X-Request-Count: 3456
X-Served-By: 4525283b180c
X-Static-Version: 23055ebb54a0
X-Usage-Input-Ops: 64
X-Usage-Output-Ops: 48
X-Usage-System-Time: 0.020775
X-Usage-User-Time: 0.435677
X-Used-Mesh: False
X-Version: 23055ebb54a0
X-View-Name: bitbucket.apps.pullrequests.api.v20.handlers.PullRequestCommentsV20Handler
X-Xss-Protection: 1; mode=block
{"values": [{"id": 752, "created_on": "2011-11-02T23:26:25.284238+00:00", "updated_on": "2011-11-02T23:26:25.301897+00:00", "content": {"type": "rendered", "raw": "I think we should leave this out of the layout css\n.aui-layout #header input[type='search'] {\n margin: 2px 0 0 0;\n padding: 0;\n}\nNot all products use the search field and it renders differently in different browsers. Probably something best left for the products to style.", "markup": "creole", "html": "<p>I think we should leave this out of the layout css\n.aui-layout #header input[type='search'] {\n margin: 2px 0 0 0;\n padding: 0;\n}\nNot all products use the search field and it renders differently in different browsers. Probably something best left for the products to style.</p>\n"}, "user": {"display_name": "Sean Curtis", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7Bdf80b26c-31cb-4dc7-826a-443fdce4b6e7%7D"}, "avatar": {"href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/SC-0.png"}, "html": {"href": "https://bitbucket.org/%7Bdf80b26c-31cb-4dc7-826a-443fdce4b6e7%7D/"}}, "type": "user", "uuid": "{df80b26c-31cb-4dc7-826a-443fdce4b6e7}", "account_id": "557057:abdc4f56-58c1-44db-a647-4531f709c366", "nickname": "Sean Curtis"}, "deleted": false, "pending": false, "type": "pullrequest_comment", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1/comments/752"}, "html": {"href": "https://bitbucket.org/atlassian/aui/pull-requests/1/_/diff#comment-752"}}, "pullrequest": {"type": "pullrequest", "id": 1, "title": "Heading size change and split typography to separate resource", "draft": false, "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1"}, "html": {"href": "https://bitbucket.org/atlassian/aui/pull-requests/1"}}, "queued": false}}, {"id": 753, "created_on": "2011-11-02T23:29:27.650347+00:00", "updated_on": "2011-11-02T23:29:27.666716+00:00", "content": {"type": "rendered", "raw": "We don't have a search in Bamboo so it didn't bother me.", "markup": "creole", "html": "<p>We don't have a search in Bamboo so it didn't bother me.</p>\n"}, "user": {"display_name": "Jason Berry", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7B2a7482f5-ccf1-4c98-89f2-4b5c8434601d%7D"}, "avatar": {"href": "https://secure.gravatar.com/avatar/5664c95b41e5fc913d9c53861e6ea4c6?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FJB-6.png"}, "html": {"href": "https://bitbucket.org/%7B2a7482f5-ccf1-4c98-89f2-4b5c8434601d%7D/"}}, "type": "user", "uuid": "{2a7482f5-ccf1-4c98-89f2-4b5c8434601d}", "account_id": "557058:d42d7454-f9de-4bdf-9584-9abfc1c8102f", "nickname": "Jason Berry"}, "deleted": false, "parent": {"id": 752, "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1/comments/752"}, "html": {"href": "https://bitbucket.org/atlassian/aui/pull-requests/1/_/diff#comment-752"}}}, "pending": false, "type": "pullrequest_comment", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1/comments/753"}, "html": {"href": "https://bitbucket.org/atlassian/aui/pull-requests/1/_/diff#comment-753"}}, "pullrequest": {"type": "pullrequest", "id": 1, "title": "Heading size change and split typography to separate resource", "draft": false, "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1"}, "html": {"href": "https://bitbucket.org/atlassian/aui/pull-requests/1"}}, "queued": false}}, {"id": 754, "created_on": "2011-11-02T23:29:35.097456+00:00", "updated_on": "2011-11-02T23:29:35.114656+00:00", "content": {"type": "rendered", "raw": "#content > header h2 is not prefixed with .aui-layout", "markup": "creole", "html": "<ol><li>content &gt; header h2 is not prefixed with .aui-layout</li></ol>\n"}, "user": {"display_name": "Sean Curtis", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7Bdf80b26c-31cb-4dc7-826a-443fdce4b6e7%7D"}, "avatar": {"href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/SC-0.png"}, "html": {"href": "https://bitbucket.org/%7Bdf80b26c-31cb-4dc7-826a-443fdce4b6e7%7D/"}}, "type": "user", "uuid": "{df80b26c-31cb-4dc7-826a-443fdce4b6e7}", "account_id": "557057:abdc4f56-58c1-44db-a647-4531f709c366", "nickname": "Sean Curtis"}, "deleted": false, "pending": false, "type": "pullrequest_comment", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1/comments/754"}, "html": {"href": "https://bitbucket.org/atlassian/aui/pull-requests/1/_/diff#comment-754"}}, "pullrequest": {"type": "pullrequest", "id": 1, "title": "Heading size change and split typography to separate resource", "draft": false, "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/atlassian/aui/pullrequests/1"}, "html": {"href": "https://bitbucket.orLine truncated
@@ -0,0 +1,37 @@
Age: 0
Atl-Request-Id: 9179a08c-393b-4ef2-9b63-0202f377b154
Atl-Traceid: 9179a08c393b4ef29b630202f377b154
Cache-Control: max-age=900
Content-Type: application/json; charset=utf-8
Etag: W/"0055c235ff1216676265b36c65aa2ea4"
Server-Timing: cdn-upstream-layer;desc="EDGE",cdn-upstream-dns;dur=1,cdn-upstream-connect;dur=0,cdn-upstream-fbl;dur=218,atl-edge;dur=115,atl-edge-internal;dur=8,atl-edge-upstream;dur=107,atl-edge-pop;desc="aws-us-east-1",cdn-cache-miss,cdn-pop;desc="VIE50-P1",cdn-rid;desc="ZezuZh_FtSEXhi0VZc38GYlAyYNS_twFTfXWN25B7YXwzL80tJAUFg==",cdn-downstream-fbl;dur=222
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Vary: Authorization, origin
Vary: Accept-Encoding
Via: 1.1 9127bf22c332a88edd7d5939b5870d1e.cloudfront.net (CloudFront)
X-Accepted-Oauth-Scopes: repository
X-Amz-Cf-Id: ZezuZh_FtSEXhi0VZc38GYlAyYNS_twFTfXWN25B7YXwzL80tJAUFg==
X-Amz-Cf-Pop: VIE50-P1
X-Asap-Succeeded: True
X-B3-Spanid: e5d99cd1c4db9972
X-B3-Traceid: 9179a08c393b4ef29b630202f377b154
X-Cache: Miss from cloudfront
X-Content-Type-Options: nosniff
X-Credential-Type: unauthenticated_identity
X-Dc-Location: Micros-3
X-Envoy-Attempt-Count: 1
X-Frame-Options: SAMEORIGIN
X-Render-Time: 0.08297491073608398
X-Request-Count: 4191
X-Served-By: b23789207f7b
X-Static-Version: 23055ebb54a0
X-Usage-Input-Ops: 0
X-Usage-Output-Ops: 0
X-Usage-System-Time: 0.000423
X-Usage-User-Time: 0.055612
X-Used-Mesh: False
X-Version: 23055ebb54a0
X-View-Name: bitbucket.apps.repo2.api.v20.repo.RepositoryHandler
X-Xss-Protection: 1; mode=block
{"type": "repository", "full_name": "gitea/test_repo", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo"}, "html": {"href": "https://bitbucket.org/gitea/test_repo"}, "avatar": {"href": "https://bytebucket.org/ravatar/%7B7b764bc5-c3f3-4910-a90b-4cdc229a66b7%7D?ts=default"}, "pullrequests": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests"}, "commits": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/commits"}, "forks": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/forks"}, "watchers": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/watchers"}, "branches": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/refs/branches"}, "tags": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/refs/tags"}, "downloads": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/downloads"}, "source": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/src"}, "clone": [{"name": "https", "href": "https://bitbucket.org/gitea/test_repo.git"}, {"name": "ssh", "href": "git@ssh.bitbucket.org:gitea/test_repo.git"}], "hooks": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/hooks"}}, "name": "test_repo", "slug": "test_repo", "description": "", "scm": "git", "website": null, "owner": {"display_name": "gitea", "links": {"self": {"href": "https://api.bitbucket.org/2.0/workspaces/%7B5e516e72-8608-4d3e-9ca4-969288daed2a%7D"}, "avatar": {"href": "https://bitbucket.org/account/gitea4/avatar/"}, "html": {"href": "https://bitbucket.org/%7B5e516e72-8608-4d3e-9ca4-969288daed2a%7D/"}}, "type": "team", "uuid": "{5e516e72-8608-4d3e-9ca4-969288daed2a}", "username": "gitea4"}, "workspace": {"type": "workspace", "uuid": "{5e516e72-8608-4d3e-9ca4-969288daed2a}", "name": "gitea", "slug": "gitea", "links": {"avatar": {"href": "https://bitbucket.org/workspaces/gitea/avatar/?ts=1791398183"}, "html": {"href": "https://bitbucket.org/gitea/"}, "self": {"href": "https://api.bitbucket.org/2.0/workspaces/gitea"}}}, "is_private": false, "project": {"type": "project", "key": "TES", "uuid": "{94fee3ab-3d66-4ba7-a36a-134aab7a1e84}", "name": "test_repo", "links": {"self": {"href": "https://api.bitbucket.org/2.0/workspaces/gitea/projects/TES"}, "html": {"href": "https://bitbucket.org/gitea/workspace/projects/TES"}, "avatar": {"href": "https://bitbucket.org/gitea/workspace/projects/TES/avatar/32?ts=1791398116"}}}, "fork_policy": "allow_forks", "created_on": "2026-10-07T18:35:17.202781+00:00", "updated_on": "2026-10-07T18:40:40.122213+00:00", "size": 3862, "language": "", "uuid": "{7b764bc5-c3f3-4910-a90b-4cdc229a66b7}", "mainbranch": {"name": "master", "type": "branch"}, "override_settings": {"default_merge_strategy": true, "branching_model": true}, "parent": null, "enforced_signed_commits": null}
@@ -0,0 +1,37 @@
Age: 0
Atl-Request-Id: 1ac56c02-99bf-4e23-8e43-4a6dc0ac64f2
Atl-Traceid: 1ac56c0299bf4e238e434a6dc0ac64f2
Cache-Control: max-age=900
Content-Type: application/json; charset=utf-8
Etag: W/"d1616edb0f00570d57f256a059ae2cf9"
Server-Timing: cdn-upstream-layer;desc="EDGE",cdn-upstream-dns;dur=0,cdn-upstream-connect;dur=0,cdn-upstream-fbl;dur=518,atl-edge;dur=416,atl-edge-internal;dur=8,atl-edge-upstream;dur=408,atl-edge-pop;desc="aws-us-east-1",cdn-cache-miss,cdn-pop;desc="VIE50-P1",cdn-rid;desc="wignpm5GNER8JwK555KBwCkS1LqBl5iNxmE1d4WmhjcJBL7TxNOwCg==",cdn-downstream-fbl;dur=522
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Vary: Authorization, origin
Vary: Accept-Encoding
Via: 1.1 9127bf22c332a88edd7d5939b5870d1e.cloudfront.net (CloudFront)
X-Accepted-Oauth-Scopes: pullrequest
X-Amz-Cf-Id: wignpm5GNER8JwK555KBwCkS1LqBl5iNxmE1d4WmhjcJBL7TxNOwCg==
X-Amz-Cf-Pop: VIE50-P1
X-Asap-Succeeded: True
X-B3-Spanid: 0c509dea12a6a26e
X-B3-Traceid: 1ac56c0299bf4e238e434a6dc0ac64f2
X-Cache: Miss from cloudfront
X-Content-Type-Options: nosniff
X-Credential-Type: unauthenticated_identity
X-Dc-Location: Micros-3
X-Envoy-Attempt-Count: 1
X-Frame-Options: SAMEORIGIN
X-Render-Time: 0.38138413429260254
X-Request-Count: 532
X-Served-By: e9d9f85586a3
X-Static-Version: 23055ebb54a0
X-Usage-Input-Ops: 24
X-Usage-Output-Ops: 0
X-Usage-System-Time: 0.008078
X-Usage-User-Time: 0.140643
X-Used-Mesh: False
X-Version: 23055ebb54a0
X-View-Name: bitbucket.apps.pullrequests.api.v20.handlers.RepositoryPullRequestsHandler
X-Xss-Protection: 1; mode=block
{"values": [{"comment_count": 0, "task_count": 0, "type": "pullrequest", "id": 1, "title": "Update LICENSE", "description": "do not merge this PR", "rendered": {}, "state": "OPEN", "draft": false, "merge_commit": null, "diff_type": 2, "latest_excluded_files_version": "", "close_source_branch": false, "closed_by": null, "author": {"display_name": "Lunny Xiao", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7B84d46ffe-e0fa-4d3c-973f-b3ce77db43f6%7D"}, "avatar": {"href": "https://secure.gravatar.com/avatar/cfb7b37d5a38d236e451a9f8e1a29fc6?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FLX-1.png"}, "html": {"href": "https://bitbucket.org/%7B84d46ffe-e0fa-4d3c-973f-b3ce77db43f6%7D/"}}, "type": "user", "uuid": "{84d46ffe-e0fa-4d3c-973f-b3ce77db43f6}", "account_id": "712020:07927e50-b64a-4fc3-a57d-dcf6945b70e6", "nickname": "Lunny Xiao"}, "reason": "", "created_on": "2026-10-07T18:39:55.551762+00:00", "updated_on": "2026-10-07T18:40:40.122213+00:00", "closed_on": null, "destination": {"branch": {"name": "master"}, "commit": {"type": "commit", "hash": "c59c9b451acca9d106cc19d61d87afe3fbbb8b83", "date": "2019-11-28T08:53:41+00:00", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/commit/c59c9b451acca9d106cc19d61d87afe3fbbb8b83"}, "html": {"href": "https://bitbucket.org/gitea/test_repo/commits/c59c9b451acca9d106cc19d61d87afe3fbbb8b83"}}}, "repository": {"type": "repository", "full_name": "gitea/test_repo", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo"}, "html": {"href": "https://bitbucket.org/gitea/test_repo"}, "avatar": {"href": "https://bytebucket.org/ravatar/%7B7b764bc5-c3f3-4910-a90b-4cdc229a66b7%7D?ts=default"}}, "name": "test_repo", "uuid": "{7b764bc5-c3f3-4910-a90b-4cdc229a66b7}"}}, "source": {"branch": {"name": "feat/test", "links": {}}, "commit": {"type": "commit", "hash": "9f733b96b98a4175276edf6a2e1231489c3bdd23", "date": "2019-11-28T15:56:02+00:00", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/commit/9f733b96b98a4175276edf6a2e1231489c3bdd23"}, "html": {"href": "https://bitbucket.org/gitea/test_repo/commits/9f733b96b98a4175276edf6a2e1231489c3bdd23"}}}, "repository": {"type": "repository", "full_name": "gitea/test_repo", "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo"}, "html": {"href": "https://bitbucket.org/gitea/test_repo"}, "avatar": {"href": "https://bytebucket.org/ravatar/%7B7b764bc5-c3f3-4910-a90b-4cdc229a66b7%7D?ts=default"}}, "name": "test_repo", "uuid": "{7b764bc5-c3f3-4910-a90b-4cdc229a66b7}"}}, "reviewers": [], "participants": [{"type": "participant", "user": {"display_name": "Lunny Xiao", "links": {"self": {"href": "https://api.bitbucket.org/2.0/users/%7B84d46ffe-e0fa-4d3c-973f-b3ce77db43f6%7D"}, "avatar": {"href": "https://secure.gravatar.com/avatar/cfb7b37d5a38d236e451a9f8e1a29fc6?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FLX-1.png"}, "html": {"href": "https://bitbucket.org/%7B84d46ffe-e0fa-4d3c-973f-b3ce77db43f6%7D/"}}, "type": "user", "uuid": "{84d46ffe-e0fa-4d3c-973f-b3ce77db43f6}", "account_id": "712020:07927e50-b64a-4fc3-a57d-dcf6945b70e6", "nickname": "Lunny Xiao"}, "role": "PARTICIPANT", "approved": true, "state": "approved", "participated_on": "2026-10-07T18:40:40.122213+00:00"}], "links": {"self": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1"}, "html": {"href": "https://bitbucket.org/gitea/test_repo/pull-requests/1"}, "commits": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/commits"}, "approve": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/approve"}, "request-changes": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/request-changes"}, "diff": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/diff/gitea/test_repo:9f733b96b98a%0Dc59c9b451acc?from_pullrequest_id=1&topic=true"}, "diffstat": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/diffstat/gitea/test_repo:9f733b96b98a%0Dc59c9b451acc?from_pullrequest_id=1&topic=true"}, "comments": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/comments"}, "activity": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/activity"}, "merge": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/merge"}, "decline": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/decline"}, "statuses": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/statuses"}, "mergeability_checks": {"href": "https://api.bitbucket.org/2.0/repositories/gitea/test_repo/pullrequests/1/mergeability/checks"}}, "properties": {}, "summary": {"type": "rendered", "raw": "Line truncated
+382
View File
@@ -0,0 +1,382 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package migrations
import (
"cmp"
"context"
"fmt"
"hash/fnv"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
base "gitea.dev/modules/migration"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
)
var (
_ base.Downloader = &BitbucketDownloader{}
_ base.DownloaderFactory = &BitbucketDownloaderFactory{}
)
const bitbucketWebURL = "https://bitbucket.org"
func init() {
RegisterDownloaderFactory(&BitbucketDownloaderFactory{})
}
type BitbucketDownloaderFactory struct{}
func (f *BitbucketDownloaderFactory) New(_ context.Context, opts base.MigrateOptions) (base.Downloader, error) {
cloneURL, err := url.Parse(opts.CloneAddr)
if err != nil {
return nil, err
}
fields := strings.Split(strings.Trim(cloneURL.Path, "/"), "/")
if !strings.EqualFold(cloneURL.Hostname(), "bitbucket.org") || len(fields) < 2 { // the token goes to api.bitbucket.org, so no other host's URL is accepted
return nil, fmt.Errorf("invalid Bitbucket Cloud URL %q, expected https://bitbucket.org/<workspace>/<repo>", cloneURL.Redacted())
}
return NewBitbucketDownloader("https://api.bitbucket.org/2.0", fields[0], strings.TrimSuffix(fields[1], ".git"), cmp.Or(opts.AuthToken, opts.AuthPassword)), nil
}
func (f *BitbucketDownloaderFactory) GitServiceType() structs.GitServiceType {
return structs.BitbucketService
}
type bitbucketPage[T any] struct {
Values []T `json:"values"`
Next string `json:"next"`
}
type bitbucketUser struct {
UUID string `json:"uuid"`
AccountID string `json:"account_id"`
Nickname string `json:"nickname"`
DisplayName string `json:"display_name"`
}
type bitbucketBranch struct {
Name string `json:"name"`
}
type bitbucketRepository struct {
Slug string `json:"slug"`
FullName string `json:"full_name"`
Description string `json:"description"`
IsPrivate bool `json:"is_private"`
Website string `json:"website"`
MainBranch bitbucketBranch `json:"mainbranch"`
}
type bitbucketCommit struct {
Hash string `json:"hash"`
}
type bitbucketPRSide struct {
Branch bitbucketBranch `json:"branch"`
Commit bitbucketCommit `json:"commit"`
Repository bitbucketRepository `json:"repository"`
}
type bitbucketParticipant struct {
User bitbucketUser `json:"user"`
Approved bool `json:"approved"`
State string `json:"state"`
ParticipatedOn time.Time `json:"participated_on"`
}
type bitbucketPullRequest struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"`
Draft bool `json:"draft"`
CommentCount int `json:"comment_count"`
Author bitbucketUser `json:"author"`
Source bitbucketPRSide `json:"source"`
Destination bitbucketPRSide `json:"destination"`
MergeCommit bitbucketCommit `json:"merge_commit"`
Participants []bitbucketParticipant `json:"participants"`
Created time.Time `json:"created_on"`
Updated time.Time `json:"updated_on"`
ClosedOn time.Time `json:"closed_on"` // zero while open; updated_on also moves on later activity
}
type bitbucketComment struct {
Content struct {
Raw string `json:"raw"`
} `json:"content"`
User bitbucketUser `json:"user"`
Created time.Time `json:"created_on"`
Updated time.Time `json:"updated_on"`
Deleted bool `json:"deleted"`
Inline *struct {
Path string `json:"path"`
From int `json:"from"`
To int `json:"to"`
} `json:"inline"`
Parent *struct{} `json:"parent"`
Resolution *struct{} `json:"resolution"`
}
// isLineComment is false for file comments, which carry neither line
func (c *bitbucketComment) isLineComment() bool {
return c.Inline != nil && (c.Inline.From > 0 || c.Inline.To > 0)
}
// bitbucketPRContext carries what GetComments and GetReviews share, so the comments of each pull request are fetched once
type bitbucketPRContext struct {
pr bitbucketPullRequest
comments []bitbucketComment
}
type BitbucketDownloader struct {
base.NullDownloader
client *http.Client
apiRepoURL string
workspace string
repoSlug string
}
func NewBitbucketDownloader(apiBaseURL, workspace, repoSlug, token string) *BitbucketDownloader {
return &BitbucketDownloader{
// Bitbucket Cloud answers 429 without Retry-After and refills its limits over a rolling hour
client: &http.Client{Transport: authTransport(retryAfterTransport{RoundTripper: getMigrationTransport(), backoff: time.Minute}, apiBaseURL, util.Iif(token != "", "Bearer "+token, ""))},
apiRepoURL: apiBaseURL + "/repositories/" + url.PathEscape(workspace) + "/" + url.PathEscape(repoSlug),
workspace: workspace,
repoSlug: repoSlug,
}
}
func (b *BitbucketDownloader) String() string {
return fmt.Sprintf("migration from bitbucket cloud %s/%s", b.workspace, b.repoSlug)
}
func (b *BitbucketDownloader) LogString() string {
if b == nil {
return "<BitbucketDownloader nil>"
}
return fmt.Sprintf("<BitbucketDownloader %s/%s>", b.workspace, b.repoSlug)
}
// FormatCloneURL adds the token into the clone URL, git accepts API and access tokens with the static user name
func (b *BitbucketDownloader) FormatCloneURL(opts base.MigrateOptions, remoteAddr string) (string, error) {
if token := cmp.Or(opts.AuthToken, opts.AuthPassword); token != "" {
opts.AuthUsername, opts.AuthPassword, opts.AuthToken = "x-token-auth", token, ""
}
return b.NullDownloader.FormatCloneURL(opts, remoteAddr)
}
func (b *BitbucketDownloader) doAPI(ctx context.Context, path string, query url.Values, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.apiRepoURL+path, nil)
if err != nil {
return err
}
req.URL.RawQuery = query.Encode()
req.Header.Set("Accept", "application/json")
resp, err := b.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &bbsStatusError{Status: resp.StatusCode, URL: req.URL.String()}
}
return json.NewDecoder(resp.Body).Decode(out)
}
func bitbucketUserID(user bitbucketUser) int64 {
hasher := fnv.New64a()
_, _ = hasher.Write([]byte(cmp.Or(user.AccountID, user.UUID, bitbucketUserName(user))))
return int64(hasher.Sum64() & 0x7fffffffffffffff)
}
func bitbucketUserName(user bitbucketUser) string {
return cmp.Or(user.Nickname, user.DisplayName, user_model.GhostUserName)
}
func bitbucketCloneURL(owner, repo string) string {
return fmt.Sprintf("%s/%s/%s.git", bitbucketWebURL, url.PathEscape(owner), url.PathEscape(repo))
}
func (b *BitbucketDownloader) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
var repo bitbucketRepository
if err := b.doAPI(ctx, "", nil, &repo); err != nil {
return nil, err
}
owner, name, _ := strings.Cut(repo.FullName, "/")
return &base.Repository{
Owner: owner,
Name: repo.Slug,
IsPrivate: repo.IsPrivate,
Description: repo.Description,
Website: repo.Website,
OriginalURL: fmt.Sprintf("%s/%s/%s", bitbucketWebURL, url.PathEscape(owner), url.PathEscape(name)),
CloneURL: bitbucketCloneURL(owner, name),
DefaultBranch: repo.MainBranch.Name,
}, nil
}
func (b *BitbucketDownloader) pullRequestComments(ctx context.Context, reviewable base.Reviewable) (*bitbucketPRContext, error) {
prCtx, ok := reviewable.GetContext().(*bitbucketPRContext)
if !ok {
return nil, fmt.Errorf("pullRequestComments: unexpected context %+v", reviewable.GetContext())
}
if prCtx.comments != nil || prCtx.pr.CommentCount == 0 {
return prCtx, nil
}
comments := make([]bitbucketComment, 0, prCtx.pr.CommentCount)
for page := 1; ; page++ {
var resp bitbucketPage[bitbucketComment]
query := url.Values{"pagelen": {"100"}, "page": {strconv.Itoa(page)}}
if err := b.doAPI(ctx, fmt.Sprintf("/pullrequests/%d/comments", prCtx.pr.ID), query, &resp); err != nil {
return nil, err
}
comments = append(comments, resp.Values...)
if resp.Next == "" {
prCtx.comments = comments
return prCtx, nil
}
}
}
func (b *BitbucketDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
prCtx, err := b.pullRequestComments(ctx, commentable)
if err != nil {
return nil, false, err
}
comments := make([]*base.Comment, 0, len(prCtx.comments))
for _, comment := range prCtx.comments {
if comment.Deleted || comment.isLineComment() {
continue
}
content := comment.Content.Raw
if comment.Inline != nil {
content = fmt.Sprintf("Comment on `%s`:\n\n%s", comment.Inline.Path, content)
}
comments = append(comments, &base.Comment{
IssueIndex: commentable.GetLocalIndex(),
PosterID: bitbucketUserID(comment.User),
PosterName: bitbucketUserName(comment.User),
Created: comment.Created,
Updated: comment.Updated,
Content: content,
})
}
return comments, true, nil
}
func (b *BitbucketDownloader) GetReviews(ctx context.Context, reviewable base.Reviewable) ([]*base.Review, error) {
prCtx, err := b.pullRequestComments(ctx, reviewable)
if err != nil {
return nil, err
}
var reviews []*base.Review
for _, comment := range prCtx.comments {
if comment.Deleted || !comment.isLineComment() {
continue
}
// every comment becomes its own review so that replies keep their author
reviews = append(reviews, &base.Review{
IssueIndex: reviewable.GetLocalIndex(),
ReviewerID: bitbucketUserID(comment.User),
ReviewerName: bitbucketUserName(comment.User),
CreatedAt: comment.Created,
State: base.ReviewStateCommented,
Comments: []*base.ReviewComment{{
Content: comment.Content.Raw,
TreePath: comment.Inline.Path,
Line: cmp.Or(comment.Inline.To, -comment.Inline.From), // a negative line addresses the old side of the diff
CommitID: prCtx.pr.Source.Commit.Hash,
UpdatedAt: comment.Updated,
Resolved: comment.Parent == nil && comment.Resolution != nil,
}},
})
}
for _, participant := range prCtx.pr.Participants {
var state string
switch {
case participant.State == "changes_requested":
state = base.ReviewStateChangesRequested
case participant.Approved || participant.State == "approved":
state = base.ReviewStateApproved
default:
continue
}
reviews = append(reviews, &base.Review{
IssueIndex: reviewable.GetLocalIndex(),
ReviewerID: bitbucketUserID(participant.User),
ReviewerName: bitbucketUserName(participant.User),
CreatedAt: cmp.Or(participant.ParticipatedOn, prCtx.pr.Updated),
State: state,
})
}
slices.SortStableFunc(reviews, func(a, b *base.Review) int { return a.CreatedAt.Compare(b.CreatedAt) })
return reviews, nil
}
func (b *BitbucketDownloader) GetPullRequests(ctx context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
var resp bitbucketPage[bitbucketPullRequest]
query := url.Values{
"fields": {"+values.participants,+values.*.commit.date,+values.merge_commit.date"}, // expanding the commits gives full instead of 12-char hashes
"pagelen": {strconv.Itoa(perPage)},
"page": {strconv.Itoa(page)},
"sort": {"created_on"},
"state": {"OPEN", "MERGED", "DECLINED", "SUPERSEDED"},
}
if err := b.doAPI(ctx, "/pullrequests", query, &resp); err != nil {
return nil, false, err
}
pullRequests := make([]*base.PullRequest, 0, len(resp.Values))
for _, pr := range resp.Values {
state := util.Iif(pr.State == "OPEN", "open", "closed")
merged := pr.State == "MERGED"
closedTime := cmp.Or(pr.ClosedOn, pr.Updated)
headOwner, headRepo, _ := strings.Cut(pr.Source.Repository.FullName, "/")
baseOwner, baseRepo, _ := strings.Cut(pr.Destination.Repository.FullName, "/")
pullRequests = append(pullRequests, &base.PullRequest{
Number: pr.ID,
Title: pr.Title,
PosterID: bitbucketUserID(pr.Author),
PosterName: bitbucketUserName(pr.Author),
Content: pr.Description,
State: state,
Created: pr.Created,
Updated: pr.Updated,
IsDraft: pr.Draft,
Closed: util.Iif(state == "closed", &closedTime, nil),
Merged: merged,
MergedTime: util.Iif(merged, &closedTime, nil),
MergeCommitSHA: pr.MergeCommit.Hash,
Head: base.PullRequestBranch{
CloneURL: util.Iif(headOwner != "" && headRepo != "", bitbucketCloneURL(headOwner, headRepo), ""),
Ref: pr.Source.Branch.Name,
SHA: pr.Source.Commit.Hash,
OwnerName: headOwner,
RepoName: headRepo,
},
Base: base.PullRequestBranch{
Ref: pr.Destination.Branch.Name,
SHA: pr.Destination.Commit.Hash,
OwnerName: baseOwner,
RepoName: baseRepo,
},
ForeignIndex: pr.ID,
Context: &bitbucketPRContext{pr: pr},
})
_ = CheckAndEnsureSafePR(pullRequests[len(pullRequests)-1], bitbucketWebURL, b)
}
return pullRequests, resp.Next == "", nil
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package migrations
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.dev/models/unittest"
base "gitea.dev/modules/migration"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBitbucketDownloadRepo(t *testing.T) {
token := os.Getenv("BITBUCKET_READ_TOKEN")
mockServer := unittest.NewMockWebServer(t, "https://api.bitbucket.org", "_mock_data/TestBitbucketDownloadRepo", token != "", unittest.MockServerOptions{
FixtureName: func(r *http.Request, _ []byte) string { return "GET_" + url.QueryEscape(r.URL.Path) },
})
ctx := t.Context()
downloader := NewBitbucketDownloader(mockServer.URL+"/2.0", "gitea", "test_repo", token)
repo, err := downloader.GetRepoInfo(ctx)
require.NoError(t, err)
assertRepositoryEqual(t, &base.Repository{
Name: "test_repo",
Owner: "gitea",
CloneURL: "https://bitbucket.org/gitea/test_repo.git",
OriginalURL: "https://bitbucket.org/gitea/test_repo",
DefaultBranch: "master",
}, repo)
prs, isEnd, err := downloader.GetPullRequests(ctx, 1, 10)
require.NoError(t, err)
assert.True(t, isEnd)
assertPullRequestsEqual(t, []*base.PullRequest{{
Number: 1,
PosterID: 5793964208530042003,
PosterName: "Lunny Xiao",
Title: "Update LICENSE",
Content: "do not merge this PR",
State: "open",
Created: time.Date(2026, 10, 7, 18, 39, 55, 551762000, time.UTC),
Updated: time.Date(2026, 10, 7, 18, 40, 40, 122213000, time.UTC),
Head: base.PullRequestBranch{
CloneURL: "https://bitbucket.org/gitea/test_repo.git",
Ref: "feat/test",
SHA: "9f733b96b98a4175276edf6a2e1231489c3bdd23",
OwnerName: "gitea",
RepoName: "test_repo",
},
Base: base.PullRequestBranch{
Ref: "master",
SHA: "c59c9b451acca9d106cc19d61d87afe3fbbb8b83",
OwnerName: "gitea",
RepoName: "test_repo",
},
}}, prs)
downloader = NewBitbucketDownloader(mockServer.URL+"/2.0", "atlassian", "aui", token)
prs, isEnd, err = downloader.GetPullRequests(ctx, 1, 1)
require.NoError(t, err)
assert.False(t, isEnd)
require.Len(t, prs, 1)
assert.Equal(t, "closed", prs[0].State)
assert.True(t, prs[0].Merged)
assert.NotNil(t, prs[0].MergedTime)
assert.Len(t, prs[0].MergeCommitSHA, 40)
assert.Empty(t, prs[0].Head.SHA)
assert.Len(t, prs[0].Base.SHA, 40)
comments, isEnd, err := downloader.GetComments(ctx, prs[0])
require.NoError(t, err)
assert.True(t, isEnd)
assert.Len(t, comments, 25)
}
func TestBitbucketDownloader(t *testing.T) {
headSHA := strings.Repeat("a", 40)
var commentRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer tok", r.Header.Get("Authorization"))
switch r.URL.Path {
case "/2.0/repositories/w/r/pullrequests":
_, _ = w.Write([]byte(`{"values":[{"id":3,"state":"OPEN","draft":true,"comment_count":5,"updated_on":"2024-01-03T00:00:00Z","source":{"commit":{"hash":"` + headSHA + `"},"repository":{"full_name":"w/r"}},"participants":[
{"user":{"nickname":"alice"},"approved":true,"participated_on":"2024-01-02T00:00:00Z"},
{"user":{"nickname":"bob"},"state":"changes_requested"},
{"user":{"nickname":"carol"}}]}]}`))
case "/2.0/repositories/w/r/pullrequests/3/comments":
commentRequests.Add(1)
_, _ = w.Write([]byte(`{"values":[
{"id":1,"content":{"raw":"general"},"user":{"nickname":"alice"}},
{"id":2,"content":{"raw":"inline"},"user":{"nickname":"bob"},"inline":{"path":"a.txt","to":5},"resolution":{}},
{"id":3,"content":{"raw":"old side"},"user":{"nickname":"bob"},"inline":{"path":"a.txt","from":7},"parent":{"id":2}},
{"id":4,"content":{"raw":"gone"},"user":{"nickname":"bob"},"deleted":true},
{"id":5,"content":{"raw":"file"},"user":{"nickname":"bob"},"inline":{"path":"b.txt"}}]}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
downloader := NewBitbucketDownloader(server.URL+"/2.0", "w", "r", "tok")
prs, _, err := downloader.GetPullRequests(t.Context(), 1, 10)
require.NoError(t, err)
require.Len(t, prs, 1)
assert.True(t, prs[0].IsDraft)
comments, _, err := downloader.GetComments(t.Context(), prs[0])
require.NoError(t, err)
require.Len(t, comments, 2)
assert.Equal(t, "general", comments[0].Content)
assert.Equal(t, "Comment on `b.txt`:\n\nfile", comments[1].Content)
reviews, err := downloader.GetReviews(t.Context(), prs[0])
require.NoError(t, err)
assert.EqualValues(t, 1, commentRequests.Load())
var inline, states []string
for _, review := range reviews {
if len(review.Comments) > 0 {
reviewComment := review.Comments[0]
inline = append(inline, fmt.Sprintf("%s:%d:%v", reviewComment.TreePath, reviewComment.Line, reviewComment.Resolved))
assert.Equal(t, headSHA, reviewComment.CommitID)
} else {
states = append(states, review.ReviewerName+":"+review.State)
}
}
assert.ElementsMatch(t, []string{"a.txt:5:true", "a.txt:-7:false"}, inline)
assert.ElementsMatch(t, []string{"alice:APPROVED", "bob:CHANGES_REQUESTED"}, states)
cloneURL, err := downloader.FormatCloneURL(base.MigrateOptions{AuthUsername: "me@example.com", AuthPassword: "tok"}, "https://bitbucket.org/w/r")
require.NoError(t, err)
assert.Equal(t, "https://x-token-auth:tok@bitbucket.org/w/r", cloneURL)
_, err = (&BitbucketDownloaderFactory{}).New(t.Context(), base.MigrateOptions{CloneAddr: "https://git.example.com/w/r.git"})
assert.ErrorContains(t, err, "invalid Bitbucket Cloud URL")
}
func TestBitbucketPullRequestClosedTime(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"values":[{"id":1,"state":"MERGED","created_on":"2024-01-01T00:00:00Z","updated_on":"2024-02-01T00:00:00Z","closed_on":"2024-01-05T00:00:00Z"},
{"id":2,"state":"DECLINED","created_on":"2024-01-01T00:00:00Z","updated_on":"2024-02-01T00:00:00Z"}]}`))
}))
defer server.Close()
prs, _, err := NewBitbucketDownloader(server.URL+"/2.0", "w", "r", "").GetPullRequests(t.Context(), 1, 10)
require.NoError(t, err)
require.Len(t, prs, 2)
assert.Equal(t, time.Date(2024, 1, 5, 0, 0, 0, 0, time.UTC), *prs[0].MergedTime)
assert.Equal(t, *prs[0].MergedTime, *prs[0].Closed)
assert.Equal(t, time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), *prs[1].Closed) // falls back to updated_on
}
+1 -1
View File
@@ -286,7 +286,7 @@ func (e *bbsStatusError) isAuthError() bool {
}
func (e *bbsStatusError) Error() string {
msg := fmt.Sprintf("bitbucket server API %s returned status %d", e.URL, e.Status)
msg := fmt.Sprintf("bitbucket API %s returned status %d", e.URL, e.Status)
if e.isAuthError() {
msg += ", check the credentials and the permission to read the repository"
}
+51 -8
View File
@@ -5,6 +5,7 @@
package migrations
import (
"cmp"
"context"
"errors"
"fmt"
@@ -43,6 +44,7 @@ type GiteaLocalUploader struct {
doer *user_model.User
repoOwner string
repoName string
cloneURL string // clone address of the source, may carry credentials
repo *repo_model.Repository
labels map[string]*issues_model.Label
milestones map[string]int64
@@ -118,6 +120,7 @@ func (g *GiteaLocalUploader) CreateRepo(ctx context.Context, repo *base.Reposito
r.Description = repo.Description
r.Website = repo.Website
g.cloneURL = repo.CloneURL
r, err = repo_service.MigrateRepositoryGitData(ctx, owner, r, base.MigrateOptions{
RepoName: g.repoName,
Description: repo.Description,
@@ -601,6 +604,24 @@ func (g *GiteaLocalUploader) commitExists(ctx context.Context, sha string) bool
return err == nil
}
// fetchCommit tries to get a missing head commit of a closed pull request by its SHA from the head repository,
// e.g. a squash-merged pull request whose source branch is gone and which the host does not expose as a ref
func (g *GiteaLocalUploader) fetchCommit(ctx context.Context, pr *base.PullRequest) bool {
remote := pr.Head.CloneURL
if !pr.IsForkPullRequest() && g.cloneURL != "" {
remote = g.cloneURL // the source repository itself, with the migration credentials
}
if remote == "" {
return false
}
_, _, err := gitcmd.NewCommand("fetch", "--no-tags").AddDashesAndList(remote, pr.Head.SHA).WithRepo(g.repo).RunStdString(ctx)
if err != nil {
log.Warn("PR #%d fetching head commit %s failed: %v", pr.Number, pr.Head.SHA, util.SanitizeCredentialURLs(err.Error()))
return false
}
return g.commitExists(ctx, pr.Head.SHA)
}
func (g *GiteaLocalUploader) updateGitForPullRequest(ctx context.Context, pr *base.PullRequest) (head string, err error) {
// SECURITY: this pr must have been must have been ensured safe
if !pr.EnsuredSafe {
@@ -739,7 +760,7 @@ func (g *GiteaLocalUploader) updateGitForPullRequest(ctx context.Context, pr *ba
if pr.Head.SHA == "" {
// The SHA is empty
log.Warn("Empty reference, no pull head for PR #%d in %s/%s", pr.Number, g.repoOwner, g.repoName)
} else if !g.commitExists(ctx, pr.Head.SHA) {
} else if !g.commitExists(ctx, pr.Head.SHA) && !g.fetchCommit(ctx, pr) {
// Git update-ref remove bad references with a relative path
log.Warn("Deprecated local head %s for PR #%d in %s/%s, removing %s", pr.Head.SHA, pr.Number, g.repoOwner, g.repoName, pr.GetGitHeadRefName())
} else if err = git.UpdateRef(ctx, g.repo, pr.GetGitHeadRefName(), pr.Head.SHA); err != nil {
@@ -874,6 +895,7 @@ func convertReviewState(state string) issues_model.ReviewType {
// CreateReviews create pull request reviews of currently migrated issues
func (g *GiteaLocalUploader) CreateReviews(ctx context.Context, reviews ...*base.Review) error {
cms := make([]*issues_model.Review, 0, len(reviews))
var fallbackComments []*issues_model.Comment
for _, review := range reviews {
var issue *issues_model.Issue
issue, ok := g.issues[review.IssueIndex]
@@ -910,15 +932,33 @@ func (g *GiteaLocalUploader) CreateReviews(ctx context.Context, reviews ...*base
}
g.prCache[issue.ID] = pr
}
headCommitID := ""
if pr.MergeBase == "" {
// No mergebase -> no basis for any patches
log.Warn("PR #%d in %s/%s: does not have a merge base, all review comments will be ignored", pr.Index, g.repoOwner, g.repoName)
continue
log.Warn("PR #%d in %s/%s: does not have a merge base, review comments will be added as plain comments", pr.Index, g.repoOwner, g.repoName)
} else if id, err := g.gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName()); err != nil {
log.Warn("PR #%d GetRefCommitID[%s] in %s/%s: %v, review comments will be added as plain comments", pr.Index, pr.GetGitHeadRefName(), g.repoOwner, g.repoName, err)
} else {
headCommitID = id
}
headCommitID, err := g.gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
if err != nil {
log.Warn("PR #%d GetRefCommitID[%s] in %s/%s: %v, all review comments will be ignored", pr.Index, pr.GetGitHeadRefName(), g.repoOwner, g.repoName, err)
if headCommitID == "" {
// without a diff to anchor to, keep the text as plain comments instead of an empty review
for _, comment := range review.Comments {
c := issues_model.Comment{
Type: issues_model.CommentTypeComment,
IssueID: issue.ID,
Content: fmt.Sprintf("Comment on `%s` line %d:\n\n%s", util.PathJoinRel(comment.TreePath), max(comment.Line, -comment.Line), comment.Content),
CreatedUnix: timeutil.TimeStamp(cmp.Or(comment.CreatedAt, review.CreatedAt).Unix()),
UpdatedUnix: timeutil.TimeStamp(cmp.Or(comment.UpdatedAt, comment.CreatedAt, review.CreatedAt).Unix()),
}
if err := g.remapUser(ctx, review, &c); err != nil {
return err
}
fallbackComments = append(fallbackComments, &c)
}
if len(review.Comments) > 0 && cm.Type == issues_model.ReviewTypeComment && cm.Content == "" {
cms = cms[:len(cms)-1]
}
continue
}
@@ -972,7 +1012,10 @@ func (g *GiteaLocalUploader) CreateReviews(ctx context.Context, reviews ...*base
}
}
return issues_model.InsertReviews(ctx, cms)
if err := issues_model.InsertReviews(ctx, cms); err != nil {
return err
}
return issues_model.InsertIssueComments(ctx, fallbackComments)
}
// Rollback when migrating failed, this will rollback all the changes.
@@ -349,3 +349,43 @@ func TestGiteaUploadForkPullRequestHeadInRepository(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, strings.TrimSpace(remotes), "no remote is added for a local head")
}
func TestGiteaUploadReviewCommentsWithoutHead(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx := t.Context()
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
require.NoError(t, pull.LoadIssue(ctx))
uploader := NewGiteaLocalUploader(ctx, doer, repo.OwnerName, repo.Name)
var err error
uploader.repo = repo
uploader.gitRepo, err = git.OpenRepository(ctx, repo)
require.NoError(t, err)
defer uploader.Close()
uploader.issues[pull.Issue.Index] = pull.Issue
require.NoError(t, git.RemoveRef(ctx, repo, pull.GetGitHeadRefName()))
opts := issues_model.FindReviewOptions{IssueID: pull.Issue.ID}
reviewsBefore, err := issues_model.CountReviews(ctx, opts)
require.NoError(t, err)
require.NoError(t, uploader.CreateReviews(ctx, &base.Review{
IssueIndex: pull.Issue.Index,
ReviewerID: doer.ID,
State: base.ReviewStateCommented,
Comments: []*base.ReviewComment{{Content: "lost anchor", TreePath: "README.md", Line: -3}},
}))
comments, err := issues_model.FindComments(ctx, &issues_model.FindCommentsOptions{IssueID: pull.Issue.ID, Type: issues_model.CommentTypeComment})
require.NoError(t, err)
var contents []string
for _, c := range comments {
contents = append(contents, c.Content)
}
assert.Contains(t, contents, "Comment on `README.md` line 3:\n\nlost anchor")
reviewsAfter, err := issues_model.CountReviews(ctx, opts)
require.NoError(t, err)
assert.Equal(t, reviewsBefore, reviewsAfter) // no empty review is left behind
}
+6 -2
View File
@@ -19,7 +19,7 @@ import (
// getMigrationHTTPClient returns the shared migration client, so downloads from one host reuse its connections
var getMigrationHTTPClient = sync.OnceValue(func() *http.Client {
return &http.Client{Transport: retryAfterTransport{getMigrationTransport()}}
return &http.Client{Transport: retryAfterTransport{RoundTripper: getMigrationTransport()}}
})
var getMigrationTransport = sync.OnceValue(newMigrationTransport)
@@ -30,7 +30,7 @@ func newMigrationHTTPClient(baseURL, authorization string) *http.Client {
// NewMigrationHTTPTransport returns a HTTP transport for migration, enforcing the migration policy on its direct dials.
func NewMigrationHTTPTransport() http.RoundTripper {
return retryAfterTransport{newMigrationTransport()}
return retryAfterTransport{RoundTripper: newMigrationTransport()}
}
func newMigrationTransport() *http.Transport {
@@ -41,6 +41,7 @@ func newMigrationTransport() *http.Transport {
type retryAfterTransport struct {
http.RoundTripper
backoff time.Duration // first wait for a 429 without Retry-After, doubled per retry, zero gives up
}
func (t retryAfterTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -54,6 +55,9 @@ func (t retryAfterTransport) RoundTrip(req *http.Request) (*http.Response, error
return resp, nil
}
delay, ok := parseRetryAfter(resp.Header.Get("Retry-After"))
if !ok && t.backoff > 0 && resp.StatusCode == http.StatusTooManyRequests {
delay, ok = t.backoff<<retries, true
}
if !ok || delay > time.Hour-waited {
return resp, nil
}
+5 -2
View File
@@ -26,20 +26,23 @@ func TestMigrationHTTPTransportRetryAfter(t *testing.T) {
case 2:
w.Header().Set("Retry-After", time.Now().UTC().Format(http.TimeFormat))
w.WriteHeader(http.StatusForbidden)
case 3:
w.WriteHeader(http.StatusTooManyRequests)
default:
_, _ = io.Copy(w, r.Body)
}
}))
defer server.Close()
resp, err := getMigrationHTTPClient().Post(server.URL, "text/plain", strings.NewReader("payload"))
client := &http.Client{Transport: retryAfterTransport{RoundTripper: getMigrationTransport(), backoff: time.Nanosecond}}
resp, err := client.Post(server.URL, "text/plain", strings.NewReader("payload"))
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "payload", string(body))
assert.EqualValues(t, 3, requests.Load())
assert.EqualValues(t, 4, requests.Load())
}
func TestAuthTransportSendsCredentialsToSameHostOnly(t *testing.T) {
+50
View File
@@ -0,0 +1,50 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository new migrate">
<div class="ui container medium-width">
<h3 class="ui top attached header">
{{ctx.Locale.Tr "repo.migrate.migrate" .service.Title}}
</h3>
<div class="ui attached segment">
{{template "base/alert" .}}
<form class="ui form left-right-form form-fetch-action" action="{{.Link}}" method="post">
{{template "repo/migrate/common-init" (dict "ServiceType" .service)}}
{{template "repo/migrate/variants" .}}
{{template "repo/migrate/common-clone-addr"}}
{{if ne .service.Name "bitbucket"}}
<div class="inline field">
<label>{{ctx.Locale.Tr "username"}}</label>
<input name="auth_username">
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "password"}}</label>
<input name="auth_password" type="password">
</div>
{{end}}
<div class="inline field">
<label>{{ctx.Locale.Tr "access_token"}}</label>
<input name="auth_token" type="password" autocomplete="new-password">
<span class="help">{{ctx.Locale.Tr (printf "repo.migrate.%s.token_desc" .service.Name)}}</span>
</div>
{{template "repo/migrate/common-options" .}}
<div id="migrate_items">
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.migrate_items"}}</label>
<div class="ui checkbox">
<input name="pull_requests" type="checkbox">
<label>{{ctx.Locale.Tr "repo.migrate_items_pullrequests"}}</label>
</div>
</div>
</div>
<div class="divider"></div>
{{template "repo/migrate/common-migrated-repo"}}
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
+1 -48
View File
@@ -1,48 +1 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository new migrate">
<div class="ui container medium-width">
<h3 class="ui top attached header">
{{ctx.Locale.Tr "repo.migrate.migrate" .service.Title}}
</h3>
<div class="ui attached segment">
{{template "base/alert" .}}
<form class="ui form left-right-form form-fetch-action" action="{{.Link}}" method="post">
{{template "repo/migrate/common-init" (dict "ServiceType" .service)}}
{{template "repo/migrate/variants" .}}
{{template "repo/migrate/common-clone-addr"}}
<div class="inline field">
<label>{{ctx.Locale.Tr "username"}}</label>
<input name="auth_username">
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "password"}}</label>
<input name="auth_password" type="password">
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "access_token"}}</label>
<input name="auth_token" type="password" autocomplete="new-password">
<span class="help">{{ctx.Locale.Tr "repo.migrate.bitbucketserver.token_desc"}}</span>
</div>
{{template "repo/migrate/common-options" .}}
<div id="migrate_items">
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.migrate_items"}}</label>
<div class="ui checkbox">
<input name="pull_requests" type="checkbox">
<label>{{ctx.Locale.Tr "repo.migrate_items_pullrequests"}}</label>
</div>
</div>
</div>
<div class="divider"></div>
{{template "repo/migrate/common-migrated-repo"}}
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
{{template "repo/migrate/bitbucket" .}}
+2 -4
View File
@@ -8,16 +8,14 @@
<a class="ui basic button migrate-entry" href="{{AppSubUrl}}/repo/migrate?service_type={{.}}&org={{$.Org}}&mirror={{$.Mirror}}">
{{if eq .Name "github"}}
{{svg "octicon-mark-github" 184 "tw-p-4"}}
{{else if eq .Name "bitbucketserver"}}
{{svg "gitea-bitbucket" 184 "tw-p-4"}}
{{else if or (eq .Name "gitlab") (eq .Name "gitbucket") (eq .Name "forgejo")}}
{{else if or (eq .Name "gitlab") (eq .Name "gitbucket") (eq .Name "forgejo") (eq .Name "bitbucket")}}
{{svg (printf "gitea-%s" .Name) 184 "tw-p-4"}}
{{else}}
{{svg (printf "gitea-%s" .Name) 184}}
{{end}}
<div class="migrate-entry-content">
<div class="migrate-entry-title">
{{.ProviderTitle}}
{{.Title}}
</div>
<div class="tw-text-balance">
{{ctx.Locale.Tr (printf "repo.migrate.%s.description" .Name)}}
+2 -1
View File
@@ -7977,7 +7977,8 @@
"codebase",
"codecommit",
"bitbucketserver",
"forgejo"
"forgejo",
"bitbucket"
],
"type": "string",
"x-go-name": "Service"
+2 -1
View File
@@ -30472,7 +30472,8 @@
"codebase",
"codecommit",
"bitbucketserver",
"forgejo"
"forgejo",
"bitbucket"
],
"x-go-name": "Service"
},
+11
View File
@@ -54,4 +54,15 @@ func TestRepoMigrate(t *testing.T) {
assert.Equal(t, 1, entries.Length())
assert.Equal(t, "Bitbucket", strings.TrimSpace(entries.Text()))
})
t.Run("BitbucketVariants", func(t *testing.T) {
for _, service := range structs.BitbucketService.Variants() {
resp := session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/repo/migrate?service_type=%d", service)), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Equal(t, fmt.Sprint(service), htmlDoc.GetInputValueByName("service"))
assert.Equal(t, 2, htmlDoc.Find(".ui.compact.menu a.item").Length())
assert.Contains(t, htmlDoc.Find(".ui.compact.menu a.item.active").AttrOr("href", ""), fmt.Sprintf("service_type=%d&", service))
assert.Equal(t, service == structs.BitbucketServerService, htmlDoc.Find(`input[name="auth_username"]`).Length() == 1)
}
})
}