mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 19:06:18 +01:00 
			
		
		
		
	Add API to manage repo tranfers (#17963)
This commit is contained in:
		| @@ -498,6 +498,85 @@ func TestAPIRepoTransfer(t *testing.T) { | |||||||
| 	_ = models.DeleteRepository(user, repo.OwnerID, repo.ID) | 	_ = models.DeleteRepository(user, repo.OwnerID, repo.ID) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func transfer(t *testing.T) *repo_model.Repository { | ||||||
|  | 	//create repo to move | ||||||
|  | 	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) | ||||||
|  | 	session := loginUser(t, user.Name) | ||||||
|  | 	token := getTokenForLoggedInUser(t, session) | ||||||
|  | 	repoName := "moveME" | ||||||
|  | 	apiRepo := new(api.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, apiRepo) | ||||||
|  |  | ||||||
|  | 	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}).(*repo_model.Repository) | ||||||
|  | 	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer?token=%s", repo.OwnerName, repo.Name, token), &api.TransferRepoOption{ | ||||||
|  | 		NewOwner: "user4", | ||||||
|  | 	}) | ||||||
|  | 	session.MakeRequest(t, req, http.StatusCreated) | ||||||
|  |  | ||||||
|  | 	return repo | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestAPIAcceptTransfer(t *testing.T) { | ||||||
|  | 	defer prepareTestEnv(t)() | ||||||
|  |  | ||||||
|  | 	repo := transfer(t) | ||||||
|  |  | ||||||
|  | 	// try to accept with not authorized user | ||||||
|  | 	session := loginUser(t, "user2") | ||||||
|  | 	token := getTokenForLoggedInUser(t, session) | ||||||
|  | 	req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token)) | ||||||
|  | 	session.MakeRequest(t, req, http.StatusForbidden) | ||||||
|  |  | ||||||
|  | 	// try to accept repo that's not marked as transferred | ||||||
|  | 	req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept?token=%s", "user2", "repo1", token)) | ||||||
|  | 	session.MakeRequest(t, req, http.StatusNotFound) | ||||||
|  |  | ||||||
|  | 	// accept transfer | ||||||
|  | 	session = loginUser(t, "user4") | ||||||
|  | 	token = getTokenForLoggedInUser(t, session) | ||||||
|  |  | ||||||
|  | 	req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept?token=%s", repo.OwnerName, repo.Name, token)) | ||||||
|  | 	resp := session.MakeRequest(t, req, http.StatusAccepted) | ||||||
|  | 	apiRepo := new(api.Repository) | ||||||
|  | 	DecodeJSON(t, resp, apiRepo) | ||||||
|  | 	assert.Equal(t, "user4", apiRepo.Owner.UserName) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestAPIRejectTransfer(t *testing.T) { | ||||||
|  | 	defer prepareTestEnv(t)() | ||||||
|  |  | ||||||
|  | 	repo := transfer(t) | ||||||
|  |  | ||||||
|  | 	// try to reject with not authorized user | ||||||
|  | 	session := loginUser(t, "user2") | ||||||
|  | 	token := getTokenForLoggedInUser(t, session) | ||||||
|  | 	req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token)) | ||||||
|  | 	session.MakeRequest(t, req, http.StatusForbidden) | ||||||
|  |  | ||||||
|  | 	// try to reject repo that's not marked as transferred | ||||||
|  | 	req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", "user2", "repo1", token)) | ||||||
|  | 	session.MakeRequest(t, req, http.StatusNotFound) | ||||||
|  |  | ||||||
|  | 	// reject transfer | ||||||
|  | 	session = loginUser(t, "user4") | ||||||
|  | 	token = getTokenForLoggedInUser(t, session) | ||||||
|  |  | ||||||
|  | 	req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token)) | ||||||
|  | 	resp := session.MakeRequest(t, req, http.StatusOK) | ||||||
|  | 	apiRepo := new(api.Repository) | ||||||
|  | 	DecodeJSON(t, resp, apiRepo) | ||||||
|  | 	assert.Equal(t, "user2", apiRepo.Owner.UserName) | ||||||
|  | } | ||||||
|  |  | ||||||
| func TestAPIGenerateRepo(t *testing.T) { | func TestAPIGenerateRepo(t *testing.T) { | ||||||
| 	defer prepareTestEnv(t)() | 	defer prepareTestEnv(t)() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ import ( | |||||||
| 	"code.gitea.io/gitea/models/perm" | 	"code.gitea.io/gitea/models/perm" | ||||||
| 	repo_model "code.gitea.io/gitea/models/repo" | 	repo_model "code.gitea.io/gitea/models/repo" | ||||||
| 	unit_model "code.gitea.io/gitea/models/unit" | 	unit_model "code.gitea.io/gitea/models/unit" | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
| 	api "code.gitea.io/gitea/modules/structs" | 	api "code.gitea.io/gitea/modules/structs" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -106,6 +107,20 @@ func innerToRepo(repo *repo_model.Repository, mode perm.AccessMode, isParent boo | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	var transfer *api.RepoTransfer | ||||||
|  | 	if repo.Status == repo_model.RepositoryPendingTransfer { | ||||||
|  | 		t, err := models.GetPendingRepositoryTransfer(repo) | ||||||
|  | 		if err != nil && !models.IsErrNoPendingTransfer(err) { | ||||||
|  | 			log.Warn("GetPendingRepositoryTransfer: %v", err) | ||||||
|  | 		} else { | ||||||
|  | 			if err := t.LoadAttributes(); err != nil { | ||||||
|  | 				log.Warn("LoadAttributes of RepoTransfer: %v", err) | ||||||
|  | 			} else { | ||||||
|  | 				transfer = ToRepoTransfer(t) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	return &api.Repository{ | 	return &api.Repository{ | ||||||
| 		ID:                        repo.ID, | 		ID:                        repo.ID, | ||||||
| 		Owner:                     ToUserWithAccessMode(repo.Owner, mode), | 		Owner:                     ToUserWithAccessMode(repo.Owner, mode), | ||||||
| @@ -151,5 +166,20 @@ func innerToRepo(repo *repo_model.Repository, mode perm.AccessMode, isParent boo | |||||||
| 		AvatarURL:                 repo.AvatarLink(), | 		AvatarURL:                 repo.AvatarLink(), | ||||||
| 		Internal:                  !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, | 		Internal:                  !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, | ||||||
| 		MirrorInterval:            mirrorInterval, | 		MirrorInterval:            mirrorInterval, | ||||||
|  | 		RepoTransfer:              transfer, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ToRepoTransfer convert a models.RepoTransfer to a structs.RepeTransfer | ||||||
|  | func ToRepoTransfer(t *models.RepoTransfer) *api.RepoTransfer { | ||||||
|  | 	var teams []*api.Team | ||||||
|  | 	for _, v := range t.Teams { | ||||||
|  | 		teams = append(teams, ToTeam(v)) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &api.RepoTransfer{ | ||||||
|  | 		Doer:      ToUser(t.Doer, nil), | ||||||
|  | 		Recipient: ToUser(t.Recipient, nil), | ||||||
|  | 		Teams:     teams, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -93,6 +93,7 @@ type Repository struct { | |||||||
| 	AvatarURL                 string           `json:"avatar_url"` | 	AvatarURL                 string           `json:"avatar_url"` | ||||||
| 	Internal                  bool             `json:"internal"` | 	Internal                  bool             `json:"internal"` | ||||||
| 	MirrorInterval            string           `json:"mirror_interval"` | 	MirrorInterval            string           `json:"mirror_interval"` | ||||||
|  | 	RepoTransfer              *RepoTransfer    `json:"repo_transfer"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // CreateRepoOption options when creating repository | // CreateRepoOption options when creating repository | ||||||
| @@ -336,3 +337,10 @@ var ( | |||||||
| 		CodebaseService, | 		CodebaseService, | ||||||
| 	} | 	} | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | // RepoTransfer represents a pending repo transfer | ||||||
|  | type RepoTransfer struct { | ||||||
|  | 	Doer      *User   `json:"doer"` | ||||||
|  | 	Recipient *User   `json:"recipient"` | ||||||
|  | 	Teams     []*Team `json:"teams"` | ||||||
|  | } | ||||||
|   | |||||||
| @@ -736,6 +736,8 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route { | |||||||
| 					Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) | 					Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) | ||||||
| 				m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) | 				m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) | ||||||
| 				m.Post("/transfer", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer) | 				m.Post("/transfer", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer) | ||||||
|  | 				m.Post("/transfer/accept", reqToken(), repo.AcceptTransfer) | ||||||
|  | 				m.Post("/transfer/reject", reqToken(), repo.RejectTransfer) | ||||||
| 				m.Combo("/notifications"). | 				m.Combo("/notifications"). | ||||||
| 					Get(reqToken(), notify.ListRepoNotifications). | 					Get(reqToken(), notify.ListRepoNotifications). | ||||||
| 					Put(reqToken(), notify.ReadRepoNotifications) | 					Put(reqToken(), notify.ReadRepoNotifications) | ||||||
|   | |||||||
| @@ -127,3 +127,105 @@ func Transfer(ctx *context.APIContext) { | |||||||
| 	log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) | 	log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) | ||||||
| 	ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, perm.AccessModeAdmin)) | 	ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, perm.AccessModeAdmin)) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // AcceptTransfer accept a repo transfer | ||||||
|  | func AcceptTransfer(ctx *context.APIContext) { | ||||||
|  | 	// swagger:operation POST /repos/{owner}/{repo}/transfer/accept repository acceptRepoTransfer | ||||||
|  | 	// --- | ||||||
|  | 	// summary: Accept a repo transfer | ||||||
|  | 	// 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 | ||||||
|  | 	// responses: | ||||||
|  | 	//   "202": | ||||||
|  | 	//     "$ref": "#/responses/Repository" | ||||||
|  | 	//   "403": | ||||||
|  | 	//     "$ref": "#/responses/forbidden" | ||||||
|  | 	//   "404": | ||||||
|  | 	//     "$ref": "#/responses/notFound" | ||||||
|  |  | ||||||
|  | 	err := acceptOrRejectRepoTransfer(ctx, true) | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, ctx.Repo.AccessMode)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // RejectTransfer reject a repo transfer | ||||||
|  | func RejectTransfer(ctx *context.APIContext) { | ||||||
|  | 	// swagger:operation POST /repos/{owner}/{repo}/transfer/reject repository rejectRepoTransfer | ||||||
|  | 	// --- | ||||||
|  | 	// summary: Reject a repo transfer | ||||||
|  | 	// 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 | ||||||
|  | 	// responses: | ||||||
|  | 	//   "200": | ||||||
|  | 	//     "$ref": "#/responses/Repository" | ||||||
|  | 	//   "403": | ||||||
|  | 	//     "$ref": "#/responses/forbidden" | ||||||
|  | 	//   "404": | ||||||
|  | 	//     "$ref": "#/responses/notFound" | ||||||
|  |  | ||||||
|  | 	err := acceptOrRejectRepoTransfer(ctx, false) | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	ctx.JSON(http.StatusOK, convert.ToRepo(ctx.Repo.Repository, ctx.Repo.AccessMode)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error { | ||||||
|  | 	repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) | ||||||
|  | 	if err != nil { | ||||||
|  | 		if models.IsErrNoPendingTransfer(err) { | ||||||
|  | 			ctx.NotFound() | ||||||
|  | 			return nil | ||||||
|  | 		} | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := repoTransfer.LoadAttributes(); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if !repoTransfer.CanUserAcceptTransfer(ctx.User) { | ||||||
|  | 		ctx.Error(http.StatusForbidden, "CanUserAcceptTransfer", nil) | ||||||
|  | 		return fmt.Errorf("user does not have permissions to do this") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if accept { | ||||||
|  | 		return repo_service.TransferOwnership(repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return models.CancelRepositoryTransfer(ctx.Repo.Repository) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -9895,6 +9895,84 @@ | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "/repos/{owner}/{repo}/transfer/accept": { | ||||||
|  |       "post": { | ||||||
|  |         "produces": [ | ||||||
|  |           "application/json" | ||||||
|  |         ], | ||||||
|  |         "tags": [ | ||||||
|  |           "repository" | ||||||
|  |         ], | ||||||
|  |         "summary": "Accept a repo transfer", | ||||||
|  |         "operationId": "acceptRepoTransfer", | ||||||
|  |         "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 | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "responses": { | ||||||
|  |           "202": { | ||||||
|  |             "$ref": "#/responses/Repository" | ||||||
|  |           }, | ||||||
|  |           "403": { | ||||||
|  |             "$ref": "#/responses/forbidden" | ||||||
|  |           }, | ||||||
|  |           "404": { | ||||||
|  |             "$ref": "#/responses/notFound" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "/repos/{owner}/{repo}/transfer/reject": { | ||||||
|  |       "post": { | ||||||
|  |         "produces": [ | ||||||
|  |           "application/json" | ||||||
|  |         ], | ||||||
|  |         "tags": [ | ||||||
|  |           "repository" | ||||||
|  |         ], | ||||||
|  |         "summary": "Reject a repo transfer", | ||||||
|  |         "operationId": "rejectRepoTransfer", | ||||||
|  |         "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 | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "responses": { | ||||||
|  |           "200": { | ||||||
|  |             "$ref": "#/responses/Repository" | ||||||
|  |           }, | ||||||
|  |           "403": { | ||||||
|  |             "$ref": "#/responses/forbidden" | ||||||
|  |           }, | ||||||
|  |           "404": { | ||||||
|  |             "$ref": "#/responses/notFound" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "/repos/{owner}/{repo}/wiki/new": { |     "/repos/{owner}/{repo}/wiki/new": { | ||||||
|       "post": { |       "post": { | ||||||
|         "consumes": [ |         "consumes": [ | ||||||
| @@ -16890,6 +16968,26 @@ | |||||||
|       }, |       }, | ||||||
|       "x-go-package": "code.gitea.io/gitea/modules/structs" |       "x-go-package": "code.gitea.io/gitea/modules/structs" | ||||||
|     }, |     }, | ||||||
|  |     "RepoTransfer": { | ||||||
|  |       "description": "RepoTransfer represents a pending repo transfer", | ||||||
|  |       "type": "object", | ||||||
|  |       "properties": { | ||||||
|  |         "doer": { | ||||||
|  |           "$ref": "#/definitions/User" | ||||||
|  |         }, | ||||||
|  |         "recipient": { | ||||||
|  |           "$ref": "#/definitions/User" | ||||||
|  |         }, | ||||||
|  |         "teams": { | ||||||
|  |           "type": "array", | ||||||
|  |           "items": { | ||||||
|  |             "$ref": "#/definitions/Team" | ||||||
|  |           }, | ||||||
|  |           "x-go-name": "Teams" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "x-go-package": "code.gitea.io/gitea/modules/structs" | ||||||
|  |     }, | ||||||
|     "Repository": { |     "Repository": { | ||||||
|       "description": "Repository represents a repository", |       "description": "Repository represents a repository", | ||||||
|       "type": "object", |       "type": "object", | ||||||
| @@ -17042,6 +17140,9 @@ | |||||||
|           "format": "int64", |           "format": "int64", | ||||||
|           "x-go-name": "Releases" |           "x-go-name": "Releases" | ||||||
|         }, |         }, | ||||||
|  |         "repo_transfer": { | ||||||
|  |           "$ref": "#/definitions/RepoTransfer" | ||||||
|  |         }, | ||||||
|         "size": { |         "size": { | ||||||
|           "type": "integer", |           "type": "integer", | ||||||
|           "format": "int64", |           "format": "int64", | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user