fix(migrations): transfer PR flow information (#7421)

- When migrating a pull requests from a Gitea or Forgejo instance, check if the pull request was created via the AGit flow and transfer that bit of information to the migrated pull request.
- Expose this bit of information as the `flow` field for the pull request.
- We have to do a horrible Go hack with Gitea's [go-sdk](gitea.com/gitea/go-sdk) to list all pull requests while being able to decode it to a struct that contains the new `Flow` field. The library does not allow you to do this out of the box, so we have to use `go:linkname` to access the private method that allows us to do this. This in turn means we have to do some boilerplate code that the library otherwise would do for us. The better option would be forking, but that would be a hassle of keeping the library in sync.
- Resolves forgejo/forgejo#5848
- Unit test added.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7421
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
This commit is contained in:
Gusted 2025-04-03 06:47:37 +00:00 committed by Gusted
parent 738f721f3a
commit 5e79b39654
12 changed files with 131 additions and 3 deletions

View file

@ -34,6 +34,7 @@ type PullRequest struct {
Assignees []string
IsLocked bool `yaml:"is_locked"`
Reactions []*Reaction
Flow int64
ForeignIndex int64
Context DownloaderContext `yaml:"-"`
EnsuredSafe bool `yaml:"ensured_safe"`

View file

@ -57,7 +57,8 @@ type PullRequest struct {
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at"`
PinOrder int `json:"pin_order"`
PinOrder int `json:"pin_order"`
Flow int64 `json:"flow"`
}
// PRBranchInfo information about a branch

View file

@ -95,6 +95,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
RequestedReviewersTeams: []*api.Team{},
AllowMaintainerEdit: pr.AllowMaintainerEdit,
Flow: int64(pr.Flow),
Base: &api.PRBranchInfo{
Name: pr.BaseBranch,

View file

@ -504,6 +504,28 @@ func (g *GiteaDownloader) GetComments(commentable base.Commentable) ([]*base.Com
return allComments, true, nil
}
type ForgejoPullRequest struct {
gitea_sdk.PullRequest
Flow int64 `json:"flow"`
}
// Extracted from https://gitea.com/gitea/go-sdk/src/commit/164e3358bc02213954fb4380b821bed80a14824d/gitea/pull.go#L347-L364
func (g *GiteaDownloader) fixPullHeadSha(pr *ForgejoPullRequest) error {
if pr.Base != nil && pr.Base.Repository != nil && pr.Base.Repository.Owner != nil && pr.Head != nil && pr.Head.Ref != "" && pr.Head.Sha == "" {
owner := pr.Base.Repository.Owner.UserName
repo := pr.Base.Repository.Name
refs, _, err := g.client.GetRepoRefs(owner, repo, pr.Head.Ref)
if err != nil {
return err
}
if len(refs) == 0 {
return fmt.Errorf("unable to resolve PR ref %q", pr.Head.Ref)
}
pr.Head.Sha = refs[0].Object.SHA
}
return nil
}
// GetPullRequests returns pull requests according page and perPage
func (g *GiteaDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
if perPage > g.maxPerPage {
@ -511,16 +533,30 @@ func (g *GiteaDownloader) GetPullRequests(page, perPage int) ([]*base.PullReques
}
allPRs := make([]*base.PullRequest, 0, perPage)
prs, _, err := g.client.ListRepoPullRequests(g.repoOwner, g.repoName, gitea_sdk.ListPullRequestsOptions{
prs := make([]*ForgejoPullRequest, 0, perPage)
opt := gitea_sdk.ListPullRequestsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: perPage,
},
State: gitea_sdk.StateAll,
})
}
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls", url.PathEscape(g.repoOwner), url.PathEscape(g.repoName)))
link.RawQuery = opt.QueryEncode()
_, err := getParsedResponse(g.client, "GET", link.String(), http.Header{"content-type": []string{"application/json"}}, nil, &prs)
if err != nil {
return nil, false, fmt.Errorf("error while listing pull requests (page: %d, pagesize: %d). Error: %w", page, perPage, err)
}
if g.client.CheckServerVersionConstraint(">= 1.14.0") != nil {
for i := range prs {
if err := g.fixPullHeadSha(prs[i]); err != nil {
return nil, false, fmt.Errorf("error while listing pull requests (page: %d, pagesize: %d). Error: %w", page, perPage, err)
}
}
}
for _, pr := range prs {
var milestone string
if pr.Milestone != nil {
@ -598,6 +634,7 @@ func (g *GiteaDownloader) GetPullRequests(page, perPage int) ([]*base.PullReques
MergeCommitSHA: mergeCommitSHA,
IsLocked: pr.IsLocked,
PatchURL: pr.PatchURL,
Flow: pr.Flow,
Head: base.PullRequestBranch{
Ref: headRef,
SHA: headSHA,

View file

@ -307,3 +307,46 @@ func TestGiteaDownloadRepo(t *testing.T) {
},
}, reviews)
}
func TestForgejoDownloadRepo(t *testing.T) {
token := os.Getenv("CODE_FORGEJO_TOKEN")
fixturePath := "./testdata/code-forgejo-org/full_download"
server := unittest.NewMockWebServer(t, "https://code.forgejo.org", fixturePath, token != "")
defer server.Close()
downloader, err := NewGiteaDownloader(t.Context(), server.URL, "Gusted/agit-test", "", "", token)
require.NoError(t, err)
require.NotNil(t, downloader)
prs, _, err := downloader.GetPullRequests(1, 50)
require.NoError(t, err)
assert.Len(t, prs, 1)
assertPullRequestEqual(t, &base.PullRequest{
Number: 1,
PosterID: 63,
PosterName: "Gusted",
PosterEmail: "postmaster@gusted.xyz",
Title: "Add extra information",
State: "open",
Created: time.Date(2025, time.April, 1, 20, 28, 45, 0, time.UTC),
Updated: time.Date(2025, time.April, 1, 20, 28, 45, 0, time.UTC),
Base: base.PullRequestBranch{
CloneURL: "",
Ref: "main",
SHA: "79ebb873a6497c8847141ba9706b3f757196a1e6",
RepoName: "agit-test",
OwnerName: "Gusted",
},
Head: base.PullRequestBranch{
CloneURL: server.URL + "/Gusted/agit-test.git",
Ref: "refs/pull/1/head",
SHA: "667e9317ec37b977e6d3d7d43e3440636970563c",
RepoName: "agit-test",
OwnerName: "Gusted",
},
PatchURL: server.URL + "/Gusted/agit-test/pulls/1.patch",
Flow: 1,
}, prs[0])
}

View file

@ -0,0 +1,16 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package migrations
import (
"io"
"net/http"
_ "unsafe" // Needed for go:linkname support
gitea_sdk "code.gitea.io/sdk/gitea"
)
//go:linkname getParsedResponse code.gitea.io/sdk/gitea.(*Client).getParsedResponse
func getParsedResponse(client *gitea_sdk.Client, method, path string, header http.Header, body io.Reader, obj any) (*gitea_sdk.Response, error)

View file

@ -802,6 +802,7 @@ func (g *GiteaLocalUploader) newPullRequest(pr *base.PullRequest) (*issues_model
MergeBase: pr.Base.SHA,
Index: pr.Number,
HasMerged: pr.Merged,
Flow: issues_model.PullRequestFlow(pr.Flow),
Issue: &issue,
}

View file

@ -136,6 +136,7 @@ func assertPullRequestEqual(t *testing.T, expected, actual *base.PullRequest) {
assert.ElementsMatch(t, expected.Assignees, actual.Assignees)
assert.Equal(t, expected.IsLocked, actual.IsLocked)
assertReactionsEqual(t, expected.Reactions, actual.Reactions)
assert.Equal(t, expected.Flow, actual.Flow)
}
func assertPullRequestsEqual(t *testing.T, expected, actual []*base.PullRequest) {

View file

@ -0,0 +1,7 @@
Content-Length: 117
Cache-Control: max-age=0, private, must-revalidate, no-transform
Content-Type: application/json;charset=utf-8
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
{"max_response_items":50,"default_paging_num":30,"default_git_trees_per_page":1000,"default_max_blob_size":10485760}

View file

@ -0,0 +1,7 @@
Cache-Control: max-age=0, private, must-revalidate, no-transform
Content-Type: application/json;charset=utf-8
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Content-Length: 53
{"version":"11.0.0-dev-617-1d1e0ced3e+gitea-1.22.0"}

View file

@ -26133,6 +26133,11 @@
"format": "date-time",
"x-go-name": "Deadline"
},
"flow": {
"type": "integer",
"format": "int64",
"x-go-name": "Flow"
},
"head": {
"$ref": "#/definitions/PRBranchInfo"
},