Files
gitea/services/migrations/bitbucketserver_test.go
T
Lunny Xiaoandbircni a2da5cd7b5 feat(migrations): add migration from Bitbucket Server (Data Center) (#39913)
Add migration from Bitbucket Server (Data Center). It has no issues,
wiki, milestones or releases, so those are not offered.

Migrated: git data with tags and LFS, and pull requests with state,
draft flag, merge commit/base, comments, approvals and inline code
comments including resolved threads. Multi-line ranges become a `Lines
a-b:` text prefix, as Gitea anchors code comments to one line. Auth is
an HTTP access token (personal, project or repository) or
username/password; 429 responses are retried.

Changes outside the downloader:
- `ReviewComment.Resolved`, applied by the uploader.
- The uploader creates a fork PR's head branch from its head commit when
already in the repository, instead of fetching the fork (Bitbucket keeps
it in `refs/pull-requests/<id>/from`; private forks need credentials).

Tested with unit tests, responses recorded from Bitbucket 10.5.1, and an
end-to-end migration. Independent of
https://github.com/go-gitea/gitea/pull/38607 (Bitbucket Cloud); the
service type enum value needs adjusting for whichever lands second.

---------

Co-authored-by: bircni <bircni@icloud.com>
2026-10-23 19:25:29 +00:00

512 lines
23 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package migrations
import (
"cmp"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"gitea.dev/models/unittest"
"gitea.dev/modules/json"
base "gitea.dev/modules/migration"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseBitbucketServerURL(t *testing.T) {
cases := []struct {
in string
baseURL, key, slug string
wantErr bool
}{
{in: "https://bb.example.com/scm/PROJ/repo.git", baseURL: "https://bb.example.com", key: "PROJ", slug: "repo"},
{in: "https://user:pass@bb.example.com:7990/scm/proj/repo", baseURL: "https://bb.example.com:7990", key: "proj", slug: "repo"},
{in: "https://bb.example.com/stash/scm/~jdoe/repo.git", baseURL: "https://bb.example.com/stash", key: "~jdoe", slug: "repo"},
{in: "https://bb.example.com/projects/PROJ/repos/repo/browse", baseURL: "https://bb.example.com", key: "PROJ", slug: "repo"},
{in: "https://bb.example.com/ctx/a/users/jdoe/repos/repo/pull-requests/1", baseURL: "https://bb.example.com/ctx/a", key: "~jdoe", slug: "repo"},
{in: "https://bb.example.com/projects/PROJ", wantErr: true},
{in: "https://bb.example.com/scm/PROJ", wantErr: true},
{in: "ssh://git@bb.example.com/PROJ/repo.git", wantErr: true},
{in: "/local/path/scm/PROJ/repo.git", wantErr: true},
}
for _, c := range cases {
baseURL, key, slug, err := parseBitbucketServerURL(c.in)
if c.wantErr {
assert.Error(t, err, c.in)
continue
}
require.NoError(t, err, c.in)
assert.Equal(t, c.baseURL, baseURL, c.in)
assert.Equal(t, c.key, key, c.in)
assert.Equal(t, c.slug, slug, c.in)
}
}
func TestBitbucketServerFormatCloneURL(t *testing.T) {
d := NewBitbucketServerDownloader(t.Context(), "https://bb.example.com", "", "", "", "PROJ", "repo")
const addr = "https://bb.example.com/scm/PROJ/repo.git"
got, err := d.FormatCloneURL(base.MigrateOptions{}, addr)
require.NoError(t, err)
assert.Equal(t, addr, got)
got, err = d.FormatCloneURL(base.MigrateOptions{AuthUsername: "u", AuthPassword: "p"}, addr)
require.NoError(t, err)
assert.Equal(t, "https://u:p@bb.example.com/scm/PROJ/repo.git", got)
got, err = d.FormatCloneURL(base.MigrateOptions{AuthToken: "tok"}, addr)
require.NoError(t, err)
assert.Equal(t, "https://token:tok@bb.example.com/scm/PROJ/repo.git", got)
// git needs the owner of the token as user name, which is learned from the API responses
d.tokenUser = "alice"
got, err = d.FormatCloneURL(base.MigrateOptions{AuthToken: "tok"}, addr)
require.NoError(t, err)
assert.Equal(t, "https://alice:tok@bb.example.com/scm/PROJ/repo.git", got)
got, err = d.FormatCloneURL(base.MigrateOptions{AuthToken: "tok", AuthUsername: "bob"}, addr)
require.NoError(t, err)
assert.Equal(t, "https://bob:tok@bb.example.com/scm/PROJ/repo.git", got)
assert.Equal(t, "j.doe", bitbucketServerGitUser("j.doe"))
// project and repository access tokens report a technical user
assert.Equal(t, "x-token-auth", bitbucketServerGitUser("access-token-user%2F1%2F2"))
assert.Equal(t, "x-token-auth", bitbucketServerGitUser("access-token-user/2/2"))
assert.Empty(t, bitbucketServerGitUser(""))
}
const bbsRepoJSON = `{"slug":"repo","name":"Repo","description":"a test repo","public":false,"project":{"key":"PROJ"}}`
func TestBitbucketServerRepoInfo(t *testing.T) {
var requests []*http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r)
w.Header().Set("X-AUSERNAME", "alice")
switch r.URL.Path {
case "/rest/api/1.0/projects/PROJ/repos/repo", "/rest/api/1.0/projects/~jdoe/repos/repo", "/rest/api/1.0/projects/DENIED/repos/repo":
_, _ = w.Write([]byte(bbsRepoJSON))
case "/rest/api/1.0/projects/~jdoe/repos/repo/default-branch":
_, _ = w.Write([]byte(`{"displayId":"main"}`))
case "/rest/api/1.0/projects/DENIED/repos/repo/default-branch":
w.WriteHeader(http.StatusUnauthorized)
default:
http.NotFound(w, r) // an empty repository has no default branch
}
}))
defer srv.Close()
d := NewBitbucketServerDownloader(t.Context(), srv.URL, "", "", "secret", "PROJ", "repo")
repo, err := d.GetRepoInfo(t.Context())
require.NoError(t, err)
assert.Equal(t, &base.Repository{
Name: "repo",
Owner: "PROJ",
IsPrivate: true,
Description: "a test repo",
CloneURL: srv.URL + "/scm/PROJ/repo.git",
OriginalURL: srv.URL + "/projects/PROJ/repos/repo",
}, repo)
assert.Equal(t, "Bearer secret", requests[0].Header.Get("Authorization"))
assert.Equal(t, "alice", d.tokenUser)
_, err = d.GetReleases(t.Context())
assert.True(t, base.IsErrNotSupported(err), "tags are synced from git")
requests = nil
repo, err = NewBitbucketServerDownloader(t.Context(), srv.URL, "u", "p", "", "~jdoe", "repo").GetRepoInfo(t.Context())
require.NoError(t, err)
assert.Equal(t, "main", repo.DefaultBranch)
assert.Equal(t, srv.URL+"/users/jdoe/repos/repo", repo.OriginalURL)
user, pass, ok := requests[0].BasicAuth()
assert.True(t, ok)
assert.Equal(t, "u", user)
assert.Equal(t, "p", pass)
_, err = NewBitbucketServerDownloader(t.Context(), srv.URL, "", "", "", "DENIED", "repo").GetRepoInfo(t.Context())
assert.True(t, IsAuthenticationError(err))
}
const bbsPullRequestsPage1JSON = `{"isLastPage":false,"nextPageStart":1,"values":[
{"id":1,"title":"merged","description":"d1","state":"MERGED","createdDate":1700000000000,"updatedDate":1700000300000,"closedDate":1700000200000,
"author":{"user":{"id":7,"name":"alice","emailAddress":"alice@example.com"}},
"fromRef":{"displayId":"feature","latestCommit":"HEAD_SHA","repository":{"slug":"repo","project":{"key":"PROJ"}}},
"toRef":{"displayId":"main","latestCommit":"BASE_SHA","repository":{"slug":"repo","project":{"key":"PROJ"}}}}
]}`
const bbsPullRequestsPage2JSON = `{"isLastPage":true,"values":[
{"id":2,"title":"fork pr","state":"OPEN","draft":true,"createdDate":1700001000000,"updatedDate":1700001000000,
"author":{"user":{"id":8,"slug":"bob"}},
"fromRef":{"displayId":"patch","latestCommit":"HEAD_SHA","repository":{"slug":"repo-fork","project":{"key":"~BOB"}}},
"toRef":{"displayId":"main","latestCommit":"BASE_SHA","repository":{"slug":"repo","project":{"key":"PROJ"}}}},
{"id":3,"title":"declined","state":"DECLINED","createdDate":1700002000000,"updatedDate":1700002500000,
"author":{"user":{"id":7,"name":"alice"}},
"fromRef":{"displayId":"x","latestCommit":"not-a-sha","repository":{"slug":"repo","project":{"key":"PROJ"}}},
"toRef":{"displayId":"main","latestCommit":"BASE_SHA","repository":{"slug":"repo","project":{"key":"PROJ"}}}}
]}`
func TestBitbucketServerPullRequests(t *testing.T) {
headSHA, baseSHA, mergeSHA, forkPoint := strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), strings.Repeat("4", 40)
const api = "/rest/api/1.0/projects/PROJ/repos/repo"
shas := strings.NewReplacer("HEAD_SHA", headSHA, "BASE_SHA", baseSHA)
var queries []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case api + "/pull-requests/1/commits":
_, _ = w.Write([]byte(`{"isLastPage":true,"values":[{"id":"c2","parents":[{"id":"c1"}]},{"id":"c1","parents":[{"id":"` + forkPoint + `"}]}]}`))
case api + "/pull-requests/1/activities":
_, _ = w.Write([]byte(`{"isLastPage":true,"values":[{"action":"MERGED","commit":{"id":"` + mergeSHA + `"}}]}`))
case api + "/pull-requests":
queries = append(queries, r.URL.RawQuery)
body := bbsPullRequestsPage1JSON
if r.URL.Query().Get("start") != "0" {
body = bbsPullRequestsPage2JSON
}
_, _ = w.Write([]byte(shas.Replace(body)))
default:
assert.Failf(t, "unexpected request", "%s", r.URL.Path)
}
}))
defer srv.Close()
d := NewBitbucketServerDownloader(t.Context(), srv.URL, "", "", "", "PROJ", "repo")
prs, isEnd, err := d.GetPullRequests(t.Context(), 1, 500)
require.NoError(t, err)
assert.False(t, isEnd)
require.Len(t, prs, 1)
page2, isEnd, err := d.GetPullRequests(t.Context(), 2, 500)
require.NoError(t, err)
assert.True(t, isEnd)
prs = append(prs, page2...)
// the page size is capped, and the server answering with fewer values must not skip any
assert.Equal(t, []string{"limit=100&order=oldest&start=0&state=ALL", "limit=100&order=oldest&start=1&state=ALL"}, queries)
require.Len(t, prs, 3)
merged := prs[0]
assert.EqualValues(t, 1, merged.Number)
assert.EqualValues(t, 1, merged.GetForeignIndex())
assert.Equal(t, "closed", merged.State)
assert.True(t, merged.Merged)
assert.Equal(t, mergeSHA, merged.MergeCommitSHA)
assert.Equal(t, time.UnixMilli(1700000200000), *merged.MergedTime)
assert.Equal(t, time.UnixMilli(1700000200000), *merged.Closed)
assert.Equal(t, "alice", merged.PosterName)
assert.EqualValues(t, 7, merged.PosterID)
assert.Equal(t, "feature", merged.Head.Ref)
assert.Equal(t, headSHA, merged.Head.SHA)
assert.Equal(t, "main", merged.Base.Ref)
assert.Equal(t, forkPoint, merged.Base.SHA, "parent of the oldest PR commit, the target branch has moved on")
assert.False(t, merged.IsForkPullRequest())
assert.True(t, merged.EnsuredSafe)
fork := prs[1]
assert.Equal(t, "open", fork.State)
assert.True(t, fork.IsDraft)
assert.Equal(t, "bob", fork.PosterName)
assert.True(t, fork.IsForkPullRequest())
assert.Equal(t, srv.URL+"/scm/~BOB/repo-fork.git", fork.Head.CloneURL)
assert.Equal(t, "~BOB", fork.Head.OwnerName)
assert.Equal(t, "patch", fork.Head.Ref)
assert.Nil(t, fork.Closed)
declined := prs[2]
assert.Equal(t, "closed", declined.State)
assert.False(t, declined.Merged)
assert.Equal(t, time.UnixMilli(1700002500000), *declined.Closed, "falls back to the update time")
assert.Empty(t, declined.Head.SHA, "invalid SHA is dropped")
}
// newest first, like the API
const bbsActivitiesJSON = `{"isLastPage":false,"nextPageStart":10,"values":[
{"action":"REOPENED","createdDate":1700000900000,"user":{"id":7,"name":"alice"}},
{"action":"DECLINED","createdDate":1700000800000,"user":{"id":7,"name":"alice"}},
{"action":"UNAPPROVED","createdDate":1700000720000,"user":{"id":10,"name":"dave"}},
{"action":"APPROVED","createdDate":1700000710000,"user":{"id":10,"name":"dave"}},
{"action":"REVIEWED","createdDate":1700000700000,"user":{"id":9,"name":"carol"}},
{"action":"COMMENTED","commentAction":"DELETED","createdDate":1700000650000,"user":{"id":7,"name":"alice"},
"comment":{"id":16,"text":"deleted","author":{"id":7,"name":"alice"},"createdDate":1700000650000}},
{"action":"COMMENTED","commentAction":"ADDED","createdDate":1700000640000,"user":{"id":7,"name":"alice"},
"comment":{"id":16,"text":"deleted","author":{"id":7,"name":"alice"},"createdDate":1700000640000}},
{"action":"COMMENTED","commentAction":"ADDED","createdDate":1700000600000,"user":{"id":7,"name":"alice"},
"commentAnchor":{"path":"README.md","fileType":"TO"},
"comment":{"id":15,"text":"file comment","author":{"id":7,"name":"alice"},"createdDate":1700000600000,"updatedDate":1700000600000}},
{"action":"COMMENTED","commentAction":"ADDED","createdDate":1700000500000,"user":{"id":7,"name":"alice"},
"commentAnchor":{"line":5,"lineType":"REMOVED","fileType":"FROM","path":"src/b.go","srcPath":"src/old_b.go","fromHash":"fffffffffffffffffffffffffffffffffffffff0","toHash":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0"},
"comment":{"id":14,"text":"inline old","author":{"id":7,"name":"alice"},"createdDate":1700000500000,"updatedDate":1700000500000}},
{"action":"COMMENTED","commentAction":"ADDED","createdDate":1700000400000,"user":{"id":8,"name":"bob"},
"commentAnchor":{"line":12,"lineType":"ADDED","fileType":"TO","path":"src/a.go","srcPath":"src/a.go","fromHash":"fffffffffffffffffffffffffffffffffffffff0","toHash":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0"},
"comment":{"id":12,"text":"inline new","author":{"id":8,"name":"bob"},"createdDate":1700000400000,"updatedDate":1700000400000,
"comments":[{"id":13,"text":"inline reply","author":{"id":7,"name":"alice"},"createdDate":1700000450000,"updatedDate":1700000450000}]}}
]}`
const bbsActivitiesPage2JSON = `{"isLastPage":true,"values":[
{"action":"APPROVED","createdDate":1700000300000,"user":{"id":8,"name":"bob"}},
{"action":"COMMENTED","commentAction":"ADDED","createdDate":1700000100000,"user":{"id":8,"name":"bob"},
"comment":{"id":10,"text":"general comment","author":{"id":8,"name":"bob","emailAddress":"bob@example.com"},"createdDate":1700000100000,"updatedDate":1700000150000,
"comments":[{"id":11,"text":"a reply","author":{"id":7,"name":"alice"},"createdDate":1700000200000,"updatedDate":1700000200000}]}},
{"action":"OPENED","createdDate":1700000000000,"user":{"id":7,"name":"alice"}}
]}`
func TestBitbucketServerCommentsAndReviews(t *testing.T) {
var activityCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/rest/api/1.0/projects/PROJ/repos/repo/pull-requests/4/activities", r.URL.Path)
activityCalls++
if r.URL.Query().Get("start") == "10" {
_, _ = w.Write([]byte(bbsActivitiesPage2JSON))
return
}
_, _ = w.Write([]byte(bbsActivitiesJSON))
}))
defer srv.Close()
d := NewBitbucketServerDownloader(t.Context(), srv.URL, "", "", "", "PROJ", "repo")
pr := &base.PullRequest{Number: 4, ForeignIndex: 4}
comments, isEnd, err := d.GetComments(t.Context(), pr)
require.NoError(t, err)
assert.True(t, isEnd)
require.Len(t, comments, 5)
assert.Equal(t, "general comment", comments[0].Content)
assert.Equal(t, "bob", comments[0].PosterName)
assert.Equal(t, "bob@example.com", comments[0].PosterEmail)
assert.EqualValues(t, 10, comments[0].Index)
assert.EqualValues(t, 4, comments[0].IssueIndex)
assert.Equal(t, time.UnixMilli(1700000150000), comments[0].Updated)
assert.Equal(t, "a reply", comments[1].Content)
assert.Equal(t, "alice", comments[1].PosterName)
assert.Equal(t, "Comment on `README.md`:\n\nfile comment", comments[2].Content)
assert.Equal(t, "close", comments[3].CommentType)
assert.Equal(t, "reopen", comments[4].CommentType)
// the deleted comment is skipped, inline comments belong to the reviews
for _, c := range comments {
assert.NotContains(t, c.Content, "deleted")
assert.NotContains(t, c.Content, "inline")
}
reviews, err := d.GetReviews(t.Context(), pr)
require.NoError(t, err)
assert.Equal(t, 2, activityCalls, "activities are paged and cached for both calls")
type summary struct {
reviewer, state, path string
line int
}
var got []summary
for _, r := range reviews {
s := summary{reviewer: r.ReviewerName, state: r.State}
if len(r.Comments) > 0 {
s.path, s.line = r.Comments[0].TreePath, r.Comments[0].Line
}
got = append(got, s)
}
assert.Equal(t, []summary{
{"bob", base.ReviewStateApproved, "", 0},
{"bob", base.ReviewStateCommented, "src/a.go", 12},
{"alice", base.ReviewStateCommented, "src/a.go", 12},
{"alice", base.ReviewStateCommented, "src/b.go", -5},
{"carol", base.ReviewStateChangesRequested, "", 0},
}, got, "dave's approval was withdrawn")
assert.Equal(t, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0", reviews[1].Comments[0].CommitID)
assert.Equal(t, "fffffffffffffffffffffffffffffffffffffff0", reviews[3].Comments[0].CommitID)
assert.Equal(t, "inline reply", reviews[2].Comments[0].Content)
assert.EqualValues(t, 4, reviews[0].IssueIndex)
}
// TestBitbucketServerDownloadRepo replays responses recorded from a Bitbucket Server 10.5.1 instance.
// To record again, set BITBUCKETSERVER_LIVE_TOKEN (an HTTP access token with project read permission) and
// optionally BITBUCKETSERVER_LIVE_URL (default http://localhost:7990/bitbucket) of an instance holding the
// project MIG with the repository demo.
func TestBitbucketServerDownloadRepo(t *testing.T) {
liveToken := os.Getenv("BITBUCKETSERVER_LIVE_TOKEN")
liveURL := cmp.Or(os.Getenv("BITBUCKETSERVER_LIVE_URL"), "http://localhost:7990/bitbucket")
_, callerFile, _, _ := runtime.Caller(0)
fixtureDir := filepath.Join(filepath.Dir(callerFile), "_mock_data/TestBitbucketServerDownloadRepo")
mockServer := unittest.NewMockWebServer(t, liveURL, fixtureDir, liveToken != "")
ctx := t.Context()
token := cmp.Or(liveToken, "replayed") // a token makes the downloader learn the name of its owner
d := NewBitbucketServerDownloader(ctx, mockServer.URL, "", "", token, "MIG", "demo")
repo, err := d.GetRepoInfo(ctx)
require.NoError(t, err)
assert.Equal(t, &base.Repository{
Name: "demo",
Owner: "MIG",
IsPrivate: true,
CloneURL: mockServer.URL + "/scm/MIG/demo.git",
OriginalURL: mockServer.URL + "/projects/MIG/repos/demo",
DefaultBranch: "main",
}, repo)
prs, isEnd, err := d.GetPullRequests(ctx, 1, 50)
require.NoError(t, err)
assert.True(t, isEnd)
require.Len(t, prs, 4)
// open PR from a branch of the same repository
open := prs[0]
assert.Equal(t, "Open PR", open.Title)
assert.Equal(t, "open", open.State)
assert.False(t, open.IsDraft)
assert.Equal(t, "admin", open.PosterName)
assert.Equal(t, "feature", open.Head.Ref)
assert.Equal(t, "810d0600716f4575691c804b583c1c6a6e6708db", open.Head.SHA)
assert.Equal(t, "main", open.Base.Ref)
assert.False(t, open.IsForkPullRequest())
merged := prs[1]
assert.Equal(t, "closed", merged.State)
assert.True(t, merged.Merged)
assert.Equal(t, "504e78ed1c8fcbc0e55f5f0b6d3c49a0bc1ae861", merged.MergeCommitSHA)
assert.Equal(t, "a41991381bd1e6944b3422cd1dc933b65c74cf67", merged.Base.SHA)
assert.NotNil(t, merged.MergedTime)
declined := prs[2]
assert.Equal(t, "closed", declined.State)
assert.False(t, declined.Merged)
assert.NotNil(t, declined.Closed)
// draft PR from a private fork
fork := prs[3]
assert.True(t, fork.IsDraft)
assert.Equal(t, "bob", fork.PosterName)
assert.Equal(t, "forkbranch", fork.Head.Ref)
assert.Equal(t, "aa537b277722995337c78d4d75018202091c0793", fork.Head.SHA)
assert.True(t, fork.IsForkPullRequest())
assert.Equal(t, mockServer.URL+"/scm/~BOB/demo.git", fork.Head.CloneURL)
comments, _, err := d.GetComments(ctx, open)
require.NoError(t, err)
var got []string
for _, c := range comments {
got = append(got, c.PosterName+": "+c.Content)
}
assert.Equal(t, []string{
"admin: General comment from admin",
"bob: Reply from bob",
"carol: Comment on `a.txt`:\n\nFile level comment",
}, got)
reviews, err := d.GetReviews(ctx, open)
require.NoError(t, err)
type summary struct {
reviewer, state, path, content string
line int
resolved bool
}
var reviewed []summary
for _, r := range reviews {
s := summary{reviewer: r.ReviewerName, state: r.State}
if len(r.Comments) > 0 {
c := r.Comments[0]
s.path, s.line, s.content, s.resolved = c.TreePath, c.Line, c.Content, c.Resolved
}
reviewed = append(reviewed, s)
}
assert.Equal(t, []summary{
{"bob", base.ReviewStateCommented, "a.txt", "Inline on added line", 2, false},
{"admin", base.ReviewStateCommented, "a.txt", "Inline reply by admin", 2, false},
{"carol", base.ReviewStateCommented, "a.txt", "Inline on removed line", -2, false},
{"bob", base.ReviewStateApproved, "", "", 0, false},
{"carol", base.ReviewStateChangesRequested, "", "", 0, false},
{"bob", base.ReviewStateCommented, "a.txt", "Lines 2-4:\n\nMulti line comment", 4, false},
{"bob", base.ReviewStateCommented, "a.txt", "Thread to resolve", 6, true},
{"admin", base.ReviewStateCommented, "a.txt", "reply on resolved thread", 6, false},
}, reviewed, "the resolved state is only set on the first comment of a thread")
declinedComments, _, err := d.GetComments(ctx, declined)
require.NoError(t, err)
require.Len(t, declinedComments, 2)
assert.Equal(t, "close", declinedComments[0].CommentType)
assert.Equal(t, "declining this", declinedComments[1].Content)
// a token is used to clone, with the name of its owner
cloneURL, err := d.FormatCloneURL(base.MigrateOptions{AuthToken: token}, repo.CloneURL)
require.NoError(t, err)
assert.Contains(t, cloneURL, "admin:")
}
func TestBitbucketServerInlineCommentRange(t *testing.T) {
var anchor bbsAnchor
require.NoError(t, json.Unmarshal([]byte(`{"line":9,"fileType":"FROM","path":"b","srcPath":"a",
"multilineMarker":{"startLine":7},"multilineSpan":{"srcSpanStart":3,"srcSpanEnd":5,"dstSpanStart":7,"dstSpanEnd":9}}`), &anchor))
c := &bbsComment{ID: 1, Text: "text", ThreadResolved: true}
// the span of the commented side wins, replies neither get the range nor the resolved state
rc := c.toReviewComment(&anchor, true)
assert.Equal(t, "Lines 3-5 (before the change):\n\ntext", rc.Content)
assert.Equal(t, -9, rc.Line)
assert.Equal(t, "b", rc.TreePath, "Gitea files old-side comments under the current path as well")
assert.True(t, rc.Resolved)
rc = c.toReviewComment(&anchor, false)
assert.Equal(t, "text", rc.Content)
assert.False(t, rc.Resolved)
// without a span the marker and the last line are used, a single line has no range
require.NoError(t, json.Unmarshal([]byte(`{"line":9,"fileType":"TO","path":"a","multilineMarker":{"startLine":7}}`), &anchor))
start, end, ok := anchor.lineRange()
assert.True(t, ok)
assert.Equal(t, [2]int{7, 9}, [2]int{start, end})
_, _, ok = (&bbsAnchor{Line: 9, FileType: "TO"}).lineRange()
assert.False(t, ok)
}
func TestBitbucketServerRateLimit(t *testing.T) {
var calls int
retryAfter, limitedUntil := "3", 2
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls <= limitedUntil {
if retryAfter != "" {
w.Header().Set("Retry-After", retryAfter)
}
w.WriteHeader(http.StatusTooManyRequests)
return
}
_, _ = w.Write([]byte(bbsRepoJSON))
}))
defer srv.Close()
var waits []time.Duration
newDownloader := func() *BitbucketServerDownloader {
d := NewBitbucketServerDownloader(t.Context(), srv.URL, "", "", "", "PROJ", "repo")
d.sleep = func(_ context.Context, wait time.Duration) error {
waits = append(waits, wait)
return nil
}
return d
}
// the server is asked again after the time it names
var repo bbsRepository
_, err := newDownloader().callAPI(t.Context(), "/projects/PROJ/repos/repo", nil, &repo)
require.NoError(t, err)
assert.Equal(t, "repo", repo.Slug)
assert.Equal(t, []time.Duration{3 * time.Second, 3 * time.Second}, waits)
// without a hint the wait grows exponentially, and is capped
calls, limitedUntil, retryAfter, waits = 0, 2, "", nil
_, err = newDownloader().callAPI(t.Context(), "/projects/PROJ/repos/repo", nil, &repo)
require.NoError(t, err)
assert.Equal(t, []time.Duration{time.Second, 2 * time.Second}, waits)
assert.Equal(t, time.Minute, bitbucketServerRetryAfter("100000", 1))
// a server that never recovers is given up on
calls, limitedUntil, waits = 0, 100, nil
_, err = newDownloader().callAPI(t.Context(), "/projects/PROJ/repos/repo", nil, &repo)
var statusErr *bbsStatusError
require.ErrorAs(t, err, &statusErr)
assert.Equal(t, http.StatusTooManyRequests, statusErr.Status)
assert.Len(t, waits, bitbucketServerMaxAttempts-1)
assert.Equal(t, bitbucketServerMaxAttempts, calls)
}