mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-26 05:19:44 +09:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a62dfffbe7 | ||
|
|
035dd58664 | ||
|
|
0d9ce64f76 | ||
|
|
4a77fbce28 | ||
|
|
2b37732d5f | ||
|
|
2695b47887 | ||
|
|
9c685dedbb | ||
|
|
e9b3917042 | ||
|
|
d7bc52beea | ||
|
|
ccd38f9a70 | ||
|
|
f47e3d930d | ||
|
|
9972bee41f | ||
|
|
375e6ea038 | ||
|
|
31c435454b | ||
|
|
a54324e2b7 | ||
|
|
d141dc729c | ||
|
|
27fed5a83a | ||
|
|
6cdd02bdad |
@@ -72,6 +72,18 @@ jobs:
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
|
||||
nightly-container:
|
||||
runs-on: namespace-profile-gitea-release-docker
|
||||
|
||||
@@ -73,6 +73,18 @@ jobs:
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
- name: Install GH CLI
|
||||
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
|
||||
with:
|
||||
|
||||
@@ -76,6 +76,18 @@ jobs:
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
- name: Install GH CLI
|
||||
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
|
||||
with:
|
||||
|
||||
@@ -4,6 +4,64 @@ This changelog goes through the changes that have been made in each release
|
||||
without substantial changes to our git log; to see the highlights of what has
|
||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
|
||||
## [1.27.1](https://github.com/go-gitea/gitea/releases/tag/v1.27.1) - 2026-07-27
|
||||
|
||||
* SECURITY
|
||||
* fix(oauth2): enforce mandatory 2FA policy on OAuth2 authorize/grant endpoints (#38591) (#38606)
|
||||
|
||||
* API
|
||||
* fix(api): align Swagger schemas for UserSettings and TopicListResponse (#38590) (#38592)
|
||||
|
||||
* ENHANCEMENTS
|
||||
* enhance: improve diff contrast in light and dark themes (#37477) (#38574)
|
||||
|
||||
* BUGFIXES
|
||||
* fix: skip OIDC end-session after password login for OAuth2 users (#38439) (#38666)
|
||||
* fix: make Actions log parser support multiple line message encoding (#38659) (#38664)
|
||||
* fix(actions): use base branch ref for pull_request_target context (#38636) (#38657)
|
||||
* fix(actions): skip already-approved runs in `ApproveRuns` (#38653) (#38654)
|
||||
* fix: orgmode render include path (#38642) (#38645)
|
||||
* fix(actions): cancel tasks immediately when the runner stopped reporting (#38616) (#38644)
|
||||
* fix(issues): fix label bulk-load key and reduce log noise in LoadLabel (#38632) (#38643)
|
||||
* fix(actions): improve runner list status sorting, labels and task job links (#38586) (#38633)
|
||||
* fix(actions): correctness and hardening fixes (#38518) (#38631)
|
||||
* fix(repo): prevent double-write redirect collisions on dependency errors, fix ui (#38627) (#38628)
|
||||
* fix: delete repo-scoped rows of seven more tables when deleting a repository (#38534) (#38618)
|
||||
* fix(webhook): remove slack channel name check (#38608) (#38612)
|
||||
* fix: download dropdown menu clipped on the branches page (#38604) (#38609)
|
||||
* fix(project): prevent database mutations on invalid MoveIssues payload (#38600) (#38602)
|
||||
* fix(actions): make SingleWorkflow.Marshal round-trip multi-line run blocks (stop silent job stranding) (#38520) (#38599)
|
||||
* fix(file-tree): handle submodule links and missing view container (#38033) (#38589)
|
||||
* fix(actions): fail unexpandable reusable workflow callers and decouple the job emitter's cross-run processing (#38565) (#38587)
|
||||
* fix: keep serving valid ACME cert when renewal fails at startup (#38554) (#38583)
|
||||
* fix: branch protection user list (#38570) (#38584)
|
||||
* fix(pulls): respect diff.orderFile in diff file tree (#38566) (#38578)
|
||||
* fix(issue): make issue action (issue list batch operation) elements have correct attributes (#38575) (#38580)
|
||||
* fix(actions): support `matrix` when evaluating workflow `if` expression (#38474) (#38557)
|
||||
* fix(actions): align status icon span for Safari rendering (#38558) (#38562)
|
||||
* fix: revert git clone http redirection forbidden (#38530) (#38545)
|
||||
* fix: clean up orphaned user-keyed tables in deleteUser (#38511) (#38514)
|
||||
* fix(actions): coerce workflow_dispatch boolean inputs to native types (#38472) (#38521)
|
||||
* fix: make the merge box button red if some checks fail (#38508) (#38516)
|
||||
* fix(pull): sign the commit when updating a branch by merge (#38441) (#38499)
|
||||
* fix: make commit message merge correctly (#38490) (#38502)
|
||||
* fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
|
||||
* fix(actions): make `cancelled()` work in job `if` evaluation (#38495) (#38497)
|
||||
* fix(actions): show retention info on hover for expired artifacts (#38477) (#38493)
|
||||
* fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475) (#38492)
|
||||
* fix: full file highlighting for git diff with CR char (#38484) (#38491)
|
||||
* fix(packages): serve noarch Alpine index for any requested architecture (#38479) (#38486)
|
||||
* fix: 500 error when updating user visibility (#38480) (#38483)
|
||||
* fix(actions): make job list item fully clickable (#38462) (#38471)
|
||||
* fix: mail template for push event (#38467) (#38468)
|
||||
* fix: make "test push webhook" always work (#38425) (#38455)
|
||||
* fix(actions): prevent bulk actions from affecting all runners (#38453) (#38457)
|
||||
* fix(org): align follow button and wrap description (#38448) (#38454)
|
||||
* fix(actions): populate `github.event` for scheduled runs (#38446) (#38452)
|
||||
|
||||
* MISC
|
||||
* refactor: git patch apply (#38637) (#38638)
|
||||
|
||||
## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
|
||||
|
||||
* BREAKING
|
||||
|
||||
@@ -62,14 +62,16 @@ func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error)
|
||||
attempt.Run = run
|
||||
}
|
||||
|
||||
if attempt.TriggerUser == nil {
|
||||
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return attempt.LoadTriggerUser(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
// LoadTriggerUser loads the attempt's trigger user if not already loaded.
|
||||
func (attempt *ActionRunAttempt) LoadTriggerUser(ctx context.Context) (err error) {
|
||||
if attempt.TriggerUser != nil {
|
||||
return nil
|
||||
}
|
||||
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) {
|
||||
|
||||
@@ -159,3 +159,12 @@ func (opts FindRunJobOptions) ToOrders() string {
|
||||
}
|
||||
|
||||
var _ db.FindOptionsOrder = FindRunJobOptions{}
|
||||
|
||||
// CountRunJobsByRunAndAttemptID counts the jobs belonging to the given run attempt.
|
||||
// It is used to enforce MaxJobNumPerRun when reusable-workflow expansion inserts new jobs.
|
||||
func CountRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (int64, error) {
|
||||
return db.Count[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(runAttemptID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -270,15 +270,31 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
|
||||
return cond
|
||||
}
|
||||
|
||||
// runnerStatusOrderExpr builds an ORDER BY fragment that ranks runners by their
|
||||
// computed status (see ActionRunner.Status): active (0), idle (1), offline (2).
|
||||
// The thresholds are evaluated against the current time, mirroring ToConds, so
|
||||
// sorting by status groups active and idle runners instead of interleaving them
|
||||
// by raw last_online.
|
||||
func runnerStatusOrderExpr() string {
|
||||
now := time.Now()
|
||||
offlineThreshold := now.Add(-RunnerOfflineTime).Unix()
|
||||
idleThreshold := now.Add(-RunnerIdleTime).Unix()
|
||||
return fmt.Sprintf("CASE WHEN last_online <= %d THEN 2 WHEN last_active <= %d THEN 1 ELSE 0 END", offlineThreshold, idleThreshold)
|
||||
}
|
||||
|
||||
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.
|
||||
// status, last_online or name keep a deterministic order across paginated
|
||||
// queries, otherwise the same runner may appear on more than one page.
|
||||
statusRank := runnerStatusOrderExpr()
|
||||
switch opts.Sort {
|
||||
case "online":
|
||||
return "last_online DESC, id ASC"
|
||||
// Rank by computed status first so idle runners are not interleaved with
|
||||
// active ones; disabled runners sink to the bottom of their status group
|
||||
// (is_disabled ASC), then last_online breaks ties within a group.
|
||||
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
|
||||
case "offline":
|
||||
return "last_online ASC, id ASC"
|
||||
return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
|
||||
case "alphabetically":
|
||||
return "name ASC, id ASC"
|
||||
case "reversealphabetically":
|
||||
@@ -288,7 +304,7 @@ func (opts FindRunnerOptions) ToOrders() string {
|
||||
case "oldest":
|
||||
return "id ASC"
|
||||
}
|
||||
return "last_online DESC, id ASC"
|
||||
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
|
||||
}
|
||||
|
||||
// GetRunnerByUUID returns a runner via uuid
|
||||
|
||||
@@ -4,16 +4,12 @@
|
||||
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) {
|
||||
@@ -85,65 +81,3 @@ func TestShouldPersistLastActive(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -60,6 +60,12 @@ type ActionTask struct {
|
||||
Updated timeutil.TimeStamp `xorm:"updated index"`
|
||||
}
|
||||
|
||||
// taskReportTimeout is how long a task may go without contact from its runner before the
|
||||
// runner is assumed gone. Runners report state and stream logs every few seconds, both of
|
||||
// which refresh ActionTask.Updated. Shorter than setting.Actions.ZombieTaskTimeout because
|
||||
// it only decides whether the runner is reachable, not whether the task should be killed.
|
||||
const taskReportTimeout = time.Minute
|
||||
|
||||
var successfulTokenTaskCache *lru.Cache[string, any]
|
||||
|
||||
func init() {
|
||||
@@ -85,11 +91,15 @@ func (task *ActionTask) IsStopped() bool {
|
||||
return task.Stopped > 0
|
||||
}
|
||||
|
||||
func (task *ActionTask) GetRunLink() string {
|
||||
if task.Job == nil || task.Job.Run == nil {
|
||||
func (task *ActionTask) GetRunJobLink() string {
|
||||
// Run.Repo can be nil when the repository was deleted while task/run rows remain
|
||||
// (TaskList.LoadAttributes copies job.Repo into run.Repo, leaving it nil on a miss).
|
||||
// Run.Link() already returns "" in that case, so guard here to avoid emitting a
|
||||
// broken relative "/jobs/N" link from the Sprintf below.
|
||||
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
|
||||
return ""
|
||||
}
|
||||
return task.Job.Run.Link()
|
||||
return fmt.Sprintf("%s/jobs/%d", task.Job.Run.Link(), task.Job.ID)
|
||||
}
|
||||
|
||||
func (task *ActionTask) GetCommitLink() string {
|
||||
@@ -563,6 +573,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
|
||||
status = StatusCancelled
|
||||
} else if !runner.HasCancellingSupport {
|
||||
status = StatusCancelled
|
||||
} else if task.Updated.AddDuration(taskReportTimeout) < now {
|
||||
// A runner that stopped reporting will never acknowledge the cancellation either,
|
||||
// so skip the handshake instead of waiting for the zombie task cleanup.
|
||||
status = StatusCancelled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +591,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return UpdateTask(ctx, task, "status")
|
||||
// NoAutoTime keeps "updated" at the runner's last contact: re-cancelling an already
|
||||
// cancelling task must not defer the timeout above or the zombie task cleanup.
|
||||
_, err := e.ID(task.ID).Cols("status").NoAutoTime().Update(task)
|
||||
return err
|
||||
}
|
||||
|
||||
task.Status = status
|
||||
|
||||
+144
-171
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -18,6 +19,21 @@ import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func TestActionTask_GetRunJobLink(t *testing.T) {
|
||||
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
|
||||
run := &ActionRun{ID: 10, Repo: repo}
|
||||
job := &ActionRunJob{ID: 42, Run: run}
|
||||
|
||||
// a task with a loaded job links to that specific job, not just the run
|
||||
task := &ActionTask{Job: job}
|
||||
assert.Equal(t, run.Link()+"/jobs/42", task.GetRunJobLink())
|
||||
|
||||
// missing job, run or repo yields an empty link instead of a broken URL
|
||||
assert.Empty(t, (&ActionTask{}).GetRunJobLink())
|
||||
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42}}).GetRunJobLink())
|
||||
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink())
|
||||
}
|
||||
|
||||
func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -83,68 +99,11 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
|
||||
newRunningTask := func(t *testing.T) (*ActionTask, *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "cancelling-finalization-job",
|
||||
Attempt: 1,
|
||||
JobID: "cancelling-finalization-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-cancelling-supported",
|
||||
Name: "runner-cancelling-supported",
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
return task, job
|
||||
}
|
||||
|
||||
testResult := func(t *testing.T, result runnerv1.Result) {
|
||||
t.Helper()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
task, job := newRunningTask(t)
|
||||
task, job := newRunningTaskForCancelling(t, "cancelling-finalization-job", true)
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
@@ -174,137 +133,92 @@ func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopTaskCancellingFallsBackForLegacyRunner(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// TestStopTaskCancellingFallsBackToCancelled covers the cases where the cancelling handshake can
|
||||
// never complete, so StopTask must cancel right away instead of waiting for the zombie task cleanup.
|
||||
func TestStopTaskCancellingFallsBackToCancelled(t *testing.T) {
|
||||
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "legacy-cancelling-job",
|
||||
Attempt: 1,
|
||||
JobID: "legacy-cancelling-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
// A runner too old to know the cancelling state can only be stopped by a final status.
|
||||
t.Run("legacy runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "legacy-cancelling-job", false)
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-legacy-no-cancelling",
|
||||
Name: "runner-legacy-no-cancelling",
|
||||
HasCancellingSupport: false,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
// The runner is gone, e.g. an ephemeral runner was cleaned up, so nobody can acknowledge.
|
||||
t.Run("missing runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "missing-runner-cancelling-job", true)
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
_, err := db.DeleteByID[ActionRunner](t.Context(), task.RunnerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
// The runner went silent, e.g. it gave up while Gitea was restarting, so it never picks up the request.
|
||||
t.Run("silent runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "silent-runner-cancelling-job", true)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
// NoAutoTime because the point of the test is an "updated" older than xorm would write
|
||||
task.Updated = timeutil.TimeStampNow().AddDuration(-2 * taskReportTimeout)
|
||||
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
|
||||
// A runner coming back still learns the outcome from the UpdateTask response,
|
||||
// and its late result does not overwrite the cancellation.
|
||||
late, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{
|
||||
Id: task.ID,
|
||||
Result: runnerv1.Result_RESULT_SUCCESS,
|
||||
StoppedAt: timestamppb.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusCancelled, late.Status)
|
||||
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, late.Status.AsResult())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopTaskCancellingFallsBackForMissingRunner(t *testing.T) {
|
||||
// TestStopTaskCancellingKeepsReportTime makes sure persisting the cancelling status does not refresh
|
||||
// "updated": re-cancelling would otherwise defer both the fallback to cancelled and the zombie task
|
||||
// cleanup, no matter how long the runner has been silent.
|
||||
func TestStopTaskCancellingKeepsReportTime(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, _ := newRunningTaskForCancelling(t, "repeated-cancelling-job", true)
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "missing-runner-cancelling-job",
|
||||
Attempt: 1,
|
||||
JobID: "missing-runner-cancelling-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-cleaned-up-before-cancel",
|
||||
Name: "runner-cleaned-up-before-cancel",
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.DeleteByID[ActionRunner](t.Context(), runner.ID)
|
||||
// NoAutoTime because the point of the test is an "updated" older than xorm would write
|
||||
lastReport := timeutil.TimeStampNow().AddDuration(-taskReportTimeout / 2)
|
||||
task.Updated = lastReport
|
||||
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
// the runner reported recently enough, so the task waits for it to acknowledge
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
assert.Equal(t, StatusCancelling, taskAfterStop.Status)
|
||||
assert.Equal(t, lastReport, taskAfterStop.Updated)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
// cancelling it again does not reset the clock either
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
taskAfterSecondStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelling, taskAfterSecondStop.Status)
|
||||
assert.Equal(t, lastReport, taskAfterSecondStop.Updated)
|
||||
}
|
||||
|
||||
// TestReleaseTaskForRunner verifies that releasing a freshly-claimed task returns
|
||||
@@ -442,3 +356,62 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
assert.Equal(t, StatusRunning, claimed.Status)
|
||||
assert.Equal(t, task.ID, claimed.TaskID)
|
||||
}
|
||||
|
||||
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
|
||||
// which is the state every cancellation test starts from.
|
||||
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: name,
|
||||
Attempt: 1,
|
||||
JobID: name,
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: name,
|
||||
Name: name,
|
||||
HasCancellingSupport: hasCancellingSupport,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
return task, job
|
||||
}
|
||||
|
||||
@@ -544,6 +544,9 @@ func (c *Comment) GetSanitizedContentHTML() template.HTML {
|
||||
|
||||
// LoadLabel if comment.Type is CommentTypeLabel, then load Label
|
||||
func (c *Comment) LoadLabel(ctx context.Context) error {
|
||||
if c.LabelID == 0 {
|
||||
return nil
|
||||
}
|
||||
var label Label
|
||||
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
|
||||
if err != nil {
|
||||
@@ -551,8 +554,8 @@ func (c *Comment) LoadLabel(ctx context.Context) error {
|
||||
} else if has {
|
||||
c.Label = &label
|
||||
} else {
|
||||
// Ignore Label is deleted, but not clear this table
|
||||
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
|
||||
// label was deleted but comment rows referencing it were not cleaned up
|
||||
log.Debug("Comment %d references deleted label %d", c.ID, c.LabelID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -81,7 +81,7 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
comment.Label = commentLabels[comment.ID]
|
||||
comment.Label = commentLabels[comment.LabelID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
|
||||
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
|
||||
htmlWriter.ExtendingWriter = w
|
||||
|
||||
res, err := org.New().Silent().Parse(input, "").Write(w)
|
||||
cfg := org.New()
|
||||
cfg.ReadFile = func(path string) ([]byte, error) {
|
||||
// actually the orgmode render doesn't support rendering the content from the content again,
|
||||
// so just leave the plain text to end users
|
||||
content := fmt.Sprintf("#+INCLUDE: [[%s]]", path)
|
||||
return []byte(content), nil
|
||||
}
|
||||
doc := cfg.Silent().Parse(input, "")
|
||||
res, err := doc.Write(w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orgmode.Render failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
func testRender(t *testing.T, input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[https://google.com/]]",
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
testRender(t, "[[https://google.com/]]",
|
||||
`<p><a href="https://google.com/">https://google.com/</a></p>`)
|
||||
test("[[ImageLink.svg][The Image Desc]]",
|
||||
testRender(t, "[[ImageLink.svg][The Image Desc]]",
|
||||
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_InternalLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:test.org][Test]]",
|
||||
testRender(t, "[[file:test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[./test.org][Test]]",
|
||||
testRender(t, "[[./test.org][Test]]",
|
||||
`<p><a href="./test.org">Test</a></p>`)
|
||||
test("[[test.org][Test]]",
|
||||
testRender(t, "[[test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[path/to/test.org][Test]]",
|
||||
testRender(t, "[[path/to/test.org][Test]]",
|
||||
`<p><a href="path/to/test.org">Test</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Media(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:../../.images/src/02/train.jpg]]",
|
||||
testRender(t, "[[file:../../.images/src/02/train.jpg]]",
|
||||
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
|
||||
test("[[file:train.jpg]]",
|
||||
testRender(t, "[[file:train.jpg]]",
|
||||
`<p><img src="train.jpg" alt="train.jpg"></p>`)
|
||||
|
||||
// With description.
|
||||
test("[[https://example.com][https://example.com/example.svg]]",
|
||||
testRender(t, "[[https://example.com][https://example.com/example.svg]]",
|
||||
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
testRender(t, "[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
|
||||
test("[[https://example.com][https://example.com/example.mp4]]",
|
||||
testRender(t, "[[https://example.com][https://example.com/example.mp4]]",
|
||||
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
testRender(t, "[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
|
||||
|
||||
// Without description.
|
||||
test("[[https://example.com/example.svg]]",
|
||||
testRender(t, "[[https://example.com/example.svg]]",
|
||||
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
|
||||
test("[[https://example.com/example.mp4]]",
|
||||
testRender(t, "[[https://example.com/example.mp4]]",
|
||||
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
|
||||
|
||||
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
|
||||
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
testRender(t, `[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
|
||||
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
testRender(t, `[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
|
||||
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
testRender(t, `[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
testRender(t, `[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Source(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test(`#+begin_src c
|
||||
testRender(t, `#+begin_src c
|
||||
int a;
|
||||
#+end_src
|
||||
`, `<div class="src src-c">
|
||||
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
|
||||
</div>`)
|
||||
}
|
||||
|
||||
func TestRender_IncludeLink(t *testing.T) {
|
||||
testRender(t, `#+INCLUDE: "./other.org" src text`, `<div class="src src-text">
|
||||
<pre><code class="chroma language-plaintext">#+INCLUDE: [[other.org]]</code></pre>
|
||||
</div>`)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,10 @@ const (
|
||||
KeyUname = "uname"
|
||||
|
||||
KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth"
|
||||
|
||||
// KeySignInMethod records how the current session was authenticated so logout
|
||||
// can decide whether RP-initiated OIDC logout is appropriate.
|
||||
KeySignInMethod = "signInMethod"
|
||||
|
||||
SignInMethodOAuth2 = "oauth2"
|
||||
)
|
||||
|
||||
@@ -3742,7 +3742,7 @@
|
||||
"actions.runners.runner_title": "Runner",
|
||||
"actions.runners.task_list": "Recent tasks on this runner",
|
||||
"actions.runners.task_list.no_tasks": "There is no task yet.",
|
||||
"actions.runners.task_list.run": "Run",
|
||||
"actions.runners.task_list.job": "Job",
|
||||
"actions.runners.task_list.status": "Status",
|
||||
"actions.runners.task_list.repository": "Repository",
|
||||
"actions.runners.task_list.commit": "Commit",
|
||||
@@ -3807,6 +3807,7 @@
|
||||
"actions.runs.cancel": "Cancel workflow run",
|
||||
"actions.runs.delete.description": "Are you sure you want to permanently delete this workflow run? This action cannot be undone.",
|
||||
"actions.runs.not_done": "This workflow run is not done.",
|
||||
"actions.runs.no_failed_jobs": "This workflow run has no failed jobs to re-run.",
|
||||
"actions.runs.view_workflow_file": "View workflow file",
|
||||
"actions.runs.summary": "Summary",
|
||||
"actions.runs.all_jobs": "All jobs",
|
||||
|
||||
@@ -1503,7 +1503,14 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil {
|
||||
failedJobs := actions_service.GetFailedJobsForRerun(jobs)
|
||||
// Empty failedJobs means no failed jobs to re-run
|
||||
if len(failedJobs) == 0 {
|
||||
ctx.APIError(http.StatusBadRequest, "this workflow run has no failed jobs to re-run")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, failedJobs); err != nil {
|
||||
handleWorkflowRerunError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -484,20 +484,26 @@ func SignOut(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func buildSignOutRedirectURL(ctx *context.Context) string {
|
||||
if ctx.Doer != nil && ctx.Doer.LoginType == auth.OAuth2 {
|
||||
if ctx.Doer != nil && shouldRedirectToOIDCEndSession(ctx) {
|
||||
if s := buildOIDCEndSessionURL(ctx, ctx.Doer); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// The assumption is: if reverse proxy auth is enabled, then the users should only sign-in via reverse proxy auth.
|
||||
// TODO: in the future, if we need to distinguish different sign-in methods, we need to save the sign-in method in session and check here
|
||||
if setting.Service.EnableReverseProxyAuth && setting.ReverseProxyLogoutRedirect != "" {
|
||||
return setting.ReverseProxyLogoutRedirect
|
||||
}
|
||||
return setting.AppSubURL + "/"
|
||||
}
|
||||
|
||||
// shouldRedirectToOIDCEndSession reports whether this session should end at the
|
||||
// OIDC provider. Prefer the session sign-in method so an OAuth2-linked account
|
||||
// that signed in with a password does not hit end_session_endpoint.
|
||||
func shouldRedirectToOIDCEndSession(ctx *context.Context) bool {
|
||||
return ctx.Session.Get(session.KeySignInMethod) == session.SignInMethodOAuth2
|
||||
}
|
||||
|
||||
func prepareSignUpPageData(ctx *context.Context) bool {
|
||||
ctx.Data["Title"] = ctx.Tr("sign_up")
|
||||
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
|
||||
|
||||
@@ -154,16 +154,31 @@ func TestWebAuthOAuth2(t *testing.T) {
|
||||
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "oidc-auth-source")
|
||||
require.NoError(t, err)
|
||||
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
|
||||
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
|
||||
ctx.Doer = &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
|
||||
SignOut(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
u, err := url.Parse(test.RedirectURL(resp))
|
||||
require.NoError(t, err)
|
||||
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}}
|
||||
assert.Equal(t, expectedValues, u.Query())
|
||||
u.RawQuery = ""
|
||||
assert.Equal(t, "https://example.com/oidc-logout", u.String())
|
||||
oauthUser := &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
|
||||
|
||||
t.Run("OAuth2SignInRedirectsToOIDC", func(t *testing.T) {
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-oauth")}
|
||||
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
|
||||
ctx.Doer = oauthUser
|
||||
require.NoError(t, ctx.Session.Set(session.KeySignInMethod, session.SignInMethodOAuth2))
|
||||
SignOut(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
u, err := url.Parse(test.RedirectURL(resp))
|
||||
require.NoError(t, err)
|
||||
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}}
|
||||
assert.Equal(t, expectedValues, u.Query())
|
||||
u.RawQuery = ""
|
||||
assert.Equal(t, "https://example.com/oidc-logout", u.String())
|
||||
})
|
||||
|
||||
t.Run("PasswordSignInSkipsOIDC", func(t *testing.T) {
|
||||
// OAuth2-linked account signed in via password form must not hit end_session_endpoint.
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-password")}
|
||||
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
|
||||
ctx.Doer = oauthUser
|
||||
SignOut(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
assert.Equal(t, "/", test.RedirectURL(resp))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -171,9 +172,10 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
|
||||
|
||||
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,
|
||||
"linkAccount": true,
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": remember,
|
||||
"linkAccount": true,
|
||||
session.KeySignInMethod: session.SignInMethodOAuth2,
|
||||
}); err != nil {
|
||||
ctx.ServerError("RegenerateSession", err)
|
||||
return
|
||||
|
||||
@@ -432,6 +432,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
session.KeySignInMethod: session.SignInMethodOAuth2,
|
||||
}); err != nil {
|
||||
ctx.ServerError("updateSession", err)
|
||||
return
|
||||
@@ -455,8 +456,9 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
|
||||
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,
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": false,
|
||||
session.KeySignInMethod: session.SignInMethodOAuth2,
|
||||
}); err != nil {
|
||||
ctx.ServerError("updateSession", err)
|
||||
return
|
||||
|
||||
@@ -567,9 +567,6 @@ func ViewPost(ctx *context_module.Context) {
|
||||
}
|
||||
|
||||
func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, jobs []*actions_model.ActionRunJob) {
|
||||
// Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest.
|
||||
isLatestAttempt := run.LatestAttemptID == 0 || (attempt != nil && attempt.ID == run.LatestAttemptID)
|
||||
|
||||
resp.State.Run.RepoID = ctx.Repo.Repository.ID
|
||||
resp.State.Run.Index = run.Index
|
||||
// the title for the "run" is from the commit message
|
||||
@@ -578,8 +575,12 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
resp.State.Run.Link = run.Link()
|
||||
resp.State.Run.ViewLink = getRunViewLink(run, attempt)
|
||||
resp.State.Run.Attempts = make([]*ViewRunAttempt, 0)
|
||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all
|
||||
// share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only.
|
||||
var runAttemptID int64
|
||||
var effectiveStatus actions_model.Status
|
||||
if attempt != nil {
|
||||
runAttemptID = attempt.ID
|
||||
effectiveStatus = attempt.Status
|
||||
resp.State.Run.RunAttempt = attempt.Attempt
|
||||
resp.State.Run.Duration = attempt.Duration().String()
|
||||
@@ -589,6 +590,9 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
resp.State.Run.Duration = run.Duration().String()
|
||||
resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
|
||||
}
|
||||
// Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest.
|
||||
isLatestAttempt := run.LatestAttemptID == 0 || runAttemptID == run.LatestAttemptID
|
||||
|
||||
resp.State.Run.Status = effectiveStatus.String()
|
||||
resp.State.Run.Done = effectiveStatus.IsDone()
|
||||
|
||||
@@ -648,7 +652,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
Status: runAttempt.Status.String(),
|
||||
Done: runAttempt.Status.IsDone(),
|
||||
Link: getRunViewLink(run, runAttempt),
|
||||
Current: runAttempt.ID == attempt.ID,
|
||||
Current: runAttempt.ID == runAttemptID,
|
||||
Latest: runAttempt.ID == run.LatestAttemptID,
|
||||
TriggeredAt: runAttempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: runAttempt.TriggerUser.GetDisplayName(),
|
||||
@@ -673,13 +677,6 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
resp.State.Run.PullRequest = refInfo.PullRequest
|
||||
resp.State.Run.TriggerEvent = run.TriggerEvent
|
||||
|
||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all
|
||||
// share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only.
|
||||
var runAttemptID int64
|
||||
if attempt != nil {
|
||||
runAttemptID = attempt.ID
|
||||
}
|
||||
|
||||
// Each step's markdown is rendered independently so an unclosed construct
|
||||
// in one step can't bleed into the next.
|
||||
// On a single-job view only that job's summaries are needed; the run view shows all.
|
||||
@@ -1007,7 +1004,15 @@ func RerunFailed(ctx *context_module.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil {
|
||||
// An empty job list means "re-run the whole run" to RerunWorkflowRunJobs, which is right for the plain
|
||||
// rerun button but wrong here, so a direct POST on a fully successful run cannot re-run everything.
|
||||
failedJobs := actions_service.GetFailedJobsForRerun(jobs)
|
||||
if len(failedJobs) == 0 {
|
||||
ctx.JSONError(ctx.Locale.Tr("actions.runs.no_failed_jobs"))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, failedJobs); err != nil {
|
||||
handleWorkflowRerunError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ func AddDependency(ctx *context.Context) {
|
||||
}
|
||||
|
||||
// Redirect
|
||||
defer ctx.Redirect(issue.Link())
|
||||
defer func() {
|
||||
if !ctx.Written() {
|
||||
ctx.Redirect(issue.Link())
|
||||
}
|
||||
}()
|
||||
|
||||
// Dependency
|
||||
dep, err := issues_model.GetIssueByID(ctx, depID)
|
||||
@@ -152,6 +156,7 @@ func RemoveDependency(ctx *context.Context) {
|
||||
if err = issues_model.RemoveIssueDependency(ctx, ctx.Doer, issue, dep, depType); err != nil {
|
||||
if issues_model.IsErrDependencyNotExists(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist"))
|
||||
ctx.Redirect(issue.Link())
|
||||
return
|
||||
}
|
||||
ctx.ServerError("RemoveIssueDependency", err)
|
||||
|
||||
@@ -28,6 +28,9 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !run.NeedApproval {
|
||||
continue
|
||||
}
|
||||
run.NeedApproval = false
|
||||
run.ApprovedBy = doer.ID
|
||||
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApproveRuns(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
insertRun := func(index int64, status actions_model.Status, needApproval bool, approvedBy int64) *actions_model.ActionRun {
|
||||
run := &actions_model.ActionRun{
|
||||
Title: "approve-run", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index,
|
||||
TriggerUserID: doer.ID, Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
|
||||
Status: status, NeedApproval: needApproval, ApprovedBy: approvedBy,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
return run
|
||||
}
|
||||
insertJob := func(run *actions_model.ActionRun, status actions_model.Status) *actions_model.ActionRunJob {
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
|
||||
Name: "job1", Attempt: 1, JobID: "job1", Status: status,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
return job
|
||||
}
|
||||
|
||||
t.Run("approve blocked run", func(t *testing.T) {
|
||||
run := insertRun(1001, actions_model.StatusBlocked, true, 0)
|
||||
job := insertJob(run, actions_model.StatusBlocked)
|
||||
|
||||
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
|
||||
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.False(t, run.NeedApproval)
|
||||
assert.Equal(t, doer.ID, run.ApprovedBy)
|
||||
assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
|
||||
})
|
||||
|
||||
t.Run("re-approving an approved run is a no-op", func(t *testing.T) {
|
||||
run := insertRun(1002, actions_model.StatusRunning, false, 4)
|
||||
job := insertJob(run, actions_model.StatusRunning)
|
||||
|
||||
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
|
||||
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.EqualValues(t, 4, run.ApprovedBy, "approver must not be overwritten")
|
||||
assert.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status, "started job must not be reset to waiting")
|
||||
})
|
||||
}
|
||||
@@ -26,11 +26,13 @@ func StopZombieTasks(ctx context.Context) error {
|
||||
}, actions_model.StatusRunning, actions_model.StatusCancelling)
|
||||
}
|
||||
|
||||
// StopEndlessTasks stops tasks in running/cancelling status with continuous updates that don't end for a long time
|
||||
// StopEndlessTasks stops running tasks with continuous updates that don't end for a long time.
|
||||
// StatusRunning only: the threshold is the task's *start* time, so including StatusCancelling would kill a
|
||||
// task mid post-cancel cleanup. StopZombieTasks covers a stalled one, keying off the last update instead.
|
||||
func StopEndlessTasks(ctx context.Context) error {
|
||||
return stopTasksByStatuses(ctx, actions_model.FindTaskOptions{
|
||||
StartedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.EndlessTaskTimeout).Unix()),
|
||||
}, actions_model.StatusRunning, actions_model.StatusCancelling)
|
||||
}, actions_model.StatusRunning)
|
||||
}
|
||||
|
||||
func stopTasksByStatuses(ctx context.Context, opts actions_model.FindTaskOptions, statuses ...actions_model.Status) error {
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -88,3 +93,51 @@ func TestShouldBlockRunByConcurrency_CancellingJobBlocks(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, shouldBlock)
|
||||
}
|
||||
|
||||
// TestStopEndlessTasksSkipsCancelling verifies that a task running its post-cancel cleanup is not
|
||||
// force-stopped by the endless-task sweep just because the job started long ago.
|
||||
func TestStopEndlessTasksSkipsCancelling(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// StopEndlessTasks emits ready jobs onto the emitter queue, mock it
|
||||
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(runID int64) error { return nil })()
|
||||
|
||||
// well past the endless-task threshold, keyed on the task's start time
|
||||
longAgo := timeutil.TimeStamp(time.Now().Add(-2 * setting.Actions.EndlessTaskTimeout).Unix())
|
||||
|
||||
var seq int64
|
||||
newTaskWithJob := func(status actions_model.Status) *actions_model.ActionTask {
|
||||
seq++
|
||||
run := &actions_model.ActionRun{
|
||||
RepoID: 1, OwnerID: 2, TriggerUserID: 2, WorkflowID: "test.yml",
|
||||
Index: 99500 + seq, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
attempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), attempt))
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: run.RepoID, OwnerID: run.OwnerID,
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Name: "j", JobID: "j", Status: status,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: job.ID, RepoID: run.RepoID, OwnerID: run.OwnerID,
|
||||
CommitSHA: job.CommitSHA, Status: status, Started: longAgo,
|
||||
TokenHash: fmt.Sprintf("endless-test-token-%d", seq), TokenSalt: "salt",
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
return task
|
||||
}
|
||||
|
||||
running := newTaskWithJob(actions_model.StatusRunning)
|
||||
cancelling := newTaskWithJob(actions_model.StatusCancelling)
|
||||
|
||||
require.NoError(t, StopEndlessTasks(t.Context()))
|
||||
|
||||
runningAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: running.ID})
|
||||
cancellingAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: cancelling.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, runningAfter.Status, "long-running task should be force-stopped")
|
||||
assert.Equal(t, actions_model.StatusCancelling, cancellingAfter.Status, "cancelling task should keep running its cleanup")
|
||||
}
|
||||
|
||||
@@ -265,7 +265,10 @@ func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository
|
||||
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
||||
}
|
||||
for _, v := range statuses {
|
||||
if v.ContextHash == legacyHash && v.Context == ctxName {
|
||||
// Only adopt the legacy (Context-only) hash from a row the Actions user itself created before the
|
||||
// #35699 fix, i.e. a pre-upgrade in-flight run. Adopting it from an external integration or the API
|
||||
// would collapse two same-named workflows back into a single check.
|
||||
if v.ContextHash == legacyHash && v.Context == ctxName && v.CreatorID == user_model.ActionsUserID {
|
||||
ctxHash = legacyHash
|
||||
break
|
||||
}
|
||||
|
||||
@@ -218,7 +218,8 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
|
||||
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
||||
sha, err := git.NewIDFromString(branch.CommitID)
|
||||
require.NoError(t, err)
|
||||
creator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
// Pre-#35699 in-flight rows were posted by the Actions user with the Context-only hash.
|
||||
creator := user_model.NewActionsUser()
|
||||
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
|
||||
Repo: repo,
|
||||
Creator: creator,
|
||||
@@ -259,6 +260,65 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
|
||||
assert.Equal(t, 1, matches)
|
||||
}
|
||||
|
||||
// TestCreateCommitStatus_LegacyHashExternalNotAdopted: a status from a non-Actions creator sharing a
|
||||
// workflow's Context must not pull the workflow into the legacy Context-only hash group.
|
||||
func TestCreateCommitStatus_LegacyHashExternalNotAdopted(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
|
||||
|
||||
workflowID := "external.yaml"
|
||||
ctxName := "external.yaml / my-job (push)"
|
||||
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
||||
distinctHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
|
||||
sha, err := git.NewIDFromString(branch.CommitID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// An external status (posted by a real user, not the Actions user) sharing the same Context.
|
||||
externalCreator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
|
||||
Repo: repo,
|
||||
Creator: externalCreator,
|
||||
SHA: sha,
|
||||
CommitStatus: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
Context: ctxName,
|
||||
ContextHash: legacyHash,
|
||||
TargetURL: "https://example.invalid/external",
|
||||
Description: "external check",
|
||||
},
|
||||
}))
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
ID: 99311, Index: 99311, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
|
||||
WorkflowID: workflowID, CommitSHA: branch.CommitID,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
job := &actions_model.ActionRunJob{
|
||||
ID: 99312, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
|
||||
Name: "my-job", Status: actions_model.StatusSuccess,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
|
||||
|
||||
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
// The external status and the workflow status must coexist under distinct hashes.
|
||||
var external, workflow *git_model.CommitStatus
|
||||
for _, s := range latest {
|
||||
switch s.ContextHash {
|
||||
case legacyHash:
|
||||
external = s
|
||||
case distinctHash:
|
||||
workflow = s
|
||||
}
|
||||
}
|
||||
require.NotNil(t, external, "external status must be preserved under the legacy hash")
|
||||
require.NotNil(t, workflow, "workflow status must use its own distinct hash, not the external legacy hash")
|
||||
assert.Equal(t, "https://example.invalid/external", external.TargetURL)
|
||||
}
|
||||
|
||||
// TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no
|
||||
// non-blank `name:` uses the file name in the Context, not an empty
|
||||
// "/ job (event)" — covers both an omitted and a whitespace-only name.
|
||||
|
||||
@@ -49,7 +49,7 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
|
||||
// In GitHub's documentation, ref should be the branch or tag that triggered workflow. But when the TriggerEvent is pull_request_target,
|
||||
// the ref will be the base branch.
|
||||
if run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
|
||||
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name
|
||||
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Ref
|
||||
sha = pullPayload.PullRequest.Base.Sha
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,12 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
|
||||
|
||||
if attempt != nil {
|
||||
gitContext["run_attempt"] = strconv.FormatInt(attempt.Attempt, 10)
|
||||
if err := attempt.LoadAttributes(ctx); err == nil {
|
||||
// Only the trigger user is needed for triggering_actor. attempt.LoadAttributes would also re-load
|
||||
// the run this function already holds as a parameter, on the hot task-dispatch path.
|
||||
if err := attempt.LoadTriggerUser(ctx); err != nil {
|
||||
log.Error("GenerateGiteaContext: load trigger user of attempt %d: %v", attempt.ID, err)
|
||||
}
|
||||
if attempt.TriggerUser != nil {
|
||||
gitContext["triggering_actor"] = attempt.TriggerUser.Name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@ import (
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
act_model "gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -318,3 +322,71 @@ func TestFindTaskNeeds(t *testing.T) {
|
||||
assert.Equal(t, "abc", ret["job1"].Outputs["output_a"])
|
||||
assert.Equal(t, "bbb", ret["job1"].Outputs["output_b"])
|
||||
}
|
||||
|
||||
func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
|
||||
payload := api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{
|
||||
Base: &api.PRBranchInfo{
|
||||
Name: "owner:main",
|
||||
Ref: "main",
|
||||
Sha: "1234567890abcdef",
|
||||
},
|
||||
Head: &api.PRBranchInfo{
|
||||
Name: "fork:feature",
|
||||
Ref: "feature",
|
||||
Sha: "fedcba0987654321",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
assert.NoError(t, err)
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
Event: webhook_module.HookEventPullRequest,
|
||||
TriggerEvent: string(actions_module.GithubEventPullRequestTarget),
|
||||
EventPayload: string(payloadBytes),
|
||||
TriggerUser: &user_model.User{Name: "test-user"},
|
||||
Repo: &repo_model.Repository{Name: "test-repo", OwnerName: "test-owner"},
|
||||
}
|
||||
|
||||
giteaCtx := GenerateGiteaContext(t.Context(), run, nil, nil)
|
||||
|
||||
assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
|
||||
assert.Equal(t, "main", giteaCtx["ref_name"])
|
||||
}
|
||||
|
||||
// TestGenerateGiteaContext_NilAttempt verifies that, with no explicit attempt,
|
||||
// use GetLatestAttempt to load the latest attempt and resolve attempt-related context variables.
|
||||
func TestGenerateGiteaContext_NilAttempt(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
require.NoError(t, repo.LoadOwner(t.Context()))
|
||||
actor := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // initiated the run
|
||||
triggerer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // initiated the latest attempt
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID,
|
||||
TriggerUserID: actor.ID, TriggerUser: actor,
|
||||
WorkflowID: "test.yml", Index: 99600, Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", TriggerEvent: "push",
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
attempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: repo.ID, RunID: run.ID, Attempt: 3, TriggerUserID: triggerer.ID, Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: repo.ID, OwnerID: repo.OwnerID,
|
||||
Name: "j", JobID: "j", Attempt: attempt.Attempt, Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
// attempt == nil forces the fallback lookup via run.GetLatestAttempt.
|
||||
gitCtx := GenerateGiteaContext(t.Context(), run, nil, job)
|
||||
assert.Equal(t, actor.Name, gitCtx["actor"])
|
||||
assert.Equal(t, triggerer.Name, gitCtx["triggering_actor"])
|
||||
assert.Equal(t, "3", gitCtx["run_attempt"])
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ type jobUpdate struct {
|
||||
RunID int64
|
||||
}
|
||||
|
||||
func EmitJobsIfReadyByRun(runID int64) error {
|
||||
var EmitJobsIfReadyByRun = func(runID int64) error {
|
||||
err := jobEmitterQueue.Push(&jobUpdate{
|
||||
RunID: runID,
|
||||
})
|
||||
|
||||
@@ -32,6 +32,20 @@ import (
|
||||
// a top-level caller may have at most MaxReusableCallLevels nested callers below it.
|
||||
const MaxReusableCallLevels = 9
|
||||
|
||||
// checkRunJobLimit rejects an expansion that would push the attempt over actions_model.MaxJobNumPerRun.
|
||||
// checkCallerChain bounds nesting *depth*, but a reusable graph also fans out in *breadth*: without a
|
||||
// cumulative cap a tiny set of files can drive exponential job-row insertion and exhaust the database.
|
||||
func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) error {
|
||||
existing, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, runID, attemptID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count existing jobs of run %d attempt %d: %w", runID, attemptID, err)
|
||||
}
|
||||
if existing+int64(adding) > actions_model.MaxJobNumPerRun {
|
||||
return fmt.Errorf("workflow run exceeds the maximum of %d jobs", actions_model.MaxJobNumPerRun)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
|
||||
// along with the (repo_id, commit_sha) the file was loaded from.
|
||||
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *jobparser.UsesRef) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
|
||||
@@ -289,6 +303,10 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
return fmt.Errorf("called workflow for caller %d (uses %q) has no jobs", caller.ID, caller.CallUses)
|
||||
}
|
||||
|
||||
if err := checkRunJobLimit(ctx, run.ID, attempt.ID, len(childWorkflows)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
priorChildren, err := actions_model.GetPriorAttemptChildrenByParent(ctx, run.ID, attempt.ID, caller.AttemptJobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup prior-attempt children of caller %d: %w", caller.ID, err)
|
||||
|
||||
@@ -207,6 +207,47 @@ func TestResolveUses(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckRunJobLimit(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
runID = 900100
|
||||
attemptA = 910001
|
||||
attemptB = 910002
|
||||
)
|
||||
|
||||
seed := func(attemptID int64, n int) {
|
||||
for i := range n {
|
||||
name := fmt.Sprintf("job-%d-%d", attemptID, i)
|
||||
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{
|
||||
RunID: runID,
|
||||
RunAttemptID: attemptID,
|
||||
RepoID: 1,
|
||||
OwnerID: 1,
|
||||
CommitSHA: "abcdef",
|
||||
Name: name,
|
||||
JobID: name,
|
||||
AttemptJobID: attemptID*1000 + int64(i),
|
||||
Status: actions_model.StatusBlocked,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
seed(attemptA, 5)
|
||||
seed(attemptB, 3) // a different attempt of the same run must not count toward attempt A
|
||||
|
||||
limit := actions_model.MaxJobNumPerRun
|
||||
|
||||
// attempt A already holds 5 jobs: filling up to the cap is allowed, one more is rejected.
|
||||
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-5))
|
||||
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-4), "maximum")
|
||||
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit), "maximum")
|
||||
|
||||
// the count is scoped to the attempt: attempt B only holds 3 jobs, so attempt A's 5 must not leak in.
|
||||
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-3))
|
||||
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-2), "maximum")
|
||||
}
|
||||
|
||||
func TestUndoExpansion(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
@@ -150,7 +150,10 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
|
||||
&repo_model.Collaboration{RepoID: repoID},
|
||||
&issues_model.Comment{RefRepoID: repoID},
|
||||
&git_model.CommitStatus{RepoID: repoID},
|
||||
&git_model.CommitStatusIndex{RepoID: repoID},
|
||||
&git_model.CommitStatusSummary{RepoID: repoID},
|
||||
&git_model.Branch{RepoID: repoID},
|
||||
&git_model.RenamedBranch{RepoID: repoID},
|
||||
&git_model.LFSLock{RepoID: repoID},
|
||||
&repo_model.LanguageStat{RepoID: repoID},
|
||||
&repo_model.RepoLicense{RepoID: repoID},
|
||||
@@ -163,22 +166,26 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
|
||||
&repo_model.Release{RepoID: repoID},
|
||||
&repo_model.RepoIndexerStatus{RepoID: repoID},
|
||||
&repo_model.Redirect{RedirectRepoID: repoID},
|
||||
&repo_model.RepoTransfer{RepoID: repoID}, // this column doesn't have index, maybe it's fine since the table shouldn't be too large.
|
||||
&repo_model.RepoUnit{RepoID: repoID},
|
||||
&repo_model.Star{RepoID: repoID},
|
||||
&admin_model.Task{RepoID: repoID},
|
||||
&repo_model.Watch{RepoID: repoID},
|
||||
&webhook.Webhook{RepoID: repoID},
|
||||
&secret_model.Secret{RepoID: repoID},
|
||||
&actions_model.ActionVariable{RepoID: repoID},
|
||||
&actions_model.ActionTaskStep{RepoID: repoID},
|
||||
&actions_model.ActionTask{RepoID: repoID},
|
||||
&actions_model.ActionRunJob{RepoID: repoID},
|
||||
&actions_model.ActionRun{RepoID: repoID},
|
||||
&actions_model.ActionRunAttempt{RepoID: repoID},
|
||||
&actions_model.ActionRunner{RepoID: repoID},
|
||||
&actions_model.ActionScheduleSpec{RepoID: repoID},
|
||||
&actions_model.ActionSchedule{RepoID: repoID},
|
||||
&actions_model.ActionArtifact{RepoID: repoID},
|
||||
&actions_model.ActionRunJobSummary{RepoID: repoID},
|
||||
&actions_model.ActionRunnerToken{RepoID: repoID},
|
||||
&actions_model.ActionTasksVersion{RepoID: repoID},
|
||||
&actions_model.ActionScopedWorkflowSource{SourceRepoID: repoID},
|
||||
&issues_model.IssuePin{RepoID: repoID},
|
||||
); err != nil {
|
||||
|
||||
@@ -6,6 +6,9 @@ package repository_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -52,3 +55,28 @@ func TestDeleteOwnerRepositoriesDirectly(t *testing.T) {
|
||||
|
||||
assert.NoError(t, repo_service.DeleteOwnerRepositoriesDirectly(t.Context(), user))
|
||||
}
|
||||
|
||||
func TestDeleteRepositoryDirectlyPurgesRepoScopedRows(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
|
||||
// One row per table that repository deletion used to leave behind (#38494).
|
||||
assert.NoError(t, db.Insert(t.Context(),
|
||||
&actions_model.ActionVariable{RepoID: 1, Name: "to_purge", Data: "value"},
|
||||
&actions_model.ActionRunAttempt{RepoID: 1, RunID: unittest.NonexistentID, Attempt: 1},
|
||||
&actions_model.ActionTasksVersion{RepoID: 1, Version: 1},
|
||||
&git_model.RenamedBranch{RepoID: 1, From: "old-name", To: "new-name"},
|
||||
&git_model.CommitStatusSummary{RepoID: 1, SHA: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", State: "success"},
|
||||
&repo_model.RepoTransfer{RepoID: 1, DoerID: 2, RecipientID: 3},
|
||||
))
|
||||
unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatusIndex{RepoID: 1})
|
||||
|
||||
assert.NoError(t, repo_service.DeleteRepositoryDirectly(t.Context(), 1))
|
||||
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionVariable{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunAttempt{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionTasksVersion{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &git_model.RenamedBranch{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &git_model.CommitStatusSummary{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &git_model.CommitStatusIndex{RepoID: 1})
|
||||
unittest.AssertNotExistsBean(t, &repo_model.RepoTransfer{RepoID: 1})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -13,7 +12,7 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/services/pull"
|
||||
)
|
||||
@@ -35,57 +34,23 @@ func (err ErrCommitIDDoesNotMatch) Error() string {
|
||||
}
|
||||
|
||||
// CherryPick cherry-picks or reverts a commit to the given repository
|
||||
func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, revert bool, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
func CherryPick(ctx reqctx.RequestContext, repo *repo_model.Repository, doer *user_model.User, revert bool, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
|
||||
gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
if err := opts.Validate(ctx, repo, gitRepo, doer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message := strings.TrimSpace(opts.Message)
|
||||
|
||||
t, err := NewTemporaryUploadRepository(repo)
|
||||
t, err := gitPatchPrepare(ctx, repo, gitRepo, doer, opts)
|
||||
if err != nil {
|
||||
log.Error("NewTemporaryUploadRepository failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer t.Close()
|
||||
if err := t.Clone(ctx, opts.OldBranch, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.SetDefaultIndex(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.RefreshIndex(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the commit of the original branch
|
||||
commit, err := t.GetBranchCommit(opts.OldBranch)
|
||||
err = t.RefreshIndex(ctx)
|
||||
if err != nil {
|
||||
return nil, err // Couldn't get a commit for the branch
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Assigned LastCommitID in opts if it hasn't been set
|
||||
if opts.LastCommitID == "" {
|
||||
opts.LastCommitID = commit.ID.String()
|
||||
} else {
|
||||
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CherryPick: Invalid last commit ID: %w", err)
|
||||
}
|
||||
opts.LastCommitID = lastCommitID.String()
|
||||
if commit.ID.String() != opts.LastCommitID {
|
||||
return nil, ErrCommitIDDoesNotMatch{
|
||||
GivenCommitID: opts.LastCommitID,
|
||||
CurrentCommitID: opts.LastCommitID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commit, err = t.GetCommit(strings.TrimSpace(opts.Content))
|
||||
commit, err := t.GetCommit(strings.TrimSpace(opts.Content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -101,8 +66,7 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("CherryPick %s onto %s", right, opts.OldBranch)
|
||||
conflict, _, err := pull.AttemptThreeWayMerge(ctx,
|
||||
t.basePath, t.gitRepo, base, opts.LastCommitID, right, description)
|
||||
conflict, _, err := pull.AttemptThreeWayMerge(ctx, t.basePath, t.gitRepo, base, opts.LastCommitID, right, description)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to three-way merge %s onto %s: %w", right, opts.OldBranch, err)
|
||||
}
|
||||
@@ -111,48 +75,5 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod
|
||||
return nil, errors.New("failed to merge due to conflicts")
|
||||
}
|
||||
|
||||
treeHash, err := t.WriteTree(ctx)
|
||||
if err != nil {
|
||||
// likely non-sensical tree due to merge conflicts...
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now commit the tree
|
||||
commitOpts := &CommitTreeUserOptions{
|
||||
ParentCommitID: "HEAD",
|
||||
TreeHash: treeHash,
|
||||
CommitMessage: message,
|
||||
SignOff: opts.Signoff,
|
||||
DoerUser: doer,
|
||||
AuthorIdentity: opts.Author,
|
||||
AuthorTime: nil,
|
||||
CommitterIdentity: opts.Committer,
|
||||
CommitterTime: nil,
|
||||
}
|
||||
if opts.Dates != nil {
|
||||
commitOpts.AuthorTime, commitOpts.CommitterTime = &opts.Dates.Author, &opts.Dates.Committer
|
||||
}
|
||||
commitHash, err := t.CommitTree(ctx, commitOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Then push this tree to NewBranch
|
||||
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit, err = t.GetCommit(commitHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil
|
||||
verification := GetPayloadCommitVerification(ctx, commit)
|
||||
fileResponse := &structs.FileResponse{
|
||||
Commit: fileCommitResponse,
|
||||
Verification: verification,
|
||||
}
|
||||
|
||||
return fileResponse, nil
|
||||
return gitPatchCommitPush(ctx, t, repo, doer, opts)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
@@ -110,31 +110,27 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyDiffPatch applies a patch to the given repository
|
||||
func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
|
||||
func gitPatchPrepare(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (_ *TemporaryUploadRepository, retErr error) {
|
||||
err := repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
if err := opts.Validate(ctx, repo, gitRepo, doer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(opts.Message)
|
||||
|
||||
t, err := NewTemporaryUploadRepository(repo)
|
||||
if err != nil {
|
||||
log.Error("NewTemporaryUploadRepository failed: %v", err)
|
||||
return nil, fmt.Errorf("NewTemporaryUploadRepository failed: %w", err)
|
||||
}
|
||||
defer t.Close()
|
||||
if err := t.Clone(ctx, opts.OldBranch, true); err != nil {
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
t.Close()
|
||||
}
|
||||
}()
|
||||
// here must NOT use bare repo, because the following git commands might operate working tree ("--index") directly
|
||||
if err := t.Clone(ctx, opts.OldBranch, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.SetDefaultIndex(ctx); err != nil {
|
||||
@@ -153,7 +149,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
|
||||
} else {
|
||||
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ApplyPatch: Invalid last commit ID: %w", err)
|
||||
return nil, fmt.Errorf("invalid last commit ID: %w", err)
|
||||
}
|
||||
opts.LastCommitID = lastCommitID.String()
|
||||
if commit.ID.String() != opts.LastCommitID {
|
||||
@@ -163,6 +159,20 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
|
||||
}
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ApplyDiffPatch applies a patch to the given repository
|
||||
func ApplyDiffPatch(ctx reqctx.RequestContext, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
|
||||
gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := gitPatchPrepare(ctx, repo, gitRepo, doer, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer t.Close()
|
||||
|
||||
cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary")
|
||||
if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
|
||||
@@ -175,17 +185,19 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
|
||||
return nil, fmt.Errorf("git apply error: %w", err)
|
||||
}
|
||||
|
||||
// Now write the tree
|
||||
return gitPatchCommitPush(ctx, t, repo, doer, opts)
|
||||
}
|
||||
|
||||
func gitPatchCommitPush(ctx context.Context, t *TemporaryUploadRepository, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
|
||||
treeHash, err := t.WriteTree(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now commit the tree
|
||||
commitOpts := &CommitTreeUserOptions{
|
||||
ParentCommitID: "HEAD",
|
||||
TreeHash: treeHash,
|
||||
CommitMessage: message,
|
||||
CommitMessage: strings.TrimSpace(opts.Message),
|
||||
SignOff: opts.Signoff,
|
||||
DoerUser: doer,
|
||||
AuthorIdentity: opts.Author,
|
||||
@@ -206,7 +218,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit, err = t.GetCommit(commitHash)
|
||||
commit, err := t.GetCommit(commitHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGitPatchPrepare(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo1)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
opts := &ApplyDiffPatchOptions{}
|
||||
tmpRepo, err := gitPatchPrepare(t.Context(), repo1, gitRepo, user2, opts)
|
||||
require.NoError(t, err)
|
||||
defer tmpRepo.Close()
|
||||
// temporary repository for patch should not be a bare repo because "--index" argument is used,
|
||||
// while it is questionable whether "--index" argument should be really used, need to figure out in the future.
|
||||
_, err = os.Stat(filepath.Join(tmpRepo.basePath, ".git"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
|
||||
|
||||
t, err := NewTemporaryUploadRepository(repo)
|
||||
if err != nil {
|
||||
log.Error("NewTemporaryUploadRepository failed: %v", err)
|
||||
return nil, fmt.Errorf("NewTemporaryUploadRepository failed: %w", err)
|
||||
}
|
||||
defer t.Close()
|
||||
hasOldBranch := true
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
webhook_model "gitea.dev/models/webhook"
|
||||
@@ -317,12 +316,10 @@ func init() {
|
||||
RegisterWebhookRequester(webhook_module.SLACK, newSlackRequest)
|
||||
}
|
||||
|
||||
var slackChannel = regexp.MustCompile(`^#?[a-z0-9_-]{1,80}$`)
|
||||
|
||||
// IsValidSlackChannel validates a channel name conforms to what slack expects:
|
||||
// https://api.slack.com/methods/conversations.rename#naming
|
||||
// Conversation names can only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less.
|
||||
// Gitea accepts if it starts with a #.
|
||||
func IsValidSlackChannel(name string) bool {
|
||||
return slackChannel.MatchString(name)
|
||||
// Some documents: https://api.slack.com/methods/conversations.rename#naming
|
||||
// 1. Internal channel name should "only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less"
|
||||
// 2. Slack would also "modify it to meet the above criteria"
|
||||
// Since we know nothing about the details, don't do any validation here.
|
||||
return name != ""
|
||||
}
|
||||
|
||||
@@ -191,22 +191,3 @@ func TestSlackJSONPayload(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>:<http://localhost:3000/test/repo/src/branch/test|test>] 2 new commits pushed by user1", body.Text)
|
||||
}
|
||||
|
||||
func TestIsValidSlackChannel(t *testing.T) {
|
||||
tt := []struct {
|
||||
channelName string
|
||||
expected bool
|
||||
}{
|
||||
{"gitea", true},
|
||||
{"#gitea", true},
|
||||
{" ", false},
|
||||
{"#", false},
|
||||
{" #", false},
|
||||
{"gitea ", false},
|
||||
{" gitea", false},
|
||||
}
|
||||
|
||||
for _, v := range tt {
|
||||
assert.Equal(t, v.expected, IsValidSlackChannel(v.channelName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<table class="ui very basic table unstackable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.task_list.run"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.task_list.job"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.task_list.status"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.task_list.repository"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.task_list.commit"}}</th>
|
||||
@@ -75,7 +75,7 @@
|
||||
<tbody>
|
||||
{{range .Tasks}}
|
||||
<tr>
|
||||
<td><a href="{{.GetRunLink}}" target="_blank">{{.ID}}</a></td>
|
||||
<td>{{if .Job}}<a href="{{.GetRunJobLink}}" target="_blank">{{.Job.ID}}</a>{{else}}{{.ID}}{{end}}</td>
|
||||
<td><span class="ui label task-status-{{.Status.String}}">{{.Status.LocaleString ctx.Locale}}</span></td>
|
||||
<td><a href="{{.GetRepoLink}}" target="_blank">{{.GetRepoName}}</a></td>
|
||||
<td>
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
{{if .AllowBulkActions}}
|
||||
<th class="tw-w-8"><div class="ui checkbox tw-flex"><input type="checkbox" class="runner-bulk-select-all" aria-label="{{ctx.Locale.Tr "admin.notices.select_all"}}"></div></th>
|
||||
{{end}}
|
||||
<th data-sortt-asc="alphabetically" data-sortt-desc="reversealphabetically">
|
||||
{{ctx.Locale.Tr "actions.runners.name"}}
|
||||
{{SortArrow "alphabetically" "reversealphabetically" .SortType false}}
|
||||
</th>
|
||||
<th data-sortt-asc="online" data-sortt-desc="offline">
|
||||
{{ctx.Locale.Tr "actions.runners.status"}}
|
||||
{{SortArrow "online" "offline" .SortType false}}
|
||||
@@ -70,10 +74,6 @@
|
||||
{{ctx.Locale.Tr "actions.runners.id"}}
|
||||
{{SortArrow "oldest" "newest" .SortType false}}
|
||||
</th>
|
||||
<th data-sortt-asc="alphabetically" data-sortt-desc="reversealphabetically">
|
||||
{{ctx.Locale.Tr "actions.runners.name"}}
|
||||
{{SortArrow "alphabetically" "reversealphabetically" .SortType false}}
|
||||
</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.version"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.owner_type"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.labels"}}</th>
|
||||
@@ -87,12 +87,12 @@
|
||||
{{if $.AllowBulkActions}}
|
||||
<td><div class="ui checkbox tw-flex"><input type="checkbox" class="runner-bulk-select" data-runner-id="{{.ID}}" aria-label="{{ctx.Locale.Tr "repo.issues.action_check"}}: {{.Name}}"></div></td>
|
||||
{{end}}
|
||||
<td><p data-tooltip-content="{{.Description}}">{{.Name}}</p></td>
|
||||
<td>
|
||||
<span class="ui label {{if .IsOnline}}green{{end}}">{{.StatusLocaleName ctx.Locale}}</span>
|
||||
{{if .IsDisabled}}<span class="ui grey label">{{ctx.Locale.Tr "actions.runners.disabled"}}</span>{{end}}
|
||||
<span class="ui label {{if eq .StatusName "active"}}green{{else if eq .StatusName "idle"}}yellow{{else if eq .StatusName "offline"}}red{{end}}">{{.StatusLocaleName ctx.Locale}}</span>
|
||||
{{if .IsDisabled}}<span class="ui grey label">{{ctx.Locale.Tr "disabled"}}</span>{{end}}
|
||||
</td>
|
||||
<td>{{.ID}}</td>
|
||||
<td><p data-tooltip-content="{{.Description}}">{{.Name}}</p></td>
|
||||
<td>{{if .Version}}{{.Version}}{{else}}{{ctx.Locale.Tr "unknown"}}{{end}}</td>
|
||||
<td><span data-tooltip-content="{{.BelongsToOwnerName}}">{{.BelongsToOwnerType.LocaleString ctx.Locale}}</span></td>
|
||||
<td>
|
||||
|
||||
@@ -143,6 +143,28 @@ jobs:
|
||||
job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
|
||||
assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID)
|
||||
|
||||
t.Run("RerunFailedWithNoFailedJobs", func(t *testing.T) {
|
||||
// The run is fully successful, so an empty failed-job list must be rejected rather than fall
|
||||
// through to re-running the whole run.
|
||||
before := getRunLatestAttemptNum(t, run.ID)
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", user2.Name, repo.Name, run.ID))
|
||||
resp := session.MakeRequest(t, req, http.StatusBadRequest)
|
||||
assert.Contains(t, resp.Body.String(), "no failed jobs")
|
||||
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
|
||||
runner.fetchNoTask(t) // no new tasks were scheduled
|
||||
})
|
||||
|
||||
t.Run("RerunFailedWithNoFailedJobsAPI", func(t *testing.T) {
|
||||
// Same rejection on the API route: an empty failed-job list must not fall through to a full re-run.
|
||||
before := getRunLatestAttemptNum(t, run.ID)
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun-failed-jobs", user2.Name, repo.Name, run.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusBadRequest)
|
||||
assert.Contains(t, resp.Body.String(), "no failed jobs")
|
||||
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
|
||||
runner.fetchNoTask(t)
|
||||
})
|
||||
|
||||
t.Run("AttemptAPI", func(t *testing.T) {
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)).
|
||||
AddTokenAuth(token)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestFindRunnersSortByStatus verifies that sorting by status ranks runners by
|
||||
// their computed status (active, then idle, then offline) instead of interleaving
|
||||
// idle runners with active ones by raw last_online, and that a disabled runner
|
||||
// sinks to the bottom of its status group rather than mixing with enabled runners.
|
||||
//
|
||||
// It lives in tests/integration rather than a unit test because the status rank is
|
||||
// a database-evaluated CASE expression; unit tests only run against SQLite, so the
|
||||
// expression must be exercised against MySQL/PostgreSQL/MSSQL in CI to catch dialect
|
||||
// differences.
|
||||
func TestFindRunnersSortByStatus(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
ctx := t.Context()
|
||||
|
||||
const ownerID = 1001
|
||||
now := time.Now()
|
||||
// An idle runner that went online most recently would sort before an active
|
||||
// runner when ordering by last_online alone; the status rank must override that.
|
||||
insert := func(name string, lastOnline, lastActive time.Time, disabled bool) {
|
||||
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunner{
|
||||
Name: name,
|
||||
UUID: "STATUS-SORT-" + name,
|
||||
TokenHash: "status-sort-token-" + name,
|
||||
OwnerID: ownerID,
|
||||
LastOnline: timeutil.TimeStamp(lastOnline.Unix()),
|
||||
LastActive: timeutil.TimeStamp(lastActive.Unix()),
|
||||
IsDisabled: disabled,
|
||||
}))
|
||||
}
|
||||
// Each disabled runner has the most recent last_online within its status group,
|
||||
// so it would sort first in that group if the disabled flag were ignored; the
|
||||
// last_active value keeps it in the intended group (offline<idle<active).
|
||||
insert("active", now.Add(-8*time.Second), now.Add(-5*time.Second), false)
|
||||
insert("active-disabled", now, now.Add(-2*time.Second), true)
|
||||
insert("idle", now.Add(-30*time.Second), now.Add(-20*time.Second), false)
|
||||
insert("idle-disabled", now, now.Add(-20*time.Second), true)
|
||||
insert("offline", now.Add(-3*time.Minute), now.Add(-3*time.Minute), false)
|
||||
insert("offline-disabled", now.Add(-90*time.Second), now.Add(-90*time.Second), true)
|
||||
|
||||
names := func(runners []*actions_model.ActionRunner) []string {
|
||||
out := make([]string, len(runners))
|
||||
for i, r := range runners {
|
||||
out[i] = r.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Active group first, then idle, then offline; within each group enabled before disabled.
|
||||
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "online"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"active", "active-disabled",
|
||||
"idle", "idle-disabled",
|
||||
"offline", "offline-disabled",
|
||||
}, names(runners))
|
||||
|
||||
// The descending status sort reverses the group order but still keeps disabled
|
||||
// runners at the bottom of their group.
|
||||
runners, err = db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "offline"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"offline", "offline-disabled",
|
||||
"idle", "idle-disabled",
|
||||
"active", "active-disabled",
|
||||
}, names(runners))
|
||||
}
|
||||
|
||||
// TestFindRunnersPaginationNoDuplicates verifies that the unique id tiebreaker in
|
||||
// FindRunnerOptions.ToOrders keeps pagination deterministic when the primary sort
|
||||
// key (last_online) is tied for every runner. It is an integration test so the
|
||||
// ordering is validated against every supported database, not only SQLite.
|
||||
func TestFindRunnersPaginationNoDuplicates(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
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 {
|
||||
require.NoError(t, db.Insert(ctx, &actions_model.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,
|
||||
}))
|
||||
}
|
||||
|
||||
// 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[actions_model.ActionRunner](ctx, actions_model.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)
|
||||
}
|
||||
}
|
||||
@@ -161,11 +161,6 @@
|
||||
.ui.fixed.table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
.ui.fixed.table th,
|
||||
.ui.fixed.table td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ui.selectable.table > tbody > tr:hover,
|
||||
.ui.table tbody tr td.selectable:hover {
|
||||
|
||||
@@ -645,6 +645,10 @@ td .commit-summary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.repository.view.issue .ui.depending .item.dependency {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.repository.view.issue .ui.depending .item.is-closed .issue-dependency-title {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -723,7 +723,7 @@ async function hashChangeListener() {
|
||||
|
||||
.job-step-logs .job-log-line .log-msg {
|
||||
flex: 1;
|
||||
white-space: break-spaces;
|
||||
white-space: break-spaces; /* decoded commands like "::error::foo%0Abar" contain "\n" */
|
||||
margin-left: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ test('LogLineMessage', () => {
|
||||
'##[debug] foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
|
||||
'::error::foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
|
||||
'::warning file=test.js,line=1::foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
|
||||
'::error::foo%0Abar': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar</span></span>',
|
||||
'::error::foo%0D%0Abar': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar</span></span>',
|
||||
'::error::100%25 done%250A': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> 100% done%0A</span></span>',
|
||||
'::error::keep%5Dsemi%3B': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> keep%5Dsemi%3B</span></span>',
|
||||
'::group::foo%0Abar': '<span class="log-msg log-cmd-group">foo\nbar</span>',
|
||||
'##[error]foo%0Abar%3B%5D': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar;]</span></span>',
|
||||
'##[command]foo%0Abar': '<span class="log-msg log-cmd-command">foo%0Abar</span>',
|
||||
'::notice::foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
|
||||
'::debug::foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
|
||||
'##[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
|
||||
|
||||
@@ -9,7 +9,8 @@ import {POST} from '../modules/fetch.ts';
|
||||
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
|
||||
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
|
||||
// * The reported logs are the processed logs.
|
||||
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
|
||||
// HOWEVER: Gitea cannot, a decoded message may contain newlines and FormatLog drops them,
|
||||
// so the commands arrive here still escaped and the frontend decodes them.
|
||||
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
||||
'::group::': 'group',
|
||||
'##[group]': 'group',
|
||||
@@ -67,14 +68,23 @@ const LogLineLabelMap: Partial<Record<LogLineCommandName, string>> = {
|
||||
'debug': 'Debug',
|
||||
};
|
||||
|
||||
function decodeLineMessage(line: LogLine, cmd: LogLineCommand | null): string {
|
||||
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
|
||||
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
|
||||
if (!cmd) return line.message;
|
||||
let msg = line.message.substring(cmd.prefix.length);
|
||||
if (cmd.name === 'command') return msg; // "command" is only an output tag, do not parse or escape it
|
||||
// "##[cmd]" also escapes ";" and "]" which delimit its header, "::cmd::" does not
|
||||
if (!cmd.prefix.startsWith('::')) msg = msg.replace(/%3B/g, ';').replace(/%5D/g, ']');
|
||||
// renderAnsiInto breaks a line per "\r", so "%0D%0A" is one break. "%25" last keeps "%250A" literal
|
||||
return msg.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%');
|
||||
}
|
||||
|
||||
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
|
||||
const logMsgAttrs = {class: 'log-msg'};
|
||||
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error"
|
||||
|
||||
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
|
||||
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
|
||||
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
|
||||
|
||||
const msgContent = decodeLineMessage(line, cmd);
|
||||
const logMsg = createElementFromAttrs('span', logMsgAttrs);
|
||||
const label = cmd ? LogLineLabelMap[cmd.name] : null;
|
||||
if (label) {
|
||||
|
||||
Reference in New Issue
Block a user