* squash * optimize * fail before make any changes * fix-headertags/v1.21.12.1
| @@ -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) | ||||
| @@ -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,14 +369,14 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) { | |||||
| return | 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 err != nil { | ||||
| if models.IsErrUserNotExist(err) { | |||||
| ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) | |||||
| return | |||||
| } | |||||
| ctx.ServerError("IsUserExist", err) | ctx.ServerError("IsUserExist", err) | ||||
| return | return | ||||
| } else if !isExist { | |||||
| ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) | |||||
| return | |||||
| } | } | ||||
| // Close the GitRepo if open | // Close the GitRepo if open | ||||
| @@ -384,7 +384,7 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) { | |||||
| 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", | ||||