Compare commits

..

17 Commits

Author SHA1 Message Date
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
66 changed files with 1295 additions and 466 deletions

View File

@@ -3008,6 +3008,10 @@ LEVEL = Info
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
;; Maximum number of attempts a single workflow run can have. Default value is 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
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
// same as those in aggregateJobStatus
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling}
// same as those in aggregateJobStatus (StatusUnknown excluded; it's the "shouldn't happen" fallback)
allStatus := []Status{StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped, StatusWaiting, StatusRunning, StatusBlocked, StatusCancelling}
statusInfoList := make([]StatusInfo, 0, len(allStatus))
for _, s := range allStatus {
statusInfoList = append(statusInfoList, StatusInfo{

View File

@@ -73,8 +73,11 @@ func TestGetStatusInfoList(t *testing.T) {
assert.Equal(t, []StatusInfo{
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
{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(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"},
}, statusInfoList)
}

View File

@@ -75,8 +75,28 @@ type ActionRunner struct {
const (
RunnerOfflineTime = time.Minute
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
func (r *ActionRunner) BelongsToOwnerName() string {
if r.RepoID != 0 {
@@ -251,21 +271,24 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
}
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 {
case "online":
return "last_online DESC"
return "last_online DESC, id ASC"
case "offline":
return "last_online ASC"
return "last_online ASC, id ASC"
case "alphabetically":
return "name ASC"
return "name ASC, id ASC"
case "reversealphabetically":
return "name DESC"
return "name DESC, id ASC"
case "newest":
return "id DESC"
case "oldest":
return "id ASC"
}
return "last_online DESC"
return "last_online DESC, id ASC"
}
// 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.
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
// atomically claims it. It iterates through all matching jobs so that a
// 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").
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
}
if jobCond.IsValid() {
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
}
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
// TODO: a more efficient way to filter labels
log.Trace("runner labels: %v", runner.AgentLabels)
for _, v := range jobs {
if !runner.CanMatchLabels(v.RunsOn) {
continue
// Page through the waiting jobs oldest-first instead of loading the whole backlog into memory on every poll.
// 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
}
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

View File

@@ -371,3 +371,74 @@ func TestReleaseTaskForRunner(t *testing.T) {
unittest.AssertNotExistsBean(t, &ActionTask{ID: 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
}
// 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(
parsed []*ParsedScopedWorkflow,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
triggedEvent webhook_module.HookEventType,
payload api.Payloader,
) []*DetectedWorkflow {
workflows := make([]*DetectedWorkflow, 0, len(parsed))
) (matched, filtered []*DetectedWorkflow) {
for _, p := range parsed {
for _, evt := range p.Events {
if evt.IsSchedule() {
// schedule is a non-target for scoped workflows
continue
}
if detectMatched(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
workflows = append(workflows, &DetectedWorkflow{
EntryName: p.EntryName,
TriggerEvent: evt,
Content: p.Content,
})
dwf := &DetectedWorkflow{
EntryName: p.EntryName,
TriggerEvent: evt,
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
}
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; emits a skipped commit status
)
func init() {
model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
// Log the error instead of panic or fatal.
@@ -172,18 +180,16 @@ func DetectWorkflows(
triggedEvent webhook_module.HookEventType,
payload api.Payloader,
detectSchedule bool,
) ([]*DetectedWorkflow, []*DetectedWorkflow, error) {
) (workflows, schedules, filtered []*DetectedWorkflow, err error) {
_, entries, err := ListWorkflows(commit)
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 {
content, err := GetContentFromEntry(entry)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
// one workflow may have multiple events
@@ -203,18 +209,24 @@ func DetectWorkflows(
}
schedules = append(schedules, dwf)
}
} else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) {
} else {
dwf := &DetectedWorkflow{
EntryName: entry.Name(),
TriggerEvent: evt,
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) {
@@ -252,9 +264,9 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
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) {
return false
return detectNotApplicable
}
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())
}
// no special filter parameters for these events, just return true if name matched
return true
return detectMatched
case // push
webhook_module.HookEventPush:
@@ -279,14 +291,20 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
webhook_module.HookEventIssueAssign,
webhook_module.HookEventIssueLabel,
webhook_module.HookEventIssueMilestone:
return matchIssuesEvent(payload.(*api.IssuePayload), evt)
if matchIssuesEvent(payload.(*api.IssuePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // issue_comment
webhook_module.HookEventIssueComment,
// `pull_request_comment` is same as `issue_comment`
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
webhook_module.HookEventPullRequestComment:
return matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt)
if matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // pull_request
webhook_module.HookEventPullRequest,
@@ -300,34 +318,49 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
case // pull_request_review
webhook_module.HookEventPullRequestReviewApproved,
webhook_module.HookEventPullRequestReviewRejected:
return matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt)
if matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // pull_request_review_comment
webhook_module.HookEventPullRequestReviewComment:
return matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt)
if matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // release
webhook_module.HookEventRelease:
return matchReleaseEvent(payload.(*api.ReleasePayload), evt)
if matchReleaseEvent(payload.(*api.ReleasePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // registry_package
webhook_module.HookEventPackage:
return matchPackageEvent(payload.(*api.PackagePayload), evt)
if matchPackageEvent(payload.(*api.PackagePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // workflow_run
webhook_module.HookEventWorkflowRun:
return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt)
if matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) {
return detectMatched
}
return detectNotApplicable
default:
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
if len(evt.Acts()) == 0 {
return true
return detectMatched
}
matchTimes := 0
@@ -393,14 +426,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
return detectNotApplicable
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
case "paths-ignore":
if refName.IsTag() {
@@ -410,14 +443,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
return detectNotApplicable
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
default:
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 {
matchTimes++
}
return matchTimes == len(evt.Acts())
if matchTimes == len(evt.Acts()) {
return detectMatched
}
return detectFilteredOut
}
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())
}
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()
activityTypeMatched := false
matchTimes := 0
@@ -525,7 +561,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
if err != nil {
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)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
return detectNotApplicable
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
case "paths-ignore":
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
return detectNotApplicable
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
default:
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 {

View File

@@ -101,49 +101,49 @@ func TestDetectMatched(t *testing.T) {
triggedEvent webhook_module.HookEventType
payload api.Payloader
yamlOn string
expected bool
expected detectResult
}{
{
desc: "HookEventCreate(create) matches GithubEventCreate(create)",
triggedEvent: webhook_module.HookEventCreate,
payload: nil,
yamlOn: "on: create",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
triggedEvent: webhook_module.HookEventIssues,
payload: &api.IssuePayload{Action: api.HookIssueOpened},
yamlOn: "on: issues",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
triggedEvent: webhook_module.HookEventIssues,
payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
yamlOn: "on: issues",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
triggedEvent: webhook_module.HookEventPullRequestSync,
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
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",
triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
yamlOn: "on: pull_request",
expected: false,
expected: detectNotApplicable,
},
{
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
yamlOn: "on: pull_request",
expected: false,
expected: detectNotApplicable,
},
{
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]",
expected: false,
expected: detectNotApplicable,
},
{
desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type",
triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
yamlOn: "on:\n pull_request:\n types: [labeled]",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
triggedEvent: webhook_module.HookEventPullRequestReviewComment,
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
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)",
triggedEvent: webhook_module.HookEventPullRequestReviewRejected,
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
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",
triggedEvent: webhook_module.HookEventRelease,
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
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",
triggedEvent: webhook_module.HookEventPackage,
payload: &api.PackagePayload{Action: api.HookPackageCreated},
yamlOn: "on:\n registry_package:\n types: [updated]",
expected: false,
expected: detectNotApplicable,
},
{
desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
triggedEvent: webhook_module.HookEventWiki,
payload: nil,
yamlOn: "on: gollum",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
triggedEvent: webhook_module.HookEventSchedule,
payload: nil,
yamlOn: "on: schedule",
expected: true,
expected: detectMatched,
},
{
desc: "push to tag matches workflow with paths condition (should skip paths check)",
@@ -222,7 +222,19 @@ func TestDetectMatched(t *testing.T) {
},
commit: nil,
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))
assert.NoError(t, err)
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

@@ -21,19 +21,6 @@ func TestCommitsCount(t *testing.T) {
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) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
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) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)

View File

@@ -14,6 +14,8 @@ import (
const defaultMaxRerunAttempts = 50
const defaultMaxConcurrentTaskPicks = 16
// Actions settings
var (
Actions = struct {
@@ -31,13 +33,18 @@ var (
WorkflowDirs []string `ini:"WORKFLOW_DIRS"`
ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"`
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,
DefaultActionsURL: defaultActionsURLGitHub,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
MaxRerunAttempts: defaultMaxRerunAttempts,
Enabled: true,
DefaultActionsURL: defaultActionsURLGitHub,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
MaxRerunAttempts: defaultMaxRerunAttempts,
MaxConcurrentTaskPicks: defaultMaxConcurrentTaskPicks,
}
)
@@ -128,6 +135,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
Actions.MaxRerunAttempts = defaultMaxRerunAttempts
}
if Actions.MaxConcurrentTaskPicks <= 0 {
Actions.MaxConcurrentTaskPicks = defaultMaxConcurrentTaskPicks
}
if !Actions.LogCompression.IsValid() {
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)
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 {
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
info, err := os.Stat(privKeyPath)
data, err := os.ReadFile(privKeyPath)
require.NoError(t, err)
metadata[privKeyPath] = info
content[privKeyPath] = data
info, err = os.Stat(pubKeyPath)
data, err = os.ReadFile(pubKeyPath)
require.NoError(t, err)
metadata[pubKeyPath] = info
content[pubKeyPath] = data
}
// 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)
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
infoPriv, err := os.Stat(privKeyPath)
dataPriv, err := os.ReadFile(privKeyPath)
require.NoError(t, err)
switch keyType {
case "rsa":
// No modification to RSA key
infoPub, err := os.Stat(pubKeyPath)
dataPub, err := os.ReadFile(pubKeyPath)
require.NoError(t, err)
assert.Equal(t, metadata[privKeyPath], infoPriv)
assert.Equal(t, metadata[pubKeyPath], infoPub)
assert.Equal(t, content[privKeyPath], dataPriv)
assert.Equal(t, content[pubKeyPath], dataPub)
case "ecdsa":
// ECDSA public key should be missing, private unchanged
assert.Equal(t, metadata[privKeyPath], infoPriv)
assert.Equal(t, content[privKeyPath], dataPriv)
assert.NoFileExists(t, pubKeyPath)
case "ed25519":
// ed25519 private key was removed, so both keys regenerated
infoPub, err := os.Stat(pubKeyPath)
dataPub, err := os.ReadFile(pubKeyPath)
require.NoError(t, err)
assert.NotEqual(t, metadata[privKeyPath], infoPriv)
assert.NotEqual(t, metadata[pubKeyPath], infoPub)
assert.NotEqual(t, content[privKeyPath], dataPriv)
assert.NotEqual(t, content[pubKeyPath], dataPub)
}
}
}

View File

@@ -6,6 +6,7 @@ package storage
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
@@ -47,40 +48,42 @@ type MinioStorage struct {
basePath string
}
func convertMinioErr(err error) error {
func convertMinioErr(err error, optMsg ...string) error {
if err == 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 {
return err
return wrapErr(err)
}
// Convert two responses to standard analogues
switch errResp.Code {
case "NoSuchKey":
return os.ErrNotExist
return wrapErr(os.ErrNotExist)
case "AccessDenied":
return os.ErrPermission
return wrapErr(os.ErrPermission)
}
return err
}
var getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
_, err := minioClient.GetBucketVersioning(ctx, bucket)
return err
return wrapErr(err)
}
// NewMinioStorage returns a minio storage
func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
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" {
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
switch config.BucketLookUpType {
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)
}
// 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{
Creds: buildMinioCredentials(config),
Secure: config.UseSSL,
@@ -101,37 +111,21 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
BucketLookup: lookup,
})
if err != nil {
return nil, convertMinioErr(err)
}
// 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
}
return nil, convertMinioErr(err, makeErrMsg("NewClient"))
}
// Check to see if we already own this bucket
exists, err := minioClient.BucketExists(ctx, config.Bucket)
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 err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
Region: config.Location,
}); err != nil {
return nil, convertMinioErr(err)
return nil, convertMinioErr(err, makeErrMsg("MakeBucket"))
}
}

View File

@@ -4,16 +4,13 @@
package storage
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/minio/minio-go/v7"
"github.com/stretchr/testify/assert"
)
@@ -84,31 +81,18 @@ func TestMinioStoragePath(t *testing.T) {
}
func TestS3StorageBadRequest(t *testing.T) {
if os.Getenv("CI") == "" {
t.Skip("S3Storage not present outside of CI")
return
}
endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
cfg := &setting.Storage{
MinioConfig: setting.MinioStorageConfig{
Endpoint: "minio:9000",
Endpoint: endpoint,
AccessKeyID: "123456",
SecretAccessKey: "12345678",
SecretAccessKey: "invalid-secret",
Bucket: "bucket",
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)
assert.ErrorContains(t, err, message)
assert.ErrorContains(t, err, "ObjectStorage.BucketExists: endpoint="+endpoint)
}
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) {
// 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>`)
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>`)
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
actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`)
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) {
actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`)

View File

@@ -8,6 +8,7 @@ import (
"crypto/subtle"
"errors"
"strings"
"time"
actions_model "gitea.dev/models/actions"
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")
}
cols := []string{"last_online"}
runner.LastOnline = timeutil.TimeStampNow()
if methodName == "UpdateTask" || methodName == "UpdateLog" {
runner.LastActive = timeutil.TimeStampNow()
now := time.Now()
cols := make([]string, 0, 2)
// Debounce last_active too: while a runner streams logs, UpdateLog fires
// 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")
}
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
log.Error("can't update runner status: %v", err)
// Debounce last_online: writing on every poll is a major source of DB load
// 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)

View File

@@ -194,9 +194,15 @@ func (s *Service) FetchTask(
// 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.
// 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)
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 {
task = t
}

View File

@@ -258,8 +258,27 @@ func GetAllCommits(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
} else if commitsCountTotal == 0 {
ctx.APIErrorNotFound()
return
// when date filters are active, a zero count may just mean no
// 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(

View File

@@ -147,6 +147,9 @@ func MustInitSessioner() func(next http.Handler) http.Handler {
Secure: setting.SessionConfig.Secure,
SameSite: setting.SessionConfig.SameSite,
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 {
log.Fatal("common.Sessioner failed: %v", err)

View File

@@ -31,9 +31,7 @@ import (
type preReceiveContext struct {
*gitea_context.PrivateContext
// loadedPusher indicates that where the following information are loaded
loadedPusher bool
user *user_model.User // it's the org user if a DeployKey is used
user *user_model.User // the "pusher", it's the org user if a DeployKey is used
userPerm access_model.Permission
deployKeyAccessMode perm_model.AccessMode
@@ -53,10 +51,7 @@ type preReceiveContext struct {
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil {
var canWrite bool
if ctx.loadPusherAndPermission() {
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
}
canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
ctx.canWriteCodeUnitCached = &canWrite
}
return *ctx.canWriteCodeUnitCached
@@ -91,9 +86,6 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
// CanCreatePullRequest returns true if pusher can create pull requests
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
if !ctx.checkedCanCreatePullRequest {
if !ctx.loadPusherAndPermission() {
return false
}
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
ctx.checkedCanCreatePullRequest = true
}
@@ -124,6 +116,10 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts: opts,
}
if !ourCtx.loadPusherAndPermission() {
return // if error occurs, loadPusherAndPermission had written the error response
}
// Iterate across the provided old commit IDs
for i := range opts.OldCommitIDs {
oldCommitID := opts.OldCommitIDs[i]
@@ -281,18 +277,10 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
}
} 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 {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, user)
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
} 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
}
// 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
// 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)
@@ -499,10 +481,6 @@ func generateGitEnv(opts *private.HookOptions) (env []string) {
// loadPusherAndPermission returns false if an error occurs, and it writes the error response
func (ctx *preReceiveContext) loadPusherAndPermission() bool {
if ctx.loadedPusher {
return true
}
if ctx.opts.UserID == user_model.ActionsUserID {
taskID := ctx.opts.ActionsTaskID
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
@@ -555,7 +533,5 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
}
ctx.deployKeyAccessMode = deployKey.Mode
}
ctx.loadedPusher = true
return true
}

View File

@@ -52,7 +52,6 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{
PrivateContext: mockCtx,
loadedPusher: true,
user: maintainer,
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)
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
@@ -357,7 +357,7 @@ func SignInPost(ctx *context.Context) {
// User will need to use WebAuthn, save data
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)
return
}
@@ -398,7 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
return
}
if err := updateSession(ctx, []string{
if err := regenerateSession(ctx, []string{
// Delete the openid, 2fa and link_account data
"openid_verified_uri",
"openid_signin_remember",
@@ -884,7 +884,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
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,
"uname": user.Name,
}); err != nil {
@@ -936,7 +936,7 @@ func ActivateEmail(ctx *context.Context) {
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 {
return fmt.Errorf("regenerate session: %w", err)
}

View File

@@ -164,7 +164,12 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
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.
"twofaUid": u.ID,
"twofaRemember": remember,

View File

@@ -285,9 +285,7 @@ func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
}
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
return updateSession(ctx, nil, map[string]any{
"linkAccountData": linkAccountData,
})
return ctx.Session.Set("linkAccountData", linkAccountData)
}
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
}
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
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.
"twofaUid": u.ID,
"twofaRemember": false,

View File

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

View File

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

View File

@@ -186,6 +186,22 @@ func ServeAttachment(ctx *context.Context, uuid string) {
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 {
context.CheckTokenScopes(ctx, repo, requiredScope)
if ctx.Written() {

View File

@@ -14,17 +14,6 @@ import (
"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) {
isPullList := ctx.PathParam("type") == "pulls"
issuePosters(ctx, isPullList)
@@ -46,14 +35,5 @@ func issuePosters(ctx *context.Context, isPullList bool) {
posters = append(posters, ctx.Doer)
}
}
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)
ctx.JSON(http.StatusOK, shared_user.ToSearchUserResponse(ctx, ctx.Doer, posters))
}

View File

@@ -27,7 +27,6 @@ import (
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/web/feed"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
"gitea.dev/services/context/upload"
"gitea.dev/services/forms"
@@ -338,7 +337,6 @@ func LatestRelease(ctx *context.Context) {
func newReleaseCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
ctx.Data["PageIsReleaseList"] = true
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
@@ -346,17 +344,8 @@ func newReleaseCommon(ctx *context.Context) {
return
}
ctx.Data["Tags"] = tags
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")
PrepareBranchList(ctx) // for New Release page
}
@@ -425,8 +414,8 @@ func GenerateReleaseNotes(ctx *context.Context) {
// NewReleasePost response for creating a release
func NewReleasePost(ctx *context.Context) {
newReleaseCommon(ctx)
if ctx.Written() {
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
@@ -450,35 +439,28 @@ func NewReleasePost(ctx *context.Context) {
// Or another choice is "always show the tag-only button" if error occurs.
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)
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
}
if !form.TagOnly && form.Title == "" {
// 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
}
handleTagReleaseError := func(err error) {
ctx.Data["Err_TagName"] = true
switch {
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):
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):
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):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("repo.release.tag_name_protected"))
default:
ctx.ServerError("handleTagReleaseError", err)
}
@@ -497,7 +479,7 @@ func NewReleasePost(ctx *context.Context) {
return
}
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
}
@@ -522,7 +504,7 @@ func NewReleasePost(ctx *context.Context) {
handleTagReleaseError(err)
return
}
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
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
// add new logic: if tag-only, do not convert the tag to a release
if form.TagOnly || !rel.IsTag {
ctx.Data["Err_TagName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("repo.release.tag_name_already_exist"))
return
}
@@ -547,7 +528,7 @@ func NewReleasePost(ctx *context.Context) {
handleTagReleaseError(err)
return
}
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
}
// EditRelease render release edit page
@@ -589,55 +570,39 @@ func EditRelease(ctx *context.Context) {
return
}
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)
}
// EditReleasePost response for edit release
func EditReleasePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditReleaseForm)
newReleaseCommon(ctx)
if ctx.Written() {
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
ctx.Data["PageIsEditRelease"] = true
form := web.GetForm(ctx).(*forms.EditReleaseForm)
tagName := ctx.PathParam("*")
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
if err != nil {
if repo_model.IsErrReleaseNotExist(err) {
ctx.NotFound(err)
ctx.JSONErrorNotFound(err.Error())
} else {
ctx.ServerError("GetRelease", err)
}
return
}
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
}
ctx.Data["tag_name"] = rel.TagName
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
ctx.Data["title"] = rel.Title
ctx.Data["content"] = rel.Note
ctx.Data["prerelease"] = rel.IsPrerelease
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplReleaseNew)
return
}
const delPrefix = "attachment-del-"
const editPrefix = "attachment-edit-"
var addAttachmentUUIDs, delAttachmentUUIDs []string
@@ -659,10 +624,14 @@ func EditReleasePost(ctx *context.Context) {
rel.IsPrerelease = form.Prerelease
if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo,
rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil {
ctx.ServerError("UpdateRelease", err)
if upload.IsErrFileTypeForbidden(err) {
ctx.JSONError(err.Error())
} else {
ctx.ServerError("UpdateRelease", err)
}
return
}
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
}
// DeleteRelease deletes a release

View File

@@ -4,12 +4,14 @@
package repo
import (
"net/http/httptest"
"testing"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
"gitea.dev/modules/test"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/contexttest"
@@ -39,15 +41,15 @@ func TestNewReleasePost(t *testing.T) {
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"])
})
post := func(t *testing.T, form forms.NewReleaseForm) *context.Context {
ctx, _ := contexttest.MockContext(t, "user2/repo1/releases/new")
post := func(t *testing.T, form forms.NewReleaseForm) (*context.Context, *httptest.ResponseRecorder) {
ctx, resp := contexttest.MockContext(t, "user2/repo1/releases/new")
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
web.SetForm(ctx, &form)
NewReleasePost(ctx)
return ctx
return ctx, resp
}
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) {
ctx := post(t, forms.NewReleaseForm{
_, resp := post(t, forms.NewReleaseForm{
TagName: "v1.1",
Target: "master",
Title: "updated-title",
@@ -80,11 +82,11 @@ func TestNewReleasePost(t *testing.T) {
require.NotNil(t, rel)
assert.False(t, rel.IsTag)
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) {
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
Target: "master",
Title: "updated-title",
@@ -95,12 +97,12 @@ func TestNewReleasePost(t *testing.T) {
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.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
})
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
Target: "master",
Title: "updated-title",
@@ -114,7 +116,7 @@ func TestNewReleasePost(t *testing.T) {
})
t.Run("TagOnly", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{
ctx, _ := post(t, forms.NewReleaseForm{
TagName: "new-tag-only",
Target: "master",
Title: "title",
@@ -128,7 +130,7 @@ func TestNewReleasePost(t *testing.T) {
})
t.Run("TagOnlyConflict", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{
_, resp := post(t, forms.NewReleaseForm{
TagName: "v1.1",
Target: "master",
Title: "title",
@@ -138,7 +140,7 @@ func TestNewReleasePost(t *testing.T) {
rel := loadRelease(t, "v1.1")
require.NotNil(t, rel)
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"
)
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 {
if doer != nil {
idx := slices.IndexFunc(users, func(u *user.User) bool {
return u.ID == doer.ID
})
if idx > 0 {
newUsers := make([]*user.User, len(users))
newUsers[0] = users[idx]
copy(newUsers[1:], users[:idx])
copy(newUsers[idx+1:], users[idx+1:])
return newUsers
}
if doer == nil {
return users
}
idx := slices.IndexFunc(users, func(u *user.User) bool {
return u.ID == doer.ID
})
if idx > 0 {
newUsers := make([]*user.User, len(users))
newUsers[0] = users[idx]
copy(newUsers[1:], users[:idx])
copy(newUsers[idx+1:], users[idx+1:])
return newUsers
}
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.
// 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.

View File

@@ -14,8 +14,10 @@ import (
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
commitstatus_service "gitea.dev/services/repository/commitstatus"
@@ -147,21 +149,78 @@ 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.
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))
}
// 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.
// 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.
func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *repo_model.Repository, event webhook_module.HookEventType, triggerEvent, workflowID string, content []byte, payload api.Payloader, scopedPrefix string) error {
// 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)
}
// "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
// share the same `name:` and job name produce distinct commit statuses
// even though they render identically — matching GitHub's behavior
// (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
// 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
// stuck forever (it lives in its own dedupe group). Only relevant for
// in-flight workflows at upgrade time.
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)
if err != nil {

View File

@@ -183,8 +183,9 @@ func notify(ctx context.Context, input *notifyInput) error {
}
var detectedWorkflows []*actions_module.DetectedWorkflow
var filteredWorkflows []*actions_module.DetectedWorkflow
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.Payload,
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 {
// detect pull_request_target workflows
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
@@ -219,7 +231,7 @@ func notify(ctx context.Context, input *notifyInput) error {
if err != nil {
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 {
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)
} else {
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 {
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 {
@@ -244,6 +269,8 @@ func notify(ctx context.Context, input *notifyInput) error {
return err
}
handleFilteredWorkflows(ctx, input, filteredWorkflows)
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
}
@@ -369,6 +396,16 @@ func buildApproveAndInsertRun(
return nil
}
// handleFilteredWorkflows posts a skipped commit status for each workflow that matched the event but was excluded by a branch/paths filter.
func handleFilteredWorkflows(ctx context.Context, input *notifyInput, filteredWorkflows []*actions_module.DetectedWorkflow) {
for _, dwf := range filteredWorkflows {
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, ""); 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 {
return newNotifyInput(issue.Repo, issue.Poster, event)
}
@@ -640,7 +677,7 @@ func detectAndHandleScopedWorkflows(
continue
}
sourceCommitSHA, detected, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
if err != nil {
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
continue
@@ -658,23 +695,40 @@ func detectAndHandleScopedWorkflows(
continue
}
}
// A filtered-out scoped workflow posts a skipped commit status.
if len(filtered) > 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); err != nil {
log.Error("scoped workflows: skipped commit status for source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
continue
}
}
}
}
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 post a skipped commit status.
func detectScopedWorkflowsForSource(
ctx context.Context,
input *notifyInput,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
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
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
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"
"errors"
"fmt"
"sync"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
secret_model "gitea.dev/models/secret"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"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) {
var (
task *runnerv1.Task
@@ -76,6 +107,15 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
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
}

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

@@ -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 {
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)
if pruneErr != nil {
// 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
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 {
cmd.AddArguments("--prune")
}
@@ -200,7 +203,8 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
}
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)
}

View File

@@ -13,8 +13,9 @@ import (
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/optional"
"xorm.io/builder"
)
// 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) {
var issueList issues_model.IssueList
for _, colIssues := range issuesMap {
issueList = append(issueList, colIssues...)
}
err := issueList.LoadAssignees(ctx)
func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) {
sub := builder.Select("distinct issue_assignees.assignee_id").
From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
Where(builder.Eq{"project_issue.project_id": projectID})
err := db.GetEngine(ctx).Table("`user`").Where(builder.In("id", sub)).Find(&users)
if err != nil {
return nil, err
}
users := make([]*user_model.User, 0, len(issueList))
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)
})
slices.SortFunc(users, func(a, b *user_model.User) int { return strings.Compare(a.Name, b.Name) })
return users, nil
}

View File

@@ -15,7 +15,6 @@ import (
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_Projects(t *testing.T) {
@@ -198,18 +197,4 @@ func Test_Projects(t *testing.T) {
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

@@ -20,9 +20,11 @@ import (
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/repository"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/services/context/upload"
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 {
if !deletedUUIDs.Contains(uuid) {
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
UUID: uuid,
Name: newName,
}, "name"); err != nil {
return err
}
if deletedUUIDs.Contains(uuid) {
continue
}
if err = upload.Verify(nil, newName, setting.Repository.Release.AllowedTypes); err != nil {
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"
user_model "gitea.dev/models/user"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"gitea.dev/services/attachment"
"gitea.dev/services/context/upload"
_ "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, "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
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, []string{attach.UUID}, 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"
}
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
// resulting ArchiveRequest is suitable for being passed to Await()
// 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
}
var archiverQueue *queue.WorkerPoolQueue[*ArchiveRequest]
var archiverQueue *queue.WorkerPoolQueue[*archiveQueueItem]
// Init initializes archiver
func Init(ctx context.Context) error {
handler := func(items ...*ArchiveRequest) []*ArchiveRequest {
for _, archiveReq := range items {
log.Trace("ArchiverData Process: %#v", archiveReq)
handler := func(items ...*archiveQueueItem) []*archiveQueueItem {
for _, item := range items {
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 {
log.Error("Archive %v failed: %v", archiveReq, err)
} else {
@@ -254,14 +291,15 @@ func Init(ctx context.Context) error {
// StartArchive push the archive request to the queue
func StartArchive(request *ArchiveRequest) error {
has, err := archiverQueue.Has(request)
item := request.toQueueItem()
has, err := archiverQueue.Has(item)
if err != nil {
return err
}
if has {
return nil
}
return archiverQueue.Push(request)
return archiverQueue.Push(item)
}
func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error {

View File

@@ -7,7 +7,9 @@ import (
"testing"
"time"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
"gitea.dev/services/contexttest"
@@ -21,6 +23,22 @@ func TestMain(m *testing.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) {
assert.NoError(t, unittest.PrepareTestDatabase())

View File

@@ -73,7 +73,7 @@
<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
<td>
<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}}"
>{{svg "octicon-trash"}}</a>
</td>

View File

@@ -86,7 +86,7 @@
<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
<td>
<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}}"
>{{svg "octicon-trash"}}</a>
</td>

View File

@@ -35,7 +35,7 @@
<div class="item-trailing">
{{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}}
<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-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
{{end}}

View File

@@ -13,7 +13,7 @@
<div class="flex-text-block">
{{if .Team.IsMember ctx $.SignedUser.ID}}
<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}}"
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
{{else if .IsOrganizationOwner}}

View File

@@ -37,7 +37,7 @@
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a>
{{if .IsMember ctx $.SignedUser.ID}}
<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}}"
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
{{end}}

View File

@@ -200,7 +200,7 @@
</span>
</button>
{{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"}}
</button>
{{end}}

View File

@@ -34,10 +34,10 @@
<div class="divider"></div>
{{end}}
{{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}}
{{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}}

View File

@@ -13,7 +13,7 @@
</h2>
{{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-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"

View File

@@ -3,7 +3,7 @@
<div class="ui right">
<button class="ui primary tiny button show-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-secret-name.value=""
data-modal-secret-name.read-only="false"
@@ -39,7 +39,7 @@
</span>
<button class="btn interact-bg show-modal tw-p-2"
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-tooltip-content="{{ctx.Locale.Tr "secrets.edit_secret"}}"
data-modal-secret-name.value="{{.Name}}"

View File

@@ -132,7 +132,7 @@
{{end}}
<li>
{{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}}
<a class="muted" href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a>
{{end}}

View File

@@ -3,7 +3,7 @@
<div class="ui right">
<button class="ui primary tiny button show-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-dialog-variable-name=""
data-modal-dialog-variable-data=""
@@ -39,7 +39,7 @@
<button class="btn interact-bg tw-p-2 show-modal"
data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}"
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-dialog-variable-name="{{.Name}}"
data-modal-dialog-variable-data="{{.Data}}"

View File

@@ -24,7 +24,7 @@
</div>
<div class="item-trailing">
<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"}}
</button>
</div>

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) {
source := createTestRepo(t, "sw-settings-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)

View File

@@ -215,8 +215,9 @@ jobs:
err = pull_service.NewPullRequest(t.Context(), prOpts)
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}))
assertSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
})
}
@@ -338,6 +339,9 @@ jobs:
})
assert.NoError(t, err)
assert.NotEmpty(t, addFileToBranchResp)
// the push to test-skip-ci is filtered by branches, so no run is created; a skipped commit status is posted instead
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
assertSkippedCommitStatusExists(t, repo.ID, addFileToBranchResp.Commit.SHA, "push")
resp := testPullCreate(t, session, "user2", "skip-ci", true, "master", "test-skip-ci", "[skip ci] test-skip-ci")
@@ -345,7 +349,7 @@ jobs:
url := test.RedirectURL(resp)
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}))
})
}
@@ -1879,3 +1883,16 @@ jobs:
runner.fetchNoTask(t)
})
}
// assertSkippedCommitStatusExists asserts that a filtered-out workflow posted a skipped commit status on sha
func assertSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
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
}
}
assert.Failf(t, "missing skipped commit status", "no skipped commit status with event %q on %s (found %d statuses)", eventSuffix, sha, len(statuses))
}

View File

@@ -241,3 +241,27 @@ func TestGetFileHistoryNotOnMaster(t *testing.T) {
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

@@ -171,6 +171,11 @@ func testGetAttachment(t *testing.T) {
{"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK},
{"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", 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 {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -22,6 +22,7 @@ import (
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/tests"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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) {
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,
htmlDoc.doc.Find(".ui.positive.message").Text(),
translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name),

View File

@@ -10,12 +10,14 @@ import (
"strings"
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
project "gitea.dev/services/projects"
"gitea.dev/tests"
"github.com/PuerkitoBio/goquery"
@@ -365,3 +367,28 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
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"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/translation"
@@ -39,11 +40,10 @@ func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title st
if draft {
postData["draft"] = "1"
}
req = NewRequestWithValues(t, "POST", link, postData)
resp = session.MakeRequest(t, req, http.StatusSeeOther)
test.RedirectURL(resp) // check that redirect URL exists
resp = session.MakeRequest(t, req, http.StatusOK)
assert.NotEmpty(t, test.ParseJSONRedirect(resp.Body.Bytes()))
}
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.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 {SvgIcon} from '../svg.ts';
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 {POST} from '../modules/fetch.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"
);
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';
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
lineNum, logTimeStamp, logMsg, logTimeSeconds,
@@ -391,9 +388,6 @@ function elStepsContainer(): HTMLElement {
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
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();
}
@@ -473,7 +467,15 @@ async function hashChangeListener() {
</div>
</div>
<!-- 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-summary"
@@ -681,8 +683,22 @@ async function hashChangeListener() {
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 */
.job-log-line .line-num, .log-time-seconds {
.job-log-line .line-num,
.job-log-line .log-time-seconds {
width: 48px;
color: var(--color-text-light-3);
text-align: right;
@@ -699,16 +715,16 @@ async function hashChangeListener() {
}
.job-log-line .log-time,
.log-time-stamp {
.job-log-line .log-time-stamp {
color: var(--color-text-light-3);
margin-left: 10px;
margin-left: 12px;
white-space: nowrap;
}
.job-step-logs .job-log-line .log-msg {
flex: 1;
white-space: break-spaces;
margin-left: 10px;
margin-left: 12px;
overflow-wrap: anywhere;
}
@@ -775,30 +791,28 @@ async function hashChangeListener() {
border-radius: 0;
}
.job-log-group .job-log-list .job-log-line .log-msg {
margin-left: 2em;
}
.job-log-group-summary {
cursor: pointer;
position: relative;
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 */
list-style: none; /* hide the standard disclosure marker (Chrome, Edge, Firefox) */
}
.job-log-group[open] > .job-log-group-summary {
list-style-type: disclosure-open;
.job-log-group-summary::-webkit-details-marker { /* hide the disclosure marker on Safari */
display: none;
}
.job-log-group-summary > .job-log-line {
position: absolute;
inset: 0;
z-index: -1; /* sit behind the disclosure marker */
overflow: hidden;
.log-line-group .log-msg::before {
content: "";
display: inline-block;
vertical-align: middle;
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 {
margin-left: 21px;
.job-log-group[open] .log-line-group .log-msg::before {
transform: rotate(90deg);
}
</style>

View File

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

View File

@@ -42,11 +42,20 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
}
export type ElementWithAssignableProperties = {
nodeName: string;
getAttribute: (name: string) => string | null;
setAttribute: (name: string, value: string) => void;
} & Record<string, any>;
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 old = el[camelizedName];
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
el.setAttribute(kebabName, val);
} 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}"`);
}
}
@@ -71,7 +80,11 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// * Then, try to query '[name=target]'
// * Then, try to query '.target'
// * 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();
const modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector);