Skip to content

Commit 13bc820

Browse files
authored
API endpoint for repo transfer (#9947)
* squash * optimize * fail before make any changes * fix-header
1 parent d816f70 commit 13bc820

File tree

9 files changed

+265
-10
lines changed

9 files changed

+265
-10
lines changed

integrations/api_repo_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,54 @@ func testAPIRepoCreateConflict(t *testing.T, u *url.URL) {
392392
assert.Equal(t, respJSON["message"], "The repository with the same name already exists.")
393393
})
394394
}
395+
396+
func TestAPIRepoTransfer(t *testing.T) {
397+
testCases := []struct {
398+
ctxUserID int64
399+
newOwner string
400+
teams *[]int64
401+
expectedStatus int
402+
}{
403+
{ctxUserID: 1, newOwner: "user2", teams: nil, expectedStatus: http.StatusAccepted},
404+
{ctxUserID: 2, newOwner: "user1", teams: nil, expectedStatus: http.StatusAccepted},
405+
{ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusForbidden},
406+
{ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity},
407+
{ctxUserID: 1, newOwner: "user3", teams: &[]int64{5}, expectedStatus: http.StatusForbidden},
408+
{ctxUserID: 1, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted},
409+
}
410+
411+
defer prepareTestEnv(t)()
412+
413+
//create repo to move
414+
user := models.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User)
415+
session := loginUser(t, user.Name)
416+
token := getTokenForLoggedInUser(t, session)
417+
repoName := "moveME"
418+
repo := new(models.Repository)
419+
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/repos?token=%s", token), &api.CreateRepoOption{
420+
Name: repoName,
421+
Description: "repo move around",
422+
Private: false,
423+
Readme: "Default",
424+
AutoInit: true,
425+
})
426+
resp := session.MakeRequest(t, req, http.StatusCreated)
427+
DecodeJSON(t, resp, repo)
428+
429+
//start testing
430+
for _, testCase := range testCases {
431+
user = models.AssertExistsAndLoadBean(t, &models.User{ID: testCase.ctxUserID}).(*models.User)
432+
repo = models.AssertExistsAndLoadBean(t, &models.Repository{ID: repo.ID}).(*models.Repository)
433+
session = loginUser(t, user.Name)
434+
token = getTokenForLoggedInUser(t, session)
435+
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer?token=%s", repo.OwnerName, repo.Name, token), &api.TransferRepoOption{
436+
NewOwner: testCase.newOwner,
437+
TeamIDs: testCase.teams,
438+
})
439+
session.MakeRequest(t, req, testCase.expectedStatus)
440+
}
441+
442+
//cleanup
443+
repo = models.AssertExistsAndLoadBean(t, &models.Repository{ID: repo.ID}).(*models.Repository)
444+
_ = models.DeleteRepository(user, repo.OwnerID, repo.ID)
445+
}

modules/structs/repo.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,15 @@ type EditRepoOption struct {
158158
Archived *bool `json:"archived,omitempty"`
159159
}
160160

161+
// TransferRepoOption options when transfer a repository's ownership
162+
// swagger:model
163+
type TransferRepoOption struct {
164+
// required: true
165+
NewOwner string `json:"new_owner"`
166+
// ID of the team or teams to add to the repository. Teams can only be added to organization-owned repositories.
167+
TeamIDs *[]int64 `json:"team_ids"`
168+
}
169+
161170
// GitServiceType represents a git service
162171
type GitServiceType int
163172

routers/api/v1/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ func RegisterRoutes(m *macaron.Macaron) {
620620
m.Combo("").Get(reqAnyRepoReader(), repo.Get).
621621
Delete(reqToken(), reqOwner(), repo.Delete).
622622
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), context.RepoRef(), repo.Edit)
623+
m.Post("/transfer", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer)
623624
m.Combo("/notifications").
624625
Get(reqToken(), notify.ListRepoNotifications).
625626
Put(reqToken(), notify.ReadRepoNotifications)

routers/api/v1/repo/transfer.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2020 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package repo
6+
7+
import (
8+
"fmt"
9+
"net/http"
10+
11+
"code.gitea.io/gitea/models"
12+
"code.gitea.io/gitea/modules/context"
13+
"code.gitea.io/gitea/modules/convert"
14+
"code.gitea.io/gitea/modules/log"
15+
api "code.gitea.io/gitea/modules/structs"
16+
repo_service "code.gitea.io/gitea/services/repository"
17+
)
18+
19+
// Transfer transfers the ownership of a repository
20+
func Transfer(ctx *context.APIContext, opts api.TransferRepoOption) {
21+
// swagger:operation POST /repos/{owner}/{repo}/transfer repository repoTransfer
22+
// ---
23+
// summary: Transfer a repo ownership
24+
// produces:
25+
// - application/json
26+
// parameters:
27+
// - name: owner
28+
// in: path
29+
// description: owner of the repo to transfer
30+
// type: string
31+
// required: true
32+
// - name: repo
33+
// in: path
34+
// description: name of the repo to transfer
35+
// type: string
36+
// required: true
37+
// - name: body
38+
// in: body
39+
// description: "Transfer Options"
40+
// required: true
41+
// schema:
42+
// "$ref": "#/definitions/TransferRepoOption"
43+
// responses:
44+
// "202":
45+
// "$ref": "#/responses/Repository"
46+
// "403":
47+
// "$ref": "#/responses/forbidden"
48+
// "404":
49+
// "$ref": "#/responses/notFound"
50+
// "422":
51+
// "$ref": "#/responses/validationError"
52+
53+
newOwner, err := models.GetUserByName(opts.NewOwner)
54+
if err != nil {
55+
if models.IsErrUserNotExist(err) {
56+
ctx.Error(http.StatusNotFound, "GetUserByName", err)
57+
return
58+
}
59+
ctx.InternalServerError(err)
60+
return
61+
}
62+
63+
var teams []*models.Team
64+
if opts.TeamIDs != nil {
65+
if !newOwner.IsOrganization() {
66+
ctx.Error(http.StatusUnprocessableEntity, "repoTransfer", "Teams can only be added to organization-owned repositories")
67+
return
68+
}
69+
70+
org := convert.ToOrganization(newOwner)
71+
for _, tID := range *opts.TeamIDs {
72+
team, err := models.GetTeamByID(tID)
73+
if err != nil {
74+
ctx.Error(http.StatusUnprocessableEntity, "team", fmt.Errorf("team %d not found", tID))
75+
return
76+
}
77+
78+
if team.OrgID != org.ID {
79+
ctx.Error(http.StatusForbidden, "team", fmt.Errorf("team %d belongs not to org %d", tID, org.ID))
80+
return
81+
}
82+
83+
teams = append(teams, team)
84+
}
85+
}
86+
87+
if err = repo_service.TransferOwnership(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil {
88+
ctx.InternalServerError(err)
89+
return
90+
}
91+
92+
newRepo, err := models.GetRepositoryByName(newOwner.ID, ctx.Repo.Repository.Name)
93+
if err != nil {
94+
ctx.InternalServerError(err)
95+
return
96+
}
97+
98+
log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name)
99+
ctx.JSON(http.StatusAccepted, newRepo.APIFormat(models.AccessModeAdmin))
100+
}

routers/api/v1/swagger/options.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ type swaggerParameterBodies struct {
8484
// in:body
8585
EditRepoOption api.EditRepoOption
8686
// in:body
87+
TransferRepoOption api.TransferRepoOption
88+
// in:body
8789
CreateForkOption api.CreateForkOption
8890

8991
// in:body

routers/repo/setting.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -369,22 +369,22 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
369369
return
370370
}
371371

372-
newOwner := ctx.Query("new_owner_name")
373-
isExist, err := models.IsUserExist(0, newOwner)
372+
newOwner, err := models.GetUserByName(ctx.Query("new_owner_name"))
374373
if err != nil {
374+
if models.IsErrUserNotExist(err) {
375+
ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil)
376+
return
377+
}
375378
ctx.ServerError("IsUserExist", err)
376379
return
377-
} else if !isExist {
378-
ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil)
379-
return
380380
}
381381

382382
// Close the GitRepo if open
383383
if ctx.Repo.GitRepo != nil {
384384
ctx.Repo.GitRepo.Close()
385385
ctx.Repo.GitRepo = nil
386386
}
387-
if err = repo_service.TransferOwnership(ctx.User, newOwner, repo); err != nil {
387+
if err = repo_service.TransferOwnership(ctx.User, newOwner, repo, nil); err != nil {
388388
if models.IsErrRepoAlreadyExist(err) {
389389
ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil)
390390
} else {
@@ -395,7 +395,7 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
395395

396396
log.Trace("Repository transferred: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner)
397397
ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed"))
398-
ctx.Redirect(setting.AppSubURL + "/" + newOwner + "/" + repo.Name)
398+
ctx.Redirect(setting.AppSubURL + "/" + newOwner.Name + "/" + repo.Name)
399399

400400
case "delete":
401401
if !ctx.Repo.IsOwner() {

services/repository/transfer.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package repository
66

77
import (
8+
"fmt"
9+
810
"code.gitea.io/gitea/models"
911
"code.gitea.io/gitea/modules/notification"
1012
"code.gitea.io/gitea/modules/sync"
@@ -16,20 +18,36 @@ import (
1618
var repoWorkingPool = sync.NewExclusivePool()
1719

1820
// TransferOwnership transfers all corresponding setting from old user to new one.
19-
func TransferOwnership(doer *models.User, newOwnerName string, repo *models.Repository) error {
21+
func TransferOwnership(doer, newOwner *models.User, repo *models.Repository, teams []*models.Team) error {
2022
if err := repo.GetOwner(); err != nil {
2123
return err
2224
}
25+
for _, team := range teams {
26+
if newOwner.ID != team.OrgID {
27+
return fmt.Errorf("team %d does not belong to organization", team.ID)
28+
}
29+
}
2330

2431
oldOwner := repo.Owner
2532

2633
repoWorkingPool.CheckIn(com.ToStr(repo.ID))
27-
if err := models.TransferOwnership(doer, newOwnerName, repo); err != nil {
34+
if err := models.TransferOwnership(doer, newOwner.Name, repo); err != nil {
2835
repoWorkingPool.CheckOut(com.ToStr(repo.ID))
2936
return err
3037
}
3138
repoWorkingPool.CheckOut(com.ToStr(repo.ID))
3239

40+
newRepo, err := models.GetRepositoryByID(repo.ID)
41+
if err != nil {
42+
return err
43+
}
44+
45+
for _, team := range teams {
46+
if err := team.AddRepository(newRepo); err != nil {
47+
return err
48+
}
49+
}
50+
3351
notification.NotifyTransferRepository(doer, repo, oldOwner.Name)
3452

3553
return nil

services/repository/transfer_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestTransferOwnership(t *testing.T) {
3232
doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
3333
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
3434
repo.Owner = models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
35-
assert.NoError(t, TransferOwnership(doer, "user2", repo))
35+
assert.NoError(t, TransferOwnership(doer, doer, repo, nil))
3636

3737
transferredRepo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
3838
assert.EqualValues(t, 2, transferredRepo.OwnerID)

templates/swagger/v1_json.tmpl

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7321,6 +7321,57 @@
73217321
}
73227322
}
73237323
},
7324+
"/repos/{owner}/{repo}/transfer": {
7325+
"post": {
7326+
"produces": [
7327+
"application/json"
7328+
],
7329+
"tags": [
7330+
"repository"
7331+
],
7332+
"summary": "Transfer a repo ownership",
7333+
"operationId": "repoTransfer",
7334+
"parameters": [
7335+
{
7336+
"type": "string",
7337+
"description": "owner of the repo to transfer",
7338+
"name": "owner",
7339+
"in": "path",
7340+
"required": true
7341+
},
7342+
{
7343+
"type": "string",
7344+
"description": "name of the repo to transfer",
7345+
"name": "repo",
7346+
"in": "path",
7347+
"required": true
7348+
},
7349+
{
7350+
"description": "Transfer Options",
7351+
"name": "body",
7352+
"in": "body",
7353+
"required": true,
7354+
"schema": {
7355+
"$ref": "#/definitions/TransferRepoOption"
7356+
}
7357+
}
7358+
],
7359+
"responses": {
7360+
"202": {
7361+
"$ref": "#/responses/Repository"
7362+
},
7363+
"403": {
7364+
"$ref": "#/responses/forbidden"
7365+
},
7366+
"404": {
7367+
"$ref": "#/responses/notFound"
7368+
},
7369+
"422": {
7370+
"$ref": "#/responses/validationError"
7371+
}
7372+
}
7373+
}
7374+
},
73247375
"/repositories/{id}": {
73257376
"get": {
73267377
"produces": [
@@ -12580,6 +12631,29 @@
1258012631
},
1258112632
"x-go-package": "code.gitea.io/gitea/modules/structs"
1258212633
},
12634+
"TransferRepoOption": {
12635+
"description": "TransferRepoOption options when transfer a repository's ownership",
12636+
"type": "object",
12637+
"required": [
12638+
"new_owner"
12639+
],
12640+
"properties": {
12641+
"new_owner": {
12642+
"type": "string",
12643+
"x-go-name": "NewOwner"
12644+
},
12645+
"team_ids": {
12646+
"description": "ID of the team or teams to add to the repository. Teams can only be added to organization-owned repositories.",
12647+
"type": "array",
12648+
"items": {
12649+
"type": "integer",
12650+
"format": "int64"
12651+
},
12652+
"x-go-name": "TeamIDs"
12653+
}
12654+
},
12655+
"x-go-package": "code.gitea.io/gitea/modules/structs"
12656+
},
1258312657
"UpdateFileOptions": {
1258412658
"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)",
1258512659
"type": "object",

0 commit comments

Comments
 (0)