Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API endpoint for repo transfer #9947

Merged
merged 6 commits into from
Jan 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions integrations/api_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
9 changes: 9 additions & 0 deletions modules/structs/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
100 changes: 100 additions & 0 deletions routers/api/v1/repo/transfer.go
Original file line number Diff line number Diff line change
@@ -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))
}
2 changes: 2 additions & 0 deletions routers/api/v1/swagger/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ type swaggerParameterBodies struct {
// in:body
EditRepoOption api.EditRepoOption
// in:body
TransferRepoOption api.TransferRepoOption
// in:body
CreateForkOption api.CreateForkOption

// in:body
Expand Down
14 changes: 7 additions & 7 deletions routers/repo/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,22 +369,22 @@ 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
if ctx.Repo.GitRepo != nil {
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 {
Expand All @@ -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() {
Expand Down
22 changes: 20 additions & 2 deletions services/repository/transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion services/repository/transfer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions templates/swagger/v1_json.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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",
Expand Down