Compare commits

...
29 Commits
Author SHA1 Message Date
7ac505ff0d enhance(ui): restyle toasts (#38842)
Restyle toasts, add success toast to match the alerts.

<img width="500" alt="Screenshot 2026-08-09 at 11 33 44"
src="https://github.com/user-attachments/assets/d36422b8-0030-41dd-8056-b2d10d86280d"
/>

Alternatively we could also tint to match the alerts, but I do prefer
untinted.

<img width="500" alt="Screenshot 2026-08-09 at 11 34 44"
src="https://github.com/user-attachments/assets/73fcfde4-618b-424f-9aa9-4ada5c2904f6"
/>

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
2026-08-09 22:17:28 +02:00
85558c28fc feat(packages): add support for uploading helm provenance files (#36695)
Adds `POST helm/api/prov` endpoint for helm repository allowing for upload of provenance files.

Tested manually to a degree but I really didn't want to mess with gpg
again so I'm not sure if helm will correctly verify the chart.
Initial draft made by gemini 3 flash but was finetuned somewhat.

Additionally there's an route that allows for upload of both files via
/api/charts - as separate files in form. If there's any interest in that
I guess it can be added but I think helm is moving to OCI anyway which
we support.

Fixes: https://github.com/go-gitea/gitea/issues/36678
Co-authored-by: silverwind <me@silverwind.io>
2026-08-09 12:55:43 +00:00
TheFox0x7andGitHub 7e34eae370 chore: rename migration package for next release to v28 (#38844) 2026-08-09 12:00:39 +00:00
silverwindandGitHub ecbef41c06 refactor: replace gliderlabs/ssh with golang.org/x/crypto/ssh (#38837)
Migrate away from this thin ssh wrapper module while adding more test
coverage.

Removes `sessionPartial`, which hand-copied the layout of a private
`gliderlabs/ssh` struct and reinterpreted a pointer to it via
`reflect.UnsafePointer` to reach the permissions of the authenticated
connection. The layout is unchecked, so an upstream field reorder would
mismatch silently.

The builtin server only needs the session channel with `exec` and
`shell`. Serving those on `x/crypto` drops the hack and the dependency,
since `PublicKeyCallback` returns permissions per key and `x/crypto`
assigns them only after verifying the signature.

Two benign behavior changes:

1. Internal session handler errors report exit status 1 rather than 0,
so a client no longer reads a failure as success.
1. An unusable host key is fatal at startup instead of being replaced by
an ephemeral one that would trigger an error at the client.
2026-08-09 11:32:50 +00:00
79535f4e01 fix(gitdiff): render exact-limit diffs and zero-limit comments (#38838)
This commit fixes the treatment of various edge cases related to diff
limits.

- Exact-limit diffs: a file containing exactly the configured maximum
was incorrectly treated as too large and hidden behind “Load diff.” It
now renders normally.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-09 11:13:22 +00:00
ad6107ab88 enhance: refine repo watching (#38835)
Follow-up to https://github.com/go-gitea/gitea/pull/37571.

"Participating and mentions" deleted the watch row, so choosing it
dropped you out of the watcher count. It is a watch like the others, so
it now keeps a row and simply subscribes to no events.

The dashboard feed ignored the per-event options, so a "Custom: issues"
watcher still got pull request activity there. It now gates on the same
options as mail and notifications. That also closes a gap where pull
request reviews bypassed the permission check.

Also, address
https://github.com/go-gitea/gitea/pull/37571#discussion_r3740487363 and
reword a UI text for clarity.

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-09 10:54:31 +00:00
76a81b24f9 chore: enable forcetypeassert linter, fix issues (#38804)
Enable [`forcetypeassert`](https://github.com/gostaticanalysis/forcetypeassert)
linter to prevent unchecked type assertions. ~650 issues fixed, most
fixes were clean, some use `setting.PanicInDevOrTesting`.

The only behaviour changes are where code would previously send a 500 error
or panic, a 4xx error is now emitted.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-09 10:25:06 +00:00
silverwindandGitHub fac8bf2eca ci: install tools for renovate post-upgrade tasks (#38815)
Renovate installs a toolchain only for the managers a branch actually
updates. A branch without a gomod update therefore has no `go` on
`PATH`, and `make tidy`
[fails](https://github.com/go-gitea/gitea/pull/38813#issuecomment-5211013198).

Fix by installing those tools. `node` is not needed because it comes
with renovate.

---------

Signed-off-by: silverwind <me@silverwind.io>
2026-08-09 10:04:14 +00:00
silverwindandGitHub 0282bf861f chore: update AGENTS.md (#38833)
Few small tweaks that should improve the quality:

- Make agents read `docs` folder
- Shorter comments, shorter PR descriptions
- Modern Go features

Signed-off-by: silverwind <me@silverwind.io>
2026-08-09 09:37:43 +00:00
GiteaBot fecd2f3bc2 [skip ci] Updated translations via Crowdin 2026-08-09 00:32:02 +00:00
7fae3d5db3 feat(api): add project APIs (#38691)
Adds REST APIs for project boards for repo, org and user scopes, using
as much shared code as possible for all 3 scopes.

Fixes: https://github.com/go-gitea/gitea/issues/14299
Fixes: https://github.com/go-gitea/gitea/issues/31769
Fixes: https://github.com/go-gitea/gitea/issues/35921
Replaces: https://github.com/go-gitea/gitea/pull/37518
Replaces: https://github.com/go-gitea/gitea/pull/36008
Replaces: https://github.com/go-gitea/gitea/pull/28111
Replaces: https://github.com/go-gitea/gitea/pull/31768
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Supen.Huang <supen.huang@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ember <ember@mubergacres.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: beardev-in <abhinav.edulakanti@gmail.com>
2026-08-08 13:53:28 +00:00
8163139ec0 fix(packages): ignore nested Package.swift (#38788)
Nested `Package.swift` files overwrote the real package manifest. The
parser matched on the base name and kept the last entry in ZIP order.

`apple/swift-collections` ships `Benchmarks/Package.swift` and
`Utils/Debugger/FormatterFixtures/Package.swift`. The latter sorts after
the root `Package.swift`, so the registry stored a fixture manifest with
the wrong `swift-tools-version`, causing a toolchain mismatch and a
failed build. GRDB, swift-markdown, swift-syntax, sentry-cocoa and
SDWebImage share this layout.

The parser now keeps only manifests from the shallowest directory
holding one. That covers both a package at the archive root and the
single top level directory `swift package archive-source` produces. At
equal depth the first directory by name wins, so an archive always
yields the same metadata.

A nested manifest above the size limit no longer rejects the upload.

Co-authored-by: silverwind <me@silverwind.io>
2026-08-08 13:20:02 +00:00
silverwindandGitHub 25350d0c79 enhance: add missing npm package metadata properties (#38826)
The npm packument left `time`, `keywords` and `maintainers` empty
although the data was available. `created` and `modified` are derived
from the versions currently served, as there is no package-level
timestamp to read them from.
2026-08-08 10:04:40 +00:00
7dbfed37eb feat: add watch options (#37571)
Adds per-event watch options, so a user can choose which repository
notifications they receive.

The watch button is now a menu with four modes: participating and
mentions (the unwatched state, as on GitHub), all activity, ignore, and
custom, which opens a dialog to pick issues, pull requests and releases.
The same dialog is reachable from the gear icon on the watched
repository list. Options apply to UI notifications and to mail.

The watcher, star and fork counts moved inside their buttons, so all
three share one shape, and the links to those lists moved to the
repository sidebar.

Assignees and requested reviewers now also receive UI notifications
without having commented first.

Buttons and dropdown:

<img width="688" height="398" alt="image"
src="https://github.com/user-attachments/assets/889b567d-086f-4a06-ac67-3a4205fc63b2"
/>

Modal:

<img width="841" height="327" alt="Screenshot 2026-08-07 at 21 10 59"
src="https://github.com/user-attachments/assets/3bedbff5-9011-4f1b-91a9-e8f5c212fb5d"
/>

New entries in sidebar:

<img width="170" height="207" alt="Screenshot 2026-08-07 at 21 13 28"
src="https://github.com/user-attachments/assets/5f5150cd-f42d-4543-bd0b-7334cf4aab0e"
/>

Fixes: https://github.com/go-gitea/gitea/issues/17238
Ref: https://github.com/go-gitea/gitea/issues/35492

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-08 07:35:13 +00:00
2087d4a1a5 refactor: use the shared workflow model from actionslib (#38768)
Pairs with https://gitea.com/gitea/runner/pulls/1143.

Gitea depends on `gitea.com/gitea/runner` for exactly two packages:
`act/model` and `act/exprparser`, the workflow model and the expression
evaluator it needs to parse workflows and to build the task payload the
runner consumes. Pulling the whole runner module in for that is heavy
and puts shared code in the repository of one of the two consumers.

Both packages now live in `gitea.dev/actionslib` (`pkg/model`,
`pkg/exprparser`), the module Gitea and the runner already share for the
runner API, so the dependency on the runner repository is dropped here.

### Changes

- `gitea.com/gitea/runner/act/model` ->
`gitea.dev/actionslib/pkg/model`, `.../act/exprparser` ->
`gitea.dev/actionslib/pkg/exprparser` (22 files, import paths only).
- `routers/api/actions/runner/interceptor.go` takes the `x-runner-uuid`
/ `x-runner-token` names from `gitea.dev/actionslib/pkg/protocol`
instead of repeating the literals the runner also has.
- `go.mod`: `gitea.com/gitea/runner` removed.

---------

Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-08-07 20:56:40 -07:00
GiteaBot c14edf3313 [skip ci] Updated translations via Crowdin 2026-08-08 00:28:20 +00:00
f899dfd6e0 chore: fix legacy git ref problems (#38827)
1. add correct "refs/heads" prefix to the branch name for commit graph
2. fix incorrect cache key in GetCommitGraphsCount
3. remove the "--" trim for the tag name, there is no security vulnerability, we never do so anywhere else

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-07 23:04:36 +00:00
09f78aed19 fix: drop newline-bearing member names in arch ParsePackage (#38102)
The arch parser keeps tar member names verbatim. The index writer joins
those values one per line into the pacman database. So a member name
with a newline adds lines to that package's own `files` entry, which
libalpm reads as further fields.

The scope is one package record. An uploader cannot forge entries for
another package, and can set the same fields in `.PKGINFO` anyway. This
is input validation, not a privilege boundary.

`ParsePackage` now drops names that contain CR or LF. `joinFields` drops
such values again when writing the index, which also covers packages
that are already stored. Real packages never carry newlines in file
paths, so well-formed uploads are unaffected.

---------

Co-authored-by: silverwind <me@silverwind.io>
2026-08-07 20:47:33 +00:00
7733f1953f fix(storage): fix Azure Blob dump failing with file does not exist (#38814)
## Issue

Gitea fails to dump LFS (and other object-storage) files when Azure Blob
Storage is configured as the storage backend. The dump reports:

Failed to dump LFS objects: /file/path: copying contents: file does not
exist

This happens with any non-empty base path (the default for LFS storage),
which is why the user could only work around it by using `--skip-*`
flags.

The root cause is in `AzureBlobStorage.IterateObjects()`: Azure's list
API already returns each blob's name including the configured base path,
but the code was building the read client by running that name through
the base-path-prepending helper a second time. This doubled the base
path (e.g. `gitea-lfs/gitea-lfs/aa/bb/hash`), pointing at a blob that
doesn't exist. `Stat()` still succeeded because it doesn't touch the
network, so the failure only surfaced when the dumper actually tried to
read the object's contents.

## Solution

Add `getBlobClientByFullName()`, which builds a blob client from a name
that is already fully qualified, without re-applying
`buildAzureBlobPath()`. `IterateObjects()` now uses it for names
obtained from Azure's list API. `getBlobClient()` (used by `Open`,
`Stat`, `Delete`, `ServeDirectURL`, which take relative paths) is
unchanged in behavior.

Also add `TestAzureBlobStorageDumpArchive`, a regression test that
drives the real dump path (`IterateObjects` → `Stat` →
`dump.Dumper.AddFileByReader` → `mholt/archives` zip writer) against a
**non-empty** `BasePath`, and verifies the produced archive contains the
object with the correct content. The existing Azure tests use an empty
`BasePath` and never read object content via `IterateObjects`, which is
why they didn't catch this.

Fixes #35476

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 16:35:30 +00:00
silverwindandGitHub 4c382cea59 feat(actions): support $/ prefix in reusable workflow uses: (#38822)
Fixes: https://github.com/go-gitea/gitea/issues/38818

Accepts GitHub's `$/` self-repository prefix in a reusable workflow
`uses:`, alongside `./`.

Gitea's `./` already resolves against the caller's own source repo and
commit, which is what `$/` means, so the two are aliases here. Cycle
detection folds both prefixes onto one key.

Related PR for step-level support:
https://gitea.com/gitea/runner/pulls/1150
2026-08-07 15:39:36 +00:00
d4333eb043 fix(release): allow publishing drafts without target #35569 (#38800)
### Issue

Fully publishing a draft release fails when the release does not have a
target set.

A draft release can be created without a target because the Git tag is
not created while the release remains a draft. However, when the draft
is published, Gitea attempts to resolve the release target to create the
tag. If the target is empty, the publish operation fails.

### Solution

Use the repository's default branch as the target when a draft release
is published without an explicitly configured target.

Also add a regression test to verify that a draft release without a
target can be successfully published.

Fixes #35569

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 15:06:16 +00:00
Zettat123andGitHub 9fc5d20006 feat(actions): add force-cancel workflow run API (#38756)
Add `POST /repos/{owner}/{repo}/actions/runs/{run}/force-cancel`, the
counterpart of [GitHub's force-cancel endpoint](https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2026-03-10#force-cancel-a-workflow-run).

It cancels a run like `POST .../cancel`, but bypasses the graceful
cancelling handshake with the runner and stops running tasks
immediately.

Permissions and responses match the `/cancel` endpoint.

References:

- https://github.blog/changelog/2023-09-21-github-actions-force-cancel-workflows/
- https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2026-03-10#force-cancel-a-workflow-run
- https://github.com/orgs/community/discussions/123240
2026-08-07 14:38:14 +00:00
2657756cac enhance: fall back to DEFAULT_TEMPLATE.md when style-specific template is missing (#38803)
Closes #38801

Introduce `DEFAULT_TEMPLATE.md` as the default message for all merge
styles.

https://gitea.com/gitea/docs/pulls/491

By the way, fix incorrect os.Expand usage for merge message & repo
template

---------

Co-authored-by: waterWang <waterWang@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 14:11:03 +00:00
a34cc4cac4 fix(migration): migration deletion returned json redirection (#38796)
Fix #38596

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 13:21:20 +00:00
8873150206 fix(api): document X-Total-Count instead of non-existent X-Total header (#38717)
The `CommitList` and `ChangedFileList` swagger responses documented an
`X-Total` response header that their endpoints never send, they send
`X-Total-Count` via `SetTotalCountHeader`. This renames the documented
header and regenerates the spec.

`repoGetAllCommits` also uses `CommitList` and sends both headers, so
the new name is accurate for all three endpoints.

Also corrects `ChangedFileList` describing its headers as commit counts.

Documentation only, no handler behaviour changes.

Fixes https://github.com/go-gitea/gitea/issues/22048

---------

Signed-off-by: davidpavlovschi <davidpavlov2048@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-07 13:00:35 +00:00
silverwindandGitHub e81ab0a5ea refactor: prepare vue components for vapor mode (#38798)
Removes what would block a later switch to Vue's vapor mode:
`vue-chartjs` and the `SvgIcon` render function are virtual DOM
components, and `v-memo` has no vapor equivalent. This does not adopt
vapor mode, which will be stable in upcoming Vue 3.6.

`vue-chartjs` was a thin wrapper over chart.js, so a local
`ChartCanvas.vue` replaces it. Chart data and options move into computed
values to keep their object identity, which is what `v-memo` was
compensating for.

`chartjs-adapter-dayjs-4` is moved first-party, just ~40 lines that are
easy to maintain.
2026-08-07 12:38:31 +00:00
silverwindandGitHub d86cb1a498 fix(ui): anchor navbar admin badge to the avatar corner (#38821)
Fix regression from https://github.com/go-gitea/gitea/pull/38614:

Fixes: https://github.com/go-gitea/gitea/issues/38820
2026-08-07 11:58:15 +00:00
silverwindandGitHub ec869e3052 fix: misc fixes in pub/gpg/tests (#38809)
- The pub registry reported the oldest version as `latest`, because the
descriptor slice is sorted ascending but the first element was used.
- Verifying a GPG or SSH key flashed success and redirected after
already writing an error response, so a failure was reported as a
success with an empty key id.
- Test packages sharing redis could tear down each other's server.
`PrepareTestRedis` started its own on the well-known port, so a package
running in parallel borrowed it and lost it when the owner's cleanup
fired. It now listens on a socket of its own.
2026-08-07 11:49:23 +00:00
silverwindandGitHub dd8ef9c888 fix(ui): change underlines to default browser style (#38819)
Remove all underline style customization on links, letting the browser defaults apply
2026-08-07 13:18:41 +02:00
470 changed files with 12738 additions and 3084 deletions
+1
View File
@@ -11,6 +11,7 @@ linters:
- dupl
- errcheck
- forbidigo
- forcetypeassert
- gocheckcompilerdirectives
- gocritic
- govet
+4 -2
View File
@@ -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
+2 -62
View File
File diff suppressed because one or more lines are too long
+27 -17
View File
@@ -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
}
+2 -3
View File
@@ -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")
+2 -3
View File
@@ -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
+2 -4
View File
@@ -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
+2 -4
View File
@@ -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=
+6 -5
View File
@@ -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 -4
View File
@@ -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)
+6 -6
View File
@@ -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"
+25
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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)
+76 -2
View File
@@ -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)
})
}
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+23 -1
View File
@@ -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
}
+34
View File
@@ -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})
+3 -2
View File
@@ -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
View File
@@ -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)
}
+3 -1
View File
@@ -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" ...
-6
View File
@@ -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
}
+4 -3
View File
@@ -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
}
+5 -1
View File
@@ -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
+13
View File
@@ -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)
}
+9
View File
@@ -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
View File
@@ -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)
+42
View File
@@ -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
}
+40
View File
@@ -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)
}
+33 -3
View File
@@ -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
View File
@@ -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
}
+21 -8
View File
@@ -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
View File
@@ -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)
}
+42 -2
View File
@@ -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())
}
+16 -12
View File
@@ -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
}
-268
View File
@@ -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
}
+3 -4
View File
@@ -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.
+16 -51
View File
@@ -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 {
+5 -5
View File
@@ -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},
+7 -4
View File
@@ -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.
+2 -1
View File
@@ -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"
+6 -6
View File
@@ -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 {
+6
View File
@@ -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"},
+7 -6
View File
@@ -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"
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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:
+34 -15
View File
@@ -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
}
}
+7 -3
View File
@@ -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)
+8 -6
View File
@@ -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))
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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()
+21
View File
@@ -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
+1 -1
View File
@@ -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
}).
+3 -1
View File
@@ -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 -1
View File
@@ -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()))
+10 -14
View File
@@ -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.
+5 -6
View File
@@ -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,
+5 -2
View File
@@ -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)
}
+6 -2
View File
@@ -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
}
+6 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
}
+4 -1
View File
@@ -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",
+17 -6
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+4 -1
View File
@@ -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)
}
+13 -22
View File
@@ -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)
+1 -1
View File
@@ -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)
+4 -6
View File
@@ -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
+1 -1
View File
@@ -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)))
+2 -2
View File
@@ -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":
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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 {
+3
View File
@@ -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
}
+1
View File
@@ -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)
+21
View File
@@ -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))
}
+17 -8
View File
@@ -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
+7 -7
View File
@@ -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
+17 -2
View File
@@ -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
+84
View File
@@ -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",
+12
View File
@@ -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
}
+4 -4
View File
@@ -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