From 0861a011929879918284d969f7a895a65a9186c6 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Fri, 19 Dec 2025 17:00:47 +0100 Subject: [PATCH 001/155] [v14.0/forgejo] feat: allow to add pam source from command line (#10485) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10388 The forgejo admin command line allows to deal with all the propose auth mecanism but pam, this PR adds full support for adding and updating pam auth mecanism via the command line without limitation. Co-authored-by: Baptiste Daroussin Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10485 Reviewed-by: Gusted Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- cmd/admin.go | 2 + cmd/admin_auth_pam.go | 145 ++++++++++++++++++ cmd/admin_auth_pam_test.go | 293 +++++++++++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+) create mode 100644 cmd/admin_auth_pam.go create mode 100644 cmd/admin_auth_pam_test.go diff --git a/cmd/admin.go b/cmd/admin.go index 90157e2d5a..60b25eb971 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -64,6 +64,8 @@ func subcmdAuth() *cli.Command { microcmdAuthUpdateLdapBindDn(), microcmdAuthAddLdapSimpleAuth(), microcmdAuthUpdateLdapSimpleAuth(), + microcmdAuthAddPAM(), + microcmdAuthUpdatePAM(), microcmdAuthAddSMTP(), microcmdAuthUpdateSMTP(), microcmdAuthList(), diff --git a/cmd/admin_auth_pam.go b/cmd/admin_auth_pam.go new file mode 100644 index 0000000000..25e32503e0 --- /dev/null +++ b/cmd/admin_auth_pam.go @@ -0,0 +1,145 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + + auth_model "forgejo.org/models/auth" + "forgejo.org/services/auth/source/pam" + + "github.com/urfave/cli/v3" +) + +func pamCLIFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Value: "", + Usage: "Application Name", + }, + &cli.StringFlag{ + Name: "service-name", + Value: "PLAIN", + Usage: "PAM service name", + }, + &cli.StringFlag{ + Name: "email-domain", + Value: "", + Usage: "PAM email domain", + }, + &cli.BoolFlag{ + Name: "skip-local-2fa", + Usage: "Skip 2FA to log on.", + Value: true, + }, + &cli.BoolFlag{ + Name: "active", + Usage: "This Authentication Source is Activated.", + Value: true, + }, + } +} + +func microcmdAuthAddPAM() *cli.Command { + return &cli.Command{ + Name: "add-pam", + Usage: "Add new PAM authentication source", + Before: noDanglingArgs, + Action: newAuthService().addPAM, + Flags: pamCLIFlags(), + } +} + +func microcmdAuthUpdatePAM() *cli.Command { + return &cli.Command{ + Name: "update-pam", + Usage: "Update existing PAM authentication source", + Before: noDanglingArgs, + Action: newAuthService().updatePAM, + Flags: append(pamCLIFlags()[:1], append([]cli.Flag{idFlag()}, pamCLIFlags()[1:]...)...), + } +} + +func parsePAMConfig(_ context.Context, c *cli.Command) *pam.Source { + return &pam.Source{ + ServiceName: c.String("service-name"), + EmailDomain: c.String("email-domain"), + SkipLocalTwoFA: c.Bool("skip-local-2fa"), + } +} + +func (a *authService) addPAM(ctx context.Context, c *cli.Command) error { + ctx, cancel := installSignals(ctx) + defer cancel() + + if err := a.initDB(ctx); err != nil { + return err + } + + if !c.IsSet("name") || len(c.String("name")) == 0 { + return errors.New("name must be set") + } + if !c.IsSet("service-name") || len(c.String("service-name")) == 0 { + return errors.New("service-name must be set") + } + active := true + if c.IsSet("active") { + active = c.Bool("active") + } + + config := parsePAMConfig(ctx, c) + + return a.createAuthSource(ctx, &auth_model.Source{ + Type: auth_model.PAM, + Name: c.String("name"), + IsActive: active, + Cfg: config, + }) +} + +func (a *authService) updatePAM(ctx context.Context, c *cli.Command) error { + if !c.IsSet("id") { + return errors.New("--id flag is missing") + } + + ctx, cancel := installSignals(ctx) + defer cancel() + + if err := a.initDB(ctx); err != nil { + return err + } + + source, err := a.getAuthSource(ctx, c, auth_model.PAM) + if err != nil { + return err + } + + pamConfig := source.Cfg.(*pam.Source) + + if c.IsSet("name") { + source.Name = c.String("name") + } + + if c.IsSet("service-name") { + pamConfig.ServiceName = c.String("service-name") + } + + if c.IsSet("email-domain") { + pamConfig.EmailDomain = c.String("email-domain") + } + + if c.IsSet("skip-local-2fa") { + pamConfig.SkipLocalTwoFA = c.Bool("skip-local-2fa") + } + + if c.IsSet("active") { + source.IsActive = c.Bool("active") + } + + source.Cfg = pamConfig + + return a.updateAuthSource(ctx, source) +} diff --git a/cmd/admin_auth_pam_test.go b/cmd/admin_auth_pam_test.go new file mode 100644 index 0000000000..d14dfe790b --- /dev/null +++ b/cmd/admin_auth_pam_test.go @@ -0,0 +1,293 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + "forgejo.org/models/auth" + "forgejo.org/modules/test" + "forgejo.org/services/auth/source/pam" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestPamService(t *testing.T) { + // Mock cli functions to do not exit on error + defer test.MockVariableValue(&cli.OsExiter, func(code int) {})() + + // Test cases + cases := []struct { + args []string + source *auth.Source + errMsg string + }{ + // case 0 + { + args: []string{ + "pam-test", + "--name", "Pam Service", + "--service-name", "myservice", + }, + source: &auth.Source{ + Type: auth.PAM, + Name: "Pam Service", + IsActive: true, + Cfg: &pam.Source{ + ServiceName: "myservice", + EmailDomain: "", + SkipLocalTwoFA: true, + }, + }, + }, + // case 1 + { + args: []string{ + "pam-test", + "--name", "Pam Service", + "--service-name", "myservice", + "--email-domain", "testdomain.org", + "--skip-local-2fa", + }, + source: &auth.Source{ + Type: auth.PAM, + Name: "Pam Service", + IsActive: true, + Cfg: &pam.Source{ + ServiceName: "myservice", + EmailDomain: "testdomain.org", + SkipLocalTwoFA: true, + }, + }, + }, + // case 2 + { + args: []string{ + "pam-test", + "--service-name", "myservice", + "--email-domain", "testdomain.org", + "--skip-local-2fa", "false", + "--active", "true", + }, + errMsg: "name must be set", + }, + // case 3 + { + args: []string{ + "pam-test", + "--name", "Pam Service", + "--email-domain", "testdomain.org", + "--skip-local-2fa", "false", + "--active", "true", + }, + errMsg: "service-name must be set", + }, + } + + for n, c := range cases { + // Mock functions. + var createdAuthSource *auth.Source + service := &authService{ + initDB: func(context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { + createdAuthSource = authSource + return nil + }, + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { + assert.FailNow(t, "should not call updateAuthSource", "case: %d", n) + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { + assert.FailNow(t, "should not call getAuthSourceByID", "case: %d", n) + return nil, nil + }, + } + + // Create a copy of command to test + app := cli.Command{} + app.Flags = microcmdAuthAddPAM().Flags + app.Action = service.addPAM + + // Run it + err := app.Run(t.Context(), c.args) + if c.errMsg != "" { + assert.EqualError(t, err, c.errMsg, "case %d: error should match", n) + } else { + require.NoError(t, err, "case %d: should have no errors", n) + assert.Equal(t, c.source, createdAuthSource, "case %d: wrong authSource", n) + } + } +} + +func TestUpdatePAM(t *testing.T) { + // Mock cli functions to do not exit on error + defer test.MockVariableValue(&cli.OsExiter, func(code int) {})() + + // Test cases + cases := []struct { + args []string + id int64 + existingAuthSource *auth.Source + authSource *auth.Source + errMsg string + }{ + // case 0 + { + args: []string{ + "pam-test", + "--id", "23", + "--name", "PAM Service", + "--service-name", "myservice", + }, + id: 23, + existingAuthSource: &auth.Source{ + Type: auth.PAM, + IsActive: true, + Cfg: &pam.Source{}, + }, + authSource: &auth.Source{ + Type: auth.PAM, + Name: "PAM Service", + IsActive: true, + Cfg: &pam.Source{ + ServiceName: "myservice", + }, + }, + }, + // case 1 + { + args: []string{ + "pam-test", + "--id", "1", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{}, + }, + }, + // case 2 + { + args: []string{ + "pam-test", + "--id", "1", + "--name", "pam service", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Name: "pam service", + Cfg: &pam.Source{}, + }, + }, + // case 3 + { + args: []string{ + "pam-test", + "--id", "1", + "--active=false", + }, + existingAuthSource: &auth.Source{ + Type: auth.PAM, + IsActive: true, + Cfg: &pam.Source{}, + }, + authSource: &auth.Source{ + Type: auth.PAM, + IsActive: false, + Cfg: &pam.Source{}, + }, + }, + // case 4 + { + args: []string{ + "pam-test", + "--id", "1", + "--service-name", "myservice", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{ + ServiceName: "myservice", + }, + }, + }, + // case 5 + { + args: []string{ + "pam-test", + "--id", "1", + "--skip-local-2fa=false", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{ + SkipLocalTwoFA: false, + }, + }, + }, + // case 6 + { + args: []string{ + "pam-test", + "--id", "1", + "--email-domain", "testdomain.org", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{ + EmailDomain: "testdomain.org", + }, + }, + }, + } + + for n, c := range cases { + // Mock functions. + var updatedAuthSource *auth.Source + service := &authService{ + initDB: func(context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { + assert.FailNow(t, "should not call createAuthSource", "case: %d", n) + return nil + }, + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { + updatedAuthSource = authSource + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { + if c.id != 0 { + assert.Equal(t, c.id, id, "case %d: wrong id", n) + } + if c.existingAuthSource != nil { + return c.existingAuthSource, nil + } + return &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{}, + }, nil + }, + } + + // Create a copy of command to test + app := cli.Command{} + app.Flags = microcmdAuthUpdatePAM().Flags + app.Action = service.updatePAM + + // Run it + err := app.Run(t.Context(), c.args) + if c.errMsg != "" { + assert.EqualError(t, err, c.errMsg, "case %d: error should match", n) + } else { + require.NoError(t, err, "case %d: should have no errors", n) + assert.Equal(t, c.authSource, updatedAuthSource, "case %d: wrong authSource", n) + } + } +} From 6df75144177ffd8e99bb4f4141b47cda75383987 Mon Sep 17 00:00:00 2001 From: Andreas Ahlenstorf Date: Fri, 19 Dec 2025 17:24:03 +0100 Subject: [PATCH 002/155] Revert "feat: add support for ephemeral runners compatible with autoscaling tools (#9409)" (#10463) Remove the unreleased HTTP API for managing runners that was introduced in https://codeberg.org/forgejo/forgejo/pulls/9409. It needs more time to mature. See also https://codeberg.org/forgejo/forgejo/pulls/10450. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [ ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [ ] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10463 Reviewed-by: Michael Kriese Reviewed-by: Mathieu Fenniak Co-authored-by: Andreas Ahlenstorf Co-committed-by: Andreas Ahlenstorf --- modules/structs/repo_actions.go | 24 - routers/api/v1/admin/runners.go | 80 +-- routers/api/v1/api.go | 14 - routers/api/v1/org/action.go | 100 ---- routers/api/v1/repo/action.go | 119 ----- routers/api/v1/shared/runners.go | 95 ---- routers/api/v1/swagger/action.go | 14 - routers/api/v1/user/runners.go | 86 ---- services/actions/interface.go | 8 - services/convert/convert.go | 26 - templates/swagger/v1_json.tmpl | 595 +---------------------- tests/integration/actions_job_test.go | 30 -- tests/integration/actions_runner_test.go | 56 --- 13 files changed, 2 insertions(+), 1245 deletions(-) diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index eada2db09f..b13f344738 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -32,27 +32,3 @@ type ActionTaskResponse struct { Entries []*ActionTask `json:"workflow_runs"` TotalCount int64 `json:"total_count"` } - -// ActionRunnerLabel represents a Runner Label -type ActionRunnerLabel struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` -} - -// ActionRunner represents a Runner -type ActionRunner struct { - ID int64 `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Busy bool `json:"busy"` - // currently unused as forgejo does not support ephemeral runners, but they are defined in gh api spec - Ephemeral bool `json:"ephemeral"` - Labels []*ActionRunnerLabel `json:"labels"` -} - -// ActionRunnersResponse returns Runners -type ActionRunnersResponse struct { - Entries []*ActionRunner `json:"runners"` - TotalCount int64 `json:"total_count"` -} diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go index 7c53a717a9..e459b947ff 100644 --- a/routers/api/v1/admin/runners.go +++ b/routers/api/v1/admin/runners.go @@ -14,7 +14,7 @@ import ( func GetRegistrationToken(ctx *context.APIContext) { // swagger:operation GET /admin/runners/registration-token admin adminGetRunnerRegistrationToken // --- - // summary: Get a global actions runner registration token + // summary: Get an global actions runner registration token // produces: // - application/json // parameters: @@ -44,81 +44,3 @@ func SearchActionRunJobs(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" shared.GetActionRunJobs(ctx, 0, 0) } - -// CreateRegistrationToken returns the token to register global runners -func CreateRegistrationToken(ctx *context.APIContext) { - // swagger:operation POST /admin/actions/runners/registration-token admin adminCreateRunnerRegistrationToken - // --- - // summary: Get a global actions runner registration token - // produces: - // - application/json - // parameters: - // responses: - // "200": - // "$ref": "#/responses/RegistrationToken" - - shared.GetRegistrationToken(ctx, 0, 0) -} - -// ListRunners get all runners -func ListRunners(ctx *context.APIContext) { - // swagger:operation GET /admin/actions/runners admin getAdminRunners - // --- - // summary: Get all runners - // produces: - // - application/json - // responses: - // "200": - // "$ref": "#/definitions/ActionRunnersResponse" - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.ListRunners(ctx, 0, 0) -} - -// GetRunner get a global runner -func GetRunner(ctx *context.APIContext) { - // swagger:operation GET /admin/actions/runners/{runner_id} admin getAdminRunner - // --- - // summary: Get a global runner - // produces: - // - application/json - // parameters: - // - name: runner_id - // in: path - // description: id of the runner - // type: string - // required: true - // responses: - // "200": - // "$ref": "#/definitions/ActionRunner" - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.GetRunner(ctx, 0, 0, ctx.ParamsInt64("runner_id")) -} - -// DeleteRunner delete a global runner -func DeleteRunner(ctx *context.APIContext) { - // swagger:operation DELETE /admin/actions/runners/{runner_id} admin deleteAdminRunner - // --- - // summary: Delete a global runner - // produces: - // - application/json - // parameters: - // - name: runner_id - // in: path - // description: id of the runner - // type: string - // required: true - // responses: - // "204": - // description: runner has been deleted - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.DeleteRunner(ctx, 0, 0, ctx.ParamsInt64("runner_id")) -} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 1206d653c1..dc0e3390d1 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -856,11 +856,7 @@ func Routes() *web.Route { }) m.Group("/runners", func() { - m.Get("", reqToken(), reqChecker, act.ListRunners) m.Get("/registration-token", reqToken(), reqChecker, act.GetRegistrationToken) - m.Post("/registration-token", reqToken(), reqChecker, act.CreateRegistrationToken) - m.Get("/{runner_id}", reqToken(), reqChecker, act.GetRunner) - m.Delete("/{runner_id}", reqToken(), reqChecker, act.DeleteRunner) m.Get("/jobs", reqToken(), reqChecker, act.SearchActionRunJobs) }) }) @@ -1020,11 +1016,7 @@ func Routes() *web.Route { }) m.Group("/runners", func() { - m.Get("", reqToken(), user.ListRunners) m.Get("/registration-token", reqToken(), user.GetRegistrationToken) - m.Post("/registration-token", reqToken(), user.CreateRegistrationToken) - m.Get("/{runner_id}", reqToken(), user.GetRunner) - m.Delete("/{runner_id}", reqToken(), user.DeleteRunner) m.Get("/jobs", reqToken(), user.SearchActionRunJobs) }) }) @@ -1702,12 +1694,6 @@ func Routes() *web.Route { Patch(bind(api.EditHookOption{}), admin.EditHook). Delete(admin.DeleteHook) }) - m.Group("/actions/runners", func() { - m.Get("", admin.ListRunners) - m.Post("/registration-token", admin.CreateRegistrationToken) - m.Get("/{runner_id}", admin.GetRunner) - m.Delete("/{runner_id}", admin.DeleteRunner) - }) m.Group("/runners", func() { m.Get("/registration-token", admin.GetRegistrationToken) m.Get("/jobs", admin.SearchActionRunJobs) diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index b3fcbca6b1..8fd8b1b985 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -214,27 +214,6 @@ func (Action) SearchActionRunJobs(ctx *context.APIContext) { shared.GetActionRunJobs(ctx, ctx.Org.Organization.ID, 0) } -// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization -// CreateRegistrationToken returns the token to register org runners -func (Action) CreateRegistrationToken(ctx *context.APIContext) { - // swagger:operation POST /orgs/{org}/actions/runners/registration-token organization orgCreateRunnerRegistrationToken - // --- - // summary: Get an organization's actions runner registration token - // produces: - // - application/json - // parameters: - // - name: org - // in: path - // description: name of the organization - // type: string - // required: true - // responses: - // "200": - // "$ref": "#/responses/RegistrationToken" - - shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0) -} - // ListVariables list org-level variables func (Action) ListVariables(ctx *context.APIContext) { // swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList @@ -287,85 +266,6 @@ func (Action) ListVariables(ctx *context.APIContext) { ctx.JSON(http.StatusOK, variables) } -// ListRunners get org-level runners -func (Action) ListRunners(ctx *context.APIContext) { - // swagger:operation GET /orgs/{org}/actions/runners organization getOrgRunners - // --- - // summary: Get org-level runners - // produces: - // - application/json - // parameters: - // - name: org - // in: path - // description: name of the organization - // type: string - // required: true - // responses: - // "200": - // "$ref": "#/definitions/ActionRunnersResponse" - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.ListRunners(ctx, ctx.Org.Organization.ID, 0) -} - -// GetRunner get an org-level runner -func (Action) GetRunner(ctx *context.APIContext) { - // swagger:operation GET /orgs/{org}/actions/runners/{runner_id} organization getOrgRunner - // --- - // summary: Get an org-level runner - // produces: - // - application/json - // parameters: - // - name: org - // in: path - // description: name of the organization - // type: string - // required: true - // - name: runner_id - // in: path - // description: id of the runner - // type: string - // required: true - // responses: - // "200": - // "$ref": "#/definitions/ActionRunner" - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.GetRunner(ctx, ctx.Org.Organization.ID, 0, ctx.ParamsInt64("runner_id")) -} - -// DeleteRunner delete an org-level runner -func (Action) DeleteRunner(ctx *context.APIContext) { - // swagger:operation DELETE /orgs/{org}/actions/runners/{runner_id} organization deleteOrgRunner - // --- - // summary: Delete an org-level runner - // produces: - // - application/json - // parameters: - // - name: org - // in: path - // description: name of the organization - // type: string - // required: true - // - name: runner_id - // in: path - // description: id of the runner - // type: string - // required: true - // responses: - // "204": - // description: runner has been deleted - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.ParamsInt64("runner_id")) -} - // GetVariable gives organization's variable func (Action) GetVariable(ctx *context.APIContext) { // swagger:operation GET /orgs/{org}/actions/variables/{variablename} organization getOrgVariable diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 9085ee8e70..40aab5e449 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -505,125 +505,6 @@ func (Action) GetRegistrationToken(ctx *context.APIContext) { shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID) } -// CreateRegistrationToken returns the token to register repo runners -func (Action) CreateRegistrationToken(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/actions/runners/registration-token repository repoCreateRunnerRegistrationToken - // --- - // summary: Get a repository's actions runner registration token - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // responses: - // "200": - // "$ref": "#/responses/RegistrationToken" - - shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID) -} - -// ListRunners get repo-level runners -func (Action) ListRunners(ctx *context.APIContext) { - // swagger:operation GET /repos/{owner}/{repo}/actions/runners repository getRepoRunners - // --- - // summary: Get repo-level runners - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // responses: - // "200": - // "$ref": "#/definitions/ActionRunnersResponse" - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.ListRunners(ctx, 0, ctx.Repo.Repository.ID) -} - -// GetRunner get a repo-level runner -func (Action) GetRunner(ctx *context.APIContext) { - // swagger:operation GET /repos/{owner}/{repo}/actions/runners/{runner_id} repository getRepoRunner - // --- - // summary: Get a repo-level runner - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // - name: runner_id - // in: path - // description: id of the runner - // type: string - // required: true - // responses: - // "200": - // "$ref": "#/definitions/ActionRunner" - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.GetRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.ParamsInt64("runner_id")) -} - -// DeleteRunner delete a repo-level runner -func (Action) DeleteRunner(ctx *context.APIContext) { - // swagger:operation DELETE /repos/{owner}/{repo}/actions/runners/{runner_id} repository deleteRepoRunner - // --- - // summary: Delete a repo-level runner - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // - name: runner_id - // in: path - // description: id of the runner - // type: string - // required: true - // responses: - // "204": - // description: runner has been deleted - // "400": - // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" - shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.ParamsInt64("runner_id")) -} - // SearchActionRunJobs return a list of actions jobs filtered by the provided parameters func (Action) SearchActionRunJobs(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runners/jobs repository repoSearchRunJobs diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go index feb47dacaa..3e10dd85af 100644 --- a/routers/api/v1/shared/runners.go +++ b/routers/api/v1/shared/runners.go @@ -5,7 +5,6 @@ package shared import ( "errors" - "fmt" "net/http" "strings" @@ -13,9 +12,7 @@ import ( "forgejo.org/models/db" "forgejo.org/modules/structs" "forgejo.org/modules/util" - "forgejo.org/routers/api/v1/utils" "forgejo.org/services/context" - "forgejo.org/services/convert" ) // RegistrationToken is a string used to register a runner with a server @@ -75,95 +72,3 @@ func fromRunJobModelToResponse(job []*actions_model.ActionRunJob, labels []strin } return res } - -// ListRunners lists runners for api route validated ownerID and repoID -// ownerID == 0 and repoID == 0 means all runners including global runners, does not appear in sql where clause -// ownerID == 0 and repoID != 0 means all runners for the given repo -// ownerID != 0 and repoID == 0 means all runners for the given user/org -// ownerID != 0 and repoID != 0 undefined behavior -// Access rights are checked at the API route level -func ListRunners(ctx *context.APIContext, ownerID, repoID int64) { - if ownerID != 0 && repoID != 0 { - ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("ownerID and repoID should not be both set: %d and %d", ownerID, repoID)) - return - } - runners, total, err := db.FindAndCount[actions_model.ActionRunner](ctx, &actions_model.FindRunnerOptions{ - OwnerID: ownerID, - RepoID: repoID, - ListOptions: utils.GetListOptions(ctx), - }) - if err != nil { - ctx.Error(http.StatusInternalServerError, "FindCountRunners", map[string]string{}) - return - } - - res := new(structs.ActionRunnersResponse) - res.TotalCount = total - - res.Entries = make([]*structs.ActionRunner, len(runners)) - for i, runner := range runners { - res.Entries[i] = convert.ToActionRunner(ctx, runner) - } - - ctx.JSON(http.StatusOK, &res) -} - -// GetRunner get the runner for api route validated ownerID and repoID -// ownerID == 0 and repoID == 0 means any runner including global runners -// ownerID == 0 and repoID != 0 means any runner for the given repo -// ownerID != 0 and repoID == 0 means any runner for the given user/org -// ownerID != 0 and repoID != 0 undefined behavior -// Access rights are checked at the API route level -func GetRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) { - if ownerID != 0 && repoID != 0 { - ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("ownerID and repoID should not be both set: %d and %d", ownerID, repoID)) - return - } - runner, err := actions_model.GetRunnerByID(ctx, runnerID) - if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.Error(http.StatusNotFound, "GetRunnerNotFound", err) - } else { - ctx.Error(http.StatusInternalServerError, "GetRunnerFailed", err) - } - return - } - if !runner.Editable(ownerID, repoID) { - ctx.Error(http.StatusNotFound, "RunnerEdit", "No permission to get this runner") - return - } - ctx.JSON(http.StatusOK, convert.ToActionRunner(ctx, runner)) -} - -// DeleteRunner deletes the runner for api route validated ownerID and repoID -// ownerID == 0 and repoID == 0 means any runner including global runners -// ownerID == 0 and repoID != 0 means any runner for the given repo -// ownerID != 0 and repoID == 0 means any runner for the given user/org -// ownerID != 0 and repoID != 0 undefined behavior -// Access rights are checked at the API route level -func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) { - if ownerID != 0 && repoID != 0 { - ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("ownerID and repoID should not be both set: %d and %d", ownerID, repoID)) - return - } - runner, err := actions_model.GetRunnerByID(ctx, runnerID) - if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.Error(http.StatusNotFound, "DeleteRunnerNotFound", err) - } else { - ctx.Error(http.StatusInternalServerError, "DeleteRunnerFailed", err) - } - return - } - if !runner.Editable(ownerID, repoID) { - ctx.Error(http.StatusNotFound, "EditRunner", "No permission to delete this runner") - return - } - - err = actions_model.DeleteRunner(ctx, runner) - if err != nil { - ctx.InternalServerError(err) - return - } - ctx.Status(http.StatusNoContent) -} diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go index cf853c986f..910c265150 100644 --- a/routers/api/v1/swagger/action.go +++ b/routers/api/v1/swagger/action.go @@ -56,17 +56,3 @@ type swaggerRegistrationToken struct { // in: body Body shared.RegistrationToken `json:"body"` } - -// ActionRunner represents a Runner -// swagger:response ActionRunner -type swaggerActionRunner struct { - // in: body - Body api.ActionRunner `json:"body"` -} - -// ActionRunnersResponse returns Runners -// swagger:response ActionRunnersResponse -type swaggerActionRunnerResponse struct { - // in: body - Body api.ActionRunnersResponse `json:"body"` -} diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go index 2cfdd8a03a..579e3eb932 100644 --- a/routers/api/v1/user/runners.go +++ b/routers/api/v1/user/runners.go @@ -50,89 +50,3 @@ func SearchActionRunJobs(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" shared.GetActionRunJobs(ctx, ctx.Doer.ID, 0) } - -// CreateRegistrationToken returns the token to register user runners -func CreateRegistrationToken(ctx *context.APIContext) { - // swagger:operation POST /user/actions/runners/registration-token user userCreateRunnerRegistrationToken - // --- - // summary: Get an user's actions runner registration token - // produces: - // - application/json - // parameters: - // responses: - // "200": - // "$ref": "#/responses/RegistrationToken" - // "401": - // "$ref": "#/responses/unauthorized" - - shared.GetRegistrationToken(ctx, ctx.Doer.ID, 0) -} - -// ListRunners get user-level runners -func ListRunners(ctx *context.APIContext) { - // swagger:operation GET /user/actions/runners user getUserRunners - // --- - // summary: Get user-level runners - // produces: - // - application/json - // responses: - // "200": - // "$ref": "#/responses/ActionRunnersResponse" - // "400": - // "$ref": "#/responses/error" - // "401": - // "$ref": "#/responses/unauthorized" - // "404": - // "$ref": "#/responses/notFound" - shared.ListRunners(ctx, ctx.Doer.ID, 0) -} - -// GetRunner get an user-level runner -func GetRunner(ctx *context.APIContext) { - // swagger:operation GET /user/actions/runners/{runner_id} user getUserRunner - // --- - // summary: Get an user-level runner - // produces: - // - application/json - // parameters: - // - name: runner_id - // in: path - // description: id of the runner - // type: string - // required: true - // responses: - // "200": - // "$ref": "#/responses/ActionRunner" - // "400": - // "$ref": "#/responses/error" - // "401": - // "$ref": "#/responses/unauthorized" - // "404": - // "$ref": "#/responses/notFound" - shared.GetRunner(ctx, ctx.Doer.ID, 0, ctx.ParamsInt64("runner_id")) -} - -// DeleteRunner delete an user-level runner -func DeleteRunner(ctx *context.APIContext) { - // swagger:operation DELETE /user/actions/runners/{runner_id} user deleteUserRunner - // --- - // summary: Delete an user-level runner - // produces: - // - application/json - // parameters: - // - name: runner_id - // in: path - // description: id of the runner - // type: string - // required: true - // responses: - // "204": - // description: runner has been deleted - // "400": - // "$ref": "#/responses/error" - // "401": - // "$ref": "#/responses/unauthorized" - // "404": - // "$ref": "#/responses/notFound" - shared.DeleteRunner(ctx, ctx.Doer.ID, 0, ctx.ParamsInt64("runner_id")) -} diff --git a/services/actions/interface.go b/services/actions/interface.go index a5e0543594..54a30061bc 100644 --- a/services/actions/interface.go +++ b/services/actions/interface.go @@ -27,12 +27,4 @@ type API interface { GetRegistrationToken(*context.APIContext) // SearchActionRunJobs get pending Action run jobs SearchActionRunJobs(*context.APIContext) - // CreateRegistrationToken get registration token - CreateRegistrationToken(*context.APIContext) - // ListRunners list runners - ListRunners(*context.APIContext) - // GetRunner get a runner - GetRunner(*context.APIContext) - // DeleteRunner delete runner - DeleteRunner(*context.APIContext) } diff --git a/services/convert/convert.go b/services/convert/convert.go index eaf6459cfd..44b12fa8a0 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -30,8 +30,6 @@ import ( api "forgejo.org/modules/structs" "forgejo.org/modules/util" "forgejo.org/services/gitdiff" - - runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1" ) // ToEmail convert models.EmailAddress to api.Email @@ -521,27 +519,3 @@ func ToChangedFile(f *gitdiff.DiffFile, repo *repo_model.Repository, commit stri return file } - -func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *api.ActionRunner { - status := runner.Status() - apiStatus := "offline" - if runner.IsOnline() { - apiStatus = "online" - } - labels := make([]*api.ActionRunnerLabel, len(runner.AgentLabels)) - for i, label := range runner.AgentLabels { - labels[i] = &api.ActionRunnerLabel{ - ID: int64(i), - Name: label, - Type: "custom", - } - } - return &api.ActionRunner{ - ID: runner.ID, - Name: runner.Name, - Status: apiStatus, - Busy: status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE, - // Ephemeral: runner.Ephemeral, - Labels: labels, - } -} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 3a75d53ba1..33a3c3d05d 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -311,108 +311,6 @@ } } }, - "/admin/actions/runners": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get all runners", - "operationId": "getAdminRunners", - "responses": { - "200": { - "$ref": "#/definitions/ActionRunnersResponse" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, - "/admin/actions/runners/registration-token": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get a global actions runner registration token", - "operationId": "adminCreateRunnerRegistrationToken", - "responses": { - "200": { - "$ref": "#/responses/RegistrationToken" - } - } - } - }, - "/admin/actions/runners/{runner_id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get a global runner", - "operationId": "getAdminRunner", - "parameters": [ - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/definitions/ActionRunner" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete a global runner", - "operationId": "deleteAdminRunner", - "parameters": [ - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "runner has been deleted" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, "/admin/cron": { "get": { "produces": [ @@ -1273,7 +1171,7 @@ "tags": [ "admin" ], - "summary": "Get a global actions runner registration token", + "summary": "Get an global actions runner registration token", "operationId": "adminGetRunnerRegistrationToken", "responses": { "200": { @@ -2627,38 +2525,6 @@ } } }, - "/orgs/{org}/actions/runners": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "organization" - ], - "summary": "Get org-level runners", - "operationId": "getOrgRunners", - "parameters": [ - { - "type": "string", - "description": "name of the organization", - "name": "org", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/definitions/ActionRunnersResponse" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, "/orgs/{org}/actions/runners/jobs": { "get": { "produces": [ @@ -2718,106 +2584,6 @@ "$ref": "#/responses/RegistrationToken" } } - }, - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "organization" - ], - "summary": "Get an organization's actions runner registration token", - "operationId": "orgCreateRunnerRegistrationToken", - "parameters": [ - { - "type": "string", - "description": "name of the organization", - "name": "org", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/RegistrationToken" - } - } - } - }, - "/orgs/{org}/actions/runners/{runner_id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "organization" - ], - "summary": "Get an org-level runner", - "operationId": "getOrgRunner", - "parameters": [ - { - "type": "string", - "description": "name of the organization", - "name": "org", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/definitions/ActionRunner" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "organization" - ], - "summary": "Delete an org-level runner", - "operationId": "deleteOrgRunner", - "parameters": [ - { - "type": "string", - "description": "name of the organization", - "name": "org", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "runner has been deleted" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } } }, "/orgs/{org}/actions/secrets": { @@ -5325,45 +5091,6 @@ } } }, - "/repos/{owner}/{repo}/actions/runners": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Get repo-level runners", - "operationId": "getRepoRunners", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo", - "name": "repo", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/definitions/ActionRunnersResponse" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, "/repos/{owner}/{repo}/actions/runners/jobs": { "get": { "produces": [ @@ -5437,127 +5164,6 @@ "$ref": "#/responses/RegistrationToken" } } - }, - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Get a repository's actions runner registration token", - "operationId": "repoCreateRunnerRegistrationToken", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo", - "name": "repo", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/RegistrationToken" - } - } - } - }, - "/repos/{owner}/{repo}/actions/runners/{runner_id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Get a repo-level runner", - "operationId": "getRepoRunner", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/definitions/ActionRunner" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Delete a repo-level runner", - "operationId": "deleteRepoRunner", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "runner has been deleted" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } } }, "/repos/{owner}/{repo}/actions/runs": { @@ -18799,32 +18405,6 @@ } } }, - "/user/actions/runners": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Get user-level runners", - "operationId": "getUserRunners", - "responses": { - "200": { - "$ref": "#/responses/ActionRunnersResponse" - }, - "400": { - "$ref": "#/responses/error" - }, - "401": { - "$ref": "#/responses/unauthorized" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, "/user/actions/runners/jobs": { "get": { "produces": [ @@ -18877,92 +18457,6 @@ "$ref": "#/responses/forbidden" } } - }, - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Get an user's actions runner registration token", - "operationId": "userCreateRunnerRegistrationToken", - "responses": { - "200": { - "$ref": "#/responses/RegistrationToken" - }, - "401": { - "$ref": "#/responses/unauthorized" - } - } - } - }, - "/user/actions/runners/{runner_id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Get an user-level runner", - "operationId": "getUserRunner", - "parameters": [ - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/ActionRunner" - }, - "400": { - "$ref": "#/responses/error" - }, - "401": { - "$ref": "#/responses/unauthorized" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Delete an user-level runner", - "operationId": "deleteUserRunner", - "parameters": [ - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "runner has been deleted" - }, - "400": { - "$ref": "#/responses/error" - }, - "401": { - "$ref": "#/responses/unauthorized" - }, - "404": { - "$ref": "#/responses/notFound" - } - } } }, "/user/actions/secrets/{secretname}": { @@ -22180,81 +21674,6 @@ }, "x-go-package": "forgejo.org/modules/structs" }, - "ActionRunner": { - "description": "ActionRunner represents a Runner", - "type": "object", - "properties": { - "busy": { - "type": "boolean", - "x-go-name": "Busy" - }, - "ephemeral": { - "description": "currently unused as forgejo does not support ephemeral runners, but they are defined in gh api spec", - "type": "boolean", - "x-go-name": "Ephemeral" - }, - "id": { - "type": "integer", - "format": "int64", - "x-go-name": "ID" - }, - "labels": { - "type": "array", - "items": { - "$ref": "#/definitions/ActionRunnerLabel" - }, - "x-go-name": "Labels" - }, - "name": { - "type": "string", - "x-go-name": "Name" - }, - "status": { - "type": "string", - "x-go-name": "Status" - } - }, - "x-go-package": "forgejo.org/modules/structs" - }, - "ActionRunnerLabel": { - "description": "ActionRunnerLabel represents a Runner Label", - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64", - "x-go-name": "ID" - }, - "name": { - "type": "string", - "x-go-name": "Name" - }, - "type": { - "type": "string", - "x-go-name": "Type" - } - }, - "x-go-package": "forgejo.org/modules/structs" - }, - "ActionRunnersResponse": { - "description": "ActionRunnersResponse returns Runners", - "type": "object", - "properties": { - "runners": { - "type": "array", - "items": { - "$ref": "#/definitions/ActionRunner" - }, - "x-go-name": "Entries" - }, - "total_count": { - "type": "integer", - "format": "int64", - "x-go-name": "TotalCount" - } - }, - "x-go-package": "forgejo.org/modules/structs" - }, "ActionTask": { "description": "ActionTask represents a ActionTask", "type": "object", @@ -29938,18 +29357,6 @@ "$ref": "#/definitions/ListActionRunResponse" } }, - "ActionRunner": { - "description": "ActionRunner represents a Runner", - "schema": { - "$ref": "#/definitions/ActionRunner" - } - }, - "ActionRunnersResponse": { - "description": "ActionRunnersResponse returns Runners", - "schema": { - "$ref": "#/definitions/ActionRunnersResponse" - } - }, "ActionVariable": { "description": "ActionVariable", "schema": { diff --git a/tests/integration/actions_job_test.go b/tests/integration/actions_job_test.go index 262358ab9f..baa1420bdd 100644 --- a/tests/integration/actions_job_test.go +++ b/tests/integration/actions_job_test.go @@ -175,36 +175,6 @@ jobs: }) } -func TestRunnerLifecycleGithubEndpoints(t *testing.T) { - if !setting.Database.Type.IsSQLite3() { - // registering a mock runner when using a database other than SQLite leaves leftovers - t.Skip() - } - onApplicationRun(t, func(t *testing.T, u *url.URL) { - user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - session := loginUser(t, user2.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) - - apiRepo := createActionsTestRepo(t, token, "actions-runner-registration-with-get", false) - runner := newMockRunner() - runner.registerAsRepoRunnerWithPost(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}) - runnersList := runner.listRunners(t, user2.Name, apiRepo.Name) - - assert.NotNil(t, runnersList) - assert.Len(t, runnersList.Entries, 1) - assert.Equal(t, "mock-runner", runnersList.Entries[0].Name) - - runnerDetails := runner.getRunner(t, user2.Name, apiRepo.Name, runnersList.Entries[0].ID) - assert.Equal(t, "mock-runner", runnerDetails.Name) - assert.Equal(t, runnersList.Entries[0].ID, runnerDetails.ID) - - runner.deleteRunner(t, user2.Name, apiRepo.Name, runnersList.Entries[0].ID) - - httpContext := NewAPITestContext(t, user2.Name, apiRepo.Name, auth_model.AccessTokenScopeWriteRepository) - doAPIDeleteRepository(httpContext)(t) - }) -} - func TestActionsJobNeedsMatrix(t *testing.T) { if !setting.Database.Type.IsSQLite3() { t.Skip() diff --git a/tests/integration/actions_runner_test.go b/tests/integration/actions_runner_test.go index 0eb2117325..6cedaf6def 100644 --- a/tests/integration/actions_runner_test.go +++ b/tests/integration/actions_runner_test.go @@ -12,7 +12,6 @@ import ( auth_model "forgejo.org/models/auth" "forgejo.org/modules/setting" - "forgejo.org/modules/structs" pingv1 "code.forgejo.org/forgejo/actions-proto/ping/v1" "code.forgejo.org/forgejo/actions-proto/ping/v1/pingv1connect" @@ -97,61 +96,6 @@ func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, run r.doRegister(t, runnerName, registrationToken.Token, labels) } -func (r *mockRunner) registerAsRepoRunnerWithPost(t *testing.T, ownerName, repoName, runnerName string, labels []string) { - if !setting.Database.Type.IsSQLite3() { - // registering a mock runner when using a database other than SQLite leaves leftovers - t.FailNow() - } - session := loginUser(t, ownerName) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", ownerName, repoName)).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var registrationToken struct { - Token string `json:"token"` - } - DecodeJSON(t, resp, ®istrationToken) - r.doRegister(t, runnerName, registrationToken.Token, labels) -} - -func (r *mockRunner) listRunners(t *testing.T, ownerName, repoName string) structs.ActionRunnersResponse { - if !setting.Database.Type.IsSQLite3() { - // registering a mock runner when using a database other than SQLite leaves leftovers - t.FailNow() - } - session := loginUser(t, ownerName) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", ownerName, repoName)).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var runnersList structs.ActionRunnersResponse - DecodeJSON(t, resp, &runnersList) - return runnersList -} - -func (r *mockRunner) getRunner(t *testing.T, ownerName, repoName string, runnerID int64) structs.ActionRunner { - if !setting.Database.Type.IsSQLite3() { - // registering a mock runner when using a database other than SQLite leaves leftovers - t.FailNow() - } - session := loginUser(t, ownerName) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", ownerName, repoName, runnerID)).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var runner structs.ActionRunner - DecodeJSON(t, resp, &runner) - return runner -} - -func (r *mockRunner) deleteRunner(t *testing.T, ownerName, repoName string, runnerID int64) { - if !setting.Database.Type.IsSQLite3() { - // registering a mock runner when using a database other than SQLite leaves leftovers - t.FailNow() - } - session := loginUser(t, ownerName) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", ownerName, repoName, runnerID)).AddTokenAuth(token) - MakeRequest(t, req, http.StatusNoContent) -} - func (r *mockRunner) maybeFetchTask(t *testing.T) *runnerv1.Task { resp, err := r.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{ TasksVersion: r.lastTasksVersion, From fed7d6486199ba4d513c3e4ae592b8ef75337798 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Sat, 20 Dec 2025 06:05:54 +0100 Subject: [PATCH 003/155] [v14.0/forgejo] fix(ui): align due date icon in issue list (#10494) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10489 Flattened nested `` tags for the calender icon in the issue list, to fix the vertical alignment Before: ![image](/attachments/f5049acb-41dc-438e-9256-ef30542e168d) After: ![image](/attachments/c4d8bc64-0474-4a3e-9061-9e2bca6abff9) ![image](/attachments/0b2c4d9c-7d34-4627-be55-2099ed32dd19) Co-authored-by: Bram Hagens Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10494 Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- templates/shared/issuelist.tmpl | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index 256b5e3e07..ff7aa9c0a4 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -108,11 +108,9 @@ {{end}} {{if ne .DeadlineUnix 0}} - - - {{svg "octicon-calendar" 14}} - {{DateUtils.AbsoluteShort .DeadlineUnix}} - + + {{svg "octicon-calendar" 14}} + {{DateUtils.AbsoluteShort .DeadlineUnix}} {{end}} {{if .IsPull}} From 44102c47d48fb2b22ff7d9d103167427e484804c Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Sat, 20 Dec 2025 07:06:26 +0100 Subject: [PATCH 004/155] [v14.0/forgejo] fix: ignore private .profile repo on user profile page (#10495) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10486 Fixes #4202 Co-authored-by: Bram Hagens Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10495 Reviewed-by: 0ko <0ko@noreply.codeberg.org> Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- routers/web/shared/user/header.go | 4 ++-- tests/integration/user_profile_test.go | 32 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 745b3c1dca..e37e3cea64 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -97,8 +97,8 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { func FindUserProfileReadme(ctx *context.Context, doer *user_model.User) (profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadmeBlob *git.Blob, profileClose func()) { profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ".profile") if err == nil { - // Don't show profile content if .profile repository is a fork - if profileDbRepo.IsFork { + // Don't show profile content if .profile repository is a fork or private + if profileDbRepo.IsFork || profileDbRepo.IsPrivate { return nil, nil, nil, func() {} } perm, err := access_model.GetUserRepoPermission(ctx, profileDbRepo, doer) diff --git a/tests/integration/user_profile_test.go b/tests/integration/user_profile_test.go index 654ff0c094..10f3ce6be6 100644 --- a/tests/integration/user_profile_test.go +++ b/tests/integration/user_profile_test.go @@ -170,5 +170,37 @@ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequa assert.True(t, forkedRepo.IsFork, "Repository should be marked as a fork") assert.Equal(t, originalRepo.ID, forkedRepo.ForkID, "Fork should reference original repository") }) + + t.Run("private-profile-repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Create a private .profile repository + profileRepo, _, f := tests.CreateDeclarativeRepo(t, user2, ".profile", nil, nil, []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + ContentReader: strings.NewReader("# Private Profile Content\nThis should NOT show up on user profile."), + }, + }) + defer f() + + // Make the repository private + profileRepo.IsPrivate = true + err := repo_service.UpdateRepository(git.DefaultContext, profileRepo, true) + require.NoError(t, err) + + // Verify that user2's profile does NOT show the private content + req := NewRequest(t, "GET", "/user2") + resp := MakeRequest(t, req, http.StatusOK) + bodyStr := resp.Body.String() + + assert.NotContains(t, bodyStr, "Private Profile Content", "Private .profile repo should NOT render profile content") + assert.NotContains(t, bodyStr, "This should NOT show up on user profile", "Private .profile repo should NOT render profile content") + + // Verify the repository is actually private + assert.True(t, profileRepo.IsPrivate, "Repository should be marked as private") + }) }) } From 83da3ae68c06cc4144c91302d61ed229570f689b Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Sat, 20 Dec 2025 16:22:36 +0100 Subject: [PATCH 005/155] [v14.0/forgejo] feat(ui): show update time when sorting by recently updated (#10500) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10488 Fixes #4712 Fixes #7783 When filtering issues or PRs by "Recently updated" or "Least recently updated", the last updated time is shown: ![image](/attachments/f8e52a05-6055-42f9-9370-78196a173108) ![image](/attachments/50b63323-fe73-4ca5-8283-79fd6952d318) Co-authored-by: Bram Hagens Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10500 Reviewed-by: 0ko <0ko@noreply.codeberg.org> Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- options/locale_next/locale_en-US.json | 1 + templates/shared/issuelist.tmpl | 6 ++++++ tests/integration/issue_test.go | 30 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index d9d23653cd..38d7d18e7e 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -62,6 +62,7 @@ "repo.issues.filter_mention.hint": "Filter by mentioned user", "repo.issues.filter_modified.hint": "Filter by last modified date", "repo.issues.filter_sort.hint": "Sort by: created/comments/updated/deadline", + "issues.updated": "updated %s", "repo.pulls.poster_manage_approval": "Manage approval", "repo.pulls.poster_requires_approval": "Some workflows are waiting to be reviewed.", "repo.pulls.poster_requires_approval.tooltip": "The author of this pull request is not trusted to run workflows triggered by a pull request created from a forked repository or with AGit. The workflows triggered by a `pull_request` event will not run until they are approved.", diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index ff7aa9c0a4..3e20cc5080 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -142,6 +142,12 @@ {{end}} {{end}} + {{if or (eq $.SortType "recentupdate") (eq $.SortType "leastupdate")}} + + {{svg "octicon-history" 14}} + {{ctx.Locale.Tr "issues.updated" (DateUtils.TimeSince .UpdatedUnix)}} + + {{end}} diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index 2e7428a1ac..b47f3fd534 100644 --- a/tests/integration/issue_test.go +++ b/tests/integration/issue_test.go @@ -131,6 +131,36 @@ func TestViewIssuesSortByType(t *testing.T) { } } +func TestViewIssuesSortByUpdatedTime(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + // When sorting by update time, the "updated" text and icon should appear + for _, sort := range []string{"recentupdate", "leastupdate"} { + req := NewRequest(t, "GET", repo.Link()+"/issues?sort="+sort) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + issueList := htmlDoc.doc.Find("#issue-list") + updatedText := issueList.Find(".flex-item-body").First().Text() + assert.Contains(t, updatedText, "updated") + + historyIcon := issueList.Find(".octicon-history") + assert.Positive(t, historyIcon.Length()) + } + + // When sorting by something else, the "updated" text and icon should NOT appear + req := NewRequest(t, "GET", repo.Link()+"/issues?sort=latest") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + issueList := htmlDoc.doc.Find("#issue-list") + historyIcon := issueList.Find(".octicon-history") + assert.Empty(t, historyIcon.Length()) +} + func TestViewIssuesKeyword(t *testing.T) { defer tests.PrepareTestEnv(t)() From dd75d0957dc391ac07f6f2a6530a6374a30dfaaf Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Sun, 21 Dec 2025 19:18:37 +0100 Subject: [PATCH 006/155] [v14.0/forgejo] feat(ui): show cancel button until all jobs are finished (#10531) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/9261 Change that the Cancel button is shown until all jobs are finished and do not hide it, when the first job failed. Additionally the wrapping of the header was changed. Fixes #8922 Co-authored-by: Beowulf Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10531 Reviewed-by: Beowulf Reviewed-by: Mathieu Fenniak Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- models/fixtures/action_run.yml | 40 +++++++++++ models/fixtures/action_run_job.yml | 60 ++++++++++++++++ models/fixtures/action_task.yml | 80 +++++++++++++++++++++ routers/web/repo/actions/view.go | 2 +- routers/web/repo/actions/view_test.go | 49 +++++++++++++ tests/integration/api_admin_actions_test.go | 6 +- web_src/js/components/RepoActionView.vue | 28 ++++++-- 7 files changed, 255 insertions(+), 10 deletions(-) diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml index 8fa04b0066..41559ef788 100644 --- a/models/fixtures/action_run.yml +++ b/models/fixtures/action_run.yml @@ -533,3 +533,43 @@ updated: 1683636626 need_approval: false approved_by: 0 + +- + id: 895 + title: "job output" + repo_id: 4 + owner_id: 1 + workflow_id: "test.yaml" + index: 191 + trigger_user_id: 1 + ref: "refs/heads/master" + commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" + event: "push" + is_fork_pull_request: false + status: 2 + started: 1683636528 + stopped: 1683636626 + created: 1683636108 + updated: 1683636626 + need_approval: false + approved_by: 0 + +- + id: 896 + title: "job output" + repo_id: 4 + owner_id: 1 + workflow_id: "test.yaml" + index: 192 + trigger_user_id: 1 + ref: "refs/heads/master" + commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" + event: "push" + is_fork_pull_request: false + status: 2 + started: 1683636528 + stopped: 1683636626 + created: 1683636108 + updated: 1683636626 + need_approval: false + approved_by: 0 diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml index bb812c1570..2d8ea9ff6f 100644 --- a/models/fixtures/action_run_job.yml +++ b/models/fixtures/action_run_job.yml @@ -69,6 +69,66 @@ status: 5 started: 1683636528 stopped: 1683636626 +- + id: 197 + run_id: 895 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: false + name: job1 (1) + attempt: 0 + job_id: job1 + task_id: 54 + status: 2 # failure + runs_on: '["postmarketOS"]' + started: 1683636528 + stopped: 1683636626 +- + id: 198 + run_id: 895 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: false + name: job1 (2) + attempt: 0 + job_id: job1 + task_id: 55 + status: 6 # running + runs_on: '["postmarketOS"]' + started: 1683636528 + stopped: 1683636626 +- + id: 199 + run_id: 896 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: false + name: job1 (1) + attempt: 0 + job_id: job1 + task_id: 56 + status: 2 # failure + runs_on: '["postmarketOS"]' + started: 1683636528 + stopped: 1683636626 +- + id: 200 + run_id: 896 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: false + name: job1 (2) + attempt: 0 + job_id: job1 + task_id: 57 + status: 1 # success + runs_on: '["postmarketOS"]' + started: 1683636528 + stopped: 1683636626 - id: 292 run_id: 891 diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml index 956bc736f9..b931ca0aa8 100644 --- a/models/fixtures/action_task.yml +++ b/models/fixtures/action_task.yml @@ -157,3 +157,83 @@ log_length: 707 log_size: 90179 log_expired: false +- + id: 54 + job_id: 197 + attempt: 0 + runner_id: 1 + status: 2 # failure + started: 1683636528 + stopped: 1683636626 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: false + token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc4784225 + token_salt: ffffffffff + token_last_eight: ffffffff + log_filename: artifact-test2/2f/47.log + log_in_storage: true + log_length: 707 + log_size: 90179 + log_expired: false +- + id: 55 + job_id: 198 + attempt: 0 + runner_id: 1 + status: 6 # running + started: 1683636528 + stopped: 1683636626 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: false + token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc4784226 + token_salt: ffffffffff + token_last_eight: ffffffff + log_filename: artifact-test2/2f/47.log + log_in_storage: true + log_length: 707 + log_size: 90179 + log_expired: false +- + id: 56 + job_id: 199 + attempt: 0 + runner_id: 1 + status: 2 # failure + started: 1683636528 + stopped: 1683636626 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: false + token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc4784227 + token_salt: ffffffffff + token_last_eight: ffffffff + log_filename: artifact-test2/2f/47.log + log_in_storage: true + log_length: 707 + log_size: 90179 + log_expired: false +- + id: 57 + job_id: 200 + attempt: 0 + runner_id: 1 + status: 1 # success + started: 1683636528 + stopped: 1683636626 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: false + token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc4784228 + token_salt: ffffffffff + token_last_eight: ffffffff + log_filename: artifact-test2/2f/47.log + log_in_storage: true + log_length: 707 + log_size: 90179 + log_expired: false diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 9b36f7b8cb..68466ee848 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -282,7 +282,6 @@ func getViewResponse(ctx *app_context.Context, req *ViewRequest, runIndex, jobIn resp.State.Run.Title = run.Title resp.State.Run.TitleHTML = templates.RenderCommitMessage(ctx, run.Title, metas) resp.State.Run.Link = run.Link() - resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) @@ -310,6 +309,7 @@ func getViewResponse(ctx *app_context.Context, req *ViewRequest, runIndex, jobIn }) } resp.State.Run.Done = done + resp.State.Run.CanCancel = !done && ctx.Repo.CanWrite(unit.TypeActions) pusher := ViewUser{ DisplayName: run.TriggerUser.GetDisplayName(), diff --git a/routers/web/repo/actions/view_test.go b/routers/web/repo/actions/view_test.go index 61579b3208..48ebdf4cdc 100644 --- a/routers/web/repo/actions/view_test.go +++ b/routers/web/repo/actions/view_test.go @@ -382,6 +382,55 @@ func TestActionsViewViewPost(t *testing.T) { } } +func TestActionsViewCancelableUntilAllJobsFinished(t *testing.T) { + unittest.PrepareTestEnv(t) + + tests := []struct { + name string + runIndex int64 + assert func(*testing.T, *ViewResponse) + }{ + { + name: "failed and running", + runIndex: 191, + assert: func(t *testing.T, actual *ViewResponse) { + assert.Equal(t, "failure", actual.State.Run.Jobs[0].Status) + assert.Equal(t, "running", actual.State.Run.Jobs[1].Status) + assert.True(t, actual.State.Run.CanCancel) + }, + }, + { + name: "failed and success", + runIndex: 192, + assert: func(t *testing.T, actual *ViewResponse) { + assert.Equal(t, "failure", actual.State.Run.Jobs[0].Status) + assert.Equal(t, "success", actual.State.Run.Jobs[1].Status) + assert.False(t, actual.State.Run.CanCancel) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "user2/repo1/actions/runs/0") + contexttest.LoadUser(t, ctx, 1) + contexttest.LoadRepo(t, ctx, 4) + ctx.SetParams(":run", fmt.Sprintf("%d", tt.runIndex)) + ctx.SetParams(":attempt", fmt.Sprintf("%d", 0)) + web.SetForm(ctx, &ViewRequest{}) + + ViewPost(ctx) + require.Equal(t, http.StatusOK, resp.Result().StatusCode, "failure in ViewPost(): %q", resp.Body.String()) + + var actual ViewResponse + err := json.Unmarshal(resp.Body.Bytes(), &actual) + require.NoError(t, err) + + tt.assert(t, &actual) + }) + } +} + func TestActionsViewRedirectToLatestAttempt(t *testing.T) { unittest.PrepareTestEnv(t) diff --git a/tests/integration/api_admin_actions_test.go b/tests/integration/api_admin_actions_test.go index 702e68b1b7..52c68098a4 100644 --- a/tests/integration/api_admin_actions_test.go +++ b/tests/integration/api_admin_actions_test.go @@ -62,6 +62,7 @@ func TestActionsAPISearchActionJobs_GlobalRunnerAllPendingJobs(t *testing.T) { defer tests.PrepareTestEnv(t)() job196 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 196}) + job198 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 198}) job393 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 393}) job394 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 394}) job395 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 395}) @@ -81,11 +82,12 @@ func TestActionsAPISearchActionJobs_GlobalRunnerAllPendingJobs(t *testing.T) { var jobs []*api.ActionRunJob DecodeJSON(t, res, &jobs) - assert.Len(t, jobs, 6) + assert.Len(t, jobs, 7) assert.Equal(t, job397.ID, jobs[0].ID) assert.Equal(t, job396.ID, jobs[1].ID) assert.Equal(t, job395.ID, jobs[2].ID) assert.Equal(t, job394.ID, jobs[3].ID) assert.Equal(t, job393.ID, jobs[4].ID) - assert.Equal(t, job196.ID, jobs[5].ID) + assert.Equal(t, job198.ID, jobs[5].ID) + assert.Equal(t, job196.ID, jobs[6].ID) } diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index e80b6475c9..0bdce36995 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -472,12 +472,14 @@ export default { - - +
+ + +
{{ run.commit.localeCommit }} @@ -629,9 +631,10 @@ export default { .action-info-summary { display: flex; + flex-wrap: wrap; align-items: center; - justify-content: space-between; gap: 8px; + margin-bottom: 8px; } .action-info-summary-title { @@ -640,6 +643,17 @@ export default { gap: 0.5em; } +.action-info-summary-actions { + display: flex; + align-items: center; + gap: var(--button-spacing); + margin-left: auto; +} + +.action-info-summary-actions > button { + margin: 0; +} + .action-info-summary-title-text { font-size: 20px; margin: 0; From cd0afc4f9095dbd64583c1968f6d4634f26ab815 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Mon, 22 Dec 2025 00:28:33 +0100 Subject: [PATCH 007/155] [v14.0/forgejo] fix(ui): add missing space before 'Commit' back (#10526) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10521 Regression from 8039240c26a40525da364a5a1308726d6cd973f8 Co-authored-by: Beowulf Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10526 Reviewed-by: Beowulf Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- templates/repo/actions/runs_list.tmpl | 2 +- tests/integration/actions_runs_list_test.go | 26 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/integration/actions_runs_list_test.go diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index 060fc1b66a..3858ae0849 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -16,7 +16,7 @@
{{if not $.CurWorkflow}}{{.WorkflowID}} {{end}}#{{.Index}} - - {{- if .ScheduleID -}} + {{if .ScheduleID -}} {{ctx.Locale.Tr "actions.runs.scheduled"}} {{- else -}} {{ctx.Locale.Tr "actions.runs.commit"}} diff --git a/tests/integration/actions_runs_list_test.go b/tests/integration/actions_runs_list_test.go new file mode 100644 index 0000000000..790e19d71a --- /dev/null +++ b/tests/integration/actions_runs_list_test.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "net/http" + "testing" + + "forgejo.org/tests" + + "github.com/stretchr/testify/assert" +) + +func TestActionRunsList(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user5/repo4/actions") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + runSubLine := htmlDoc.Find(".run-list .flex-item-body").Text() + assert.Contains(t, runSubLine, "Commit") + assert.NotContains(t, runSubLine, "-Commit") +} From 650252f851604bc09c6f3e96e7944c99e0758f93 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Mon, 22 Dec 2025 00:29:08 +0100 Subject: [PATCH 008/155] [v14.0/forgejo] Add to html button in markdown `type="button"` (#10527) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10520 This is for preventing that a markdown button is recognized as button for submission in a html form. Buttons can't be stripped from the markdown due to: https://codeberg.org/forgejo/forgejo/pulls/7670#issuecomment-4086608 There is no issue with buttons if they always have `type="button"`, so this should be fine. This is a "follow-up" to !7670. Fixes #7656 Co-authored-by: Beowulf Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10527 Reviewed-by: Beowulf Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- modules/markup/markdown/goldmark.go | 3 ++ modules/markup/markdown/markdown_test.go | 27 +++++++++++++++ modules/markup/markdown/transform_html.go | 28 +++++++++++++++ modules/markup/sanitizer.go | 6 +++- .../issue-comment-file-preview.test.e2e.ts | 34 +++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 modules/markup/markdown/transform_html.go create mode 100644 tests/e2e/issue-comment-file-preview.test.e2e.ts diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 8a3da3b08f..1ea3375ab5 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -1,4 +1,5 @@ // Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package markdown @@ -87,6 +88,8 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa if scope, found := ctx.Metas["scope"]; found { v.Name = fmt.Appendf(v.Name, "-%s", scope) } + case *ast.RawHTML: + g.transformRawHTML(ctx, v, reader) } return ast.WalkContinue, nil }) diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index 7c13494a67..82c2c7fe8c 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -1,4 +1,5 @@ // Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package markdown_test @@ -125,6 +126,32 @@ func TestRender_Images(t *testing.T) { `

`+title+`

`) } +func TestRender_Buttons(t *testing.T) { + setting.AppURL = AppURL + + test := func(input, expected string) { + buffer, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + }, + }, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) + } + + test( + "", + `

`) + + test( + ``, + `

`) + test( + ``, + `

`) +} + func testAnswers(baseURLContent, baseURLImages string) []string { return []string{ `

Wiki! Enjoy :)

diff --git a/modules/markup/markdown/transform_html.go b/modules/markup/markdown/transform_html.go new file mode 100644 index 0000000000..9bebb45554 --- /dev/null +++ b/modules/markup/markdown/transform_html.go @@ -0,0 +1,28 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package markdown + +import ( + "strings" + + "forgejo.org/modules/markup" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func (g *ASTTransformer) addTypeToButton(v *ast.RawHTML, segment string) { + segment = strings.TrimPrefix(segment, " 0 { policy.AllowURLSchemes(setting.Markdown.CustomURLSchemes...) diff --git a/tests/e2e/issue-comment-file-preview.test.e2e.ts b/tests/e2e/issue-comment-file-preview.test.e2e.ts new file mode 100644 index 0000000000..03f7557dc0 --- /dev/null +++ b/tests/e2e/issue-comment-file-preview.test.e2e.ts @@ -0,0 +1,34 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +// @watch start +// modules/markup/** +// web_src/js/features/repo-unicode-escape.js +// @watch end + +import {expect} from '@playwright/test'; +import {dynamic_id, test} from './utils_e2e.ts'; + +test.use({user: 'user2'}); + +test('Escape button in file preview', async ({page}) => { + await page.goto('/user2/unicode-escaping/src/branch/main/a-file'); + + const url = await page.getByRole('link', {name: 'Permalink'}).getAttribute('href'); + + const response = await page.goto('/user2/repo1/issues/new'); + expect(response?.status()).toBe(200); + + // Create a new issue + await page.getByPlaceholder('Title').fill(dynamic_id()); + await page.getByPlaceholder('Leave a comment').fill(`http://localhost:3003${url}#L1`); + await page.getByRole('button', {name: 'Create issue'}).click(); + + await expect(page).toHaveURL(/\/user2\/repo1\/issues\/\d+$/); + + await expect(page.locator('table.file-preview.unicode-escaped')).toHaveCount(0); + await expect(async () => { + await page.locator('button.toggle-escape-button').click(); + await expect(page.locator('table.file-preview.unicode-escaped')).toHaveCount(1); + }).toPass(); +}); From fcb22b1a476f3a9cc4034a58fb90b69a0f1ff7f1 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Mon, 22 Dec 2025 13:45:28 +0100 Subject: [PATCH 009/155] [v14.0/forgejo] fix: always search for issue posters by user and full name (#10542) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10394 Previously searching for posters would use full name or username depending on the `[ui].DEFAULT_SHOW_FULL_NAME` setting, now it searches for both of them regardless of the setting. This also a fixes a bug when `[ui].DEFAULT_SHOW_FULL_NAME=true` that users without a full name where not able to searched for. Co-authored-by: BtbN Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10542 Reviewed-by: Gusted Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- models/repo/user_repo.go | 12 +++++------ routers/web/repo/issue.go | 2 +- tests/integration/issue_test.go | 37 +++++++++++++++++++-------------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index 309bfee18f..ca02c1e3f0 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -162,14 +162,12 @@ func GetReviewers(ctx context.Context, repo *Repository, doerID, posterID int64) return users, db.GetEngine(ctx).Where(cond).OrderBy(user_model.GetOrderByName()).Find(&users) } -// GetIssuePostersWithSearch returns users with limit of 30 whose username started with prefix that have authored an issue/pull request for the given repository -// If isShowFullName is set to true, also include full name prefix search -func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull bool, search string, isShowFullName bool) ([]*user_model.User, error) { +// GetIssuePostersWithSearch returns up to 30 users whose username starts with or full_name contains the given search string for the given repository. +func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull bool, search string) ([]*user_model.User, error) { users := make([]*user_model.User, 0, 30) - prefixCond := db.BuildCaseInsensitiveLike("name", search+"%") - if isShowFullName { - prefixCond = db.BuildCaseInsensitiveLike("full_name", "%"+search+"%") - } + prefixCond := builder.Or( + db.BuildCaseInsensitiveLike("name", search+"%"), + db.BuildCaseInsensitiveLike("full_name", "%"+search+"%")) cond := builder.In("`user`.id", builder.Select("poster_id").From("issue").Where( diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index df61ef0daf..d46ea7f133 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -3767,7 +3767,7 @@ func PullPosters(ctx *context.Context) { func issuePosters(ctx *context.Context, isPullList bool) { repo := ctx.Repo.Repository search := strings.TrimSpace(ctx.FormString("q")) - posters, err := repo_model.GetIssuePostersWithSearch(ctx, repo, isPullList, search, setting.UI.DefaultShowFullName) + posters, err := repo_model.GetIssuePostersWithSearch(ctx, repo, isPullList, search) if err != nil { ctx.JSON(http.StatusInternalServerError, err) return diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index b47f3fd534..19fe59a10a 100644 --- a/tests/integration/issue_test.go +++ b/tests/integration/issue_test.go @@ -1547,34 +1547,39 @@ func TestIssuePostersSearch(t *testing.T) { Results []*userSearchInfo `json:"results"` } - t.Run("Name search", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - defer test.MockVariableValue(&setting.UI.DefaultShowFullName, false)() + testCase := func(t *testing.T, showFullName bool, url, wantUserName string, wantUserID int64) { + t.Helper() + defer test.MockVariableValue(&setting.UI.DefaultShowFullName, showFullName)() - req := NewRequest(t, "GET", "/user2/repo1/issues/posters?q=USer2") + req := NewRequest(t, "GET", url) resp := MakeRequest(t, req, http.StatusOK) var data userSearchResponse DecodeJSON(t, resp, &data) assert.Len(t, data.Results, 1) - assert.Equal(t, "user2", data.Results[0].UserName) - assert.EqualValues(t, 2, data.Results[0].UserID) + assert.Equal(t, wantUserName, data.Results[0].UserName) + assert.Equal(t, wantUserID, data.Results[0].UserID) + } + + t.Run("Name search", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCase(t, false, "/user2/repo1/issues/posters?q=USer2", "user2", 2) + }) + + t.Run("Name search (default full_name)", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCase(t, true, "/user2/repo1/issues/posters?q=USer2", "user2", 2) }) t.Run("Full name search", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - defer test.MockVariableValue(&setting.UI.DefaultShowFullName, true)() + testCase(t, true, "/user2/repo1/issues/posters?q=OnE", "user1", 1) + }) - req := NewRequest(t, "GET", "/user2/repo1/issues/posters?q=OnE") - resp := MakeRequest(t, req, http.StatusOK) - - var data userSearchResponse - DecodeJSON(t, resp, &data) - - assert.Len(t, data.Results, 1) - assert.Equal(t, "user1", data.Results[0].UserName) - assert.EqualValues(t, 1, data.Results[0].UserID) + t.Run("Full name search (no default full_name)", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCase(t, false, "/user2/repo1/issues/posters?q=OnE", "user1", 1) }) } From 690760152925b3e1940a5e63289f8a7ddb7b0dd7 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Mon, 22 Dec 2025 14:49:27 +0100 Subject: [PATCH 010/155] [v14.0/forgejo] fix(ui): add dynamic aria-label to monospace button in markdown editor (#10543) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/8244 The aria-label now changes dynamically depending on whether the monospace font is enabled or disabled. Greetings from GPN :) Fixes #7669. Co-authored-by: JohnnyJayJay Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10543 Reviewed-by: Beowulf Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- tests/e2e/markdown-editor.test.e2e.ts | 26 +++++++++++++++++++ .../js/features/comp/ComboMarkdownEditor.js | 2 ++ 2 files changed, 28 insertions(+) diff --git a/tests/e2e/markdown-editor.test.e2e.ts b/tests/e2e/markdown-editor.test.e2e.ts index 3f6e1608b6..e2c2f36ed1 100644 --- a/tests/e2e/markdown-editor.test.e2e.ts +++ b/tests/e2e/markdown-editor.test.e2e.ts @@ -560,3 +560,29 @@ test('Markdown bold/italic toolbar and shortcut', async ({page}) => { await textarea.press('ControlOrMeta+KeyI'); await expect(textarea).toHaveValue(`line 1\nline 2\nline 3\nline 4`); }); + +test('Monospace button aria-label', async ({page}) => { + // Load page with editor + const response = await page.goto('/user2/repo1/issues/new'); + expect(response?.status()).toBe(200); + + const monospaceButton = page.locator('.markdown-switch-monospace'); + const enableText = await monospaceButton.getAttribute('data-enable-text'); + const disableText = await monospaceButton.getAttribute('data-disable-text'); + + async function assertAriaLabel(enabled: boolean) { + const expected = enabled ? disableText : enableText; + + await expect(monospaceButton).toHaveAttribute('aria-label', expected); + } + + const enabled = await monospaceButton.getAttribute('aria-checked') === 'true'; + + await assertAriaLabel(enabled); + + await monospaceButton.click(); + await assertAriaLabel(!enabled); + + await monospaceButton.click(); + await assertAriaLabel(enabled); +}); diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index 8c010ae386..b4bc443c0e 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -176,6 +176,7 @@ class ComboMarkdownEditor { const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true'; const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text'); monospaceButton.setAttribute('data-tooltip-content', monospaceText); + monospaceButton.setAttribute('aria-label', monospaceText); monospaceButton.setAttribute('aria-checked', String(monospaceEnabled)); monospaceButton?.addEventListener('click', (e) => { @@ -185,6 +186,7 @@ class ComboMarkdownEditor { this.textarea.classList.toggle('tw-font-mono', enabled); const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text'); monospaceButton.setAttribute('data-tooltip-content', text); + monospaceButton.setAttribute('aria-label', text); monospaceButton.setAttribute('aria-checked', String(enabled)); }); From 5a131275c19a3006de51ad221dbc044a5990cf4e Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Mon, 22 Dec 2025 23:07:10 +0100 Subject: [PATCH 011/155] [v14.0/forgejo] fix(ui): avatar for dismissed review is stretched if not square (#10540) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10524 The timeline event for a dismissed review didn't used the `AvatarUtils` until now. The `AvatarUtils` also adds classes to the img tag, which makes sure the avatar is correctly styled and not stretched. This PR replaces the img tag with the expected call to the `AvatarUtils`. Co-authored-by: Beowulf Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10540 Reviewed-by: Beowulf Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- templates/repo/issue/view_content/comments.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index dc0ec01df2..c3cc94dfb3 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -635,7 +635,7 @@
- + {{ctx.AvatarUtils.Avatar .Poster 40}} {{svg "octicon-x" 16}} From 8dff8ba7c2462414ee03b60288931efdf895e8b0 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Tue, 23 Dec 2025 00:52:58 +0100 Subject: [PATCH 012/155] [v14.0/forgejo] port(gitea): Fix password leak in log messages (go-gitea/gitea!35584) (#10555) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10550 Link to original PR: https://github.com/go-gitea/gitea/pull/35584 Original Author: https://github.com/shashank-netapp Co-authored-by: Shiny Nematoda Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10555 Reviewed-by: Gusted Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- modules/indexer/code/indexer.go | 7 ++++--- modules/indexer/issues/indexer.go | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/indexer/code/indexer.go b/modules/indexer/code/indexer.go index f3ed091a30..a3e20e1d5a 100644 --- a/modules/indexer/code/indexer.go +++ b/modules/indexer/code/indexer.go @@ -21,6 +21,7 @@ import ( "forgejo.org/modules/process" "forgejo.org/modules/queue" "forgejo.org/modules/setting" + "forgejo.org/modules/util" ) var ( @@ -182,12 +183,12 @@ func Init() { log.Fatal("PID: %d Unable to initialize the bleve Repository Indexer at path: %s Error: %v", os.Getpid(), setting.Indexer.RepoPath, err) } case "elasticsearch": - log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), setting.Indexer.RepoConnStr) + log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr)) defer func() { if err := recover(); err != nil { log.Error("PANIC whilst initializing repository indexer: %v\nStacktrace: %s", err, log.Stack(2)) log.Error("The indexer files are likely corrupted and may need to be deleted") - log.Error("You can completely remove the \"%s\" index to make Forgejo recreate the indexes", setting.Indexer.RepoConnStr) + log.Error("You can completely remove the \"%s\" index to make Forgejo recreate the indexes", util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr)) } }() @@ -197,7 +198,7 @@ func Init() { cancel() (*globalIndexer.Load()).Close() close(waitChannel) - log.Fatal("PID: %d Unable to initialize the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), setting.Indexer.RepoConnStr, err) + log.Fatal("PID: %d Unable to initialize the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr), err) } default: diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go index d472067cd8..eebcc9a509 100644 --- a/modules/indexer/issues/indexer.go +++ b/modules/indexer/issues/indexer.go @@ -25,6 +25,7 @@ import ( "forgejo.org/modules/process" "forgejo.org/modules/queue" "forgejo.org/modules/setting" + "forgejo.org/modules/util" ) // IndexerMetadata is used to send data to the queue, so it contains only the ids. @@ -100,7 +101,7 @@ func InitIssueIndexer(syncReindex bool) { issueIndexer = elasticsearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueIndexerName) existed, err = issueIndexer.Init(ctx) if err != nil { - log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err) + log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", util.SanitizeCredentialURLs(setting.Indexer.IssueConnStr), err) } case "db": issueIndexer = db_index.NewIndexer() @@ -108,7 +109,7 @@ func InitIssueIndexer(syncReindex bool) { issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName) existed, err = issueIndexer.Init(ctx) if err != nil { - log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err) + log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", util.SanitizeCredentialURLs(setting.Indexer.IssueConnStr), err) } default: log.Fatal("Unknown issue indexer type: %s", setting.Indexer.IssueType) From 462fe3819bc6f08607ea02a240dd7efd47f8e279 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Fri, 26 Dec 2025 23:01:50 +0100 Subject: [PATCH 013/155] [v14.0/forgejo] fix: ListTrackedTimes API has no defined record ordering (#10593) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10588 API call `GET /repos/{owner}/{repo}/issues/{index}/times` has no defined ordering implemented in it, causing PostgreSQL to have intermittent test failures on `TestAPIGetTrackedTimes` which expected records to be returned in ID order. ID order is reasonable enough, so this PR adds that ordering. Fixes #10577. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Co-authored-by: Mathieu Fenniak Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10593 Reviewed-by: Mathieu Fenniak Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- models/issues/tracked_time.go | 2 +- .../integration/api_issue_tracked_time_test.go | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/models/issues/tracked_time.go b/models/issues/tracked_time.go index d229d83470..e083f6e1e8 100644 --- a/models/issues/tracked_time.go +++ b/models/issues/tracked_time.go @@ -148,7 +148,7 @@ func (opts *FindTrackedTimesOptions) toSession(e db.Engine) db.Engine { // GetTrackedTimes returns all tracked times that fit to the given options. func GetTrackedTimes(ctx context.Context, options *FindTrackedTimesOptions) (trackedTimes TrackedTimeList, err error) { - err = options.toSession(db.GetEngine(ctx)).Find(&trackedTimes) + err = options.toSession(db.GetEngine(ctx)).Asc("tracked_time.id").Find(&trackedTimes) return trackedTimes, err } diff --git a/tests/integration/api_issue_tracked_time_test.go b/tests/integration/api_issue_tracked_time_test.go index a4f3d15f88..5e8b383437 100644 --- a/tests/integration/api_issue_tracked_time_test.go +++ b/tests/integration/api_issue_tracked_time_test.go @@ -61,8 +61,21 @@ func TestAPIGetTrackedTimes(t *testing.T) { var filterAPITimes api.TrackedTimeList DecodeJSON(t, resp, &filterAPITimes) assert.Len(t, filterAPITimes, 2) - assert.Equal(t, int64(3), filterAPITimes[0].ID) - assert.Equal(t, int64(6), filterAPITimes[1].ID) + assert.EqualValues(t, 3, filterAPITimes[0].ID) + assert.EqualValues(t, 6, filterAPITimes[1].ID) + + // test pagination + allIDs := []int64{} + for _, page := range []int{1, 2, 3} { + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/times?page=%d&limit=1", user2.Name, issue2.Repo.Name, issue2.Index, page). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var pageAPITimes api.TrackedTimeList + DecodeJSON(t, resp, &pageAPITimes) + require.Len(t, pageAPITimes, 1) + allIDs = append(allIDs, pageAPITimes[0].ID) + } + assert.Equal(t, []int64{2, 3, 6}, allIDs) } func TestAPIDeleteTrackedTime(t *testing.T) { From c6c51dcde625b96045dbb454f004edcbd322ab7e Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Sat, 27 Dec 2025 02:23:25 +0100 Subject: [PATCH 014/155] [v14.0/forgejo] test: fix intermittent PostgreSQL failure in TestAdminViewRepos (#10592) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10587 Intermittent test failure ([example](https://codeberg.org/forgejo/forgejo/actions/runs/125874/jobs/9/attempt/1)): ``` === TestAdminViewRepos (tests/test_utils.go:327) --- FAIL: TestAdminViewRepos (0.39s) testlogger.go:411: 2025/12/26 15:21:27 ...les/storage/local.go:33:NewLocalStorage() [I] Creating new Local Storage at /workspace/forgejo/forgejo/tests/gitea-lfs-meta testlogger.go:411: 2025/12/26 15:21:27 ...eb/routing/logger.go:102:func1() [I] router: completed POST /user/login for test-mock:12345, 303 See Other in 4.7ms @ auth/auth.go:178(auth.SignInPost) testlogger.go:411: 2025/12/26 15:21:27 ...eb/routing/logger.go:102:func1() [I] router: completed GET /admin/repos for test-mock:12345, 200 OK in 75.1ms @ admin/repos.go:29(admin.Repos) admin_repo_test.go:29: Error Trace: /workspace/forgejo/forgejo/tests/integration/admin_repo_test.go:29 Error: Not equal: expected: 1 actual : 0 Test: TestAdminViewRepos admin_repo_test.go:30: Error Trace: /workspace/forgejo/forgejo/tests/integration/admin_repo_test.go:30 Error: Not equal: expected: "repo49" actual : "" Diff: --- Expected +++ Actual @@ -1 +1 @@ -repo49 + Test: TestAdminViewRepos ``` Cause: the page is displaying 50 out of 65 repos in the fixture with a default sort of "recently updated"; on PostgreSQL that is occasionally causing the target link not to appear on the first page. As a fix, I've switched the test to load with reverse alphabetical order which should cause it to consistently appear on the first page. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] I do not want this change to show in the release notes. - [ ] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Co-authored-by: Mathieu Fenniak Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10592 Reviewed-by: Mathieu Fenniak Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- tests/integration/admin_repo_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/admin_repo_test.go b/tests/integration/admin_repo_test.go index 3c3b8a62d9..dd5b8f5d13 100644 --- a/tests/integration/admin_repo_test.go +++ b/tests/integration/admin_repo_test.go @@ -16,12 +16,12 @@ func TestAdminViewRepos(t *testing.T) { defer tests.PrepareTestEnv(t)() session := loginUser(t, "user1") - req := NewRequest(t, "GET", "/admin/repos") + req := NewRequest(t, "GET", "/admin/repos?q=&sort=reversealphabetically") resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - // Should be 50 rows of repositories rendered... + // Should be 50 rows of repositories rendered; this is the page size, and there are 65 repos in-fixture. assert.Equal(t, 50, htmlDoc.Find("table tbody tr").Length()) // Check for a specific repo link to see if it is rendered correctly From a89978a207f0bae4608eb7dfb0cfdfb11146ac9b Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Sat, 27 Dec 2025 17:24:50 +0100 Subject: [PATCH 015/155] [v14.0/forgejo] fix: allow Actions trust management on conflicted PRs (#10600) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10594 Fixes #10589. ![20251226_134200](/attachments/0aac3594-62d7-467f-82f2-2d9063fb5ffb) ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Co-authored-by: Mathieu Fenniak Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10600 Reviewed-by: Mathieu Fenniak Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- templates/repo/issue/view_content/pull.tmpl | 1 + tests/integration/actions_trust_test.go | 91 ++++++++++++++++++++- tests/integration/html_helper.go | 8 ++ 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/templates/repo/issue/view_content/pull.tmpl b/templates/repo/issue/view_content/pull.tmpl index 21f0d613ed..3a6a306b12 100644 --- a/templates/repo/issue/view_content/pull.tmpl +++ b/templates/repo/issue/view_content/pull.tmpl @@ -77,6 +77,7 @@
  • {{.}}
  • {{end}} + {{template "repo/pulls/trust" .}} {{else if .IsPullRequestBroken}}
    {{svg "octicon-x"}} diff --git a/tests/integration/actions_trust_test.go b/tests/integration/actions_trust_test.go index 7a683d5625..651700cd26 100644 --- a/tests/integration/actions_trust_test.go +++ b/tests/integration/actions_trust_test.go @@ -20,6 +20,7 @@ import ( actions_module "forgejo.org/modules/actions" "forgejo.org/modules/git" "forgejo.org/modules/structs" + "forgejo.org/modules/translation" actions_service "forgejo.org/services/actions" pull_service "forgejo.org/services/pull" repo_service "forgejo.org/services/repository" @@ -68,6 +69,32 @@ func actionsTrustTestAssertNoTrustPanel(t *testing.T, session *TestSession, url actionsTrustTestAssertTrustPanelPresence(t, session, url, false) } +func actionsTrustTestAssertPRIsWIP(t *testing.T, session *TestSession, url string) { + t.Helper() + + req := NewRequest(t, "GET", url) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + locale := translation.NewLocale("en-US") + assert.Equal(t, 1, htmlDoc.FindByTextTrim("div", locale.TrString("repo.pulls.cannot_merge_work_in_progress")).Length()) +} + +func actionsTrustTestAssertPRConflicted(t *testing.T, session *TestSession, url string) { + t.Helper() + + req := NewRequest(t, "GET", url) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + locale := translation.NewLocale("en-US") + + // ....Eventually is used because conflict checking is async and may not complete immediately. + require.Eventually(t, func() bool { + return htmlDoc.FindByTextTrim("div", locale.TrString("repo.pulls.files_conflicted")).Length() == 1 + }, 5*time.Second, time.Millisecond*100) +} + func actionsTrustTestCreateBaseRepo(t *testing.T, owner *user_model.User) (*repo_model.Repository, func()) { t.Helper() @@ -127,13 +154,19 @@ func actionsTrustTestRequireRun(t *testing.T, repo *repo_model.Repository, modif func actionsTrustTestRepoCreateBranch(t *testing.T, doer *user_model.User, repo *repo_model.Repository) *structs.FilesResponse { t.Helper() - return actionsTrustTestModifyRepo(t, doer, repo, "file_in_fork.txt", "main", "fork-branch-1") + return actionsTrustTestModifyRepo(t, doer, repo, "file_in_fork.txt", "main", "fork-branch-1", "content") +} + +func actionsTrustMakePRConflicted(t *testing.T, doer *user_model.User, repo *repo_model.Repository) *structs.FilesResponse { + t.Helper() + + return actionsTrustTestModifyRepo(t, doer, repo, "file_in_fork.txt", "main", "main", "conflicting content") } func actionsTrustTestRepoModify(t *testing.T, doer *user_model.User, baseRepo, headRepo *repo_model.Repository, filename string) *structs.FilesResponse { t.Helper() - modified := actionsTrustTestModifyRepo(t, doer, headRepo, filename, "fork-branch-1", "fork-branch-1") + modified := actionsTrustTestModifyRepo(t, doer, headRepo, filename, "fork-branch-1", "fork-branch-1", "content") // the creation of the run is not synchronous require.Eventually(t, func() bool { return unittest.BeanExists(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modified.Commit.SHA}) @@ -141,7 +174,7 @@ func actionsTrustTestRepoModify(t *testing.T, doer *user_model.User, baseRepo, h return modified } -func actionsTrustTestModifyRepo(t *testing.T, doer *user_model.User, repo *repo_model.Repository, filename, oldBranch, newBranch string) *structs.FilesResponse { +func actionsTrustTestModifyRepo(t *testing.T, doer *user_model.User, repo *repo_model.Repository, filename, oldBranch, newBranch, content string) *structs.FilesResponse { t.Helper() // add a new file to the forked repo @@ -150,7 +183,7 @@ func actionsTrustTestModifyRepo(t *testing.T, doer *user_model.User, repo *repo_ { Operation: "create", TreePath: filename, - ContentReader: strings.NewReader("content"), + ContentReader: strings.NewReader(content), }, }, Message: "add " + filename, @@ -220,6 +253,23 @@ func actionsTrustTestCreatePullRequestFromForkedRepo(t *testing.T, baseUser *use return forkedRepo, pullRequest, addFileToForkedResp } +// Mark the PR as a work-in-progress PR +func actionsTrustTestSetPullRequestWIP(t *testing.T, pullRequest *issues_model.PullRequest, wip bool) { + t.Helper() + newTitle := pullRequest.Issue.Title + if wip && !pullRequest.IsWorkInProgress(t.Context()) { + newTitle = fmt.Sprintf("WIP: %s", pullRequest.Issue.Title) + } else if !wip { + prefix := pullRequest.GetWorkInProgressPrefix(t.Context()) + newTitle = pullRequest.Issue.Title[len(prefix):] + } + pullRequest.Issue.Title = newTitle + require.NoError(t, issues_model.UpdateIssueCols(t.Context(), pullRequest.Issue, "name")) + + pullRequest.Issue = nil + require.NoError(t, pullRequest.LoadIssue(t.Context())) +} + func TestActionsPullRequestTrustPanel(t *testing.T) { onApplicationRun(t, func(t *testing.T, u *url.URL) { ownerUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo @@ -353,3 +403,36 @@ func TestActionsPullRequestTrustPanel(t *testing.T) { }) }) } + +func TestActionsPullRequestTrustPanelWIPConflicts(t *testing.T) { + onApplicationRun(t, func(t *testing.T, u *url.URL) { + ownerUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo + + regularUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // a regular user with no specific permission + regularSession := loginUser(t, regularUser.Name) + + userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // the instance admin + adminSession := loginUser(t, userAdmin.Name) + + baseRepo, f := actionsTrustTestCreateBaseRepo(t, ownerUser) + defer f() + + _, pullRequest, _ := actionsTrustTestCreatePullRequestFromForkedRepo(t, ownerUser, baseRepo, regularUser) + pullRequestLink := pullRequest.Issue.Link() + + actionsTrustTestSetPullRequestWIP(t, pullRequest, true) + actionsTrustTestAssertPRIsWIP(t, adminSession, pullRequestLink) + + t.Run("Regular user sees pending approval even though PR is a WIP PR", func(t *testing.T) { + actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink) + }) + + actionsTrustTestSetPullRequestWIP(t, pullRequest, false) + _ = actionsTrustMakePRConflicted(t, userAdmin, baseRepo) + actionsTrustTestAssertPRConflicted(t, adminSession, pullRequestLink) + + t.Run("Regular user sees pending approval even though PR is conflicted", func(t *testing.T) { + actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink) + }) + }) +} diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go index bcf63e9730..e793009ce0 100644 --- a/tests/integration/html_helper.go +++ b/tests/integration/html_helper.go @@ -6,6 +6,7 @@ package integration import ( "bytes" "fmt" + "strings" "testing" "github.com/PuerkitoBio/goquery" @@ -108,6 +109,13 @@ func (doc *HTMLDoc) FindByText(selector, text string) *goquery.Selection { }) } +// FindByText gets all elements by selector that also has the given text, w/ leading & trailing whitespace trimmed +func (doc *HTMLDoc) FindByTextTrim(selector, text string) *goquery.Selection { + return doc.doc.Find(selector).FilterFunction(func(i int, s *goquery.Selection) bool { + return strings.TrimSpace(s.Text()) == text + }) +} + // AssertSelection check if selection exists or does not exist depending on checkExists func (doc *HTMLDoc) AssertSelection(t testing.TB, selection *goquery.Selection, checkExists bool) { if checkExists { From 763547f43f8eb9686e3b5617da33afdf04610326 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Mon, 29 Dec 2025 03:49:03 +0100 Subject: [PATCH 016/155] [v14.0/forgejo] migration: update existing foreign key migrations to automatically fix inconsistencies (#10621) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10568 Changes foreign key database inconsistency handling so that inconsistent records are automatically deleted with an administrator warning during migration. As noted in discussion: https://codeberg.org/forgejo/discussions/issues/385#issuecomment-9175566 Because these migrations are now deleting data, rather than allowing the administrator to do it, all migrations have been covered with an integration test that verifies expected data is deleted. This is particularly interesting with nullable fields. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Co-authored-by: Mathieu Fenniak Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10621 Reviewed-by: Mathieu Fenniak Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- .../forgejo_migrations/foreign_key_utils.go | 36 ++++----- .../v14a_add-foreign-keys-collaboration.go | 9 ++- ...14a_add-foreign-keys-collaboration_test.go | 53 +++++++++++++ ...14a_add-foreign-keys-forgejo_auth_token.go | 6 +- ...dd-foreign-keys-forgejo_auth_token_test.go | 50 +++++++++++++ .../v14a_add-foreign-keys-pull_request-1.go | 9 ++- ...4a_add-foreign-keys-pull_request-1_test.go | 69 +++++++++++++++++ models/forgejo_migrations_legacy/v41.go | 56 +++++++++----- models/forgejo_migrations_legacy/v41_test.go | 75 +++++++++++++++++++ models/forgejo_migrations_legacy/v44.go | 9 ++- models/forgejo_migrations_legacy/v44_test.go | 50 +++++++++++++ .../Test_AddForeignKeysAccess/access.yml | 28 +++++++ .../Test_AddForeignKeysAccess/repository.yml | 2 + .../tracked_time.yml | 33 ++++++++ .../Test_AddForeignKeysAccess/user.yml | 2 + .../issue.yml | 2 + .../stopwatch.yml | 28 +++++++ .../tracked_time.yml | 33 ++++++++ .../user.yml | 2 + .../collaboration.yml | 16 ++++ .../repository.yml | 2 + .../Test_addForeignKeysCollaboration/user.yml | 2 + .../forgejo_auth_token.yml | 16 ++++ .../user.yml | 2 + .../Test_addForeignKeysPullRequest1/issue.yml | 2 + .../pull_request.yml | 28 +++++++ .../repository.yml | 2 + 27 files changed, 578 insertions(+), 44 deletions(-) create mode 100644 models/forgejo_migrations/v14a_add-foreign-keys-collaboration_test.go create mode 100644 models/forgejo_migrations/v14a_add-foreign-keys-forgejo_auth_token_test.go create mode 100644 models/forgejo_migrations/v14a_add-foreign-keys-pull_request-1_test.go create mode 100644 models/forgejo_migrations_legacy/v41_test.go create mode 100644 models/forgejo_migrations_legacy/v44_test.go create mode 100644 models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/access.yml create mode 100644 models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/repository.yml create mode 100644 models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/tracked_time.yml create mode 100644 models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/user.yml create mode 100644 models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/issue.yml create mode 100644 models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/stopwatch.yml create mode 100644 models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/tracked_time.yml create mode 100644 models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/user.yml create mode 100644 models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/collaboration.yml create mode 100644 models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/repository.yml create mode 100644 models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/user.yml create mode 100644 models/gitea_migrations/fixtures/Test_addForeignKeysForgejoAuthToken/forgejo_auth_token.yml create mode 100644 models/gitea_migrations/fixtures/Test_addForeignKeysForgejoAuthToken/user.yml create mode 100644 models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/issue.yml create mode 100644 models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/pull_request.yml create mode 100644 models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/repository.yml diff --git a/models/forgejo_migrations/foreign_key_utils.go b/models/forgejo_migrations/foreign_key_utils.go index fc2680e43e..2a792fd66b 100644 --- a/models/forgejo_migrations/foreign_key_utils.go +++ b/models/forgejo_migrations/foreign_key_utils.go @@ -4,28 +4,30 @@ package forgejo_migrations import ( - "errors" + "fmt" "forgejo.org/modules/log" + "xorm.io/builder" "xorm.io/xorm" ) -func syncDoctorForeignKey(x *xorm.Engine, beans []any) error { - for _, bean := range beans { - // Sync() drops indexes by default, which will cause unnecessary rebuilding of indexes when syncDoctorForeignKey - // is used with partial bean definitions; so we disable that option - _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, bean) - if err != nil { - if errors.Is(err, xorm.ErrForeignKeyViolation) { - tableName := x.TableName(bean) - log.Error( - "Foreign key creation on table %s failed. Run `forgejo doctor check --all` to identify the orphaned records preventing this foreign key from being created. Error was: %v", - tableName, err) - return err - } - return err - } +// syncForeignKeyWithDelete will delete any records that match `cond`, and if present, log and warn to the +// administrator; then it will perform an `xorm.Sync()` in order to create foreign keys on the table definition. +func syncForeignKeyWithDelete(x *xorm.Engine, bean any, cond builder.Cond) error { + rowsDeleted, err := x.Where(cond).Delete(bean) + if err != nil { + return fmt.Errorf("failure to delete inconsistent records before foreign key sync: %w", err) } - return nil + if rowsDeleted > 0 { + tableName := x.TableName(bean) + log.Warn( + "Foreign key creation on table %s required deleting %d records with inconsistent foreign key values.", + tableName, rowsDeleted) + } + + // Sync() drops indexes by default, which will cause unnecessary rebuilding of indexes when syncForeignKeyWithDelete + // is used with partial bean definitions; so we disable that option + _, err = x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, bean) + return err } diff --git a/models/forgejo_migrations/v14a_add-foreign-keys-collaboration.go b/models/forgejo_migrations/v14a_add-foreign-keys-collaboration.go index a52e8d6220..774dfe158c 100644 --- a/models/forgejo_migrations/v14a_add-foreign-keys-collaboration.go +++ b/models/forgejo_migrations/v14a_add-foreign-keys-collaboration.go @@ -4,6 +4,7 @@ package forgejo_migrations import ( + "xorm.io/builder" "xorm.io/xorm" ) @@ -19,7 +20,11 @@ func addForeignKeysCollaboration(x *xorm.Engine) error { RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL REFERENCES(repository, id)"` UserID int64 `xorm:"UNIQUE(s) INDEX NOT NULL REFERENCES(user, id)"` } - return syncDoctorForeignKey(x, []any{ + return syncForeignKeyWithDelete(x, new(Collaboration), - }) + builder.Or( + builder.Expr("NOT EXISTS (SELECT id FROM repository WHERE repository.id = collaboration.repo_id)"), + builder.Expr("NOT EXISTS (SELECT id FROM `user` WHERE `user`.id = collaboration.user_id)"), + ), + ) } diff --git a/models/forgejo_migrations/v14a_add-foreign-keys-collaboration_test.go b/models/forgejo_migrations/v14a_add-foreign-keys-collaboration_test.go new file mode 100644 index 0000000000..657bdf377a --- /dev/null +++ b/models/forgejo_migrations/v14a_add-foreign-keys-collaboration_test.go @@ -0,0 +1,53 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "testing" + + "forgejo.org/models/db" + migration_tests "forgejo.org/models/gitea_migrations/test" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_addForeignKeysCollaboration(t *testing.T) { + type AccessMode int + type Collaboration struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` + UserID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` + Mode AccessMode `xorm:"DEFAULT 2 NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + type Repository struct { + ID int64 `xorm:"pk autoincr"` + } + type User struct { + ID int64 `xorm:"pk autoincr"` + } + x, deferable := migration_tests.PrepareTestEnv(t, 0, new(User), new(Repository), new(Collaboration)) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, addForeignKeysCollaboration(x)) + + var remainingRecords []*Collaboration + require.NoError(t, + db.GetEngine(t.Context()). + Table("collaboration"). + Select("`id`, `repo_id`, `user_id`"). + OrderBy("`id`"). + Find(&remainingRecords)) + assert.Equal(t, + []*Collaboration{ + {ID: 1, UserID: 1, RepoID: 1}, + }, + remainingRecords) +} diff --git a/models/forgejo_migrations/v14a_add-foreign-keys-forgejo_auth_token.go b/models/forgejo_migrations/v14a_add-foreign-keys-forgejo_auth_token.go index a5d8126d91..4aea62da50 100644 --- a/models/forgejo_migrations/v14a_add-foreign-keys-forgejo_auth_token.go +++ b/models/forgejo_migrations/v14a_add-foreign-keys-forgejo_auth_token.go @@ -4,6 +4,7 @@ package forgejo_migrations import ( + "xorm.io/builder" "xorm.io/xorm" ) @@ -18,7 +19,8 @@ func addForeignKeysForgejoAuthToken(x *xorm.Engine) error { type ForgejoAuthToken struct { UID int64 `xorm:"INDEX REFERENCES(user, id)"` } - return syncDoctorForeignKey(x, []any{ + return syncForeignKeyWithDelete(x, new(ForgejoAuthToken), - }) + builder.Expr("NOT EXISTS (SELECT id FROM `user` WHERE `user`.id = forgejo_auth_token.uid)"), + ) } diff --git a/models/forgejo_migrations/v14a_add-foreign-keys-forgejo_auth_token_test.go b/models/forgejo_migrations/v14a_add-foreign-keys-forgejo_auth_token_test.go new file mode 100644 index 0000000000..7f01a9ccf3 --- /dev/null +++ b/models/forgejo_migrations/v14a_add-foreign-keys-forgejo_auth_token_test.go @@ -0,0 +1,50 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "testing" + + "forgejo.org/models/db" + migration_tests "forgejo.org/models/gitea_migrations/test" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_addForeignKeysForgejoAuthToken(t *testing.T) { + type AuthorizationPurpose string + type ForgejoAuthToken struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX"` + LookupKey string `xorm:"INDEX UNIQUE"` + HashedValidator string + Purpose AuthorizationPurpose `xorm:"NOT NULL DEFAULT 'long_term_authorization'"` + Expiry timeutil.TimeStamp + } + type User struct { + ID int64 `xorm:"pk autoincr"` + } + x, deferable := migration_tests.PrepareTestEnv(t, 0, new(User), new(ForgejoAuthToken)) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, addForeignKeysForgejoAuthToken(x)) + + var remainingRecords []*ForgejoAuthToken + require.NoError(t, + db.GetEngine(t.Context()). + Table("forgejo_auth_token"). + Select("`id`, `uid`"). + OrderBy("`id`"). + Find(&remainingRecords)) + assert.Equal(t, + []*ForgejoAuthToken{ + {ID: 1, UID: 1}, + }, + remainingRecords) +} diff --git a/models/forgejo_migrations/v14a_add-foreign-keys-pull_request-1.go b/models/forgejo_migrations/v14a_add-foreign-keys-pull_request-1.go index 6d262f385f..6e67207c7d 100644 --- a/models/forgejo_migrations/v14a_add-foreign-keys-pull_request-1.go +++ b/models/forgejo_migrations/v14a_add-foreign-keys-pull_request-1.go @@ -4,6 +4,7 @@ package forgejo_migrations import ( + "xorm.io/builder" "xorm.io/xorm" ) @@ -19,7 +20,11 @@ func addForeignKeysPullRequest1(x *xorm.Engine) error { IssueID int64 `xorm:"INDEX REFERENCES(issue, id)"` BaseRepoID int64 `xorm:"INDEX REFERENCES(repository, id)"` } - return syncDoctorForeignKey(x, []any{ + return syncForeignKeyWithDelete(x, new(PullRequest), - }) + builder.Or( + builder.Expr("NOT EXISTS (SELECT id FROM issue WHERE issue.id = pull_request.issue_id)"), + builder.Expr("NOT EXISTS (SELECT id FROM repository WHERE repository.id = pull_request.base_repo_id)"), + ), + ) } diff --git a/models/forgejo_migrations/v14a_add-foreign-keys-pull_request-1_test.go b/models/forgejo_migrations/v14a_add-foreign-keys-pull_request-1_test.go new file mode 100644 index 0000000000..ebe8600350 --- /dev/null +++ b/models/forgejo_migrations/v14a_add-foreign-keys-pull_request-1_test.go @@ -0,0 +1,69 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "testing" + + "forgejo.org/models/db" + migration_tests "forgejo.org/models/gitea_migrations/test" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_addForeignKeysPullRequest1(t *testing.T) { + type PullRequestType int + type PullRequestStatus int + type PullRequestFlow int + type PullRequest struct { + ID int64 `xorm:"pk autoincr"` + Type PullRequestType + Status PullRequestStatus + ConflictedFiles []string `xorm:"TEXT JSON"` + CommitsAhead int + CommitsBehind int + ChangedProtectedFiles []string `xorm:"TEXT JSON"` + IssueID int64 `xorm:"INDEX"` + Index int64 + HeadRepoID int64 `xorm:"INDEX"` + BaseRepoID int64 `xorm:"INDEX"` + HeadBranch string + BaseBranch string + MergeBase string `xorm:"VARCHAR(64)"` + AllowMaintainerEdit bool `xorm:"NOT NULL DEFAULT false"` + HasMerged bool `xorm:"INDEX"` + MergedCommitID string `xorm:"VARCHAR(64)"` + MergerID int64 `xorm:"INDEX"` + MergedUnix timeutil.TimeStamp `xorm:"updated INDEX"` + Flow PullRequestFlow `xorm:"NOT NULL DEFAULT 0"` + } + type Repository struct { + ID int64 `xorm:"pk autoincr"` + } + type Issue struct { + ID int64 `xorm:"pk autoincr"` + } + x, deferable := migration_tests.PrepareTestEnv(t, 0, new(Issue), new(Repository), new(PullRequest)) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, addForeignKeysPullRequest1(x)) + + var remainingRecords []*PullRequest + require.NoError(t, + db.GetEngine(t.Context()). + Table("pull_request"). + Select("`id`, `issue_id`, `base_repo_id`"). + OrderBy("`id`"). + Find(&remainingRecords)) + assert.Equal(t, + []*PullRequest{ + {ID: 1, BaseRepoID: 1, IssueID: 1}, + }, + remainingRecords) +} diff --git a/models/forgejo_migrations_legacy/v41.go b/models/forgejo_migrations_legacy/v41.go index 4b3306debd..6dd25486c6 100644 --- a/models/forgejo_migrations_legacy/v41.go +++ b/models/forgejo_migrations_legacy/v41.go @@ -4,7 +4,6 @@ package forgejo_migrations_legacy import ( - "errors" "fmt" "forgejo.org/modules/log" @@ -13,23 +12,24 @@ import ( "xorm.io/xorm" ) -func syncDoctorForeignKey(x *xorm.Engine, beans []any) error { - for _, bean := range beans { - // Sync() drops indexes by default, which will cause unnecessary rebuilding of indexes when syncDoctorForeignKey - // is used with partial bean definitions; so we disable that option - _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, bean) - if err != nil { - if errors.Is(err, xorm.ErrForeignKeyViolation) { - tableName := x.TableName(bean) - log.Error( - "Foreign key creation on table %s failed. Run `forgejo doctor check --all` to identify the orphaned records preventing this foreign key from being created. Error was: %v", - tableName, err) - return err - } - return err - } +// syncForeignKeyWithDelete will delete any records that match `cond`, and if present, log and warn to the +// administrator; then it will perform an `xorm.Sync()` in order to create foreign keys on the table definition. +func syncForeignKeyWithDelete(x *xorm.Engine, bean any, cond builder.Cond) error { + rowsDeleted, err := x.Where(cond).Delete(bean) + if err != nil { + return fmt.Errorf("failure to delete inconsistent records before foreign key sync: %w", err) } - return nil + if rowsDeleted > 0 { + tableName := x.TableName(bean) + log.Warn( + "Foreign key creation on table %s required deleting %d records with inconsistent foreign key values.", + tableName, rowsDeleted) + } + + // Sync() drops indexes by default, which will cause unnecessary rebuilding of indexes when syncForeignKeyWithDelete + // is used with partial bean definitions; so we disable that option + _, err = x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, bean) + return err } func AddForeignKeysStopwatchTrackedTime(x *xorm.Engine) error { @@ -50,6 +50,7 @@ func AddForeignKeysStopwatchTrackedTime(x *xorm.Engine) error { err := x.Table("tracked_time"). Join("LEFT", "`user`", "`tracked_time`.user_id = `user`.id"). Where(builder.IsNull{"`user`.id"}). + Where(builder.NotNull{"tracked_time.user_id"}). Find(&trackedTime) if err != nil { return err @@ -63,8 +64,25 @@ func AddForeignKeysStopwatchTrackedTime(x *xorm.Engine) error { } } - return syncDoctorForeignKey(x, []any{ + err = syncForeignKeyWithDelete(x, new(Stopwatch), + builder.Or( + builder.Expr("NOT EXISTS (SELECT id FROM issue WHERE issue.id = stopwatch.issue_id)"), + builder.Expr("NOT EXISTS (SELECT id FROM `user` WHERE `user`.id = stopwatch.user_id)"), + ), + ) + if err != nil { + return err + } + + return syncForeignKeyWithDelete(x, new(TrackedTime), - }) + builder.Or( + builder.And( + builder.Expr("user_id IS NOT NULL"), + builder.Expr("NOT EXISTS (SELECT id FROM `user` WHERE `user`.id = tracked_time.user_id)"), + ), + builder.Expr("NOT EXISTS (SELECT id FROM issue WHERE issue.id = tracked_time.issue_id)"), + ), + ) } diff --git a/models/forgejo_migrations_legacy/v41_test.go b/models/forgejo_migrations_legacy/v41_test.go new file mode 100644 index 0000000000..47fc7f582e --- /dev/null +++ b/models/forgejo_migrations_legacy/v41_test.go @@ -0,0 +1,75 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations_legacy + +import ( + "testing" + + "forgejo.org/models/db" + migration_tests "forgejo.org/models/gitea_migrations/test" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_AddForeignKeysStopwatchTrackedTime(t *testing.T) { + type Stopwatch struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + UserID int64 `xorm:"INDEX"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + type TrackedTime struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + UserID int64 `xorm:"INDEX"` + CreatedUnix int64 `xorm:"created"` + Time int64 `xorm:"NOT NULL"` + Deleted bool `xorm:"NOT NULL DEFAULT false"` + } + type User struct { + ID int64 `xorm:"pk autoincr"` + } + type Issue struct { + ID int64 `xorm:"pk autoincr"` + } + x, deferable := migration_tests.PrepareTestEnv(t, 0, new(User), new(Issue), new(Stopwatch), new(TrackedTime)) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, AddForeignKeysStopwatchTrackedTime(x)) + + var remainingStopwatch []*Stopwatch + require.NoError(t, + db.GetEngine(t.Context()). + Table("stopwatch"). + Select("`id`, `issue_id`, `user_id`"). + OrderBy("`id`"). + Find(&remainingStopwatch)) + assert.Equal(t, + []*Stopwatch{ + {1, 1, 1, 0}, + }, + remainingStopwatch, + "stopwatch") + + var remainingTrackedTime []*TrackedTime + require.NoError(t, + db.GetEngine(t.Context()). + Table("tracked_time"). + Select("`id`, `issue_id`, `user_id`"). + OrderBy("`id`"). + Find(&remainingTrackedTime)) + assert.Equal(t, + []*TrackedTime{ + {ID: 1, IssueID: 1, UserID: 1}, + {ID: 4, IssueID: 1, UserID: 0}, + {ID: 5, IssueID: 1, UserID: 0}, + }, + remainingTrackedTime, + "tracked_time") +} diff --git a/models/forgejo_migrations_legacy/v44.go b/models/forgejo_migrations_legacy/v44.go index 459c7ac29d..9f2fcef4f4 100644 --- a/models/forgejo_migrations_legacy/v44.go +++ b/models/forgejo_migrations_legacy/v44.go @@ -4,6 +4,7 @@ package forgejo_migrations_legacy import ( + "xorm.io/builder" "xorm.io/xorm" ) @@ -12,7 +13,11 @@ func AddForeignKeysAccess(x *xorm.Engine) error { UserID int64 `xorm:"UNIQUE(s) REFERENCES(user, id)"` RepoID int64 `xorm:"UNIQUE(s) REFERENCES(repository, id)"` } - return syncDoctorForeignKey(x, []any{ + return syncForeignKeyWithDelete(x, new(Access), - }) + builder.Or( + builder.Expr("NOT EXISTS (SELECT id FROM repository WHERE repository.id = access.repo_id)"), + builder.Expr("NOT EXISTS (SELECT id FROM `user` WHERE `user`.id = access.user_id)"), + ), + ) } diff --git a/models/forgejo_migrations_legacy/v44_test.go b/models/forgejo_migrations_legacy/v44_test.go new file mode 100644 index 0000000000..e78aa2fe46 --- /dev/null +++ b/models/forgejo_migrations_legacy/v44_test.go @@ -0,0 +1,50 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations_legacy + +import ( + "testing" + + "forgejo.org/models/db" + migration_tests "forgejo.org/models/gitea_migrations/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_AddForeignKeysAccess(t *testing.T) { + type AccessMode int + type Access struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"UNIQUE(s)"` + RepoID int64 `xorm:"UNIQUE(s)"` + Mode AccessMode + } + type User struct { + ID int64 `xorm:"pk autoincr"` + } + type Repository struct { + ID int64 `xorm:"pk autoincr"` + } + x, deferable := migration_tests.PrepareTestEnv(t, 0, new(User), new(Repository), new(Access)) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, AddForeignKeysAccess(x)) + + var remainingRecords []*Access + require.NoError(t, + db.GetEngine(t.Context()). + Table("access"). + Select("`id`, `user_id`, `repo_id`"). + OrderBy("`id`"). + Find(&remainingRecords)) + assert.Equal(t, + []*Access{ + {ID: 1, UserID: 1, RepoID: 1}, + }, + remainingRecords) +} diff --git a/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/access.yml b/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/access.yml new file mode 100644 index 0000000000..8ce770fbd0 --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/access.yml @@ -0,0 +1,28 @@ +- + id: 1 + repo_id: 1 + user_id: 1 + +# Expected to be deleted due to invalid repository foreign key +- + id: 2 + repo_id: 100 + user_id: 1 + +# Expected to be deleted due to null repository foreign key +- + id: 3 + repo_id: null + user_id: 1 + +# Expected to be deleted due to invalid user foreign key +- + id: 4 + repo_id: 1 + user_id: 100 + +# Expected to be deleted due to null user foreign key +- + id: 5 + repo_id: 1 + user_id: null diff --git a/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/repository.yml b/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/repository.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/repository.yml @@ -0,0 +1,2 @@ +- + id: 1 diff --git a/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/tracked_time.yml b/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/tracked_time.yml new file mode 100644 index 0000000000..0be83d55a5 --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/tracked_time.yml @@ -0,0 +1,33 @@ +- + id: 1 + issue_id: 1 + user_id: 1 + time: 100 + +# Expected to be deleted due to invalid issue foreign key +- + id: 2 + issue_id: 100 + user_id: 1 + time: 100 + +# Expected to be deleted due to null issue foreign key +- + id: 3 + issue_id: null + user_id: 1 + time: 100 + +# Expected to be retained with null, due to invalid user foreign key +- + id: 4 + issue_id: 1 + user_id: 100 + time: 100 + +# Expected to be retained with null user foreign key +- + id: 5 + issue_id: 1 + user_id: null + time: 100 diff --git a/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/user.yml b/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/user.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_AddForeignKeysAccess/user.yml @@ -0,0 +1,2 @@ +- + id: 1 diff --git a/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/issue.yml b/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/issue.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/issue.yml @@ -0,0 +1,2 @@ +- + id: 1 diff --git a/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/stopwatch.yml b/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/stopwatch.yml new file mode 100644 index 0000000000..5d84c3a78d --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/stopwatch.yml @@ -0,0 +1,28 @@ +- + id: 1 + issue_id: 1 + user_id: 1 + +# Expected to be deleted due to invalid issue foreign key +- + id: 2 + issue_id: 100 + user_id: 1 + +# Expected to be deleted due to null issue foreign key +- + id: 3 + issue_id: null + user_id: 1 + +# Expected to be deleted due to invalid user foreign key +- + id: 4 + issue_id: 1 + user_id: 100 + +# Expected to be deleted due to null user foreign key +- + id: 5 + issue_id: 1 + user_id: null diff --git a/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/tracked_time.yml b/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/tracked_time.yml new file mode 100644 index 0000000000..0be83d55a5 --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/tracked_time.yml @@ -0,0 +1,33 @@ +- + id: 1 + issue_id: 1 + user_id: 1 + time: 100 + +# Expected to be deleted due to invalid issue foreign key +- + id: 2 + issue_id: 100 + user_id: 1 + time: 100 + +# Expected to be deleted due to null issue foreign key +- + id: 3 + issue_id: null + user_id: 1 + time: 100 + +# Expected to be retained with null, due to invalid user foreign key +- + id: 4 + issue_id: 1 + user_id: 100 + time: 100 + +# Expected to be retained with null user foreign key +- + id: 5 + issue_id: 1 + user_id: null + time: 100 diff --git a/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/user.yml b/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/user.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_AddForeignKeysStopwatchTrackedTime/user.yml @@ -0,0 +1,2 @@ +- + id: 1 diff --git a/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/collaboration.yml b/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/collaboration.yml new file mode 100644 index 0000000000..a96c2340c8 --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/collaboration.yml @@ -0,0 +1,16 @@ +- + id: 1 + user_id: 1 + repo_id: 1 + +# Expected to be deleted due to invalid user_id foreign key +- + id: 2 + user_id: 100 + repo_id: 1 + +# Expected to be deleted due to invalid repo_id foreign key +- + id: 3 + user_id: 1 + repo_id: 100 diff --git a/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/repository.yml b/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/repository.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/repository.yml @@ -0,0 +1,2 @@ +- + id: 1 diff --git a/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/user.yml b/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/user.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_addForeignKeysCollaboration/user.yml @@ -0,0 +1,2 @@ +- + id: 1 diff --git a/models/gitea_migrations/fixtures/Test_addForeignKeysForgejoAuthToken/forgejo_auth_token.yml b/models/gitea_migrations/fixtures/Test_addForeignKeysForgejoAuthToken/forgejo_auth_token.yml new file mode 100644 index 0000000000..ee151f769e --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_addForeignKeysForgejoAuthToken/forgejo_auth_token.yml @@ -0,0 +1,16 @@ +- + id: 1 + uid: 1 + lookup_key: key-1 + +# Expected to be deleted due to invalid user foreign key +- + id: 2 + uid: 100 + lookup_key: key-2 + +# Expected to be deleted due to a null user foreign key +- + id: 3 + uid: null + lookup_key: key-3 diff --git a/models/gitea_migrations/fixtures/Test_addForeignKeysForgejoAuthToken/user.yml b/models/gitea_migrations/fixtures/Test_addForeignKeysForgejoAuthToken/user.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_addForeignKeysForgejoAuthToken/user.yml @@ -0,0 +1,2 @@ +- + id: 1 diff --git a/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/issue.yml b/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/issue.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/issue.yml @@ -0,0 +1,2 @@ +- + id: 1 diff --git a/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/pull_request.yml b/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/pull_request.yml new file mode 100644 index 0000000000..390bb2ff4d --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/pull_request.yml @@ -0,0 +1,28 @@ +- + id: 1 + issue_id: 1 + base_repo_id: 1 + +# Expected to be deleted due to invalid issue foreign key +- + id: 2 + issue_id: 100 + base_repo_id: 1 + +# Expected to be deleted due to null issue foreign key +- + id: 3 + issue_id: null + base_repo_id: 1 + +# Expected to be deleted due to invalid repository foreign key +- + id: 4 + issue_id: 1 + base_repo_id: 100 + +# Expected to be deleted due to null repository foreign key +- + id: 5 + issue_id: 1 + base_repo_id: null diff --git a/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/repository.yml b/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/repository.yml new file mode 100644 index 0000000000..a88c2ef89f --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_addForeignKeysPullRequest1/repository.yml @@ -0,0 +1,2 @@ +- + id: 1 From 8514af643d21962ef8674025ad3f4cbfc98ccf68 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Mon, 29 Dec 2025 20:56:20 +0100 Subject: [PATCH 017/155] [v14.0/forgejo] fix: display orphan branches separately in commit graph (#10622) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10484 Fixes #2705. Fixes #7635. This PR fixes the commit graph showing false connections for orphan/root commits. Connection lines are now shown only when a parent/child relationship exists. Visible relationships are determined using `git log`'s `%P` output by the new `ComputeGlyphConnectivity` function. The SVG template is adapted to render vertical lines conditionally. Unit tests for `ComputeGlyphConnectivity` cover regular linear commit history, orphan commits, merge commits, and non-commit glyphs (`|`, `/`, `\`). Unit tests also cover the changes to the `git log` parsing. The SVG template was verified manually. Co-authored-by: Bram Hagens Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10622 Reviewed-by: Gusted Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- services/repository/gitgraph/graph.go | 5 +- services/repository/gitgraph/graph_models.go | 127 +++++++++++++---- services/repository/gitgraph/graph_test.go | 139 ++++++++++++++++++- services/repository/gitgraph/parser.go | 3 + templates/repo/graph/svgcontainer.tmpl | 10 +- 5 files changed, 255 insertions(+), 29 deletions(-) diff --git a/services/repository/gitgraph/graph.go b/services/repository/gitgraph/graph.go index bf15baed2a..1d1cea4225 100644 --- a/services/repository/gitgraph/graph.go +++ b/services/repository/gitgraph/graph.go @@ -16,7 +16,7 @@ import ( // GetCommitGraph return a list of commit (GraphItems) from all branches func GetCommitGraph(r *git.Repository, page, maxAllowedColors int, hidePRRefs bool, branches, files []string) (*Graph, error) { - format := "DATA:%D|%H|%aD|%h|%s" + format := "DATA:%P|%D|%H|%aD|%h|%s" if page == 0 { page = 1 @@ -112,5 +112,8 @@ func GetCommitGraph(r *git.Repository, page, maxAllowedColors int, hidePRRefs bo }); err != nil { return graph, err } + + graph.ComputeGlyphConnectivity() + return graph, nil } diff --git a/services/repository/gitgraph/graph_models.go b/services/repository/gitgraph/graph_models.go index 20107cc646..2c4133e1f2 100644 --- a/services/repository/gitgraph/graph_models.go +++ b/services/repository/gitgraph/graph_models.go @@ -15,6 +15,7 @@ import ( git_model "forgejo.org/models/git" repo_model "forgejo.org/models/repo" user_model "forgejo.org/models/user" + "forgejo.org/modules/container" "forgejo.org/modules/git" "forgejo.org/modules/log" ) @@ -27,18 +28,20 @@ func NewGraph() *Graph { Column: -1, } graph.Flows = map[int64]*Flow{} + graph.continuationAbove = map[[2]int]bool{} return graph } // Graph represents a collection of flows type Graph struct { - Flows map[int64]*Flow - Commits []*Commit - MinRow int - MinColumn int - MaxRow int - MaxColumn int - relationCommit *Commit + Flows map[int64]*Flow + Commits []*Commit + MinRow int + MinColumn int + MaxRow int + MaxColumn int + relationCommit *Commit + continuationAbove map[[2]int]bool } // Width returns the width of the graph @@ -51,6 +54,69 @@ func (graph *Graph) Height() int { return graph.MaxRow - graph.MinRow + 1 } +// ComputeGlyphConnectivity sets ConnectsUp/ConnectsDown for commit glyphs based on parent/child relationships +func (graph *Graph) ComputeGlyphConnectivity() { + revs := make(container.Set[string]) + revByPos := make(map[[2]int]string) + for _, c := range graph.Commits { + if c.Rev != "" { + revs.Add(c.Rev) + revByPos[[2]int{c.Row, c.Column}] = c.Rev + } + } + + connectsDown := make(container.Set[string]) + connectsUp := make(container.Set[string]) + + // Commits with parents connect down (even if parent is on another page) + // Commits with visible children connect up + for _, c := range graph.Commits { + if len(c.ParentHashes) > 0 { + connectsDown.Add(c.Rev) + } + for _, parentHash := range c.ParentHashes { + if revs.Contains(parentHash) { + connectsUp.Add(parentHash) + } + } + } + + // Commits with a non-commit glyph above also connect up + for _, flow := range graph.Flows { + for _, g := range flow.Glyphs { + if g.Glyph != '*' { + pos := [2]int{g.Row + 1, g.Column} + if rev, exists := revByPos[pos]; exists { + connectsUp.Add(rev) + } + } + } + } + + // Commits with continuation from previous page connect up + for pos := range graph.continuationAbove { + if rev, exists := revByPos[pos]; exists { + connectsUp.Add(rev) + } + } + + for _, flow := range graph.Flows { + for i := range flow.Glyphs { + glyph := &flow.Glyphs[i] + if glyph.Glyph == '*' { + pos := [2]int{glyph.Row, glyph.Column} + if rev, exists := revByPos[pos]; exists { + glyph.ConnectsUp = connectsUp.Contains(rev) + glyph.ConnectsDown = connectsDown.Contains(rev) + } + } else { + glyph.ConnectsUp = true + glyph.ConnectsDown = true + } + } + } +} + // AddGlyph adds glyph to flows func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) { flow, ok := graph.Flows[flowID] @@ -175,17 +241,19 @@ func (flow *Flow) AddGlyph(row, column int, glyph byte) { } flow.Glyphs = append(flow.Glyphs, Glyph{ - row, - column, - glyph, + Row: row, + Column: column, + Glyph: glyph, }) } // Glyph represents a coordinate and glyph type Glyph struct { - Row int - Column int - Glyph byte + Row int + Column int + Glyph byte + ConnectsUp bool + ConnectsDown bool } // RelationCommit represents an empty relation commit @@ -195,28 +263,36 @@ var RelationCommit = &Commit{ // NewCommit creates a new commit from a provided line func NewCommit(row, column int, line []byte) (*Commit, error) { - data := bytes.SplitN(line, []byte("|"), 5) - if len(data) < 5 { + data := bytes.SplitN(line, []byte("|"), 6) + if len(data) < 6 { return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line)) } // Format is a slight modification from RFC1123Z - t, err := time.Parse("Mon, _2 Jan 2006 15:04:05 -0700", string(data[2])) + t, err := time.Parse("Mon, _2 Jan 2006 15:04:05 -0700", string(data[3])) if err != nil { return nil, fmt.Errorf("could not parse date of commit: %w", err) } + + var parents []string + for p := range bytes.FieldsSeq(data[0]) { + parents = append(parents, string(p)) + } + return &Commit{ Row: row, Column: column, - // 0 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1) - Refs: newRefsFromRefNames(data[0]), - // 1 matches git log --pretty=format:%H => commit hash - Rev: string(data[1]), - // 2 matches git log --pretty=format:%aD => author date, RFC2822 style + // 0 matches git log --pretty=format:%P => parent hashes + ParentHashes: parents, + // 1 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1) + Refs: newRefsFromRefNames(data[1]), + // 2 matches git log --pretty=format:%H => commit hash + Rev: string(data[2]), + // 3 matches git log --pretty=format:%aD => author date, RFC2822 style Date: t, - // 3 matches git log --pretty=format:%h => abbreviated commit hash - ShortRev: string(data[3]), - // 4 matches git log --pretty=format:%s => subject - Subject: string(data[4]), + // 4 matches git log --pretty=format:%h => abbreviated commit hash + ShortRev: string(data[4]), + // 5 matches git log --pretty=format:%s => subject + Subject: string(data[5]), }, nil } @@ -254,6 +330,7 @@ type Commit struct { Date time.Time ShortRev string Subject string + ParentHashes []string } // OnlyRelation returns whether this a relation only commit diff --git a/services/repository/gitgraph/graph_test.go b/services/repository/gitgraph/graph_test.go index 374341b276..6dafaf03fd 100644 --- a/services/repository/gitgraph/graph_test.go +++ b/services/repository/gitgraph/graph_test.go @@ -10,6 +10,9 @@ import ( "testing" "forgejo.org/modules/git" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func BenchmarkGetCommitGraph(b *testing.B) { @@ -32,7 +35,7 @@ func BenchmarkGetCommitGraph(b *testing.B) { } func BenchmarkParseCommitString(b *testing.B) { - testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph" + testString := "* DATA:abc123||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|Tue, 20 Dec 2016 21:10:41 +0100|4e61bac|Add route for graph" parser := &Parser{} parser.Reset() @@ -241,7 +244,7 @@ func TestParseGlyphs(t *testing.T) { } func TestCommitStringParsing(t *testing.T) { - dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|Tue, 20 Dec 2016 21:10:41 +0100|4e61bac|" + dataFirstPart := "* DATA:abc123||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|Tue, 20 Dec 2016 21:10:41 +0100|4e61bac|" tests := []struct { shouldPass bool testName string @@ -269,6 +272,138 @@ func TestCommitStringParsing(t *testing.T) { } } +func TestNewCommitParentHashes(t *testing.T) { + tests := []struct { + name string + data string + expectedParents []string + }{ + { + name: "no parents (orphan)", + data: "||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|Tue, 20 Dec 2016 21:10:41 +0100|4e61bac|subject", + expectedParents: nil, + }, + { + name: "single parent", + data: "abc123||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|Tue, 20 Dec 2016 21:10:41 +0100|4e61bac|subject", + expectedParents: []string{"abc123"}, + }, + { + name: "multiple parents (merge)", + data: "abc123 def456||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|Tue, 20 Dec 2016 21:10:41 +0100|4e61bac|subject", + expectedParents: []string{"abc123", "def456"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + commit, err := NewCommit(0, 0, []byte(test.data)) + require.NoError(t, err) + assert.Equal(t, test.expectedParents, commit.ParentHashes) + }) + } +} + +func TestComputeGlyphConnectivity(t *testing.T) { + addCommit := func(graph *Graph, row, col int, hash string, parents []string) { + flowID := int64(col + 1) + commit := &Commit{Row: row, Column: col, Rev: hash, ParentHashes: parents, Flow: flowID} + graph.AddGlyph(row, col, flowID, 1, '*') + graph.Commits = append(graph.Commits, commit) + graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit) + } + + getCommitConnectivity := func(graph *Graph, row, col int) (up, down bool) { + for _, flow := range graph.Flows { + for _, g := range flow.Glyphs { + if g.Row == row && g.Column == col && g.Glyph == '*' { + return g.ConnectsUp, g.ConnectsDown + } + } + } + return false, false + } + + t.Run("ConnectsDown/no parents", func(t *testing.T) { + graph := NewGraph() + addCommit(graph, 0, 0, "orphan", nil) + graph.ComputeGlyphConnectivity() + + _, down := getCommitConnectivity(graph, 0, 0) + assert.False(t, down) + }) + + t.Run("ConnectsDown/has parent in graph", func(t *testing.T) { + graph := NewGraph() + addCommit(graph, 0, 0, "child", []string{"parent"}) + addCommit(graph, 1, 0, "parent", nil) + graph.ComputeGlyphConnectivity() + + _, down := getCommitConnectivity(graph, 0, 0) + assert.True(t, down) + }) + + t.Run("ConnectsDown/has parent outside graph", func(t *testing.T) { + graph := NewGraph() + addCommit(graph, 0, 0, "child", []string{"parent-not-in-graph"}) + graph.ComputeGlyphConnectivity() + + _, down := getCommitConnectivity(graph, 0, 0) + assert.True(t, down) + }) + + t.Run("ConnectsUp/no child, no glyph above, no continuation", func(t *testing.T) { + graph := NewGraph() + addCommit(graph, 0, 0, "orphan", nil) + graph.ComputeGlyphConnectivity() + + up, _ := getCommitConnectivity(graph, 0, 0) + assert.False(t, up) + }) + + t.Run("ConnectsUp/has visible child", func(t *testing.T) { + graph := NewGraph() + addCommit(graph, 0, 0, "child", []string{"parent"}) + addCommit(graph, 1, 0, "parent", nil) + graph.ComputeGlyphConnectivity() + + up, _ := getCommitConnectivity(graph, 1, 0) + assert.True(t, up) + }) + + t.Run("ConnectsUp/has non-commit glyph above", func(t *testing.T) { + graph := NewGraph() + graph.AddGlyph(0, 0, 1, 1, '|') + addCommit(graph, 1, 0, "commit", nil) + graph.ComputeGlyphConnectivity() + + up, _ := getCommitConnectivity(graph, 1, 0) + assert.True(t, up) + }) + + t.Run("ConnectsUp/has continuationAbove", func(t *testing.T) { + graph := NewGraph() + addCommit(graph, 0, 0, "commit", nil) + graph.continuationAbove[[2]int{0, 0}] = true + graph.ComputeGlyphConnectivity() + + up, _ := getCommitConnectivity(graph, 0, 0) + assert.True(t, up) + }) + + t.Run("non-commit glyphs always connect both ways", func(t *testing.T) { + for _, glyph := range []byte{'|', '/', '\\'} { + graph := NewGraph() + graph.AddGlyph(0, 0, 1, 1, glyph) + graph.ComputeGlyphConnectivity() + + g := graph.Flows[1].Glyphs[0] + assert.True(t, g.ConnectsUp, "glyph %q should connect up", glyph) + assert.True(t, g.ConnectsDown, "glyph %q should connect down", glyph) + } + }) +} + var testglyphs = `* * * diff --git a/services/repository/gitgraph/parser.go b/services/repository/gitgraph/parser.go index f6bf9b0b90..3c98e66273 100644 --- a/services/repository/gitgraph/parser.go +++ b/services/repository/gitgraph/parser.go @@ -80,6 +80,9 @@ func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error { } continue } + if column < len(parser.oldGlyphs) && parser.oldGlyphs[column] == '|' { + graph.continuationAbove[[2]int{row, column}] = true + } err2 := graph.AddCommit(row, column, flowID, line[idx+5:]) if err != nil && err2 != nil { err = fmt.Errorf("%v %w", err2, err) diff --git a/templates/repo/graph/svgcontainer.tmpl b/templates/repo/graph/svgcontainer.tmpl index 99c3c87399..a04b8d310e 100644 --- a/templates/repo/graph/svgcontainer.tmpl +++ b/templates/repo/graph/svgcontainer.tmpl @@ -3,8 +3,16 @@ {{range $flowid, $flow := .Graph.Flows}} Date: Mon, 29 Dec 2025 22:13:14 +0100 Subject: [PATCH 018/155] [v14.0/forgejo] fix(ui): don't stretch activity top author image (#10628) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10556 This is a followup to !10524. In addition I changed that the tooltip triggers for the whole height, instead for only the bar height, because otherwise it is esp for small bars nearly impossible to get the tooltip to open. Co-authored-by: Beowulf Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10628 Reviewed-by: Gusted Reviewed-by: Beowulf Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- .../components/RepoActivityTopAuthors.test.js | 28 ++++ .../js/components/RepoActivityTopAuthors.vue | 125 +++++++++++------- 2 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 web_src/js/components/RepoActivityTopAuthors.test.js diff --git a/web_src/js/components/RepoActivityTopAuthors.test.js b/web_src/js/components/RepoActivityTopAuthors.test.js new file mode 100644 index 0000000000..410329441a --- /dev/null +++ b/web_src/js/components/RepoActivityTopAuthors.test.js @@ -0,0 +1,28 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +import {flushPromises, mount} from '@vue/test-utils'; +import RepoActivityTopAuthors from './RepoActivityTopAuthors.vue'; +import {expect, test, vi} from 'vitest'; + +test('calc image size and shift', async () => { + vi.spyOn(RepoActivityTopAuthors.methods, 'init').mockResolvedValue({}); + + const repoActivityTopAuthors = mount(RepoActivityTopAuthors, { + props: { + locale: { + commitActivity: '', + }, + }, + }); + await flushPromises(); + + const square = repoActivityTopAuthors.vm.calcImageSizeAndShift({naturalWidth: 50, naturalHeight: 50}); + expect(square).toEqual([20, 20, 0, 0]); + + const portrait = repoActivityTopAuthors.vm.calcImageSizeAndShift({naturalWidth: 5, naturalHeight: 50}); + expect(portrait).toEqual([2, 20, 9, 0]); + + const landscape = repoActivityTopAuthors.vm.calcImageSizeAndShift({naturalWidth: 500, naturalHeight: 5}); + expect(landscape).toEqual([20, 0.2, 0, 9.9]); +}); diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue index 22c0b9bdb3..eeaf0c4f73 100644 --- a/web_src/js/components/RepoActivityTopAuthors.vue +++ b/web_src/js/components/RepoActivityTopAuthors.vue @@ -42,56 +42,7 @@ export default { i18nCommitActivity: this, }), mounted() { - const refStyle = window.getComputedStyle(this.$refs.style); - this.colors.barColor = refStyle.backgroundColor; - - for (const item of this.activityTopAuthors) { - const img = new Image(); - img.src = item.avatar_link; - item.avatar_img = img; - } - - Chart.register({ - id: 'image_label', - afterDraw: (chart) => { - const xAxis = chart.boxes[0]; - const yAxis = chart.boxes[1]; - for (const [index] of xAxis.ticks.entries()) { - const x = xAxis.getPixelForTick(index); - const img = this.activityTopAuthors[index].avatar_img; - - chart.ctx.save(); - chart.ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, x - 10, yAxis.bottom + 10, 20, 20); - chart.ctx.restore(); - } - }, - beforeEvent: (chart, args) => { - const event = args.event; - if (event.type !== 'mousemove' && event.type !== 'click') return; - - const yAxis = chart.boxes[1]; - if (event.y < yAxis.bottom + 10 || event.y > yAxis.bottom + 30) { - chart.canvas.style.cursor = ''; - return; - } - - const xAxis = chart.boxes[0]; - const pointIdx = xAxis.ticks.findIndex((_, index) => { - const x = xAxis.getPixelForTick(index); - return event.x >= x - 10 && event.x <= x + 10; - }); - - if (pointIdx === -1) { - chart.canvas.style.cursor = ''; - return; - } - - chart.canvas.style.cursor = 'pointer'; - if (event.type === 'click' && this.activityTopAuthors[pointIdx].home_link) { - window.location.href = this.activityTopAuthors[pointIdx].home_link; - } - }, - }); + this.init(); }, methods: { graphPoints() { @@ -137,8 +88,82 @@ export default { }, }, }, + plugins: { + tooltip: { + intersect: false, + }, + }, }; }, + init() { + const refStyle = window.getComputedStyle(this.$refs.style); + this.colors.barColor = refStyle.backgroundColor; + + for (const item of this.activityTopAuthors) { + const img = new Image(); + img.src = item.avatar_link; + item.avatar_img = img; + } + + Chart.register({ + id: 'image_label', + afterDraw: (chart) => { + const xAxis = chart.boxes[0]; + const yAxis = chart.boxes[1]; + for (const [index] of xAxis.ticks.entries()) { + const x = xAxis.getPixelForTick(index); + const img = this.activityTopAuthors[index].avatar_img; + + chart.ctx.save(); + const [width, height, dx, dy] = this.calcImageSizeAndShift(img); + chart.ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, x - 10 + dx, yAxis.bottom + 10 + dy, width, height); + chart.ctx.restore(); + } + }, + beforeEvent: (chart, args) => { + const event = args.event; + if (event.type !== 'mousemove' && event.type !== 'click') return; + + const yAxis = chart.boxes[1]; + if (event.y < yAxis.bottom + 10 || event.y > yAxis.bottom + 30) { + chart.canvas.style.cursor = ''; + return; + } + + const xAxis = chart.boxes[0]; + const pointIdx = xAxis.ticks.findIndex((_, index) => { + const x = xAxis.getPixelForTick(index); + return event.x >= x - 10 && event.x <= x + 10; + }); + + if (pointIdx === -1) { + chart.canvas.style.cursor = ''; + return; + } + + chart.canvas.style.cursor = 'pointer'; + if (event.type === 'click' && this.activityTopAuthors[pointIdx].home_link) { + window.location.href = this.activityTopAuthors[pointIdx].home_link; + } + }, + }); + }, + calcImageSizeAndShift(img) { + const targetSize = 20; + const [imgWidth, imgHeight] = [img.naturalWidth, img.naturalHeight]; + + // The image should be contained in a square, + // so the scale depends on the longer dimension. + const scale = targetSize / (Math.max(imgWidth, imgHeight)); + const calcScale = (size) => size * scale; + const [width, height] = [calcScale(imgWidth), calcScale(imgHeight)]; + + // The image should be centered in the 20x20 square. + const calcShift = (size) => (targetSize - size) / 2; + const [dx, dy] = [calcShift(width), calcShift(height)]; + + return [width, height, dx, dy]; + }, }, }; From 400c17e29038523cf4cf1effdc813440a896584f Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Tue, 30 Dec 2025 02:26:44 +0100 Subject: [PATCH 019/155] [v14.0/forgejo] fix(ui): process dynamically added content via htmx (#10630) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10572 When new content is added via JS and htmx is not used for this change, htmx need to be informed that DOM changes happened and that it needs to reprocess the DOM (or at least the changed parts). When a diff is really large, it is hidden by default. The user can press a button to load the diff, which then will be added via JS. The diff contains buttons to expand it, which are using htmx behind the scenes. Therefore a reprocessing via htmx needs to be triggered after adding the large diff. Fixes #10570 Co-authored-by: Beowulf Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10630 Reviewed-by: Beowulf Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- tests/e2e/declare_repos_test.go | 29 ++++++++++++++++++++++ tests/e2e/file-diff.test.e2e.ts | 41 ++++++++++++++++++++++++++++++++ web_src/js/features/repo-diff.js | 2 ++ 3 files changed, 72 insertions(+) create mode 100644 tests/e2e/file-diff.test.e2e.ts diff --git a/tests/e2e/declare_repos_test.go b/tests/e2e/declare_repos_test.go index 23bc023b70..85e3fcbda8 100644 --- a/tests/e2e/declare_repos_test.go +++ b/tests/e2e/declare_repos_test.go @@ -6,6 +6,7 @@ package e2e import ( "fmt" "os" + "strconv" "strings" "testing" "time" @@ -144,6 +145,34 @@ body: addCommitToBranch(t, user, repo, "test-branch", "test-branch", "test-README.md", commit2Sha, readStringFile(t, "tests/e2e/declarative-repo/long-diff-test/3-README.md")) }), + newRepo(t, 2, "huge-diff-test", nil, []FileChanges{{ + Filename: "glossary.po", + Versions: []string{ + func() string { + var sb strings.Builder + sb.Write([]byte("0")) + for i := 1; i < 2000; i++ { + sb.WriteString(strconv.Itoa(i)) + sb.WriteByte('\n') + } + return sb.String() + }(), + }, + }}, func(user *user_model.User, repo *repo_model.Repository) { + addCommitToBranch(t, user, repo, "main", "main-2", "glossary.po", "", + func() string { + var sb strings.Builder + sb.Write([]byte("0")) + for i := 1; i < 2000; i++ { + sb.WriteString(strconv.Itoa(i)) + if i%12 == 0 { + sb.WriteString("Blub") + } + sb.WriteByte('\n') + } + return sb.String() + }()) + }), // add your repo declarations here } diff --git a/tests/e2e/file-diff.test.e2e.ts b/tests/e2e/file-diff.test.e2e.ts new file mode 100644 index 0000000000..f6c398e009 --- /dev/null +++ b/tests/e2e/file-diff.test.e2e.ts @@ -0,0 +1,41 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +// @watch start +// templates/repo/diff/** +// web_src/css/review.css +// web_src/js/features/repo-diff.js +// @watch end + +import {expect} from '@playwright/test'; +import {test} from './utils_e2e.ts'; + +test.use({user: 'user2'}); + +test('Expand Large diff', async ({page}) => { + let response = await page.goto('/user2/huge-diff-test/src/branch/main-2', {waitUntil: 'domcontentloaded'}); + expect(response?.status()).toBe(200); + + const commitUrl = await page.locator('.commit-summary a').getAttribute('href'); + + response = await page.goto(commitUrl, {waitUntil: 'domcontentloaded'}); + expect(response?.status()).toBe(200); + + const loadDiff = page.locator('.diff-load-button'); + const expandDiff = page.locator('.code-expander-button'); + const diffLines = page.locator('.file-body tbody > tr'); + + await expect(loadDiff).toBeVisible(); + + loadDiff.click(); + await page.waitForLoadState('load'); + + await expect(expandDiff).toHaveCount(167); + await expect(diffLines).toHaveCount(1495); + + await expandDiff.first().click(); + await page.waitForLoadState('load'); + + await expect(expandDiff).toHaveCount(166); + await expect(diffLines).toHaveCount(1502); +}); diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js index 9b62b9f94f..02481be853 100644 --- a/web_src/js/features/repo-diff.js +++ b/web_src/js/features/repo-diff.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import htmx from 'htmx.org'; import {initCompReactionSelector} from './comp/ReactionSelector.js'; import {initRepoIssueContentHistory} from './repo-issue-content.js'; import {initDiffFileTree} from './repo-diff-filetree.js'; @@ -151,6 +152,7 @@ function onShowMoreFiles() { initViewedCheckboxListenerFor(); countAndUpdateViewedFiles(); initImageDiff(); + htmx.process(document.body); } export async function loadMoreFiles(url) { From 180ebee6dee6c50d40888060465c0f5aae52901d Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Tue, 30 Dec 2025 04:28:18 +0100 Subject: [PATCH 020/155] [v14.0/forgejo] fix: build-release workflow stops its own end-to-end checks when run concurrently (#10635) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10632 `build-release.yml` attempts to run an end-to-end check with a cascading PR, but it doesn't target the currently building branch. When two releases build simultaneously (eg. `forgejo/v14.0` and `forgejo`), whichever one starts the end-to-end test first is then "cancelled" by the second one as it pushes an update to the same branch. This will be a bit of an experimental change due to the difficulty in setting up a test environment. After merge, I intend to watch a v14 and forgejo build and verify that they are independent, and, that both are actually tested with the correct target build. This introduces a need to backport any changes to `.forgejo/cascading-release-end-to-end` in the future to maintain cascading functionality in all active releases. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] I do not want this change to show in the release notes. - [ ] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Co-authored-by: Mathieu Fenniak Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10635 Reviewed-by: Mathieu Fenniak Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- .forgejo/cascading-release-end-to-end | 3 +++ .forgejo/workflows/build-release.yml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.forgejo/cascading-release-end-to-end b/.forgejo/cascading-release-end-to-end index 9be0737b0f..f78c3aeedb 100755 --- a/.forgejo/cascading-release-end-to-end +++ b/.forgejo/cascading-release-end-to-end @@ -2,6 +2,9 @@ set -ex +# WARNING: Changes to the behaviour of this file should be backported to all active releases, as it is used in +# `build-release.yml` from release branches. + end_to_end=$1 end_to_end_pr=$2 forgejo=$3 diff --git a/.forgejo/workflows/build-release.yml b/.forgejo/workflows/build-release.yml index 042a981881..a30bc01943 100644 --- a/.forgejo/workflows/build-release.yml +++ b/.forgejo/workflows/build-release.yml @@ -206,7 +206,7 @@ jobs: origin-url: ${{ env.GITHUB_SERVER_URL }} origin-repo: ${{ github.repository }} origin-token: ${{ secrets.CASCADE_ORIGIN_TOKEN }} - origin-ref: refs/heads/forgejo + origin-ref: ${{ github.ref }} destination-url: https://code.forgejo.org destination-fork-repo: ${{ vars.CASCADE_DESTINATION_DOER }}/end-to-end destination-repo: forgejo/end-to-end From 766104acaefe3e4b2d7cc6335cc780e43684b4fc Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Wed, 31 Dec 2025 00:32:51 +0100 Subject: [PATCH 021/155] [v14.0/forgejo] feat: add Forgejo server version to runner context (#10643) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10642 Currently, there's no way for actions runners to know what version of Forgejo is running on the server side. This makes it difficult/impossible to know which features are available and can make maintaining compatibility tricky. Let's add the Forgejo server version to the context. See associated PR in the runner repo: https://code.forgejo.org/forgejo/runner/pulls/1249 Co-authored-by: John Moon Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10643 Reviewed-by: Mathieu Fenniak Reviewed-by: Michael Kriese Reviewed-by: Beowulf Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- services/actions/context.go | 1 + tests/integration/actions_job_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/services/actions/context.go b/services/actions/context.go index 4e4bfd75ea..964cba3b64 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -101,6 +101,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio // additional contexts gitContext["gitea_default_actions_url"] = setting.Actions.DefaultActionsURL.URL() + gitContext["forgejo_server_version"] = setting.AppVer if job != nil { gitContext["job"] = job.JobID diff --git a/tests/integration/actions_job_test.go b/tests/integration/actions_job_test.go index baa1420bdd..e91f0e92bd 100644 --- a/tests/integration/actions_job_test.go +++ b/tests/integration/actions_job_test.go @@ -443,6 +443,7 @@ jobs: assert.Equal(t, actionRun.WorkflowID, gtCtx["workflow"].GetStringValue()) assert.Equal(t, "user2/actions-gitea-context/.gitea/workflows/pull.yml@refs/pull/1/head", gtCtx["workflow_ref"].GetStringValue()) assert.Equal(t, setting.Actions.DefaultActionsURL.URL(), gtCtx["gitea_default_actions_url"].GetStringValue()) + assert.Equal(t, setting.AppVer, gtCtx["forgejo_server_version"].GetStringValue()) token := gtCtx["token"].GetStringValue() assert.Equal(t, actionTask.TokenLastEight, token[len(token)-8:]) From ee42a69b3a93d6064cd542740ba071653032d4ee Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Fri, 2 Jan 2026 22:10:33 +0100 Subject: [PATCH 022/155] [v14.0/forgejo] fix: don't duplicate commit status records on workflows with empty name (#10679) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10678 Fixes #10671. Cleanup for the inflated number of records in this table will come in a near future change. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Co-authored-by: Mathieu Fenniak Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10679 Reviewed-by: Mathieu Fenniak Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- .../TestCreateCommitStatus/action_run.yml | 25 ++++++++++ .../TestCreateCommitStatus/action_run_job.yml | 26 +++++++++++ services/actions/commit_status.go | 4 +- services/actions/commit_status_test.go | 46 +++++++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 services/actions/TestCreateCommitStatus/action_run.yml create mode 100644 services/actions/TestCreateCommitStatus/action_run_job.yml diff --git a/services/actions/TestCreateCommitStatus/action_run.yml b/services/actions/TestCreateCommitStatus/action_run.yml new file mode 100644 index 0000000000..cbeef34d70 --- /dev/null +++ b/services/actions/TestCreateCommitStatus/action_run.yml @@ -0,0 +1,25 @@ +- + id: 900 + title: "update actions" + repo_id: 4 + owner_id: 1 + workflow_id: "artifact.yaml" + index: 200 + trigger_user_id: 1 + ref: "refs/heads/master" + commit_sha: "c7cd3cd144e6d23c9d6f3d07e52b2c1a956e0338" + event: "push" + is_fork_pull_request: false + status: 1 + started: 1683636528 + stopped: 1683636626 + created: 1683636108 + updated: 1683636626 + need_approval: false + approved_by: 0 + event_payload: | + { + "head_commit": { + "id": "c7cd3cd144e6d23c9d6f3d07e52b2c1a956e0338" + } + } diff --git a/services/actions/TestCreateCommitStatus/action_run_job.yml b/services/actions/TestCreateCommitStatus/action_run_job.yml new file mode 100644 index 0000000000..1c3b8e4b7d --- /dev/null +++ b/services/actions/TestCreateCommitStatus/action_run_job.yml @@ -0,0 +1,26 @@ +- + id: 400 + run_id: 900 + repo_id: 4 + owner_id: 1 + commit_sha: c7cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 + is_fork_pull_request: false + name: job_2 + attempt: 1 + job_id: job_2 + task_id: 47 + status: 7 # blocked + started: 1683636528 + stopped: 1683636626 + workflow_payload: | + "on": + push: + jobs: + produce-artifacts: + name: produce-artifacts + runs-on: docker + steps: + - run: echo "OK!" + strategy: + matrix: + color: red diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index e5093301ea..64d9acf1da 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "path" + "strings" actions_model "forgejo.org/models/actions" "forgejo.org/models/db" @@ -94,7 +95,8 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er state := toCommitStatus(job.Status) if statuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll); err == nil { for _, v := range statuses { - if v.Context == ctxname { + // TrimSpace(ctxname) to match stored value which is trimmed in `git.NewCommitStatus` + if v.Context == strings.TrimSpace(ctxname) { if v.State == state { // no need to update return nil diff --git a/services/actions/commit_status_test.go b/services/actions/commit_status_test.go index 0872a9d7df..c58b44e9d8 100644 --- a/services/actions/commit_status_test.go +++ b/services/actions/commit_status_test.go @@ -7,9 +7,14 @@ import ( "testing" actions_model "forgejo.org/models/actions" + git_model "forgejo.org/models/git" "forgejo.org/models/unittest" + "forgejo.org/modules/cache" + "forgejo.org/modules/structs" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "xorm.io/builder" ) func TestCreateCommitStatus_IncompleteMatrix(t *testing.T) { @@ -36,3 +41,44 @@ func TestCreateCommitStatus_IncompleteMatrix(t *testing.T) { err = createCommitStatus(t.Context(), job) require.NoError(t, err) } + +func TestCreateCommitStatus_AvoidsDuplicates(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestCreateCommitStatus")() + require.NoError(t, unittest.PrepareTestDatabase()) + cache.Init() + + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 400}) + targetCommitStatusFilter := builder.Eq{"repo_id": 4, "sha": job.CommitSHA} + + // Begin with 0 commit statuses + assert.Equal(t, 0, unittest.GetCount(t, &git_model.CommitStatus{}, targetCommitStatusFilter)) + + err := createCommitStatus(t.Context(), job) + require.NoError(t, err) + + // Should have 1 commit status now with one createCommitStatus call + assert.Equal(t, 1, unittest.GetCount(t, &git_model.CommitStatus{}, targetCommitStatusFilter)) + status := unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatus{}, targetCommitStatusFilter) + assert.EqualValues(t, 4, status.RepoID) + assert.Equal(t, structs.CommitStatusState("pending"), status.State) + assert.Equal(t, "c7cd3cd144e6d23c9d6f3d07e52b2c1a956e0338", status.SHA) + assert.Equal(t, "/user5/repo4/actions/runs/200/jobs/0", status.TargetURL) + assert.Equal(t, "Blocked by required conditions", status.Description) + assert.Equal(t, "39c46bc9f0f68e0dcfbb59118e12778fa095b066", status.ContextHash) + assert.Equal(t, "/ job_2 (push)", status.Context) // This test is intended to cover the runName = "" case, which trims whitespace in this context string -- don't change it in the future + + // No status change, but createCommitStatus invoked again + err = createCommitStatus(t.Context(), job) + require.NoError(t, err) + + // Should have just the same 1 commit status since the context & state was unchanged. + assert.Equal(t, 1, unittest.GetCount(t, &git_model.CommitStatus{}, targetCommitStatusFilter)) + + // Update job status & create new commit status + job.Status = actions_model.StatusSuccess + err = createCommitStatus(t.Context(), job) + require.NoError(t, err) + + // Should have 2 commit statuses now + assert.Equal(t, 2, unittest.GetCount(t, &git_model.CommitStatus{}, targetCommitStatusFilter)) +} From a604f85c601f44f533a889a99b1204e895b0a7e0 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Sun, 4 Jan 2026 05:38:18 +0100 Subject: [PATCH 023/155] [v14.0/forgejo] chore: download git-man over TLS (#10694) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/10692 - No need to use http when https is available. Co-authored-by: Gusted Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10694 Reviewed-by: Gusted Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- .../workflows-composite/install-minimum-git-version/action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows-composite/install-minimum-git-version/action.yaml b/.forgejo/workflows-composite/install-minimum-git-version/action.yaml index d4e6e3f2a7..ae50d38794 100644 --- a/.forgejo/workflows-composite/install-minimum-git-version/action.yaml +++ b/.forgejo/workflows-composite/install-minimum-git-version/action.yaml @@ -13,7 +13,7 @@ runs: apt-get update -qq apt-get -q install -y -qq curl ca-certificates - curl -sS -o /tmp/git-man.deb http://archive.ubuntu.com/ubuntu/pool/main/g/git/git-man_2.34.1-1ubuntu1_all.deb + curl -sS -o /tmp/git-man.deb https://archive.ubuntu.com/ubuntu/pool/main/g/git/git-man_2.34.1-1ubuntu1_all.deb curl -sS -o /tmp/git.deb https://archive.ubuntu.com/ubuntu/pool/main/g/git/git_2.34.1-1ubuntu1_amd64.deb curl -sS -o /tmp/git-lfs.deb https://archive.ubuntu.com/ubuntu/pool/universe/g/git-lfs/git-lfs_3.0.2-1_amd64.deb From 626929eaa3c91ecbddd46a2b95b81f515416fa4f Mon Sep 17 00:00:00 2001 From: Beowulf Date: Mon, 5 Jan 2026 06:26:42 +0100 Subject: [PATCH 024/155] feat(ui): replace Monaco with CodeMirror (#10559) (#10697) - Replace the [Monaco Editor](https://microsoft.github.io/monaco-editor/) with [CodeMirror 6](https://codemirror.net/). This editor is used to facilitate the 'Add file' and 'Edit file' functionality. - Rationale: - Monaco editor is a great and powerful editor, however for Forgejo's purpose it acts more like a small IDE than a code editor and is doing too much. In my limited user research the usage of editing files via the web UI is largely for small changes that does not need the features that Monaco editor provides. - Monaco editor has no mobile support, Codemirror is very usable on mobile. - Monaco editor pulls in large dependencies (for language support) and by replacing it with Codemirror the amount of time that webpack needs to build the frontend is reduced by 50% (~30s -> ~15s). - The binary of Forgejo (build with `bindata` tag) is reduced by 2MiB. - Codemirror is much more lightweight and should be more usable on less powerful hardware, most notably the lazy loading is much faster as codemirror uses less javascript. - Because Codemirror is modular it is much easier to change the behavior of the code editor if we wish to. - Drawbacks: - Codemirror is quite modular and as seen in `package.json` and in `codeeditor.ts` we have to supply a lot more of its features to have feature parity with Monaco editor. - Monaco editor has great integrated language support (features that an lsp would provide), Codemirror only has such language support to an extend. - Monaco editor has its famous command palette (known by many as its also available in VSCode), this is not available in code mirror. - Good to note: - All features that was added on top of the monaco editor (such as dynamically changing language support depending on the filename) still works and the theme is based on the VSCode colors which largely resembles the monaco editor. - The code editor is still lazy-loaded (this is painfully clear by reading how imports are passed around in `codeeditor.ts`). - This change was privately tested by a few people, a few bugs were found (and fixed) but no major drawbacks were noted for their usage of the web editor. - There's a "search" button in the top bar, so that search can be used on mobile. It is otherwise only accessible via Ctrl+f. Co-authored-by: Beowulf Co-authored-by: Gusted Co-committed-by: Gusted Co-committed-by: Beowulf (cherry picked from commit 28e0af23faf6c8e8f353ba2ae818ee0f83fd3e5c) Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10697 Reviewed-by: Gusted --- eslint.config.mjs | 6 +- options/locale_next/locale_en-US.json | 8 + package-lock.json | 537 ++++++++++++++++++++- package.json | 29 +- renovate.json | 5 - templates/repo/editor/edit.tmpl | 23 +- templates/repo/editor/patch.tmpl | 9 +- templates/repo/settings/githook_edit.tmpl | 2 +- templates/shared/codemirror_container.tmpl | 11 + tests/e2e/codemirror.test.e2e.ts | 217 +++++++++ tests/e2e/e2e_test.go | 11 +- tests/e2e/image-diff.test.e2e.ts | 4 +- tests/e2e/markdown-editor.test.e2e.ts | 7 +- tests/e2e/modal.test.e2e.ts | 4 +- tests/e2e/repo-new.test.e2e.ts | 2 +- tsconfig.json | 2 +- vitest.config.ts | 4 - web_src/css/features/codeeditor.css | 129 +++-- web_src/js/bootstrap.js | 15 - web_src/js/features/codeeditor.js | 191 -------- web_src/js/features/codeeditor.ts | 124 +++++ web_src/js/features/codemirror-lang.ts | 161 ++++++ web_src/js/features/codemirror-search.ts | 240 +++++++++ web_src/js/features/codemirror.ts | 218 +++++++++ web_src/js/features/repo-editor.js | 4 +- web_src/js/features/repo-issue.test.js | 1 - web_src/js/features/repo-settings.js | 4 +- web_src/js/globals.d.ts | 3 + web_src/js/svg.js | 4 + web_src/js/types.d.ts | 5 + webpack.config.js | 10 +- 31 files changed, 1665 insertions(+), 325 deletions(-) create mode 100644 templates/shared/codemirror_container.tmpl create mode 100644 tests/e2e/codemirror.test.e2e.ts delete mode 100644 web_src/js/features/codeeditor.js create mode 100644 web_src/js/features/codeeditor.ts create mode 100644 web_src/js/features/codemirror-lang.ts create mode 100644 web_src/js/features/codemirror-search.ts create mode 100644 web_src/js/features/codemirror.ts create mode 100644 web_src/js/globals.d.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 61987aef14..df3425d7a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -528,11 +528,7 @@ export default tseslint.config( 'no-this-before-super': [2], 'no-throw-literal': [2], 'no-undef-init': [2], - - 'no-undef': [2, { - typeof: true, - }], - + 'no-undef': [0], 'no-undefined': [0], 'no-underscore-dangle': [0], 'no-unexpected-multiline': [2], diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 38d7d18e7e..40fc64b5d0 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -239,5 +239,13 @@ "admin.auths.oauth2_quota_group_claim_name": "Claim name providing group names for this source to be used for quota management. (Optional)", "admin.auths.oauth2_quota_group_map": "Map claimed groups to quota groups. (Optional - requires claim name above)", "admin.auths.oauth2_quota_group_map_removal": "Remove users from synchronized quota groups if user does not belong to corresponding group.", + "editor.search": "Search", + "editor.find_previous": "Previous find", + "editor.find_next": "Next find", + "editor.replace": "Replace", + "editor.replace_all": "Replace all", + "editor.toggle_case": "Toggle case sensitivity", + "editor.toggle_regex": "Toggle using regular expressions", + "editor.toggle_whole_word": "Toggle matching whole words", "meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it." } diff --git a/package-lock.json b/package-lock.json index 357528d355..b1ce7cc2b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,33 @@ "@citation-js/core": "0.7.21", "@citation-js/plugin-bibtex": "0.7.21", "@citation-js/plugin-software-formats": "0.6.1", + "@codemirror/autocomplete": "6.19.1", + "@codemirror/commands": "6.10.0", + "@codemirror/lang-cpp": "6.0.3", + "@codemirror/lang-css": "6.3.1", + "@codemirror/lang-go": "6.0.1", + "@codemirror/lang-html": "6.4.11", + "@codemirror/lang-java": "6.0.2", + "@codemirror/lang-javascript": "6.2.4", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-less": "6.0.2", + "@codemirror/lang-liquid": "6.3.0", + "@codemirror/lang-markdown": "6.5.0", + "@codemirror/lang-php": "6.0.2", + "@codemirror/lang-python": "6.2.1", + "@codemirror/lang-rust": "6.0.2", + "@codemirror/lang-sass": "6.0.2", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/lang-yaml": "6.1.2", + "@codemirror/language": "6.11.3", + "@codemirror/search": "6.5.11", + "@codemirror/state": "6.5.2", + "@codemirror/view": "6.38.2", "@github/markdown-toolbar-element": "2.2.3", "@github/quote-selection": "2.1.0", "@github/text-expander-element": "2.8.0", "@google/model-viewer": "4.1.0", + "@lezer/highlight": "1.2.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.14.0", "ansi_up": "6.0.5", @@ -35,8 +58,6 @@ "mermaid": "11.12.2", "mini-css-extract-plugin": "2.9.3", "minimatch": "10.1.1", - "monaco-editor": "0.52.2", - "monaco-editor-webpack-plugin": "7.1.1", "pdfobject": "2.3.0", "postcss": "8.5.6", "postcss-loader": "8.2.0", @@ -477,6 +498,297 @@ "node": ">=14.0.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz", + "integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz", + "integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.0.tgz", + "integrity": "sha512-fY1YsUExcieXRTsCiwX/bQ9+PbCTA/Fumv7C7mTUZHoFkibfESnaXwpr2aKH6zZVwysEunsHHkaIpM/pl3xETQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", + "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", + "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz", + "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", + "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.2.tgz", + "integrity": "sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", @@ -2131,6 +2443,183 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", + "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.4.tgz", + "integrity": "sha512-aYSdZyUueeTgnfXQntiGUqKNW5WujlAsIbbHzkfJDneSZoyjPg8ObmWG3bzDPVYMC/Kf4l43WJLCunPnYFfQ0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", + "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz", + "integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz", + "integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.1.tgz", + "integrity": "sha512-72ah+Sml7lD8Wn7lnz9vwYmZBo9aQT+I2gjK/0epI+gjdwUbWw3MJ/ZBGEqG1UfrIauRqH37/c5mVHXeCTGXtA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz", + "integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", @@ -2146,6 +2635,12 @@ "@lit-labs/ssr-dom-shim": "^1.4.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@mcaptcha/core-glue": { "version": "0.1.0-alpha-5", "resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz", @@ -5937,6 +6432,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -11324,26 +11825,6 @@ "ufo": "^1.6.1" } }, - "node_modules/monaco-editor": { - "version": "0.52.2", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", - "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT", - "peer": true - }, - "node_modules/monaco-editor-webpack-plugin": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.1.tgz", - "integrity": "sha512-WxdbFHS3Wtz4V9hzhe/Xog5hQRSMxmDLkEEYZwqMDHgJlkZo00HVFZR0j5d0nKypjTUkkygH3dDSXERLG4757A==", - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.2" - }, - "peerDependencies": { - "monaco-editor": ">= 0.31.0", - "webpack": "^4.5.0 || 5.x" - } - }, "node_modules/moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -13781,6 +14262,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-search": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", @@ -15292,6 +15779,12 @@ "vue": "^3.2.29" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", diff --git a/package.json b/package.json index cee576d1ba..54d91c99bf 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,33 @@ "@citation-js/core": "0.7.21", "@citation-js/plugin-bibtex": "0.7.21", "@citation-js/plugin-software-formats": "0.6.1", + "@codemirror/autocomplete": "6.19.1", + "@codemirror/commands": "6.10.0", + "@codemirror/lang-cpp": "6.0.3", + "@codemirror/lang-css": "6.3.1", + "@codemirror/lang-go": "6.0.1", + "@codemirror/lang-html": "6.4.11", + "@codemirror/lang-java": "6.0.2", + "@codemirror/lang-javascript": "6.2.4", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-less": "6.0.2", + "@codemirror/lang-liquid": "6.3.0", + "@codemirror/lang-markdown": "6.5.0", + "@codemirror/lang-php": "6.0.2", + "@codemirror/lang-python": "6.2.1", + "@codemirror/lang-rust": "6.0.2", + "@codemirror/lang-sass": "6.0.2", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/lang-yaml": "6.1.2", + "@codemirror/language": "6.11.3", + "@codemirror/search": "6.5.11", + "@codemirror/state": "6.5.2", + "@codemirror/view": "6.38.2", "@github/markdown-toolbar-element": "2.2.3", "@github/quote-selection": "2.1.0", "@github/text-expander-element": "2.8.0", "@google/model-viewer": "4.1.0", + "@lezer/highlight": "1.2.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.14.0", "ansi_up": "6.0.5", @@ -34,8 +57,6 @@ "mermaid": "11.12.2", "mini-css-extract-plugin": "2.9.3", "minimatch": "10.1.1", - "monaco-editor": "0.52.2", - "monaco-editor-webpack-plugin": "7.1.1", "pdfobject": "2.3.0", "postcss": "8.5.6", "postcss-loader": "8.2.0", @@ -101,9 +122,7 @@ "vite-string-plugin": "1.4.9", "vitest": "4.0.14" }, - "browserslist": [ - "defaults" - ], + "browserslist": ["defaults"], "scarfSettings": { "enabled": false } diff --git a/renovate.json b/renovate.json index 908c24e836..6d75cc303c 100644 --- a/renovate.json +++ b/renovate.json @@ -137,11 +137,6 @@ ], "automerge": true }, - { - "description": "Hold back on some package updates for a few days", - "matchPackageNames": ["monaco-editor"], - "minimumReleaseAge": "30 days" - }, { "description": "Disable indirect updates for stable branches", "matchBaseBranches": ["/^v\\d+\\.\\d+\\/forgejo$/"], diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index f5006bc3b5..47ff6dac67 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -25,25 +25,28 @@
    -