API endpoint for repo transfer (#9947)
* squash * optimize * fail before make any changes * fix-header
This commit is contained in:
parent
d816f7018b
commit
13bc82009c
9 changed files with 265 additions and 10 deletions
|
@ -392,3 +392,54 @@ func testAPIRepoCreateConflict(t *testing.T, u *url.URL) {
|
|||
assert.Equal(t, respJSON["message"], "The repository with the same name already exists.")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIRepoTransfer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
ctxUserID int64
|
||||
newOwner string
|
||||
teams *[]int64
|
||||
expectedStatus int
|
||||
}{
|
||||
{ctxUserID: 1, newOwner: "user2", teams: nil, expectedStatus: http.StatusAccepted},
|
||||
{ctxUserID: 2, newOwner: "user1", teams: nil, expectedStatus: http.StatusAccepted},
|
||||
{ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusForbidden},
|
||||
{ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity},
|
||||
{ctxUserID: 1, newOwner: "user3", teams: &[]int64{5}, expectedStatus: http.StatusForbidden},
|
||||
{ctxUserID: 1, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted},
|
||||
}
|
||||
|
||||
defer prepareTestEnv(t)()
|
||||
|
||||
//create repo to move
|
||||
user := models.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User)
|
||||
session := loginUser(t, user.Name)
|
||||
token := getTokenForLoggedInUser(t, session)
|
||||
repoName := "moveME"
|
||||
repo := new(models.Repository)
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/repos?token=%s", token), &api.CreateRepoOption{
|
||||
Name: repoName,
|
||||
Description: "repo move around",
|
||||
Private: false,
|
||||
Readme: "Default",
|
||||
AutoInit: true,
|
||||
})
|
||||
resp := session.MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, repo)
|
||||
|
||||
//start testing
|
||||
for _, testCase := range testCases {
|
||||
user = models.AssertExistsAndLoadBean(t, &models.User{ID: testCase.ctxUserID}).(*models.User)
|
||||
repo = models.AssertExistsAndLoadBean(t, &models.Repository{ID: repo.ID}).(*models.Repository)
|
||||
session = loginUser(t, user.Name)
|
||||
token = getTokenForLoggedInUser(t, session)
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer?token=%s", repo.OwnerName, repo.Name, token), &api.TransferRepoOption{
|
||||
NewOwner: testCase.newOwner,
|
||||
TeamIDs: testCase.teams,
|
||||
})
|
||||
session.MakeRequest(t, req, testCase.expectedStatus)
|
||||
}
|
||||
|
||||
//cleanup
|
||||
repo = models.AssertExistsAndLoadBean(t, &models.Repository{ID: repo.ID}).(*models.Repository)
|
||||
_ = models.DeleteRepository(user, repo.OwnerID, repo.ID)
|
||||
}
|
||||
|
|
|
@ -158,6 +158,15 @@ type EditRepoOption struct {
|
|||
Archived *bool `json:"archived,omitempty"`
|
||||
}
|
||||
|
||||
// TransferRepoOption options when transfer a repository's ownership
|
||||
// swagger:model
|
||||
type TransferRepoOption struct {
|
||||
// required: true
|
||||
NewOwner string `json:"new_owner"`
|
||||
// ID of the team or teams to add to the repository. Teams can only be added to organization-owned repositories.
|
||||
TeamIDs *[]int64 `json:"team_ids"`
|
||||
}
|
||||
|
||||
// GitServiceType represents a git service
|
||||
type GitServiceType int
|
||||
|
||||
|
|
|
@ -620,6 +620,7 @@ func RegisterRoutes(m *macaron.Macaron) {
|
|||
m.Combo("").Get(reqAnyRepoReader(), repo.Get).
|
||||
Delete(reqToken(), reqOwner(), repo.Delete).
|
||||
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), context.RepoRef(), repo.Edit)
|
||||
m.Post("/transfer", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer)
|
||||
m.Combo("/notifications").
|
||||
Get(reqToken(), notify.ListRepoNotifications).
|
||||
Put(reqToken(), notify.ReadRepoNotifications)
|
||||
|
|
100
routers/api/v1/repo/transfer.go
Normal file
100
routers/api/v1/repo/transfer.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/convert"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
// Transfer transfers the ownership of a repository
|
||||
func Transfer(ctx *context.APIContext, opts api.TransferRepoOption) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/transfer repository repoTransfer
|
||||
// ---
|
||||
// summary: Transfer a repo ownership
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo to transfer
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo to transfer
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// description: "Transfer Options"
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/TransferRepoOption"
|
||||
// responses:
|
||||
// "202":
|
||||
// "$ref": "#/responses/Repository"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
newOwner, err := models.GetUserByName(opts.NewOwner)
|
||||
if err != nil {
|
||||
if models.IsErrUserNotExist(err) {
|
||||
ctx.Error(http.StatusNotFound, "GetUserByName", err)
|
||||
return
|
||||
}
|
||||
ctx.InternalServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
var teams []*models.Team
|
||||
if opts.TeamIDs != nil {
|
||||
if !newOwner.IsOrganization() {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "repoTransfer", "Teams can only be added to organization-owned repositories")
|
||||
return
|
||||
}
|
||||
|
||||
org := convert.ToOrganization(newOwner)
|
||||
for _, tID := range *opts.TeamIDs {
|
||||
team, err := models.GetTeamByID(tID)
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "team", fmt.Errorf("team %d not found", tID))
|
||||
return
|
||||
}
|
||||
|
||||
if team.OrgID != org.ID {
|
||||
ctx.Error(http.StatusForbidden, "team", fmt.Errorf("team %d belongs not to org %d", tID, org.ID))
|
||||
return
|
||||
}
|
||||
|
||||
teams = append(teams, team)
|
||||
}
|
||||
}
|
||||
|
||||
if err = repo_service.TransferOwnership(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil {
|
||||
ctx.InternalServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
newRepo, err := models.GetRepositoryByName(newOwner.ID, ctx.Repo.Repository.Name)
|
||||
if err != nil {
|
||||
ctx.InternalServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name)
|
||||
ctx.JSON(http.StatusAccepted, newRepo.APIFormat(models.AccessModeAdmin))
|
||||
}
|
|
@ -84,6 +84,8 @@ type swaggerParameterBodies struct {
|
|||
// in:body
|
||||
EditRepoOption api.EditRepoOption
|
||||
// in:body
|
||||
TransferRepoOption api.TransferRepoOption
|
||||
// in:body
|
||||
CreateForkOption api.CreateForkOption
|
||||
|
||||
// in:body
|
||||
|
|
|
@ -369,14 +369,14 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
|
|||
return
|
||||
}
|
||||
|
||||
newOwner := ctx.Query("new_owner_name")
|
||||
isExist, err := models.IsUserExist(0, newOwner)
|
||||
newOwner, err := models.GetUserByName(ctx.Query("new_owner_name"))
|
||||
if err != nil {
|
||||
if models.IsErrUserNotExist(err) {
|
||||
ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("IsUserExist", err)
|
||||
return
|
||||
} else if !isExist {
|
||||
ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Close the GitRepo if open
|
||||
|
@ -384,7 +384,7 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
|
|||
ctx.Repo.GitRepo.Close()
|
||||
ctx.Repo.GitRepo = nil
|
||||
}
|
||||
if err = repo_service.TransferOwnership(ctx.User, newOwner, repo); err != nil {
|
||||
if err = repo_service.TransferOwnership(ctx.User, newOwner, repo, nil); err != nil {
|
||||
if models.IsErrRepoAlreadyExist(err) {
|
||||
ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil)
|
||||
} else {
|
||||
|
@ -395,7 +395,7 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
|
|||
|
||||
log.Trace("Repository transferred: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner)
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed"))
|
||||
ctx.Redirect(setting.AppSubURL + "/" + newOwner + "/" + repo.Name)
|
||||
ctx.Redirect(setting.AppSubURL + "/" + newOwner.Name + "/" + repo.Name)
|
||||
|
||||
case "delete":
|
||||
if !ctx.Repo.IsOwner() {
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/modules/notification"
|
||||
"code.gitea.io/gitea/modules/sync"
|
||||
|
@ -16,20 +18,36 @@ import (
|
|||
var repoWorkingPool = sync.NewExclusivePool()
|
||||
|
||||
// TransferOwnership transfers all corresponding setting from old user to new one.
|
||||
func TransferOwnership(doer *models.User, newOwnerName string, repo *models.Repository) error {
|
||||
func TransferOwnership(doer, newOwner *models.User, repo *models.Repository, teams []*models.Team) error {
|
||||
if err := repo.GetOwner(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, team := range teams {
|
||||
if newOwner.ID != team.OrgID {
|
||||
return fmt.Errorf("team %d does not belong to organization", team.ID)
|
||||
}
|
||||
}
|
||||
|
||||
oldOwner := repo.Owner
|
||||
|
||||
repoWorkingPool.CheckIn(com.ToStr(repo.ID))
|
||||
if err := models.TransferOwnership(doer, newOwnerName, repo); err != nil {
|
||||
if err := models.TransferOwnership(doer, newOwner.Name, repo); err != nil {
|
||||
repoWorkingPool.CheckOut(com.ToStr(repo.ID))
|
||||
return err
|
||||
}
|
||||
repoWorkingPool.CheckOut(com.ToStr(repo.ID))
|
||||
|
||||
newRepo, err := models.GetRepositoryByID(repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
if err := team.AddRepository(newRepo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
notification.NotifyTransferRepository(doer, repo, oldOwner.Name)
|
||||
|
||||
return nil
|
||||
|
|
|
@ -32,7 +32,7 @@ func TestTransferOwnership(t *testing.T) {
|
|||
doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
|
||||
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
|
||||
repo.Owner = models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
|
||||
assert.NoError(t, TransferOwnership(doer, "user2", repo))
|
||||
assert.NoError(t, TransferOwnership(doer, doer, repo, nil))
|
||||
|
||||
transferredRepo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
|
||||
assert.EqualValues(t, 2, transferredRepo.OwnerID)
|
||||
|
|
|
@ -7321,6 +7321,57 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/transfer": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Transfer a repo ownership",
|
||||
"operationId": "repoTransfer",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo to transfer",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repo to transfer",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Transfer Options",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/TransferRepoOption"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"$ref": "#/responses/Repository"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/responses/validationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repositories/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
@ -12580,6 +12631,29 @@
|
|||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"TransferRepoOption": {
|
||||
"description": "TransferRepoOption options when transfer a repository's ownership",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"new_owner"
|
||||
],
|
||||
"properties": {
|
||||
"new_owner": {
|
||||
"type": "string",
|
||||
"x-go-name": "NewOwner"
|
||||
},
|
||||
"team_ids": {
|
||||
"description": "ID of the team or teams to add to the repository. Teams can only be added to organization-owned repositories.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-go-name": "TeamIDs"
|
||||
}
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"UpdateFileOptions": {
|
||||
"description": "UpdateFileOptions options for updating files\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)",
|
||||
"type": "object",
|
||||
|
|
Reference in a new issue