mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-10 05:24:18 +09:00
Compare commits
29
Commits
b349a4e746
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ac505ff0d | ||
|
|
85558c28fc | ||
|
|
7e34eae370 | ||
|
|
ecbef41c06 | ||
|
|
79535f4e01 | ||
|
|
ad6107ab88 | ||
|
|
76a81b24f9 | ||
|
|
fac8bf2eca | ||
|
|
0282bf861f | ||
|
|
fecd2f3bc2 | ||
|
|
7fae3d5db3 | ||
|
|
8163139ec0 | ||
|
|
25350d0c79 | ||
|
|
7dbfed37eb | ||
|
|
2087d4a1a5 | ||
|
|
c14edf3313 | ||
|
|
f899dfd6e0 | ||
|
|
09f78aed19 | ||
|
|
7733f1953f | ||
|
|
4c382cea59 | ||
|
|
d4333eb043 | ||
|
|
9fc5d20006 | ||
|
|
2657756cac | ||
|
|
a34cc4cac4 | ||
|
|
8873150206 | ||
|
|
e81ab0a5ea | ||
|
|
d86cb1a498 | ||
|
|
ec869e3052 | ||
|
|
dd8ef9c888 |
@@ -11,6 +11,7 @@ linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- gocheckcompilerdirectives
|
||||
- gocritic
|
||||
- govet
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
- Never assume, verify before claiming
|
||||
- List development targets with `make help`
|
||||
- PR descriptions: minimal, only what and why, no task lists or file listings. Include screenshots for UI changes, before and after when modifying existing UI
|
||||
- Read relevant developer documentation in the `docs` folder
|
||||
- PR descriptions: minimal, only what and why, no task or file listings. Include screenshots for UI changes, before and after when modifying existing UI. Aim for less than 1000 characters
|
||||
- Reference issues and PRs by full URL, not by number
|
||||
- Use Conventional Commits for commit messages and PR titles, plus Gitea's `enhance` type for user-facing enhancements
|
||||
- Add an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer to commit messages, never `Co-Authored-By` or `Signed-off-by`
|
||||
- Attribute agent authorship on one trailing line in issue and PR comments, never as a PR description section
|
||||
- Never rewrite git history unless asked, update PRs with new commits and normal push
|
||||
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
|
||||
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply. If you need to write a paragraph-long comment, rethink your implementation, it is likely too complicated
|
||||
- Add the current year to copyright headers of new `.go` files
|
||||
- In `options/locale`, only edit `locale_en-US.json`, other locales are synced automatically
|
||||
- In TS, use `!` instead of `?.`/`??` when a value always exists
|
||||
- In Go, prefer to use modern language features wherever possible
|
||||
- Prefer `tw-*` utilities over inline `style` and `flex-*` helpers over per-child `tw-ml-*`/`tw-mr-*` margins, falling back to `tw-*` where specificity requires `!important`
|
||||
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, `make generate-swagger` after API changes, and lint what changed with `make lint-go`, `lint-js`, `lint-css` or `lint-templates`
|
||||
- Fix the cause rather than disabling a linter or weakening a test. Where unavoidable, use the narrowest scope with a trailing comment giving the reason
|
||||
|
||||
Generated
+2
-62
File diff suppressed because one or more lines are too long
+27
-17
@@ -354,13 +354,10 @@ func findLdapSecurityProtocolByName(name string) (ldap.SecurityProtocol, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// getAuthSource gets the login source by its id defined in the command line flags.
|
||||
// It returns an error if the id is not set, does not match any source or if the source is not of expected type.
|
||||
func (a *authService) getAuthSource(ctx context.Context, c *cli.Command, authType auth.Type) (*auth.Source, error) {
|
||||
if err := argsSet(c, "id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authSource, err := a.getAuthSourceByID(ctx, c.Int64("id"))
|
||||
// getAuthSourceOfType gets the login source by id.
|
||||
// It returns an error if the id does not match any source or if the source is not of the expected type.
|
||||
func (a *authService) getAuthSourceOfType(ctx context.Context, id int64, authType auth.Type) (*auth.Source, error) {
|
||||
authSource, err := a.getAuthSourceByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -372,6 +369,15 @@ func (a *authService) getAuthSource(ctx context.Context, c *cli.Command, authTyp
|
||||
return authSource, nil
|
||||
}
|
||||
|
||||
// getAuthSource gets the login source by its id defined in the command line flags.
|
||||
// It returns an error if the id is not set, does not match any source or if the source is not of expected type.
|
||||
func (a *authService) getAuthSource(ctx context.Context, c *cli.Command, authType auth.Type) (*auth.Source, error) {
|
||||
if err := argsSet(c, "id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.getAuthSourceOfType(ctx, c.Int64("id"), authType)
|
||||
}
|
||||
|
||||
// addLdapBindDn adds a new LDAP via Bind DN authentication source.
|
||||
func (a *authService) addLdapBindDn(ctx context.Context, c *cli.Command) error {
|
||||
if err := argsSet(c, "name", "security-protocol", "host", "port", "user-search-base", "user-filter", "email-attribute"); err != nil {
|
||||
@@ -381,16 +387,17 @@ func (a *authService) addLdapBindDn(ctx context.Context, c *cli.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ldapConfig := &ldap.Source{
|
||||
Enabled: true, // always true
|
||||
}
|
||||
authSource := &auth.Source{
|
||||
Type: auth.LDAP,
|
||||
IsActive: true, // active by default
|
||||
Cfg: &ldap.Source{
|
||||
Enabled: true, // always true
|
||||
},
|
||||
Cfg: ldapConfig,
|
||||
}
|
||||
|
||||
parseAuthSourceLdap(c, authSource)
|
||||
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
|
||||
if err := parseLdapConfig(c, ldapConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -407,9 +414,10 @@ func (a *authService) updateLdapBindDn(ctx context.Context, c *cli.Command) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ldapConfig := auth.MustSourceCfg[*ldap.Source](authSource)
|
||||
|
||||
parseAuthSourceLdap(c, authSource)
|
||||
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
|
||||
if err := parseLdapConfig(c, ldapConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -426,16 +434,17 @@ func (a *authService) addLdapSimpleAuth(ctx context.Context, c *cli.Command) err
|
||||
return err
|
||||
}
|
||||
|
||||
ldapConfig := &ldap.Source{
|
||||
Enabled: true, // always true
|
||||
}
|
||||
authSource := &auth.Source{
|
||||
Type: auth.DLDAP,
|
||||
IsActive: true, // active by default
|
||||
Cfg: &ldap.Source{
|
||||
Enabled: true, // always true
|
||||
},
|
||||
Cfg: ldapConfig,
|
||||
}
|
||||
|
||||
parseAuthSourceLdap(c, authSource)
|
||||
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
|
||||
if err := parseLdapConfig(c, ldapConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -452,9 +461,10 @@ func (a *authService) updateLdapSimpleAuth(ctx context.Context, c *cli.Command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ldapConfig := auth.MustSourceCfg[*ldap.Source](authSource)
|
||||
|
||||
parseAuthSourceLdap(c, authSource)
|
||||
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
|
||||
if err := parseLdapConfig(c, ldapConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -221,12 +221,11 @@ func (a *authService) runUpdateOauth(ctx context.Context, c *cli.Command) error
|
||||
return err
|
||||
}
|
||||
|
||||
source, err := a.getAuthSourceByID(ctx, c.Int64("id"))
|
||||
source, err := a.getAuthSourceOfType(ctx, c.Int64("id"), auth_model.OAuth2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oAuth2Config := source.Cfg.(*oauth2.Source)
|
||||
oAuth2Config := auth_model.MustSourceCfg[*oauth2.Source](source)
|
||||
|
||||
if c.IsSet("name") {
|
||||
source.Name = c.String("name")
|
||||
|
||||
@@ -175,12 +175,11 @@ func (a *authService) runUpdateSMTP(ctx context.Context, c *cli.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
source, err := a.getAuthSourceByID(ctx, c.Int64("id"))
|
||||
source, err := a.getAuthSourceOfType(ctx, c.Int64("id"), auth_model.SMTP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
smtpConfig := source.Cfg.(*smtp.Source)
|
||||
smtpConfig := auth_model.MustSourceCfg[*smtp.Source](source)
|
||||
|
||||
if err := parseSMTPConfig(c, smtpConfig); err != nil {
|
||||
return err
|
||||
|
||||
@@ -6,14 +6,13 @@ toolchain go1.26.5
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.20.0
|
||||
gitea.com/gitea/runner v1.0.8
|
||||
gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a
|
||||
gitea.com/go-chi/cache v0.2.1
|
||||
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
|
||||
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
|
||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
||||
gitea.dev/actions-proto-go v0.6.0
|
||||
gitea.dev/actionslib v0.7.0
|
||||
gitea.dev/sdk v1.2.0
|
||||
github.com/42wim/httpsig v1.2.4
|
||||
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
|
||||
@@ -45,7 +44,6 @@ require (
|
||||
github.com/felixge/fgprof v0.9.5
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/getkin/kin-openapi v0.145.0
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-co-op/gocron/v2 v2.22.0
|
||||
@@ -134,7 +132,6 @@ require (
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.32 // indirect
|
||||
@@ -254,6 +251,7 @@ require (
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/unknwon/com v1.0.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
|
||||
@@ -8,8 +8,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
gitea.com/gitea/runner v1.0.8 h1:zKfC4+zyyGIDagqhII3WVw52P+A9iAa63It0lniN4SI=
|
||||
gitea.com/gitea/runner v1.0.8/go.mod h1:AGLQXo8ELz9WPzNJ5W1SvnCik8ZX3jF0o6yCPCmwLtM=
|
||||
gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a h1:JHoBrfuTSF9Ke9aNfSYj1XRPBHjKPgCApVprnt2Am0M=
|
||||
gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a/go.mod h1:FOsLJIMdpiHzBp3Vby6Wfkdw2ppGscrjgU1IC7E4/zQ=
|
||||
gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
|
||||
@@ -24,8 +22,8 @@ gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHq
|
||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||
gitea.dev/actions-proto-go v0.6.0 h1:gjllYQ5vmwlkqOeofTQu5qKTZpmf7kWsafoHvoPCSzY=
|
||||
gitea.dev/actions-proto-go v0.6.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
||||
gitea.dev/actionslib v0.7.0 h1:JCV8eeIGwjlXcuSr7ojEdQC22VoE2466+K+D9vuQWKQ=
|
||||
gitea.dev/actionslib v0.7.0/go.mod h1:DI3Lqp+8TrycM7/semMdqsDQOHaBskjIAuMn+SXfZJ0=
|
||||
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
|
||||
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
|
||||
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||
|
||||
@@ -28,11 +28,11 @@ import (
|
||||
"gitea.dev/modelmigration/v1_25"
|
||||
"gitea.dev/modelmigration/v1_26"
|
||||
"gitea.dev/modelmigration/v1_27"
|
||||
"gitea.dev/modelmigration/v1_28"
|
||||
"gitea.dev/modelmigration/v1_6"
|
||||
"gitea.dev/modelmigration/v1_7"
|
||||
"gitea.dev/modelmigration/v1_8"
|
||||
"gitea.dev/modelmigration/v1_9"
|
||||
"gitea.dev/modelmigration/v28"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -416,10 +416,11 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(342, "Add scoped workflows schema", v1_27.AddScopedWorkflowsSchema),
|
||||
// Gitea 1.27.0 ends at migration ID number 342 (database version 343)
|
||||
|
||||
newMigration(343, "Add max_parallel column to action_run_job", v1_28.AddMaxParallelToActionRunJob),
|
||||
newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob),
|
||||
newMigration(345, "Add block on CODEOWNERS reviews branch protection", v1_28.AddBlockOnCodeownerReviews),
|
||||
newMigration(346, "Add license_path column to repo_license and backfill", v1_28.AddLicensePathToRepoLicense),
|
||||
newMigration(343, "Add max_parallel column to action_run_job", v28.AddMaxParallelToActionRunJob),
|
||||
newMigration(344, "Add deferred-matrix columns to ActionRunJob", v28.AddDeferredMatrixColumnsToActionRunJob),
|
||||
newMigration(345, "Add block on CODEOWNERS reviews branch protection", v28.AddBlockOnCodeownerReviews),
|
||||
newMigration(346, "Add license_path column to repo_license and backfill", v28.AddLicensePathToRepoLicense),
|
||||
newMigration(347, "Add watch options", v28.AddWatchOptions),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package v1_14
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -106,8 +107,8 @@ func FixPublisherIDforTagReleases(ctx context.Context, x base.EngineMigration) e
|
||||
|
||||
commit, err := gitRepo.GetTagCommit(ctx, release.TagName)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
log.Warn("Unable to find commit %s for Tag: %s in [%d]%s/%s. Cannot update publisher ID.", err.(git.ErrNotExist).ID, release.TagName, repo.ID, repo.OwnerName, repo.Name)
|
||||
if errNotExist, ok := errors.AsType[git.ErrNotExist](err); ok {
|
||||
log.Warn("Unable to find commit %s for Tag: %s in [%d]%s/%s. Cannot update publisher ID.", errNotExist.ID, release.TagName, repo.ID, repo.OwnerName, repo.Name)
|
||||
continue
|
||||
}
|
||||
log.Error("Error whilst getting commit for Tag: %s in [%d]%s/%s. Error: %v", release.TagName, repo.ID, repo.OwnerName, repo.Name, err)
|
||||
@@ -118,8 +119,8 @@ func FixPublisherIDforTagReleases(ctx context.Context, x base.EngineMigration) e
|
||||
log.Warn("Tag: %s in Repo[%d]%s/%s does not have a tagger.", release.TagName, repo.ID, repo.OwnerName, repo.Name)
|
||||
commit, err = gitRepo.GetCommit(ctx, commit.ID.String())
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
log.Warn("Unable to find commit %s for Tag: %s in [%d]%s/%s. Cannot update publisher ID.", err.(git.ErrNotExist).ID, release.TagName, repo.ID, repo.OwnerName, repo.Name)
|
||||
if errNotExist, ok := errors.AsType[git.ErrNotExist](err); ok {
|
||||
log.Warn("Unable to find commit %s for Tag: %s in [%d]%s/%s. Cannot update publisher ID.", errNotExist.ID, release.TagName, repo.ID, repo.OwnerName, repo.Name)
|
||||
continue
|
||||
}
|
||||
log.Error("Error whilst getting commit for Tag: %s in [%d]%s/%s. Error: %v", release.TagName, repo.ID, repo.OwnerName, repo.Name, err)
|
||||
|
||||
@@ -51,16 +51,16 @@ func AddPullRequestRebaseWithMerge(_ context.Context, x base.EngineMigration) er
|
||||
// Allow the new merge style if all other merge styles are allowed
|
||||
allowMergeRebase := true
|
||||
|
||||
if allowMerge, ok := unit.Config["AllowMerge"]; ok {
|
||||
allowMergeRebase = allowMergeRebase && allowMerge.(bool)
|
||||
if allowMerge, ok := unit.Config["AllowMerge"].(bool); ok {
|
||||
allowMergeRebase = allowMergeRebase && allowMerge
|
||||
}
|
||||
|
||||
if allowRebase, ok := unit.Config["AllowRebase"]; ok {
|
||||
allowMergeRebase = allowMergeRebase && allowRebase.(bool)
|
||||
if allowRebase, ok := unit.Config["AllowRebase"].(bool); ok {
|
||||
allowMergeRebase = allowMergeRebase && allowRebase
|
||||
}
|
||||
|
||||
if allowSquash, ok := unit.Config["AllowSquash"]; ok {
|
||||
allowMergeRebase = allowMergeRebase && allowSquash.(bool)
|
||||
if allowSquash, ok := unit.Config["AllowSquash"].(bool); ok {
|
||||
allowMergeRebase = allowMergeRebase && allowSquash
|
||||
}
|
||||
|
||||
if _, ok := unit.Config["AllowRebaseMerge"]; !ok {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_28
|
||||
package v28
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_28
|
||||
package v28
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_28
|
||||
package v28
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_28
|
||||
package v28
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_28
|
||||
package v28
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_28
|
||||
package v28
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_28
|
||||
package v28
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v28
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/modelmigration/base"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddWatchOptions(_ context.Context, x base.EngineMigration) error {
|
||||
type Watch struct {
|
||||
PullRequests bool `xorm:"NOT NULL DEFAULT true"`
|
||||
Issues bool `xorm:"NOT NULL DEFAULT true"`
|
||||
Releases bool `xorm:"NOT NULL DEFAULT true"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreConstrains: true,
|
||||
IgnoreIndices: true,
|
||||
}, new(Watch))
|
||||
return err
|
||||
}
|
||||
@@ -404,5 +404,5 @@ func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunA
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
}
|
||||
|
||||
return CancelJobs(ctx, jobsToCancel)
|
||||
return CancelJobs(ctx, jobsToCancel, false)
|
||||
}
|
||||
|
||||
+14
-12
@@ -703,7 +703,7 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin
|
||||
return cancelledJobs, err
|
||||
}
|
||||
|
||||
cjs, err := CancelJobs(ctx, jobs)
|
||||
cjs, err := CancelJobs(ctx, jobs, false)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
@@ -749,17 +749,18 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
}
|
||||
|
||||
return CancelJobs(ctx, jobsToCancel)
|
||||
return CancelJobs(ctx, jobsToCancel, false)
|
||||
}
|
||||
|
||||
// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it
|
||||
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one.
|
||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
|
||||
// CancelJobs cancels every cancellable job it is given, force skipping the graceful cancelling
|
||||
// handshake so a running task is marked cancelled without waiting for its runner. It leaves the
|
||||
// status of a run it cancelled nothing in untouched, SettleRunAfterCancel gives such a run a final one.
|
||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob, force bool) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
||||
|
||||
for _, job := range jobs {
|
||||
if job.IsReusableCaller {
|
||||
sub, err := cancelReusableCaller(ctx, job)
|
||||
sub, err := cancelReusableCaller(ctx, job, force)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
@@ -767,7 +768,7 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
|
||||
continue
|
||||
}
|
||||
|
||||
c, err := cancelOneJob(ctx, job)
|
||||
c, err := cancelOneJob(ctx, job, force)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
@@ -789,7 +790,7 @@ func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
|
||||
}
|
||||
|
||||
// cancelOneJob cancels a single job and returns the post-cancel row
|
||||
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
|
||||
func cancelOneJob(ctx context.Context, job *ActionRunJob, force bool) (*ActionRunJob, error) {
|
||||
if job.Status.IsDone() {
|
||||
return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error"
|
||||
}
|
||||
@@ -808,7 +809,8 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error)
|
||||
return job, nil
|
||||
}
|
||||
// Has a task: stop the task and re-read the row.
|
||||
if err := StopTask(ctx, job.TaskID, StatusCancelling); err != nil {
|
||||
stopStatus := util.Iif(force, StatusCancelled, StatusCancelling)
|
||||
if err := StopTask(ctx, job.TaskID, stopStatus); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
|
||||
@@ -819,7 +821,7 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error)
|
||||
}
|
||||
|
||||
// cancelReusableCaller cancels `caller` and all its child jobs
|
||||
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionRunJob, error) {
|
||||
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0)
|
||||
|
||||
attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID)
|
||||
@@ -834,7 +836,7 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR
|
||||
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
||||
|
||||
for _, c := range descendants {
|
||||
cancelled, err := cancelOneJob(ctx, c)
|
||||
cancelled, err := cancelOneJob(ctx, c, force)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
@@ -843,7 +845,7 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := cancelOneJob(ctx, caller); err != nil {
|
||||
if c, err := cancelOneJob(ctx, caller, force); err != nil {
|
||||
return cancelledJobs, err
|
||||
} else if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
|
||||
@@ -187,7 +187,7 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
||||
// Cancel all jobs of the attempt, ordered by id (parent before child).
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = CancelJobs(ctx, jobs)
|
||||
_, err = CancelJobs(ctx, jobs, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, j := range []*ActionRunJob{outer, inner} {
|
||||
@@ -272,7 +272,7 @@ func TestSettleRunAfterCancel(t *testing.T) {
|
||||
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
|
||||
|
||||
// mirrors what the CancelRun service does
|
||||
cancelled, err := CancelJobs(t.Context(), jobs)
|
||||
cancelled, err := CancelJobs(t.Context(), jobs, false)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
|
||||
require.NoError(t, SettleRunAfterCancel(t.Context(), run))
|
||||
@@ -327,3 +327,77 @@ jobs:
|
||||
assert.Equal(t, "build (1)", parsed.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestForceCancelJobs(t *testing.T) {
|
||||
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
taskAfter := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfter.Status)
|
||||
assert.NotZero(t, taskAfter.Stopped)
|
||||
|
||||
jobAfter := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfter.Status)
|
||||
assert.NotZero(t, jobAfter.Stopped)
|
||||
}
|
||||
|
||||
// A running task is force-cancelled directly, without trying the graceful cancel first.
|
||||
t.Run("running task", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "force-cancel-job", true)
|
||||
|
||||
cancelledJobs, err := CancelJobs(t.Context(), []*ActionRunJob{job}, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cancelledJobs, 1)
|
||||
assert.Equal(t, StatusCancelled, cancelledJobs[0].Status)
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
// A task already in the cancelling handshake whose runner never finishes the cleanup.
|
||||
t.Run("cancelling task", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "force-cancel-cancelling-job", true)
|
||||
|
||||
cancelling, err := CancelJobs(t.Context(), []*ActionRunJob{job}, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cancelling, 1)
|
||||
assert.Equal(t, StatusCancelling, cancelling[0].Status)
|
||||
|
||||
job = unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
cancelled, err := CancelJobs(t.Context(), []*ActionRunJob{job}, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cancelled, 1)
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
// A caller is cancelled through its descendants, so the force has to reach their tasks too.
|
||||
t.Run("reusable caller", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, child := newRunningTaskForCancelling(t, "force-cancel-child", true)
|
||||
|
||||
caller := &ActionRunJob{
|
||||
RunID: child.RunID,
|
||||
RepoID: child.RepoID,
|
||||
OwnerID: child.OwnerID,
|
||||
CommitSHA: child.CommitSHA,
|
||||
Name: "force-cancel-caller",
|
||||
JobID: "force-cancel-caller",
|
||||
Attempt: 1,
|
||||
Status: StatusRunning,
|
||||
IsReusableCaller: true,
|
||||
IsExpanded: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), caller))
|
||||
child.ParentJobID = caller.ID
|
||||
_, err := UpdateRunJob(t.Context(), child, nil, "parent_job_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelled, err := CancelJobs(t.Context(), []*ActionRunJob{caller}, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cancelled, 2)
|
||||
assertCancelled(t, task, child)
|
||||
|
||||
callerAfter := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: caller.ID})
|
||||
assert.Equal(t, StatusCancelled, callerAfter.Status)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/shared/types"
|
||||
|
||||
@@ -6,7 +6,7 @@ package actions
|
||||
import (
|
||||
"slices"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"gitea.dev/modules/translation"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ package actions
|
||||
import (
|
||||
"testing"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unit"
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
@@ -106,7 +106,8 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
|
||||
}
|
||||
toNotify.AddMultiple(issueWatches...)
|
||||
if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) {
|
||||
repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID)
|
||||
watchType := util.Iif(issue.IsPull, repo_model.WatchPullRequests, repo_model.WatchIssues)
|
||||
repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID, watchType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -117,6 +118,18 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
|
||||
return nil, err
|
||||
}
|
||||
toNotify.AddMultiple(issueParticipants...)
|
||||
issueAssignees, err := issues_model.GetAssigneeIDsByIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toNotify.AddMultiple(issueAssignees...)
|
||||
if issue.IsPull {
|
||||
issueReviewers, err := issues_model.GetPullRequestRequestedReviewerIDs(ctx, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toNotify.AddMultiple(issueReviewers...)
|
||||
}
|
||||
|
||||
// don't notify user who cause notification
|
||||
delete(toNotify, notificationAuthorID)
|
||||
@@ -130,6 +143,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
|
||||
}
|
||||
}
|
||||
|
||||
// muting the repository outranks every other source, including mentions
|
||||
ignorers, err := repo_model.GetRepoIgnorersIDs(ctx, issue.RepoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range ignorers {
|
||||
toNotify.Remove(id)
|
||||
}
|
||||
|
||||
if err := issue.LoadRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
@@ -32,6 +33,39 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) {
|
||||
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// user 13 neither watches repo 1 nor participates in PR 3
|
||||
assert.NoError(t, db.Insert(t.Context(), &issues_model.IssueAssignees{AssigneeID: 13, IssueID: 3}))
|
||||
_, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 3, 0, 1, 0)
|
||||
assert.NoError(t, err)
|
||||
unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 13, IssueID: 3})
|
||||
|
||||
// user 1 is a requested reviewer of PR 12 and does not participate in it
|
||||
_, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), 12, 0, 2, 0)
|
||||
assert.NoError(t, err)
|
||||
unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 1, IssueID: 12})
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// user 4 watches repo 1 and would be notified about issue 1
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
assert.NoError(t, repo_model.WatchIgnoreRepo(t.Context(), user, repo))
|
||||
|
||||
notified, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, notified, user.ID)
|
||||
|
||||
// muting outranks a direct receiver too
|
||||
notified, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, notified)
|
||||
}
|
||||
|
||||
func TestNotificationsForUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// AuthorizedStringCommentPrefix is a magic tag
|
||||
@@ -162,8 +163,8 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error {
|
||||
|
||||
// RegeneratePublicKeys regenerates the authorized_keys file
|
||||
func RegeneratePublicKeys(ctx context.Context, t io.Writer) error {
|
||||
if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) {
|
||||
return WriteAuthorizedStringForValidKey(bean.(*PublicKey), t)
|
||||
if err := db.Iterate(ctx, builder.Neq{"type": KeyTypePrincipal}, func(ctx context.Context, key *PublicKey) error {
|
||||
return WriteAuthorizedStringForValidKey(key, t)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+16
-20
@@ -98,20 +98,12 @@ type RegisterableSource interface {
|
||||
|
||||
var registeredConfigs = map[Type]func() Config{}
|
||||
|
||||
// RegisterTypeConfig register a config for a provided type
|
||||
func RegisterTypeConfig(typ Type, exemplar Config) {
|
||||
if reflect.TypeOf(exemplar).Kind() == reflect.Pointer {
|
||||
// Pointer:
|
||||
registeredConfigs[typ] = func() Config {
|
||||
return reflect.New(reflect.ValueOf(exemplar).Elem().Type()).Interface().(Config)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Not a Pointer
|
||||
registeredConfigs[typ] = func() Config {
|
||||
return reflect.New(reflect.TypeOf(exemplar)).Elem().Interface().(Config)
|
||||
}
|
||||
// RegisterTypeConfig register a config for a provided type, the exemplar argument only serves type inference
|
||||
func RegisterTypeConfig[T interface {
|
||||
*E
|
||||
Config
|
||||
}, E any](typ Type, _ T) {
|
||||
registeredConfigs[typ] = func() Config { return T(new(E)) }
|
||||
}
|
||||
|
||||
// Source represents an external way for authorizing users.
|
||||
@@ -188,6 +180,16 @@ func (source *Source) IsSSPI() bool {
|
||||
return source.Type == SSPI
|
||||
}
|
||||
|
||||
// MustSourceCfg returns the source's config as T. The registry populates Cfg from the
|
||||
// source type, so a mismatch is a programming error the caller can't recover from.
|
||||
func MustSourceCfg[T Config](source *Source) T {
|
||||
cfg, ok := source.Cfg.(T)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("auth source %q (id=%d, type=%s) has config %T, expected %s", source.Name, source.ID, source.Type, source.Cfg, reflect.TypeFor[T]()))
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// HasTLS returns true of this source supports TLS.
|
||||
func (source *Source) HasTLS() bool {
|
||||
hasTLSer, ok := source.Cfg.(HasTLSer)
|
||||
@@ -371,12 +373,6 @@ type ErrSourceAlreadyExist struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsErrSourceAlreadyExist checks if an error is a ErrSourceAlreadyExist.
|
||||
func IsErrSourceAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrSourceAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrSourceAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("login source already exists [name: %s]", err.Name)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInTransaction(t *testing.T) {
|
||||
@@ -107,7 +108,8 @@ func TestContextSafety(t *testing.T) {
|
||||
_ = db.GetEngine(ctx).Iterate(&TestModel1{}, func(i int, bean any) error {
|
||||
// here: db.GetEngine(ctx) is always the unclosed "Iterate" *Session with autoResetStatement=false,
|
||||
// and the internal states (including "cond" and others) are always there and not be reset in this callback.
|
||||
m1 := bean.(*TestModel1)
|
||||
m1, ok := bean.(*TestModel1)
|
||||
require.True(t, ok)
|
||||
assert.EqualValues(t, i+1, m1.ID)
|
||||
|
||||
// here: XORM bug, it fails because the SQL becomes "WHERE id=-1", "WHERE id=-1 AND id=-2", "WHERE id=-1 AND id=-2 AND id=-3" ...
|
||||
|
||||
@@ -14,12 +14,6 @@ type ErrCancelled struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// IsErrCancelled checks if an error is a ErrCancelled.
|
||||
func IsErrCancelled(err error) bool {
|
||||
_, ok := err.(ErrCancelled)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrCancelled) Error() string {
|
||||
return "Cancelled: " + err.Message
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ func (issue *Issue) LoadProjects(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (issue *Issue) projectIDs(ctx context.Context) (projectIDs []int64, _ error) {
|
||||
// ProjectIDs lists the IDs of the projects this issue belongs to.
|
||||
func (issue *Issue) ProjectIDs(ctx context.Context) (projectIDs []int64, _ error) {
|
||||
err := db.GetEngine(ctx).Table("project_issue").Where("issue_id = ?", issue.ID).Cols("project_id").Find(&projectIDs)
|
||||
return projectIDs, err
|
||||
}
|
||||
@@ -72,7 +73,7 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
|
||||
return err
|
||||
}
|
||||
|
||||
oldProjectIDs, err := issue.projectIDs(ctx)
|
||||
oldProjectIDs, err := issue.ProjectIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -119,7 +120,7 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
|
||||
return err
|
||||
}
|
||||
|
||||
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, projectID, defaultColumn.ID)
|
||||
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, defaultColumn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// IssueWatch is connection request for receiving issue notification.
|
||||
@@ -81,7 +82,10 @@ func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) (
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return repo_model.IsWatchMode(w.Mode) || IsUserParticipantsOfIssue(ctx, user, issue), nil
|
||||
if repo_model.IsWatchMode(w.Mode) && util.Iif(issue.IsPull, w.PullRequests, w.Issues) {
|
||||
return true, nil
|
||||
}
|
||||
return IsUserParticipantsOfIssue(ctx, user, issue), nil
|
||||
}
|
||||
|
||||
// GetIssueWatchersIDs returns IDs of subscribers or explicit unsubscribers to a given issue id
|
||||
|
||||
@@ -1012,3 +1012,16 @@ func GetPullRequestByMergedCommit(ctx context.Context, repoID int64, sha string)
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// GetPullRequestRequestedReviewerIDs returns IDs of reviewers currently requested for the given pull request.
|
||||
func GetPullRequestRequestedReviewerIDs(ctx context.Context, issueID int64) ([]int64, error) {
|
||||
userIDs := make([]int64, 0, 5)
|
||||
return userIDs, db.GetEngine(ctx).
|
||||
Table("review").
|
||||
Cols("reviewer_id").
|
||||
Where("issue_id=?", issueID).
|
||||
And("type=?", ReviewTypeRequest).
|
||||
And("reviewer_id > 0").
|
||||
Distinct("reviewer_id").
|
||||
Find(&userIDs)
|
||||
}
|
||||
|
||||
@@ -239,6 +239,15 @@ func GetPackageDescriptorWithCache(ctx context.Context, pv *PackageVersion, c *c
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DescriptorMetadata returns the descriptor's metadata, which getPackageDescriptor has created from the package type
|
||||
func DescriptorMetadata[T interface{ *E }, E any](pd *PackageDescriptor) T {
|
||||
metadata, ok := pd.Metadata.(T)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("package %s of type %s has metadata type %T instead of %T", pd.Package.Name, pd.Package.Type, pd.Metadata, metadata))
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// GetPackageFileDescriptor gets a package file descriptor for a package file
|
||||
func GetPackageFileDescriptor(ctx context.Context, pf *PackageFile) (*PackageFileDescriptor, error) {
|
||||
return getPackageFileDescriptor(ctx, pf, cache.NewEphemeralCache())
|
||||
|
||||
+30
-46
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
@@ -37,6 +38,13 @@ const (
|
||||
// ColumnColorPattern is a regexp witch can validate ColumnColor
|
||||
var ColumnColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
|
||||
|
||||
func validateColumnColor(color string) error {
|
||||
if len(color) != 0 && !ColumnColorPattern.MatchString(color) {
|
||||
return util.ErrorWrap(util.ErrUnprocessableContent, "invalid column color %q, expected a 6-digit hex string like #FF0000", color)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Column is used to represent column on a project
|
||||
type Column struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
@@ -134,9 +142,10 @@ const maxProjectColumns = 20
|
||||
|
||||
// NewColumn adds a new project column to a given project
|
||||
func NewColumn(ctx context.Context, column *Column) error {
|
||||
if len(column.Color) != 0 && !ColumnColorPattern.MatchString(column.Color) {
|
||||
return fmt.Errorf("bad color code: %s", column.Color)
|
||||
if err := validateColumnColor(column.Color); err != nil {
|
||||
return err
|
||||
}
|
||||
column.Title = util.EllipsisDisplayString(column.Title, 255)
|
||||
|
||||
res := struct {
|
||||
MaxSorting int64
|
||||
@@ -147,9 +156,10 @@ func NewColumn(ctx context.Context, column *Column) error {
|
||||
return err
|
||||
}
|
||||
if res.ColumnCount >= maxProjectColumns {
|
||||
return errors.New("NewBoard: maximum number of columns reached")
|
||||
return util.ErrorWrap(util.ErrUnprocessableContent, "maximum number of columns reached")
|
||||
}
|
||||
column.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
|
||||
// MaxInt8+1 would wrap the appended column to the front
|
||||
column.Sorting = int8(min(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0), math.MaxInt8))
|
||||
_, err := db.GetEngine(ctx).Insert(column)
|
||||
return err
|
||||
}
|
||||
@@ -161,6 +171,10 @@ func DeleteColumnByID(ctx context.Context, columnID int64) error {
|
||||
})
|
||||
}
|
||||
|
||||
// errColumnIsDefault is returned when deleting the column new issues land in, which would
|
||||
// leave the project without a landing column.
|
||||
var errColumnIsDefault = util.ErrorWrap(util.ErrUnprocessableContent, "cannot delete the default column")
|
||||
|
||||
func deleteColumnByID(ctx context.Context, columnID int64) error {
|
||||
column, err := GetColumn(ctx, columnID)
|
||||
if err != nil {
|
||||
@@ -172,7 +186,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error {
|
||||
}
|
||||
|
||||
if column.Default {
|
||||
return errors.New("deleteColumnByID: cannot delete default column")
|
||||
return errColumnIsDefault
|
||||
}
|
||||
|
||||
// move all issues to the default column
|
||||
@@ -225,38 +239,17 @@ func GetColumnByIDAndProjectID(ctx context.Context, columnID, projectID int64) (
|
||||
return column, nil
|
||||
}
|
||||
|
||||
// UpdateColumn updates a project column
|
||||
// UpdateColumn writes the column's title, sorting and color. Callers load the column
|
||||
// first, so every field carries a deliberate value, including a sorting of 0.
|
||||
func UpdateColumn(ctx context.Context, column *Column) error {
|
||||
var fieldToUpdate []string
|
||||
|
||||
if column.Sorting != 0 {
|
||||
fieldToUpdate = append(fieldToUpdate, "sorting")
|
||||
if err := validateColumnColor(column.Color); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if column.Title != "" {
|
||||
fieldToUpdate = append(fieldToUpdate, "title")
|
||||
}
|
||||
|
||||
if len(column.Color) != 0 && !ColumnColorPattern.MatchString(column.Color) {
|
||||
return fmt.Errorf("bad color code: %s", column.Color)
|
||||
}
|
||||
fieldToUpdate = append(fieldToUpdate, "color")
|
||||
|
||||
_, err := db.GetEngine(ctx).ID(column.ID).Cols(fieldToUpdate...).Update(column)
|
||||
|
||||
column.Title = util.EllipsisDisplayString(column.Title, 255)
|
||||
_, err := db.GetEngine(ctx).ID(column.ID).Cols("title", "sorting", "color").Update(column)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetColumns fetches all columns related to a project
|
||||
func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) {
|
||||
columns := make([]*Column, 0, 5)
|
||||
if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&columns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// getDefaultColumnWithFallback return default column if one exists
|
||||
// otherwise return the first column by sorting and set it as default column
|
||||
func (p *Project) getDefaultColumnWithFallback(ctx context.Context) (*Column, error) {
|
||||
@@ -337,22 +330,13 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error {
|
||||
})
|
||||
}
|
||||
|
||||
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
|
||||
columns := make([]*Column, 0, 5)
|
||||
if len(columnsIDs) == 0 {
|
||||
return columns, nil
|
||||
}
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("project_id =?", projectID).
|
||||
In("id", columnsIDs).
|
||||
OrderBy("sorting").Find(&columns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// MoveColumnsOnProject sorts columns in a project
|
||||
func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
|
||||
for sorting := range sortedColumnIDs {
|
||||
if sorting < math.MinInt8 || sorting > math.MaxInt8 {
|
||||
return util.ErrorWrap(util.ErrUnprocessableContent, "column sorting %d is out of range", sorting)
|
||||
}
|
||||
}
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
sess := db.GetEngine(ctx)
|
||||
columnIDs := util.ValuesOfMap(sortedColumnIDs)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
)
|
||||
|
||||
// CountColumns returns the total number of columns for a project
|
||||
func CountColumns(ctx context.Context, projectID int64) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("project_id=?", projectID).Count(&Column{})
|
||||
}
|
||||
|
||||
// GetColumns returns a list of columns for a project with pagination
|
||||
func GetColumns(ctx context.Context, projectID int64, opts db.ListOptions) (ColumnList, error) {
|
||||
columns := make([]*Column, 0, opts.PageSize)
|
||||
s := db.GetEngine(ctx).Where("project_id=?", projectID).OrderBy("sorting, id")
|
||||
if !opts.IsListAll() {
|
||||
db.SetSessionPagination(s, &opts)
|
||||
}
|
||||
if err := s.Find(&columns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
|
||||
columns := make([]*Column, 0, 5)
|
||||
if len(columnsIDs) == 0 {
|
||||
return columns, nil
|
||||
}
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("project_id =?", projectID).
|
||||
In("id", columnsIDs).
|
||||
OrderBy("sorting").Find(&columns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetColumnsPaginated(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const projectID = 1
|
||||
count, err := CountColumns(t.Context(), projectID)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 3, count)
|
||||
|
||||
// Page 1, limit 2 — returns first 2 columns
|
||||
page1, err := GetColumns(t.Context(), projectID, db.ListOptions{Page: 1, PageSize: 2})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, page1, 2)
|
||||
|
||||
// Page 2, limit 2 — returns remaining column
|
||||
page2, err := GetColumns(t.Context(), projectID, db.ListOptions{Page: 2, PageSize: 2})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, page2, 1)
|
||||
|
||||
// Page 1 and page 2 together cover all columns with no overlap
|
||||
allIDs := make(map[int64]bool)
|
||||
for _, c := range append(page1, page2...) {
|
||||
assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID)
|
||||
allIDs[c.ID] = true
|
||||
}
|
||||
assert.Len(t, allIDs, 3)
|
||||
}
|
||||
@@ -5,9 +5,12 @@ package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -79,7 +82,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
|
||||
columns, err := project1.GetColumns(t.Context())
|
||||
columns, err := GetColumns(t.Context(), project1.ID, db.ListOptionsAll)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columns, 3)
|
||||
assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work
|
||||
@@ -93,19 +96,22 @@ func Test_MoveColumnsOnProject(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
columnsAfter, err := project1.GetColumns(t.Context())
|
||||
columnsAfter, err := GetColumns(t.Context(), project1.ID, db.ListOptionsAll)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columnsAfter, 3)
|
||||
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
|
||||
assert.Equal(t, columns[2].ID, columnsAfter[1].ID)
|
||||
assert.Equal(t, columns[0].ID, columnsAfter[2].ID)
|
||||
|
||||
err = MoveColumnsOnProject(t.Context(), project1, map[int64]int64{200: columns[0].ID})
|
||||
assert.ErrorIs(t, err, util.ErrUnprocessableContent) // int8 column, 200 would wrap
|
||||
}
|
||||
|
||||
func Test_NewColumn(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
|
||||
columns, err := project1.GetColumns(t.Context())
|
||||
columns, err := GetColumns(t.Context(), project1.ID, db.ListOptionsAll)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columns, 3)
|
||||
|
||||
@@ -123,3 +129,27 @@ func Test_NewColumn(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "maximum number of columns reached")
|
||||
}
|
||||
|
||||
func Test_ColumnSorting(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("appending an issue counts the legacy rows the default column renders", func(t *testing.T) {
|
||||
_, err := db.Exec(t.Context(), "UPDATE `project_issue` SET sorting=9 WHERE project_id=1 AND project_board_id=0")
|
||||
assert.NoError(t, err)
|
||||
|
||||
defaultColumn, err := GetColumn(t.Context(), 1)
|
||||
assert.NoError(t, err)
|
||||
next, err := GetColumnIssueNextSorting(t.Context(), defaultColumn)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 10, next)
|
||||
})
|
||||
|
||||
t.Run("appending a column at the int8 maximum does not wrap to the front", func(t *testing.T) {
|
||||
_, err := db.Exec(t.Context(), "UPDATE `project_board` SET sorting=? WHERE id=3", math.MaxInt8)
|
||||
assert.NoError(t, err)
|
||||
|
||||
appended := &Column{Title: "appended", ProjectID: 1}
|
||||
assert.NoError(t, NewColumn(t.Context(), appended))
|
||||
assert.EqualValues(t, math.MaxInt8, appended.Sorting)
|
||||
})
|
||||
}
|
||||
|
||||
+33
-5
@@ -17,7 +17,7 @@ type ProjectIssue struct { //revive:disable-line:exported
|
||||
IssueID int64 `xorm:"INDEX"`
|
||||
ProjectID int64 `xorm:"INDEX"`
|
||||
|
||||
// ProjectColumnID should not be zero since 1.22. If it's zero, the issue will not be displayed on UI and it might result in errors.
|
||||
// ProjectColumnID should not be zero since 1.22. Legacy zero rows render in the default column.
|
||||
ProjectColumnID int64 `xorm:"'project_board_id' INDEX"`
|
||||
|
||||
// the sorting order on the column
|
||||
@@ -33,16 +33,44 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
|
||||
return err
|
||||
}
|
||||
|
||||
// columnIssueIDs lists the project_board_id values a column claims. Rows written before
|
||||
// 1.22 carry 0, which the board renders in the default column, so the default column has
|
||||
// to claim them too.
|
||||
func columnIssueIDs(column *Column) []int64 {
|
||||
if column.Default {
|
||||
return []int64{column.ID, 0}
|
||||
}
|
||||
return []int64{column.ID}
|
||||
}
|
||||
|
||||
// IsIssueInColumn reports whether the issue is placed in the column.
|
||||
func IsIssueInColumn(ctx context.Context, issueID int64, column *Column) (bool, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where("issue_id=?", issueID).
|
||||
And("project_id=?", column.ProjectID).
|
||||
In("project_board_id", columnIssueIDs(column)).
|
||||
Exist(new(ProjectIssue))
|
||||
}
|
||||
|
||||
// GetColumnIssueIDs returns the IDs of the issues placed in a column.
|
||||
func GetColumnIssueIDs(ctx context.Context, column *Column) ([]int64, error) {
|
||||
issueIDs := make([]int64, 0, 10)
|
||||
return issueIDs, db.GetEngine(ctx).Table("project_issue").
|
||||
Where("project_id=?", column.ProjectID).
|
||||
In("project_board_id", columnIssueIDs(column)).
|
||||
Cols("issue_id").Find(&issueIDs)
|
||||
}
|
||||
|
||||
// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column.
|
||||
func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) {
|
||||
func GetColumnIssueNextSorting(ctx context.Context, column *Column) (int64, error) {
|
||||
res := struct {
|
||||
MaxSorting int64
|
||||
IssueCount int64
|
||||
}{}
|
||||
if _, err := db.GetEngine(ctx).Select("max(sorting) AS max_sorting, count(*) AS issue_count").
|
||||
Table("project_issue").
|
||||
Where("project_id=?", projectID).
|
||||
And("project_board_id=?", columnID).
|
||||
Where("project_id=?", column.ProjectID).
|
||||
In("project_board_id", columnIssueIDs(column)).
|
||||
Get(&res); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -66,7 +94,7 @@ func moveIssuesToAnotherColumn(ctx context.Context, oldColumn, newColumn *Column
|
||||
return nil
|
||||
}
|
||||
|
||||
nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn.ProjectID, newColumn.ID)
|
||||
nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/perm"
|
||||
@@ -295,44 +296,56 @@ func (r *RepoUnit) Unit() unit.Unit {
|
||||
return unit.Units[r.Type]
|
||||
}
|
||||
|
||||
// unitConfig returns the unit's config, which BeforeSet has created from the unit type
|
||||
func unitConfig[T interface {
|
||||
*E
|
||||
convert.Conversion
|
||||
}, E any](r *RepoUnit) T {
|
||||
config, ok := r.Config.(T)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("repo unit %d of type %s has config type %T instead of %T", r.ID, r.Type.LogString(), r.Config, config))
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// CodeConfig returns config for unit.TypeCode
|
||||
func (r *RepoUnit) CodeConfig() *UnitConfig {
|
||||
return r.Config.(*UnitConfig)
|
||||
return unitConfig[*UnitConfig](r)
|
||||
}
|
||||
|
||||
// PullRequestsConfig returns config for unit.TypePullRequests
|
||||
func (r *RepoUnit) PullRequestsConfig() *PullRequestsConfig {
|
||||
return r.Config.(*PullRequestsConfig)
|
||||
return unitConfig[*PullRequestsConfig](r)
|
||||
}
|
||||
|
||||
// ReleasesConfig returns config for unit.TypeReleases
|
||||
func (r *RepoUnit) ReleasesConfig() *UnitConfig {
|
||||
return r.Config.(*UnitConfig)
|
||||
return unitConfig[*UnitConfig](r)
|
||||
}
|
||||
|
||||
// ExternalWikiConfig returns config for unit.TypeExternalWiki
|
||||
func (r *RepoUnit) ExternalWikiConfig() *ExternalWikiConfig {
|
||||
return r.Config.(*ExternalWikiConfig)
|
||||
return unitConfig[*ExternalWikiConfig](r)
|
||||
}
|
||||
|
||||
// IssuesConfig returns config for unit.TypeIssues
|
||||
func (r *RepoUnit) IssuesConfig() *IssuesConfig {
|
||||
return r.Config.(*IssuesConfig)
|
||||
return unitConfig[*IssuesConfig](r)
|
||||
}
|
||||
|
||||
// ExternalTrackerConfig returns config for unit.TypeExternalTracker
|
||||
func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig {
|
||||
return r.Config.(*ExternalTrackerConfig)
|
||||
return unitConfig[*ExternalTrackerConfig](r)
|
||||
}
|
||||
|
||||
// ActionsConfig returns config for unit.ActionsConfig
|
||||
func (r *RepoUnit) ActionsConfig() *ActionsConfig {
|
||||
return r.Config.(*ActionsConfig)
|
||||
return unitConfig[*ActionsConfig](r)
|
||||
}
|
||||
|
||||
// ProjectsConfig returns config for unit.ProjectsConfig
|
||||
func (r *RepoUnit) ProjectsConfig() *ProjectsConfig {
|
||||
return r.Config.(*ProjectsConfig)
|
||||
return unitConfig[*ProjectsConfig](r)
|
||||
}
|
||||
|
||||
func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err error) {
|
||||
|
||||
+118
-13
@@ -28,14 +28,26 @@ const (
|
||||
WatchModeAuto // 3
|
||||
)
|
||||
|
||||
// WatchType is the `watch` column gating one kind of notification
|
||||
type WatchType string
|
||||
|
||||
const (
|
||||
WatchPullRequests WatchType = "pull_requests"
|
||||
WatchIssues WatchType = "issues"
|
||||
WatchReleases WatchType = "releases"
|
||||
)
|
||||
|
||||
// Watch is connection request for receiving repository notification.
|
||||
type Watch struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"UNIQUE(watch)"`
|
||||
RepoID int64 `xorm:"UNIQUE(watch)"`
|
||||
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"UNIQUE(watch)"`
|
||||
RepoID int64 `xorm:"UNIQUE(watch)"`
|
||||
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
PullRequests bool `xorm:"NOT NULL DEFAULT true"`
|
||||
Issues bool `xorm:"NOT NULL DEFAULT true"`
|
||||
Releases bool `xorm:"NOT NULL DEFAULT true"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -48,8 +60,8 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) {
|
||||
if err != nil {
|
||||
return watch, err
|
||||
}
|
||||
if watch == nil {
|
||||
watch = &Watch{UserID: userID, RepoID: repoID}
|
||||
if watch == nil { // the dummy record must mirror the column defaults
|
||||
watch = &Watch{UserID: userID, RepoID: repoID, PullRequests: true, Issues: true, Releases: true}
|
||||
}
|
||||
if !has {
|
||||
watch.Mode = WatchModeNone
|
||||
@@ -57,6 +69,34 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) {
|
||||
return watch, nil
|
||||
}
|
||||
|
||||
// IsIgnoring reports whether the user muted the repository entirely
|
||||
func (w *Watch) IsIgnoring() bool {
|
||||
return w.Mode == WatchModeDont
|
||||
}
|
||||
|
||||
// IsWatching reports whether the watch counts the user as a watcher of the repository
|
||||
func (w *Watch) IsWatching() bool {
|
||||
return IsWatchMode(w.Mode)
|
||||
}
|
||||
|
||||
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
|
||||
func (w *Watch) IsWatchingAll() bool {
|
||||
return w.PullRequests && w.Issues && w.Releases
|
||||
}
|
||||
|
||||
// SelectedMode returns the mode the user picked in the watch menu
|
||||
func (w *Watch) SelectedMode() string {
|
||||
switch {
|
||||
case w.IsIgnoring():
|
||||
return "ignore"
|
||||
case !IsWatchMode(w.Mode), !(w.PullRequests || w.Issues || w.Releases):
|
||||
return "participate" // also the default while there is no watch row
|
||||
case w.IsWatchingAll():
|
||||
return "all"
|
||||
}
|
||||
return "custom"
|
||||
}
|
||||
|
||||
// IsWatchMode Decodes watchability of WatchMode
|
||||
func IsWatchMode(mode WatchMode) bool {
|
||||
return mode != WatchModeNone && mode != WatchModeDont
|
||||
@@ -87,15 +127,16 @@ func watchRepoMode(ctx context.Context, watch *Watch, mode WatchMode) (err error
|
||||
repodiff = -1
|
||||
}
|
||||
|
||||
if repodiff == 1 { // starting to watch resets the options, otherwise a custom selection survives
|
||||
watch.PullRequests, watch.Issues, watch.Releases = true, true, true
|
||||
}
|
||||
watch.Mode = mode
|
||||
|
||||
if !hadrec && needsrec {
|
||||
watch.Mode = mode
|
||||
if err = db.Insert(ctx, watch); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if needsrec {
|
||||
watch.Mode = mode
|
||||
if _, err := db.GetEngine(ctx).ID(watch.ID).AllCols().Update(watch); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,25 +168,89 @@ func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doW
|
||||
return watchRepoMode(ctx, watch, WatchModeNormal)
|
||||
}
|
||||
|
||||
// GetWatchers returns all watchers of given repository.
|
||||
// WatchIgnoreRepo mutes the repository (unwatch), so nothing about it reaches the user.
|
||||
func WatchIgnoreRepo(ctx context.Context, doer *user_model.User, repo *Repository) error {
|
||||
watch, err := GetWatch(ctx, doer.ID, repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return watchRepoMode(ctx, watch, WatchModeDont)
|
||||
}
|
||||
|
||||
type WatchOptions struct {
|
||||
PullRequests bool
|
||||
Issues bool
|
||||
Releases bool
|
||||
}
|
||||
|
||||
// WatchRepoWithOptions starts watching the repository and subscribes to the given events
|
||||
func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repository, opts WatchOptions) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := WatchRepo(ctx, doer, repo, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return SetWatchOptions(ctx, doer.ID, repo.ID, opts)
|
||||
})
|
||||
}
|
||||
|
||||
// SetWatchOptions updates the per-event options of a watch, callers must run WatchRepo first
|
||||
func SetWatchOptions(ctx context.Context, userID, repoID int64, opts WatchOptions) error {
|
||||
_, err := db.GetEngine(ctx).Where("user_id=? AND repo_id=?", userID, repoID).
|
||||
Cols(string(WatchPullRequests), string(WatchIssues), string(WatchReleases)).
|
||||
Update(&Watch{PullRequests: opts.PullRequests, Issues: opts.Issues, Releases: opts.Releases})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserWatches returns the watches of one user, keyed by repository ID
|
||||
func GetUserWatches(ctx context.Context, userID int64, repoIDs []int64) (map[int64]*Watch, error) {
|
||||
if len(repoIDs) == 0 {
|
||||
return map[int64]*Watch{}, nil
|
||||
}
|
||||
watches := make([]*Watch, 0, len(repoIDs))
|
||||
if err := db.GetEngine(ctx).Where("user_id=?", userID).
|
||||
In("repo_id", repoIDs).
|
||||
And("mode<>?", WatchModeDont).
|
||||
Find(&watches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
watchesByRepo := make(map[int64]*Watch, len(watches))
|
||||
for _, watch := range watches {
|
||||
watchesByRepo[watch.RepoID] = watch
|
||||
}
|
||||
return watchesByRepo, nil
|
||||
}
|
||||
|
||||
// GetWatchers returns all watchers of given repository, skipping those subscribed to no event.
|
||||
func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
|
||||
watches := make([]*Watch, 0, 10)
|
||||
return watches, db.GetEngine(ctx).Where("`watch`.repo_id=?", repoID).
|
||||
And("`watch`.mode<>?", WatchModeDont).
|
||||
And(builder.Or(builder.Eq{"`watch`.pull_requests": true}, builder.Eq{"`watch`.issues": true}, builder.Eq{"`watch`.releases": true})).
|
||||
And("`user`.is_active=?", true).
|
||||
And("`user`.prohibit_login=?", false).
|
||||
Join("INNER", "`user`", "`user`.id = `watch`.user_id").
|
||||
Find(&watches)
|
||||
}
|
||||
|
||||
// GetRepoWatchersIDs returns IDs of watchers for a given repo ID
|
||||
// GetRepoIgnorersIDs returns IDs of users who muted the given repo ID
|
||||
func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) {
|
||||
ids := make([]int64, 0, 8)
|
||||
return ids, db.GetEngine(ctx).Table("watch").
|
||||
Where("repo_id=?", repoID).
|
||||
And("mode=?", WatchModeDont).
|
||||
Select("user_id").
|
||||
Find(&ids)
|
||||
}
|
||||
|
||||
// GetRepoWatchersIDs returns IDs of watchers for a given repo ID that opted into watchType
|
||||
// but avoids joining with `user` for performance reasons
|
||||
// User permissions must be verified elsewhere if required
|
||||
func GetRepoWatchersIDs(ctx context.Context, repoID int64) ([]int64, error) {
|
||||
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
|
||||
ids := make([]int64, 0, 64)
|
||||
return ids, db.GetEngine(ctx).Table("watch").
|
||||
Where("watch.repo_id=?", repoID).
|
||||
And("watch.mode<>?", WatchModeDont).
|
||||
And(builder.Eq{"watch." + string(watchType): true}).
|
||||
Select("user_id").
|
||||
Find(&ids)
|
||||
}
|
||||
|
||||
@@ -125,16 +125,56 @@ func TestClearRepoWatches(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const repoID int64 = 1
|
||||
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repoID)
|
||||
watchers, err := repo_model.GetRepoWatchers(t.Context(), repoID, db.ListOptions{Page: 1})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, watchers)
|
||||
|
||||
assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID))
|
||||
|
||||
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repoID)
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repoID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, watchers)
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
assert.Zero(t, repo.NumWatches)
|
||||
}
|
||||
|
||||
func TestWatchOptions(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
assert.NoError(t, repo_model.SetWatchOptions(t.Context(), user.ID, repo.ID, repo_model.WatchOptions{PullRequests: true}))
|
||||
|
||||
for watchType, expected := range map[repo_model.WatchType][]int64{
|
||||
repo_model.WatchPullRequests: {1, 4, 9, 11},
|
||||
repo_model.WatchIssues: {4, 9, 11},
|
||||
repo_model.WatchReleases: {4, 9, 11},
|
||||
} {
|
||||
ids, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID, watchType)
|
||||
assert.NoError(t, err)
|
||||
assert.ElementsMatch(t, expected, ids, watchType)
|
||||
}
|
||||
|
||||
// the options of one user must not show up for another
|
||||
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, watches[repo.ID].Issues)
|
||||
|
||||
// watching again resets a custom selection
|
||||
assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, false))
|
||||
assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, true))
|
||||
watch, err := repo_model.GetWatch(t.Context(), user.ID, repo.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, watch.IsWatchingAll())
|
||||
}
|
||||
|
||||
func TestWatchSelectedMode(t *testing.T) {
|
||||
// a user without a watch row gets the dummy record, whose flags are the column defaults
|
||||
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNone, PullRequests: true, Issues: true, Releases: true}).SelectedMode())
|
||||
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
|
||||
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
|
||||
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, Issues: true}).SelectedMode())
|
||||
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, PullRequests: true, Issues: true, Releases: true}).SelectedMode())
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ type FixtureItem struct {
|
||||
|
||||
type fixturesLoaderInternal struct {
|
||||
xormEngine *xorm.Engine
|
||||
tableSyncMap sync.Map
|
||||
tableSyncMu sync.Mutex
|
||||
tableSynced map[string]bool
|
||||
db *sql.DB
|
||||
dbType schemas.DBType
|
||||
fixtures map[string]*FixtureItem
|
||||
@@ -152,32 +153,35 @@ func (f *fixturesLoaderInternal) Load() error {
|
||||
|
||||
ctx := context.WithValue(context.Background(), db.ContextKeyTestFixtures, true)
|
||||
|
||||
f.tableSyncMu.Lock()
|
||||
defer f.tableSyncMu.Unlock()
|
||||
|
||||
for _, fixture := range f.fixtures {
|
||||
synced, existing := f.tableSyncMap.Load(fixture.tableName)
|
||||
if synced == true || !existing {
|
||||
synced, existing := f.tableSynced[fixture.tableName]
|
||||
if synced || !existing {
|
||||
continue
|
||||
}
|
||||
if err := f.loadFixtures(tx, fixture); err != nil {
|
||||
return fmt.Errorf("failed to load fixtures from %s: %w", fixture.fileFullPath, err)
|
||||
}
|
||||
f.tableSyncMap.Store(fixture.tableName, true)
|
||||
f.tableSynced[fixture.tableName] = true
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
f.tableSyncMap.Range(func(k, v any) bool {
|
||||
tableName, synced := k.(string), v.(bool)
|
||||
for tableName, synced := range f.tableSynced {
|
||||
if !synced && f.fixtures[tableName] == nil {
|
||||
_, _ = f.xormEngine.Context(ctx).Exec("DELETE FROM `" + tableName + "`")
|
||||
}
|
||||
f.tableSyncMap.Store(tableName, true)
|
||||
return true
|
||||
})
|
||||
f.tableSynced[tableName] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fixturesLoaderInternal) MarkTableChanged(tableName string) {
|
||||
f.tableSyncMap.Store(tableName, false)
|
||||
f.tableSyncMu.Lock()
|
||||
defer f.tableSyncMu.Unlock()
|
||||
f.tableSynced[tableName] = false
|
||||
}
|
||||
|
||||
func FixturesFileFullPaths(dir string, files []string) (map[string]*FixtureItem, error) {
|
||||
@@ -212,7 +216,7 @@ func NewFixturesLoader(x *xorm.Engine, opts FixturesOptions) (FixturesLoader, er
|
||||
return nil, fmt.Errorf("failed to get fixtures files: %w", err)
|
||||
}
|
||||
|
||||
f := &fixturesLoaderInternal{xormEngine: x, db: x.DB().DB, dbType: x.Dialect().URI().DBType, fixtures: fixtureItems}
|
||||
f := &fixturesLoaderInternal{xormEngine: x, db: x.DB().DB, dbType: x.Dialect().URI().DBType, fixtures: fixtureItems, tableSynced: map[string]bool{}}
|
||||
switch f.dbType {
|
||||
case schemas.SQLITE:
|
||||
f.quoteObject = func(s string) string { return fmt.Sprintf(`"%s"`, s) }
|
||||
@@ -233,7 +237,7 @@ func NewFixturesLoader(x *xorm.Engine, opts FixturesOptions) (FixturesLoader, er
|
||||
xormBeans, _ := db.NamesToBean()
|
||||
for _, bean := range xormBeans {
|
||||
beanTableName := x.TableName(bean)
|
||||
f.tableSyncMap.Store(trimTableNameQuotes(beanTableName), false)
|
||||
f.tableSynced[trimTableNameQuotes(beanTableName)] = false
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// ExpressionEvaluator is copied from runner.expressionEvaluator,
|
||||
// to avoid unnecessary dependencies
|
||||
type ExpressionEvaluator struct {
|
||||
interpreter exprparser.Interpreter
|
||||
}
|
||||
|
||||
func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvaluator {
|
||||
return &ExpressionEvaluator{interpreter: interpreter}
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
||||
var in string
|
||||
if err := node.Decode(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return nil
|
||||
}
|
||||
res, err := ee.evaluateScalar(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return node.Encode(res)
|
||||
}
|
||||
|
||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||
var insertDirective = regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
|
||||
for i := 0; i < len(node.Content)/2; {
|
||||
k := node.Content[i*2]
|
||||
v := node.Content[i*2+1]
|
||||
if err := ee.EvaluateYamlNode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
var sk string
|
||||
// Merge the nested map of the insert directive
|
||||
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
|
||||
node.Content = append(append(node.Content[:i*2], v.Content...), node.Content[(i+1)*2:]...)
|
||||
i += len(v.Content) / 2
|
||||
} else {
|
||||
if err := ee.EvaluateYamlNode(k); err != nil {
|
||||
return err
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateSequenceYamlNode(node *yaml.Node) error {
|
||||
for i := 0; i < len(node.Content); {
|
||||
v := node.Content[i]
|
||||
// Preserve nested sequences
|
||||
wasseq := v.Kind == yaml.SequenceNode
|
||||
if err := ee.EvaluateYamlNode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
// GitHub has this undocumented feature to merge sequences / arrays
|
||||
// We have a nested sequence via evaluation, merge the arrays
|
||||
if v.Kind == yaml.SequenceNode && !wasseq {
|
||||
node.Content = append(append(node.Content[:i], v.Content...), node.Content[i+1:]...)
|
||||
i += len(v.Content)
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
return ee.evaluateScalarYamlNode(node)
|
||||
case yaml.MappingNode:
|
||||
return ee.evaluateMappingYamlNode(node)
|
||||
case yaml.SequenceNode:
|
||||
return ee.evaluateSequenceYamlNode(node)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
||||
func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(parts) == 1 && !parts[0].isExpr {
|
||||
return in, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(in))
|
||||
for _, part := range parts {
|
||||
if !part.isExpr {
|
||||
out.WriteString(part.text)
|
||||
continue
|
||||
}
|
||||
evaluated, err := ee.interpreter.Evaluate(part.text, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(coerceToString(evaluated))
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
||||
func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parts) == 1 && parts[0].isExpr {
|
||||
return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
|
||||
}
|
||||
return ee.interpolate(in)
|
||||
}
|
||||
|
||||
// evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
|
||||
// interpolates to a string, so the success() default applies to it separately.
|
||||
func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
evaluated, err := ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckSuccess)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(evaluated), nil
|
||||
}
|
||||
|
||||
// mixed content is a string, so the success() default applies to it separately
|
||||
if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
|
||||
status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exprparser.IsTruthy(status) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
interpolated, err := ee.interpolate(in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(interpolated), nil
|
||||
}
|
||||
|
||||
// coerceToString converts an evaluated expression value to a string the way GitHub does,
|
||||
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
|
||||
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
|
||||
func coerceToString(v any) string {
|
||||
value, ok := v.(reflect.Value)
|
||||
if !ok {
|
||||
value = reflect.ValueOf(v)
|
||||
}
|
||||
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return ""
|
||||
|
||||
case reflect.Bool:
|
||||
return strconv.FormatBool(value.Bool())
|
||||
|
||||
case reflect.String:
|
||||
return value.String()
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return strconv.FormatInt(value.Int(), 10)
|
||||
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return strconv.FormatUint(value.Uint(), 10)
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
if math.IsInf(value.Float(), 1) {
|
||||
return "Infinity"
|
||||
} else if math.IsInf(value.Float(), -1) {
|
||||
return "-Infinity"
|
||||
}
|
||||
return fmt.Sprintf("%.15G", value.Float())
|
||||
|
||||
case reflect.Slice, reflect.Array:
|
||||
return "Array"
|
||||
|
||||
// contexts such as `github` are pointers to structs, so they stringify as objects too
|
||||
case reflect.Map, reflect.Struct:
|
||||
return "Object"
|
||||
|
||||
case reflect.Interface, reflect.Pointer:
|
||||
if value.IsNil() {
|
||||
return ""
|
||||
}
|
||||
return coerceToString(value.Elem())
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
|
||||
type exprPart struct {
|
||||
text string
|
||||
isExpr bool
|
||||
}
|
||||
|
||||
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
|
||||
// complete expression literal.
|
||||
func splitSubExpressions(in string) ([]exprPart, error) {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return []exprPart{{text: in}}, nil
|
||||
}
|
||||
|
||||
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
|
||||
for {
|
||||
start := strings.Index(in, "${{")
|
||||
if start < 0 {
|
||||
if in != "" {
|
||||
parts = append(parts, exprPart{text: in})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
if start > 0 {
|
||||
parts = append(parts, exprPart{text: in[:start]})
|
||||
}
|
||||
rest := in[start+len("${{"):]
|
||||
end := indexExprEnd(rest)
|
||||
if end < 0 {
|
||||
return nil, errors.New("unclosed expression")
|
||||
}
|
||||
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
|
||||
in = rest[end+len("}}"):]
|
||||
}
|
||||
}
|
||||
|
||||
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
|
||||
// state, so a `}}` inside a string does not end it.
|
||||
func indexExprEnd(in string) int {
|
||||
inString := false
|
||||
for i := range len(in) {
|
||||
switch {
|
||||
case in[i] == '\'':
|
||||
inString = !inString
|
||||
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -59,8 +60,6 @@ func NewInterpeter(
|
||||
ee := &exprparser.EvaluationEnvironment{
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
// Job must be non-nil because cancelled() dereferences Job.Status unconditionally.
|
||||
// See: https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299
|
||||
// TODO: The empty JobContext.Status is right for now because Gitea never checks `if` condition when the workflow run is cancelled.
|
||||
// This is an implementation gap in Gitea Actions. When a workflow run is cancelled, Gitea should check the job's `if` condition,
|
||||
// and if the condition is met (e.g. `if: ${{ cancelled() }}` ), the job should be executed rather than cancelled.
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.dev/actionslib/pkg/expreval"
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
|
||||
"github.com/rhysd/actionlint"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -72,15 +74,7 @@ func ExpressionReadsMatrix(ifValue string) bool {
|
||||
// the status functions that run a job whatever its needs did rather than under the implicit success().
|
||||
// Keep in sync with act's exprparser, which owns the same list for the evaluation itself.
|
||||
func ExpressionIgnoresNeedResults(ifValue string) bool {
|
||||
return expressionCallsFunction(asIfExpression(ifValue), "always", "failure", "cancelled")
|
||||
}
|
||||
|
||||
// expressionCallsFunction reports whether any ${{ }} expression in value calls one of the functions.
|
||||
func expressionCallsFunction(value string, names ...string) bool {
|
||||
return expressionsMatch(value, func(node actionlint.ExprNode) bool {
|
||||
call, ok := node.(*actionlint.FuncCallNode)
|
||||
return ok && slices.Contains(names, strings.ToLower(call.Callee))
|
||||
})
|
||||
return expreval.CallsFunction(asIfExpression(ifValue), "always", "failure", "cancelled")
|
||||
}
|
||||
|
||||
// asIfExpression wraps an `if:` that omits the `${{ }}`, which GitHub evaluates as one expression anyway.
|
||||
@@ -95,40 +89,12 @@ func asIfExpression(ifValue string) string {
|
||||
|
||||
// expressionReadsContext reports whether value holds a ${{ }} expression reading the named context.
|
||||
func expressionReadsContext(value, contextName string) bool {
|
||||
return expressionsMatch(value, func(node actionlint.ExprNode) bool {
|
||||
return expreval.Match(value, func(node actionlint.ExprNode) bool {
|
||||
variable, ok := node.(*actionlint.VariableNode)
|
||||
return ok && strings.EqualFold(variable.Name, contextName)
|
||||
})
|
||||
}
|
||||
|
||||
// expressionsMatch reports whether any ${{ }} expression in value holds a node the predicate accepts.
|
||||
func expressionsMatch(value string, match func(node actionlint.ExprNode) bool) bool {
|
||||
parts, err := splitSubExpressions(value)
|
||||
if err != nil {
|
||||
return true // unparseable here, let the expansion report it against the real values
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !part.isExpr {
|
||||
continue
|
||||
}
|
||||
// The lexer needs the closing `}}` that the scanner strips.
|
||||
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
|
||||
if err != nil {
|
||||
return true // unparseable here, let the expansion report it against the real values
|
||||
}
|
||||
matched := false
|
||||
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
|
||||
if entering && match(node) {
|
||||
matched = true
|
||||
}
|
||||
})
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
origin, err := model.ReadWorkflow(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
@@ -162,8 +128,8 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
return nil, fmt.Errorf("invalid jobs: %w", err)
|
||||
}
|
||||
|
||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
|
||||
if workflow.RunName, err = evaluator.interpolate(workflow.RunName); err != nil {
|
||||
evaluator := expreval.New(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}).Evaluate)
|
||||
if workflow.RunName, err = evaluator.Interpolate(workflow.RunName); err != nil {
|
||||
return nil, fmt.Errorf("interpolate run-name: %w", err)
|
||||
}
|
||||
|
||||
@@ -230,7 +196,7 @@ func ExpandMatrixWithNeeds(jobID string, job *Job, gitCtx *model.GithubContext,
|
||||
}}
|
||||
|
||||
// Resolve fromJson(needs.*.outputs.*) and friends into concrete matrix values.
|
||||
if err := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, gitCtx, results, vars, inputs)).
|
||||
if err := expreval.New(NewInterpeter(jobID, actJob, nil, gitCtx, results, vars, inputs).Evaluate).
|
||||
EvaluateYamlNode(&actJob.Strategy.RawMatrix); err != nil {
|
||||
return nil, fmt.Errorf("evaluate matrix: %w", err)
|
||||
}
|
||||
@@ -253,7 +219,6 @@ func ExpandMatrixWithNeeds(jobID string, job *Job, gitCtx *model.GithubContext,
|
||||
// matrixesOf is this package's only entry to act's GetMatrixes, so that every caller is covered by
|
||||
// the filter check below. A deferred placeholder is the first thing carrying a raw matrix this far,
|
||||
// and the emitter reads its `if:` before expanding it.
|
||||
// TODO: drop the check once gitea.com/gitea/runner validates the shape itself.
|
||||
func matrixesOf(job *model.Job) ([]map[string]any, error) {
|
||||
if err := validateMatrixFilters(job); err != nil {
|
||||
return nil, err
|
||||
@@ -265,9 +230,9 @@ func matrixesOf(job *model.Job) ([]map[string]any, error) {
|
||||
return matrixes, nil
|
||||
}
|
||||
|
||||
// validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings. act asserts
|
||||
// that shape without checking, so anything else panics there; an unevaluated ${{ }} expression, which
|
||||
// is still a scalar, is the usual way to reach it.
|
||||
// validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings, so that the
|
||||
// usual way to get there, an unevaluated ${{ }} expression that is still a scalar, is named as such
|
||||
// instead of surfacing from the middle of the expansion.
|
||||
func validateMatrixFilters(job *model.Job) error {
|
||||
if job.Strategy == nil || job.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
@@ -306,13 +271,13 @@ func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, actJob
|
||||
combo.Name = jobID
|
||||
}
|
||||
combo.Strategy.RawMatrix = encodeMatrix(matrix)
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, gitCtx, results, vars, inputs))
|
||||
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, gitCtx, results, vars, inputs).Evaluate)
|
||||
if combo.Name, err = nameWithMatrix(combo.Name, matrix, evaluator); err != nil {
|
||||
return nil, fmt.Errorf("interpolate name for job %q: %w", jobID, err)
|
||||
}
|
||||
runsOn := slices.Clone(srcRunsOn)
|
||||
for i := range runsOn {
|
||||
if runsOn[i], err = evaluator.interpolate(runsOn[i]); err != nil {
|
||||
if runsOn[i], err = evaluator.Interpolate(runsOn[i]); err != nil {
|
||||
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", jobID, err)
|
||||
}
|
||||
}
|
||||
@@ -386,7 +351,7 @@ func encodeRunsOn(runsOn []string) yaml.Node {
|
||||
return node
|
||||
}
|
||||
|
||||
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) (string, error) {
|
||||
func nameWithMatrix(name string, m map[string]any, evaluator expreval.Evaluator) (string, error) {
|
||||
if len(m) == 0 {
|
||||
return name, nil
|
||||
}
|
||||
@@ -395,7 +360,7 @@ func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluato
|
||||
return name + " " + matrixName(m), nil
|
||||
}
|
||||
|
||||
return evaluator.interpolate(name)
|
||||
return evaluator.Interpolate(name)
|
||||
}
|
||||
|
||||
func matrixName(m map[string]any) string {
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
@@ -286,8 +287,8 @@ func evaluateJobIf(t *testing.T, matrixYAML, ifExpr string, deferred bool) (bool
|
||||
}
|
||||
|
||||
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
|
||||
// act dereferences include/exclude entries as mappings without checking, so an unevaluated
|
||||
// expression panics there. Every entry point into act's matrix expansion must reject it. The
|
||||
// An unevaluated expression is still a scalar, which is not a filter act can apply.
|
||||
// Every entry point into act's matrix expansion must reject it. The
|
||||
// expression here reads `vars`, which is available while planning, so the job is not deferred and
|
||||
// nothing will ever resolve the filter: the error is the right answer at both entry points.
|
||||
// A deferred placeholder is the other case, covered by TestEvaluateJobIfExpressionLeavesRawMatrixUnavailable.
|
||||
@@ -328,8 +329,7 @@ jobs:
|
||||
parseCount int // what Parse makes of the stored payload
|
||||
parseErrHas string // ... or the error it fails with
|
||||
}{
|
||||
// The canonical GitHub dynamic-matrix idiom. act dereferences include entries as mappings, so
|
||||
// validateMatrixFilters rejects the still-scalar expression outright.
|
||||
// The canonical GitHub dynamic-matrix idiom. validateMatrixFilters rejects the still-scalar expression outright.
|
||||
{name: "include expression", matrix: "include: ${{ fromJson(needs.setup.outputs.m) }}", parseErrHas: "must be a list of mappings"},
|
||||
// A static vector crossed with the unevaluated expression: one workflow per static value.
|
||||
{name: "static vector and expression", matrix: "os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.m) }}", parseCount: 2},
|
||||
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.dev/actionslib/pkg/expreval"
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -276,7 +279,7 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
|
||||
var node yaml.Node
|
||||
if err := node.Encode(rc); err != nil {
|
||||
return "", false, fmt.Errorf("failed to encode concurrency: %w", err)
|
||||
@@ -524,8 +527,8 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
return evaluator.evaluateCondition(job.If.Value)
|
||||
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
|
||||
return evaluator.EvalBool(job.If.Value, exprparser.DefaultStatusCheckSuccess)
|
||||
}
|
||||
|
||||
// parseMappingNode parse a mapping node and preserve order.
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type UsesKind int
|
||||
|
||||
const (
|
||||
// UsesKindLocalSameRepo is "./<dir>/foo.yml" - a path inside the calling repository.
|
||||
// UsesKindLocalSameRepo is "./<dir>/foo.yml" or "$/<dir>/foo.yml" - a path inside the calling repository.
|
||||
// For example: "./.gitea/workflows/foo.yml"
|
||||
UsesKindLocalSameRepo UsesKind = iota + 1
|
||||
// UsesKindLocalCrossRepo is "owner/repo/<dir>/foo.yml@ref" - a workflow in another repo on the same instance.
|
||||
@@ -33,13 +33,13 @@ type UsesRef struct {
|
||||
}
|
||||
|
||||
var (
|
||||
reLocalSameRepo = regexp.MustCompile(`^\./([^@]+\.ya?ml)$`)
|
||||
reLocalSameRepo = regexp.MustCompile(`^[.$]/([^@]+\.ya?ml)$`)
|
||||
reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/([^@]+\.ya?ml)@(.+)$`)
|
||||
)
|
||||
|
||||
// ParseUses parses the SYNTAX of a reusable workflow "uses:" value into a UsesRef. Two forms are supported:
|
||||
// - "./<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
|
||||
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
|
||||
// - "./<dir>/foo.yml" or "$/<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
|
||||
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
|
||||
//
|
||||
// It deliberately does NOT validate that <dir> is an allowed workflow directory: the allowed directories are instance-configurable (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS).
|
||||
// The caller (services/actions.ResolveUses) enforces the directory allowlist. The returned Path is the cleaned, repo-relative file path.
|
||||
@@ -49,10 +49,10 @@ func ParseUses(s string) (*UsesRef, error) {
|
||||
return nil, errors.New("empty uses value")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s, "./") {
|
||||
if strings.HasPrefix(s, "./") || strings.HasPrefix(s, "$/") {
|
||||
m := reLocalSameRepo.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.yml)`, s)
|
||||
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.yml or $/<dir>/<file>.yml)`, s)
|
||||
}
|
||||
p := m[1]
|
||||
if path.Clean(p) != p {
|
||||
|
||||
@@ -53,6 +53,11 @@ func TestParseUses(t *testing.T) {
|
||||
in: "./.gitea/custom_workflows/x.yaml",
|
||||
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/custom_workflows/x.yaml"},
|
||||
},
|
||||
{
|
||||
name: "self-repo prefix",
|
||||
in: "$/.gitea/workflows/build.yml",
|
||||
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
|
||||
},
|
||||
{
|
||||
name: "leading/trailing whitespace is trimmed",
|
||||
in: " ./.gitea/workflows/build.yml ",
|
||||
@@ -160,6 +165,7 @@ func TestParseUses(t *testing.T) {
|
||||
|
||||
// Same-repo malformed (note: a wrong *directory* parses and should be rejected by the caller)
|
||||
{name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"},
|
||||
{name: "self-repo with @ref", in: "$/.gitea/workflows/build.yml@v1"},
|
||||
{name: "same-repo wrong extension", in: "./.gitea/workflows/build.txt"},
|
||||
{name: "same-repo missing extension", in: "./.gitea/workflows/build"},
|
||||
{name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"},
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/actionslib/pkg/expreval"
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -185,7 +186,7 @@ func EvaluateCallerWith(
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
|
||||
|
||||
out := make(map[string]any, len(job.With))
|
||||
for k, raw := range job.With {
|
||||
@@ -260,7 +261,7 @@ func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]a
|
||||
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
|
||||
switch typ {
|
||||
case InputTypeString:
|
||||
return coerceToString(v), nil
|
||||
return exprparser.CoerceToString(v), nil
|
||||
case InputTypeBoolean:
|
||||
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
|
||||
if b, ok := v.(bool); ok {
|
||||
@@ -361,11 +362,11 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
|
||||
Vars: vars,
|
||||
Inputs: inputs,
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(env, exprparser.Config{}))
|
||||
evaluator := expreval.New(exprparser.NewInterpeter(env, exprparser.Config{}).Evaluate)
|
||||
|
||||
out := make(map[string]string, len(spec.Outputs))
|
||||
for name, o := range spec.Outputs {
|
||||
v, err := evaluator.interpolate(o.Value)
|
||||
v, err := evaluator.Interpolate(o.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"gitea.dev/models/dbfs"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/storage"
|
||||
|
||||
@@ -64,7 +64,7 @@ func MatchScopedWorkflows(
|
||||
parsed []*ParsedScopedWorkflow,
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
triggedEvent webhook_module.HookEventType,
|
||||
inputEvent webhook_module.HookEventType,
|
||||
payload api.Payloader,
|
||||
) (matched, filtered []*DetectedWorkflow) {
|
||||
for _, p := range parsed {
|
||||
@@ -78,7 +78,7 @@ func MatchScopedWorkflows(
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
}
|
||||
switch detectWorkflowMatch(ctx, consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
||||
switch detectWorkflowMatch(ctx, consumerGitRepo, consumerCommit, inputEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
matched = append(matched, dwf)
|
||||
case detectFilteredOut:
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/actions/workflowpattern"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -21,7 +22,6 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -266,12 +266,22 @@ func DetectScheduledWorkflows(ctx context.Context, gitRepo *git.Repository, comm
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func detectWorkflowMatch(ctx context.Context, gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) detectResult {
|
||||
if !canGithubEventMatch(evt.Name, triggedEvent) {
|
||||
// payloadAs returns the payload as the type the event is expected to carry
|
||||
func payloadAs[T api.Payloader](payload api.Payloader, inputEvent webhook_module.HookEventType) T {
|
||||
typedPayload, ok := payload.(T)
|
||||
if !ok {
|
||||
// the event type determines the payload type, so a mismatch can only be a programming error
|
||||
panic(fmt.Errorf("event %q was triggered with payload type %T instead of %T", inputEvent, payload, typedPayload))
|
||||
}
|
||||
return typedPayload
|
||||
}
|
||||
|
||||
func detectWorkflowMatch(ctx context.Context, gitRepo *git.Repository, commit *git.Commit, inputEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) detectResult {
|
||||
if !canGithubEventMatch(evt.Name, inputEvent) {
|
||||
return detectNotApplicable
|
||||
}
|
||||
|
||||
switch triggedEvent {
|
||||
switch inputEvent {
|
||||
case // events with no activity types
|
||||
webhook_module.HookEventCreate,
|
||||
webhook_module.HookEventDelete,
|
||||
@@ -279,21 +289,23 @@ func detectWorkflowMatch(ctx context.Context, gitRepo *git.Repository, commit *g
|
||||
webhook_module.HookEventWiki,
|
||||
webhook_module.HookEventSchedule:
|
||||
if len(evt.Acts()) != 0 {
|
||||
log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
|
||||
log.Warn("Ignore unsupported %s event arguments %v", inputEvent, evt.Acts())
|
||||
}
|
||||
// no special filter parameters for these events, just return true if name matched
|
||||
return detectMatched
|
||||
|
||||
case // push
|
||||
webhook_module.HookEventPush:
|
||||
return matchPushEvent(ctx, gitRepo, commit, payload.(*api.PushPayload), evt)
|
||||
pushPayload := payloadAs[*api.PushPayload](payload, inputEvent)
|
||||
return matchPushEvent(ctx, gitRepo, commit, pushPayload, evt)
|
||||
|
||||
case // issues
|
||||
webhook_module.HookEventIssues,
|
||||
webhook_module.HookEventIssueAssign,
|
||||
webhook_module.HookEventIssueLabel,
|
||||
webhook_module.HookEventIssueMilestone:
|
||||
if matchIssuesEvent(payload.(*api.IssuePayload), evt) {
|
||||
issuePayload := payloadAs[*api.IssuePayload](payload, inputEvent)
|
||||
if matchIssuesEvent(issuePayload, evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
@@ -303,7 +315,8 @@ func detectWorkflowMatch(ctx context.Context, gitRepo *git.Repository, commit *g
|
||||
// `pull_request_comment` is same as `issue_comment`
|
||||
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
|
||||
webhook_module.HookEventPullRequestComment:
|
||||
if matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt) {
|
||||
issueCommentPayload := payloadAs[*api.IssueCommentPayload](payload, inputEvent)
|
||||
if matchIssueCommentEvent(issueCommentPayload, evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
@@ -315,46 +328,52 @@ func detectWorkflowMatch(ctx context.Context, gitRepo *git.Repository, commit *g
|
||||
webhook_module.HookEventPullRequestLabel,
|
||||
webhook_module.HookEventPullRequestReviewRequest,
|
||||
webhook_module.HookEventPullRequestMilestone:
|
||||
return matchPullRequestEvent(ctx, gitRepo, commit, payload.(*api.PullRequestPayload), evt)
|
||||
pullRequestPayload := payloadAs[*api.PullRequestPayload](payload, inputEvent)
|
||||
return matchPullRequestEvent(ctx, gitRepo, commit, pullRequestPayload, evt)
|
||||
|
||||
case // pull_request_review
|
||||
webhook_module.HookEventPullRequestReviewApproved,
|
||||
webhook_module.HookEventPullRequestReviewRejected:
|
||||
if matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt) {
|
||||
reviewPayload := payloadAs[*api.PullRequestPayload](payload, inputEvent)
|
||||
if matchPullRequestReviewEvent(reviewPayload, evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // pull_request_review_comment
|
||||
webhook_module.HookEventPullRequestReviewComment:
|
||||
if matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt) {
|
||||
reviewCommentPayload := payloadAs[*api.PullRequestPayload](payload, inputEvent)
|
||||
if matchPullRequestReviewCommentEvent(reviewCommentPayload, evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // release
|
||||
webhook_module.HookEventRelease:
|
||||
if matchReleaseEvent(payload.(*api.ReleasePayload), evt) {
|
||||
releasePayload := payloadAs[*api.ReleasePayload](payload, inputEvent)
|
||||
if matchReleaseEvent(releasePayload, evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // registry_package
|
||||
webhook_module.HookEventPackage:
|
||||
if matchPackageEvent(payload.(*api.PackagePayload), evt) {
|
||||
packagePayload := payloadAs[*api.PackagePayload](payload, inputEvent)
|
||||
if matchPackageEvent(packagePayload, evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // workflow_run
|
||||
webhook_module.HookEventWorkflowRun:
|
||||
if matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) {
|
||||
workflowRunPayload := payloadAs[*api.WorkflowRunPayload](payload, inputEvent)
|
||||
if matchWorkflowRunEvent(workflowRunPayload, evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
default:
|
||||
log.Warn("unsupported event %q", triggedEvent)
|
||||
log.Warn("unsupported event %q", inputEvent)
|
||||
return detectNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ func TestEmbed(t *testing.T) {
|
||||
assert.Equal(t, "a", string(content))
|
||||
fi, err := fs.Stat(efs, "a.txt")
|
||||
require.NoError(t, err)
|
||||
_, ok := fi.(EmbeddedFileInfo).GetGzipContent()
|
||||
fiEmbedded, ok := fi.(EmbeddedFileInfo)
|
||||
require.True(t, ok)
|
||||
_, ok = fiEmbedded.GetGzipContent()
|
||||
assert.False(t, ok)
|
||||
|
||||
// test a compressed file
|
||||
@@ -48,7 +50,9 @@ func TestEmbed(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.False(t, fi.Mode().IsDir())
|
||||
assert.True(t, fi.Mode().IsRegular())
|
||||
gzipContent, ok := fi.(EmbeddedFileInfo).GetGzipContent()
|
||||
fiEmbedded, ok = fi.(EmbeddedFileInfo)
|
||||
require.True(t, ok)
|
||||
gzipContent, ok := fiEmbedded.GetGzipContent()
|
||||
assert.True(t, ok)
|
||||
assert.Greater(t, len(gzipContent), 1)
|
||||
assert.Less(t, len(gzipContent), 1000)
|
||||
@@ -82,7 +86,7 @@ func TestEmbed(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
hi, err := hf.Stat()
|
||||
require.NoError(t, err)
|
||||
fiEmbedded, ok := hi.(EmbeddedFileInfo)
|
||||
fiEmbedded, ok = hi.(EmbeddedFileInfo)
|
||||
require.True(t, ok)
|
||||
gzipContent, ok = fiEmbedded.GetGzipContent()
|
||||
assert.True(t, ok)
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// EncodeSha256 string to sha256 hex value.
|
||||
func EncodeSha256(str string) string {
|
||||
h := sha256.New()
|
||||
_, _ = h.Write([]byte(str))
|
||||
_, _ = h.Write(util.UnsafeStringToBytes(str))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
@@ -64,15 +64,17 @@ func CreateTimeLimitCode[T time.Time | string](data string, minutes int, startTi
|
||||
const format = "200601021504"
|
||||
|
||||
var start time.Time
|
||||
var startTimeAny any = startTimeGeneric
|
||||
if t, ok := startTimeAny.(time.Time); ok {
|
||||
start = t
|
||||
} else {
|
||||
switch startTime := any(startTimeGeneric).(type) {
|
||||
case time.Time:
|
||||
start = startTime
|
||||
case string:
|
||||
var err error
|
||||
start, err = time.ParseInLocation(format, startTimeAny.(string), time.Local)
|
||||
start, err = time.ParseInLocation(format, startTime, time.Local)
|
||||
if err != nil {
|
||||
return "" // return an invalid code because the "parse" failed
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported start time type %T", startTime)) // it shouldn't happen
|
||||
}
|
||||
startStr := start.Format(format)
|
||||
end := start.Add(time.Minute * time.Duration(minutes))
|
||||
|
||||
Vendored
+1
-1
@@ -25,7 +25,7 @@ func TestWithCacheContext(t *testing.T) {
|
||||
c.Put(field, "my_config1", 1)
|
||||
v, _ = c.Get(field, "my_config1")
|
||||
assert.NotNil(t, v)
|
||||
assert.Equal(t, 1, v.(int))
|
||||
assert.Equal(t, 1, v)
|
||||
|
||||
c.Delete(field, "my_config1")
|
||||
c.Delete(field, "my_config2") // remove a non-exist key
|
||||
|
||||
@@ -82,7 +82,7 @@ func getLastCommitForPathsByCommitNode(ctx context.Context, gitRepo *Repository,
|
||||
|
||||
// We do a tree traversal with nodes sorted by commit time
|
||||
heap := binaryheap.NewWith(func(a, b any) int {
|
||||
if a.(*commitAndPaths).commit.CommitTime().Before(b.(*commitAndPaths).commit.CommitTime()) {
|
||||
if a.(*commitAndPaths).commit.CommitTime().Before(b.(*commitAndPaths).commit.CommitTime()) { //nolint:forcetypeassert // this heap only ever holds *commitAndPaths
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
@@ -110,7 +110,7 @@ heaploop:
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
current := cIn.(*commitAndPaths)
|
||||
current := cIn.(*commitAndPaths) //nolint:forcetypeassert // this heap only ever holds *commitAndPaths
|
||||
|
||||
// Load the parent commits for the one we are currently examining
|
||||
numParents := current.commit.NumParents()
|
||||
|
||||
@@ -7,11 +7,18 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/git/gitrepo"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type FastImportInit struct {
|
||||
Bare bool
|
||||
ObjectFormat string
|
||||
}
|
||||
|
||||
type FastImportFile struct {
|
||||
Mode EntryMode
|
||||
Path string
|
||||
@@ -24,6 +31,20 @@ type FastImportCommit struct {
|
||||
Files []FastImportFile
|
||||
}
|
||||
|
||||
// ForceFastImportWithInit is for mainly for testing purpose
|
||||
func ForceFastImportWithInit(ctx context.Context, repoLocalPath string, commits []FastImportCommit, initOpts ...FastImportInit) (RepositoryFacade, error) {
|
||||
repo := gitrepo.RepositoryUnmanaged(repoLocalPath)
|
||||
initOpt := util.OptionalArg(initOpts, FastImportInit{Bare: true})
|
||||
if exist, _ := IsRepositoryExist(ctx, repo); !exist {
|
||||
_ = os.MkdirAll(repoLocalPath, 0o755)
|
||||
err := InitRepositoryLocal(ctx, repoLocalPath, initOpt.Bare, util.IfZero(initOpt.ObjectFormat, "sha1"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return repo, ForceFastImport(ctx, repo, commits)
|
||||
}
|
||||
|
||||
// ForceFastImport is for mainly for testing purpose
|
||||
func ForceFastImport(ctx context.Context, repo RepositoryFacade, commits []FastImportCommit) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -144,7 +144,7 @@ func (repo *Repository) GetTagInfos(ctx context.Context, page, pageSize int) ([]
|
||||
sortTagsByTime(tags)
|
||||
tagsTotal = len(tags)
|
||||
if page != 0 {
|
||||
tags = util.PaginateSlice(tags, page, pageSize).([]*Tag)
|
||||
tags = util.PaginateSlice(tags, page, pageSize)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
|
||||
@@ -174,8 +174,10 @@ func TestGlob(t *testing.T) {
|
||||
} {
|
||||
g, err := Compile(test.pattern, test.delimiters...)
|
||||
require.NoError(t, err)
|
||||
compiler, ok := g.(*globCompiler)
|
||||
require.True(t, ok)
|
||||
result := g.Match(test.match)
|
||||
assert.Equal(t, test.should, result, "pattern %q matching %q should be %v but got %v, compiled=%s", test.pattern, test.match, test.should, result, g.(*globCompiler).regexpPattern)
|
||||
assert.Equal(t, test.should, result, "pattern %q matching %q should be %v but got %v, compiled=%s", test.pattern, test.match, test.should, result, compiler.regexpPattern)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -14,10 +17,13 @@ import (
|
||||
|
||||
func TestLockAndDo(t *testing.T) {
|
||||
t.Run("redis", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&redisLockExpiry, 5*time.Second)() // Close waits for the extend goroutine's next tick
|
||||
locker := newTestRedisLocker(t)
|
||||
defaultLocker.Store(new(locker))
|
||||
testLockAndDo(t)
|
||||
require.NoError(t, locker.(*redisLocker).Close())
|
||||
rl, ok := locker.(*redisLocker)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, rl.Close())
|
||||
})
|
||||
t.Run("memory", func(t *testing.T) {
|
||||
defaultLocker.Store(new(NewMemoryLocker()))
|
||||
|
||||
@@ -5,13 +5,11 @@ package globallock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/go-redsync/redsync/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -20,14 +18,7 @@ import (
|
||||
|
||||
func newTestRedisLocker(t *testing.T) Locker {
|
||||
t.Helper()
|
||||
redisURL := util.IfZero(os.Getenv("TEST_REDIS_URL"), "redis://127.0.0.1:6379/0")
|
||||
rl := NewRedisLocker(redisURL).(*redisLocker)
|
||||
err := rl.conn.Ping(t.Context()).Err()
|
||||
if err != nil && test.AllowSkipExternalService() {
|
||||
t.Skip("no redis server for testing, skipped")
|
||||
}
|
||||
require.NoError(t, err, "redis error for testing: %v", err)
|
||||
return rl
|
||||
return NewRedisLocker(test.PrepareTestRedis(t))
|
||||
}
|
||||
|
||||
func TestLocker(t *testing.T) {
|
||||
@@ -35,13 +26,17 @@ func TestLocker(t *testing.T) {
|
||||
defer test.MockVariableValue(&redisLockExpiry, 5*time.Second)() // make it shorter for testing
|
||||
locker := newTestRedisLocker(t)
|
||||
testLocker(t, locker)
|
||||
testRedisLocker(t, locker.(*redisLocker))
|
||||
require.NoError(t, locker.(*redisLocker).Close())
|
||||
rl, ok := locker.(*redisLocker)
|
||||
require.True(t, ok)
|
||||
testRedisLocker(t, rl)
|
||||
require.NoError(t, rl.Close())
|
||||
})
|
||||
t.Run("memory", func(t *testing.T) {
|
||||
locker := NewMemoryLocker()
|
||||
testLocker(t, locker)
|
||||
testMemoryLocker(t, locker.(*memoryLocker))
|
||||
ml, ok := locker.(*memoryLocker)
|
||||
require.True(t, ok)
|
||||
testMemoryLocker(t, ml)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,7 +166,8 @@ func testRedisLocker(t *testing.T, locker *redisLocker) {
|
||||
// It simulates that there are some problems with extending like network issues or redis server down.
|
||||
v, ok := locker.mutexM.Load("test")
|
||||
require.True(t, ok)
|
||||
m := v.(*redsync.Mutex)
|
||||
m, ok := v.(*redsync.Mutex)
|
||||
require.True(t, ok)
|
||||
_, _ = m.Unlock() // release it to make it impossible to extend
|
||||
|
||||
// In current design, callers can't know the lock can't be extended.
|
||||
|
||||
@@ -52,11 +52,10 @@ func (l *redisLocker) Lock(ctx context.Context, key string) (ReleaseFunc, error)
|
||||
func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) {
|
||||
f, err := l.lock(ctx, key, 1)
|
||||
|
||||
var (
|
||||
errTaken *redsync.ErrTaken
|
||||
errNodeTaken *redsync.ErrNodeTaken
|
||||
)
|
||||
if errors.As(err, &errTaken) || errors.As(err, &errNodeTaken) {
|
||||
if _, taken := errors.AsType[*redsync.ErrTaken](err); taken {
|
||||
return false, f, nil
|
||||
}
|
||||
if _, nodeTaken := errors.AsType[*redsync.ErrNodeTaken](err); nodeTaken {
|
||||
return false, f, nil
|
||||
}
|
||||
return err == nil, f, err
|
||||
@@ -112,7 +111,7 @@ func (l *redisLocker) startExtend() {
|
||||
|
||||
toExtend := make([]*redsync.Mutex, 0)
|
||||
l.mutexM.Range(func(_, value any) bool {
|
||||
m := value.(*redsync.Mutex)
|
||||
m := value.(*redsync.Mutex) //nolint:forcetypeassert // mutexM only ever holds *redsync.Mutex
|
||||
|
||||
// Extend the lock if it is not expired.
|
||||
// Although the mutex will be removed from the map before it is released,
|
||||
|
||||
@@ -177,13 +177,15 @@ func GetListenerTCP(network string, address *net.TCPAddr) (*net.TCPListener, err
|
||||
// look for a provided listener
|
||||
for i, l := range providedListeners {
|
||||
if isSameAddr(l.Addr(), address) {
|
||||
tcpListener := l.(*net.TCPListener) //nolint:forcetypeassert // a listener matching a *net.TCPAddr is a *net.TCPListener
|
||||
|
||||
providedListeners = append(providedListeners[:i], providedListeners[i+1:]...)
|
||||
needsUnlink := providedListenersToUnlink[i]
|
||||
providedListenersToUnlink = append(providedListenersToUnlink[:i], providedListenersToUnlink[i+1:]...)
|
||||
|
||||
activeListeners = append(activeListeners, l)
|
||||
activeListenersToUnlink = append(activeListenersToUnlink, needsUnlink)
|
||||
return l.(*net.TCPListener), nil
|
||||
return tcpListener, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,13 +213,14 @@ func GetListenerUnix(network string, address *net.UnixAddr) (*net.UnixListener,
|
||||
// look for a provided listener
|
||||
for i, l := range providedListeners {
|
||||
if isSameAddr(l.Addr(), address) {
|
||||
unixListener := l.(*net.UnixListener) //nolint:forcetypeassert // a listener matching a *net.UnixAddr is a *net.UnixListener
|
||||
|
||||
providedListeners = append(providedListeners[:i], providedListeners[i+1:]...)
|
||||
needsUnlink := providedListenersToUnlink[i]
|
||||
providedListenersToUnlink = append(providedListenersToUnlink[:i], providedListenersToUnlink[i+1:]...)
|
||||
|
||||
activeListenersToUnlink = append(activeListenersToUnlink, needsUnlink)
|
||||
activeListeners = append(activeListeners, l)
|
||||
unixListener := l.(*net.UnixListener)
|
||||
if needsUnlink {
|
||||
unixListener.SetUnlinkOnClose(true)
|
||||
}
|
||||
|
||||
@@ -44,10 +44,14 @@ func RestartProcess() (int, error) {
|
||||
// Extract the fds from the listeners.
|
||||
files := make([]*os.File, len(listeners))
|
||||
for i, l := range listeners {
|
||||
var err error
|
||||
// Now, all our listeners actually have File() functions so instead of
|
||||
// individually casting we just use a hacky interface
|
||||
files[i], err = l.(filer).File()
|
||||
lf, ok := l.(filer)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("listener %T does not provide a File", l)
|
||||
}
|
||||
var err error
|
||||
files[i], err = lf.File()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package graceful
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -260,7 +261,11 @@ func (wl *wrappedListener) Accept() (c net.Conn, err error) {
|
||||
|
||||
func (wl *wrappedListener) File() (*os.File, error) {
|
||||
// returns a dup(2) - FD_CLOEXEC flag *not* set so the listening socket can be passed to child processes
|
||||
return wl.Listener.(filer).File()
|
||||
lf, ok := wl.Listener.(filer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("listener %T does not provide a File", wl.Listener)
|
||||
}
|
||||
return lf.File()
|
||||
}
|
||||
|
||||
type wrappedConn struct {
|
||||
|
||||
@@ -63,7 +63,7 @@ func (t *traceBuiltinSpan) toString(out *strings.Builder, indent int) {
|
||||
}
|
||||
out.WriteString("\n")
|
||||
for _, c := range t.ts.children {
|
||||
span := c.internalSpans[t.internalSpanIdx].(*traceBuiltinSpan)
|
||||
span := c.internalSpans[t.internalSpanIdx].(*traceBuiltinSpan) //nolint:forcetypeassert // this tracer only stores its own spans
|
||||
span.toString(out, indent+2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// "vendor span" is a simple demo for a span from a vendor library
|
||||
@@ -82,7 +83,9 @@ func TestTraceStarter(t *testing.T) {
|
||||
collectSpanNames(fullName, c)
|
||||
}
|
||||
}
|
||||
collectSpanNames("", span.internalSpans[0].(*testTraceSpan).vendorSpan)
|
||||
rootSpan, ok := span.internalSpans[0].(*testTraceSpan)
|
||||
require.True(t, ok)
|
||||
collectSpanNames("", rootSpan.vendorSpan)
|
||||
assert.Equal(t, []string{
|
||||
"/root",
|
||||
"/root/span1",
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"gitea.dev/modules/indexer/code/internal"
|
||||
indexer_internal "gitea.dev/modules/indexer/internal"
|
||||
inner_bleve "gitea.dev/modules/indexer/internal/bleve"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
@@ -330,6 +331,17 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
|
||||
searchResults := make([]*internal.SearchResult, len(result.Hits))
|
||||
for i, hit := range result.Hits {
|
||||
content, okContent := hit.Fields["Content"].(string)
|
||||
language, okLanguage := hit.Fields["Language"].(string)
|
||||
commitID, okCommitID := hit.Fields["CommitID"].(string)
|
||||
updatedAt, okUpdatedAt := hit.Fields["UpdatedAt"].(string)
|
||||
repoID, okRepoID := hit.Fields["RepoID"].(float64)
|
||||
if !okContent || !okLanguage || !okCommitID || !okUpdatedAt || !okRepoID {
|
||||
hitFieldsJson, _ := json.Marshal(hit.Fields)
|
||||
setting.PanicInDevOrTesting("unexpected field types in search hit %q: %s", hit.ID, string(hitFieldsJson))
|
||||
return 0, nil, nil, fmt.Errorf("unexpected field types in search hit %q", hit.ID)
|
||||
}
|
||||
|
||||
startIndex, endIndex := -1, -1
|
||||
for _, locations := range hit.Locations["Content"] {
|
||||
location := locations[0]
|
||||
@@ -343,21 +355,20 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
|
||||
}
|
||||
}
|
||||
if len(hit.Locations["Filename"]) > 0 {
|
||||
startIndex, endIndex = internal.FilenameMatchIndexPos(hit.Fields["Content"].(string))
|
||||
startIndex, endIndex = internal.FilenameMatchIndexPos(content)
|
||||
}
|
||||
|
||||
language := hit.Fields["Language"].(string)
|
||||
var updatedUnix timeutil.TimeStamp
|
||||
if t, err := time.Parse(time.RFC3339, hit.Fields["UpdatedAt"].(string)); err == nil {
|
||||
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
|
||||
updatedUnix = timeutil.TimeStamp(t.Unix())
|
||||
}
|
||||
searchResults[i] = &internal.SearchResult{
|
||||
RepoID: int64(hit.Fields["RepoID"].(float64)),
|
||||
RepoID: int64(repoID),
|
||||
StartIndex: startIndex,
|
||||
EndIndex: endIndex,
|
||||
Filename: internal.FilenameOfIndexerID(hit.ID),
|
||||
Content: hit.Fields["Content"].(string),
|
||||
CommitID: hit.Fields["CommitID"].(string),
|
||||
Content: content,
|
||||
CommitID: commitID,
|
||||
UpdatedUnix: updatedUnix,
|
||||
Language: language,
|
||||
Color: enry.GetColor(language),
|
||||
|
||||
@@ -261,12 +261,21 @@ func convertResult(searchResult *es.SearchResponse, kw string, pageSize int) (in
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
|
||||
content, okContent := res["content"].(string)
|
||||
language, okLanguage := res["language"].(string)
|
||||
commitID, okCommitID := res["commit_id"].(string)
|
||||
updatedAt, okUpdatedAt := res["updated_at"].(float64)
|
||||
if !okContent || !okLanguage || !okCommitID || !okUpdatedAt {
|
||||
setting.PanicInDevOrTesting("unexpected field types in search hit %q: %s", hit.ID, string(hit.Source))
|
||||
return 0, nil, nil, fmt.Errorf("unexpected field types in search hit %q", hit.ID)
|
||||
}
|
||||
|
||||
// FIXME: There is no way to get the position the keyword on the content currently on the same request.
|
||||
// So we get it from content, this may made the query slower. See
|
||||
// https://discuss.elastic.co/t/fetching-position-of-keyword-in-matched-document/94291
|
||||
var startIndex, endIndex int
|
||||
if c, ok := hit.Highlight["filename"]; ok && len(c) > 0 {
|
||||
startIndex, endIndex = internal.FilenameMatchIndexPos(res["content"].(string))
|
||||
startIndex, endIndex = internal.FilenameMatchIndexPos(content)
|
||||
} else if c, ok := hit.Highlight["content"]; ok && len(c) > 0 {
|
||||
// FIXME: Since the highlighting content will include <em> and </em> for the keywords,
|
||||
// now we should find the positions. But how to avoid html content which contains the
|
||||
@@ -279,14 +288,12 @@ func convertResult(searchResult *es.SearchResponse, kw string, pageSize int) (in
|
||||
panic(fmt.Sprintf("2===%#v", hit.Highlight))
|
||||
}
|
||||
|
||||
language := res["language"].(string)
|
||||
|
||||
hits = append(hits, &internal.SearchResult{
|
||||
RepoID: repoID,
|
||||
Filename: fileName,
|
||||
CommitID: res["commit_id"].(string),
|
||||
Content: res["content"].(string),
|
||||
UpdatedUnix: timeutil.TimeStamp(res["updated_at"].(float64)),
|
||||
CommitID: commitID,
|
||||
Content: content,
|
||||
UpdatedUnix: timeutil.TimeStamp(updatedAt),
|
||||
Language: language,
|
||||
StartIndex: startIndex,
|
||||
EndIndex: endIndex,
|
||||
|
||||
@@ -24,7 +24,7 @@ var _ EventWriter = (*eventWriterConn)(nil)
|
||||
|
||||
func NewEventWriterConn(writerName string, writerMode WriterMode) EventWriter {
|
||||
w := &eventWriterConn{EventWriterBaseImpl: NewEventWriterBase(writerName, "conn", writerMode)}
|
||||
opt := writerMode.WriterOption.(WriterConnOption)
|
||||
opt := writerMode.WriterOption.(WriterConnOption) //nolint:forcetypeassert // a conn writer is only created with WriterConnOption
|
||||
w.connWriter = connWriter{
|
||||
ReconnectOnMsg: opt.ReconnectOnMsg,
|
||||
Reconnect: opt.Reconnect,
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ EventWriter = (*eventWriterConsole)(nil)
|
||||
|
||||
func NewEventWriterConsole(name string, mode WriterMode) EventWriter {
|
||||
w := &eventWriterConsole{EventWriterBaseImpl: NewEventWriterBase(name, "console", mode)}
|
||||
opt := mode.WriterOption.(WriterConsoleOption)
|
||||
opt := mode.WriterOption.(WriterConsoleOption) //nolint:forcetypeassert // a console writer is only created with WriterConsoleOption
|
||||
if opt.Stderr {
|
||||
w.OutputWriteCloser = util.NopCloser{Writer: os.Stderr}
|
||||
} else {
|
||||
|
||||
@@ -29,7 +29,7 @@ var _ EventWriter = (*eventWriterFile)(nil)
|
||||
|
||||
func NewEventWriterFile(name string, mode WriterMode) EventWriter {
|
||||
w := &eventWriterFile{EventWriterBaseImpl: NewEventWriterBase(name, "file", mode)}
|
||||
opt := mode.WriterOption.(WriterFileOption)
|
||||
opt := mode.WriterOption.(WriterFileOption) //nolint:forcetypeassert // a file writer is only created with WriterFileOption
|
||||
var err error
|
||||
w.fileWriter, err = rotatingfilewriter.Open(opt.FileName, &rotatingfilewriter.Options{
|
||||
Rotate: opt.LogRotate,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSharedWorker(t *testing.T) {
|
||||
@@ -37,6 +38,8 @@ func TestSharedWorker(t *testing.T) {
|
||||
|
||||
m.Close()
|
||||
|
||||
logs := w.(*dummyWriter).FetchLogs()
|
||||
dw, ok := w.(*dummyWriter)
|
||||
require.True(t, ok)
|
||||
logs := dw.FetchLogs()
|
||||
assert.Equal(t, []string{"msg-1\n", "msg-2\n", "msg-3\n"}, logs)
|
||||
}
|
||||
|
||||
@@ -237,10 +237,8 @@ func (b *footnoteBlockParser) Continue(node ast.Node, reader text.Reader, pc par
|
||||
}
|
||||
|
||||
func (b *footnoteBlockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
|
||||
var list *FootnoteList
|
||||
if tlist := pc.Get(footnoteListKey); tlist != nil {
|
||||
list = tlist.(*FootnoteList)
|
||||
} else {
|
||||
list, _ := pc.Get(footnoteListKey).(*FootnoteList)
|
||||
if list == nil {
|
||||
list = NewFootnoteList()
|
||||
pc.Set(footnoteListKey, list)
|
||||
node.Parent().InsertBefore(node.Parent(), node, list)
|
||||
@@ -295,17 +293,14 @@ func (s *footnoteParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
|
||||
value := block.Value(text.NewSegment(segment.Start+open, segment.Start+closes))
|
||||
block.Advance(closes + 1)
|
||||
|
||||
var list *FootnoteList
|
||||
if tlist := pc.Get(footnoteListKey); tlist != nil {
|
||||
list = tlist.(*FootnoteList)
|
||||
}
|
||||
list, _ := pc.Get(footnoteListKey).(*FootnoteList)
|
||||
if list == nil {
|
||||
return nil
|
||||
}
|
||||
index := 0
|
||||
name := []byte{}
|
||||
for def := list.FirstChild(); def != nil; def = def.NextSibling() {
|
||||
d := def.(*Footnote)
|
||||
d := def.(*Footnote) //nolint:forcetypeassert // a FootnoteList only holds *Footnote children
|
||||
if bytes.Equal(d.Ref, value) {
|
||||
if d.Index < 0 {
|
||||
list.Count++
|
||||
@@ -339,10 +334,8 @@ func NewFootnoteASTTransformer() parser.ASTTransformer {
|
||||
}
|
||||
|
||||
func (a *footnoteASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
var list *FootnoteList
|
||||
if tlist := pc.Get(footnoteListKey); tlist != nil {
|
||||
list = tlist.(*FootnoteList)
|
||||
} else {
|
||||
list, _ := pc.Get(footnoteListKey).(*FootnoteList)
|
||||
if list == nil {
|
||||
return
|
||||
}
|
||||
pc.Set(footnoteListKey, nil)
|
||||
@@ -352,18 +345,16 @@ func (a *footnoteASTTransformer) Transform(node *ast.Document, reader text.Reade
|
||||
if fc := container.LastChild(); fc != nil && ast.IsParagraph(fc) {
|
||||
container = fc
|
||||
}
|
||||
footnoteNode := footnote.(*Footnote)
|
||||
index := footnoteNode.Index
|
||||
name := footnoteNode.Name
|
||||
if index < 0 {
|
||||
footnoteNode := footnote.(*Footnote) //nolint:forcetypeassert // a FootnoteList only holds *Footnote children
|
||||
if footnoteNode.Index < 0 {
|
||||
list.RemoveChild(list, footnote)
|
||||
} else {
|
||||
container.AppendChild(container, NewFootnoteBackLink(index, name))
|
||||
container.AppendChild(container, NewFootnoteBackLink(footnoteNode.Index, footnoteNode.Name))
|
||||
}
|
||||
footnote = next
|
||||
}
|
||||
list.SortChildren(func(n1, n2 ast.Node) int {
|
||||
if n1.(*Footnote).Index < n2.(*Footnote).Index {
|
||||
if n1.(*Footnote).Index < n2.(*Footnote).Index { //nolint:forcetypeassert // a FootnoteList only holds *Footnote children
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
@@ -403,7 +394,7 @@ func (r *FootnoteHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegist
|
||||
|
||||
func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := node.(*FootnoteLink)
|
||||
n := node.(*FootnoteLink) //nolint:forcetypeassert // registered for KindFootnoteLink only
|
||||
is := strconv.Itoa(n.Index)
|
||||
_, _ = w.WriteString(`<sup id="fnref:user-content-`)
|
||||
_, _ = w.Write(n.Name)
|
||||
@@ -418,7 +409,7 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt
|
||||
|
||||
func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := node.(*FootnoteBackLink)
|
||||
n := node.(*FootnoteBackLink) //nolint:forcetypeassert // registered for KindFootnoteBackLink only
|
||||
_, _ = w.WriteString(` <a href="#fnref:user-content-`)
|
||||
_, _ = w.Write(n.Name)
|
||||
_, _ = w.WriteString(`" class="footnote-backref" role="doc-backlink">`)
|
||||
@@ -429,7 +420,7 @@ func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source [
|
||||
}
|
||||
|
||||
func (r *FootnoteHTMLRenderer) renderFootnote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*Footnote)
|
||||
n := node.(*Footnote) //nolint:forcetypeassert // registered for KindFootnote only
|
||||
if entering {
|
||||
_, _ = w.WriteString(`<li id="fn:user-content-`)
|
||||
_, _ = w.Write(n.Name)
|
||||
|
||||
@@ -149,7 +149,7 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
if hasExtTrackFormat && !ref.IsPull {
|
||||
ctx.RenderOptions.Metas["index"] = ref.Issue
|
||||
|
||||
res, err := vars.Expand(ctx.RenderOptions.Metas["format"], ctx.RenderOptions.Metas)
|
||||
res, err := vars.ExpandCurlyBrace(ctx.RenderOptions.Metas["format"], ctx.RenderOptions.Metas)
|
||||
if err != nil {
|
||||
// here we could just log the error and continue the rendering
|
||||
log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
|
||||
|
||||
@@ -43,8 +43,8 @@ func (g *ASTTransformer) applyElementDir(n ast.Node) {
|
||||
// Transform transforms the given AST tree.
|
||||
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
firstChild := node.FirstChild()
|
||||
ctx := pc.Get(renderContextKey).(*markup.RenderContext)
|
||||
rc := pc.Get(renderConfigKey).(*RenderConfig)
|
||||
ctx := pc.Get(renderContextKey).(*markup.RenderContext) //nolint:forcetypeassert // the renderer always seeds this key before parsing
|
||||
rc := pc.Get(renderConfigKey).(*RenderConfig) //nolint:forcetypeassert // the renderer always seeds this key before parsing
|
||||
|
||||
tocMode := ""
|
||||
if rc.yamlNode != nil {
|
||||
@@ -150,9 +150,7 @@ func (r *HTMLRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.No
|
||||
}
|
||||
|
||||
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Document)
|
||||
|
||||
if val, has := n.AttributeString("lang"); has {
|
||||
if val, has := node.AttributeString("lang"); has {
|
||||
var err error
|
||||
if entering {
|
||||
_, err = w.WriteString("<div")
|
||||
@@ -212,7 +210,7 @@ func (r *HTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.N
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*RawHTML)
|
||||
n := node.(*RawHTML) //nolint:forcetypeassert // registered for KindRawHTML only
|
||||
_, err := w.WriteString(string(r.renderInternal.ProtectSafeAttrs(n.rawHTML)))
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
|
||||
@@ -86,7 +86,7 @@ func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Contex
|
||||
|
||||
// Continue parses the current line and returns a result of parsing.
|
||||
func (b *blockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
|
||||
block := node.(*Block)
|
||||
block := node.(*Block) //nolint:forcetypeassert // this parser only ever opens *Block nodes
|
||||
if block.Closed {
|
||||
return parser.Close
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node)
|
||||
}
|
||||
|
||||
func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||
n := node.(*Block)
|
||||
n := node.(*Block) //nolint:forcetypeassert // registered for KindBlock only
|
||||
if entering {
|
||||
codeHTML := giteaUtil.Iif[template.HTML](n.Inline, "", `<pre class="code-block is-loading">`) + `<code class="language-math">`
|
||||
_, _ = w.WriteString(string(r.renderInternal.ProtectSafeAttrs(codeHTML)))
|
||||
|
||||
@@ -19,8 +19,8 @@ func (n *Inline) Inline() {}
|
||||
// IsBlank returns if this inline node is empty
|
||||
func (n *Inline) IsBlank(source []byte) bool {
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
text := c.(*ast.Text).Segment
|
||||
if !util.IsBlank(text.Value(source)) {
|
||||
text := c.(*ast.Text) //nolint:forcetypeassert // an inline math node only holds text children
|
||||
if !util.IsBlank(text.Segment.Value(source)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,12 +160,12 @@ func trimBlock(node *Inline, block text.Reader) {
|
||||
}
|
||||
|
||||
// trim first space and last space
|
||||
first := node.FirstChild().(*ast.Text)
|
||||
first := node.FirstChild().(*ast.Text) //nolint:forcetypeassert // an inline math node only holds text children
|
||||
if !(!first.Segment.IsEmpty() && block.Source()[first.Segment.Start] == ' ') {
|
||||
return
|
||||
}
|
||||
|
||||
last := node.LastChild().(*ast.Text)
|
||||
last := node.LastChild().(*ast.Text) //nolint:forcetypeassert // an inline math node only holds text children
|
||||
if !(!last.Segment.IsEmpty() && block.Source()[last.Segment.Stop-1] == ' ') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Nod
|
||||
if entering {
|
||||
_, _ = w.WriteString(string(r.renderInternal.ProtectSafeAttrs(`<code class="language-math">`)))
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
segment := c.(*ast.Text).Segment //nolint:forcetypeassert // an inline math node only holds text children
|
||||
value := util.EscapeHTML(segment.Value(source))
|
||||
if bytes.HasSuffix(value, []byte("\n")) {
|
||||
_, _ = w.Write(value[:len(value)-1])
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// renderAttention renders a quote marked with i.e. "> **Note**" or "> [!Warning]" with a corresponding svg
|
||||
func (r *HTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := node.(*Attention)
|
||||
n := node.(*Attention) //nolint:forcetypeassert // registered for KindAttention only
|
||||
var octiconName string
|
||||
switch n.AttentionType {
|
||||
case "tip":
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func (r *HTMLRenderer) renderTaskCheckBoxListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*TaskCheckBoxListItem)
|
||||
n := node.(*TaskCheckBoxListItem) //nolint:forcetypeassert // registered for KindTaskCheckBoxListItem only
|
||||
if entering {
|
||||
if n.Attributes() != nil {
|
||||
_, _ = w.WriteString("<li")
|
||||
@@ -60,7 +60,7 @@ func (g *ASTTransformer) transformList(_ *markup.RenderContext, v *ast.List, rc
|
||||
v.RemoveChildren(v)
|
||||
|
||||
for _, child := range children {
|
||||
listItem := child.(*ast.ListItem)
|
||||
listItem := child.(*ast.ListItem) //nolint:forcetypeassert // a list only holds list items
|
||||
if !child.HasChildren() || !child.FirstChild().HasChildren() {
|
||||
v.AppendChild(v, child)
|
||||
continue
|
||||
|
||||
@@ -22,8 +22,8 @@ func TestMigrationJSON_IssueOK(t *testing.T) {
|
||||
func TestMigrationJSON_IssueFail(t *testing.T) {
|
||||
issues := make([]*Issue, 0, 10)
|
||||
err := Load("file_format_testdata/issue_b.json", &issues, true)
|
||||
if _, ok := err.(*jsonschema.ValidationError); ok {
|
||||
errors := strings.Split(err.(*jsonschema.ValidationError).GoString(), "\n")
|
||||
if validationErr, ok := err.(*jsonschema.ValidationError); ok {
|
||||
errors := strings.Split(validationErr.GoString(), "\n")
|
||||
assert.Contains(t, errors[1], "missing properties")
|
||||
assert.Contains(t, errors[1], "poster_id")
|
||||
} else {
|
||||
|
||||
@@ -153,6 +153,9 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, err
|
||||
}
|
||||
} else if !strings.HasPrefix(filename, ".") {
|
||||
if strings.ContainsAny(hd.Name, "\n\r") {
|
||||
continue // a newline would forge extra lines in the pacman index
|
||||
}
|
||||
if err := files.Add(hd.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func TestParsePackage(t *testing.T) {
|
||||
data := createPackage(c, map[string][]byte{
|
||||
".PKGINFO": createPKGINFOContent(packageName, packageVersion),
|
||||
"/test/dummy.txt": {},
|
||||
"usr/lib/legit\n\n%FILES%\n/etc/cron.d/x": {}, // must not reach the file list
|
||||
})
|
||||
|
||||
p, err := ParsePackage(data)
|
||||
|
||||
@@ -5,6 +5,7 @@ package helm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/validation"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
|
||||
"github.com/hashicorp/go-version"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -25,6 +27,8 @@ var (
|
||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
||||
// ErrInvalidChart indicates an invalid chart
|
||||
ErrInvalidChart = util.NewInvalidArgumentErrorf("chart is invalid")
|
||||
// ErrInvalidProvenance indicates an invalid provenance file
|
||||
ErrInvalidProvenance = util.NewInvalidArgumentErrorf("provenance file is invalid")
|
||||
)
|
||||
|
||||
// Metadata for a Chart file. This models the structure of a Chart.yaml file.
|
||||
@@ -128,3 +132,20 @@ func ParseChartFile(r io.Reader) (*Metadata, error) {
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// ParseProvenanceFile parses a provenance file to retrieve the metadata of a Helm chart
|
||||
func ParseProvenanceFile(r io.Reader) (*Metadata, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A provenance file must be a clearsigned PGP message
|
||||
block, _ := clearsign.Decode(data)
|
||||
if block == nil {
|
||||
return nil, ErrInvalidProvenance
|
||||
}
|
||||
|
||||
// Use the plaintext content from the clearsigned message
|
||||
return ParseChartFile(bytes.NewReader(block.Plaintext))
|
||||
}
|
||||
|
||||
@@ -56,21 +56,30 @@ func NewMultiHasher() *MultiHasher {
|
||||
}
|
||||
}
|
||||
|
||||
// marshalHash saves the state of a hash, every stdlib hash implements the marshaler interfaces
|
||||
func marshalHash(h hash.Hash) ([]byte, error) {
|
||||
return h.(encoding.BinaryMarshaler).MarshalBinary() //nolint:forcetypeassert // every hash used here is a stdlib hash
|
||||
}
|
||||
|
||||
func unmarshalHash(h hash.Hash, state []byte) error {
|
||||
return h.(encoding.BinaryUnmarshaler).UnmarshalBinary(state) //nolint:forcetypeassert // every hash used here is a stdlib hash
|
||||
}
|
||||
|
||||
// MarshalBinary implements encoding.BinaryMarshaler
|
||||
func (h *MultiHasher) MarshalBinary() ([]byte, error) {
|
||||
md5Bytes, err := h.md5.(encoding.BinaryMarshaler).MarshalBinary()
|
||||
md5Bytes, err := marshalHash(h.md5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sha1Bytes, err := h.sha1.(encoding.BinaryMarshaler).MarshalBinary()
|
||||
sha1Bytes, err := marshalHash(h.sha1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sha256Bytes, err := h.sha256.(encoding.BinaryMarshaler).MarshalBinary()
|
||||
sha256Bytes, err := marshalHash(h.sha256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sha512Bytes, err := h.sha512.(encoding.BinaryMarshaler).MarshalBinary()
|
||||
sha512Bytes, err := marshalHash(h.sha512)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,22 +98,22 @@ func (h *MultiHasher) UnmarshalBinary(b []byte) error {
|
||||
return errors.New("invalid hash state size")
|
||||
}
|
||||
|
||||
if err := h.md5.(encoding.BinaryUnmarshaler).UnmarshalBinary(b[:marshaledSizeMD5]); err != nil {
|
||||
if err := unmarshalHash(h.md5, b[:marshaledSizeMD5]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b = b[marshaledSizeMD5:]
|
||||
if err := h.sha1.(encoding.BinaryUnmarshaler).UnmarshalBinary(b[:marshaledSizeSHA1]); err != nil {
|
||||
if err := unmarshalHash(h.sha1, b[:marshaledSizeSHA1]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b = b[marshaledSizeSHA1:]
|
||||
if err := h.sha256.(encoding.BinaryUnmarshaler).UnmarshalBinary(b[:marshaledSizeSHA256]); err != nil {
|
||||
if err := unmarshalHash(h.sha256, b[:marshaledSizeSHA256]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b = b[marshaledSizeSHA256:]
|
||||
return h.sha512.(encoding.BinaryUnmarshaler).UnmarshalBinary(b[:marshaledSizeSHA512])
|
||||
return unmarshalHash(h.sha512, b[:marshaledSizeSHA512])
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
|
||||
@@ -107,13 +107,13 @@ func (e *MarshalEncoder) marshal(v any) error {
|
||||
return e.marshalArray(val)
|
||||
}
|
||||
|
||||
switch typ.Name() {
|
||||
case "RubyUserMarshal":
|
||||
return e.marshalUserMarshal(val.Interface().(RubyUserMarshal))
|
||||
case "RubyUserDef":
|
||||
return e.marshalUserDef(val.Interface().(RubyUserDef))
|
||||
case "RubyObject":
|
||||
return e.marshalObject(val.Interface().(RubyObject))
|
||||
switch obj := val.Interface().(type) {
|
||||
case RubyUserMarshal:
|
||||
return e.marshalUserMarshal(obj)
|
||||
case RubyUserDef:
|
||||
return e.marshalUserDef(obj)
|
||||
case RubyObject:
|
||||
return e.marshalObject(obj)
|
||||
}
|
||||
|
||||
return ErrUnsupportedType
|
||||
|
||||
@@ -123,11 +123,26 @@ func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) {
|
||||
},
|
||||
}
|
||||
|
||||
// Nested packages (test fixtures, examples, benchmarks) ship their own manifests, which must not
|
||||
// replace the package manifest. The package sits at the archive root or in a single top level
|
||||
// directory, so keep only the shallowest manifest directory, breaking ties by name for stability.
|
||||
var manifestFiles []*zip.File
|
||||
manifestDir, manifestDepth := "", 0
|
||||
for _, file := range zr.File {
|
||||
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
||||
if len(manifestMatch) == 0 {
|
||||
if strings.HasSuffix(file.Name, "/") || !manifestPattern.MatchString(path.Base(file.Name)) {
|
||||
continue
|
||||
}
|
||||
dir, depth := path.Dir(file.Name), strings.Count(file.Name, "/")
|
||||
switch {
|
||||
case manifestFiles == nil || depth < manifestDepth || (depth == manifestDepth && dir < manifestDir):
|
||||
manifestDir, manifestDepth, manifestFiles = dir, depth, []*zip.File{file}
|
||||
case dir == manifestDir:
|
||||
manifestFiles = append(manifestFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range manifestFiles {
|
||||
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
||||
|
||||
if file.UncompressedSize64 > maxManifestFileSize {
|
||||
return nil, ErrManifestFileTooLarge
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package swift
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -24,6 +25,18 @@ const (
|
||||
packageLicense = "MIT"
|
||||
)
|
||||
|
||||
// writeOrderedZipArchive writes name/content pairs in the given order, which map based test.WriteZipArchive cannot do
|
||||
func writeOrderedZipArchive(entries [][2]string) *bytes.Buffer {
|
||||
buf := &bytes.Buffer{}
|
||||
zw := zip.NewWriter(buf)
|
||||
for _, entry := range entries {
|
||||
w, _ := zw.Create(entry[0])
|
||||
_, _ = w.Write([]byte(entry[1]))
|
||||
}
|
||||
_ = zw.Close()
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestParsePackage(t *testing.T) {
|
||||
t.Run("MissingManifestFile", func(t *testing.T) {
|
||||
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
|
||||
@@ -65,6 +78,77 @@ func TestParsePackage(t *testing.T) {
|
||||
assert.Equal(t, content2, m.Content)
|
||||
})
|
||||
|
||||
t.Run("IgnoresNestedManifests", func(t *testing.T) {
|
||||
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||
rootAltManifest := "// swift-tools-version:5.5\n//\n// Package@swift-5.5.swift"
|
||||
rootPatchAltManifest := "// swift-tools-version:5.7.1\n//\n// Package@swift-5.7.1.swift"
|
||||
nestedManifest := "// swift-tools-version:6.3\n//\n// nested fixture package"
|
||||
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"Package.swift", rootManifest},
|
||||
{"Package@swift-5.5.swift", rootAltManifest},
|
||||
{"Package@swift-5.7.1.swift", rootPatchAltManifest},
|
||||
{"Benchmarks/Package.swift", nestedManifest},
|
||||
{"Utils/Fixtures/PlainPackage/Package.swift", nestedManifest},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, p.Metadata.Manifests, 3)
|
||||
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
|
||||
assert.Equal(t, "5.7", p.Metadata.Manifests[""].ToolsVersion)
|
||||
assert.Equal(t, rootAltManifest, p.Metadata.Manifests["5.5"].Content)
|
||||
assert.Equal(t, rootPatchAltManifest, p.Metadata.Manifests["5.7.1"].Content)
|
||||
})
|
||||
|
||||
t.Run("IgnoresNestedManifestsInPrefixedArchive", func(t *testing.T) {
|
||||
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||
|
||||
// `swift package archive-source` produces archives with a single top level directory
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"gitea-1.0.1/Package.swift", rootManifest},
|
||||
{"gitea-1.0.1/Tests/Fixtures/Package.swift", "// swift-tools-version:6.3"},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, p.Metadata.Manifests, 1)
|
||||
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
|
||||
})
|
||||
|
||||
t.Run("AltManifestOnlyInRootDirectory", func(t *testing.T) {
|
||||
// a deeper Package.swift belongs to a nested package and must not stand in for the missing root manifest
|
||||
data := test.WriteZipArchive(map[string]string{
|
||||
"Package@swift-5.5.swift": "// swift-tools-version:5.5",
|
||||
"Sub/Package.swift": "// swift-tools-version:5.7",
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrMissingManifestFile)
|
||||
})
|
||||
|
||||
t.Run("ManifestDirectoryTieBreak", func(t *testing.T) {
|
||||
contentA := "// swift-tools-version:5.7\n// A"
|
||||
contentB := "// swift-tools-version:5.7\n// B"
|
||||
|
||||
// at equal depth the name decides, never the archive order
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"a/Package.swift", contentA},
|
||||
{"b/Package.swift", contentB},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, p.Metadata.Manifests, 1)
|
||||
assert.Equal(t, contentA, p.Metadata.Manifests[""].Content)
|
||||
})
|
||||
|
||||
t.Run("WithMetadata", func(t *testing.T) {
|
||||
data := test.WriteZipArchive(map[string]string{
|
||||
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
|
||||
|
||||
@@ -5,13 +5,25 @@ package reqctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/process"
|
||||
)
|
||||
|
||||
// MustContextValue returns the value stored under key. A missing or mistyped value can only
|
||||
// be a programming error, and callers can't do anything useful with a zero value, so it panics.
|
||||
func MustContextValue[T any](ctx context.Context, key any) T {
|
||||
value, ok := ctx.Value(key).(T)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("context value %v is %T, expected %s", key, ctx.Value(key), reflect.TypeFor[T]()))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ContextDataProvider interface {
|
||||
GetData() ContextData
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ func RegenerateSession(resp http.ResponseWriter, req *http.Request) (Store, erro
|
||||
f(resp, req)
|
||||
}
|
||||
if setting.IsInTesting {
|
||||
if store := req.Context().Value(MockStoreContextKey); store != nil {
|
||||
return store.(Store), nil
|
||||
if store, ok := req.Context().Value(MockStoreContextKey).(Store); ok {
|
||||
return store, nil
|
||||
}
|
||||
}
|
||||
return session.RegenerateSession(resp, req)
|
||||
@@ -37,8 +37,8 @@ func RegenerateSession(resp http.ResponseWriter, req *http.Request) (Store, erro
|
||||
|
||||
func GetContextSession(req *http.Request) Store {
|
||||
if setting.IsInTesting {
|
||||
if store := req.Context().Value(MockStoreContextKey); store != nil {
|
||||
return store.(Store)
|
||||
if store, ok := req.Context().Value(MockStoreContextKey).(Store); ok {
|
||||
return store
|
||||
}
|
||||
}
|
||||
return session.GetSession(req)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user