mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-26 06:16:56 +09:00
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>
512 lines
23 KiB
Go
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)
|
|
}
|