mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-09 17:37:20 -06:00
Bitbucket Server can answer 500 for `/pull-requests/{id}/activities` of
some pull requests. That error aborted the whole repository migration:
`bitbucket server API .../pull-requests/128/activities returned status
500`
Now a 500 is logged as a warning and that pull request's comments,
reviews and merge commit are skipped, cached so the endpoint is not hit
again. Other errors are still returned, so the migration retries them as
before.
---------
Co-authored-by: silverwind <me@silverwind.io>
528 lines
23 KiB
Go
528 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) {
|
|
activityCalls := map[string]int{}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /rest/api/1.0/projects/PROJ/repos/repo/pull-requests/{id}/activities", func(w http.ResponseWriter, r *http.Request) {
|
|
activityCalls[r.PathValue("id")]++
|
|
switch {
|
|
case r.PathValue("id") == "5":
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
case r.PathValue("id") == "6":
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
case r.URL.Query().Get("start") == "10":
|
|
_, _ = w.Write([]byte(bbsActivitiesPage2JSON))
|
|
default:
|
|
_, _ = w.Write([]byte(bbsActivitiesJSON))
|
|
}
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
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)
|
|
|
|
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)
|
|
|
|
skipped := &base.PullRequest{Number: 5, ForeignIndex: 5}
|
|
comments, _, err = d.GetComments(t.Context(), skipped)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, comments)
|
|
reviews, err = d.GetReviews(t.Context(), skipped)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, reviews)
|
|
_, _, err = d.GetComments(t.Context(), &base.PullRequest{Number: 6, ForeignIndex: 6})
|
|
assert.Error(t, err, "only a 500 is skipped")
|
|
assert.Equal(t, map[string]int{"4": 2, "5": 1, "6": 1}, activityCalls, "activities are paged and cached for both calls, also when skipped")
|
|
}
|
|
|
|
// 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)
|
|
}
|