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.")
|
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"`
|
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
|
// GitServiceType represents a git service
|
||||||
type GitServiceType int
|
type GitServiceType int
|
||||||
|
|
||||||
|
|
|
@ -620,6 +620,7 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
m.Combo("").Get(reqAnyRepoReader(), repo.Get).
|
m.Combo("").Get(reqAnyRepoReader(), repo.Get).
|
||||||
Delete(reqToken(), reqOwner(), repo.Delete).
|
Delete(reqToken(), reqOwner(), repo.Delete).
|
||||||
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), context.RepoRef(), repo.Edit)
|
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), context.RepoRef(), repo.Edit)
|
||||||
|
m.Post("/transfer", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer)
|
||||||
m.Combo("/notifications").
|
m.Combo("/notifications").
|
||||||
Get(reqToken(), notify.ListRepoNotifications).
|
Get(reqToken(), notify.ListRepoNotifications).
|
||||||
Put(reqToken(), notify.ReadRepoNotifications)
|
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
|
// in:body
|
||||||
EditRepoOption api.EditRepoOption
|
EditRepoOption api.EditRepoOption
|
||||||
// in:body
|
// in:body
|
||||||
|
TransferRepoOption api.TransferRepoOption
|
||||||
|
// in:body
|
||||||
CreateForkOption api.CreateForkOption
|
CreateForkOption api.CreateForkOption
|
||||||
|
|
||||||
// in:body
|
// in:body
|
||||||
|
|
|
@ -369,22 +369,22 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newOwner := ctx.Query("new_owner_name")
|
newOwner, err := models.GetUserByName(ctx.Query("new_owner_name"))
|
||||||
isExist, err := models.IsUserExist(0, newOwner)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("IsUserExist", err)
|
if models.IsErrUserNotExist(err) {
|
||||||
return
|
|
||||||
} else if !isExist {
|
|
||||||
ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil)
|
ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ctx.ServerError("IsUserExist", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Close the GitRepo if open
|
// Close the GitRepo if open
|
||||||
if ctx.Repo.GitRepo != nil {
|
if ctx.Repo.GitRepo != nil {
|
||||||
ctx.Repo.GitRepo.Close()
|
ctx.Repo.GitRepo.Close()
|
||||||
ctx.Repo.GitRepo = nil
|
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) {
|
if models.IsErrRepoAlreadyExist(err) {
|
||||||
ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil)
|
ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil)
|
||||||
} else {
|
} 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)
|
log.Trace("Repository transferred: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner)
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed"))
|
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":
|
case "delete":
|
||||||
if !ctx.Repo.IsOwner() {
|
if !ctx.Repo.IsOwner() {
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models"
|
"code.gitea.io/gitea/models"
|
||||||
"code.gitea.io/gitea/modules/notification"
|
"code.gitea.io/gitea/modules/notification"
|
||||||
"code.gitea.io/gitea/modules/sync"
|
"code.gitea.io/gitea/modules/sync"
|
||||||
|
@ -16,20 +18,36 @@ import (
|
||||||
var repoWorkingPool = sync.NewExclusivePool()
|
var repoWorkingPool = sync.NewExclusivePool()
|
||||||
|
|
||||||
// TransferOwnership transfers all corresponding setting from old user to new one.
|
// 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 {
|
if err := repo.GetOwner(); err != nil {
|
||||||
return err
|
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
|
oldOwner := repo.Owner
|
||||||
|
|
||||||
repoWorkingPool.CheckIn(com.ToStr(repo.ID))
|
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))
|
repoWorkingPool.CheckOut(com.ToStr(repo.ID))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
repoWorkingPool.CheckOut(com.ToStr(repo.ID))
|
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)
|
notification.NotifyTransferRepository(doer, repo, oldOwner.Name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -32,7 +32,7 @@ func TestTransferOwnership(t *testing.T) {
|
||||||
doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
|
doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
|
||||||
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
|
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
|
||||||
repo.Owner = models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
|
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)
|
transferredRepo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
|
||||||
assert.EqualValues(t, 2, transferredRepo.OwnerID)
|
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}": {
|
"/repositories/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -12580,6 +12631,29 @@
|
||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
"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": {
|
"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)",
|
"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",
|
"type": "object",
|
||||||
|
|
Reference in a new issue