diff --git a/models/repo/org_repo.go b/models/repo/org_repo.go index 96f21ba2ac..d8c2c91fec 100644 --- a/models/repo/org_repo.go +++ b/models/repo/org_repo.go @@ -18,7 +18,14 @@ import ( // GetOrgRepositories get repos belonging to the given organization func GetOrgRepositories(ctx context.Context, orgID int64) (RepositoryList, error) { var orgRepos []*Repository - return orgRepos, db.GetEngine(ctx).Where("owner_id = ?", orgID).Find(&orgRepos) + err := db.GetEngine(ctx).Where("owner_id = ?", orgID).Find(&orgRepos) + return orgRepos, err +} + +// GetOrgRepositoryIDs get repo IDs belonging to the given organization +func GetOrgRepositoryIDs(ctx context.Context, orgID int64) (repoIDs []int64, _ error) { + err := db.GetEngine(ctx).Table("repository").Where("owner_id = ?", orgID).Cols("id").Find(&repoIDs) + return repoIDs, err } type SearchTeamRepoOptions struct { @@ -26,7 +33,7 @@ type SearchTeamRepoOptions struct { TeamID int64 } -// GetRepositories returns paginated repositories in team of organization. +// GetTeamRepositories returns paginated repositories in team of organization. func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) { sess := db.GetEngine(ctx) if opts.TeamID > 0 { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index c1733095cf..2d80692fef 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1610,7 +1610,8 @@ func Routes() *web.Router { Delete(reqToken(), reqOrgOwnership(), org.Delete) m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename) m.Combo("/repos").Get(user.ListOrgRepos). - Post(reqToken(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo) + Post(reqToken(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo). + Delete(reqToken(), reqOrgOwnership(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), org.DeleteOrgRepos) m.Group("/members", func() { m.Get("", reqToken(), org.ListMembers) m.Combo("/{username}").Get(reqToken(), org.IsMember). diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index ce2a2e5580..7c6d11bbc4 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -5,14 +5,20 @@ package org import ( + gocontext "context" "errors" + "fmt" "net/http" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -23,6 +29,7 @@ import ( "code.gitea.io/gitea/services/convert" feed_service "code.gitea.io/gitea/services/feed" "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" user_service "code.gitea.io/gitea/services/user" ) @@ -497,3 +504,70 @@ func ListOrgActivityFeeds(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) } + +func deleteOrgReposBackground(ctx gocontext.Context, org *organization.Organization, repoIDs []int64, doer *user_model.User) { + defer func() { + if r := recover(); r != nil { + log.Error("panic during org repo deletion: %v, stack: %v", r, log.Stack(2)) + } + }() + + for _, repoID := range repoIDs { + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + desc := fmt.Sprintf("Failed to get repository ID %d in org %s: %v", repoID, org.Name, err) + _ = system_model.CreateNotice(ctx, system_model.NoticeRepository, desc) + log.Error("GetRepositoryByID failed: %v", desc) + continue + } + if err := repo_service.DeleteRepository(ctx, doer, repo, true); err != nil { + desc := fmt.Sprintf("Failed to delete repository %s (ID: %d) in org %s: %v", repo.Name, repo.ID, org.Name, err) + _ = system_model.CreateNotice(ctx, system_model.NoticeRepository, desc) + log.Error("DeleteRepository failed: %v", desc) + continue + } + log.Info("Successfully deleted repository %s (ID: %d) in org %s", repo.Name, repo.ID, org.Name) + } + log.Info("Completed deletion of repositories in org %s", org.Name) +} + +func DeleteOrgRepos(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/repos organization orgDeleteRepos + // --- + // summary: Delete all repositories in an organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "202": + // "$ref": "#/responses/empty" + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + // Intentionally it only loads repository IDs to avoid loading too much data into memory + // There is no need to do pagination here as the number of repositories is expected to be manageable + repoIDs, err := repo_model.GetOrgRepositoryIDs(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if len(repoIDs) == 0 { + ctx.Status(http.StatusNoContent) + return + } + + // Start deletion (slow) in background with detached context, so it can continue even if the request is canceled + go deleteOrgReposBackground(graceful.GetManager().ShutdownContext(), ctx.Org.Organization, repoIDs, ctx.Doer) + + ctx.Status(http.StatusAccepted) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e5b276f746..703a25336f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3633,6 +3633,39 @@ "$ref": "#/responses/notFound" } } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Delete all repositories in an organization", + "operationId": "orgDeleteRepos", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "$ref": "#/responses/empty" + }, + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } } }, "/orgs/{org}/teams": { diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 42f9e4cbf6..320b22a4ff 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -8,10 +8,12 @@ import ( "net/http" "strings" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" org_model "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -24,8 +26,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestAPIOrgCreateRename(t *testing.T) { +func TestAPIOrg(t *testing.T) { defer tests.PrepareTestEnv(t)() + t.Run("General", testAPIOrgGeneral) + t.Run("CreateAndRename", testAPIOrgCreateRename) + t.Run("DeleteOrgRepos", testAPIDeleteOrgRepos) +} + +func testAPIOrgCreateRename(t *testing.T) { token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization) org := api.CreateOrgOption{ @@ -110,8 +118,7 @@ func TestAPIOrgCreateRename(t *testing.T) { }) } -func TestAPIOrgGeneral(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAPIOrgGeneral(t *testing.T) { user1Session := loginUser(t, "user1") user1Token := getTokenForLoggedInUser(t, user1Session, auth_model.AccessTokenScopeWriteOrganization) @@ -260,3 +267,33 @@ func TestAPIOrgGeneral(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) }) } + +func testAPIDeleteOrgRepos(t *testing.T) { + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + orgRepos, err := repo_model.GetOrgRepositories(t.Context(), org3.ID) + require.NoError(t, err) + assert.NotEmpty(t, orgRepos) // this org contains repositories, so we can test the deletion of all org repos + + t.Run("NoPermission", func(t *testing.T) { + nonOwnerSession := loginUser(t, "user4") + nonOwnerToken := getTokenForLoggedInUser(t, nonOwnerSession, auth_model.AccessTokenScopeWriteOrganization) + req := NewRequest(t, "DELETE", "/api/v1/orgs/org3/repos").AddTokenAuth(nonOwnerToken) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("DeleteAllOrgRepos", func(t *testing.T) { + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", org3.Name)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusAccepted) + + assert.Eventually(t, func() bool { + repos, err := repo_model.GetOrgRepositories(t.Context(), org3.ID) + require.NoError(t, err) + return len(repos) == 0 + }, 2*time.Second, 50*time.Millisecond) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", org3.Name)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) // The org contains no repositories, so the API should return StatusNoContent + }) +}