mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-12 17:43:26 +02:00
Add bulk repository deletion for organizations (#36763)
Fixes #36512 This PR adds a new API endpoint to delete all repositories within an organization in a single operation, improving efficiency for organization cleanup and management tasks. --------- Signed-off-by: Karthik Bhandary <34509856+karthikbhandary2@users.noreply.github.com> Co-authored-by: karthik.bhandary <karthik.bhandary@kfintech.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
03205d94da
commit
fc178e3203
@ -18,7 +18,14 @@ import (
|
|||||||
// GetOrgRepositories get repos belonging to the given organization
|
// GetOrgRepositories get repos belonging to the given organization
|
||||||
func GetOrgRepositories(ctx context.Context, orgID int64) (RepositoryList, error) {
|
func GetOrgRepositories(ctx context.Context, orgID int64) (RepositoryList, error) {
|
||||||
var orgRepos []*Repository
|
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 {
|
type SearchTeamRepoOptions struct {
|
||||||
@ -26,7 +33,7 @@ type SearchTeamRepoOptions struct {
|
|||||||
TeamID int64
|
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) {
|
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
|
||||||
sess := db.GetEngine(ctx)
|
sess := db.GetEngine(ctx)
|
||||||
if opts.TeamID > 0 {
|
if opts.TeamID > 0 {
|
||||||
|
|||||||
@ -1610,7 +1610,8 @@ func Routes() *web.Router {
|
|||||||
Delete(reqToken(), reqOrgOwnership(), org.Delete)
|
Delete(reqToken(), reqOrgOwnership(), org.Delete)
|
||||||
m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename)
|
m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename)
|
||||||
m.Combo("/repos").Get(user.ListOrgRepos).
|
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.Group("/members", func() {
|
||||||
m.Get("", reqToken(), org.ListMembers)
|
m.Get("", reqToken(), org.ListMembers)
|
||||||
m.Combo("/{username}").Get(reqToken(), org.IsMember).
|
m.Combo("/{username}").Get(reqToken(), org.IsMember).
|
||||||
|
|||||||
@ -5,14 +5,20 @@
|
|||||||
package org
|
package org
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
gocontext "context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
activities_model "code.gitea.io/gitea/models/activities"
|
activities_model "code.gitea.io/gitea/models/activities"
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/models/organization"
|
"code.gitea.io/gitea/models/organization"
|
||||||
"code.gitea.io/gitea/models/perm"
|
"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"
|
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"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
@ -23,6 +29,7 @@ import (
|
|||||||
"code.gitea.io/gitea/services/convert"
|
"code.gitea.io/gitea/services/convert"
|
||||||
feed_service "code.gitea.io/gitea/services/feed"
|
feed_service "code.gitea.io/gitea/services/feed"
|
||||||
"code.gitea.io/gitea/services/org"
|
"code.gitea.io/gitea/services/org"
|
||||||
|
repo_service "code.gitea.io/gitea/services/repository"
|
||||||
user_service "code.gitea.io/gitea/services/user"
|
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))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
33
templates/swagger/v1_json.tmpl
generated
33
templates/swagger/v1_json.tmpl
generated
@ -3633,6 +3633,39 @@
|
|||||||
"$ref": "#/responses/notFound"
|
"$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": {
|
"/orgs/{org}/teams": {
|
||||||
|
|||||||
@ -8,10 +8,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
org_model "code.gitea.io/gitea/models/organization"
|
org_model "code.gitea.io/gitea/models/organization"
|
||||||
"code.gitea.io/gitea/models/perm"
|
"code.gitea.io/gitea/models/perm"
|
||||||
|
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/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
@ -24,8 +26,14 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAPIOrgCreateRename(t *testing.T) {
|
func TestAPIOrg(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(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)
|
token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
|
||||||
|
|
||||||
org := api.CreateOrgOption{
|
org := api.CreateOrgOption{
|
||||||
@ -110,8 +118,7 @@ func TestAPIOrgCreateRename(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIOrgGeneral(t *testing.T) {
|
func testAPIOrgGeneral(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
|
||||||
user1Session := loginUser(t, "user1")
|
user1Session := loginUser(t, "user1")
|
||||||
user1Token := getTokenForLoggedInUser(t, user1Session, auth_model.AccessTokenScopeWriteOrganization)
|
user1Token := getTokenForLoggedInUser(t, user1Session, auth_model.AccessTokenScopeWriteOrganization)
|
||||||
|
|
||||||
@ -260,3 +267,33 @@ func TestAPIOrgGeneral(t *testing.T) {
|
|||||||
MakeRequest(t, req, http.StatusForbidden)
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user