From 02b1b8a5497cc32f85a0106c66c8658a5b826c2b Mon Sep 17 00:00:00 2001 From: pomidorry <106489913+Pomidorry@users.noreply.github.com> Date: Fri, 1 May 2026 14:00:03 +0300 Subject: [PATCH 1/5] Add mirror auth updates to repo edit API and settings (#37468) ## Summary This PR adds support for updating pull mirror authentication via the repository edit API and UI. It introduces new mirror authentication fields in _EditRepoOption_, updates the API logic to safely handle partial credential updates, and fixes the web settings flow so that the existing remote username is preserved when only the password is changed. ### What changed - added _auth_username_, _auth_password_, and _auth_token_ to EditRepoOption - updated the repository edit API to apply mirror auth changes via _updateMirror_ - preserved existing username/password when only part of the auth payload is provided - used oauth2 as the default username when _auth_token_ is provided - kept stored mirror URLs sanitized in DB and API responses - updated Swagger schema for the new API fields - added API integration tests for password-only and token-only updates - added a web settings test to ensure username preservation on partial updates ## Why Some use cases require automated synchronization of pull mirrors, for example in CI/CD pipelines or integrations with external systems. At the same time, many organizations enforce security policies that require periodic token rotation (e.g., monthly). Currently, mirror credentials can only be updated via the UI, which makes automation difficult. ## This change enables: - automated token rotation - avoiding manual updates via the UI - easier integration with secret management systems ## Testing - added integration coverage for mirror auth updates via _PATCH /api/v1/repos/{owner}/{repo}_ - added web settings tests for password-only updates preserving the existing username ## Result Ability to automate auth update 1 image Generative AI was used to help with making this PR. ## --- modules/structs/repo.go | 6 +++ routers/api/v1/repo/migrate.go | 2 +- routers/api/v1/repo/repo.go | 60 ++++++++++++++++++++++- routers/web/repo/setting/setting.go | 9 +++- routers/web/repo/setting/settings_test.go | 45 +++++++++++++++++ templates/swagger/v1_json.tmpl | 15 ++++++ templates/swagger/v1_openapi3_json.tmpl | 15 ++++++ tests/integration/api_repo_edit_test.go | 54 ++++++++++++++++++++ 8 files changed, 202 insertions(+), 4 deletions(-) diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 138be33c1c..0c3a0ab44e 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -236,6 +236,12 @@ type EditRepoOption struct { MirrorInterval *string `json:"mirror_interval,omitempty"` // enable prune - remove obsolete remote-tracking references when mirroring EnablePrune *bool `json:"enable_prune,omitempty"` + // authentication username for the remote repository (mirrors) + MirrorUsername *string `json:"mirror_username,omitempty"` + // authentication password for the remote repository (mirrors) + MirrorPassword *string `json:"mirror_password,omitempty"` + // authentication token for the remote repository (mirrors) + MirrorToken *string `json:"mirror_token,omitempty"` } // GenerateRepoOption options when creating a repository using a template diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index dc99cf8c16..7431493a3f 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -257,7 +257,7 @@ func handleRemoteAddrError(ctx *context.APIContext, err error) { addrErr := err.(*git.ErrInvalidCloneAddr) switch { case addrErr.IsURLError: - ctx.APIError(http.StatusUnprocessableEntity, err) + ctx.APIError(http.StatusUnprocessableEntity, "The provided URL is invalid.") case addrErr.IsPermissionDenied: if addrErr.LocalPath { ctx.APIError(http.StatusUnprocessableEntity, "You are not allowed to import local repositories.") diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index f0428f2f6d..8b0dc7c863 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -21,6 +21,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/log" @@ -37,6 +38,8 @@ import ( "code.gitea.io/gitea/services/convert" feed_service "code.gitea.io/gitea/services/feed" "code.gitea.io/gitea/services/issue" + "code.gitea.io/gitea/services/migrations" + mirror_service "code.gitea.io/gitea/services/mirror" repo_service "code.gitea.io/gitea/services/repository" ) @@ -628,7 +631,11 @@ func Edit(ctx *context.APIContext) { } } - if opts.MirrorInterval != nil || opts.EnablePrune != nil { + if opts.MirrorInterval != nil || + opts.EnablePrune != nil || + opts.MirrorUsername != nil || + opts.MirrorPassword != nil || + opts.MirrorToken != nil { if err := updateMirror(ctx, opts); err != nil { return } @@ -1059,6 +1066,57 @@ func updateMirror(ctx *context.APIContext, opts api.EditRepoOption) error { log.Trace("Repository %s Mirror[%d] Set EnablePrune: %t", repo.FullName(), mirror.ID, mirror.EnablePrune) } + authUpdateRequested := opts.MirrorPassword != nil || opts.MirrorToken != nil || opts.MirrorUsername != nil + if authUpdateRequested { + remoteURL, err := gitrepo.GitRemoteGetURL(ctx, repo, mirror.GetRemoteName()) + if err != nil { + ctx.APIErrorInternal(err) + return err + } + + authUsername := "" + if opts.MirrorUsername != nil { + authUsername = *opts.MirrorUsername + } else if remoteURL.User != nil { + authUsername = remoteURL.User.Username() + } + + authPassword := "" + authToken := "" + if opts.MirrorPassword != nil { + authPassword = *opts.MirrorPassword + } + if opts.MirrorToken != nil { + authToken = *opts.MirrorToken + } + + if opts.MirrorPassword == nil && opts.MirrorToken == nil && remoteURL.User != nil && (authUsername == "" || authUsername == remoteURL.User.Username()) { + authPassword, _ = remoteURL.User.Password() + } + + if authToken != "" { + authPassword = authToken + } + + composedAddress, err := git.ParseRemoteAddr(repo.OriginalURL, authUsername, authPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(composedAddress, ctx.Doer) + } + if err != nil { + handleRemoteAddrError(ctx, err) + return err + } + + if err := mirror_service.UpdateAddress(ctx, mirror, composedAddress); err != nil { + ctx.APIErrorInternal(err) + return err + } + + if sanitized, err := util.SanitizeURL(repo.OriginalURL); err == nil { + mirror.RemoteAddress = sanitized + } + } + // finally update the mirror in the DB if err := repo_model.UpdateMirror(ctx, mirror); err != nil { log.Error("Failed to Set Mirror Interval: %s", err) diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 703d002250..816fd91cd8 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -265,8 +265,13 @@ func handleSettingsPostMirror(ctx *context.Context) { handleSettingRemoteAddrError(ctx, err, form) return } - if u.User != nil && form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() { - form.MirrorPassword, _ = u.User.Password() + if u.User != nil { + if form.MirrorUsername == "" { + form.MirrorUsername = u.User.Username() + } + if form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() { + form.MirrorPassword, _ = u.User.Password() + } } address, err := git.ParseRemoteAddr(form.MirrorAddress, form.MirrorUsername, form.MirrorPassword) diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 4c65b696c5..154d01fda4 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -13,15 +13,18 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" + mirror_service "code.gitea.io/gitea/services/mirror" repo_service "code.gitea.io/gitea/services/repository" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAddReadOnlyDeployKey(t *testing.T) { @@ -386,3 +389,45 @@ func TestDeleteTeam(t *testing.T) { assert.False(t, repo_service.HasRepository(t.Context(), team, re.ID)) } + +func TestHandleSettingsPostMirrorPreservesExistingUsername(t *testing.T) { + defer test.MockVariableValue(&setting.Mirror.Enabled, true)() + + unittest.PrepareTestEnv(t) + + // Use the existing fixture mirror repo (org3/repo5) which has a git repo on disk. + mirrorRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5}) + mirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: 5}) + + require.NoError(t, mirror_service.UpdateAddress(t.Context(), mirror, "https://existing-user:existing-password@example.com/user2/repo1.git")) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + ctx, _ := contexttest.MockContext(t, mirrorRepo.Link()+"/settings") + contexttest.LoadUser(t, ctx, user.ID) + contexttest.LoadRepo(t, ctx, mirrorRepo.ID) + + web.SetForm(ctx, &forms.RepoSettingForm{ + Interval: "8h", + MirrorAddress: "https://example.com/user2/repo1.git", + MirrorPassword: "updated-password", + }) + + handleSettingsPostMirror(ctx) + + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) + + updatedMirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID}) + assert.Equal(t, "https://example.com/user2/repo1.git", updatedMirror.RemoteAddress) + + updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: mirrorRepo.ID}) + assert.Equal(t, "https://example.com/user2/repo1.git", updatedRepo.OriginalURL) + + remoteURL, err := gitrepo.GitRemoteGetURL(t.Context(), updatedRepo, updatedMirror.GetRemoteName()) + require.NoError(t, err) + require.NotNil(t, remoteURL.User) + assert.Equal(t, "existing-user", remoteURL.User.Username()) + password, ok := remoteURL.User.Password() + require.True(t, ok) + assert.Equal(t, "updated-password", password) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index eac1ae6724..26d45940f2 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -25510,6 +25510,21 @@ "type": "string", "x-go-name": "MirrorInterval" }, + "mirror_password": { + "description": "authentication password for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorPassword" + }, + "mirror_token": { + "description": "authentication token for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorToken" + }, + "mirror_username": { + "description": "authentication username for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorUsername" + }, "name": { "description": "name of the repository", "type": "string", diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index a45def2ddf..33adff75e0 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -5738,6 +5738,21 @@ "type": "string", "x-go-name": "MirrorInterval" }, + "mirror_password": { + "description": "authentication password for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorPassword" + }, + "mirror_token": { + "description": "authentication token for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorToken" + }, + "mirror_username": { + "description": "authentication username for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorUsername" + }, "name": { "description": "name of the repository", "type": "string", diff --git a/tests/integration/api_repo_edit_test.go b/tests/integration/api_repo_edit_test.go index 46bcccc31e..e37b214fa8 100644 --- a/tests/integration/api_repo_edit_test.go +++ b/tests/integration/api_repo_edit_test.go @@ -15,9 +15,12 @@ import ( unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" api "code.gitea.io/gitea/modules/structs" + mirror_service "code.gitea.io/gitea/services/mirror" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // getRepoEditOptionFromRepo gets the options for an existing repo exactly as is @@ -432,5 +435,56 @@ func TestAPIRepoEdit(t *testing.T) { DefaultDeleteBranchAfterMerge: &bFalse, }).AddTokenAuth(token2) _ = MakeRequest(t, req, http.StatusOK) + + // Test updating mirror password without changing the existing username + ctx := t.Context() + mirrorRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5}) + mirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: 5}) + newPassword := "updated-password" + + require.NoError(t, mirror_service.UpdateAddress(ctx, mirror, "https://existing-user:existing-password@example.com/user2/repo1.git")) + + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", mirrorRepo.OwnerName, mirrorRepo.Name), &api.EditRepoOption{ + MirrorPassword: &newPassword, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + updatedMirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID}) + assert.Equal(t, "https://example.com/user2/repo1.git", updatedMirror.RemoteAddress) + + updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: mirrorRepo.ID}) + assert.Equal(t, "https://example.com/user2/repo1.git", updatedRepo.OriginalURL) + + remoteURL, err := gitrepo.GitRemoteGetURL(ctx, updatedRepo, updatedMirror.GetRemoteName()) + require.NoError(t, err) + require.NotNil(t, remoteURL.User) + assert.Equal(t, "existing-user", remoteURL.User.Username()) + password, ok := remoteURL.User.Password() + require.True(t, ok) + assert.Equal(t, newPassword, password) + + // Test updating mirror token without guessing a username + token := "mirror-token-value" + + require.NoError(t, mirror_service.UpdateAddress(ctx, mirror, "https://example.com/user2/repo1.git")) + + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", mirrorRepo.OwnerName, mirrorRepo.Name), &api.EditRepoOption{ + MirrorToken: &token, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + updatedMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID}) + assert.Equal(t, "https://example.com/user2/repo1.git", updatedMirror.RemoteAddress) + + updatedRepo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: mirrorRepo.ID}) + assert.Equal(t, "https://example.com/user2/repo1.git", updatedRepo.OriginalURL) + + remoteURL, err = gitrepo.GitRemoteGetURL(ctx, updatedRepo, updatedMirror.GetRemoteName()) + require.NoError(t, err) + require.NotNil(t, remoteURL.User) + assert.Empty(t, remoteURL.User.Username()) + password, ok = remoteURL.User.Password() + require.True(t, ok) + assert.Equal(t, token, password) }) } From deb31d3f3030d2a6aad2f966a9efc365f49daf67 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 1 May 2026 23:38:38 +0800 Subject: [PATCH 2/5] Refactor database connection (#37496) Clean up legacy copied&pasted code, introduce the unique "database connection" function. Move migration testing helper function PrepareTestEnv to a separate package. By the way, remove "shadow connection secrets" tricks: showing connection string on UI is useless --------- Co-authored-by: Nicolas --- Makefile | 2 +- cmd/dump.go | 4 +- models/db/conn.go | 173 ++++++++++++++ .../db/conn_test.go | 10 +- ...ith_schema.go => driver_postgresschema.go} | 4 +- models/db/driver_sqlite_mattn.go | 34 +++ models/db/engine_dump.go | 8 +- models/db/engine_init.go | 28 +-- models/db/engine_test.go | 2 +- models/migrations/base/db_test.go | 5 +- models/migrations/base/tests.go | 223 ------------------ models/migrations/migrationtest/tests.go | 120 ++++++++++ models/migrations/v1_14/main_test.go | 4 +- models/migrations/v1_14/v176_test.go | 4 +- models/migrations/v1_14/v177_test.go | 4 +- models/migrations/v1_15/main_test.go | 4 +- models/migrations/v1_15/v181_test.go | 4 +- models/migrations/v1_15/v182_test.go | 4 +- models/migrations/v1_16/main_test.go | 4 +- models/migrations/v1_16/v189_test.go | 4 +- models/migrations/v1_16/v193_test.go | 4 +- models/migrations/v1_16/v195_test.go | 4 +- models/migrations/v1_16/v210_test.go | 4 +- models/migrations/v1_17/main_test.go | 4 +- models/migrations/v1_17/v221_test.go | 4 +- models/migrations/v1_18/main_test.go | 4 +- models/migrations/v1_18/v229_test.go | 4 +- models/migrations/v1_18/v230_test.go | 4 +- models/migrations/v1_19/main_test.go | 4 +- models/migrations/v1_19/v233_test.go | 4 +- models/migrations/v1_20/main_test.go | 4 +- models/migrations/v1_20/v259_test.go | 4 +- models/migrations/v1_21/main_test.go | 4 +- models/migrations/v1_22/main_test.go | 4 +- models/migrations/v1_22/v283_test.go | 4 +- models/migrations/v1_22/v286_test.go | 4 +- models/migrations/v1_22/v287_test.go | 4 +- models/migrations/v1_22/v293_test.go | 4 +- models/migrations/v1_22/v294_test.go | 4 +- models/migrations/v1_23/main_test.go | 4 +- models/migrations/v1_23/v302_test.go | 4 +- models/migrations/v1_23/v304_test.go | 4 +- models/migrations/v1_25/main_test.go | 4 +- models/migrations/v1_25/v321_test.go | 6 +- models/migrations/v1_25/v322_test.go | 6 +- models/migrations/v1_26/main_test.go | 4 +- models/migrations/v1_26/v325_test.go | 4 +- models/migrations/v1_26/v326_test.go | 4 +- models/migrations/v1_26/v327_test.go | 4 +- models/migrations/v1_26/v329_test.go | 4 +- models/migrations/v1_27/main_test.go | 4 +- models/migrations/v1_27/v331_test.go | 6 +- models/unittest/testdb.go | 106 ++++++++- modules/setting/database.go | 157 ++---------- modules/setting/database_sqlite.go | 15 -- options/locale/locale_en-US.json | 2 - routers/init.go | 6 - routers/install/install.go | 4 +- routers/web/admin/admin_test.go | 58 ----- routers/web/admin/config.go | 63 +---- routers/web/healthcheck/check.go | 10 +- templates/admin/config.tmpl | 4 - .../migration-test/gitea-v1.6.4.mssql.sql.gz | Bin 12969 -> 13260 bytes .../migration-test/gitea-v1.7.0.mssql.sql.gz | Bin 13068 -> 13369 bytes .../migration-test/migration_test.go | 174 ++++---------- tests/sqlite.ini.tmpl | 2 +- tests/test_utils.go | 96 +------- 67 files changed, 611 insertions(+), 865 deletions(-) create mode 100644 models/db/conn.go rename modules/setting/database_test.go => models/db/conn_test.go (88%) rename models/db/{sql_postgres_with_schema.go => driver_postgresschema.go} (92%) create mode 100644 models/db/driver_sqlite_mattn.go delete mode 100644 models/migrations/base/tests.go create mode 100644 models/migrations/migrationtest/tests.go delete mode 100644 modules/setting/database_sqlite.go diff --git a/Makefile b/Makefile index ca4df2b174..ad7739c07b 100644 --- a/Makefile +++ b/Makefile @@ -164,7 +164,7 @@ TEST_PGSQL_PASSWORD ?= postgres TEST_PGSQL_SCHEMA ?= gtestschema TEST_MINIO_ENDPOINT ?= minio:9000 TEST_MSSQL_HOST ?= mssql:1433 -TEST_MSSQL_DBNAME ?= gitea +TEST_MSSQL_DBNAME ?= testgitea TEST_MSSQL_USERNAME ?= sa TEST_MSSQL_PASSWORD ?= MwantsaSecurePassword1 diff --git a/cmd/dump.go b/cmd/dump.go index 49f4d9e894..40b73f69aa 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -203,8 +203,8 @@ func runDump(ctx context.Context, cmd *cli.Command) error { } }() - targetDBType := cmd.String("database") - if len(targetDBType) > 0 && targetDBType != setting.Database.Type.String() { + targetDBType := setting.DatabaseType(cmd.String("database")) + if targetDBType != "" && targetDBType != setting.Database.Type { log.Info("Dumping database %s => %s...", setting.Database.Type, targetDBType) } else { log.Info("Dumping database...") diff --git a/models/db/conn.go b/models/db/conn.go new file mode 100644 index 0000000000..de6f3cd5ec --- /dev/null +++ b/models/db/conn.go @@ -0,0 +1,173 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package db + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +type ConnOptions struct { + Type setting.DatabaseType + Host string + Database string + User string + Passwd string + Schema string + SSLMode string + + SQLitePath string + SQLiteBusyTimeout int + SQLiteJournalMode string +} + +type SQLiteConnStrOptions struct { + FilePath string + BusyTimeout int + JournalMode string +} + +func GlobalConnOptions() ConnOptions { + return ConnOptions{ + Type: setting.Database.Type, + Host: setting.Database.Host, + Database: setting.Database.Name, + User: setting.Database.User, + Passwd: setting.Database.Passwd, + Schema: setting.Database.Schema, + SSLMode: setting.Database.SSLMode, + + SQLitePath: setting.Database.Path, + SQLiteBusyTimeout: setting.Database.SQLiteBusyTimeout, + SQLiteJournalMode: setting.Database.SQLiteJournalMode, + } +} + +const sqlDriverPostgresSchema = "postgresschema" + +var makeSQLiteConnStr = func(opts SQLiteConnStrOptions) (string, string, error) { + return "", "", errors.New(`this Gitea binary was not built with SQLite3 support, get an official release or rebuild with: -tags sqlite,sqlite_unlock_notify`) +} + +func ConnStrDefaultDatabase(opts ConnOptions) (string, string, error) { + opts.Database, opts.Schema = "", "" + return ConnStr(opts) +} + +func ConnStr(opts ConnOptions) (string, string, error) { + switch { + case opts.Type.IsMySQL(): + // use unix socket or tcp socket + connType := util.Iif(strings.HasPrefix(opts.Host, "/"), "unix", "tcp") + // allow (Postgres-inspired) default value to work in MySQL + tls := util.Iif(opts.SSLMode == "disable", "false", opts.SSLMode) + // in case the database name is a partial connection string which contains "?" parameters + paramSep := util.Iif(strings.Contains(opts.Database, "?"), "&", "?") + connStr := fmt.Sprintf("%s:%s@%s(%s)/%s%sparseTime=true&tls=%s", opts.User, opts.Passwd, connType, opts.Host, opts.Database, paramSep, tls) + return "mysql", connStr, nil + + case opts.Type.IsPostgreSQL(): + connStr := makePgSQLConnStr(opts.Host, opts.User, opts.Passwd, opts.Database, opts.SSLMode) + driver := util.Iif(opts.Schema == "", "postgres", sqlDriverPostgresSchema) + registerPostgresSchemaDriver() + return driver, connStr, nil + + case opts.Type.IsMSSQL(): + host, port := parseMSSQLHostPort(opts.Host) + connStr := fmt.Sprintf("server=%s; port=%s; user id=%s; password=%s;", host, port, opts.User, opts.Passwd) + if opts.Database != "" { + connStr += "; database=" + opts.Database + } + return "mssql", connStr, nil + + case opts.Type.IsSQLite3(): + if opts.SQLitePath == "" { + return "", "", errors.New("sqlite3 database path cannot be empty") + } + if err := os.MkdirAll(filepath.Dir(opts.SQLitePath), os.ModePerm); err != nil { + return "", "", fmt.Errorf("failed to create directories: %w", err) + } + return makeSQLiteConnStr(SQLiteConnStrOptions{ + FilePath: opts.SQLitePath, + JournalMode: opts.SQLiteJournalMode, + BusyTimeout: opts.SQLiteBusyTimeout, + }) + } + return "", "", fmt.Errorf("unknown database type: %s", opts.Type) +} + +// parsePgSQLHostPort parses given input in various forms defined in +// https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING +// and returns proper host and port number. +func parsePgSQLHostPort(info string) (host, port string) { + if h, p, err := net.SplitHostPort(info); err == nil { + host, port = h, p + } else { + // treat the "info" as "host", if it's an IPv6 address, remove the wrapper + host = info + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + } + + // set fallback values + if host == "" { + host = "127.0.0.1" + } + if port == "" { + port = "5432" + } + return host, port +} + +func makePgSQLConnStr(dbHost, dbUser, dbPasswd, dbName, dbsslMode string) (connStr string) { + dbName, dbParam, _ := strings.Cut(dbName, "?") + host, port := parsePgSQLHostPort(dbHost) + connURL := url.URL{ + Scheme: "postgres", + User: url.UserPassword(dbUser, dbPasswd), + Host: net.JoinHostPort(host, port), + Path: dbName, + OmitHost: false, + RawQuery: dbParam, + } + query := connURL.Query() + if strings.HasPrefix(host, "/") { // looks like a unix socket + query.Add("host", host) + connURL.Host = ":" + port + } + query.Set("sslmode", dbsslMode) + connURL.RawQuery = query.Encode() + return connURL.String() +} + +// parseMSSQLHostPort splits the host into host and port +func parseMSSQLHostPort(info string) (string, string) { + // the default port "0" might be related to MSSQL's dynamic port, maybe it should be double-confirmed in the future + host, port := "127.0.0.1", "0" + if strings.Contains(info, ":") { + host = strings.Split(info, ":")[0] + port = strings.Split(info, ":")[1] + } else if strings.Contains(info, ",") { + host = strings.Split(info, ",")[0] + port = strings.TrimSpace(strings.Split(info, ",")[1]) + } else if len(info) > 0 { + host = info + } + if host == "" { + host = "127.0.0.1" + } + if port == "" { + port = "0" + } + return host, port +} diff --git a/modules/setting/database_test.go b/models/db/conn_test.go similarity index 88% rename from modules/setting/database_test.go rename to models/db/conn_test.go index a742d54f8c..ba33d252f2 100644 --- a/modules/setting/database_test.go +++ b/models/db/conn_test.go @@ -1,7 +1,7 @@ // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package setting +package db import ( "testing" @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_parsePostgreSQLHostPort(t *testing.T) { +func TestParsePgSQLHostPort(t *testing.T) { tests := map[string]struct { HostPort string Host string @@ -49,14 +49,14 @@ func Test_parsePostgreSQLHostPort(t *testing.T) { for k, test := range tests { t.Run(k, func(t *testing.T) { t.Log(test.HostPort) - host, port := parsePostgreSQLHostPort(test.HostPort) + host, port := parsePgSQLHostPort(test.HostPort) assert.Equal(t, test.Host, host) assert.Equal(t, test.Port, port) }) } } -func Test_getPostgreSQLConnectionString(t *testing.T) { +func TestMakePgSQLConnStr(t *testing.T) { tests := []struct { Host string User string @@ -103,7 +103,7 @@ func Test_getPostgreSQLConnectionString(t *testing.T) { } for _, test := range tests { - connStr := getPostgreSQLConnectionString(test.Host, test.User, test.Passwd, test.Name, test.SSLMode) + connStr := makePgSQLConnStr(test.Host, test.User, test.Passwd, test.Name, test.SSLMode) assert.Equal(t, test.Output, connStr) } } diff --git a/models/db/sql_postgres_with_schema.go b/models/db/driver_postgresschema.go similarity index 92% rename from models/db/sql_postgres_with_schema.go rename to models/db/driver_postgresschema.go index 812fe4a6a6..b673500763 100644 --- a/models/db/sql_postgres_with_schema.go +++ b/models/db/driver_postgresschema.go @@ -18,8 +18,8 @@ var registerOnce sync.Once func registerPostgresSchemaDriver() { registerOnce.Do(func() { - sql.Register("postgresschema", &postgresSchemaDriver{}) - dialects.RegisterDriver("postgresschema", dialects.QueryDriver("postgres")) + sql.Register(sqlDriverPostgresSchema, &postgresSchemaDriver{}) + dialects.RegisterDriver(sqlDriverPostgresSchema, dialects.QueryDriver("postgres")) }) } diff --git a/models/db/driver_sqlite_mattn.go b/models/db/driver_sqlite_mattn.go new file mode 100644 index 0000000000..4988a43d3f --- /dev/null +++ b/models/db/driver_sqlite_mattn.go @@ -0,0 +1,34 @@ +//go:build sqlite + +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package db + +import ( + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/setting" + + _ "github.com/mattn/go-sqlite3" +) + +func init() { + setting.SupportedDatabaseTypes = append(setting.SupportedDatabaseTypes, "sqlite3") + makeSQLiteConnStr = makeSQLiteConnStrMattnCGO +} + +func makeSQLiteConnStrMattnCGO(opts SQLiteConnStrOptions) (string, string, error) { + var params []string + params = append(params, "cache=shared") + params = append(params, "mode=rwc") + params = append(params, "_busy_timeout="+strconv.Itoa(opts.BusyTimeout)) + params = append(params, "_txlock=immediate") + if opts.JournalMode != "" { + params = append(params, "_journal_mode="+opts.JournalMode) + } + connStr := fmt.Sprintf("file:%s?%s", opts.FilePath, strings.Join(params, "&")) + return "sqlite3", connStr, nil +} diff --git a/models/db/engine_dump.go b/models/db/engine_dump.go index 63f2d4e093..1d8d555b44 100644 --- a/models/db/engine_dump.go +++ b/models/db/engine_dump.go @@ -3,10 +3,14 @@ package db -import "xorm.io/xorm/schemas" +import ( + "code.gitea.io/gitea/modules/setting" + + "xorm.io/xorm/schemas" +) // DumpDatabase dumps all data from database according the special database SQL syntax to file system. -func DumpDatabase(filePath, dbType string) error { +func DumpDatabase(filePath string, dbType setting.DatabaseType) error { var tbs []*schemas.Table for _, t := range registeredModels { t, err := xormEngine.TableInfo(t) diff --git a/models/db/engine_init.go b/models/db/engine_init.go index ef5db3ff5e..65192d3327 100644 --- a/models/db/engine_init.go +++ b/models/db/engine_init.go @@ -6,7 +6,6 @@ package db import ( "context" "fmt" - "strings" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -24,31 +23,23 @@ func init() { // newXORMEngine returns a new XORM engine from the configuration func newXORMEngine() (*xorm.Engine, error) { - connStr, err := setting.DBConnStr() + connOpts := GlobalConnOptions() + driver, connStr, err := ConnStr(connOpts) if err != nil { return nil, err } - var engine *xorm.Engine - - if setting.Database.Type.IsPostgreSQL() && len(setting.Database.Schema) > 0 { - // OK whilst we sort out our schema issues - create a schema aware postgres - registerPostgresSchemaDriver() - engine, err = xorm.NewEngine("postgresschema", connStr) - } else { - engine, err = xorm.NewEngine(setting.Database.Type.String(), connStr) - } - + engine, err := xorm.NewEngine(driver, connStr) if err != nil { return nil, err } - switch setting.Database.Type { - case "mysql": + switch { + case connOpts.Type.IsMySQL(): engine.Dialect().SetParams(map[string]string{"rowFormat": "DYNAMIC"}) - case "mssql": + case connOpts.Type.IsMSSQL(): engine.Dialect().SetParams(map[string]string{"DEFAULT_VARCHAR": "nvarchar"}) } - engine.SetSchema(setting.Database.Schema) + engine.SetSchema(connOpts.Schema) return engine, nil } @@ -56,10 +47,7 @@ func newXORMEngine() (*xorm.Engine, error) { func InitEngine(ctx context.Context) error { xe, err := newXORMEngine() if err != nil { - if strings.Contains(err.Error(), "SQLite3 support") { - return fmt.Errorf("sqlite3 requires: -tags sqlite,sqlite_unlock_notify\n%w", err) - } - return fmt.Errorf("failed to connect to database: %w", err) + return fmt.Errorf("failed to init database engine: %w", err) } xe.SetMapper(names.GonicMapper{}) diff --git a/models/db/engine_test.go b/models/db/engine_test.go index 1c218df77f..6a6264b535 100644 --- a/models/db/engine_test.go +++ b/models/db/engine_test.go @@ -30,7 +30,7 @@ func TestDumpDatabase(t *testing.T) { assert.NoError(t, db.GetEngine(t.Context()).Sync(new(Version))) for _, dbType := range setting.SupportedDatabaseTypes { - assert.NoError(t, db.DumpDatabase(filepath.Join(dir, dbType+".sql"), dbType)) + assert.NoError(t, db.DumpDatabase(filepath.Join(dir, dbType+".sql"), setting.DatabaseType(dbType))) } } diff --git a/models/migrations/base/db_test.go b/models/migrations/base/db_test.go index dc4e502e42..ce6e1169d8 100644 --- a/models/migrations/base/db_test.go +++ b/models/migrations/base/db_test.go @@ -6,17 +6,18 @@ package base import ( "testing" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/timeutil" "xorm.io/xorm/names" ) func TestMain(m *testing.M) { - MainTest(m) + migrationtest.MainTest(m) } func Test_DropTableColumns(t *testing.T) { - x, deferable := PrepareTestEnv(t, 0) + x, deferable := migrationtest.PrepareTestEnv(t, 0) defer deferable() // FIXME: this logic seems wrong. Need to add an assertion here in the future, but it seems causing failure. if x == nil || t.Failed() { diff --git a/models/migrations/base/tests.go b/models/migrations/base/tests.go deleted file mode 100644 index 0ec979a513..0000000000 --- a/models/migrations/base/tests.go +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package base - -import ( - "database/sql" - "fmt" - "os" - "path" - "path/filepath" - "testing" - - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/testlogger" - "code.gitea.io/gitea/modules/util" - - "github.com/stretchr/testify/require" - "xorm.io/xorm" - "xorm.io/xorm/schemas" -) - -// FIXME: this file shouldn't be in a normal package, it should only be compiled for tests - -func newXORMEngine(t *testing.T) (*xorm.Engine, error) { - if err := db.InitEngine(t.Context()); err != nil { - return nil, err - } - x := unittest.GetXORMEngine() - return x, nil -} - -func deleteDB() error { - switch { - case setting.Database.Type.IsSQLite3(): - if err := util.Remove(setting.Database.Path); err != nil { - return err - } - return os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm) - - case setting.Database.Type.IsMySQL(): - db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/", - setting.Database.User, setting.Database.Passwd, setting.Database.Host)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { - return err - } - - if _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name); err != nil { - return err - } - return nil - case setting.Database.Type.IsPostgreSQL(): - db, err := sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { - return err - } - - if _, err = db.Exec("CREATE DATABASE " + setting.Database.Name); err != nil { - return err - } - db.Close() - - // Check if we need to set up a specific schema - if len(setting.Database.Schema) != 0 { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) - if err != nil { - return err - } - defer db.Close() - - schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) - if err != nil { - return err - } - defer schrows.Close() - - if !schrows.Next() { - // Create and set up a DB schema - _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema) - if err != nil { - return err - } - } - - // Make the user's default search path the created schema; this will affect new connections - _, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema)) - if err != nil { - return err - } - return nil - } - case setting.Database.Type.IsMSSQL(): - host, port := setting.ParseMSSQLHostPort(setting.Database.Host) - db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", - host, port, "master", setting.Database.User, setting.Database.Passwd)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS [%s]", setting.Database.Name)); err != nil { - return err - } - if _, err = db.Exec(fmt.Sprintf("CREATE DATABASE [%s]", setting.Database.Name)); err != nil { - return err - } - default: - return fmt.Errorf("unsupported database type: %s", setting.Database.Type) - } - - return nil -} - -// PrepareTestEnv prepares the test environment and reset the database. The skip parameter should usually be 0. -// Provide models to be sync'd with the database - in particular any models you expect fixtures to be loaded from. -// -// fixtures in `models/migrations/fixtures/` will be loaded automatically -func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, func()) { - t.Helper() - ourSkip := 2 - ourSkip += skip - deferFn := testlogger.PrintCurrentTest(t, ourSkip) - giteaRoot := setting.GetGiteaTestSourceRoot() - require.NoError(t, unittest.SyncDirs(filepath.Join(giteaRoot, "tests/gitea-repositories-meta"), setting.RepoRootPath)) - - if err := deleteDB(); err != nil { - t.Fatalf("unable to reset database: %v", err) - return nil, deferFn - } - - x, err := newXORMEngine(t) - require.NoError(t, err) - if x != nil { - oldDefer := deferFn - deferFn = func() { - oldDefer() - if err := x.Close(); err != nil { - t.Errorf("error during close: %v", err) - } - if err := deleteDB(); err != nil { - t.Errorf("unable to reset database: %v", err) - } - } - } - if err != nil { - return x, deferFn - } - - if len(syncModels) > 0 { - if err := x.Sync(syncModels...); err != nil { - t.Errorf("error during sync: %v", err) - return x, deferFn - } - } - - fixturesDir := filepath.Join(giteaRoot, "models", "migrations", "fixtures", t.Name()) - - if _, err := os.Stat(fixturesDir); err == nil { - t.Logf("initializing fixtures from: %s", fixturesDir) - if err := unittest.InitFixtures( - unittest.FixturesOptions{ - Dir: fixturesDir, - }, x); err != nil { - t.Errorf("error whilst initializing fixtures from %s: %v", fixturesDir, err) - return x, deferFn - } - if err := unittest.LoadFixtures(); err != nil { - t.Errorf("error whilst loading fixtures from %s: %v", fixturesDir, err) - return x, deferFn - } - } else if !os.IsNotExist(err) { - t.Errorf("unexpected error whilst checking for existence of fixtures: %v", err) - } else { - t.Logf("no fixtures found in: %s", fixturesDir) - } - - return x, deferFn -} - -func LoadTableSchemasMap(t *testing.T, x *xorm.Engine) map[string]*schemas.Table { - tables, err := x.DBMetas() - require.NoError(t, err) - tableMap := make(map[string]*schemas.Table) - for _, table := range tables { - tableMap[table.Name] = table - } - return tableMap -} - -func mainTest(m *testing.M) int { - testlogger.Init() - err := setting.PrepareIntegrationTestConfig() - if err != nil { - return testlogger.MainErrorf("Unable to prepare integration test config: %v", err) - } - setting.SetupGiteaTestEnv() - - if err = git.InitFull(); err != nil { - return testlogger.MainErrorf("Unable to InitFull: %v", err) - } - setting.LoadDBSetting() - setting.InitLoggersForTest() - return m.Run() -} - -func MainTest(m *testing.M) { - os.Exit(mainTest(m)) -} diff --git a/models/migrations/migrationtest/tests.go b/models/migrations/migrationtest/tests.go new file mode 100644 index 0000000000..ed8bb16ef1 --- /dev/null +++ b/models/migrations/migrationtest/tests.go @@ -0,0 +1,120 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package migrationtest + +import ( + "os" + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/testlogger" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +// PrepareTestEnv prepares the test environment and reset the database. The skip parameter should usually be 0. +// Provide models to be sync'd with the database - in particular any models you expect fixtures to be loaded from. +// +// fixtures in `models/migrations/fixtures/` will be loaded automatically +func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, func()) { + t.Helper() + ourSkip := 2 + ourSkip += skip + deferFn := testlogger.PrintCurrentTest(t, ourSkip) + giteaRoot := setting.GetGiteaTestSourceRoot() + require.NoError(t, unittest.SyncDirs(filepath.Join(giteaRoot, "tests/gitea-repositories-meta"), setting.RepoRootPath)) + + cleanup, err := unittest.ResetTestDatabase() + if err != nil { + t.Fatalf("unable to reset database: %v", err) + return nil, deferFn + } + { + oldDefer := deferFn + deferFn = func() { + cleanup() + oldDefer() + } + } + + err = db.InitEngine(t.Context()) + if !assert.NoError(t, err) { + return nil, deferFn + } + x := unittest.GetXORMEngine() + { + oldDefer := deferFn + deferFn = func() { + _ = x.Close() + oldDefer() + } + } + + if len(syncModels) > 0 { + if err := x.Sync(syncModels...); err != nil { + t.Errorf("error during sync: %v", err) + return x, deferFn + } + } + + fixturesDir := filepath.Join(giteaRoot, "models", "migrations", "fixtures", t.Name()) + + if _, err := os.Stat(fixturesDir); err == nil { + t.Logf("initializing fixtures from: %s", fixturesDir) + if err := unittest.InitFixtures( + unittest.FixturesOptions{ + Dir: fixturesDir, + }, x); err != nil { + t.Errorf("error whilst initializing fixtures from %s: %v", fixturesDir, err) + return x, deferFn + } + if err := unittest.LoadFixtures(); err != nil { + t.Errorf("error whilst loading fixtures from %s: %v", fixturesDir, err) + return x, deferFn + } + } else if !os.IsNotExist(err) { + t.Errorf("unexpected error whilst checking for existence of fixtures: %v", err) + } else { + t.Logf("no fixtures found in: %s", fixturesDir) + } + + return x, deferFn +} + +func LoadTableSchemasMap(t *testing.T, x *xorm.Engine) map[string]*schemas.Table { + tables, err := x.DBMetas() + require.NoError(t, err) + tableMap := make(map[string]*schemas.Table) + for _, table := range tables { + tableMap[table.Name] = table + } + return tableMap +} + +func mainTest(m *testing.M) int { + testlogger.Init() + err := setting.PrepareIntegrationTestConfig() + if err != nil { + return testlogger.MainErrorf("Unable to prepare integration test config: %v", err) + } + setting.SetupGiteaTestEnv() + + if err = git.InitFull(); err != nil { + return testlogger.MainErrorf("Unable to InitFull: %v", err) + } + setting.LoadDBSetting() + setting.InitLoggersForTest() + return m.Run() +} + +func MainTest(m *testing.M) { + os.Exit(mainTest(m)) +} diff --git a/models/migrations/v1_14/main_test.go b/models/migrations/v1_14/main_test.go index 978f88577c..6ed240c407 100644 --- a/models/migrations/v1_14/main_test.go +++ b/models/migrations/v1_14/main_test.go @@ -6,9 +6,9 @@ package v1_14 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_14/v176_test.go b/models/migrations/v1_14/v176_test.go index 5c1db4db71..aa57b5ad1d 100644 --- a/models/migrations/v1_14/v176_test.go +++ b/models/migrations/v1_14/v176_test.go @@ -6,7 +6,7 @@ package v1_14 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -47,7 +47,7 @@ func Test_RemoveInvalidLabels(t *testing.T) { } // load and prepare the test database - x, deferable := base.PrepareTestEnv(t, 0, new(Comment), new(Issue), new(Repository), new(IssueLabel), new(Label)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(Comment), new(Issue), new(Repository), new(IssueLabel), new(Label)) if x == nil || t.Failed() { defer deferable() return diff --git a/models/migrations/v1_14/v177_test.go b/models/migrations/v1_14/v177_test.go index 263f69f338..a86fb98830 100644 --- a/models/migrations/v1_14/v177_test.go +++ b/models/migrations/v1_14/v177_test.go @@ -6,7 +6,7 @@ package v1_14 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" @@ -34,7 +34,7 @@ func Test_DeleteOrphanedIssueLabels(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(IssueLabel), new(Label)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(IssueLabel), new(Label)) if x == nil || t.Failed() { defer deferable() return diff --git a/models/migrations/v1_15/main_test.go b/models/migrations/v1_15/main_test.go index d01585e997..768bbd310b 100644 --- a/models/migrations/v1_15/main_test.go +++ b/models/migrations/v1_15/main_test.go @@ -6,9 +6,9 @@ package v1_15 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_15/v181_test.go b/models/migrations/v1_15/v181_test.go index 73b5c1f3d6..e230c684ea 100644 --- a/models/migrations/v1_15/v181_test.go +++ b/models/migrations/v1_15/v181_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -20,7 +20,7 @@ func Test_AddPrimaryEmail2EmailAddress(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(User)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(User)) if x == nil || t.Failed() { defer deferable() return diff --git a/models/migrations/v1_15/v182_test.go b/models/migrations/v1_15/v182_test.go index 5fc6a0c467..c0a1378534 100644 --- a/models/migrations/v1_15/v182_test.go +++ b/models/migrations/v1_15/v182_test.go @@ -6,7 +6,7 @@ package v1_15 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -20,7 +20,7 @@ func Test_AddIssueResourceIndexTable(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(Issue)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(Issue)) if x == nil || t.Failed() { defer deferable() return diff --git a/models/migrations/v1_16/main_test.go b/models/migrations/v1_16/main_test.go index 7f93d6e9e5..c54424788d 100644 --- a/models/migrations/v1_16/main_test.go +++ b/models/migrations/v1_16/main_test.go @@ -6,9 +6,9 @@ package v1_16 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_16/v189_test.go b/models/migrations/v1_16/v189_test.go index fb56ac8e11..44424dd369 100644 --- a/models/migrations/v1_16/v189_test.go +++ b/models/migrations/v1_16/v189_test.go @@ -6,7 +6,7 @@ package v1_16 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/json" "github.com/stretchr/testify/assert" @@ -27,7 +27,7 @@ func (ls *LoginSourceOriginalV189) TableName() string { func Test_UnwrapLDAPSourceCfg(t *testing.T) { // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(LoginSourceOriginalV189)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(LoginSourceOriginalV189)) if x == nil || t.Failed() { defer deferable() return diff --git a/models/migrations/v1_16/v193_test.go b/models/migrations/v1_16/v193_test.go index 2e827f0550..f68dd6d92d 100644 --- a/models/migrations/v1_16/v193_test.go +++ b/models/migrations/v1_16/v193_test.go @@ -6,7 +6,7 @@ package v1_16 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -31,7 +31,7 @@ func Test_AddRepoIDForAttachment(t *testing.T) { } // Prepare and load the testing database - x, deferrable := base.PrepareTestEnv(t, 0, new(Attachment), new(Issue), new(Release)) + x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(Attachment), new(Issue), new(Release)) defer deferrable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_16/v195_test.go b/models/migrations/v1_16/v195_test.go index 946e06e399..bbfa5e162a 100644 --- a/models/migrations/v1_16/v195_test.go +++ b/models/migrations/v1_16/v195_test.go @@ -6,7 +6,7 @@ package v1_16 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -21,7 +21,7 @@ func Test_AddTableCommitStatusIndex(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(CommitStatus)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(CommitStatus)) if x == nil || t.Failed() { defer deferable() return diff --git a/models/migrations/v1_16/v210_test.go b/models/migrations/v1_16/v210_test.go index 3b4ac7aa4b..7bff2572e1 100644 --- a/models/migrations/v1_16/v210_test.go +++ b/models/migrations/v1_16/v210_test.go @@ -6,7 +6,7 @@ package v1_16 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" @@ -44,7 +44,7 @@ func Test_RemigrateU2FCredentials(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(WebauthnCredential), new(U2fRegistration), new(ExpectedWebauthnCredential)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(WebauthnCredential), new(U2fRegistration), new(ExpectedWebauthnCredential)) if x == nil || t.Failed() { defer deferable() return diff --git a/models/migrations/v1_17/main_test.go b/models/migrations/v1_17/main_test.go index 571a4f55a3..8652201871 100644 --- a/models/migrations/v1_17/main_test.go +++ b/models/migrations/v1_17/main_test.go @@ -6,9 +6,9 @@ package v1_17 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_17/v221_test.go b/models/migrations/v1_17/v221_test.go index a2dc0fae55..6fda9b9980 100644 --- a/models/migrations/v1_17/v221_test.go +++ b/models/migrations/v1_17/v221_test.go @@ -7,7 +7,7 @@ import ( "encoding/base32" "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -38,7 +38,7 @@ func Test_StoreWebauthnCredentialIDAsBytes(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(WebauthnCredential), new(ExpectedWebauthnCredential)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(WebauthnCredential), new(ExpectedWebauthnCredential)) defer deferable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_18/main_test.go b/models/migrations/v1_18/main_test.go index ebcfb45a94..b8641526f3 100644 --- a/models/migrations/v1_18/main_test.go +++ b/models/migrations/v1_18/main_test.go @@ -6,9 +6,9 @@ package v1_18 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_18/v229_test.go b/models/migrations/v1_18/v229_test.go index 5722dd3557..638983ad0b 100644 --- a/models/migrations/v1_18/v229_test.go +++ b/models/migrations/v1_18/v229_test.go @@ -7,7 +7,7 @@ import ( "testing" "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -16,7 +16,7 @@ func Test_UpdateOpenMilestoneCounts(t *testing.T) { type ExpectedMilestone issues.Milestone // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(issues.Milestone), new(ExpectedMilestone), new(issues.Issue)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(issues.Milestone), new(ExpectedMilestone), new(issues.Issue)) defer deferable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_18/v230_test.go b/models/migrations/v1_18/v230_test.go index 25b2f6525d..e5e28ea63f 100644 --- a/models/migrations/v1_18/v230_test.go +++ b/models/migrations/v1_18/v230_test.go @@ -6,7 +6,7 @@ package v1_18 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -18,7 +18,7 @@ func Test_AddConfidentialClientColumnToOAuth2ApplicationTable(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(oauth2Application)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(oauth2Application)) defer deferable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_19/main_test.go b/models/migrations/v1_19/main_test.go index 87e807be6e..784ca0e46e 100644 --- a/models/migrations/v1_19/main_test.go +++ b/models/migrations/v1_19/main_test.go @@ -6,9 +6,9 @@ package v1_19 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_19/v233_test.go b/models/migrations/v1_19/v233_test.go index 7436ff7483..3f7900c58f 100644 --- a/models/migrations/v1_19/v233_test.go +++ b/models/migrations/v1_19/v233_test.go @@ -6,7 +6,7 @@ package v1_19 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" @@ -39,7 +39,7 @@ func Test_AddHeaderAuthorizationEncryptedColWebhook(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(Webhook), new(ExpectedWebhook), new(HookTask)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(Webhook), new(ExpectedWebhook), new(HookTask)) defer deferable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_20/main_test.go b/models/migrations/v1_20/main_test.go index 2fd63a7118..3ceb9a3c66 100644 --- a/models/migrations/v1_20/main_test.go +++ b/models/migrations/v1_20/main_test.go @@ -6,9 +6,9 @@ package v1_20 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_20/v259_test.go b/models/migrations/v1_20/v259_test.go index 0bf63719e5..3864eecb78 100644 --- a/models/migrations/v1_20/v259_test.go +++ b/models/migrations/v1_20/v259_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -66,7 +66,7 @@ func Test_ConvertScopedAccessTokens(t *testing.T) { }) } - x, deferable := base.PrepareTestEnv(t, 0, new(AccessToken)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(AccessToken)) defer deferable() if x == nil || t.Failed() { t.Skip() diff --git a/models/migrations/v1_21/main_test.go b/models/migrations/v1_21/main_test.go index 536a7ade08..daf98d40f4 100644 --- a/models/migrations/v1_21/main_test.go +++ b/models/migrations/v1_21/main_test.go @@ -6,9 +6,9 @@ package v1_21 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_22/main_test.go b/models/migrations/v1_22/main_test.go index ac8facd6aa..e02c8a5328 100644 --- a/models/migrations/v1_22/main_test.go +++ b/models/migrations/v1_22/main_test.go @@ -6,9 +6,9 @@ package v1_22 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_22/v283_test.go b/models/migrations/v1_22/v283_test.go index 743f860466..8e4c9410bd 100644 --- a/models/migrations/v1_22/v283_test.go +++ b/models/migrations/v1_22/v283_test.go @@ -6,7 +6,7 @@ package v1_22 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -21,7 +21,7 @@ func Test_AddCombinedIndexToIssueUser(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(IssueUser)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(IssueUser)) defer deferable() assert.NoError(t, AddCombinedIndexToIssueUser(x)) diff --git a/models/migrations/v1_22/v286_test.go b/models/migrations/v1_22/v286_test.go index b4a50f6fcb..1bd7fac2f1 100644 --- a/models/migrations/v1_22/v286_test.go +++ b/models/migrations/v1_22/v286_test.go @@ -6,7 +6,7 @@ package v1_22 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" "xorm.io/xorm" @@ -64,7 +64,7 @@ func PrepareOldRepository(t *testing.T) (*xorm.Engine, func()) { } // Prepare and load the testing database - return base.PrepareTestEnv(t, 0, + return migrationtest.PrepareTestEnv(t, 0, new(Repository), new(CommitStatus), new(RepoArchiver), diff --git a/models/migrations/v1_22/v287_test.go b/models/migrations/v1_22/v287_test.go index 2b42a33c38..21946a662a 100644 --- a/models/migrations/v1_22/v287_test.go +++ b/models/migrations/v1_22/v287_test.go @@ -7,7 +7,7 @@ import ( "strconv" "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -20,7 +20,7 @@ func Test_UpdateBadgeColName(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(Badge)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(Badge)) defer deferable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_22/v293_test.go b/models/migrations/v1_22/v293_test.go index c7b643c7e0..bc3a33055c 100644 --- a/models/migrations/v1_22/v293_test.go +++ b/models/migrations/v1_22/v293_test.go @@ -6,7 +6,7 @@ package v1_22 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/models/project" "github.com/stretchr/testify/assert" @@ -14,7 +14,7 @@ import ( func Test_CheckProjectColumnsConsistency(t *testing.T) { // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(project.Project), new(project.Column)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(project.Project), new(project.Column)) defer deferable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_22/v294_test.go b/models/migrations/v1_22/v294_test.go index 1cf03d6120..a711b5ec5f 100644 --- a/models/migrations/v1_22/v294_test.go +++ b/models/migrations/v1_22/v294_test.go @@ -6,7 +6,7 @@ package v1_22 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" "xorm.io/xorm/schemas" @@ -20,7 +20,7 @@ func Test_AddUniqueIndexForProjectIssue(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(ProjectIssue)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ProjectIssue)) defer deferable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_23/main_test.go b/models/migrations/v1_23/main_test.go index f7b2caed83..ffccac0fd3 100644 --- a/models/migrations/v1_23/main_test.go +++ b/models/migrations/v1_23/main_test.go @@ -6,9 +6,9 @@ package v1_23 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_23/v302_test.go b/models/migrations/v1_23/v302_test.go index b008b6fc03..1832adf39a 100644 --- a/models/migrations/v1_23/v302_test.go +++ b/models/migrations/v1_23/v302_test.go @@ -6,7 +6,7 @@ package v1_23 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" @@ -44,7 +44,7 @@ func Test_AddIndexToActionTaskStoppedLogExpired(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(ActionTask)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ActionTask)) defer deferable() assert.NoError(t, AddIndexToActionTaskStoppedLogExpired(x)) diff --git a/models/migrations/v1_23/v304_test.go b/models/migrations/v1_23/v304_test.go index c3dfa5e7e7..9af84cd257 100644 --- a/models/migrations/v1_23/v304_test.go +++ b/models/migrations/v1_23/v304_test.go @@ -6,7 +6,7 @@ package v1_23 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" @@ -33,7 +33,7 @@ func Test_AddIndexForReleaseSha1(t *testing.T) { } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(Release)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(Release)) defer deferable() assert.NoError(t, AddIndexForReleaseSha1(x)) diff --git a/models/migrations/v1_25/main_test.go b/models/migrations/v1_25/main_test.go index d2c4a4105d..33c981edb9 100644 --- a/models/migrations/v1_25/main_test.go +++ b/models/migrations/v1_25/main_test.go @@ -6,9 +6,9 @@ package v1_25 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_25/v321_test.go b/models/migrations/v1_25/v321_test.go index 3ef2c68aa3..0749a20e20 100644 --- a/models/migrations/v1_25/v321_test.go +++ b/models/migrations/v1_25/v321_test.go @@ -6,7 +6,7 @@ package v1_25 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -44,12 +44,12 @@ func Test_UseLongTextInSomeColumnsAndFixBugs(t *testing.T) { } // Prepare and load the testing database - x, deferrable := base.PrepareTestEnv(t, 0, new(ReviewState), new(PackageProperty), new(Notice)) + x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(ReviewState), new(PackageProperty), new(Notice)) defer deferrable() require.NoError(t, UseLongTextInSomeColumnsAndFixBugs(x)) - tables := base.LoadTableSchemasMap(t, x) + tables := migrationtest.LoadTableSchemasMap(t, x) table := tables["review_state"] column := table.GetColumn("updated_files") assert.Equal(t, "LONGTEXT", column.SQLType.Name) diff --git a/models/migrations/v1_25/v322_test.go b/models/migrations/v1_25/v322_test.go index 78d890704c..1964614035 100644 --- a/models/migrations/v1_25/v322_test.go +++ b/models/migrations/v1_25/v322_test.go @@ -6,7 +6,7 @@ package v1_25 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" @@ -23,11 +23,11 @@ func Test_ExtendCommentTreePathLength(t *testing.T) { TreePath string `xorm:"VARCHAR(255)"` } - x, deferrable := base.PrepareTestEnv(t, 0, new(Comment)) + x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(Comment)) defer deferrable() require.NoError(t, ExtendCommentTreePathLength(x)) - table := base.LoadTableSchemasMap(t, x)["comment"] + table := migrationtest.LoadTableSchemasMap(t, x)["comment"] column := table.GetColumn("tree_path") assert.Contains(t, []string{"NVARCHAR", "VARCHAR"}, column.SQLType.Name) assert.EqualValues(t, 4000, column.Length) diff --git a/models/migrations/v1_26/main_test.go b/models/migrations/v1_26/main_test.go index 5aa12d553c..0b271b9bbc 100644 --- a/models/migrations/v1_26/main_test.go +++ b/models/migrations/v1_26/main_test.go @@ -6,9 +6,9 @@ package v1_26 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_26/v325_test.go b/models/migrations/v1_26/v325_test.go index d4a66fee81..3fd658e01b 100644 --- a/models/migrations/v1_26/v325_test.go +++ b/models/migrations/v1_26/v325_test.go @@ -6,7 +6,7 @@ package v1_26 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/require" @@ -38,7 +38,7 @@ func Test_FixMissedRepoIDWhenMigrateAttachments(t *testing.T) { } // Prepare and load the testing database - x, deferrable := base.PrepareTestEnv(t, 0, new(Attachment), new(Issue), new(Release)) + x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(Attachment), new(Issue), new(Release)) defer deferrable() require.NoError(t, FixMissedRepoIDWhenMigrateAttachments(x)) diff --git a/models/migrations/v1_26/v326_test.go b/models/migrations/v1_26/v326_test.go index b92eed35f6..a0225eb774 100644 --- a/models/migrations/v1_26/v326_test.go +++ b/models/migrations/v1_26/v326_test.go @@ -6,7 +6,7 @@ package v1_26 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" @@ -57,7 +57,7 @@ func Test_FixCommitStatusTargetURLToUseRunAndJobID(t *testing.T) { TargetURL string } - x, deferable := base.PrepareTestEnv(t, 0, + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(Repository), new(ActionRun), new(ActionRunJob), diff --git a/models/migrations/v1_26/v327_test.go b/models/migrations/v1_26/v327_test.go index 971707be4f..98e948cf05 100644 --- a/models/migrations/v1_26/v327_test.go +++ b/models/migrations/v1_26/v327_test.go @@ -6,7 +6,7 @@ package v1_26 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/require" ) @@ -17,7 +17,7 @@ func Test_AddDisabledToActionRunner(t *testing.T) { Name string } - x, deferable := base.PrepareTestEnv(t, 0, new(ActionRunner)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ActionRunner)) defer deferable() _, err := x.Insert(&ActionRunner{Name: "runner"}) diff --git a/models/migrations/v1_26/v329_test.go b/models/migrations/v1_26/v329_test.go index cab8e79906..e4bebfb71d 100644 --- a/models/migrations/v1_26/v329_test.go +++ b/models/migrations/v1_26/v329_test.go @@ -6,7 +6,7 @@ package v1_26 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" ) @@ -22,7 +22,7 @@ func (UserBadgeBefore) TableName() string { } func Test_AddUniqueIndexForUserBadge(t *testing.T) { - x, deferable := base.PrepareTestEnv(t, 0, new(UserBadgeBefore)) + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(UserBadgeBefore)) defer deferable() if x == nil || t.Failed() { return diff --git a/models/migrations/v1_27/main_test.go b/models/migrations/v1_27/main_test.go index e269e3df9a..0c6a6a2440 100644 --- a/models/migrations/v1_27/main_test.go +++ b/models/migrations/v1_27/main_test.go @@ -6,9 +6,9 @@ package v1_27 import ( "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" ) func TestMain(m *testing.M) { - base.MainTest(m) + migrationtest.MainTest(m) } diff --git a/models/migrations/v1_27/v331_test.go b/models/migrations/v1_27/v331_test.go index 45f467cf9b..2302fee024 100644 --- a/models/migrations/v1_27/v331_test.go +++ b/models/migrations/v1_27/v331_test.go @@ -8,7 +8,7 @@ import ( "slices" "testing" - "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/migrations/migrationtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -49,7 +49,7 @@ func (actionArtifactBeforeV331) TableName() string { } func Test_AddActionRunAttemptModel(t *testing.T) { - x, deferable := base.PrepareTestEnv(t, 0, + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(actionRunBeforeV331), new(actionRunJobBeforeV331), new(actionArtifactBeforeV331), @@ -69,7 +69,7 @@ func Test_AddActionRunAttemptModel(t *testing.T) { require.NoError(t, AddActionRunAttemptModel(x)) - tableMap := base.LoadTableSchemasMap(t, x) + tableMap := migrationtest.LoadTableSchemasMap(t, x) attemptTable := tableMap["action_run_attempt"] require.NotNil(t, attemptTable) diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go index bd832348e7..116fdab496 100644 --- a/models/unittest/testdb.go +++ b/models/unittest/testdb.go @@ -5,6 +5,8 @@ package unittest import ( "context" + "database/sql" + "errors" "fmt" "os" "path/filepath" @@ -102,6 +104,101 @@ func mainTest(m *testing.M, testOptsArg ...*TestOptions) int { return exitStatus } +func ResetTestDatabase() (cleanup func(), err error) { + defer func() { + if cleanup == nil { + cleanup = func() {} + } + }() + + connOpts := db.GlobalConnOptions() + driverDefault, connStrDefault, err := db.ConnStrDefaultDatabase(connOpts) + if err != nil { + return nil, err + } + driverDatabase, connStrDatabase, err := db.ConnStr(connOpts) + if err != nil { + return nil, err + } + + if connOpts.Type.IsSQLite3() { + if !strings.HasSuffix(connOpts.SQLitePath, "-test.db") { + return nil, errors.New(`testing database file for sqlite3 must end in "-test.db"`) + } + _ = os.Remove(connOpts.SQLitePath) + err = os.MkdirAll(filepath.Dir(connOpts.SQLitePath), os.ModePerm) + if err != nil { + return nil, err + } + cleanup = func() { + _ = os.Remove(connOpts.SQLitePath) + _ = os.Remove(filepath.Dir(connOpts.SQLitePath)) + } + return cleanup, nil + } + + if !strings.Contains(connOpts.Database, "test") { + return nil, fmt.Errorf(`testing database name for %s must contain "test"`, connOpts.Database) + } + + quotedDbName := connOpts.Database + if connOpts.Type.IsMSSQL() { + quotedDbName = `[` + connOpts.Database + `]` + } + + sqlExec := func(sqlDB *sql.DB, sql string) error { + _, err := sqlDB.Exec(sql) + if err != nil { + return fmt.Errorf("failed to execute SQL %q: %w", sql, err) + } + return nil + } + + createDatabase := func() error { + sqlDB, err := sql.Open(driverDefault, connStrDefault) + if err != nil { + return err + } + defer sqlDB.Close() + if err = sqlExec(sqlDB, "DROP DATABASE IF EXISTS "+quotedDbName); err != nil { + return err + } + return sqlExec(sqlDB, "CREATE DATABASE "+quotedDbName) + } + if err = createDatabase(); err != nil { + return nil, err + } + + cleanup = func() { + sqlDB, err := sql.Open(driverDefault, connStrDefault) + if err != nil { + return + } + defer sqlDB.Close() + _, _ = sqlDB.Exec("DROP DATABASE IF EXISTS " + quotedDbName) + } + + createDatabaseSchema := func() error { + if !connOpts.Type.IsPostgreSQL() { + return nil + } + if connOpts.Schema == "" { + return nil + } + sqlDB, err := sql.Open(driverDatabase, connStrDatabase) + if err != nil { + return err + } + defer sqlDB.Close() + if err = sqlExec(sqlDB, "DROP SCHEMA IF EXISTS "+connOpts.Schema); err != nil { + return err + } + return sqlExec(sqlDB, "CREATE SCHEMA "+connOpts.Schema) + } + + return cleanup, createDatabaseSchema() +} + // FixturesOptions fixtures needs to be loaded options type FixturesOptions struct { Dir string @@ -110,11 +207,12 @@ type FixturesOptions struct { // CreateTestEngine creates a memory database and loads the fixture data from fixturesDir func CreateTestEngine(opts FixturesOptions) error { - x, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=immediate") + driver, connStr, err := db.ConnStr(db.ConnOptions{Type: "sqlite3", SQLitePath: ":memory:"}) + if err != nil { + return err + } + x, err := xorm.NewEngine(driver, connStr) if err != nil { - if strings.Contains(err.Error(), "unknown driver") { - return fmt.Errorf("sqlite3 requires: -tags sqlite,sqlite_unlock_notify\n%w", err) - } return err } x.SetMapper(names.GonicMapper{}) diff --git a/modules/setting/database.go b/modules/setting/database.go index 1a4bf64805..2b069a6292 100644 --- a/modules/setting/database.go +++ b/modules/setting/database.go @@ -4,13 +4,7 @@ package setting import ( - "errors" - "fmt" - "net" - "net/url" - "os" "path/filepath" - "strings" "time" ) @@ -20,24 +14,22 @@ var ( // DatabaseTypeNames contains the friendly names for all database types DatabaseTypeNames = map[string]string{"mysql": "MySQL", "postgres": "PostgreSQL", "mssql": "MSSQL", "sqlite3": "SQLite3"} - // EnableSQLite3 use SQLite3, set by build flag - EnableSQLite3 bool - // Database holds the database settings Database = struct { - Type DatabaseType - Host string - Name string - User string - Passwd string - Schema string - SSLMode string - Path string + Type DatabaseType + Host string + Name string + User string + Passwd string + Schema string + SSLMode string + Path string + + SQLiteBusyTimeout int + SQLiteJournalMode string + LogSQL bool - MysqlCharset string CharsetCollation string - Timeout int // seconds - SQLiteJournalMode string DBConnectRetries int DBConnectBackoff time.Duration MaxIdleConns int @@ -47,7 +39,7 @@ var ( AutoMigration bool SlowQueryThreshold time.Duration }{ - Timeout: 500, + SQLiteBusyTimeout: 500, IterateBufferSize: 50, } ) @@ -64,15 +56,14 @@ func loadDBSetting(rootCfg ConfigProvider) { Database.Host = sec.Key("HOST").String() Database.Name = sec.Key("NAME").String() Database.User = sec.Key("USER").String() - if len(Database.Passwd) == 0 { - Database.Passwd = sec.Key("PASSWD").String() - } + Database.Passwd = sec.Key("PASSWD").String() + Database.Schema = sec.Key("SCHEMA").String() Database.SSLMode = sec.Key("SSL_MODE").MustString("disable") Database.CharsetCollation = sec.Key("CHARSET_COLLATION").String() Database.Path = sec.Key("PATH").MustString(filepath.Join(AppDataPath, "gitea.db")) - Database.Timeout = sec.Key("SQLITE_TIMEOUT").MustInt(500) + Database.SQLiteBusyTimeout = sec.Key("SQLITE_TIMEOUT").MustInt(500) Database.SQLiteJournalMode = sec.Key("SQLITE_JOURNAL_MODE").MustString("") Database.MaxIdleConns = sec.Key("MAX_IDLE_CONNS").MustInt(2) @@ -91,123 +82,9 @@ func loadDBSetting(rootCfg ConfigProvider) { Database.SlowQueryThreshold = sec.Key("SLOW_QUERY_THRESHOLD").MustDuration(5 * time.Second) } -// DBConnStr returns database connection string -func DBConnStr() (string, error) { - var connStr string - paramSep := "?" - if strings.Contains(Database.Name, paramSep) { - paramSep = "&" - } - switch Database.Type { - case "mysql": - connType := "tcp" - if len(Database.Host) > 0 && Database.Host[0] == '/' { // looks like a unix socket - connType = "unix" - } - tls := Database.SSLMode - if tls == "disable" { // allow (Postgres-inspired) default value to work in MySQL - tls = "false" - } - connStr = fmt.Sprintf("%s:%s@%s(%s)/%s%sparseTime=true&tls=%s", - Database.User, Database.Passwd, connType, Database.Host, Database.Name, paramSep, tls) - case "postgres": - connStr = getPostgreSQLConnectionString(Database.Host, Database.User, Database.Passwd, Database.Name, Database.SSLMode) - case "mssql": - host, port := ParseMSSQLHostPort(Database.Host) - connStr = fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", host, port, Database.Name, Database.User, Database.Passwd) - case "sqlite3": - if !EnableSQLite3 { - return "", errors.New("this Gitea binary was not built with SQLite3 support") - } - if err := os.MkdirAll(filepath.Dir(Database.Path), os.ModePerm); err != nil { - return "", fmt.Errorf("Failed to create directories: %w", err) - } - journalMode := "" - if Database.SQLiteJournalMode != "" { - journalMode = "&_journal_mode=" + Database.SQLiteJournalMode - } - connStr = fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=%d&_txlock=immediate%s", - Database.Path, Database.Timeout, journalMode) - default: - return "", fmt.Errorf("unknown database type: %s", Database.Type) - } - - return connStr, nil -} - -// parsePostgreSQLHostPort parses given input in various forms defined in -// https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING -// and returns proper host and port number. -func parsePostgreSQLHostPort(info string) (host, port string) { - if h, p, err := net.SplitHostPort(info); err == nil { - host, port = h, p - } else { - // treat the "info" as "host", if it's an IPv6 address, remove the wrapper - host = info - if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { - host = host[1 : len(host)-1] - } - } - - // set fallback values - if host == "" { - host = "127.0.0.1" - } - if port == "" { - port = "5432" - } - return host, port -} - -func getPostgreSQLConnectionString(dbHost, dbUser, dbPasswd, dbName, dbsslMode string) (connStr string) { - dbName, dbParam, _ := strings.Cut(dbName, "?") - host, port := parsePostgreSQLHostPort(dbHost) - connURL := url.URL{ - Scheme: "postgres", - User: url.UserPassword(dbUser, dbPasswd), - Host: net.JoinHostPort(host, port), - Path: dbName, - OmitHost: false, - RawQuery: dbParam, - } - query := connURL.Query() - if strings.HasPrefix(host, "/") { // looks like a unix socket - query.Add("host", host) - connURL.Host = ":" + port - } - query.Set("sslmode", dbsslMode) - connURL.RawQuery = query.Encode() - return connURL.String() -} - -// ParseMSSQLHostPort splits the host into host and port -func ParseMSSQLHostPort(info string) (string, string) { - // the default port "0" might be related to MSSQL's dynamic port, maybe it should be double-confirmed in the future - host, port := "127.0.0.1", "0" - if strings.Contains(info, ":") { - host = strings.Split(info, ":")[0] - port = strings.Split(info, ":")[1] - } else if strings.Contains(info, ",") { - host = strings.Split(info, ",")[0] - port = strings.TrimSpace(strings.Split(info, ",")[1]) - } else if len(info) > 0 { - host = info - } - if host == "" { - host = "127.0.0.1" - } - if port == "" { - port = "0" - } - return host, port -} - +// DatabaseType FIXME: it is also used directly with "schemas.DBType", so the names must be consistent type DatabaseType string -func (t DatabaseType) String() string { - return string(t) -} - func (t DatabaseType) IsSQLite3() bool { return t == "sqlite3" } diff --git a/modules/setting/database_sqlite.go b/modules/setting/database_sqlite.go deleted file mode 100644 index c1037cfb27..0000000000 --- a/modules/setting/database_sqlite.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build sqlite - -// Copyright 2014 The Gogs Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - _ "github.com/mattn/go-sqlite3" -) - -func init() { - EnableSQLite3 = true - SupportedDatabaseTypes = append(SupportedDatabaseTypes, "sqlite3") -} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 9c6903edc6..80692acdaf 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3315,7 +3315,6 @@ "admin.config.cache_config": "Cache Configuration", "admin.config.cache_adapter": "Cache Adapter", "admin.config.cache_interval": "Cache Interval", - "admin.config.cache_conn": "Cache Connection", "admin.config.cache_item_ttl": "Cache Item TTL", "admin.config.cache_test": "Test Cache", "admin.config.cache_test_failed": "Failed to probe the cache: %v.", @@ -3330,7 +3329,6 @@ "admin.config.instance_web_banner.message_placeholder": "Banner message (supports markdown)", "admin.config.session_config": "Session Configuration", "admin.config.session_provider": "Session Provider", - "admin.config.provider_config": "Provider Config", "admin.config.cookie_name": "Cookie Name", "admin.config.gc_interval_time": "GC Interval Time", "admin.config.session_life_time": "Session Life Time", diff --git a/routers/init.go b/routers/init.go index 92eab5eaf2..e04b711c4d 100644 --- a/routers/init.go +++ b/routers/init.go @@ -134,12 +134,6 @@ func InitWebInstalled(ctx context.Context) { external.RegisterRenderers() markup.Init(markup_service.FormalRenderHelperFuncs()) - if setting.EnableSQLite3 { - log.Info("SQLite3 support is enabled") - } else if setting.Database.Type.IsSQLite3() { - log.Fatal("SQLite3 support is disabled, but it is used for database setting. Please get or build a Gitea release with SQLite3 support.") - } - mustInitCtx(ctx, common.InitDBEngine) log.Info("ORM engine initialization successful!") mustInit(system.Init) diff --git a/routers/install/install.go b/routers/install/install.go index a0f32fb939..718ede6564 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -76,7 +76,7 @@ func Install(ctx *context.Context) { form.DbSchema = setting.Database.Schema form.SSLMode = setting.Database.SSLMode - curDBType := setting.Database.Type.String() + curDBType := string(setting.Database.Type) if !slices.Contains(setting.SupportedDatabaseTypes, curDBType) { curDBType = "mysql" } @@ -328,7 +328,7 @@ func SubmitInstall(ctx *context.Context) { cfg.Section("").Key("WORK_PATH").SetValue(setting.AppWorkPath) cfg.Section("").Key("RUN_MODE").SetValue("prod") - cfg.Section("database").Key("DB_TYPE").SetValue(setting.Database.Type.String()) + cfg.Section("database").Key("DB_TYPE").SetValue(string(setting.Database.Type)) cfg.Section("database").Key("HOST").SetValue(setting.Database.Host) cfg.Section("database").Key("NAME").SetValue(setting.Database.Name) cfg.Section("database").Key("USER").SetValue(setting.Database.User) diff --git a/routers/web/admin/admin_test.go b/routers/web/admin/admin_test.go index ecdd462f9e..929faa1968 100644 --- a/routers/web/admin/admin_test.go +++ b/routers/web/admin/admin_test.go @@ -16,64 +16,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestShadowPassword(t *testing.T) { - kases := []struct { - Provider string - CfgItem string - Result string - }{ - { - Provider: "redis", - CfgItem: "network=tcp,addr=:6379,password=gitea,db=0,pool_size=100,idle_timeout=180", - Result: "network=tcp,addr=:6379,password=******,db=0,pool_size=100,idle_timeout=180", - }, - { - Provider: "mysql", - CfgItem: "root:@tcp(localhost:3306)/gitea?charset=utf8", - Result: "root:******@tcp(localhost:3306)/gitea?charset=utf8", - }, - { - Provider: "mysql", - CfgItem: "/gitea?charset=utf8", - Result: "/gitea?charset=utf8", - }, - { - Provider: "mysql", - CfgItem: "user:mypassword@/dbname", - Result: "user:******@/dbname", - }, - { - Provider: "postgres", - CfgItem: "user=pqgotest dbname=pqgotest sslmode=verify-full", - Result: "user=pqgotest dbname=pqgotest sslmode=verify-full", - }, - { - Provider: "postgres", - CfgItem: "user=pqgotest password= dbname=pqgotest sslmode=verify-full", - Result: "user=pqgotest password=****** dbname=pqgotest sslmode=verify-full", - }, - { - Provider: "postgres", - CfgItem: "postgres://user:pass@hostname/dbname", - Result: "postgres://user:******@hostname/dbname", - }, - { - Provider: "couchbase", - CfgItem: "http://dev-couchbase.example.com:8091/", - Result: "http://dev-couchbase.example.com:8091/", - }, - { - Provider: "couchbase", - CfgItem: "http://user:the_password@dev-couchbase.example.com:8091/", - Result: "http://user:******@dev-couchbase.example.com:8091/", - }, - } - - for _, k := range kases { - assert.Equal(t, k.Result, shadowPassword(k.Provider, k.CfgItem)) - } -} - func TestSelfCheckPost(t *testing.T) { defer test.MockVariableValue(&setting.PublicURLDetection)() defer test.MockVariableValue(&setting.AppURL, "http://config/sub/")() diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index bf48e554df..03a15b6713 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -7,8 +7,6 @@ package admin import ( "errors" "net/http" - "net/url" - "strings" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/modules/cache" @@ -59,63 +57,6 @@ func TestCache(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/-/admin/config") } -func shadowPasswordKV(cfgItem, splitter string) string { - fields := strings.Split(cfgItem, splitter) - for i := range fields { - if strings.HasPrefix(fields[i], "password=") { - fields[i] = "password=******" - break - } - } - return strings.Join(fields, splitter) -} - -func shadowURL(provider, cfgItem string) string { - u, err := url.Parse(cfgItem) - if err != nil { - log.Error("Shadowing Password for %v failed: %v", provider, err) - return cfgItem - } - if u.User != nil { - atIdx := strings.Index(cfgItem, "@") - if atIdx > 0 { - colonIdx := strings.LastIndex(cfgItem[:atIdx], ":") - if colonIdx > 0 { - return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:] - } - } - } - return cfgItem -} - -func shadowPassword(provider, cfgItem string) string { - switch provider { - case "redis": - return shadowPasswordKV(cfgItem, ",") - case "mysql": - // root:@tcp(localhost:3306)/macaron?charset=utf8 - atIdx := strings.Index(cfgItem, "@") - if atIdx > 0 { - colonIdx := strings.Index(cfgItem[:atIdx], ":") - if colonIdx > 0 { - return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:] - } - } - return cfgItem - case "postgres": - // user=jiahuachen dbname=macaron port=5432 sslmode=disable - if !strings.HasPrefix(cfgItem, "postgres://") { - return shadowPasswordKV(cfgItem, " ") - } - fallthrough - case "couchbase": - return shadowURL(provider, cfgItem) - // postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full - // Notice: use shadowURL - } - return cfgItem -} - // Config show admin config page func Config(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.config_summary") @@ -150,8 +91,6 @@ func Config(ctx *context.Context) { ctx.Data["CacheAdapter"] = setting.CacheService.Adapter ctx.Data["CacheInterval"] = setting.CacheService.Interval - - ctx.Data["CacheConn"] = shadowPassword(setting.CacheService.Adapter, setting.CacheService.Conn) ctx.Data["CacheItemTTL"] = setting.CacheService.TTL sessionCfg := setting.SessionConfig @@ -169,7 +108,7 @@ func Config(ctx *context.Context) { sessionCfg.Secure = realSession.Secure sessionCfg.Domain = realSession.Domain } - sessionCfg.ProviderConfig = shadowPassword(sessionCfg.Provider, sessionCfg.ProviderConfig) + sessionCfg.ProviderConfig = "" ctx.Data["SessionConfig"] = sessionCfg ctx.Data["Git"] = setting.Git diff --git a/routers/web/healthcheck/check.go b/routers/web/healthcheck/check.go index de9b2c8ec1..116aab886b 100644 --- a/routers/web/healthcheck/check.go +++ b/routers/web/healthcheck/check.go @@ -111,16 +111,10 @@ func checkDatabase(ctx context.Context, checks checks) status { } if setting.Database.Type.IsSQLite3() && st.Status == pass { - if !setting.EnableSQLite3 { + if _, err := os.Stat(setting.Database.Path); err != nil { st.Status = fail st.Time = getCheckTime() - log.Error("SQLite3 health check failed with error: %v", "this Gitea binary is built without SQLite3 enabled") - } else { - if _, err := os.Stat(setting.Database.Path); err != nil { - st.Status = fail - st.Time = getCheckTime() - log.Error("SQLite3 file exists check failed with error: %v", err) - } + log.Error("SQLite3 file exists check failed with error: %v", err) } } diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index b68f2c1a7a..c381c5bf1d 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -244,8 +244,6 @@
{{.CacheInterval}} {{ctx.Locale.Tr "tool.raw_seconds"}}
{{end}} {{if .CacheConn}} -
{{ctx.Locale.Tr "admin.config.cache_conn"}}
-
{{.CacheConn}}
{{ctx.Locale.Tr "admin.config.cache_item_ttl"}}
{{.CacheItemTTL}}
{{end}} @@ -266,8 +264,6 @@
{{ctx.Locale.Tr "admin.config.session_provider"}}
{{.SessionConfig.Provider}}
-
{{ctx.Locale.Tr "admin.config.provider_config"}}
-
{{if .SessionConfig.ProviderConfig}}{{.SessionConfig.ProviderConfig}}{{else}}-{{end}}
{{ctx.Locale.Tr "admin.config.cookie_name"}}
{{.SessionConfig.CookieName}}
{{ctx.Locale.Tr "admin.config.gc_interval_time"}}
diff --git a/tests/integration/migration-test/gitea-v1.6.4.mssql.sql.gz b/tests/integration/migration-test/gitea-v1.6.4.mssql.sql.gz index 1b676feda1cbced0754c4ce0751e159e4a078e6f..85ba253cf88183a77afb8ee4ad58b1892789cb58 100644 GIT binary patch literal 13260 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 I2n6u|0F9u9%m4rY diff --git a/tests/integration/migration-test/gitea-v1.7.0.mssql.sql.gz b/tests/integration/migration-test/gitea-v1.7.0.mssql.sql.gz index bd869cfa5836cddd10010afefa33cc000fefb44d..4f36c7c0cf162f8d257f9102dccc333eadb395f9 100644 GIT binary patch literal 13369 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 literal 13068 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 S1!RhMdwEqJXsBA?U;hWbLWzX{ diff --git a/tests/integration/migration-test/migration_test.go b/tests/integration/migration-test/migration_test.go index 2d6fc947a7..d28442b714 100644 --- a/tests/integration/migration-test/migration_test.go +++ b/tests/integration/migration-test/migration_test.go @@ -11,7 +11,6 @@ import ( "fmt" "io" "os" - "path" "path/filepath" "regexp" "sort" @@ -26,7 +25,6 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/testlogger" - "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -55,7 +53,7 @@ func availableVersions() ([]string, error) { return nil, err } defer migrationsDir.Close() - versionRE, err := regexp.Compile("gitea-v(?P.+)" + regexp.QuoteMeta("."+setting.Database.Type.String()+".sql.gz")) + versionRE, err := regexp.Compile("gitea-v(?P.+)" + regexp.QuoteMeta("."+string(setting.Database.Type)+".sql.gz")) if err != nil { return nil, err } @@ -64,7 +62,7 @@ func availableVersions() ([]string, error) { if err != nil { return nil, err } - versions := []string{} + var versions []string for _, filename := range filenames { if versionRE.MatchString(filename) { substrings := versionRE.FindStringSubmatch(filename) @@ -76,11 +74,8 @@ func availableVersions() ([]string, error) { } func readSQLFromFile(version string) (string, error) { - filename := filepath.Join(setting.GetGiteaTestSourceRoot(), "tests/integration/migration-test", fmt.Sprintf("gitea-v%s.%s.sql.gz", version, setting.Database.Type)) - - if _, err := os.Stat(filename); os.IsNotExist(err) { - return "", nil - } + filename := fmt.Sprintf("tests/integration/migration-test/gitea-v%s.%s.sql.gz", version, setting.Database.Type) + filename = filepath.Join(setting.GetGiteaTestSourceRoot(), filename) file, err := os.Open(filename) if err != nil { @@ -106,134 +101,51 @@ func restoreOldDB(t *testing.T, version string) { require.NoError(t, err) require.NotEmpty(t, data, "No data found for %s version: %s", setting.Database.Type, version) - switch { - case setting.Database.Type.IsSQLite3(): - util.Remove(setting.Database.Path) - err := os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm) - assert.NoError(t, err) + cleanup, err := unittest.ResetTestDatabase() + require.NoError(t, err) + _ = cleanup // no clean up yet (not needed at the moment) - db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=%d&_txlock=immediate", setting.Database.Path, setting.Database.Timeout)) - assert.NoError(t, err) - defer db.Close() + connOpts := db.GlobalConnOptions() - _, err = db.Exec(data) - assert.NoError(t, err) - db.Close() - - case setting.Database.Type.IsMySQL(): - db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/", - setting.Database.User, setting.Database.Passwd, setting.Database.Host)) - assert.NoError(t, err) - defer db.Close() - - _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name) - assert.NoError(t, err) - - _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name) - assert.NoError(t, err) - db.Close() - - db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s?multiStatements=true", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name)) - assert.NoError(t, err) - defer db.Close() - - _, err = db.Exec(data) - assert.NoError(t, err) - db.Close() - - case setting.Database.Type.IsPostgreSQL(): - var db *sql.DB - var err error - if setting.Database.Host[0] == '/' { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/?sslmode=%s&host=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.SSLMode, setting.Database.Host)) - assert.NoError(t, err) - } else { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode)) - assert.NoError(t, err) + if !connOpts.Type.IsMSSQL() { + if connOpts.Type.IsMySQL() { + connOpts.Database += "?multiStatements=true" } - defer db.Close() + driver, connStr, err := db.ConnStr(connOpts) + require.NoError(t, err) - _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name) - assert.NoError(t, err) + sqlDB, err := sql.Open(driver, connStr) + require.NoError(t, err) + defer sqlDB.Close() - _, err = db.Exec("CREATE DATABASE " + setting.Database.Name) - assert.NoError(t, err) - db.Close() - - // Check if we need to setup a specific schema - if len(setting.Database.Schema) != 0 { - if setting.Database.Host[0] == '/' { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host)) - } else { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) - } - require.NoError(t, err) - defer db.Close() - - schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) - require.NoError(t, err) - require.NotEmpty(t, schrows) - - if !schrows.Next() { - // Create and setup a DB schema - _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema) - assert.NoError(t, err) - } - schrows.Close() - - // Make the user's default search path the created schema; this will affect new connections - _, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema)) - assert.NoError(t, err) - - db.Close() - } - - if setting.Database.Host[0] == '/' { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host)) - } else { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) - } - assert.NoError(t, err) - defer db.Close() - - _, err = db.Exec(data) - assert.NoError(t, err) - db.Close() - - case setting.Database.Type.IsMSSQL(): - host, port := setting.ParseMSSQLHostPort(setting.Database.Host) - db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", - host, port, "master", setting.Database.User, setting.Database.Passwd)) - assert.NoError(t, err) - defer db.Close() - - _, err = db.Exec("DROP DATABASE IF EXISTS [gitea]") - assert.NoError(t, err) - - statements := strings.Split(data, "\nGO\n") - for _, statement := range statements { - if len(statement) > 5 && statement[:5] == "USE [" { - dbname := statement[5 : len(statement)-1] - db.Close() - db, err = sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", - host, port, dbname, setting.Database.User, setting.Database.Passwd)) - assert.NoError(t, err) - defer db.Close() - } - _, err = db.Exec(statement) - assert.NoError(t, err, "Failure whilst running: %s\nError: %v", statement, err) - } - db.Close() - default: - assert.Failf(t, "unsupported database type", "setting.Database.Type=%v", setting.Database.Type) + _, err = sqlDB.Exec(data) + require.NoError(t, err) + return } + + // MSSQL is special. the test fixture will create the [testgitea] database again, so drop it ahead if it exists + driver, connStr, err := db.ConnStrDefaultDatabase(connOpts) + require.NoError(t, err) + sqlDB, err := sql.Open(driver, connStr) + require.NoError(t, err) + + _, err = sqlDB.Exec("DROP DATABASE IF EXISTS [testgitea]") + require.NoError(t, err, "drop existing database testgitea") + + for statement := range strings.SplitSeq(data, "\nGO\n") { + if useStmtAfter, ok := strings.CutPrefix(statement, "USE ["); ok { + _ = sqlDB.Close() + dbname := strings.TrimSuffix(useStmtAfter, "]") // extract the database name from "USE [dbname]" + connOpts.Database = dbname + driver, connStr, err := db.ConnStr(connOpts) + require.NoError(t, err) + sqlDB, err = sql.Open(driver, connStr) + require.NoError(t, err) + } + _, err = sqlDB.Exec(statement) + require.NoError(t, err, "SQL Exec failed when running: %s\nError: %v", statement, err) + } + _ = sqlDB.Close() } func wrappedMigrate(ctx context.Context, x *xorm.Engine) error { diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index 03393c620b..95a1df283f 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -4,7 +4,7 @@ RUN_MODE = prod [database] DB_TYPE = sqlite3 -PATH = gitea.db +PATH = gitea-test.db [indexer] REPO_INDEXER_ENABLED = true diff --git a/tests/test_utils.go b/tests/test_utils.go index f16754b056..11e5ac2434 100644 --- a/tests/test_utils.go +++ b/tests/test_utils.go @@ -4,10 +4,7 @@ package tests import ( - "database/sql" - "fmt" "path/filepath" - "strings" "testing" "code.gitea.io/gitea/models/db" @@ -40,97 +37,14 @@ func InitIntegrationTest() error { } setting.LoadDBSetting() - if err := storage.Init(); err != nil { + cleanupDb, err := unittest.ResetTestDatabase() + if err != nil { return err } + _ = cleanupDb // no clean up yet (not really needed at the moment) - switch { - case setting.Database.Type.IsMySQL(): - { - connType := util.Iif(strings.HasPrefix(setting.Database.Host, "/"), "unix", "tcp") - db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@%s(%s)/", - setting.Database.User, setting.Database.Passwd, connType, setting.Database.Host)) - if err != nil { - return err - } - defer db.Close() - if _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name); err != nil { - return err - } - } - case setting.Database.Type.IsPostgreSQL(): - openPostgreSQL := func() (*sql.DB, error) { - if strings.HasPrefix(setting.Database.Host, "/") { - return sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host)) - } - return sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) - } - - // create database - { - db, err := openPostgreSQL() - if err != nil { - return err - } - defer db.Close() - dbRows, err := db.Query(fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname = '%s'", setting.Database.Name)) - if err != nil { - return err - } - defer dbRows.Close() - - if !dbRows.Next() { - if _, err = db.Exec("CREATE DATABASE " + setting.Database.Name); err != nil { - return err - } - } - // Check if we need to set up a specific schema - if setting.Database.Schema == "" { - break - } - db.Close() - } - - // create schema - { - db, err := openPostgreSQL() - if err != nil { - return err - } - defer db.Close() - - schemaRows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) - if err != nil { - return err - } - defer schemaRows.Close() - - if !schemaRows.Next() { - // Create and set up a DB schema - if _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema); err != nil { - return err - } - } - } - - case setting.Database.Type.IsMSSQL(): - { - host, port := setting.ParseMSSQLHostPort(setting.Database.Host) - db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", - host, port, "master", setting.Database.User, setting.Database.Passwd)) - if err != nil { - return err - } - defer db.Close() - if _, err = db.Exec(fmt.Sprintf("If(db_id(N'%s') IS NULL) BEGIN CREATE DATABASE %s; END;", setting.Database.Name, setting.Database.Name)); err != nil { - return err - } - } - case setting.Database.Type.IsSQLite3(): - default: - return fmt.Errorf("unsupported database type: %s", setting.Database.Type) + if err := storage.Init(); err != nil { + return err } routers.InitWebInstalled(graceful.GetManager().HammerContext()) From 31cee60cc76e7fc9e46170a3a06946f412ba4826 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 1 May 2026 19:41:31 +0200 Subject: [PATCH 3/5] Improve code editor text selection and clean up lint enablement (#37474) 1. Make the content area stretch the box, enabling text selection to start over empty space. 2. Disable linter for markdown, it can never produce lint errors, this hides the unnecessary lint gutter on markdown files. 3. Verified all languages linter enablement, all accurate. 4. Refactor `getLinterExtension` to not rely on file extensions. 5. Include jsonc/json5 extensions in regex. --- This PR was written with the help of Claude Opus 4.7 --------- Co-authored-by: Claude (Opus 4.7) Co-authored-by: Nicolas --- web_src/css/modules/codeeditor.css | 20 +++++++++++--------- web_src/js/modules/codeeditor/main.ts | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/web_src/css/modules/codeeditor.css b/web_src/css/modules/codeeditor.css index 11b772c05c..558ba7a44f 100644 --- a/web_src/css/modules/codeeditor.css +++ b/web_src/css/modules/codeeditor.css @@ -24,6 +24,8 @@ font-family: var(--fonts-monospace); font-size: 12px; max-height: 90vh; + flex: 1; + min-height: 0; } .code-editor-container .cm-editor, @@ -31,24 +33,22 @@ border-radius: 0 0 var(--border-radius) var(--border-radius); } -.code-editor-container .cm-content, -.code-editor-container .cm-gutter { - min-height: 200px; -} - .code-editor-container .cm-scroller { overflow: auto; line-height: var(--line-height-code); + flex: 1; + min-height: 0; +} + +.code-editor-container .cm-content { + align-self: stretch; + padding: 0; } .code-editor-container .cm-content * { caret-color: inherit; } -.code-editor-container .cm-content { - padding: 0; -} - .code-editor-container .cm-cursor, .code-editor-container .cm-dropCursor { border-left-color: var(--color-caret); @@ -341,6 +341,8 @@ .code-editor-container { position: relative; min-height: 90vh; + display: flex; + flex-direction: column; } .cm-command-palette { diff --git a/web_src/js/modules/codeeditor/main.ts b/web_src/js/modules/codeeditor/main.ts index 320d73dd9f..e847f357cb 100644 --- a/web_src/js/modules/codeeditor/main.ts +++ b/web_src/js/modules/codeeditor/main.ts @@ -7,7 +7,7 @@ import type {PaletteCommand} from './command-palette.ts'; import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts'; import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts'; import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts'; -import type {LanguageDescription} from '@codemirror/language'; +import type {LanguageDescription, LanguageSupport} from '@codemirror/language'; import type {Compartment, Extension} from '@codemirror/state'; import type {EditorView, ViewUpdate} from '@codemirror/view'; @@ -295,16 +295,19 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn return editor; } -// files that are JSONC despite having a .json extension -const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$/; +// files that the JSON parser is too strict for (comments, trailing commas) +const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$|\.(jsonc|json5)$/i; -async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: {language: unknown} | null): Promise { - const ext = extname(filename).toLowerCase(); - if (ext === '.json' || ext === '.map') { +async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: LanguageSupport | null): Promise { + if (!loadedLang) return []; + const lang = loadedLang.language; + // StreamLanguage (legacy modes) don't produce Lezer error nodes + if (lang instanceof cm.language.StreamLanguage) return []; + if (lang.name === 'json') { return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)]; } - // StreamLanguage (legacy modes) don't produce Lezer error nodes - if (!loadedLang || loadedLang.language instanceof cm.language.StreamLanguage) return []; + // markdown's parser emits no error nodes, and nested code-fence overlays aren't traversed + if (lang.name === 'markdown') return []; return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)]; } From abcfa53040ad59fcbbcabbed48ecd7608ad05ac7 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 2 May 2026 00:12:54 +0200 Subject: [PATCH 4/5] Replace `olivere/elastic` with REST API client, add OpenSearch support (#37411) Drops `github.com/olivere/elastic/v7` (unmaintained) and replaces it with a small in-house wrapper that speaks the Elasticsearch REST API directly via `net/http`. The subset used by Gitea (`_cluster/health`, `_bulk`, `_doc`, `_delete_by_query`, `_refresh`, `_search`, `HEAD`/`PUT` index) is stable across the targeted servers, so no client library is needed. **Targets tested** - Elasticsearch 7, 8, 9 - OpenSearch 1, 2, 3 **Why not `go-elasticsearch`?** The official client enforces an `X-Elastic-Product` server-identity check that OpenSearch deliberately fails, which would force shipping a transport shim to defeat it. Going direct over `net/http` removes that fight along with several MB of transitive deps (`elastic-transport-go`, `go.opentelemetry.io/otel{,/metric,/trace}`, `auto/sdk`, `easyjson`, `intern`, `logr`, `stdr`). Replaces: #30755 Fixes: https://github.com/go-gitea/gitea/issues/30752 --- This PR was written with the help of Claude Opus 4.7 --------- Co-authored-by: Claude (Opus 4.7) Co-authored-by: Lunny Xiao --- .github/workflows/pull-db-tests.yml | 6 +- assets/go-licenses.json | 10 - custom/conf/app.example.ini | 4 +- go.mod | 3 +- go.sum | 10 +- .../code/elasticsearch/elasticsearch.go | 252 +++++------- modules/indexer/code/indexer_test.go | 11 + .../indexer/internal/elasticsearch/indexer.go | 389 ++++++++++++++++-- .../internal/elasticsearch/indexer_test.go | 44 ++ .../indexer/internal/elasticsearch/query.go | 132 ++++++ .../indexer/internal/elasticsearch/types.go | 76 ++++ .../indexer/internal/elasticsearch/util.go | 40 +- .../issues/elasticsearch/elasticsearch.go | 171 +++----- .../elasticsearch/elasticsearch_test.go | 28 +- .../indexer/issues/internal/tests/tests.go | 10 + 15 files changed, 825 insertions(+), 361 deletions(-) create mode 100644 modules/indexer/internal/elasticsearch/indexer_test.go create mode 100644 modules/indexer/internal/elasticsearch/query.go create mode 100644 modules/indexer/internal/elasticsearch/types.go diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index 0ed1105e82..c2293ac853 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -102,9 +102,10 @@ jobs: runs-on: ubuntu-latest services: elasticsearch: - image: elasticsearch:7.5.0 + image: docker.elastic.co/elasticsearch/elasticsearch:8.19.14 env: discovery.type: single-node + xpack.security.enabled: false ports: - "9200:9200" meilisearch: @@ -180,9 +181,10 @@ jobs: options: >- --mount type=tmpfs,destination=/bitnami/mysql/data elasticsearch: - image: elasticsearch:7.5.0 + image: docker.elastic.co/elasticsearch/elasticsearch:8.19.14 env: discovery.type: single-node + xpack.security.enabled: false ports: - "9200:9200" smtpimap: diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 26c7d6a0d8..e642cb2c59 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -1004,16 +1004,6 @@ "path": "github.com/olekukonko/tablewriter/LICENSE.md", "licenseText": "Copyright (C) 2014 by Oleku Konko\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, - { - "name": "github.com/olivere/elastic/v7", - "path": "github.com/olivere/elastic/v7/LICENSE", - "licenseText": "The MIT License (MIT)\nCopyright © 2012-2015 Oliver Eilhard\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the “Software”), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included\nin all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\nIN THE SOFTWARE.\n" - }, - { - "name": "github.com/olivere/elastic/v7/uritemplates", - "path": "github.com/olivere/elastic/v7/uritemplates/LICENSE", - "licenseText": "Copyright (c) 2013 Joshua Tacoma\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" - }, { "name": "github.com/opencontainers/go-digest", "path": "github.com/opencontainers/go-digest/LICENSE", diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 4245957191..dd62cf8e83 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1524,7 +1524,7 @@ LEVEL = Info ;; Issue Indexer settings ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; -;; Issue indexer type, currently support: bleve, db, elasticsearch or meilisearch default is bleve +;; Issue indexer type, currently support: bleve, db, elasticsearch (also compatible with OpenSearch) or meilisearch default is bleve ;ISSUE_INDEXER_TYPE = bleve ;; ;; Issue indexer storage path, available when ISSUE_INDEXER_TYPE is bleve @@ -1551,7 +1551,7 @@ LEVEL = Info ;; If empty then it defaults to `sources` only, as if you'd like to disable fully please see REPO_INDEXER_ENABLED. ;REPO_INDEXER_REPO_TYPES = sources,forks,mirrors,templates ;; -;; Code search engine type, could be `bleve` or `elasticsearch`. +;; Code search engine type, could be `bleve` or `elasticsearch` (also compatible with OpenSearch). ;REPO_INDEXER_TYPE = bleve ;; ;; Index file used for code search. available when `REPO_INDEXER_TYPE` is bleve diff --git a/go.mod b/go.mod index 2e3df9b68f..156abf4afb 100644 --- a/go.mod +++ b/go.mod @@ -87,7 +87,6 @@ require ( github.com/msteinert/pam/v2 v2.1.0 github.com/nektos/act v0.2.63 github.com/niklasfasching/go-org v1.9.1 - github.com/olivere/elastic/v7 v7.0.32 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/pquerna/otp v1.5.0 @@ -222,7 +221,7 @@ require ( github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/libdns/libdns v1.1.1 // indirect - github.com/mailru/easyjson v0.9.2 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/markbates/going v1.0.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect diff --git a/go.sum b/go.sum index d7e5690040..b8ff05bb0a 100644 --- a/go.sum +++ b/go.sum @@ -267,8 +267,6 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= -github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -507,9 +505,8 @@ github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= -github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/markbates/going v1.0.3 h1:mY45T5TvW+Xz5A6jY7lf4+NLg9D8+iuStIHyR7M8qsE= github.com/markbates/going v1.0.3/go.mod h1:fQiT6v6yQar9UD6bd/D4Z5Afbk9J6BBVBtLiyY4gp2o= github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ= @@ -585,8 +582,6 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/olivere/elastic/v7 v7.0.32 h1:R7CXvbu8Eq+WlsLgxmKVKPox0oOwAE/2T9Si5BnvK6E= -github.com/olivere/elastic/v7 v7.0.32/go.mod h1:c7PVmLe3Fxq77PIfY/bZmxY/TAamBhCzZ8xDOE09a9k= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= @@ -667,9 +662,8 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/assertions v1.1.1 h1:T/YLemO5Yp7KPzS+lVtu+WsHn8yoSwTfItdAd1r3cck= -github.com/smartystreets/assertions v1.1.1/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 h1:WN9BUFbdyOsSH/XohnWpXOlq9NBD5sGAB2FciQMUEe8= github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= diff --git a/modules/indexer/code/elasticsearch/elasticsearch.go b/modules/indexer/code/elasticsearch/elasticsearch.go index 9d170528ad..f9a3d4156d 100644 --- a/modules/indexer/code/elasticsearch/elasticsearch.go +++ b/modules/indexer/code/elasticsearch/elasticsearch.go @@ -18,8 +18,7 @@ import ( "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/indexer" "code.gitea.io/gitea/modules/indexer/code/internal" - indexer_internal "code.gitea.io/gitea/modules/indexer/internal" - inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch" + es "code.gitea.io/gitea/modules/indexer/internal/elasticsearch" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -28,23 +27,15 @@ import ( "code.gitea.io/gitea/modules/util" "github.com/go-enry/go-enry/v2" - "github.com/olivere/elastic/v7" ) -const ( - esRepoIndexerLatestVersion = 3 - // multi-match-types, currently only 2 types are used - // Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types - esMultiMatchTypeBestFields = "best_fields" - esMultiMatchTypePhrasePrefix = "phrase_prefix" -) +const esRepoIndexerLatestVersion = 3 var _ internal.Indexer = &Indexer{} // Indexer implements Indexer interface type Indexer struct { - inner *inner_elasticsearch.Indexer - indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much + *es.Indexer } func (b *Indexer) SupportedSearchModes() []indexer.SearchMode { @@ -53,12 +44,7 @@ func (b *Indexer) SupportedSearchModes() []indexer.SearchMode { // NewIndexer creates a new elasticsearch indexer func NewIndexer(url, indexerName string) *Indexer { - inner := inner_elasticsearch.NewIndexer(url, indexerName, esRepoIndexerLatestVersion, defaultMapping) - indexer := &Indexer{ - inner: inner, - Indexer: inner, - } - return indexer + return &Indexer{Indexer: es.NewIndexer(url, indexerName, esRepoIndexerLatestVersion, defaultMapping)} } const ( @@ -138,7 +124,7 @@ const ( }` ) -func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]elastic.BulkableRequest, error) { +func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]es.BulkOp, error) { // Ignore vendored files in code search if setting.Indexer.ExcludeVendored && analyze.IsVendor(update.Filename) { return nil, nil @@ -157,8 +143,9 @@ func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, } } + id := internal.FilenameIndexerID(repo.ID, update.Filename) if size > setting.Indexer.MaxIndexerFileSize { - return []elastic.BulkableRequest{b.addDelete(update.Filename, repo)}, nil + return []es.BulkOp{es.DeleteOp(id)}, nil } info, batchReader, err := catFileBatch.QueryContent(update.BlobSha) @@ -177,33 +164,24 @@ func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, if _, err = batchReader.Discard(1); err != nil { return nil, err } - id := internal.FilenameIndexerID(repo.ID, update.Filename) - return []elastic.BulkableRequest{ - elastic.NewBulkIndexRequest(). - Index(b.inner.VersionedIndexName()). - Id(id). - Doc(map[string]any{ - "repo_id": repo.ID, - "filename": update.Filename, - "content": string(charset.ToUTF8DropErrors(fileContents)), - "commit_id": sha, - "language": analyze.GetCodeLanguage(update.Filename, fileContents), - "updated_at": timeutil.TimeStampNow(), - }), - }, nil + return []es.BulkOp{es.IndexOp(id, map[string]any{ + "repo_id": repo.ID, + "filename": update.Filename, + "content": string(charset.ToUTF8DropErrors(fileContents)), + "commit_id": sha, + "language": analyze.GetCodeLanguage(update.Filename, fileContents), + "updated_at": timeutil.TimeStampNow(), + })}, nil } -func (b *Indexer) addDelete(filename string, repo *repo_model.Repository) elastic.BulkableRequest { - id := internal.FilenameIndexerID(repo.ID, filename) - return elastic.NewBulkDeleteRequest(). - Index(b.inner.VersionedIndexName()). - Id(id) +func (b *Indexer) addDelete(filename string, repo *repo_model.Repository) es.BulkOp { + return es.DeleteOp(internal.FilenameIndexerID(repo.ID, filename)) } // Index will save the index data func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *internal.RepoChanges) error { - reqs := make([]elastic.BulkableRequest, 0) + ops := make([]es.BulkOp, 0) if len(changes.Updates) > 0 { batch, err := gitrepo.NewBatch(ctx, repo) if err != nil { @@ -212,29 +190,25 @@ func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha st defer batch.Close() for _, update := range changes.Updates { - updateReqs, err := b.addUpdate(ctx, batch, sha, update, repo) + updateOps, err := b.addUpdate(ctx, batch, sha, update, repo) if err != nil { return err } - if len(updateReqs) > 0 { - reqs = append(reqs, updateReqs...) + if len(updateOps) > 0 { + ops = append(ops, updateOps...) } } } for _, filename := range changes.RemovedFilenames { - reqs = append(reqs, b.addDelete(filename, repo)) + ops = append(ops, b.addDelete(filename, repo)) } - if len(reqs) > 0 { + if len(ops) > 0 { esBatchSize := 50 - for i := 0; i < len(reqs); i += esBatchSize { - _, err := b.inner.Client.Bulk(). - Index(b.inner.VersionedIndexName()). - Add(reqs[i:min(i+esBatchSize, len(reqs))]...). - Do(ctx) - if err != nil { + for i := 0; i < len(ops); i += esBatchSize { + if err := b.Bulk(ctx, ops[i:min(i+esBatchSize, len(ops))]); err != nil { return err } } @@ -246,33 +220,21 @@ func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha st func (b *Indexer) Delete(ctx context.Context, repoID int64) error { if err := b.doDelete(ctx, repoID); err != nil { // Maybe there is a conflict during the delete operation, so we should retry after a refresh - log.Warn("Deletion of entries of repo %v within index %v was erroneous. Trying to refresh index before trying again", repoID, b.inner.VersionedIndexName(), err) - if err := b.refreshIndex(ctx); err != nil { + log.Warn("Deletion of entries of repo %v within index %v was erroneous: %v. Trying to refresh index before trying again", repoID, b.VersionedIndexName(), err) + if err := b.Refresh(ctx); err != nil { return err } if err := b.doDelete(ctx, repoID); err != nil { - log.Error("Could not delete entries of repo %v within index %v", repoID, b.inner.VersionedIndexName()) + log.Error("Could not delete entries of repo %v within index %v", repoID, b.VersionedIndexName()) return err } } return nil } -func (b *Indexer) refreshIndex(ctx context.Context) error { - if _, err := b.inner.Client.Refresh(b.inner.VersionedIndexName()).Do(ctx); err != nil { - log.Error("Error while trying to refresh index %v", b.inner.VersionedIndexName(), err) - return err - } - - return nil -} - // Delete entries by repoId func (b *Indexer) doDelete(ctx context.Context, repoID int64) error { - _, err := b.inner.Client.DeleteByQuery(b.inner.VersionedIndexName()). - Query(elastic.NewTermsQuery("repo_id", repoID)). - Do(ctx) - return err + return b.DeleteByQuery(ctx, es.TermsQuery("repo_id", repoID)) } // contentMatchIndexPos find words positions for start and the following end on content. It will @@ -291,10 +253,10 @@ func contentMatchIndexPos(content, start, end string) (int, int) { return startIdx, (startIdx + len(start) + endIdx + len(end)) - 9 // remove the length since we give Content the original data } -func convertResult(searchResult *elastic.SearchResult, kw string, pageSize int) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) { +func convertResult(searchResult *es.SearchResponse, kw string, pageSize int) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) { hits := make([]*internal.SearchResult, 0, pageSize) - for _, hit := range searchResult.Hits.Hits { - repoID, fileName := internal.ParseIndexerID(hit.Id) + for _, hit := range searchResult.Hits { + repoID, fileName := internal.ParseIndexerID(hit.ID) res := make(map[string]any) if err := json.Unmarshal(hit.Source, &res); err != nil { return 0, nil, nil, err @@ -333,111 +295,111 @@ func convertResult(searchResult *elastic.SearchResult, kw string, pageSize int) }) } - return searchResult.TotalHits(), hits, extractAggs(searchResult), nil + return searchResult.Total, hits, extractAggs(searchResult), nil } -func extractAggs(searchResult *elastic.SearchResult) []*internal.SearchResultLanguages { - var searchResultLanguages []*internal.SearchResultLanguages - agg, found := searchResult.Aggregations.Terms("language") - if found { - searchResultLanguages = make([]*internal.SearchResultLanguages, 0, 10) - - for _, bucket := range agg.Buckets { - searchResultLanguages = append(searchResultLanguages, &internal.SearchResultLanguages{ - Language: bucket.Key.(string), - Color: enry.GetColor(bucket.Key.(string)), - Count: int(bucket.DocCount), - }) +func extractAggs(searchResult *es.SearchResponse) []*internal.SearchResultLanguages { + buckets, found := searchResult.Aggregations["language"] + if !found { + return nil + } + searchResultLanguages := make([]*internal.SearchResultLanguages, 0, 10) + for _, bucket := range buckets { + // language is mapped as keyword so the key is always a string; if the + // mapping ever changes, skip rather than emit an empty-language bucket. + key, ok := bucket.Key.(string) + if !ok { + continue } + searchResultLanguages = append(searchResultLanguages, &internal.SearchResultLanguages{ + Language: key, + Color: enry.GetColor(key), + Count: int(bucket.DocCount), + }) } return searchResultLanguages } // Search searches for codes and language stats by given conditions. func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) { - var contentQuery elastic.Query searchMode := util.IfZero(opts.SearchMode, b.SupportedSearchModes()[0].ModeValue) + contentQuery := es.Query(es.NewMultiMatchQuery(opts.Keyword, "content").Type(es.MultiMatchTypeBestFields).Operator("and")) if searchMode == indexer.SearchModeExact { - // 1.21 used NewMultiMatchQuery().Type(esMultiMatchTypePhrasePrefix), but later releases changed to NewMatchPhraseQuery - contentQuery = elastic.NewMatchPhraseQuery("content", opts.Keyword) - } else /* words */ { - contentQuery = elastic.NewMultiMatchQuery("content", opts.Keyword).Type(esMultiMatchTypeBestFields).Operator("and") + contentQuery = es.MatchPhraseQuery("content", opts.Keyword) } - kwQuery := elastic.NewBoolQuery().Should( + kwQuery := es.NewBoolQuery().Should( contentQuery, - elastic.NewMultiMatchQuery(opts.Keyword, "filename^10").Type(esMultiMatchTypePhrasePrefix), + es.NewMultiMatchQuery(opts.Keyword, "filename^10").Type(es.MultiMatchTypePhrasePrefix), ) - query := elastic.NewBoolQuery() - query = query.Must(kwQuery) + query := es.NewBoolQuery().Must(kwQuery) if len(opts.RepoIDs) > 0 { - repoStrs := make([]any, 0, len(opts.RepoIDs)) - for _, repoID := range opts.RepoIDs { - repoStrs = append(repoStrs, repoID) - } - repoQuery := elastic.NewTermsQuery("repo_id", repoStrs...) - query = query.Must(repoQuery) + query.Must(es.TermsQuery("repo_id", es.ToAnySlice(opts.RepoIDs)...)) } - var ( - start, pageSize = opts.GetSkipTake() - kw = "" + opts.Keyword + "" - aggregation = elastic.NewTermsAggregation().Field("language").Size(10).OrderByCountDesc() - ) + start, pageSize := opts.GetSkipTake() + kw := "" + opts.Keyword + "" + languageAggs := map[string]any{ + "language": map[string]any{ + "terms": map[string]any{ + "field": "language", + "size": 10, + "order": map[string]any{"_count": "desc"}, + }, + }, + } + // number_of_fragments=0 returns the full highlighted content (no fragmentation). + highlight := map[string]any{ + "fields": map[string]any{ + "content": map[string]any{}, + "filename": map[string]any{}, + }, + "number_of_fragments": 0, + "type": "fvh", + } + sort := []es.SortField{ + {Field: "_score", Desc: true}, + {Field: "updated_at", Desc: false}, + } if len(opts.Language) == 0 { - searchResult, err := b.inner.Client.Search(). - Index(b.inner.VersionedIndexName()). - Aggregation("language", aggregation). - Query(query). - Highlight( - elastic.NewHighlight(). - Field("content"). - Field("filename"). - NumOfFragments(0). // return all highlighting content on fragments - HighlighterType("fvh"), - ). - Sort("_score", false). - Sort("updated_at", true). - From(start).Size(pageSize). - Do(ctx) + resp, err := b.Indexer.Search(ctx, es.SearchRequest{ + Query: query, + Sort: sort, + From: start, + Size: pageSize, + TrackTotal: true, + Aggregations: languageAggs, + Highlight: highlight, + }) if err != nil { return 0, nil, nil, err } - - return convertResult(searchResult, kw, pageSize) + return convertResult(resp, kw, pageSize) } - langQuery := elastic.NewMatchQuery("language", opts.Language) - countResult, err := b.inner.Client.Search(). - Index(b.inner.VersionedIndexName()). - Aggregation("language", aggregation). - Query(query). - Size(0). // We only need stats information - Do(ctx) + countResp, err := b.Indexer.Search(ctx, es.SearchRequest{ + Query: query, + Size: 0, // stats only + TrackTotal: true, + Aggregations: languageAggs, + }) if err != nil { return 0, nil, nil, err } - query = query.Must(langQuery) - searchResult, err := b.inner.Client.Search(). - Index(b.inner.VersionedIndexName()). - Query(query). - Highlight( - elastic.NewHighlight(). - Field("content"). - Field("filename"). - NumOfFragments(0). // return all highlighting content on fragments - HighlighterType("fvh"), - ). - Sort("_score", false). - Sort("updated_at", true). - From(start).Size(pageSize). - Do(ctx) + query.Must(es.MatchQuery("language", opts.Language)) + resp, err := b.Indexer.Search(ctx, es.SearchRequest{ + Query: query, + Sort: sort, + From: start, + Size: pageSize, + TrackTotal: true, + Highlight: highlight, + }) if err != nil { return 0, nil, nil, err } - total, hits, _, err := convertResult(searchResult, kw, pageSize) - - return total, hits, extractAggs(countResult), err + total, hits, _, err := convertResult(resp, kw, pageSize) + return total, hits, extractAggs(countResp), err } diff --git a/modules/indexer/code/indexer_test.go b/modules/indexer/code/indexer_test.go index a884ab733a..a8baf44edc 100644 --- a/modules/indexer/code/indexer_test.go +++ b/modules/indexer/code/indexer_test.go @@ -8,6 +8,7 @@ import ( "os" "slices" "testing" + "time" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" @@ -39,6 +40,16 @@ func TestMain(m *testing.M) { func testIndexer(name string, t *testing.T, indexer internal.Indexer) { t.Run(name, func(t *testing.T) { assert.NoError(t, setupRepositoryIndexes(t.Context(), indexer)) + // Wait for the index to catch up: ES/OpenSearch make writes visible + // only after a refresh (default interval: 1s). Bleve is synchronous + // and passes on the first iteration. + require.Eventually(t, func() bool { + total, _, _, err := indexer.Search(t.Context(), &internal.SearchOptions{ + Keyword: "Description", + Paginator: &db.ListOptions{Page: 1, PageSize: 1}, + }) + return err == nil && total > 0 + }, 10*time.Second, 100*time.Millisecond, "index did not become searchable") keywords := []struct { RepoIDs []int64 diff --git a/modules/indexer/internal/elasticsearch/indexer.go b/modules/indexer/internal/elasticsearch/indexer.go index 265ce26585..eaeb89efaa 100644 --- a/modules/indexer/internal/elasticsearch/indexer.go +++ b/modules/indexer/internal/elasticsearch/indexer.go @@ -4,52 +4,80 @@ package elasticsearch import ( + "bytes" "context" - "errors" "fmt" + "io" + "net" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" "code.gitea.io/gitea/modules/indexer/internal" - - "github.com/olivere/elastic/v7" + "code.gitea.io/gitea/modules/json" ) var _ internal.Indexer = &Indexer{} -// Indexer represents a basic elasticsearch indexer implementation +// Indexer is a narrow wrapper around an Elasticsearch/OpenSearch cluster. +// It targets the REST subset shared by Elasticsearch 7/8/9 and OpenSearch 3. type Indexer struct { - Client *elastic.Client + client *http.Client + base string // base URL with trailing slash, no userinfo + user string + pass string - url string indexName string version int mapping string } -func NewIndexer(url, indexName string, version int, mapping string) *Indexer { +// NewIndexer builds an Indexer. The connection is opened by Init. +func NewIndexer(rawURL, indexName string, version int, mapping string) *Indexer { return &Indexer{ - url: url, + base: rawURL, indexName: indexName, version: version, mapping: mapping, } } -// Init initializes the indexer +// Init connects and creates the versioned index if missing, returning true if it already existed. func (i *Indexer) Init(ctx context.Context) (bool, error) { - if i == nil { - return false, errors.New("cannot init nil indexer") - } - if i.Client != nil { - return false, errors.New("indexer is already initialized") - } - - client, err := i.initClient() + parsed, err := url.Parse(i.base) if err != nil { - return false, err + return false, fmt.Errorf("parse elasticsearch url: %w", err) + } + if parsed.User != nil { + i.user = parsed.User.Username() + i.pass, _ = parsed.User.Password() + parsed.User = nil + } + base := parsed.String() + if !strings.HasSuffix(base, "/") { + base += "/" + } + i.base = base + // No client-level Timeout: bulk/_delete_by_query can legitimately run for + // minutes on large repos. Per-request deadlines come from the caller's ctx; + // transport-level timeouts cover stalled connects/handshakes/headers so a + // half-open server cannot wedge the indexer indefinitely. + i.client = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + IdleConnTimeout: 90 * time.Second, + MaxIdleConns: 100, + }, } - i.Client = client - exists, err := i.Client.IndexExists(i.VersionedIndexName()).Do(ctx) + exists, err := i.indexExists(ctx, i.VersionedIndexName()) if err != nil { return false, err } @@ -61,34 +89,321 @@ func (i *Indexer) Init(ctx context.Context) (bool, error) { return false, err } - return exists, nil + return false, nil } -// Ping checks if the indexer is available +// Ping returns an error when the cluster is unusable (status != green/yellow). func (i *Indexer) Ping(ctx context.Context) error { - if i == nil { - return errors.New("cannot ping nil indexer") + var body struct { + Status string `json:"status"` } - if i.Client == nil { - return errors.New("indexer is not initialized") - } - - resp, err := i.Client.ClusterHealth().Do(ctx) - if err != nil { + if err := i.doJSON(ctx, http.MethodGet, "_cluster/health", nil, &body); err != nil { return err } - if resp.Status != "green" && resp.Status != "yellow" { - // It's healthy if the status is green, and it's available if the status is yellow, - // see https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-health.html - return fmt.Errorf("status of elasticsearch cluster is %s", resp.Status) + // Healthy = green; usable = yellow. Red is unusable. + // https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-health.html + if body.Status != "green" && body.Status != "yellow" { + return fmt.Errorf("status of elasticsearch cluster is %s", body.Status) } return nil } -// Close closes the indexer +// Close releases idle HTTP connections held by the client. func (i *Indexer) Close() { - if i == nil { + if i == nil || i.client == nil { return } - i.Client = nil + i.client.CloseIdleConnections() + i.client = nil +} + +// Bulk submits index/delete ops. Returns the first item-level failure, if any. +func (i *Indexer) Bulk(ctx context.Context, ops []BulkOp) error { + if len(ops) == 0 { + return nil + } + + index := i.VersionedIndexName() + var buf bytes.Buffer + buf.Grow(len(ops) * 256) + for _, op := range ops { + meta := map[string]any{op.action: map[string]any{"_index": index, "_id": op.id}} + if err := writeJSONLine(&buf, meta); err != nil { + return err + } + if op.action == bulkActionIndex { + if err := writeJSONLine(&buf, op.doc); err != nil { + return err + } + } + } + + res, err := i.do(ctx, http.MethodPost, urlPath(index, "_bulk"), "application/x-ndjson", bytes.NewReader(buf.Bytes())) + if err != nil { + return err + } + defer drainAndClose(res) + + var body struct { + Errors bool `json:"errors"` + Items []map[string]struct { + Status int `json:"status"` + Error json.Value `json:"error"` + } `json:"items"` + } + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + return err + } + if !body.Errors { + return nil + } + return firstBulkError(body.Items) +} + +// firstBulkError returns the first item-level failure in a bulk response. +// Each items entry is a single-key map ({"index": {...}} or {"delete": {...}}). +// Delete-of-missing (404) is idempotent and not reported. +func firstBulkError(items []map[string]struct { + Status int `json:"status"` + Error json.Value `json:"error"` +}, +) error { + for _, item := range items { + for action, result := range item { + if action == bulkActionDelete && result.Status == http.StatusNotFound { + continue + } + if result.Status >= 300 { + return fmt.Errorf("bulk %s failed (status %d): %s", action, result.Status, string(result.Error)) + } + } + } + return nil +} + +// Index writes a single document. +func (i *Indexer) Index(ctx context.Context, id string, doc any) error { + body, err := json.Marshal(doc) + if err != nil { + return err + } + return i.doJSON(ctx, http.MethodPut, urlPath(i.VersionedIndexName(), "_doc", id), bytes.NewReader(body), nil) +} + +// Delete removes a single document by id. Missing ids are not an error. +func (i *Indexer) Delete(ctx context.Context, id string) error { + res, err := i.do(ctx, http.MethodDelete, urlPath(i.VersionedIndexName(), "_doc", id), "", nil, http.StatusNotFound) + if err != nil { + return err + } + drainAndClose(res) + return nil +} + +// DeleteByQuery removes every document matching the query. +func (i *Indexer) DeleteByQuery(ctx context.Context, query Query) error { + body, err := json.Marshal(map[string]any{"query": query.querySource()}) + if err != nil { + return err + } + return i.doJSON(ctx, http.MethodPost, urlPath(i.VersionedIndexName(), "_delete_by_query"), bytes.NewReader(body), nil) +} + +// Refresh forces a refresh so recent writes are searchable. +func (i *Indexer) Refresh(ctx context.Context) error { + return i.doJSON(ctx, http.MethodPost, urlPath(i.VersionedIndexName(), "_refresh"), nil, nil) +} + +// Search runs a search request and decodes the reply. +func (i *Indexer) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) { + body := map[string]any{} + if req.Query != nil { + body["query"] = req.Query.querySource() + } + if len(req.Sort) > 0 { + sorts := make([]map[string]any, len(req.Sort)) + for idx, s := range req.Sort { + sorts[idx] = s.source() + } + body["sort"] = sorts + } + if req.From > 0 { + body["from"] = req.From + } + body["size"] = req.Size + if len(req.Aggregations) > 0 { + body["aggs"] = req.Aggregations + } + if len(req.Highlight) > 0 { + body["highlight"] = req.Highlight + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + + // Default track_total_hits is 10000 (capped count); send it explicitly so + // callers can choose between exact totals (true) and skipping counting (false). + path := urlPath(i.VersionedIndexName(), "_search") + "?track_total_hits=" + strconv.FormatBool(req.TrackTotal) + res, err := i.do(ctx, http.MethodPost, path, "application/json", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + defer drainAndClose(res) + return decodeSearchResponse(res.Body) +} + +func (i *Indexer) indexExists(ctx context.Context, name string) (bool, error) { + res, err := i.do(ctx, http.MethodHead, urlPath(name), "", nil, http.StatusNotFound) + if err != nil { + return false, err + } + drainAndClose(res) + return res.StatusCode == http.StatusOK, nil +} + +func (i *Indexer) createIndex(ctx context.Context) error { + var body struct { + Acknowledged bool `json:"acknowledged"` + } + if err := i.doJSON(ctx, http.MethodPut, urlPath(i.VersionedIndexName()), bytes.NewBufferString(i.mapping), &body); err != nil { + return fmt.Errorf("create index %s: %w", i.VersionedIndexName(), err) + } + if !body.Acknowledged { + return fmt.Errorf("create index %s not acknowledged", i.VersionedIndexName()) + } + + i.checkOldIndexes(ctx) + return nil +} + +// do sends a request and returns the response. Status >= 300 is turned into +// an error unless the status appears in okStatus. The caller closes Body. +func (i *Indexer) do(ctx context.Context, method, path, contentType string, body io.Reader, okStatus ...int) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, i.base+path, body) + if err != nil { + return nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if i.user != "" || i.pass != "" { + req.SetBasicAuth(i.user, i.pass) + } + res, err := i.client.Do(req) + if err != nil { + return nil, err + } + if res.StatusCode >= 300 && !slices.Contains(okStatus, res.StatusCode) { + msg := readErrBody(res) + res.Body.Close() + return nil, fmt.Errorf("%s %s: %s", method, path, msg) + } + return res, nil +} + +// doJSON sends a request with a JSON body and, when out is non-nil, decodes +// the JSON response into it. +func (i *Indexer) doJSON(ctx context.Context, method, path string, body io.Reader, out any) error { + contentType := "" + if body != nil { + contentType = "application/json" + } + res, err := i.do(ctx, method, path, contentType, body) + if err != nil { + return err + } + defer drainAndClose(res) + if out == nil { + return nil + } + return json.NewDecoder(res.Body).Decode(out) +} + +// drainAndClose discards any unread response body before closing so the +// underlying TCP connection can be reused for keep-alive. +func drainAndClose(res *http.Response) { + _, _ = io.Copy(io.Discard, res.Body) + res.Body.Close() +} + +func writeJSONLine(buf *bytes.Buffer, v any) error { + enc, err := json.Marshal(v) + if err != nil { + return err + } + buf.Write(enc) + buf.WriteByte('\n') + return nil +} + +// readErrBody reads up to 4 KiB of an error response and drains the rest so +// the underlying connection can be reused (keep-alive needs Body fully read). +func readErrBody(res *http.Response) string { + const limit = 4 << 10 + b, _ := io.ReadAll(io.LimitReader(res.Body, limit)) + _, _ = io.Copy(io.Discard, res.Body) + return fmt.Sprintf("status %d: %s", res.StatusCode, bytes.TrimSpace(b)) +} + +func decodeSearchResponse(r io.Reader) (*SearchResponse, error) { + var raw struct { + Hits struct { + Total struct { + Value int64 `json:"value"` + } `json:"total"` + Hits []struct { + ID string `json:"_id"` + Score float64 `json:"_score"` + Source json.Value `json:"_source"` + Highlight map[string][]string `json:"highlight"` + } `json:"hits"` + } `json:"hits"` + Aggregations map[string]struct { + Buckets []struct { + Key any `json:"key"` + DocCount int64 `json:"doc_count"` + } `json:"buckets"` + } `json:"aggregations"` + } + if err := json.NewDecoder(r).Decode(&raw); err != nil { + return nil, err + } + + resp := &SearchResponse{ + Total: raw.Hits.Total.Value, + Hits: make([]SearchHit, 0, len(raw.Hits.Hits)), + } + for _, h := range raw.Hits.Hits { + resp.Hits = append(resp.Hits, SearchHit{ + ID: h.ID, + Score: h.Score, + Source: h.Source, + Highlight: h.Highlight, + }) + } + if len(raw.Aggregations) > 0 { + resp.Aggregations = make(map[string][]AggBucket, len(raw.Aggregations)) + for name, agg := range raw.Aggregations { + buckets := make([]AggBucket, len(agg.Buckets)) + for idx, b := range agg.Buckets { + buckets[idx] = AggBucket{Key: b.Key, DocCount: b.DocCount} + } + resp.Aggregations[name] = buckets + } + } + return resp, nil +} + +// urlPath joins path segments with `/` and percent-escapes each. +func urlPath(segments ...string) string { + var b bytes.Buffer + for idx, s := range segments { + if idx > 0 { + b.WriteByte('/') + } + b.WriteString(url.PathEscape(s)) + } + return b.String() } diff --git a/modules/indexer/internal/elasticsearch/indexer_test.go b/modules/indexer/internal/elasticsearch/indexer_test.go new file mode 100644 index 0000000000..774d6fbd6c --- /dev/null +++ b/modules/indexer/internal/elasticsearch/indexer_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package elasticsearch + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func newRealIndexer(t *testing.T) *Indexer { + t.Helper() + url := "http://elasticsearch:9200" + if os.Getenv("CI") == "" { + url = os.Getenv("TEST_ELASTICSEARCH_URL") + if url == "" { + t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI") + } + } + indexName := "gitea_test_" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "_") + ix := NewIndexer(url, indexName, 1, `{"mappings":{"properties":{"x":{"type":"keyword"}}}}`) + _, err := ix.Init(t.Context()) + require.NoError(t, err) + t.Cleanup(ix.Close) + return ix +} + +func TestPing(t *testing.T) { + ix := newRealIndexer(t) + require.NoError(t, ix.Ping(t.Context())) +} + +func TestDeleteSwallows404(t *testing.T) { + ix := newRealIndexer(t) + require.NoError(t, ix.Delete(t.Context(), "missing-id")) +} + +func TestBulkAcceptsDelete404(t *testing.T) { + ix := newRealIndexer(t) + require.NoError(t, ix.Bulk(t.Context(), []BulkOp{DeleteOp("missing-id")})) +} diff --git a/modules/indexer/internal/elasticsearch/query.go b/modules/indexer/internal/elasticsearch/query.go new file mode 100644 index 0000000000..8f8cf74303 --- /dev/null +++ b/modules/indexer/internal/elasticsearch/query.go @@ -0,0 +1,132 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package elasticsearch + +// MultiMatch types used by the call sites. See +// https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#multi-match-types +const ( + MultiMatchTypeBestFields = "best_fields" + MultiMatchTypePhrasePrefix = "phrase_prefix" +) + +// ToAnySlice converts []T to []any for variadic query args like TermsQuery. +func ToAnySlice[T any](s []T) []any { + out := make([]any, len(s)) + for idx, v := range s { + out[idx] = v + } + return out +} + +// Query is an Elasticsearch query DSL node. It marshals to the JSON +// object expected by the ES query API. +type Query interface { + querySource() map[string]any +} + +type rawQuery map[string]any + +func (q rawQuery) querySource() map[string]any { return q } + +// TermQuery matches documents whose `field` exactly equals `value`. +func TermQuery(field string, value any) Query { + return rawQuery{"term": map[string]any{field: value}} +} + +// TermsQuery matches documents whose `field` equals any of `values`. +func TermsQuery(field string, values ...any) Query { + return rawQuery{"terms": map[string]any{field: values}} +} + +// MatchQuery is a full-text match on a single field. +func MatchQuery(field string, value any) Query { + return rawQuery{"match": map[string]any{field: value}} +} + +// MatchPhraseQuery matches the exact phrase on `field`. +func MatchPhraseQuery(field, value string) Query { + return rawQuery{"match_phrase": map[string]any{field: value}} +} + +// MultiMatchQuery is the fluent builder for a multi_match query. +type MultiMatchQuery struct { + query any + fields []string + typ string + operator string +} + +// NewMultiMatchQuery creates a multi_match query over the given fields. +func NewMultiMatchQuery(query any, fields ...string) *MultiMatchQuery { + return &MultiMatchQuery{query: query, fields: fields} +} + +func (m *MultiMatchQuery) Type(t string) *MultiMatchQuery { m.typ = t; return m } +func (m *MultiMatchQuery) Operator(op string) *MultiMatchQuery { m.operator = op; return m } + +func (m *MultiMatchQuery) querySource() map[string]any { + body := map[string]any{"query": m.query} + if len(m.fields) > 0 { + body["fields"] = m.fields + } + if m.typ != "" { + body["type"] = m.typ + } + if m.operator != "" { + body["operator"] = m.operator + } + return map[string]any{"multi_match": body} +} + +// RangeQuery is the fluent builder for a range query. +type RangeQuery struct { + field string + body map[string]any +} + +func NewRangeQuery(field string) *RangeQuery { + return &RangeQuery{field: field, body: map[string]any{}} +} + +func (r *RangeQuery) Gte(v any) *RangeQuery { r.body["gte"] = v; return r } +func (r *RangeQuery) Lte(v any) *RangeQuery { r.body["lte"] = v; return r } + +func (r *RangeQuery) querySource() map[string]any { + return map[string]any{"range": map[string]any{r.field: r.body}} +} + +// BoolQuery is the fluent builder for a bool query. +type BoolQuery struct { + must []Query + should []Query + mustNot []Query +} + +func NewBoolQuery() *BoolQuery { return &BoolQuery{} } + +func (b *BoolQuery) Must(q ...Query) *BoolQuery { b.must = append(b.must, q...); return b } +func (b *BoolQuery) Should(q ...Query) *BoolQuery { b.should = append(b.should, q...); return b } +func (b *BoolQuery) MustNot(q ...Query) *BoolQuery { b.mustNot = append(b.mustNot, q...); return b } + +func (b *BoolQuery) querySource() map[string]any { + body := map[string]any{} + if len(b.must) > 0 { + body["must"] = querySlice(b.must) + } + if len(b.should) > 0 { + body["should"] = querySlice(b.should) + } + if len(b.mustNot) > 0 { + body["must_not"] = querySlice(b.mustNot) + } + return map[string]any{"bool": body} +} + +func querySlice(queries []Query) []map[string]any { + out := make([]map[string]any, len(queries)) + for idx, q := range queries { + out[idx] = q.querySource() + } + return out +} diff --git a/modules/indexer/internal/elasticsearch/types.go b/modules/indexer/internal/elasticsearch/types.go new file mode 100644 index 0000000000..106f2faa8e --- /dev/null +++ b/modules/indexer/internal/elasticsearch/types.go @@ -0,0 +1,76 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package elasticsearch + +import "code.gitea.io/gitea/modules/json" + +const ( + bulkActionIndex = "index" + bulkActionDelete = "delete" +) + +// BulkOp is a single write inside a Bulk call. Construct with IndexOp or DeleteOp. +type BulkOp struct { + action string + id string + doc any +} + +// IndexOp builds a bulk index operation. +func IndexOp(id string, doc any) BulkOp { + return BulkOp{action: bulkActionIndex, id: id, doc: doc} +} + +// DeleteOp builds a bulk delete operation. +func DeleteOp(id string) BulkOp { + return BulkOp{action: bulkActionDelete, id: id} +} + +// SortField is one entry of the search sort array. +type SortField struct { + Field string + Desc bool +} + +func (s SortField) source() map[string]any { + order := "asc" + if s.Desc { + order = "desc" + } + return map[string]any{s.Field: map[string]any{"order": order}} +} + +// SearchRequest captures everything Gitea sends to the _search endpoint. +// Aggregations and Highlight are raw ES JSON bodies — callers write them as +// map[string]any since each has exactly one call site with a fixed shape. +type SearchRequest struct { + Query Query + Sort []SortField + From int + Size int + TrackTotal bool + Aggregations map[string]any + Highlight map[string]any +} + +// SearchHit is a single result row. +type SearchHit struct { + ID string + Score float64 + Source json.Value + Highlight map[string][]string +} + +// AggBucket is a terms-aggregation bucket. +type AggBucket struct { + Key any + DocCount int64 +} + +// SearchResponse is Gitea's decoded view of the search reply. +type SearchResponse struct { + Total int64 + Hits []SearchHit + Aggregations map[string][]AggBucket +} diff --git a/modules/indexer/internal/elasticsearch/util.go b/modules/indexer/internal/elasticsearch/util.go index 9e034bd553..2e96d4220a 100644 --- a/modules/indexer/internal/elasticsearch/util.go +++ b/modules/indexer/internal/elasticsearch/util.go @@ -6,14 +6,11 @@ package elasticsearch import ( "context" "fmt" - "time" "code.gitea.io/gitea/modules/log" - - "github.com/olivere/elastic/v7" ) -// VersionedIndexName returns the full index name with version +// VersionedIndexName returns the full index name with version suffix. func (i *Indexer) VersionedIndexName() string { return versionedIndexName(i.indexName, i.version) } @@ -26,41 +23,10 @@ func versionedIndexName(indexName string, version int) string { return fmt.Sprintf("%s.v%d", indexName, version) } -func (i *Indexer) createIndex(ctx context.Context) error { - createIndex, err := i.Client.CreateIndex(i.VersionedIndexName()).BodyString(i.mapping).Do(ctx) - if err != nil { - return err - } - if !createIndex.Acknowledged { - return fmt.Errorf("create index %s with %s failed", i.VersionedIndexName(), i.mapping) - } - - i.checkOldIndexes(ctx) - - return nil -} - -func (i *Indexer) initClient() (*elastic.Client, error) { - opts := []elastic.ClientOptionFunc{ - elastic.SetURL(i.url), - elastic.SetSniff(false), - elastic.SetHealthcheckInterval(10 * time.Second), - elastic.SetGzip(false), - } - - logger := log.GetLogger(log.DEFAULT) - - opts = append(opts, elastic.SetTraceLog(&log.PrintfLogger{Logf: logger.Trace})) - opts = append(opts, elastic.SetInfoLog(&log.PrintfLogger{Logf: logger.Info})) - opts = append(opts, elastic.SetErrorLog(&log.PrintfLogger{Logf: logger.Error})) - - return elastic.NewClient(opts...) -} - func (i *Indexer) checkOldIndexes(ctx context.Context) { - for v := 0; v < i.version; v++ { + for v := range i.version { indexName := versionedIndexName(i.indexName, v) - exists, err := i.Client.IndexExists(indexName).Do(ctx) + exists, err := i.indexExists(ctx, indexName) if err == nil && exists { log.Warn("Found older elasticsearch index named %q, Gitea will keep the old NOT DELETED. You can delete the old version after the upgrade succeed.", indexName) } diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go index 5c8ce570d1..8286e72f49 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch.go @@ -11,27 +11,18 @@ import ( "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/indexer" indexer_internal "code.gitea.io/gitea/modules/indexer/internal" - inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch" + es "code.gitea.io/gitea/modules/indexer/internal/elasticsearch" "code.gitea.io/gitea/modules/indexer/issues/internal" "code.gitea.io/gitea/modules/util" - - "github.com/olivere/elastic/v7" ) -const ( - issueIndexerLatestVersion = 3 - // multi-match-types, currently only 2 types are used - // Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types - esMultiMatchTypeBestFields = "best_fields" - esMultiMatchTypePhrasePrefix = "phrase_prefix" -) +const issueIndexerLatestVersion = 3 var _ internal.Indexer = &Indexer{} // Indexer implements Indexer interface type Indexer struct { - inner *inner_elasticsearch.Indexer - indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much + *es.Indexer } func (b *Indexer) SupportedSearchModes() []indexer.SearchMode { @@ -41,12 +32,7 @@ func (b *Indexer) SupportedSearchModes() []indexer.SearchMode { // NewIndexer creates a new elasticsearch indexer func NewIndexer(url, indexerName string) *Indexer { - inner := inner_elasticsearch.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping) - indexer := &Indexer{ - inner: inner, - Indexer: inner, - } - return indexer + return &Indexer{Indexer: es.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping)} } const ( @@ -93,29 +79,14 @@ func (b *Indexer) Index(ctx context.Context, issues ...*internal.IndexerData) er return nil } else if len(issues) == 1 { issue := issues[0] - _, err := b.inner.Client.Index(). - Index(b.inner.VersionedIndexName()). - Id(strconv.FormatInt(issue.ID, 10)). - BodyJson(issue). - Do(ctx) - return err + return b.Indexer.Index(ctx, strconv.FormatInt(issue.ID, 10), issue) } - reqs := make([]elastic.BulkableRequest, 0) + ops := make([]es.BulkOp, 0, len(issues)) for _, issue := range issues { - reqs = append(reqs, - elastic.NewBulkIndexRequest(). - Index(b.inner.VersionedIndexName()). - Id(strconv.FormatInt(issue.ID, 10)). - Doc(issue), - ) + ops = append(ops, es.IndexOp(strconv.FormatInt(issue.ID, 10), issue)) } - - _, err := b.inner.Client.Bulk(). - Index(b.inner.VersionedIndexName()). - Add(reqs...). - Do(graceful.GetManager().HammerContext()) - return err + return b.Bulk(graceful.GetManager().HammerContext(), ops) } // Delete deletes indexes by ids @@ -123,129 +94,116 @@ func (b *Indexer) Delete(ctx context.Context, ids ...int64) error { if len(ids) == 0 { return nil } else if len(ids) == 1 { - _, err := b.inner.Client.Delete(). - Index(b.inner.VersionedIndexName()). - Id(strconv.FormatInt(ids[0], 10)). - Do(ctx) - return err + return b.Indexer.Delete(ctx, strconv.FormatInt(ids[0], 10)) } - reqs := make([]elastic.BulkableRequest, 0) + ops := make([]es.BulkOp, 0, len(ids)) for _, id := range ids { - reqs = append(reqs, - elastic.NewBulkDeleteRequest(). - Index(b.inner.VersionedIndexName()). - Id(strconv.FormatInt(id, 10)), - ) + ops = append(ops, es.DeleteOp(strconv.FormatInt(id, 10))) } - - _, err := b.inner.Client.Bulk(). - Index(b.inner.VersionedIndexName()). - Add(reqs...). - Do(graceful.GetManager().HammerContext()) - return err + return b.Bulk(graceful.GetManager().HammerContext(), ops) } // Search searches for issues by given conditions. // Returns the matching issue IDs func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { - query := elastic.NewBoolQuery() + query := es.NewBoolQuery() if options.Keyword != "" { searchMode := util.IfZero(options.SearchMode, b.SupportedSearchModes()[0].ModeValue) + mm := es.NewMultiMatchQuery(options.Keyword, "title", "content", "comments") if searchMode == indexer.SearchModeExact { - query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(esMultiMatchTypePhrasePrefix)) - } else /* words */ { - query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(esMultiMatchTypeBestFields).Operator("and")) + mm = mm.Type(es.MultiMatchTypePhrasePrefix) + } else { + mm = mm.Type(es.MultiMatchTypeBestFields).Operator("and") } + query.Must(mm) } if len(options.RepoIDs) > 0 { - q := elastic.NewBoolQuery() - q.Should(elastic.NewTermsQuery("repo_id", toAnySlice(options.RepoIDs)...)) + q := es.NewBoolQuery() + q.Should(es.TermsQuery("repo_id", es.ToAnySlice(options.RepoIDs)...)) if options.AllPublic { - q.Should(elastic.NewTermQuery("is_public", true)) + q.Should(es.TermQuery("is_public", true)) } query.Must(q) } if options.IsPull.Has() { - query.Must(elastic.NewTermQuery("is_pull", options.IsPull.Value())) + query.Must(es.TermQuery("is_pull", options.IsPull.Value())) } if options.IsClosed.Has() { - query.Must(elastic.NewTermQuery("is_closed", options.IsClosed.Value())) + query.Must(es.TermQuery("is_closed", options.IsClosed.Value())) } if options.IsArchived.Has() { - query.Must(elastic.NewTermQuery("is_archived", options.IsArchived.Value())) + query.Must(es.TermQuery("is_archived", options.IsArchived.Value())) } if options.NoLabelOnly { - query.Must(elastic.NewTermQuery("no_label", true)) + query.Must(es.TermQuery("no_label", true)) } else { if len(options.IncludedLabelIDs) > 0 { - q := elastic.NewBoolQuery() + q := es.NewBoolQuery() for _, labelID := range options.IncludedLabelIDs { - q.Must(elastic.NewTermQuery("label_ids", labelID)) + q.Must(es.TermQuery("label_ids", labelID)) } query.Must(q) } else if len(options.IncludedAnyLabelIDs) > 0 { - query.Must(elastic.NewTermsQuery("label_ids", toAnySlice(options.IncludedAnyLabelIDs)...)) + query.Must(es.TermsQuery("label_ids", es.ToAnySlice(options.IncludedAnyLabelIDs)...)) } if len(options.ExcludedLabelIDs) > 0 { - q := elastic.NewBoolQuery() + q := es.NewBoolQuery() for _, labelID := range options.ExcludedLabelIDs { - q.MustNot(elastic.NewTermQuery("label_ids", labelID)) + q.MustNot(es.TermQuery("label_ids", labelID)) } query.Must(q) } } if len(options.MilestoneIDs) > 0 { - query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...)) + query.Must(es.TermsQuery("milestone_id", es.ToAnySlice(options.MilestoneIDs)...)) } if options.NoProjectOnly { - query.Must(elastic.NewTermQuery("no_project", true)) + query.Must(es.TermQuery("no_project", true)) } else if len(options.ProjectIDs) > 0 { // FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR" - query.Must(elastic.NewTermsQuery("project_ids", toAnySlice(options.ProjectIDs)...)) + query.Must(es.TermsQuery("project_ids", es.ToAnySlice(options.ProjectIDs)...)) } if options.PosterID != "" { // "(none)" becomes 0, it means no poster posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64) - query.Must(elastic.NewTermQuery("poster_id", posterIDInt64)) + query.Must(es.TermQuery("poster_id", posterIDInt64)) } if options.AssigneeID != "" { if options.AssigneeID == "(any)" { - q := elastic.NewRangeQuery("assignee_id") - q.Gte(1) - query.Must(q) + query.Must(es.NewRangeQuery("assignee_id").Gte(1)) } else { // "(none)" becomes 0, it means no assignee assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64) - query.Must(elastic.NewTermQuery("assignee_id", assigneeIDInt64)) + query.Must(es.TermQuery("assignee_id", assigneeIDInt64)) } } if options.MentionID.Has() { - query.Must(elastic.NewTermQuery("mention_ids", options.MentionID.Value())) + query.Must(es.TermQuery("mention_ids", options.MentionID.Value())) } if options.ReviewedID.Has() { - query.Must(elastic.NewTermQuery("reviewed_ids", options.ReviewedID.Value())) + query.Must(es.TermQuery("reviewed_ids", options.ReviewedID.Value())) } if options.ReviewRequestedID.Has() { - query.Must(elastic.NewTermQuery("review_requested_ids", options.ReviewRequestedID.Value())) + query.Must(es.TermQuery("review_requested_ids", options.ReviewRequestedID.Value())) } if options.SubscriberID.Has() { - query.Must(elastic.NewTermQuery("subscriber_ids", options.SubscriberID.Value())) + query.Must(es.TermQuery("subscriber_ids", options.SubscriberID.Value())) } if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() { - q := elastic.NewRangeQuery("updated_unix") + q := es.NewRangeQuery("updated_unix") if options.UpdatedAfterUnix.Has() { q.Gte(options.UpdatedAfterUnix.Value()) } @@ -258,9 +216,9 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( if options.SortBy == "" { options.SortBy = internal.SortByCreatedAsc } - sortBy := []elastic.Sorter{ + sortBy := []es.SortField{ parseSortBy(options.SortBy), - elastic.NewFieldSort("id").Desc(), + {Field: "id", Desc: true}, } // See https://stackoverflow.com/questions/35206409/elasticsearch-2-1-result-window-is-too-large-index-max-result-window/35221900 @@ -268,43 +226,30 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( const maxPageSize = 10000 skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxPageSize) - searchResult, err := b.inner.Client.Search(). - Index(b.inner.VersionedIndexName()). - Query(query). - SortBy(sortBy...). - From(skip).Size(limit). - Do(ctx) + resp, err := b.Indexer.Search(ctx, es.SearchRequest{ + Query: query, + Sort: sortBy, + From: skip, + Size: limit, + TrackTotal: true, + }) if err != nil { return nil, err } - hits := make([]internal.Match, 0, limit) - for _, hit := range searchResult.Hits.Hits { - id, _ := strconv.ParseInt(hit.Id, 10, 64) - hits = append(hits, internal.Match{ - ID: id, - }) + hits := make([]internal.Match, 0, len(resp.Hits)) + for _, hit := range resp.Hits { + id, _ := strconv.ParseInt(hit.ID, 10, 64) + hits = append(hits, internal.Match{ID: id}) } return &internal.SearchResult{ - Total: searchResult.TotalHits(), + Total: resp.Total, Hits: hits, }, nil } -func toAnySlice[T any](s []T) []any { - ret := make([]any, 0, len(s)) - for _, item := range s { - ret = append(ret, item) - } - return ret -} - -func parseSortBy(sortBy internal.SortBy) elastic.Sorter { - field := strings.TrimPrefix(string(sortBy), "-") - ret := elastic.NewFieldSort(field) - if strings.HasPrefix(string(sortBy), "-") { - ret.Desc() - } - return ret +func parseSortBy(sortBy internal.SortBy) es.SortField { + field, desc := strings.CutPrefix(string(sortBy), "-") + return es.SortField{Field: field, Desc: desc} } diff --git a/modules/indexer/issues/elasticsearch/elasticsearch_test.go b/modules/indexer/issues/elasticsearch/elasticsearch_test.go index cb9ed3889d..9b0eaebef6 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch_test.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch_test.go @@ -6,6 +6,7 @@ package elasticsearch import ( "fmt" "net/http" + "net/url" "os" "testing" "time" @@ -17,19 +18,36 @@ import ( func TestElasticsearchIndexer(t *testing.T) { // The elasticsearch instance started by pull-db-tests.yml > test-unit > services > elasticsearch - url := "http://elastic:changeme@elasticsearch:9200" + rawURL := "http://elastic:changeme@elasticsearch:9200" if os.Getenv("CI") == "" { // Make it possible to run tests against a local elasticsearch instance - url = os.Getenv("TEST_ELASTICSEARCH_URL") - if url == "" { + rawURL = os.Getenv("TEST_ELASTICSEARCH_URL") + if rawURL == "" { t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI") return } } + // Go's net/http does not auto-attach URL userinfo as Basic Auth, so extract + // it and set the header explicitly; otherwise auth-enforced clusters answer + // 401 and the probe never reports ready. + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + user := parsed.User + parsed.User = nil + probeURL := parsed.String() + require.Eventually(t, func() bool { - resp, err := http.Get(url) + req, err := http.NewRequest(http.MethodGet, probeURL, nil) + if err != nil { + return false + } + if user != nil { + pass, _ := user.Password() + req.SetBasicAuth(user.Username(), pass) + } + resp, err := http.DefaultClient.Do(req) if err != nil { return false } @@ -37,7 +55,7 @@ func TestElasticsearchIndexer(t *testing.T) { return resp.StatusCode == http.StatusOK }, time.Minute, time.Second, "Expected elasticsearch to be up") - indexer := NewIndexer(url, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix())) + indexer := NewIndexer(rawURL, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix())) defer indexer.Close() tests.TestIndexer(t, indexer) diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index 18f49d6d4d..20e64a5955 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -116,6 +116,16 @@ var cases = []*testIndexerCase{ assert.Equal(t, len(data), int(result.Total)) }, }, + { + // Exercises the single-doc Index/Delete fast path in backends that have one (e.g. Elasticsearch). + Name: "single-doc index", + ExtraData: []*internal.IndexerData{ + {ID: 999, Title: "solo-issue-marker"}, + }, + SearchOptions: &internal.SearchOptions{Keyword: "solo-issue-marker"}, + ExpectedIDs: []int64{999}, + ExpectedTotal: 1, + }, { Name: "Keyword", ExtraData: []*internal.IndexerData{ From 6b8dd90dc7101e8b37c73c8bf4e29a4451fab6c9 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Sat, 2 May 2026 01:02:44 +0000 Subject: [PATCH 5/5] [skip ci] Updated translations via Crowdin --- options/locale/locale_fr-FR.json | 3 +-- options/locale/locale_ga-IE.json | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/options/locale/locale_fr-FR.json b/options/locale/locale_fr-FR.json index e86e07f818..870e1be56f 100644 --- a/options/locale/locale_fr-FR.json +++ b/options/locale/locale_fr-FR.json @@ -1385,6 +1385,7 @@ "repo.projects.column.delete": "Supprimer la colonne", "repo.projects.column.deletion_desc": "La suppression d’une colonne déplace tous ses tickets dans la colonne par défaut. Continuer ?", "repo.projects.column.color": "Couleur", + "repo.projects.column": "Colonne", "repo.projects.open": "Ouvrir", "repo.projects.close": "Fermer", "repo.projects.column.assigned_to": "Assigné à", @@ -3314,7 +3315,6 @@ "admin.config.cache_config": "Configuration du cache", "admin.config.cache_adapter": "Adaptateur du Cache", "admin.config.cache_interval": "Intervales du Cache", - "admin.config.cache_conn": "Liaison du Cache", "admin.config.cache_item_ttl": "Durée de vie des éléments dans le cache", "admin.config.cache_test": "Test du cache", "admin.config.cache_test_failed": "Impossible d’interroger le cache : %v.", @@ -3329,7 +3329,6 @@ "admin.config.instance_web_banner.message_placeholder": "Message de bannière (supporte markdown)", "admin.config.session_config": "Configuration de session", "admin.config.session_provider": "Fournisseur de session", - "admin.config.provider_config": "Configuration du fournisseur", "admin.config.cookie_name": "Nom du cookie", "admin.config.gc_interval_time": "Intervals GC", "admin.config.session_life_time": "Durée des sessions", diff --git a/options/locale/locale_ga-IE.json b/options/locale/locale_ga-IE.json index cee97810b6..3584e0b597 100644 --- a/options/locale/locale_ga-IE.json +++ b/options/locale/locale_ga-IE.json @@ -122,6 +122,7 @@ "unpin": "Díphoráil", "artifacts": "Déantáin", "expired": "Imithe in éag", + "artifact_expires_at": "Éagaíonn ag %s", "confirm_delete_artifact": "An bhfuil tú cinnte gur mian leat an déantán '%s' a scriosadh?", "archived": "Cartlann", "concept_system_global": "Domhanda", @@ -223,6 +224,7 @@ "error.occurred": "Tharla earráid", "error.report_message": "Má chreideann tú gur fabht Gitea é seo, déan cuardach le haghaidh ceisteanna ar GitHub nó oscail eagrán nua más gá.", "error.not_found": "Ní raibh an sprioc in ann a fháil.", + "error.permission_denied": "Cead diúltaithe.", "error.network_error": "Earráid líonra", "startpage.app_desc": "Seirbhís Git gan phian, féin-óstáil", "startpage.install": "Éasca a shuiteáil", @@ -637,14 +639,8 @@ "user.block.unblock.failure": "Theip ar an úsáideoir a díbhlocáil: %s", "user.block.blocked": "Chuir tú bac ar an úsáideoir seo.", "user.block.title": "Cuir bac ar úsáideoir", - "user.block.info": "Cuireann blocáil úsáideora cosc orthu idirghníomhú le stórais, mar shampla iarratais tarraingthe nó saincheisteanna a oscailt nó trácht a dhéanamh orthu. Níos mó a fhoghlaim faoi bhac úsáideora.", - "user.block.info_1": "Cuireann blocáil úsáideora cosc ar na gníomhartha seo a leanas ar do chuntas agus ar do stór:", - "user.block.info_2": "ag leanúint do chuntas", - "user.block.info_3": "seol fógraí chugat ag @mentioning d'ainm úsáideora", - "user.block.info_4": "ag tabhairt cuireadh duit mar chomhoibritheoir chuig a stórtha", - "user.block.info_5": "ag réaladh, ag forcáil nó ag féachaint ar stórais", - "user.block.info_6": "ceisteanna nó iarrataí tarraingthe a oscailt agus trácht", - "user.block.info_7": "ag freagairt do do thuairimí i saincheisteanna nó i n-iarratais tarraingthe", + "user.block.info": "Má chuireann bac ar úsáideoir, cuirtear cosc ​​​​orthu idirghníomhú le stórtha, amhail iarratais tarraingthe nó saincheisteanna a oscailt nó trácht a dhéanamh orthu.", + "user.block.info.docs": "Foghlaim tuilleadh faoi úsáideoir a bhlocáil.", "user.block.user_to_block": "Úsáideoir chun blocáil", "user.block.note": "Nóta", "user.block.note.title": "Nóta roghnach:", @@ -1065,8 +1061,8 @@ "repo.transfer.accept_desc": "Aistriú chuig “%s”", "repo.transfer.reject": "Diúltaigh aistriú", "repo.transfer.reject_desc": "Cealaigh aistriú chuig \"%s\"", - "repo.transfer.no_permission_to_accept": "Níl cead agat glacadh leis an aistriú seo.", - "repo.transfer.no_permission_to_reject": "Níl cead agat an aistriú seo a dhiúltú.", + "repo.transfer.is_transferring": "Ag aistriú…", + "repo.transfer.is_transferring_prompt": "Tá an stórlann á aistriú go %s", "repo.desc.private": "Príobháideach", "repo.desc.public": "Poiblí", "repo.desc.public_access": "Rochtain Phoiblí", @@ -1389,6 +1385,7 @@ "repo.projects.column.delete": "Scrios Colún", "repo.projects.column.deletion_desc": "Ag scriosadh colún tionscadail aistríonn gach saincheist ghaolmhar chuig an gcolún. Lean ar aghaidh?", "repo.projects.column.color": "Dath", + "repo.projects.column": "Colún", "repo.projects.open": "Oscailte", "repo.projects.close": "Dún", "repo.projects.column.assigned_to": "Sannta do", @@ -1406,11 +1403,12 @@ "repo.issues.new": "Eagrán Nua", "repo.issues.new.title_empty": "Ní féidir leis an teideal a bheith folamh", "repo.issues.new.labels": "Lipéid", - "repo.issues.new.no_label": "Gan Lipéad", + "repo.issues.new.no_labels": "Gan lipéid", "repo.issues.new.clear_labels": "Lipéid shoiléir", "repo.issues.new.projects": "Tionscadail", "repo.issues.new.clear_projects": "Tionscadail soiléire", - "repo.issues.new.no_projects": "Gan aon tionscadal", + "repo.issues.new.no_projects": "Gan aon tionscadail", + "repo.issues.new.no_column": "Gan aon cholún", "repo.issues.new.open_projects": "Tionscadail Oscailte", "repo.issues.new.closed_projects": "Tionscadail Dúnta", "repo.issues.new.no_items": "Gan aon earraí", @@ -1787,6 +1785,7 @@ "repo.pulls.review_only_possible_for_full_diff": "Ní féidir athbhreithniú a dhéanamh ach amháin nuair a bhreathnaítear ar an difríocht iomlán", "repo.pulls.filter_changes_by_commit": "Scagaigh de réir tiomantas", "repo.pulls.nothing_to_compare": "Tá na brainsí seo cothrom. Ní gá iarratas tarraingthe a chruthú.", + "repo.pulls.no_common_history": "Níl bonn cumaisc coitianta ag na brainsí seo. Roghnaigh bonn difriúil nó cuir brainse i gcomparáid.", "repo.pulls.nothing_to_compare_have_tag": "Tá na brainsí/clibeanna roghnaithe comhionann.", "repo.pulls.nothing_to_compare_and_allow_empty_pr": "Tá na brainsí seo cothrom. Beidh an PR seo folamh.", "repo.pulls.has_pull_request": "Tá iarratas tarraingthe idir na brainsí seo ann cheana: %[2]s#%[3]d", @@ -1853,6 +1852,7 @@ "repo.pulls.merge_manually": "Cumaisc de láimh", "repo.pulls.merge_commit_id": "ID an tiomantis cumaisc", "repo.pulls.require_signed_wont_sign": "Éilíonn an bhrainse tiomáintí shínithe, ach ní shínífear an cumasc seo", + "repo.pulls.require_signed_head_commits_unverified": "Teastaíonn gealltanais sínithe ón mbrainse ach ní dheimhnítear gealltanas amháin nó níos mó ar an iarratas tarraingte seo", "repo.pulls.invalid_merge_option": "Ní féidir leat an rogha cumaisc seo a úsáid don iarratas tarraingthe seo.", "repo.pulls.merge_conflict": "Theip ar an gCumasc: Bhí coimhlint ann agus an cumasc á dhéanamh. Leid: Bain triail as straitéis dhifriúil.", "repo.pulls.merge_conflict_summary": "Teachtaireacht Earráide", @@ -3315,7 +3315,6 @@ "admin.config.cache_config": "Cumraíocht taisce", "admin.config.cache_adapter": "Cuibheoir taisce", "admin.config.cache_interval": "Eatramh Taisce", - "admin.config.cache_conn": "Ceangal Taisce", "admin.config.cache_item_ttl": "Mír Taisce TTL", "admin.config.cache_test": "Taisce Tástáil", "admin.config.cache_test_failed": "Theip ar an taisce a thaiscéaladh: %v.", @@ -3330,7 +3329,6 @@ "admin.config.instance_web_banner.message_placeholder": "Teachtaireacht meirge (tacaíonn sé le Markdown)", "admin.config.session_config": "Cumraíocht Seisiúin", "admin.config.session_provider": "Soláthraí Seisiúin", - "admin.config.provider_config": "Cumraíocht Soláthraí", "admin.config.cookie_name": "Ainm Fianán", "admin.config.gc_interval_time": "Am Eatramh GC", "admin.config.session_life_time": "Am Saoil na Seisiúin", @@ -3774,9 +3772,11 @@ "actions.runs.delete.description": "An bhfuil tú cinnte gur mian leat an rith sreabha oibre seo a scriosadh go buan? Ní féidir an gníomh seo a chealú.", "actions.runs.not_done": "Níl an rith sreabha oibre seo críochnaithe.", "actions.runs.view_workflow_file": "Féach ar chomhad sreabha oibre", - "actions.runs.workflow_graph": "Graf Sreabhadh Oibre", "actions.runs.summary": "Achoimre", "actions.runs.all_jobs": "Gach post", + "actions.runs.attempt": "Iarracht", + "actions.runs.latest": "Is déanaí", + "actions.runs.latest_attempt": "An iarracht is déanaí", "actions.runs.triggered_via": "Spreagtha trí %s", "actions.runs.total_duration": "Fad iomlán:", "actions.workflow.disable": "Díchumasaigh sreabhadh oibre",