Skip to content

Commit 7cc4449

Browse files
authored
Add API to manage repo tranfers (#17963)
1 parent 5754080 commit 7cc4449

File tree

6 files changed

+322
-0
lines changed

6 files changed

+322
-0
lines changed

integrations/api_repo_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,85 @@ func TestAPIRepoTransfer(t *testing.T) {
498498
_ = models.DeleteRepository(user, repo.OwnerID, repo.ID)
499499
}
500500

501+
func transfer(t *testing.T) *repo_model.Repository {
502+
//create repo to move
503+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
504+
session := loginUser(t, user.Name)
505+
token := getTokenForLoggedInUser(t, session)
506+
repoName := "moveME"
507+
apiRepo := new(api.Repository)
508+
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/repos?token=%s", token), &api.CreateRepoOption{
509+
Name: repoName,
510+
Description: "repo move around",
511+
Private: false,
512+
Readme: "Default",
513+
AutoInit: true,
514+
})
515+
516+
resp := session.MakeRequest(t, req, http.StatusCreated)
517+
DecodeJSON(t, resp, apiRepo)
518+
519+
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}).(*repo_model.Repository)
520+
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer?token=%s", repo.OwnerName, repo.Name, token), &api.TransferRepoOption{
521+
NewOwner: "user4",
522+
})
523+
session.MakeRequest(t, req, http.StatusCreated)
524+
525+
return repo
526+
}
527+
528+
func TestAPIAcceptTransfer(t *testing.T) {
529+
defer prepareTestEnv(t)()
530+
531+
repo := transfer(t)
532+
533+
// try to accept with not authorized user
534+
session := loginUser(t, "user2")
535+
token := getTokenForLoggedInUser(t, session)
536+
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token))
537+
session.MakeRequest(t, req, http.StatusForbidden)
538+
539+
// try to accept repo that's not marked as transferred
540+
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept?token=%s", "user2", "repo1", token))
541+
session.MakeRequest(t, req, http.StatusNotFound)
542+
543+
// accept transfer
544+
session = loginUser(t, "user4")
545+
token = getTokenForLoggedInUser(t, session)
546+
547+
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept?token=%s", repo.OwnerName, repo.Name, token))
548+
resp := session.MakeRequest(t, req, http.StatusAccepted)
549+
apiRepo := new(api.Repository)
550+
DecodeJSON(t, resp, apiRepo)
551+
assert.Equal(t, "user4", apiRepo.Owner.UserName)
552+
}
553+
554+
func TestAPIRejectTransfer(t *testing.T) {
555+
defer prepareTestEnv(t)()
556+
557+
repo := transfer(t)
558+
559+
// try to reject with not authorized user
560+
session := loginUser(t, "user2")
561+
token := getTokenForLoggedInUser(t, session)
562+
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token))
563+
session.MakeRequest(t, req, http.StatusForbidden)
564+
565+
// try to reject repo that's not marked as transferred
566+
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", "user2", "repo1", token))
567+
session.MakeRequest(t, req, http.StatusNotFound)
568+
569+
// reject transfer
570+
session = loginUser(t, "user4")
571+
token = getTokenForLoggedInUser(t, session)
572+
573+
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token))
574+
resp := session.MakeRequest(t, req, http.StatusOK)
575+
apiRepo := new(api.Repository)
576+
DecodeJSON(t, resp, apiRepo)
577+
assert.Equal(t, "user2", apiRepo.Owner.UserName)
578+
}
579+
501580
func TestAPIGenerateRepo(t *testing.T) {
502581
defer prepareTestEnv(t)()
503582

modules/convert/repository.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"code.gitea.io/gitea/models/perm"
1111
repo_model "code.gitea.io/gitea/models/repo"
1212
unit_model "code.gitea.io/gitea/models/unit"
13+
"code.gitea.io/gitea/modules/log"
1314
api "code.gitea.io/gitea/modules/structs"
1415
)
1516

@@ -106,6 +107,20 @@ func innerToRepo(repo *repo_model.Repository, mode perm.AccessMode, isParent boo
106107
}
107108
}
108109

110+
var transfer *api.RepoTransfer
111+
if repo.Status == repo_model.RepositoryPendingTransfer {
112+
t, err := models.GetPendingRepositoryTransfer(repo)
113+
if err != nil && !models.IsErrNoPendingTransfer(err) {
114+
log.Warn("GetPendingRepositoryTransfer: %v", err)
115+
} else {
116+
if err := t.LoadAttributes(); err != nil {
117+
log.Warn("LoadAttributes of RepoTransfer: %v", err)
118+
} else {
119+
transfer = ToRepoTransfer(t)
120+
}
121+
}
122+
}
123+
109124
return &api.Repository{
110125
ID: repo.ID,
111126
Owner: ToUserWithAccessMode(repo.Owner, mode),
@@ -151,5 +166,20 @@ func innerToRepo(repo *repo_model.Repository, mode perm.AccessMode, isParent boo
151166
AvatarURL: repo.AvatarLink(),
152167
Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate,
153168
MirrorInterval: mirrorInterval,
169+
RepoTransfer: transfer,
170+
}
171+
}
172+
173+
// ToRepoTransfer convert a models.RepoTransfer to a structs.RepeTransfer
174+
func ToRepoTransfer(t *models.RepoTransfer) *api.RepoTransfer {
175+
var teams []*api.Team
176+
for _, v := range t.Teams {
177+
teams = append(teams, ToTeam(v))
178+
}
179+
180+
return &api.RepoTransfer{
181+
Doer: ToUser(t.Doer, nil),
182+
Recipient: ToUser(t.Recipient, nil),
183+
Teams: teams,
154184
}
155185
}

modules/structs/repo.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ type Repository struct {
9393
AvatarURL string `json:"avatar_url"`
9494
Internal bool `json:"internal"`
9595
MirrorInterval string `json:"mirror_interval"`
96+
RepoTransfer *RepoTransfer `json:"repo_transfer"`
9697
}
9798

9899
// CreateRepoOption options when creating repository
@@ -336,3 +337,10 @@ var (
336337
CodebaseService,
337338
}
338339
)
340+
341+
// RepoTransfer represents a pending repo transfer
342+
type RepoTransfer struct {
343+
Doer *User `json:"doer"`
344+
Recipient *User `json:"recipient"`
345+
Teams []*Team `json:"teams"`
346+
}

routers/api/v1/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,8 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route {
736736
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit)
737737
m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate)
738738
m.Post("/transfer", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer)
739+
m.Post("/transfer/accept", reqToken(), repo.AcceptTransfer)
740+
m.Post("/transfer/reject", reqToken(), repo.RejectTransfer)
739741
m.Combo("/notifications").
740742
Get(reqToken(), notify.ListRepoNotifications).
741743
Put(reqToken(), notify.ReadRepoNotifications)

routers/api/v1/repo/transfer.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,105 @@ func Transfer(ctx *context.APIContext) {
127127
log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name)
128128
ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, perm.AccessModeAdmin))
129129
}
130+
131+
// AcceptTransfer accept a repo transfer
132+
func AcceptTransfer(ctx *context.APIContext) {
133+
// swagger:operation POST /repos/{owner}/{repo}/transfer/accept repository acceptRepoTransfer
134+
// ---
135+
// summary: Accept a repo transfer
136+
// produces:
137+
// - application/json
138+
// parameters:
139+
// - name: owner
140+
// in: path
141+
// description: owner of the repo to transfer
142+
// type: string
143+
// required: true
144+
// - name: repo
145+
// in: path
146+
// description: name of the repo to transfer
147+
// type: string
148+
// required: true
149+
// responses:
150+
// "202":
151+
// "$ref": "#/responses/Repository"
152+
// "403":
153+
// "$ref": "#/responses/forbidden"
154+
// "404":
155+
// "$ref": "#/responses/notFound"
156+
157+
err := acceptOrRejectRepoTransfer(ctx, true)
158+
if ctx.Written() {
159+
return
160+
}
161+
if err != nil {
162+
ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err)
163+
return
164+
}
165+
166+
ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, ctx.Repo.AccessMode))
167+
}
168+
169+
// RejectTransfer reject a repo transfer
170+
func RejectTransfer(ctx *context.APIContext) {
171+
// swagger:operation POST /repos/{owner}/{repo}/transfer/reject repository rejectRepoTransfer
172+
// ---
173+
// summary: Reject a repo transfer
174+
// produces:
175+
// - application/json
176+
// parameters:
177+
// - name: owner
178+
// in: path
179+
// description: owner of the repo to transfer
180+
// type: string
181+
// required: true
182+
// - name: repo
183+
// in: path
184+
// description: name of the repo to transfer
185+
// type: string
186+
// required: true
187+
// responses:
188+
// "200":
189+
// "$ref": "#/responses/Repository"
190+
// "403":
191+
// "$ref": "#/responses/forbidden"
192+
// "404":
193+
// "$ref": "#/responses/notFound"
194+
195+
err := acceptOrRejectRepoTransfer(ctx, false)
196+
if ctx.Written() {
197+
return
198+
}
199+
if err != nil {
200+
ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err)
201+
return
202+
}
203+
204+
ctx.JSON(http.StatusOK, convert.ToRepo(ctx.Repo.Repository, ctx.Repo.AccessMode))
205+
}
206+
207+
func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error {
208+
repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository)
209+
if err != nil {
210+
if models.IsErrNoPendingTransfer(err) {
211+
ctx.NotFound()
212+
return nil
213+
}
214+
return err
215+
}
216+
217+
if err := repoTransfer.LoadAttributes(); err != nil {
218+
return err
219+
}
220+
221+
if !repoTransfer.CanUserAcceptTransfer(ctx.User) {
222+
ctx.Error(http.StatusForbidden, "CanUserAcceptTransfer", nil)
223+
return fmt.Errorf("user does not have permissions to do this")
224+
}
225+
226+
if accept {
227+
return repo_service.TransferOwnership(repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams)
228+
}
229+
230+
return models.CancelRepositoryTransfer(ctx.Repo.Repository)
231+
}

templates/swagger/v1_json.tmpl

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9895,6 +9895,84 @@
98959895
}
98969896
}
98979897
},
9898+
"/repos/{owner}/{repo}/transfer/accept": {
9899+
"post": {
9900+
"produces": [
9901+
"application/json"
9902+
],
9903+
"tags": [
9904+
"repository"
9905+
],
9906+
"summary": "Accept a repo transfer",
9907+
"operationId": "acceptRepoTransfer",
9908+
"parameters": [
9909+
{
9910+
"type": "string",
9911+
"description": "owner of the repo to transfer",
9912+
"name": "owner",
9913+
"in": "path",
9914+
"required": true
9915+
},
9916+
{
9917+
"type": "string",
9918+
"description": "name of the repo to transfer",
9919+
"name": "repo",
9920+
"in": "path",
9921+
"required": true
9922+
}
9923+
],
9924+
"responses": {
9925+
"202": {
9926+
"$ref": "#/responses/Repository"
9927+
},
9928+
"403": {
9929+
"$ref": "#/responses/forbidden"
9930+
},
9931+
"404": {
9932+
"$ref": "#/responses/notFound"
9933+
}
9934+
}
9935+
}
9936+
},
9937+
"/repos/{owner}/{repo}/transfer/reject": {
9938+
"post": {
9939+
"produces": [
9940+
"application/json"
9941+
],
9942+
"tags": [
9943+
"repository"
9944+
],
9945+
"summary": "Reject a repo transfer",
9946+
"operationId": "rejectRepoTransfer",
9947+
"parameters": [
9948+
{
9949+
"type": "string",
9950+
"description": "owner of the repo to transfer",
9951+
"name": "owner",
9952+
"in": "path",
9953+
"required": true
9954+
},
9955+
{
9956+
"type": "string",
9957+
"description": "name of the repo to transfer",
9958+
"name": "repo",
9959+
"in": "path",
9960+
"required": true
9961+
}
9962+
],
9963+
"responses": {
9964+
"200": {
9965+
"$ref": "#/responses/Repository"
9966+
},
9967+
"403": {
9968+
"$ref": "#/responses/forbidden"
9969+
},
9970+
"404": {
9971+
"$ref": "#/responses/notFound"
9972+
}
9973+
}
9974+
}
9975+
},
98989976
"/repos/{owner}/{repo}/wiki/new": {
98999977
"post": {
99009978
"consumes": [
@@ -16890,6 +16968,26 @@
1689016968
},
1689116969
"x-go-package": "code.gitea.io/gitea/modules/structs"
1689216970
},
16971+
"RepoTransfer": {
16972+
"description": "RepoTransfer represents a pending repo transfer",
16973+
"type": "object",
16974+
"properties": {
16975+
"doer": {
16976+
"$ref": "#/definitions/User"
16977+
},
16978+
"recipient": {
16979+
"$ref": "#/definitions/User"
16980+
},
16981+
"teams": {
16982+
"type": "array",
16983+
"items": {
16984+
"$ref": "#/definitions/Team"
16985+
},
16986+
"x-go-name": "Teams"
16987+
}
16988+
},
16989+
"x-go-package": "code.gitea.io/gitea/modules/structs"
16990+
},
1689316991
"Repository": {
1689416992
"description": "Repository represents a repository",
1689516993
"type": "object",
@@ -17042,6 +17140,9 @@
1704217140
"format": "int64",
1704317141
"x-go-name": "Releases"
1704417142
},
17143+
"repo_transfer": {
17144+
"$ref": "#/definitions/RepoTransfer"
17145+
},
1704517146
"size": {
1704617147
"type": "integer",
1704717148
"format": "int64",

0 commit comments

Comments
 (0)