Compare commits

...
11 Commits
Author SHA1 Message Date
7733f1953f fix(storage): fix Azure Blob dump failing with file does not exist (#38814)
## Issue

Gitea fails to dump LFS (and other object-storage) files when Azure Blob
Storage is configured as the storage backend. The dump reports:

Failed to dump LFS objects: /file/path: copying contents: file does not
exist

This happens with any non-empty base path (the default for LFS storage),
which is why the user could only work around it by using `--skip-*`
flags.

The root cause is in `AzureBlobStorage.IterateObjects()`: Azure's list
API already returns each blob's name including the configured base path,
but the code was building the read client by running that name through
the base-path-prepending helper a second time. This doubled the base
path (e.g. `gitea-lfs/gitea-lfs/aa/bb/hash`), pointing at a blob that
doesn't exist. `Stat()` still succeeded because it doesn't touch the
network, so the failure only surfaced when the dumper actually tried to
read the object's contents.

## Solution

Add `getBlobClientByFullName()`, which builds a blob client from a name
that is already fully qualified, without re-applying
`buildAzureBlobPath()`. `IterateObjects()` now uses it for names
obtained from Azure's list API. `getBlobClient()` (used by `Open`,
`Stat`, `Delete`, `ServeDirectURL`, which take relative paths) is
unchanged in behavior.

Also add `TestAzureBlobStorageDumpArchive`, a regression test that
drives the real dump path (`IterateObjects` → `Stat` →
`dump.Dumper.AddFileByReader` → `mholt/archives` zip writer) against a
**non-empty** `BasePath`, and verifies the produced archive contains the
object with the correct content. The existing Azure tests use an empty
`BasePath` and never read object content via `IterateObjects`, which is
why they didn't catch this.

Fixes #35476

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 16:35:30 +00:00
silverwindandGitHub 4c382cea59 feat(actions): support $/ prefix in reusable workflow uses: (#38822)
Fixes: https://github.com/go-gitea/gitea/issues/38818

Accepts GitHub's `$/` self-repository prefix in a reusable workflow
`uses:`, alongside `./`.

Gitea's `./` already resolves against the caller's own source repo and
commit, which is what `$/` means, so the two are aliases here. Cycle
detection folds both prefixes onto one key.

Related PR for step-level support:
https://gitea.com/gitea/runner/pulls/1150
2026-08-07 15:39:36 +00:00
d4333eb043 fix(release): allow publishing drafts without target #35569 (#38800)
### Issue

Fully publishing a draft release fails when the release does not have a
target set.

A draft release can be created without a target because the Git tag is
not created while the release remains a draft. However, when the draft
is published, Gitea attempts to resolve the release target to create the
tag. If the target is empty, the publish operation fails.

### Solution

Use the repository's default branch as the target when a draft release
is published without an explicitly configured target.

Also add a regression test to verify that a draft release without a
target can be successfully published.

Fixes #35569

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 15:06:16 +00:00
Zettat123andGitHub 9fc5d20006 feat(actions): add force-cancel workflow run API (#38756)
Add `POST /repos/{owner}/{repo}/actions/runs/{run}/force-cancel`, the
counterpart of [GitHub's force-cancel endpoint](https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2026-03-10#force-cancel-a-workflow-run).

It cancels a run like `POST .../cancel`, but bypasses the graceful
cancelling handshake with the runner and stops running tasks
immediately.

Permissions and responses match the `/cancel` endpoint.

References:

- https://github.blog/changelog/2023-09-21-github-actions-force-cancel-workflows/
- https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2026-03-10#force-cancel-a-workflow-run
- https://github.com/orgs/community/discussions/123240
2026-08-07 14:38:14 +00:00
2657756cac enhance: fall back to DEFAULT_TEMPLATE.md when style-specific template is missing (#38803)
Closes #38801

Introduce `DEFAULT_TEMPLATE.md` as the default message for all merge
styles.

https://gitea.com/gitea/docs/pulls/491

By the way, fix incorrect os.Expand usage for merge message & repo
template

---------

Co-authored-by: waterWang <waterWang@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 14:11:03 +00:00
a34cc4cac4 fix(migration): migration deletion returned json redirection (#38796)
Fix #38596

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 13:21:20 +00:00
8873150206 fix(api): document X-Total-Count instead of non-existent X-Total header (#38717)
The `CommitList` and `ChangedFileList` swagger responses documented an
`X-Total` response header that their endpoints never send, they send
`X-Total-Count` via `SetTotalCountHeader`. This renames the documented
header and regenerates the spec.

`repoGetAllCommits` also uses `CommitList` and sends both headers, so
the new name is accurate for all three endpoints.

Also corrects `ChangedFileList` describing its headers as commit counts.

Documentation only, no handler behaviour changes.

Fixes https://github.com/go-gitea/gitea/issues/22048

---------

Signed-off-by: davidpavlovschi <davidpavlov2048@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-07 13:00:35 +00:00
silverwindandGitHub e81ab0a5ea refactor: prepare vue components for vapor mode (#38798)
Removes what would block a later switch to Vue's vapor mode:
`vue-chartjs` and the `SvgIcon` render function are virtual DOM
components, and `v-memo` has no vapor equivalent. This does not adopt
vapor mode, which will be stable in upcoming Vue 3.6.

`vue-chartjs` was a thin wrapper over chart.js, so a local
`ChartCanvas.vue` replaces it. Chart data and options move into computed
values to keep their object identity, which is what `v-memo` was
compensating for.

`chartjs-adapter-dayjs-4` is moved first-party, just ~40 lines that are
easy to maintain.
2026-08-07 12:38:31 +00:00
silverwindandGitHub d86cb1a498 fix(ui): anchor navbar admin badge to the avatar corner (#38821)
Fix regression from https://github.com/go-gitea/gitea/pull/38614:

Fixes: https://github.com/go-gitea/gitea/issues/38820
2026-08-07 11:58:15 +00:00
silverwindandGitHub ec869e3052 fix: misc fixes in pub/gpg/tests (#38809)
- The pub registry reported the oldest version as `latest`, because the
descriptor slice is sorted ascending but the first element was used.
- Verifying a GPG or SSH key flashed success and redirected after
already writing an error response, so a failure was reported as a
success with an empty key id.
- Test packages sharing redis could tear down each other's server.
`PrepareTestRedis` started its own on the well-known port, so a package
running in parallel borrowed it and lost it when the owner's cleanup
fired. It now listens on a socket of its own.
2026-08-07 11:49:23 +00:00
silverwindandGitHub dd8ef9c888 fix(ui): change underlines to default browser style (#38819)
Remove all underline style customization on links, letting the browser defaults apply
2026-08-07 13:18:41 +02:00
65 changed files with 1034 additions and 521 deletions
+1 -1
View File
@@ -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
View File
@@ -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)
+76 -2
View File
@@ -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)
})
}
+6 -6
View File
@@ -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 {
+6
View File
@@ -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"},
+21
View File
@@ -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
+4
View File
@@ -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)
+1 -10
View File
@@ -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) {
+1 -1
View File
@@ -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)
+34 -41
View File
@@ -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))
}
+21 -58
View File
@@ -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)))
+6 -4
View File
@@ -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
View File
@@ -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)
}
}
+22 -66
View File
@@ -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) {
+19
View File
@@ -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
+78 -34
View File
@@ -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) })
}
+29 -3
View File
@@ -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
})
}
+23 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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",
-29
View File
@@ -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)
+1 -1
View File
@@ -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,
})
}
+1
View File
@@ -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)
+51 -1
View File
@@ -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
+4 -4
View File
@@ -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"`
+1 -1
View File
@@ -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)
+2
View File
@@ -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")
+14 -2
View File
@@ -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...)
+1 -1
View File
@@ -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)
}
+13 -9
View File
@@ -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
View File
@@ -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
+48
View File
@@ -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)
})
}
+2 -1
View File
@@ -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
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
+2 -6
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+129
View File
@@ -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
+16 -20
View File
@@ -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)
})
}
-2
View File
@@ -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 */
-1
View File
@@ -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 {
+3 -2
View File
@@ -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 -1
View File
@@ -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';
+1 -1
View File
@@ -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<{
+71
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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';
+2 -1
View File
@@ -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 -1
View File
@@ -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';
+9 -15
View File
@@ -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>
+26 -23
View File
@@ -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>
+1 -1
View File
@@ -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({
+9 -15
View File
@@ -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>
+10
View File
@@ -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>`,
);
});
+37
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
},
});