Compare commits

...
4 Commits
Author SHA1 Message Date
f0d9af3a18 enhance(ui): use OpenAPI 3.0 spec in API viewer, add spec buttons (#38478)
Follow-ups from https://github.com/go-gitea/gitea/pull/37038:

- Render the OpenAPI 3.0 spec in the API viewer, it is richer than the
Swagger 2.0 rendering
- Replace the back link with gitea-styled buttons to view both specs and
return to Gitea, flowing above swagger-ui on viewports where they would
overlap the title
- Key `VisibilityModes` by the string enum type, now named
`VisibilityString`, removing the `string()` casts at API call sites
- Drop the raw visibility strings from the `Service` settings struct in
favor of the typed mode fields, which also removes the org default
visibility row from the admin config page
- Fix two pre-existing issues surfaced in review: an invalid
`DEFAULT_USER_VISIBILITY` was silently accepted as public, and the org
visibility error message showed the enum zero value instead of the
submitted input

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-16 17:38:33 +00:00
02f82a2054 fix(actions): show retention info on hover for expired artifacts (#38477)
The artifact info hover tooltip (#37100) only worked for live artifacts.
Hovering an expired/deleted artifact showed nothing, and even on live
artifacts the retention date was missing on real runs.

Two root causes:

1. **Expired artifacts had no tooltip.** In the run view sidebar,
expired artifacts render in a separate branch that omitted
`data-tooltip-content` entirely, so there was nothing to hover.
2. **`ExpiresUnix` was no longer sent.** The `ActionRunAttempt` refactor
(#37119) dropped `ExpiresUnix` from `fillViewRunResponseArtifacts`, so
real runs always returned `0` and the tooltip fell back to size-only.
Only the devtest mock still populated it, which masked the regression.

Artifacts without a recorded expiry (`expiresUnix <= 0`) still degrade
gracefully to a size-only tooltip. Both states can be previewed on the
`/devtest/mock/*` actions run page.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-16 15:58:31 +02:00
bircniandGitHub 1850a108e5 fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475)
Fixes the UI issue reported in #38466: matrix jobs that invoke a
reusable workflow (`build-call (linux)`, `build-call (windows)`, …) were
rendered as separate nodes in the run's workflow graph, while an
equivalent regular matrix
(`build (linux)`, `build (windows)`, …) collapsed neatly into a single
matrix node.

Before: three loose `build-call (...)` boxes next to one grouped `build` box.
After: both matrices collapse into a single node.
2026-07-16 20:58:05 +08:00
wxiaoguangandGitHub 875b2e8def fix: full file highlighting for git diff with CR char (#38484)
fix #38481
2026-07-16 11:36:46 +00:00
32 changed files with 273 additions and 150 deletions
+2 -2
View File
@@ -117,8 +117,8 @@ func (t *Team) CanNonMemberReadMeta(ctx context.Context, org, doer *user_model.U
}
}
func NormalizeTeamVisibility(s string) structs.VisibleType {
if vt, ok := structs.VisibilityModes[s]; ok {
func NormalizeTeamVisibility[T ~string](v T) structs.VisibleType {
if vt, ok := structs.VisibilityModes[structs.VisibilityString(v)]; ok {
return vt
}
return structs.VisibleTypePrivate
+26 -30
View File
@@ -25,12 +25,9 @@ const (
// Service settings
var Service = struct {
DefaultUserVisibility string
DefaultUserVisibilityMode structs.VisibleType
AllowedUserVisibilityModes []string
AllowedUserVisibilityModesSlice AllowedVisibility `ini:"-"`
DefaultOrgVisibility string
DefaultOrgVisibilityMode structs.VisibleType
DefaultUserVisibilityMode structs.VisibleType `ini:"-"`
AllowedUserVisibilityModesSlice AllowedVisibility `ini:"-"`
DefaultOrgVisibilityMode structs.VisibleType `ini:"-"`
ActiveCodeLives int
ResetPwdCodeLives int
RegisterEmailConfirm bool
@@ -217,36 +214,35 @@ func loadServiceFrom(rootCfg ConfigProvider) {
Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true)
Service.AutoWatchNewRepos = sec.Key("AUTO_WATCH_NEW_REPOS").MustBool(true)
Service.AutoWatchOnChanges = sec.Key("AUTO_WATCH_ON_CHANGES").MustBool(false)
modes := sec.Key("ALLOWED_USER_VISIBILITY_MODES").Strings(",")
if len(modes) != 0 {
Service.AllowedUserVisibilityModes = []string{}
Service.AllowedUserVisibilityModesSlice = []bool{false, false, false}
for _, sMode := range modes {
if tp, ok := structs.VisibilityModes[sMode]; ok { // remove unsupported modes
Service.AllowedUserVisibilityModes = append(Service.AllowedUserVisibilityModes, sMode)
Service.AllowedUserVisibilityModesSlice[tp] = true
} else {
log.Warn("ALLOWED_USER_VISIBILITY_MODES %s is unsupported", sMode)
}
Service.AllowedUserVisibilityModesSlice = AllowedVisibility{false, false, false}
var allowedUserVisibilityModes []structs.VisibilityString
for _, allowedMode := range sec.Key("ALLOWED_USER_VISIBILITY_MODES").Strings(",") {
if tp, ok := structs.VisibilityModes[structs.VisibilityString(allowedMode)]; ok { // remove unsupported modes
allowedUserVisibilityModes = append(allowedUserVisibilityModes, structs.VisibilityString(allowedMode))
Service.AllowedUserVisibilityModesSlice[tp] = true
} else {
log.Warn("ALLOWED_USER_VISIBILITY_MODES %s is unsupported", allowedMode)
}
}
if len(Service.AllowedUserVisibilityModes) == 0 {
Service.AllowedUserVisibilityModes = []string{"public", "limited", "private"}
Service.AllowedUserVisibilityModesSlice = []bool{true, true, true}
if len(allowedUserVisibilityModes) == 0 {
allowedUserVisibilityModes = []structs.VisibilityString{structs.VisibilityStringPublic, structs.VisibilityStringLimited, structs.VisibilityStringPrivate}
Service.AllowedUserVisibilityModesSlice = AllowedVisibility{true, true, true}
}
Service.DefaultUserVisibility = sec.Key("DEFAULT_USER_VISIBILITY").String()
if Service.DefaultUserVisibility == "" {
Service.DefaultUserVisibility = Service.AllowedUserVisibilityModes[0]
} else if !Service.AllowedUserVisibilityModesSlice[structs.VisibilityModes[Service.DefaultUserVisibility]] {
log.Warn("DEFAULT_USER_VISIBILITY %s is wrong or not in ALLOWED_USER_VISIBILITY_MODES, using first allowed", Service.DefaultUserVisibility)
Service.DefaultUserVisibility = Service.AllowedUserVisibilityModes[0]
defaultUserVisibility := structs.VisibilityString(sec.Key("DEFAULT_USER_VISIBILITY").String())
if defaultUserVisibility == "" {
defaultUserVisibility = allowedUserVisibilityModes[0]
} else if vt, ok := structs.VisibilityModes[defaultUserVisibility]; !ok || !Service.AllowedUserVisibilityModesSlice[vt] {
log.Warn("DEFAULT_USER_VISIBILITY %s is wrong or not in ALLOWED_USER_VISIBILITY_MODES, using first allowed", defaultUserVisibility)
defaultUserVisibility = allowedUserVisibilityModes[0]
}
Service.DefaultUserVisibilityMode = structs.VisibilityModes[Service.DefaultUserVisibility]
Service.DefaultOrgVisibility = sec.Key("DEFAULT_ORG_VISIBILITY").In("public", structs.ExtractKeysFromMapString(structs.VisibilityModes))
Service.DefaultOrgVisibilityMode = structs.VisibilityModes[Service.DefaultOrgVisibility]
Service.DefaultUserVisibilityMode = structs.VisibilityModes[defaultUserVisibility]
defaultOrgVisibility := structs.VisibilityString(sec.Key("DEFAULT_ORG_VISIBILITY").In("public", structs.VisibilityModeKeys()))
Service.DefaultOrgVisibilityMode = structs.VisibilityModes[defaultOrgVisibility]
Service.DefaultOrgMemberVisible = sec.Key("DEFAULT_ORG_MEMBER_VISIBLE").MustBool()
Service.UserDeleteWithCommentsMaxTime = sec.Key("USER_DELETE_WITH_COMMENTS_MAX_TIME").MustDuration(0)
sec.Key("VALID_SITE_URL_SCHEMES").MustString("http,https")
Service.ValidSiteURLSchemes = sec.Key("VALID_SITE_URL_SCHEMES").Strings(",")
+27 -24
View File
@@ -48,80 +48,83 @@ EMAIL_DOMAIN_BLOCKLIST = d3, *.b
func TestLoadServiceVisibilityModes(t *testing.T) {
defer test.MockVariableValue(&Service)()
kases := map[string]func(){
visibleTypeSlice := func(s ...structs.VisibilityString) (ret []structs.VisibleType) {
for _, v := range s {
ret = append(ret, structs.VisibilityModes[v])
}
return ret
}
testCases := map[string]func(){
`
[service]
DEFAULT_USER_VISIBILITY = public
ALLOWED_USER_VISIBILITY_MODES = public,limited,private
`: func() {
assert.Equal(t, "public", Service.DefaultUserVisibility)
assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes)
assert.Equal(t, visibleTypeSlice("public", "limited", "private"), Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice())
},
`
[service]
DEFAULT_USER_VISIBILITY = public
`: func() {
assert.Equal(t, "public", Service.DefaultUserVisibility)
assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes)
assert.Equal(t, visibleTypeSlice("public", "limited", "private"), Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice())
},
`
[service]
DEFAULT_USER_VISIBILITY = limited
`: func() {
assert.Equal(t, "limited", Service.DefaultUserVisibility)
assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode)
assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes)
assert.Equal(t, visibleTypeSlice("public", "limited", "private"), Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice())
},
`
[service]
ALLOWED_USER_VISIBILITY_MODES = public,limited,private
`: func() {
assert.Equal(t, "public", Service.DefaultUserVisibility)
assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
assert.Equal(t, []string{"public", "limited", "private"}, Service.AllowedUserVisibilityModes)
assert.Equal(t, visibleTypeSlice("public", "limited", "private"), Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice())
},
`
[service]
DEFAULT_USER_VISIBILITY = public
ALLOWED_USER_VISIBILITY_MODES = limited,private
`: func() {
assert.Equal(t, "limited", Service.DefaultUserVisibility)
assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode)
assert.Equal(t, []string{"limited", "private"}, Service.AllowedUserVisibilityModes)
assert.Equal(t, visibleTypeSlice("limited", "private"), Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice())
},
`
[service]
DEFAULT_USER_VISIBILITY = my_type
ALLOWED_USER_VISIBILITY_MODES = limited,private
`: func() {
assert.Equal(t, "limited", Service.DefaultUserVisibility)
assert.Equal(t, structs.VisibleTypeLimited, Service.DefaultUserVisibilityMode)
assert.Equal(t, []string{"limited", "private"}, Service.AllowedUserVisibilityModes)
assert.Equal(t, visibleTypeSlice("limited", "private"), Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice())
},
`
[service]
DEFAULT_USER_VISIBILITY = my_type
`: func() {
assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
assert.Equal(t, visibleTypeSlice("public", "limited", "private"), Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice())
},
`
[service]
DEFAULT_USER_VISIBILITY = public
ALLOWED_USER_VISIBILITY_MODES = public, limit, privated
`: func() {
assert.Equal(t, "public", Service.DefaultUserVisibility)
assert.Equal(t, structs.VisibleTypePublic, Service.DefaultUserVisibilityMode)
assert.Equal(t, []string{"public"}, Service.AllowedUserVisibilityModes)
assert.Equal(t, visibleTypeSlice("public"), Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice())
},
}
for kase, fun := range kases {
t.Run(kase, func(t *testing.T) {
cfg, err := NewConfigProviderFromData(kase)
for tc, fn := range testCases {
t.Run(tc, func(t *testing.T) {
Service.AllowedUserVisibilityModesSlice = []bool{true, true, true}
Service.DefaultUserVisibilityMode = structs.VisibleTypePublic
cfg, err := NewConfigProviderFromData(tc)
assert.NoError(t, err)
loadServiceFrom(cfg)
fun()
// reset
Service.AllowedUserVisibilityModesSlice = []bool{true, true, true}
Service.AllowedUserVisibilityModes = []string{}
Service.DefaultUserVisibility = ""
Service.DefaultUserVisibilityMode = structs.VisibleTypePublic
fn()
})
}
}
+2 -2
View File
@@ -29,7 +29,7 @@ type CreateUserOption struct {
// Whether the user has restricted access privileges
Restricted *bool `json:"restricted"`
// User visibility level: public, limited, or private
Visibility UserVisibility `json:"visibility" binding:"In(,public,limited,private)"`
Visibility VisibilityString `json:"visibility" binding:"In(,public,limited,private)"`
// For explicitly setting the user creation timestamp. Useful when users are
// migrated from other systems. When omitted, the user's creation timestamp
@@ -78,5 +78,5 @@ type EditUserOption struct {
// Whether the user has restricted access privileges
Restricted *bool `json:"restricted"`
// User visibility level: public, limited, or private
Visibility UserVisibility `json:"visibility" binding:"In(,public,limited,private)"`
Visibility VisibilityString `json:"visibility" binding:"In(,public,limited,private)"`
}
+3 -3
View File
@@ -22,7 +22,7 @@ type Organization struct {
// The location of the organization
Location string `json:"location"`
// The visibility level of the organization (public, limited, private)
Visibility UserVisibility `json:"visibility"`
Visibility VisibilityString `json:"visibility"`
// Whether repository administrators can change team access
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
// username of the organization
@@ -60,7 +60,7 @@ type CreateOrgOption struct {
// The location of the organization
Location string `json:"location" binding:"MaxSize(50)"`
// possible values are `public` (default), `limited` or `private`
Visibility UserVisibility `json:"visibility" binding:"In(,public,limited,private)"`
Visibility VisibilityString `json:"visibility" binding:"In(,public,limited,private)"`
// Whether repository administrators can change team access
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
}
@@ -78,7 +78,7 @@ type EditOrgOption struct {
// The location of the organization
Location *string `json:"location" binding:"MaxSize(50)"`
// possible values are `public`, `limited` or `private`
Visibility *UserVisibility `json:"visibility" binding:"In(,public,limited,private)"`
Visibility *VisibilityString `json:"visibility" binding:"In(,public,limited,private)"`
// Whether repository administrators can change team access
RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access"`
}
+1 -1
View File
@@ -51,7 +51,7 @@ type User struct {
// the user's description
Description string `json:"description"`
// User visibility level option: public, limited, private
Visibility UserVisibility `json:"visibility"`
Visibility VisibilityString `json:"visibility"`
// user counts
Followers int `json:"followers_count"`
+16 -16
View File
@@ -18,10 +18,10 @@ const (
)
// VisibilityModes is a map of Visibility types
var VisibilityModes = map[string]VisibleType{
"public": VisibleTypePublic,
"limited": VisibleTypeLimited,
"private": VisibleTypePrivate,
var VisibilityModes = map[VisibilityString]VisibleType{
VisibilityStringPublic: VisibleTypePublic,
VisibilityStringLimited: VisibleTypeLimited,
VisibilityStringPrivate: VisibleTypePrivate,
}
// IsPublic returns true if VisibleType is public
@@ -43,27 +43,27 @@ func (vt VisibleType) IsPrivate() bool {
func (vt VisibleType) String() string {
for k, v := range VisibilityModes {
if vt == v {
return k
return string(k)
}
}
return ""
}
// ExtractKeysFromMapString provides a slice of keys from map
func ExtractKeysFromMapString(in map[string]VisibleType) (keys []string) {
for k := range in {
keys = append(keys, k)
// VisibilityModeKeys returns the visibility mode names (public, limited, private)
func VisibilityModeKeys() (keys []string) {
for k := range VisibilityModes {
keys = append(keys, string(k))
}
return keys
}
// UserVisibility defines the visibility level of a user or organization as
// rendered in API payloads. The DB representation is VisibleType (int).
// swagger:enum UserVisibility
type UserVisibility string
// VisibilityString defines the visibility level of a user/organization/team as
// rendered in API and config payloads. The DB representation is VisibleType (int).
// swagger:enum VisibilityString
type VisibilityString string
const (
UserVisibilityPublic UserVisibility = "public"
UserVisibilityLimited UserVisibility = "limited"
UserVisibilityPrivate UserVisibility = "private"
VisibilityStringPublic VisibilityString = "public"
VisibilityStringLimited VisibilityString = "limited"
VisibilityStringPrivate VisibilityString = "private"
)
+1 -1
View File
@@ -124,6 +124,7 @@
"artifacts": "Artifacts",
"expired": "Expired",
"artifact_expires_at": "Expires at %s",
"artifact_expired_at": "Expired at %s",
"confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?",
"archived": "Archived",
"concept_system_global": "Global",
@@ -3305,7 +3306,6 @@
"admin.config.default_enable_timetracking": "Enable Time Tracking by Default",
"admin.config.default_allow_only_contributors_to_track_time": "Let Only Contributors Track Time",
"admin.config.no_reply_address": "Hidden Email Domain",
"admin.config.default_visibility_organization": "Default visibility for new Organizations",
"admin.config.default_enable_dependencies": "Enable Issue Dependencies by Default",
"admin.config.webhook_config": "Webhook Configuration",
"admin.config.queue_length": "Queue Length",
+1 -1
View File
@@ -48,7 +48,7 @@ func CreateOrg(ctx *context.APIContext) {
visibility := api.VisibleTypePublic
if form.Visibility != "" {
visibility = api.VisibilityModes[string(form.Visibility)]
visibility = api.VisibilityModes[form.Visibility]
}
org := &organization.Organization{
+3 -3
View File
@@ -121,7 +121,7 @@ func CreateUser(ctx *context.APIContext) {
}
if form.Visibility != "" {
visibility := api.VisibilityModes[string(form.Visibility)]
visibility := api.VisibilityModes[form.Visibility]
overwriteDefault.Visibility = &visibility
}
@@ -237,7 +237,7 @@ func EditUser(ctx *context.APIContext) {
Description: optional.FromPtr(form.Description),
IsActive: optional.FromPtr(form.Active),
IsAdmin: user_service.UpdateOptionFieldFromPtr(form.Admin),
Visibility: optional.FromMapLookup(api.VisibilityModes, string(form.Visibility)),
Visibility: optional.FromMapLookup(api.VisibilityModes, form.Visibility),
AllowGitHook: optional.FromPtr(form.AllowGitHook),
AllowImportLocal: optional.FromPtr(form.AllowImportLocal),
MaxRepoCreation: optional.FromPtr(form.MaxRepoCreation),
@@ -469,7 +469,7 @@ func SearchUsers(ctx *context.APIContext) {
var visible []api.VisibleType
visibilityParam := ctx.FormString("visibility")
if visibility, ok := api.VisibilityModes[visibilityParam]; ok {
if visibility, ok := api.VisibilityModes[api.VisibilityString(visibilityParam)]; ok {
visible = []api.VisibleType{visibility}
} else if visibilityParam != "" {
ctx.APIError(http.StatusUnprocessableEntity, "invalid visibility")
+2 -2
View File
@@ -269,7 +269,7 @@ func Create(ctx *context.APIContext) {
visibility := api.VisibleTypePublic
if form.Visibility != "" {
visibility = api.VisibilityModes[string(form.Visibility)]
visibility = api.VisibilityModes[form.Visibility]
}
org := &organization.Organization{
@@ -413,7 +413,7 @@ func Edit(ctx *context.APIContext) {
Description: optional.FromPtr(form.Description),
Website: optional.FromPtr(form.Website),
Location: optional.FromPtr(form.Location),
Visibility: optional.FromMapLookup(api.VisibilityModes, string(optional.FromPtr(form.Visibility).Value())),
Visibility: optional.FromMapLookup(api.VisibilityModes, optional.FromPtr(form.Visibility).Value()),
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
}
if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil {
+2 -2
View File
@@ -223,7 +223,7 @@ func CreateTeam(ctx *context.APIContext) {
IncludesAllRepositories: form.IncludesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo,
AccessMode: teamPermission,
Visibility: organization.NormalizeTeamVisibility(string(form.Visibility)),
Visibility: organization.NormalizeTeamVisibility(form.Visibility),
}
if team.AccessMode < perm.AccessModeAdmin {
@@ -302,7 +302,7 @@ func EditTeam(ctx *context.APIContext) {
}
if form.Visibility != nil && !team.IsOwnerTeam() {
team.Visibility = organization.NormalizeTeamVisibility(string(*form.Visibility))
team.Visibility = organization.NormalizeTeamVisibility(*form.Visibility)
}
isAuthChanged := false
+64
View File
@@ -382,6 +382,8 @@ func MockActionsRunsJobs(ctx *context.Context) {
// └ deep_job (regular)
// cross_caller (caller, cross-repo, expanded)
// └ external_job (regular)
// build (linux|windows|macos) (regular matrix; graph folds into one "build" node)
// build-call (linux|windows|macos) (caller matrix, each calls build.yml; folds into one "build-call" node like "build")
// final (regular, needs local_caller + cross_caller)
const (
prepareID = int64(400)
@@ -392,6 +394,21 @@ func MockActionsRunsJobs(ctx *context.Context) {
crossCallerID = int64(405)
externalJobID = int64(406)
finalID = int64(407)
// Regular matrix set the graph already folds these into a single "build" node.
buildLinuxID = int64(410)
buildWindowsID = int64(411)
buildMacosID = int64(412)
// Caller matrix set each matrix leg calls the same reusable workflow. #38466: like the
// regular "build" matrix above, these fold into one "build-call" node. Matrix legs share a
// single JobID, so the legs below use JobID "build-call" and differ only by their name suffix.
buildCallLinuxID = int64(420)
buildCallWindowsID = int64(421)
buildCallMacosID = int64(422)
buildCallLinuxJobID = int64(423)
buildCallWinJobID = int64(424)
buildCallMacJobID = int64(425)
)
resp.State.Run.Jobs = []*actions.ViewJob{
@@ -432,6 +449,53 @@ func MockActionsRunsJobs(ctx *context.Context) {
Status: actions_model.StatusWaiting.String(), Duration: "0s",
ParentJobID: crossCallerID,
},
// Regular matrix "build" these fold into one matrix node in the graph. The matrix legs
// share a single JobID ("build"); the " (variant)" name suffix distinguishes the legs.
{
ID: buildLinuxID, Link: jobLink(buildLinuxID), JobID: "build", Name: "build (linux)",
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
},
{
ID: buildWindowsID, Link: jobLink(buildWindowsID), JobID: "build", Name: "build (windows)",
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
},
{
ID: buildMacosID, Link: jobLink(buildMacosID), JobID: "build", Name: "build (macos)",
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
},
// Caller matrix "build-call" each leg calls the same reusable workflow. #38466: like the
// regular "build" matrix above, these fold into one node. The matrix legs share a single
// JobID ("build-call"); the " (variant)" name suffix distinguishes the legs.
{
ID: buildCallLinuxID, Link: jobLink(buildCallLinuxID), JobID: "build-call", Name: "build-call (linux)",
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
},
{
ID: buildCallLinuxJobID, Link: jobLink(buildCallLinuxJobID), JobID: "bc_linux_build", Name: "build",
Status: actions_model.StatusSuccess.String(), Duration: "1m", ParentJobID: buildCallLinuxID,
},
{
ID: buildCallWindowsID, Link: jobLink(buildCallWindowsID), JobID: "build-call", Name: "build-call (windows)",
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
},
{
ID: buildCallWinJobID, Link: jobLink(buildCallWinJobID), JobID: "bc_windows_build", Name: "build",
Status: actions_model.StatusSuccess.String(), Duration: "2m", ParentJobID: buildCallWindowsID,
},
{
ID: buildCallMacosID, Link: jobLink(buildCallMacosID), JobID: "build-call", Name: "build-call (macos)",
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
},
{
ID: buildCallMacJobID, Link: jobLink(buildCallMacJobID), JobID: "bc_macos_build", Name: "build",
Status: actions_model.StatusSuccess.String(), Duration: "90s", ParentJobID: buildCallMacosID,
},
{
ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final",
Status: actions_model.StatusBlocked.String(), Duration: "0s",
+3 -2
View File
@@ -235,9 +235,10 @@ func SettingsRenamePost(ctx *context.Context) {
// SettingsChangeVisibilityPost response for change organization visibility
func SettingsChangeVisibilityPost(ctx *context.Context) {
visibility, ok := structs.VisibilityModes[ctx.FormString("visibility")]
visibilityStr := ctx.FormString("visibility")
visibility, ok := structs.VisibilityModes[structs.VisibilityString(visibilityStr)]
if !ok {
ctx.Flash.Error(ctx.Tr("invalid_data", visibility))
ctx.Flash.Error(ctx.Tr("invalid_data", visibilityStr))
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(ctx.Org.Organization.Name) + "/settings")
return
}
+4 -3
View File
@@ -716,9 +716,10 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts))
for _, art := range arts {
resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{
Name: art.ArtifactName,
Size: art.FileSize,
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
Name: art.ArtifactName,
Size: art.FileSize,
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
ExpiresUnix: int64(art.ExpiredUnix),
})
}
}
+1 -1
View File
@@ -864,7 +864,7 @@ func ToOrganization(ctx context.Context, org *organization.Organization) *api.Or
Description: org.Description,
Website: org.Website,
Location: org.Location,
Visibility: api.UserVisibility(org.Visibility.String()),
Visibility: api.VisibilityString(org.Visibility.String()),
RepoAdminChangeTeamAccess: org.RepoAdminChangeTeamAccess,
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap
StarredRepos: user.NumStars,
}
result.Visibility = api.UserVisibility(user.Visibility.String())
result.Visibility = api.VisibilityString(user.Visibility.String())
// hide primary email if API caller is anonymous or user keep email private
if signed && (!user.KeepEmailPrivate || authed) {
+2 -2
View File
@@ -29,11 +29,11 @@ func TestUser_ToUser(t *testing.T) {
apiUser = toUser(t.Context(), user1, false, false)
assert.False(t, apiUser.IsAdmin)
assert.Equal(t, api.UserVisibilityPublic, apiUser.Visibility)
assert.Equal(t, api.VisibilityStringPublic, apiUser.Visibility)
user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31, IsAdmin: false, Visibility: api.VisibleTypePrivate})
apiUser = toUser(t.Context(), user31, true, true)
assert.False(t, apiUser.IsAdmin)
assert.Equal(t, api.UserVisibilityPrivate, apiUser.Visibility)
assert.Equal(t, api.VisibilityStringPrivate, apiUser.Visibility)
}
+7 -1
View File
@@ -1285,6 +1285,8 @@ func getDiffBasic(ctx context.Context, gitRepo *git.Repository, opts *DiffOption
return nil, nil, nil, err
}
// HINT: GIT-DIFF-HIGHLIGHT-LINE-NUMBER: git doesn't treat CR(\r) as EOL, CR is just a plain char which can appear anywhere in the diff output
// Since we have to do full-file-highlighting for the diff result, we need to make sure the highlighted lines exactly match the git's diff output.
cmdDiff := gitcmd.NewCommand().
AddArguments("diff", "--src-prefix=\\a/", "--dst-prefix=\\b/").
AddArguments(opts.WhitespaceBehavior...).
@@ -1405,8 +1407,12 @@ func highlightCodeLines(name, lang string, sections []*DiffSection, isLeft bool,
if setting.Git.DisableDiffHighlight || len(rawContent) > MaxFullFileHighlightSizeLimit {
return nil
}
content := util.UnsafeBytesToString(charset.ToUTF8(rawContent, charset.ConvertOpts{}))
// HINT: GIT-DIFF-HIGHLIGHT-LINE-NUMBER: it should handle all CR(\r) before highlight to make line numbers match
if strings.Contains(content, "\r") {
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "␍")
}
lexer := highlight.DetectChromaLexerByFileName(name, lang)
highlightedNewContent := highlight.RenderCodeByLexer(lexer, content)
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlightedNewContent)
+13
View File
@@ -1143,6 +1143,19 @@ func TestHighlightCodeLines(t *testing.T) {
1: `<span class="n">b</span>` + nl,
}, ret)
})
t.Run("CharCR", func(t *testing.T) {
diffFile := &DiffFile{
Name: "a.txt",
Sections: []*DiffSection{
{
Lines: []*DiffLine{{LeftIdx: 1}, {LeftIdx: 2}},
},
},
}
ret := highlightCodeLinesForDiffFile(diffFile, true, []byte("a\rb\r\nc"))
assert.Equal(t, "a␍b\n", string(ret[0]))
assert.Equal(t, `c`, string(ret[1]))
})
}
func TestSyncUserSpecificDiff_UpdatedFiles(t *testing.T) {
-2
View File
@@ -155,8 +155,6 @@
<dt>{{ctx.Locale.Tr "admin.config.default_allow_only_contributors_to_track_time"}}</dt>
<dd>{{svg (Iif .Service.DefaultAllowOnlyContributorsToTrackTime "octicon-check" "octicon-x")}}</dd>
{{end}}
<dt>{{ctx.Locale.Tr "admin.config.default_visibility_organization"}}</dt>
<dd>{{.Service.DefaultOrgVisibility}}</dd>
<dt>{{ctx.Locale.Tr "admin.config.no_reply_address"}}</dt>
<dd>{{if .Service.NoReplyAddress}}{{.Service.NoReplyAddress}}{{else}}-{{end}}</dd>
@@ -39,6 +39,7 @@
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}"
data-locale-artifact-expires-at="{{ctx.Locale.Tr "artifact_expires_at"}}"
data-locale-artifact-expired-at="{{ctx.Locale.Tr "artifact_expired_at"}}"
data-locale-confirm-delete-artifact="{{ctx.Locale.Tr "confirm_delete_artifact"}}"
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
+7 -3
View File
@@ -4,13 +4,17 @@
{{ctx.HeadMetaContentSecurityPolicy}}
<title>Gitea API</title>
<link rel="stylesheet" href="{{ctx.CurrentWebTheme.PublicAssetURI}}">
{{/* HINT: SWAGGER-CSS-IMPORT: load swagger styles ahead to avoid flicker (e.g. the swagger-back-link) */}}
{{/* HINT: SWAGGER-CSS-IMPORT: load swagger styles ahead to avoid flicker (e.g. the swagger-page-links) */}}
{{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>
<div class="swagger-page-links">
<a href="{{AppSubUrl}}/openapi3.v1.json" target="_blank">{{svg "octicon-file-code"}}OpenAPI JSON</a>
<a href="{{AppSubUrl}}/swagger.v1.json" target="_blank">{{svg "octicon-file-code"}}Swagger JSON</a>
<a href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a>
</div>
<div id="swagger-ui" data-source="{{AppSubUrl}}/openapi3.v1.json"></div>
<footer class="page-footer"></footer>
{{ctx.ScriptImport "web_src/js/swagger.ts" "module"}}
</body>
+6 -6
View File
@@ -24561,7 +24561,7 @@
"limited",
"private"
],
"x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate",
"x-go-enum-desc": "public VisibilityStringPublic\nlimited VisibilityStringLimited\nprivate VisibilityStringPrivate",
"x-go-name": "Visibility"
},
"website": {
@@ -25131,7 +25131,7 @@
"limited",
"private"
],
"x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate",
"x-go-enum-desc": "public VisibilityStringPublic\nlimited VisibilityStringLimited\nprivate VisibilityStringPrivate",
"x-go-name": "Visibility"
}
},
@@ -25833,7 +25833,7 @@
"limited",
"private"
],
"x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate",
"x-go-enum-desc": "public VisibilityStringPublic\nlimited VisibilityStringLimited\nprivate VisibilityStringPrivate",
"x-go-name": "Visibility"
},
"website": {
@@ -26375,7 +26375,7 @@
"limited",
"private"
],
"x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate",
"x-go-enum-desc": "public VisibilityStringPublic\nlimited VisibilityStringLimited\nprivate VisibilityStringPrivate",
"x-go-name": "Visibility"
},
"website": {
@@ -28227,7 +28227,7 @@
"limited",
"private"
],
"x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate",
"x-go-enum-desc": "public VisibilityStringPublic\nlimited VisibilityStringLimited\nprivate VisibilityStringPrivate",
"x-go-name": "Visibility"
},
"website": {
@@ -30590,7 +30590,7 @@
"limited",
"private"
],
"x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate",
"x-go-enum-desc": "public VisibilityStringPublic\nlimited VisibilityStringLimited\nprivate VisibilityStringPrivate",
"x-go-name": "Visibility"
},
"website": {
+7 -7
View File
@@ -4304,7 +4304,7 @@
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/UserVisibility"
"$ref": "#/components/schemas/VisibilityString"
}
],
"description": "possible values are `public` (default), `limited` or `private`"
@@ -4845,7 +4845,7 @@
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/UserVisibility"
"$ref": "#/components/schemas/VisibilityString"
}
],
"description": "User visibility level: public, limited, or private"
@@ -5549,7 +5549,7 @@
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/UserVisibility"
"$ref": "#/components/schemas/VisibilityString"
}
],
"description": "possible values are `public`, `limited` or `private`"
@@ -6075,7 +6075,7 @@
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/UserVisibility"
"$ref": "#/components/schemas/VisibilityString"
}
],
"description": "User visibility level: public, limited, or private"
@@ -7949,7 +7949,7 @@
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/UserVisibility"
"$ref": "#/components/schemas/VisibilityString"
}
],
"description": "The visibility level of the organization (public, limited, private)"
@@ -10343,7 +10343,7 @@
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/UserVisibility"
"$ref": "#/components/schemas/VisibilityString"
}
],
"description": "User visibility level option: public, limited, private"
@@ -10496,7 +10496,7 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"UserVisibility": {
"VisibilityString": {
"enum": [
"public",
"limited",
+4 -4
View File
@@ -128,7 +128,7 @@ func testAPIOrgGeneral(t *testing.T) {
apiOrgList := DecodeJSON(t, resp, []*api.Organization{})
assert.Len(t, apiOrgList, 13)
assert.Equal(t, "Limited Org 36", apiOrgList[1].FullName)
assert.Equal(t, api.UserVisibilityLimited, apiOrgList[1].Visibility)
assert.Equal(t, api.VisibilityStringLimited, apiOrgList[1].Visibility)
// accessing without a token will return only public orgs
req = NewRequest(t, "GET", "/api/v1/orgs")
@@ -137,7 +137,7 @@ func testAPIOrgGeneral(t *testing.T) {
apiOrgList = DecodeJSON(t, resp, []*api.Organization{})
assert.Len(t, apiOrgList, 9)
assert.Equal(t, "org 17", apiOrgList[0].FullName)
assert.Equal(t, api.UserVisibilityPublic, apiOrgList[0].Visibility)
assert.Equal(t, api.VisibilityStringPublic, apiOrgList[0].Visibility)
})
t.Run("OrgEdit", func(t *testing.T) {
@@ -149,7 +149,7 @@ func testAPIOrgGeneral(t *testing.T) {
Description: new("new description"),
Website: new("https://org3-new-website.example.com"),
Location: new("new location"),
Visibility: new(api.UserVisibilityLimited),
Visibility: new(api.VisibilityStringLimited),
Email: new("org3-new-email@example.com"),
}
req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org3Edit).AddTokenAuth(user1Token)
@@ -179,7 +179,7 @@ func testAPIOrgGeneral(t *testing.T) {
t.Run("OrgEditInvalidVisibility", func(t *testing.T) {
org := api.EditOrgOption{
Visibility: new(api.UserVisibility("invalid-visibility")),
Visibility: new(api.VisibilityString("invalid-visibility")),
}
req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).AddTokenAuth(user1Token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
+2 -2
View File
@@ -47,7 +47,7 @@ func TestAPIUserSearchLoggedIn(t *testing.T) {
for _, user := range results.Data {
assert.Contains(t, user.UserName, query)
assert.NotEmpty(t, user.Email)
assert.Equal(t, api.UserVisibilityPublic, user.Visibility)
assert.Equal(t, api.VisibilityStringPublic, user.Visibility)
}
}
@@ -103,7 +103,7 @@ func TestAPIUserSearchAdminLoggedInUserHidden(t *testing.T) {
for _, user := range results.Data {
assert.Contains(t, user.UserName, query)
assert.NotEmpty(t, user.Email)
assert.Equal(t, api.UserVisibilityPrivate, user.Visibility)
assert.Equal(t, api.VisibilityStringPrivate, user.Visibility)
}
}
+45 -14
View File
@@ -4,26 +4,57 @@ body {
margin: 0;
}
.swagger-back-link {
color: var(--color-primary);
text-decoration: none;
.swagger-page-links {
/* overlay the top-right of the info block by replicating swagger-ui's ".wrapper" and ".info" metrics */
position: absolute;
top: 1rem;
right: 1.5rem;
top: 50px;
left: 0;
right: 0;
max-width: 1460px;
margin: 0 auto;
padding: 0 20px;
box-sizing: border-box;
display: flex;
justify-content: flex-end;
gap: 8px;
font-family: sans-serif;
font-size: 14px;
pointer-events: none; /* only the links themselves are clickable */
}
/* below this width the links would overlap the title, flow above swagger-ui instead */
@media (max-width: 991.98px) {
.swagger-page-links {
position: static;
padding: 10px 20px 0;
flex-wrap: wrap;
}
}
/* mimic the regular gitea button, the main site CSS is not loaded on this page */
.swagger-page-links a {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
background: var(--color-button);
border: 1px solid var(--color-light-border);
color: var(--color-text);
text-decoration: none;
white-space: nowrap;
}
.swagger-back-link:hover {
text-decoration: underline;
.swagger-page-links a:hover {
background: var(--color-hover);
}
.swagger-back-link svg {
color: inherit;
.swagger-page-links a:active {
background: var(--color-active);
}
.swagger-page-links svg {
fill: currentcolor;
margin-right: 0.5rem;
}
.swagger-spec-content {
display: none;
}
+6 -1
View File
@@ -274,7 +274,12 @@ onBeforeUnmount(() => {
<SvgIcon name="octicon-trash"/>
</a>
</template>
<span v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2">
<span
v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2"
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiredAt)"
data-tooltip-render="html"
data-tooltip-placement="top-end"
>
<SvgIcon name="octicon-file-removed"/>
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
@@ -157,16 +157,18 @@ test('reusable callers with identical dependency signature are kept as separate
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller');
});
test('reusable caller with matrix-pattern name does not get absorbed into a sibling matrix node', () => {
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
const jobs: ActionsJob[] = [
{id: 1, link: '', jobId: 'deploy_dev', name: 'deploy (dev)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
{id: 2, link: '', jobId: 'deploy_qa', name: 'deploy (qa)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
{id: 3, link: '', jobId: 'deploy_staging', name: 'deploy (staging)', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2s', callUses: './.gitea/workflows/deploy.yml'},
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
{id: 2, link: '', jobId: 'build_call_linux', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
{id: 3, link: '', jobId: 'build_call_windows', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
{id: 4, link: '', jobId: 'build_call_macos', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
];
const graph = createWorkflowGraphModel(jobs);
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('deploy (staging)');
const matrixNode = graph.nodes.find((n) => n.type === 'matrix');
expect(matrixNode?.jobs.map((j) => j.id).sort()).toEqual([1, 2]);
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
expect(matrixNodes).toHaveLength(1);
expect(matrixNodes[0].matrixKey).toBe('build-call');
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
});
test('directed highlight state covers ancestors and descendants of the hovered node', () => {
+4 -7
View File
@@ -264,9 +264,8 @@ function buildVisualGraph(
const matrixJobsByKey = new Map<string, ActionsJob[]>();
for (const job of jobs) {
// Reusable callers are distinct workflow files — never fold them into a matrix bucket
// even if their display name happens to look like "name (variant)".
if (job.isReusableCaller) continue;
// Matrix legs that call a reusable workflow are still one logical job (a single `uses:`
// expanded over the matrix), so fold them into a matrix node like any other matrix job.
const matrixKey = matrixKeyFromJobName(job.name);
if (!matrixKey) continue;
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
@@ -322,10 +321,8 @@ function buildVisualGraph(
const visualIdByJobId = new Map<number, string>();
for (const job of jobs) {
const matrixKey = matrixKeyFromJobName(job.name);
// Symmetric with the matrix-bucket loop above: a reusable caller whose display name
// happens to look like "name (variant)" must never be folded into the matrix node, or it
// would silently vanish (its visualId would point at a matrix node it isn't part of).
if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
// Symmetric with the matrix-bucket loop above (callers included).
if (matrixKey && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
continue;
}
+1
View File
@@ -71,6 +71,7 @@ function initRepositoryActionsView() {
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
artifactExpiredAt: el.getAttribute('data-locale-artifact-expired-at'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),