From 416c36f3034e228a27258b5a8a15eec4e5e426ba Mon Sep 17 00:00:00 2001 From: Rowan Bohde Date: Tue, 16 Jul 2024 13:33:16 -0500 Subject: [PATCH 1/5] allow synchronizing user status from OAuth2 login providers (#31572) This leverages the existing `sync_external_users` cron job to synchronize the `IsActive` flag on users who use an OAuth2 provider set to synchronize. This synchronization is done by checking for expired access tokens, and using the stored refresh token to request a new access token. If the response back from the OAuth2 provider is the `invalid_grant` error code, the user is marked as inactive. However, the user is able to reactivate their account by logging in the web browser through their OAuth2 flow. Also changed to support this is that a linked `ExternalLoginUser` is always created upon a login or signup via OAuth2. ### Notes on updating permissions Ideally, we would also refresh permissions from the configured OAuth provider (e.g., admin, restricted and group mappings) to match the implementation of LDAP. However, the OAuth library used for this `goth`, doesn't seem to support issuing a session via refresh tokens. The interface provides a [`RefreshToken` method](https://github.com/markbates/goth/blob/master/provider.go#L20), but the returned `oauth.Token` doesn't implement the `goth.Session` we would need to call `FetchUser`. Due to specific implementations, we would need to build a compatibility function for every provider, since they cast to concrete types (e.g. [Azure](https://github.com/markbates/goth/blob/master/providers/azureadv2/azureadv2.go#L132)) --------- Co-authored-by: Kyle D --- models/auth/source.go | 2 +- models/user/external_login_user.go | 41 ++++++- routers/web/auth/auth.go | 6 +- routers/web/auth/oauth.go | 65 +++++----- services/auth/source/oauth2/main_test.go | 14 +++ services/auth/source/oauth2/providers_test.go | 62 ++++++++++ services/auth/source/oauth2/source.go | 2 +- services/auth/source/oauth2/source_sync.go | 114 ++++++++++++++++++ .../auth/source/oauth2/source_sync_test.go | 100 +++++++++++++++ services/externalaccount/user.go | 6 +- templates/admin/auth/edit.tmpl | 2 +- templates/admin/auth/new.tmpl | 2 +- tests/integration/auth_ldap_test.go | 3 +- 13 files changed, 370 insertions(+), 49 deletions(-) create mode 100644 services/auth/source/oauth2/main_test.go create mode 100644 services/auth/source/oauth2/providers_test.go create mode 100644 services/auth/source/oauth2/source_sync.go create mode 100644 services/auth/source/oauth2/source_sync_test.go diff --git a/models/auth/source.go b/models/auth/source.go index f360ca9801..a3a250cd91 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -210,7 +210,7 @@ func CreateSource(ctx context.Context, source *Source) error { return ErrSourceAlreadyExist{source.Name} } // Synchronization is only available with LDAP for now - if !source.IsLDAP() { + if !source.IsLDAP() && !source.IsOAuth2() { source.IsSyncEnabled = false } diff --git a/models/user/external_login_user.go b/models/user/external_login_user.go index 965b7a5ed1..0e764efb9f 100644 --- a/models/user/external_login_user.go +++ b/models/user/external_login_user.go @@ -160,12 +160,34 @@ func UpdateExternalUserByExternalID(ctx context.Context, external *ExternalLogin return err } +// EnsureLinkExternalToUser link the external user to the user +func EnsureLinkExternalToUser(ctx context.Context, external *ExternalLoginUser) error { + has, err := db.Exist[ExternalLoginUser](ctx, builder.Eq{ + "external_id": external.ExternalID, + "login_source_id": external.LoginSourceID, + }) + if err != nil { + return err + } + + if has { + _, err = db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).AllCols().Update(external) + return err + } + + _, err = db.GetEngine(ctx).Insert(external) + return err +} + // FindExternalUserOptions represents an options to find external users type FindExternalUserOptions struct { db.ListOptions - Provider string - UserID int64 - OrderBy string + Provider string + UserID int64 + LoginSourceID int64 + HasRefreshToken bool + Expired bool + OrderBy string } func (opts FindExternalUserOptions) ToConds() builder.Cond { @@ -176,9 +198,22 @@ func (opts FindExternalUserOptions) ToConds() builder.Cond { if opts.UserID > 0 { cond = cond.And(builder.Eq{"user_id": opts.UserID}) } + if opts.Expired { + cond = cond.And(builder.Lt{"expires_at": time.Now()}) + } + if opts.HasRefreshToken { + cond = cond.And(builder.Neq{"refresh_token": ""}) + } + if opts.LoginSourceID != 0 { + cond = cond.And(builder.Eq{"login_source_id": opts.LoginSourceID}) + } return cond } func (opts FindExternalUserOptions) ToOrders() string { return opts.OrderBy } + +func IterateExternalLogin(ctx context.Context, opts FindExternalUserOptions, f func(ctx context.Context, u *ExternalLoginUser) error) error { + return db.Iterate(ctx, opts.ToConds(), f) +} diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 842020791f..15a80bc104 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -622,10 +622,8 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth. // update external user information if gothUser != nil { - if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil { - if !errors.Is(err, util.ErrNotExist) { - log.Error("UpdateExternalUser failed: %v", err) - } + if err := externalaccount.EnsureLinkExternalToUser(ctx, u, *gothUser); err != nil { + log.Error("EnsureLinkExternalToUser failed: %v", err) } } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 50f0dff2b6..1205c2c578 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -27,7 +27,6 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" auth_service "code.gitea.io/gitea/services/auth" @@ -1148,9 +1147,39 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model groups := getClaimedGroups(oauth2Source, &gothUser) + opts := &user_service.UpdateOptions{} + + // Reactivate user if they are deactivated + if !u.IsActive { + opts.IsActive = optional.Some(true) + } + + // Update GroupClaims + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + + if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil { + ctx.ServerError("EnsureLinkExternalToUser", err) + return + } + // If this user is enrolled in 2FA and this source doesn't override it, // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. if !needs2FA { + // Register last login + opts.SetLastLogin = true + + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err := updateSession(ctx, nil, map[string]any{ "uid": u.ID, "uname": u.Name, @@ -1162,29 +1191,6 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model // Clear whatever CSRF cookie has right now, force to generate a new one ctx.Csrf.DeleteCookie(ctx) - opts := &user_service.UpdateOptions{ - SetLastLogin: true, - } - opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) - if err := user_service.UpdateUser(ctx, u, opts); err != nil { - ctx.ServerError("UpdateUser", err) - return - } - - if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { - ctx.ServerError("SyncGroupsToTeams", err) - return - } - } - - // update external user information - if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { - if !errors.Is(err, util.ErrNotExist) { - log.Error("UpdateExternalUser failed: %v", err) - } - } - if err := resetLocale(ctx, u); err != nil { ctx.ServerError("resetLocale", err) return @@ -1200,22 +1206,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model return } - opts := &user_service.UpdateOptions{} - opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) - if opts.IsAdmin.Has() || opts.IsRestricted.Has() { + if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() { if err := user_service.UpdateUser(ctx, u, opts); err != nil { ctx.ServerError("UpdateUser", err) return } } - if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { - ctx.ServerError("SyncGroupsToTeams", err) - return - } - } - if err := updateSession(ctx, nil, map[string]any{ // User needs to use 2FA, save data and redirect to 2FA page. "twofaUid": u.ID, diff --git a/services/auth/source/oauth2/main_test.go b/services/auth/source/oauth2/main_test.go new file mode 100644 index 0000000000..57c74fd3e7 --- /dev/null +++ b/services/auth/source/oauth2/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{}) +} diff --git a/services/auth/source/oauth2/providers_test.go b/services/auth/source/oauth2/providers_test.go new file mode 100644 index 0000000000..353816c71e --- /dev/null +++ b/services/auth/source/oauth2/providers_test.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +type fakeProvider struct{} + +func (p *fakeProvider) Name() string { + return "fake" +} + +func (p *fakeProvider) SetName(name string) {} + +func (p *fakeProvider) BeginAuth(state string) (goth.Session, error) { + return nil, nil +} + +func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) { + return nil, nil +} + +func (p *fakeProvider) FetchUser(goth.Session) (goth.User, error) { + return goth.User{}, nil +} + +func (p *fakeProvider) Debug(bool) { +} + +func (p *fakeProvider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + switch refreshToken { + case "expired": + return nil, &oauth2.RetrieveError{ + ErrorCode: "invalid_grant", + } + default: + return &oauth2.Token{ + AccessToken: "token", + TokenType: "Bearer", + RefreshToken: "refresh", + Expiry: time.Now().Add(time.Hour), + }, nil + } +} + +func (p *fakeProvider) RefreshTokenAvailable() bool { + return true +} + +func init() { + RegisterGothProvider( + NewSimpleProvider("fake", "Fake", []string{"account"}, + func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider { + return &fakeProvider{} + })) +} diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go index 675005e55a..3454c9ad55 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -36,7 +36,7 @@ func (source *Source) FromDB(bs []byte) error { return json.UnmarshalHandleDoubleEncode(bs, &source) } -// ToDB exports an SMTPConfig to a serialized format. +// ToDB exports an OAuth2Config to a serialized format. func (source *Source) ToDB() ([]byte, error) { return json.Marshal(source) } diff --git a/services/auth/source/oauth2/source_sync.go b/services/auth/source/oauth2/source_sync.go new file mode 100644 index 0000000000..5e30313c8f --- /dev/null +++ b/services/auth/source/oauth2/source_sync.go @@ -0,0 +1,114 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "context" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Sync causes this OAuth2 source to synchronize its users with the db. +func (source *Source) Sync(ctx context.Context, updateExisting bool) error { + log.Trace("Doing: SyncExternalUsers[%s] %d", source.authSource.Name, source.authSource.ID) + + if !updateExisting { + log.Info("SyncExternalUsers[%s] not running since updateExisting is false", source.authSource.Name) + return nil + } + + provider, err := createProvider(source.authSource.Name, source) + if err != nil { + return err + } + + if !provider.RefreshTokenAvailable() { + log.Trace("SyncExternalUsers[%s] provider doesn't support refresh tokens, can't synchronize", source.authSource.Name) + return nil + } + + opts := user_model.FindExternalUserOptions{ + HasRefreshToken: true, + Expired: true, + LoginSourceID: source.authSource.ID, + } + + return user_model.IterateExternalLogin(ctx, opts, func(ctx context.Context, u *user_model.ExternalLoginUser) error { + return source.refresh(ctx, provider, u) + }) +} + +func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *user_model.ExternalLoginUser) error { + log.Trace("Syncing login_source_id=%d external_id=%s expiration=%s", u.LoginSourceID, u.ExternalID, u.ExpiresAt) + + shouldDisable := false + + token, err := provider.RefreshToken(u.RefreshToken) + if err != nil { + if err, ok := err.(*oauth2.RetrieveError); ok && err.ErrorCode == "invalid_grant" { + // this signals that the token is not valid and the user should be disabled + shouldDisable = true + } else { + return err + } + } + + user := &user_model.User{ + LoginName: u.ExternalID, + LoginType: auth.OAuth2, + LoginSource: u.LoginSourceID, + } + + hasUser, err := user_model.GetUser(ctx, user) + if err != nil { + return err + } + + // If the grant is no longer valid, disable the user and + // delete local tokens. If the OAuth2 provider still + // recognizes them as a valid user, they will be able to login + // via their provider and reactivate their account. + if shouldDisable { + log.Info("SyncExternalUsers[%s] disabling user %d", source.authSource.Name, user.ID) + + return db.WithTx(ctx, func(ctx context.Context) error { + if hasUser { + user.IsActive = false + err := user_model.UpdateUserCols(ctx, user, "is_active") + if err != nil { + return err + } + } + + // Delete stored tokens, since they are invalid. This + // also provents us from checking this in subsequent runs. + u.AccessToken = "" + u.RefreshToken = "" + u.ExpiresAt = time.Time{} + + return user_model.UpdateExternalUserByExternalID(ctx, u) + }) + } + + // Otherwise, update the tokens + u.AccessToken = token.AccessToken + u.ExpiresAt = token.Expiry + + // Some providers only update access tokens provide a new + // refresh token, so avoid updating it if it's empty + if token.RefreshToken != "" { + u.RefreshToken = token.RefreshToken + } + + err = user_model.UpdateExternalUserByExternalID(ctx, u) + + return err +} diff --git a/services/auth/source/oauth2/source_sync_test.go b/services/auth/source/oauth2/source_sync_test.go new file mode 100644 index 0000000000..e2f04bcb25 --- /dev/null +++ b/services/auth/source/oauth2/source_sync_test.go @@ -0,0 +1,100 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestSource(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + source := &Source{ + Provider: "fake", + authSource: &auth.Source{ + ID: 12, + Type: auth.OAuth2, + Name: "fake", + IsActive: true, + IsSyncEnabled: true, + }, + } + + user := &user_model.User{ + LoginName: "external", + LoginType: auth.OAuth2, + LoginSource: source.authSource.ID, + Name: "test", + Email: "external@example.com", + } + + err := user_model.CreateUser(context.Background(), user, &user_model.CreateUserOverwriteOptions{}) + assert.NoError(t, err) + + e := &user_model.ExternalLoginUser{ + ExternalID: "external", + UserID: user.ID, + LoginSourceID: user.LoginSource, + RefreshToken: "valid", + } + err = user_model.LinkExternalToUser(context.Background(), user, e) + assert.NoError(t, err) + + provider, err := createProvider(source.authSource.Name, source) + assert.NoError(t, err) + + t.Run("refresh", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + err := source.refresh(context.Background(), provider, e) + assert.NoError(t, err) + + e := &user_model.ExternalLoginUser{ + ExternalID: e.ExternalID, + LoginSourceID: e.LoginSourceID, + } + + ok, err := user_model.GetExternalLogin(context.Background(), e) + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, e.RefreshToken, "refresh") + assert.Equal(t, e.AccessToken, "token") + + u, err := user_model.GetUserByID(context.Background(), user.ID) + assert.NoError(t, err) + assert.True(t, u.IsActive) + }) + + t.Run("expired", func(t *testing.T) { + err := source.refresh(context.Background(), provider, &user_model.ExternalLoginUser{ + ExternalID: "external", + UserID: user.ID, + LoginSourceID: user.LoginSource, + RefreshToken: "expired", + }) + assert.NoError(t, err) + + e := &user_model.ExternalLoginUser{ + ExternalID: e.ExternalID, + LoginSourceID: e.LoginSourceID, + } + + ok, err := user_model.GetExternalLogin(context.Background(), e) + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, e.RefreshToken, "") + assert.Equal(t, e.AccessToken, "") + + u, err := user_model.GetUserByID(context.Background(), user.ID) + assert.NoError(t, err) + assert.False(t, u.IsActive) + }) + }) +} diff --git a/services/externalaccount/user.go b/services/externalaccount/user.go index 3cfd8c81f9..b53e33654a 100644 --- a/services/externalaccount/user.go +++ b/services/externalaccount/user.go @@ -71,14 +71,14 @@ func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth return nil } -// UpdateExternalUser updates external user's information -func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { +// EnsureLinkExternalToUser link the gothUser to the user +func EnsureLinkExternalToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) if err != nil { return err } - return user_model.UpdateExternalUserByExternalID(ctx, externalLoginUser) + return user_model.EnsureLinkExternalToUser(ctx, externalLoginUser) } // UpdateMigrationsByType updates all migrated repositories' posterid from gitServiceType to replace originalAuthorID to posterID diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index e140d6b5eb..660f0d0881 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -412,7 +412,7 @@

{{ctx.Locale.Tr "admin.auths.sspi_default_language_helper"}}

{{end}} - {{if .Source.IsLDAP}} + {{if (or .Source.IsLDAP .Source.IsOAuth2)}}
diff --git a/templates/admin/auth/new.tmpl b/templates/admin/auth/new.tmpl index f130e18f65..e3985cb227 100644 --- a/templates/admin/auth/new.tmpl +++ b/templates/admin/auth/new.tmpl @@ -59,7 +59,7 @@
-
+
diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go index 0d733f663a..317787f403 100644 --- a/tests/integration/auth_ldap_test.go +++ b/tests/integration/auth_ldap_test.go @@ -224,7 +224,8 @@ func TestLDAPUserSync(t *testing.T) { } defer tests.PrepareTestEnv(t)() addAuthSourceLDAP(t, "", "") - auth.SyncExternalUsers(context.Background(), true) + err := auth.SyncExternalUsers(context.Background(), true) + assert.NoError(t, err) // Check if users exists for _, gitLDAPUser := range gitLDAPUsers { From 3571b7e3dd3107d728845ab339c0e1a927e4314f Mon Sep 17 00:00:00 2001 From: Carsten Klein Date: Wed, 17 Jul 2024 00:49:05 +0200 Subject: [PATCH 2/5] Allow searching issues by ID (#31479) When you are entering a number in the issue search, you likely want the issue with the given ID (code internal concept: issue index). As such, when a number is detected, the issue with the corresponding ID will now be added to the results. Fixes #4479 Co-authored-by: wxiaoguang --- modules/indexer/issues/db/db.go | 6 ++++ modules/indexer/issues/indexer.go | 4 +-- modules/indexer/issues/indexer_test.go | 38 ++++++++++++++++++++++++ modules/indexer/issues/internal/model.go | 8 +++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/modules/indexer/issues/db/db.go b/modules/indexer/issues/db/db.go index 05ec548435..6c9cfcf670 100644 --- a/modules/indexer/issues/db/db.go +++ b/modules/indexer/issues/db/db.go @@ -71,6 +71,12 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( )), ), ) + + if options.IsKeywordNumeric() { + cond = cond.Or( + builder.Eq{"`index`": options.Keyword}, + ) + } } opt, err := ToDBOptions(ctx, options) diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go index 1cb86feb82..c82dc0867e 100644 --- a/modules/indexer/issues/indexer.go +++ b/modules/indexer/issues/indexer.go @@ -283,9 +283,9 @@ const ( func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) { indexer := *globalIndexer.Load() - if opts.Keyword == "" { + if opts.Keyword == "" || opts.IsKeywordNumeric() { // This is a conservative shortcut. - // If the keyword is empty, db has better (at least not worse) performance to filter issues. + // If the keyword is empty or an integer, db has better (at least not worse) performance to filter issues. // When the keyword is empty, it tends to listing rather than searching issues. // So if the user creates an issue and list issues immediately, the issue may not be listed because the indexer needs time to index the issue. // Even worse, the external indexer like elastic search may not be available for a while, diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index e426229f78..4986d8d294 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -31,6 +31,7 @@ func TestDBSearchIssues(t *testing.T) { InitIssueIndexer(true) t.Run("search issues with keyword", searchIssueWithKeyword) + t.Run("search issues by index", searchIssueByIndex) t.Run("search issues in repo", searchIssueInRepo) t.Run("search issues by ID", searchIssueByID) t.Run("search issues is pr", searchIssueIsPull) @@ -87,6 +88,43 @@ func searchIssueWithKeyword(t *testing.T) { } } +func searchIssueByIndex(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + Keyword: "1000", + RepoIDs: []int64{1}, + }, + []int64{}, + }, + { + SearchOptions{ + Keyword: "2", + RepoIDs: []int64{1, 2, 3, 32}, + }, + []int64{17, 12, 7, 2}, + }, + { + SearchOptions{ + Keyword: "1", + RepoIDs: []int64{58}, + }, + []int64{19}, + }, + } + + for _, test := range tests { + issueIDs, _, err := SearchIssues(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, issueIDs) + } +} + func searchIssueInRepo(t *testing.T) { tests := []struct { opts SearchOptions diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index 2dfee8b72e..a43c6be005 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -4,6 +4,8 @@ package internal import ( + "strconv" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/timeutil" @@ -124,6 +126,12 @@ func (o *SearchOptions) Copy(edit ...func(options *SearchOptions)) *SearchOption return &v } +// used for optimized issue index based search +func (o *SearchOptions) IsKeywordNumeric() bool { + _, err := strconv.Atoi(o.Keyword) + return err == nil +} + type SortBy string const ( From de1a5506d1c1da71415393e6c943f341195e610b Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Wed, 17 Jul 2024 00:27:54 +0000 Subject: [PATCH 3/5] [skip ci] Updated translations via Crowdin --- options/locale/locale_cs-CZ.ini | 1 + options/locale/locale_de-DE.ini | 1 + options/locale/locale_el-GR.ini | 1 + options/locale/locale_es-ES.ini | 1 + options/locale/locale_fa-IR.ini | 1 + options/locale/locale_fi-FI.ini | 1 + options/locale/locale_fr-FR.ini | 1 + options/locale/locale_hu-HU.ini | 1 + options/locale/locale_id-ID.ini | 1 + options/locale/locale_is-IS.ini | 1 + options/locale/locale_it-IT.ini | 1 + options/locale/locale_ja-JP.ini | 1 + options/locale/locale_ko-KR.ini | 1 + options/locale/locale_lv-LV.ini | 1 + options/locale/locale_nl-NL.ini | 1 + options/locale/locale_pl-PL.ini | 1 + options/locale/locale_pt-BR.ini | 1 + options/locale/locale_pt-PT.ini | 1 + options/locale/locale_ru-RU.ini | 1 + options/locale/locale_si-LK.ini | 1 + options/locale/locale_sv-SE.ini | 1 + options/locale/locale_tr-TR.ini | 1 + options/locale/locale_uk-UA.ini | 1 + options/locale/locale_zh-CN.ini | 1 + options/locale/locale_zh-HK.ini | 1 + options/locale/locale_zh-TW.ini | 1 + 26 files changed, 26 insertions(+) diff --git a/options/locale/locale_cs-CZ.ini b/options/locale/locale_cs-CZ.ini index eeaf1453f1..49c6fdc84d 100644 --- a/options/locale/locale_cs-CZ.ini +++ b/options/locale/locale_cs-CZ.ini @@ -1927,6 +1927,7 @@ milestones.edit_success=Milník „%s“ byl aktualizován. milestones.deletion=Smazat milník milestones.deletion_desc=Odstranění milníku jej smaže ze všech souvisejících úkolů. Pokračovat? milestones.deletion_success=Milník byl odstraněn. +milestones.filter_sort.name=Název milestones.filter_sort.earliest_due_data=Nejstarší datum dokončení milestones.filter_sort.latest_due_date=Nejnovější datum dokončení milestones.filter_sort.least_complete=Nejméně dokončené diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index d9e40ebc95..d8bc62711b 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -1907,6 +1907,7 @@ milestones.edit_success=Meilenstein "%s" wurde aktualisiert. milestones.deletion=Meilenstein löschen milestones.deletion_desc=Das Löschen des Meilensteins entfernt ihn von allen Issues. Fortfahren? milestones.deletion_success=Der Meilenstein wurde gelöscht. +milestones.filter_sort.name=Name milestones.filter_sort.earliest_due_data=Frühestes Fälligkeitsdatum milestones.filter_sort.latest_due_date=Spätestes Fälligkeitsdatum milestones.filter_sort.least_complete=Am wenigsten vollständig diff --git a/options/locale/locale_el-GR.ini b/options/locale/locale_el-GR.ini index 15b6950be6..1c5c9227ce 100644 --- a/options/locale/locale_el-GR.ini +++ b/options/locale/locale_el-GR.ini @@ -1826,6 +1826,7 @@ milestones.edit_success=Το ορόσημο "%s" ενημερώθηκε. milestones.deletion=Διαγραφή Ορόσημου milestones.deletion_desc=Η διαγραφή ενός ορόσημου το αφαιρεί από όλα τα συναφή ζητήματα. Συνέχεια; milestones.deletion_success=Το ορόσημο έχει διαγραφεί. +milestones.filter_sort.name=Όνομα milestones.filter_sort.earliest_due_data=Πλησιέστερη παράδοση milestones.filter_sort.latest_due_date=Απώτερη παράδοση milestones.filter_sort.least_complete=Λιγότερο πλήρη diff --git a/options/locale/locale_es-ES.ini b/options/locale/locale_es-ES.ini index 5d8fd1d3f1..18fd2c17f2 100644 --- a/options/locale/locale_es-ES.ini +++ b/options/locale/locale_es-ES.ini @@ -1812,6 +1812,7 @@ milestones.edit_success=Se ha actualizado el hito "%s". milestones.deletion=Eliminar hito milestones.deletion_desc=Eliminando un hito lo elimina de todos los problemas relacionados. ¿Continuar? milestones.deletion_success=El hito se ha eliminado. +milestones.filter_sort.name=Nombre milestones.filter_sort.earliest_due_data=Fecha de vencimiento más temprana milestones.filter_sort.latest_due_date=Fecha de vencimiento más lejana milestones.filter_sort.least_complete=Menos completa diff --git a/options/locale/locale_fa-IR.ini b/options/locale/locale_fa-IR.ini index f77fad8305..9c35d99a8b 100644 --- a/options/locale/locale_fa-IR.ini +++ b/options/locale/locale_fa-IR.ini @@ -1389,6 +1389,7 @@ milestones.modify=به روزرسانی نقطه عطف milestones.deletion=حذف نقطه عطف milestones.deletion_desc=نقاط عطف از تمام مسائل مرتبط حذف میشوند. آیا ادامه میدهید؟ milestones.deletion_success=نقطه عطف حذف شد. +milestones.filter_sort.name=نام milestones.filter_sort.least_complete=حداقل کامل شده milestones.filter_sort.most_complete=بیشترین کامل شده milestones.filter_sort.most_issues=بیشترین مسائل diff --git a/options/locale/locale_fi-FI.ini b/options/locale/locale_fi-FI.ini index 6d0d41ee68..4563612040 100644 --- a/options/locale/locale_fi-FI.ini +++ b/options/locale/locale_fi-FI.ini @@ -1009,6 +1009,7 @@ milestones.clear=Tyhjennä milestones.edit=Muokkaa merkkipaalua milestones.cancel=Peruuta milestones.modify=Päivitä merkkipaalu +milestones.filter_sort.name=Nimi milestones.filter_sort.most_issues=Eniten ongelmia milestones.filter_sort.least_issues=Vähiten ongelmia diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index efceb8d9ce..7a04ab4843 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -1927,6 +1927,7 @@ milestones.edit_success=Le jalon "%s" a été mis à jour. milestones.deletion=Supprimer un Jalon milestones.deletion_desc=Supprimer un jalon le retire de tous les tickets. Continuer ? milestones.deletion_success=Le jalon a été supprimé. +milestones.filter_sort.name=Nom milestones.filter_sort.earliest_due_data=Date d’échéance la plus ancienne milestones.filter_sort.latest_due_date=Date d’échéance la plus récente milestones.filter_sort.least_complete=Le moins complété diff --git a/options/locale/locale_hu-HU.ini b/options/locale/locale_hu-HU.ini index fc3201426a..dbb5c7dae3 100644 --- a/options/locale/locale_hu-HU.ini +++ b/options/locale/locale_hu-HU.ini @@ -962,6 +962,7 @@ milestones.modify=Mérföldkő frissítése milestones.deletion=Mérföldkő törlése milestones.deletion_desc=A mérföldkő törlése eltávolítja az összes hozzárendelt hibajegyet. Biztosan folytatja? milestones.deletion_success=A mérföldkő törölve. +milestones.filter_sort.name=Név milestones.filter_sort.least_complete=Legkevésbé befejezve milestones.filter_sort.most_complete=Leginkább befejezve milestones.filter_sort.most_issues=Legtöbb hibajegy diff --git a/options/locale/locale_id-ID.ini b/options/locale/locale_id-ID.ini index ea0b2030fe..a3a6f8598e 100644 --- a/options/locale/locale_id-ID.ini +++ b/options/locale/locale_id-ID.ini @@ -770,6 +770,7 @@ milestones.due_date=Jatuh Tempo (opsional) milestones.clear=Bersihkan milestones.edit=Ubah Milestone milestones.cancel=Batal +milestones.filter_sort.name=Nama milestones.filter_sort.least_complete=Paling tidak lengkap milestones.filter_sort.most_complete=Paling lengkap milestones.filter_sort.most_issues=Paling banyak masalah diff --git a/options/locale/locale_is-IS.ini b/options/locale/locale_is-IS.ini index a75a11ba34..af71558e50 100644 --- a/options/locale/locale_is-IS.ini +++ b/options/locale/locale_is-IS.ini @@ -916,6 +916,7 @@ milestones.desc=Lýsing milestones.due_date=Eindagi (valfrjálst) milestones.clear=Hreinsa milestones.cancel=Hætta við +milestones.filter_sort.name=Heiti milestones.filter_sort.most_issues=Flest vandamál milestones.filter_sort.least_issues=Fæst vandamál diff --git a/options/locale/locale_it-IT.ini b/options/locale/locale_it-IT.ini index 79a469d248..d9acd9f44a 100644 --- a/options/locale/locale_it-IT.ini +++ b/options/locale/locale_it-IT.ini @@ -1512,6 +1512,7 @@ milestones.modify=Aggiorna pietra miliare milestones.deletion=Elimina pietra miliare milestones.deletion_desc=Eliminare una pietra miliare la rimuove da tutte le relative issue. Continuare? milestones.deletion_success=La pietra miliare è stata eliminata. +milestones.filter_sort.name=Nome milestones.filter_sort.least_complete=Meno completato milestones.filter_sort.most_complete=Più completato milestones.filter_sort.most_issues=Più problemi diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini index 64f44ac98b..2cdae3e47d 100644 --- a/options/locale/locale_ja-JP.ini +++ b/options/locale/locale_ja-JP.ini @@ -1933,6 +1933,7 @@ milestones.edit_success=マイルストーン "%s" を更新しました。 milestones.deletion=マイルストーンの削除 milestones.deletion_desc=マイルストーンを削除すると、関連するすべてのイシューから除去されます。 続行しますか? milestones.deletion_success=マイルストーンを削除しました。 +milestones.filter_sort.name=名称 milestones.filter_sort.earliest_due_data=期日が早い順 milestones.filter_sort.latest_due_date=期日が遅い順 milestones.filter_sort.least_complete=消化率の低い順 diff --git a/options/locale/locale_ko-KR.ini b/options/locale/locale_ko-KR.ini index 8b7b44588e..16f4bd310e 100644 --- a/options/locale/locale_ko-KR.ini +++ b/options/locale/locale_ko-KR.ini @@ -875,6 +875,7 @@ milestones.modify=마일스톤 갱신 milestones.deletion=마일스톤 삭제 milestones.deletion_desc=마일스톤을 삭제하면 연관된 모든 이슈에서 삭제됩니다. 계속 하시겠습니까? milestones.deletion_success=마일스톤이 삭제되었습니다. +milestones.filter_sort.name=이름 milestones.filter_sort.least_complete=완료율이 낮은 순 milestones.filter_sort.most_complete=완료율이 높은 순 milestones.filter_sort.most_issues=이슈 많은 순 diff --git a/options/locale/locale_lv-LV.ini b/options/locale/locale_lv-LV.ini index f2f2e3b233..a862835ba8 100644 --- a/options/locale/locale_lv-LV.ini +++ b/options/locale/locale_lv-LV.ini @@ -1828,6 +1828,7 @@ milestones.edit_success=Izmaiņas atskaites punktā "%s" tika veiksmīgi saglab milestones.deletion=Dzēst atskaites punktu milestones.deletion_desc=Dzēšot šo atskaites punktu, tas tiks noņemts no visām saistītajām problēmām un izmaiņu pieprasījumiem. Vai turpināt? milestones.deletion_success=Atskaites punkts tika veiksmīgi izdzēsts. +milestones.filter_sort.name=Nosaukums milestones.filter_sort.earliest_due_data=Agrākais izpildes laiks milestones.filter_sort.latest_due_date=Vēlākais izpildes laiks milestones.filter_sort.least_complete=Vismazāk pabeigtais diff --git a/options/locale/locale_nl-NL.ini b/options/locale/locale_nl-NL.ini index a6651ecd25..6acb314018 100644 --- a/options/locale/locale_nl-NL.ini +++ b/options/locale/locale_nl-NL.ini @@ -1507,6 +1507,7 @@ milestones.modify=Mijlpaal bijwerken milestones.deletion=Mijlpaal verwijderen milestones.deletion_desc=Als je een mijlpaal verwijdert, wordt hij van alle gerelateerde kwesties verwijderd. Doorgaan? milestones.deletion_success=De mijlpaal is verwijderd. +milestones.filter_sort.name=Naam milestones.filter_sort.least_complete=Minst compleet milestones.filter_sort.most_complete=Meest compleet milestones.filter_sort.most_issues=Meeste problemen diff --git a/options/locale/locale_pl-PL.ini b/options/locale/locale_pl-PL.ini index 3028eab9ba..e6bd9ecf2a 100644 --- a/options/locale/locale_pl-PL.ini +++ b/options/locale/locale_pl-PL.ini @@ -1360,6 +1360,7 @@ milestones.modify=Zaktualizuj cel milestones.deletion=Usuń kamień milowy milestones.deletion_desc=Usunięcie celu usuwa go z wszystkich pozostałych zagadnień. Kontynuować? milestones.deletion_success=Cel został usunięty. +milestones.filter_sort.name=Nazwa milestones.filter_sort.least_complete=Najmniej kompletne milestones.filter_sort.most_complete=Najbardziej kompletne milestones.filter_sort.most_issues=Najwięcej zgłoszeń diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini index aa183321aa..a1b0849870 100644 --- a/options/locale/locale_pt-BR.ini +++ b/options/locale/locale_pt-BR.ini @@ -1820,6 +1820,7 @@ milestones.edit_success=O marco "%s" foi atualizado. milestones.deletion=Excluir marco milestones.deletion_desc=A exclusão deste marco irá removê-lo de todas as issues. Tem certeza que deseja continuar? milestones.deletion_success=O marco foi excluído. +milestones.filter_sort.name=Nome milestones.filter_sort.earliest_due_data=Data limite mais próxima milestones.filter_sort.latest_due_date=Data limite mais distante milestones.filter_sort.least_complete=Menos completo diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 7fbb1e0fa7..9c1706c94d 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -1933,6 +1933,7 @@ milestones.edit_success=A etapa "%s" foi modificada. milestones.deletion=Eliminar etapa milestones.deletion_desc=Se eliminar uma etapa, irá removê-la de todas as questões relacionadas. Quer continuar? milestones.deletion_success=A etapa foi eliminada. +milestones.filter_sort.name=Nome milestones.filter_sort.earliest_due_data=Data de vencimento mais próxima milestones.filter_sort.latest_due_date=Data de vencimento mais distante milestones.filter_sort.least_complete=Menos completo diff --git a/options/locale/locale_ru-RU.ini b/options/locale/locale_ru-RU.ini index 7f00d68324..9c443f7d77 100644 --- a/options/locale/locale_ru-RU.ini +++ b/options/locale/locale_ru-RU.ini @@ -1789,6 +1789,7 @@ milestones.edit_success=Этап «%s» обновлён. milestones.deletion=Удалить этап milestones.deletion_desc=Удаление этапа приведет к его удалению из всех связанных задач. Продолжить? milestones.deletion_success=Этап успешно удалён. +milestones.filter_sort.name=Название milestones.filter_sort.earliest_due_data=По возрастанию даты завершения milestones.filter_sort.latest_due_date=По убыванию даты завершения milestones.filter_sort.least_complete=Менее полное diff --git a/options/locale/locale_si-LK.ini b/options/locale/locale_si-LK.ini index 758d8386bf..8d664be3e0 100644 --- a/options/locale/locale_si-LK.ini +++ b/options/locale/locale_si-LK.ini @@ -1352,6 +1352,7 @@ milestones.modify=සන්ධිස්ථානයක් යාවත්කා milestones.deletion=සන්ධිස්ථානය මකන්න milestones.deletion_desc=සන්ධිස්ථානයක් මකා දැමීම සම්බන්ධ සියලු ගැටළු වලින් එය ඉවත් කරයි. දිගටම? milestones.deletion_success=සන්ධිස්ථානය මකා දමා ඇත. +milestones.filter_sort.name=නම milestones.filter_sort.least_complete=අවම වශයෙන් සම්පූර්ණයි milestones.filter_sort.most_complete=වඩාත්ම සම්පූර්ණයි milestones.filter_sort.most_issues=බොහෝ ප්රශ්න diff --git a/options/locale/locale_sv-SE.ini b/options/locale/locale_sv-SE.ini index d93700c190..11f5ed79fb 100644 --- a/options/locale/locale_sv-SE.ini +++ b/options/locale/locale_sv-SE.ini @@ -1132,6 +1132,7 @@ milestones.modify=Uppdatera milstolpe milestones.deletion=Ta bort milstolpe milestones.deletion_desc=Borttagning av en milstolpe tar bort den från samtliga relaterade ärende. Fortsätta? milestones.deletion_success=Milstolpen har blivit borttagen. +milestones.filter_sort.name=Namn milestones.filter_sort.least_complete=Minst klar milestones.filter_sort.most_complete=Mest klar milestones.filter_sort.most_issues=Mest ärenden diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini index f52299c67f..f30b49683a 100644 --- a/options/locale/locale_tr-TR.ini +++ b/options/locale/locale_tr-TR.ini @@ -1920,6 +1920,7 @@ milestones.edit_success=`"%s" dönüm noktası güncellendi.` milestones.deletion=Kilometre Taşını Sil milestones.deletion_desc=Bir kilometre taşını silmek, onu ilgili tüm sorunlardan kaldırır. Devam edilsin mi? milestones.deletion_success=Kilometre taşı silindi. +milestones.filter_sort.name=İsim milestones.filter_sort.earliest_due_data=En erken bitiş tarihi milestones.filter_sort.latest_due_date=En uzak bitiş tarihi milestones.filter_sort.least_complete=En az tamamlama diff --git a/options/locale/locale_uk-UA.ini b/options/locale/locale_uk-UA.ini index daf0fc6948..00a1fd552e 100644 --- a/options/locale/locale_uk-UA.ini +++ b/options/locale/locale_uk-UA.ini @@ -1399,6 +1399,7 @@ milestones.modify=Оновити етап milestones.deletion=Видалити етап milestones.deletion_desc=Видалення етапу призведе до його видалення з усіх пов'язаних задач. Продовжити? milestones.deletion_success=Етап успішно видалено. +milestones.filter_sort.name=Назва milestones.filter_sort.least_complete=Менш повне milestones.filter_sort.most_complete=Більш повне milestones.filter_sort.most_issues=Найбільш задач diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 064c42d60a..0e8c6677e6 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -1923,6 +1923,7 @@ milestones.edit_success=里程碑 %s 已经更新。 milestones.deletion=删除里程碑 milestones.deletion_desc=删除该里程碑将会移除所有工单中相关的信息。是否继续? milestones.deletion_success=里程碑已被删除。 +milestones.filter_sort.name=名称 milestones.filter_sort.earliest_due_data=到期日从远到近 milestones.filter_sort.latest_due_date=到期日从近到远 milestones.filter_sort.least_complete=完成度从低到高 diff --git a/options/locale/locale_zh-HK.ini b/options/locale/locale_zh-HK.ini index ceb9e2844d..64a012e307 100644 --- a/options/locale/locale_zh-HK.ini +++ b/options/locale/locale_zh-HK.ini @@ -511,6 +511,7 @@ milestones.due_date=截止日期(可選) milestones.clear=清除 milestones.edit=編輯里程碑 milestones.cancel=取消 +milestones.filter_sort.name=組織名稱 milestones.filter_sort.least_complete=完成度由低到高 milestones.filter_sort.most_complete=完成度由高到低 milestones.filter_sort.most_issues=問題由多到少 diff --git a/options/locale/locale_zh-TW.ini b/options/locale/locale_zh-TW.ini index b278639c2e..071cc76f68 100644 --- a/options/locale/locale_zh-TW.ini +++ b/options/locale/locale_zh-TW.ini @@ -1656,6 +1656,7 @@ milestones.edit_success=已更新里程碑「%s」。 milestones.deletion=刪除里程碑 milestones.deletion_desc=刪除里程碑會從所有相關的問題移除它。是否繼續? milestones.deletion_success=里程碑已刪除 +milestones.filter_sort.name=名稱 milestones.filter_sort.least_complete=完成度由低到高 milestones.filter_sort.most_complete=完成度由高到低 milestones.filter_sort.most_issues=問題由多到少 From 0c1127a2fb4c07576b4a2e4cffbcd2b0c8670a27 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 17 Jul 2024 12:04:28 +0200 Subject: [PATCH 4/5] Remove unneccessary uses of `word-break: break-all` (#31637) Fixes: https://github.com/go-gitea/gitea/issues/31636 1. Issue sidebar topic is disussed in https://github.com/go-gitea/gitea/issues/31636 2. Org description already has `overflow-wrap: anywhere` to ensure no overflow. Co-authored-by: Giteabot --- web_src/css/org.css | 1 - web_src/css/repo.css | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web_src/css/org.css b/web_src/css/org.css index 148cb975e4..90e5d7ad0e 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -96,7 +96,6 @@ .page-content.organization #org-info { overflow-wrap: anywhere; flex: 1; - word-break: break-all; } .page-content.organization #org-info .ui.header { diff --git a/web_src/css/repo.css b/web_src/css/repo.css index f34b1e7ea5..85f33f858e 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -2466,7 +2466,7 @@ tbody.commit-list { .sidebar-item-link { display: inline-flex; align-items: center; - word-break: break-all; + overflow-wrap: anywhere; } .diff-file-header { From 03c8c2683ccf0b697f0a7f68f37c452255407f9e Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Thu, 18 Jul 2024 00:27:07 +0000 Subject: [PATCH 5/5] [skip ci] Updated translations via Crowdin --- options/locale/locale_pt-PT.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 9c1706c94d..85e7a79bc9 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -477,6 +477,7 @@ activate_email=Valide o seu endereço de email activate_email.title=%s, por favor valide o seu endereço de email activate_email.text=Por favor clique na seguinte ligação para validar o seu endereço de email dentro de %s: +register_notify=Bem-vindo(a) a %s register_notify.title=%[1]s, bem-vindo(a) a %[2]s register_notify.text_1=este é o seu email de confirmação de registo para %s! register_notify.text_2=Agora pode iniciar a sessão com o nome de utilizador: %s. @@ -2385,6 +2386,7 @@ settings.protect_enable_merge=Habilitar integração settings.protect_enable_merge_desc=Qualquer pessoa com permissão de escrita tem autorização para realizar neste ramo as integrações constantes nos pedidos. settings.protect_whitelist_committers=Lista de permissões para restringir os envios settings.protect_whitelist_committers_desc=Apenas os utilizadores ou equipas constantes na lista terão permissão para enviar para este ramo (mas não poderão fazer envios forçados). +settings.protect_whitelist_deploy_keys=Lista de permissão de chaves de instalação com acesso de escrita para enviar. settings.protect_whitelist_users=Utilizadores com permissão para enviar: settings.protect_whitelist_teams=Equipas com permissão para enviar: settings.protect_force_push_allowlist_users=Utilizadores na lista de permissão para enviar forçadamente: