Compare commits

...

23 Commits

Author SHA1 Message Date
Giteabot
bfebc4b0e1 fix: incorrect co-author detection on commit page (#38386) (#38387)
Backport #38386 by @bircni


The commit page built its "co-authored by" list from
`AllParticipantIdentities()[1:]`, which only drops the author and leaves
the committer in the list even though the committer is already shown
separately as "committed by". This caused the committer to be wrongly
rendered as a co-author (issue #38384): a commit authored by
`silverwind`, committed by `bircni`, with a `Co-authored-by: silverwind`
trailer displayed "co-authored by bircni" instead of the actual trailer
identity. This adds a `Commit.CoAuthorIdentities()` method that excludes
both the author and the committer, uses it on the commit page, and
covers the fix with a regression test.

Closes #38384

Co-authored-by: bircni <bircni@icloud.com>
2026-07-09 18:29:28 -07:00
Giteabot
d9534e7bfa fix(lfs): require proof of possession for cross-repo objects (#38322) (#38389)
Backport #38322 by @bircni

The LFS batch and upload handlers linked an object that already existed
in the content store but was not linked to the current repo whenever the
token's user could access it in another repo. Deploy-key tokens carry
the repo owner's identity, so a single-repo write deploy key could link
and then download objects from any repo the owner can see.

This drops the cross-repo access check: the batch handler now makes the
client upload (hash-verified) any object not yet linked to the repo, and
the upload handler skips proof of possession only when the object is
already linked to the current repo.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-09 22:18:07 +00:00
Giteabot
532828cc82 enhance(actions): only create filtered-out workflow commit status for required contexts (#38371) (#38385)
Backport #38371 by @Zettat123

Follow #38237

#38237 posts "skipped" commit statuses for every workflow that is not
triggered due to a filter (e.g. `paths` or `branches`) mismatch.
However, for non-required workflows, creating "skipped" commit statuses
for them would generate a lot of noise.

To address this issue, this PR adds a check before creating commit
status:

- For the context that matches any required status check patterns, a
"skipped" commit status will be created. The `Required` label can inform
users that this status check is required, but has been skipped because
of a filter mismatch.

<img width="800" alt="image"
src="https://github.com/user-attachments/assets/264fd716-413c-457d-a5a6-6717175d3807"
/>

- For a non-required context, nothing will be created.

NOTE: Reducing noise is a best-effort approach and isn't entirely
accurate. When creating commit statuses, it is impossible to predict
which branch protection rule will take effect. Therefore, we have to
compare the commit status context against the required patterns from all
rules. If any rule matches, the context is considered "required".
 
Closes https://github.com/go-gitea/gitea/issues/38351

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-09 15:58:46 +00:00
Giteabot
8cf988f0a6 fix(ui): restore commits table column widths (#38379) (#38383)
Backport #38379 by @silverwind

https://github.com/go-gitea/gitea/pull/37594 widened the author column
from `three wide` to `four wide`, leaving a large empty gap around the
SHA column in the common single-author case. The old author cell was
capped at 180px, so the wider column only adds whitespace: avatar-stack
names ellipsize at 240px each, and wider multi-author rows still expand
naturally in the auto-layout table. Restore the pre-existing
`three`/`eight` widths.

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-09 17:07:20 +02:00
Giteabot
04a26f40a0 test(e2e): fix race in pdf file render test (#38380) (#38381)
Backport #38380 by @silverwind

`data-render-name` is set before the plugin's async render runs, so
measuring the container height right after the attribute appears can
observe the pre-render 48px height when the `pdfobject` chunk loads
slowly (flaked in CI). Poll for the height instead, like the asciicast
test in the same file does.

Co-authored-by: silverwind <me@silverwind.io>
2026-07-09 13:57:50 +00:00
Giteabot
fc4eac390a fix: golang html template url escaping (#38363) (#38369)
Backport #38363

fix #38362

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-08 12:10:26 +08:00
Giteabot
64d559a8e6 perf(actions): debounce runner heartbeat writes and throttle task picks (#38281) (#38368)
Backport #38281 by @bircni

3 reductions in the DB load generated by many runners polling `FetchTask`:

**1. Debounce runner heartbeat writes**
Every poll wrote `last_online`, and every `UpdateTask`/`UpdateLog` wrote
`last_active` — while a runner streams logs that is many writes per
second per runner. These are now persisted only when stale enough to
actually affect the active/offline status (`ShouldPersistLastOnline` /
`ShouldPersistLastActive`), using the existing columns.

**2. Throttle concurrent task picks**
A new in-process semaphore (`MAX_CONCURRENT_TASK_PICKS`) bounds how many
runners run the task-assignment transaction at once, so a fleet polling
together cannot stampede the query. Throttled polls retry on their next
poll without advancing the runner's tasks version.

**3. Paginate the task-pick query**
`CreateTaskForRunner` previously loaded every waiting job in the
runner's scope into memory on each poll (no `LIMIT`). Now it pages
through the waiting backlog oldest-first with `LIMIT`, claiming the
first label-matching job.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-07 16:27:00 -07:00
Giteabot
03372836ab fix(mirror): disable HTTP redirects on pull mirror sync (#38320) (#38367)
Backport #38320 by @bircni

Pull mirror sync ran `git fetch` / `remote update` / `remote prune`
without disabling HTTP redirects. A mirror remote that later starts
redirecting to an otherwise-blocked or internal address could be used as
an SSRF/exfiltration vector on scheduled syncs, bypassing the
allow/block validation applied at migration time.

This sets `http.followRedirects=false` on all three remote-contacting
commands in the pull mirror path, matching the existing guard already
present on the clone path.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-07 17:59:33 +00:00
Giteabot
1af1e06ac4 fix: minio init check (#38355) (#38361)
Backport #38355

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-07 14:41:57 +02:00
Giteabot
e07a5de53f fix: org project view assignee list (#38357) (#38360)
Backport #38357

fix #38129

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-07 07:07:30 +00:00
Giteabot
d4d81af583 fix(actions): release claimed task if context is cancelled during FetchTask (#38343) (#38347) 2026-07-06 07:38:47 +02:00
Giteabot
6cd2c647ec test: compare key file contents instead of FileInfo in TestInitKeys (#38330) (#38331)
Backport #38330 by @Zettat123

`TestInitKeys` verified whether a host key file was regenerated by
comparing `os.FileInfo` before and after a `InitDefaultHostKeys` call.
On systems where the OS clock / filesystem timestamp granularity is
coarse, both writes land in the same tick and get an identical mtime.
The resulting `FileInfo` is then byte-for-byte equal even though the key
was actually regenerated, so `assert.NotEqual` fails.

Since a regenerated key always produces different random bytes,
comparing the content can reliably detect regeneration.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-04 20:52:52 +00:00
Giteabot
37c8604105 fix(release): validate web attachment renames against allowed types (#38314) (#38328)
Backport #38314 by @lunny

This fixes the web release edit flow so renamed release attachments are
validated against `[repository.release] ALLOWED_TYPES`.

Previously, the API attachment edit endpoint already enforced release
attachment type restrictions, but the web release edit form passed
`attachment-edit-*` values into `release_service.UpdateRelease`, which
updated attachment names directly without validating the new filename
against `setting.Repository.Release.AllowedTypes`.

As a result, a user with repository write access could rename an
existing release attachment to a disallowed extension through the web
UI.

- validate edited release attachment names in
`release_service.UpdateRelease`
- reject forbidden attachment renames using
  `setting.Repository.Release.AllowedTypes`
- re-render the web release edit page with a validation error instead of
  returning an internal server error
- add regression coverage for both the service layer and the web flow

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-04 17:45:11 +02:00
Giteabot
a9814e789c fix(actions): make runner list pagination order deterministic (#38313) (#38327)
Backport #38313 by @bircni

The runner-listing API endpoints (`admin`, `org`, `user`, `repo`, and
`shared`) return runners in a non-deterministic order across pages. When
paging through runners, some runners from page 1 could reappear on page
2 (and others get skipped entirely).

The cause is in `FindRunnerOptions.ToOrders()`. Most sort modes order by
a **non-unique** column only:

- default / `online` / `offline` → `last_online`
- `alphabetically` / `reversealphabetically` → `name`

When multiple runners tie on the sort key (e.g. every offline runner
shares `last_online = 0`, or two runners have the same name), the
database is free to return the tied rows in any order between separate
queries. Combined with `LIMIT`/`OFFSET` pagination, this means the same
runner can land on more than one page.

## Fix

Append the unique primary key `id` as a stable tiebreaker to each
non-unique sort order:

The `newest`/`oldest` modes already sort by the unique `id`, so they are
left unchanged.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-03 20:59:33 +00:00
Giteabot
ab10e37acf fix(release): gate draft release attachments on web download endpoints (#38318) (#38325)
Backport #38318 by @bircni

Draft-release access control was enforced only on the API release
endpoints (`/api/v1/repos/{owner}/{repo}/releases/...`) but not on the
UUID-based web attachment endpoints (`/attachments/{uuid}`,
`/{owner}/{repo}/attachments/{uuid}`,
`/{owner}/{repo}/releases/attachments/{uuid}`).

Anyone who obtained an attachment UUID — including unauthenticated
callers — could download files belonging to a hidden draft release,
since `ServeAttachment` only checked repo-level read permission and
never the release's draft state.

This extends `ServeAttachment` to require write access to releases when
the attachment belongs to a draft release, mirroring the existing
API-side `canAccessReleaseDraft` gate. A regression test is included.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-03 20:34:37 +02:00
Giteabot
f7bc6b89c1 fix: Improve since/until when counting commits for X-Total-Count (#38243) (#38304)
Backport #38243 by @puni9869

Follow up for https://github.com/go-gitea/gitea/pull/38204.

Signed-off-by: puni9869 <80308335+puni9869@users.noreply.github.com>
Co-authored-by: puni9869 <80308335+puni9869@users.noreply.github.com>
2026-07-02 10:07:29 +00:00
Giteabot
fabc5e6fcb fix(actions): prevent chevron overlap with log text when timestamps are enabled (#38227) (#38307)
Backport #38227 by @SudhanshuMatrix

### Description
This PR resolves a UI alignment bug in the Gitea Actions log viewer
where the expand/collapse disclosure chevron overlaps with the log text
(specifically the timestamp) when timestamps are enabled.

### Cause
When log timestamps are enabled, the timestamp element
(`.log-time-stamp`) is rendered as the first element next to the line
number. Because it only had a default `10px` left margin, it positioned
itself exactly where the group's expand/collapse chevron is located,
causing them to overlap.

Fixes #38222.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-02 11:22:26 +02:00
Giteabot
a016d678db fix(workflows): branch protection status checks fail when workflow uses on: paths filter (#38237) (#38302) 2026-07-01 20:10:11 +00:00
Giteabot
b6e409badd fix(oauth2): persist linkAccountData during auto-link 2FA flow (#38274) (#38295)
Backport #38274 by @afahey03

Fixes HTTP 500 when OIDC auto account linking (`ACCOUNT_LINKING=auto`)
requires local 2FA. `oauth2LinkAccount` set `linkAccount` in the session
before redirecting to 2FA but did not persist `linkAccountData`, so
`TwoFactorPost` failed with `not in LinkAccount session`. The manual
linking flow already stored both, this aligns auto-link with that
behavior.

Created the test, `TestOAuth2AutoLinkWithTwoFactor`, which verifies that
automatic account linking completes after the user passes local 2FA when
an OIDC identity matches an existing account.

DISCLAIMER: I used AI to create the test

Closes #38171

Co-authored-by: Aidan Fahey <afahey2003@yahoo.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-01 13:18:37 +02:00
Giteabot
2734504cfc fix(actions): allow Actions bot to push to protected branches (#38284) (#38293)
Backport #38284 by @bircni

Fixes #38278

## Problem

When branch protection matches the branch an Actions workflow pushes to,
the runner's `git push` is rejected — even though the workflow token has
`contents: write` and the same push performed with a PAT (write access)
succeeds. Disabling protection or changing the pattern so it no longer
matches makes the push work.

## Root cause

In `preReceiveBranch` (`routers/private/hook_pre_receive.go`), the "can
the doer push to this protected branch" check resolves the pusher with
`user_model.GetUserByID(ctx, ctx.opts.UserID)`. For an Actions push the
user ID is `-2` (the virtual `ActionsUserID`), which has no database
row, so the lookup fails. Even past that, `CanUserPush` →
`HasAccessUnit`/whitelist membership cannot evaluate a virtual user and
returns `false`. As a result the Actions bot was rejected on every
matching protected branch, despite the earlier `assertCanWriteRef`
already confirming the token's code-write via
`GetActionsUserRepoPermission`.

This was inconsistent: a PAT with identical write access passed the
exact same check.

## Fix

Evaluate the Actions bot against its already-computed token permission
instead of a user lookup, mirroring the existing
`IsUserMergeWhitelisted` pattern:

- Add `CanActionsUserPush` / `CanActionsUserForcePush` on
`ProtectedBranch`, which take the precomputed `access_model.Permission`.
- Allow the push when push is enabled, **no** push whitelist is
enforced, and the token has code-write.
- Keep the bot blocked when a whitelist is enforced — it cannot be added
to one, so it must use a pull request. This preserves the whitelist as a
real security boundary.

Force-push, signed-commit and protected-file-path checks are untouched.

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-01 11:46:17 +02:00
Giteabot
eee7967b81 fix(actions): include all aggregable run statuses in status filter (#38280) (#38287)
Backport #38280 by @bircni

The **Status** filter dropdown on the repository Actions run list does
not let you filter for **Blocked** runs (nor **Cancelled** or
**Skipped**). These statuses are missing from the dropdown even though a
run can legitimately end up in any of them.

A run's status is computed by `aggregateJobStatus`, which can return
`Blocked`, `Cancelled` and `Skipped`. Because the filter dropdown only
offered Success, Failure, Waiting, Running and Cancelling, runs in those
other states existed but were impossible to filter for.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-01 10:12:11 +02:00
Giteabot
1df8f91691 fix(archiver): use serializable repo-archive queue payload (#38273) (#38283)
Backport #38273 by @Vinod-OAI

After upgrading from 1.25.x to 1.26.x, `repo-archive` workers can fail
to unmarshal queued items:

```
Failed to unmarshal item from queue "repo-archive":
json: unable to unmarshal into Go convert.Conversion within "/Repo/Units/0/Config":
cannot derive concrete type for nil interface with finite type set
```

`ArchiveRequest` started embedding `*repo_model.Repository` in 1.26,
which does not round-trip through the JSON queue.

This change stores a minimal `archiveQueueItem` (`RepoID`, `Type`,
`CommitID`, `Paths`) in `repo-archive` and loads the repository in the
worker. `UnmarshalJSON` accepts legacy payloads that used `RepoID` or
embedded `Repo.id`.

Fixes #38272

<!--
Before submitting:
- Target the `main` branch; release branches are for backports only.
- Use a Conventional Commits title, e.g. `fix(repo): handle empty branch
names`.
- Read the contributing guidelines:
https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md
- Documentation changes go to https://gitea.com/gitea/docs

Describe your change below and link any issue it fixes.
-->

Co-authored-by: Vinod-OAI <venkat.vinod@observe.ai>
Co-authored-by: bircni <bircni@icloud.com>
2026-06-30 19:17:29 +02:00
bircni
4654e7eccd docs: Changelog for 1.27.0-rc0 (#38247)
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-29 21:06:07 +02:00
76 changed files with 1820 additions and 522 deletions

View File

@@ -4,6 +4,274 @@ This changelog goes through the changes that have been made in each release
without substantial changes to our git log; to see the highlights of what has without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com). been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.27.0-rc0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0-rc0) - 2026-06-28
* BREAKING
* Feat(actions)!: improve support for reusable workflows (#37478)
* Use Content-Security-Policy: script nonce (#37232)
* SECURITY
* Fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 [security] (#37786)
* Fix(oauth): restrict introspection to the token's client (#38042)
* Fix(api): don't expose private org membership via public_members (#38145)
* Fix(actions): deny fork-PR cross-repo access via collaborative owner (#38214)
* Fix(migrations): prevent path traversal in repository restore (#38215)
* FEATURES
* Feat(actions): add workflow status badge modal (#38196)
* Feat(actions): support owner-level and global scoped workflows (#38154)
* Feat(api): support ref suffixes in compare (#38148)
* Feat(actions): implement `jobs.<job_id>.continue-on-error` (#38100)
* Feat(actions): show run status on browser tab favicon (#38071)
* Feat(api): add token introspection and self-deletion endpoint (#37995)
* Feat(api): add q parameter to list branches API for server-side filtering (#37982)
* Feat(repo): split repository creation limit into user and org scopes (#37872)
* Feat(actions): bulk delete, disable and enable runners in admin UI (#37869)
* Feat(actions): List workflows that were executed once but got removed from the default branch (#37835)
* Feat(org): add team visibility so org members can discover teams (#37680)
* Feat: add raw diff/patch endpoint for repository comparisons (#37632)
* Feat: Add avatar stacks (#37594)
* Feat(actions): add job summaries (GITHUB_STEP_SUMMARY) (#37500)
* Feat(web): Add Jupyter Notebook (.ipynb) Rendering Support (#37433)
* Support for Custom URI Schemes in OAuth2 Redirect URIs (#37356)
* Feat(orgs): Add search bar for organization members tab page (#37347)
* Feat(api): Add assignees APIs (#37330)
* Feat(api): Add GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs (#37196)
* Serve OpenAPI 3.0 spec at /openapi.v1.json (#37038)
* Add project column picker to issue and pull request sidebar (#37037)
* Allow multiple projects per issue and pull requests (#36784)
* Feat(ui): add "follow rename" to file commit history list (#34994)
* Feat(ssh): auto generate additional ssh keys (#33974)
* ENHANCEMENTS
* Enhance: allow builtin default git config options to be overridden (#38172)
* Enhance: allow MathML core elements (#38034)
* Enhance(markup): improve issue title rendering (#37908)
* Enhance(actions): set descriptive browser tab title on run view (#37870)
* Enhance: Migrate remaining gopkg.in/yaml.v3 usages to go.yaml.in/yaml/v4 (#37866)
* Enhance(actions): show workflow name from YAML instead of filename (#37833)
* Feat(actions): add before/after to PR synchronize event payload (#37827)
* Enhance(actions): add branch filters to run list (#37826)
* Enhance(actions): Make Summary UI more beautiful with more infos (#37824)
* Feat: add copy button to action step header, improve other copy buttons (#37744)
* Fix(icon): use repo-forked icon to display forks count (#37731)
* Feat(api): add sort and order query parameters to job list endpoints (#37672)
* Feat(api): add last_sync to repository API (#37566)
* Enhance: Adjust Workflow Graph styling (#37497)
* Improve code editor text selection and clean up lint enablement (#37474)
* Add mirror auth updates to repo edit API and settings (#37468)
* Replace `olivere/elastic` with REST API client, add OpenSearch support (#37411)
* Feat: Add default PR branch update style setting (#37410)
* Fix inconsistent disabled styling on logged-out repo header buttons (#37406)
* Allow fast-forward-only merge when signed commits are required (#37335)
* Enhance styling in actions page (#37323)
* Fix: improve actions status icons and texts (#37206)
* Make Markdown fenced code block work with more syntaxes (#37154)
* Fix: Sort action run jobs by JobID and Name with matrix examples (#37046)
* Add API endpoint to reply to pull request review comments (#36683)
* PERFORMANCE
* Perf(web): sort the action_run query by a repo-scoped index when possible (#38155)
* Perf: Various performance regression fixes (#38078)
* Perf: extend action `c_u` index to include `created_unix` for faster dashboard feeds (#38076)
* Batch-load related data in actions run, job, and task API endpoints (#37032)
* BUGFIXES
* Fix: update npm dependencies, fix misc issues (#38257)
* Fix(api): respect since/until when counting commits for X-Total-Count (#38204)
* Fix: codemirror regressions (#38248)
* Fix(api): support HEAD requests on all API GET endpoints (#38245)
* Fix(actions): Cleanup workflow status badge code (#38241)
* Fix(web): Correctly align the "disabled" label on larger workflow names (#38240)
* Fix(actions): don't swallow HTML entities into linkified URLs (#38239)
* Fix(packages): accept npm "repository" and "bin" in string form (#38236)
* Fix(actions): fix 500 error when canceling a canceling task (#38223)
* Fix(deps): update module golang.org/x/image to v0.43.0 [security] (#38219)
* Fix(mssql): convert legacy DATETIME columns to DATETIME2 (#38216)
* Fix(api): deny private org member enumeration via /members (#38213)
* Fix(actions): ensure all waiting jobs get runners in large workflows (#38200)
* Fix(deps): update go dependencies (#38194)
* Fix(deps): update npm dependencies (#38193)
* Fix(cli): default must-change-password to false for bot users (#38175)
* Fix(actions): show run index in run view and fix summary graph height (#38165)
* Fix: csp (#38162)
* Fix(deps): update npm dependencies (#38123)
* Fix(mssql): expand legacy issue and comment long-text columns (#38120)
* Fix(packages): validate debian distribution and component names (#38116)
* Fix(packages): validate module version in goproxy ParsePackage (#38104)
* Fix(deps): update dependency esbuild to v0.28.1 [security] (#38097)
* Fix: git push hook post receive (#38089)
* Fix(ui): prevent commit status popup overflowing its row (#38081)
* Fix: validate gem name in rubygems parseMetadataFile (#38061)
* Fix: commit display name (#38057)
* Fix: csp regressions (#38047)
* Fix: api error message (#38031)
* Fix(deps): update npm dependencies (#38029)
* Fix: pgsql lint (#38022)
* Fix(indexer): fix assignee filters in issue search (#38021)
* Fix: various dropdown problems (#38020)
* Fix: refactor git error handling and make archive streaming handle non-existing commit id (#38007)
* Fix: raise git required version to 2.13 (#37996)
* Fix: remove "no-transfrom" from the cache-control header (#37985)
* Fix(deps): update module github.com/google/go-github/v87 to v88 (#37971)
* Fix: use committer time where ever possible as default (#37969)
* Fix(deps): update npm dependencies, remove nolyfill (#37968)
* Fix(deps): update go dependencies (#37967)
* Fix(pull): preserve squash message trailers and additional commit messages (#37954)
* Fix(deps): update module golang.org/x/image to v0.41.0 [security] (#37904)
* Fix: support ##[command] log prefix in action run UI (#37882)
* Fix(deps): update module github.com/google/go-github/v86 to v87 (#37845)
* Fix(deps): update npm dependencies (#37844)
* Fix(deps): update go dependencies (#37841)
* Fix(frontend): resolve Vite assets by manifest source path (#37836)
* Fix(locales): Replace hardcoded strings (#37788)
* Fix(packages): render markdown links relative to linked repo (#37676)
* Fix: persist mirror repository metadata (#37519)
* Fix cmd tests by mocking builtin paths (#37369)
* Add `form-fetch-action` to some forms, fix "fetch action" resp bug (#37305)
* Feat: execute post run cleanup when workflow is cancelled (#37275)
* Fix `relative-time` error and improve global error handler (#37241)
* Refactor flash message and remove SanitizeHTML template func (#37179)
* TESTING
* Test: speed up two tests (#37905)
* Test: Fix random failure test (#37887)
* Test: fix flaky `issue-comment` close test (#37880)
* Test: enable WAL for sqlite integration tests (#37861)
* Test: fix flaky `TestResourceIndex` and reduce its runtime (#37847)
* Test: run `TestAPIRepoMigrate` offline via a local clone source (#37817)
* Ci: shard tests and reduce redundant work (#37618)
* Test(e2e): run playwright via container (#37300)
* Remove external service dependencies in migration tests (#36866)
* BUILD
* Fix(actions): authenticate snapcraft before nightly remote build (#38252)
* Ci: cap Elasticsearch heap in db-tests (#37816)
* Build(snap): publish nightly version to snapcraft via actions (#37814)
* Ci: split pgsql shards into plain jobs, dedupe setup actions (#37802)
* Ci: narrow files-changed frontend filter (#37749)
* Ci: add `zizmor` to `lint-actions` (#37720)
* Chore: clean up "contrib" dir (#37690)
* Fix: snap build (main branch) (#37685)
* Ci: Also lint json5 files (#37659)
* Feat(editor): broaden language detection in web code editor (#37619)
* Build: update pnpm to v11 (#37591)
* Refactor(deps): migrate from `nektos/act` fork to `gitea/runner` (#37557)
* Refactor: lint bare `fill`/`stroke` colors, add vars for git graph color series (#37543)
* Update go js py dependencies (#37525)
* Ci: lint PR titles with commitlint (#37498)
* Chore: upgrade Go version in devcontainer image to 1.26 (#37374)
* Update GitHub Actions to latest major versions (#37313)
* Update go js dependencies (#37312)
* Fail vite build on rolldown warnings via NODE_ENV=test (#37270)
* Remove htmx (#37224)
* Replace custom Go formatter with `golangci-lint fmt` (#37194)
* Refactor htmx and fetch-action related code (#37186)
* Integrate renovate bot for all dependency updates (#37050)
* Build(sign): move to sigstore (#38250)
* DOCS
* Docs: update changelog for 1.26.3 & 1.26.4 (#38178)
* Docs: fix duplicated word in foreachref doc comment (#38161)
* Docs: Clarify criteria for becoming a merger (#38113)
* Docs: Publish TOC Election Result 2026 (#38111)
* Docs: mark openapi3 as autogenerated in attributes (#37963)
* Docs: add development setup guide (#37960)
* MISC
* Revert(sign): restore gpg (#38251)
* Refactor: replace legacy `delete-button` with `link-action` (#38143)
* Refactor(actions): read runner capabilities from proto field (#38068)
* Refactor(api): clarify APIError message usage and fix legacy lint error (#38012)
* Refactor: Use db.Get[] instead of db.GetEngine(ctx).Get(bean) to avoid zero value fetching wrong database record (#37977)
* Fix(deps): update go dependencies (#37851)
* Ci: Fix sync PR labels from the conventional-commit title (#37784) (#37825)
* Ci: tweak `files-changed`, add `free-disk-space` (#37819)
* Fix(deps): update module golang.org/x/crypto to v0.52.0 [security] (#37806)
* Test(e2e): add comment, release, star, PR and fork tests (#37800)
* Chore: simplify issue and pull request templates (#37799)
* Chore: Update giteabot to fix failure when backport (#37789)
* Fix(api): handle partial failures in push mirror synchronization gracefully (#37782)
* Fix(deps): update module gitlab.com/gitlab-org/api/client-go/v2 to v2.26.0 (#37771)
* Ci: split giteabot workflow (#37770)
* Fix(deps): update npm dependencies (#37768)
* Refactor(waitgroup): replace Add/Done goroutines with WaitGroup.Go (#37764)
* Fix(deps): update module google.golang.org/grpc to v1.81.1 (#37762)
* Ci: fix cache-related issues (#37761)
* Chore: fix tests (#37760)
* Fix(deps): update module github.com/google/go-github/v85 to v86 (#37754)
* Fix(deps): update npm dependencies (#37753)
* Fix(deps): update go dependencies (#37752)
* Chore(deps): update action dependencies (#37751)
* Fix(markup): wrap indented code blocks for the code-copy button (#37748)
* Chore(db): introduce db.Session and db.EngineMigration interfaces (#37746)
* Feat(web): also display PR counts in repo list (#37739)
* Refactor(glob): use strings.Builder for regexp compilation (#37730)
* Chore(doctor): remove four obsolete doctor check implementations (#37728)
* Refactor(org): simplify owner-team org repo creation logic (#37727)
* Refactor: move `workflowpattern` into `modules/actions` (#37717)
* Chore: clean up tests (#37715)
* Style: misc UI fixes (#37691)
* Ci: add shellcheck linter (#37682)
* Fix: catch and fix more lint problems (#37674)
* Fix(deps): update dependency mermaid to v11.15.0 [security], add e2e test (#37662)
* Fix(deps): update npm dependencies (#37647)
* Ci(renovate): update Go import paths on major bumps (#37641)
* Fix(deps): update go dependencies (major) (#37639)
* Chore(deps): update action dependencies (major) (#37638)
* Fix(deps): update module code.gitea.io/sdk/gitea to v0.25.0 (#37637)
* Fix(deps): update npm dependencies (#37636)
* Refactor(log): replace log.Critical with log.Error (#37624)
* Build(deps): bump fast-uri from 3.1.0 to 3.1.2 (#37616)
* Feat(oauth): Support AWS Cognito OAuth2 provider (#37607)
* Chore(deps): update action dependencies (#37603)
* Ci: allow `chore` type in PR title lint (#37575)
* Refactor: only reset a database table when the table's data was changed (#37573)
* Ci: increase renovate frequency and fix RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS (#37565)
* Refactor: use modernc sqlite driver as default (#37562)
* Docs: fix 4 typos in CHANGELOG.md (#37549)
* Fix(deps): update go dependencies (#37541)
* Chore(deps): update action dependencies (#37540)
* Refactor pull request view (6) (#37522)
* Fix: redirect early CLI console logger to stderr (#37507)
* Refactor "flex-list" to "flex-divided-list" (#37505)
* Refactor compare diff/pull page (1) (#37481)
* Refactor pull request view (4) (#37451)
* Update 1.26.1 changelog in main (#37442)
* Refactor: use named `Permission` field in `Repository` struct instead of anonymous embedding (#37441)
* Refactor: serve site manifest via `/assets/site-manifest.json` endpoint (#37405)
* Remove IsValidExternalURL/IsAPIURL and use IsValidURL at call sites (#37364)
* Update `Block a user` form (#37359)
* Move review request functions to a standalone file (#37358)
* Feat(security): set X-Content-Type-Options: nosniff by default (#37354)
* Enable strict TypeScript, add `errorMessage` helper (#37292)
* Refactor frontend `tw-justify-between` layouts to `flex-left-right` (#37291)
* Update Nix flake (#37284)
* Fix Repository transferring page (#37277)
* Remove `SubmitEvent` polyfill (#37276)
* Remove dead code identified by `deadcode` tool (#37271)
* Upgrade go-git to v5.18.0 (#37268)
* Don't add useless labels which will bother changelog generation (#37267)
* Move heatmap to first-party code (#37262)
* Tests/integration: simplify code (#37249)
* Add pagination and search box to org teams list (#37245)
* Remove error returns from crypto random helpers and callers (#37240)
* Add `ExternalIDClaim` option for OAuth2 OIDC auth source (#37229)
* Refactor: simplify ParseCatFileTreeLine and catBatchParseTreeEntries (#37210)
* Refactor "htmx" to "fetch action" (#37208)
* Update go js py dependencies (#37204)
* Add comment for the design of "user activity time" (#37195)
* Remove outdated RunUser logic (#37180)
* Models/fixtures: add "DO NOT add more test data" comment to all yml fixture files (#37150)
* Update javascript dependencies (#37142)
* Update go dependencies (#37141)
* Frontport changelog of v1.26.0-rc0 (#37138)
* Introduce `ActionRunAttempt` to represent each execution of a run (#37119)
* Workflow Artifact Info Hover (#37100)
* Extend issue context popup beyond markdown content (#36908)
* Add bulk repository deletion for organizations (#36763)
* Feat: Add bypass allowlist for branch protection (#36514)
## [1.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21 ## [1.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21
* SECURITY * SECURITY

View File

@@ -3008,6 +3008,10 @@ LEVEL = Info
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows ;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
;; Maximum number of attempts a single workflow run can have. Default value is 50. ;; Maximum number of attempts a single workflow run can have. Default value is 50.
;MAX_RERUN_ATTEMPTS = 50 ;MAX_RERUN_ATTEMPTS = 50
;; Maximum number of runners that may run the task-assignment query concurrently, per Gitea instance.
;; Caps this instance's DB load when many runners poll at once; excess runners retry on their next poll.
;; In a multi-instance deployment the cluster-wide limit is this value times the number of instances. Default value is 16.
;MAX_CONCURRENT_TASK_PICKS = 16
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -135,8 +135,8 @@ type StatusInfo struct {
// GetStatusInfoList returns a slice of StatusInfo // GetStatusInfoList returns a slice of StatusInfo
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo { func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
// same as those in aggregateJobStatus // same as those in aggregateJobStatus (StatusUnknown excluded; it's the "shouldn't happen" fallback)
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling} allStatus := []Status{StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped, StatusWaiting, StatusRunning, StatusBlocked, StatusCancelling}
statusInfoList := make([]StatusInfo, 0, len(allStatus)) statusInfoList := make([]StatusInfo, 0, len(allStatus))
for _, s := range allStatus { for _, s := range allStatus {
statusInfoList = append(statusInfoList, StatusInfo{ statusInfoList = append(statusInfoList, StatusInfo{

View File

@@ -73,8 +73,11 @@ func TestGetStatusInfoList(t *testing.T) {
assert.Equal(t, []StatusInfo{ assert.Equal(t, []StatusInfo{
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"}, {Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
{Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"}, {Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"},
{Status: int(StatusCancelled), StatusName: StatusCancelled.String(), DisplayedStatus: "actions.status.cancelled"},
{Status: int(StatusSkipped), StatusName: StatusSkipped.String(), DisplayedStatus: "actions.status.skipped"},
{Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"}, {Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"},
{Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"}, {Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"},
{Status: int(StatusBlocked), StatusName: StatusBlocked.String(), DisplayedStatus: "actions.status.blocked"},
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"}, {Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
}, statusInfoList) }, statusInfoList)
} }

View File

@@ -75,8 +75,28 @@ type ActionRunner struct {
const ( const (
RunnerOfflineTime = time.Minute RunnerOfflineTime = time.Minute
RunnerIdleTime = 10 * time.Second RunnerIdleTime = 10 * time.Second
// RunnerHeartbeatInterval is how often last_online is persisted on poll.
// Must stay well below RunnerOfflineTime so runners don't flap to offline.
RunnerHeartbeatInterval = 30 * time.Second
// RunnerActiveInterval is how often last_active is persisted while a runner
// streams task updates and logs. Must stay well below RunnerIdleTime so a
// busy runner keeps showing ACTIVE without a DB write on every RPC.
RunnerActiveInterval = 5 * time.Second
) )
// ShouldPersistLastOnline reports whether last_online is stale enough to be
// worth writing back. Avoids a DB write on every runner poll.
func ShouldPersistLastOnline(last timeutil.TimeStamp, now time.Time) bool {
return now.Sub(last.AsTime()) >= RunnerHeartbeatInterval
}
// ShouldPersistLastActive reports whether last_active is stale enough to be
// worth writing back. Avoids a DB write on every UpdateTask/UpdateLog RPC while
// a runner is actively streaming logs.
func ShouldPersistLastActive(last timeutil.TimeStamp, now time.Time) bool {
return now.Sub(last.AsTime()) >= RunnerActiveInterval
}
// BelongsToOwnerName before calling, should guarantee that all attributes are loaded // BelongsToOwnerName before calling, should guarantee that all attributes are loaded
func (r *ActionRunner) BelongsToOwnerName() string { func (r *ActionRunner) BelongsToOwnerName() string {
if r.RepoID != 0 { if r.RepoID != 0 {
@@ -251,21 +271,24 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
} }
func (opts FindRunnerOptions) ToOrders() string { func (opts FindRunnerOptions) ToOrders() string {
// A unique tiebreaker (id) is appended so that runners sharing the same
// last_online or name keep a deterministic order across paginated queries,
// otherwise the same runner may appear on more than one page.
switch opts.Sort { switch opts.Sort {
case "online": case "online":
return "last_online DESC" return "last_online DESC, id ASC"
case "offline": case "offline":
return "last_online ASC" return "last_online ASC, id ASC"
case "alphabetically": case "alphabetically":
return "name ASC" return "name ASC, id ASC"
case "reversealphabetically": case "reversealphabetically":
return "name DESC" return "name DESC, id ASC"
case "newest": case "newest":
return "id DESC" return "id DESC"
case "oldest": case "oldest":
return "id ASC" return "id ASC"
} }
return "last_online DESC" return "last_online DESC, id ASC"
} }
// GetRunnerByUUID returns a runner via uuid // GetRunnerByUUID returns a runner via uuid

View File

@@ -0,0 +1,149 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
"time"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldPersistLastOnline(t *testing.T) {
now := time.Now()
tests := []struct {
name string
last timeutil.TimeStamp
want bool
}{
{
name: "fresh, skip write",
last: timeutil.TimeStamp(now.Add(-5 * time.Second).Unix()),
want: false,
},
{
name: "exactly at interval, write",
last: timeutil.TimeStamp(now.Add(-RunnerHeartbeatInterval).Unix()),
want: true,
},
{
name: "stale, write",
last: timeutil.TimeStamp(now.Add(-2 * RunnerHeartbeatInterval).Unix()),
want: true,
},
{
name: "zero (never seen), write",
last: 0,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, ShouldPersistLastOnline(tt.last, now))
})
}
}
func TestShouldPersistLastActive(t *testing.T) {
now := time.Now()
tests := []struct {
name string
last timeutil.TimeStamp
want bool
}{
{
name: "fresh, skip write",
last: timeutil.TimeStamp(now.Add(-1 * time.Second).Unix()),
want: false,
},
{
name: "exactly at interval, write",
last: timeutil.TimeStamp(now.Add(-RunnerActiveInterval).Unix()),
want: true,
},
{
name: "stale, write",
last: timeutil.TimeStamp(now.Add(-2 * RunnerActiveInterval).Unix()),
want: true,
},
{
name: "zero (never seen), write",
last: 0,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, ShouldPersistLastActive(tt.last, now))
})
}
}
func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) {
// Sorts on a non-unique column must end with the unique id tiebreaker so
// pagination is deterministic; without it, runners sharing the same
// last_online or name can appear on more than one page. Sorts already on
// the unique id need no tiebreaker.
expected := map[string]string{
"": "last_online DESC, id ASC",
"online": "last_online DESC, id ASC",
"offline": "last_online ASC, id ASC",
"alphabetically": "name ASC, id ASC",
"reversealphabetically": "name DESC, id ASC",
"newest": "id DESC",
"oldest": "id ASC",
}
for sort, want := range expected {
assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort)
}
}
func TestFindRunners_PaginationNoDuplicates(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Create several runners that all share the same last_online value so the
// primary sort key (last_online) is tied for all of them.
const ownerID = 1000
const count = 6
for i := range count {
runner := &ActionRunner{
Name: "paginated-runner",
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
OwnerID: ownerID,
RepoID: 0,
LastOnline: 42,
}
require.NoError(t, db.Insert(ctx, runner))
}
// Page through the runners and ensure every id is returned exactly once.
seen := make(map[int64]int)
const pageSize = 2
for page := 1; ; page++ {
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
OwnerID: ownerID,
})
require.NoError(t, err)
if len(runners) == 0 {
break
}
for _, r := range runners {
seen[r.ID]++
}
}
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
for id, n := range seen {
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
}
}

View File

@@ -231,6 +231,11 @@ func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
// another runner won the optimistic-lock race; it is never returned to callers. // another runner won the optimistic-lock race; it is never returned to callers.
var errJobAlreadyClaimed = errors.New("job already claimed by another runner") var errJobAlreadyClaimed = errors.New("job already claimed by another runner")
// pickTaskBatchSize bounds how many waiting jobs each CreateTaskForRunner query loads,
// so a large backlog is not fetched into memory on every runner poll.
// It is a var only so tests can shrink it to exercise pagination cheaply.
var pickTaskBatchSize = 100
// CreateTaskForRunner finds a waiting job that matches the runner's labels and // CreateTaskForRunner finds a waiting job that matches the runner's labels and
// atomically claims it. It iterates through all matching jobs so that a // atomically claims it. It iterates through all matching jobs so that a
// concurrent claim by another runner (which would lose the optimistic lock on // concurrent claim by another runner (which would lose the optimistic lock on
@@ -249,31 +254,51 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id"). Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions})) Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
} }
if jobCond.IsValid() { baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
jobCond = builder.In("run_id", builder.Select("id").From("action_run").Where(jobCond))
}
var jobs []*ActionRunJob
if err := e.Where("task_id=? AND status=? AND is_reusable_caller=?", 0, StatusWaiting, false).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil {
return nil, false, err
}
// TODO: a more efficient way to filter labels // TODO: a more efficient way to filter labels
log.Trace("runner labels: %v", runner.AgentLabels) log.Trace("runner labels: %v", runner.AgentLabels)
for _, v := range jobs {
if !runner.CanMatchLabels(v.RunsOn) { // Page through the waiting jobs oldest-first instead of loading the whole backlog into memory on every poll.
continue // Keyset pagination on (updated, id) is safe under concurrent claims:
// updated only moves forward, so the advancing cursor never skips a still-waiting job even as claimed jobs drop out.
var cursorUpdated timeutil.TimeStamp
var cursorID int64
for {
cond := baseCond
if cursorID > 0 {
cond = cond.And(builder.Or(
builder.Gt{"updated": cursorUpdated},
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
))
} }
task, ok, err := claimJobForRunner(ctx, runner, v)
if err != nil { var jobs []*ActionRunJob
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
return nil, false, err return nil, false, err
} }
if ok {
return task, true, nil for _, v := range jobs {
if !runner.CanMatchLabels(v.RunsOn) {
continue
}
task, ok, err := claimJobForRunner(ctx, runner, v)
if err != nil {
return nil, false, err
}
if ok {
return task, true, nil
}
// Another runner claimed this job concurrently; try the next one.
} }
// Another runner claimed this job concurrently; try the next one.
// A short page means no waiting jobs remain beyond it.
if len(jobs) < pickTaskBatchSize {
return nil, false, nil
}
last := jobs[len(jobs)-1]
cursorUpdated, cursorID = last.Updated, last.ID
} }
return nil, false, nil
} }
// claimJobForRunner attempts to atomically claim job for runner inside its own // claimJobForRunner attempts to atomically claim job for runner inside its own

View File

@@ -371,3 +371,74 @@ func TestReleaseTaskForRunner(t *testing.T) {
unittest.AssertNotExistsBean(t, &ActionTask{ID: task.ID}) unittest.AssertNotExistsBean(t, &ActionTask{ID: task.ID})
unittest.AssertNotExistsBean(t, &ActionTaskStep{TaskID: task.ID}) unittest.AssertNotExistsBean(t, &ActionTaskStep{TaskID: task.ID})
} }
// TestCreateTaskForRunnerPagination verifies that a job sitting beyond the first page is still claimed
func TestCreateTaskForRunnerPagination(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer func(orig int) { pickTaskBatchSize = orig }(pickTaskBatchSize)
pickTaskBatchSize = 2
run := &ActionRun{
Title: "pagination-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 9903,
TriggerUserID: 2,
Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusWaiting,
}
require.NoError(t, db.Insert(t.Context(), run))
// Five waiting jobs the runner cannot run, then one it can.
// With a page size of 2 the matching job only appears on the third page.
for i := range 5 {
mismatch := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "mismatch-" + string(rune('a'+i)),
Attempt: 1,
JobID: "mismatch-" + string(rune('a'+i)),
Status: StatusWaiting,
RunsOn: []string{"windows-latest"},
}
require.NoError(t, db.Insert(t.Context(), mismatch))
}
target := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "target-job",
Attempt: 1,
JobID: "target-job",
Status: StatusWaiting,
RunsOn: []string{"ubuntu-latest"},
WorkflowPayload: []byte("on: push\njobs:\n target-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
}
require.NoError(t, db.Insert(t.Context(), target))
runner := &ActionRunner{
UUID: "pagination-runner-uuid",
Name: "pagination-runner",
AgentLabels: []string{"ubuntu-latest"},
}
runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner))
task, ok, err := CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, task)
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
assert.Equal(t, StatusRunning, claimed.Status)
assert.Equal(t, task.ID, claimed.TaskID)
}

View File

@@ -55,29 +55,34 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er
return parsed, nil return parsed, nil
} }
// MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event, returning those whose `on:` matches. // MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event.
// It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
func MatchScopedWorkflows( func MatchScopedWorkflows(
parsed []*ParsedScopedWorkflow, parsed []*ParsedScopedWorkflow,
consumerGitRepo *git.Repository, consumerGitRepo *git.Repository,
consumerCommit *git.Commit, consumerCommit *git.Commit,
triggedEvent webhook_module.HookEventType, triggedEvent webhook_module.HookEventType,
payload api.Payloader, payload api.Payloader,
) []*DetectedWorkflow { ) (matched, filtered []*DetectedWorkflow) {
workflows := make([]*DetectedWorkflow, 0, len(parsed))
for _, p := range parsed { for _, p := range parsed {
for _, evt := range p.Events { for _, evt := range p.Events {
if evt.IsSchedule() { if evt.IsSchedule() {
// schedule is a non-target for scoped workflows // schedule is a non-target for scoped workflows
continue continue
} }
if detectMatched(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) { dwf := &DetectedWorkflow{
workflows = append(workflows, &DetectedWorkflow{ EntryName: p.EntryName,
EntryName: p.EntryName, TriggerEvent: evt,
TriggerEvent: evt, Content: p.Content,
Content: p.Content, }
}) switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
case detectMatched:
matched = append(matched, dwf)
case detectFilteredOut:
filtered = append(filtered, dwf)
case detectNotApplicable:
} }
} }
} }
return workflows return matched, filtered
} }

View File

@@ -30,6 +30,14 @@ type DetectedWorkflow struct {
Content []byte Content []byte
} }
type detectResult int
const (
detectMatched detectResult = iota // event matched; run normally
detectNotApplicable // event/type doesn't apply; create nothing
detectFilteredOut // matched but excluded by a branch/paths filter; posts a skipped commit status when the context is a required check
)
func init() { func init() {
model.OnDecodeNodeError = func(node yaml.Node, out any, err error) { model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
// Log the error instead of panic or fatal. // Log the error instead of panic or fatal.
@@ -172,18 +180,16 @@ func DetectWorkflows(
triggedEvent webhook_module.HookEventType, triggedEvent webhook_module.HookEventType,
payload api.Payloader, payload api.Payloader,
detectSchedule bool, detectSchedule bool,
) ([]*DetectedWorkflow, []*DetectedWorkflow, error) { ) (workflows, schedules, filtered []*DetectedWorkflow, err error) {
_, entries, err := ListWorkflows(commit) _, entries, err := ListWorkflows(commit)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
workflows := make([]*DetectedWorkflow, 0, len(entries))
schedules := make([]*DetectedWorkflow, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
content, err := GetContentFromEntry(entry) content, err := GetContentFromEntry(entry)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
// one workflow may have multiple events // one workflow may have multiple events
@@ -203,18 +209,24 @@ func DetectWorkflows(
} }
schedules = append(schedules, dwf) schedules = append(schedules, dwf)
} }
} else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) { } else {
dwf := &DetectedWorkflow{ dwf := &DetectedWorkflow{
EntryName: entry.Name(), EntryName: entry.Name(),
TriggerEvent: evt, TriggerEvent: evt,
Content: content, Content: content,
} }
workflows = append(workflows, dwf) switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
case detectMatched:
workflows = append(workflows, dwf)
case detectFilteredOut:
filtered = append(filtered, dwf)
case detectNotApplicable:
}
} }
} }
} }
return workflows, schedules, nil return workflows, schedules, filtered, nil
} }
func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) { func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) {
@@ -252,9 +264,9 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
return wfs, nil return wfs, nil
} }
func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) bool { func detectWorkflowMatch(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) detectResult {
if !canGithubEventMatch(evt.Name, triggedEvent) { if !canGithubEventMatch(evt.Name, triggedEvent) {
return false return detectNotApplicable
} }
switch triggedEvent { switch triggedEvent {
@@ -268,7 +280,7 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts()) log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
} }
// no special filter parameters for these events, just return true if name matched // no special filter parameters for these events, just return true if name matched
return true return detectMatched
case // push case // push
webhook_module.HookEventPush: webhook_module.HookEventPush:
@@ -279,14 +291,20 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
webhook_module.HookEventIssueAssign, webhook_module.HookEventIssueAssign,
webhook_module.HookEventIssueLabel, webhook_module.HookEventIssueLabel,
webhook_module.HookEventIssueMilestone: webhook_module.HookEventIssueMilestone:
return matchIssuesEvent(payload.(*api.IssuePayload), evt) if matchIssuesEvent(payload.(*api.IssuePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // issue_comment case // issue_comment
webhook_module.HookEventIssueComment, webhook_module.HookEventIssueComment,
// `pull_request_comment` is same as `issue_comment` // `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 // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
webhook_module.HookEventPullRequestComment: webhook_module.HookEventPullRequestComment:
return matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt) if matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // pull_request case // pull_request
webhook_module.HookEventPullRequest, webhook_module.HookEventPullRequest,
@@ -300,34 +318,49 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
case // pull_request_review case // pull_request_review
webhook_module.HookEventPullRequestReviewApproved, webhook_module.HookEventPullRequestReviewApproved,
webhook_module.HookEventPullRequestReviewRejected: webhook_module.HookEventPullRequestReviewRejected:
return matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt) if matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // pull_request_review_comment case // pull_request_review_comment
webhook_module.HookEventPullRequestReviewComment: webhook_module.HookEventPullRequestReviewComment:
return matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt) if matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // release case // release
webhook_module.HookEventRelease: webhook_module.HookEventRelease:
return matchReleaseEvent(payload.(*api.ReleasePayload), evt) if matchReleaseEvent(payload.(*api.ReleasePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // registry_package case // registry_package
webhook_module.HookEventPackage: webhook_module.HookEventPackage:
return matchPackageEvent(payload.(*api.PackagePayload), evt) if matchPackageEvent(payload.(*api.PackagePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // workflow_run case // workflow_run
webhook_module.HookEventWorkflowRun: webhook_module.HookEventWorkflowRun:
return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) if matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) {
return detectMatched
}
return detectNotApplicable
default: default:
log.Warn("unsupported event %q", triggedEvent) log.Warn("unsupported event %q", triggedEvent)
return false return detectNotApplicable
} }
} }
func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) bool { func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) detectResult {
// with no special filter parameters // with no special filter parameters
if len(evt.Acts()) == 0 { if len(evt.Acts()) == 0 {
return true return detectMatched
} }
matchTimes := 0 matchTimes := 0
@@ -393,14 +426,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before) filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
if err != nil { if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err) log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else { return detectNotApplicable
patterns, err := workflowpattern.CompilePatterns(vals...) }
if err != nil { patterns, err := workflowpattern.CompilePatterns(vals...)
break if err != nil {
} break
if !workflowpattern.Skip(patterns, filesChanged) { }
matchTimes++ if !workflowpattern.Skip(patterns, filesChanged) {
} matchTimes++
} }
case "paths-ignore": case "paths-ignore":
if refName.IsTag() { if refName.IsTag() {
@@ -410,14 +443,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before) filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
if err != nil { if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err) log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else { return detectNotApplicable
patterns, err := workflowpattern.CompilePatterns(vals...) }
if err != nil { patterns, err := workflowpattern.CompilePatterns(vals...)
break if err != nil {
} break
if !workflowpattern.Filter(patterns, filesChanged) { }
matchTimes++ if !workflowpattern.Filter(patterns, filesChanged) {
} matchTimes++
} }
default: default:
log.Warn("push event unsupported condition %q", cond) log.Warn("push event unsupported condition %q", cond)
@@ -427,7 +460,10 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
if hasBranchFilter && hasTagFilter { if hasBranchFilter && hasTagFilter {
matchTimes++ matchTimes++
} }
return matchTimes == len(evt.Acts()) if matchTimes == len(evt.Acts()) {
return detectMatched
}
return detectFilteredOut
} }
func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool { func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool {
@@ -478,7 +514,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
return matchTimes == len(evt.Acts()) return matchTimes == len(evt.Acts())
} }
func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool { func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) detectResult {
acts := evt.Acts() acts := evt.Acts()
activityTypeMatched := false activityTypeMatched := false
matchTimes := 0 matchTimes := 0
@@ -525,7 +561,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha) headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
if err != nil { if err != nil {
log.Error("GetCommit [ref: %s]: %v", prPayload.PullRequest.Head.Sha, err) log.Error("GetCommit [ref: %s]: %v", prPayload.PullRequest.Head.Sha, err)
return false return detectNotApplicable
} }
} }
@@ -557,33 +593,39 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase) filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
if err != nil { if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err) log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
} else { return detectNotApplicable
patterns, err := workflowpattern.CompilePatterns(vals...) }
if err != nil { patterns, err := workflowpattern.CompilePatterns(vals...)
break if err != nil {
} break
if !workflowpattern.Skip(patterns, filesChanged) { }
matchTimes++ if !workflowpattern.Skip(patterns, filesChanged) {
} matchTimes++
} }
case "paths-ignore": case "paths-ignore":
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase) filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
if err != nil { if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err) log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
} else { return detectNotApplicable
patterns, err := workflowpattern.CompilePatterns(vals...) }
if err != nil { patterns, err := workflowpattern.CompilePatterns(vals...)
break if err != nil {
} break
if !workflowpattern.Filter(patterns, filesChanged) { }
matchTimes++ if !workflowpattern.Filter(patterns, filesChanged) {
} matchTimes++
} }
default: default:
log.Warn("pull request event unsupported condition %q", cond) log.Warn("pull request event unsupported condition %q", cond)
} }
} }
return activityTypeMatched && matchTimes == len(evt.Acts()) if !activityTypeMatched {
return detectNotApplicable
}
if matchTimes != len(evt.Acts()) {
return detectFilteredOut
}
return detectMatched
} }
func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool { func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool {

View File

@@ -101,49 +101,49 @@ func TestDetectMatched(t *testing.T) {
triggedEvent webhook_module.HookEventType triggedEvent webhook_module.HookEventType
payload api.Payloader payload api.Payloader
yamlOn string yamlOn string
expected bool expected detectResult
}{ }{
{ {
desc: "HookEventCreate(create) matches GithubEventCreate(create)", desc: "HookEventCreate(create) matches GithubEventCreate(create)",
triggedEvent: webhook_module.HookEventCreate, triggedEvent: webhook_module.HookEventCreate,
payload: nil, payload: nil,
yamlOn: "on: create", yamlOn: "on: create",
expected: true, expected: detectMatched,
}, },
{ {
desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)", desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
triggedEvent: webhook_module.HookEventIssues, triggedEvent: webhook_module.HookEventIssues,
payload: &api.IssuePayload{Action: api.HookIssueOpened}, payload: &api.IssuePayload{Action: api.HookIssueOpened},
yamlOn: "on: issues", yamlOn: "on: issues",
expected: true, expected: detectMatched,
}, },
{ {
desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)", desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
triggedEvent: webhook_module.HookEventIssues, triggedEvent: webhook_module.HookEventIssues,
payload: &api.IssuePayload{Action: api.HookIssueMilestoned}, payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
yamlOn: "on: issues", yamlOn: "on: issues",
expected: true, expected: detectMatched,
}, },
{ {
desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)", desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
triggedEvent: webhook_module.HookEventPullRequestSync, triggedEvent: webhook_module.HookEventPullRequestSync,
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized}, payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
yamlOn: "on: pull_request", yamlOn: "on: pull_request",
expected: true, expected: detectMatched,
}, },
{ {
desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type", desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
triggedEvent: webhook_module.HookEventPullRequest, triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated}, payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
yamlOn: "on: pull_request", yamlOn: "on: pull_request",
expected: false, expected: detectNotApplicable,
}, },
{ {
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type", desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
triggedEvent: webhook_module.HookEventPullRequest, triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueClosed}, payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
yamlOn: "on: pull_request", yamlOn: "on: pull_request",
expected: false, expected: detectNotApplicable,
}, },
{ {
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches", desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches",
@@ -155,56 +155,56 @@ func TestDetectMatched(t *testing.T) {
}, },
}, },
yamlOn: "on:\n pull_request:\n branches: [main]", yamlOn: "on:\n pull_request:\n branches: [main]",
expected: false, expected: detectNotApplicable,
}, },
{ {
desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type", desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type",
triggedEvent: webhook_module.HookEventPullRequest, triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated}, payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
yamlOn: "on:\n pull_request:\n types: [labeled]", yamlOn: "on:\n pull_request:\n types: [labeled]",
expected: true, expected: detectMatched,
}, },
{ {
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)", desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
triggedEvent: webhook_module.HookEventPullRequestReviewComment, triggedEvent: webhook_module.HookEventPullRequestReviewComment,
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed}, payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
yamlOn: "on:\n pull_request_review_comment:\n types: [created]", yamlOn: "on:\n pull_request_review_comment:\n types: [created]",
expected: true, expected: detectMatched,
}, },
{ {
desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)", desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)",
triggedEvent: webhook_module.HookEventPullRequestReviewRejected, triggedEvent: webhook_module.HookEventPullRequestReviewRejected,
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed}, payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
yamlOn: "on:\n pull_request_review:\n types: [dismissed]", yamlOn: "on:\n pull_request_review:\n types: [dismissed]",
expected: false, expected: detectNotApplicable,
}, },
{ {
desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type", desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type",
triggedEvent: webhook_module.HookEventRelease, triggedEvent: webhook_module.HookEventRelease,
payload: &api.ReleasePayload{Action: api.HookReleasePublished}, payload: &api.ReleasePayload{Action: api.HookReleasePublished},
yamlOn: "on:\n release:\n types: [published]", yamlOn: "on:\n release:\n types: [published]",
expected: true, expected: detectMatched,
}, },
{ {
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type", desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
triggedEvent: webhook_module.HookEventPackage, triggedEvent: webhook_module.HookEventPackage,
payload: &api.PackagePayload{Action: api.HookPackageCreated}, payload: &api.PackagePayload{Action: api.HookPackageCreated},
yamlOn: "on:\n registry_package:\n types: [updated]", yamlOn: "on:\n registry_package:\n types: [updated]",
expected: false, expected: detectNotApplicable,
}, },
{ {
desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)", desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
triggedEvent: webhook_module.HookEventWiki, triggedEvent: webhook_module.HookEventWiki,
payload: nil, payload: nil,
yamlOn: "on: gollum", yamlOn: "on: gollum",
expected: true, expected: detectMatched,
}, },
{ {
desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)", desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
triggedEvent: webhook_module.HookEventSchedule, triggedEvent: webhook_module.HookEventSchedule,
payload: nil, payload: nil,
yamlOn: "on: schedule", yamlOn: "on: schedule",
expected: true, expected: detectMatched,
}, },
{ {
desc: "push to tag matches workflow with paths condition (should skip paths check)", desc: "push to tag matches workflow with paths condition (should skip paths check)",
@@ -222,7 +222,19 @@ func TestDetectMatched(t *testing.T) {
}, },
commit: nil, commit: nil,
yamlOn: "on:\n push:\n paths:\n - src/**", yamlOn: "on:\n push:\n paths:\n - src/**",
expected: true, expected: detectMatched,
},
{
desc: "push branch filter excludes -> filtered out",
triggedEvent: webhook_module.HookEventPush,
payload: &api.PushPayload{
Ref: "refs/heads/feature/x",
Before: "0000000",
Commits: []*api.PayloadCommit{{ID: "abc", Added: []string{"a.go"}, Message: "x"}},
},
commit: nil,
yamlOn: "on:\n push:\n branches: [main]",
expected: detectFilteredOut,
}, },
} }
@@ -231,7 +243,7 @@ func TestDetectMatched(t *testing.T) {
evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn)) evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn))
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, evts, 1) assert.Len(t, evts, 1)
assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0])) assert.Equal(t, tc.expected, detectWorkflowMatch(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0]))
}) })
} }
} }

View File

@@ -131,3 +131,17 @@ func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
} }
return c.allParticipants return c.allParticipants
} }
// CoAuthorIdentities returns the commit's co-authors: the participants without the
// author and the committer, which are already displayed separately. This avoids
// surfacing the committer as a "co-author" (see issue #38384).
func (c *Commit) CoAuthorIdentities() []*CommitIdentity {
var ret []*CommitIdentity
for _, p := range c.AllParticipantIdentities()[1:] { // index 0 is always the author
if c.Committer.Email != "" && strings.EqualFold(p.Email, c.Committer.Email) {
continue
}
ret = append(ret, p)
}
return ret
}

View File

@@ -80,3 +80,42 @@ func TestCommitMessageAllParticipantIdentities(t *testing.T) {
assert.Equal(t, c.participant, c.commit.AllParticipantIdentities()) assert.Equal(t, c.participant, c.commit.AllParticipantIdentities())
} }
} }
func TestCommitMessageCoAuthorIdentities(t *testing.T) {
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
idt := func(n, e string) *CommitIdentity { return &CommitIdentity{Name: n, Email: e} }
cases := []struct {
commit *Commit
coAuthors []*CommitIdentity
}{
{
// a genuine co-author (neither author nor committer) is reported
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>"},
},
[]*CommitIdentity{idt("x", "x@m.com")},
},
{
// the committer is shown separately as "committed by", so it must
// not appear as a co-author even though it is a participant
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
},
nil,
},
{
// regression for #38384: a Co-authored-by trailer naming the author
// must not cause the committer to be surfaced as the co-author
&Commit{
Author: sig("silverwind", "me@silverwind.io"), Committer: sig("bircni", "bircni@icloud.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: silverwind <me@silverwind.io>"},
},
nil,
},
}
for _, c := range cases {
assert.Equal(t, c.coAuthors, c.commit.CoAuthorIdentities())
}
}

View File

@@ -21,19 +21,6 @@ func TestCommitsCount(t *testing.T) {
assert.Equal(t, int64(3), commitsCount) assert.Equal(t, int64(3), commitsCount)
} }
func TestCommitsCountWithoutBase(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Not: "master",
Revision: []string{"branch1"},
})
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestCommitsCountWithSinceUntil(t *testing.T) { func TestCommitsCountWithSinceUntil(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"} bareRepo1 := &mockRepository{path: "repo1_bare"}
revision := []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"} revision := []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}
@@ -65,6 +52,19 @@ func TestCommitsCountWithSinceUntil(t *testing.T) {
} }
} }
func TestCommitsCountWithoutBase(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Not: "master",
Revision: []string{"branch1"},
})
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestGetLatestCommitTime(t *testing.T) { func TestGetLatestCommitTime(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"} bareRepo1 := &mockRepository{path: "repo1_bare"}
lct, err := GetLatestCommitTime(t.Context(), bareRepo1) lct, err := GetLatestCommitTime(t.Context(), bareRepo1)

View File

@@ -14,6 +14,8 @@ import (
const defaultMaxRerunAttempts = 50 const defaultMaxRerunAttempts = 50
const defaultMaxConcurrentTaskPicks = 16
// Actions settings // Actions settings
var ( var (
Actions = struct { Actions = struct {
@@ -31,13 +33,18 @@ var (
WorkflowDirs []string `ini:"WORKFLOW_DIRS"` WorkflowDirs []string `ini:"WORKFLOW_DIRS"`
ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"` ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"`
MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"` MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"`
// MaxConcurrentTaskPicks bounds how many runners may run the task-assignment
// transaction at once per Gitea instance, to avoid a thundering herd when many
// runners poll together. It is a per-process limit, not a cluster-wide one.
MaxConcurrentTaskPicks int `ini:"MAX_CONCURRENT_TASK_PICKS"`
}{ }{
Enabled: true, Enabled: true,
DefaultActionsURL: defaultActionsURLGitHub, DefaultActionsURL: defaultActionsURLGitHub,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}, SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"}, WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"}, ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
MaxRerunAttempts: defaultMaxRerunAttempts, MaxRerunAttempts: defaultMaxRerunAttempts,
MaxConcurrentTaskPicks: defaultMaxConcurrentTaskPicks,
} }
) )
@@ -128,6 +135,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
Actions.MaxRerunAttempts = defaultMaxRerunAttempts Actions.MaxRerunAttempts = defaultMaxRerunAttempts
} }
if Actions.MaxConcurrentTaskPicks <= 0 {
Actions.MaxConcurrentTaskPicks = defaultMaxConcurrentTaskPicks
}
if !Actions.LogCompression.IsValid() { if !Actions.LogCompression.IsValid() {
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression) return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
} }

View File

@@ -73,17 +73,18 @@ func TestInitKeys(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, keyFiles, len(keyTypes)) assert.Len(t, keyFiles, len(keyTypes))
metadata := map[string]os.FileInfo{} // Record file contents so regeneration can be detected
content := map[string][]byte{}
for _, keyType := range keyTypes { for _, keyType := range keyTypes {
privKeyPath := filepath.Join(tempDir, "gitea."+keyType) privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub") pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
info, err := os.Stat(privKeyPath) data, err := os.ReadFile(privKeyPath)
require.NoError(t, err) require.NoError(t, err)
metadata[privKeyPath] = info content[privKeyPath] = data
info, err = os.Stat(pubKeyPath) data, err = os.ReadFile(pubKeyPath)
require.NoError(t, err) require.NoError(t, err)
metadata[pubKeyPath] = info content[pubKeyPath] = data
} }
// Test recreation on missing private key and noop for missing pub key // Test recreation on missing private key and noop for missing pub key
@@ -98,26 +99,26 @@ func TestInitKeys(t *testing.T) {
privKeyPath := filepath.Join(tempDir, "gitea."+keyType) privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub") pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
infoPriv, err := os.Stat(privKeyPath) dataPriv, err := os.ReadFile(privKeyPath)
require.NoError(t, err) require.NoError(t, err)
switch keyType { switch keyType {
case "rsa": case "rsa":
// No modification to RSA key // No modification to RSA key
infoPub, err := os.Stat(pubKeyPath) dataPub, err := os.ReadFile(pubKeyPath)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, metadata[privKeyPath], infoPriv) assert.Equal(t, content[privKeyPath], dataPriv)
assert.Equal(t, metadata[pubKeyPath], infoPub) assert.Equal(t, content[pubKeyPath], dataPub)
case "ecdsa": case "ecdsa":
// ECDSA public key should be missing, private unchanged // ECDSA public key should be missing, private unchanged
assert.Equal(t, metadata[privKeyPath], infoPriv) assert.Equal(t, content[privKeyPath], dataPriv)
assert.NoFileExists(t, pubKeyPath) assert.NoFileExists(t, pubKeyPath)
case "ed25519": case "ed25519":
// ed25519 private key was removed, so both keys regenerated // ed25519 private key was removed, so both keys regenerated
infoPub, err := os.Stat(pubKeyPath) dataPub, err := os.ReadFile(pubKeyPath)
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, metadata[privKeyPath], infoPriv) assert.NotEqual(t, content[privKeyPath], dataPriv)
assert.NotEqual(t, metadata[pubKeyPath], infoPub) assert.NotEqual(t, content[pubKeyPath], dataPub)
} }
} }
} }

View File

@@ -6,6 +6,7 @@ package storage
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -47,40 +48,42 @@ type MinioStorage struct {
basePath string basePath string
} }
func convertMinioErr(err error) error { func convertMinioErr(err error, optMsg ...string) error {
if err == nil { if err == nil {
return nil return nil
} }
errResp, ok := err.(minio.ErrorResponse)
wrapErr := func(err error) error {
if len(optMsg) == 0 {
return err
}
return fmt.Errorf("%s: %w", optMsg[0], err)
}
errResp, ok := errors.AsType[minio.ErrorResponse](err)
if !ok { if !ok {
return err return wrapErr(err)
} }
// Convert two responses to standard analogues // Convert two responses to standard analogues
switch errResp.Code { switch errResp.Code {
case "NoSuchKey": case "NoSuchKey":
return os.ErrNotExist return wrapErr(os.ErrNotExist)
case "AccessDenied": case "AccessDenied":
return os.ErrPermission return wrapErr(os.ErrPermission)
} }
return err return wrapErr(err)
}
var getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
_, err := minioClient.GetBucketVersioning(ctx, bucket)
return err
} }
// NewMinioStorage returns a minio storage // NewMinioStorage returns a minio storage
func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) { func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
config := cfg.MinioConfig config := cfg.MinioConfig
log.Info("Creating minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" { if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" {
return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm) return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm)
} }
log.Info("Creating Minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
var lookup minio.BucketLookupType var lookup minio.BucketLookupType
switch config.BucketLookUpType { switch config.BucketLookUpType {
case "auto", "": case "auto", "":
@@ -93,6 +96,13 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
return nil, fmt.Errorf("invalid minio bucket lookup type: %s", config.BucketLookUpType) return nil, fmt.Errorf("invalid minio bucket lookup type: %s", config.BucketLookUpType)
} }
// The request error message is something like:
// * "The request signature we calculated does not match the signature you provided. Check your key and signing method."
// It doesn't contain useful information to site admin, so here we wrap the error with our error message
// to tell the site admin what is the problem.
makeErrMsg := func(hint string) string {
return fmt.Sprintf("ObjectStorage.%s: endpoint=%s, location=%s, bucket=%s", hint, config.Endpoint, config.Location, config.Bucket)
}
minioClient, err := minio.New(config.Endpoint, &minio.Options{ minioClient, err := minio.New(config.Endpoint, &minio.Options{
Creds: buildMinioCredentials(config), Creds: buildMinioCredentials(config),
Secure: config.UseSSL, Secure: config.UseSSL,
@@ -101,37 +111,21 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
BucketLookup: lookup, BucketLookup: lookup,
}) })
if err != nil { if err != nil {
return nil, convertMinioErr(err) return nil, convertMinioErr(err, makeErrMsg("NewClient"))
}
// The GetBucketVersioning is only used for checking whether the Object Storage parameters are generally good. It doesn't need to succeed.
// The assumption is that if the API returns the HTTP code 400, then the parameters could be incorrect.
// Otherwise even if the request itself fails (403, 404, etc), the code should still continue because the parameters seem "good" enough.
// Keep in mind that GetBucketVersioning requires "owner" to really succeed, so it can't be used to check the existence.
// Not using "BucketExists (HeadBucket)" because it doesn't include detailed failure reasons.
err = getBucketVersioning(ctx, minioClient, config.Bucket)
if err != nil {
errResp, ok := err.(minio.ErrorResponse)
if !ok {
return nil, err
}
if errResp.StatusCode == http.StatusBadRequest {
log.Error("S3 storage connection failure at %s:%s with base path %s and region: %s", config.Endpoint, config.Bucket, config.Location, errResp.Message)
return nil, err
}
} }
// Check to see if we already own this bucket // Check to see if we already own this bucket
exists, err := minioClient.BucketExists(ctx, config.Bucket) exists, err := minioClient.BucketExists(ctx, config.Bucket)
if err != nil { if err != nil {
return nil, convertMinioErr(err) return nil, convertMinioErr(err, makeErrMsg("BucketExists"))
} }
// If the bucket doesn't exist, try to create one
if !exists { if !exists {
if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{ if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
Region: config.Location, Region: config.Location,
}); err != nil { }); err != nil {
return nil, convertMinioErr(err) return nil, convertMinioErr(err, makeErrMsg("MakeBucket"))
} }
} }

View File

@@ -4,16 +4,13 @@
package storage package storage
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"testing" "testing"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/test" "gitea.dev/modules/test"
"github.com/minio/minio-go/v7"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -84,31 +81,18 @@ func TestMinioStoragePath(t *testing.T) {
} }
func TestS3StorageBadRequest(t *testing.T) { func TestS3StorageBadRequest(t *testing.T) {
if os.Getenv("CI") == "" { endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
t.Skip("S3Storage not present outside of CI")
return
}
cfg := &setting.Storage{ cfg := &setting.Storage{
MinioConfig: setting.MinioStorageConfig{ MinioConfig: setting.MinioStorageConfig{
Endpoint: "minio:9000", Endpoint: endpoint,
AccessKeyID: "123456", AccessKeyID: "123456",
SecretAccessKey: "12345678", SecretAccessKey: "invalid-secret",
Bucket: "bucket", Bucket: "bucket",
Location: "us-east-1", Location: "us-east-1",
}, },
} }
message := "ERROR"
old := getBucketVersioning
defer func() { getBucketVersioning = old }()
getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
return minio.ErrorResponse{
StatusCode: http.StatusBadRequest,
Code: "FixtureError",
Message: message,
}
}
_, err := NewStorage(setting.MinioStorageType, cfg) _, err := NewStorage(setting.MinioStorageType, cfg)
assert.ErrorContains(t, err, message) assert.ErrorContains(t, err, "ObjectStorage.BucketExists: endpoint="+endpoint)
} }
func TestMinioCredentials(t *testing.T) { func TestMinioCredentials(t *testing.T) {

View File

@@ -92,9 +92,12 @@ func TestTemplateEscape(t *testing.T) {
} }
t.Run("Golang URL Escape", func(t *testing.T) { t.Run("Golang URL Escape", func(t *testing.T) {
// Golang template considers "href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping // HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: demo cases (html/template/attr.go):
// Golang template considers "href", "data-href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping
actual := execTmpl(`<a href="?a={{"%"}}"></a>`) actual := execTmpl(`<a href="?a={{"%"}}"></a>`)
assert.Equal(t, `<a href="?a=%25"></a>`, actual) assert.Equal(t, `<a href="?a=%25"></a>`, actual)
actual = execTmpl(`<a data-href="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-href="?a=%25"></a>`, actual)
actual = execTmpl(`<a data-xxx-url="?a={{"%"}}"></a>`) actual = execTmpl(`<a data-xxx-url="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-xxx-url="?a=%25"></a>`, actual) assert.Equal(t, `<a data-xxx-url="?a=%25"></a>`, actual)
}) })
@@ -102,6 +105,10 @@ func TestTemplateEscape(t *testing.T) {
// non-URL content isn't auto-escaped // non-URL content isn't auto-escaped
actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`) actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-link="?a=%"></a>`, actual) assert.Equal(t, `<a data-link="?a=%"></a>`, actual)
// the attr names like "data-href" and "data-action" are treated as URL (as the "data-" prefix is stripped)
// but "data-xxx-href" and "data-xxx-action" are not, so no escaping.
actual = execTmpl(`<a data-xxx-href="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-xxx-href="?a=%"></a>`, actual)
}) })
t.Run("QueryBuild", func(t *testing.T) { t.Run("QueryBuild", func(t *testing.T) {
actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`) actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`)

View File

@@ -8,6 +8,7 @@ import (
"crypto/subtle" "crypto/subtle"
"errors" "errors"
"strings" "strings"
"time"
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth" auth_model "gitea.dev/models/auth"
@@ -45,14 +46,26 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
return nil, status.Error(codes.Unauthenticated, "unregistered runner") return nil, status.Error(codes.Unauthenticated, "unregistered runner")
} }
cols := []string{"last_online"} now := time.Now()
runner.LastOnline = timeutil.TimeStampNow() cols := make([]string, 0, 2)
if methodName == "UpdateTask" || methodName == "UpdateLog" { // Debounce last_active too: while a runner streams logs, UpdateLog fires
runner.LastActive = timeutil.TimeStampNow() // many times per second and writing on each is a major source of DB load.
// Persist only when stale enough to affect the active/idle status.
if (methodName == "UpdateTask" || methodName == "UpdateLog") &&
actions_model.ShouldPersistLastActive(runner.LastActive, now) {
runner.LastActive = timeutil.TimeStamp(now.Unix())
cols = append(cols, "last_active") cols = append(cols, "last_active")
} }
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil { // Debounce last_online: writing on every poll is a major source of DB load
log.Error("can't update runner status: %v", err) // with many runners. Persist only when stale enough to affect offline status.
if actions_model.ShouldPersistLastOnline(runner.LastOnline, now) {
runner.LastOnline = timeutil.TimeStamp(now.Unix())
cols = append(cols, "last_online")
}
if len(cols) > 0 {
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
log.Error("can't update runner status: %v", err)
}
} }
ctx = context.WithValue(ctx, runnerCtxKey{}, runner) ctx = context.WithValue(ctx, runnerCtxKey{}, runner)

View File

@@ -194,9 +194,15 @@ func (s *Service) FetchTask(
// if the task version in request is not equal to the version in db, // if the task version in request is not equal to the version in db,
// it means there may still be some tasks that haven't been assigned. // it means there may still be some tasks that haven't been assigned.
// try to pick a task for the runner that send the request. // try to pick a task for the runner that send the request.
if t, ok, err := actions_service.PickTask(ctx, freshRunner); err != nil { if t, ok, throttled, err := actions_service.TryPickTask(ctx, freshRunner); err != nil {
log.Error("pick task failed: %v", err) log.Error("pick task failed: %v", err)
return nil, status.Errorf(codes.Internal, "pick task: %v", err) return nil, status.Errorf(codes.Internal, "pick task: %v", err)
} else if throttled {
// Concurrency limit reached: don't advance the runner's tasks version,
// so it retries on its next poll instead of sleeping until the next bump.
latestVersion = tasksVersion
// A steady stream here means MAX_CONCURRENT_TASK_PICKS is too low for the fleet.
log.Debug("task pick throttled for runner %q (id %d); it will retry on its next poll", freshRunner.Name, freshRunner.ID)
} else if ok { } else if ok {
task = t task = t
} }

View File

@@ -258,8 +258,27 @@ func GetAllCommits(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} else if commitsCountTotal == 0 { } else if commitsCountTotal == 0 {
ctx.APIErrorNotFound() // when date filters are active, a zero count may just mean no
return // commits in the requested range — not that the path is invalid
if since == "" && until == "" {
ctx.APIErrorNotFound()
return
}
// verify the path actually exists in the revision history
totalWithoutDate, err := gitrepo.CommitsCount(ctx, ctx.Repo.Repository,
gitrepo.CommitsCountOptions{
Not: not,
Revision: []string{sha},
RelPath: []string{path},
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
if totalWithoutDate == 0 {
ctx.APIErrorNotFound()
return
}
} }
commits, _, err = ctx.Repo.GitRepo.CommitsByFileAndRange( commits, _, err = ctx.Repo.GitRepo.CommitsByFileAndRange(

View File

@@ -147,6 +147,9 @@ func MustInitSessioner() func(next http.Handler) http.Handler {
Secure: setting.SessionConfig.Secure, Secure: setting.SessionConfig.Secure,
SameSite: setting.SessionConfig.SameSite, SameSite: setting.SessionConfig.SameSite,
Domain: setting.SessionConfig.Domain, Domain: setting.SessionConfig.Domain,
// in the future, if websocket is used, the websocket handler should manage its own session sync (release)
IgnoreReleaseForWebSocket: true,
}) })
if err != nil { if err != nil {
log.Fatal("common.Sessioner failed: %v", err) log.Fatal("common.Sessioner failed: %v", err)

View File

@@ -31,9 +31,7 @@ import (
type preReceiveContext struct { type preReceiveContext struct {
*gitea_context.PrivateContext *gitea_context.PrivateContext
// loadedPusher indicates that where the following information are loaded user *user_model.User // the "pusher", it's the org user if a DeployKey is used
loadedPusher bool
user *user_model.User // it's the org user if a DeployKey is used
userPerm access_model.Permission userPerm access_model.Permission
deployKeyAccessMode perm_model.AccessMode deployKeyAccessMode perm_model.AccessMode
@@ -53,10 +51,7 @@ type preReceiveContext struct {
func (ctx *preReceiveContext) canWriteCodeUnit() bool { func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil { if ctx.canWriteCodeUnitCached == nil {
var canWrite bool canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
if ctx.loadPusherAndPermission() {
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
}
ctx.canWriteCodeUnitCached = &canWrite ctx.canWriteCodeUnitCached = &canWrite
} }
return *ctx.canWriteCodeUnitCached return *ctx.canWriteCodeUnitCached
@@ -91,9 +86,6 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
// CanCreatePullRequest returns true if pusher can create pull requests // CanCreatePullRequest returns true if pusher can create pull requests
func (ctx *preReceiveContext) CanCreatePullRequest() bool { func (ctx *preReceiveContext) CanCreatePullRequest() bool {
if !ctx.checkedCanCreatePullRequest { if !ctx.checkedCanCreatePullRequest {
if !ctx.loadPusherAndPermission() {
return false
}
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests) ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
ctx.checkedCanCreatePullRequest = true ctx.checkedCanCreatePullRequest = true
} }
@@ -124,6 +116,10 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts: opts, opts: opts,
} }
if !ourCtx.loadPusherAndPermission() {
return // if error occurs, loadPusherAndPermission had written the error response
}
// Iterate across the provided old commit IDs // Iterate across the provided old commit IDs
for i := range opts.OldCommitIDs { for i := range opts.OldCommitIDs {
oldCommitID := opts.OldCommitIDs[i] oldCommitID := opts.OldCommitIDs[i]
@@ -281,18 +277,10 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys) canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
} }
} else { } else {
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
if err != nil {
log.Error("Unable to GetUserByID for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: fmt.Sprintf("Unable to GetUserByID for commits from %s to %s: %v", oldCommitID, newCommitID, err),
})
return
}
if isForcePush { if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, user) canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
} else { } else {
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, user) canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.user)
} }
} }
@@ -354,12 +342,6 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
return return
} }
// although we should have called `loadPusherAndPermission` before, here we call it explicitly again because we need to access ctx.user below
if !ctx.loadPusherAndPermission() {
// if error occurs, loadPusherAndPermission had written the error response
return
}
// Now check if the user is allowed to merge PRs for this repository // Now check if the user is allowed to merge PRs for this repository
// Note: we can use ctx.perm and ctx.user directly as they will have been loaded above // Note: we can use ctx.perm and ctx.user directly as they will have been loaded above
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user) allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user)
@@ -499,10 +481,6 @@ func generateGitEnv(opts *private.HookOptions) (env []string) {
// loadPusherAndPermission returns false if an error occurs, and it writes the error response // loadPusherAndPermission returns false if an error occurs, and it writes the error response
func (ctx *preReceiveContext) loadPusherAndPermission() bool { func (ctx *preReceiveContext) loadPusherAndPermission() bool {
if ctx.loadedPusher {
return true
}
if ctx.opts.UserID == user_model.ActionsUserID { if ctx.opts.UserID == user_model.ActionsUserID {
taskID := ctx.opts.ActionsTaskID taskID := ctx.opts.ActionsTaskID
ctx.user = user_model.NewActionsUserWithTaskID(taskID) ctx.user = user_model.NewActionsUserWithTaskID(taskID)
@@ -555,7 +533,5 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
} }
ctx.deployKeyAccessMode = deployKey.Mode ctx.deployKeyAccessMode = deployKey.Mode
} }
ctx.loadedPusher = true
return true return true
} }

View File

@@ -52,7 +52,6 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
mockCtx, _ := contexttest.MockPrivateContext(t, "/") mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{ ctx := &preReceiveContext{
PrivateContext: mockCtx, PrivateContext: mockCtx,
loadedPusher: true,
user: maintainer, user: maintainer,
userPerm: headPerm, userPerm: headPerm,
} }

View File

@@ -118,7 +118,7 @@ func autoSignIn(ctx *context.Context) (bool, error) {
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
if err := updateSession(ctx, nil, map[string]any{ if err := regenerateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID, session.KeyUID: u.ID,
session.KeyUname: u.Name, session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
@@ -357,7 +357,7 @@ func SignInPost(ctx *context.Context) {
// User will need to use WebAuthn, save data // User will need to use WebAuthn, save data
updates["totpEnrolled"] = u.ID updates["totpEnrolled"] = u.ID
} }
if err := updateSession(ctx, nil, updates); err != nil { if err := regenerateSession(ctx, nil, updates); err != nil {
ctx.ServerError("UserSignIn: Unable to update session", err) ctx.ServerError("UserSignIn: Unable to update session", err)
return return
} }
@@ -398,7 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
return return
} }
if err := updateSession(ctx, []string{ if err := regenerateSession(ctx, []string{
// Delete the openid, 2fa and link_account data // Delete the openid, 2fa and link_account data
"openid_verified_uri", "openid_verified_uri",
"openid_signin_remember", "openid_signin_remember",
@@ -884,7 +884,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
log.Trace("User activated: %s", user.Name) log.Trace("User activated: %s", user.Name)
if err := updateSession(ctx, nil, map[string]any{ if err := regenerateSession(ctx, nil, map[string]any{
"uid": user.ID, "uid": user.ID,
"uname": user.Name, "uname": user.Name,
}); err != nil { }); err != nil {
@@ -936,7 +936,7 @@ func ActivateEmail(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/user/settings/account") ctx.Redirect(setting.AppSubURL + "/user/settings/account")
} }
func updateSession(ctx *context.Context, deletes []string, updates map[string]any) error { func regenerateSession(ctx *context.Context, deletes []string, updates map[string]any) error {
if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil { if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil {
return fmt.Errorf("regenerate session: %w", err) return fmt.Errorf("regenerate session: %w", err)
} }

View File

@@ -164,7 +164,12 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
return return
} }
if err := updateSession(ctx, nil, map[string]any{ if err := Oauth2SetLinkAccountData(ctx, *linkAccountData); err != nil {
ctx.ServerError("Oauth2SetLinkAccountData", err)
return
}
if err := regenerateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page. // User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID, "twofaUid": u.ID,
"twofaRemember": remember, "twofaRemember": remember,

View File

@@ -285,9 +285,7 @@ func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
} }
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error { func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
return updateSession(ctx, nil, map[string]any{ return ctx.Session.Set("linkAccountData", linkAccountData)
"linkAccountData": linkAccountData,
})
} }
func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) { func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
@@ -409,7 +407,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
return return
} }
if err := updateSession(ctx, nil, map[string]any{ if err := regenerateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID, session.KeyUID: u.ID,
session.KeyUname: u.Name, session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
@@ -434,7 +432,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
} }
} }
if err := updateSession(ctx, nil, map[string]any{ if err := regenerateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page. // User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID, "twofaUid": u.ID,
"twofaRemember": false, "twofaRemember": false,

View File

@@ -213,7 +213,7 @@ func signInOpenIDVerify(ctx *context.Context) {
if u != nil { if u != nil {
nickname = u.LowerName nickname = u.LowerName
} }
if err := updateSession(ctx, nil, map[string]any{ if err := regenerateSession(ctx, nil, map[string]any{
"openid_verified_uri": id, "openid_verified_uri": id,
"openid_determined_email": email, "openid_determined_email": email,
"openid_determined_username": nickname, "openid_determined_username": nickname,

View File

@@ -450,7 +450,7 @@ func ViewProject(ctx *context.Context) {
ctx.Data["MilestoneID"] = milestoneID ctx.Data["MilestoneID"] = milestoneID
// Get assignees. // Get assignees.
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap) assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, project.ID)
if err != nil { if err != nil {
ctx.ServerError("LoadIssuesAssigneesForProject", err) ctx.ServerError("LoadIssuesAssigneesForProject", err)
return return

View File

@@ -186,6 +186,22 @@ func ServeAttachment(ctx *context.Context, uuid string) {
return return
} }
// Draft release attachments must not be exposed to anyone without write
// access, matching the API-side canAccessReleaseDraft gate. Otherwise the
// UUID-based web endpoints would leak draft attachments to any recipient of
// the (leaked) download URL.
if unitType == unit.TypeReleases && attach.ReleaseID != 0 && !perm.CanWrite(unit.TypeReleases) {
rel, err := repo_model.GetReleaseByID(ctx, attach.ReleaseID)
if err != nil {
ctx.ServerError("GetReleaseByID", err)
return
}
if rel.IsDraft {
ctx.HTTPError(http.StatusNotFound)
return
}
}
if requiredScope, ok := attachmentReadScope(unitType); ok { if requiredScope, ok := attachmentReadScope(unitType); ok {
context.CheckTokenScopes(ctx, repo, requiredScope) context.CheckTokenScopes(ctx, repo, requiredScope)
if ctx.Written() { if ctx.Written() {

View File

@@ -398,7 +398,7 @@ func Diff(ctx *context.Context) {
verification := asymkey_service.ParseCommitWithSignature(ctx, commit) verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
ctx.Data["Verification"] = verification ctx.Data["Verification"] = verification
ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit) ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit)
ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.AllParticipantIdentities(), nil).Participants[1:] ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.CoAuthorIdentities(), nil).Participants
ctx.Data["Parents"] = parents ctx.Data["Parents"] = parents
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0

View File

@@ -14,17 +14,6 @@ import (
"gitea.dev/services/context" "gitea.dev/services/context"
) )
type userSearchInfo struct {
UserID int64 `json:"user_id"`
UserName string `json:"username"`
AvatarLink string `json:"avatar_link"`
FullName string `json:"full_name"`
}
type userSearchResponse struct {
Results []*userSearchInfo `json:"results"`
}
func IssuePullPosters(ctx *context.Context) { func IssuePullPosters(ctx *context.Context) {
isPullList := ctx.PathParam("type") == "pulls" isPullList := ctx.PathParam("type") == "pulls"
issuePosters(ctx, isPullList) issuePosters(ctx, isPullList)
@@ -46,14 +35,5 @@ func issuePosters(ctx *context.Context, isPullList bool) {
posters = append(posters, ctx.Doer) posters = append(posters, ctx.Doer)
} }
} }
ctx.JSON(http.StatusOK, shared_user.ToSearchUserResponse(ctx, ctx.Doer, posters))
posters = shared_user.MakeSelfOnTop(ctx.Doer, posters)
resp := &userSearchResponse{}
resp.Results = make([]*userSearchInfo, len(posters))
for i, user := range posters {
resp.Results[i] = &userSearchInfo{UserID: user.ID, UserName: user.Name, AvatarLink: user.AvatarLink(ctx)}
resp.Results[i].FullName = user.FullName
}
ctx.JSON(http.StatusOK, resp)
} }

View File

@@ -27,7 +27,6 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/modules/web" "gitea.dev/modules/web"
"gitea.dev/routers/web/feed" "gitea.dev/routers/web/feed"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context" "gitea.dev/services/context"
"gitea.dev/services/context/upload" "gitea.dev/services/context/upload"
"gitea.dev/services/forms" "gitea.dev/services/forms"
@@ -338,7 +337,6 @@ func LatestRelease(ctx *context.Context) {
func newReleaseCommon(ctx *context.Context) { func newReleaseCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.release.new_release") ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
ctx.Data["PageIsReleaseList"] = true
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
@@ -346,17 +344,8 @@ func newReleaseCommon(ctx *context.Context) {
return return
} }
ctx.Data["Tags"] = tags ctx.Data["Tags"] = tags
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoAssignees", err)
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
upload.AddUploadContext(ctx, "release") upload.AddUploadContext(ctx, "release")
PrepareBranchList(ctx) // for New Release page PrepareBranchList(ctx) // for New Release page
} }
@@ -425,8 +414,8 @@ func GenerateReleaseNotes(ctx *context.Context) {
// NewReleasePost response for creating a release // NewReleasePost response for creating a release
func NewReleasePost(ctx *context.Context) { func NewReleasePost(ctx *context.Context) {
newReleaseCommon(ctx) if ctx.HasError() {
if ctx.Written() { ctx.JSONError(ctx.GetErrMsg())
return return
} }
@@ -450,35 +439,28 @@ func NewReleasePost(ctx *context.Context) {
// Or another choice is "always show the tag-only button" if error occurs. // Or another choice is "always show the tag-only button" if error occurs.
ctx.Data["ShowCreateTagOnlyButton"] = form.TagOnly || rel == nil ctx.Data["ShowCreateTagOnlyButton"] = form.TagOnly || rel == nil
// do some form checks
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplReleaseNew)
return
}
form.Target = util.IfZero(form.Target, ctx.Repo.Repository.DefaultBranch) form.Target = util.IfZero(form.Target, ctx.Repo.Repository.DefaultBranch)
if exist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, form.Target); !exist { if exist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, form.Target); !exist {
ctx.RenderWithErrDeprecated(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form) ctx.JSONError(ctx.Tr("form.target_branch_not_exist"))
return return
} }
if !form.TagOnly && form.Title == "" { if !form.TagOnly && form.Title == "" {
// if not "tag only", then the title of the release cannot be empty // if not "tag only", then the title of the release cannot be empty
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.title_empty"), tplReleaseNew, &form) ctx.JSONError(ctx.Tr("repo.release.title_empty"))
return return
} }
handleTagReleaseError := func(err error) { handleTagReleaseError := func(err error) {
ctx.Data["Err_TagName"] = true
switch { switch {
case release_service.IsErrTagAlreadyExists(err): case release_service.IsErrTagAlreadyExists(err):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.branch.tag_collision", form.TagName), tplReleaseNew, &form) ctx.JSONError(ctx.Tr("repo.branch.tag_collision", form.TagName))
case repo_model.IsErrReleaseAlreadyExist(err): case repo_model.IsErrReleaseAlreadyExist(err):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form) ctx.JSONError(ctx.Tr("repo.release.tag_name_already_exist"))
case release_service.IsErrInvalidTagName(err): case release_service.IsErrInvalidTagName(err):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form) ctx.JSONError(ctx.Tr("repo.release.tag_name_invalid"))
case release_service.IsErrProtectedTagName(err): case release_service.IsErrProtectedTagName(err):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form) ctx.JSONError(ctx.Tr("repo.release.tag_name_protected"))
default: default:
ctx.ServerError("handleTagReleaseError", err) ctx.ServerError("handleTagReleaseError", err)
} }
@@ -497,7 +479,7 @@ func NewReleasePost(ctx *context.Context) {
return return
} }
ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.TagName)) ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.TagName))
ctx.Redirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.TagName)) ctx.JSONRedirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.TagName))
return return
} }
@@ -522,7 +504,7 @@ func NewReleasePost(ctx *context.Context) {
handleTagReleaseError(err) handleTagReleaseError(err)
return return
} }
ctx.Redirect(ctx.Repo.RepoLink + "/releases") ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
return return
} }
@@ -530,8 +512,7 @@ func NewReleasePost(ctx *context.Context) {
// old logic: if the release is not a tag (it is a real release), do not update it on the "new release" page // old logic: if the release is not a tag (it is a real release), do not update it on the "new release" page
// add new logic: if tag-only, do not convert the tag to a release // add new logic: if tag-only, do not convert the tag to a release
if form.TagOnly || !rel.IsTag { if form.TagOnly || !rel.IsTag {
ctx.Data["Err_TagName"] = true ctx.JSONError(ctx.Tr("repo.release.tag_name_already_exist"))
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form)
return return
} }
@@ -547,7 +528,7 @@ func NewReleasePost(ctx *context.Context) {
handleTagReleaseError(err) handleTagReleaseError(err)
return return
} }
ctx.Redirect(ctx.Repo.RepoLink + "/releases") ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
} }
// EditRelease render release edit page // EditRelease render release edit page
@@ -589,55 +570,39 @@ func EditRelease(ctx *context.Context) {
return return
} }
ctx.Data["attachments"] = rel.Attachments ctx.Data["attachments"] = rel.Attachments
// Get assignees.
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, rel.Repo)
if err != nil {
ctx.ServerError("GetRepoAssignees", err)
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.HTML(http.StatusOK, tplReleaseNew) ctx.HTML(http.StatusOK, tplReleaseNew)
} }
// EditReleasePost response for edit release // EditReleasePost response for edit release
func EditReleasePost(ctx *context.Context) { func EditReleasePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditReleaseForm) if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
newReleaseCommon(ctx)
if ctx.Written() {
return return
} }
ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") form := web.GetForm(ctx).(*forms.EditReleaseForm)
ctx.Data["PageIsEditRelease"] = true
tagName := ctx.PathParam("*") tagName := ctx.PathParam("*")
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
if err != nil { if err != nil {
if repo_model.IsErrReleaseNotExist(err) { if repo_model.IsErrReleaseNotExist(err) {
ctx.NotFound(err) ctx.JSONErrorNotFound(err.Error())
} else { } else {
ctx.ServerError("GetRelease", err) ctx.ServerError("GetRelease", err)
} }
return return
} }
if rel.IsTag { if rel.IsTag {
ctx.NotFound(err) // for a pure tag release, don't allow to edit it as a release ctx.JSONErrorNotFound() // for a pure tag release, don't allow to edit it as a release
return return
} }
ctx.Data["tag_name"] = rel.TagName ctx.Data["tag_name"] = rel.TagName
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch) ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
ctx.Data["title"] = rel.Title ctx.Data["title"] = rel.Title
ctx.Data["content"] = rel.Note ctx.Data["content"] = rel.Note
ctx.Data["prerelease"] = rel.IsPrerelease ctx.Data["prerelease"] = rel.IsPrerelease
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplReleaseNew)
return
}
const delPrefix = "attachment-del-" const delPrefix = "attachment-del-"
const editPrefix = "attachment-edit-" const editPrefix = "attachment-edit-"
var addAttachmentUUIDs, delAttachmentUUIDs []string var addAttachmentUUIDs, delAttachmentUUIDs []string
@@ -659,10 +624,14 @@ func EditReleasePost(ctx *context.Context) {
rel.IsPrerelease = form.Prerelease rel.IsPrerelease = form.Prerelease
if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo,
rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil { rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil {
ctx.ServerError("UpdateRelease", err) if upload.IsErrFileTypeForbidden(err) {
ctx.JSONError(err.Error())
} else {
ctx.ServerError("UpdateRelease", err)
}
return return
} }
ctx.Redirect(ctx.Repo.RepoLink + "/releases") ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
} }
// DeleteRelease deletes a release // DeleteRelease deletes a release

View File

@@ -4,12 +4,14 @@
package repo package repo
import ( import (
"net/http/httptest"
"testing" "testing"
"gitea.dev/models/db" "gitea.dev/models/db"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit" "gitea.dev/models/unit"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/test"
"gitea.dev/modules/web" "gitea.dev/modules/web"
"gitea.dev/services/context" "gitea.dev/services/context"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
@@ -39,15 +41,15 @@ func TestNewReleasePost(t *testing.T) {
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"]) assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"])
}) })
post := func(t *testing.T, form forms.NewReleaseForm) *context.Context { post := func(t *testing.T, form forms.NewReleaseForm) (*context.Context, *httptest.ResponseRecorder) {
ctx, _ := contexttest.MockContext(t, "user2/repo1/releases/new") ctx, resp := contexttest.MockContext(t, "user2/repo1/releases/new")
contexttest.LoadUser(t, ctx, 2) contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, 1) contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadGitRepo(t, ctx) contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
web.SetForm(ctx, &form) web.SetForm(ctx, &form)
NewReleasePost(ctx) NewReleasePost(ctx)
return ctx return ctx, resp
} }
loadRelease := func(t *testing.T, tagName string) *repo_model.Release { loadRelease := func(t *testing.T, tagName string) *repo_model.Release {
@@ -70,7 +72,7 @@ func TestNewReleasePost(t *testing.T) {
}) })
t.Run("ReleaseExistsDoUpdate(non-tag)", func(t *testing.T) { t.Run("ReleaseExistsDoUpdate(non-tag)", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{ _, resp := post(t, forms.NewReleaseForm{
TagName: "v1.1", TagName: "v1.1",
Target: "master", Target: "master",
Title: "updated-title", Title: "updated-title",
@@ -80,11 +82,11 @@ func TestNewReleasePost(t *testing.T) {
require.NotNil(t, rel) require.NotNil(t, rel)
assert.False(t, rel.IsTag) assert.False(t, rel.IsTag)
assert.Equal(t, "testing-release", rel.Title) assert.Equal(t, "testing-release", rel.Title)
assert.NotEmpty(t, ctx.Flash.ErrorMsg) assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
}) })
t.Run("ReleaseExistsDoUpdate(tag-only)", func(t *testing.T) { t.Run("ReleaseExistsDoUpdate(tag-only)", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{ ctx, resp := post(t, forms.NewReleaseForm{
TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture
Target: "master", Target: "master",
Title: "updated-title", Title: "updated-title",
@@ -95,12 +97,12 @@ func TestNewReleasePost(t *testing.T) {
require.NotNil(t, rel) require.NotNil(t, rel)
assert.True(t, rel.IsTag) // the record should not be updated because the request is "tag-only". TODO: need to improve the logic? assert.True(t, rel.IsTag) // the record should not be updated because the request is "tag-only". TODO: need to improve the logic?
assert.Equal(t, "delete-tag", rel.Title) assert.Equal(t, "delete-tag", rel.Title)
assert.NotEmpty(t, ctx.Flash.ErrorMsg) assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"]) // still show the "tag-only" button assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"]) // still show the "tag-only" button
}) })
t.Run("ReleaseExistsDoUpdate(tag-release)", func(t *testing.T) { t.Run("ReleaseExistsDoUpdate(tag-release)", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{ ctx, _ := post(t, forms.NewReleaseForm{
TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture
Target: "master", Target: "master",
Title: "updated-title", Title: "updated-title",
@@ -114,7 +116,7 @@ func TestNewReleasePost(t *testing.T) {
}) })
t.Run("TagOnly", func(t *testing.T) { t.Run("TagOnly", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{ ctx, _ := post(t, forms.NewReleaseForm{
TagName: "new-tag-only", TagName: "new-tag-only",
Target: "master", Target: "master",
Title: "title", Title: "title",
@@ -128,7 +130,7 @@ func TestNewReleasePost(t *testing.T) {
}) })
t.Run("TagOnlyConflict", func(t *testing.T) { t.Run("TagOnlyConflict", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{ _, resp := post(t, forms.NewReleaseForm{
TagName: "v1.1", TagName: "v1.1",
Target: "master", Target: "master",
Title: "title", Title: "title",
@@ -138,7 +140,7 @@ func TestNewReleasePost(t *testing.T) {
rel := loadRelease(t, "v1.1") rel := loadRelease(t, "v1.1")
require.NotNil(t, rel) require.NotNil(t, rel)
assert.False(t, rel.IsTag) assert.False(t, rel.IsTag)
assert.NotEmpty(t, ctx.Flash.ErrorMsg) assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
}) })
} }

View File

@@ -11,22 +11,44 @@ import (
"gitea.dev/models/user" "gitea.dev/models/user"
) )
type SearchUserInfo struct {
UserID int64 `json:"user_id"`
UserName string `json:"username"`
AvatarLink string `json:"avatar_link"`
FullName string `json:"full_name"`
}
type SearchUserResponse struct {
Results []*SearchUserInfo `json:"results"`
}
func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
if doer != nil { if doer == nil {
idx := slices.IndexFunc(users, func(u *user.User) bool { return users
return u.ID == doer.ID }
}) idx := slices.IndexFunc(users, func(u *user.User) bool {
if idx > 0 { return u.ID == doer.ID
newUsers := make([]*user.User, len(users)) })
newUsers[0] = users[idx] if idx > 0 {
copy(newUsers[1:], users[:idx]) newUsers := make([]*user.User, len(users))
copy(newUsers[idx+1:], users[idx+1:]) newUsers[0] = users[idx]
return newUsers copy(newUsers[1:], users[:idx])
} copy(newUsers[idx+1:], users[idx+1:])
return newUsers
} }
return users return users
} }
func ToSearchUserResponse(ctx context.Context, self *user.User, users []*user.User) (ret *SearchUserResponse) {
infos := MakeSelfOnTop(self, users)
resp := &SearchUserResponse{Results: make([]*SearchUserInfo, len(infos))}
for i, u := range infos {
resp.Results[i] = &SearchUserInfo{UserID: u.ID, UserName: u.Name, AvatarLink: u.AvatarLink(ctx)}
resp.Results[i].FullName = u.FullName
}
return resp
}
// GetFilterUserIDByName tries to get the user ID from the given username. // GetFilterUserIDByName tries to get the user ID from the given username.
// Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list. // Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list.
// So it's better to make it work like GitHub: users could input username directly. // So it's better to make it work like GitHub: users could input username directly.

View File

@@ -7,6 +7,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
"gitea.dev/models/db" "gitea.dev/models/db"
@@ -14,10 +15,14 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions" actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/commitstatus" "gitea.dev/modules/commitstatus"
"gitea.dev/modules/glob"
"gitea.dev/modules/log" "gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util" "gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook" webhook_module "gitea.dev/modules/webhook"
pull_service "gitea.dev/services/pull"
commitstatus_service "gitea.dev/services/repository/commitstatus" commitstatus_service "gitea.dev/services/repository/commitstatus"
) )
@@ -147,21 +152,113 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
// scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks. // scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks.
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event) ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event)
} }
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
return createWorkflowCommitStatus(ctx, repo, commitID, ctxName, run.WorkflowID, toCommitStatus(job.Status), targetURL, toCommitStatusDescription(job))
}
// getAllRequiredStatusContextGlobs returns the compiled globs of every status-check context required in the repo:
// its protected branch rules' contexts and its required scoped workflows' patterns (see EffectiveRequiredContexts).
func getAllRequiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repository) ([]glob.Glob, error) {
rules, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID)
if err != nil {
return nil, fmt.Errorf("FindRepoProtectedBranchRules: %w", err)
}
required, err := pull_service.EffectiveRequiredContexts(ctx, repo, rules...)
if err != nil {
return nil, fmt.Errorf("EffectiveRequiredContexts: %w", err)
}
globs := make([]glob.Glob, 0, len(required))
for _, c := range required {
if gp, err := glob.Compile(c); err != nil {
log.Error("glob.Compile %q: %v", c, err)
} else {
globs = append(globs, gp)
}
}
return globs, nil
}
// CreateSkippedCommitStatusForFilteredWorkflow posts a skipped commit status for each job of a
// workflow that matched the triggering event but was excluded by a branch/paths filter.
// This lets a required status check tied to that context be satisfied without the workflow running.
// Only contexts matching requiredGlobs are posted; a non-required context gets no skipped status.
// No ActionRun is created, so the status has no target URL (there is no run/job to link to).
// A non-empty scopedPrefix prefixes each context with its source repo, matching scoped runs.
//
// TODO: requiredGlobs over-approximates by including every protected branch rule's required contexts.
// If possible, the set should be narrowed to the required contexts of a specific branch protection rule (and the contexts from required scoped workflows).
// Currently, a `push` event must keep the repo-wide union since its future pull request base branch is unknown.
func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *repo_model.Repository, event webhook_module.HookEventType, triggerEvent, workflowID string, content []byte, payload api.Payloader, scopedPrefix string, requiredGlobs []glob.Glob) error {
if len(requiredGlobs) == 0 {
return nil // nothing is required, so no skipped status is needed
}
// Derive the status event name and target commit from the payload.
// TODO: this mirrors getCommitStatusEventNameAndCommitID, which derives the same from a persisted run. Should merge the logic if possible.
var statusEvent, commitID string
switch event {
case webhook_module.HookEventPush:
if p, ok := payload.(*api.PushPayload); ok && p.HeadCommit != nil {
statusEvent, commitID = "push", p.HeadCommit.ID
}
case webhook_module.HookEventPullRequest,
webhook_module.HookEventPullRequestSync,
webhook_module.HookEventPullRequestAssign,
webhook_module.HookEventPullRequestLabel,
webhook_module.HookEventPullRequestReviewRequest,
webhook_module.HookEventPullRequestMilestone:
if p, ok := payload.(*api.PullRequestPayload); ok && p.PullRequest != nil && p.PullRequest.Head != nil {
statusEvent, commitID = "pull_request", p.PullRequest.Head.Sha
if triggerEvent == actions_module.GithubEventPullRequestTarget {
statusEvent = "pull_request_target"
}
}
}
if statusEvent == "" || commitID == "" {
return nil // unsupported event or missing commit id, nothing to post
}
workflows, err := jobparser.Parse(content)
if err != nil {
return fmt.Errorf("jobparser.Parse: %w", err)
}
displayName := actions_module.WorkflowDisplayName(workflowID, content)
for _, sw := range workflows {
_, job := sw.Job()
if job == nil {
continue
}
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
ctxName := actions_module.WorkflowStatusContextName(displayName, jobName, statusEvent)
if scopedPrefix != "" {
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, jobName, statusEvent)
}
// Only a required context needs the skipped status.
if !slices.ContainsFunc(requiredGlobs, func(gp glob.Glob) bool { return gp.Match(ctxName) }) {
continue
}
// "Skipped" mirrors toCommitStatusDescription for StatusSkipped.
if err := createWorkflowCommitStatus(ctx, repo, commitID, ctxName, workflowID, commitstatus.CommitStatusSkipped, "", "Skipped"); err != nil {
return err
}
}
return nil
}
// createWorkflowCommitStatus posts the commit status for one workflow-job context.
func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository, commitID, ctxName, workflowID string, state commitstatus.CommitStatusState, targetURL, description string) error {
// Mix the workflow file path into the hash so two workflow files that // Mix the workflow file path into the hash so two workflow files that
// share the same `name:` and job name produce distinct commit statuses // share the same `name:` and job name produce distinct commit statuses
// even though they render identically — matching GitHub's behavior // even though they render identically — matching GitHub's behavior
// (issue #35699). // (issue #35699).
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + run.WorkflowID) ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
// Pre-fix rows were hashed from Context alone. If a pre-existing row with // Pre-fix rows were hashed from Context alone. If a pre-existing row with
// the legacy hash is still the "latest" for this SHA, reuse that hash so // the legacy hash is still the "latest" for this SHA, reuse that hash so
// the new row supersedes it; otherwise the old pending status would stay // the new row supersedes it; otherwise the old pending status would stay
// stuck forever (it lives in its own dedupe group). Only relevant for // stuck forever (it lives in its own dedupe group). Only relevant for
// in-flight workflows at upgrade time. // in-flight workflows at upgrade time.
legacyHash := git_model.HashCommitStatusContext(ctxName) legacyHash := git_model.HashCommitStatusContext(ctxName)
state := toCommitStatus(job.Status)
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
description := toCommitStatusDescription(job)
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll) statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
if err != nil { if err != nil {

View File

@@ -183,8 +183,9 @@ func notify(ctx context.Context, input *notifyInput) error {
} }
var detectedWorkflows []*actions_module.DetectedWorkflow var detectedWorkflows []*actions_module.DetectedWorkflow
var filteredWorkflows []*actions_module.DetectedWorkflow
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig() actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit, workflows, schedules, filtered, err := actions_module.DetectWorkflows(gitRepo, commit,
input.Event, input.Event,
input.Payload, input.Payload,
shouldDetectSchedules, shouldDetectSchedules,
@@ -212,6 +213,17 @@ func notify(ctx context.Context, input *notifyInput) error {
} }
} }
for _, wf := range filtered {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget {
filteredWorkflows = append(filteredWorkflows, wf)
}
}
if input.PullRequest != nil { if input.PullRequest != nil {
// detect pull_request_target workflows // detect pull_request_target workflows
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
@@ -219,7 +231,7 @@ func notify(ctx context.Context, input *notifyInput) error {
if err != nil { if err != nil {
return fmt.Errorf("gitRepo.GetCommit: %w", err) return fmt.Errorf("gitRepo.GetCommit: %w", err)
} }
baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false) baseWorkflows, _, baseFiltered, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
if err != nil { if err != nil {
return fmt.Errorf("DetectWorkflows: %w", err) return fmt.Errorf("DetectWorkflows: %w", err)
} }
@@ -227,11 +239,24 @@ func notify(ctx context.Context, input *notifyInput) error {
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RelativePath(), baseCommit.ID) log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RelativePath(), baseCommit.ID)
} else { } else {
for _, wf := range baseWorkflows { for _, wf := range baseWorkflows {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget { if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
detectedWorkflows = append(detectedWorkflows, wf) detectedWorkflows = append(detectedWorkflows, wf)
} }
} }
} }
for _, wf := range baseFiltered {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
filteredWorkflows = append(filteredWorkflows, wf)
}
}
} }
if shouldDetectSchedules { if shouldDetectSchedules {
@@ -244,6 +269,8 @@ func notify(ctx context.Context, input *notifyInput) error {
return err return err
} }
handleFilteredWorkflows(ctx, input, filteredWorkflows)
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit) return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
} }
@@ -369,6 +396,28 @@ func buildApproveAndInsertRun(
return nil return nil
} }
// handleFilteredWorkflows posts a skipped commit status for each filtered-out workflow whose context is a required status check;
// a non-required one posts nothing, so it cannot leak into a pull request.
func handleFilteredWorkflows(ctx context.Context, input *notifyInput, filteredWorkflows []*actions_module.DetectedWorkflow) {
if len(filteredWorkflows) == 0 {
return
}
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
if err != nil {
log.Error("repo %s: required status contexts: %v", input.Repo.RelativePath(), err)
return
}
if len(requiredGlobs) == 0 {
return
}
for _, dwf := range filteredWorkflows {
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, "", requiredGlobs); err != nil {
log.Error("repo %s: skipped commit status for workflow %s: %v", input.Repo.RelativePath(), dwf.EntryName, err)
continue
}
}
}
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput { func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
return newNotifyInput(issue.Repo, issue.Poster, event) return newNotifyInput(issue.Repo, issue.Poster, event)
} }
@@ -616,6 +665,12 @@ func detectAndHandleScopedWorkflows(
isForkPullRequest := isForkPullRequestInput(input) isForkPullRequest := isForkPullRequestInput(input)
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig() actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
// A filtered-out scoped workflow only posts a skipped status when its context is a required check.
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
if err != nil {
log.Error("scoped workflows: required status contexts for %s: %v", input.Repo.RelativePath(), err)
}
// The same source repo may be registered at both the owner and instance level; dedup // The same source repo may be registered at both the owner and instance level; dedup
// the IDs and batch-load them in one query instead of one round-trip per source. // the IDs and batch-load them in one query instead of one round-trip per source.
seen := make(container.Set[int64], len(sources)) seen := make(container.Set[int64], len(sources))
@@ -640,7 +695,7 @@ func detectAndHandleScopedWorkflows(
continue continue
} }
sourceCommitSHA, detected, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo) sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
if err != nil { if err != nil {
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err) log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
continue continue
@@ -658,23 +713,40 @@ func detectAndHandleScopedWorkflows(
continue continue
} }
} }
// A filtered-out scoped workflow posts a skipped commit status for its required-check contexts.
if len(filtered) > 0 && len(requiredGlobs) > 0 {
scopedPrefix := actions_model.ScopedStatusContextPrefix(ctx, sourceRepo.ID)
for _, dwf := range filtered {
if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) {
continue
}
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, scopedPrefix, requiredGlobs); err != nil {
log.Error("scoped workflows: skipped commit status for source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
continue
}
}
}
} }
return nil return nil
} }
// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch // detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch.
// detected are the workflows to run; filtered matched the event but were excluded by a branch/paths
// filter, and later post a skipped commit status only for a required-check context.
func detectScopedWorkflowsForSource( func detectScopedWorkflowsForSource(
ctx context.Context, ctx context.Context,
input *notifyInput, input *notifyInput,
consumerGitRepo *git.Repository, consumerGitRepo *git.Repository,
consumerCommit *git.Commit, consumerCommit *git.Commit,
sourceRepo *repo_model.Repository, sourceRepo *repo_model.Repository,
) (sourceCommitSHA string, detected []*actions_module.DetectedWorkflow, err error) { ) (sourceCommitSHA string, detected, filtered []*actions_module.DetectedWorkflow, err error) {
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events // scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo) sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil { if err != nil {
return "", nil, err return "", nil, nil, err
} }
return sourceCommitSHA, actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload), nil detected, filtered = actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload)
return sourceCommitSHA, detected, filtered, nil
} }

View File

@@ -7,16 +7,47 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sync"
runnerv1 "gitea.dev/actions-proto-go/runner/v1" runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
"gitea.dev/models/db" "gitea.dev/models/db"
secret_model "gitea.dev/models/secret" secret_model "gitea.dev/models/secret"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting"
"google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/structpb"
) )
var (
taskPickSem chan struct{}
taskPickSemOnce sync.Once
)
func taskPickLimiter() chan struct{} {
taskPickSemOnce.Do(func() {
taskPickSem = make(chan struct{}, setting.Actions.MaxConcurrentTaskPicks)
})
return taskPickSem
}
// TryPickTask attempts to assign a task to the runner, bounding the number of
// concurrent assignment transactions to avoid a thundering herd when many
// runners poll at once. When the concurrency limit is reached it returns
// throttled=true without touching the DB, so the caller can let the runner
// retry on its next poll instead of advancing its tasks version.
func TryPickTask(ctx context.Context, runner *actions_model.ActionRunner) (task *runnerv1.Task, ok, throttled bool, err error) {
sem := taskPickLimiter()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
default:
return nil, false, true, nil
}
task, ok, err = PickTask(ctx, runner)
return task, ok, false, err
}
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) { func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
var ( var (
task *runnerv1.Task task *runnerv1.Task
@@ -76,6 +107,15 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID) NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID)
} }
// The job is claimed and its payload assembled, but if the request context was cancelled meanwhile, response can no longer reach the runner.
// Release the claim so another runner can pick the job up.
if err := ctx.Err(); err != nil {
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
}
return nil, false, err
}
return task, true, nil return task, true, nil
} }

View File

@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTryPickTaskThrottled(t *testing.T) {
sem := taskPickLimiter()
// Saturate every assignment slot so the next attempt must be throttled.
for range cap(sem) {
sem <- struct{}{}
}
defer func() {
for range cap(sem) {
<-sem
}
}()
// No DB access happens on the throttled path, so this is safe without fixtures.
task, ok, throttled, err := TryPickTask(t.Context(), &actions_model.ActionRunner{})
require.NoError(t, err)
assert.Nil(t, task)
assert.False(t, ok)
assert.True(t, throttled)
}

View File

@@ -254,6 +254,17 @@ func BatchHandler(ctx *context.Context) {
var responseObject *lfs_module.ObjectResponse var responseObject *lfs_module.ObjectResponse
if isUpload { if isUpload {
if exists && meta == nil {
// The object exists in the content store but is not linked to this
// repo. Do not auto-link it based on cross-repo access: the token
// only authorizes this repo, and for deploy keys ctx.Doer is the
// repo owner, so trusting it here would let a single-repo key pull
// objects from any repo the owner can see. Require proof of
// possession by making the client upload the (hash-verified) bytes,
// and treat it as a new upload for size-limit enforcement below.
exists = false
}
var err *lfs_module.ObjectError var err *lfs_module.ObjectError
if !exists && setting.LFS.MaxFileSize > 0 && p.Size > setting.LFS.MaxFileSize { if !exists && setting.LFS.MaxFileSize > 0 && p.Size > setting.LFS.MaxFileSize {
err = &lfs_module.ObjectError{ err = &lfs_module.ObjectError{
@@ -262,25 +273,6 @@ func BatchHandler(ctx *context.Context) {
} }
} }
if exists && meta == nil {
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
if err != nil {
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
writeStatus(ctx, http.StatusInternalServerError)
return
}
if accessible {
_, err := git_model.NewLFSMetaObject(ctx, repository.ID, p)
if err != nil {
log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err)
writeStatus(ctx, http.StatusInternalServerError)
return
}
} else {
exists = false
}
}
responseObject = buildObjectResponse(rc, p, false, !exists, err) responseObject = buildObjectResponse(rc, p, false, !exists, err)
} else { } else {
var err *lfs_module.ObjectError var err *lfs_module.ObjectError
@@ -337,13 +329,17 @@ func UploadHandler(ctx *context.Context) {
uploadOrVerify := func() error { uploadOrVerify := func() error {
if exists { if exists {
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid) // The bytes already exist in the content store. Only skip proof of
if err != nil { // possession when the object is already linked to *this* repo; never
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err) // trust cross-repo access (ctx.Doer is the repo owner for deploy keys),
// which would let a caller link an object it cannot produce.
meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid)
if err != nil && err != git_model.ErrLFSObjectNotExist {
log.Error("Unable to get LFS MetaObject [%s]. Error: %v", p.Oid, err)
return err return err
} }
if !accessible { if meta == nil {
// The file exists but the user has no access to it. // The file exists but is not linked to this repo.
// The upload gets verified by hashing and size comparison to prove access to it. // The upload gets verified by hashing and size comparison to prove access to it.
hash := sha256.New() hash := sha256.New()
written, err := io.Copy(hash, ctx.Req.Body) written, err := io.Copy(hash, ctx.Req.Body)

View File

@@ -71,7 +71,8 @@ func UpdateAddress(ctx context.Context, m *repo_model.Mirror, addr string) error
} }
func pruneBrokenReferences(ctx context.Context, m *repo_model.Mirror, gitRepo gitrepo.Repository, timeout time.Duration) error { func pruneBrokenReferences(ctx context.Context, m *repo_model.Mirror, gitRepo gitrepo.Repository, timeout time.Duration) error {
cmd := gitcmd.NewCommand("remote", "prune").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout) // Never follow HTTP redirects, see cmdFetch in runSync.
cmd := gitcmd.NewCommand("remote", "prune").AddConfig("http.followRedirects", "false").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
stdout, _, pruneErr := gitrepo.RunCmdString(ctx, gitRepo, cmd) stdout, _, pruneErr := gitrepo.RunCmdString(ctx, gitRepo, cmd)
if pruneErr != nil { if pruneErr != nil {
// sanitize the output, since it may contain the remote address, which may contain a password // sanitize the output, since it may contain the remote address, which may contain a password
@@ -119,7 +120,9 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
// use fetch but not remote update because git fetch support --tags but remote update doesn't // use fetch but not remote update because git fetch support --tags but remote update doesn't
cmdFetch := func() *gitcmd.Command { cmdFetch := func() *gitcmd.Command {
cmd := gitcmd.NewCommand("fetch", "--tags") // Never follow HTTP redirects: a mirror remote that later starts redirecting to an
// otherwise-blocked address would be an SSRF/exfiltration vector on scheduled syncs.
cmd := gitcmd.NewCommand("fetch", "--tags").AddConfig("http.followRedirects", "false")
if m.EnablePrune { if m.EnablePrune {
cmd.AddArguments("--prune") cmd.AddArguments("--prune")
} }
@@ -200,7 +203,8 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
} }
cmdRemoteUpdatePrune := func() *gitcmd.Command { cmdRemoteUpdatePrune := func() *gitcmd.Command {
return gitcmd.NewCommand("remote", "update", "--prune"). // Never follow HTTP redirects, see cmdFetch above.
return gitcmd.NewCommand("remote", "update", "--prune").AddConfig("http.followRedirects", "false").
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs) AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
} }

View File

@@ -13,8 +13,9 @@ import (
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project" project_model "gitea.dev/models/project"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
"xorm.io/builder"
) )
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column // MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
@@ -100,28 +101,15 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
}) })
} }
func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) { func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) {
var issueList issues_model.IssueList sub := builder.Select("distinct issue_assignees.assignee_id").
for _, colIssues := range issuesMap { From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
issueList = append(issueList, colIssues...) Where(builder.Eq{"project_issue.project_id": projectID})
} err := db.GetEngine(ctx).Table("`user`").Where(builder.In("id", sub)).Find(&users)
err := issueList.LoadAssignees(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
users := make([]*user_model.User, 0, len(issueList)) slices.SortFunc(users, func(a, b *user_model.User) int { return strings.Compare(a.Name, b.Name) })
usersAdded := container.Set[int64]{}
for _, issue := range issueList {
for _, assignee := range issue.Assignees {
if !usersAdded.Contains(assignee.ID) {
usersAdded.Add(assignee.ID)
users = append(users, assignee)
}
}
}
slices.SortFunc(users, func(a, b *user_model.User) int {
return strings.Compare(a.Name, b.Name)
})
return users, nil return users, nil
} }

View File

@@ -15,7 +15,6 @@ import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func Test_Projects(t *testing.T) { func Test_Projects(t *testing.T) {
@@ -198,18 +197,4 @@ func Test_Projects(t *testing.T) {
assert.Len(t, columnIssues[3], 1) assert.Len(t, columnIssues[3], 1)
}) })
}) })
t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) {
issuesMap := map[int64]issues_model.IssueList{}
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
issuesMap[1] = issues_model.IssueList{issue1}
issuesMap[2] = issues_model.IssueList{issue6}
assignees, err := LoadIssuesAssigneesForProject(t.Context(), issuesMap)
require.NoError(t, err)
require.Len(t, assignees, 3)
require.Equal(t, "user1", assignees[0].Name)
require.Equal(t, "user10", assignees[1].Name)
require.Equal(t, "user2", assignees[2].Name)
})
} }

View File

@@ -156,11 +156,16 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR
return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil
} }
// EffectiveRequiredContexts returns the required status-check contexts for a PR head: // EffectiveRequiredContexts returns the required status-check contexts to enforce, drawn from:
// 1. every required scoped workflow's status-check patterns effective for the repo (always) // 1. every required scoped workflow's status-check patterns effective for the repo (always)
// 2. the branch protection's own configured contexts, only when its status check is enabled // 2. each given protected branch rule's own configured contexts, only when that rule's status check is enabled
func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pb *git_model.ProtectedBranch) ([]string, error) { //
if pb == nil { // Passing no rule or a single nil rule yields nothing, not even scoped patterns.
// A single rule yields that rule's effective contexts.
// Passing several rules unions their effective contexts; this is used when the governing rule is not yet known.
func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pbs ...*git_model.ProtectedBranch) ([]string, error) {
// No protection rule in effect: nothing is required.
if len(pbs) == 0 || (len(pbs) == 1 && pbs[0] == nil) {
return nil, nil return nil, nil
} }
@@ -169,36 +174,28 @@ func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository,
return nil, fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err) return nil, fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
} }
required := make(container.Set[string])
// Every required scoped workflow's admin-authored status-check patterns, matched must-present-and-pass: // Every required scoped workflow's admin-authored status-check patterns, matched must-present-and-pass:
// a required scoped check that posts no matching status blocks the merge. // a required scoped check that posts no matching status blocks the merge.
seen := make(container.Set[string])
var scoped []string
for _, source := range sources { for _, source := range sources {
for _, cfg := range source.WorkflowConfigs { for _, cfg := range source.WorkflowConfigs {
if !cfg.Required { if !cfg.Required {
continue continue
} }
for _, p := range cfg.Patterns { required.AddMultiple(cfg.Patterns...)
if seen.Add(p) {
scoped = append(scoped, p)
}
}
} }
} }
slices.Sort(scoped) // sort for stable output // Union the configured contexts of every rule whose own status check is enabled (a disabled rule contributes none)
for _, pb := range pbs {
// With the branch protection's own status check disabled, only the required scoped checks (mandated by the owner or instance admin) gate the merge. if pb == nil || !pb.EnableStatusCheck {
if !pb.EnableStatusCheck { continue
return scoped, nil
}
// Status check enabled: the rule's configured contexts, then the scoped patterns not already among them.
required := slices.Clone(pb.StatusCheckContexts)
for _, p := range scoped {
if !slices.Contains(pb.StatusCheckContexts, p) {
required = append(required, p)
} }
required.AddMultiple(pb.StatusCheckContexts...)
} }
return required, nil
values := required.Values()
slices.Sort(values) // stable output
return values, nil
} }

View File

@@ -153,4 +153,14 @@ func TestEffectiveRequiredContexts(t *testing.T) {
"org/src: ci.yaml / lint (pull_request)", "org/src: ci.yaml / lint (pull_request)",
}, got) }, got)
}) })
t.Run("several rules union their enabled contexts, deduplicated; a disabled rule contributes none", func(t *testing.T) {
noSourceRepo := &repo_model.Repository{ID: consumer.ID, OwnerID: 99999} // no scoped sources, so only the rules' contexts gate
a := &git_model.ProtectedBranch{EnableStatusCheck: true, StatusCheckContexts: []string{"a/check", "shared/check"}}
b := &git_model.ProtectedBranch{EnableStatusCheck: true, StatusCheckContexts: []string{"b/check", "shared/check"}}
off := &git_model.ProtectedBranch{EnableStatusCheck: false, StatusCheckContexts: []string{"off/check"}}
got, err := EffectiveRequiredContexts(t.Context(), noSourceRepo, a, b, off)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"a/check", "b/check", "shared/check"}, got)
})
} }

View File

@@ -20,9 +20,11 @@ import (
"gitea.dev/modules/graceful" "gitea.dev/modules/graceful"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/repository" "gitea.dev/modules/repository"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage" "gitea.dev/modules/storage"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/services/context/upload"
notify_service "gitea.dev/services/notify" notify_service "gitea.dev/services/notify"
) )
@@ -319,13 +321,17 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
} }
for uuid, newName := range editAttachments { for uuid, newName := range editAttachments {
if !deletedUUIDs.Contains(uuid) { if deletedUUIDs.Contains(uuid) {
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{ continue
UUID: uuid, }
Name: newName, if err = upload.Verify(nil, newName, setting.Repository.Release.AllowedTypes); err != nil {
}, "name"); err != nil { return err
return err }
} if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
UUID: uuid,
Name: newName,
}, "name"); err != nil {
return err
} }
} }
} }

View File

@@ -12,8 +12,11 @@ import (
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/gitrepo" "gitea.dev/modules/gitrepo"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/services/attachment" "gitea.dev/services/attachment"
"gitea.dev/services/context/upload"
_ "gitea.dev/models/actions" _ "gitea.dev/models/actions"
@@ -270,6 +273,17 @@ func TestRelease_Update(t *testing.T) {
assert.Equal(t, release.ID, release.Attachments[0].ReleaseID) assert.Equal(t, release.ID, release.Attachments[0].ReleaseID)
assert.Equal(t, "test2.txt", release.Attachments[0].Name) assert.Equal(t, "test2.txt", release.Attachments[0].Name)
defer test.MockVariableValue(&setting.Repository.Release.AllowedTypes, ".zip")()
err = UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, map[string]string{
attach.UUID: "test.exe",
})
assert.Error(t, err)
assert.True(t, upload.IsErrFileTypeForbidden(err))
release.Attachments = nil
assert.NoError(t, repo_model.GetReleaseAttachments(t.Context(), release))
assert.Len(t, release.Attachments, 1)
assert.Equal(t, "test2.txt", release.Attachments[0].Name)
// delete the attachment // delete the attachment
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, []string{attach.UUID}, nil)) assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, []string{attach.UUID}, nil))
release.Attachments = nil release.Attachments = nil

View File

@@ -42,6 +42,38 @@ type ArchiveRequest struct {
archiveRefShortName string // the ref short name to download the archive, for example: "master", "v1.0.0", "commit id" archiveRefShortName string // the ref short name to download the archive, for example: "master", "v1.0.0", "commit id"
} }
type archiveQueueItem struct {
RepoID int64 `json:"RepoID"`
Type repo_model.ArchiveType `json:"Type"`
CommitID string `json:"CommitID"`
Paths []string `json:"Paths,omitempty"`
ArchiveRefShortName string `json:"ArchiveRefShortName,omitempty"`
}
func (aReq *ArchiveRequest) toQueueItem() *archiveQueueItem {
return &archiveQueueItem{
RepoID: aReq.Repo.ID,
Type: aReq.Type,
CommitID: aReq.CommitID,
Paths: aReq.Paths,
ArchiveRefShortName: aReq.archiveRefShortName,
}
}
func (item *archiveQueueItem) toArchiveRequest(ctx context.Context) (*ArchiveRequest, error) {
repo, err := repo_model.GetRepositoryByID(ctx, item.RepoID)
if err != nil {
return nil, err
}
return &ArchiveRequest{
Repo: repo,
Type: item.Type,
CommitID: item.CommitID,
Paths: item.Paths,
archiveRefShortName: item.ArchiveRefShortName,
}, nil
}
// NewRequest creates an archival request, based on the URI. The // NewRequest creates an archival request, based on the URI. The
// resulting ArchiveRequest is suitable for being passed to Await() // resulting ArchiveRequest is suitable for being passed to Await()
// if it's determined that the request still needs to be satisfied. // if it's determined that the request still needs to be satisfied.
@@ -227,13 +259,18 @@ func doArchive(ctx context.Context, r *ArchiveRequest) (*repo_model.RepoArchiver
return archiver, nil return archiver, nil
} }
var archiverQueue *queue.WorkerPoolQueue[*ArchiveRequest] var archiverQueue *queue.WorkerPoolQueue[*archiveQueueItem]
// Init initializes archiver // Init initializes archiver
func Init(ctx context.Context) error { func Init(ctx context.Context) error {
handler := func(items ...*ArchiveRequest) []*ArchiveRequest { handler := func(items ...*archiveQueueItem) []*archiveQueueItem {
for _, archiveReq := range items { for _, item := range items {
log.Trace("ArchiverData Process: %#v", archiveReq) log.Trace("ArchiverData Process: %#v", item)
archiveReq, err := item.toArchiveRequest(ctx)
if err != nil {
log.Error("Archive repo %d: %v", item.RepoID, err)
continue
}
if archiver, err := doArchive(ctx, archiveReq); err != nil { if archiver, err := doArchive(ctx, archiveReq); err != nil {
log.Error("Archive %v failed: %v", archiveReq, err) log.Error("Archive %v failed: %v", archiveReq, err)
} else { } else {
@@ -254,14 +291,15 @@ func Init(ctx context.Context) error {
// StartArchive push the archive request to the queue // StartArchive push the archive request to the queue
func StartArchive(request *ArchiveRequest) error { func StartArchive(request *ArchiveRequest) error {
has, err := archiverQueue.Has(request) item := request.toQueueItem()
has, err := archiverQueue.Has(item)
if err != nil { if err != nil {
return err return err
} }
if has { if has {
return nil return nil
} }
return archiverQueue.Push(request) return archiverQueue.Push(item)
} }
func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error { func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error {

View File

@@ -7,7 +7,9 @@ import (
"testing" "testing"
"time" "time"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/json"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
@@ -21,6 +23,22 @@ func TestMain(m *testing.M) {
unittest.MainTest(m) unittest.MainTest(m)
} }
func TestArchiveQueueItemJSON(t *testing.T) {
orig := &archiveQueueItem{
RepoID: 7,
Type: repo_model.ArchiveZip,
CommitID: "abc123",
Paths: []string{"agents"},
ArchiveRefShortName: "main",
}
bs, err := json.Marshal(orig)
require.NoError(t, err)
var decoded archiveQueueItem
require.NoError(t, json.Unmarshal(bs, &decoded))
assert.Equal(t, *orig, decoded)
}
func TestArchive_Basic(t *testing.T) { func TestArchive_Basic(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())

View File

@@ -73,7 +73,7 @@
<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
<td> <td>
<a class="tw-text-red show-modal" href data-modal="#admin-package-delete-modal" <a class="tw-text-red show-modal" href data-modal="#admin-package-delete-modal"
data-modal-form.action="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.Version.ID}}" data-modal-form.url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.Version.ID}}"
data-modal-package-name="{{.Package.Name}}" data-modal-package-version="{{.Version.Version}}" data-modal-package-name="{{.Package.Name}}" data-modal-package-version="{{.Version.Version}}"
>{{svg "octicon-trash"}}</a> >{{svg "octicon-trash"}}</a>
</td> </td>

View File

@@ -86,7 +86,7 @@
<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
<td> <td>
<a class="tw-text-red show-modal" href data-modal="#admin-repo-delete-modal" <a class="tw-text-red show-modal" href data-modal="#admin-repo-delete-modal"
data-modal-form.action="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.ID}}" data-modal-form.url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.ID}}"
data-modal-repo-name="{{.Name}}" data-modal-repo-name="{{.Name}}"
>{{svg "octicon-trash"}}</a> >{{svg "octicon-trash"}}</a>
</td> </td>

View File

@@ -35,7 +35,7 @@
<div class="item-trailing"> <div class="item-trailing">
{{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}} {{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}}
<button class="ui red button show-modal" data-modal="#remove-team-member" <button class="ui red button show-modal" data-modal="#remove-team-member"
data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove?uid={{.ID}}" data-modal-form.url="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove?uid={{.ID}}"
data-modal-name="{{.DisplayName}}" data-modal-name="{{.DisplayName}}"
data-modal-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button> data-modal-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
{{end}} {{end}}

View File

@@ -13,7 +13,7 @@
<div class="flex-text-block"> <div class="flex-text-block">
{{if .Team.IsMember ctx $.SignedUser.ID}} {{if .Team.IsMember ctx $.SignedUser.ID}}
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team" <button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}" data-modal-form.url="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
data-modal-to-leave-team-name="{{$.Team.Name}}" data-modal-to-leave-team-name="{{$.Team.Name}}"
>{{ctx.Locale.Tr "org.teams.leave"}}</button> >{{ctx.Locale.Tr "org.teams.leave"}}</button>
{{else if .IsOrganizationOwner}} {{else if .IsOrganizationOwner}}

View File

@@ -37,7 +37,7 @@
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a> <a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a>
{{if .IsMember ctx $.SignedUser.ID}} {{if .IsMember ctx $.SignedUser.ID}}
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team" <button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
data-modal-form.action="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}" data-modal-form.url="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
data-modal-to-leave-team-name="{{.Name}}" data-modal-to-leave-team-name="{{.Name}}"
>{{ctx.Locale.Tr "org.teams.leave"}}</button> >{{ctx.Locale.Tr "org.teams.leave"}}</button>
{{end}} {{end}}

View File

@@ -200,7 +200,7 @@
</span> </span>
</button> </button>
{{else}} {{else}}
<button class="btn interact-bg tw-p-2 show-modal delete-branch-button tw-text-red" data-modal="#delete-branch-modal" data-modal-form.action="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-modal-name="{{.DBBranch.Name}}"> <button class="btn interact-bg tw-p-2 show-modal delete-branch-button tw-text-red" data-modal="#delete-branch-modal" data-modal-form.url="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-modal-name="{{.DBBranch.Name}}">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
</button> </button>
{{end}} {{end}}

View File

@@ -2,9 +2,9 @@
<table class="ui very basic table unstackable" id="commits-table"> <table class="ui very basic table unstackable" id="commits-table">
<thead> <thead>
<tr> <tr>
<th class="four wide">{{ctx.Locale.Tr "repo.commits.author"}}</th> <th class="three wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
<th class="two wide sha">{{StringUtils.ToUpper $.Repository.ObjectFormatName}}</th> <th class="two wide sha">{{StringUtils.ToUpper $.Repository.ObjectFormatName}}</th>
<th class="seven wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th> <th class="eight wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
<th class="two wide tw-text-right">{{ctx.Locale.Tr "repo.commits.date"}}</th> <th class="two wide tw-text-right">{{ctx.Locale.Tr "repo.commits.date"}}</th>
<th class="one wide"></th> <th class="one wide"></th>
</tr> </tr>

View File

@@ -34,10 +34,10 @@
<div class="divider"></div> <div class="divider"></div>
{{end}} {{end}}
{{if $canUserBlock}} {{if $canUserBlock}}
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</div> <div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.url="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</div>
{{end}} {{end}}
{{if $canOrgBlock}} {{if $canOrgBlock}}
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{ctx.RootData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div> <div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.url="{{ctx.RootData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
{{end}} {{end}}
{{end}} {{end}}
{{end}} {{end}}

View File

@@ -13,7 +13,7 @@
</h2> </h2>
{{template "base/alert" .}} {{template "base/alert" .}}
<form class="ui form" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm" <form class="ui form form-fetch-action" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm"
data-existing-tags="{{JsonUtils.EncodeToString .Tags}}" data-existing-tags="{{JsonUtils.EncodeToString .Tags}}"
data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}" data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}" data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"

View File

@@ -3,7 +3,7 @@
<div class="ui right"> <div class="ui right">
<button class="ui primary tiny button show-modal" <button class="ui primary tiny button show-modal"
data-modal="#add-secret-modal" data-modal="#add-secret-modal"
data-modal-form.action="{{.Link}}" data-modal-form.url="{{.Link}}"
data-modal-header="{{ctx.Locale.Tr "secrets.add_secret"}}" data-modal-header="{{ctx.Locale.Tr "secrets.add_secret"}}"
data-modal-secret-name.value="" data-modal-secret-name.value=""
data-modal-secret-name.read-only="false" data-modal-secret-name.read-only="false"
@@ -39,7 +39,7 @@
</span> </span>
<button class="btn interact-bg show-modal tw-p-2" <button class="btn interact-bg show-modal tw-p-2"
data-modal="#add-secret-modal" data-modal="#add-secret-modal"
data-modal-form.action="{{$.Link}}" data-modal-form.url="{{$.Link}}"
data-modal-header="{{ctx.Locale.Tr "secrets.edit_secret"}}" data-modal-header="{{ctx.Locale.Tr "secrets.edit_secret"}}"
data-tooltip-content="{{ctx.Locale.Tr "secrets.edit_secret"}}" data-tooltip-content="{{ctx.Locale.Tr "secrets.edit_secret"}}"
data-modal-secret-name.value="{{.Name}}" data-modal-secret-name.value="{{.Name}}"

View File

@@ -132,7 +132,7 @@
{{end}} {{end}}
<li> <li>
{{if not .UserBlocking}} {{if not .UserBlocking}}
<a class="muted show-modal" href="#" data-modal="#block-user-modal" data-modal-modal-blockee="{{.ContextUser.Name}}" data-modal-modal-blockee-name="{{.ContextUser.GetDisplayName}}" data-modal-modal-form.action="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</a> <a class="muted show-modal" href="#" data-modal="#block-user-modal" data-modal-modal-blockee="{{.ContextUser.Name}}" data-modal-modal-blockee-name="{{.ContextUser.GetDisplayName}}" data-modal-modal-form.url="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</a>
{{else}} {{else}}
<a class="muted" href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a> <a class="muted" href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a>
{{end}} {{end}}

View File

@@ -3,7 +3,7 @@
<div class="ui right"> <div class="ui right">
<button class="ui primary tiny button show-modal" <button class="ui primary tiny button show-modal"
data-modal="#edit-variable-modal" data-modal="#edit-variable-modal"
data-modal-form.action="{{.Link}}/new" data-modal-form.url="{{.Link}}/new"
data-modal-header="{{ctx.Locale.Tr "actions.variables.creation"}}" data-modal-header="{{ctx.Locale.Tr "actions.variables.creation"}}"
data-modal-dialog-variable-name="" data-modal-dialog-variable-name=""
data-modal-dialog-variable-data="" data-modal-dialog-variable-data=""
@@ -39,7 +39,7 @@
<button class="btn interact-bg tw-p-2 show-modal" <button class="btn interact-bg tw-p-2 show-modal"
data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}" data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}"
data-modal="#edit-variable-modal" data-modal="#edit-variable-modal"
data-modal-form.action="{{$.Link}}/{{.ID}}/edit" data-modal-form.url="{{$.Link}}/{{.ID}}/edit"
data-modal-header="{{ctx.Locale.Tr "actions.variables.edit"}}" data-modal-header="{{ctx.Locale.Tr "actions.variables.edit"}}"
data-modal-dialog-variable-name="{{.Name}}" data-modal-dialog-variable-name="{{.Name}}"
data-modal-dialog-variable-data="{{.Data}}" data-modal-dialog-variable-data="{{.Data}}"

View File

@@ -24,7 +24,7 @@
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<button class="ui red button show-modal" data-modal="#leave-organization" <button class="ui red button show-modal" data-modal="#leave-organization"
data-modal-form.action="{{.OrganisationLink}}/members/action/leave?uid={{$.SignedUser.ID}}" data-modal-form.url="{{.OrganisationLink}}/members/action/leave?uid={{$.SignedUser.ID}}"
data-modal-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}} data-modal-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}
</button> </button>
</div> </div>

View File

@@ -35,7 +35,7 @@ test('pdf file', async ({page, request}) => {
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`); await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
const container = page.locator('.file-view-render-container'); const container = page.locator('.file-view-render-container');
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer'); await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
expect((await container.boundingBox())!.height).toBeGreaterThan(300); await expect.poll(async () => (await container.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(container, page.locator('.file-view')); await assertFlushWithParent(container, page.locator('.file-view'));
}); });

View File

@@ -344,6 +344,59 @@ jobs:
}) })
}) })
t.Run("Filtered required scoped check passes as skipped and allows merge", func(t *testing.T) {
// A required scoped workflow excluded by a paths filter posts a skipped (success) commit status,
// so the required check is satisfied and the PR can merge.
const scopedFilteredPRWorkflow = `name: Scoped Filtered PR
on:
pull_request:
paths:
- src/**
jobs:
scoped-filtered-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped-filtered
`
source := createTestRepo(t, "sw-filtered-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedFilteredPRWorkflow)
registerUserScopedSource(t, source, "pr.yaml") // required
consumer := createTestRepo(t, "sw-filtered-consumer", false)
// Protect the default branch (its own status check stays off, so only the required scoped check gates the merge).
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), map[string]string{
"rule_name": consumer.DefaultBranch,
"enable_push": "true",
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
}), http.StatusSeeOther)
// Open a PR that changes a file NOT matching the workflow's `paths: [src/**]`, so it is filtered out.
prFile := &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: consumer.DefaultBranch, NewBranchName: "filtered-pr", Message: "pr change",
Author: api.Identity{Name: user2.Name, Email: user2.Email},
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
}
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "docs.txt", prFile)
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, "filtered-pr")(t)
require.NoError(t, err)
// Filtered: no scoped run is created, but a skipped commit status is posted on the PR head.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "filtered scoped workflow creates no run")
assertSkippedCommitStatusExists(t, consumer.ID, pr.Head.Sha, "pull_request")
// The skipped (success) status satisfies the required scoped check (prefixed with the source repo), so the merge is allowed.
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
mergeReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
user2Session.MakeRequest(t, mergeReq, http.StatusOK)
})
t.Run("Settings page required patterns", func(t *testing.T) { t.Run("Settings page required patterns", func(t *testing.T) {
source := createTestRepo(t, "sw-settings-source", false) source := createTestRepo(t, "sw-settings-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)

View File

@@ -166,6 +166,14 @@ jobs:
assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA) assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent) assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
// require the workflow's status check on the base branch, so a filtered-out run posts a skipped status to satisfy it
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), baseRepo, &git_model.ProtectedBranch{
RepoID: baseRepo.ID,
RuleName: "main",
EnableStatusCheck: true,
StatusCheckContexts: []string{"*"},
}, git_model.WhitelistOptions{}))
// add another file whose name cannot match the specified path // add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{ addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{ Files: []*files_service.ChangeRepoFile{
@@ -215,8 +223,68 @@ jobs:
err = pull_service.NewPullRequest(t.Context(), prOpts) err = pull_service.NewPullRequest(t.Context(), prOpts)
assert.NoError(t, err) assert.NoError(t, err)
// the new pull request cannot trigger actions, so there is still only 1 record // the new pull request is filtered by paths, so no run is created; a skipped commit status is posted instead
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID})) assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
assertSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
// delete the branch protection rule: with nothing required, a filtered-out run must post no skipped status
pb, err := git_model.GetProtectedBranchRuleByName(t.Context(), baseRepo.ID, "main")
require.NoError(t, err)
require.NotNil(t, pb)
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), baseRepo, pb.ID))
// add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "bar.txt",
ContentReader: strings.NewReader("bar"),
},
},
Message: "add bar.txt",
OldBranch: "main",
NewBranch: "fork-branch-3",
Author: &files_service.IdentityOptions{
GitUserName: user4.Name,
GitUserEmail: user4.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: user4.Name,
GitUserEmail: user4.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
assert.NoError(t, err)
assert.NotEmpty(t, addFileToForkedResp)
// create Pull
pullIssue = &issues_model.Issue{
RepoID: baseRepo.ID,
Title: "A mismatched path with no required status check posts no skipped status",
PosterID: user4.ID,
Poster: user4,
IsPull: true,
}
pullRequest = &issues_model.PullRequest{
HeadRepoID: forkedRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "fork-branch-3",
BaseBranch: "main",
HeadRepo: forkedRepo,
BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea,
}
prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
err = pull_service.NewPullRequest(t.Context(), prOpts)
assert.NoError(t, err)
// filtered by paths and no required status check remains, so no run and no skipped commit status
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
assertNoSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
}) })
} }
@@ -338,6 +406,10 @@ jobs:
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, addFileToBranchResp) assert.NotEmpty(t, addFileToBranchResp)
// the push to test-skip-ci is filtered by branches, so no run is created;
// its context is not a required status check either, so no skipped commit status is posted.
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
assertNoSkippedCommitStatusExists(t, repo.ID, addFileToBranchResp.Commit.SHA, "push")
resp := testPullCreate(t, session, "user2", "skip-ci", true, "master", "test-skip-ci", "[skip ci] test-skip-ci") resp := testPullCreate(t, session, "user2", "skip-ci", true, "master", "test-skip-ci", "[skip ci] test-skip-ci")
@@ -345,7 +417,7 @@ jobs:
url := test.RedirectURL(resp) url := test.RedirectURL(resp)
assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url) assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
// the pr title contains a configured skip-ci string, so there is still only 1 record // the pr title contains a configured skip-ci string, so no run and no skipped status are created
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
}) })
} }
@@ -1879,3 +1951,29 @@ jobs:
runner.fetchNoTask(t) runner.fetchNoTask(t)
}) })
} }
// assertSkippedCommitStatusExists asserts that a filtered-out required workflow posted a skipped commit status on sha
func assertSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
t.Helper()
assert.Truef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "missing skipped commit status with event %q on %s", eventSuffix, sha)
}
// assertNoSkippedCommitStatusExists asserts that no skipped commit status for the given event was posted on sha,
// e.g. a filtered-out workflow whose context is not a required status check must not leave one behind.
func assertNoSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
t.Helper()
assert.Falsef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "unexpected skipped commit status with event %q on %s", eventSuffix, sha)
}
// hasSkippedCommitStatus reports whether a skipped commit status for the given event was posted on sha.
func hasSkippedCommitStatus(t *testing.T, repoID int64, sha, eventSuffix string) bool {
t.Helper()
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repoID, sha, db.ListOptionsAll)
require.NoError(t, err)
for _, s := range statuses {
if s.State == commitstatus.CommitStatusSkipped && strings.Contains(s.Context, "("+eventSuffix+")") {
return true
}
}
return false
}

View File

@@ -241,3 +241,27 @@ func TestGetFileHistoryNotOnMaster(t *testing.T) {
assert.Equal(t, "1", resp.Header().Get("X-Total")) assert.Equal(t, "1", resp.Header().Get("X-Total"))
} }
func TestGetFileHistoryEmptyDateRange(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// Login as User2.
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
// readme.md exists in repo16 but no commits fall before 1970, so the date
// filter yields an empty range: this must return 200 with an empty list,
// not 404 (regression: a valid path with an empty date range was a 404).
req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?path=readme.md&sha=good-sign&until=1970-01-01T00:00:00Z", user.Name).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
apiData := DecodeJSON(t, resp, []api.Commit{})
assert.Empty(t, apiData)
assert.Equal(t, "0", resp.Header().Get("X-Total"))
// a path that does not exist must still return 404 even with a date filter
req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?path=does-not-exist.md&sha=good-sign&until=1970-01-01T00:00:00Z", user.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}

View File

@@ -240,9 +240,14 @@ func TestAPILFSBatch(t *testing.T) {
assert.Equal(t, "Size must be less than or equal to 2", br.Objects[0].Error.Message) assert.Equal(t, "Size must be less than or equal to 2", br.Objects[0].Error.Message)
}) })
t.Run("AddMeta", func(t *testing.T) { t.Run("CrossRepoObjectRequiresUpload", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
// An object whose bytes already exist in the store but which is not
// linked to this repo must not be silently linked, even when the
// caller can access it in another repo. Auto-linking let a deploy key
// (whose token carries the repo owner's identity) exfiltrate objects
// across repos without proving possession. The client must upload.
p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6} p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6}
contentStore := lfs.NewContentStore() contentStore := lfs.NewContentStore()
@@ -250,6 +255,7 @@ func TestAPILFSBatch(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, exist) assert.True(t, exist)
// The object is linked to another repo owned by the same user.
repo2 := createLFSTestRepository(t, "lfs-batch2-repo") repo2 := createLFSTestRepository(t, "lfs-batch2-repo")
storeObjectInRepo(t, repo2.ID, "dummy0") storeObjectInRepo(t, repo2.ID, "dummy0")
@@ -266,11 +272,13 @@ func TestAPILFSBatch(t *testing.T) {
br := decodeResponse(t, resp.Body) br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1) assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error) assert.Nil(t, br.Objects[0].Error)
assert.Empty(t, br.Objects[0].Actions) // The client is told to upload instead of the object being linked.
assert.Contains(t, br.Objects[0].Actions, "upload")
// No meta object may have been created for this repo.
meta, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid) meta, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
assert.NoError(t, err) assert.Nil(t, meta)
assert.NotNil(t, meta) assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
// Cleanup // Cleanup
err = contentStore.Delete(p.RelativePath()) err = contentStore.Delete(p.RelativePath())

View File

@@ -171,6 +171,11 @@ func testGetAttachment(t *testing.T) {
{"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK}, {"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK},
{"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound}, {"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound},
{"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound}, {"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound},
// draft release attachments must only be reachable by users with write access, even on a public repo
{"DraftReleaseByOwner", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user2Session, http.StatusOK},
{"DraftReleaseByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, adminSession, http.StatusOK},
{"DraftReleaseByNonCollaborator", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user8Session, http.StatusNotFound},
{"DraftReleaseByAnonymous", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, emptySession, http.StatusNotFound},
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {

View File

@@ -22,6 +22,7 @@ import (
"gitea.dev/services/auth/source/oauth2" "gitea.dev/services/auth/source/oauth2"
"gitea.dev/tests" "gitea.dev/tests"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"xorm.io/builder" "xorm.io/builder"
@@ -489,3 +490,73 @@ func TestOAuth2GroupClaimsManualLinking(t *testing.T) {
}) })
} }
} }
// TestOAuth2AutoLinkWithTwoFactor verifies that automatic account linking completes
// after the user passes local 2FA when an OIDC identity matches an existing account.
func TestOAuth2AutoLinkWithTwoFactor(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameEmail)()
const (
sourceName = "test-oauth-auto-link-2fa"
sub = "oidc-auto-link-2fa-sub"
email = "oidc-auto-link-2fa@example.com"
userName = "oidc-auto-link-2fa"
)
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: sub, Email: email, Name: "OIDC Auto Link 2FA"})
addOAuth2Source(t, sourceName, oauth2.Source{
Provider: "openidConnect",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
})
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
require.NoError(t, err)
localUser := &user_model.User{Name: userName, Email: email}
require.NoError(t, user_model.CreateUser(t.Context(), localUser, &user_model.Meta{}))
otpKey, err := totp.Generate(totp.GenerateOpts{
SecretSize: 40,
Issuer: "gitea-test",
AccountName: localUser.Name,
})
require.NoError(t, err)
tfa := &auth_model.TwoFactor{UID: localUser.ID}
require.NoError(t, tfa.SetSecret(otpKey.Secret()))
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
unittest.AssertNotExistsBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
session := emptyTestSession(t)
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
location := resp.Header().Get("Location")
u, err := url.Parse(location)
require.NoError(t, err)
state := u.Query().Get("state")
require.NotEmpty(t, state)
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
resp = session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
assert.Contains(t, resp.Header().Get("Location"), "/user/two_factor")
session.MakeRequest(t, NewRequest(t, "GET", "/user/two_factor"), http.StatusOK)
passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now())
require.NoError(t, err)
req := NewRequestWithValues(t, "POST", "/user/two_factor", map[string]string{
"passcode": passcode,
})
session.MakeRequest(t, req, http.StatusSeeOther)
externalLink := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
assert.Equal(t, localUser.ID, externalLink.UserID)
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK)
}

View File

@@ -58,7 +58,7 @@ func TestUndoDeleteBranch(t *testing.T) {
} }
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
htmlDoc, name := branchAction(t, ".delete-branch-button", "data-modal-form.action") htmlDoc, name := branchAction(t, ".delete-branch-button", "data-modal-form.url")
assert.Contains(t, assert.Contains(t,
htmlDoc.doc.Find(".ui.positive.message").Text(), htmlDoc.doc.Find(".ui.positive.message").Text(),
translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name), translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name),

View File

@@ -10,12 +10,14 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project" project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit" "gitea.dev/models/unit"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
project "gitea.dev/services/projects"
"gitea.dev/tests" "gitea.dev/tests"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
@@ -365,3 +367,28 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
assert.NotContains(t, issueIDs, issue17.ID) assert.NotContains(t, issueIDs, issue17.ID)
}) })
} }
func TestProjects(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) {
_ = db.TruncateBeans(t.Context(), "project_issue", "issue_assignees")
_ = db.Insert(t.Context(),
&project_model.ProjectIssue{ProjectID: 1, IssueID: 1},
&project_model.ProjectIssue{ProjectID: 1, IssueID: 6},
)
_ = db.Insert(t.Context(),
&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 1},
&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 10},
&issues_model.IssueAssignees{IssueID: 1, AssigneeID: 2},
&issues_model.IssueAssignees{IssueID: 6, AssigneeID: 2},
&issues_model.IssueAssignees{IssueID: 6, AssigneeID: 4},
)
assignees, err := project.LoadIssuesAssigneesForProject(t.Context(), 1)
require.NoError(t, err)
require.Len(t, assignees, 4)
require.Equal(t, "user1", assignees[0].Name)
require.Equal(t, "user10", assignees[1].Name)
require.Equal(t, "user2", assignees[2].Name)
require.Equal(t, "user4", assignees[3].Name)
})
}

View File

@@ -10,6 +10,7 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/test" "gitea.dev/modules/test"
"gitea.dev/modules/translation" "gitea.dev/modules/translation"
@@ -39,11 +40,10 @@ func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title st
if draft { if draft {
postData["draft"] = "1" postData["draft"] = "1"
} }
req = NewRequestWithValues(t, "POST", link, postData) req = NewRequestWithValues(t, "POST", link, postData)
resp = session.MakeRequest(t, req, http.StatusOK)
resp = session.MakeRequest(t, req, http.StatusSeeOther) assert.NotEmpty(t, test.ParseJSONRedirect(resp.Body.Bytes()))
test.RedirectURL(resp) // check that redirect URL exists
} }
func checkLatestReleaseAndCount(t *testing.T, session *TestSession, repoURL, version, label string, count int) { func checkLatestReleaseAndCount(t *testing.T, session *TestSession, repoURL, version, label string, count int) {
@@ -253,3 +253,27 @@ func TestDownloadReleaseAttachment(t *testing.T) {
session := loginUser(t, "user2") session := loginUser(t, "user2")
session.MakeRequest(t, req, http.StatusOK) session.MakeRequest(t, req, http.StatusOK)
} }
func TestEditReleaseAttachmentRejectsForbiddenRename(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Repository.Release.AllowedTypes, ".zip")()
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9})
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: attachment.ReleaseID})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, repoOwner.Name)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/releases/edit/%s", repo.Link(), release.TagName), map[string]string{
"title": release.Title,
"content": release.Note,
"attachment-edit-" + attachment.UUID: "evil.exe",
})
resp := session.MakeRequest(t, req, http.StatusBadRequest)
errMsg := test.ParseJSONError(resp.Body.Bytes()).ErrorMessage
assert.Equal(t, "This file cannot be uploaded or modified due to a forbidden file extension or type.", errMsg)
attachment = unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: attachment.ID})
assert.NotEqual(t, "evil.exe", attachment.Name)
}

View File

@@ -2,7 +2,7 @@
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue'; import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue'; import ActionStatusIcon from './ActionStatusIcon.vue';
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts'; import {addDelegatedEventListener, createElementFromAttrs} from '../utils/dom.ts';
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts'; import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
import {POST} from '../modules/fetch.ts'; import {POST} from '../modules/fetch.ts';
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts'; import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
@@ -247,9 +247,6 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
`${seconds}s`, // for "Show seconds" `${seconds}s`, // for "Show seconds"
); );
toggleElem(logTimeStamp, timeVisible.value['log-time-stamp']);
toggleElem(logTimeSeconds, timeVisible.value['log-time-seconds']);
const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line'; const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line';
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass}, return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
lineNum, logTimeStamp, logMsg, logTimeSeconds, lineNum, logTimeStamp, logMsg, logTimeSeconds,
@@ -391,9 +388,6 @@ function elStepsContainer(): HTMLElement {
function toggleTimeDisplay(type: 'seconds' | 'stamp') { function toggleTimeDisplay(type: 'seconds' | 'stamp') {
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`]; timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
for (const el of elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
toggleElem(el, timeVisible.value[`log-time-${type}`]);
}
saveLocaleStorageOptions(); saveLocaleStorageOptions();
} }
@@ -473,7 +467,15 @@ async function hashChangeListener() {
</div> </div>
</div> </div>
<!-- always create the node because we have our own event listeners on it, don't use "v-if" --> <!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
<div class="job-step-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length"> <div
class="job-step-container"
ref="stepsContainer"
v-show="!isCallerJob && currentJob.steps.length"
:class="{
'log-line-show-timestamps': timeVisible['log-time-stamp'],
'log-line-show-seconds': timeVisible['log-time-seconds']
}"
>
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx"> <div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
<div <div
class="job-step-summary" class="job-step-summary"
@@ -681,8 +683,22 @@ async function hashChangeListener() {
scroll-margin-top: 95px; scroll-margin-top: 95px;
} }
.job-log-line .log-time-stamp,
.job-log-line .log-time-seconds {
display: none;
}
.log-line-show-timestamps .job-log-line .log-time-stamp {
display: inline;
}
.log-line-show-seconds .job-log-line .log-time-seconds {
display: inline;
}
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */ /* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
.job-log-line .line-num, .log-time-seconds { .job-log-line .line-num,
.job-log-line .log-time-seconds {
width: 48px; width: 48px;
color: var(--color-text-light-3); color: var(--color-text-light-3);
text-align: right; text-align: right;
@@ -699,16 +715,16 @@ async function hashChangeListener() {
} }
.job-log-line .log-time, .job-log-line .log-time,
.log-time-stamp { .job-log-line .log-time-stamp {
color: var(--color-text-light-3); color: var(--color-text-light-3);
margin-left: 10px; margin-left: 12px;
white-space: nowrap; white-space: nowrap;
} }
.job-step-logs .job-log-line .log-msg { .job-step-logs .job-log-line .log-msg {
flex: 1; flex: 1;
white-space: break-spaces; white-space: break-spaces;
margin-left: 10px; margin-left: 12px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -775,30 +791,28 @@ async function hashChangeListener() {
border-radius: 0; border-radius: 0;
} }
.job-log-group .job-log-list .job-log-line .log-msg {
margin-left: 2em;
}
.job-log-group-summary { .job-log-group-summary {
cursor: pointer; cursor: pointer;
position: relative; list-style: none; /* hide the standard disclosure marker (Chrome, Edge, Firefox) */
display: list-item;
list-style: disclosure-closed inside;
padding-left: 58px; /* line-num gutter (48px) + log-msg margin (10px), so the marker sits in the content column */
} }
.job-log-group[open] > .job-log-group-summary { .job-log-group-summary::-webkit-details-marker { /* hide the disclosure marker on Safari */
list-style-type: disclosure-open; display: none;
} }
.job-log-group-summary > .job-log-line { .log-line-group .log-msg::before {
position: absolute; content: "";
inset: 0; display: inline-block;
z-index: -1; /* sit behind the disclosure marker */ vertical-align: middle;
overflow: hidden; margin-top: -2.5px;
margin-right: 8px;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid var(--color-text-light-3);
transition: transform 0.1s ease;
} }
.job-log-group-summary > .job-log-line .log-msg { .job-log-group[open] .log-line-group .log-msg::before {
margin-left: 21px; transform: rotate(90deg);
} }
</style> </style>

View File

@@ -2,9 +2,10 @@ import {assignElementProperty, type ElementWithAssignableProperties} from './com
test('assignElementProperty', () => { test('assignElementProperty', () => {
const elForm = document.createElement('form'); const elForm = document.createElement('form');
assignElementProperty(elForm, 'action', '/test-link'); expect(() => assignElementProperty(elForm, 'action', '/test-link')).toThrow();
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL assignElementProperty(elForm, 'url', '/test-url');
expect(elForm.getAttribute('action')).eq('/test-link'); expect(elForm.action).contains('/test-url'); // the DOM always returns absolute URL
expect(elForm.getAttribute('action')).eq('/test-url');
assignElementProperty(elForm, 'text-content', 'dummy'); assignElementProperty(elForm, 'text-content', 'dummy');
expect(elForm.textContent).toBe('dummy'); expect(elForm.textContent).toBe('dummy');
@@ -14,8 +15,9 @@ test('assignElementProperty', () => {
_attrs: Record<string, string> = {}; _attrs: Record<string, string> = {};
setAttribute(name: string, value: string) { this._attrs[name] = value } setAttribute(name: string, value: string) { this._attrs[name] = value }
getAttribute(name: string): string | null { return this._attrs[name] } getAttribute(name: string): string | null { return this._attrs[name] }
nodeName = 'FORM';
}(); }();
assignElementProperty(elFormWithAction, 'action', '/bar'); assignElementProperty(elFormWithAction, 'url', '/bar');
expect(elFormWithAction.getAttribute('action')).eq('/bar'); expect(elFormWithAction.getAttribute('action')).eq('/bar');
const elInput = document.createElement('input'); const elInput = document.createElement('input');

View File

@@ -42,11 +42,20 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
} }
export type ElementWithAssignableProperties = { export type ElementWithAssignableProperties = {
nodeName: string;
getAttribute: (name: string) => string | null; getAttribute: (name: string) => string | null;
setAttribute: (name: string, value: string) => void; setAttribute: (name: string, value: string) => void;
} & Record<string, any>; } & Record<string, any>;
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) { export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
if (el.nodeName === 'FORM') {
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: a special case for Golang HTML template escaping.
// Golang HTML template only handles some "known" attribute names as URL (e.g.: when the name is "action" or contains "url")
// To prevent template developers from making mistakes like `data-modal-form.action="?k={{ValueWithSpecialChars}}" (no escaping),
// here we use `data-modal-form.url="?k={{ValueWithSpecialChars}}", then the value gets correctly escaped by Golang HTML template.
if (kebabName === 'action') throw new Error(`don't assign element property "action" by value, use "data-modal-form.url" instead`);
if (kebabName === 'url') kebabName = 'action';
}
const camelizedName = camelize(kebabName); const camelizedName = camelize(kebabName);
const old = el[camelizedName]; const old = el[camelizedName];
if (typeof old === 'boolean') { if (typeof old === 'boolean') {
@@ -59,7 +68,7 @@ export function assignElementProperty(el: ElementWithAssignableProperties, kebab
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute // "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
el.setAttribute(kebabName, val); el.setAttribute(kebabName, val);
} else { } else {
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."` // in the future, maybe we could introduce a better typing system if it is really needed
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`); throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
} }
} }
@@ -71,7 +80,11 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// * Then, try to query '[name=target]' // * Then, try to query '[name=target]'
// * Then, try to query '.target' // * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag // * Then, try to query 'target' as HTML tag
// If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName". // If there's a ".{prop-name}" part like "data-modal-input.value", the "input" element's "value" property will be set,
// the "prop-name" will be camel-cased to "propName" (e.g.: "data-modal-input.read-only" for "readOnly" property).
//
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: Form element's "action" property must be set by "data-modal-form.url"
// to make the template variables get correctly escaped in the URL.
e.preventDefault(); e.preventDefault();
const modalSelector = el.getAttribute('data-modal')!; const modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector); const elModal = document.querySelector(modalSelector);