mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 19:06:18 +01:00 
			
		
		
		
	API endpoint for repo transfer (#9947)
* squash * optimize * fail before make any changes * fix-header
This commit is contained in:
		| @@ -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,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 { | ||||
| 			ctx.ServerError("IsUserExist", err) | ||||
| 			return | ||||
| 		} else if !isExist { | ||||
| 			if models.IsErrUserNotExist(err) { | ||||
| 				ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) | ||||
| 				return | ||||
| 			} | ||||
| 			ctx.ServerError("IsUserExist", err) | ||||
| 			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 { | ||||
| @@ -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 New Issue
	
	Block a user