mirror of
https://github.com/go-gitea/gitea
synced 2026-07-28 09:42:36 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a62dfffbe7 | ||
|
|
035dd58664 | ||
|
|
0d9ce64f76 | ||
|
|
4a77fbce28 | ||
|
|
2b37732d5f | ||
|
|
2695b47887 | ||
|
|
9c685dedbb | ||
|
|
e9b3917042 | ||
|
|
d7bc52beea | ||
|
|
ccd38f9a70 | ||
|
|
f47e3d930d | ||
|
|
9972bee41f | ||
|
|
375e6ea038 | ||
|
|
31c435454b | ||
|
|
a54324e2b7 | ||
|
|
d141dc729c | ||
|
|
27fed5a83a | ||
|
|
6cdd02bdad | ||
|
|
cc4ee6387b | ||
|
|
4780cffe08 | ||
|
|
483fb19b37 | ||
|
|
560535a97f | ||
|
|
62c61aa8ce | ||
|
|
337fa5a950 | ||
|
|
ad84c14d53 | ||
|
|
65ea4079ce | ||
|
|
1cf54fed70 | ||
|
|
1ae686696e | ||
|
|
e2f0358368 | ||
|
|
d88bbfd0db | ||
|
|
5e494f9cad | ||
|
|
9731ad7c3c | ||
|
|
148d528814 | ||
|
|
1af5277aba | ||
|
|
6d41731184 | ||
|
|
cfc9f4c685 | ||
|
|
895d848ff4 | ||
|
|
7199547218 | ||
|
|
7cb4201f8e | ||
|
|
5f017302bf | ||
|
|
59c619660c | ||
|
|
8599289459 | ||
|
|
da9f0a3726 | ||
|
|
08fd59959e | ||
|
|
d60215c2a2 | ||
|
|
bf594690db | ||
|
|
5cb7ec9304 | ||
|
|
6e86c4cde8 | ||
|
|
c32af046a2 | ||
|
|
a98468da30 |
@@ -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
|
||||
|
||||
+15
-3
@@ -6,6 +6,7 @@ package cmd
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -93,10 +94,21 @@ func runACME(listenAddr string, m http.Handler) error {
|
||||
myACME := certmagic.NewACMEIssuer(magic, certmagic.DefaultACME)
|
||||
magic.Issuers = []certmagic.Issuer{myACME}
|
||||
|
||||
// this obtains certificates or renews them if necessary
|
||||
err := magic.ManageSync(graceful.GetManager().HammerContext(), []string{setting.Domain})
|
||||
// Obtain certificates or renew them if necessary. ManageSync fails closed on
|
||||
// renewal errors even when a still-valid certificate is already on disk, which
|
||||
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
|
||||
// Prefer keeping the existing cert and retrying renewals asynchronously.
|
||||
ctx := graceful.GetManager().ShutdownContext()
|
||||
err := magic.ManageSync(ctx, []string{setting.Domain})
|
||||
if err != nil {
|
||||
return err
|
||||
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.Domain)
|
||||
if cacheErr != nil || cert.Expired() {
|
||||
return errors.Join(err, cacheErr)
|
||||
}
|
||||
log.Error("ACME certificate manage failed; continuing with existing certificate: %v", err)
|
||||
if err := magic.ManageAsync(ctx, []string{setting.Domain}); err != nil {
|
||||
log.Error("Failed to start async ACME management: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
tlsConfig := magic.TLSConfig()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -333,6 +333,14 @@ func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (A
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// DeleteDirectChildJobsByParent deletes the direct child jobs of a parent job.
|
||||
func DeleteDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) error {
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID).
|
||||
Delete(new(ActionRunJob))
|
||||
return err
|
||||
}
|
||||
|
||||
// CollectAllDescendantJobs returns every job in `allJobs` that lives under parent's subtree (recursively), excluding `parent` itself
|
||||
func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob {
|
||||
parents := map[int64]bool{parent.ID: true}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ func updateApprovalWhitelist(ctx context.Context, repo *repo_model.Repository, c
|
||||
return currentWhitelist, nil
|
||||
}
|
||||
|
||||
prUserIDs, err := access_model.GetUserIDsWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
|
||||
prUserIDs, err := access_model.GetUserIDsWithAnyUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -599,8 +599,8 @@ func HasAnyUnitAccess(ctx context.Context, userID int64, repo *repo_model.Reposi
|
||||
return perm.HasAnyUnitAccess(), nil
|
||||
}
|
||||
|
||||
func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (users []*user_model.User, err error) {
|
||||
userIDs, err := GetUserIDsWithUnitAccess(ctx, repo, mode, unitType)
|
||||
func GetUsersWithAnyUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type, moreUnitTypes ...unit.Type) (users []*user_model.User, err error) {
|
||||
userIDs, err := GetUserIDsWithAnyUnitAccess(ctx, repo, mode, unitType, moreUnitTypes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -613,7 +613,7 @@ func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mo
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (container.Set[int64], error) {
|
||||
func GetUserIDsWithAnyUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type, moreUnitTypes ...unit.Type) (container.Set[int64], error) {
|
||||
userIDs := container.Set[int64]{}
|
||||
e := db.GetEngine(ctx)
|
||||
accesses := make([]*Access, 0, 10)
|
||||
@@ -630,7 +630,7 @@ func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository,
|
||||
if !repo.Owner.IsOrganization() {
|
||||
userIDs.Add(repo.Owner.ID)
|
||||
} else {
|
||||
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType)
|
||||
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType, moreUnitTypes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -226,12 +226,12 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
|
||||
|
||||
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
|
||||
users, err := GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, user.ID, users[0].ID)
|
||||
|
||||
users, err = GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
users, err = GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, users)
|
||||
})
|
||||
@@ -245,7 +245,7 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
|
||||
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues])
|
||||
|
||||
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
users, err := GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, user.ID, users[0].ID)
|
||||
|
||||
@@ -57,9 +57,14 @@ func NewInterpeter(
|
||||
}
|
||||
|
||||
ee := &exprparser.EvaluationEnvironment{
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
Job: nil, // no need
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
// Job must be non-nil because cancelled() dereferences Job.Status unconditionally.
|
||||
// See: https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299
|
||||
// TODO: The empty JobContext.Status is right for now because Gitea never checks `if` condition when the workflow run is cancelled.
|
||||
// This is an implementation gap in Gitea Actions. When a workflow run is cancelled, Gitea should check the job's `if` condition,
|
||||
// and if the condition is met (e.g. `if: ${{ cancelled() }}` ), the job should be executed rather than cancelled.
|
||||
Job: &model.JobContext{},
|
||||
Steps: nil, // no need
|
||||
Runner: nil, // no need
|
||||
Secrets: nil, // no need
|
||||
|
||||
@@ -75,7 +75,22 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
|
||||
}
|
||||
|
||||
func (w *SingleWorkflow) Marshal() ([]byte, error) {
|
||||
return yaml.Marshal(w)
|
||||
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
|
||||
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
|
||||
// `run:` step that begins with blank lines) with a wrong explicit indentation
|
||||
// indicator (`run: |4`) that then fails to re-parse, which silently strands
|
||||
// the job during concurrency evaluation. Keeping both encoders at indent 2
|
||||
// makes the serialized single workflow round-trip.
|
||||
var buf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
@@ -490,7 +505,19 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
|
||||
RawMatrix: job.Strategy.RawMatrix,
|
||||
},
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, toGitContext(gitCtx), results, vars, inputs))
|
||||
// Each per-matrix job carries its single matrix combination in RawMatrix so resolve it and pass it in;
|
||||
// otherwise `matrix.*` references in `if:` evaluate to null.
|
||||
// GetMatrixes always returns at least one element (an empty map for a job without a matrix),
|
||||
// so only a non-empty combination should populate `matrix.*`, leaving it nil otherwise.
|
||||
var matrix map[string]any
|
||||
matrixes, err := actJob.GetMatrixes()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
expr, err := rewriteSubExpression(job.If.Value, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -464,3 +465,106 @@ func TestParseMappingNode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
|
||||
ifExprs := []string{
|
||||
`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
|
||||
`${{ contains('["linux","windows"]', matrix.target) }}`,
|
||||
}
|
||||
|
||||
want := map[string]bool{
|
||||
"build (linux)": true,
|
||||
"build (windows)": true,
|
||||
"build (macos)": false,
|
||||
}
|
||||
|
||||
for _, ifExpr := range ifExprs {
|
||||
t.Run(ifExpr, func(t *testing.T) {
|
||||
content := fmt.Sprintf(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: %s
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [linux, windows, macos]
|
||||
steps:
|
||||
- run: echo ${{ matrix.target }}
|
||||
`, ifExpr)
|
||||
|
||||
swfs, err := Parse([]byte(content))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, swfs, 3)
|
||||
|
||||
got := make(map[string]bool, len(swfs))
|
||||
for _, swf := range swfs {
|
||||
id, job := swf.Job()
|
||||
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil)
|
||||
require.NoError(t, err)
|
||||
got[job.Name] = shouldRun
|
||||
}
|
||||
assert.Equal(t, want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateJobIfExpression(t *testing.T) {
|
||||
kases := []struct {
|
||||
name string
|
||||
ifCond string
|
||||
needResult string
|
||||
expected bool
|
||||
}{
|
||||
{name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true},
|
||||
{name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true},
|
||||
{name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true},
|
||||
{name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false},
|
||||
{name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true},
|
||||
// cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:`
|
||||
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
|
||||
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
|
||||
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
|
||||
}
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.name, func(t *testing.T) {
|
||||
content := strings.ReplaceAll(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo job1
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job1]
|
||||
if: IF_COND
|
||||
steps:
|
||||
- run: echo job2
|
||||
`, "IF_COND", kase.ifCond)
|
||||
|
||||
workflows, err := Parse([]byte(content))
|
||||
require.NoError(t, err)
|
||||
|
||||
var job2 *Job
|
||||
for _, wf := range workflows {
|
||||
if id, job := wf.Job(); id == "job2" {
|
||||
job2 = job
|
||||
}
|
||||
}
|
||||
require.NotNil(t, job2)
|
||||
|
||||
// mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs
|
||||
results := map[string]*JobResult{
|
||||
"job1": {Result: kase.needResult},
|
||||
"job2": {Needs: []string{"job1"}},
|
||||
}
|
||||
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, kase.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A step whose `run:` block starts with blank lines must still survive the
|
||||
// Parse -> SingleWorkflow.Marshal -> Parse round-trip. Previously Marshal used a
|
||||
// different indentation than SetJob, which made the encoder emit the block scalar
|
||||
// with a wrong explicit indentation indicator (`run: |4`) that no longer parsed;
|
||||
// the job then stayed silently blocked during concurrency evaluation.
|
||||
func TestSingleWorkflowRoundTripRunBlockLeadingBlankLines(t *testing.T) {
|
||||
const wf = `name: demo
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_name:
|
||||
type: string
|
||||
required: true
|
||||
jobs:
|
||||
build:
|
||||
name: build
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.app_name }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: ${{ inputs.app_name != '' }}
|
||||
name: packages
|
||||
run: |
|
||||
|
||||
|
||||
echo start
|
||||
echo done
|
||||
`
|
||||
sws, err := Parse([]byte(wf))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sws, 1)
|
||||
|
||||
// pin the original run block as the baseline
|
||||
_, origJob := sws[0].Job()
|
||||
require.Len(t, origJob.Steps, 1)
|
||||
const wantRun = "\n\necho start\necho done\n"
|
||||
require.Equal(t, wantRun, origJob.Steps[0].Run)
|
||||
|
||||
payload, err := sws[0].Marshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
// the serialized single workflow must be parseable again -- this is what the
|
||||
// server does in EvaluateJobConcurrencyFillModel -> ParseJob. Before the fix
|
||||
// Marshal emitted `run: |4`, which failed here and left the job blocked.
|
||||
roundTripped, err := Parse(payload)
|
||||
require.NoError(t, err, "serialized single workflow must round-trip; got payload:\n%s", payload)
|
||||
require.Len(t, roundTripped, 1)
|
||||
|
||||
// the round-trip must preserve the run block byte-for-byte
|
||||
_, gotJob := roundTripped[0].Job()
|
||||
require.Len(t, gotJob.Steps, 1)
|
||||
require.Equal(t, wantRun, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload)
|
||||
}
|
||||
@@ -77,8 +77,12 @@ func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
|
||||
}
|
||||
|
||||
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
|
||||
// the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n"
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n-{3,}\n+|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*\n*)$`)
|
||||
// ref: https://git-scm.com/docs/git-interpret-trailers
|
||||
// TODO: the regexp is not able to perfectly parse the all kinds of trailers
|
||||
// It was just copied from legacy code, it is not exactly the same as how Git parses the trailer and not quite right in some cases.
|
||||
// For the key characters: it follows RFC 822 field name syntax (or RFC 2822/RFC 5322): printable ASCII characters between 33 and 126 except the colon (:),
|
||||
// but maybe we don't want to make it that complicated, so here we only support some common "symbol-like" characters.
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n+-{3,}\n+|\n{2,})(?P<trailer>(?:[A-Za-z0-9][-\w]*:[^\n]*(\n\s+[^\n]*)*\n?)*\n*)$`)
|
||||
})
|
||||
|
||||
// CommitMessageSplitTrailer tries to split the message by the trailer separator
|
||||
@@ -93,6 +97,41 @@ func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
|
||||
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
|
||||
}
|
||||
|
||||
// CommitMessageMerge merges two commit messages with their trailers
|
||||
func CommitMessageMerge(m1, m2 string) string {
|
||||
c1, s1, t1 := CommitMessageSplitTrailer(m1)
|
||||
c2, s2, t2 := CommitMessageSplitTrailer(m2)
|
||||
c1, t1 = strings.TrimSpace(c1), strings.TrimSpace(t1)
|
||||
c2, t2 = strings.TrimSpace(c2), strings.TrimSpace(t2)
|
||||
out := strings.Builder{}
|
||||
if c1 != "" && c2 != "" {
|
||||
out.WriteString(c1)
|
||||
out.WriteString("\n\n")
|
||||
out.WriteString(c2)
|
||||
} else if c1 != "" {
|
||||
out.WriteString(c1)
|
||||
} else if c2 != "" {
|
||||
out.WriteString(c2)
|
||||
}
|
||||
if t1 != "" || t2 != "" {
|
||||
sep := util.Iif(t1 == "", s2, s1)
|
||||
sep = util.IfZero(sep, "\n\n")
|
||||
if c1 != "" || c2 != "" {
|
||||
out.WriteString(sep)
|
||||
}
|
||||
if t1 != "" {
|
||||
out.WriteString(t1)
|
||||
}
|
||||
if t1 != "" && t2 != "" {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
if t2 != "" {
|
||||
out.WriteString(t2)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
ret := CommitMessageTrailerValues{}
|
||||
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
||||
|
||||
@@ -26,10 +26,12 @@ func TestCommitMessageTrailer(t *testing.T) {
|
||||
{"a", "a", "", ""},
|
||||
{"a\n\nk", "a\n\nk", "", ""},
|
||||
{"a\n\nk:v", "a", "\n\n", "k:v"},
|
||||
{"a\n\nk:v\n next-line", "a", "\n\n", "k:v\n next-line"},
|
||||
{"a\n\nk:v\n next-line\nother: v", "a", "\n\n", "k:v\n next-line\nother: v"},
|
||||
{"a\n\nk:v\n\n", "a", "\n\n", "k:v\n\n"},
|
||||
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"},
|
||||
{"a\n\n---\n\nk:v", "a\n", "\n---\n\n", "k:v"},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"}, // TODO: should we support such case? No empty line between "---" and the trailer
|
||||
{"a\n\n---\n\nk:v", "a", "\n\n---\n\n", "k:v"},
|
||||
|
||||
{"k: v", "", "", "k: v"},
|
||||
{"\nk:v", "", "\n", "k:v"},
|
||||
@@ -127,3 +129,31 @@ func TestCommitMessageParticipants(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommitMessageMerge(t *testing.T) {
|
||||
cases := []struct {
|
||||
m1, m2 string
|
||||
out string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"msg1", "", "msg1"},
|
||||
{"", "msg2", "msg2"},
|
||||
{"msg1", "msg2", "msg1\n\nmsg2"},
|
||||
{"k1: a", "", "k1: a"},
|
||||
{"", "k2: b", "k2: b"},
|
||||
{"k1: a", "k2: b", "k1: a\nk2: b"},
|
||||
{"msg1", "k2: b", "msg1\n\nk2: b"},
|
||||
{"k1: a", "msg2", "msg2\n\nk1: a"},
|
||||
{"msg1\n\nk1: a", "msg2", "msg1\n\nmsg2\n\nk1: a"},
|
||||
{"msg1\n----\nk1: a", "msg2", "msg1\n\nmsg2\n----\nk1: a"},
|
||||
{"msg1\n\n----\n\nk1: a", "msg2", "msg1\n\nmsg2\n\n----\n\nk1: a"},
|
||||
{"msg1", "msg2\n----\nk2: b", "msg1\n\nmsg2\n----\nk2: b"},
|
||||
{"msg1", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk2: b"},
|
||||
{"msg1\n\nk1: a", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk1: a\nk2: b"},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
out := CommitMessageMerge(c.m1, c.m2)
|
||||
assert.Equal(t, c.out, out, "idx=%d, m1=%q m2=%q", i, c.m1, c.m2)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -91,7 +91,7 @@ func syncGitConfig(ctx context.Context) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// By default partial clones are disabled, enable them from git v2.22
|
||||
// By default, partial clones are disabled, enable them from git v2.22
|
||||
if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
|
||||
if err = configSet(ctx, "uploadpack.allowfilter", "true"); err != nil {
|
||||
return err
|
||||
@@ -114,9 +114,23 @@ func syncGitConfig(ctx context.Context) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
GlobalConfig = &GlobalConfigStruct{}
|
||||
// HINT: GIT-DIFF-TREE-UI-CONFIG: Git's bug: git-diff-tree loads config with /* no "diff" UI options */ (since 20 years ago).
|
||||
// https://github.com/git/git/blame/5d2e7709234afea1b6ddb25cd4f60d3d5fb3c200/builtin/diff-tree.c#L127
|
||||
// Although document and manual say that "git-diff-tree" supports "diff.orderfile" option, but it is not actually supported.
|
||||
// So we need to apply the diff.orderfile explicitly in our code.
|
||||
GlobalConfig.DiffOrderFile, _ = configGet(ctx, "diff.orderfile")
|
||||
return nil
|
||||
}
|
||||
|
||||
func configGet(ctx context.Context, key string) (string, error) {
|
||||
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
|
||||
if err != nil && !gitcmd.IsErrorExitCode(err, 1) {
|
||||
return "", fmt.Errorf("failed to get git config %s, err: %w", key, err)
|
||||
}
|
||||
return strings.TrimRight(stdout, "\r\n"), nil
|
||||
}
|
||||
|
||||
func configSet(ctx context.Context, key, value string) error {
|
||||
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").
|
||||
AddDynamicArguments(key).
|
||||
|
||||
+8
-1
@@ -36,7 +36,14 @@ type Features struct {
|
||||
SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base"
|
||||
}
|
||||
|
||||
var defaultFeatures *Features
|
||||
type GlobalConfigStruct struct {
|
||||
DiffOrderFile string
|
||||
}
|
||||
|
||||
var (
|
||||
defaultFeatures *Features
|
||||
GlobalConfig *GlobalConfigStruct
|
||||
)
|
||||
|
||||
func (f *Features) CheckVersionAtLeast(atLeast string) bool {
|
||||
return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import "gitea.dev/modules/git/gitcmd"
|
||||
|
||||
func HandleGitCmdHTTPRedirection(cmd *gitcmd.Command, targets ...string) {
|
||||
// Protect from SSRF vector (e.g. migrating from an attacker URL).
|
||||
// cmd.AddConfig("http.followRedirects", "false")
|
||||
// However, we can't do so at the moment:
|
||||
// this fails due to 301: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}
|
||||
// this succeeds: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}.git
|
||||
// FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future
|
||||
}
|
||||
+1
-3
@@ -121,9 +121,7 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
|
||||
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
|
||||
cmd.AddArguments("-c", "http.followRedirects=false")
|
||||
HandleGitCmdHTTPRedirection(cmd, from, to)
|
||||
if opts.SkipTLSVerify {
|
||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestRepoIsEmpty(t *testing.T) {
|
||||
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
|
||||
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
||||
func TestCloneRefusesRedirects(t *testing.T) {
|
||||
t.Skip("FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future")
|
||||
var targetHit atomic.Bool
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetHit.Store(true)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -32,3 +32,8 @@ type RepoTopicOptions struct {
|
||||
// list of topic names
|
||||
Topics []string `json:"topics"`
|
||||
}
|
||||
|
||||
// TopicListResponse returns a list of TopicResponse
|
||||
type TopicListResponse struct {
|
||||
Topics []*TopicResponse `json:"topics"`
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"artifacts": "Artifacts",
|
||||
"expired": "Expired",
|
||||
"artifact_expires_at": "Expires at %s",
|
||||
"artifact_expired_at": "Expired at %s",
|
||||
"confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?",
|
||||
"archived": "Archived",
|
||||
"concept_system_global": "Global",
|
||||
@@ -2251,7 +2252,6 @@
|
||||
"repo.settings.webhook_deletion_success": "The webhook has been removed.",
|
||||
"repo.settings.webhook.test_delivery": "Test Push Event",
|
||||
"repo.settings.webhook.test_delivery_desc": "Test this webhook with a fake push event.",
|
||||
"repo.settings.webhook.test_delivery_desc_disabled": "To test this webhook with a fake event, activate it.",
|
||||
"repo.settings.webhook.request": "Request",
|
||||
"repo.settings.webhook.response": "Response",
|
||||
"repo.settings.webhook.headers": "Headers",
|
||||
@@ -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",
|
||||
@@ -3784,6 +3784,9 @@
|
||||
"actions.runs.pushed_by": "pushed by",
|
||||
"actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s",
|
||||
"actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s",
|
||||
"actions.runs.no_runner_online": "No runner is online to pick up this job.",
|
||||
"actions.runs.waiting_for_available_runner": "Waiting for a matching runner to become available.",
|
||||
"actions.runs.waiting_for_dependent_jobs": "Waiting for the following jobs to complete: %s",
|
||||
"actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.",
|
||||
"actions.runs.no_job": "The workflow must contain at least one job",
|
||||
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
||||
@@ -3804,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",
|
||||
|
||||
@@ -67,15 +67,34 @@ func GetRepositoryFile(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
branch := ctx.PathParam("branch")
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.PathParam("branch"), ctx.PathParam("repository"), ctx.PathParam("architecture")),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
// A repository that only contains "noarch" packages has no per-architecture
|
||||
// index. Since noarch packages are installable on every architecture, fall
|
||||
// back to the noarch index so clients requesting their own architecture
|
||||
// (e.g. x86_64) can still discover them.
|
||||
if errors.Is(err, util.ErrNotExist) && architecture != alpine_module.NoArch {
|
||||
s, u, pf, err = packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, alpine_module.NoArch),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestHook(ctx *context.APIContext) {
|
||||
commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit)
|
||||
|
||||
commitID := ctx.Repo.Commit.ID.String()
|
||||
if err := webhook_service.PrepareWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{
|
||||
if err := webhook_service.PrepareTestWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{
|
||||
Ref: ref,
|
||||
Before: commitID,
|
||||
After: commitID,
|
||||
|
||||
@@ -300,7 +300,7 @@ func TopicSearch(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"topics": topicResponses,
|
||||
ctx.JSON(http.StatusOK, api.TopicListResponse{
|
||||
Topics: topicResponses,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ type swaggerFileDeleteResponse struct {
|
||||
// swagger:response TopicListResponse
|
||||
type swaggerTopicListResponse struct {
|
||||
// in: body
|
||||
Body []api.TopicResponse `json:"body"`
|
||||
Body api.TopicListResponse `json:"body"`
|
||||
}
|
||||
|
||||
// TopicNames
|
||||
|
||||
@@ -46,7 +46,7 @@ type swaggerResponseUserHeatmapData struct {
|
||||
// swagger:response UserSettings
|
||||
type swaggerResponseUserSettings struct {
|
||||
// in:body
|
||||
Body []api.UserSettings `json:"body"`
|
||||
Body api.UserSettings `json:"body"`
|
||||
}
|
||||
|
||||
// BadgeList
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -207,9 +207,24 @@ func IntrospectOAuth(ctx *context.Context) {
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func oauthDoerAuthorizePreCheck(ctx *context.Context, formState string) bool {
|
||||
if ctx.DoerNeedTwoFactorAuth() {
|
||||
handleAuthorizeError(ctx, AuthorizeError{
|
||||
ErrorCode: ErrorCodeAccessDenied,
|
||||
ErrorDescription: "two-factor authentication is required",
|
||||
State: formState,
|
||||
}, "")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AuthorizeOAuth manages authorize requests
|
||||
func AuthorizeOAuth(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.AuthorizationForm)
|
||||
if !oauthDoerAuthorizePreCheck(ctx, form.State) {
|
||||
return
|
||||
}
|
||||
errs := binding.Errors{}
|
||||
errs = form.Validate(ctx.Req, errs)
|
||||
if len(errs) > 0 {
|
||||
@@ -385,6 +400,10 @@ func AuthorizeOAuth(ctx *context.Context) {
|
||||
// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
|
||||
func GrantApplicationOAuth(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.GrantApplicationForm)
|
||||
if !oauthDoerAuthorizePreCheck(ctx, form.State) {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
|
||||
ctx.Session.Get("redirect_uri") != form.RedirectURI {
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
|
||||
@@ -382,6 +382,8 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
// └ deep_job (regular)
|
||||
// cross_caller (caller, cross-repo, expanded)
|
||||
// └ external_job (regular)
|
||||
// build (linux|windows|macos) (regular matrix; graph folds into one "build" node)
|
||||
// build-call (linux|windows|macos) (caller matrix, each calls build.yml; folds into one "build-call" node like "build")
|
||||
// final (regular, needs local_caller + cross_caller)
|
||||
const (
|
||||
prepareID = int64(400)
|
||||
@@ -392,6 +394,21 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
crossCallerID = int64(405)
|
||||
externalJobID = int64(406)
|
||||
finalID = int64(407)
|
||||
|
||||
// Regular matrix set – the graph already folds these into a single "build" node.
|
||||
buildLinuxID = int64(410)
|
||||
buildWindowsID = int64(411)
|
||||
buildMacosID = int64(412)
|
||||
|
||||
// Caller matrix set – each matrix leg calls the same reusable workflow. #38466: like the
|
||||
// regular "build" matrix above, these fold into one "build-call" node. Matrix legs share a
|
||||
// single JobID, so the legs below use JobID "build-call" and differ only by their name suffix.
|
||||
buildCallLinuxID = int64(420)
|
||||
buildCallWindowsID = int64(421)
|
||||
buildCallMacosID = int64(422)
|
||||
buildCallLinuxJobID = int64(423)
|
||||
buildCallWinJobID = int64(424)
|
||||
buildCallMacJobID = int64(425)
|
||||
)
|
||||
|
||||
resp.State.Run.Jobs = []*actions.ViewJob{
|
||||
@@ -432,6 +449,53 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
Status: actions_model.StatusWaiting.String(), Duration: "0s",
|
||||
ParentJobID: crossCallerID,
|
||||
},
|
||||
|
||||
// Regular matrix "build" – these fold into one matrix node in the graph. The matrix legs
|
||||
// share a single JobID ("build"); the " (variant)" name suffix distinguishes the legs.
|
||||
{
|
||||
ID: buildLinuxID, Link: jobLink(buildLinuxID), JobID: "build", Name: "build (linux)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
|
||||
},
|
||||
{
|
||||
ID: buildWindowsID, Link: jobLink(buildWindowsID), JobID: "build", Name: "build (windows)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
|
||||
},
|
||||
{
|
||||
ID: buildMacosID, Link: jobLink(buildMacosID), JobID: "build", Name: "build (macos)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
|
||||
},
|
||||
|
||||
// Caller matrix "build-call" – each leg calls the same reusable workflow. #38466: like the
|
||||
// regular "build" matrix above, these fold into one node. The matrix legs share a single
|
||||
// JobID ("build-call"); the " (variant)" name suffix distinguishes the legs.
|
||||
{
|
||||
ID: buildCallLinuxID, Link: jobLink(buildCallLinuxID), JobID: "build-call", Name: "build-call (linux)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallLinuxJobID, Link: jobLink(buildCallLinuxJobID), JobID: "bc_linux_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", ParentJobID: buildCallLinuxID,
|
||||
},
|
||||
{
|
||||
ID: buildCallWindowsID, Link: jobLink(buildCallWindowsID), JobID: "build-call", Name: "build-call (windows)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallWinJobID, Link: jobLink(buildCallWinJobID), JobID: "bc_windows_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", ParentJobID: buildCallWindowsID,
|
||||
},
|
||||
{
|
||||
ID: buildCallMacosID, Link: jobLink(buildCallMacosID), JobID: "build-call", Name: "build-call (macos)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallMacJobID, Link: jobLink(buildCallMacJobID), JobID: "bc_macos_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", ParentJobID: buildCallMacosID,
|
||||
},
|
||||
|
||||
{
|
||||
ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final",
|
||||
Status: actions_model.StatusBlocked.String(), Duration: "0s",
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/storage"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
@@ -566,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
|
||||
@@ -577,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()
|
||||
@@ -588,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()
|
||||
|
||||
@@ -647,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(),
|
||||
@@ -672,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.
|
||||
@@ -716,9 +714,10 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts))
|
||||
for _, art := range arts {
|
||||
resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{
|
||||
Name: art.ArtifactName,
|
||||
Size: art.FileSize,
|
||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||
Name: art.ArtifactName,
|
||||
Size: art.FileSize,
|
||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||
ExpiresUnix: int64(art.ExpiredUnix),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -752,6 +751,8 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
|
||||
resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
|
||||
if run.NeedApproval {
|
||||
resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
|
||||
} else if detail := describePendingJobDetail(ctx, current, jobs); detail != "" {
|
||||
resp.State.CurrentJob.Detail = detail
|
||||
}
|
||||
resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
|
||||
resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json
|
||||
@@ -766,6 +767,78 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
|
||||
}
|
||||
}
|
||||
|
||||
// describePendingJobDetail explains why a blocked or waiting job has not started
|
||||
// yet, so the user can tell whether it is waiting on its dependencies or on an
|
||||
// available runner. It returns an empty string when the job is not pending or the
|
||||
// cause can't be determined (the caller keeps the generic status label then).
|
||||
func describePendingJobDetail(ctx *context_module.Context, current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) string {
|
||||
switch {
|
||||
case current.Status.IsBlocked():
|
||||
// A blocked job is held back by the jobs listed in its `needs`.
|
||||
if pending := pendingNeeds(current, jobs); len(pending) > 0 {
|
||||
return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", "))
|
||||
}
|
||||
case current.Status.IsWaiting():
|
||||
// A waiting job has no runner to pick it up yet. A busy runner is still
|
||||
// "online", so distinguish three cases: no runner online at all, online
|
||||
// runners but none match the labels, and a matching runner that is busy.
|
||||
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
|
||||
RepoID: current.RepoID,
|
||||
IsOnline: optional.Some(true),
|
||||
WithAvailable: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("FindRunners for job %d: %v", current.ID, err)
|
||||
return ""
|
||||
}
|
||||
hasOnlineRunner, hasMatchingRunner := false, false
|
||||
for _, runner := range runners {
|
||||
if runner.IsDisabled {
|
||||
continue
|
||||
}
|
||||
hasOnlineRunner = true
|
||||
if runner.CanMatchLabels(current.RunsOn) {
|
||||
hasMatchingRunner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !hasOnlineRunner:
|
||||
return ctx.Locale.TrString("actions.runs.no_runner_online")
|
||||
case !hasMatchingRunner:
|
||||
return ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(current.RunsOn, ", "))
|
||||
default:
|
||||
// A matching runner exists but hasn't claimed the job, so it is busy.
|
||||
return ctx.Locale.TrString("actions.runs.waiting_for_available_runner")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pendingNeeds returns the `needs` keys of jobs the given job depends on that
|
||||
// have not finished yet, scoped to the same parent job (matrix expansions of a
|
||||
// need are all required to be done). Unresolved needs are treated as pending.
|
||||
func pendingNeeds(current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) []string {
|
||||
var pending []string
|
||||
for _, need := range current.Needs {
|
||||
found, allDone := false, true
|
||||
for _, job := range jobs {
|
||||
if job.ParentJobID != current.ParentJobID || job.JobID != need {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !job.Status.IsDone() {
|
||||
allDone = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || !allDone {
|
||||
pending = append(pending, need)
|
||||
}
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
|
||||
var viewJobs []*ViewJobStep
|
||||
var logs []*ViewStepLog
|
||||
@@ -931,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
|
||||
}
|
||||
|
||||
@@ -138,3 +138,48 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T)
|
||||
}
|
||||
assert.Equal(t, expectedViewJobs, viewJobSteps)
|
||||
}
|
||||
|
||||
func TestPendingNeeds(t *testing.T) {
|
||||
current := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build", "test"}}
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "test", Status: actions_model.StatusRunning},
|
||||
}
|
||||
// "test" is not done yet, "build" succeeded, so only "test" blocks.
|
||||
assert.Equal(t, []string{"test"}, pendingNeeds(current, jobs))
|
||||
|
||||
t.Run("all needs done", func(t *testing.T) {
|
||||
done := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "test", Status: actions_model.StatusSkipped},
|
||||
}
|
||||
assert.Empty(t, pendingNeeds(current, done))
|
||||
})
|
||||
|
||||
t.Run("matrix expansion all required", func(t *testing.T) {
|
||||
matrix := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "build", Status: actions_model.StatusRunning},
|
||||
{JobID: "test", Status: actions_model.StatusSuccess},
|
||||
}
|
||||
assert.Equal(t, []string{"build"}, pendingNeeds(current, matrix))
|
||||
})
|
||||
|
||||
t.Run("unresolved need treated as pending", func(t *testing.T) {
|
||||
missing := []*actions_model.ActionRunJob{current}
|
||||
assert.Equal(t, []string{"build", "test"}, pendingNeeds(current, missing))
|
||||
})
|
||||
|
||||
t.Run("parent job scope", func(t *testing.T) {
|
||||
// a same-named job under a different parent must not satisfy the need
|
||||
scoped := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build"}, ParentJobID: 5}
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
scoped,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess, ParentJobID: 0},
|
||||
}
|
||||
assert.Equal(t, []string{"build"}, pendingNeeds(scoped, jobs))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -495,7 +495,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxCommitSigning(ctx *context.Con
|
||||
|
||||
wontSignReason := ""
|
||||
if ctx.Doer != nil {
|
||||
sign, key, _, err := asymkey_service.SignMerge(ctx, pull, ctx.Doer, ctx.Repo.GitRepo)
|
||||
sign, key, _, err := asymkey_service.SignMerge(ctx, pull, ctx.Doer, ctx.Repo.GitRepo, pull.BaseBranch, pull.GetGitHeadRefName())
|
||||
data.willSign = sign
|
||||
data.signingKeyMergeDisplay = asymkey_model.GetDisplaySigningKey(key)
|
||||
if err != nil {
|
||||
|
||||
@@ -742,6 +742,7 @@ func MoveIssues(ctx *context.Context) {
|
||||
form := &movedIssuesForm{}
|
||||
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
|
||||
ctx.ServerError("DecodeMovedIssuesForm", err)
|
||||
return
|
||||
}
|
||||
|
||||
issueIDs := make([]int64, 0, len(form.Issues))
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
pull_model "gitea.dev/models/pull"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/svg"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -62,20 +64,23 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
hasPendingPullRequestMergeTip = ctx.Locale.Tr("repo.pulls.auto_merge_has_pending_schedule", pendingPullRequestMerge.Doer.Name, createdPRMergeStr)
|
||||
}
|
||||
|
||||
defaultMergeTitle, defaultMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.ServerError("GetDefaultMergeMessage", err)
|
||||
return
|
||||
}
|
||||
defaultSquashMergeTitle, defaultSquashMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.ServerError("GetDefaultSquashMergeMessage", err)
|
||||
return
|
||||
}
|
||||
|
||||
var defaultMergeTitle, defaultMergeBody string
|
||||
var defaultSquashMergeTitle, defaultSquashMergeBody string
|
||||
var defaultSquashMergeCommitMessages string
|
||||
if !prInfo.IsPullRequestBroken {
|
||||
defaultSquashMergeCommitMessages = pull_service.GetSquashMergeCommitMessages(ctx, pull)
|
||||
var err error
|
||||
defaultMergeTitle, defaultMergeBody, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetDefaultMergeMessage for style %s failed, error: %v", mergeStyle, err)
|
||||
}
|
||||
defaultSquashMergeTitle, defaultSquashMergeBody, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetDefaultMergeMessage for squash failed, error: %v", err)
|
||||
}
|
||||
defaultSquashMergeCommitMessages, err = pull_service.GetSquashMergeCommitMessages(ctx, pull)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetSquashMergeCommitMessages failed, error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
allOverridableChecksOk := !prInfo.MergeBoxData.hasOverridableBlockers
|
||||
@@ -106,7 +111,6 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
|
||||
// if this pr can be merged now, then hide the auto merge
|
||||
generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
|
||||
|
||||
var mergeStyles []any
|
||||
if pull.IsStatusMergeable() {
|
||||
mergeStyles = []any{
|
||||
@@ -138,7 +142,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
"allowed": prConfig.AllowSquash,
|
||||
"textDoMerge": ctx.Locale.Tr("repo.pulls.squash_merge_pull_request"),
|
||||
"mergeTitleFieldText": defaultSquashMergeTitle,
|
||||
"mergeMessageFieldText": defaultSquashMergeCommitMessages + defaultSquashMergeBody,
|
||||
"mergeMessageFieldText": git.CommitMessageMerge(defaultSquashMergeCommitMessages, defaultSquashMergeBody),
|
||||
"hideAutoMerge": generalHideAutoMerge,
|
||||
},
|
||||
map[string]any{
|
||||
|
||||
@@ -74,7 +74,7 @@ func SettingsProtectedBranch(c *context.Context) {
|
||||
|
||||
c.Data["PageIsSettingsBranches"] = true
|
||||
c.Data["Title"] = c.Locale.TrString("repo.settings.protected_branch") + " - " + rule.RuleName
|
||||
users, err := access_model.GetUsersWithUnitAccess(c, c.Repo.Repository, perm.AccessModeRead, unit.TypePullRequests)
|
||||
users, err := access_model.GetUsersWithAnyUnitAccess(c, c.Repo.Repository, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
c.ServerError("GetUsersWithUnitAccess", err)
|
||||
return
|
||||
|
||||
@@ -149,7 +149,7 @@ func setTagsContext(ctx *context.Context) error {
|
||||
}
|
||||
ctx.Data["ProtectedTags"] = protectedTags
|
||||
|
||||
users, err := access_model.GetUsersWithUnitAccess(ctx, ctx.Repo.Repository, perm.AccessModeRead, unit.TypePullRequests)
|
||||
users, err := access_model.GetUsersWithAnyUnitAccess(ctx, ctx.Repo.Repository, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUsersWithUnitAccess", err)
|
||||
return err
|
||||
|
||||
@@ -664,19 +664,14 @@ func TestWebhook(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Grab latest commit or fake one if it's empty repository.
|
||||
// Note: in old code, the "ctx.Repo.Commit" is the last commit of the default branch.
|
||||
// New code doesn't set that commit, so it always uses the fake commit to test webhook.
|
||||
commit := ctx.Repo.Commit
|
||||
if commit == nil {
|
||||
ghost := user_model.NewGhostUser()
|
||||
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
|
||||
commit = &git.Commit{
|
||||
ID: objectFormat.EmptyObjectID(),
|
||||
Author: ghost.NewGitSig(),
|
||||
Committer: ghost.NewGitSig(),
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "This is a fake commit"},
|
||||
}
|
||||
// use a fake commit to test webhook
|
||||
ghostUser := user_model.NewGhostUser()
|
||||
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
|
||||
commit := &git.Commit{
|
||||
ID: objectFormat.EmptyObjectID(),
|
||||
Author: ghostUser.NewGitSig(),
|
||||
Committer: ghostUser.NewGitSig(),
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "This is a fake commit for webhook push test"},
|
||||
}
|
||||
|
||||
apiUser := convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone)
|
||||
@@ -697,7 +692,7 @@ func TestWebhook(ctx *context.Context) {
|
||||
|
||||
commitID := commit.ID.String()
|
||||
p := &api.PushPayload{
|
||||
Ref: git.BranchPrefix + ctx.Repo.Repository.DefaultBranch,
|
||||
Ref: git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch).String(),
|
||||
Before: commitID,
|
||||
After: commitID,
|
||||
CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID),
|
||||
@@ -708,8 +703,8 @@ func TestWebhook(ctx *context.Context) {
|
||||
Pusher: apiUser,
|
||||
Sender: apiUser,
|
||||
}
|
||||
if err := webhook_service.PrepareWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
|
||||
ctx.Flash.Error("PrepareWebhook: " + err.Error())
|
||||
if err := webhook_service.PrepareTestWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
|
||||
ctx.Flash.Error("PrepareTestWebhook: " + err.Error())
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
ctx.Flash.Info(ctx.Tr("repo.settings.webhook.delivery.success"))
|
||||
|
||||
@@ -373,16 +373,18 @@ func RunnerBulkActionPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var runnerIDs []int64
|
||||
if rCtx.IsAdmin {
|
||||
// ATTENTION: it completely depends on the assumption that the doer is "site admin"
|
||||
// So it doesn't do extra permission check to the runner IDs
|
||||
// In the future, if you need to support such operation on non-admin pages, be careful!
|
||||
runnerIDs = ctx.FormStringInt64s("ids")
|
||||
} else {
|
||||
if !rCtx.IsAdmin {
|
||||
ctx.HTTPError(http.StatusForbidden, "bulk actions are admin-only")
|
||||
return
|
||||
}
|
||||
// ATTENTION: it completely depends on the assumption that the doer is "site admin"
|
||||
// So it doesn't do extra permission check to the runner IDs
|
||||
// In the future, if you need to support such operation on non-admin pages, be careful!
|
||||
runnerIDs := ctx.FormStringInt64s("ids")
|
||||
if len(runnerIDs) == 0 {
|
||||
ctx.HTTPError(http.StatusBadRequest, "missing runner IDs")
|
||||
return
|
||||
}
|
||||
|
||||
action := ctx.FormString("action")
|
||||
var successKey, failedKey string
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
@@ -26,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,
|
||||
})
|
||||
@@ -96,7 +97,7 @@ func checkJobsByRunID(ctx context.Context, runID int64) error {
|
||||
continue
|
||||
}
|
||||
if err := EmitJobsIfReadyByRun(rid); err != nil {
|
||||
log.Error("re-emit run %d after caller expansion: %v", rid, err)
|
||||
log.Error("re-emit run %d: %v", rid, err)
|
||||
}
|
||||
}
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, result.CancelledJobs)
|
||||
@@ -147,57 +148,66 @@ func createCommitStatusesForJobsByRun(ctx context.Context, jobs []*actions_model
|
||||
return nil
|
||||
}
|
||||
|
||||
// findBlockedRunIDByConcurrency finds a blocked concurrent run in a repo and returns 0 when there is no blocked run.
|
||||
func findBlockedRunIDByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (int64, error) {
|
||||
// findConcurrencyWaiterToWake returns a run (other than excludeRunID) blocked on the group that can be woken now
|
||||
func findConcurrencyWaiterToWake(ctx context.Context, repoID, excludeRunID int64, concurrencyGroup string) (int64, error) {
|
||||
if concurrencyGroup == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// The slot should be free before any waiter can proceed.
|
||||
holderAttempts, holderJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusRunning, actions_model.StatusCancelling})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("find concurrency-group holders: %w", err)
|
||||
}
|
||||
if len(holderAttempts) > 0 || len(holderJobs) > 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
cAttempts, cJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
||||
return 0, fmt.Errorf("find blocked concurrent runs: %w", err)
|
||||
}
|
||||
|
||||
if len(cAttempts) > 0 {
|
||||
return cAttempts[0].RunID, nil
|
||||
for _, a := range cAttempts {
|
||||
if a.RunID != excludeRunID {
|
||||
return a.RunID, nil
|
||||
}
|
||||
}
|
||||
if len(cJobs) > 0 {
|
||||
return cJobs[0].RunID, nil
|
||||
for _, j := range cJobs {
|
||||
if j.RunID != excludeRunID {
|
||||
return j.RunID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func checkBlockedConcurrentRun(ctx context.Context, repoID, runID int64) (*jobsCheckResult, error) {
|
||||
concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get run %d: %w", runID, err)
|
||||
}
|
||||
if concurrentRun.NeedApproval {
|
||||
return &jobsCheckResult{}, nil
|
||||
}
|
||||
|
||||
return checkJobsOfCurrentRunAttempt(ctx, concurrentRun)
|
||||
}
|
||||
|
||||
// checkRunConcurrency rechecks runs blocked by concurrency that may become unblocked after the current run releases a workflow-level or job-level concurrency group.
|
||||
// RunIDsToReEmit propagates from inner checkJobsOfCurrentRunAttempt calls; see that function's doc.
|
||||
// checkRunConcurrency wakes a run blocked by concurrency that may become runnable now that
|
||||
// the current run's activity may have freed a workflow-level or job-level concurrency group.
|
||||
func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) {
|
||||
result := &jobsCheckResult{}
|
||||
checkedConcurrencyGroup := make(container.Set[string])
|
||||
|
||||
collect := func(concurrencyGroup string) error {
|
||||
concurrentRunID, err := findBlockedRunIDByConcurrency(ctx, run.RepoID, concurrencyGroup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find blocked run by concurrency: %w", err)
|
||||
}
|
||||
if concurrentRunID > 0 {
|
||||
r, err := checkBlockedConcurrentRun(ctx, run.RepoID, concurrentRunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.merge(r)
|
||||
}
|
||||
checkedConcurrencyGroup.Add(concurrencyGroup)
|
||||
|
||||
// Exclude run.ID: this run's own jobs are resolved by checkJobsOfCurrentRunAttempt, no need to re-emit.
|
||||
concurrentRunID, err := findConcurrencyWaiterToWake(ctx, run.RepoID, run.ID, concurrencyGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if concurrentRunID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, concurrentRunID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get concurrent run %d: %w", concurrentRunID, err)
|
||||
}
|
||||
// A run awaiting approval is not advanced by concurrency; ApproveRuns emits it once approved.
|
||||
if concurrentRun.NeedApproval {
|
||||
return nil
|
||||
}
|
||||
|
||||
result.RunIDsToReEmit = append(result.RunIDsToReEmit, concurrentRunID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -282,10 +292,26 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
|
||||
switch status {
|
||||
case actions_model.StatusWaiting:
|
||||
if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil {
|
||||
return fmt.Errorf("trigger caller-ready %d: %w", job.ID, err)
|
||||
// Terminal expansion failure (an invalid/unresolvable reusable workflow): fail this caller.
|
||||
log.Warn("caller %d cannot be expanded: %v", job.ID, err)
|
||||
job.Status = actions_model.StatusFailure
|
||||
job.Stopped = timeutil.TimeStampNow()
|
||||
if n, uerr := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked, "is_expanded": false}, "status", "stopped"); uerr != nil {
|
||||
return fmt.Errorf("mark unexpandable caller %d failed: %w", job.ID, uerr)
|
||||
} else if n == 1 {
|
||||
log.Warn("unexpandable caller %d has been marked as failed", job.ID)
|
||||
result.UpdatedJobs = append(result.UpdatedJobs, job)
|
||||
// Re-emit so the failed caller's dependents get resolved on the next pass.
|
||||
expandedAnyCaller = true
|
||||
} else {
|
||||
// A concurrent writer advanced the caller; restore the in-memory state.
|
||||
log.Warn("unexpandable caller %d has been advanced by a concurrent writer, not marking it failed", job.ID)
|
||||
job.Status = actions_model.StatusBlocked
|
||||
job.Stopped = 0
|
||||
}
|
||||
} else {
|
||||
expandedAnyCaller = true
|
||||
}
|
||||
// expandReusableWorkflowCaller inserts children as Blocked. They need a follow-up resolver pass.
|
||||
expandedAnyCaller = true
|
||||
case actions_model.StatusSkipped:
|
||||
job.Status = actions_model.StatusSkipped
|
||||
if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status"); err != nil {
|
||||
@@ -477,7 +503,7 @@ type jobsCheckResult struct {
|
||||
UpdatedJobs []*actions_model.ActionRunJob
|
||||
// CancelledJobs are jobs cancelled by job-level concurrency while preparing to start.
|
||||
CancelledJobs []*actions_model.ActionRunJob
|
||||
// RunIDsToReEmit are runs whose newly expanded reusable workflow callers need another resolver pass.
|
||||
// RunIDsToReEmit are runs that need another resolver pass in their own transaction.
|
||||
RunIDsToReEmit []int64
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// Run A: the triggering run of attempt A
|
||||
// Run A: the triggering run of attempt A. It is done, so it no longer holds "test-cg", which is what lets checkRunConcurrency wake the blocked waiter.
|
||||
runA := &actions_model.ActionRun{
|
||||
RepoID: 4,
|
||||
OwnerID: 1,
|
||||
@@ -211,16 +211,16 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
|
||||
WorkflowID: "test.yml",
|
||||
Index: 9901,
|
||||
Ref: "refs/heads/main",
|
||||
Status: actions_model.StatusRunning,
|
||||
Status: actions_model.StatusSuccess,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runA))
|
||||
|
||||
// Attempt A: an attempt of run A with concurrency group "test-cg"
|
||||
// Attempt A: a done attempt of run A with concurrency group "test-cg"
|
||||
runAAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: 4,
|
||||
RunID: runA.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusRunning,
|
||||
Status: actions_model.StatusSuccess,
|
||||
ConcurrencyGroup: "test-cg",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runAAttempt))
|
||||
@@ -283,9 +283,11 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
|
||||
result, err := checkRunConcurrency(ctx, runA)
|
||||
assert.NoError(t, err)
|
||||
|
||||
if assert.Len(t, result.Jobs, 1) {
|
||||
assert.Equal(t, jobBBlocked.ID, result.Jobs[0].ID)
|
||||
// "test-cg" is free, so the single blocked waiter (run B) is collected for re-emit.
|
||||
if assert.Len(t, result.RunIDsToReEmit, 1) {
|
||||
assert.Equal(t, runB.ID, result.RunIDsToReEmit[0])
|
||||
}
|
||||
assert.Empty(t, result.Jobs)
|
||||
}
|
||||
|
||||
// Test_checkJobsOfCurrentRunAttempt_RunLevelConcurrencyKeepsJobsBlocked verifies that
|
||||
@@ -352,3 +354,82 @@ jobs:
|
||||
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID})
|
||||
assert.Equal(t, actions_model.StatusBlocked, refreshed.Status)
|
||||
}
|
||||
|
||||
// Test_checkRunConcurrency_HeldGroupDoesNotWake verifies that only an unoccupied concurrency group can wake up a blocked run/job.
|
||||
func Test_checkRunConcurrency_HeldGroupDoesNotWake(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// Run A holds "test-cg": its attempt is still running.
|
||||
runA := &actions_model.ActionRun{
|
||||
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
|
||||
Index: 9911, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runA))
|
||||
runAAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: 4, RunID: runA.ID, Attempt: 1, Status: actions_model.StatusRunning, ConcurrencyGroup: "test-cg",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runAAttempt))
|
||||
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Run B is blocked on the same group.
|
||||
runB := &actions_model.ActionRun{
|
||||
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
|
||||
Index: 9912, Ref: "refs/heads/main", Status: actions_model.StatusBlocked,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runB))
|
||||
runBAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: 4, RunID: runB.ID, Attempt: 1, Status: actions_model.StatusBlocked, ConcurrencyGroup: "test-cg",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runBAttempt))
|
||||
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
runA, _, _ = db.GetByID[actions_model.ActionRun](ctx, runA.ID)
|
||||
result, err := checkRunConcurrency(ctx, runA)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// The group is held by run A, so run B must not be woken; A will wake it when it releases the group.
|
||||
assert.Empty(t, result.RunIDsToReEmit)
|
||||
}
|
||||
|
||||
// Test_findConcurrencyWaiterToWake covers the finder's contract: it skips the run being processed (excludeRunID),
|
||||
// returns another blocked waiter when the group is free, and returns 0 while the group is still held.
|
||||
func Test_findConcurrencyWaiterToWake(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
const repoID int64 = 4
|
||||
seed := func(index int64, group string, status actions_model.Status) *actions_model.ActionRun {
|
||||
run := &actions_model.ActionRun{
|
||||
RepoID: repoID, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
|
||||
Index: index, Ref: "refs/heads/main", Status: status,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, run))
|
||||
assert.NoError(t, db.Insert(ctx, &actions_model.ActionRunAttempt{
|
||||
RepoID: repoID, RunID: run.ID, Attempt: 1, Status: status, ConcurrencyGroup: group,
|
||||
}))
|
||||
return run
|
||||
}
|
||||
|
||||
// Free group "excl-cg" with two blocked runs: excluding self returns the other waiter, not self.
|
||||
self := seed(99701, "excl-cg", actions_model.StatusBlocked)
|
||||
other := seed(99702, "excl-cg", actions_model.StatusBlocked)
|
||||
id, err := findConcurrencyWaiterToWake(ctx, repoID, self.ID, "excl-cg")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, other.ID, id)
|
||||
|
||||
// Free group "solo-cg" with only self blocked: excluding it leaves no waiter.
|
||||
solo := seed(99703, "solo-cg", actions_model.StatusBlocked)
|
||||
id, err = findConcurrencyWaiterToWake(ctx, repoID, solo.ID, "solo-cg")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(0), id)
|
||||
|
||||
// Held group "held-cg" (a running holder) has a blocked waiter, but nothing is woken while held.
|
||||
seed(99704, "held-cg", actions_model.StatusRunning)
|
||||
seed(99705, "held-cg", actions_model.StatusBlocked)
|
||||
id, err = findConcurrencyWaiterToWake(ctx, repoID, 0, "held-cg")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(0), id)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -31,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) {
|
||||
@@ -236,30 +251,33 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
return fmt.Errorf("build call payload: %w", err)
|
||||
}
|
||||
|
||||
// 8. Insert direct children of this caller.
|
||||
existingChildren, err := actions_model.GetDirectChildJobsByParent(ctx, caller)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get existing children of caller %d: %w", caller.ID, err)
|
||||
}
|
||||
if len(existingChildren) > 0 {
|
||||
// Should not happen - child jobs cannot be expanded before the caller gets ready
|
||||
return fmt.Errorf("invariant violation: caller %d has %d pre-existing children", caller.ID, len(existingChildren))
|
||||
}
|
||||
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 9. Update caller-related cols.
|
||||
caller.CallPayload = string(callPayload)
|
||||
// 8. Claim the expansion by flipping is_expanded false->true BEFORE inserting any children.
|
||||
// Two concurrent expanders serialize on this row: exactly one winner matches (n==1) and owns the expansion.
|
||||
// Children are only ever inserted by the claim winner, so no duplicate child rows can arise.
|
||||
caller.IsExpanded = true
|
||||
n, err := actions_model.UpdateRunJob(ctx, caller,
|
||||
n, err := actions_model.UpdateRunJob(ctx, caller, builder.And(
|
||||
builder.Eq{"is_expanded": false},
|
||||
"call_secrets", "reusable_workflow_content", "call_payload", "is_expanded")
|
||||
builder.In("status", actions_model.StatusBlocked, actions_model.StatusWaiting),
|
||||
), "is_expanded")
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit caller %d expansion: %w", caller.ID, err)
|
||||
caller.IsExpanded = false // the claim was not established
|
||||
return fmt.Errorf("claim caller %d expansion: %w", caller.ID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("caller %d already expanded by another writer", caller.ID)
|
||||
// Another writer won the expansion, or the caller has been moved to a terminal status (e.g. failed/cancelled).
|
||||
return nil
|
||||
}
|
||||
|
||||
// 9. We own the expansion: insert the direct children.
|
||||
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil {
|
||||
// On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless.
|
||||
return errors.Join(err, undoExpansion(ctx, caller))
|
||||
}
|
||||
|
||||
// 10. Persist the remaining caller metadata (the row is already ours via the claim above).
|
||||
caller.CallPayload = string(callPayload)
|
||||
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "call_payload"); err != nil {
|
||||
return errors.Join(fmt.Errorf("persist caller %d expansion metadata: %w", caller.ID, err), undoExpansion(ctx, caller))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -285,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)
|
||||
@@ -375,3 +397,16 @@ func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// undoExpansion rolls back a partial expansion owned by the current transaction:
|
||||
// it removes the inserted children and releases the is_expanded claim itself.
|
||||
func undoExpansion(ctx context.Context, caller *actions_model.ActionRunJob) error {
|
||||
if err := actions_model.DeleteDirectChildJobsByParent(ctx, caller); err != nil {
|
||||
return fmt.Errorf("delete children of caller %d: %w", caller.ID, err)
|
||||
}
|
||||
caller.IsExpanded = false
|
||||
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "is_expanded"); err != nil {
|
||||
return fmt.Errorf("release caller %d expansion claim: %w", caller.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -206,3 +206,75 @@ func TestResolveUses(t *testing.T) {
|
||||
assert.ErrorContains(t, err, "must point to this Gitea instance")
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// A claimed caller with two children inserted by the aborted expansion, plus a sibling that must survive.
|
||||
caller := &actions_model.ActionRunJob{
|
||||
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "caller", Name: "caller",
|
||||
Status: actions_model.StatusBlocked, IsReusableCaller: true, IsExpanded: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, caller))
|
||||
for _, jobID := range []string{"child1", "child2"} {
|
||||
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{
|
||||
RunID: 991, RepoID: 4, OwnerID: 1, JobID: jobID, Name: jobID,
|
||||
Status: actions_model.StatusBlocked, ParentJobID: caller.ID,
|
||||
}))
|
||||
}
|
||||
sibling := &actions_model.ActionRunJob{
|
||||
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "sibling", Name: "sibling",
|
||||
Status: actions_model.StatusBlocked,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, sibling))
|
||||
|
||||
require.NoError(t, undoExpansion(ctx, caller))
|
||||
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: caller.ID}))
|
||||
assert.False(t, caller.IsExpanded)
|
||||
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID})
|
||||
assert.False(t, refreshed.IsExpanded)
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID})
|
||||
}
|
||||
|
||||
+131
-92
@@ -60,7 +60,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model
|
||||
// The title will be cut off at 255 characters if it's longer than 255 characters.
|
||||
func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error {
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
var hasWaitingCallerJobs bool
|
||||
var needPostCommitEmit bool
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
|
||||
if err != nil {
|
||||
@@ -133,99 +133,15 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
||||
|
||||
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
|
||||
var hasWaitingJobs bool
|
||||
|
||||
for _, v := range jobs {
|
||||
id, job := v.Job()
|
||||
needs := job.Needs()
|
||||
if err := v.SetJob(id, job.EraseNeeds()); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := v.Marshal()
|
||||
|
||||
isReusableWorkflowCaller := job.Uses != ""
|
||||
shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval
|
||||
|
||||
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
|
||||
runJob, jobsToCancel, jobNeedsPostCommitEmit, err := insertRunJob(ctx, run, runAttempt, v, vars, inputs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alloc attempt_job_id: %w", err)
|
||||
}
|
||||
|
||||
job.Name = util.EllipsisDisplayString(job.Name, 255)
|
||||
runJob := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: runAttempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
IsForkPullRequest: run.IsForkPullRequest,
|
||||
Name: job.Name,
|
||||
Attempt: runAttempt.Attempt,
|
||||
WorkflowPayload: payload,
|
||||
JobID: id,
|
||||
AttemptJobID: attemptJobID,
|
||||
Needs: needs,
|
||||
RunsOn: job.RunsOn(),
|
||||
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
|
||||
WorkflowSourceRepoID: run.WorkflowRepoID,
|
||||
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
|
||||
ContinueOnError: job.GetContinueOnError(),
|
||||
}
|
||||
// Parse workflow/job permissions (no clamping here)
|
||||
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
|
||||
runJob.TokenPermissions = perms
|
||||
}
|
||||
|
||||
if isReusableWorkflowCaller {
|
||||
runJob.IsReusableCaller = true
|
||||
runJob.CallUses = job.Uses
|
||||
}
|
||||
|
||||
// check job concurrency
|
||||
if job.RawConcurrency != nil {
|
||||
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal raw concurrency: %w", err)
|
||||
}
|
||||
runJob.RawConcurrency = string(rawConcurrency)
|
||||
|
||||
// do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter
|
||||
if len(needs) == 0 {
|
||||
err = EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate job concurrency: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop
|
||||
// No need to check job concurrency for a blocked job (it will be checked by job emitter later)
|
||||
if runJob.Status == actions_model.StatusWaiting {
|
||||
var jobsToCancel []*actions_model.ActionRunJob
|
||||
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare to start job with concurrency: %w", err)
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
}
|
||||
}
|
||||
|
||||
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
|
||||
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !isReusableWorkflowCaller)
|
||||
if err := db.Insert(ctx, runJob); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// expand reusable caller
|
||||
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
|
||||
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, runJob, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", runJob.ID, err)
|
||||
}
|
||||
// refresh the caller status
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, runJob); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", runJob.ID, err)
|
||||
}
|
||||
hasWaitingCallerJobs = true
|
||||
}
|
||||
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
needPostCommitEmit = needPostCommitEmit || jobNeedsPostCommitEmit
|
||||
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
|
||||
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !runJob.IsReusableCaller)
|
||||
runJobs = append(runJobs, runJob)
|
||||
}
|
||||
|
||||
@@ -249,8 +165,8 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
|
||||
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
||||
|
||||
// Post-commit kick for expanded callers: let job_emitter resolve its child jobs
|
||||
if hasWaitingCallerJobs {
|
||||
// Post-commit kick: let the job emitter resolve jobs if needed
|
||||
if needPostCommitEmit {
|
||||
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
|
||||
log.Error("emit run %d after InsertRun: %v", run.ID, err)
|
||||
}
|
||||
@@ -258,3 +174,126 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertRunJob builds a single run job from a parsed workflow job, evaluates its
|
||||
// job-level concurrency, inserts it, and — for a ready no-needs reusable caller —
|
||||
// inline-expands (or skips) it. It returns the inserted job, any jobs cancelled by
|
||||
// job concurrency, and whether a post-commit emitter pass is needed to resolve the
|
||||
// caller's dependents.
|
||||
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
|
||||
id, job := workflowJob.Job()
|
||||
needs := job.Needs()
|
||||
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
payload, _ := workflowJob.Marshal()
|
||||
|
||||
isReusableWorkflowCaller := job.Uses != ""
|
||||
shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval
|
||||
|
||||
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("alloc attempt_job_id: %w", err)
|
||||
}
|
||||
|
||||
job.Name = util.EllipsisDisplayString(job.Name, 255)
|
||||
runJob := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: runAttempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
IsForkPullRequest: run.IsForkPullRequest,
|
||||
Name: job.Name,
|
||||
Attempt: runAttempt.Attempt,
|
||||
WorkflowPayload: payload,
|
||||
JobID: id,
|
||||
AttemptJobID: attemptJobID,
|
||||
Needs: needs,
|
||||
RunsOn: job.RunsOn(),
|
||||
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
|
||||
WorkflowSourceRepoID: run.WorkflowRepoID,
|
||||
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
|
||||
ContinueOnError: job.GetContinueOnError(),
|
||||
}
|
||||
// Parse workflow/job permissions (no clamping here)
|
||||
if perms := ExtractJobPermissionsFromWorkflow(workflowJob, job); perms != nil {
|
||||
runJob.TokenPermissions = perms
|
||||
}
|
||||
|
||||
if isReusableWorkflowCaller {
|
||||
runJob.IsReusableCaller = true
|
||||
runJob.CallUses = job.Uses
|
||||
}
|
||||
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
// check job concurrency
|
||||
if job.RawConcurrency != nil {
|
||||
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("marshal raw concurrency: %w", err)
|
||||
}
|
||||
runJob.RawConcurrency = string(rawConcurrency)
|
||||
|
||||
// do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter
|
||||
if len(needs) == 0 {
|
||||
if err := EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs); err != nil {
|
||||
return nil, nil, false, fmt.Errorf("evaluate job concurrency: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop
|
||||
// No need to check job concurrency for a blocked job (it will be checked by job emitter later)
|
||||
if runJob.Status == actions_model.StatusWaiting {
|
||||
var jobsToCancel []*actions_model.ActionRunJob
|
||||
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("prepare to start job with concurrency: %w", err)
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, runJob); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
// expand reusable caller
|
||||
var needPostCommitEmit bool
|
||||
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
|
||||
if err := processInlineReusableCaller(ctx, run, runAttempt, runJob, vars); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
// A processed caller always needs a resolver pass:
|
||||
// - if the caller is expanded, resolve its children jobs;
|
||||
// - if the caller is skipped, propagate its state to its dependents
|
||||
needPostCommitEmit = true
|
||||
}
|
||||
|
||||
return runJob, cancelledConcurrencyJobs, needPostCommitEmit, nil
|
||||
}
|
||||
|
||||
// processInlineReusableCaller evaluates a no-needs reusable caller's own `if:` and
|
||||
// either inline-expands it into child jobs or marks it skipped.
|
||||
// (A caller with needs is Blocked and gets its `if:` evaluated by the job emitter instead.)
|
||||
func processInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
|
||||
shouldStart, err := evaluateJobIf(ctx, run, runAttempt, caller, vars, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate caller %d if: %w", caller.ID, err)
|
||||
}
|
||||
if shouldStart {
|
||||
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, caller, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", caller.ID, err)
|
||||
}
|
||||
// refresh the caller status
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, caller); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", caller.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
caller.Status = actions_model.StatusSkipped
|
||||
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "status"); err != nil {
|
||||
return fmt.Errorf("skip caller %d: %w", caller.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,16 +6,22 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
"gitea.dev/services/convert"
|
||||
)
|
||||
|
||||
// StartScheduleTasks start the task
|
||||
@@ -102,7 +108,19 @@ func startTasks(ctx context.Context) error {
|
||||
// It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job.
|
||||
func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
|
||||
cron := spec.Schedule
|
||||
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec)
|
||||
|
||||
// Scheduled runs carry no webhook payload; synthesize what github.event.* expects.
|
||||
if err := spec.Repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
fields := map[string]any{
|
||||
"repository": convert.ToRepo(ctx, spec.Repo, access_model.Permission{AccessMode: perm_model.AccessModeRead}),
|
||||
"sender": convert.ToUser(ctx, user_model.NewActionsUser(), nil),
|
||||
}
|
||||
if spec.Repo.Owner.IsOrganization() {
|
||||
fields["organization"] = convert.ToOrganization(ctx, organization.OrgFromUser(spec.Repo.Owner))
|
||||
}
|
||||
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec, fields)
|
||||
|
||||
// Create a new action run based on the schedule
|
||||
run := &actions_model.ActionRun{
|
||||
@@ -134,7 +152,7 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS
|
||||
return nil
|
||||
}
|
||||
|
||||
func withScheduleInEventPayload(eventPayload, schedule string) string {
|
||||
func withScheduleInEventPayload(eventPayload, schedule string, fields map[string]any) string {
|
||||
if schedule == "" {
|
||||
return eventPayload
|
||||
}
|
||||
@@ -153,6 +171,7 @@ func withScheduleInEventPayload(eventPayload, schedule string) string {
|
||||
event = map[string]any{}
|
||||
}
|
||||
|
||||
maps.Copy(event, fields)
|
||||
event["schedule"] = schedule
|
||||
updatedPayload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
t.Run("adds schedule to existing payload", func(t *testing.T) {
|
||||
payload := `{"ref":"refs/heads/main"}`
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
@@ -23,7 +24,7 @@ func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("adds schedule to null payload", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("null", "37 12 5 1 2")
|
||||
updated := withScheduleInEventPayload("null", "37 12 5 1 2", nil)
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
@@ -31,22 +32,37 @@ func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("adds schedule to empty payload", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("", "37 12 5 1 2")
|
||||
updated := withScheduleInEventPayload("", "37 12 5 1 2", nil)
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
assert.Equal(t, "37 12 5 1 2", event["schedule"])
|
||||
})
|
||||
|
||||
t.Run("adds schedule with repository, sender, organization", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("null", "@weekly", map[string]any{
|
||||
"repository": &api.Repository{Name: "test-repo"},
|
||||
"sender": &api.User{UserName: "test-user"},
|
||||
"organization": &api.Organization{Name: "test-org"},
|
||||
})
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
assert.Equal(t, "@weekly", event["schedule"])
|
||||
assert.Equal(t, "test-repo", event["repository"].(map[string]any)["name"])
|
||||
assert.Equal(t, "test-user", event["sender"].(map[string]any)["login"])
|
||||
assert.Equal(t, "test-org", event["organization"].(map[string]any)["name"])
|
||||
})
|
||||
|
||||
t.Run("keeps payload when schedule empty", func(t *testing.T) {
|
||||
payload := `{"ref":"refs/heads/main"}`
|
||||
updated := withScheduleInEventPayload(payload, "")
|
||||
updated := withScheduleInEventPayload(payload, "", nil)
|
||||
assert.Equal(t, payload, updated)
|
||||
})
|
||||
|
||||
t.Run("keeps payload when malformed JSON", func(t *testing.T) {
|
||||
payload := `not a json object`
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
|
||||
assert.Equal(t, payload, updated)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,6 +144,11 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// The dispatch callbacks fill boolean inputs as the strings "true"/"false". Normalize them to
|
||||
// native JSON booleans so `type: boolean` inputs match GitHub, whose `inputs` context preserves
|
||||
// booleans as booleans. Without this, a server-side needs-gated job `if: inputs.flag == true`
|
||||
// evaluates against the string "true" and never matches, leaving the job blocked forever.
|
||||
coerceDispatchInputTypes(workflowDispatch, inputsWithDefaults)
|
||||
|
||||
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
@@ -169,6 +174,23 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
return run.ID, nil
|
||||
}
|
||||
|
||||
// coerceDispatchInputTypes normalizes workflow_dispatch input values to the JSON types declared by
|
||||
// the workflow. Only booleans are coerced, matching GitHub, whose `inputs` context "preserves
|
||||
// Boolean values as Booleans instead of converting them to strings" while every other type stays a
|
||||
// string. workflow_dispatch has no `number` type (its input types are string, choice, boolean and
|
||||
// environment), so booleans are the complete set to coerce here.
|
||||
// A value that is already a bool is left untouched, so the coercion is idempotent.
|
||||
func coerceDispatchInputTypes(dispatch *model.WorkflowDispatch, inputs map[string]any) {
|
||||
for name, cfg := range dispatch.Inputs {
|
||||
if cfg.Type != "boolean" {
|
||||
continue
|
||||
}
|
||||
if s, ok := inputs[name].(string); ok {
|
||||
inputs[name] = s == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run.
|
||||
// - Repo-level: from the consumer's runTargetCommit.
|
||||
// - Scoped: from the source repo's default branch.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCoerceDispatchInputTypes(t *testing.T) {
|
||||
dispatch := &model.WorkflowDispatch{
|
||||
Inputs: map[string]model.WorkflowDispatchInput{
|
||||
"build_server": {Type: "boolean"},
|
||||
"dry_run": {Type: "boolean"},
|
||||
"already_bool": {Type: "boolean"},
|
||||
"version": {Type: "string"},
|
||||
},
|
||||
}
|
||||
|
||||
inputs := map[string]any{
|
||||
// dispatch callbacks fill booleans as strconv.FormatBool(...) strings
|
||||
"build_server": "true",
|
||||
"dry_run": "false",
|
||||
// already-native booleans are passed through unchanged (coercion is idempotent)
|
||||
"already_bool": true,
|
||||
// non-boolean inputs must be left untouched
|
||||
"version": "1.2.3",
|
||||
}
|
||||
|
||||
coerceDispatchInputTypes(dispatch, inputs)
|
||||
|
||||
// Regression: without coercion these stay strings, and a server-side needs-gated
|
||||
// job `if: inputs.build_server == true` never matches, leaving the job blocked.
|
||||
assert.Equal(t, true, inputs["build_server"])
|
||||
assert.Equal(t, false, inputs["dry_run"])
|
||||
assert.Equal(t, true, inputs["already_bool"])
|
||||
assert.Equal(t, "1.2.3", inputs["version"])
|
||||
}
|
||||
+14
-10
@@ -270,19 +270,21 @@ Loop:
|
||||
return true, signingKey, sig, nil
|
||||
}
|
||||
|
||||
// SignMerge determines if we should sign a PR merge commit to the base repository
|
||||
func SignMerge(ctx context.Context, pr *issues_model.PullRequest, u *user_model.User, gitRepo *git.Repository) (bool, *git.SigningKey, *git.Signature, error) {
|
||||
// SignMerge determines if we should sign a PR merge commit to the base repository.
|
||||
// baseRef and headRef must resolve in gitRepo. Callers pass the temporary merge repo's own
|
||||
// refs for an update by merge, whose fake reverse PR has no head ref in the base repository.
|
||||
func SignMerge(ctx context.Context, pr *issues_model.PullRequest, u *user_model.User, gitRepo *git.Repository, baseRef, headRef string) (bool, *git.SigningKey, *git.Signature, error) {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("Unable to get Base Repo for pull request")
|
||||
return false, nil, nil, err
|
||||
}
|
||||
repo := pr.BaseRepo
|
||||
|
||||
baseCommit, err := gitRepo.GetCommit(pr.BaseBranch)
|
||||
baseCommit, err := gitRepo.GetCommit(baseRef)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
headCommit, err := gitRepo.GetCommit(pr.GetGitHeadRefName())
|
||||
headCommit, err := gitRepo.GetCommit(headRef)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
@@ -338,7 +340,7 @@ Loop:
|
||||
return false, nil, nil, &ErrWontSign{headSigned}
|
||||
}
|
||||
case commitsSigned:
|
||||
verified, err := AllHeadCommitsVerified(ctx, pr, gitRepo)
|
||||
verified, err := allCommitsVerified(ctx, baseCommit, headCommit)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
@@ -361,11 +363,13 @@ func AllHeadCommitsVerified(ctx context.Context, pr *issues_model.PullRequest, g
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
commitList, err := headCommit.CommitsBeforeUntil(git.RefNameFromCommit(mergeBaseCommit))
|
||||
return allCommitsVerified(ctx, baseCommit, headCommit)
|
||||
}
|
||||
|
||||
// allCommitsVerified checks the commits a merge would introduce, those reachable from
|
||||
// headCommit but not from baseCommit. Both commits must come from the same repository.
|
||||
func allCommitsVerified(ctx context.Context, baseCommit, headCommit *git.Commit) (bool, error) {
|
||||
commitList, err := headCommit.CommitsBeforeUntil(baseCommit.ID.RefName())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func getWhitelistEntities[T *user_model.User | *organization.Team](entities []T,
|
||||
|
||||
// ToBranchProtection convert a ProtectedBranch to api.BranchProtection
|
||||
func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo *repo_model.Repository) *api.BranchProtection {
|
||||
readers, err := access_model.GetUsersWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
|
||||
readers, err := access_model.GetUsersWithAnyUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
log.Error("GetRepoReaders: %v", err)
|
||||
}
|
||||
@@ -941,7 +941,7 @@ func ToAnnotatedTagObject(repo *repo_model.Repository, commit *git.Commit) *api.
|
||||
|
||||
// ToTagProtection convert a git.ProtectedTag to an api.TagProtection
|
||||
func ToTagProtection(ctx context.Context, pt *git_model.ProtectedTag, repo *repo_model.Repository) *api.TagProtection {
|
||||
readers, err := access_model.GetUsersWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
|
||||
readers, err := access_model.GetUsersWithAnyUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
log.Error("GetRepoReaders: %v", err)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ func runGitDiffTree(ctx context.Context, gitRepo *git.Repository, useMergeBase b
|
||||
cmd := gitcmd.NewCommand("diff-tree", "--raw", "-r", "--root").
|
||||
AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold)
|
||||
|
||||
// HINT: GIT-DIFF-TREE-UI-CONFIG: apply the diff.orderfile explicitly
|
||||
if git.GlobalConfig.DiffOrderFile != "" {
|
||||
cmd.AddOptionFormat("-O%s", git.GlobalConfig.DiffOrderFile)
|
||||
}
|
||||
|
||||
if useMergeBase {
|
||||
cmd.AddArguments("--merge-base")
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
package gitdiff
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -217,6 +220,43 @@ func TestGitDiffTree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitDiffTreeRespectsDiffOrderFile(t *testing.T) {
|
||||
gitRepo, err := git.OpenRepository(t.Context(), "../../modules/git/tests/repos/repo5_pulls")
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
testDiffTree := func(t *testing.T) (filePaths []string) {
|
||||
t.Helper()
|
||||
diffTree, err := GetDiffTree(t.Context(), gitRepo, false, "72866af952e98d02a73003501836074b286a78f6", "d8e0bbb45f200e67d9a784ce55bd90821af45ebd")
|
||||
require.NoError(t, err)
|
||||
for _, f := range diffTree.Files {
|
||||
filePaths = append(filePaths, f.HeadPath)
|
||||
}
|
||||
return filePaths
|
||||
}
|
||||
|
||||
t.Run("NoDiffOrderFile", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"LICENSE", "README.md"}, testDiffTree(t))
|
||||
})
|
||||
|
||||
t.Run("GlobalDiffOrderFile", func(t *testing.T) {
|
||||
diffOrderFilePath := filepath.Join(t.TempDir(), "test-diff-order.txt")
|
||||
err = os.WriteFile(diffOrderFilePath, []byte("README.md\nLICENSE\n"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = gitcmd.NewCommand("config", "set", "--global").AddDynamicArguments("diff.orderFile", diffOrderFilePath).RunStdString(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, git.InitFull())
|
||||
defer func() {
|
||||
_, _, err = gitcmd.NewCommand("config", "unset", "--global").AddDynamicArguments("diff.orderFile").RunStdString(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, git.InitFull())
|
||||
}()
|
||||
|
||||
assert.Equal(t, []string{"README.md", "LICENSE"}, testDiffTree(t))
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseGitDiffTree(t *testing.T) {
|
||||
test := []struct {
|
||||
Name string
|
||||
|
||||
@@ -1285,6 +1285,8 @@ func getDiffBasic(ctx context.Context, gitRepo *git.Repository, opts *DiffOption
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// HINT: GIT-DIFF-HIGHLIGHT-LINE-NUMBER: git doesn't treat CR(\r) as EOL, CR is just a plain char which can appear anywhere in the diff output
|
||||
// Since we have to do full-file-highlighting for the diff result, we need to make sure the highlighted lines exactly match the git's diff output.
|
||||
cmdDiff := gitcmd.NewCommand().
|
||||
AddArguments("diff", "--src-prefix=\\a/", "--dst-prefix=\\b/").
|
||||
AddArguments(opts.WhitespaceBehavior...).
|
||||
@@ -1405,8 +1407,12 @@ func highlightCodeLines(name, lang string, sections []*DiffSection, isLeft bool,
|
||||
if setting.Git.DisableDiffHighlight || len(rawContent) > MaxFullFileHighlightSizeLimit {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := util.UnsafeBytesToString(charset.ToUTF8(rawContent, charset.ConvertOpts{}))
|
||||
// HINT: GIT-DIFF-HIGHLIGHT-LINE-NUMBER: it should handle all CR(\r) before highlight to make line numbers match
|
||||
if strings.Contains(content, "\r") {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "␍")
|
||||
}
|
||||
lexer := highlight.DetectChromaLexerByFileName(name, lang)
|
||||
highlightedNewContent := highlight.RenderCodeByLexer(lexer, content)
|
||||
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlightedNewContent)
|
||||
|
||||
@@ -1143,6 +1143,19 @@ func TestHighlightCodeLines(t *testing.T) {
|
||||
1: `<span class="n">b</span>` + nl,
|
||||
}, ret)
|
||||
})
|
||||
t.Run("CharCR", func(t *testing.T) {
|
||||
diffFile := &DiffFile{
|
||||
Name: "a.txt",
|
||||
Sections: []*DiffSection{
|
||||
{
|
||||
Lines: []*DiffLine{{LeftIdx: 1}, {LeftIdx: 2}},
|
||||
},
|
||||
},
|
||||
}
|
||||
ret := highlightCodeLinesForDiffFile(diffFile, true, []byte("a\rb\r\nc"))
|
||||
assert.Equal(t, "a␍b\n", string(ret[0]))
|
||||
assert.Equal(t, `c`, string(ret[1]))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyncUserSpecificDiff_UpdatedFiles(t *testing.T) {
|
||||
|
||||
@@ -99,7 +99,9 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
|
||||
}
|
||||
}
|
||||
locale := translation.NewLocale(lang)
|
||||
|
||||
if lang == "mock" {
|
||||
locale = &translation.MockLocale{}
|
||||
}
|
||||
mailMeta := map[string]any{
|
||||
"locale": locale,
|
||||
"FallbackSubject": fallback,
|
||||
|
||||
@@ -18,10 +18,14 @@ import (
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/gituser"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
@@ -556,3 +560,32 @@ func TestEmbedBase64Images(t *testing.T) {
|
||||
assert.Equal(t, expected, string(resultMailBody))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMailPullRequestPush(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
mc := &mailComment{
|
||||
Issue: issue,
|
||||
Comment: comment,
|
||||
Doer: doer,
|
||||
}
|
||||
issue.IsPull = true
|
||||
issue.PullRequest = &issues_model.PullRequest{BaseRepo: mc.Issue.Repo}
|
||||
mc.Comment.Type = issues_model.CommentTypePullRequestPush
|
||||
mc.Comment.Commits = []*git_model.SignCommitWithStatuses{
|
||||
{
|
||||
SignCommit: &asymkey.SignCommit{
|
||||
UserCommit: &gituser.UserCommit{
|
||||
GitCommit: &git.Commit{
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "test commit msg"},
|
||||
ID: git.Sha1ObjectFormat.EmptyObjectID(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), mc, "mock", []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}, false, "pull request push")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, msgs[0].Body, `<a href="https://try.gitea.io/user2/repo1/commit/0000000000000000000000000000000000000000">0000000000</a> - test commit msg`)
|
||||
assert.Contains(t, msgs[0].Body, `</html>`)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ func UpdateAddress(ctx context.Context, m *repo_model.Mirror, addr string) error
|
||||
|
||||
func pruneBrokenReferences(ctx context.Context, m *repo_model.Mirror, gitRepo gitrepo.Repository, timeout time.Duration) error {
|
||||
// Never follow HTTP redirects, see cmdFetch in runSync.
|
||||
cmd := gitcmd.NewCommand("remote", "prune").AddConfig("http.followRedirects", "false").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
|
||||
cmd := gitcmd.NewCommand("remote", "prune").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
|
||||
git.HandleGitCmdHTTPRedirection(cmd, m.GetRemoteName())
|
||||
stdout, _, pruneErr := gitrepo.RunCmdString(ctx, gitRepo, cmd)
|
||||
if pruneErr != nil {
|
||||
// sanitize the output, since it may contain the remote address, which may contain a password
|
||||
@@ -129,9 +130,8 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
||||
|
||||
// use fetch but not remote update because git fetch support --tags but remote update doesn't
|
||||
cmdFetch := func() *gitcmd.Command {
|
||||
// Never follow HTTP redirects: a mirror remote that later starts redirecting to an
|
||||
// otherwise-blocked address would be an SSRF/exfiltration vector on scheduled syncs.
|
||||
cmd := gitcmd.NewCommand("fetch", "--tags").AddConfig("http.followRedirects", "false")
|
||||
cmd := gitcmd.NewCommand("fetch", "--tags")
|
||||
git.HandleGitCmdHTTPRedirection(cmd, m.GetRemoteName())
|
||||
if m.EnablePrune {
|
||||
cmd.AddArguments("--prune")
|
||||
}
|
||||
@@ -212,9 +212,9 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
||||
}
|
||||
|
||||
cmdRemoteUpdatePrune := func() *gitcmd.Command {
|
||||
// Never follow HTTP redirects, see cmdFetch above.
|
||||
return gitcmd.NewCommand("remote", "update", "--prune").AddConfig("http.followRedirects", "false").
|
||||
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
|
||||
cmd := gitcmd.NewCommand("remote", "update", "--prune").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
|
||||
git.HandleGitCmdHTTPRedirection(cmd, m.GetRemoteName())
|
||||
return cmd
|
||||
}
|
||||
|
||||
if repo_service.HasWiki(ctx, m.Repo) {
|
||||
|
||||
@@ -264,7 +264,7 @@ func checkSigningRequirements(ctx context.Context, pr *issues_model.PullRequest,
|
||||
}
|
||||
|
||||
if mergeStyle != repo_model.MergeStyleFastForwardOnly {
|
||||
if _, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo); err != nil {
|
||||
if _, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo, pr.BaseBranch, pr.GetGitHeadRefName()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
@@ -103,15 +102,18 @@ func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullReque
|
||||
mergeCtx.sig = doer.NewGitSig()
|
||||
mergeCtx.committer = mergeCtx.sig
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
||||
gitRepo, err := git.OpenRepository(ctx, mergeCtx.tmpBasePath)
|
||||
if err != nil {
|
||||
defer cancel()
|
||||
return nil, nil, fmt.Errorf("failed to open temp git repo for pr[%d]: %w", mergeCtx.pr.ID, err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
// Determine if we should sign
|
||||
sign, key, signer, _ := asymkey_service.SignMerge(ctx, pr, doer, gitRepo)
|
||||
// Determine if we should sign, using the temp repo's own refs (see SignMerge for why)
|
||||
sign, key, signer, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo, git.BranchPrefix+tmpRepoBaseBranch, git.BranchPrefix+tmpRepoTrackingBranch)
|
||||
if err != nil && !asymkey_service.IsErrWontSign(err) {
|
||||
log.Error("%-v SignMerge: %v", mergeCtx.pr, err) // the merge proceeds unsigned regardless, so log it here
|
||||
}
|
||||
if sign {
|
||||
mergeCtx.signKey = key
|
||||
if pr.BaseRepo.GetTrustModel() == repo_model.CommitterTrustModel || pr.BaseRepo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
|
||||
|
||||
+8
-15
@@ -777,30 +777,25 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re
|
||||
}
|
||||
|
||||
// GetSquashMergeCommitMessages returns the commit messages between head and merge base (if there is one)
|
||||
func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) string {
|
||||
func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) (_ string, err error) {
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("Cannot load issue %d for PR id %d: Error: %v", pr.IssueID, pr.ID, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := pr.Issue.LoadPoster(ctx); err != nil {
|
||||
log.Error("Cannot load poster %d for pr id %d, index %d Error: %v", pr.Issue.PosterID, pr.ID, pr.Index, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
if pr.HeadRepo == nil {
|
||||
var err error
|
||||
pr.HeadRepo, err = repo_model.GetRepositoryByID(ctx, pr.HeadRepoID)
|
||||
if err != nil {
|
||||
log.Error("GetRepositoryByIdCtx[%d]: %v", pr.HeadRepoID, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
||||
if err != nil {
|
||||
log.Error("Unable to open head repository: Error: %v", err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
@@ -810,8 +805,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
} else {
|
||||
pr.HeadCommitID, err = gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
log.Error("Unable to get head commit: %s Error: %v", pr.GetGitHeadRefName(), err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
headCommitRef = git.RefNameFromCommit(pr.HeadCommitID)
|
||||
}
|
||||
@@ -822,8 +816,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
|
||||
limitedCommits, err := gitRepo.CommitsBetween(headCommitRef, mergeBaseRef, limit)
|
||||
if err != nil {
|
||||
log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
mergeMessage := strings.TrimSpace(pr.Issue.Content) // use PR's title and description as squash commit message
|
||||
@@ -831,7 +824,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
mergeMessage = formatSquashMergeCommitMessages(limitedCommits) // use PR's commit messages as squash commit message
|
||||
}
|
||||
coAuthors := collectSquashMergeCommitCoAuthors(ctx, gitRepo, pr, headCommitRef, mergeBaseRef, limit, limitedCommits)
|
||||
return buildSquashMergeCommitMessages(mergeMessage, coAuthors)
|
||||
return buildSquashMergeCommitMessages(mergeMessage, coAuthors), nil
|
||||
}
|
||||
|
||||
func buildSquashMergeCommitMessages(mergeMessage string, coAuthors []string) string {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -96,6 +96,10 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
|
||||
&user_model.Blocking{BlockeeID: u.ID},
|
||||
&actions_model.ActionRunnerToken{OwnerID: u.ID},
|
||||
&actions_model.ActionScopedWorkflowSource{OwnerID: u.ID},
|
||||
&auth_model.TwoFactor{UID: u.ID},
|
||||
&auth_model.WebAuthnCredential{UserID: u.ID},
|
||||
&activities_model.Notification{UserID: u.ID},
|
||||
&issues_model.IssueWatch{UserID: u.ID},
|
||||
); err != nil {
|
||||
return fmt.Errorf("deleteBeans: %w", err)
|
||||
}
|
||||
|
||||
@@ -145,7 +145,8 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Visibility.Has() {
|
||||
// only validate and persist the visibility when it actually changes
|
||||
if opts.Visibility.Has() && opts.Visibility.Value() != u.Visibility {
|
||||
if !u.IsOrganization() && !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(opts.Visibility.Value()) {
|
||||
return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String())
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
password_module "gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -121,3 +123,33 @@ func TestUpdateAuth(t *testing.T) {
|
||||
Password: optional.Some("aaaa"),
|
||||
}), password_module.ErrMinLength)
|
||||
}
|
||||
|
||||
func TestUpdateUserVisibility(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// user28's current visibility is public, e.g. an account created before public was disallowed
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
assert.Equal(t, structs.VisibleTypePublic, user.Visibility)
|
||||
|
||||
// public is no longer an allowed visibility mode, e.g. ALLOWED_USER_VISIBILITY_MODES = limited, private
|
||||
defer test.MockVariableValue(&setting.Service.AllowedUserVisibilityModesSlice, setting.AllowedVisibility{false, true, true})()
|
||||
|
||||
// re-submitting the unchanged (now-disallowed) visibility must not fail the whole update
|
||||
assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
FullName: optional.Some("Changed Name"),
|
||||
Visibility: optional.Some(structs.VisibleTypePublic),
|
||||
}))
|
||||
assert.Equal(t, "Changed Name", user.FullName)
|
||||
assert.Equal(t, structs.VisibleTypePublic, user.Visibility)
|
||||
|
||||
// changing to an allowed visibility still works
|
||||
assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
Visibility: optional.Some(structs.VisibleTypePrivate),
|
||||
}))
|
||||
assert.Equal(t, structs.VisibleTypePrivate, user.Visibility)
|
||||
|
||||
// genuinely changing to a disallowed visibility is still rejected
|
||||
assert.Error(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
Visibility: optional.Some(structs.VisibleTypePublic),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -61,6 +63,32 @@ func TestDeleteUser(t *testing.T) {
|
||||
|
||||
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
assert.Error(t, DeleteUser(t.Context(), org, false))
|
||||
|
||||
t.Run("CleanupOrphanedTables", func(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// assert they exist before deletion
|
||||
unittest.AssertExistsAndLoadBean(t, &auth.TwoFactor{UID: 24})
|
||||
unittest.AssertExistsAndLoadBean(t, &auth.WebAuthnCredential{UserID: 32})
|
||||
unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 2})
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueWatch{UserID: 2})
|
||||
|
||||
// delete users
|
||||
user24 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 24})
|
||||
assert.NoError(t, DeleteUser(t.Context(), user24, true))
|
||||
|
||||
user32 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32})
|
||||
assert.NoError(t, DeleteUser(t.Context(), user32, true))
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
assert.NoError(t, DeleteUser(t.Context(), user2, true))
|
||||
|
||||
// assert they do not exist after deletion
|
||||
unittest.AssertNotExistsBean(t, &auth.TwoFactor{UID: 24})
|
||||
unittest.AssertNotExistsBean(t, &auth.WebAuthnCredential{UserID: 32})
|
||||
unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: 2})
|
||||
unittest.AssertNotExistsBean(t, &issues_model.IssueWatch{UserID: 2})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserUnlinkedAttachments(t *testing.T) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,33 @@ func checkBranchFilter(branchFilter string, ref git.RefName) bool {
|
||||
return g.Match(ref.String())
|
||||
}
|
||||
|
||||
// PrepareTestWebhook always creates and enqueues a hook task for manual testing.
|
||||
// Unlike PrepareWebhook, it ignores event subscriptions and branch filters so the
|
||||
// Test Push Event control can verify delivery even when those gates would suppress
|
||||
// a real event.
|
||||
func PrepareTestWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook_module.HookEventType, p api.Payloader) error {
|
||||
if setting.DisableWebhooks {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := p.JSONPayload()
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSONPayload for %s: %w", event, err)
|
||||
}
|
||||
|
||||
task, err := webhook_model.CreateHookTask(ctx, &webhook_model.HookTask{
|
||||
HookID: w.ID,
|
||||
PayloadContent: string(payload),
|
||||
EventType: event,
|
||||
PayloadVersion: 2,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateHookTask for %s: %w", event, err)
|
||||
}
|
||||
|
||||
return enqueueHookTask(task.ID)
|
||||
}
|
||||
|
||||
// PrepareWebhook creates a hook task and enqueues it for processing.
|
||||
// The payload is saved as-is. The adjustments depending on the webhook type happen
|
||||
// right before delivery, in the [Deliver] method.
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestWebhookService(t *testing.T) {
|
||||
t.Run("PrepareBranchFilterNoMatch", testWebhookPrepareBranchFilterNoMatch)
|
||||
t.Run("WebhookUserMail", testWebhookUserMail)
|
||||
t.Run("CheckBranchFilter", testWebhookCheckBranchFilter)
|
||||
t.Run("PrepareTestWebhookIgnoresGates", testPrepareTestWebhookIgnoresGates)
|
||||
}
|
||||
|
||||
func testWebhookGetSlackHook(t *testing.T) {
|
||||
@@ -132,3 +133,37 @@ func testWebhookCheckBranchFilter(t *testing.T) {
|
||||
assert.Equal(t, v.match, checkBranchFilter(v.filter, v.ref), "filter: %q ref: %q", v.filter, v.ref)
|
||||
}
|
||||
}
|
||||
|
||||
func testPrepareTestWebhookIgnoresGates(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
hook := &webhook_model.Webhook{
|
||||
RepoID: repo.ID,
|
||||
URL: "http://localhost/gitea-webhook-test-prepare_test_webhook",
|
||||
ContentType: webhook_model.ContentTypeJSON,
|
||||
IsActive: true,
|
||||
HookEvent: &webhook_module.HookEvent{
|
||||
ChooseEvents: true,
|
||||
BranchFilter: "dev",
|
||||
HookEvents: webhook_module.HookEvents{
|
||||
webhook_module.HookEventWorkflowRun: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, hook.UpdateEvent())
|
||||
require.NoError(t, db.Insert(t.Context(), hook))
|
||||
|
||||
payload := &api.PushPayload{
|
||||
Ref: "refs/heads/master",
|
||||
Commits: []*api.PayloadCommit{{}},
|
||||
}
|
||||
hookTask := &webhook_model.HookTask{HookID: hook.ID, EventType: webhook_module.HookEventPush}
|
||||
|
||||
// Real deliveries stay gated: no push event + branch filter mismatch => nothing queued.
|
||||
unittest.AssertNotExistsBean(t, hookTask)
|
||||
require.NoError(t, PrepareWebhook(t.Context(), hook, webhook_module.HookEventPush, payload))
|
||||
unittest.AssertNotExistsBean(t, hookTask)
|
||||
|
||||
// Manual test delivery always queues so the endpoint can be verified.
|
||||
require.NoError(t, PrepareTestWebhook(t.Context(), hook, webhook_module.HookEventPush, payload))
|
||||
unittest.AssertExistsAndLoadBean(t, hookTask)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest">
|
||||
<div class="ui container">
|
||||
<h3>Flex List (standalone)</h3>
|
||||
<h3>Flex Relaxed List</h3>
|
||||
<div class="flex-container tw-border">
|
||||
<div class="flex-relaxed-list tw-flex-1">
|
||||
<div class="flex-left-right">
|
||||
<span class="gt-ellipsis">left looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong</span>
|
||||
<span>right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Flex Divided List (standalone)</h3>
|
||||
<div class="divider"></div>
|
||||
<div class="flex-divided-list items-with-main">
|
||||
<div class="item">
|
||||
@@ -87,7 +97,7 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h3>Flex List (with "ui segment")</h3>
|
||||
<h3>Flex Divided List (with "ui segment")</h3>
|
||||
<div class="ui attached segment">
|
||||
<div class="flex-divided-list">
|
||||
<div class="item">item 1</div>
|
||||
@@ -101,7 +111,7 @@
|
||||
<div class="item">item 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Flex List (with "ui segment fitted", items have their own padding)</h3>
|
||||
<h3>Flex Divided List (with "ui segment fitted", items have their own padding)</h3>
|
||||
<div class="ui fitted segment">
|
||||
<div class="flex-divided-list items-px-default">
|
||||
<div class="item">item 1</div>
|
||||
|
||||
@@ -63,19 +63,18 @@
|
||||
<div>{{.RenderedContent}}</div>
|
||||
</div>
|
||||
{{end -}}
|
||||
{{if eq .ActionName "push"}}
|
||||
<ul>
|
||||
{{$repoURL := $.Comment.Issue.PullRequest.BaseRepo.HTMLURL}}
|
||||
{{range $commit := $.Comment.Commits}}
|
||||
<li>
|
||||
<a href="{{$repoURL}}/commit/{{$commit.ID}}">
|
||||
{{ShortSha $commit.ID.String}}
|
||||
</a> - {{$commit.MessageTitle}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</p>
|
||||
{{if eq .ActionName "push"}}
|
||||
<ul>
|
||||
{{$repoURL := $.Comment.Issue.PullRequest.BaseRepo.HTMLURL}}
|
||||
{{range $commit := $.Comment.Commits}}
|
||||
{{$gitCommit := $commit.UserCommit.GitCommit}}
|
||||
<li>
|
||||
<a href="{{$repoURL}}/commit/{{$gitCommit.ID}}">{{ShortSha $gitCommit.ID.String}}</a> - {{$gitCommit.MessageTitle}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<div style="font-size:small; color:#666;">
|
||||
<p>
|
||||
---
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user