mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-06 12:49:46 +02:00
Merge branch 'main' into lunny/fix_force_push
This commit is contained in:
commit
6594b38c4e
@ -67,35 +67,24 @@ linters:
|
||||
revive:
|
||||
severity: error
|
||||
rules:
|
||||
- name: atomic
|
||||
- name: bare-return
|
||||
- name: blank-imports
|
||||
- name: constant-logical-expr
|
||||
- name: context-as-argument
|
||||
- name: context-keys-type
|
||||
- name: dot-imports
|
||||
- name: duplicated-imports
|
||||
- name: empty-lines
|
||||
- name: error-naming
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: errorf
|
||||
- name: exported
|
||||
- name: identical-branches
|
||||
- name: if-return
|
||||
- name: increment-decrement
|
||||
- name: indent-error-flow
|
||||
- name: modifies-value-receiver
|
||||
- name: package-comments
|
||||
- name: range
|
||||
- name: receiver-naming
|
||||
- name: redefines-builtin-id
|
||||
- name: string-of-int
|
||||
- name: superfluous-else
|
||||
- name: time-naming
|
||||
- name: unconditional-recursion
|
||||
- name: unexported-return
|
||||
- name: unreachable-code
|
||||
- name: var-declaration
|
||||
- name: var-naming
|
||||
arguments:
|
||||
@ -133,16 +122,12 @@ linters:
|
||||
- linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosec
|
||||
- staticcheck
|
||||
- unparam
|
||||
path: _test\.go
|
||||
- linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosec
|
||||
path: models/migrations/v
|
||||
- linters:
|
||||
- forbidigo
|
||||
@ -154,7 +139,6 @@ linters:
|
||||
- gocritic
|
||||
text: (?i)`ID' should not be capitalized
|
||||
- linters:
|
||||
- deadcode
|
||||
- unused
|
||||
text: (?i)swagger
|
||||
- linters:
|
||||
|
||||
@ -183,7 +183,7 @@ Here's how to run the test suite:
|
||||
## Translation
|
||||
|
||||
All translation work happens on [Crowdin](https://translate.gitea.com).
|
||||
The only translation that is maintained in this repository is [the English translation](https://github.com/go-gitea/gitea/blob/main/options/locale/locale_en-US.ini).
|
||||
The only translation that is maintained in this repository is [the English translation](https://github.com/go-gitea/gitea/blob/main/options/locale/locale_en-US.json).
|
||||
It is synced regularly with Crowdin. \
|
||||
Other locales on main branch **should not** be updated manually as they will be overwritten with each sync. \
|
||||
Once a language has reached a **satisfactory percentage** of translated keys (~25%), it will be synced back into this repo and included in the next released version.
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@ -1,5 +1,12 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build stage
|
||||
# Build frontend on the native platform to avoid QEMU-related issues with esbuild/webpack
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
|
||||
RUN apk --no-cache add build-base git nodejs pnpm
|
||||
WORKDIR /src
|
||||
COPY --exclude=.git/ . .
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store make frontend
|
||||
|
||||
# Build backend for each target platform
|
||||
FROM docker.io/library/golang:1.26-alpine3.23 AS build-env
|
||||
|
||||
ARG GOPROXY=direct
|
||||
@ -12,22 +19,19 @@ ARG CGO_EXTRA_CFLAGS
|
||||
# Build deps
|
||||
RUN apk --no-cache add \
|
||||
build-base \
|
||||
git \
|
||||
nodejs \
|
||||
pnpm
|
||||
git
|
||||
|
||||
WORKDIR ${GOPATH}/src/code.gitea.io/gitea
|
||||
# Use COPY but not "mount" because some directories like "node_modules" contain platform-depended contents and these directories need to be ignored.
|
||||
# ".git" directory will be mounted later separately for getting version data.
|
||||
# TODO: in the future, maybe we can pre-build the frontend assets on one platform and share them for different platforms, the benefit is that it won't be affected by webpack plugin compatibility problems, then the working directory can be fully mounted and the COPY is not needed.
|
||||
# Use COPY instead of bind mount as read-only one breaks makefile state tracking and read-write one needs binary to be moved as it's discarded.
|
||||
# ".git" directory is mounted separately later only for version data extraction.
|
||||
COPY --exclude=.git/ . .
|
||||
COPY --from=frontend-build /src/public/assets public/assets
|
||||
|
||||
# Build gitea, .git mount is required for version data
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target="/root/.cache/go-build" \
|
||||
--mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
--mount=type=bind,source=".git/",target=".git/" \
|
||||
make
|
||||
make backend
|
||||
|
||||
COPY docker/root /tmp/local
|
||||
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build stage
|
||||
# Build frontend on the native platform to avoid QEMU-related issues with esbuild/webpack
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
|
||||
RUN apk --no-cache add build-base git nodejs pnpm
|
||||
WORKDIR /src
|
||||
COPY --exclude=.git/ . .
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store make frontend
|
||||
|
||||
# Build backend for each target platform
|
||||
FROM docker.io/library/golang:1.26-alpine3.23 AS build-env
|
||||
|
||||
ARG GOPROXY=direct
|
||||
@ -12,20 +19,18 @@ ARG CGO_EXTRA_CFLAGS
|
||||
# Build deps
|
||||
RUN apk --no-cache add \
|
||||
build-base \
|
||||
git \
|
||||
nodejs \
|
||||
pnpm
|
||||
git
|
||||
|
||||
WORKDIR ${GOPATH}/src/code.gitea.io/gitea
|
||||
# See the comments in Dockerfile
|
||||
COPY --exclude=.git/ . .
|
||||
COPY --from=frontend-build /src/public/assets public/assets
|
||||
|
||||
# Build gitea, .git mount is required for version data
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target="/root/.cache/go-build" \
|
||||
--mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
--mount=type=bind,source=".git/",target=".git/" \
|
||||
make
|
||||
make backend
|
||||
|
||||
COPY docker/rootless /tmp/local
|
||||
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
println("usage: backport-locales <to-ref>")
|
||||
println("eg: backport-locales release/v1.19")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mustNoErr := func(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
collectInis := func(ref string) map[string]setting.ConfigProvider {
|
||||
inis := map[string]setting.ConfigProvider{}
|
||||
err := filepath.WalkDir("options/locale", func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".ini") {
|
||||
return nil
|
||||
}
|
||||
cfg, err := setting.NewConfigProviderForLocale(path)
|
||||
mustNoErr(err)
|
||||
inis[path] = cfg
|
||||
fmt.Printf("collecting: %s @ %s\n", path, ref)
|
||||
return nil
|
||||
})
|
||||
mustNoErr(err)
|
||||
return inis
|
||||
}
|
||||
|
||||
// collect new locales from current working directory
|
||||
inisNew := collectInis("HEAD")
|
||||
|
||||
// switch to the target ref, and collect the old locales
|
||||
cmd := exec.Command("git", "checkout", os.Args[1])
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
mustNoErr(cmd.Run())
|
||||
inisOld := collectInis(os.Args[1])
|
||||
|
||||
// use old en-US as the base, and copy the new translations to the old locales
|
||||
enUsOld := inisOld["options/locale/locale_en-US.ini"]
|
||||
brokenWarned := make(container.Set[string])
|
||||
for path, iniOld := range inisOld {
|
||||
if iniOld == enUsOld {
|
||||
continue
|
||||
}
|
||||
iniNew := inisNew[path]
|
||||
if iniNew == nil {
|
||||
continue
|
||||
}
|
||||
for _, secEnUS := range enUsOld.Sections() {
|
||||
secOld := iniOld.Section(secEnUS.Name())
|
||||
secNew := iniNew.Section(secEnUS.Name())
|
||||
for _, keyEnUs := range secEnUS.Keys() {
|
||||
if secNew.HasKey(keyEnUs.Name()) {
|
||||
oldStr := secOld.Key(keyEnUs.Name()).String()
|
||||
newStr := secNew.Key(keyEnUs.Name()).String()
|
||||
broken := oldStr != "" && strings.Count(oldStr, "%") != strings.Count(newStr, "%")
|
||||
broken = broken || strings.Contains(oldStr, "\n") || strings.Contains(oldStr, "\n")
|
||||
if broken {
|
||||
brokenWarned.Add(secOld.Name() + "." + keyEnUs.Name())
|
||||
fmt.Println("----")
|
||||
fmt.Printf("WARNING: skip broken locale: %s , [%s] %s\n", path, secEnUS.Name(), keyEnUs.Name())
|
||||
fmt.Printf("\told: %s\n", strings.ReplaceAll(oldStr, "\n", "\\n"))
|
||||
fmt.Printf("\tnew: %s\n", strings.ReplaceAll(newStr, "\n", "\\n"))
|
||||
continue
|
||||
}
|
||||
secOld.Key(keyEnUs.Name()).SetValue(newStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
mustNoErr(iniOld.SaveTo(path))
|
||||
}
|
||||
|
||||
fmt.Println("========")
|
||||
|
||||
for path, iniNew := range inisNew {
|
||||
for _, sec := range iniNew.Sections() {
|
||||
for _, key := range sec.Keys() {
|
||||
str := sec.Key(key.Name()).String()
|
||||
broken := strings.Contains(str, "\n")
|
||||
broken = broken || strings.HasPrefix(str, "`") != strings.HasSuffix(str, "`")
|
||||
broken = broken || strings.HasPrefix(str, "\"`")
|
||||
broken = broken || strings.HasPrefix(str, "`\"")
|
||||
broken = broken || strings.Count(str, `"`)%2 == 1
|
||||
broken = broken || strings.Count(str, "`")%2 == 1
|
||||
if broken && !brokenWarned.Contains(sec.Name()+"."+key.Name()) {
|
||||
fmt.Printf("WARNING: found broken locale: %s , [%s] %s\n", path, sec.Name(), key.Name())
|
||||
fmt.Printf("\tstr: %s\n", strings.ReplaceAll(str, "\n", "\\n"))
|
||||
fmt.Println("----")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -211,7 +211,7 @@ export default defineConfig([
|
||||
'@typescript-eslint/no-non-null-asserted-nullish-coalescing': [0],
|
||||
'@typescript-eslint/no-non-null-asserted-optional-chain': [2],
|
||||
'@typescript-eslint/no-non-null-assertion': [0],
|
||||
'@typescript-eslint/no-redeclare': [0],
|
||||
'@typescript-eslint/no-redeclare': [2],
|
||||
'@typescript-eslint/no-redundant-type-constituents': [2],
|
||||
'@typescript-eslint/no-require-imports': [2],
|
||||
'@typescript-eslint/no-restricted-imports': [0],
|
||||
@ -437,7 +437,7 @@ export default defineConfig([
|
||||
'no-import-assign': [2],
|
||||
'no-inline-comments': [0],
|
||||
'no-inner-declarations': [2],
|
||||
'no-invalid-regexp': [2],
|
||||
'no-invalid-regexp': [0], // handled by regexp/no-invalid-regexp
|
||||
'no-invalid-this': [0],
|
||||
'no-irregular-whitespace': [2],
|
||||
'no-iterator': [2],
|
||||
@ -551,7 +551,7 @@ export default defineConfig([
|
||||
'no-new-func': [0], // handled by @typescript-eslint/no-implied-eval
|
||||
'no-new-native-nonconstructor': [2],
|
||||
'no-new-object': [2],
|
||||
'no-new-symbol': [2],
|
||||
'no-new-symbol': [0], // handled by no-new-native-nonconstructor
|
||||
'no-new-wrappers': [2],
|
||||
'no-new': [0],
|
||||
'no-nonoctal-decimal-escape': [2],
|
||||
@ -582,7 +582,7 @@ export default defineConfig([
|
||||
'no-template-curly-in-string': [2],
|
||||
'no-ternary': [0],
|
||||
'no-this-before-super': [2],
|
||||
'no-throw-literal': [2],
|
||||
'no-throw-literal': [0], // handled by @typescript-eslint/only-throw-error
|
||||
'no-undef-init': [2],
|
||||
'no-undef': [2], // it is still needed by eslint & IDE to prompt undefined names in real time
|
||||
'no-undefined': [0],
|
||||
@ -600,7 +600,7 @@ export default defineConfig([
|
||||
'no-unused-vars': [0], // handled by @typescript-eslint/no-unused-vars
|
||||
'no-use-before-define': [0], // handled by @typescript-eslint/no-use-before-define
|
||||
'no-useless-assignment': [2],
|
||||
'no-useless-backreference': [2],
|
||||
'no-useless-backreference': [0], // handled by regexp/no-useless-backreference
|
||||
'no-useless-call': [2],
|
||||
'no-useless-catch': [2],
|
||||
'no-useless-computed-key': [2],
|
||||
@ -608,7 +608,7 @@ export default defineConfig([
|
||||
'no-useless-constructor': [2],
|
||||
'no-useless-escape': [2],
|
||||
'no-useless-rename': [2],
|
||||
'no-useless-return': [2],
|
||||
'no-useless-return': [0], // handled by sonarjs/no-redundant-jump
|
||||
'no-var': [2],
|
||||
'no-void': [2],
|
||||
'no-warning-comments': [0],
|
||||
@ -617,7 +617,7 @@ export default defineConfig([
|
||||
'one-var-declaration-per-line': [0],
|
||||
'one-var': [0],
|
||||
'operator-assignment': [2, 'always'],
|
||||
'operator-linebreak': [2, 'after'],
|
||||
'operator-linebreak': [0], // handled by @stylistic/operator-linebreak
|
||||
'prefer-arrow-callback': [2, {allowNamedFunctions: true, allowUnboundThis: true}],
|
||||
'prefer-const': [2, {destructuring: 'all', ignoreReadBeforeAssign: true}],
|
||||
'prefer-destructuring': [0],
|
||||
@ -697,7 +697,7 @@ export default defineConfig([
|
||||
'regexp/prefer-question-quantifier': [2],
|
||||
'regexp/prefer-range': [2],
|
||||
'regexp/prefer-regexp-exec': [2],
|
||||
'regexp/prefer-regexp-test': [2],
|
||||
'regexp/prefer-regexp-test': [0], // handled by unicorn/prefer-regexp-test
|
||||
'regexp/prefer-result-array-groups': [0],
|
||||
'regexp/prefer-set-operation': [2],
|
||||
'regexp/prefer-star-quantifier': [2],
|
||||
@ -727,7 +727,7 @@ export default defineConfig([
|
||||
'sonarjs/no-empty-collection': [2],
|
||||
'sonarjs/no-extra-arguments': [2],
|
||||
'sonarjs/no-gratuitous-expressions': [2],
|
||||
'sonarjs/no-identical-conditions': [2],
|
||||
'sonarjs/no-identical-conditions': [0], // handled by no-dupe-else-if
|
||||
'sonarjs/no-identical-expressions': [2],
|
||||
'sonarjs/no-identical-functions': [2, 5],
|
||||
'sonarjs/no-ignored-return': [2],
|
||||
@ -740,7 +740,7 @@ export default defineConfig([
|
||||
'sonarjs/no-small-switch': [0],
|
||||
'sonarjs/no-unused-collection': [2],
|
||||
'sonarjs/no-use-of-empty-return-value': [2],
|
||||
'sonarjs/no-useless-catch': [2],
|
||||
'sonarjs/no-useless-catch': [0], // handled by no-useless-catch
|
||||
'sonarjs/non-existent-operator': [2],
|
||||
'sonarjs/prefer-immediate-return': [0],
|
||||
'sonarjs/prefer-object-literal': [0],
|
||||
@ -767,6 +767,7 @@ export default defineConfig([
|
||||
'unicorn/filename-case': [0],
|
||||
'unicorn/import-index': [0],
|
||||
'unicorn/import-style': [0],
|
||||
'unicorn/isolated-functions': [2, {functions: []}],
|
||||
'unicorn/new-for-builtins': [2],
|
||||
'unicorn/no-abusive-eslint-disable': [0],
|
||||
'unicorn/no-anonymous-default-export': [0],
|
||||
@ -806,7 +807,7 @@ export default defineConfig([
|
||||
'unicorn/no-unnecessary-await': [2],
|
||||
'unicorn/no-unnecessary-polyfills': [2],
|
||||
'unicorn/no-unreadable-array-destructuring': [0],
|
||||
'unicorn/no-unreadable-iife': [2],
|
||||
'unicorn/no-unreadable-iife': [0],
|
||||
'unicorn/no-unused-properties': [2],
|
||||
'unicorn/no-useless-collection-argument': [2],
|
||||
'unicorn/no-useless-fallback-in-spread': [2],
|
||||
@ -819,7 +820,7 @@ export default defineConfig([
|
||||
'unicorn/number-literal-case': [0],
|
||||
'unicorn/numeric-separators-style': [0],
|
||||
'unicorn/prefer-add-event-listener': [2],
|
||||
'unicorn/prefer-array-find': [2],
|
||||
'unicorn/prefer-array-find': [0], // handled by @typescript-eslint/prefer-find
|
||||
'unicorn/prefer-array-flat': [2],
|
||||
'unicorn/prefer-array-flat-map': [2],
|
||||
'unicorn/prefer-array-index-of': [2],
|
||||
@ -836,7 +837,7 @@ export default defineConfig([
|
||||
'unicorn/prefer-event-target': [2],
|
||||
'unicorn/prefer-export-from': [0],
|
||||
'unicorn/prefer-global-this': [0],
|
||||
'unicorn/prefer-includes': [2],
|
||||
'unicorn/prefer-includes': [0], // handled by @typescript-eslint/prefer-includes
|
||||
'unicorn/prefer-json-parse-buffer': [0],
|
||||
'unicorn/prefer-keyboard-event-key': [2],
|
||||
'unicorn/prefer-logical-operator-over-ternary': [2],
|
||||
@ -863,7 +864,7 @@ export default defineConfig([
|
||||
'unicorn/prefer-string-raw': [0],
|
||||
'unicorn/prefer-string-replace-all': [0],
|
||||
'unicorn/prefer-string-slice': [0],
|
||||
'unicorn/prefer-string-starts-ends-with': [2],
|
||||
'unicorn/prefer-string-starts-ends-with': [0], // handled by @typescript-eslint/prefer-string-starts-ends-with
|
||||
'unicorn/prefer-string-trim-start-end': [2],
|
||||
'unicorn/prefer-structured-clone': [2],
|
||||
'unicorn/prefer-switch': [0],
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
@ -20,6 +21,7 @@ import (
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"github.com/nektos/act/pkg/jobparser"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@ -214,6 +216,20 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro
|
||||
return nil, errNotExist
|
||||
}
|
||||
|
||||
func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
||||
if step.Name != "" {
|
||||
name = step.Name // the step has an explicit name
|
||||
} else {
|
||||
// for unnamed step, its "String()" method tries to get a display name by its "name", "uses",
|
||||
// "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display
|
||||
// for multi-line "run" scripts, only use the first line to match GitHub's behavior
|
||||
// https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58
|
||||
runStr, _, _ := strings.Cut(strings.TrimSpace(step.Run), "\n")
|
||||
name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String())
|
||||
}
|
||||
return util.EllipsisDisplayString(name, limit) // database column has a length limit
|
||||
}
|
||||
|
||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
@ -293,9 +309,8 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
if len(workflowJob.Steps) > 0 {
|
||||
steps := make([]*ActionTaskStep, len(workflowJob.Steps))
|
||||
for i, v := range workflowJob.Steps {
|
||||
name := util.EllipsisDisplayString(v.String(), 255)
|
||||
steps[i] = &ActionTaskStep{
|
||||
Name: name,
|
||||
Name: makeTaskStepDisplayName(v, 255),
|
||||
TaskID: task.ID,
|
||||
Index: int64(i),
|
||||
RepoID: task.RepoID,
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
// ActionTaskStep represents a step of ActionTask
|
||||
type ActionTaskStep struct {
|
||||
ID int64
|
||||
Name string `xorm:"VARCHAR(255)"`
|
||||
Name string `xorm:"VARCHAR(255)"` // the step name, for display purpose only, it will be truncated if it is too long
|
||||
TaskID int64 `xorm:"index unique(task_index)"`
|
||||
Index int64 `xorm:"index unique(task_index)"`
|
||||
RepoID int64 `xorm:"index"`
|
||||
|
||||
76
models/actions/task_test.go
Normal file
76
models/actions/task_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nektos/act/pkg/jobparser"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jobStep *jobparser.Step
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "explicit name",
|
||||
jobStep: &jobparser.Step{
|
||||
Name: "Test Step",
|
||||
},
|
||||
expected: "Test Step",
|
||||
},
|
||||
{
|
||||
name: "uses step",
|
||||
jobStep: &jobparser.Step{
|
||||
Uses: "actions/checkout@v4",
|
||||
},
|
||||
expected: "Run actions/checkout@v4",
|
||||
},
|
||||
{
|
||||
name: "single-line run",
|
||||
jobStep: &jobparser.Step{
|
||||
Run: "echo hello",
|
||||
},
|
||||
expected: "Run echo hello",
|
||||
},
|
||||
{
|
||||
name: "multi-line run block scalar",
|
||||
jobStep: &jobparser.Step{
|
||||
Run: "\n echo hello \r\n echo world \n ",
|
||||
},
|
||||
expected: "Run echo hello",
|
||||
},
|
||||
{
|
||||
name: "fallback to id",
|
||||
jobStep: &jobparser.Step{
|
||||
ID: "step-id",
|
||||
},
|
||||
expected: "Run step-id",
|
||||
},
|
||||
{
|
||||
name: "very long name truncated",
|
||||
jobStep: &jobparser.Step{
|
||||
Name: strings.Repeat("a", 300),
|
||||
},
|
||||
expected: strings.Repeat("a", 252) + "…",
|
||||
},
|
||||
{
|
||||
name: "very long run truncated",
|
||||
jobStep: &jobparser.Step{
|
||||
Run: strings.Repeat("a", 300),
|
||||
},
|
||||
expected: "Run " + strings.Repeat("a", 248) + "…",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := makeTaskStepDisplayName(tt.jobStep, 255)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -19,14 +19,14 @@ type UserHeatmapData struct {
|
||||
Contributions int64 `json:"contributions"`
|
||||
}
|
||||
|
||||
// GetUserHeatmapDataByUser returns an array of UserHeatmapData
|
||||
// GetUserHeatmapDataByUser returns an array of UserHeatmapData, it checks whether doer can access user's activity
|
||||
func GetUserHeatmapDataByUser(ctx context.Context, user, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||
return getUserHeatmapData(ctx, user, nil, doer)
|
||||
}
|
||||
|
||||
// GetUserHeatmapDataByUserTeam returns an array of UserHeatmapData
|
||||
func GetUserHeatmapDataByUserTeam(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||
return getUserHeatmapData(ctx, user, team, doer)
|
||||
// GetUserHeatmapDataByOrgTeam returns an array of UserHeatmapData, it checks whether doer can access org's activity
|
||||
func GetUserHeatmapDataByOrgTeam(ctx context.Context, org *organization.Organization, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||
return getUserHeatmapData(ctx, org.AsUser(), team, doer)
|
||||
}
|
||||
|
||||
func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) {
|
||||
@ -71,12 +71,3 @@ func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organi
|
||||
OrderBy("timestamp").
|
||||
Find(&hdata)
|
||||
}
|
||||
|
||||
// GetTotalContributionsInHeatmap returns the total number of contributions in a heatmap
|
||||
func GetTotalContributionsInHeatmap(hdata []*UserHeatmapData) int64 {
|
||||
var total int64
|
||||
for _, v := range hdata {
|
||||
total += v.Contributions
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@ -139,26 +139,7 @@
|
||||
updated: 1683636626
|
||||
need_approval: 0
|
||||
approved_by: 0
|
||||
-
|
||||
id: 804
|
||||
title: "use a private action"
|
||||
repo_id: 60
|
||||
owner_id: 40
|
||||
workflow_id: "run.yaml"
|
||||
index: 189
|
||||
trigger_user_id: 40
|
||||
ref: "refs/heads/master"
|
||||
commit_sha: "6e64b26de7ba966d01d90ecfaf5c7f14ef203e86"
|
||||
event: "push"
|
||||
trigger_event: "push"
|
||||
is_fork_pull_request: 0
|
||||
status: 1
|
||||
started: 1683636528
|
||||
stopped: 1683636626
|
||||
created: 1683636108
|
||||
updated: 1683636626
|
||||
need_approval: 0
|
||||
approved_by: 0
|
||||
|
||||
-
|
||||
id: 805
|
||||
title: "update actions"
|
||||
|
||||
@ -129,20 +129,7 @@
|
||||
status: 5
|
||||
started: 1683636528
|
||||
stopped: 1683636626
|
||||
-
|
||||
id: 205
|
||||
run_id: 804
|
||||
repo_id: 6
|
||||
owner_id: 10
|
||||
commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86
|
||||
is_fork_pull_request: 0
|
||||
name: job_2
|
||||
attempt: 1
|
||||
job_id: job_2
|
||||
task_id: 48
|
||||
status: 1
|
||||
started: 1683636528
|
||||
stopped: 1683636626
|
||||
|
||||
-
|
||||
id: 206
|
||||
run_id: 805
|
||||
|
||||
@ -177,26 +177,7 @@
|
||||
log_length: 0
|
||||
log_size: 0
|
||||
log_expired: 0
|
||||
-
|
||||
id: 55
|
||||
job_id: 205
|
||||
attempt: 1
|
||||
runner_id: 1
|
||||
status: 6 # 6 is the status code for "running"
|
||||
started: 1683636528
|
||||
stopped: 1683636626
|
||||
repo_id: 6
|
||||
owner_id: 10
|
||||
commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86
|
||||
is_fork_pull_request: 0
|
||||
token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc478422b
|
||||
token_salt: ERxJGHvg3I
|
||||
token_last_eight: 182199eb
|
||||
log_filename: collaborative-owner-test/1a/49.log
|
||||
log_in_storage: 1
|
||||
log_length: 707
|
||||
log_size: 90179
|
||||
log_expired: 0
|
||||
|
||||
-
|
||||
id: 56
|
||||
attempt: 1
|
||||
|
||||
@ -397,10 +397,16 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, from, to str
|
||||
|
||||
if protectedBranch != nil {
|
||||
// there is a protect rule for this branch
|
||||
protectedBranch.RuleName = to
|
||||
if _, err = sess.ID(protectedBranch.ID).Cols("branch_name").Update(protectedBranch); err != nil {
|
||||
existingRule, err := GetProtectedBranchRuleByName(ctx, repo.ID, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existingRule == nil || existingRule.ID == protectedBranch.ID {
|
||||
protectedBranch.RuleName = to
|
||||
if _, err = sess.ID(protectedBranch.ID).Cols("branch_name").Update(protectedBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// some glob protect rules may match this branch
|
||||
protected, err := IsBranchProtected(ctx, repo.ID, from)
|
||||
|
||||
@ -159,6 +159,53 @@ func TestRenameBranch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenameBranchProtectedRuleConflict(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
master := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "master"})
|
||||
|
||||
devBranch := &git_model.Branch{
|
||||
RepoID: repo1.ID,
|
||||
Name: "dev",
|
||||
CommitID: master.CommitID,
|
||||
CommitMessage: master.CommitMessage,
|
||||
CommitTime: master.CommitTime,
|
||||
PusherID: master.PusherID,
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), devBranch))
|
||||
|
||||
pbDev := git_model.ProtectedBranch{
|
||||
RepoID: repo1.ID,
|
||||
RuleName: "dev",
|
||||
CanPush: true,
|
||||
}
|
||||
assert.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, &pbDev, git_model.WhitelistOptions{}))
|
||||
|
||||
pbMain := git_model.ProtectedBranch{
|
||||
RepoID: repo1.ID,
|
||||
RuleName: "main",
|
||||
CanPush: true,
|
||||
}
|
||||
assert.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, &pbMain, git_model.WhitelistOptions{}))
|
||||
|
||||
assert.NoError(t, git_model.RenameBranch(t.Context(), repo1, "dev", "main", func(ctx context.Context, isDefault bool) error {
|
||||
return nil
|
||||
}))
|
||||
|
||||
unittest.AssertNotExistsBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "dev"})
|
||||
unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "main"})
|
||||
|
||||
protectedDev, err := git_model.GetProtectedBranchRuleByName(t.Context(), repo1.ID, "dev")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, protectedDev)
|
||||
assert.Equal(t, "dev", protectedDev.RuleName)
|
||||
|
||||
protectedMainByID, err := git_model.GetProtectedBranchRuleByID(t.Context(), repo1.ID, pbMain.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, protectedMainByID)
|
||||
assert.Equal(t, "main", protectedMainByID.RuleName)
|
||||
}
|
||||
|
||||
func TestOnlyGetDeletedBranchOnCorrectRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
||||
@ -37,6 +37,10 @@ type CommitSignature struct {
|
||||
|
||||
// Message returns the commit message. Same as retrieving CommitMessage directly.
|
||||
func (c *Commit) Message() string {
|
||||
// FIXME: GIT-COMMIT-MESSAGE-ENCODING: this logic is not right
|
||||
// * When need to use commit message in templates/database, it should be valid UTF-8
|
||||
// * When need to get the original commit message, it should just use "c.CommitMessage"
|
||||
// It's not easy to refactor at the moment, many templates need to be updated and tested
|
||||
return c.CommitMessage
|
||||
}
|
||||
|
||||
|
||||
38
package.json
38
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.29.2",
|
||||
"packageManager": "pnpm@10.30.0",
|
||||
"engines": {
|
||||
"node": ">= 22.6.0",
|
||||
"pnpm": ">= 10.0.0"
|
||||
@ -16,20 +16,20 @@
|
||||
"@github/text-expander-element": "2.9.4",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@primer/octicons": "19.21.2",
|
||||
"@primer/octicons": "19.22.0",
|
||||
"@resvg/resvg-wasm": "2.6.2",
|
||||
"@silverwind/vue3-calendar-heatmap": "2.1.1",
|
||||
"@techknowlogick/license-checker-webpack-plugin": "0.3.0",
|
||||
"add-asset-webpack-plugin": "3.1.1",
|
||||
"ansi_up": "6.0.6",
|
||||
"asciinema-player": "3.14.0",
|
||||
"asciinema-player": "3.14.15",
|
||||
"chart.js": "4.5.1",
|
||||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"clippie": "4.1.10",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "1.6.2",
|
||||
"css-loader": "7.1.3",
|
||||
"css-loader": "7.1.4",
|
||||
"dayjs": "1.11.19",
|
||||
"dropzone": "6.0.0-beta.2",
|
||||
"easymde": "2.20.0",
|
||||
@ -39,7 +39,7 @@
|
||||
"jquery": "4.0.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"katex": "0.16.28",
|
||||
"mermaid": "11.12.2",
|
||||
"mermaid": "11.12.3",
|
||||
"mini-css-extract-plugin": "2.10.0",
|
||||
"monaco-editor": "0.55.1",
|
||||
"monaco-editor-webpack-plugin": "7.1.1",
|
||||
@ -47,9 +47,9 @@
|
||||
"pdfobject": "2.3.1",
|
||||
"perfect-debounce": "2.1.0",
|
||||
"postcss": "8.5.6",
|
||||
"postcss-loader": "8.2.0",
|
||||
"sortablejs": "1.15.6",
|
||||
"swagger-ui-dist": "5.31.0",
|
||||
"postcss-loader": "8.2.1",
|
||||
"sortablejs": "1.15.7",
|
||||
"swagger-ui-dist": "5.31.1",
|
||||
"tailwindcss": "3.4.17",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
@ -62,7 +62,7 @@
|
||||
"vue-bar-graph": "2.2.0",
|
||||
"vue-chartjs": "5.3.3",
|
||||
"vue-loader": "17.4.2",
|
||||
"webpack": "5.105.0",
|
||||
"webpack": "5.105.2",
|
||||
"webpack-cli": "6.0.1",
|
||||
"wrap-ansi": "9.0.2"
|
||||
},
|
||||
@ -83,9 +83,9 @@
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/toastify-js": "1.12.4",
|
||||
"@typescript-eslint/parser": "8.55.0",
|
||||
"@typescript-eslint/parser": "8.56.0",
|
||||
"@vitejs/plugin-vue": "6.0.4",
|
||||
"@vitest/eslint-plugin": "1.6.7",
|
||||
"@vitest/eslint-plugin": "1.6.9",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-array-func": "5.1.0",
|
||||
@ -93,28 +93,28 @@
|
||||
"eslint-plugin-import-x": "4.16.1",
|
||||
"eslint-plugin-playwright": "2.5.1",
|
||||
"eslint-plugin-regexp": "3.0.0",
|
||||
"eslint-plugin-sonarjs": "3.0.6",
|
||||
"eslint-plugin-unicorn": "62.0.0",
|
||||
"eslint-plugin-vue": "10.7.0",
|
||||
"eslint-plugin-sonarjs": "3.0.7",
|
||||
"eslint-plugin-unicorn": "63.0.0",
|
||||
"eslint-plugin-vue": "10.8.0",
|
||||
"eslint-plugin-vue-scoped-css": "2.12.0",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"globals": "17.3.0",
|
||||
"happy-dom": "20.6.0",
|
||||
"happy-dom": "20.6.1",
|
||||
"jiti": "2.6.1",
|
||||
"markdownlint-cli": "0.47.0",
|
||||
"material-icon-theme": "5.31.0",
|
||||
"nolyfill": "1.0.44",
|
||||
"postcss-html": "1.8.1",
|
||||
"spectral-cli-bundle": "1.0.4",
|
||||
"stylelint": "17.1.1",
|
||||
"spectral-cli-bundle": "1.0.7",
|
||||
"stylelint": "17.3.0",
|
||||
"stylelint-config-recommended": "18.0.0",
|
||||
"stylelint-declaration-block-no-ignored-properties": "3.0.0",
|
||||
"stylelint-declaration-strict-value": "1.10.11",
|
||||
"stylelint-value-no-unknown-custom-properties": "6.1.1",
|
||||
"svgo": "4.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.55.0",
|
||||
"updates": "17.4.0",
|
||||
"typescript-eslint": "8.56.0",
|
||||
"updates": "17.5.7",
|
||||
"vite-string-plugin": "2.0.1",
|
||||
"vitest": "4.0.18",
|
||||
"vue-tsc": "3.2.4"
|
||||
|
||||
804
pnpm-lock.yaml
generated
804
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1
public/assets/img/svg/octicon-book-locked.svg
generated
Normal file
1
public/assets/img/svg/octicon-book-locked.svg
generated
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-book-locked" width="16" height="16" aria-hidden="true"><path d="M12 6a3 3 0 0 1 3 3v1.168c.591.281 1 .884 1 1.582v2.5A1.75 1.75 0 0 1 14.25 16h-4.5A1.75 1.75 0 0 1 8 14.25v-2.5c0-.698.409-1.301 1-1.582V9a3 3 0 0 1 3-3m0 1.5A1.5 1.5 0 0 0 10.5 9v1h3V9A1.5 1.5 0 0 0 12 7.5"/><path d="M5.003 1c1.227 0 2.317.59 3.001 1.501A3.75 3.75 0 0 1 11.005 1h4.245a.75.75 0 0 1 .75.75V5.5a.75.75 0 0 1-1.5 0v-3h-3.495c-1.21 0-2.204.956-2.255 2.153V6.5a.75.75 0 0 1-1.5 0V4.69A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.758c.612 0 1.208.15 1.74.429l.005.001a.75.75 0 0 1-.705 1.324l-.001-.001v.002A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75V1.75A.75.75 0 0 1 .75 1z"/></svg>
|
||||
|
After Width: | Height: | Size: 737 B |
1
public/assets/img/svg/octicon-comment-locked.svg
generated
Normal file
1
public/assets/img/svg/octicon-comment-locked.svg
generated
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-comment-locked" width="16" height="16" aria-hidden="true"><path d="M12 6a3 3 0 0 1 3 3v1.168c.591.281 1 .884 1 1.582v2.5A1.75 1.75 0 0 1 14.25 16h-4.5A1.75 1.75 0 0 1 8 14.25v-2.5c0-.698.409-1.301 1-1.582V9a3 3 0 0 1 3-3m0 1.5A1.5 1.5 0 0 0 10.5 9v1h3V9A1.5 1.5 0 0 0 12 7.5"/><path d="M10.25 1A1.75 1.75 0 0 1 12 2.75v1.5a.75.75 0 0 1-1.5 0v-1.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25v5.5a.25.25 0 0 0 .25.25h1c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.75.75 0 0 1 .53-.22h.35a.75.75 0 0 1 0 1.5h-.039l-2.574 2.573A1.457 1.457 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5A1.75 1.75 0 0 1 1.75 1zm4 2c.966 0 1.75.784 1.75 1.75v.75q0-.091-.006-.164A.75.75 0 0 1 14.5 5.25v-.5a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1-.53-.22.747.747 0 0 1 0-1.06.75.75 0 0 1 .53-.22z"/></svg>
|
||||
|
After Width: | Height: | Size: 878 B |
1
public/assets/img/svg/octicon-git-pull-request-locked.svg
generated
Normal file
1
public/assets/img/svg/octicon-git-pull-request-locked.svg
generated
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-git-pull-request-locked" width="16" height="16" aria-hidden="true"><path d="M12 6a3 3 0 0 1 3 3v1.169c.591.281 1 .883 1 1.581v2.5A1.75 1.75 0 0 1 14.25 16h-4.5A1.75 1.75 0 0 1 8 14.25v-2.5c0-.698.409-1.3 1-1.581V9a3 3 0 0 1 3-3m0 1.5A1.5 1.5 0 0 0 10.5 9v1h3V9A1.5 1.5 0 0 0 12 7.5M3.25 1A2.25 2.25 0 0 1 4 5.372v5.257a2.25 2.25 0 1 1-1.5 0V5.372A2.252 2.252 0 0 1 3.25 1m0 1.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5m0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5M10 .854a.25.25 0 0 0-.427-.177L7.177 3.073a.25.25 0 0 0 0 .355l2.396 2.395A.25.25 0 0 0 10 5.646z"/><path d="M11.997 2.708A2.5 2.5 0 0 0 11 2.5h-1V4h1c.5 0 .891 0 .956.597a.735.735 0 0 0 .746.674.75.75 0 0 0 .746-.674c0-.097 0-.147-.066-.356 0 0-.041-.122-.073-.198a2.2 2.2 0 0 0-.209-.393 2 2 0 0 0-.327-.412l-.039-.036a3 3 0 0 0-.127-.114q-.014-.014-.03-.026a2 2 0 0 0-.172-.129l-.035-.023a3 3 0 0 0-.156-.095l-.047-.025a3 3 0 0 0-.17-.082"/></svg>
|
||||
|
After Width: | Height: | Size: 987 B |
1
public/assets/img/svg/octicon-issue-locked.svg
generated
Normal file
1
public/assets/img/svg/octicon-issue-locked.svg
generated
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-issue-locked" width="16" height="16" aria-hidden="true"><path d="M12.001 6a3 3 0 0 1 3 3v1.168c.591.281 1 .884 1 1.582v2.5a1.75 1.75 0 0 1-1.75 1.75h-4.5a1.75 1.75 0 0 1-1.75-1.75v-2.5c0-.698.409-1.301 1-1.582V9a3 3 0 0 1 3-3m0 1.5a1.5 1.5 0 0 0-1.5 1.5v1h3V9a1.5 1.5 0 0 0-1.5-1.5"/><path d="M5.095.546a8 8 0 0 1 3.847-.49l.259.035a8 8 0 0 1 3.58 1.494l.207.16a8 8 0 0 1 2.148 2.639c.187.369-.005.807-.391.959s-.817-.04-1.013-.406a6.5 6.5 0 0 0-1.242-1.635l-.11-.105-.052-.046a6 6 0 0 0-.226-.193l-.049-.04-.042-.031a6 6 0 0 0-.249-.187l-.082-.057a6 6 0 0 0-.683-.411l-.028-.014a6.5 6.5 0 0 0-1.146-.458l-.039-.011a7 7 0 0 0-.376-.095l-.018-.005a6 6 0 0 0-.409-.075l-.003-.001h-.003l-.015-.002a8 8 0 0 0-.479-.051 6 6 0 0 0-.26-.015l-.155-.004L8 1.5q-.084.001-.168.004-.081 0-.162.004a6 6 0 0 0-.37.029l-.069.009q-.165.02-.325.047l-.079.014a7 7 0 0 0-.383.082q-.405.1-.788.249l-.016.005a6.6 6.6 0 0 0-1.096.553l-.083.053a7 7 0 0 0-.288.197l-.022.017a6 6 0 0 0-.609.509l-.064.061q-.123.119-.238.243l-.038.039a7 7 0 0 0-.254.296l-.015.019q-.017.021-.033.044a6 6 0 0 0-.188.249q-.028.037-.054.076a6.5 6.5 0 0 0-.89 1.854l-.014.048a7 7 0 0 0-.084.327l-.02.089a6.4 6.4 0 0 0-.145 1.159l-.003.129L1.5 8q.001.099.005.196l.003.102a7 7 0 0 0 .034.434q.022.184.052.366l.007.034c.148.84.456 1.625.893 2.321l.034.052q.087.135.18.266l.054.076q.115.157.239.306l.024.029q.113.133.232.259l.073.077q.095.098.195.193.043.043.088.084a7 7 0 0 0 .299.259q.093.074.187.145l.072.052.146.104a6.5 6.5 0 0 0 1.929.904c.399.112.68.492.615.901s-.45.691-.851.588a8 8 0 0 1-3.041-1.528l-.202-.169A8.01 8.01 0 0 1 .059 7.03l.036-.259a8 8 0 0 1 1.507-3.574l.161-.207A8 8 0 0 1 5.095.546"/><path d="M8.001 6.5c.259 0 .511.068.733.192A4 4 0 0 0 8.001 9v.5a1.503 1.503 0 0 1-1.5-1.5 1.503 1.503 0 0 1 1.5-1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@ -27,6 +27,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/common"
|
||||
@ -302,7 +303,7 @@ func ViewPost(ctx *context_module.Context) {
|
||||
resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
|
||||
resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json
|
||||
if task != nil {
|
||||
steps, logs, err := convertToViewModel(ctx, req.LogCursors, task)
|
||||
steps, logs, err := convertToViewModel(ctx, ctx.Locale, req.LogCursors, task)
|
||||
if err != nil {
|
||||
ctx.ServerError("convertToViewModel", err)
|
||||
return
|
||||
@ -314,7 +315,7 @@ func ViewPost(ctx *context_module.Context) {
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func convertToViewModel(ctx *context_module.Context, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
|
||||
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
|
||||
var viewJobs []*ViewJobStep
|
||||
var logs []*ViewStepLog
|
||||
|
||||
@ -344,7 +345,7 @@ func convertToViewModel(ctx *context_module.Context, cursors []LogCursor, task *
|
||||
Lines: []*ViewStepLogLine{
|
||||
{
|
||||
Index: 1,
|
||||
Message: ctx.Locale.TrString("actions.runs.expire_log_message"),
|
||||
Message: locale.TrString("actions.runs.expire_log_message"),
|
||||
// Timestamp doesn't mean anything when the log is expired.
|
||||
// Set it to the task's updated time since it's probably the time when the log has expired.
|
||||
Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second),
|
||||
|
||||
47
routers/web/repo/actions/view_test.go
Normal file
47
routers/web/repo/actions/view_test.go
Normal file
@ -0,0 +1,47 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertToViewModel(t *testing.T) {
|
||||
task := &actions_model.ActionTask{
|
||||
Status: actions_model.StatusSuccess,
|
||||
Steps: []*actions_model.ActionTaskStep{
|
||||
{Name: "Run step-name", Index: 0, Status: actions_model.StatusSuccess, LogLength: 1, Started: timeutil.TimeStamp(1), Stopped: timeutil.TimeStamp(5)},
|
||||
},
|
||||
Stopped: timeutil.TimeStamp(20),
|
||||
}
|
||||
|
||||
viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedViewJobs := []*ViewJobStep{
|
||||
{
|
||||
Summary: "Set up job",
|
||||
Duration: "0s",
|
||||
Status: "success",
|
||||
},
|
||||
{
|
||||
Summary: "Run step-name",
|
||||
Duration: "4s",
|
||||
Status: "success",
|
||||
},
|
||||
{
|
||||
Summary: "Complete job",
|
||||
Duration: "15s",
|
||||
Status: "success",
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedViewJobs, viewJobSteps)
|
||||
}
|
||||
@ -7,7 +7,6 @@ import (
|
||||
gocontext "context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -426,6 +425,36 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
|
||||
return compareInfo
|
||||
}
|
||||
|
||||
func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*git_model.SignCommitWithStatuses) (title, content string) {
|
||||
title = ci.HeadRef.ShortName()
|
||||
|
||||
if len(commits) > 0 {
|
||||
// the "commits" are from "ShowPrettyFormatLogToList", which is ordered from newest to oldest, here take the oldest one
|
||||
c := commits[len(commits)-1]
|
||||
title = strings.TrimSpace(c.UserCommit.Summary())
|
||||
}
|
||||
|
||||
if len(commits) == 1 {
|
||||
// FIXME: GIT-COMMIT-MESSAGE-ENCODING: try to convert the encoding for commit message explicitly, ideally it should be done by a git commit struct method
|
||||
c := commits[0]
|
||||
_, content, _ = strings.Cut(strings.TrimSpace(c.UserCommit.CommitMessage), "\n")
|
||||
content = strings.TrimSpace(content)
|
||||
content = string(charset.ToUTF8([]byte(content), charset.ConvertOpts{}))
|
||||
}
|
||||
|
||||
var titleTrailer string
|
||||
// TODO: 255 doesn't seem to be a good limit for title, just keep the old behavior
|
||||
title, titleTrailer = util.EllipsisDisplayStringX(title, 255)
|
||||
if titleTrailer != "" {
|
||||
if content != "" {
|
||||
content = titleTrailer + "\n\n" + content
|
||||
} else {
|
||||
content = titleTrailer + "\n"
|
||||
}
|
||||
}
|
||||
return title, content
|
||||
}
|
||||
|
||||
// PrepareCompareDiff renders compare diff page
|
||||
func PrepareCompareDiff(
|
||||
ctx *context.Context,
|
||||
@ -539,30 +568,7 @@ func PrepareCompareDiff(
|
||||
ctx.Data["Commits"] = commits
|
||||
ctx.Data["CommitCount"] = len(commits)
|
||||
|
||||
title := ci.HeadRef.ShortName()
|
||||
if len(commits) == 1 {
|
||||
c := commits[0]
|
||||
title = strings.TrimSpace(c.UserCommit.Summary())
|
||||
|
||||
body := strings.Split(strings.TrimSpace(c.UserCommit.Message()), "\n")
|
||||
if len(body) > 1 {
|
||||
ctx.Data["content"] = strings.Join(body[1:], "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(title) > 255 {
|
||||
var trailer string
|
||||
title, trailer = util.EllipsisDisplayStringX(title, 255)
|
||||
if len(trailer) > 0 {
|
||||
if ctx.Data["content"] != nil {
|
||||
ctx.Data["content"] = fmt.Sprintf("%s\n\n%s", trailer, ctx.Data["content"])
|
||||
} else {
|
||||
ctx.Data["content"] = trailer + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["title"] = title
|
||||
ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits)
|
||||
ctx.Data["Username"] = ci.HeadRepo.OwnerName
|
||||
ctx.Data["Reponame"] = ci.HeadRepo.Name
|
||||
|
||||
|
||||
@ -4,9 +4,16 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
asymkey_model "code.gitea.io/gitea/models/asymkey"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
git_service "code.gitea.io/gitea/services/git"
|
||||
"code.gitea.io/gitea/services/gitdiff"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -38,3 +45,47 @@ func TestAttachCommentsToLines(t *testing.T) {
|
||||
assert.Equal(t, int64(300), section.Lines[1].Comments[0].ID)
|
||||
assert.Equal(t, int64(301), section.Lines[1].Comments[1].ID)
|
||||
}
|
||||
|
||||
func TestNewPullRequestTitleContent(t *testing.T) {
|
||||
ci := &git_service.CompareInfo{HeadRef: "refs/heads/head-branch"}
|
||||
|
||||
mockCommit := func(msg string) *git_model.SignCommitWithStatuses {
|
||||
return &git_model.SignCommitWithStatuses{
|
||||
SignCommit: &asymkey_model.SignCommit{
|
||||
UserCommit: &user_model.UserCommit{
|
||||
Commit: &git.Commit{
|
||||
CommitMessage: msg,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
title, content := prepareNewPullRequestTitleContent(ci, nil)
|
||||
assert.Equal(t, "head-branch", title)
|
||||
assert.Empty(t, content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-only")})
|
||||
assert.Equal(t, "title-only", title)
|
||||
assert.Empty(t, content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-" + strings.Repeat("a", 255))})
|
||||
assert.Equal(t, "title-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…", title)
|
||||
assert.Equal(t, "…aaaaaaaaa\n", content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title\nbody")})
|
||||
assert.Equal(t, "title", title)
|
||||
assert.Equal(t, "body", content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("a\xf0\xf0\xf0\nb\xf0\xf0\xf0")})
|
||||
assert.Equal(t, "a?", title) // FIXME: GIT-COMMIT-MESSAGE-ENCODING: "title" doesn't use the same charset converting logic as "content"
|
||||
assert.Equal(t, "b"+string(utf8.RuneError)+string(utf8.RuneError), content)
|
||||
|
||||
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{
|
||||
// ordered from newest to oldest
|
||||
mockCommit("title2\nbody2"),
|
||||
mockCommit("title1\nbody1"),
|
||||
})
|
||||
assert.Equal(t, "title1", title)
|
||||
assert.Empty(t, content)
|
||||
}
|
||||
|
||||
66
routers/web/user/heatmap.go
Normal file
66
routers/web/user/heatmap.go
Normal file
@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func prepareHeatmapURL(ctx *context.Context) {
|
||||
ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap
|
||||
if !setting.Service.EnableUserHeatmap {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Org.Organization == nil {
|
||||
// for individual user
|
||||
ctx.Data["HeatmapURL"] = ctx.Doer.HomeLink() + "/-/heatmap"
|
||||
return
|
||||
}
|
||||
|
||||
// for org or team
|
||||
heatmapURL := ctx.Org.Organization.OrganisationLink() + "/dashboard/-/heatmap"
|
||||
if ctx.Org.Team != nil {
|
||||
heatmapURL += "/" + url.PathEscape(ctx.Org.Team.LowerName)
|
||||
}
|
||||
ctx.Data["HeatmapURL"] = heatmapURL
|
||||
}
|
||||
|
||||
func writeHeatmapJSON(ctx *context.Context, hdata []*activities_model.UserHeatmapData) {
|
||||
data := make([][2]int64, len(hdata))
|
||||
var total int64
|
||||
for i, v := range hdata {
|
||||
data[i] = [2]int64{int64(v.Timestamp), v.Contributions}
|
||||
total += v.Contributions
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"heatmapData": data,
|
||||
"totalContributions": total,
|
||||
})
|
||||
}
|
||||
|
||||
// DashboardHeatmap returns heatmap data as JSON, for the individual user, organization or team dashboard.
|
||||
func DashboardHeatmap(ctx *context.Context) {
|
||||
if !setting.Service.EnableUserHeatmap {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
var data []*activities_model.UserHeatmapData
|
||||
var err error
|
||||
if ctx.Org.Organization == nil {
|
||||
data, err = activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
|
||||
} else {
|
||||
data, err = activities_model.GetUserHeatmapDataByOrgTeam(ctx, ctx.Org.Organization, ctx.Org.Team, ctx.Doer)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserHeatmapData", err)
|
||||
return
|
||||
}
|
||||
writeHeatmapJSON(ctx, data)
|
||||
}
|
||||
@ -54,8 +54,8 @@ const (
|
||||
tplProfile templates.TplName = "user/profile"
|
||||
)
|
||||
|
||||
// getDashboardContextUser finds out which context user dashboard is being viewed as .
|
||||
func getDashboardContextUser(ctx *context.Context) *user_model.User {
|
||||
// prepareDashboardContextUserOrgTeams finds out which context user dashboard is being viewed as .
|
||||
func prepareDashboardContextUserOrgTeams(ctx *context.Context) *user_model.User {
|
||||
ctxUser := ctx.Doer
|
||||
orgName := ctx.PathParam("org")
|
||||
if len(orgName) > 0 {
|
||||
@ -76,7 +76,7 @@ func getDashboardContextUser(ctx *context.Context) *user_model.User {
|
||||
|
||||
// Dashboard render the dashboard page
|
||||
func Dashboard(ctx *context.Context) {
|
||||
ctxUser := getDashboardContextUser(ctx)
|
||||
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
@ -109,15 +109,7 @@ func Dashboard(ctx *context.Context) {
|
||||
"uid": uid,
|
||||
}
|
||||
|
||||
if setting.Service.EnableUserHeatmap {
|
||||
data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["HeatmapData"] = data
|
||||
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
|
||||
}
|
||||
prepareHeatmapURL(ctx)
|
||||
|
||||
feeds, count, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
|
||||
RequestedUser: ctxUser,
|
||||
@ -156,7 +148,7 @@ func Milestones(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("milestones")
|
||||
ctx.Data["PageIsMilestonesDashboard"] = true
|
||||
|
||||
ctxUser := getDashboardContextUser(ctx)
|
||||
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
@ -371,7 +363,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
|
||||
// Return with NotFound or ServerError if unsuccessful.
|
||||
// ----------------------------------------------------
|
||||
|
||||
ctxUser := getDashboardContextUser(ctx)
|
||||
ctxUser := prepareDashboardContextUserOrgTeams(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@ -103,6 +103,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
||||
repos []*repo_model.Repository
|
||||
count int64
|
||||
total int
|
||||
curRows int
|
||||
orderBy db.SearchOrderBy
|
||||
)
|
||||
|
||||
@ -161,21 +162,15 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
||||
ctx.Data["Cards"] = following
|
||||
total = int(numFollowing)
|
||||
case "activity":
|
||||
// prepare heatmap data
|
||||
if setting.Service.EnableUserHeatmap {
|
||||
data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserHeatmapDataByUser", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["HeatmapData"] = data
|
||||
ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
|
||||
if setting.Service.EnableUserHeatmap && activities_model.ActivityReadable(ctx.ContextUser, ctx.Doer) {
|
||||
ctx.Data["EnableHeatmap"] = true
|
||||
ctx.Data["HeatmapURL"] = ctx.ContextUser.HomeLink() + "/-/heatmap"
|
||||
}
|
||||
|
||||
date := ctx.FormString("date")
|
||||
pagingNum = setting.UI.FeedPagingNum
|
||||
showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
|
||||
items, count, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
||||
items, feedCount, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
|
||||
RequestedUser: ctx.ContextUser,
|
||||
Actor: ctx.Doer,
|
||||
IncludePrivate: showPrivate,
|
||||
@ -193,8 +188,8 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
||||
}
|
||||
ctx.Data["Feeds"] = items
|
||||
ctx.Data["Date"] = date
|
||||
|
||||
total = int(count)
|
||||
curRows = len(items)
|
||||
total = feedCount
|
||||
case "stars":
|
||||
ctx.Data["PageIsProfileStarList"] = true
|
||||
ctx.Data["ShowRepoOwnerOnList"] = true
|
||||
@ -316,6 +311,9 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
||||
}
|
||||
|
||||
pager := context.NewPagination(total, pagingNum, page, 5)
|
||||
if tab == "activity" {
|
||||
pager.WithCurRows(curRows)
|
||||
}
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
}
|
||||
|
||||
@ -888,6 +888,8 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Group("/{org}", func() {
|
||||
m.Get("/dashboard", user.Dashboard)
|
||||
m.Get("/dashboard/{team}", user.Dashboard)
|
||||
m.Get("/dashboard/-/heatmap", user.DashboardHeatmap)
|
||||
m.Get("/dashboard/-/heatmap/{team}", user.DashboardHeatmap)
|
||||
m.Get("/issues", user.Issues)
|
||||
m.Get("/issues/{team}", user.Issues)
|
||||
m.Get("/pulls", user.Pulls)
|
||||
@ -1024,6 +1026,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
}
|
||||
|
||||
m.Get("/repositories", org.Repositories)
|
||||
m.Get("/heatmap", user.DashboardHeatmap)
|
||||
|
||||
m.Group("/projects", func() {
|
||||
m.Group("", func() {
|
||||
|
||||
@ -16,10 +16,14 @@ import (
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
type themeCollection struct {
|
||||
themeList []*ThemeMetaInfo
|
||||
themeMap map[string]*ThemeMetaInfo
|
||||
}
|
||||
|
||||
var (
|
||||
availableThemes []*ThemeMetaInfo
|
||||
availableThemeMap map[string]*ThemeMetaInfo
|
||||
themeOnce sync.Once
|
||||
themeMu sync.RWMutex
|
||||
availableThemes *themeCollection
|
||||
)
|
||||
|
||||
const (
|
||||
@ -129,23 +133,13 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
|
||||
return themeInfo
|
||||
}
|
||||
|
||||
func initThemes() {
|
||||
availableThemes = nil
|
||||
defer func() {
|
||||
availableThemeMap = map[string]*ThemeMetaInfo{}
|
||||
for _, theme := range availableThemes {
|
||||
availableThemeMap[theme.InternalName] = theme
|
||||
}
|
||||
if availableThemeMap[setting.UI.DefaultTheme] == nil {
|
||||
setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
|
||||
}
|
||||
}()
|
||||
func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
|
||||
cssFiles, err := public.AssetFS().ListFiles("assets/css")
|
||||
if err != nil {
|
||||
log.Error("Failed to list themes: %v", err)
|
||||
availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)}
|
||||
return
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var foundThemes []*ThemeMetaInfo
|
||||
for _, fileName := range cssFiles {
|
||||
if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) {
|
||||
@ -157,39 +151,84 @@ func initThemes() {
|
||||
foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
|
||||
}
|
||||
}
|
||||
|
||||
themeList = foundThemes
|
||||
if len(setting.UI.Themes) > 0 {
|
||||
themeList = nil // only allow the themes specified in the setting
|
||||
allowedThemes := container.SetOf(setting.UI.Themes...)
|
||||
for _, theme := range foundThemes {
|
||||
if allowedThemes.Contains(theme.InternalName) {
|
||||
availableThemes = append(availableThemes, theme)
|
||||
themeList = append(themeList, theme)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
availableThemes = foundThemes
|
||||
}
|
||||
sort.Slice(availableThemes, func(i, j int) bool {
|
||||
if availableThemes[i].InternalName == setting.UI.DefaultTheme {
|
||||
|
||||
sort.Slice(themeList, func(i, j int) bool {
|
||||
if themeList[i].InternalName == setting.UI.DefaultTheme {
|
||||
return true
|
||||
}
|
||||
if availableThemes[i].ColorblindType != availableThemes[j].ColorblindType {
|
||||
return availableThemes[i].ColorblindType < availableThemes[j].ColorblindType
|
||||
if themeList[i].ColorblindType != themeList[j].ColorblindType {
|
||||
return themeList[i].ColorblindType < themeList[j].ColorblindType
|
||||
}
|
||||
return availableThemes[i].DisplayName < availableThemes[j].DisplayName
|
||||
return themeList[i].DisplayName < themeList[j].DisplayName
|
||||
})
|
||||
if len(availableThemes) == 0 {
|
||||
setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme")
|
||||
availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)}
|
||||
|
||||
themeMap = map[string]*ThemeMetaInfo{}
|
||||
for _, theme := range themeList {
|
||||
themeMap[theme.InternalName] = theme
|
||||
}
|
||||
return themeList, themeMap
|
||||
}
|
||||
|
||||
func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
|
||||
themeMu.RLock()
|
||||
if availableThemes != nil {
|
||||
themeList, themeMap = availableThemes.themeList, availableThemes.themeMap
|
||||
}
|
||||
themeMu.RUnlock()
|
||||
if len(themeList) != 0 {
|
||||
return themeList, themeMap
|
||||
}
|
||||
|
||||
themeMu.Lock()
|
||||
defer themeMu.Unlock()
|
||||
// no need to double-check "availableThemes.themeList" since the loading isn't really slow, to keep code simple
|
||||
themeList, themeMap = loadThemesFromAssets()
|
||||
hasAvailableThemes := len(themeList) > 0
|
||||
if !hasAvailableThemes {
|
||||
defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)
|
||||
themeList = []*ThemeMetaInfo{defaultTheme}
|
||||
themeMap = map[string]*ThemeMetaInfo{setting.UI.DefaultTheme: defaultTheme}
|
||||
}
|
||||
|
||||
if setting.IsProd {
|
||||
if !hasAvailableThemes {
|
||||
setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme")
|
||||
}
|
||||
if themeMap[setting.UI.DefaultTheme] == nil {
|
||||
setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
|
||||
}
|
||||
availableThemes = &themeCollection{themeList, themeMap}
|
||||
return themeList, themeMap
|
||||
}
|
||||
|
||||
// In dev mode, only store the loaded themes if the list is not empty, in case the frontend is still being built.
|
||||
// TBH, there still could be a data-race that the themes are only partially built then the list is incomplete for first time loading.
|
||||
// Such edge case can be handled by checking whether the loaded themes are the same in a period or there is a flag file, but it is an over-kill, so, no.
|
||||
if hasAvailableThemes {
|
||||
availableThemes = &themeCollection{themeList, themeMap}
|
||||
}
|
||||
return themeList, themeMap
|
||||
}
|
||||
|
||||
func GetAvailableThemes() []*ThemeMetaInfo {
|
||||
themeOnce.Do(initThemes)
|
||||
return availableThemes
|
||||
themes, _ := getAvailableThemes()
|
||||
return themes
|
||||
}
|
||||
|
||||
func GetThemeMetaInfo(internalName string) *ThemeMetaInfo {
|
||||
themeOnce.Do(initThemes)
|
||||
return availableThemeMap[internalName]
|
||||
_, themeMap := getAvailableThemes()
|
||||
return themeMap[internalName]
|
||||
}
|
||||
|
||||
// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo,
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<div class=" tw-mr-4 not-mobile">{{ctx.AvatarUtils.Avatar .SignedUser 40}}</div>
|
||||
<div class="ui segment content tw-my-0 avatar-content-left-arrow">
|
||||
<div class="field">
|
||||
<input name="title" data-global-init="initInputAutoFocusEnd" id="issue_title" required maxlength="255" autocomplete="off"
|
||||
<input name="title" data-global-init="autoFocusEnd" id="issue_title" required maxlength="255" autocomplete="off"
|
||||
placeholder="{{ctx.Locale.Tr "repo.milestones.title"}}"
|
||||
value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}"
|
||||
>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{{if .HeatmapData}}
|
||||
{{if .EnableHeatmap}}
|
||||
<div class="activity-heatmap-container">
|
||||
<div id="user-heatmap" class="is-loading"
|
||||
data-heatmap-data="{{JsonUtils.EncodeToString .HeatmapData}}"
|
||||
data-locale-total-contributions="{{ctx.Locale.Tr "heatmap.number_of_contributions_in_the_last_12_months" (ctx.Locale.PrettyNumber .HeatmapTotalContributions)}}"
|
||||
data-heatmap-url="{{.HeatmapURL}}"
|
||||
data-locale-total-contributions="{{ctx.Locale.Tr "heatmap.number_of_contributions_in_the_last_12_months" "%s"}}"
|
||||
data-locale-no-contributions="{{ctx.Locale.Tr "heatmap.no_contributions"}}"
|
||||
data-locale-more="{{ctx.Locale.Tr "heatmap.more"}}"
|
||||
data-locale-less="{{ctx.Locale.Tr "heatmap.less"}}"
|
||||
|
||||
@ -9,8 +9,8 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
@ -19,30 +19,52 @@ import (
|
||||
|
||||
func TestActionsCollaborativeOwner(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
// user2 is the owner of "reusable_workflow" repo
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user2Session := loginUser(t, user2.Name)
|
||||
// user2 is the owner of the private "reusable_workflow" repo
|
||||
user2Session := loginUser(t, "user2")
|
||||
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
repo := createActionsTestRepo(t, user2Token, "reusable_workflow", true)
|
||||
apiReusableWorkflowRepo := createActionsTestRepo(t, user2Token, "reusable_workflow", true)
|
||||
reusableWorkflowRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiReusableWorkflowRepo.ID})
|
||||
|
||||
// a private repo(id=6) of user10 will try to clone "reusable_workflow" repo
|
||||
user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
|
||||
// task id is 55 and its repo_id=6
|
||||
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 55, RepoID: 6})
|
||||
taskToken := "674f727a81ed2f195bccab036cccf86a182199eb"
|
||||
tokenHash := auth_model.HashToken(taskToken, task.TokenSalt)
|
||||
assert.Equal(t, task.TokenHash, tokenHash)
|
||||
// user4 is the owner of the private caller repo
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
user4Session := loginUser(t, user4.Name)
|
||||
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
apiCallerRepo := createActionsTestRepo(t, user4Token, "caller_workflow", true)
|
||||
callerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiCallerRepo.ID})
|
||||
|
||||
// create a mock runner for caller
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, callerRepo.OwnerName, callerRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
// init the workflow for caller
|
||||
wfTreePath := ".gitea/workflows/test_collaborative_owner.yml"
|
||||
wfFileContent := `name: Test Collaborative Owner
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'test collaborative owner'
|
||||
`
|
||||
opts := getWorkflowCreateFileOptions(user4, callerRepo.DefaultBranch, "create "+wfTreePath, wfFileContent)
|
||||
createWorkflowFile(t, user4Token, callerRepo.OwnerName, callerRepo.Name, wfTreePath, opts)
|
||||
|
||||
// fetch the task and get its token
|
||||
task := runner.fetchTask(t)
|
||||
taskToken := task.Secrets["GITEA_TOKEN"]
|
||||
assert.NotEmpty(t, taskToken)
|
||||
|
||||
// prepare for clone
|
||||
dstPath := t.TempDir()
|
||||
u.Path = fmt.Sprintf("%s/%s.git", repo.Owner.UserName, repo.Name)
|
||||
u.Path = fmt.Sprintf("%s/%s.git", "user2", "reusable_workflow")
|
||||
u.User = url.UserPassword("gitea-actions", taskToken)
|
||||
|
||||
// the git clone will fail
|
||||
doGitCloneFail(u)(t)
|
||||
|
||||
// add user10 to the list of collaborative owners
|
||||
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", repo.Owner.UserName, repo.Name), map[string]string{
|
||||
"collaborative_owner": user10.Name,
|
||||
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", reusableWorkflowRepo.OwnerName, reusableWorkflowRepo.Name), map[string]string{
|
||||
"collaborative_owner": user4.Name,
|
||||
})
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
@ -50,7 +72,7 @@ func TestActionsCollaborativeOwner(t *testing.T) {
|
||||
doGitClone(dstPath, u)(t)
|
||||
|
||||
// remove user10 from the list of collaborative owners
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/delete?id=%d", repo.Owner.UserName, repo.Name, user10.ID))
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/delete?id=%d", reusableWorkflowRepo.OwnerName, reusableWorkflowRepo.Name, user4.ID))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// the git clone will fail
|
||||
|
||||
59
tests/integration/heatmap_test.go
Normal file
59
tests/integration/heatmap_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHeatmapEndpoints(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// Mock time so fixture actions fall within the heatmap's time window
|
||||
timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
|
||||
t.Run("UserProfile", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
req := NewRequest(t, "GET", "/user2/-/heatmap")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var result map[string]any
|
||||
DecodeJSON(t, resp, &result)
|
||||
assert.Contains(t, result, "heatmapData")
|
||||
assert.Contains(t, result, "totalContributions")
|
||||
assert.Positive(t, result["totalContributions"])
|
||||
})
|
||||
|
||||
t.Run("OrgDashboard", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
req := NewRequest(t, "GET", "/org/org3/dashboard/-/heatmap")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var result map[string]any
|
||||
DecodeJSON(t, resp, &result)
|
||||
assert.Contains(t, result, "heatmapData")
|
||||
assert.Contains(t, result, "totalContributions")
|
||||
})
|
||||
|
||||
t.Run("OrgTeamDashboard", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
req := NewRequest(t, "GET", "/org/org3/dashboard/-/heatmap/team1")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var result map[string]any
|
||||
DecodeJSON(t, resp, &result)
|
||||
assert.Contains(t, result, "heatmapData")
|
||||
assert.Contains(t, result, "totalContributions")
|
||||
})
|
||||
}
|
||||
@ -2,6 +2,7 @@ import {POST} from '../modules/fetch.ts';
|
||||
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {camelize} from 'vue';
|
||||
import {applyAutoFocus} from './common-page.ts';
|
||||
|
||||
export function initGlobalButtonClickOnEnter(): void {
|
||||
addDelegatedEventListener(document, 'keypress', 'div.ui.button, span.ui.button', (el, e: KeyboardEvent) => {
|
||||
@ -88,7 +89,7 @@ function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
|
||||
const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
|
||||
for (const elem of elems) {
|
||||
if (isElemVisible(elem as HTMLElement)) {
|
||||
elem.querySelector<HTMLElement>('[autofocus]')?.focus();
|
||||
applyAutoFocus(elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,12 +116,30 @@ function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>)
|
||||
}
|
||||
}
|
||||
|
||||
function autoFocusEnd(el: HTMLInputElement | HTMLTextAreaElement) {
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}
|
||||
|
||||
export function applyAutoFocus(container: Element) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/autofocus
|
||||
// "autofocus" behavior is defined by the standard: when a container (e.g.: dialog) becomes visible, focus the element with "autofocus" attribute
|
||||
// Fomantic UI already supports it for its modal dialog, we need to cover more cases (e.g.: ".show-panel" button)
|
||||
// Here is just a simple support, we don't expect more than one element that need "autofocus" appearing in the same container
|
||||
container.querySelector<HTMLElement>('[autofocus]')?.focus();
|
||||
// Also, apply our autoFocusEnd behavior
|
||||
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: use "~=" operator in case we would extend the "data-global-init" to support more functions in the future.
|
||||
const el = container.querySelector<HTMLInputElement>('[data-global-init~="autoFocusEnd"]');
|
||||
if (el) autoFocusEnd(el);
|
||||
}
|
||||
|
||||
export function initGlobalInput() {
|
||||
registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
|
||||
registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => {
|
||||
el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
});
|
||||
|
||||
// autoFocusEnd is used for autofocus an input/textarea and move the cursor to the end of the text.
|
||||
// It is useful for "New Issue"/"New PR" pages when the title is pre-filled with prefix text (e.g.: from template or commit message)
|
||||
// The native "autofocus" isn't used because there is a delay between "focused (DOM rendering)" and "move cursor to end (our JS)", it causes flickers.
|
||||
registerGlobalInitFunc('autoFocusEnd', autoFocusEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
import {createApp} from 'vue';
|
||||
import ActivityHeatmap from '../components/ActivityHeatmap.vue';
|
||||
import {translateMonth, translateDay} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
export function initHeatmap() {
|
||||
const el = document.querySelector('#user-heatmap');
|
||||
type HeatmapResponse = {
|
||||
heatmapData: Array<[number, number]>; // [[1617235200, 2]] = [unix timestamp, count]
|
||||
totalContributions: number;
|
||||
};
|
||||
|
||||
export async function initHeatmap() {
|
||||
const el = document.querySelector<HTMLElement>('#user-heatmap');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
const url = el.getAttribute('data-heatmap-url')!;
|
||||
const resp = await GET(url);
|
||||
if (!resp.ok) throw new Error(`Failed to load heatmap data: ${resp.status} ${resp.statusText}`);
|
||||
const {heatmapData, totalContributions} = await resp.json() as HeatmapResponse;
|
||||
|
||||
const heatmap: Record<string, number> = {};
|
||||
for (const {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data')!)) {
|
||||
for (const [timestamp, contributions] of heatmapData) {
|
||||
// Convert to user timezone and sum contributions by date
|
||||
const dateStr = new Date(timestamp * 1000).toDateString();
|
||||
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
|
||||
@ -18,6 +29,9 @@ export function initHeatmap() {
|
||||
return {date: new Date(v), count: heatmap[v]};
|
||||
});
|
||||
|
||||
const totalFormatted = totalContributions.toLocaleString();
|
||||
const textTotalContributions = el.getAttribute('data-locale-total-contributions')!.replace('%s', totalFormatted);
|
||||
|
||||
// last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
|
||||
const locale = {
|
||||
heatMapLocale: {
|
||||
@ -28,7 +42,7 @@ export function initHeatmap() {
|
||||
less: el.getAttribute('data-locale-less'),
|
||||
},
|
||||
tooltipUnit: 'contributions',
|
||||
textTotalContributions: el.getAttribute('data-locale-total-contributions'),
|
||||
textTotalContributions,
|
||||
noDataText: el.getAttribute('data-locale-no-contributions'),
|
||||
};
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ export function registerGlobalInitFunc<T extends HTMLElement>(name: string, hand
|
||||
}
|
||||
|
||||
function callGlobalInitFunc(el: HTMLElement) {
|
||||
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: maybe in the future we need to extend it to support multiple functions, for example: `data-global-init="func1 func2 func3"`
|
||||
const initFunc = el.getAttribute('data-global-init')!;
|
||||
const func = globalInitFuncs[initFunc];
|
||||
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user