From 2410415e6073352edfe47c043581d91b43a9b128 Mon Sep 17 00:00:00 2001 From: JakobDev Date: Mon, 19 Feb 2024 16:38:44 +0100 Subject: [PATCH] Update --- modules/git/commit.go | 18 +-- modules/structs/fork.go | 7 + options/locale/locale_en-US.ini | 3 + routers/api/v1/api.go | 3 + routers/api/v1/repo/sync_fork.go | 148 ++++++++++++++++-- routers/api/v1/swagger/repo.go | 7 + routers/web/repo/repo.go | 16 +- routers/web/repo/view.go | 8 +- services/repository/sync_fork.go | 57 +++++-- templates/repo/home.tmpl | 4 +- templates/swagger/v1_json.tmpl | 152 ++++++++++++++++++- tests/integration/api_repo_sync_fork_test.go | 80 ++++++++++ tests/integration/pull_reopen_test.go | 1 + 13 files changed, 452 insertions(+), 52 deletions(-) create mode 100644 tests/integration/api_repo_sync_fork_test.go diff --git a/modules/git/commit.go b/modules/git/commit.go index 705a97c927..71a5bf8011 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -439,23 +439,7 @@ func (c *Commit) GetBranchName() (string, error) { // GetAllBranches returns a slice with all branches that contains this commit func (c *Commit) GetAllBranches() ([]string, error) { - branchList := make([]string, 0) - - cmd := NewCommand(c.repo.Ctx, "branch", "--format=%(refname:short)", "--contains").AddDynamicArguments(c.ID.String()) - data, _, err := cmd.RunStdString(&RunOpts{Dir: c.repo.Path}) - if err != nil { - return branchList, err - } - - branchNames := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n") - for _, branch := range branchNames { - branch = strings.TrimSpace(branch) - if branch != "" { - branchList = append(branchList, branch) - } - } - - return branchList, nil + return c.repo.getBranches(c, 1) } // CommitFileStatus represents status of files in a commit. diff --git a/modules/structs/fork.go b/modules/structs/fork.go index eb7774afbc..3c46102245 100644 --- a/modules/structs/fork.go +++ b/modules/structs/fork.go @@ -10,3 +10,10 @@ type CreateForkOption struct { // name of the forked repository Name *string `json:"name"` } + +// SyncForkInfo information about syncing a fork +type SyncForkInfo struct { + Allowed bool `json:"allowed"` + ForkCommit string `json:"fork_commit"` + BaseCommit string `json:"base_commit"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 3dd87bb4c4..d14e3a7ff6 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1092,6 +1092,9 @@ archive.title_date = This repository has been archived on %s. You can view files archive.issue.nocomment = This repo is archived. You cannot comment on issues. archive.pull.nocomment = This repo is archived. You cannot comment on pull requests. +sync_fork.text = This branch is behind %s +sync_fork.button = Sync + form.reach_limit_of_creation_1 = The owner has already reached the limit of %d repository. form.reach_limit_of_creation_n = The owner has already reached the limit of %d repositories. form.name_reserved = The repository name "%s" is reserved. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 0199ec8cbd..369c96a233 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1342,6 +1342,9 @@ func Routes() *web.Route { m.Delete("", repo.DeleteAvatar) }, reqAdmin(), reqToken()) m.Group("/sync_fork", func() { + m.Get("", repo.SyncForkDefaultInfo) + m.Post("", repo.SyncForkDefault) + m.Get("/{branch}", repo.SyncForkBranchInfo) m.Post("/{branch}", repo.SyncForkBranch) }, reqToken(), reqRepoWriter(unit.TypeCode)) }, repoAssignment()) diff --git a/routers/api/v1/repo/sync_fork.go b/routers/api/v1/repo/sync_fork.go index fcc339db58..a6e2a57129 100644 --- a/routers/api/v1/repo/sync_fork.go +++ b/routers/api/v1/repo/sync_fork.go @@ -3,15 +3,64 @@ package repo import ( "net/http" + git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/modules/context" repo_service "code.gitea.io/gitea/services/repository" ) -// SyncForkBranch syncs a fork branch with the base branch -func SyncForkBranch(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/sync_fork/{branch} repository repoSyncForkBranch +func getSyncForkInfo(ctx *context.APIContext, branch string) { + if !ctx.Repo.Repository.IsFork { + ctx.Error(http.StatusBadRequest, "NoFork", "The Repo must be a fork") + return + } + + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) + if err != nil { + if git_model.IsErrBranchNotExist(err) { + ctx.NotFound(err, branch) + return + } + + ctx.Error(http.StatusInternalServerError, "GetSyncForkInfo", err) + return + } + + ctx.JSON(http.StatusOK, syncForkInfo) +} + +// SyncForkBranchInfo returns information about syncing the default fork branch with the base branch +func SyncForkDefaultInfo(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/sync_fork repository repoSyncForkDefaultInfo // --- - // summary: Syncs a fork + // summary: Gets information about syncing the fork default branch with the base branch + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/SyncForkInfo" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + getSyncForkInfo(ctx, ctx.Repo.Repository.DefaultBranch) +} + +// SyncForkBranchInfo returns information about syncing a fork branch with the base branch +func SyncForkBranchInfo(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/sync_fork/{branch} repository repoSyncForkBranchInfo + // --- + // summary: Gets information about syncing a fork branch with the base branch // produces: // - application/json // parameters: @@ -31,24 +80,103 @@ func SyncForkBranch(ctx *context.APIContext) { // type: string // required: true // responses: + // "200": + // "$ref": "#/responses/SyncForkInfo" // "400": // "$ref": "#/responses/error" - // "204": - // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" + getSyncForkInfo(ctx, ctx.Params("branch")) +} + +func syncForkBranch(ctx *context.APIContext, branch string) { if !ctx.Repo.Repository.IsFork { ctx.Error(http.StatusBadRequest, "NoFork", "The Repo must be a fork") return } - branch := ctx.Params("branch") - - err := repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) if err != nil { - ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err) + if git_model.IsErrBranchNotExist(err) { + ctx.NotFound(err, branch) + return + } + + ctx.Error(http.StatusInternalServerError, "GetSyncForkInfo", err) + return + } + + if !syncForkInfo.Allowed { + ctx.Error(http.StatusBadRequest, "NotAllowed", "You can't sync this branch") + return + } + + err = repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SyncFork", err) return } ctx.Status(http.StatusNoContent) } + +// SyncForkBranch syncs the default of a fork with the base branch +func SyncForkDefault(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/sync_fork repository repoSyncForkDefault + // --- + // summary: Syncs the default branch of a fork with the base branch + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + syncForkBranch(ctx, ctx.Repo.Repository.DefaultBranch) +} + +// SyncForkBranch syncs a fork branch with the base branch +func SyncForkBranch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/sync_fork/{branch} repository repoSyncForkBranch + // --- + // summary: Syncs a fork branch with the base branch + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: branch + // in: path + // description: The branch + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + syncForkBranch(ctx, ctx.Params("branch")) +} diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 263e335873..f0752c0696 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -421,3 +421,10 @@ type swaggerBlockedUserList struct { // in:body Body []api.BlockedUser `json:"body"` } + +// SyncForkInfo +// swagger:response SyncForkInfo +type swaggerSyncForkInfo struct { + // in:body + Body []api.SyncForkInfo `json:"body"` +} diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 8ddcd5b86c..9e055258ab 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -739,13 +739,25 @@ func PrepareBranchList(ctx *context.Context) { } func SyncFork(ctx *context.Context) { + redirectURL := fmt.Sprintf("%s/src/branch/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName)) branch := ctx.Params("branch") - err := repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, branch) + if err != nil { + ctx.ServerError("GetSyncForkInfo", err) + return + } + + if !syncForkInfo.Allowed { + ctx.Redirect(redirectURL) + return + } + + err = repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) if err != nil { ctx.ServerError("SyncFork", err) return } - ctx.Redirect(fmt.Sprintf("%s/src/branch/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName))) + ctx.Redirect(redirectURL) } diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index d03d079998..dd568dc8d3 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -49,8 +49,8 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" issue_service "code.gitea.io/gitea/services/issue" - files_service "code.gitea.io/gitea/services/repository/files" repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" "github.com/nektos/act/pkg/model" @@ -1115,14 +1115,14 @@ PostRecentBranchCheck: return } - canSync, err := repo_service.CanSyncFork(ctx, ctx.Repo.Repository, ctx.Repo.BranchName) + syncForkInfo, err := repo_service.GetSyncForkInfo(ctx, ctx.Repo.Repository, ctx.Repo.BranchName) if err != nil { ctx.ServerError("CanSync", err) return } - if canSync { - ctx.Data["CanSyncFork"] = canSync + if syncForkInfo.Allowed { + ctx.Data["CanSyncFork"] = true ctx.Data["SyncForkLink"] = fmt.Sprintf("%s/sync_fork/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName)) ctx.Data["BaseBranchLink"] = fmt.Sprintf("%s/src/branch/%s", ctx.Repo.Repository.BaseRepo.HTMLURL(), util.PathEscapeSegments(ctx.Repo.BranchName)) } diff --git a/services/repository/sync_fork.go b/services/repository/sync_fork.go index 8b8ae6aae3..2193644f49 100644 --- a/services/repository/sync_fork.go +++ b/services/repository/sync_fork.go @@ -1,3 +1,6 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package repository import ( @@ -9,7 +12,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" + api "code.gitea.io/gitea/modules/structs" ) // SyncFork syncs a branch of a fork with the base repo @@ -23,11 +28,15 @@ func SyncFork(ctx context.Context, doer *user_model.User, repo *repo_model.Repos if err != nil { return err } - defer repo_module.RemoveTemporaryPath(tmpPath) + defer func() { + if err := repo_module.RemoveTemporaryPath(tmpPath); err != nil { + log.Error("SyncFork: RemoveTemporaryPath: %s", err) + } + }() - err = git.NewCommand(ctx, "clone", "-b").AddDynamicArguments(branch, repo.RepoPath(), tmpPath).Run(&git.RunOpts{Dir: tmpPath}) + err = git.Clone(ctx, repo.RepoPath(), tmpPath, git.CloneRepoOptions{}) if err != nil { - return fmt.Errorf("Clone: %v", err) + return err } gitRepo, err := git.OpenRepository(ctx, tmpPath) @@ -71,51 +80,69 @@ func SyncFork(ctx context.Context, doer *user_model.User, repo *repo_model.Repos return nil } -// CanSyncFork returns if a branch of the fork can be synced with the base repo -func CanSyncFork(ctx context.Context, repo *repo_model.Repository, branch string) (bool, error) { +// CanSyncFork returns inofmrtaion about syncing a fork +func GetSyncForkInfo(ctx context.Context, repo *repo_model.Repository, branch string) (*api.SyncForkInfo, error) { + info := new(api.SyncForkInfo) + info.Allowed = false + + if !repo.IsFork { + return info, nil + } + + err := repo.GetBaseRepo(ctx) + if err != nil { + return nil, err + } + forkBranch, err := git_model.GetBranch(ctx, repo.ID, branch) if err != nil { - return false, err + return nil, err } + info.ForkCommit = forkBranch.CommitID + baseBranch, err := git_model.GetBranch(ctx, repo.BaseRepo.ID, branch) if err != nil { if git_model.IsErrBranchNotExist(err) { // If the base repo don't have the branch, we don't need to continue - return false, nil + return info, nil } - return false, err + return nil, err } + info.BaseCommit = baseBranch.CommitID + // If both branches has the same latest commit, we don't need to sync if forkBranch.CommitID == baseBranch.CommitID { - return false, nil + return info, nil } // If the fork has newer commits, we can't sync if forkBranch.CommitTime >= baseBranch.CommitTime { - return false, nil + return info, nil } // Check if the latest commit of the fork is also in the base gitRepo, err := git.OpenRepository(ctx, repo.BaseRepo.RepoPath()) if err != nil { - return false, err + return nil, err } defer gitRepo.Close() commit, err := gitRepo.GetCommit(forkBranch.CommitID) if err != nil { if git.IsErrNotExist(err) { - return false, nil + return info, nil } - return false, err + return nil, err } branchList, err := commit.GetAllBranches() if err != nil { - return false, err + return nil, err } - return slices.Contains(branchList, branch), nil + info.Allowed = slices.Contains(branchList, branch) + + return info, nil } diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index 73db9bc9c8..44aaf4f4d5 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -182,10 +182,10 @@ {{if .CanSyncFork}}
- This branch is behind {{printf "%s/%s:%s" .Repository.BaseRepo.OwnerName .Repository.BaseRepo.Name .BranchName}} + {{ctx.Locale.Tr "repo.sync_fork.text" (printf "%s/%s:%s" .BaseBranchLink .Repository.BaseRepo.OwnerName .Repository.BaseRepo.Name .BranchName | Safe)}}
- Sync + {{ctx.Locale.Tr "repo.sync_fork.button"}}
{{end}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 4253e5348f..8135462f3b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13388,7 +13388,44 @@ } } }, - "/repos/{owner}/{repo}/sync_fork/{branch}": { + "/repos/{owner}/{repo}/sync_fork": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets information about syncing the fork default branch with the base branch", + "operationId": "repoSyncForkDefaultInfo", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/SyncForkInfo" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, "post": { "produces": [ "application/json" @@ -13396,7 +13433,90 @@ "tags": [ "repository" ], - "summary": "Syncs a fork", + "summary": "Syncs the default branch of a fork with the base branch", + "operationId": "repoSyncForkDefault", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/sync_fork/{branch}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets information about syncing a fork branch with the base branch", + "operationId": "repoSyncForkBranchInfo", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The branch", + "name": "branch", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/SyncForkInfo" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Syncs a fork branch with the base branch", "operationId": "repoSyncForkBranch", "parameters": [ { @@ -23232,6 +23352,25 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "SyncForkInfo": { + "description": "SyncForkInfo information about syncing a fork", + "type": "object", + "properties": { + "allowed": { + "type": "boolean", + "x-go-name": "Allowed" + }, + "base_commit": { + "type": "string", + "x-go-name": "BaseCommit" + }, + "fork_commit": { + "type": "string", + "x-go-name": "ForkCommit" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Tag": { "description": "Tag represents a repository tag", "type": "object", @@ -24797,6 +24936,15 @@ } } }, + "SyncForkInfo": { + "description": "SyncForkInfo", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/SyncForkInfo" + } + } + }, "Tag": { "description": "Tag", "schema": { diff --git a/tests/integration/api_repo_sync_fork_test.go b/tests/integration/api_repo_sync_fork_test.go new file mode 100644 index 0000000000..653612ccff --- /dev/null +++ b/tests/integration/api_repo_sync_fork_test.go @@ -0,0 +1,80 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func syncForkTest(t *testing.T, forkName, urlPart string) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20}) + + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: baseRepo.OwnerID}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + /// Create a new fork + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseUser.Name, baseRepo.LowerName), &api.CreateForkOption{Name: &forkName}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusAccepted) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var syncForkInfo *api.SyncForkInfo + DecodeJSON(t, resp, &syncForkInfo) + + // This is a new fork, so the commits in both branches should be the same + assert.False(t, syncForkInfo.Allowed) + assert.Equal(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) + + // Make a commit on the base branch + err := createOrReplaceFileInBranch(baseUser, baseRepo, "sync_fork.txt", "master", "Hello") + require.NoError(t, err) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &syncForkInfo) + + // The commits should no longer be the same and we can sync + assert.True(t, syncForkInfo.Allowed) + assert.NotEqual(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) + + // Sync the fork + req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusNoContent) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/%s", user.Name, forkName, urlPart).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &syncForkInfo) + + // After the sync both commits should be the same again + assert.False(t, syncForkInfo.Allowed) + assert.Equal(t, syncForkInfo.BaseCommit, syncForkInfo.ForkCommit) +} + +func TestAPIRepoSyncForkDefault(t *testing.T) { + syncForkTest(t, "SyncForkDefault", "sync_fork") +} + +func TestAPIRepoSyncForkBranch(t *testing.T) { + syncForkTest(t, "SyncForkBranch", "sync_fork/master") +} diff --git a/tests/integration/pull_reopen_test.go b/tests/integration/pull_reopen_test.go index d8dfffc36a..51f208794e 100644 --- a/tests/integration/pull_reopen_test.go +++ b/tests/integration/pull_reopen_test.go @@ -26,6 +26,7 @@ import ( repo_service "code.gitea.io/gitea/services/repository" files_service "code.gitea.io/gitea/services/repository/files" "code.gitea.io/gitea/tests" + "github.com/stretchr/testify/assert" )