Files
gitea/services/migrations/bitbucketserver.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

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
}