mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-10 05:24:18 +09:00
Compare commits
11
Commits
b349a4e746
...
7733f1953f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7733f1953f | ||
|
|
4c382cea59 | ||
|
|
d4333eb043 | ||
|
|
9fc5d20006 | ||
|
|
2657756cac | ||
|
|
a34cc4cac4 | ||
|
|
8873150206 | ||
|
|
e81ab0a5ea | ||
|
|
d86cb1a498 | ||
|
|
ec869e3052 | ||
|
|
dd8ef9c888 |
@@ -404,5 +404,5 @@ func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunA
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
}
|
||||
|
||||
return CancelJobs(ctx, jobsToCancel)
|
||||
return CancelJobs(ctx, jobsToCancel, false)
|
||||
}
|
||||
|
||||
+14
-12
@@ -703,7 +703,7 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin
|
||||
return cancelledJobs, err
|
||||
}
|
||||
|
||||
cjs, err := CancelJobs(ctx, jobs)
|
||||
cjs, err := CancelJobs(ctx, jobs, false)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
@@ -749,17 +749,18 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
}
|
||||
|
||||
return CancelJobs(ctx, jobsToCancel)
|
||||
return CancelJobs(ctx, jobsToCancel, false)
|
||||
}
|
||||
|
||||
// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it
|
||||
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one.
|
||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
|
||||
// CancelJobs cancels every cancellable job it is given, force skipping the graceful cancelling
|
||||
// handshake so a running task is marked cancelled without waiting for its runner. It leaves the
|
||||
// status of a run it cancelled nothing in untouched, SettleRunAfterCancel gives such a run a final one.
|
||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob, force bool) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
||||
|
||||
for _, job := range jobs {
|
||||
if job.IsReusableCaller {
|
||||
sub, err := cancelReusableCaller(ctx, job)
|
||||
sub, err := cancelReusableCaller(ctx, job, force)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
@@ -767,7 +768,7 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
|
||||
continue
|
||||
}
|
||||
|
||||
c, err := cancelOneJob(ctx, job)
|
||||
c, err := cancelOneJob(ctx, job, force)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
@@ -789,7 +790,7 @@ func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
|
||||
}
|
||||
|
||||
// cancelOneJob cancels a single job and returns the post-cancel row
|
||||
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
|
||||
func cancelOneJob(ctx context.Context, job *ActionRunJob, force bool) (*ActionRunJob, error) {
|
||||
if job.Status.IsDone() {
|
||||
return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error"
|
||||
}
|
||||
@@ -808,7 +809,8 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error)
|
||||
return job, nil
|
||||
}
|
||||
// Has a task: stop the task and re-read the row.
|
||||
if err := StopTask(ctx, job.TaskID, StatusCancelling); err != nil {
|
||||
stopStatus := util.Iif(force, StatusCancelled, StatusCancelling)
|
||||
if err := StopTask(ctx, job.TaskID, stopStatus); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
|
||||
@@ -819,7 +821,7 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error)
|
||||
}
|
||||
|
||||
// cancelReusableCaller cancels `caller` and all its child jobs
|
||||
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionRunJob, error) {
|
||||
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0)
|
||||
|
||||
attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID)
|
||||
@@ -834,7 +836,7 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR
|
||||
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
||||
|
||||
for _, c := range descendants {
|
||||
cancelled, err := cancelOneJob(ctx, c)
|
||||
cancelled, err := cancelOneJob(ctx, c, force)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
@@ -843,7 +845,7 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := cancelOneJob(ctx, caller); err != nil {
|
||||
if c, err := cancelOneJob(ctx, caller, force); err != nil {
|
||||
return cancelledJobs, err
|
||||
} else if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
|
||||
@@ -187,7 +187,7 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
||||
// Cancel all jobs of the attempt, ordered by id (parent before child).
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = CancelJobs(ctx, jobs)
|
||||
_, err = CancelJobs(ctx, jobs, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, j := range []*ActionRunJob{outer, inner} {
|
||||
@@ -272,7 +272,7 @@ func TestSettleRunAfterCancel(t *testing.T) {
|
||||
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
|
||||
|
||||
// mirrors what the CancelRun service does
|
||||
cancelled, err := CancelJobs(t.Context(), jobs)
|
||||
cancelled, err := CancelJobs(t.Context(), jobs, false)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
|
||||
require.NoError(t, SettleRunAfterCancel(t.Context(), run))
|
||||
@@ -327,3 +327,77 @@ jobs:
|
||||
assert.Equal(t, "build (1)", parsed.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestForceCancelJobs(t *testing.T) {
|
||||
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
taskAfter := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfter.Status)
|
||||
assert.NotZero(t, taskAfter.Stopped)
|
||||
|
||||
jobAfter := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfter.Status)
|
||||
assert.NotZero(t, jobAfter.Stopped)
|
||||
}
|
||||
|
||||
// A running task is force-cancelled directly, without trying the graceful cancel first.
|
||||
t.Run("running task", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "force-cancel-job", true)
|
||||
|
||||
cancelledJobs, err := CancelJobs(t.Context(), []*ActionRunJob{job}, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cancelledJobs, 1)
|
||||
assert.Equal(t, StatusCancelled, cancelledJobs[0].Status)
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
// A task already in the cancelling handshake whose runner never finishes the cleanup.
|
||||
t.Run("cancelling task", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "force-cancel-cancelling-job", true)
|
||||
|
||||
cancelling, err := CancelJobs(t.Context(), []*ActionRunJob{job}, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cancelling, 1)
|
||||
assert.Equal(t, StatusCancelling, cancelling[0].Status)
|
||||
|
||||
job = unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
cancelled, err := CancelJobs(t.Context(), []*ActionRunJob{job}, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cancelled, 1)
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
// A caller is cancelled through its descendants, so the force has to reach their tasks too.
|
||||
t.Run("reusable caller", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, child := newRunningTaskForCancelling(t, "force-cancel-child", true)
|
||||
|
||||
caller := &ActionRunJob{
|
||||
RunID: child.RunID,
|
||||
RepoID: child.RepoID,
|
||||
OwnerID: child.OwnerID,
|
||||
CommitSHA: child.CommitSHA,
|
||||
Name: "force-cancel-caller",
|
||||
JobID: "force-cancel-caller",
|
||||
Attempt: 1,
|
||||
Status: StatusRunning,
|
||||
IsReusableCaller: true,
|
||||
IsExpanded: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), caller))
|
||||
child.ParentJobID = caller.ID
|
||||
_, err := UpdateRunJob(t.Context(), child, nil, "parent_job_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelled, err := CancelJobs(t.Context(), []*ActionRunJob{caller}, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cancelled, 2)
|
||||
assertCancelled(t, task, child)
|
||||
|
||||
callerAfter := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: caller.ID})
|
||||
assert.Equal(t, StatusCancelled, callerAfter.Status)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type UsesKind int
|
||||
|
||||
const (
|
||||
// UsesKindLocalSameRepo is "./<dir>/foo.yml" - a path inside the calling repository.
|
||||
// UsesKindLocalSameRepo is "./<dir>/foo.yml" or "$/<dir>/foo.yml" - a path inside the calling repository.
|
||||
// For example: "./.gitea/workflows/foo.yml"
|
||||
UsesKindLocalSameRepo UsesKind = iota + 1
|
||||
// UsesKindLocalCrossRepo is "owner/repo/<dir>/foo.yml@ref" - a workflow in another repo on the same instance.
|
||||
@@ -33,13 +33,13 @@ type UsesRef struct {
|
||||
}
|
||||
|
||||
var (
|
||||
reLocalSameRepo = regexp.MustCompile(`^\./([^@]+\.ya?ml)$`)
|
||||
reLocalSameRepo = regexp.MustCompile(`^[.$]/([^@]+\.ya?ml)$`)
|
||||
reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/([^@]+\.ya?ml)@(.+)$`)
|
||||
)
|
||||
|
||||
// ParseUses parses the SYNTAX of a reusable workflow "uses:" value into a UsesRef. Two forms are supported:
|
||||
// - "./<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
|
||||
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
|
||||
// - "./<dir>/foo.yml" or "$/<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
|
||||
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
|
||||
//
|
||||
// It deliberately does NOT validate that <dir> is an allowed workflow directory: the allowed directories are instance-configurable (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS).
|
||||
// The caller (services/actions.ResolveUses) enforces the directory allowlist. The returned Path is the cleaned, repo-relative file path.
|
||||
@@ -49,10 +49,10 @@ func ParseUses(s string) (*UsesRef, error) {
|
||||
return nil, errors.New("empty uses value")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s, "./") {
|
||||
if strings.HasPrefix(s, "./") || strings.HasPrefix(s, "$/") {
|
||||
m := reLocalSameRepo.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.yml)`, s)
|
||||
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.yml or $/<dir>/<file>.yml)`, s)
|
||||
}
|
||||
p := m[1]
|
||||
if path.Clean(p) != p {
|
||||
|
||||
@@ -53,6 +53,11 @@ func TestParseUses(t *testing.T) {
|
||||
in: "./.gitea/custom_workflows/x.yaml",
|
||||
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/custom_workflows/x.yaml"},
|
||||
},
|
||||
{
|
||||
name: "self-repo prefix",
|
||||
in: "$/.gitea/workflows/build.yml",
|
||||
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
|
||||
},
|
||||
{
|
||||
name: "leading/trailing whitespace is trimmed",
|
||||
in: " ./.gitea/workflows/build.yml ",
|
||||
@@ -160,6 +165,7 @@ func TestParseUses(t *testing.T) {
|
||||
|
||||
// Same-repo malformed (note: a wrong *directory* parses and should be rejected by the caller)
|
||||
{name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"},
|
||||
{name: "self-repo with @ref", in: "$/.gitea/workflows/build.yml@v1"},
|
||||
{name: "same-repo wrong extension", in: "./.gitea/workflows/build.txt"},
|
||||
{name: "same-repo missing extension", in: "./.gitea/workflows/build"},
|
||||
{name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"},
|
||||
|
||||
@@ -7,11 +7,18 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/git/gitrepo"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type FastImportInit struct {
|
||||
Bare bool
|
||||
ObjectFormat string
|
||||
}
|
||||
|
||||
type FastImportFile struct {
|
||||
Mode EntryMode
|
||||
Path string
|
||||
@@ -24,6 +31,20 @@ type FastImportCommit struct {
|
||||
Files []FastImportFile
|
||||
}
|
||||
|
||||
// ForceFastImportWithInit is for mainly for testing purpose
|
||||
func ForceFastImportWithInit(ctx context.Context, repoLocalPath string, commits []FastImportCommit, initOpts ...FastImportInit) (RepositoryFacade, error) {
|
||||
repo := gitrepo.RepositoryUnmanaged(repoLocalPath)
|
||||
initOpt := util.OptionalArg(initOpts, FastImportInit{Bare: true})
|
||||
if exist, _ := IsRepositoryExist(ctx, repo); !exist {
|
||||
_ = os.MkdirAll(repoLocalPath, 0o755)
|
||||
err := InitRepositoryLocal(ctx, repoLocalPath, initOpt.Bare, util.IfZero(initOpt.ObjectFormat, "sha1"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return repo, ForceFastImport(ctx, repo, commits)
|
||||
}
|
||||
|
||||
// ForceFastImport is for mainly for testing purpose
|
||||
func ForceFastImport(ctx context.Context, repo RepositoryFacade, commits []FastImportCommit) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
|
||||
func TestLockAndDo(t *testing.T) {
|
||||
t.Run("redis", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&redisLockExpiry, 5*time.Second)() // Close waits for the extend goroutine's next tick
|
||||
locker := newTestRedisLocker(t)
|
||||
defaultLocker.Store(new(locker))
|
||||
testLockAndDo(t)
|
||||
|
||||
@@ -5,13 +5,11 @@ package globallock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/go-redsync/redsync/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -20,14 +18,7 @@ import (
|
||||
|
||||
func newTestRedisLocker(t *testing.T) Locker {
|
||||
t.Helper()
|
||||
redisURL := util.IfZero(os.Getenv("TEST_REDIS_URL"), "redis://127.0.0.1:6379/0")
|
||||
rl := NewRedisLocker(redisURL).(*redisLocker)
|
||||
err := rl.conn.Ping(t.Context()).Err()
|
||||
if err != nil && test.AllowSkipExternalService() {
|
||||
t.Skip("no redis server for testing, skipped")
|
||||
}
|
||||
require.NoError(t, err, "redis error for testing: %v", err)
|
||||
return rl
|
||||
return NewRedisLocker(test.PrepareTestRedis(t))
|
||||
}
|
||||
|
||||
func TestLocker(t *testing.T) {
|
||||
|
||||
@@ -149,7 +149,7 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
if hasExtTrackFormat && !ref.IsPull {
|
||||
ctx.RenderOptions.Metas["index"] = ref.Issue
|
||||
|
||||
res, err := vars.Expand(ctx.RenderOptions.Metas["format"], ctx.RenderOptions.Metas)
|
||||
res, err := vars.ExpandCurlyBrace(ctx.RenderOptions.Metas["format"], ctx.RenderOptions.Metas)
|
||||
if err != nil {
|
||||
// here we could just log the error and continue the rendering
|
||||
log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
|
||||
|
||||
@@ -32,21 +32,21 @@ var _ Object = &azureBlobObject{}
|
||||
|
||||
type azureBlobObject struct {
|
||||
blobClient *blob.Client
|
||||
Context context.Context
|
||||
Name string
|
||||
Size int64
|
||||
ModTime *time.Time
|
||||
ctx context.Context
|
||||
name string
|
||||
size int64
|
||||
modTime *time.Time
|
||||
offset int64
|
||||
}
|
||||
|
||||
func (a *azureBlobObject) Read(p []byte) (int, error) {
|
||||
// TODO: improve the performance, we can implement another interface, maybe implement io.WriteTo
|
||||
if a.offset >= a.Size {
|
||||
if a.offset >= a.size {
|
||||
return 0, io.EOF
|
||||
}
|
||||
count := min(int64(len(p)), a.Size-a.offset)
|
||||
count := min(int64(len(p)), a.size-a.offset)
|
||||
|
||||
res, err := a.blobClient.DownloadBuffer(a.Context, p, &blob.DownloadBufferOptions{
|
||||
res, err := a.blobClient.DownloadBuffer(a.ctx, p, &blob.DownloadBufferOptions{
|
||||
Range: blob.HTTPRange{
|
||||
Offset: a.offset,
|
||||
Count: count,
|
||||
@@ -71,12 +71,12 @@ func (a *azureBlobObject) Seek(offset int64, whence int) (int64, error) {
|
||||
case io.SeekCurrent:
|
||||
offset += a.offset
|
||||
case io.SeekEnd:
|
||||
offset = a.Size + offset
|
||||
offset = a.size + offset
|
||||
default:
|
||||
return 0, errors.New("Seek: invalid whence")
|
||||
}
|
||||
|
||||
if offset > a.Size {
|
||||
if offset > a.size {
|
||||
return 0, errors.New("Seek: invalid offset")
|
||||
} else if offset < 0 {
|
||||
return 0, errors.New("Seek: invalid offset")
|
||||
@@ -87,15 +87,14 @@ func (a *azureBlobObject) Seek(offset int64, whence int) (int64, error) {
|
||||
|
||||
func (a *azureBlobObject) Stat() (os.FileInfo, error) {
|
||||
return &azureBlobFileInfo{
|
||||
a.Name,
|
||||
a.Size,
|
||||
*a.ModTime,
|
||||
a.name,
|
||||
a.size,
|
||||
*a.modTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ ObjectStorage = &AzureBlobStorage{}
|
||||
|
||||
// AzureStorage returns a azure blob storage
|
||||
type AzureBlobStorage struct {
|
||||
cfg *setting.AzureBlobStorageConfig
|
||||
ctx context.Context
|
||||
@@ -150,11 +149,7 @@ func NewAzureBlobStorage(ctx context.Context, cfg *setting.Storage) (ObjectStora
|
||||
}
|
||||
|
||||
func (a *AzureBlobStorage) buildAzureBlobPath(p string) string {
|
||||
p = util.PathJoinRelX(a.cfg.BasePath, p)
|
||||
if p == "." || p == "/" {
|
||||
p = "" // azure uses prefix, so path should be empty as relative path
|
||||
}
|
||||
return p
|
||||
return buildObjectStorePath(a.cfg.BasePath, p)
|
||||
}
|
||||
|
||||
func (a *AzureBlobStorage) getObjectNameFromPath(path string) string {
|
||||
@@ -170,11 +165,11 @@ func (a *AzureBlobStorage) Open(path string) (Object, error) {
|
||||
return nil, convertAzureBlobErr(err)
|
||||
}
|
||||
return &azureBlobObject{
|
||||
Context: a.ctx,
|
||||
ctx: a.ctx,
|
||||
blobClient: blobClient,
|
||||
Name: a.getObjectNameFromPath(path),
|
||||
Size: *res.ContentLength,
|
||||
ModTime: res.LastModified,
|
||||
name: a.getObjectNameFromPath(path),
|
||||
size: *res.ContentLength,
|
||||
modTime: res.LastModified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -302,33 +297,32 @@ func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqPar
|
||||
return url.Parse(u)
|
||||
}
|
||||
|
||||
// IterateObjects iterates across the objects in the azureblobstorage
|
||||
func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
||||
dirName = a.buildAzureBlobPath(dirName)
|
||||
if dirName != "" {
|
||||
dirName += "/"
|
||||
}
|
||||
basePrefix := buildObjectStorePathPrefix(a.cfg.BasePath, "")
|
||||
dirPrefix := buildObjectStorePathPrefix(a.cfg.BasePath, dirName)
|
||||
pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{
|
||||
Prefix: &dirName,
|
||||
Prefix: &dirPrefix,
|
||||
})
|
||||
|
||||
callback := func(object *azureBlobObject, objPath string) error {
|
||||
defer object.Close()
|
||||
return fn(objPath, object)
|
||||
}
|
||||
for pager.More() {
|
||||
resp, err := pager.NextPage(a.ctx)
|
||||
if err != nil {
|
||||
return convertAzureBlobErr(err)
|
||||
}
|
||||
for _, object := range resp.Segment.BlobItems {
|
||||
blobClient := a.getBlobClient(*object.Name)
|
||||
object := &azureBlobObject{
|
||||
Context: a.ctx,
|
||||
blobClient: blobClient,
|
||||
Name: *object.Name,
|
||||
Size: *object.Properties.ContentLength,
|
||||
ModTime: object.Properties.LastModified,
|
||||
for _, azureObj := range resp.Segment.BlobItems {
|
||||
objPath := strings.TrimPrefix(*azureObj.Name, basePrefix)
|
||||
objWrap := &azureBlobObject{
|
||||
ctx: a.ctx,
|
||||
blobClient: a.getBlobClient(objPath),
|
||||
name: *azureObj.Name,
|
||||
size: *azureObj.Properties.ContentLength,
|
||||
modTime: azureObj.Properties.LastModified,
|
||||
}
|
||||
if err := func(object *azureBlobObject, fn func(path string, obj Object) error) error {
|
||||
defer object.Close()
|
||||
return fn(strings.TrimPrefix(object.Name, a.cfg.BasePath), object)
|
||||
}(object, fn); err != nil {
|
||||
if err := callback(objWrap, objPath); err != nil {
|
||||
return convertAzureBlobErr(err)
|
||||
}
|
||||
}
|
||||
@@ -336,7 +330,6 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete delete a file
|
||||
func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client {
|
||||
return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path))
|
||||
}
|
||||
|
||||
@@ -10,82 +10,45 @@ import (
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAzureBlobStorage(t *testing.T) {
|
||||
func prepareAzureStorageConfig(t *testing.T, basePath ...string) *setting.Storage {
|
||||
endpoint := test.ExternalServiceHTTP(t, "TEST_AZURESTORAGE_ENDPOINT", "http://devstoreaccount1.azurite.local:10000")
|
||||
storageType := setting.AzureBlobStorageType
|
||||
config := &setting.Storage{
|
||||
return &setting.Storage{
|
||||
AzureBlobConfig: setting.AzureBlobStorageConfig{
|
||||
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
|
||||
Endpoint: endpoint,
|
||||
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#well-known-storage-account-and-key
|
||||
AccountName: "devstoreaccount1",
|
||||
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
|
||||
Container: "test",
|
||||
Container: "test-container",
|
||||
BasePath: util.OptionalArg(basePath),
|
||||
},
|
||||
}
|
||||
table := []struct {
|
||||
name string
|
||||
test func(t *testing.T, typStr Type, cfg *setting.Storage)
|
||||
}{
|
||||
{
|
||||
name: "iterator",
|
||||
test: testStorageIterator,
|
||||
},
|
||||
{
|
||||
name: "testBlobStorageURLContentTypeAndDisposition",
|
||||
test: testBlobStorageURLContentTypeAndDisposition,
|
||||
},
|
||||
}
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
entry.test(t, storageType, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureBlobStoragePath(t *testing.T) {
|
||||
m := &AzureBlobStorage{cfg: &setting.AzureBlobStorageConfig{BasePath: ""}}
|
||||
assert.Empty(t, m.buildAzureBlobPath("/"))
|
||||
assert.Empty(t, m.buildAzureBlobPath("."))
|
||||
assert.Equal(t, "a", m.buildAzureBlobPath("/a"))
|
||||
assert.Equal(t, "a/b", m.buildAzureBlobPath("/a/b/"))
|
||||
|
||||
m = &AzureBlobStorage{cfg: &setting.AzureBlobStorageConfig{BasePath: "/"}}
|
||||
assert.Empty(t, m.buildAzureBlobPath("/"))
|
||||
assert.Empty(t, m.buildAzureBlobPath("."))
|
||||
assert.Equal(t, "a", m.buildAzureBlobPath("/a"))
|
||||
assert.Equal(t, "a/b", m.buildAzureBlobPath("/a/b/"))
|
||||
|
||||
m = &AzureBlobStorage{cfg: &setting.AzureBlobStorageConfig{BasePath: "/base"}}
|
||||
assert.Equal(t, "base", m.buildAzureBlobPath("/"))
|
||||
assert.Equal(t, "base", m.buildAzureBlobPath("."))
|
||||
assert.Equal(t, "base/a", m.buildAzureBlobPath("/a"))
|
||||
assert.Equal(t, "base/a/b", m.buildAzureBlobPath("/a/b/"))
|
||||
|
||||
m = &AzureBlobStorage{cfg: &setting.AzureBlobStorageConfig{BasePath: "/base/"}}
|
||||
assert.Equal(t, "base", m.buildAzureBlobPath("/"))
|
||||
assert.Equal(t, "base", m.buildAzureBlobPath("."))
|
||||
assert.Equal(t, "base/a", m.buildAzureBlobPath("/a"))
|
||||
assert.Equal(t, "base/a/b", m.buildAzureBlobPath("/a/b/"))
|
||||
func TestAzureBlobStorage(t *testing.T) {
|
||||
t.Run("NoBasePath", func(t *testing.T) {
|
||||
config := prepareAzureStorageConfig(t)
|
||||
objStore, err := NewStorage(setting.AzureBlobStorageType, config)
|
||||
require.NoError(t, err)
|
||||
testStorageGeneral(t, objStore)
|
||||
})
|
||||
t.Run("WithBasePath", func(t *testing.T) {
|
||||
config := prepareAzureStorageConfig(t, "test-base-path")
|
||||
objStore, err := NewStorage(setting.AzureBlobStorageType, config)
|
||||
require.NoError(t, err)
|
||||
testStorageGeneral(t, objStore)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_azureBlobObject(t *testing.T) {
|
||||
endpoint := test.ExternalServiceHTTP(t, "TEST_AZURESTORAGE_ENDPOINT", "http://devstoreaccount1.azurite.local:10000")
|
||||
s, err := NewStorage(setting.AzureBlobStorageType, &setting.Storage{
|
||||
AzureBlobConfig: setting.AzureBlobStorageConfig{
|
||||
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
|
||||
Endpoint: endpoint,
|
||||
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#well-known-storage-account-and-key
|
||||
AccountName: "devstoreaccount1",
|
||||
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
|
||||
Container: "test",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
s, err := NewStorage(setting.AzureBlobStorageType, prepareAzureStorageConfig(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
data := "Q2xTckt6Y1hDOWh0"
|
||||
_, err = s.Save("test.txt", strings.NewReader(data), int64(len(data)))
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLocalStorage(t *testing.T) {
|
||||
objStore, err := NewStorage(setting.LocalStorageType, &setting.Storage{Path: t.TempDir()})
|
||||
require.NoError(t, err)
|
||||
testStorageGeneral(t, objStore)
|
||||
}
|
||||
|
||||
func TestBuildLocalPath(t *testing.T) {
|
||||
kases := []struct {
|
||||
localDir string
|
||||
@@ -98,7 +104,3 @@ func TestLocalStorageDelete(t *testing.T) {
|
||||
assertExists(t, ".", true)
|
||||
assertExists(t, "dir", false)
|
||||
}
|
||||
|
||||
func TestLocalStorageIterator(t *testing.T) {
|
||||
testStorageIterator(t, setting.LocalStorageType, &setting.Storage{Path: t.TempDir()})
|
||||
}
|
||||
|
||||
+18
-23
@@ -158,20 +158,11 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
|
||||
}
|
||||
|
||||
func (m *MinioStorage) buildMinioPath(p string) string {
|
||||
p = strings.TrimPrefix(util.PathJoinRelX(m.basePath, p), "/") // object store doesn't use slash for root path
|
||||
if p == "." {
|
||||
p = "" // object store doesn't use dot as relative path
|
||||
}
|
||||
return p
|
||||
return buildObjectStorePath(m.basePath, p)
|
||||
}
|
||||
|
||||
func (m *MinioStorage) buildMinioDirPrefix(p string) string {
|
||||
// ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
|
||||
p = m.buildMinioPath(p) + "/"
|
||||
if p == "/" {
|
||||
p = "" // object store doesn't use slash for root path
|
||||
}
|
||||
return p
|
||||
return buildObjectStorePathPrefix(m.basePath, p)
|
||||
}
|
||||
|
||||
func buildMinioCredentials(config setting.MinioStorageConfig) *credentials.Credentials {
|
||||
@@ -312,22 +303,26 @@ func (m *MinioStorage) ServeDirectURL(storePath, name, method string, opt *Serve
|
||||
return u, convertMinioErr(err)
|
||||
}
|
||||
|
||||
// IterateObjects iterates across the objects in the miniostorage
|
||||
func (m *MinioStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
||||
opts := minio.GetObjectOptions{}
|
||||
// FIXME: this loop is not right and causes resource leaking, see the comment of ListObjects
|
||||
for mObjInfo := range m.client.ListObjects(m.ctx, m.bucket, minio.ListObjectsOptions{
|
||||
Prefix: m.buildMinioDirPrefix(dirName),
|
||||
Recursive: true,
|
||||
}) {
|
||||
object, err := m.client.GetObject(m.ctx, m.bucket, mObjInfo.Key, opts)
|
||||
basePrefix := m.buildMinioDirPrefix("")
|
||||
dirPrefix := m.buildMinioDirPrefix(dirName)
|
||||
callback := func(object *minio.Object, objPath string) error {
|
||||
defer object.Close()
|
||||
return fn(objPath, &minioObject{object})
|
||||
}
|
||||
|
||||
ctxList, ctxListCancel := context.WithCancel(m.ctx)
|
||||
defer ctxListCancel() // ListObjectsIter: make sure to cancel the passed context, without that you might leak coroutines
|
||||
|
||||
getOpts := minio.GetObjectOptions{}
|
||||
listOpts := minio.ListObjectsOptions{Prefix: dirPrefix, Recursive: true}
|
||||
for mObjInfo := range m.client.ListObjectsIter(ctxList, m.bucket, listOpts) {
|
||||
object, err := m.client.GetObject(m.ctx, m.bucket, mObjInfo.Key, getOpts)
|
||||
if err != nil {
|
||||
return convertMinioErr(err)
|
||||
}
|
||||
if err := func(object *minio.Object, fn func(path string, obj Object) error) error {
|
||||
defer object.Close()
|
||||
return fn(strings.TrimPrefix(mObjInfo.Key, m.basePath), &minioObject{object})
|
||||
}(object, fn); err != nil {
|
||||
objPath := strings.TrimPrefix(mObjInfo.Key, basePrefix)
|
||||
if err := callback(object, objPath); err != nil {
|
||||
return convertMinioErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,89 +10,45 @@ import (
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMinioStorage(t *testing.T) {
|
||||
endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
|
||||
storageType := setting.MinioStorageType
|
||||
config := &setting.Storage{
|
||||
func prepareMinioStorageConfig(t *testing.T, basePath ...string) *setting.Storage {
|
||||
return &setting.Storage{
|
||||
MinioConfig: setting.MinioStorageConfig{
|
||||
Endpoint: endpoint,
|
||||
Endpoint: test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000"),
|
||||
AccessKeyID: "123456",
|
||||
SecretAccessKey: "12345678",
|
||||
Bucket: "gitea",
|
||||
Location: "us-east-1",
|
||||
BasePath: util.OptionalArg(basePath),
|
||||
},
|
||||
}
|
||||
table := []struct {
|
||||
name string
|
||||
test func(t *testing.T, typStr Type, cfg *setting.Storage)
|
||||
}{
|
||||
{
|
||||
name: "iterator",
|
||||
test: testStorageIterator,
|
||||
},
|
||||
{
|
||||
name: "testBlobStorageURLContentTypeAndDisposition",
|
||||
test: testBlobStorageURLContentTypeAndDisposition,
|
||||
},
|
||||
}
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
entry.test(t, storageType, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinioStoragePath(t *testing.T) {
|
||||
m := &MinioStorage{basePath: ""}
|
||||
assert.Empty(t, m.buildMinioPath("/"))
|
||||
assert.Empty(t, m.buildMinioPath("."))
|
||||
assert.Equal(t, "a", m.buildMinioPath("/a"))
|
||||
assert.Equal(t, "a/b", m.buildMinioPath("/a/b/"))
|
||||
assert.Empty(t, m.buildMinioDirPrefix(""))
|
||||
assert.Equal(t, "a/", m.buildMinioDirPrefix("/a/"))
|
||||
|
||||
m = &MinioStorage{basePath: "/"}
|
||||
assert.Empty(t, m.buildMinioPath("/"))
|
||||
assert.Empty(t, m.buildMinioPath("."))
|
||||
assert.Equal(t, "a", m.buildMinioPath("/a"))
|
||||
assert.Equal(t, "a/b", m.buildMinioPath("/a/b/"))
|
||||
assert.Empty(t, m.buildMinioDirPrefix(""))
|
||||
assert.Equal(t, "a/", m.buildMinioDirPrefix("/a/"))
|
||||
|
||||
m = &MinioStorage{basePath: "/base"}
|
||||
assert.Equal(t, "base", m.buildMinioPath("/"))
|
||||
assert.Equal(t, "base", m.buildMinioPath("."))
|
||||
assert.Equal(t, "base/a", m.buildMinioPath("/a"))
|
||||
assert.Equal(t, "base/a/b", m.buildMinioPath("/a/b/"))
|
||||
assert.Equal(t, "base/", m.buildMinioDirPrefix(""))
|
||||
assert.Equal(t, "base/a/", m.buildMinioDirPrefix("/a/"))
|
||||
|
||||
m = &MinioStorage{basePath: "/base/"}
|
||||
assert.Equal(t, "base", m.buildMinioPath("/"))
|
||||
assert.Equal(t, "base", m.buildMinioPath("."))
|
||||
assert.Equal(t, "base/a", m.buildMinioPath("/a"))
|
||||
assert.Equal(t, "base/a/b", m.buildMinioPath("/a/b/"))
|
||||
assert.Equal(t, "base/", m.buildMinioDirPrefix(""))
|
||||
assert.Equal(t, "base/a/", m.buildMinioDirPrefix("/a/"))
|
||||
func TestMinioStorage(t *testing.T) {
|
||||
t.Run("NoBasePath", func(t *testing.T) {
|
||||
config := prepareMinioStorageConfig(t)
|
||||
objStore, err := NewStorage(setting.MinioStorageType, config)
|
||||
require.NoError(t, err)
|
||||
testStorageGeneral(t, objStore)
|
||||
})
|
||||
t.Run("WithBasePath", func(t *testing.T) {
|
||||
config := prepareMinioStorageConfig(t, "test-base-path")
|
||||
objStore, err := NewStorage(setting.MinioStorageType, config)
|
||||
require.NoError(t, err)
|
||||
testStorageGeneral(t, objStore)
|
||||
})
|
||||
}
|
||||
|
||||
func TestS3StorageBadRequest(t *testing.T) {
|
||||
endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
|
||||
cfg := &setting.Storage{
|
||||
MinioConfig: setting.MinioStorageConfig{
|
||||
Endpoint: endpoint,
|
||||
AccessKeyID: "123456",
|
||||
SecretAccessKey: "invalid-secret",
|
||||
Bucket: "bucket",
|
||||
Location: "us-east-1",
|
||||
},
|
||||
}
|
||||
cfg := prepareMinioStorageConfig(t)
|
||||
cfg.MinioConfig.SecretAccessKey = "invalid-secret"
|
||||
_, err := NewStorage(setting.MinioStorageType, cfg)
|
||||
assert.ErrorContains(t, err, "ObjectStorage.BucketExists: endpoint="+endpoint)
|
||||
assert.ErrorContains(t, err, "ObjectStorage.BucketExists: endpoint="+cfg.MinioConfig.Endpoint)
|
||||
}
|
||||
|
||||
func TestMinioCredentials(t *testing.T) {
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrURLNotSupported represents url is not supported
|
||||
@@ -139,6 +141,23 @@ func SaveFrom(objStorage ObjectStorage, path string, callback func(w io.Writer)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildObjectStorePath(base, p string) string {
|
||||
p = strings.TrimPrefix(util.PathJoinRelX(base, p), "/") // object store doesn't use slash for root path
|
||||
if p == "." {
|
||||
p = "" // object store doesn't use dot as relative path
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func buildObjectStorePathPrefix(base, p string) string {
|
||||
// ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
|
||||
p = buildObjectStorePath(base, p) + "/"
|
||||
if p == "/" {
|
||||
p = "" // object store doesn't use slash for root path
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
var (
|
||||
// Attachments represents attachments storage
|
||||
Attachments ObjectStorage = uninitializedStorage
|
||||
|
||||
@@ -4,20 +4,50 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||
l, err := NewStorage(typStr, cfg)
|
||||
assert.NoError(t, err)
|
||||
func TestObjectStoragePath(t *testing.T) {
|
||||
base := ""
|
||||
assert.Empty(t, buildObjectStorePath(base, "/"))
|
||||
assert.Empty(t, buildObjectStorePath(base, "."))
|
||||
assert.Equal(t, "a", buildObjectStorePath(base, "/a"))
|
||||
assert.Equal(t, "a/b", buildObjectStorePath(base, "/a/b/"))
|
||||
assert.Empty(t, buildObjectStorePathPrefix(base, ""))
|
||||
assert.Equal(t, "a/", buildObjectStorePathPrefix(base, "/a/"))
|
||||
|
||||
base = "/"
|
||||
assert.Empty(t, buildObjectStorePath(base, "/"))
|
||||
assert.Empty(t, buildObjectStorePath(base, "."))
|
||||
assert.Equal(t, "a", buildObjectStorePath(base, "/a"))
|
||||
assert.Equal(t, "a/b", buildObjectStorePath(base, "/a/b/"))
|
||||
assert.Empty(t, buildObjectStorePathPrefix(base, ""))
|
||||
assert.Equal(t, "a/", buildObjectStorePathPrefix(base, "/a/"))
|
||||
|
||||
base = "/base"
|
||||
assert.Equal(t, "base", buildObjectStorePath(base, "/"))
|
||||
assert.Equal(t, "base", buildObjectStorePath(base, "."))
|
||||
assert.Equal(t, "base/a", buildObjectStorePath(base, "/a"))
|
||||
assert.Equal(t, "base/a/b", buildObjectStorePath(base, "/a/b/"))
|
||||
assert.Equal(t, "base/", buildObjectStorePathPrefix(base, ""))
|
||||
assert.Equal(t, "base/a/", buildObjectStorePathPrefix(base, "/a/"))
|
||||
|
||||
base = "/base/"
|
||||
assert.Equal(t, "base", buildObjectStorePath(base, "/"))
|
||||
assert.Equal(t, "base", buildObjectStorePath(base, "."))
|
||||
assert.Equal(t, "base/a", buildObjectStorePath(base, "/a"))
|
||||
assert.Equal(t, "base/a/b", buildObjectStorePath(base, "/a/b/"))
|
||||
assert.Equal(t, "base/", buildObjectStorePathPrefix(base, ""))
|
||||
assert.Equal(t, "base/a/", buildObjectStorePathPrefix(base, "/a/"))
|
||||
}
|
||||
|
||||
func testStorageIterator(t *testing.T, objStore ObjectStorage) {
|
||||
testFiles := [][]string{
|
||||
{"a/1.txt", "a1"},
|
||||
{"/a/1.txt", "aa1"}, // same as above, but with leading slash that will be trim
|
||||
@@ -28,12 +58,19 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||
{"b/x 4.txt", "bx4"},
|
||||
}
|
||||
for _, f := range testFiles {
|
||||
_, err = l.Save(f[0], strings.NewReader(f[1]), -1)
|
||||
_, err := objStore.Save(f[0], strings.NewReader(f[1]), -1)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
defer func() {
|
||||
for _, f := range testFiles {
|
||||
_ = objStore.Delete(f[0])
|
||||
}
|
||||
}()
|
||||
|
||||
expectedList := map[string][]string{
|
||||
"a": {"a/1.txt"},
|
||||
"a/": {"a/1.txt"},
|
||||
"/a/": {"a/1.txt"},
|
||||
"b": {"b/1.txt", "b/2.txt", "b/3.txt", "b/x 4.txt"},
|
||||
"": {"a/1.txt", "b/1.txt", "b/2.txt", "b/3.txt", "b/x 4.txt", "ab/1.txt"},
|
||||
"/": {"a/1.txt", "b/1.txt", "b/2.txt", "b/3.txt", "b/x 4.txt", "ab/1.txt"},
|
||||
@@ -42,8 +79,10 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||
}
|
||||
for dir, expected := range expectedList {
|
||||
count := 0
|
||||
err = l.IterateObjects(dir, func(path string, f Object) error {
|
||||
defer f.Close()
|
||||
err := objStore.IterateObjects(dir, func(path string, f Object) error {
|
||||
content, err := io.ReadAll(f)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, content)
|
||||
assert.Contains(t, expected, path)
|
||||
count++
|
||||
return nil
|
||||
@@ -53,52 +92,57 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||
}
|
||||
}
|
||||
|
||||
type expectedServeDirectHeaders struct {
|
||||
ContentType string
|
||||
ContentDisposition string
|
||||
}
|
||||
|
||||
func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected expectedServeDirectHeaders, reqParams *ServeDirectOptions) {
|
||||
u, err := s.ServeDirectURL(path, name, http.MethodGet, reqParams)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.Get(u.String())
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if expected.ContentType != "" {
|
||||
assert.Equal(t, expected.ContentType, resp.Header.Get("Content-Type"))
|
||||
func testStorageURLContentTypeAndDisposition(t *testing.T, objStore ObjectStorage) {
|
||||
type expectedServeDirectHeaders struct {
|
||||
ContentType string
|
||||
ContentDisposition string
|
||||
}
|
||||
if expected.ContentDisposition != "" {
|
||||
assert.Equal(t, expected.ContentDisposition, resp.Header.Get("Content-Disposition"))
|
||||
test := func(t *testing.T, s ObjectStorage, path, name string, expected expectedServeDirectHeaders, reqParams *ServeDirectOptions) {
|
||||
u, err := s.ServeDirectURL(path, name, http.MethodGet, reqParams)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.Get(u.String())
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if expected.ContentType != "" {
|
||||
assert.Equal(t, expected.ContentType, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
if expected.ContentDisposition != "" {
|
||||
assert.Equal(t, expected.ContentDisposition, resp.Header.Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testBlobStorageURLContentTypeAndDisposition(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||
s, err := NewStorage(typStr, cfg)
|
||||
assert.NoError(t, err)
|
||||
|
||||
testFilename := "test.txt"
|
||||
_, err = s.Save(testFilename, strings.NewReader("dummy-content"), -1)
|
||||
_, err := objStore.Save(testFilename, strings.NewReader("dummy-content"), -1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.txt", expectedServeDirectHeaders{
|
||||
test(t, objStore, testFilename, "test.txt", expectedServeDirectHeaders{
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
ContentDisposition: `inline; filename=test.txt`,
|
||||
}, nil)
|
||||
|
||||
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.pdf", expectedServeDirectHeaders{
|
||||
test(t, objStore, testFilename, "test.pdf", expectedServeDirectHeaders{
|
||||
ContentType: "application/pdf",
|
||||
ContentDisposition: `inline; filename=test.pdf`,
|
||||
}, nil)
|
||||
|
||||
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.wasm", expectedServeDirectHeaders{
|
||||
test(t, objStore, testFilename, "test.wasm", expectedServeDirectHeaders{
|
||||
ContentDisposition: `inline; filename=test.wasm`,
|
||||
}, nil)
|
||||
|
||||
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.wasm", expectedServeDirectHeaders{
|
||||
test(t, objStore, testFilename, "test.wasm", expectedServeDirectHeaders{
|
||||
ContentType: "application/wasm",
|
||||
ContentDisposition: `inline; filename=test.wasm`,
|
||||
}, &ServeDirectOptions{
|
||||
ContentType: "application/wasm",
|
||||
})
|
||||
assert.NoError(t, s.Delete(testFilename))
|
||||
assert.NoError(t, objStore.Delete(testFilename))
|
||||
}
|
||||
|
||||
func testStorageGeneral(t *testing.T, objStore ObjectStorage) {
|
||||
t.Run("StorageIterator", func(t *testing.T) { testStorageIterator(t, objStore) })
|
||||
|
||||
if _, ok := objStore.(*LocalStorage); ok {
|
||||
t.Skipf("Skipping tests for local storage")
|
||||
}
|
||||
t.Run("StorageURLContentTypeAndDisposition", func(t *testing.T) { testStorageURLContentTypeAndDisposition(t, objStore) })
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ package vars
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Expand replaces all variables like {var} by `vars` map, it always returns the expanded string regardless of errors
|
||||
// if error occurs, the error part doesn't change and is returned as it is.
|
||||
func Expand(template string, vars map[string]string) (string, error) {
|
||||
// ExpandCurlyBrace replaces all variables like {var} by `vars` map,
|
||||
// it always returns the expanded string regardless of errors.
|
||||
// if error occurs (wrong syntax, missing variable), the error part doesn't change and is returned as it is.
|
||||
func ExpandCurlyBrace(template string, vars map[string]string) (string, error) {
|
||||
// in the future, if necessary, we can introduce some escape-char,
|
||||
// for example: it will use `#' as a reversed char, templates will use `{#{}` to do escape and output char '{'.
|
||||
var buf strings.Builder
|
||||
@@ -71,3 +74,26 @@ func Expand(template string, vars map[string]string) (string, error) {
|
||||
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() (ret struct {
|
||||
regexpShellLike *regexp.Regexp
|
||||
},
|
||||
) {
|
||||
ret.regexpShellLike = regexp.MustCompile(`(\$\{[a-zA-Z_]\w*\}|\$[a-zA-Z_]\w*)`)
|
||||
return ret
|
||||
})
|
||||
|
||||
// ExpandShellLike works like os.Expand, the difference is that this function keeps the non-existing keys
|
||||
func ExpandShellLike(template string, vars map[string]string) string {
|
||||
re := globalVars().regexpShellLike
|
||||
return re.ReplaceAllStringFunc(template, func(s string) string {
|
||||
key := s[1:]
|
||||
if strings.HasPrefix(key, "{") && strings.HasSuffix(key, "}") {
|
||||
key = key[1 : len(key)-1]
|
||||
}
|
||||
if val, ok := vars[key]; ok {
|
||||
return val
|
||||
}
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExpandVars(t *testing.T) {
|
||||
kases := []struct {
|
||||
func TestExpandCurlyBrace(t *testing.T) {
|
||||
cases := []struct {
|
||||
tmpl string
|
||||
data map[string]string
|
||||
out string
|
||||
@@ -57,11 +57,11 @@ func TestExpandVars(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.tmpl, func(t *testing.T) {
|
||||
res, err := Expand(kase.tmpl, kase.data)
|
||||
assert.Equal(t, kase.out, res)
|
||||
if kase.error {
|
||||
for _, c := range cases {
|
||||
t.Run(c.tmpl, func(t *testing.T) {
|
||||
res, err := ExpandCurlyBrace(c.tmpl, c.data)
|
||||
assert.Equal(t, c.out, res)
|
||||
if c.error {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
@@ -69,3 +69,19 @@ func TestExpandVars(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandShellLike(t *testing.T) {
|
||||
cases := []struct {
|
||||
tmpl string
|
||||
data map[string]string
|
||||
out string
|
||||
}{
|
||||
{tmpl: "$key ${key} $other ${other}", data: map[string]string{"key": "val"}, out: "val val $other ${other}"},
|
||||
{tmpl: "$ key ${key }", data: map[string]string{"key": "val"}, out: "$ key ${key }"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
out := ExpandShellLike(c.tmpl, c.data)
|
||||
assert.Equal(t, c.out, out, "tmpl: %s", c.tmpl)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-36
@@ -7,23 +7,19 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testRedisHost = "127.0.0.1"
|
||||
testRedisPort = "6379"
|
||||
testRedisAddr = testRedisHost + ":" + testRedisPort
|
||||
testRedisConnStr = "redis://" + testRedisAddr + "/0"
|
||||
)
|
||||
const testRedisAddr = "127.0.0.1:6379"
|
||||
|
||||
// waitRedisReady reports whether redis accepts connections within dur. Redis
|
||||
// binds its listener last during startup, so a successful dial means it can
|
||||
// serve. A plain dial, not a redis PING: the client retries its pool on a
|
||||
// waitRedisReady reports whether redis accepts connections on addr within dur.
|
||||
// Redis binds its listener last during startup, so a successful dial means it
|
||||
// can serve. A plain dial, not a redis PING: the client retries its pool on a
|
||||
// refused connect, which makes the "is one already running" probe take ~1s.
|
||||
func waitRedisReady(dur time.Duration) bool {
|
||||
for start := time.Now(); ; time.Sleep(50 * time.Millisecond) {
|
||||
conn, err := net.DialTimeout("tcp", testRedisAddr, time.Second)
|
||||
func waitRedisReady(network, addr string, dur time.Duration) bool {
|
||||
for start := time.Now(); ; time.Sleep(5 * time.Millisecond) {
|
||||
conn, err := net.DialTimeout(network, addr, time.Second)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return true
|
||||
@@ -34,35 +30,33 @@ func waitRedisReady(dur time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func redisServerCmd(t TestingT) *exec.Cmd {
|
||||
// PrepareTestRedis returns a connection string to a running redis, reusing one
|
||||
// already listening on the well-known port, otherwise starting one for the
|
||||
// duration of the test.
|
||||
func PrepareTestRedis(t TestingT) string {
|
||||
if waitRedisReady("tcp", testRedisAddr, 0) {
|
||||
return "redis://" + testRedisAddr + "/0"
|
||||
}
|
||||
redisServerProg, err := exec.LookPath("redis-server")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &exec.Cmd{
|
||||
Path: redisServerProg,
|
||||
Args: []string{redisServerProg, "--bind", testRedisHost, "--port", testRedisPort},
|
||||
Dir: t.TempDir(),
|
||||
Stdin: os.Stdin,
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareTestRedis returns a connection string to a running redis, starting one
|
||||
// for the duration of the test if the port is free.
|
||||
func PrepareTestRedis(t TestingT) string {
|
||||
if waitRedisReady(0) {
|
||||
return testRedisConnStr
|
||||
}
|
||||
redisServer := redisServerCmd(t)
|
||||
if redisServer == nil {
|
||||
if AllowSkipExternalService() {
|
||||
t.Skipf("redis-server command not found, skipped")
|
||||
} else {
|
||||
t.Fatalf("no redis server or command, but skipping is not allowed")
|
||||
}
|
||||
return testRedisConnStr
|
||||
return ""
|
||||
}
|
||||
// listen on a socket of our own rather than a port, so that packages running
|
||||
// in parallel can neither reach nor tear down each other's server
|
||||
dir := t.TempDir()
|
||||
socket := filepath.Join(dir, "redis.sock")
|
||||
redisServer := &exec.Cmd{
|
||||
Path: redisServerProg,
|
||||
Args: []string{redisServerProg, "--port", "0", "--unixsocket", socket},
|
||||
Dir: dir,
|
||||
Stdin: os.Stdin,
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
}
|
||||
if err := redisServer.Start(); err != nil {
|
||||
t.Fatalf("failed to start redis-server: %v", err)
|
||||
@@ -71,8 +65,8 @@ func PrepareTestRedis(t TestingT) string {
|
||||
_ = redisServer.Process.Signal(os.Interrupt)
|
||||
_ = redisServer.Wait()
|
||||
})
|
||||
if !waitRedisReady(5 * time.Second) {
|
||||
if !waitRedisReady("unix", socket, 5*time.Second) {
|
||||
t.Fatalf("failed to start redis-server")
|
||||
}
|
||||
return testRedisConnStr
|
||||
return "redis+socket://" + socket
|
||||
}
|
||||
|
||||
+1
-3
@@ -37,7 +37,6 @@
|
||||
"@vitejs/plugin-vue": "6.0.8",
|
||||
"asciinema-player": "3.17.0",
|
||||
"chart.js": "4.5.1",
|
||||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"clippie": "4.2.1",
|
||||
"codemirror-lang-elixir": "4.0.1",
|
||||
@@ -66,8 +65,7 @@
|
||||
"vanilla-colorful": "0.7.2",
|
||||
"vite": "8.1.5",
|
||||
"vite-string-plugin": "2.0.5",
|
||||
"vue": "3.5.40",
|
||||
"vue-chartjs": "5.3.4"
|
||||
"vue": "3.5.40"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.7.2",
|
||||
|
||||
Generated
-29
@@ -101,9 +101,6 @@ importers:
|
||||
chart.js:
|
||||
specifier: 4.5.1
|
||||
version: 4.5.1
|
||||
chartjs-adapter-dayjs-4:
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4(chart.js@4.5.1)(dayjs@1.11.21)
|
||||
chartjs-plugin-zoom:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(chart.js@4.5.1)
|
||||
@@ -191,9 +188,6 @@ importers:
|
||||
vue:
|
||||
specifier: 3.5.40
|
||||
version: 3.5.40(typescript@6.0.3)
|
||||
vue-chartjs:
|
||||
specifier: 5.3.4
|
||||
version: 5.3.4(chart.js@4.5.1)(vue@3.5.40(typescript@6.0.3))
|
||||
devDependencies:
|
||||
'@eslint-community/eslint-plugin-eslint-comments':
|
||||
specifier: 4.7.2
|
||||
@@ -1834,13 +1828,6 @@ packages:
|
||||
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
|
||||
engines: {pnpm: '>=8'}
|
||||
|
||||
chartjs-adapter-dayjs-4@1.0.4:
|
||||
resolution: {integrity: sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
chart.js: '>=4.0.1'
|
||||
dayjs: ^1.9.7
|
||||
|
||||
chartjs-plugin-zoom@2.2.0:
|
||||
resolution: {integrity: sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==}
|
||||
peerDependencies:
|
||||
@@ -4303,12 +4290,6 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-chartjs@5.3.4:
|
||||
resolution: {integrity: sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==}
|
||||
peerDependencies:
|
||||
chart.js: ^4.1.1
|
||||
vue: ^3.0.0-0 || ^2.7.0
|
||||
|
||||
vue-eslint-parser@10.4.0:
|
||||
resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -6125,11 +6106,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.4
|
||||
|
||||
chartjs-adapter-dayjs-4@1.0.4(chart.js@4.5.1)(dayjs@1.11.21):
|
||||
dependencies:
|
||||
chart.js: 4.5.1
|
||||
dayjs: 1.11.21
|
||||
|
||||
chartjs-plugin-zoom@2.2.0(chart.js@4.5.1):
|
||||
dependencies:
|
||||
'@types/hammerjs': 2.0.46
|
||||
@@ -8999,11 +8975,6 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-chartjs@5.3.4(chart.js@4.5.1)(vue@3.5.40(typescript@6.0.3)):
|
||||
dependencies:
|
||||
chart.js: 4.5.1
|
||||
vue: 3.5.40(typescript@6.0.3)
|
||||
|
||||
vue-eslint-parser@10.4.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
|
||||
@@ -108,7 +108,7 @@ func EnumeratePackageVersions(ctx *context.Context) {
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, &packageVersions{
|
||||
Name: pds[0].Package.Name,
|
||||
Latest: packageDescriptorToMetadata(baseURL, pds[0]),
|
||||
Latest: versions[len(versions)-1], // versions mirrors pds, sorted ascending
|
||||
Versions: versions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1363,6 +1363,7 @@ func Routes() *web.Router {
|
||||
m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun)
|
||||
m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun)
|
||||
m.Post("/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelWorkflowRun)
|
||||
m.Post("/force-cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.ForceCancelWorkflowRun)
|
||||
m.Post("/approve", reqToken(), reqRepoWriter(unit.TypeActions), repo.ApproveWorkflowRun)
|
||||
m.Group("/jobs", func() {
|
||||
m.Get("", repo.ListWorkflowRunJobs)
|
||||
|
||||
@@ -88,6 +88,51 @@ func CancelWorkflowRun(ctx *context.APIContext) {
|
||||
// "409":
|
||||
// "$ref": "#/responses/conflict"
|
||||
|
||||
cancelWorkflowRun(ctx, false)
|
||||
}
|
||||
|
||||
func ForceCancelWorkflowRun(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/force-cancel repository forceCancelWorkflowRun
|
||||
// ---
|
||||
// summary: Force-cancel a workflow run
|
||||
// description: |
|
||||
// Cancels a workflow run without waiting for its runners to acknowledge the cancellation.
|
||||
// The jobs are marked cancelled at once and anything a runner reports for them afterwards is discarded.
|
||||
// Only use this endpoint when the workflow run does not respond to `POST /repos/{owner}/{repo}/actions/runs/{run}/cancel`.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: run
|
||||
// in: path
|
||||
// description: run ID
|
||||
// type: integer
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/WorkflowRun"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "409":
|
||||
// "$ref": "#/responses/conflict"
|
||||
|
||||
cancelWorkflowRun(ctx, true)
|
||||
}
|
||||
|
||||
func cancelWorkflowRun(ctx *context.APIContext, force bool) {
|
||||
run, jobs := getCurrentRepoActionRunJobsByID(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
@@ -99,7 +144,12 @@ func CancelWorkflowRun(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
run, err := actions_service.CancelRun(ctx, run, jobs)
|
||||
var err error
|
||||
if force {
|
||||
run, err = actions_service.ForceCancelRun(ctx, run, jobs)
|
||||
} else {
|
||||
run, err = actions_service.CancelRun(ctx, run, jobs)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
|
||||
@@ -248,7 +248,7 @@ type swaggerCommitList struct {
|
||||
PerPage int `json:"X-PerPage"`
|
||||
|
||||
// Total commit count
|
||||
Total int `json:"X-Total"`
|
||||
Total int `json:"X-Total-Count"`
|
||||
|
||||
// Total number of pages
|
||||
PageCount int `json:"X-PageCount"`
|
||||
@@ -266,11 +266,11 @@ type swaggerChangedFileList struct {
|
||||
// The current page
|
||||
Page int `json:"X-Page"`
|
||||
|
||||
// Commits per page
|
||||
// Files per page
|
||||
PerPage int `json:"X-PerPage"`
|
||||
|
||||
// Total commit count
|
||||
Total int `json:"X-Total"`
|
||||
// Total file count
|
||||
Total int `json:"X-Total-Count"`
|
||||
|
||||
// Total number of pages
|
||||
PageCount int `json:"X-PageCount"`
|
||||
|
||||
@@ -284,7 +284,7 @@ func handleViewIssueRedirectExternal(ctx *context.Context) {
|
||||
if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" {
|
||||
metas := ctx.Repo.Repository.ComposeCommentMetas(ctx)
|
||||
metas["index"] = ctx.PathParam("index")
|
||||
res, err := vars.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas)
|
||||
res, err := vars.ExpandCurlyBrace(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas)
|
||||
if err != nil {
|
||||
log.Error("unable to expand template vars for issue url. issue: %s, err: %v", metas["index"], err)
|
||||
ctx.ServerError("Expand", err)
|
||||
|
||||
@@ -161,6 +161,7 @@ func KeysPost(ctx *context.Context) {
|
||||
default:
|
||||
ctx.ServerError("VerifyGPG", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("settings.verify_gpg_key_success", keyID))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||
@@ -232,6 +233,7 @@ func KeysPost(ctx *context.Context) {
|
||||
default:
|
||||
ctx.ServerError("VerifySSH", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("settings.verify_ssh_key_success", fingerprint))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||
|
||||
@@ -12,10 +12,21 @@ import (
|
||||
)
|
||||
|
||||
// CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state.
|
||||
// A runner that supports it gets to run its post-cancel cleanup before the job reaches its final status.
|
||||
func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (*actions_model.ActionRun, error) {
|
||||
return cancelRun(ctx, run, jobs, false)
|
||||
}
|
||||
|
||||
// ForceCancelRun cancels a run like CancelRun, but does not wait for the runners to acknowledge it:
|
||||
// the jobs are marked cancelled at once and whatever a runner reports for them afterwards is discarded.
|
||||
func ForceCancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (*actions_model.ActionRun, error) {
|
||||
return cancelRun(ctx, run, jobs, true)
|
||||
}
|
||||
|
||||
func cancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, force bool) (*actions_model.ActionRun, error) {
|
||||
var updatedJobs []*actions_model.ActionRunJob
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
updatedJobs, err = actions_model.CancelJobs(ctx, jobs)
|
||||
updatedJobs, err = actions_model.CancelJobs(ctx, jobs, force)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CancelJobs: %w", err)
|
||||
}
|
||||
@@ -27,7 +38,8 @@ func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*action
|
||||
return nil, err
|
||||
}
|
||||
|
||||
CreateCommitStatusForRunJobs(ctx, run, jobs...)
|
||||
// updatedJobs, not jobs: cancelOneJob re-reads the cancelled rows, the input ones still carry their pre-cancel status
|
||||
CreateCommitStatusForRunJobs(ctx, run, updatedJobs...)
|
||||
EmitJobsIfReadyByJobs(updatedJobs)
|
||||
NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ func CancelAbandonedJobs(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedJobs, err := actions_model.CancelJobs(ctx, abandonedJobs)
|
||||
updatedJobs, err := actions_model.CancelJobs(ctx, abandonedJobs, false)
|
||||
if err != nil {
|
||||
log.Warn("cancel abandoned jobs: %v", err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
|
||||
switch ref.Kind {
|
||||
case jobparser.UsesKindLocalSameRepo:
|
||||
// `./` is resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
|
||||
// `./` and `$/` are resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
|
||||
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
||||
@@ -115,7 +115,7 @@ func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refO
|
||||
// - rejects cycles (caller.CallUses appearing in any ancestor's CallUses)
|
||||
// - enforces MaxReusableCallLevels on the number of ancestors above `caller`
|
||||
//
|
||||
// Cycle detection is intentionally *syntactic* (string equality on CallUses), not semantic.
|
||||
// Cycle detection is intentionally *syntactic* (string equality on canonicalCallUses), not semantic.
|
||||
// So `owner/repo/lib.yml@v1` and `owner/repo/lib.yml@refs/heads/v1` resolving to the same commit are NOT treated as the same node.
|
||||
// Going semantic (Owner, Repo, Path, ResolvedSHA tuples) would require extra git reads.
|
||||
func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) error {
|
||||
@@ -123,8 +123,7 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e
|
||||
return nil // top-level caller: depth 0, no ancestors to walk
|
||||
}
|
||||
|
||||
visited := make(container.Set[string])
|
||||
visited.Add(caller.CallUses)
|
||||
visited := container.SetOf(canonicalCallUses(caller.CallUses))
|
||||
|
||||
depth := 0
|
||||
current := caller
|
||||
@@ -138,16 +137,21 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e
|
||||
if depth > MaxReusableCallLevels {
|
||||
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses)
|
||||
}
|
||||
if current.IsReusableCaller && current.CallUses != "" {
|
||||
if visited.Contains(current.CallUses) {
|
||||
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
|
||||
}
|
||||
visited.Add(current.CallUses)
|
||||
if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current.CallUses)) {
|
||||
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// canonicalCallUses folds the two same-repo prefixes into one key, because `$/x.yml` and `./x.yml` name the same file.
|
||||
func canonicalCallUses(uses string) string {
|
||||
if ref, err := jobparser.ParseUses(uses); err == nil && ref.Kind == jobparser.UsesKindLocalSameRepo {
|
||||
return "./" + ref.Path
|
||||
}
|
||||
return uses
|
||||
}
|
||||
|
||||
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
|
||||
// It expands only ONE level: a child that is itself a reusable caller is inserted Blocked and expanded later by a subsequent resolver pass.
|
||||
// It does NOT schedule a follow-up resolver pass; the caller of this function is responsible for emitting.
|
||||
|
||||
@@ -42,6 +42,17 @@ func TestCheckCallerChain_Cycle(t *testing.T) {
|
||||
assert.ErrorContains(t, err, "cycle detected")
|
||||
})
|
||||
|
||||
t.Run("MixedPrefixCycle", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// A -> A written with both same-repo prefixes: they name the same file.
|
||||
chain := buildCallerChain(t,
|
||||
"./.gitea/workflows/a.yml",
|
||||
"$/.gitea/workflows/a.yml",
|
||||
)
|
||||
err := checkCallerChain(t.Context(), chain[len(chain)-1])
|
||||
assert.ErrorContains(t, err, "cycle detected")
|
||||
})
|
||||
|
||||
t.Run("NoCycle", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// Sanity: linear chain with distinct CallUses must not trip cycle detection.
|
||||
|
||||
+26
-9
@@ -32,6 +32,7 @@ import (
|
||||
"gitea.dev/modules/references"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates/vars"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
@@ -66,17 +67,15 @@ func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issue
|
||||
reviewedBy := pr.GetApprovers(ctx)
|
||||
|
||||
if mergeStyle != "" {
|
||||
templateFilepath := fmt.Sprintf(".gitea/default_merge_message/%s_TEMPLATE.md", strings.ToUpper(string(mergeStyle)))
|
||||
commit, err := baseGitRepo.GetBranchCommit(ctx, pr.BaseRepo.DefaultBranch)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
templateContent, err := commit.GetFileContent(ctx, baseGitRepo, templateFilepath, setting.Repository.PullRequest.DefaultMergeMessageSize)
|
||||
templateContent, err := resolveMergeMessageTemplate(ctx, baseGitRepo, commit, mergeStyle)
|
||||
if err != nil {
|
||||
if !git.IsErrNotExist(err) {
|
||||
return "", "", err
|
||||
}
|
||||
} else {
|
||||
return "", "", err
|
||||
}
|
||||
if templateContent != "" {
|
||||
vars := map[string]string{
|
||||
"BaseRepoOwnerName": pr.BaseRepo.OwnerName,
|
||||
"BaseRepoName": pr.BaseRepo.Name,
|
||||
@@ -146,14 +145,32 @@ func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issue
|
||||
return fmt.Sprintf("Merge pull request '%s' (%s%d) from %s:%s into %s", pr.Issue.Title, issueReference, pr.Issue.Index, pr.HeadRepo.FullName(), pr.HeadBranch, pr.BaseBranch), body, nil
|
||||
}
|
||||
|
||||
func expandDefaultMergeMessage(template string, vars map[string]string) (message, body string) {
|
||||
// resolveMergeMessageTemplate returns the content of the merge message template for the given
|
||||
// merge style. It first looks for a style-specific template ({STYLE}_TEMPLATE.md), and falls back
|
||||
// to the generic DEFAULT_TEMPLATE.md if the style-specific one is not found.
|
||||
func resolveMergeMessageTemplate(ctx context.Context, baseGitRepo *git.Repository, commit *git.Commit, mergeStyle repo_model.MergeStyle) (string, error) {
|
||||
templateFilepath := fmt.Sprintf(".gitea/default_merge_message/%s_TEMPLATE.md", strings.ToUpper(string(mergeStyle)))
|
||||
templateContent, err := commit.GetFileContent(ctx, baseGitRepo, templateFilepath, setting.Repository.PullRequest.DefaultMergeMessageSize)
|
||||
if err == nil {
|
||||
return templateContent, nil
|
||||
}
|
||||
if !git.IsErrNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
templateContent, err = commit.GetFileContent(ctx, baseGitRepo, ".gitea/default_merge_message/DEFAULT_TEMPLATE.md", setting.Repository.PullRequest.DefaultMergeMessageSize)
|
||||
if err == nil || git.IsErrNotExist(err) {
|
||||
return templateContent, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func expandDefaultMergeMessage(template string, varsMap map[string]string) (message, body string) {
|
||||
message = strings.TrimSpace(template)
|
||||
if splits := strings.SplitN(message, "\n", 2); len(splits) == 2 {
|
||||
message = splits[0]
|
||||
body = strings.TrimSpace(splits[1])
|
||||
}
|
||||
mapping := func(s string) string { return vars[s] }
|
||||
return os.Expand(message, mapping), os.Expand(body, mapping)
|
||||
return vars.ExpandShellLike(message, varsMap), vars.ExpandShellLike(body, varsMap)
|
||||
}
|
||||
|
||||
// GetDefaultMergeMessage returns default message used when merging pull request
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
package pull
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_expandDefaultMergeMessage(t *testing.T) {
|
||||
@@ -90,3 +94,47 @@ func TestAddCommitMessageTailer(t *testing.T) {
|
||||
assert.Equal(t, "title\n\nTest-tailer: v1\nTest-tailer: v2", AddCommitMessageTailer("title\n\nTest-tailer: v1", "Test-tailer", "v2"))
|
||||
assert.Equal(t, "title\n\nTest-tailer: v1\nTest-tailer: v2", AddCommitMessageTailer("title\n\nTest-tailer: v1\n", "Test-tailer", "v2"))
|
||||
}
|
||||
|
||||
func TestResolveMergeMessageTemplate(t *testing.T) {
|
||||
t.Run("NoDefault", func(t *testing.T) {
|
||||
repo, err := git.ForceFastImportWithInit(t.Context(), filepath.Join(t.TempDir(), "test-repo"), []git.FastImportCommit{
|
||||
{Ref: "refs/heads/master", Files: []git.FastImportFile{
|
||||
{Path: ".gitea/default_merge_message/REBASE_TEMPLATE.md", Content: "rebase template"},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
gitRepo, err := git.OpenRepository(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(t.Context(), "master")
|
||||
require.NoError(t, err)
|
||||
tmpl, err := resolveMergeMessageTemplate(t.Context(), gitRepo, commit, "merge")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", tmpl)
|
||||
tmpl, err = resolveMergeMessageTemplate(t.Context(), gitRepo, commit, "rebase")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "rebase template", tmpl)
|
||||
})
|
||||
t.Run("WithDefault", func(t *testing.T) {
|
||||
repo, err := git.ForceFastImportWithInit(t.Context(), filepath.Join(t.TempDir(), "test-repo"), []git.FastImportCommit{
|
||||
{Ref: "refs/heads/master", Files: []git.FastImportFile{
|
||||
{Path: ".gitea/default_merge_message/DEFAULT_TEMPLATE.md", Content: "default template"},
|
||||
{Path: ".gitea/default_merge_message/REBASE_TEMPLATE.md", Content: "rebase template"},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
gitRepo, err := git.OpenRepository(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(t.Context(), "master")
|
||||
require.NoError(t, err)
|
||||
tmpl, err := resolveMergeMessageTemplate(t.Context(), gitRepo, commit, "merge")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "default template", tmpl)
|
||||
tmpl, err = resolveMergeMessageTemplate(t.Context(), gitRepo, commit, "rebase")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "rebase template", tmpl)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,7 +102,8 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel
|
||||
}
|
||||
}
|
||||
|
||||
commit, err := gitRepo.GetCommit(ctx, rel.Target)
|
||||
target := util.IfZero(rel.Target, rel.Repo.DefaultBranch)
|
||||
commit, err := gitRepo.GetCommit(ctx, target)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestRelease_Update(t *testing.T) {
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v1.1.2",
|
||||
Target: "master",
|
||||
Target: "",
|
||||
Title: "v1.1.2 is released",
|
||||
Note: "v1.1.2 is released",
|
||||
IsDraft: true,
|
||||
|
||||
@@ -91,7 +91,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir
|
||||
"CloneURL.HTTPS": cloneLink.HTTPS,
|
||||
"OwnerName": repo.OwnerName,
|
||||
}
|
||||
res, err := vars.Expand(string(data), match)
|
||||
res, err := vars.ExpandCurlyBrace(string(data), match)
|
||||
if err != nil {
|
||||
// here we could just log the error and continue the rendering
|
||||
log.Error("unable to expand template vars for repo README: %s, err: %v", opts.Readme, err)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates/vars"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/huandu/xstrings"
|
||||
@@ -86,12 +87,7 @@ func generateExpansion(ctx context.Context, src string, templateRepo, generateRe
|
||||
}
|
||||
}
|
||||
|
||||
return os.Expand(src, func(key string) string {
|
||||
if val, ok := expansionMap[key]; ok {
|
||||
return val
|
||||
}
|
||||
return key
|
||||
})
|
||||
return vars.ExpandShellLike(src, expansionMap)
|
||||
}
|
||||
|
||||
type giteaTemplateFileMatcher struct {
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
{{ctx.Locale.Tr "repo.settings.delete_notices_fork_1"}}
|
||||
{{end}}
|
||||
</div>
|
||||
<form class="ui form" action="{{.Link}}/settings" method="post">
|
||||
<form class="ui form form-fetch-action" action="{{.Link}}/settings" method="post">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<div class="field">
|
||||
<label>
|
||||
@@ -88,7 +88,7 @@
|
||||
<div class="header">
|
||||
{{ctx.Locale.Tr "repo.migrate.cancel_migrating_title"}}
|
||||
</div>
|
||||
<form action="{{.Link}}/settings/migrate/cancel" method="post">
|
||||
<form class="form-fetch-action" action="{{.Link}}/settings/migrate/cancel" method="post">
|
||||
<div class="content">
|
||||
{{ctx.Locale.Tr "repo.migrate.cancel_migrating_confirm"}}
|
||||
</div>
|
||||
|
||||
+60
-4
@@ -213,14 +213,14 @@
|
||||
}
|
||||
},
|
||||
"X-PerPage": {
|
||||
"description": "Commits per page",
|
||||
"description": "Files per page",
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"X-Total": {
|
||||
"description": "Total commit count",
|
||||
"X-Total-Count": {
|
||||
"description": "Total file count",
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -311,7 +311,7 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"X-Total": {
|
||||
"X-Total-Count": {
|
||||
"description": "Total commit count",
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
@@ -16802,6 +16802,62 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/actions/runs/{run}/force-cancel": {
|
||||
"post": {
|
||||
"description": "Cancels a workflow run without waiting for its runners to acknowledge the cancellation.\nThe jobs are marked cancelled at once and anything a runner reports for them afterwards is discarded.\nOnly use this endpoint when the workflow run does not respond to `POST /repos/{owner}/{repo}/actions/runs/{run}/cancel`.\n",
|
||||
"operationId": "forceCancelWorkflowRun",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "owner of the repo",
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "name of the repository",
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "run ID",
|
||||
"in": "path",
|
||||
"name": "run",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/WorkflowRun"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/error"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/components/responses/conflict"
|
||||
}
|
||||
},
|
||||
"summary": "Force-cancel a workflow run",
|
||||
"tags": [
|
||||
"repository"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
|
||||
"get": {
|
||||
"operationId": "listWorkflowRunJobs",
|
||||
|
||||
+57
-4
@@ -5737,6 +5737,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/actions/runs/{run}/force-cancel": {
|
||||
"post": {
|
||||
"description": "Cancels a workflow run without waiting for its runners to acknowledge the cancellation.\nThe jobs are marked cancelled at once and anything a runner reports for them afterwards is discarded.\nOnly use this endpoint when the workflow run does not respond to `POST /repos/{owner}/{repo}/actions/runs/{run}/cancel`.\n",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Force-cancel a workflow run",
|
||||
"operationId": "forceCancelWorkflowRun",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repository",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "run ID",
|
||||
"name": "run",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/WorkflowRun"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/responses/error"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/responses/conflict"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -31247,12 +31300,12 @@
|
||||
"X-PerPage": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Commits per page"
|
||||
"description": "Files per page"
|
||||
},
|
||||
"X-Total": {
|
||||
"X-Total-Count": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Total commit count"
|
||||
"description": "Total file count"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -31311,7 +31364,7 @@
|
||||
"format": "int64",
|
||||
"description": "Commits per page"
|
||||
},
|
||||
"X-Total": {
|
||||
"X-Total-Count": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Total commit count"
|
||||
|
||||
@@ -17,10 +17,13 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/tests"
|
||||
@@ -42,6 +45,7 @@ func TestAPIActionsWorkflowRun(t *testing.T) {
|
||||
t.Run("GetWorkflowJobLogsNotFound", testAPIActionsGetWorkflowJobLogsNotFound)
|
||||
// finishes run 793, so it must come after everything that needs it still running
|
||||
t.Run("CancelWorkflowRun", testAPIActionsCancelWorkflowRun)
|
||||
t.Run("ForceCancelWorkflowRun", testAPIActionsForceCancelWorkflowRun)
|
||||
t.Run("ApproveWorkflowRun", testAPIActionsApproveWorkflowRun)
|
||||
// deletes run 795, so it must come after everything that reads it
|
||||
t.Run("DeleteRunGeneral", testAPIActionsDeleteRunGeneral)
|
||||
@@ -373,6 +377,131 @@ func testAPIActionsCancelWorkflowRun(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func testAPIActionsForceCancelWorkflowRun(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
ownerSession := loginUser(t, owner.Name)
|
||||
ownerToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
// repo4's master head, so the run's commit statuses are created against a commit that exists
|
||||
const commitSHA = "c7cd3cd144e6d23c9d6f3d07e52b2c1a956e0338"
|
||||
eventPayload, err := json.Marshal(&api.PushPayload{HeadCommit: &api.PayloadCommit{ID: commitSHA}})
|
||||
require.NoError(t, err)
|
||||
|
||||
// A running run whose runner advertises cancelling support and reports on time:
|
||||
// a normal cancel only starts the graceful cancelling handshake, so only a force-cancel finishes it.
|
||||
run := &actions_model.ActionRun{
|
||||
Title: "force-cancel-test",
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: "force-cancel.yaml",
|
||||
Index: 9601,
|
||||
TriggerUserID: owner.ID,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: commitSHA,
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: string(eventPayload),
|
||||
Status: actions_model.StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
attempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: run.RepoID,
|
||||
RunID: run.ID,
|
||||
Attempt: 1,
|
||||
TriggerUserID: owner.ID,
|
||||
Status: actions_model.StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
require.NoError(t, actions_model.UpdateRun(t.Context(), run, "latest_attempt_id"))
|
||||
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "job1",
|
||||
Attempt: 1,
|
||||
JobID: "job1",
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &actions_model.ActionRunner{
|
||||
UUID: "force-cancel-runner",
|
||||
Name: "force-cancel-runner",
|
||||
RepoID: repo.ID,
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: actions_model.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 = actions_model.UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo.FullName(), run.ID)
|
||||
forceCancelURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/force-cancel", repo.FullName(), run.ID)
|
||||
|
||||
// a normal cancel only starts the graceful handshake
|
||||
MakeRequest(t, NewRequest(t, "POST", cancelURL).AddTokenAuth(ownerToken), http.StatusOK)
|
||||
cancellingTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelling, cancellingTask.Status)
|
||||
|
||||
// the commit status describes the cancellation, not the job's pre-cancel state
|
||||
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, commitSHA, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, statuses, 1)
|
||||
assert.Equal(t, "Canceling", statuses[0].Description)
|
||||
|
||||
// force-cancel bypasses the handshake and finishes the run immediately
|
||||
resp := MakeRequest(t, NewRequest(t, "POST", forceCancelURL).AddTokenAuth(ownerToken), http.StatusOK)
|
||||
cancelledRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{})
|
||||
assert.Equal(t, "completed", cancelledRun.Status)
|
||||
assert.Equal(t, "cancelled", cancelledRun.Conclusion)
|
||||
|
||||
cancelledTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, cancelledTask.Status)
|
||||
gotAttempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: attempt.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, gotAttempt.Status)
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, gotRun.Status)
|
||||
|
||||
// the run is done, so its commit status must be final instead of pending
|
||||
statuses, err = git_model.GetLatestCommitStatus(t.Context(), repo.ID, commitSHA, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, statuses, 1)
|
||||
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[0].State)
|
||||
|
||||
// both endpoints refuse the completed run
|
||||
MakeRequest(t, NewRequest(t, "POST", cancelURL).AddTokenAuth(ownerToken), http.StatusConflict)
|
||||
MakeRequest(t, NewRequest(t, "POST", forceCancelURL).AddTokenAuth(ownerToken), http.StatusConflict)
|
||||
|
||||
// the route is guarded like /cancel: user2 has no access to repo4, owned by user5
|
||||
user2Token := getTokenForLoggedInUser(t, loginUser(t, "user2"), auth_model.AccessTokenScopeWriteRepository)
|
||||
MakeRequest(t, NewRequest(t, "POST", forceCancelURL).AddTokenAuth(user2Token), http.StatusForbidden)
|
||||
|
||||
missingRunURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/force-cancel", repo.FullName())
|
||||
MakeRequest(t, NewRequest(t, "POST", missingRunURL).AddTokenAuth(ownerToken), http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIActionsApproveWorkflowRun(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
// user5 owns repo4, user4 is a write collaborator on it, user2 has no access at all
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
@@ -20,6 +19,7 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
pub_module "gitea.dev/modules/packages/pub"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -34,26 +34,19 @@ func TestPackagePub(t *testing.T) {
|
||||
|
||||
packageName := "test_package"
|
||||
packageVersion := "1.0.1"
|
||||
packageVersionLatest := "1.0.2"
|
||||
packageDescription := "Test Description"
|
||||
|
||||
filename := packageVersion + ".tar.gz"
|
||||
|
||||
pubspecContent := `name: ` + packageName + `
|
||||
version: ` + packageVersion + `
|
||||
description: ` + packageDescription
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
archive := tar.NewWriter(zw)
|
||||
archive.WriteHeader(&tar.Header{
|
||||
Name: "pubspec.yaml",
|
||||
Mode: 0o600,
|
||||
Size: int64(len(pubspecContent)),
|
||||
})
|
||||
archive.Write([]byte(pubspecContent))
|
||||
archive.Close()
|
||||
zw.Close()
|
||||
content := buf.Bytes()
|
||||
buildPackage := func(version string) []byte {
|
||||
return test.WriteTarCompression(gzip.NewWriter, map[string]string{
|
||||
"pubspec.yaml": `name: ` + packageName + `
|
||||
version: ` + version + `
|
||||
description: ` + packageDescription,
|
||||
}).Bytes()
|
||||
}
|
||||
content := buildPackage(packageVersion)
|
||||
|
||||
root := fmt.Sprintf("/api/packages/%s/pub", user.Name)
|
||||
|
||||
@@ -120,6 +113,8 @@ description: ` + packageDescription
|
||||
assert.Equal(t, int64(len(content)), pb.Size)
|
||||
|
||||
_ = uploadFile(t, result.URL, content, http.StatusConflict)
|
||||
|
||||
uploadFile(t, result.URL, buildPackage(packageVersionLatest), http.StatusNoContent)
|
||||
})
|
||||
|
||||
t.Run("Download", func(t *testing.T) {
|
||||
@@ -169,9 +164,10 @@ description: ` + packageDescription
|
||||
|
||||
assert.Equal(t, packageName, result.Name)
|
||||
assert.NotNil(t, result.Latest)
|
||||
assert.Len(t, result.Versions, 1)
|
||||
assert.Equal(t, result.Latest.Version, result.Versions[0].Version)
|
||||
assert.Equal(t, packageVersion, result.Latest.Version)
|
||||
assert.Len(t, result.Versions, 2)
|
||||
assert.Equal(t, packageVersion, result.Versions[0].Version)
|
||||
assert.Equal(t, packageVersionLatest, result.Versions[1].Version)
|
||||
assert.Equal(t, packageVersionLatest, result.Latest.Version)
|
||||
assert.NotNil(t, result.Latest.Pubspec)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -273,12 +273,10 @@ a {
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
text-decoration-line: none;
|
||||
text-decoration-skip-ink: all;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration-line: underline;
|
||||
text-underline-position: under; /* necessary for CJK fonts, otherwise, default "auto" makes the underline cross-over the CJK text bottom */
|
||||
}
|
||||
|
||||
/* a = always colored, underlined on hover */
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
}
|
||||
.console a:hover {
|
||||
color: var(--color-primary);
|
||||
text-underline-position: auto; /* the global "a:hover" sets "under", which would shift the underline */
|
||||
}
|
||||
|
||||
@keyframes blink-animation {
|
||||
|
||||
@@ -144,8 +144,9 @@
|
||||
|
||||
#navbar .item .navbar-admin-badge {
|
||||
position: absolute;
|
||||
bottom: calc(100% - 29px);
|
||||
left: calc(100% - 18px);
|
||||
left: auto;
|
||||
right: -7px;
|
||||
bottom: -5px;
|
||||
padding: 1.5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import {addDelegatedEventListener, createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
-->
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {getActionStatusIcon, type ActionStatusIconVariant} from '../modules/action-status-icon.ts';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts" setup>
|
||||
import {onMounted, onUnmounted, toRaw, useTemplateRef, watch, type ShallowRef} from 'vue';
|
||||
import {_adapters, BarController, Chart, LinearScale, LineController, TimeScale, type ChartData, type ChartOptions, type TimeUnit} from 'chart.js';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import dayjs from 'dayjs';
|
||||
import advancedFormat from 'dayjs/plugin/advancedFormat.js';
|
||||
import quarterOfYear from 'dayjs/plugin/quarterOfYear.js';
|
||||
import type {ConfigType, ManipulateType} from 'dayjs';
|
||||
|
||||
dayjs.extend(advancedFormat); // the quarter format needs the `Q` token
|
||||
dayjs.extend(quarterOfYear);
|
||||
|
||||
Chart.defaults.color = chartJsColors.text;
|
||||
Chart.defaults.borderColor = chartJsColors.border;
|
||||
Chart.register(BarController, LineController, LinearScale, TimeScale);
|
||||
|
||||
// minimal port of chartjs-adapter-dayjs-4, MIT license, Copyright (c) 2022 bolstycjw
|
||||
_adapters._date.override({
|
||||
formats: () => ({
|
||||
datetime: 'MMM D, YYYY, h:mm:ss a',
|
||||
millisecond: 'h:mm:ss.SSS a',
|
||||
second: 'h:mm:ss a',
|
||||
minute: 'h:mm a',
|
||||
hour: 'hA',
|
||||
day: 'MMM D',
|
||||
week: 'MMM D, YYYY',
|
||||
month: 'MMM YYYY',
|
||||
quarter: '[Q]Q - YYYY',
|
||||
year: 'YYYY',
|
||||
}),
|
||||
parse: (value: ConfigType) => {
|
||||
const date = dayjs(value);
|
||||
return date.isValid() ? date.valueOf() : null;
|
||||
},
|
||||
format: (time: number, format: string) => dayjs(time).format(format),
|
||||
add: (time: number, amount: number, unit: TimeUnit) => dayjs(time).add(amount, unit as ManipulateType).valueOf(), // the quarter plugin widens this at runtime
|
||||
diff: (max: number, min: number, unit: TimeUnit) => dayjs(max).diff(min, unit),
|
||||
// chart.js only asks for `isoWeek` when `time.isoWeekday` is set, which we never do
|
||||
startOf: (time: number, unit: TimeUnit) => dayjs(time).startOf(unit).valueOf(),
|
||||
endOf: (time: number, unit: TimeUnit) => dayjs(time).endOf(unit).valueOf(),
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'bar' | 'line',
|
||||
data: ChartData,
|
||||
options: ChartOptions,
|
||||
}>();
|
||||
|
||||
const elCanvas = useTemplateRef('elCanvas') as Readonly<ShallowRef<HTMLCanvasElement>>;
|
||||
let chart: Chart | undefined;
|
||||
|
||||
// chart.js mutates what it gets, so it must never see a reactive proxy
|
||||
onMounted(() => {
|
||||
chart = new Chart(elCanvas.value, {type: props.type, data: toRaw(props.data), options: toRaw(props.options)});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
chart?.destroy();
|
||||
});
|
||||
|
||||
watch([() => props.data, () => props.options], ([data, options]) => {
|
||||
if (!chart) return; // chart creation failed
|
||||
chart.data = toRaw(data);
|
||||
chart.options = toRaw(options);
|
||||
chart.update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="elCanvas" role="img"/>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
|
||||
import {computed} from 'vue';
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, onMounted, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {urlQueryEscape} from '../utils/url.ts';
|
||||
import type {SvgName} from '../svg.ts';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {generateElemId} from '../utils/dom.ts';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon, type SvgName} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import type {SvgName} from '../svg.ts';
|
||||
import {shallowRef} from 'vue';
|
||||
import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import {computed, onBeforeUnmount, ref, toRefs, watch} from 'vue';
|
||||
import {resetActionFavicon, syncActionRunFavicon} from '../modules/favicon-status.ts';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch, type ShallowRef} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {
|
||||
Chart,
|
||||
Legend,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
type ChartData,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {Line as ChartLine} from 'vue-chartjs';
|
||||
import ChartCanvas from './ChartCanvas.vue';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
@@ -23,17 +21,11 @@ import {
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, shallowRef} from 'vue';
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
Chart.defaults.color = chartJsColors.text;
|
||||
Chart.defaults.borderColor = chartJsColors.border;
|
||||
|
||||
Chart.register(
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
Legend,
|
||||
PointElement,
|
||||
LineElement,
|
||||
@@ -85,7 +77,9 @@ async function fetchGraphData() {
|
||||
}
|
||||
}
|
||||
|
||||
function toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
|
||||
const graphData = computed(() => toGraphData(data.value));
|
||||
|
||||
function toGraphData(data: DayData[]): ChartData<'line'> {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
@@ -159,9 +153,9 @@ const options: ChartOptions<'line'> = {
|
||||
{{ errorText }}
|
||||
</div>
|
||||
</div>
|
||||
<ChartLine
|
||||
v-memo="data" v-if="data.length !== 0"
|
||||
:data="toGraphData(data)" :options="options"
|
||||
<ChartCanvas
|
||||
v-if="data.length !== 0"
|
||||
type="line" :data="graphData" :options="options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import dayjs from 'dayjs';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {Line as ChartLine} from 'vue-chartjs';
|
||||
import ChartCanvas from './ChartCanvas.vue';
|
||||
import {
|
||||
Chart,
|
||||
Title,
|
||||
BarElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
@@ -19,7 +16,6 @@ import {
|
||||
} from 'chart.js';
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
@@ -56,13 +52,7 @@ type LineOptions = ChartOptions<'line'> & {
|
||||
};
|
||||
}
|
||||
|
||||
Chart.defaults.color = chartJsColors.text;
|
||||
Chart.defaults.borderColor = chartJsColors.border;
|
||||
|
||||
Chart.register(
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
PointElement,
|
||||
LineElement,
|
||||
@@ -102,7 +92,8 @@ const errorText = shallowRef('');
|
||||
const totalStats = shallowRef<Record<string, any>>({});
|
||||
const sortedContributors = shallowRef<Array<Record<string, any>>>([]);
|
||||
const type = shallowRef<ContributionType>('commits');
|
||||
let contributorsStats: Record<string, any> = {}; // these three are not read during render
|
||||
let contributorsStats: Record<string, any> = {};
|
||||
// plain values, so the main chart options do not follow the zoomed range
|
||||
let xAxisStart: number | null = null;
|
||||
let xAxisEnd: number | null = null;
|
||||
const xAxisMin = shallowRef<number | null>(null);
|
||||
@@ -301,8 +292,9 @@ function getOptions(chartType: ChartType): LineOptions {
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
min: xAxisMin.value ?? undefined,
|
||||
max: xAxisMax.value ?? undefined,
|
||||
// the main chart keeps its own zoom range
|
||||
min: (chartType === 'main' ? xAxisStart : xAxisMin.value) ?? undefined,
|
||||
max: (chartType === 'main' ? xAxisEnd : xAxisMax.value) ?? undefined,
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
@@ -325,6 +317,17 @@ function getOptions(chartType: ChartType): LineOptions {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mainChart = computed(() => ({
|
||||
graphData: toGraphData(totalStats.value.weeks),
|
||||
chartOptions: getOptions('main'),
|
||||
}));
|
||||
|
||||
const contributorCharts = computed(() => sortedContributors.value.map((contributor) => ({
|
||||
contributor,
|
||||
graphData: toGraphData(contributor.weeks),
|
||||
chartOptions: getOptions('contributor'), // chart.js mutates it, so each chart needs its own
|
||||
})));
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
@@ -386,16 +389,15 @@ function getOptions(chartType: ChartType): LineOptions {
|
||||
{{ errorText }}
|
||||
</div>
|
||||
</div>
|
||||
<ChartLine
|
||||
v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
|
||||
:data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
|
||||
<ChartCanvas
|
||||
v-if="Object.keys(totalStats).length !== 0"
|
||||
type="line" :data="mainChart.graphData" :options="mainChart.chartOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="contributor-grid">
|
||||
<div
|
||||
v-for="(contributor, index) in sortedContributors"
|
||||
v-for="({contributor, graphData, chartOptions}, index) in contributorCharts"
|
||||
:key="index"
|
||||
v-memo="[sortedContributors, type]"
|
||||
>
|
||||
<div class="ui top attached header tw-flex tw-flex-1">
|
||||
<b class="ui right">#{{ index + 1 }}</b>
|
||||
@@ -421,9 +423,10 @@ function getOptions(chartType: ChartType): LineOptions {
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<div>
|
||||
<ChartLine
|
||||
:data="toGraphData(contributor.weeks)"
|
||||
:options="getOptions('contributor')"
|
||||
<ChartCanvas
|
||||
type="line"
|
||||
:data="graphData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {generateElemId} from '../utils/dom.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {filterRepoFilesWeighted} from '../features/repo-findfile.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {throttle} from '../utils/func.ts';
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {
|
||||
Chart,
|
||||
Tooltip,
|
||||
BarElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
type ChartOptions,
|
||||
type ChartData,
|
||||
type ChartDataset,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {Bar} from 'vue-chartjs';
|
||||
import ChartCanvas from './ChartCanvas.vue';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
@@ -22,17 +20,11 @@ import {
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, ref, shallowRef} from 'vue';
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
Chart.defaults.color = chartJsColors.text;
|
||||
Chart.defaults.borderColor = chartJsColors.border;
|
||||
|
||||
Chart.register(
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
);
|
||||
@@ -48,7 +40,7 @@ defineProps<{
|
||||
const isLoading = shallowRef(false);
|
||||
const errorText = shallowRef('');
|
||||
const repoLink = pageData.repoLink!;
|
||||
const data = ref<DayData[]>([]);
|
||||
const data = shallowRef<DayData[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
fetchGraphData();
|
||||
@@ -81,6 +73,8 @@ async function fetchGraphData() {
|
||||
}
|
||||
}
|
||||
|
||||
const graphData = computed(() => toGraphData(data.value));
|
||||
|
||||
function toGraphData(data: DayData[]): ChartData<'bar'> {
|
||||
return {
|
||||
datasets: [
|
||||
@@ -137,9 +131,9 @@ const options: ChartOptions<'bar'> = {
|
||||
{{ errorText }}
|
||||
</div>
|
||||
</div>
|
||||
<Bar
|
||||
v-memo="data" v-if="data.length !== 0"
|
||||
:data="toGraphData(data)" :options="options"
|
||||
<ChartCanvas
|
||||
v-if="data.length !== 0"
|
||||
type="bar" :data="graphData" :options="options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {createApp, h} from 'vue';
|
||||
|
||||
test('SvgIcon', () => {
|
||||
const root = document.createElement('div');
|
||||
createApp({render: () => h(SvgIcon, {name: 'octicon-dot-fill', size: 24, class: 'base', symbolId: 'svg-symbol-dot'})}).mount(root);
|
||||
expect(root.innerHTML).toBe(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24" aria-hidden="true" class="svg octicon-dot-fill tw-hidden svg-symbol-container base"><symbol id="svg-symbol-dot" viewBox="0 0 16 16"><path d="M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8"></path></symbol></svg>`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue';
|
||||
import {svgParseOuterInner, type SvgName} from '../svg.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: SvgName,
|
||||
size?: number,
|
||||
symbolId?: string,
|
||||
}>(), {
|
||||
size: 16,
|
||||
symbolId: undefined,
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
let {svgOuter, svgInnerHtml} = svgParseOuterInner(props.name);
|
||||
const attrs: Record<string, string | number> = {};
|
||||
for (const attr of svgOuter.attributes) {
|
||||
if (attr.name === 'class') continue;
|
||||
attrs[attr.name] = attr.value;
|
||||
}
|
||||
attrs.width = props.size;
|
||||
attrs.height = props.size;
|
||||
|
||||
const classes = Array.from(svgOuter.classList);
|
||||
if (props.symbolId) {
|
||||
classes.push('tw-hidden', 'svg-symbol-container');
|
||||
svgInnerHtml = html`<symbol id="${props.symbolId}" viewBox="${attrs.viewBox}">${htmlRaw(svgInnerHtml)}</symbol>`;
|
||||
}
|
||||
attrs.innerHTML = svgInnerHtml; // the icons are bundled, they carry no user input
|
||||
return {attrs, classes};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-bind="icon.attrs" :class="icon.classes"/>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import {isPlainClick} from '../utils/dom.ts';
|
||||
import {shouldTriggerAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {shallowRef} from 'vue';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import {isPlainClick} from '../utils/dom.ts';
|
||||
|
||||
+1
-13
@@ -1,5 +1,4 @@
|
||||
import {svg, SvgIcon, svgParseOuterInner} from './svg.ts';
|
||||
import {createApp, h} from 'vue';
|
||||
import {svg, svgParseOuterInner} from './svg.ts';
|
||||
|
||||
test('svg', () => {
|
||||
expect(svg('octicon-repo')).toMatch(/^<svg/);
|
||||
@@ -13,14 +12,3 @@ test('svgParseOuterInner', () => {
|
||||
expect(svgOuter.classList.contains('octicon-repo')).toBeTruthy();
|
||||
expect(svgInnerHtml).toContain('<path');
|
||||
});
|
||||
|
||||
test('SvgIcon', () => {
|
||||
const root = document.createElement('div');
|
||||
createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base'})}).mount(root);
|
||||
const node = root.firstChild as Element;
|
||||
expect(node.nodeName).toEqual('svg');
|
||||
expect(node.getAttribute('width')).toEqual('24');
|
||||
expect(node.getAttribute('height')).toEqual('24');
|
||||
expect(node.classList.contains('octicon-link')).toBeTruthy();
|
||||
expect(node.classList.contains('base')).toBeTruthy();
|
||||
});
|
||||
|
||||
+1
-35
@@ -1,6 +1,5 @@
|
||||
import {defineComponent, h, type PropType} from 'vue';
|
||||
import {parseDom, serializeXml} from './utils.ts';
|
||||
import {html, htmlRaw} from './utils/html.ts';
|
||||
import {htmlRaw} from './utils/html.ts';
|
||||
import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
|
||||
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
|
||||
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
|
||||
@@ -222,36 +221,3 @@ export function svgParseOuterInner(name: SvgName) {
|
||||
const svgOuter = svgDoc.firstChild as SVGElement;
|
||||
return {svgOuter, svgInnerHtml};
|
||||
}
|
||||
|
||||
export const SvgIcon = defineComponent({
|
||||
name: 'SvgIcon',
|
||||
props: {
|
||||
name: {type: String as PropType<SvgName>, required: true},
|
||||
size: {type: Number, default: 16},
|
||||
symbolId: {type: String},
|
||||
},
|
||||
render() {
|
||||
let {svgOuter, svgInnerHtml} = svgParseOuterInner(this.name);
|
||||
// https://vuejs.org/guide/extras/render-function.html#creating-vnodes
|
||||
// the `^` is used for attr, set SVG attributes like 'width', `aria-hidden`, `viewBox`, etc
|
||||
const attrs: Record<string, any> = {};
|
||||
for (const attr of svgOuter.attributes) {
|
||||
if (attr.name === 'class') continue;
|
||||
attrs[`^${attr.name}`] = attr.value;
|
||||
}
|
||||
attrs[`^width`] = this.size;
|
||||
attrs[`^height`] = this.size;
|
||||
|
||||
const classes = Array.from(svgOuter.classList);
|
||||
if (this.symbolId) {
|
||||
classes.push('tw-hidden', 'svg-symbol-container');
|
||||
svgInnerHtml = html`<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${htmlRaw(svgInnerHtml)}</symbol>`;
|
||||
}
|
||||
// create VNode
|
||||
return h('svg', {
|
||||
...attrs,
|
||||
class: classes,
|
||||
innerHTML: svgInnerHtml,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user