mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-02 10:23:42 +00:00
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:
1 parent
cc20c4c11b
commit
bd5fbcfb29
21 files changed
+874
-80
No files matched your search
+7
-12
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
+37
@@ -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
|
||||
+35
@@ -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 > 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
|
||||
+37
@@ -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}
|
||||
+37
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 +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" .}}
|
||||
@@ -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
@@ -7977,7 +7977,8 @@
|
||||
"codebase",
|
||||
"codecommit",
|
||||
"bitbucketserver",
|
||||
"forgejo"
|
||||
"forgejo",
|
||||
"bitbucket"
|
||||
],
|
||||
"type": "string",
|
||||
"x-go-name": "Service"
|
||||
|
||||
+2
-1
@@ -30472,7 +30472,8 @@
|
||||
"codebase",
|
||||
"codecommit",
|
||||
"bitbucketserver",
|
||||
"forgejo"
|
||||
"forgejo",
|
||||
"bitbucket"
|
||||
],
|
||||
"x-go-name": "Service"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user