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>
723 lines
23 KiB
Go
723 lines
23 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package migrations
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dev/modules/json"
|
|
"gitea.dev/modules/log"
|
|
base "gitea.dev/modules/migration"
|
|
"gitea.dev/modules/structs"
|
|
)
|
|
|
|
var (
|
|
_ base.Downloader = &BitbucketServerDownloader{}
|
|
_ base.DownloaderFactory = &BitbucketServerDownloaderFactory{}
|
|
)
|
|
|
|
const (
|
|
bitbucketServerMaxPageSize = 100
|
|
bitbucketServerMaxResponseSize = 64 * 1024 * 1024
|
|
bitbucketServerMaxAttempts = 5
|
|
bitbucketServerMaxRetryWait = time.Minute
|
|
)
|
|
|
|
func init() {
|
|
RegisterDownloaderFactory(&BitbucketServerDownloaderFactory{})
|
|
}
|
|
|
|
// BitbucketServerDownloaderFactory defines a downloader factory for Bitbucket Server (Data Center)
|
|
type BitbucketServerDownloaderFactory struct{}
|
|
|
|
// New returns a Downloader related to this factory according MigrateOptions
|
|
func (f *BitbucketServerDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) {
|
|
baseURL, projectKey, repoSlug, err := parseBitbucketServerURL(opts.CloneAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log.Trace("Create Bitbucket Server downloader. BaseURL: %s Project: %s Repo: %s", baseURL, projectKey, repoSlug)
|
|
return NewBitbucketServerDownloader(ctx, baseURL, opts.AuthUsername, opts.AuthPassword, opts.AuthToken, projectKey, repoSlug), nil
|
|
}
|
|
|
|
// GitServiceType returns the type of git service
|
|
func (f *BitbucketServerDownloaderFactory) GitServiceType() structs.GitServiceType {
|
|
return structs.BitbucketServerService
|
|
}
|
|
|
|
// parseBitbucketServerURL splits a clone or browse URL into the base URL (with context path), project key and repo slug
|
|
func parseBitbucketServerURL(rawURL string) (baseURL, projectKey, repoSlug string, err error) {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return "", "", "", fmt.Errorf("unsupported scheme %q, an http(s) URL is required", u.Scheme)
|
|
}
|
|
|
|
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
for i, seg := range segs {
|
|
rest := segs[i+1:]
|
|
switch {
|
|
case seg == "scm" && len(rest) >= 2:
|
|
projectKey, repoSlug = rest[0], rest[1]
|
|
case seg == "projects" && len(rest) >= 3 && rest[1] == "repos":
|
|
projectKey, repoSlug = rest[0], rest[2]
|
|
case seg == "users" && len(rest) >= 3 && rest[1] == "repos":
|
|
projectKey, repoSlug = "~"+rest[0], rest[2]
|
|
default:
|
|
continue
|
|
}
|
|
repoSlug = strings.TrimSuffix(repoSlug, ".git")
|
|
if projectKey == "" || repoSlug == "" {
|
|
break
|
|
}
|
|
root := url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/" + strings.Join(segs[:i], "/")}
|
|
return strings.TrimSuffix(root.String(), "/"), projectKey, repoSlug, nil
|
|
}
|
|
return "", "", "", fmt.Errorf("invalid Bitbucket Server URL %q, expected .../scm/<project>/<repo>.git or .../projects/<project>/repos/<repo>", u.Redacted())
|
|
}
|
|
|
|
// BitbucketServerDownloader implements a Downloader for Bitbucket Server (Data Center) REST API 1.0.
|
|
// Bitbucket Server has no issues, wiki, labels, milestones or releases.
|
|
type BitbucketServerDownloader struct {
|
|
base.NullDownloader
|
|
client *http.Client
|
|
sleep func(context.Context, time.Duration) error
|
|
baseURL string
|
|
projectKey string
|
|
repoSlug string
|
|
username string
|
|
password string
|
|
token string
|
|
|
|
tokenUser string // user name git over HTTP needs together with the token as the basic-auth password
|
|
prNextStart int // offset of the next page of pull requests, pages are fetched in order
|
|
|
|
// activities of the PRs on the last fetched page, shared by comments, reviews and merge info
|
|
activities map[int64][]*bbsActivity
|
|
}
|
|
|
|
// NewBitbucketServerDownloader creates a Bitbucket Server downloader
|
|
func NewBitbucketServerDownloader(_ context.Context, baseURL, username, password, token, projectKey, repoSlug string) *BitbucketServerDownloader {
|
|
return &BitbucketServerDownloader{
|
|
client: getMigrationHTTPClient(),
|
|
sleep: bitbucketServerSleep,
|
|
baseURL: strings.TrimSuffix(baseURL, "/"),
|
|
projectKey: projectKey,
|
|
repoSlug: repoSlug,
|
|
username: username,
|
|
password: password,
|
|
token: token,
|
|
activities: map[int64][]*bbsActivity{},
|
|
}
|
|
}
|
|
|
|
// String implements Stringer
|
|
func (d *BitbucketServerDownloader) String() string {
|
|
return fmt.Sprintf("migration from bitbucket server %s %s/%s", d.baseURL, d.projectKey, d.repoSlug)
|
|
}
|
|
|
|
// LogString returns a log friendly description
|
|
func (d *BitbucketServerDownloader) LogString() string {
|
|
if d == nil {
|
|
return "<BitbucketServerDownloader nil>"
|
|
}
|
|
return fmt.Sprintf("<BitbucketServerDownloader %s %s/%s>", d.baseURL, d.projectKey, d.repoSlug)
|
|
}
|
|
|
|
// FormatCloneURL adds authentication into the remote URL
|
|
func (d *BitbucketServerDownloader) FormatCloneURL(opts base.MigrateOptions, remoteAddr string) (string, error) {
|
|
u, err := url.Parse(remoteAddr)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch {
|
|
case opts.AuthToken != "":
|
|
// HTTP access tokens are accepted as the basic-auth password of their owner
|
|
u.User = url.UserPassword(cmp.Or(opts.AuthUsername, d.tokenUser, "token"), opts.AuthToken)
|
|
case opts.AuthUsername != "" || opts.AuthPassword != "":
|
|
u.User = url.UserPassword(opts.AuthUsername, opts.AuthPassword)
|
|
}
|
|
return u.String(), nil
|
|
}
|
|
|
|
type bbsUser struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
EmailAddress string `json:"emailAddress"`
|
|
}
|
|
|
|
func (u bbsUser) login() string {
|
|
return cmp.Or(u.Name, u.Slug)
|
|
}
|
|
|
|
type bbsRepository struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Public bool `json:"public"`
|
|
Project struct {
|
|
Key string `json:"key"`
|
|
} `json:"project"`
|
|
}
|
|
|
|
type bbsRef struct {
|
|
DisplayID string `json:"displayId"`
|
|
LatestCommit string `json:"latestCommit"`
|
|
Repository *bbsRepository `json:"repository"`
|
|
}
|
|
|
|
type bbsPullRequest struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
State string `json:"state"` // OPEN, MERGED, DECLINED
|
|
Draft bool `json:"draft"`
|
|
CreatedDate int64 `json:"createdDate"`
|
|
UpdatedDate int64 `json:"updatedDate"`
|
|
ClosedDate int64 `json:"closedDate"`
|
|
FromRef bbsRef `json:"fromRef"`
|
|
ToRef bbsRef `json:"toRef"`
|
|
Author struct {
|
|
User bbsUser `json:"user"`
|
|
} `json:"author"`
|
|
}
|
|
|
|
type bbsAnchor struct {
|
|
Line int `json:"line"`
|
|
LineType string `json:"lineType"` // ADDED, REMOVED, CONTEXT
|
|
FileType string `json:"fileType"` // FROM, TO
|
|
Path string `json:"path"`
|
|
SrcPath string `json:"srcPath"`
|
|
FromHash string `json:"fromHash"`
|
|
ToHash string `json:"toHash"`
|
|
|
|
// set when the comment spans several lines, Line is then the last one
|
|
MultilineMarker struct {
|
|
StartLine int `json:"startLine"`
|
|
} `json:"multilineMarker"`
|
|
MultilineSpan struct {
|
|
SrcStart int `json:"srcSpanStart"`
|
|
SrcEnd int `json:"srcSpanEnd"`
|
|
DstStart int `json:"dstSpanStart"`
|
|
DstEnd int `json:"dstSpanEnd"`
|
|
} `json:"multilineSpan"`
|
|
}
|
|
|
|
// lineRange returns the first and last line of a multi-line comment, in the numbering of the commented side
|
|
func (a *bbsAnchor) lineRange() (start, end int, ok bool) {
|
|
start, end = a.MultilineSpan.DstStart, a.MultilineSpan.DstEnd
|
|
if a.FileType == "FROM" {
|
|
start, end = a.MultilineSpan.SrcStart, a.MultilineSpan.SrcEnd
|
|
}
|
|
if start == 0 || end == 0 {
|
|
start, end = a.MultilineMarker.StartLine, a.Line
|
|
}
|
|
return start, end, start > 0 && start < end
|
|
}
|
|
|
|
func (a *bbsAnchor) isInline() bool {
|
|
return a != nil && a.Path != "" && a.Line > 0
|
|
}
|
|
|
|
type bbsComment struct {
|
|
ID int64 `json:"id"`
|
|
Text string `json:"text"`
|
|
Author bbsUser `json:"author"`
|
|
CreatedDate int64 `json:"createdDate"`
|
|
UpdatedDate int64 `json:"updatedDate"`
|
|
Comments []*bbsComment `json:"comments"` // replies
|
|
|
|
ThreadResolved bool `json:"threadResolved"`
|
|
}
|
|
|
|
func (c *bbsComment) updated() time.Time {
|
|
return time.UnixMilli(cmp.Or(c.UpdatedDate, c.CreatedDate))
|
|
}
|
|
|
|
type bbsActivity struct {
|
|
Action string `json:"action"` // COMMENTED, APPROVED, UNAPPROVED, REVIEWED, DECLINED, REOPENED, MERGED, ...
|
|
CommentAction string `json:"commentAction"`
|
|
CreatedDate int64 `json:"createdDate"`
|
|
User bbsUser `json:"user"`
|
|
Comment *bbsComment `json:"comment"`
|
|
CommentAnchor *bbsAnchor `json:"commentAnchor"`
|
|
Commit *bbsCommit `json:"commit"` // merge commit of a MERGED activity
|
|
}
|
|
|
|
type bbsCommit struct {
|
|
ID string `json:"id"`
|
|
Parents []struct {
|
|
ID string `json:"id"`
|
|
} `json:"parents"`
|
|
}
|
|
|
|
type bbsPage[T any] struct {
|
|
Values []T `json:"values"`
|
|
IsLastPage bool `json:"isLastPage"`
|
|
NextPageStart int `json:"nextPageStart"`
|
|
}
|
|
|
|
func (p *bbsPage[T]) isLast(start int) bool {
|
|
return p.IsLastPage || len(p.Values) == 0 || p.NextPageStart <= start
|
|
}
|
|
|
|
type bbsStatusError struct {
|
|
Status int
|
|
URL string
|
|
}
|
|
|
|
func (e *bbsStatusError) isAuthError() bool {
|
|
return e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden
|
|
}
|
|
|
|
func (e *bbsStatusError) Error() string {
|
|
msg := fmt.Sprintf("bitbucket server API %s returned status %d", e.URL, e.Status)
|
|
if e.isAuthError() {
|
|
msg += ", check the credentials and the permission to read the repository"
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func (d *BitbucketServerDownloader) repoPath() string {
|
|
return "/projects/" + url.PathEscape(d.projectKey) + "/repos/" + url.PathEscape(d.repoSlug)
|
|
}
|
|
|
|
func (d *BitbucketServerDownloader) cloneURL(projectKey, repoSlug string) string {
|
|
return d.baseURL + "/scm/" + url.PathEscape(projectKey) + "/" + url.PathEscape(repoSlug) + ".git"
|
|
}
|
|
|
|
func (d *BitbucketServerDownloader) browseURL() string {
|
|
if name, ok := strings.CutPrefix(d.projectKey, "~"); ok {
|
|
return d.baseURL + "/users/" + url.PathEscape(name) + "/repos/" + url.PathEscape(d.repoSlug)
|
|
}
|
|
return d.baseURL + d.repoPath()
|
|
}
|
|
|
|
// callAPI sends a GET request and returns the response headers, a rate limited (429) request is repeated after the
|
|
// time the server asks for
|
|
func (d *BitbucketServerDownloader) callAPI(ctx context.Context, path string, query url.Values, result any) (http.Header, error) {
|
|
endpoint := d.baseURL + "/rest/api/1.0" + path
|
|
apiURL := endpoint
|
|
if len(query) > 0 {
|
|
apiURL += "?" + query.Encode()
|
|
}
|
|
|
|
resp, err := d.doRequest(ctx, apiURL)
|
|
for attempt := 1; err == nil && resp.StatusCode == http.StatusTooManyRequests && attempt < bitbucketServerMaxAttempts; attempt++ {
|
|
wait := bitbucketServerRetryAfter(resp.Header.Get("Retry-After"), attempt)
|
|
resp.Body.Close()
|
|
log.Warn("Bitbucket Server rate limited the request to %s, retrying in %s", endpoint, wait)
|
|
if err := d.sleep(ctx, wait); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err = d.doRequest(ctx, apiURL)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &bbsStatusError{Status: resp.StatusCode, URL: endpoint}
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, bitbucketServerMaxResponseSize+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > bitbucketServerMaxResponseSize {
|
|
return nil, errors.New("bitbucket server API response is too large")
|
|
}
|
|
return resp.Header, json.Unmarshal(data, result)
|
|
}
|
|
|
|
func (d *BitbucketServerDownloader) doRequest(ctx context.Context, apiURL string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if d.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+d.token)
|
|
} else if d.username != "" || d.password != "" {
|
|
req.SetBasicAuth(d.username, d.password)
|
|
}
|
|
|
|
return d.client.Do(req)
|
|
}
|
|
|
|
// bitbucketServerGitUser returns the user name to clone with for the user name an API response reports.
|
|
// Project and repository access tokens act as a technical user, which git does not accept, but "x-token-auth".
|
|
func bitbucketServerGitUser(reported string) string {
|
|
name, err := url.PathUnescape(reported)
|
|
if err != nil || name == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(name, "access-token-user/") {
|
|
return "x-token-auth"
|
|
}
|
|
return name
|
|
}
|
|
|
|
// bitbucketServerRetryAfter returns the time to wait for a rate limited request: the Retry-After header,
|
|
// otherwise an exponential backoff.
|
|
func bitbucketServerRetryAfter(header string, attempt int) time.Duration {
|
|
wait := time.Duration(1<<(attempt-1)) * time.Second
|
|
if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
|
|
wait = time.Duration(seconds) * time.Second
|
|
}
|
|
return min(wait, bitbucketServerMaxRetryWait)
|
|
}
|
|
|
|
func bitbucketServerSleep(ctx context.Context, d time.Duration) error {
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func bbsGetPage[T any](ctx context.Context, d *BitbucketServerDownloader, path string, query url.Values, start, limit int) (*bbsPage[T], error) {
|
|
q := url.Values{}
|
|
maps.Copy(q, query)
|
|
q.Set("start", strconv.Itoa(start))
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
var page bbsPage[T]
|
|
if _, err := d.callAPI(ctx, path, q, &page); err != nil {
|
|
return nil, err
|
|
}
|
|
return &page, nil
|
|
}
|
|
|
|
func bbsGetAll[T any](ctx context.Context, d *BitbucketServerDownloader, path string, query url.Values) ([]T, error) {
|
|
var all []T
|
|
start := 0
|
|
for {
|
|
page, err := bbsGetPage[T](ctx, d, path, query, start, bitbucketServerMaxPageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, page.Values...)
|
|
if page.isLast(start) {
|
|
return all, nil
|
|
}
|
|
start = page.NextPageStart
|
|
}
|
|
}
|
|
|
|
// GetRepoInfo returns repository information
|
|
func (d *BitbucketServerDownloader) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
|
|
var repo bbsRepository
|
|
header, err := d.callAPI(ctx, d.repoPath(), nil, &repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.token != "" {
|
|
d.tokenUser = bitbucketServerGitUser(header.Get("X-AUSERNAME"))
|
|
}
|
|
|
|
// an empty repository has no default branch and the API answers 404
|
|
var branch struct {
|
|
DisplayID string `json:"displayId"`
|
|
}
|
|
if _, err := d.callAPI(ctx, d.repoPath()+"/default-branch", nil, &branch); err != nil {
|
|
if statusErr, ok := errors.AsType[*bbsStatusError](err); !ok || statusErr.Status != http.StatusNotFound {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &base.Repository{
|
|
Name: repo.Slug,
|
|
Owner: repo.Project.Key,
|
|
IsPrivate: !repo.Public,
|
|
Description: repo.Description,
|
|
CloneURL: d.cloneURL(d.projectKey, d.repoSlug),
|
|
OriginalURL: d.browseURL(),
|
|
DefaultBranch: branch.DisplayID,
|
|
}, nil
|
|
}
|
|
|
|
// GetPullRequests returns a page of pull requests, oldest first
|
|
func (d *BitbucketServerDownloader) GetPullRequests(ctx context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
|
|
if page <= 1 {
|
|
d.prNextStart = 0
|
|
}
|
|
limit := min(perPage, bitbucketServerMaxPageSize)
|
|
if limit <= 0 {
|
|
limit = bitbucketServerMaxPageSize
|
|
}
|
|
// the server may cap the page size, so the offset is taken from the previous page
|
|
start := d.prNextStart
|
|
result, err := bbsGetPage[bbsPullRequest](ctx, d, d.repoPath()+"/pull-requests",
|
|
url.Values{"state": {"ALL"}, "order": {"oldest"}}, start, limit)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
d.prNextStart = result.NextPageStart
|
|
|
|
clear(d.activities)
|
|
prs := make([]*base.PullRequest, 0, len(result.Values))
|
|
for i := range result.Values {
|
|
prs = append(prs, d.convertPullRequest(ctx, &result.Values[i]))
|
|
}
|
|
return prs, result.isLast(start), nil
|
|
}
|
|
|
|
func (d *BitbucketServerDownloader) convertPullRequest(ctx context.Context, orig *bbsPullRequest) *base.PullRequest {
|
|
pr := &base.PullRequest{
|
|
Number: orig.ID,
|
|
ForeignIndex: orig.ID,
|
|
Title: orig.Title,
|
|
Content: orig.Description,
|
|
PosterID: orig.Author.User.ID,
|
|
PosterName: orig.Author.User.login(),
|
|
PosterEmail: orig.Author.User.EmailAddress,
|
|
State: "open",
|
|
Created: time.UnixMilli(orig.CreatedDate),
|
|
Updated: time.UnixMilli(orig.UpdatedDate),
|
|
IsDraft: orig.Draft,
|
|
Merged: orig.State == "MERGED",
|
|
Head: d.convertRef(&orig.FromRef),
|
|
Base: d.convertRef(&orig.ToRef),
|
|
}
|
|
// the merge base is computed by the uploader, the target branch tip given by the API may have moved on
|
|
pr.Base.SHA = ""
|
|
|
|
if orig.State != "OPEN" {
|
|
pr.State = "closed"
|
|
closed := time.UnixMilli(cmp.Or(orig.ClosedDate, orig.UpdatedDate))
|
|
pr.Closed = &closed
|
|
if pr.Merged {
|
|
pr.MergedTime = &closed
|
|
}
|
|
}
|
|
|
|
if pr.Merged {
|
|
d.completeMergedPullRequest(ctx, pr)
|
|
}
|
|
|
|
_ = CheckAndEnsureSafePR(pr, d.baseURL, d)
|
|
return pr
|
|
}
|
|
|
|
// completeMergedPullRequest adds the merge commit and the merge base, which the uploader cannot derive from
|
|
// the target branch as it already contains the merged head
|
|
func (d *BitbucketServerDownloader) completeMergedPullRequest(ctx context.Context, pr *base.PullRequest) {
|
|
activities, err := d.getActivities(ctx, pr.Number)
|
|
if err != nil {
|
|
log.Warn("Bitbucket Server: unable to load activities of pull request %d: %v", pr.Number, err)
|
|
}
|
|
for _, activity := range activities {
|
|
if activity.Action == "MERGED" && activity.Commit != nil {
|
|
pr.MergeCommitSHA = activity.Commit.ID
|
|
}
|
|
}
|
|
|
|
commits, err := bbsGetAll[bbsCommit](ctx, d, d.repoPath()+"/pull-requests/"+strconv.FormatInt(pr.Number, 10)+"/commits", nil)
|
|
if err != nil {
|
|
log.Warn("Bitbucket Server: unable to load commits of pull request %d: %v", pr.Number, err)
|
|
return
|
|
}
|
|
// listed newest first
|
|
if len(commits) > 0 && len(commits[len(commits)-1].Parents) > 0 {
|
|
pr.Base.SHA = commits[len(commits)-1].Parents[0].ID
|
|
}
|
|
}
|
|
|
|
func (d *BitbucketServerDownloader) convertRef(ref *bbsRef) base.PullRequestBranch {
|
|
branch := base.PullRequestBranch{Ref: ref.DisplayID, SHA: ref.LatestCommit}
|
|
if ref.Repository != nil {
|
|
branch.OwnerName = ref.Repository.Project.Key
|
|
branch.RepoName = ref.Repository.Slug
|
|
// built from the user supplied base URL instead of trusting the links of the response
|
|
branch.CloneURL = d.cloneURL(branch.OwnerName, branch.RepoName)
|
|
}
|
|
return branch
|
|
}
|
|
|
|
func (d *BitbucketServerDownloader) getActivities(ctx context.Context, prID int64) ([]*bbsActivity, error) {
|
|
if activities, ok := d.activities[prID]; ok {
|
|
return activities, nil
|
|
}
|
|
activities, err := bbsGetAll[*bbsActivity](ctx, d, d.repoPath()+"/pull-requests/"+strconv.FormatInt(prID, 10)+"/activities", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
slices.Reverse(activities) // listed newest first
|
|
d.activities[prID] = activities
|
|
return activities, nil
|
|
}
|
|
|
|
// walkBitbucketServerComments visits each non-empty comment once, with the anchor of its thread.
|
|
// Deleted comments are skipped, also in the activity that added them.
|
|
func walkBitbucketServerComments(activities []*bbsActivity, visit func(c *bbsComment, anchor *bbsAnchor, isRoot bool)) {
|
|
seen := map[int64]bool{}
|
|
for _, activity := range activities {
|
|
if activity.CommentAction == "DELETED" && activity.Comment != nil {
|
|
seen[activity.Comment.ID] = true
|
|
}
|
|
}
|
|
var walk func(c *bbsComment, anchor *bbsAnchor, isRoot bool)
|
|
walk = func(c *bbsComment, anchor *bbsAnchor, isRoot bool) {
|
|
if c == nil || seen[c.ID] {
|
|
return
|
|
}
|
|
seen[c.ID] = true
|
|
if strings.TrimSpace(c.Text) != "" {
|
|
visit(c, anchor, isRoot)
|
|
}
|
|
for _, reply := range c.Comments {
|
|
walk(reply, anchor, false)
|
|
}
|
|
}
|
|
for _, activity := range activities {
|
|
if activity.Action == "COMMENTED" {
|
|
walk(activity.Comment, activity.CommentAnchor, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetComments returns the general and file comments and the decline/reopen events of a pull request
|
|
func (d *BitbucketServerDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
|
|
activities, err := d.getActivities(ctx, commentable.GetForeignIndex())
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
var comments []*base.Comment
|
|
walkBitbucketServerComments(activities, func(c *bbsComment, anchor *bbsAnchor, _ bool) {
|
|
if anchor.isInline() {
|
|
return
|
|
}
|
|
content := c.Text
|
|
if anchor != nil && anchor.Path != "" {
|
|
content = fmt.Sprintf("Comment on `%s`:\n\n%s", anchor.Path, c.Text)
|
|
}
|
|
comments = append(comments, &base.Comment{
|
|
IssueIndex: commentable.GetLocalIndex(),
|
|
Index: c.ID,
|
|
PosterID: c.Author.ID,
|
|
PosterName: c.Author.login(),
|
|
PosterEmail: c.Author.EmailAddress,
|
|
Created: time.UnixMilli(c.CreatedDate),
|
|
Updated: c.updated(),
|
|
Content: content,
|
|
})
|
|
})
|
|
for _, activity := range activities {
|
|
var commentType string
|
|
switch activity.Action {
|
|
case "DECLINED":
|
|
commentType = "close"
|
|
case "REOPENED":
|
|
commentType = "reopen"
|
|
default:
|
|
continue
|
|
}
|
|
comments = append(comments, &base.Comment{
|
|
IssueIndex: commentable.GetLocalIndex(),
|
|
CommentType: commentType,
|
|
PosterID: activity.User.ID,
|
|
PosterName: activity.User.login(),
|
|
PosterEmail: activity.User.EmailAddress,
|
|
Created: time.UnixMilli(activity.CreatedDate),
|
|
Updated: time.UnixMilli(activity.CreatedDate),
|
|
})
|
|
}
|
|
slices.SortStableFunc(comments, func(a, b *base.Comment) int { return a.Created.Compare(b.Created) })
|
|
return comments, true, nil
|
|
}
|
|
|
|
// GetReviews returns the approvals, "needs work" votes and the inline code comments of a pull request
|
|
func (d *BitbucketServerDownloader) GetReviews(ctx context.Context, reviewable base.Reviewable) ([]*base.Review, error) {
|
|
activities, err := d.getActivities(ctx, reviewable.GetForeignIndex())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var reviews []*base.Review
|
|
walkBitbucketServerComments(activities, func(c *bbsComment, anchor *bbsAnchor, isRoot bool) {
|
|
if !anchor.isInline() {
|
|
return
|
|
}
|
|
// every comment becomes its own review so that replies keep their author
|
|
reviews = append(reviews, &base.Review{
|
|
IssueIndex: reviewable.GetLocalIndex(),
|
|
ReviewerID: c.Author.ID,
|
|
ReviewerName: c.Author.login(),
|
|
CreatedAt: time.UnixMilli(c.CreatedDate),
|
|
State: base.ReviewStateCommented,
|
|
Comments: []*base.ReviewComment{c.toReviewComment(anchor, isRoot)},
|
|
})
|
|
})
|
|
|
|
lastVote := map[string]*bbsActivity{} // the last vote of every reviewer wins
|
|
for _, activity := range activities {
|
|
if activity.Action == "APPROVED" || activity.Action == "REVIEWED" || activity.Action == "UNAPPROVED" {
|
|
lastVote[activity.User.login()] = activity
|
|
}
|
|
}
|
|
for _, key := range slices.Sorted(maps.Keys(lastVote)) {
|
|
activity := lastVote[key]
|
|
if activity.Action == "UNAPPROVED" {
|
|
continue
|
|
}
|
|
state := base.ReviewStateApproved
|
|
if activity.Action == "REVIEWED" {
|
|
state = base.ReviewStateChangesRequested
|
|
}
|
|
reviews = append(reviews, &base.Review{
|
|
IssueIndex: reviewable.GetLocalIndex(),
|
|
ReviewerID: activity.User.ID,
|
|
ReviewerName: activity.User.login(),
|
|
CreatedAt: time.UnixMilli(activity.CreatedDate),
|
|
State: state,
|
|
})
|
|
}
|
|
slices.SortStableFunc(reviews, func(a, b *base.Review) int { return a.CreatedAt.Compare(b.CreatedAt) })
|
|
return reviews, nil
|
|
}
|
|
|
|
// toReviewComment converts a comment of a code thread, isRoot marks the first one, which carries the resolved state and line range
|
|
func (c *bbsComment) toReviewComment(anchor *bbsAnchor, isRoot bool) *base.ReviewComment {
|
|
rc := &base.ReviewComment{
|
|
ID: c.ID,
|
|
Content: c.Text,
|
|
TreePath: anchor.Path,
|
|
Line: anchor.Line,
|
|
CommitID: anchor.ToHash,
|
|
PosterID: c.Author.ID,
|
|
CreatedAt: time.UnixMilli(c.CreatedDate),
|
|
UpdatedAt: c.updated(),
|
|
Resolved: isRoot && c.ThreadResolved,
|
|
}
|
|
side := ""
|
|
if anchor.FileType == "FROM" {
|
|
side = " (before the change)"
|
|
rc.Line = -anchor.Line // a negative line addresses the old side of the diff
|
|
rc.CommitID = anchor.FromHash
|
|
}
|
|
// Gitea anchors a code comment to a single line only, so the range is kept in the text
|
|
if start, end, ok := anchor.lineRange(); isRoot && ok {
|
|
rc.Content = fmt.Sprintf("Lines %d-%d%s:\n\n%s", start, end, side, c.Text)
|
|
}
|
|
return rc
|
|
}
|