diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index ea19f529bd..25ab2d6586 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -514,49 +514,76 @@ func DeleteOrgRepos(ctx *context.APIContext) { // required: true // responses: // "202": - // description: Deletion started + // "$ref": "#/responses/empty" // "204": - // description: No repositories to delete + // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" org := ctx.Org.Organization - repos, err := repo_model.GetOrgRepositories(ctx, org.ID) + orgID := org.ID + doer := ctx.Doer + + // Check if org has any repos + count, err := db.GetEngine(ctx).Where("owner_id = ?", orgID).Count(new(repo_model.Repository)) if err != nil { ctx.APIErrorInternal(err) return } - if len(repos) == 0 { + if count == 0 { ctx.Status(http.StatusNoContent) return } - doer := ctx.Doer - // Start deletion in background with detached context go func() { defer func() { if r := recover(); r != nil { - log.Error("Panic during org repo deletion: %v", r) + desc := fmt.Sprintf("Panic during org repo deletion for org ID %d: %v", orgID, r) + if noticeErr := system_model.CreateNotice(graceful.GetManager().HammerContext(), system_model.NoticeRepository, desc); noticeErr != nil { + log.Error("Failed to create notice for panic: %v", noticeErr) + } } }() // Use HammerContext so deletion continues even if client disconnects bgCtx := graceful.GetManager().HammerContext() - for _, repo := range repos { - if err := repo_service.DeleteRepository(bgCtx, 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) + const batchSize = 50 + + for { + repos := make([]*repo_model.Repository, 0, batchSize) + // Always fetch from offset 0 since we're deleting as we go + err := db.GetEngine(bgCtx).Where("owner_id = ?", orgID). + Limit(batchSize, 0). + Find(&repos) + if err != nil { + desc := fmt.Sprintf("Failed to fetch repositories for org ID %d: %v", orgID, err) if noticeErr := system_model.CreateNotice(bgCtx, system_model.NoticeRepository, desc); noticeErr != nil { - log.Error("Failed to create notice for repo deletion failure: %v", noticeErr) + log.Error("Failed to create notice for repo fetch failure: %v", noticeErr) + } + break + } + + // exit the loop when there are no more repos to delete + if len(repos) == 0 { + break + } + + for _, repo := range repos { + if err := repo_service.DeleteRepository(bgCtx, doer, repo, true); err != nil { + desc := fmt.Sprintf("Failed to delete repository %s (ID: %d) in org ID %d: %v", repo.Name, repo.ID, orgID, err) + if noticeErr := system_model.CreateNotice(bgCtx, system_model.NoticeRepository, desc); noticeErr != nil { + log.Error("Failed to create notice for repo deletion failure: %v", noticeErr) + } + } else { + log.Info("Successfully deleted repository %s (ID: %d) in org ID %d", repo.Name, repo.ID, orgID) } - } else { - log.Info("Successfully deleted repository %s (ID: %d) in org %s", repo.Name, repo.ID, org.Name) } } - log.Info("Completed deletion of %d repositories in org %s", len(repos), org.Name) + log.Info("Completed deletion of repositories in org ID %d", orgID) }() ctx.Status(http.StatusAccepted) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d2be725b4b..9f87c07da8 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3549,10 +3549,10 @@ ], "responses": { "202": { - "description": "Deletion started" + "$ref": "#/responses/empty" }, "204": { - "description": "No repositories to delete" + "$ref": "#/responses/empty" }, "403": { "$ref": "#/responses/forbidden" diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index aaf1fbb102..013037193d 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -255,9 +255,8 @@ func TestAPIOrgGeneral(t *testing.T) { } func TestAPIDeleteOrgRepos(t *testing.T) { - defer tests.PrepareTestEnv(t)() - t.Run("Delete all repos successfully", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() defer tests.PrintCurrentTest(t)() // Create test org with owner @@ -284,7 +283,8 @@ func TestAPIDeleteOrgRepos(t *testing.T) { MakeRequest(t, req, http.StatusAccepted) }) - t.Run("Verify response structure", func(t *testing.T) { + t.Run("Verify delete status code", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") @@ -310,6 +310,7 @@ func TestAPIDeleteOrgRepos(t *testing.T) { }) t.Run("Fail without permissions", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() defer tests.PrintCurrentTest(t)() // user2 is owner of org3 @@ -332,6 +333,7 @@ func TestAPIDeleteOrgRepos(t *testing.T) { }) t.Run("No system notice created on successful deletion", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") @@ -356,8 +358,23 @@ func TestAPIDeleteOrgRepos(t *testing.T) { req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) MakeRequest(t, req, http.StatusAccepted) - // Wait for background deletion to complete - time.Sleep(2 * time.Second) + // Wait for background deletion to complete (poll until done) + org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) + maxWait := 10 * time.Second + checkInterval := 200 * time.Millisecond + elapsed := time.Duration(0) + + for elapsed < maxWait { + time.Sleep(checkInterval) + elapsed += checkInterval + + remainingRepos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) + assert.NoError(t, err) + + if len(remainingRepos) == 0 { + break + } + } // Check if notices were created (should be 0 for successful deletions) finalNotices := unittest.GetCount(t, &system_model.Notice{Type: system_model.NoticeRepository}) @@ -365,6 +382,7 @@ func TestAPIDeleteOrgRepos(t *testing.T) { }) t.Run("Returns 204 when repos already deleted", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") @@ -397,6 +415,7 @@ func TestAPIDeleteOrgRepos(t *testing.T) { }) t.Run("Returns 204 when no repos exist", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") @@ -412,4 +431,54 @@ func TestAPIDeleteOrgRepos(t *testing.T) { req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) }) + + t.Run("Pagination works for large org", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + orgName := "test_pagination_org" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ + UserName: orgName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Create 75 repos to test pagination (batch size is 50) + for i := range 75 { + repoName := fmt.Sprintf("test_repo_%d", i) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", orgName), &api.CreateRepoOption{ + Name: repoName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + } + + // Delete all repos - should return 202 Accepted + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusAccepted) + + // Wait for background deletion to complete (poll until done) + org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) + maxWait := 30 * time.Second + checkInterval := 500 * time.Millisecond + elapsed := time.Duration(0) + + for elapsed < maxWait { + time.Sleep(checkInterval) + elapsed += checkInterval + + remainingRepos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) + assert.NoError(t, err) + + if len(remainingRepos) == 0 { + break + } + } + + // Verify all repos were deleted + remainingRepos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) + assert.NoError(t, err) + assert.Empty(t, remainingRepos, "Org is empty") + }) }