Merge branch 'main' into add-matrix-dynamic-job-input-support

Co-Authored-By: Claude (Opus 4.8) <noreply@anthropic.com>
This commit is contained in:
silverwind
2026-05-29 07:07:30 +02:00
co-authored by Claude
58 changed files with 513 additions and 181 deletions
+10 -1
View File
@@ -1024,9 +1024,18 @@ LEVEL = Info
;; Default private when using push-to-create
;DEFAULT_PUSH_CREATE_PRIVATE = true
;;
;; Global limit of repositories per user, applied at creation time. -1 means no limit
;; Global limit of repositories per user or org, applied at creation time. -1 means no limit
;; To configure independent limits for users and orgs, use USER_MAX_CREATION_LIMIT and ORG_MAX_CREATION_LIMIT
;MAX_CREATION_LIMIT = -1
;;
;; Global limit of repositories per user, applied at creation time. -1 means no limit
;; Takes precedence over MAX_CREATION_LIMIT when set
;USER_MAX_CREATION_LIMIT = -1
;;
;; Global limit of repositories per organization, applied at creation time. -1 means no limit
;; Takes precedence over MAX_CREATION_LIMIT when set
;ORG_MAX_CREATION_LIMIT = -1
;;
;; Preferred Licenses to place at the top of the List
;; The name here must match the filename in options/license or custom/options/license
;PREFERRED_LICENSES = Apache License 2.0,MIT License
+1 -1
View File
@@ -113,7 +113,6 @@ require (
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
gopkg.in/ini.v1 v1.67.2
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
mvdan.cc/xurls/v2 v2.6.0
strk.kbt.io/projects/go/libravatar v0.0.0-20260301104140-add494e31dab
@@ -282,6 +281,7 @@ require (
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401020348-3a24fdc17823 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"gitea.dev/models/db"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
+11 -10
View File
@@ -244,12 +244,15 @@ func (u *User) IsOAuth2() bool {
return u.LoginType == auth.OAuth2
}
// MaxCreationLimit returns the number of repositories a user is allowed to create
// MaxCreationLimit returns the number of repositories a user or an organization is allowed to create
func (u *User) MaxCreationLimit() int {
if u.MaxRepoCreation <= -1 {
return setting.Repository.MaxCreationLimit
if u.MaxRepoCreation > -1 {
return u.MaxRepoCreation
}
return u.MaxRepoCreation
if u.IsOrganization() {
return setting.Repository.OrgMaxCreationLimit
}
return setting.Repository.UserMaxCreationLimit
}
// CanCreateRepoIn checks whether the doer(u) can create a repository in the owner
@@ -264,13 +267,11 @@ func (u *User) CanCreateRepoIn(owner *User) bool {
return true
}
const noLimit = -1
if owner.MaxRepoCreation == noLimit {
if setting.Repository.MaxCreationLimit == noLimit {
return true
}
return owner.NumRepos < setting.Repository.MaxCreationLimit
limit := owner.MaxCreationLimit()
if limit == noLimit {
return true
}
return owner.NumRepos < owner.MaxRepoCreation
return owner.NumRepos < limit
}
// CanCreateOrganization returns true if user can create organisation.
+38 -2
View File
@@ -674,12 +674,18 @@ func TestGetInactiveUsers(t *testing.T) {
func TestCanCreateRepo(t *testing.T) {
defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)()
defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit)()
defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit)()
const noLimit = -1
doerActions := user_model.NewActionsUser()
doerNormal := &user_model.User{ID: 2}
doerAdmin := &user_model.User{ID: 1, IsAdmin: true}
orgOwner := func(numRepos, maxRepoCreation int) *user_model.User {
return &user_model.User{ID: 3, Type: user_model.UserTypeOrganization, NumRepos: numRepos, MaxRepoCreation: maxRepoCreation}
}
t.Run("NoGlobalLimit", func(t *testing.T) {
setting.Repository.MaxCreationLimit = noLimit
setting.Repository.UserMaxCreationLimit = noLimit
setting.Repository.OrgMaxCreationLimit = noLimit
assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0}))
assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100}))
@@ -693,7 +699,8 @@ func TestCanCreateRepo(t *testing.T) {
})
t.Run("GlobalLimit50", func(t *testing.T) {
setting.Repository.MaxCreationLimit = 50
setting.Repository.UserMaxCreationLimit = 50
setting.Repository.OrgMaxCreationLimit = 50
assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit}))
assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit
@@ -707,4 +714,33 @@ func TestCanCreateRepo(t *testing.T) {
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100}))
})
t.Run("UserBlockedOrgsUnlimited", func(t *testing.T) {
// User and org limits are independent: a deployment can block personal repos while leaving orgs unrestricted.
setting.Repository.UserMaxCreationLimit = 0
setting.Repository.OrgMaxCreationLimit = noLimit
// regular user is blocked
assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 0, MaxRepoCreation: noLimit}))
// per-user override grants individual exceptions even when the global user limit is 0
assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 3, MaxRepoCreation: 5}))
assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 5, MaxRepoCreation: 5}))
// organization can create unlimited repos
assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(10, noLimit)))
assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(999, noLimit)))
// per-org override still wins over the global org limit
assert.False(t, doerNormal.CanCreateRepoIn(orgOwner(5, 5)))
})
t.Run("OrgGlobalLimitWithPerOrgOverride", func(t *testing.T) {
setting.Repository.UserMaxCreationLimit = noLimit
setting.Repository.OrgMaxCreationLimit = 10
assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(5, noLimit)))
assert.False(t, doerNormal.CanCreateRepoIn(orgOwner(10, noLimit)))
// per-org override bypasses the global org limit
assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(10, 100)))
})
}
+12
View File
@@ -298,6 +298,9 @@ func toGitContext(input map[string]any) *model.GithubContext {
return gitContext
}
// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection.
const workflowCallEvent = "workflow_call"
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
switch rawOn.Kind {
case yaml.ScalarNode:
@@ -306,6 +309,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
if err != nil {
return nil, err
}
if val == workflowCallEvent {
return []*Event{}, nil
}
return []*Event{
{Name: val},
}, nil
@@ -319,6 +325,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
for _, v := range val {
switch t := v.(type) {
case string:
if t == workflowCallEvent {
continue
}
res = append(res, &Event{Name: t})
default:
return nil, fmt.Errorf("invalid type %T", t)
@@ -332,6 +341,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
}
res := make([]*Event, 0, len(events))
for i, k := range events {
if k == workflowCallEvent {
continue
}
v := triggers[i]
switch v.Kind {
case yaml.ScalarNode:
+47
View File
@@ -254,6 +254,53 @@ func TestParseRawOn(t *testing.T) {
},
},
},
{
// `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection.
input: `on:
workflow_call:
inputs:
env:
type: string
required: true
outputs:
sha:
value: ${{ jobs.build.outputs.commit }}
secrets:
DEPLOY_KEY:
required: true
`,
result: []*Event{},
},
{
// Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces.
input: `on:
workflow_call:
inputs:
env:
type: string
push:
branches: [main]
`,
result: []*Event{
{
Name: "push",
acts: map[string][]string{"branches": {"main"}},
},
},
},
{
// Scalar form: a purely reusable workflow has no event triggers.
input: "on: workflow_call",
result: []*Event{},
},
{
// Sequence form: `workflow_call` is excluded while sibling events are kept.
input: "on:\n - push\n - workflow_call\n - pull_request",
result: []*Event{
{Name: "push"},
{Name: "pull_request"},
},
},
}
for _, kase := range kases {
t.Run(kase.input, func(t *testing.T) {
+1 -1
View File
@@ -14,7 +14,7 @@ import (
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
// CouldBe indicates a file with the filename could be a template,
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"gitea.dev/modules/options"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
type labelFile struct {
+1 -1
View File
@@ -90,6 +90,6 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou
</html>`,
p.name, ctx.RenderOptions.RelativePath,
contentEncoding, contentString,
public.AssetURI("js/external-render-frontend.js"))
public.AssetURI("web_src/js/external-render-frontend.ts"))
return err
}
+21 -3
View File
@@ -175,16 +175,25 @@ var emojiProcessors = []processor{
emojiProcessor,
}
// isBareURLSubject reports whether the (HTML-escaped) commit subject content
// is entirely a single URL, ignoring leading/trailing whitespace.
func isBareURLSubject(content string) bool {
s := strings.TrimSpace(html.UnescapeString(content))
if s == "" {
return false
}
m := common.GlobalVars().LinkRegex.FindStringIndex(s)
return m != nil && m[0] == 0 && m[1] == len(s)
}
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
// which changes every text node into a link to the passed default link.
// emailAddressProcessor, and wraps the whole subject in defaultLink.
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
procs := []processor{
fullIssuePatternProcessor,
comparePatternProcessor,
fullHashPatternProcessor,
linkProcessor,
mentionProcessor,
issueIndexPatternProcessor,
commitCrossReferencePatternProcessor,
@@ -192,6 +201,15 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
emojiShortCodeProcessor,
emojiProcessor,
}
// When the whole subject is a bare URL, linkProcessor would turn it into
// a competing anchor and hijack the surrounding defaultLink wrapper, leaving
// the subject visually unclickable. Match GitHub: render such subjects as
// plain text inside defaultLink. Partial URLs inside larger text still become
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
// naturally breaks on that span, same as on GitHub).
if !isBareURLSubject(content) {
procs = append(procs, linkProcessor)
}
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
node.Type = html.ElementNode
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func nodeToTable(meta *yaml.Node) ast.Node {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"unicode"
"unicode/utf8"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func isYAMLSeparator(line []byte) bool {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"gitea.dev/modules/markup"
"github.com/yuin/goldmark/ast"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
// RenderConfig represents rendering configuration for this file
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func TestRenderConfig_UnmarshalYAML(t *testing.T) {
+1 -1
View File
@@ -243,7 +243,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
return RenderIFrame(ctx, &extOpts, output)
}
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
extraScriptSrc := public.AssetURI("js/external-render-helper.js")
extraScriptSrc := public.AssetURI("web_src/js/external-render-helper.ts")
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"gitea.dev/modules/log"
"github.com/santhosh-tekuri/jsonschema/v6"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
// schemaLoader implements jsonschema.URLLoader
+1 -1
View File
@@ -6,7 +6,7 @@ package optional
import (
"gitea.dev/modules/json"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func (o *Option[T]) UnmarshalJSON(data []byte) error {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"gitea.dev/modules/optional"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
type testSerializationStruct struct {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"gitea.dev/modules/validation"
"github.com/hashicorp/go-version"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
var (
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"gitea.dev/modules/validation"
"github.com/hashicorp/go-version"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
var (
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"gitea.dev/modules/util"
"gitea.dev/modules/validation"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
var (
+69 -51
View File
@@ -4,8 +4,11 @@
package public
import (
"html"
"html/template"
"io"
"path"
"strings"
"sync"
"sync/atomic"
"time"
@@ -15,16 +18,17 @@ import (
"gitea.dev/modules/setting"
)
// https://vite.dev/guide/backend-integration
type manifestEntry struct {
File string `json:"file"`
Name string `json:"name"`
IsEntry bool `json:"isEntry"`
CSS []string `json:"css"`
Imports []string `json:"imports"`
}
type manifestDataStruct struct {
paths map[string]string // unhashed path -> hashed path
names map[string]string // hashed path -> entry name
entries map[string]*manifestEntry // source path -> entry
names map[string]string // content-hashed output file -> entry name
modTime int64
checkTime time.Time
}
@@ -36,35 +40,16 @@ var (
const manifestPath = "assets/.vite/manifest.json"
func parseManifest(data []byte) (map[string]string, map[string]string) {
var manifest map[string]manifestEntry
if err := json.Unmarshal(data, &manifest); err != nil {
func parseManifest(data []byte) (entries map[string]*manifestEntry, names map[string]string) {
if err := json.Unmarshal(data, &entries); err != nil {
log.Error("Failed to parse frontend manifest: %v", err)
return nil, nil
}
paths := make(map[string]string)
names := make(map[string]string)
for _, entry := range manifest {
if !entry.IsEntry || entry.Name == "" {
continue
}
// Build unhashed key from file path: "js/index.js", "css/theme-gitea-dark.css"
dir := path.Dir(entry.File)
ext := path.Ext(entry.File)
key := dir + "/" + entry.Name + ext
paths[key] = entry.File
names = make(map[string]string, len(entries))
for _, entry := range entries {
names[entry.File] = entry.Name
// Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css"
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: the logic is wrong, Vite manifest doesn't work this way
// It just happens to be correct for the current modules dependencies
for _, css := range entry.CSS {
cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css)
paths[cssKey] = css
names[css] = entry.Name
}
}
return paths, names
return entries, names
}
func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
@@ -102,9 +87,9 @@ func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
}
func storeManifestFromBytes(manifestContent []byte, modTime int64, checkTime time.Time) *manifestDataStruct {
paths, names := parseManifest(manifestContent)
entries, names := parseManifest(manifestContent)
data := &manifestDataStruct{
paths: paths,
entries: entries,
names: names,
modTime: modTime,
checkTime: checkTime,
@@ -127,33 +112,66 @@ func getManifestData() *manifestDataStruct {
return data
}
// AssetURI returns the URI for a frontend asset.
// It may return a relative path or a full URL depending on the StaticURLPrefix setting.
// In Vite dev mode, known entry points are mapped to their source paths
// so the reverse proxy serves them from the Vite dev server.
// In production, it resolves the content-hashed path from the manifest.
func AssetURI(originPath string) string {
// devAssetURL returns a source file's Vite dev server URL, panicking in dev/testing if it's absent.
func devAssetURL(src string) string {
if url := viteDevSourceURL(src); url != "" {
return url
}
setting.PanicInDevOrTesting("Failed to locate source file for asset: %s", src)
return ""
}
// AssetURI resolves a frontend asset by its source path (the Vite manifest key, e.g.
// "web_src/js/index.ts"). Dev mode serves the source file; production resolves the hashed output.
func AssetURI(srcPath string) string {
if IsViteDevMode() {
if src := viteDevSourceURL(originPath); src != "" {
return src
}
// it should be caused by incorrect vite config
setting.PanicInDevOrTesting("Failed to locate local path for managed asset URI: %s", originPath)
return devAssetURL(srcPath)
}
if entry := getManifestData().entries[srcPath]; entry != nil {
return setting.StaticURLPrefix + "/assets/" + entry.File
}
// The only expected manifest miss is a user's custom theme CSS, served as a static asset
// under "/assets/css/". Anything else is a misconfigured or missing entry.
if path.Ext(srcPath) == ".css" {
return setting.StaticURLPrefix + "/assets/css/" + path.Base(srcPath)
}
log.Error("asset not found in frontend manifest: %s", srcPath)
return setting.StaticURLPrefix + "/assets/" + path.Base(srcPath)
}
// Try to resolve an unhashed asset path (origin path) to its content-hashed path from the frontend manifest.
// Example: "js/index.js" -> "js/index.C6Z2MRVQ.js"
data := getManifestData()
assetPath := data.paths[originPath]
if assetPath == "" {
// it should be caused by either: "incorrect vite config" or "user's custom theme"
assetPath = originPath
if !setting.IsProd {
log.Warn("Failed to find managed asset URI for origin path: %s", originPath)
// AssetCSSLinks renders the <link> tags for a JS entry's stylesheets: the entry's CSS plus the CSS
// of every statically-imported chunk. Dev links devStylesheetSrc and lets the JS module inject the rest.
func AssetCSSLinks(jsEntrySrc, devStylesheetSrc string) template.HTML {
var b strings.Builder
for _, href := range entryStyleURLs(jsEntrySrc, devStylesheetSrc) {
b.WriteString(`<link rel="stylesheet" href="` + html.EscapeString(href) + `">`)
}
return template.HTML(b.String())
}
func entryStyleURLs(jsEntrySrc, devStylesheetSrc string) []string {
if IsViteDevMode() {
return []string{devAssetURL(devStylesheetSrc)}
}
entries := getManifestData().entries
var urls []string
seen := make(map[string]bool)
var walk func(key string)
walk = func(key string) {
entry := entries[key]
if entry == nil || seen[key] {
return
}
seen[key] = true
for _, css := range entry.CSS {
urls = append(urls, setting.StaticURLPrefix+"/assets/"+css)
}
for _, imp := range entry.Imports {
walk(imp)
}
}
return setting.StaticURLPrefix + "/assets/" + assetPath
walk(jsEntrySrc)
return urls
}
// AssetNameFromHashedPath returns the asset entry name for a given hashed asset path.
+30 -34
View File
@@ -4,6 +4,7 @@
package public
import (
"html/template"
"testing"
"time"
@@ -22,59 +23,54 @@ func TestViteManifest(t *testing.T) {
"name": "index",
"src": "web_src/js/index.ts",
"isEntry": true,
"css": ["css/index.B3zrQPqD.css"]
"imports": ["_shared.AaAaAaAa.js"],
"css": ["css/index.B3zrQPqD.css", "css/index-extra.CcCcCcCc.css"]
},
"_shared.AaAaAaAa.js": {
"file": "js/shared.AaAaAaAa.js",
"name": "shared",
"css": ["css/shared.BbBbBbBb.css"]
},
"web_src/css/themes/theme-gitea-dark.css": {
"file": "css/theme-gitea-dark.CyAaQnn5.css",
"name": "theme-gitea-dark",
"src": "web_src/css/themes/theme-gitea-dark.css",
"isEntry": true
},
"web_src/js/features/eventsource.sharedworker.ts": {
"file": "js/eventsource.sharedworker.Dug1twio.js",
"name": "eventsource.sharedworker",
"src": "web_src/js/features/eventsource.sharedworker.ts",
"isEntry": true
},
"_chunk.js": {
"file": "js/chunk.abc123.js",
"name": "chunk"
}
}`
t.Run("EmptyManifest", func(t *testing.T) {
storeManifestFromBytes([]byte(``), 0, time.Now())
assert.Equal(t, "/assets/js/index.js", AssetURI("js/index.js"))
assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("css/theme-gitea-dark.css"))
assert.Equal(t, "", AssetNameFromHashedPath("css/no-such-file.css"))
// not in manifest -> custom theme fallback
assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("web_src/css/themes/theme-gitea-dark.css"))
assert.Empty(t, entryStyleURLs("web_src/js/index.ts", "web_src/css/index.css"))
assert.Empty(t, AssetNameFromHashedPath("css/no-such-file.css"))
})
t.Run("ParseManifest", func(t *testing.T) {
storeManifestFromBytes([]byte(testManifest), 0, time.Now())
paths, names := manifestData.Load().paths, manifestData.Load().names
// JS entries
assert.Equal(t, "js/index.C6Z2MRVQ.js", paths["js/index.js"])
assert.Equal(t, "js/eventsource.sharedworker.Dug1twio.js", paths["js/eventsource.sharedworker.js"])
// assets are addressed by their source path (the manifest key)
assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("web_src/js/index.ts"))
assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("web_src/css/themes/theme-gitea-dark.css"))
// Associated CSS from JS entries
assert.Equal(t, "css/index.B3zrQPqD.css", paths["css/index.css"])
// custom theme not in the manifest falls back to the static asset location
assert.Equal(t, "/assets/css/theme-custom.css", AssetURI("web_src/css/themes/theme-custom.css"))
// CSS-only entries
assert.Equal(t, "css/theme-gitea-dark.CyAaQnn5.css", paths["css/theme-gitea-dark.css"])
// a JS entry's stylesheets: all of the entry's own CSS plus the CSS of statically-imported chunks
assert.Equal(t, []string{
"/assets/css/index.B3zrQPqD.css",
"/assets/css/index-extra.CcCcCcCc.css",
"/assets/css/shared.BbBbBbBb.css",
}, entryStyleURLs("web_src/js/index.ts", "web_src/css/index.css"))
assert.Equal(t, template.HTML(
`<link rel="stylesheet" href="/assets/css/index.B3zrQPqD.css">`+
`<link rel="stylesheet" href="/assets/css/index-extra.CcCcCcCc.css">`+
`<link rel="stylesheet" href="/assets/css/shared.BbBbBbBb.css">`,
), AssetCSSLinks("web_src/js/index.ts", "web_src/css/index.css"))
// Non-entry chunks should not be included
assert.Empty(t, paths["js/chunk.js"])
// Names: hashed path -> entry name
assert.Equal(t, "index", names["js/index.C6Z2MRVQ.js"])
assert.Equal(t, "index", names["css/index.B3zrQPqD.css"])
assert.Equal(t, "theme-gitea-dark", names["css/theme-gitea-dark.CyAaQnn5.css"])
assert.Equal(t, "eventsource.sharedworker", names["js/eventsource.sharedworker.Dug1twio.js"])
// Test Asset related functions
assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("js/index.js"))
assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("css/theme-gitea-dark.css"))
// hashed output file -> entry name
assert.Equal(t, "theme-gitea-dark", AssetNameFromHashedPath("css/theme-gitea-dark.CyAaQnn5.css"))
assert.Empty(t, AssetNameFromHashedPath("css/no-such-file.css"))
})
}
+6 -24
View File
@@ -140,31 +140,13 @@ func IsViteDevMode() bool {
return isDev
}
func detectWebSrcPath(webSrcPath string) string {
localPath := util.FilePathJoinAbs(setting.StaticRootPath, "web_src", webSrcPath)
if _, err := os.Stat(localPath); err == nil {
return setting.AppSubURL + "/web_src/" + webSrcPath
// viteDevSourceURL returns the dev server URL for a source file, or "" if it doesn't exist.
func viteDevSourceURL(srcPath string) string {
localPath := util.FilePathJoinAbs(setting.StaticRootPath, srcPath)
if _, err := os.Stat(localPath); err != nil {
return ""
}
return ""
}
func viteDevSourceURL(name string) string {
if strings.HasPrefix(name, "css/theme-") {
// Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/
themeFilePath := "css/themes/" + strings.TrimPrefix(name, "css/")
if srcPath := detectWebSrcPath(themeFilePath); srcPath != "" {
return srcPath
}
}
// try to map ".js" files to ".ts" files
pathPrefix, ok := strings.CutSuffix(name, ".js")
if ok {
if srcPath := detectWebSrcPath(pathPrefix + ".ts"); srcPath != "" {
return srcPath
}
}
// for all others that the names match
return detectWebSrcPath(name)
return setting.AppSubURL + "/" + srcPath
}
// isViteDevRequest returns true if the request should be proxied to the Vite dev server.
+8
View File
@@ -37,6 +37,8 @@ var (
DefaultPrivate string
DefaultPushCreatePrivate bool
MaxCreationLimit int
UserMaxCreationLimit int
OrgMaxCreationLimit int
PreferredLicenses []string
DisableHTTPGit bool
AccessControlAllowOrigin string
@@ -165,6 +167,8 @@ var (
DefaultPrivate: RepoCreatingLastUserVisibility,
DefaultPushCreatePrivate: true,
MaxCreationLimit: -1,
UserMaxCreationLimit: -1,
OrgMaxCreationLimit: -1,
PreferredLicenses: []string{"Apache License 2.0", "MIT License"},
DisableHTTPGit: false,
AccessControlAllowOrigin: "",
@@ -297,7 +301,11 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool()
Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool()
Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https")
// MAX_CREATION_LIMIT is a shortcut that sets the default for the two per-type limits below.
// USER_/ORG_MAX_CREATION_LIMIT take precedence when explicitly set.
Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1)
Repository.UserMaxCreationLimit = sec.Key("USER_MAX_CREATION_LIMIT").MustInt(Repository.MaxCreationLimit)
Repository.OrgMaxCreationLimit = sec.Key("ORG_MAX_CREATION_LIMIT").MustInt(Repository.MaxCreationLimit)
Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString(Repository.DefaultBranch)
RepoRootPath = sec.Key("ROOT").MustString(filepath.Join(AppDataPath, "gitea-repositories"))
if !filepath.IsAbs(RepoRootPath) {
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestLoadRepositoryCreationLimits(t *testing.T) {
defer test.MockVariableValue(&Repository.MaxCreationLimit)()
defer test.MockVariableValue(&Repository.UserMaxCreationLimit)()
defer test.MockVariableValue(&Repository.OrgMaxCreationLimit)()
t.Run("ShortcutPropagatesToBoth", func(t *testing.T) {
cfg, err := NewConfigProviderFromData(`
[repository]
MAX_CREATION_LIMIT = 5
`)
assert.NoError(t, err)
loadRepositoryFrom(cfg)
assert.Equal(t, 5, Repository.MaxCreationLimit)
assert.Equal(t, 5, Repository.UserMaxCreationLimit)
assert.Equal(t, 5, Repository.OrgMaxCreationLimit)
})
t.Run("PerTypeKeysOverrideShortcut", func(t *testing.T) {
cfg, err := NewConfigProviderFromData(`
[repository]
MAX_CREATION_LIMIT = 5
USER_MAX_CREATION_LIMIT = 0
ORG_MAX_CREATION_LIMIT = -1
`)
assert.NoError(t, err)
loadRepositoryFrom(cfg)
assert.Equal(t, 0, Repository.UserMaxCreationLimit)
assert.Equal(t, -1, Repository.OrgMaxCreationLimit)
})
t.Run("PartialOverrideOtherInheritsShortcut", func(t *testing.T) {
cfg, err := NewConfigProviderFromData(`
[repository]
MAX_CREATION_LIMIT = 7
ORG_MAX_CREATION_LIMIT = -1
`)
assert.NoError(t, err)
loadRepositoryFrom(cfg)
assert.Equal(t, 7, Repository.UserMaxCreationLimit)
assert.Equal(t, -1, Repository.OrgMaxCreationLimit)
})
t.Run("NoKeyDefaultsToNoLimit", func(t *testing.T) {
cfg, err := NewConfigProviderFromData(`
[repository]
`)
assert.NoError(t, err)
loadRepositoryFrom(cfg)
assert.Equal(t, -1, Repository.MaxCreationLimit)
assert.Equal(t, -1, Repository.UserMaxCreationLimit)
assert.Equal(t, -1, Repository.OrgMaxCreationLimit)
})
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"strings"
"time"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
// StateType issue state type
@@ -231,7 +231,7 @@ func (l *IssueTemplateStringSlice) UnmarshalYAML(value *yaml.Node) error {
*l = labels
return nil
}
return fmt.Errorf("line %d: cannot unmarshal %s into IssueTemplateStringSlice", value.Line, value.ShortTag())
return fmt.Errorf("cannot unmarshal %s into IssueTemplateStringSlice", value.ShortTag())
}
type IssueConfigContactLink struct {
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func TestIssueTemplate_Type(t *testing.T) {
@@ -88,7 +88,7 @@ labels:
b: bb
`,
tmpl: &IssueTemplate{},
wantErr: "line 3: cannot unmarshal !!map into IssueTemplateStringSlice",
wantErr: "yaml: unmarshal errors:\n line 3: cannot unmarshal !!map into IssueTemplateStringSlice",
},
}
for _, tt := range tests {
+2 -1
View File
@@ -67,7 +67,8 @@ func newFuncMapWebPage() template.FuncMap {
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
},
"AssetURI": public.AssetURI,
"AssetURI": public.AssetURI,
"AssetCSSLinks": public.AssetCSSLinks,
// -----------------------------------------------------------------
// setting
+12
View File
@@ -140,6 +140,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
})
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
// a bare URL in the subject must not hijack the default link
expected := `<a href="https://example.com/link" class="muted">https://example.com/file.bin</a>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
})
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
// a URL embedded in larger subject text still becomes its own link
expected := `<a href="https://example.com/link" class="muted">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted"> here</a>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
})
t.Run("RenderIssueTitle", func(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
expected := ` space @mention-user<SPACE><SPACE>
+1 -1
View File
@@ -23,7 +23,7 @@ import (
"gitea.dev/services/context"
packages_service "gitea.dev/services/packages"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func apiError(ctx *context.Context, status int, obj any) {
+2
View File
@@ -1091,6 +1091,8 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
ctx.APIError(http.StatusNotFound, err)
} else if errors.Is(err, util.ErrPermissionDenied) {
ctx.APIError(http.StatusForbidden, err)
} else if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusUnprocessableEntity, err)
} else {
ctx.APIErrorInternal(err)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"gitea.dev/services/context"
"gitea.dev/services/mailer"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func MailPreviewRender(ctx *context.Context) {
+11
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
actions_model "gitea.dev/models/actions"
@@ -206,6 +207,16 @@ func View(ctx *context_module.Context) {
jobID := ctx.PathParamInt64("job")
ctx.Data["JobID"] = jobID // it can be 0 when no job (e.g.: run summary view)
// Browser tab title, ordered most-specific → least-specific so narrow tabs keep the useful part.
// Separator matches the " - " used by head.tmpl when joining to PageTitleCommon.
titleParts := []string{run.Title, run.WorkflowID}
if jobID > 0 {
if job, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID); err == nil && job.Name != "" {
titleParts = append([]string{job.Name}, titleParts...)
}
}
ctx.Data["Title"] = strings.Join(titleParts, " - ")
attemptNum := ctx.PathParamInt64("attempt")
// ActionsViewURL is the endpoint for viewing a run (job summary), a job, or a job attempt.
+10 -4
View File
@@ -140,11 +140,17 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
workflow := &model.Workflow{
RawOn: singleWorkflow.RawOn,
}
workflowDispatch := workflow.WorkflowDispatchConfig()
if workflowDispatch == nil {
return 0, util.ErrorWrapTranslatable(
util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID),
"actions.workflow.has_no_workflow_dispatch", workflowID,
)
}
inputsWithDefaults := make(map[string]any)
if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
return 0, err
}
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
return 0, err
}
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
+1 -1
View File
@@ -16,7 +16,7 @@ import (
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
// templateDirCandidates issue templates directory
+1 -1
View File
@@ -26,7 +26,7 @@ import (
"gitea.dev/modules/structs"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
var _ base.Uploader = &RepositoryDumper{}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
base "gitea.dev/modules/migration"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
// RepositoryRestorer implements an Downloader from the local directory
+2 -5
View File
@@ -20,7 +20,6 @@ import (
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/globallock"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
notify_service "gitea.dev/services/notify"
)
@@ -62,8 +61,7 @@ func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, d
}
if !doer.CanCreateRepoIn(repoTransfer.Recipient) {
limit := util.Iif(repoTransfer.Recipient.MaxRepoCreation >= 0, repoTransfer.Recipient.MaxRepoCreation, setting.Repository.MaxCreationLimit)
return LimitReachedError{Limit: limit}
return LimitReachedError{Limit: repoTransfer.Recipient.MaxCreationLimit()}
}
if !repoTransfer.CanUserAcceptOrRejectTransfer(ctx, doer) {
@@ -434,8 +432,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use
}
if !doer.CanForkRepoIn(newOwner) {
limit := util.Iif(newOwner.MaxRepoCreation >= 0, newOwner.MaxRepoCreation, setting.Repository.MaxCreationLimit)
return LimitReachedError{Limit: limit}
return LimitReachedError{Limit: newOwner.MaxCreationLimit()}
}
var isDirectTransfer bool
+2
View File
@@ -133,6 +133,8 @@ func TestRepositoryTransferRejection(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// Set limit to 0 repositories so no repositories can be transferred
defer test.MockVariableValue(&setting.Repository.MaxCreationLimit, 0)()
defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit, 0)()
defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit, 0)()
// Admin case
doerAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+1 -1
View File
@@ -45,7 +45,7 @@ type ThemeMetaInfo struct {
}
func (info *ThemeMetaInfo) PublicAssetURI() string {
return public.AssetURI("css/theme-" + url.PathEscape(info.InternalName) + ".css")
return public.AssetURI("web_src/css/themes/theme-" + url.PathEscape(info.InternalName) + ".css")
}
func (info *ThemeMetaInfo) GetDescription() string {
+1 -1
View File
@@ -9,7 +9,7 @@
</div>
{{template "custom/body_outer_post" .}}
{{template "base/footer_content" .}}
{{ctx.ScriptImport "js/index.js" "module"}}
{{ctx.ScriptImport "web_src/js/index.ts" "module"}}
{{template "custom/footer" .}}
<script nonce="{{ctx.CspScriptNonce}}" type="module">
if (!window.config?.frontendInited && window.config?.runModeIsProd) alert("Frontend is not initialized, check console errors or asset files.");
+2 -2
View File
@@ -16,7 +16,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
enableTimeTracking: {{EnableTimetracking}},
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
sharedWorkerUri: '{{AssetURI "js/eventsource.sharedworker.js"}}',
sharedWorkerUri: '{{AssetURI "web_src/js/eventsource.sharedworker.ts"}}',
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
i18n: {
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
@@ -31,4 +31,4 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
window.config.pageData = window.config.pageData || {};
</script>
{{ctx.ScriptImport "js/iife.js"}}
{{ctx.ScriptImport "web_src/js/iife.ts"}}
+1 -1
View File
@@ -1,2 +1,2 @@
<link rel="stylesheet" href="{{AssetURI "css/index.css"}}">
{{AssetCSSLinks "web_src/js/index.ts" "web_src/css/index.css"}}
<link rel="stylesheet" href="{{ctx.CurrentWebTheme.PublicAssetURI}}">
+1 -1
View File
@@ -1,4 +1,4 @@
{{template "base/head" ctx.RootData}}
<link rel="stylesheet" href="{{AssetURI "css/devtest.css"}}">
<link rel="stylesheet" href="{{AssetURI "web_src/css/devtest.css"}}">
<div class="tw-hidden" data-global-init="initDevtestPage"></div>
<div class="ui container tw-mt-4">{{template "base/alert" ctx.RootData}}</div>
+3 -3
View File
@@ -4,14 +4,14 @@
{{ctx.HeadMetaContentSecurityPolicy}}
<title>Gitea API</title>
<link rel="stylesheet" href="{{ctx.CurrentWebTheme.PublicAssetURI}}">
{{/* HINT: SWAGGER-CSS-IMPORT: import swagger styles ahead to avoid UI flicker (e.g.: the swagger-back-link element) */}}
<link rel="stylesheet" href="{{AssetURI "css/swagger.css"}}">
{{/* HINT: SWAGGER-CSS-IMPORT: load swagger styles ahead to avoid flicker (e.g. the swagger-back-link) */}}
{{AssetCSSLinks "web_src/js/swagger.ts" "web_src/css/swagger-standalone.css"}}
</head>
<body>
{{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}}
<a class="swagger-back-link" href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a>
<div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.v1.json"></div>
<footer class="page-footer"></footer>
{{ctx.ScriptImport "js/swagger.js" "module"}}
{{ctx.ScriptImport "web_src/js/swagger.ts" "module"}}
</body>
</html>
+70
View File
@@ -931,6 +931,76 @@ jobs:
})
}
func TestWorkflowDispatchPublicApiRequiresWorkflowDispatchTrigger(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
repo, err := repo_service.CreateRepository(t.Context(), user2, user2, repo_service.CreateRepoOptions{
Name: "workflow-dispatch-requires-trigger",
Description: "test workflow dispatch requires workflow_dispatch",
AutoInit: true,
Gitignores: "Go",
License: "MIT",
Readme: "Default",
DefaultBranch: "main",
IsPrivate: false,
})
require.NoError(t, err)
require.NotNil(t, repo)
addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(t.Context(), repo, user2, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/push-only.yml",
ContentReader: strings.NewReader(`
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo helloworld
`),
},
},
Message: "add workflow",
OldBranch: "main",
NewBranch: "main",
Author: &files_service.IdentityOptions{
GitUserName: user2.Name,
GitUserEmail: user2.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: user2.Name,
GitUserEmail: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
require.NotNil(t, addWorkflowToBaseResp)
values := url.Values{}
values.Set("ref", "main")
req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/push-only.yml/dispatches", repo.FullName()), values).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusUnprocessableEntity)
apiError := DecodeJSON(t, resp, &api.APIError{})
assert.Contains(t, apiError.Message, "has no workflow_dispatch event trigger")
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{
RepoID: repo.ID,
Event: "workflow_dispatch",
WorkflowID: "push-only.yml",
})
})
}
func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func createIssueConfig(t *testing.T, user *user_model.User, repo *repo_model.Repository, issueConfig map[string]any) {
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func TestPackageHelm(t *testing.T) {
+2 -2
View File
@@ -304,8 +304,8 @@ func TestPackageNpm(t *testing.T) {
resp := MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
rendered, _ := doc.Find(".markup.markdown").Html()
assert.Equal(t, `<p dir="auto"><a href="/user2/repo1/src/branch/master/package-subdir/docs/usage.md" rel="nofollow">docs</a>
<a href="/user2/repo1/src/branch/master/package-subdir/logo.png" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/branch/master/package-subdir/logo.png" alt="logo" loading="lazy"/></a></p>
assertHTMLEq(t, `<p dir="auto"><a href="/user2/repo1/src/branch/master/package-subdir/docs/usage.md" rel="nofollow">docs</a>
<a href="/user2/repo1/src/branch/master/package-subdir/logo.png" rel="nofollow noopener" target="_blank"><img src="/user2/repo1/media/branch/master/package-subdir/logo.png" alt="logo" loading="lazy"/></a></p>
`, rendered)
})
+1 -1
View File
@@ -24,7 +24,7 @@ import (
"gitea.dev/services/migrations"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func TestDumpRestore(t *testing.T) {
+39
View File
@@ -5,10 +5,13 @@ package integration
import (
"io"
"slices"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"golang.org/x/net/html"
)
// HTMLDoc struct
@@ -47,3 +50,39 @@ func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string
assert.Equal(t, v, sel.Length())
}
}
func assertHTMLEq(t testing.TB, expected, actual string) {
t.Helper()
if expected == actual {
return
}
exp, err := html.Parse(strings.NewReader(expected))
if !assert.NoError(t, err) {
return
}
act, err := html.Parse(strings.NewReader(actual))
if !assert.NoError(t, err) {
return
}
var normalize func(n *html.Node)
normalize = func(n *html.Node) {
slices.SortFunc(n.Attr, func(a, b html.Attribute) int {
if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 {
return cmp
}
if cmp := strings.Compare(a.Key, b.Key); cmp != 0 {
return cmp
}
return strings.Compare(a.Val, b.Val)
})
for c := n.FirstChild; c != nil; c = c.NextSibling {
normalize(c)
}
}
normalize(exp)
normalize(act)
var expNormalized, actNormalized strings.Builder
assert.NoError(t, html.Render(&expNormalized, exp))
assert.NoError(t, html.Render(&actNormalized, act))
assert.Equal(t, expNormalized.String(), actNormalized.String())
}
+4 -4
View File
@@ -109,8 +109,8 @@ func TestExternalMarkupRenderer(t *testing.T) {
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
// FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "<script>" tag, but it indeed is the sanitizer's job
assert.Equal(t,
`<script nonce crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`" id="gitea-external-render-helper" data-render-query-string=""></script>`+
`<link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`">`+
`<script nonce crossorigin src="`+public.AssetURI("web_src/js/external-render-helper.ts")+`" id="gitea-external-render-helper" data-render-query-string=""></script>`+
`<link rel="stylesheet" href="`+public.AssetURI("web_src/css/themes/theme-gitea-auto.css")+`">`+
`<div><any attr="val">&lt;script&gt;&lt;/script&gt;</any></div>`,
respSub.Body.String(),
)
@@ -137,8 +137,8 @@ func TestExternalMarkupRenderer(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer?a=1%2f2")
respSub := MakeRequest(t, req, http.StatusOK)
assert.Equal(t,
`<script nonce crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`" id="gitea-external-render-helper" data-render-query-string="a=1%2f2"></script>`+
`<link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`">`+
`<script nonce crossorigin src="`+public.AssetURI("web_src/js/external-render-helper.ts")+`" id="gitea-external-render-helper" data-render-query-string="a=1%2f2"></script>`+
`<link rel="stylesheet" href="`+public.AssetURI("web_src/css/themes/theme-gitea-auto.css")+`">`+
`<script>foo("raw")</script>`,
respSub.Body.String(),
)
-1
View File
@@ -267,7 +267,6 @@ export default defineConfig(commonViteOpts({
manifest: true,
rolldownOptions: {
input: {
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: the "css importing" logic in backend is wrong
index: join(import.meta.dirname, 'web_src/js/index.ts'),
swagger: join(import.meta.dirname, 'web_src/js/swagger.ts'),
'external-render-frontend': join(import.meta.dirname, 'web_src/js/external-render-frontend.ts'),
@@ -16,6 +16,7 @@ test('LogLineMessage', () => {
'::warning file=test.js,line=1::foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
'::notice::foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
'::debug::foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'##[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
// hidden is special, it is actually skipped before creating
+1
View File
@@ -20,6 +20,7 @@ const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
'##[warning]': 'warning',
'##[notice]': 'notice',
'##[debug]': 'debug',
'##[command]': 'command',
'[command]': 'command',
// https://github.com/actions/toolkit/blob/master/docs/commands.md
-2
View File
@@ -1,5 +1,3 @@
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: it just happens to work for current dependencies
// If this module depends on another one and that one imports "swagger.css", then {{AssetURI "css/swagger.css"}} won't work
import '../css/swagger-standalone.css';
import {initSwaggerUI} from './render/swagger.ts';