From 0b1942150fb501254fa180500b152f199a5225e9 Mon Sep 17 00:00:00 2001 From: mactynow Date: Sun, 14 Sep 2025 14:25:05 +0200 Subject: [PATCH] feat: Add converting mirror repos to normal to the API (#8932) - Add `POST /repos/{owner}/{repo}/convert` to the API to allow mirror repositories to be converted to normal repositories. - Resolves forgejo/forgejo#7733 Co-authored-by: Charles Martinot Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8932 Reviewed-by: Gusted Co-authored-by: mactynow Co-committed-by: mactynow --- routers/api/v1/api.go | 1 + routers/api/v1/repo/repo.go | 60 ++++++++++++++++++++ routers/api/v1/repo/repo_test.go | 14 +++++ routers/web/repo/setting/setting.go | 8 +-- services/repository/repository.go | 11 ++++ services/repository/repository_test.go | 13 +++++ templates/swagger/v1_json.tmpl | 42 ++++++++++++++ tests/integration/api_repo_convert_test.go | 66 ++++++++++++++++++++++ 8 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 tests/integration/api_repo_convert_test.go diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8c9eabc613..fd8cf72003 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1124,6 +1124,7 @@ func Routes() *web.Route { m.Combo("").Get(reqAnyRepoReader(), repo.Get). Delete(reqToken(), reqOwner(), repo.Delete). Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) + m.Post("/convert", reqOwner(), repo.Convert) m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) m.Group("/transfer", func() { m.Post("", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer) diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 42385d54a6..568c2432cf 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -672,6 +672,47 @@ func Edit(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, ctx.Repo.Permission)) } +// Convert converts a mirror to a normal repo +func Convert(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/convert repository repoConvert + // --- + // summary: Convert a mirror repo to a normal repo. + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to convert + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to convert + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if err := convertMirrorToNormalRepo(ctx); err != nil { + return + } + + repo, err := repo_model.GetRepositoryByID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, ctx.Repo.Permission)) +} + // updateBasicProperties updates the basic properties of a repo: Name, Description, Website and Visibility func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) error { owner := ctx.Repo.Owner @@ -1140,6 +1181,25 @@ func updateMirror(ctx *context.APIContext, opts api.EditRepoOption) error { return nil } +// convertMirrorToNormalRepository converts a mirror to a normal repo +func convertMirrorToNormalRepo(ctx *context.APIContext) error { + repo := ctx.Repo.Repository + + if !repo.IsMirror { + err := errors.New("Repository is not a mirror") + ctx.Error(http.StatusUnprocessableEntity, "ConvertMirror", err) + return nil + } + + if err := repo_service.ConvertMirrorToNormalRepo(ctx, repo); err != nil { + log.Error("Failed to Disable Mirror: %s", err) + ctx.Error(http.StatusUnprocessableEntity, "ConvertMirror", err) + return err + } + + return nil +} + // Delete one repository func Delete(ctx *context.APIContext) { // swagger:operation DELETE /repos/{owner}/{repo} repository repoDelete diff --git a/routers/api/v1/repo/repo_test.go b/routers/api/v1/repo/repo_test.go index 024376c146..a6bd23ae06 100644 --- a/routers/api/v1/repo/repo_test.go +++ b/routers/api/v1/repo/repo_test.go @@ -84,3 +84,17 @@ func TestRepoEditNameChange(t *testing.T) { ID: 1, }, unittest.Cond("name = ?", opts.Name)) } + +func TestRepoConvertToNormalRepo(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockAPIContext(t, "user3/repo5") + contexttest.LoadRepo(t, ctx, 5) + contexttest.LoadUser(t, ctx, 3) + ctx.Repo.Owner = ctx.Doer + assert.True(t, ctx.Repo.Repository.IsMirror) + + Convert(ctx) + assert.Equal(t, http.StatusOK, ctx.Resp.Status()) + assert.False(t, ctx.Repo.Repository.IsMirror) +} diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index ec18fa55a9..1f54ba353d 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -828,13 +828,9 @@ func SettingsPost(ctx *context.Context) { ctx.Error(http.StatusNotFound) return } - repo.IsMirror = false - if _, err := repo_service.CleanUpMigrateInfo(ctx, repo); err != nil { - ctx.ServerError("CleanUpMigrateInfo", err) - return - } else if err = repo_model.DeleteMirrorByRepoID(ctx, ctx.Repo.Repository.ID); err != nil { - ctx.ServerError("DeleteMirrorByRepoID", err) + if err := repo_service.ConvertMirrorToNormalRepo(ctx, ctx.Repo.Repository); err != nil { + ctx.ServerError("ConvertMirror", err) return } log.Trace("Repository converted from mirror to regular: %s", repo.FullName()) diff --git a/services/repository/repository.go b/services/repository/repository.go index 41f3a96dd1..0d5ce647e0 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -119,6 +119,17 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili return committer.Commit() } +// ConvertMirrorToNormalRepo converts a mirror to a normal repo +func ConvertMirrorToNormalRepo(ctx context.Context, repo *repo_model.Repository) (err error) { + repo.IsMirror = false + + if _, err := CleanUpMigrateInfo(ctx, repo); err != nil { + return err + } + + return repo_model.DeleteMirrorByRepoID(ctx, repo.ID) +} + // LinkedRepository returns the linked repo if any func LinkedRepository(ctx context.Context, a *repo_model.Attachment) (*repo_model.Repository, unit.Type, error) { if a.IssueID != 0 { diff --git a/services/repository/repository_test.go b/services/repository/repository_test.go index c08f7151ca..9f71bdec23 100644 --- a/services/repository/repository_test.go +++ b/services/repository/repository_test.go @@ -41,3 +41,16 @@ func TestLinkedRepository(t *testing.T) { }) } } + +func TestConvertMirrorToNormalRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo.IsMirror = true + err := repo_model.UpdateRepositoryCols(db.DefaultContext, repo, "is_mirror") + + require.NoError(t, err) + + err = ConvertMirrorToNormalRepo(db.DefaultContext, repo) + require.NoError(t, err) + assert.False(t, repo.IsMirror) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index ede509409c..2ea24987c2 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7272,6 +7272,48 @@ } } }, + "/repos/{owner}/{repo}/convert": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Convert a mirror repo to a normal repo.", + "operationId": "repoConvert", + "parameters": [ + { + "type": "string", + "description": "owner of the repo to convert", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo to convert", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Repository" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/diffpatch": { "post": { "consumes": [ diff --git a/tests/integration/api_repo_convert_test.go b/tests/integration/api_repo_convert_test.go new file mode 100644 index 0000000000..fb9756f975 --- /dev/null +++ b/tests/integration/api_repo_convert_test.go @@ -0,0 +1,66 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "forgejo.org/models/auth" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + api "forgejo.org/modules/structs" + "forgejo.org/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIConvert(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + repo5 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5}) + repo4 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + org3 := "org3" + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + // Get user5's token + session = loginUser(t, user5.Name) + token5 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/convert", org3, repo5.Name)).AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + var repo api.Repository + DecodeJSON(t, resp, &repo) + assert.NotNil(t, repo) + assert.False(t, repo.Mirror) + + repo5edited := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5}) + assert.False(t, repo5edited.IsMirror) + + // Test editing a non-existing repo return 404 + name := "repodoesnotexist" + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/convert", org3, name)).AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusNotFound) + + // Test converting a repo when not owner returns 422 + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/convert", org3, repo5.Name)).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Tests converting a repo with no token returns 404 + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/convert", org3, repo5.Name)) + _ = MakeRequest(t, req, http.StatusNotFound) + + // Test converting a repo that is not a mirror does nothing and returns 422 + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/convert", user5.Name, repo4.Name)).AddTokenAuth(token5) + _ = MakeRequest(t, req, http.StatusUnprocessableEntity) + repo4edited := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + assert.False(t, repo4edited.IsMirror) +}