From fad641befc31413c486f4d4a5cdbf8ffb393232d Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Tue, 24 Feb 2026 17:35:38 +0530 Subject: [PATCH 01/29] added DeleteOrgRepos func --- modules/structs/repo.go | 12 +++++++++ routers/api/v1/api.go | 3 ++- routers/api/v1/org/org.go | 55 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 765546a5aaedd..0d67df41c25f1 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -440,3 +440,15 @@ type UpdateRepoAvatarOption struct { // image must be base64 encoded Image string `json:"image" binding:"Required"` } + +type DeleteOrgReposResponse struct { + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + Deleted []string `json:"deleted"` + Failed []DeleteRepoFailure `json:"failed"` +} + +type DeleteRepoFailure struct { + RepoName string `json:"repo_name"` + Reason string `json:"reason"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 359d5af4c4bc4..9e80ee7728bd8 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1621,7 +1621,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(), repo.DeleteOrgRepo) 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 0c108a933c12a..20fb7c3e9be7f 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models/perm" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/optional" + repo_module "code.gitea.io/gitea/modules/repository" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" @@ -493,3 +494,57 @@ func ListOrgActivityFeeds(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) } + +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: + // "200": + // "$ref": "#/responses/DeleteOrgReposResponse" + // "403": + // "$ref": "#/responses/forbidden" + org := ctx.Org.Organization + repos, err := repo_model.GetOrgRepositories(ctx, org.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + response := &api.DeleteOrgReposResponse{ + Deleted: []string{}, + Failed: []api.DeleteRepoFailure{}, + } + for _, repo := range repos { + canDelete, err := repo_module.CanUserDelete(ctx, repo, ctx.Doer) + if !canDelete || err != nil { + reason := "Permission denied" + if err != nil { + reason = err.Error() + } + response.Failed = append(response.Failed, api.DeleteRepoFailure{ + RepoName: repo.Name, + Reason: reason, + }) + continue + } + if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil { + response.Failed = append(response.Failed, api.DeleteRepoFailure{ + RepoName: repo.Name, + Reason: err.Error(), + }) + } else { + response.Deleted = append(response.Deleted, repo.Name) + } + } + response.SuccessCount = len(response.Deleted) + response.FailureCount = len(response.Failed) + ctx.JSON(http.StatusOK, response) +} From e940dd27b1aa2cb3db23035824fdc01c462c7d86 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Thu, 26 Feb 2026 13:59:44 +0530 Subject: [PATCH 02/29] Verified and tested the DeleteRepoOrgs --- modules/structs/repo.go | 19 ++++-- routers/api/v1/api.go | 2 +- routers/api/v1/org/org.go | 4 +- routers/api/v1/swagger/org.go | 8 +++ templates/swagger/v1_json.tmpl | 88 ++++++++++++++++++++++++++++ tests/integration/api_org_test.go | 97 +++++++++++++++++++++++++++++++ 6 files changed, 211 insertions(+), 7 deletions(-) diff --git a/modules/structs/repo.go b/modules/structs/repo.go index a32871e0eb459..30697feadde2f 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -441,14 +441,23 @@ type UpdateRepoAvatarOption struct { Image string `json:"image" binding:"Required"` } +// DeleteOrgReposResponse represents the response for deleting organization repositories +// swagger:model type DeleteOrgReposResponse struct { - SuccessCount int `json:"success_count"` - FailureCount int `json:"failure_count"` - Deleted []string `json:"deleted"` - Failed []DeleteRepoFailure `json:"failed"` + // Number of repositories successfully deleted + SuccessCount int `json:"success_count"` + // Number of repositories that failed to delete + FailureCount int `json:"failure_count"` + // List of repository names that were deleted + Deleted []string `json:"deleted"` + // Details about repositories that failed to delete + Failed []DeleteRepoFailure `json:"failed"` } +// DeleteRepoFailure represents a repository that failed to delete type DeleteRepoFailure struct { + // Repository name RepoName string `json:"repo_name"` - Reason string `json:"reason"` + // Reason for deletion failure + Reason string `json:"reason"` } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9e80ee7728bd8..cb88a0a520309 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1622,7 +1622,7 @@ func Routes() *web.Router { m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename) m.Combo("/repos").Get(user.ListOrgRepos). Post(reqToken(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo). - Delete(reqToken(), reqOrgOwnership(), repo.DeleteOrgRepo) + Delete(reqToken(), reqOrgOwnership(), 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 20fb7c3e9be7f..4147b2e0f2487 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -11,6 +11,7 @@ import ( "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" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" @@ -22,6 +23,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" ) @@ -509,7 +511,7 @@ func DeleteOrgRepos(ctx *context.APIContext) { // required: true // responses: // "200": - // "$ref": "#/responses/DeleteOrgReposResponse" + // "$ref": "#/responses/DeleteOrgReposList" // "403": // "$ref": "#/responses/forbidden" org := ctx.Org.Organization diff --git a/routers/api/v1/swagger/org.go b/routers/api/v1/swagger/org.go index 0105446b00a33..f6cdbcab69b7b 100644 --- a/routers/api/v1/swagger/org.go +++ b/routers/api/v1/swagger/org.go @@ -41,3 +41,11 @@ type swaggerResponseOrganizationPermissions struct { // in:body Body api.OrganizationPermissions `json:"body"` } + +// DeleteOrgReposList +// swagger:response DeleteOrgReposList +type swaggerDeleteOrgReposList struct { + // List of successfully deleted repositories and failures + //in:body + Body []api.DeleteOrgReposResponse `json:"body"` +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a1ecc7fb4fe2a..c87e4e3908a63 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3638,6 +3638,33 @@ "$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": { + "200": { + "$ref": "#/responses/DeleteOrgReposList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } } }, "/orgs/{org}/teams": { @@ -24180,6 +24207,58 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "DeleteOrgReposResponse": { + "description": "DeleteOrgReposResponse represents the response for deleting organization repositories", + "type": "object", + "properties": { + "deleted": { + "description": "List of repository names that were deleted", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Deleted" + }, + "failed": { + "description": "Details about repositories that failed to delete", + "type": "array", + "items": { + "$ref": "#/definitions/DeleteRepoFailure" + }, + "x-go-name": "Failed" + }, + "failure_count": { + "description": "Number of repositories that failed to delete", + "type": "integer", + "format": "int64", + "x-go-name": "FailureCount" + }, + "success_count": { + "description": "Number of repositories successfully deleted", + "type": "integer", + "format": "int64", + "x-go-name": "SuccessCount" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "DeleteRepoFailure": { + "description": "DeleteRepoFailure represents a repository that failed to delete ", + "type": "object", + "properties": { + "reason": { + "description": "Reason for deletion failure", + "type": "string", + "x-go-name": "Reason" + }, + "repo_name": { + "description": "Repository name", + "type": "string", + "x-go-name": "RepoName" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "DeployKey": { "description": "DeployKey a deploy key", "type": "object", @@ -29728,6 +29807,15 @@ } } }, + "DeleteOrgReposList": { + "description": "DeleteOrgReposList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/DeleteOrgReposResponse" + } + } + }, "DeployKey": { "description": "DeployKey", "schema": { diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 6b7826fbb8b87..f9286587213d2 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -249,3 +249,100 @@ func TestAPIOrgGeneral(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) }) } + +func TestAPIDeleteOrgRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Delete all repos successfully", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create test org with owner + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + orgName := "test_delete_org" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ + UserName: orgName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Create 60 repos to test efficiency + for i := range 60 { + 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 + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var result api.DeleteOrgReposResponse + DecodeJSON(t, resp, &result) + + assert.Equal(t, 60, result.SuccessCount) + assert.Equal(t, 0, result.FailureCount) + assert.Len(t, result.Deleted, 60) + assert.Empty(t, result.Failed) + }) + + t.Run("Verify response structure", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + orgName := "test_response_org" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ + UserName: orgName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Create a few repos + for i := range 3 { + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", orgName), &api.CreateRepoOption{ + Name: fmt.Sprintf("repo_%d", i), + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + } + + // Delete all repos + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var result api.DeleteOrgReposResponse + DecodeJSON(t, resp, &result) + + // Verify response structure + assert.Equal(t, 3, result.SuccessCount) + assert.Equal(t, 0, result.FailureCount) + assert.Len(t, result.Deleted, 3) + assert.Empty(t, result.Failed) + assert.NotNil(t, result.Deleted) + assert.NotNil(t, result.Failed) + }) + + t.Run("Fail without permissions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // user2 is owner of org3 + ownerSession := loginUser(t, "user2") + ownerToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + // Create repo in org3 + req := NewRequestWithJSON(t, "POST", "/api/v1/org/org3/repos", &api.CreateRepoOption{ + Name: "test_perm_repo", + }).AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusCreated) + + // user4 is not owner of org3 + nonOwnerSession := loginUser(t, "user4") + nonOwnerToken := getTokenForLoggedInUser(t, nonOwnerSession, auth_model.AccessTokenScopeWriteOrganization) + + // Try to delete repos without owner permission + req = NewRequest(t, "DELETE", "/api/v1/orgs/org3/repos").AddTokenAuth(nonOwnerToken) + MakeRequest(t, req, http.StatusForbidden) + }) +} From c319345af3dc72f1200bccb931c96e9057659ea8 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Thu, 26 Feb 2026 16:04:46 +0530 Subject: [PATCH 03/29] ran make generate-swagger --- templates/swagger/v1_json.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index c87e4e3908a63..a9f559487d22f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -24243,7 +24243,7 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "DeleteRepoFailure": { - "description": "DeleteRepoFailure represents a repository that failed to delete ", + "description": "DeleteRepoFailure represents a repository that failed to delete", "type": "object", "properties": { "reason": { From e4c5c388aac11b8143288946da5f762a9e9a8f11 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Thu, 26 Feb 2026 18:44:04 +0530 Subject: [PATCH 04/29] error handling and swagger updated --- modules/structs/repo.go | 5 +++-- routers/api/v1/org/org.go | 22 +++++++++++++++------- routers/api/v1/swagger/org.go | 2 +- templates/swagger/v1_json.tmpl | 12 ++++++------ 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 30697feadde2f..7f09acf2dcdd4 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -455,9 +455,10 @@ type DeleteOrgReposResponse struct { } // DeleteRepoFailure represents a repository that failed to delete +// swagger:model type DeleteRepoFailure struct { // Repository name RepoName string `json:"repo_name"` - // Reason for deletion failure - Reason string `json:"reason"` + // Message to be displayed + Message string `json:"reason"` } diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 4147b2e0f2487..0ccb8507af0cb 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" api "code.gitea.io/gitea/modules/structs" @@ -514,6 +515,8 @@ func DeleteOrgRepos(ctx *context.APIContext) { // "$ref": "#/responses/DeleteOrgReposList" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" org := ctx.Org.Organization repos, err := repo_model.GetOrgRepositories(ctx, org.ID) if err != nil { @@ -526,21 +529,26 @@ func DeleteOrgRepos(ctx *context.APIContext) { } for _, repo := range repos { canDelete, err := repo_module.CanUserDelete(ctx, repo, ctx.Doer) - if !canDelete || err != nil { - reason := "Permission denied" - if err != nil { - reason = err.Error() - } + if !canDelete { + response.Failed = append(response.Failed, api.DeleteRepoFailure{ + RepoName: repo.Name, + Message: "Insufficient permissions to delete repository", + }) + continue + } + if err != nil { + log.Error("Error checking delete permission for repo %s: %v", repo.Name, err) response.Failed = append(response.Failed, api.DeleteRepoFailure{ RepoName: repo.Name, - Reason: reason, + Message: "Failed to verify delete permissions", }) continue } if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil { + log.Error("Error deleting repo %s: %v", repo.Name, err) response.Failed = append(response.Failed, api.DeleteRepoFailure{ RepoName: repo.Name, - Reason: err.Error(), + Message: "Failed to delete repository", }) } else { response.Deleted = append(response.Deleted, repo.Name) diff --git a/routers/api/v1/swagger/org.go b/routers/api/v1/swagger/org.go index f6cdbcab69b7b..ef5b53d81375e 100644 --- a/routers/api/v1/swagger/org.go +++ b/routers/api/v1/swagger/org.go @@ -47,5 +47,5 @@ type swaggerResponseOrganizationPermissions struct { type swaggerDeleteOrgReposList struct { // List of successfully deleted repositories and failures //in:body - Body []api.DeleteOrgReposResponse `json:"body"` + Body api.DeleteOrgReposResponse `json:"body"` } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a9f559487d22f..6e3312be22b35 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3663,6 +3663,9 @@ }, "403": { "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" } } } @@ -24247,9 +24250,9 @@ "type": "object", "properties": { "reason": { - "description": "Reason for deletion failure", + "description": "Message to be displayed", "type": "string", - "x-go-name": "Reason" + "x-go-name": "Message" }, "repo_name": { "description": "Repository name", @@ -29810,10 +29813,7 @@ "DeleteOrgReposList": { "description": "DeleteOrgReposList", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/DeleteOrgReposResponse" - } + "$ref": "#/definitions/DeleteOrgReposResponse" } }, "DeployKey": { From ed4b335f9fdbde5e68e229ab7258b993d551465a Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Thu, 26 Feb 2026 19:38:55 +0530 Subject: [PATCH 05/29] Removed additional check for permissions in DeleteOrgRepos --- routers/api/v1/org/org.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 0ccb8507af0cb..d225e6ba3d13f 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -15,7 +15,6 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" - repo_module "code.gitea.io/gitea/modules/repository" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" @@ -528,22 +527,6 @@ func DeleteOrgRepos(ctx *context.APIContext) { Failed: []api.DeleteRepoFailure{}, } for _, repo := range repos { - canDelete, err := repo_module.CanUserDelete(ctx, repo, ctx.Doer) - if !canDelete { - response.Failed = append(response.Failed, api.DeleteRepoFailure{ - RepoName: repo.Name, - Message: "Insufficient permissions to delete repository", - }) - continue - } - if err != nil { - log.Error("Error checking delete permission for repo %s: %v", repo.Name, err) - response.Failed = append(response.Failed, api.DeleteRepoFailure{ - RepoName: repo.Name, - Message: "Failed to verify delete permissions", - }) - continue - } if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil { log.Error("Error deleting repo %s: %v", repo.Name, err) response.Failed = append(response.Failed, api.DeleteRepoFailure{ From 5b819b42aafab6fd01643c34a0e3ee077c96d907 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Fri, 27 Feb 2026 22:50:38 +0530 Subject: [PATCH 06/29] updated DeleteOrgRepos to delete in the background --- modules/structs/repo.go | 22 ----------- routers/api/v1/org/org.go | 46 +++++++++++++---------- routers/api/v1/swagger/org.go | 8 ---- templates/swagger/v1_json.tmpl | 62 +------------------------------ tests/integration/api_org_test.go | 27 ++------------ 5 files changed, 33 insertions(+), 132 deletions(-) diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 7f09acf2dcdd4..a08cf36037171 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -440,25 +440,3 @@ type UpdateRepoAvatarOption struct { // image must be base64 encoded Image string `json:"image" binding:"Required"` } - -// DeleteOrgReposResponse represents the response for deleting organization repositories -// swagger:model -type DeleteOrgReposResponse struct { - // Number of repositories successfully deleted - SuccessCount int `json:"success_count"` - // Number of repositories that failed to delete - FailureCount int `json:"failure_count"` - // List of repository names that were deleted - Deleted []string `json:"deleted"` - // Details about repositories that failed to delete - Failed []DeleteRepoFailure `json:"failed"` -} - -// DeleteRepoFailure represents a repository that failed to delete -// swagger:model -type DeleteRepoFailure struct { - // Repository name - RepoName string `json:"repo_name"` - // Message to be displayed - Message string `json:"reason"` -} diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index d225e6ba3d13f..e498c2c0de508 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" 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" @@ -510,8 +511,8 @@ func DeleteOrgRepos(ctx *context.APIContext) { // type: string // required: true // responses: - // "200": - // "$ref": "#/responses/DeleteOrgReposList" + // "202": + // description: Deletion started // "403": // "$ref": "#/responses/forbidden" // "404": @@ -522,22 +523,29 @@ func DeleteOrgRepos(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } - response := &api.DeleteOrgReposResponse{ - Deleted: []string{}, - Failed: []api.DeleteRepoFailure{}, - } - for _, repo := range repos { - if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil { - log.Error("Error deleting repo %s: %v", repo.Name, err) - response.Failed = append(response.Failed, api.DeleteRepoFailure{ - RepoName: repo.Name, - Message: "Failed to delete repository", - }) - } else { - response.Deleted = append(response.Deleted, repo.Name) + + 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) + } + }() + + // 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 { + log.Error("Failed to delete repository %s (ID: %d) in org %s: %v", repo.Name, repo.ID, org.Name, err) + } else { + log.Info("Successfully deleted repository %s (ID: %d) in org %s", repo.Name, repo.ID, org.Name) + } } - } - response.SuccessCount = len(response.Deleted) - response.FailureCount = len(response.Failed) - ctx.JSON(http.StatusOK, response) + log.Info("Completed deletion of %d repositories in org %s", len(repos), org.Name) + }() + + ctx.Status(http.StatusAccepted) } diff --git a/routers/api/v1/swagger/org.go b/routers/api/v1/swagger/org.go index ef5b53d81375e..0105446b00a33 100644 --- a/routers/api/v1/swagger/org.go +++ b/routers/api/v1/swagger/org.go @@ -41,11 +41,3 @@ type swaggerResponseOrganizationPermissions struct { // in:body Body api.OrganizationPermissions `json:"body"` } - -// DeleteOrgReposList -// swagger:response DeleteOrgReposList -type swaggerDeleteOrgReposList struct { - // List of successfully deleted repositories and failures - //in:body - Body api.DeleteOrgReposResponse `json:"body"` -} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 6e3312be22b35..5216f3270e2d7 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3658,8 +3658,8 @@ } ], "responses": { - "200": { - "$ref": "#/responses/DeleteOrgReposList" + "202": { + "description": "Deletion started" }, "403": { "$ref": "#/responses/forbidden" @@ -24210,58 +24210,6 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, - "DeleteOrgReposResponse": { - "description": "DeleteOrgReposResponse represents the response for deleting organization repositories", - "type": "object", - "properties": { - "deleted": { - "description": "List of repository names that were deleted", - "type": "array", - "items": { - "type": "string" - }, - "x-go-name": "Deleted" - }, - "failed": { - "description": "Details about repositories that failed to delete", - "type": "array", - "items": { - "$ref": "#/definitions/DeleteRepoFailure" - }, - "x-go-name": "Failed" - }, - "failure_count": { - "description": "Number of repositories that failed to delete", - "type": "integer", - "format": "int64", - "x-go-name": "FailureCount" - }, - "success_count": { - "description": "Number of repositories successfully deleted", - "type": "integer", - "format": "int64", - "x-go-name": "SuccessCount" - } - }, - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, - "DeleteRepoFailure": { - "description": "DeleteRepoFailure represents a repository that failed to delete", - "type": "object", - "properties": { - "reason": { - "description": "Message to be displayed", - "type": "string", - "x-go-name": "Message" - }, - "repo_name": { - "description": "Repository name", - "type": "string", - "x-go-name": "RepoName" - } - }, - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, "DeployKey": { "description": "DeployKey a deploy key", "type": "object", @@ -29810,12 +29758,6 @@ } } }, - "DeleteOrgReposList": { - "description": "DeleteOrgReposList", - "schema": { - "$ref": "#/definitions/DeleteOrgReposResponse" - } - }, "DeployKey": { "description": "DeployKey", "schema": { diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index f9286587213d2..7678f7e0d0941 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -275,17 +275,9 @@ func TestAPIDeleteOrgRepos(t *testing.T) { MakeRequest(t, req, http.StatusCreated) } - // Delete all repos + // Delete all repos - should return 202 Accepted req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - - var result api.DeleteOrgReposResponse - DecodeJSON(t, resp, &result) - - assert.Equal(t, 60, result.SuccessCount) - assert.Equal(t, 0, result.FailureCount) - assert.Len(t, result.Deleted, 60) - assert.Empty(t, result.Failed) + MakeRequest(t, req, http.StatusAccepted) }) t.Run("Verify response structure", func(t *testing.T) { @@ -308,20 +300,9 @@ func TestAPIDeleteOrgRepos(t *testing.T) { MakeRequest(t, req, http.StatusCreated) } - // Delete all repos + // Delete all repos - should return 202 Accepted req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - - var result api.DeleteOrgReposResponse - DecodeJSON(t, resp, &result) - - // Verify response structure - assert.Equal(t, 3, result.SuccessCount) - assert.Equal(t, 0, result.FailureCount) - assert.Len(t, result.Deleted, 3) - assert.Empty(t, result.Failed) - assert.NotNil(t, result.Deleted) - assert.NotNil(t, result.Failed) + MakeRequest(t, req, http.StatusAccepted) }) t.Run("Fail without permissions", func(t *testing.T) { From a5092e5aa78445cae68d33989db9c0cf8d616486 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Fri, 6 Mar 2026 16:37:11 +0530 Subject: [PATCH 07/29] added system notice --- routers/api/v1/org/org.go | 14 ++++- templates/swagger/v1_json.tmpl | 3 ++ tests/integration/api_org_test.go | 86 +++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 4d4e2ef269b38..ea19f529bd772 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -5,6 +5,7 @@ package org import ( + "fmt" "net/http" activities_model "code.gitea.io/gitea/models/activities" @@ -12,6 +13,7 @@ import ( "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" @@ -513,6 +515,8 @@ func DeleteOrgRepos(ctx *context.APIContext) { // responses: // "202": // description: Deletion started + // "204": + // description: No repositories to delete // "403": // "$ref": "#/responses/forbidden" // "404": @@ -524,6 +528,11 @@ func DeleteOrgRepos(ctx *context.APIContext) { return } + if len(repos) == 0 { + ctx.Status(http.StatusNoContent) + return + } + doer := ctx.Doer // Start deletion in background with detached context @@ -539,7 +548,10 @@ func DeleteOrgRepos(ctx *context.APIContext) { for _, repo := range repos { if err := repo_service.DeleteRepository(bgCtx, doer, repo, true); err != nil { - log.Error("Failed to delete repository %s (ID: %d) in org %s: %v", repo.Name, repo.ID, org.Name, err) + desc := fmt.Sprintf("Failed to delete repository %s (ID: %d) in org %s: %v", repo.Name, repo.ID, org.Name, 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 %s", repo.Name, repo.ID, org.Name) } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index caa530c67f0a4..d2be725b4bd30 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3551,6 +3551,9 @@ "202": { "description": "Deletion started" }, + "204": { + "description": "No repositories to delete" + }, "403": { "$ref": "#/responses/forbidden" }, diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 7678f7e0d0941..aaf1fbb102b5c 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -8,16 +8,20 @@ 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" + system_model "code.gitea.io/gitea/models/system" unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" + repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" @@ -326,4 +330,86 @@ func TestAPIDeleteOrgRepos(t *testing.T) { req = NewRequest(t, "DELETE", "/api/v1/orgs/org3/repos").AddTokenAuth(nonOwnerToken) MakeRequest(t, req, http.StatusForbidden) }) + + t.Run("No system notice created on successful deletion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + orgName := "test_notice_org" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ + UserName: orgName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Create a repo + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", orgName), &api.CreateRepoOption{ + Name: "test_repo", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Get initial notice count + initialNotices := unittest.GetCount(t, &system_model.Notice{Type: system_model.NoticeRepository}) + + // Delete repos + 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) + + // Check if notices were created (should be 0 for successful deletions) + finalNotices := unittest.GetCount(t, &system_model.Notice{Type: system_model.NoticeRepository}) + assert.Equal(t, initialNotices, finalNotices, "No notices should be created for successful deletions") + }) + + t.Run("Returns 204 when repos already deleted", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + orgName := "test_fail_notice" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ + UserName: orgName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Create a repo + repoName := "test_fail_repo" + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", orgName), &api.CreateRepoOption{ + Name: repoName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Delete the repo directly to cause the bulk delete to fail + org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: repoName, OwnerID: org.ID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + + err := repo_service.DeleteRepository(t.Context(), user, repo, true) + assert.NoError(t, err) + + // Now try to delete all org repos - should return 204 since no repos exist + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + }) + + t.Run("Returns 204 when no repos exist", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + orgName := "test_empty_org" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ + UserName: orgName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Delete repos when org has no repos - should return 204 + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + }) } From 20e3fe40d707b855f9a06bb8054db24feb35df92 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Sat, 7 Mar 2026 16:34:32 +0530 Subject: [PATCH 08/29] added batch processing and other fixes --- routers/api/v1/org/org.go | 55 +++++++++++++++------ templates/swagger/v1_json.tmpl | 4 +- tests/integration/api_org_test.go | 79 +++++++++++++++++++++++++++++-- 3 files changed, 117 insertions(+), 21 deletions(-) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index ea19f529bd772..25ab2d6586dc0 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 d2be725b4bd30..9f87c07da88fe 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 aaf1fbb102b5c..013037193dab5 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") + }) } From 008415e752b9b8586e61e18b2b7d025ebbdca2d6 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Sat, 7 Mar 2026 17:58:58 +0530 Subject: [PATCH 09/29] added retry logic --- routers/api/v1/org/org.go | 22 +++++++++++++++++++++- tests/integration/api_org_test.go | 11 +++-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 25ab2d6586dc0..c7a3330014e4d 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -552,6 +552,10 @@ func DeleteOrgRepos(ctx *context.APIContext) { bgCtx := graceful.GetManager().HammerContext() const batchSize = 50 + const maxRetries = 3 + // Track failed deletions with retry limit to prevent infinite loops when repos cannot be deleted. + // If a repo fails 3 times, skip it; if all remaining repos have hit max retries, exit the loop. + failedRepos := make(map[int64]int) // repo ID -> retry count for { repos := make([]*repo_model.Repository, 0, batchSize) @@ -572,16 +576,32 @@ func DeleteOrgRepos(ctx *context.APIContext) { break } + allFailed := true for _, repo := range repos { + // Skip repos that have failed too many times + if failedRepos[repo.ID] >= maxRetries { + continue + } + 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) + failedRepos[repo.ID]++ + desc := fmt.Sprintf("Failed to delete repository %s (ID: %d) in org ID %d (attempt %d/%d): %v", + repo.Name, repo.ID, orgID, failedRepos[repo.ID], maxRetries, 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 { + allFailed = false + delete(failedRepos, repo.ID) // Remove from failed map if it succeeds log.Info("Successfully deleted repository %s (ID: %d) in org ID %d", repo.Name, repo.ID, orgID) } } + + // If all repos in this batch have failed max retries, break to avoid infinite loop + if allFailed && len(failedRepos) > 0 { + log.Error("All remaining repositories failed to delete after %d retries for org ID %d", maxRetries, orgID) + break + } } log.Info("Completed deletion of repositories in org ID %d", orgID) }() diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 013037193dab5..5248a8584c4ee 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -255,8 +255,9 @@ 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 +285,6 @@ func TestAPIDeleteOrgRepos(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,7 +310,6 @@ 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 @@ -333,7 +332,6 @@ 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") @@ -382,7 +380,6 @@ 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") @@ -415,7 +412,6 @@ 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") @@ -433,7 +429,6 @@ func TestAPIDeleteOrgRepos(t *testing.T) { }) t.Run("Pagination works for large org", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") @@ -479,6 +474,6 @@ func TestAPIDeleteOrgRepos(t *testing.T) { // 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") + assert.Empty(t, remainingRepos, "All repositories should be deleted") }) } From 63f9dbf2a6b6eb779a6d8f83dda05eeea41bd292 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Sat, 7 Mar 2026 18:57:02 +0530 Subject: [PATCH 10/29] reverted back to old logic --- routers/api/v1/org/org.go | 68 ++++++------------------------- tests/integration/api_org_test.go | 49 ---------------------- 2 files changed, 12 insertions(+), 105 deletions(-) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index c7a3330014e4d..f229a84f66a35 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -522,26 +522,24 @@ func DeleteOrgRepos(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" org := ctx.Org.Organization - 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)) + repos, err := repo_model.GetOrgRepositories(ctx, org.ID) if err != nil { ctx.APIErrorInternal(err) return } - if count == 0 { + if len(repos) == 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 { - desc := fmt.Sprintf("Panic during org repo deletion for org ID %d: %v", orgID, r) + desc := fmt.Sprintf("Panic during org repo deletion for org ID %d: %v", org.ID, 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) } @@ -551,59 +549,17 @@ func DeleteOrgRepos(ctx *context.APIContext) { // Use HammerContext so deletion continues even if client disconnects bgCtx := graceful.GetManager().HammerContext() - const batchSize = 50 - const maxRetries = 3 - // Track failed deletions with retry limit to prevent infinite loops when repos cannot be deleted. - // If a repo fails 3 times, skip it; if all remaining repos have hit max retries, exit the loop. - failedRepos := make(map[int64]int) // repo ID -> retry count - - 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) + 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) if noticeErr := system_model.CreateNotice(bgCtx, system_model.NoticeRepository, desc); noticeErr != nil { - 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 - } - - allFailed := true - for _, repo := range repos { - // Skip repos that have failed too many times - if failedRepos[repo.ID] >= maxRetries { - continue + log.Error("Failed to create notice for repo deletion failure: %v", noticeErr) } - - if err := repo_service.DeleteRepository(bgCtx, doer, repo, true); err != nil { - failedRepos[repo.ID]++ - desc := fmt.Sprintf("Failed to delete repository %s (ID: %d) in org ID %d (attempt %d/%d): %v", - repo.Name, repo.ID, orgID, failedRepos[repo.ID], maxRetries, 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 { - allFailed = false - delete(failedRepos, repo.ID) // Remove from failed map if it succeeds - log.Info("Successfully deleted repository %s (ID: %d) in org ID %d", repo.Name, repo.ID, orgID) - } - } - - // If all repos in this batch have failed max retries, break to avoid infinite loop - if allFailed && len(failedRepos) > 0 { - log.Error("All remaining repositories failed to delete after %d retries for org ID %d", maxRetries, orgID) - break + } else { + 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 ID %d", orgID) + log.Info("Completed deletion of repositories in org %s", org.Name) }() ctx.Status(http.StatusAccepted) diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 5248a8584c4ee..754b96e3ff6cf 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -427,53 +427,4 @@ 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.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, "All repositories should be deleted") - }) } From cfcbdca1f269c3a3ebb4bfe7298b23d1d1e103db Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Sun, 22 Mar 2026 19:16:48 +0000 Subject: [PATCH 11/29] updated DeleteOrgRepos to use GetRepositoryByID --- models/repo/org_repo.go | 6 ++++++ routers/api/v1/org/org.go | 14 +++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/models/repo/org_repo.go b/models/repo/org_repo.go index 96f21ba2aca7a..82951fc2e74f1 100644 --- a/models/repo/org_repo.go +++ b/models/repo/org_repo.go @@ -21,6 +21,12 @@ func GetOrgRepositories(ctx context.Context, orgID int64) (RepositoryList, error return orgRepos, db.GetEngine(ctx).Where("owner_id = ?", orgID).Find(&orgRepos) } +// GetOrgRepositoryIDs get repo IDs belonging to the given organization +func GetOrgRepositoryIDs(ctx context.Context, orgID int64) ([]int64, error) { + var repoIDs []int64 + return repoIDs, db.GetEngine(ctx).Table("repository").Where("owner_id = ?", orgID).Cols("id").Find(&repoIDs) +} + type SearchTeamRepoOptions struct { db.ListOptions TeamID int64 diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 0f4da7966cad8..ccff453080b68 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -522,13 +522,13 @@ func DeleteOrgRepos(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" org := ctx.Org.Organization - repos, err := repo_model.GetOrgRepositories(ctx, org.ID) + repoIDs, err := repo_model.GetOrgRepositoryIDs(ctx, org.ID) if err != nil { ctx.APIErrorInternal(err) return } - if len(repos) == 0 { + if len(repoIDs) == 0 { ctx.Status(http.StatusNoContent) return } @@ -549,7 +549,15 @@ func DeleteOrgRepos(ctx *context.APIContext) { // Use HammerContext so deletion continues even if client disconnects bgCtx := graceful.GetManager().HammerContext() - for _, repo := range repos { + for _, repoID := range repoIDs { + repo, err := repo_model.GetRepositoryByID(bgCtx, repoID) + if err != nil { + desc := fmt.Sprintf("Failed to get repository ID %d in org %s: %v", repoID, org.Name, err) + if noticeErr := system_model.CreateNotice(bgCtx, system_model.NoticeRepository, desc); noticeErr != nil { + log.Error("Failed to create notice for repo get failure: %v", noticeErr) + } + continue + } 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) if noticeErr := system_model.CreateNotice(bgCtx, system_model.NoticeRepository, desc); noticeErr != nil { From 53a1aac33c799b420ac3369eb7c1a0b1c399f4d0 Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Fri, 27 Mar 2026 18:59:37 +0000 Subject: [PATCH 12/29] merge conflict fix --- routers/api/v1/org/org.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index a1f6714ad1bd0..5f7d4fb6085a3 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -5,8 +5,8 @@ package org import ( - "fmt" "errors" + "fmt" "net/http" activities_model "code.gitea.io/gitea/models/activities" From 88381aa13111a666986cf80ca67c53b70ff21dde Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Sat, 4 Apr 2026 11:45:41 +0000 Subject: [PATCH 13/29] add test to check if deletes were successful --- tests/integration/api_org_test.go | 138 ++++++++++++++++++++++++------ 1 file changed, 110 insertions(+), 28 deletions(-) diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 31b99e29192fe..5af193b16bc5f 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -293,9 +293,17 @@ func TestAPIDeleteOrgRepos(t *testing.T) { // 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) + + // Verify deletion completed + org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) + assert.EventuallyWithT(t, func(c *assert.CollectT) { + repos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) + assert.NoError(c, err) + assert.Empty(c, repos, "All repos should be deleted") + }, 10*time.Second, 200*time.Millisecond) }) - t.Run("Verify delete status code", func(t *testing.T) { + t.Run("Verify response structure", func(t *testing.T) { defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") @@ -318,6 +326,14 @@ func TestAPIDeleteOrgRepos(t *testing.T) { // 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) + + // Verify deletion completed + org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) + assert.EventuallyWithT(t, func(c *assert.CollectT) { + repos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) + assert.NoError(c, err) + assert.Empty(c, repos, "All repos should be deleted") + }, 10*time.Second, 200*time.Millisecond) }) t.Run("Fail without permissions", func(t *testing.T) { @@ -346,29 +362,44 @@ func TestAPIDeleteOrgRepos(t *testing.T) { defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + token := getTokenForLoggedInUser( + t, + session, + auth_model.AccessTokenScopeWriteOrganization, + auth_model.AccessTokenScopeWriteRepository, + ) orgName := "test_notice_org" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ UserName: orgName, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) - // Create a repo - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", orgName), &api.CreateRepoOption{ - Name: "test_repo", - }).AddTokenAuth(token) + req = NewRequestWithJSON( + t, + "POST", + fmt.Sprintf("/api/v1/org/%s/repos", orgName), + &api.CreateRepoOption{ + Name: "test_repo", + }, + ).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) - // Get initial notice count - initialNotices := unittest.GetCount(t, &system_model.Notice{Type: system_model.NoticeRepository}) + initialNotices := unittest.GetCount( + t, + &system_model.Notice{Type: system_model.NoticeRepository}, + ) - // Delete repos - req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) + 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 := 10 * time.Second checkInterval := 200 * time.Millisecond elapsed := time.Duration(0) @@ -385,57 +416,108 @@ func TestAPIDeleteOrgRepos(t *testing.T) { } } - // Check if notices were created (should be 0 for successful deletions) - finalNotices := unittest.GetCount(t, &system_model.Notice{Type: system_model.NoticeRepository}) - assert.Equal(t, initialNotices, finalNotices, "No notices should be created for successful deletions") + finalNotices := unittest.GetCount( + t, + &system_model.Notice{Type: system_model.NoticeRepository}, + ) + + assert.Equal(t, initialNotices, finalNotices, + "No notices should be created for successful deletions") }) - t.Run("Returns 204 when repos already deleted", func(t *testing.T) { + t.Run("Returns no content when repos already deleted", func(t *testing.T) { defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + token := getTokenForLoggedInUser( + t, + session, + auth_model.AccessTokenScopeWriteOrganization, + auth_model.AccessTokenScopeWriteRepository, + ) orgName := "test_fail_notice" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ UserName: orgName, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) - // Create a repo repoName := "test_fail_repo" - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", orgName), &api.CreateRepoOption{ - Name: repoName, - }).AddTokenAuth(token) + + req = NewRequestWithJSON( + t, + "POST", + fmt.Sprintf("/api/v1/org/%s/repos", orgName), + &api.CreateRepoOption{ + Name: repoName, + }, + ).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) - // Delete the repo directly to cause the bulk delete to fail org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: repoName, OwnerID: org.ID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ + Name: repoName, + OwnerID: org.ID, + }) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) err := repo_service.DeleteRepository(t.Context(), user, repo, true) assert.NoError(t, err) - // Now try to delete all org repos - should return 204 since no repos exist - req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) + req = NewRequest( + t, + "DELETE", + fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), + ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) }) - t.Run("Returns 204 when no repos exist", func(t *testing.T) { + t.Run("Returns no content when no repos exist", func(t *testing.T) { defer tests.PrintCurrentTest(t)() session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + token := getTokenForLoggedInUser( + t, + session, + auth_model.AccessTokenScopeWriteOrganization, + auth_model.AccessTokenScopeWriteRepository, + ) orgName := "test_empty_org" + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ UserName: orgName, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) - // Delete repos when org has no repos - should return 204 - req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName)).AddTokenAuth(token) + req = NewRequest( + t, + "DELETE", + fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), + ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) + + 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 + } + } + + remainingRepos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) + assert.NoError(t, err) + assert.Empty(t, remainingRepos) }) } From 41cff2ff7e92d14ee8dc1b921d6d9de9f64f0fd6 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 6 Apr 2026 03:39:25 +0800 Subject: [PATCH 14/29] refactor --- routers/api/v1/org/org.go | 66 +++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 5f7d4fb6085a3..b7cae396b3176 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -5,6 +5,7 @@ package org import ( + gocontext "context" "errors" "fmt" "net/http" @@ -504,6 +505,31 @@ 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) + 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 // --- @@ -525,8 +551,8 @@ func DeleteOrgRepos(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - org := ctx.Org.Organization - repoIDs, err := repo_model.GetOrgRepositoryIDs(ctx, org.ID) + + repoIDs, err := repo_model.GetOrgRepositoryIDs(ctx, ctx.Org.Organization.ID) if err != nil { ctx.APIErrorInternal(err) return @@ -537,42 +563,8 @@ func DeleteOrgRepos(ctx *context.APIContext) { return } - doer := ctx.Doer - // Start deletion in background with detached context - go func() { - defer func() { - if r := recover(); r != nil { - desc := fmt.Sprintf("Panic during org repo deletion for org ID %d: %v", org.ID, 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 _, repoID := range repoIDs { - repo, err := repo_model.GetRepositoryByID(bgCtx, repoID) - if err != nil { - desc := fmt.Sprintf("Failed to get repository ID %d in org %s: %v", repoID, org.Name, err) - if noticeErr := system_model.CreateNotice(bgCtx, system_model.NoticeRepository, desc); noticeErr != nil { - log.Error("Failed to create notice for repo get failure: %v", noticeErr) - } - continue - } - 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) - 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 %s", repo.Name, repo.ID, org.Name) - } - } - log.Info("Completed deletion of repositories in org %s", org.Name) - }() + go deleteOrgReposBackground(graceful.GetManager().ShutdownContext(), ctx.Org.Organization, repoIDs, ctx.Doer) ctx.Status(http.StatusAccepted) } From f723d35409a1f073a4a4b41ed0ba7e30615fb91d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 6 Apr 2026 03:40:33 +0800 Subject: [PATCH 15/29] refactor --- routers/api/v1/org/org.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index b7cae396b3176..9079beeb67969 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -522,6 +522,7 @@ func deleteOrgReposBackground(ctx gocontext.Context, org *organization.Organizat } 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 } From 76189cc022f6c1cc7651483834ecccd5f9b17fc9 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Mon, 6 Apr 2026 06:58:47 +0000 Subject: [PATCH 16/29] tests updated and delete endpoint updated --- routers/api/v1/api.go | 2 +- tests/integration/api_org_test.go | 44 +++++++------------------------ 2 files changed, 11 insertions(+), 35 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 3ca9023488524..2d80692fef567 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1611,7 +1611,7 @@ func Routes() *web.Router { m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename) m.Combo("/repos").Get(user.ListOrgRepos). Post(reqToken(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo). - Delete(reqToken(), reqOrgOwnership(), org.DeleteOrgRepos) + 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/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 5af193b16bc5f..8efb6aa348042 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -400,21 +400,11 @@ func TestAPIDeleteOrgRepos(t *testing.T) { 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 - } - } + assert.EventuallyWithT(t, func(c *assert.CollectT) { + repos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) + assert.NoError(c, err) + assert.Empty(c, repos, "All repos should be deleted") + }, 10*time.Second, 200*time.Millisecond) finalNotices := unittest.GetCount( t, @@ -500,24 +490,10 @@ func TestAPIDeleteOrgRepos(t *testing.T) { 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 - } - } - - remainingRepos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) - assert.NoError(t, err) - assert.Empty(t, remainingRepos) + assert.EventuallyWithT(t, func(c *assert.CollectT) { + repos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) + assert.NoError(c, err) + assert.Empty(c, repos) + }, 10*time.Second, 200*time.Millisecond) }) } From 9e7154570602bf249c15160a44eba06b9bbd5b22 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 7 Apr 2026 16:02:50 +0800 Subject: [PATCH 17/29] clean up AI slop --- routers/api/v1/org/org.go | 2 + tests/integration/api_org_test.go | 251 ++++-------------------------- 2 files changed, 31 insertions(+), 222 deletions(-) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 9079beeb67969..969ecccc111a3 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -553,6 +553,8 @@ func DeleteOrgRepos(ctx *context.APIContext) { // "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) diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 8efb6aa348042..62ebae276025d 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -11,6 +11,7 @@ import ( "time" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" org_model "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" @@ -21,15 +22,20 @@ import ( "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" - repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" "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{ @@ -114,8 +120,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) @@ -265,235 +270,37 @@ 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.PrintCurrentTest(t)() - - // Create test org with owner - session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) - - orgName := "test_delete_org" - req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ - UserName: orgName, - }).AddTokenAuth(token) - MakeRequest(t, req, http.StatusCreated) - - // Create 60 repos to test efficiency - for i := range 60 { - 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) - - // Verify deletion completed - org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) - assert.EventuallyWithT(t, func(c *assert.CollectT) { - repos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) - assert.NoError(c, err) - assert.Empty(c, repos, "All repos should be deleted") - }, 10*time.Second, 200*time.Millisecond) - }) - - t.Run("Verify response structure", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() +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.Len(t, orgRepos, 2) - session := loginUser(t, "user1") - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) - - orgName := "test_response_org" - req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ - UserName: orgName, - }).AddTokenAuth(token) - MakeRequest(t, req, http.StatusCreated) - - // Create a few repos - for i := range 3 { - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", orgName), &api.CreateRepoOption{ - Name: fmt.Sprintf("repo_%d", i), - }).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) - - // Verify deletion completed - org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) - assert.EventuallyWithT(t, func(c *assert.CollectT) { - repos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) - assert.NoError(c, err) - assert.Empty(c, repos, "All repos should be deleted") - }, 10*time.Second, 200*time.Millisecond) - }) - - t.Run("Fail without permissions", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - // user2 is owner of org3 - ownerSession := loginUser(t, "user2") - ownerToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) - - // Create repo in org3 - req := NewRequestWithJSON(t, "POST", "/api/v1/org/org3/repos", &api.CreateRepoOption{ - Name: "test_perm_repo", - }).AddTokenAuth(ownerToken) - MakeRequest(t, req, http.StatusCreated) - - // user4 is not owner of org3 + t.Run("NoPermission", func(t *testing.T) { nonOwnerSession := loginUser(t, "user4") nonOwnerToken := getTokenForLoggedInUser(t, nonOwnerSession, auth_model.AccessTokenScopeWriteOrganization) - - // Try to delete repos without owner permission - req = NewRequest(t, "DELETE", "/api/v1/orgs/org3/repos").AddTokenAuth(nonOwnerToken) + req := NewRequest(t, "DELETE", "/api/v1/orgs/org3/repos").AddTokenAuth(nonOwnerToken) MakeRequest(t, req, http.StatusForbidden) }) - t.Run("No system notice created on successful deletion", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() + t.Run("DeleteAllOrgRepos", func(t *testing.T) { + _ = db.TruncateBeans(t.Context(), &system_model.Notice{}) session := loginUser(t, "user1") - token := getTokenForLoggedInUser( - t, - session, - auth_model.AccessTokenScopeWriteOrganization, - auth_model.AccessTokenScopeWriteRepository, - ) - - orgName := "test_notice_org" - - req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ - UserName: orgName, - }).AddTokenAuth(token) - MakeRequest(t, req, http.StatusCreated) - - req = NewRequestWithJSON( - t, - "POST", - fmt.Sprintf("/api/v1/org/%s/repos", orgName), - &api.CreateRepoOption{ - Name: "test_repo", - }, - ).AddTokenAuth(token) - MakeRequest(t, req, http.StatusCreated) - - initialNotices := unittest.GetCount( - t, - &system_model.Notice{Type: system_model.NoticeRepository}, - ) - - req = NewRequest( - t, - "DELETE", - fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), - ).AddTokenAuth(token) + 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) - org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) - - assert.EventuallyWithT(t, func(c *assert.CollectT) { - repos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) - assert.NoError(c, err) - assert.Empty(c, repos, "All repos should be deleted") - }, 10*time.Second, 200*time.Millisecond) - - finalNotices := unittest.GetCount( - t, - &system_model.Notice{Type: system_model.NoticeRepository}, - ) - - assert.Equal(t, initialNotices, finalNotices, - "No notices should be created for successful deletions") - }) - - t.Run("Returns no content when repos already deleted", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - session := loginUser(t, "user1") - token := getTokenForLoggedInUser( - t, - session, - auth_model.AccessTokenScopeWriteOrganization, - auth_model.AccessTokenScopeWriteRepository, - ) - - orgName := "test_fail_notice" - - req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ - UserName: orgName, - }).AddTokenAuth(token) - MakeRequest(t, req, http.StatusCreated) - - repoName := "test_fail_repo" - - req = NewRequestWithJSON( - t, - "POST", - fmt.Sprintf("/api/v1/org/%s/repos", orgName), - &api.CreateRepoOption{ - Name: repoName, - }, - ).AddTokenAuth(token) - MakeRequest(t, req, http.StatusCreated) - - org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ - Name: repoName, - OwnerID: org.ID, - }) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) - - err := repo_service.DeleteRepository(t.Context(), user, repo, true) - assert.NoError(t, err) - - req = NewRequest( - t, - "DELETE", - fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), - ).AddTokenAuth(token) - MakeRequest(t, req, http.StatusNoContent) - }) - - t.Run("Returns no content when no repos exist", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - session := loginUser(t, "user1") - token := getTokenForLoggedInUser( - t, - session, - auth_model.AccessTokenScopeWriteOrganization, - auth_model.AccessTokenScopeWriteRepository, - ) - - orgName := "test_empty_org" - - req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ - UserName: orgName, - }).AddTokenAuth(token) - MakeRequest(t, req, http.StatusCreated) - - req = NewRequest( - t, - "DELETE", - fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), - ).AddTokenAuth(token) - MakeRequest(t, req, http.StatusNoContent) + 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) - org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: orgName}) + finalNotices := unittest.GetCount(t, &system_model.Notice{Type: system_model.NoticeRepository}) + assert.Empty(t, finalNotices, "No notices should be created for successful deletions") - assert.EventuallyWithT(t, func(c *assert.CollectT) { - repos, err := repo_model.GetOrgRepositories(t.Context(), org.ID) - assert.NoError(c, err) - assert.Empty(c, repos) - }, 10*time.Second, 200*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 }) } From 115b5829f5543158c9a1385509aaf444ed802a62 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 7 Apr 2026 16:05:52 +0800 Subject: [PATCH 18/29] fine tune --- tests/integration/api_org_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 62ebae276025d..574b32c0ab6d6 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -274,7 +274,7 @@ 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.Len(t, orgRepos, 2) + 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") From 8017f5dcbf5c278dd1ac7f744734fced306516e2 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 7 Apr 2026 16:11:40 +0800 Subject: [PATCH 19/29] fine tune --- models/repo/org_repo.go | 11 ++++++----- routers/api/v1/org/org.go | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/models/repo/org_repo.go b/models/repo/org_repo.go index 82951fc2e74f1..d8c2c91fec562 100644 --- a/models/repo/org_repo.go +++ b/models/repo/org_repo.go @@ -18,13 +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) ([]int64, error) { - var repoIDs []int64 - return repoIDs, db.GetEngine(ctx).Table("repository").Where("owner_id = ?", orgID).Cols("id").Find(&repoIDs) +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 { @@ -32,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/org/org.go b/routers/api/v1/org/org.go index 969ecccc111a3..7c6d11bbc4f3f 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -566,7 +566,7 @@ func DeleteOrgRepos(ctx *context.APIContext) { return } - // Start deletion in background with detached context + // 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) From bbc34917e3a266838361ee04b87e7187814fd539 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 7 Apr 2026 18:17:43 +0800 Subject: [PATCH 20/29] remove unnecessary test --- tests/integration/api_org_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 574b32c0ab6d6..320b22a4ff027 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -11,11 +11,9 @@ import ( "time" auth_model "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/models/db" org_model "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" unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -284,8 +282,6 @@ func testAPIDeleteOrgRepos(t *testing.T) { }) t.Run("DeleteAllOrgRepos", func(t *testing.T) { - _ = db.TruncateBeans(t.Context(), &system_model.Notice{}) - 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) @@ -297,9 +293,6 @@ func testAPIDeleteOrgRepos(t *testing.T) { return len(repos) == 0 }, 2*time.Second, 50*time.Millisecond) - finalNotices := unittest.GetCount(t, &system_model.Notice{Type: system_model.NoticeRepository}) - assert.Empty(t, finalNotices, "No notices should be created for successful deletions") - 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 }) From d83c602da229b833b8547cd106cf8b5dda464b7b Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Sun, 26 Apr 2026 12:59:48 +0000 Subject: [PATCH 21/29] feat:Render .ipynb files natively --- modules/markup/external/external.go | 5 + modules/markup/external/frontend.go | 53 +++++ tests/e2e/jupyter-render.test.ts | 223 ++++++++++++++++++ web_src/css/features/jupyter.css | 182 ++++++++++++++ web_src/css/index.css | 1 + web_src/js/external-render-frontend.ts | 1 + .../plugins/frontend-jupyter-notebook.ts | 184 +++++++++++++++ 7 files changed, 649 insertions(+) create mode 100644 tests/e2e/jupyter-render.test.ts create mode 100644 web_src/css/features/jupyter.css create mode 100644 web_src/js/render/plugins/frontend-jupyter-notebook.ts diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 4b3c96fd33d2c..1926c46f4af5e 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -48,6 +48,11 @@ func RegisterRenderers() { }, }) + markup.RegisterRenderer(&frontendRenderer{ + name: "jupyter-notebook", + patterns: []string{"*.ipynb"}, + }) + for _, renderer := range setting.ExternalMarkupRenderers { markup.RegisterRenderer(&Renderer{renderer}) } diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go index 7327503d28a9b..a68d265bd421e 100644 --- a/modules/markup/external/frontend.go +++ b/modules/markup/external/frontend.go @@ -4,12 +4,16 @@ package external import ( + "bytes" "encoding/base64" "io" + "strings" "unicode/utf8" "code.gitea.io/gitea/modules/htmlutil" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -58,6 +62,46 @@ func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRend return ret } +type notebookCell struct { + CellType string `json:"cell_type"` + Source []string `json:"source"` + Outputs []map[string]any `json:"outputs,omitempty"` +} + +type notebookData struct { + Cells []notebookCell `json:"cells"` + Metadata map[string]any `json:"metadata,omitempty"` + Nbformat int `json:"nbformat,omitempty"` + NbformatMinor int `json:"nbformat_minor,omitempty"` +} + +func preprocessJupyterNotebook(ctx *markup.RenderContext, input io.Reader) ([]byte, error) { + content, err := io.ReadAll(input) + if err != nil { + return nil, err + } + + var nb notebookData + if err := json.Unmarshal(content, &nb); err != nil { + return content, nil + } + + for i := range nb.Cells { + if nb.Cells[i].CellType == "markdown" { + var sourceBuilder strings.Builder + for _, line := range nb.Cells[i].Source { + sourceBuilder.WriteString(line) + } + var buf bytes.Buffer + if err := markdown.RenderRaw(ctx, bytes.NewReader([]byte(sourceBuilder.String())), &buf); err == nil { + nb.Cells[i].Source = []string{buf.String()} + } + } + } + + return json.Marshal(nb) +} + func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { if ctx.RenderOptions.StandalonePageOptions == nil { opts := p.GetExternalRendererOptions() @@ -69,6 +113,13 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou return err } + if p.name == "jupyter-notebook" { + preprocessed, err := preprocessJupyterNotebook(ctx, bytes.NewReader(content)) + if err == nil { + content = preprocessed + } + } + contentEncoding, contentString := "text", util.UnsafeBytesToString(content) if !utf8.Valid(content) { contentEncoding = "base64" @@ -81,6 +132,7 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou +
@@ -88,6 +140,7 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou `, + public.AssetURI("css/index.css"), p.name, ctx.RenderOptions.RelativePath, contentEncoding, contentString, public.AssetURI("js/external-render-frontend.js")) diff --git a/tests/e2e/jupyter-render.test.ts b/tests/e2e/jupyter-render.test.ts new file mode 100644 index 0000000000000..6f5dc14c1446d --- /dev/null +++ b/tests/e2e/jupyter-render.test.ts @@ -0,0 +1,223 @@ +import {env} from 'node:process'; +import {expect, test} from '@playwright/test'; +import type {APIRequestContext} from '@playwright/test'; +import {login, apiCreateRepo, apiDeleteRepo, assertFlushWithParent, assertNoJsError, randomString, baseUrl, apiHeaders} from './utils.ts'; + +// Helper to create multiple files in a single commit +async function apiCreateFiles(request: APIRequestContext, owner: string, repo: string, files: Record) { + const branch = 'main'; + + // Get the latest commit SHA + const branchInfo = await request.get(`${baseUrl()}/api/v1/repos/${owner}/${repo}/branches/${branch}`); + const branchData = await branchInfo.json(); + const latestCommitSha = branchData.commit.id; + + // Create file changes + const fileChanges = Object.entries(files).map(([path, content]) => ({ + operation: 'create', + path, + content: globalThis.btoa(content), + })); + + // Create all files in one commit + await request.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents`, { + headers: apiHeaders(), + data: { + branch, + files: fileChanges, + message: 'Add test notebooks', + sha: latestCommitSha, + }, + }); +} + +test('jupyter notebook - all scenarios', async ({page, request}) => { + test.setTimeout(25000); + const repoName = `e2e-jupyter-all-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + + await Promise.all([ + apiCreateRepo(request, {name: repoName}), + login(page), + ]); + + try { + // Define all notebooks + const mainNotebook = JSON.stringify({ + cells: [ + { + cell_type: 'markdown', + source: ['# Test Notebook\n', 'This is **markdown** with `code`.'], + }, + { + cell_type: 'code', + execution_count: 1, + source: ['print("Hello World")'], + outputs: [{output_type: 'stream', name: 'stdout', text: ['Hello World\n']}], + }, + { + cell_type: 'code', + execution_count: 2, + source: ['import matplotlib.pyplot as plt'], + outputs: [{ + output_type: 'execute_result', + data: { + 'image/png': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + }, + }], + }, + { + cell_type: 'code', + execution_count: 3, + source: ['x = 5'], + outputs: [{ + output_type: 'execute_result', + data: { + 'text/latex': ['$$x^2 + y^2 = z^2$$'], + 'text/plain': ['x^2 + y^2 = z^2'], + }, + }], + }, + ], + metadata: {}, + nbformat: 4, + nbformat_minor: 5, + }); + + const noOutputNotebook = JSON.stringify({ + cells: [ + { + cell_type: 'code', + source: ['# Code with no output'], + outputs: [], + }, + ], + metadata: {}, + nbformat: 4, + nbformat_minor: 5, + }); + + const errorNotebook = JSON.stringify({ + cells: [ + { + cell_type: 'code', + source: ['raise ValueError("Test error")'], + outputs: [{ + output_type: 'error', + ename: 'ValueError', + evalue: 'Test error', + traceback: ['ValueError: Test error'], + }], + }, + ], + metadata: {}, + nbformat: 4, + nbformat_minor: 5, + }); + + const mixedNotebook = JSON.stringify({ + cells: [ + { + cell_type: 'code', + source: ['print("text")'], + outputs: [ + {output_type: 'stream', name: 'stdout', text: ['text\n']}, + { + output_type: 'execute_result', + data: { + 'text/html': ['HTML output'], + 'text/plain': ['HTML output'], + }, + }, + { + output_type: 'execute_result', + data: { + 'image/png': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + }, + }, + ], + }, + ], + metadata: {}, + nbformat: 4, + nbformat_minor: 5, + }); + + // Create all files in a single commit (fast, no race condition) + await apiCreateFiles(request, owner, repoName, { + 'test.ipynb': mainNotebook, + 'no-output.ipynb': noOutputNotebook, + 'error.ipynb': errorNotebook, + 'mixed.ipynb': mixedNotebook, + }); + + // Test 1: Main notebook rendering + await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + + let iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + + let frame = page.frameLocator('iframe.external-render-iframe'); + let viewer = frame.locator('#frontend-render-viewer'); + + await Promise.all([ + expect(viewer.locator('.cell.markdown h1')).toContainText('Test Notebook'), + expect(viewer.locator('.cell.markdown strong')).toContainText('markdown'), + expect(viewer.locator('.cell.code .input code').first()).toContainText('print("Hello World")'), + expect(viewer.locator('.cell.code .output pre').first()).toContainText('Hello World'), + expect(viewer.locator('.cell.code .output img')).toBeVisible(), + expect(viewer.locator('.cell.code .input .prompt').first()).toContainText('In [1]:'), + expect(viewer.locator('.cell.code .output .prompt').first()).toContainText('Out[2]:'), + expect(viewer.locator('.cell.code')).toHaveCount(3), + ]); + + await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(200); + await assertFlushWithParent(iframe, page.locator('.file-view')); + await assertNoJsError(page); + + // Test 2: No outputs + await page.goto(`/${owner}/${repoName}/src/branch/main/no-output.ipynb`); + + iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + + frame = page.frameLocator('iframe.external-render-iframe'); + viewer = frame.locator('#frontend-render-viewer'); + + await Promise.all([ + expect(viewer.locator('.cell.code')).toBeVisible(), + expect(viewer.locator('.cell.code .output')).toBeHidden(), + ]); + await assertNoJsError(page); + + // Test 3: Error output + await page.goto(`/${owner}/${repoName}/src/branch/main/error.ipynb`); + + iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + + frame = page.frameLocator('iframe.external-render-iframe'); + viewer = frame.locator('#frontend-render-viewer'); + + await expect(viewer.locator('.error-output')).toContainText('ValueError: Test error'); + await assertNoJsError(page); + + // Test 4: Mixed outputs + await page.goto(`/${owner}/${repoName}/src/branch/main/mixed.ipynb`); + + iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + + frame = page.frameLocator('iframe.external-render-iframe'); + viewer = frame.locator('#frontend-render-viewer'); + + await Promise.all([ + expect(viewer.locator('.cell.code .output pre').first()).toContainText('text'), + expect(viewer.locator('.cell.code .output b')).toContainText('HTML output'), + expect(viewer.locator('.cell.code .output img')).toBeVisible(), + ]); + await assertNoJsError(page); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); diff --git a/web_src/css/features/jupyter.css b/web_src/css/features/jupyter.css new file mode 100644 index 0000000000000..f407acd1d6795 --- /dev/null +++ b/web_src/css/features/jupyter.css @@ -0,0 +1,182 @@ +.jupyter-notebook { + padding: 20px; + background: var(--color-body); + color: var(--color-text); + font-family: inherit; +} + +/* Cell containers */ +.jupyter-notebook .cell { + margin-bottom: 20px; + overflow: hidden; +} + +/* Markdown cells */ +.jupyter-notebook .cell.markdown { + background: var(--color-body); + border: none; +} + +.jupyter-notebook .cell.markdown .input { + padding: 6px 12px; + line-height: 1.6; + background: var(--color-body); + color: var(--color-text); +} + +.jupyter-notebook .cell.markdown h1, +.jupyter-notebook .cell.markdown h2, +.jupyter-notebook .cell.markdown h3 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: var(--font-weight-semibold); + line-height: 1.25; + color: var(--color-text); +} + +.jupyter-notebook .cell.markdown h1 { + font-size: 1.875em; + border-bottom: 1px solid var(--color-secondary-alpha-20); + padding-bottom: 0.3em; +} + +.jupyter-notebook .cell.markdown h2 { + font-size: 1.5em; +} + +.jupyter-notebook .cell.markdown h3 { + font-size: 1.25em; +} + +.jupyter-notebook .cell.markdown p { + margin-top: 0; + margin-bottom: 16px; +} + +.jupyter-notebook .cell.markdown code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + background-color: var(--color-secondary-alpha-20); + border-radius: 3px; + font-family: var(--fonts-monospace); +} + +.jupyter-notebook .cell.markdown table { + border-collapse: collapse; + width: 100%; + margin: 16px 0; + font-size: 13px; +} + +.jupyter-notebook .cell.markdown table th, +.jupyter-notebook .cell.markdown table td { + border: 1px solid var(--color-secondary); + padding: 6px 13px; + text-align: left; +} + +.jupyter-notebook .cell.markdown table th { + background: var(--color-secondary-alpha-20); + font-weight: var(--font-weight-semibold); +} + +.jupyter-notebook .cell.markdown table tr:nth-child(even) { + background: var(--color-secondary-alpha-10); +} + +/* Code cells */ +.jupyter-notebook .cell.code { + background: transparent; + border: none; +} + +.jupyter-notebook .cell.code .input { + display: flex; + background-color: var(--color-code-bg); + border: 1px solid var(--color-secondary-alpha-20); + border-radius: 4px; +} + +.jupyter-notebook .cell.code .prompt { + padding: 10px 16px; + color: var(--color-text-light-2); + font-family: var(--fonts-monospace); + font-size: 13px; + white-space: nowrap; + user-select: none; + min-width: 80px; +} + +.jupyter-notebook .cell.code .input pre { + flex: 1; + margin: 0; + padding: 10px 16px 10px 0; + font-family: var(--fonts-monospace); + font-size: 13px; + line-height: 1.5; + overflow-x: auto; + color: var(--color-text); + background: transparent; +} + +.jupyter-notebook .cell.code .input code { + display: block; + font-family: var(--fonts-monospace); +} + +/* Code outputs */ +.jupyter-notebook .cell.code .output { + background: var(--color-body); + color: var(--color-text); +} + +.jupyter-notebook .cell.code .output .prompt { + padding: 10px 16px; + color: var(--color-text-light-2); + font-family: var(--fonts-monospace); + font-size: 13px; + min-width: 80px; +} + +.jupyter-notebook .cell.code .output pre { + margin: 0; + padding: 10px 16px; + font-family: var(--fonts-monospace); + font-size: 13px; + line-height: 1.5; + overflow-x: auto; + color: var(--color-text); + background: var(--color-body); + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.jupyter-notebook .cell.code .output table { + border-collapse: collapse; + margin: 10px 16px; + font-size: 13px; +} + +.jupyter-notebook .cell.code .output table th, +.jupyter-notebook .cell.code .output table td { + border: 1px solid var(--color-secondary); + padding: 6px 13px; + text-align: left; +} + +.jupyter-notebook .cell.code .output table th { + background: var(--color-secondary-alpha-20); + font-weight: var(--font-weight-semibold); +} + +.jupyter-notebook .cell.code .output table tr:nth-child(even) { + background: var(--color-secondary-alpha-10); +} + +.jupyter-notebook .cell.code .output img { + max-width: 100%; + height: auto; + display: block; + margin: 10px 16px; +} diff --git a/web_src/css/index.css b/web_src/css/index.css index c23e3e1c19ff8..0d19cd8fc931c 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -48,6 +48,7 @@ @import "./features/expander.css"; @import "./features/cropper.css"; @import "./features/console.css"; +@import "./features/jupyter.css"; @import "./features/captcha.css"; @import "./markup/content.css"; diff --git a/web_src/js/external-render-frontend.ts b/web_src/js/external-render-frontend.ts index 9d969bcf90004..bd2c548741297 100644 --- a/web_src/js/external-render-frontend.ts +++ b/web_src/js/external-render-frontend.ts @@ -8,6 +8,7 @@ type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>; const frontendPlugins: Record = { 'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'), 'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'), + 'jupyter-notebook': () => import('./render/plugins/frontend-jupyter-notebook.ts'), }; class Options implements FrontendRenderOptions { diff --git a/web_src/js/render/plugins/frontend-jupyter-notebook.ts b/web_src/js/render/plugins/frontend-jupyter-notebook.ts new file mode 100644 index 0000000000000..562efdcec8243 --- /dev/null +++ b/web_src/js/render/plugins/frontend-jupyter-notebook.ts @@ -0,0 +1,184 @@ +import type {FrontendRenderFunc} from '../plugin.ts'; + +export const frontendRender: FrontendRenderFunc = async (opts) => { + try { + const notebook = JSON.parse(opts.contentString()); + + if (!notebook.cells || !Array.isArray(notebook.cells)) { + throw new Error('Invalid notebook format: missing or invalid cells array'); + } + + const container = document.createElement('div'); + container.className = 'jupyter-notebook'; + + let executionCount = 1; + + for (const cell of notebook.cells) { + if (!cell.cell_type) continue; + + const cellDiv = document.createElement('div'); + cellDiv.className = `cell ${cell.cell_type}`; + + if (cell.cell_type === 'markdown') { + const inputDiv = document.createElement('div'); + inputDiv.className = 'input'; + const source = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || ''); + inputDiv.innerHTML = source; + cellDiv.append(inputDiv); + } else if (cell.cell_type === 'code') { + const inputDiv = document.createElement('div'); + inputDiv.className = 'input'; + + const prompt = document.createElement('div'); + prompt.className = 'prompt'; + prompt.textContent = `In [${cell.execution_count || executionCount}]:`; + inputDiv.append(prompt); + + const pre = document.createElement('pre'); + const code = document.createElement('code'); + code.className = 'language-python'; + const source = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || ''); + code.textContent = source; + pre.append(code); + inputDiv.append(pre); + cellDiv.append(inputDiv); + + if (cell.outputs && Array.isArray(cell.outputs) && cell.outputs.length > 0) { + const outputDiv = document.createElement('div'); + outputDiv.className = 'output'; + + const hasExecutionResult = cell.outputs.some((o: any) => o.output_type === 'execute_result'); + if (hasExecutionResult) { + const outPrompt = document.createElement('div'); + outPrompt.className = 'prompt'; + outPrompt.textContent = `Out[${cell.execution_count || executionCount}]:`; + outputDiv.append(outPrompt); + } + + for (const output of cell.outputs) { + try { + if (output.data) { + if (output.data['image/png']) { + const img = document.createElement('img'); + const imgData = Array.isArray(output.data['image/png']) ? + output.data['image/png'].join('') : output.data['image/png']; + img.src = `data:image/png;base64,${imgData}`; + img.style.maxWidth = '100%'; + outputDiv.append(img); + } else if (output.data['image/jpeg']) { + const img = document.createElement('img'); + const imgData = Array.isArray(output.data['image/jpeg']) ? + output.data['image/jpeg'].join('') : output.data['image/jpeg']; + img.src = `data:image/jpeg;base64,${imgData}`; + img.style.maxWidth = '100%'; + outputDiv.append(img); + } else if (output.data['image/svg+xml']) { + const svgDiv = document.createElement('div'); + const svgData = Array.isArray(output.data['image/svg+xml']) ? + output.data['image/svg+xml'].join('') : output.data['image/svg+xml']; + svgDiv.innerHTML = svgData; + outputDiv.append(svgDiv); + } else if (output.data['text/html']) { + const htmlDiv = document.createElement('div'); + const htmlData = Array.isArray(output.data['text/html']) ? + output.data['text/html'].join('') : output.data['text/html']; + htmlDiv.innerHTML = htmlData; + outputDiv.append(htmlDiv); + } else if (output.data['application/javascript']) { + const jsDiv = document.createElement('div'); + jsDiv.className = 'js-output-warning'; + jsDiv.textContent = '[JavaScript output - execution disabled for security]'; + jsDiv.style.color = 'var(--color-text-light-2)'; + jsDiv.style.fontStyle = 'italic'; + outputDiv.append(jsDiv); + } else if (output.data['application/vnd.plotly.v1+json']) { + const plotlyDiv = document.createElement('div'); + plotlyDiv.className = 'plotly-output-warning'; + plotlyDiv.textContent = '[Plotly output - interactive plots not supported]'; + plotlyDiv.style.color = 'var(--color-text-light-2)'; + plotlyDiv.style.fontStyle = 'italic'; + outputDiv.append(plotlyDiv); + } else if (output.data['application/vnd.jupyter.widget-view+json']) { + const widgetDiv = document.createElement('div'); + widgetDiv.className = 'widget-output-warning'; + widgetDiv.textContent = '[Jupyter widget - interactive widgets not supported]'; + widgetDiv.style.color = 'var(--color-text-light-2)'; + widgetDiv.style.fontStyle = 'italic'; + outputDiv.append(widgetDiv); + } else if (output.data['text/latex']) { + const latex = Array.isArray(output.data['text/latex']) ? + output.data['text/latex'].join('') : output.data['text/latex']; + const pre = document.createElement('pre'); + const mathCode = document.createElement('code'); + mathCode.className = 'language-math display'; + mathCode.textContent = latex.replace(/^\$\$|\$\$$/g, ''); + pre.append(mathCode); + outputDiv.append(pre); + } else if (output.data['text/plain']) { + const textPre = document.createElement('pre'); + const plainText = Array.isArray(output.data['text/plain']) ? + output.data['text/plain'].join('') : output.data['text/plain']; + textPre.textContent = plainText; + outputDiv.append(textPre); + } + } else if (output.text) { + const textPre = document.createElement('pre'); + const text = Array.isArray(output.text) ? output.text.join('') : output.text; + textPre.textContent = text; + outputDiv.append(textPre); + } else if (output.output_type === 'stream' && output.name) { + const streamPre = document.createElement('pre'); + streamPre.className = `stream-${output.name}`; + const streamText = Array.isArray(output.text) ? output.text.join('') : (output.text || ''); + streamPre.textContent = streamText; + outputDiv.append(streamPre); + } else if (output.output_type === 'error') { + const errorPre = document.createElement('pre'); + errorPre.className = 'error-output'; + errorPre.style.color = '#d32f2f'; + const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : + (output.ename && output.evalue ? `${output.ename}: ${output.evalue}` : 'Error'); + errorPre.textContent = traceback; + outputDiv.append(errorPre); + } + } catch (outputError) { + console.warn('Failed to render output:', outputError); + const errorDiv = document.createElement('div'); + errorDiv.textContent = '[Output rendering failed]'; + errorDiv.style.color = 'var(--color-text-light-2)'; + errorDiv.style.fontStyle = 'italic'; + outputDiv.append(errorDiv); + } + } + + if (outputDiv.children.length > 0) { + cellDiv.append(outputDiv); + } + } + + executionCount++; + } + + container.append(cellDiv); + } + + opts.container.append(container); + + const {initMarkupCodeMath} = await import('../../markup/math.ts'); + await initMarkupCodeMath(container); + + return true; + } catch (error) { + console.error('Jupyter notebook rendering failed:', error); + const errorDiv = document.createElement('div'); + errorDiv.style.padding = '20px'; + errorDiv.style.color = '#d32f2f'; + const errorTitle = document.createElement('strong'); + errorTitle.textContent = 'Failed to render notebook:'; + errorDiv.append(errorTitle); + errorDiv.append(document.createElement('br')); + errorDiv.append(document.createTextNode(error.message)); + opts.container.append(errorDiv); + return false; + } +}; From 53cbc734a5774c2c75624b5c0d986f9ae5d6bbe7 Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Mon, 27 Apr 2026 06:21:34 +0000 Subject: [PATCH 22/29] Add Jupyter Notebook (.ipynb) rendering support --- modules/markup/external/frontend.go | 53 ---- tests/e2e/jupyter-render.test.ts | 253 +++--------------- web_src/css/features/jupyter.css | 48 ++-- .../plugins/frontend-jupyter-notebook.ts | 76 ++++-- 4 files changed, 126 insertions(+), 304 deletions(-) diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go index a68d265bd421e..7327503d28a9b 100644 --- a/modules/markup/external/frontend.go +++ b/modules/markup/external/frontend.go @@ -4,16 +4,12 @@ package external import ( - "bytes" "encoding/base64" "io" - "strings" "unicode/utf8" "code.gitea.io/gitea/modules/htmlutil" - "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -62,46 +58,6 @@ func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRend return ret } -type notebookCell struct { - CellType string `json:"cell_type"` - Source []string `json:"source"` - Outputs []map[string]any `json:"outputs,omitempty"` -} - -type notebookData struct { - Cells []notebookCell `json:"cells"` - Metadata map[string]any `json:"metadata,omitempty"` - Nbformat int `json:"nbformat,omitempty"` - NbformatMinor int `json:"nbformat_minor,omitempty"` -} - -func preprocessJupyterNotebook(ctx *markup.RenderContext, input io.Reader) ([]byte, error) { - content, err := io.ReadAll(input) - if err != nil { - return nil, err - } - - var nb notebookData - if err := json.Unmarshal(content, &nb); err != nil { - return content, nil - } - - for i := range nb.Cells { - if nb.Cells[i].CellType == "markdown" { - var sourceBuilder strings.Builder - for _, line := range nb.Cells[i].Source { - sourceBuilder.WriteString(line) - } - var buf bytes.Buffer - if err := markdown.RenderRaw(ctx, bytes.NewReader([]byte(sourceBuilder.String())), &buf); err == nil { - nb.Cells[i].Source = []string{buf.String()} - } - } - } - - return json.Marshal(nb) -} - func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { if ctx.RenderOptions.StandalonePageOptions == nil { opts := p.GetExternalRendererOptions() @@ -113,13 +69,6 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou return err } - if p.name == "jupyter-notebook" { - preprocessed, err := preprocessJupyterNotebook(ctx, bytes.NewReader(content)) - if err == nil { - content = preprocessed - } - } - contentEncoding, contentString := "text", util.UnsafeBytesToString(content) if !utf8.Valid(content) { contentEncoding = "base64" @@ -132,7 +81,6 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou -
@@ -140,7 +88,6 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou `, - public.AssetURI("css/index.css"), p.name, ctx.RenderOptions.RelativePath, contentEncoding, contentString, public.AssetURI("js/external-render-frontend.js")) diff --git a/tests/e2e/jupyter-render.test.ts b/tests/e2e/jupyter-render.test.ts index 6f5dc14c1446d..dfa62ed9157e4 100644 --- a/tests/e2e/jupyter-render.test.ts +++ b/tests/e2e/jupyter-render.test.ts @@ -1,223 +1,54 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import type {APIRequestContext} from '@playwright/test'; -import {login, apiCreateRepo, apiDeleteRepo, assertFlushWithParent, assertNoJsError, randomString, baseUrl, apiHeaders} from './utils.ts'; - -// Helper to create multiple files in a single commit -async function apiCreateFiles(request: APIRequestContext, owner: string, repo: string, files: Record) { - const branch = 'main'; - - // Get the latest commit SHA - const branchInfo = await request.get(`${baseUrl()}/api/v1/repos/${owner}/${repo}/branches/${branch}`); - const branchData = await branchInfo.json(); - const latestCommitSha = branchData.commit.id; - - // Create file changes - const fileChanges = Object.entries(files).map(([path, content]) => ({ - operation: 'create', - path, - content: globalThis.btoa(content), - })); - - // Create all files in one commit - await request.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents`, { - headers: apiHeaders(), - data: { - branch, - files: fileChanges, - message: 'Add test notebooks', - sha: latestCommitSha, - }, - }); -} - -test('jupyter notebook - all scenarios', async ({page, request}) => { - test.setTimeout(25000); - const repoName = `e2e-jupyter-all-${randomString(8)}`; - const owner = env.GITEA_TEST_E2E_USER; - - await Promise.all([ - apiCreateRepo(request, {name: repoName}), - login(page), - ]); - - try { - // Define all notebooks - const mainNotebook = JSON.stringify({ +import {login, apiCreateRepo, apiCreateFile, randomString} from './utils.ts'; + +test.describe('jupyter notebook rendering', () => { + let repoName: string; + let owner: string; + + test.beforeAll(async ({request}) => { + repoName = `e2e-jupyter-${randomString(8)}`; + owner = env.GITEA_TEST_E2E_USER; + + await apiCreateRepo(request, {name: repoName}); + + // Single comprehensive test notebook + const notebook = JSON.stringify({ cells: [ - { - cell_type: 'markdown', - source: ['# Test Notebook\n', 'This is **markdown** with `code`.'], - }, - { - cell_type: 'code', - execution_count: 1, - source: ['print("Hello World")'], - outputs: [{output_type: 'stream', name: 'stdout', text: ['Hello World\n']}], - }, - { - cell_type: 'code', - execution_count: 2, - source: ['import matplotlib.pyplot as plt'], - outputs: [{ - output_type: 'execute_result', - data: { - 'image/png': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - }, - }], - }, - { - cell_type: 'code', - execution_count: 3, - source: ['x = 5'], - outputs: [{ - output_type: 'execute_result', - data: { - 'text/latex': ['$$x^2 + y^2 = z^2$$'], - 'text/plain': ['x^2 + y^2 = z^2'], - }, - }], - }, + {cell_type: 'markdown', source: ['# Test\n', '**bold**']}, + {cell_type: 'code', execution_count: 1, source: ['print("Hello")'], outputs: [{output_type: 'stream', name: 'stdout', text: ['Hello\n']}]}, + {cell_type: 'code', execution_count: 2, source: ['x'], outputs: [{output_type: 'execute_result', data: {'image/png': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='}}]}, + {cell_type: 'code', source: ['# No output'], outputs: []}, + {cell_type: 'code', source: ['err'], outputs: [{output_type: 'error', ename: 'ValueError', evalue: 'Test', traceback: ['ValueError: Test']}]}, + {cell_type: 'code', source: ['mixed'], outputs: [{output_type: 'stream', name: 'stdout', text: ['text\n']}, {output_type: 'execute_result', data: {'text/html': ['HTML']}}]}, ], - metadata: {}, - nbformat: 4, - nbformat_minor: 5, + metadata: {}, nbformat: 4, nbformat_minor: 5, }); - const noOutputNotebook = JSON.stringify({ - cells: [ - { - cell_type: 'code', - source: ['# Code with no output'], - outputs: [], - }, - ], - metadata: {}, - nbformat: 4, - nbformat_minor: 5, - }); - - const errorNotebook = JSON.stringify({ - cells: [ - { - cell_type: 'code', - source: ['raise ValueError("Test error")'], - outputs: [{ - output_type: 'error', - ename: 'ValueError', - evalue: 'Test error', - traceback: ['ValueError: Test error'], - }], - }, - ], - metadata: {}, - nbformat: 4, - nbformat_minor: 5, - }); - - const mixedNotebook = JSON.stringify({ - cells: [ - { - cell_type: 'code', - source: ['print("text")'], - outputs: [ - {output_type: 'stream', name: 'stdout', text: ['text\n']}, - { - output_type: 'execute_result', - data: { - 'text/html': ['HTML output'], - 'text/plain': ['HTML output'], - }, - }, - { - output_type: 'execute_result', - data: { - 'image/png': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - }, - }, - ], - }, - ], - metadata: {}, - nbformat: 4, - nbformat_minor: 5, - }); - - // Create all files in a single commit (fast, no race condition) - await apiCreateFiles(request, owner, repoName, { - 'test.ipynb': mainNotebook, - 'no-output.ipynb': noOutputNotebook, - 'error.ipynb': errorNotebook, - 'mixed.ipynb': mixedNotebook, - }); + await apiCreateFile(request, owner, repoName, 'test.ipynb', notebook); + }); - // Test 1: Main notebook rendering + test('renders markdown cells', async ({page}) => { + await login(page); await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await expect(page.frameLocator('iframe.external-render-iframe').locator('.cell.markdown strong')).toBeVisible(); + }); - let iframe = page.locator('iframe.external-render-iframe'); - await expect(iframe).toBeVisible(); - - let frame = page.frameLocator('iframe.external-render-iframe'); - let viewer = frame.locator('#frontend-render-viewer'); - - await Promise.all([ - expect(viewer.locator('.cell.markdown h1')).toContainText('Test Notebook'), - expect(viewer.locator('.cell.markdown strong')).toContainText('markdown'), - expect(viewer.locator('.cell.code .input code').first()).toContainText('print("Hello World")'), - expect(viewer.locator('.cell.code .output pre').first()).toContainText('Hello World'), - expect(viewer.locator('.cell.code .output img')).toBeVisible(), - expect(viewer.locator('.cell.code .input .prompt').first()).toContainText('In [1]:'), - expect(viewer.locator('.cell.code .output .prompt').first()).toContainText('Out[2]:'), - expect(viewer.locator('.cell.code')).toHaveCount(3), - ]); - - await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(200); - await assertFlushWithParent(iframe, page.locator('.file-view')); - await assertNoJsError(page); - - // Test 2: No outputs - await page.goto(`/${owner}/${repoName}/src/branch/main/no-output.ipynb`); - - iframe = page.locator('iframe.external-render-iframe'); - await expect(iframe).toBeVisible(); - - frame = page.frameLocator('iframe.external-render-iframe'); - viewer = frame.locator('#frontend-render-viewer'); - - await Promise.all([ - expect(viewer.locator('.cell.code')).toBeVisible(), - expect(viewer.locator('.cell.code .output')).toBeHidden(), - ]); - await assertNoJsError(page); - - // Test 3: Error output - await page.goto(`/${owner}/${repoName}/src/branch/main/error.ipynb`); - - iframe = page.locator('iframe.external-render-iframe'); - await expect(iframe).toBeVisible(); - - frame = page.frameLocator('iframe.external-render-iframe'); - viewer = frame.locator('#frontend-render-viewer'); - - await expect(viewer.locator('.error-output')).toContainText('ValueError: Test error'); - await assertNoJsError(page); - - // Test 4: Mixed outputs - await page.goto(`/${owner}/${repoName}/src/branch/main/mixed.ipynb`); - - iframe = page.locator('iframe.external-render-iframe'); - await expect(iframe).toBeVisible(); + test('renders code cells with outputs', async ({page}) => { + await login(page); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await expect(page.frameLocator('iframe.external-render-iframe').locator('.cell.code .output pre').first()).toBeVisible(); + }); - frame = page.frameLocator('iframe.external-render-iframe'); - viewer = frame.locator('#frontend-render-viewer'); + test('renders image outputs', async ({page}) => { + await login(page); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await expect(page.frameLocator('iframe.external-render-iframe').locator('.cell.code .output img')).toBeVisible(); + }); - await Promise.all([ - expect(viewer.locator('.cell.code .output pre').first()).toContainText('text'), - expect(viewer.locator('.cell.code .output b')).toContainText('HTML output'), - expect(viewer.locator('.cell.code .output img')).toBeVisible(), - ]); - await assertNoJsError(page); - } finally { - await apiDeleteRepo(request, owner, repoName); - } + test('renders error outputs', async ({page}) => { + await login(page); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await expect(page.frameLocator('iframe.external-render-iframe').locator('.error-output')).toBeVisible(); + }); }); diff --git a/web_src/css/features/jupyter.css b/web_src/css/features/jupyter.css index f407acd1d6795..5762210651d4a 100644 --- a/web_src/css/features/jupyter.css +++ b/web_src/css/features/jupyter.css @@ -1,3 +1,4 @@ +/* Override notebookjs default styles with Gitea theme */ .jupyter-notebook { padding: 20px; background: var(--color-body); @@ -22,6 +23,7 @@ line-height: 1.6; background: var(--color-body); color: var(--color-text); + margin-left: 90px; } .jupyter-notebook .cell.markdown h1, @@ -91,28 +93,35 @@ border: none; } -.jupyter-notebook .cell.code .input { +.jupyter-notebook .cell.code .input-wrapper, +.jupyter-notebook .cell.code .output-wrapper { display: flex; - background-color: var(--color-code-bg); - border: 1px solid var(--color-secondary-alpha-20); - border-radius: 4px; + align-items: flex-start; } .jupyter-notebook .cell.code .prompt { - padding: 10px 16px; + padding: 10px 10px 10px 0; color: var(--color-text-light-2); font-family: var(--fonts-monospace); font-size: 13px; white-space: nowrap; user-select: none; - min-width: 80px; + text-align: right; + width: 80px; + flex-shrink: 0; } -.jupyter-notebook .cell.code .input pre { +.jupyter-notebook .cell.code .input { flex: 1; + background-color: var(--color-code-bg, #f6f8fa); + border: 1px solid var(--color-secondary-alpha-20, #d0d7de); + border-radius: 4px; +} + +.jupyter-notebook .cell.code .input pre { margin: 0; - padding: 10px 16px 10px 0; - font-family: var(--fonts-monospace); + padding: 10px 16px; + font-family: var(--fonts-monospace, monospace); font-size: 13px; line-height: 1.5; overflow-x: auto; @@ -122,23 +131,16 @@ .jupyter-notebook .cell.code .input code { display: block; - font-family: var(--fonts-monospace); + font-family: inherit; } /* Code outputs */ .jupyter-notebook .cell.code .output { + flex: 1; background: var(--color-body); color: var(--color-text); } -.jupyter-notebook .cell.code .output .prompt { - padding: 10px 16px; - color: var(--color-text-light-2); - font-family: var(--fonts-monospace); - font-size: 13px; - min-width: 80px; -} - .jupyter-notebook .cell.code .output pre { margin: 0; padding: 10px 16px; @@ -156,6 +158,7 @@ border-collapse: collapse; margin: 10px 16px; font-size: 13px; + max-width: 100%; } .jupyter-notebook .cell.code .output table th, @@ -175,8 +178,13 @@ } .jupyter-notebook .cell.code .output img { - max-width: 100%; + max-width: 90%; height: auto; display: block; - margin: 10px 16px; + margin: 10px 0; +} + +.jupyter-notebook .cell.code .output table img { + margin: 0; + width: auto; } diff --git a/web_src/js/render/plugins/frontend-jupyter-notebook.ts b/web_src/js/render/plugins/frontend-jupyter-notebook.ts index 562efdcec8243..edaca1263f174 100644 --- a/web_src/js/render/plugins/frontend-jupyter-notebook.ts +++ b/web_src/js/render/plugins/frontend-jupyter-notebook.ts @@ -1,4 +1,22 @@ import type {FrontendRenderFunc} from '../plugin.ts'; +import '../../../css/features/jupyter.css'; + +// Simple markdown to HTML converter for notebook cells +function renderMarkdown(markdown: string): string { + return markdown + // Headers + .replace(/^### (.*$)/gim, '

$1

') + .replace(/^## (.*$)/gim, '

$1

') + .replace(/^# (.*$)/gim, '

$1

') + // Bold + .replace(/\*\*(.+?)\*\*/g, '$1') + // Italic + .replace(/\*(.+?)\*/g, '$1') + // Inline code + .replace(/`(.+?)`/g, '$1') + // Line breaks + .replace(/\n/g, '
'); +} export const frontendRender: FrontendRenderFunc = async (opts) => { try { @@ -21,18 +39,21 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { if (cell.cell_type === 'markdown') { const inputDiv = document.createElement('div'); - inputDiv.className = 'input'; + inputDiv.className = 'input markup'; const source = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || ''); - inputDiv.innerHTML = source; + inputDiv.innerHTML = renderMarkdown(source); cellDiv.append(inputDiv); } else if (cell.cell_type === 'code') { - const inputDiv = document.createElement('div'); - inputDiv.className = 'input'; + const inputWrapper = document.createElement('div'); + inputWrapper.className = 'input-wrapper'; const prompt = document.createElement('div'); - prompt.className = 'prompt'; + prompt.className = 'prompt input-prompt'; prompt.textContent = `In [${cell.execution_count || executionCount}]:`; - inputDiv.append(prompt); + inputWrapper.append(prompt); + + const inputDiv = document.createElement('div'); + inputDiv.className = 'input'; const pre = document.createElement('pre'); const code = document.createElement('code'); @@ -41,33 +62,38 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { code.textContent = source; pre.append(code); inputDiv.append(pre); - cellDiv.append(inputDiv); + inputWrapper.append(inputDiv); + cellDiv.append(inputWrapper); if (cell.outputs && Array.isArray(cell.outputs) && cell.outputs.length > 0) { - const outputDiv = document.createElement('div'); - outputDiv.className = 'output'; + const outputWrapper = document.createElement('div'); + outputWrapper.className = 'output-wrapper'; const hasExecutionResult = cell.outputs.some((o: any) => o.output_type === 'execute_result'); + + const outPrompt = document.createElement('div'); + outPrompt.className = 'prompt output-prompt'; if (hasExecutionResult) { - const outPrompt = document.createElement('div'); - outPrompt.className = 'prompt'; outPrompt.textContent = `Out[${cell.execution_count || executionCount}]:`; - outputDiv.append(outPrompt); } + outputWrapper.append(outPrompt); + + const outputDiv = document.createElement('div'); + outputDiv.className = 'output'; for (const output of cell.outputs) { try { if (output.data) { if (output.data['image/png']) { const img = document.createElement('img'); - const imgData = Array.isArray(output.data['image/png']) ? + const imgData = Array.isArray(output.data['image/png']) ? output.data['image/png'].join('') : output.data['image/png']; img.src = `data:image/png;base64,${imgData}`; img.style.maxWidth = '100%'; outputDiv.append(img); } else if (output.data['image/jpeg']) { const img = document.createElement('img'); - const imgData = Array.isArray(output.data['image/jpeg']) ? + const imgData = Array.isArray(output.data['image/jpeg']) ? output.data['image/jpeg'].join('') : output.data['image/jpeg']; img.src = `data:image/jpeg;base64,${imgData}`; img.style.maxWidth = '100%'; @@ -79,11 +105,20 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { svgDiv.innerHTML = svgData; outputDiv.append(svgDiv); } else if (output.data['text/html']) { + const wrapperDiv = document.createElement('div'); + wrapperDiv.style.overflowX = 'auto'; + wrapperDiv.style.maxWidth = '100%'; const htmlDiv = document.createElement('div'); const htmlData = Array.isArray(output.data['text/html']) ? output.data['text/html'].join('') : output.data['text/html']; htmlDiv.innerHTML = htmlData; - outputDiv.append(htmlDiv); + // Ensure images inside HTML outputs are constrained + htmlDiv.querySelectorAll('img').forEach((img) => { + img.style.maxWidth = '100%'; + img.style.height = 'auto'; + }); + wrapperDiv.append(htmlDiv); + outputDiv.append(wrapperDiv); } else if (output.data['application/javascript']) { const jsDiv = document.createElement('div'); jsDiv.className = 'js-output-warning'; @@ -135,8 +170,8 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { } else if (output.output_type === 'error') { const errorPre = document.createElement('pre'); errorPre.className = 'error-output'; - errorPre.style.color = '#d32f2f'; - const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : + errorPre.style.color = 'var(--color-red)'; + const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : (output.ename && output.evalue ? `${output.ename}: ${output.evalue}` : 'Error'); errorPre.textContent = traceback; outputDiv.append(errorPre); @@ -152,7 +187,8 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { } if (outputDiv.children.length > 0) { - cellDiv.append(outputDiv); + outputWrapper.append(outputDiv); + cellDiv.append(outputWrapper); } } @@ -166,13 +202,13 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { const {initMarkupCodeMath} = await import('../../markup/math.ts'); await initMarkupCodeMath(container); - + return true; } catch (error) { console.error('Jupyter notebook rendering failed:', error); const errorDiv = document.createElement('div'); errorDiv.style.padding = '20px'; - errorDiv.style.color = '#d32f2f'; + errorDiv.style.color = 'var(--color-red)'; const errorTitle = document.createElement('strong'); errorTitle.textContent = 'Failed to render notebook:'; errorDiv.append(errorTitle); From 6002070af0c531cd5136af6c2892120aa76c80f4 Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Mon, 27 Apr 2026 06:55:42 +0000 Subject: [PATCH 23/29] ran lint and fmt --- tests/e2e/jupyter-render.test.ts | 4 +- web_src/css/features/jupyter.css | 7 +- .../plugins/frontend-jupyter-notebook.ts | 81 ++++++++++++++----- 3 files changed, 66 insertions(+), 26 deletions(-) diff --git a/tests/e2e/jupyter-render.test.ts b/tests/e2e/jupyter-render.test.ts index dfa62ed9157e4..020c2aee04ddc 100644 --- a/tests/e2e/jupyter-render.test.ts +++ b/tests/e2e/jupyter-render.test.ts @@ -9,9 +9,9 @@ test.describe('jupyter notebook rendering', () => { test.beforeAll(async ({request}) => { repoName = `e2e-jupyter-${randomString(8)}`; owner = env.GITEA_TEST_E2E_USER; - + await apiCreateRepo(request, {name: repoName}); - + // Single comprehensive test notebook const notebook = JSON.stringify({ cells: [ diff --git a/web_src/css/features/jupyter.css b/web_src/css/features/jupyter.css index 5762210651d4a..49d2029cb996e 100644 --- a/web_src/css/features/jupyter.css +++ b/web_src/css/features/jupyter.css @@ -23,7 +23,7 @@ line-height: 1.6; background: var(--color-body); color: var(--color-text); - margin-left: 90px; + margin-left: 110px; } .jupyter-notebook .cell.markdown h1, @@ -107,7 +107,7 @@ white-space: nowrap; user-select: none; text-align: right; - width: 80px; + width: 100px; flex-shrink: 0; } @@ -116,6 +116,7 @@ background-color: var(--color-code-bg, #f6f8fa); border: 1px solid var(--color-secondary-alpha-20, #d0d7de); border-radius: 4px; + min-height: 40px; } .jupyter-notebook .cell.code .input pre { @@ -139,6 +140,8 @@ flex: 1; background: var(--color-body); color: var(--color-text); + overflow-x: auto; + min-width: 0; } .jupyter-notebook .cell.code .output pre { diff --git a/web_src/js/render/plugins/frontend-jupyter-notebook.ts b/web_src/js/render/plugins/frontend-jupyter-notebook.ts index edaca1263f174..d67671d51c635 100644 --- a/web_src/js/render/plugins/frontend-jupyter-notebook.ts +++ b/web_src/js/render/plugins/frontend-jupyter-notebook.ts @@ -1,21 +1,58 @@ import type {FrontendRenderFunc} from '../plugin.ts'; import '../../../css/features/jupyter.css'; -// Simple markdown to HTML converter for notebook cells -function renderMarkdown(markdown: string): string { - return markdown +// Simple markdown to HTML converter for notebook cells using DOM methods +function renderMarkdown(markdown: string): HTMLElement { + const container = document.createElement('div'); + + // Split by lines and process + const lines = markdown.split('\n'); + for (const line of lines) { + let element: HTMLElement; + // Headers - .replace(/^### (.*$)/gim, '

$1

') - .replace(/^## (.*$)/gim, '

$1

') - .replace(/^# (.*$)/gim, '

$1

') - // Bold - .replace(/\*\*(.+?)\*\*/g, '$1') - // Italic - .replace(/\*(.+?)\*/g, '$1') - // Inline code - .replace(/`(.+?)`/g, '$1') - // Line breaks - .replace(/\n/g, '
'); + if (line.startsWith('### ')) { + element = document.createElement('h3'); + element.textContent = line.substring(4); + } else if (line.startsWith('## ')) { + element = document.createElement('h2'); + element.textContent = line.substring(3); + } else if (line.startsWith('# ')) { + element = document.createElement('h1'); + element.textContent = line.substring(2); + } else { + element = document.createElement('p'); + // Process inline formatting + processInlineFormatting(element, line); + } + + container.append(element); + } + + return container; +} + +// Process bold, italic, and inline code +function processInlineFormatting(element: HTMLElement, text: string) { + const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/); + + for (const part of parts) { + if (part.startsWith('**') && part.endsWith('**')) { + const strong = document.createElement('strong'); + strong.textContent = part.slice(2, -2); + element.append(strong); + } else if (part.startsWith('*') && part.endsWith('*')) { + const em = document.createElement('em'); + em.textContent = part.slice(1, -1); + element.append(em); + } else if (part.startsWith('`') && part.endsWith('`')) { + const code = document.createElement('code'); + code.textContent = part.slice(1, -1); + element.append(code); + } else if (part) { + element.append(document.createTextNode(part)); + } + } } export const frontendRender: FrontendRenderFunc = async (opts) => { @@ -41,7 +78,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { const inputDiv = document.createElement('div'); inputDiv.className = 'input markup'; const source = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || ''); - inputDiv.innerHTML = renderMarkdown(source); + inputDiv.append(renderMarkdown(source)); cellDiv.append(inputDiv); } else if (cell.cell_type === 'code') { const inputWrapper = document.createElement('div'); @@ -70,7 +107,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { outputWrapper.className = 'output-wrapper'; const hasExecutionResult = cell.outputs.some((o: any) => o.output_type === 'execute_result'); - + const outPrompt = document.createElement('div'); outPrompt.className = 'prompt output-prompt'; if (hasExecutionResult) { @@ -86,14 +123,14 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { if (output.data) { if (output.data['image/png']) { const img = document.createElement('img'); - const imgData = Array.isArray(output.data['image/png']) ? + const imgData = Array.isArray(output.data['image/png']) ? output.data['image/png'].join('') : output.data['image/png']; img.src = `data:image/png;base64,${imgData}`; img.style.maxWidth = '100%'; outputDiv.append(img); } else if (output.data['image/jpeg']) { const img = document.createElement('img'); - const imgData = Array.isArray(output.data['image/jpeg']) ? + const imgData = Array.isArray(output.data['image/jpeg']) ? output.data['image/jpeg'].join('') : output.data['image/jpeg']; img.src = `data:image/jpeg;base64,${imgData}`; img.style.maxWidth = '100%'; @@ -113,10 +150,10 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { output.data['text/html'].join('') : output.data['text/html']; htmlDiv.innerHTML = htmlData; // Ensure images inside HTML outputs are constrained - htmlDiv.querySelectorAll('img').forEach((img) => { + for (const img of htmlDiv.querySelectorAll('img')) { img.style.maxWidth = '100%'; img.style.height = 'auto'; - }); + } wrapperDiv.append(htmlDiv); outputDiv.append(wrapperDiv); } else if (output.data['application/javascript']) { @@ -171,7 +208,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { const errorPre = document.createElement('pre'); errorPre.className = 'error-output'; errorPre.style.color = 'var(--color-red)'; - const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : + const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : (output.ename && output.evalue ? `${output.ename}: ${output.evalue}` : 'Error'); errorPre.textContent = traceback; outputDiv.append(errorPre); @@ -202,7 +239,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { const {initMarkupCodeMath} = await import('../../markup/math.ts'); await initMarkupCodeMath(container); - + return true; } catch (error) { console.error('Jupyter notebook rendering failed:', error); From 79d496a78860023e680a90a168bc6a612e407265 Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Mon, 27 Apr 2026 07:04:51 +0000 Subject: [PATCH 24/29] addressed lint issue --- .../plugins/frontend-jupyter-notebook.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/web_src/js/render/plugins/frontend-jupyter-notebook.ts b/web_src/js/render/plugins/frontend-jupyter-notebook.ts index d67671d51c635..41227464846d1 100644 --- a/web_src/js/render/plugins/frontend-jupyter-notebook.ts +++ b/web_src/js/render/plugins/frontend-jupyter-notebook.ts @@ -4,12 +4,12 @@ import '../../../css/features/jupyter.css'; // Simple markdown to HTML converter for notebook cells using DOM methods function renderMarkdown(markdown: string): HTMLElement { const container = document.createElement('div'); - + // Split by lines and process const lines = markdown.split('\n'); for (const line of lines) { let element: HTMLElement; - + // Headers if (line.startsWith('### ')) { element = document.createElement('h3'); @@ -25,17 +25,17 @@ function renderMarkdown(markdown: string): HTMLElement { // Process inline formatting processInlineFormatting(element, line); } - + container.append(element); } - + return container; } // Process bold, italic, and inline code function processInlineFormatting(element: HTMLElement, text: string) { - const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/); - + const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); + for (const part of parts) { if (part.startsWith('**') && part.endsWith('**')) { const strong = document.createElement('strong'); @@ -55,6 +55,7 @@ function processInlineFormatting(element: HTMLElement, text: string) { } } + export const frontendRender: FrontendRenderFunc = async (opts) => { try { const notebook = JSON.parse(opts.contentString()); @@ -107,7 +108,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { outputWrapper.className = 'output-wrapper'; const hasExecutionResult = cell.outputs.some((o: any) => o.output_type === 'execute_result'); - + const outPrompt = document.createElement('div'); outPrompt.className = 'prompt output-prompt'; if (hasExecutionResult) { @@ -123,14 +124,14 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { if (output.data) { if (output.data['image/png']) { const img = document.createElement('img'); - const imgData = Array.isArray(output.data['image/png']) ? + const imgData = Array.isArray(output.data['image/png']) ? output.data['image/png'].join('') : output.data['image/png']; img.src = `data:image/png;base64,${imgData}`; img.style.maxWidth = '100%'; outputDiv.append(img); } else if (output.data['image/jpeg']) { const img = document.createElement('img'); - const imgData = Array.isArray(output.data['image/jpeg']) ? + const imgData = Array.isArray(output.data['image/jpeg']) ? output.data['image/jpeg'].join('') : output.data['image/jpeg']; img.src = `data:image/jpeg;base64,${imgData}`; img.style.maxWidth = '100%'; @@ -150,10 +151,10 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { output.data['text/html'].join('') : output.data['text/html']; htmlDiv.innerHTML = htmlData; // Ensure images inside HTML outputs are constrained - for (const img of htmlDiv.querySelectorAll('img')) { + htmlDiv.querySelectorAll('img').forEach((img) => { img.style.maxWidth = '100%'; img.style.height = 'auto'; - } + }); wrapperDiv.append(htmlDiv); outputDiv.append(wrapperDiv); } else if (output.data['application/javascript']) { @@ -208,7 +209,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { const errorPre = document.createElement('pre'); errorPre.className = 'error-output'; errorPre.style.color = 'var(--color-red)'; - const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : + const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : (output.ename && output.evalue ? `${output.ename}: ${output.evalue}` : 'Error'); errorPre.textContent = traceback; outputDiv.append(errorPre); @@ -239,7 +240,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { const {initMarkupCodeMath} = await import('../../markup/math.ts'); await initMarkupCodeMath(container); - + return true; } catch (error) { console.error('Jupyter notebook rendering failed:', error); @@ -250,7 +251,8 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { errorTitle.textContent = 'Failed to render notebook:'; errorDiv.append(errorTitle); errorDiv.append(document.createElement('br')); - errorDiv.append(document.createTextNode(error.message)); + const errorMessage = error instanceof Error ? error.message : String(error); + errorDiv.append(document.createTextNode(errorMessage)); opts.container.append(errorDiv); return false; } From 40608b2690e8fe4682454a9829be9b56dda31e2f Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Mon, 27 Apr 2026 07:10:23 +0000 Subject: [PATCH 25/29] fix: lint --- .../plugins/frontend-jupyter-notebook.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/web_src/js/render/plugins/frontend-jupyter-notebook.ts b/web_src/js/render/plugins/frontend-jupyter-notebook.ts index 41227464846d1..796b0a893f90a 100644 --- a/web_src/js/render/plugins/frontend-jupyter-notebook.ts +++ b/web_src/js/render/plugins/frontend-jupyter-notebook.ts @@ -4,12 +4,12 @@ import '../../../css/features/jupyter.css'; // Simple markdown to HTML converter for notebook cells using DOM methods function renderMarkdown(markdown: string): HTMLElement { const container = document.createElement('div'); - + // Split by lines and process const lines = markdown.split('\n'); for (const line of lines) { let element: HTMLElement; - + // Headers if (line.startsWith('### ')) { element = document.createElement('h3'); @@ -25,17 +25,17 @@ function renderMarkdown(markdown: string): HTMLElement { // Process inline formatting processInlineFormatting(element, line); } - + container.append(element); } - + return container; } // Process bold, italic, and inline code function processInlineFormatting(element: HTMLElement, text: string) { - const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); - + const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/); + for (const part of parts) { if (part.startsWith('**') && part.endsWith('**')) { const strong = document.createElement('strong'); @@ -55,7 +55,6 @@ function processInlineFormatting(element: HTMLElement, text: string) { } } - export const frontendRender: FrontendRenderFunc = async (opts) => { try { const notebook = JSON.parse(opts.contentString()); @@ -108,7 +107,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { outputWrapper.className = 'output-wrapper'; const hasExecutionResult = cell.outputs.some((o: any) => o.output_type === 'execute_result'); - + const outPrompt = document.createElement('div'); outPrompt.className = 'prompt output-prompt'; if (hasExecutionResult) { @@ -124,14 +123,14 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { if (output.data) { if (output.data['image/png']) { const img = document.createElement('img'); - const imgData = Array.isArray(output.data['image/png']) ? + const imgData = Array.isArray(output.data['image/png']) ? output.data['image/png'].join('') : output.data['image/png']; img.src = `data:image/png;base64,${imgData}`; img.style.maxWidth = '100%'; outputDiv.append(img); } else if (output.data['image/jpeg']) { const img = document.createElement('img'); - const imgData = Array.isArray(output.data['image/jpeg']) ? + const imgData = Array.isArray(output.data['image/jpeg']) ? output.data['image/jpeg'].join('') : output.data['image/jpeg']; img.src = `data:image/jpeg;base64,${imgData}`; img.style.maxWidth = '100%'; @@ -151,10 +150,10 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { output.data['text/html'].join('') : output.data['text/html']; htmlDiv.innerHTML = htmlData; // Ensure images inside HTML outputs are constrained - htmlDiv.querySelectorAll('img').forEach((img) => { + for (const img of htmlDiv.querySelectorAll('img')) { img.style.maxWidth = '100%'; img.style.height = 'auto'; - }); + } wrapperDiv.append(htmlDiv); outputDiv.append(wrapperDiv); } else if (output.data['application/javascript']) { @@ -209,7 +208,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { const errorPre = document.createElement('pre'); errorPre.className = 'error-output'; errorPre.style.color = 'var(--color-red)'; - const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : + const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : (output.ename && output.evalue ? `${output.ename}: ${output.evalue}` : 'Error'); errorPre.textContent = traceback; outputDiv.append(errorPre); @@ -240,7 +239,7 @@ export const frontendRender: FrontendRenderFunc = async (opts) => { const {initMarkupCodeMath} = await import('../../markup/math.ts'); await initMarkupCodeMath(container); - + return true; } catch (error) { console.error('Jupyter notebook rendering failed:', error); From fa84ca713e8c1c641c7098143212782048827daf Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Mon, 27 Apr 2026 07:24:56 +0000 Subject: [PATCH 26/29] fix: removed jupyter.css from index.css --- web_src/css/index.css | 1 - 1 file changed, 1 deletion(-) diff --git a/web_src/css/index.css b/web_src/css/index.css index 0d19cd8fc931c..c23e3e1c19ff8 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -48,7 +48,6 @@ @import "./features/expander.css"; @import "./features/cropper.css"; @import "./features/console.css"; -@import "./features/jupyter.css"; @import "./features/captcha.css"; @import "./markup/content.css"; From d967030ff038fe8c46f8d0664c12d6826a7b6882 Mon Sep 17 00:00:00 2001 From: "karthik.bhandary" Date: Tue, 28 Apr 2026 07:53:29 +0000 Subject: [PATCH 27/29] fix --- tests/e2e/jupyter-render.test.ts | 6 +- web_src/css/features/jupyter.css | 2 +- .../plugins/frontend-jupyter-notebook.ts | 60 ++++++++++++++++--- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/tests/e2e/jupyter-render.test.ts b/tests/e2e/jupyter-render.test.ts index 020c2aee04ddc..20fe6797fe086 100644 --- a/tests/e2e/jupyter-render.test.ts +++ b/tests/e2e/jupyter-render.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {login, apiCreateRepo, apiCreateFile, randomString} from './utils.ts'; +import {login, apiCreateRepo, apiCreateFile, assertNoJsError, randomString} from './utils.ts'; test.describe('jupyter notebook rendering', () => { let repoName: string; @@ -31,24 +31,28 @@ test.describe('jupyter notebook rendering', () => { test('renders markdown cells', async ({page}) => { await login(page); await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await assertNoJsError(page); await expect(page.frameLocator('iframe.external-render-iframe').locator('.cell.markdown strong')).toBeVisible(); }); test('renders code cells with outputs', async ({page}) => { await login(page); await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await assertNoJsError(page); await expect(page.frameLocator('iframe.external-render-iframe').locator('.cell.code .output pre').first()).toBeVisible(); }); test('renders image outputs', async ({page}) => { await login(page); await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await assertNoJsError(page); await expect(page.frameLocator('iframe.external-render-iframe').locator('.cell.code .output img')).toBeVisible(); }); test('renders error outputs', async ({page}) => { await login(page); await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await assertNoJsError(page); await expect(page.frameLocator('iframe.external-render-iframe').locator('.error-output')).toBeVisible(); }); }); diff --git a/web_src/css/features/jupyter.css b/web_src/css/features/jupyter.css index 49d2029cb996e..41274b77828fc 100644 --- a/web_src/css/features/jupyter.css +++ b/web_src/css/features/jupyter.css @@ -1,4 +1,4 @@ -/* Override notebookjs default styles with Gitea theme */ +/* Gitea styles for Jupyter notebook content */ .jupyter-notebook { padding: 20px; background: var(--color-body); diff --git a/web_src/js/render/plugins/frontend-jupyter-notebook.ts b/web_src/js/render/plugins/frontend-jupyter-notebook.ts index 796b0a893f90a..7c30f4063cd1e 100644 --- a/web_src/js/render/plugins/frontend-jupyter-notebook.ts +++ b/web_src/js/render/plugins/frontend-jupyter-notebook.ts @@ -1,6 +1,43 @@ import type {FrontendRenderFunc} from '../plugin.ts'; import '../../../css/features/jupyter.css'; +// Sanitize HTML by removing dangerous attributes and elements +function sanitizeHtml(element: HTMLElement) { + const dangerousAttrs = ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onmousemove', + 'onmouseenter', 'onmouseleave', 'onfocus', 'onblur', 'onchange', 'onsubmit', 'onkeydown', + 'onkeyup', 'onkeypress', 'onanimationstart', 'onanimationend', 'onbegin', 'onend', 'onrepeat']; + + const walker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT); + const nodes: Element[] = []; + let node: Node | null; + while ((node = walker.nextNode())) { + nodes.push(node as Element); + } + + for (const el of nodes) { + // Remove all on* event handlers + for (const attr of dangerousAttrs) { + el.removeAttribute(attr); + } + + // Remove javascript: and data: URLs from href and src + const urlPattern = /^(javascript|data):/; + const href = el.getAttribute('href'); + if (href && urlPattern.test(href.toLowerCase().trim())) { + el.removeAttribute('href'); + } + const src = el.getAttribute('src'); + if (src && urlPattern.test(src.toLowerCase().trim())) { + el.removeAttribute('src'); + } + + // Remove