This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
import {argv, env, exit} from 'node:process';
|
||||
|
||||
const allowedTypes = [
|
||||
'build',
|
||||
'chore',
|
||||
'ci',
|
||||
'docs',
|
||||
'enhance',
|
||||
'feat',
|
||||
'fix',
|
||||
'perf',
|
||||
'refactor',
|
||||
'revert',
|
||||
'style',
|
||||
'test',
|
||||
] as const;
|
||||
type CommitType = typeof allowedTypes[number];
|
||||
|
||||
const allowedTypesList = allowedTypes.join(', ');
|
||||
const titlePattern = new RegExp(`^(${allowedTypes.join('|')})(\\([\\w/.-]+\\))?(!)?: .+$`);
|
||||
|
||||
function parsePrTitle(title: string): {type: CommitType; breaking: boolean} | null {
|
||||
const match = titlePattern.exec(title);
|
||||
return match ? {type: match[1] as CommitType, breaking: Boolean(match[3])} : null;
|
||||
}
|
||||
|
||||
const breakingLabel = 'pr/breaking';
|
||||
|
||||
// Mutually exclusive type labels, fully synced with the title type (added and removed).
|
||||
const typeLabels: Partial<Record<CommitType, string>> = {
|
||||
feat: 'type/feature',
|
||||
enhance: 'type/enhancement',
|
||||
fix: 'type/bug',
|
||||
docs: 'type/docs',
|
||||
test: 'type/testing',
|
||||
};
|
||||
|
||||
// Non-type labels, only added, never auto-removed, so manual labeling is not clobbered.
|
||||
const extraLabels: Partial<Record<CommitType, string>> = {
|
||||
chore: 'skip-changelog',
|
||||
ci: 'skip-changelog',
|
||||
build: 'topic/build',
|
||||
};
|
||||
|
||||
// Labels this tool may remove when the title no longer implies them.
|
||||
const removableLabels = [...Object.values(typeLabels), breakingLabel];
|
||||
|
||||
function labelsForPrTitle(title: string): string[] {
|
||||
const parsed = parsePrTitle(title);
|
||||
if (!parsed) return [];
|
||||
return [typeLabels[parsed.type], extraLabels[parsed.type], parsed.breaking ? breakingLabel : undefined]
|
||||
.filter((label): label is string => label !== undefined);
|
||||
}
|
||||
|
||||
// Command: validate PR_TITLE against the allowed Conventional Commits format.
|
||||
function lintPrTitle(): void {
|
||||
if (!env.PR_TITLE) {
|
||||
console.error('Missing PR_TITLE');
|
||||
exit(1);
|
||||
}
|
||||
if (!parsePrTitle(env.PR_TITLE)) {
|
||||
console.error(`Invalid PR title: ${env.PR_TITLE}`);
|
||||
console.error('Expected format: type(scope): subject (scope optional, append "!" for breaking changes)');
|
||||
console.error(`Allowed types: ${allowedTypesList}`);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Command: sync the title-derived labels onto the PR via the GitHub API.
|
||||
async function setPrLabels(): Promise<void> {
|
||||
if (!env.PR_TITLE || !env.GITHUB_TOKEN || !env.GITHUB_REPOSITORY || !env.PR_NUMBER) {
|
||||
console.error('set-pr-labels requires PR_TITLE, GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const labelsUrl = `https://api.github.com/repos/${env.GITHUB_REPOSITORY}/issues/${env.PR_NUMBER}/labels`;
|
||||
|
||||
async function request(url: string, method = 'GET', body?: unknown): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...(Boolean(body) && {'Content-Type': 'application/json'}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API ${method} ${url} failed (${response.status}): ${await response.text()}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const desired = labelsForPrTitle(env.PR_TITLE);
|
||||
const response = await request(`${labelsUrl}?per_page=100`);
|
||||
const current = ((await response.json()) as Array<{name: string}>).map((label) => label.name);
|
||||
|
||||
const toAdd = desired.filter((name) => !current.includes(name));
|
||||
const toRemove = removableLabels.filter((name) => current.includes(name) && !desired.includes(name));
|
||||
|
||||
if (toAdd.length) {
|
||||
await request(labelsUrl, 'POST', {labels: toAdd});
|
||||
console.info(`Added labels: ${toAdd.join(', ')}`);
|
||||
}
|
||||
for (const name of toRemove) {
|
||||
await request(`${labelsUrl}/${encodeURIComponent(name)}`, 'DELETE');
|
||||
console.info(`Removed label: ${name}`);
|
||||
}
|
||||
if (!toAdd.length && !toRemove.length) {
|
||||
console.info('PR labels already in sync');
|
||||
}
|
||||
}
|
||||
|
||||
const commands: Record<string, () => void | Promise<void>> = {
|
||||
'lint-pr-title': lintPrTitle,
|
||||
'set-pr-labels': setPrLabels,
|
||||
};
|
||||
|
||||
const command = argv[2];
|
||||
const handler = commands[command];
|
||||
if (!handler) {
|
||||
console.error(`Usage: ci-tools.ts <${Object.keys(commands).join('|')}>`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await handler();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
exit(1);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/tools/codeformat"
|
||||
)
|
||||
|
||||
// Windows has a limitation for command line arguments, the size can not exceed 32KB.
|
||||
// So we have to feed the files to some tools (like gofmt) batch by batch
|
||||
|
||||
// We also introduce a `gitea-fmt` command, it does better import formatting than gofmt/goimports. `gitea-fmt` calls `gofmt` internally.
|
||||
|
||||
var optionLogVerbose bool
|
||||
|
||||
func logVerbose(msg string, args ...any) {
|
||||
if optionLogVerbose {
|
||||
log.Printf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func passThroughCmd(cmd string, args []string) error {
|
||||
foundCmd, err := exec.LookPath(cmd)
|
||||
if err != nil {
|
||||
log.Fatalf("can not find cmd: %s", cmd)
|
||||
}
|
||||
c := exec.Cmd{
|
||||
Path: foundCmd,
|
||||
Args: append([]string{cmd}, args...),
|
||||
Stdin: os.Stdin,
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
}
|
||||
return c.Run()
|
||||
}
|
||||
|
||||
type fileCollector struct {
|
||||
dirs []string
|
||||
includePatterns []*regexp.Regexp
|
||||
excludePatterns []*regexp.Regexp
|
||||
batchSize int
|
||||
}
|
||||
|
||||
func newFileCollector(fileFilter string, batchSize int) (*fileCollector, error) {
|
||||
co := &fileCollector{batchSize: batchSize}
|
||||
if fileFilter == "go-own" {
|
||||
co.dirs = []string{
|
||||
"build",
|
||||
"cmd",
|
||||
"contrib",
|
||||
"tests",
|
||||
"models",
|
||||
"modules",
|
||||
"routers",
|
||||
"services",
|
||||
}
|
||||
co.includePatterns = append(co.includePatterns, regexp.MustCompile(`.*\.go$`))
|
||||
|
||||
co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`.*\bbindata\.go$`))
|
||||
co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`\.pb\.go$`))
|
||||
co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`tests/gitea-repositories-meta`))
|
||||
co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`tests/integration/migration-test`))
|
||||
co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`modules/git/tests`))
|
||||
co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`models/fixtures`))
|
||||
co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`models/migrations/fixtures`))
|
||||
co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`services/gitdiff/testdata`))
|
||||
}
|
||||
|
||||
if co.dirs == nil {
|
||||
return nil, fmt.Errorf("unknown file-filter: %s", fileFilter)
|
||||
}
|
||||
return co, nil
|
||||
}
|
||||
|
||||
func (fc *fileCollector) matchPatterns(path string, regexps []*regexp.Regexp) bool {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
for _, re := range regexps {
|
||||
if re.MatchString(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (fc *fileCollector) collectFiles() (res [][]string, err error) {
|
||||
var batch []string
|
||||
for _, dir := range fc.dirs {
|
||||
err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
include := len(fc.includePatterns) == 0 || fc.matchPatterns(path, fc.includePatterns)
|
||||
exclude := fc.matchPatterns(path, fc.excludePatterns)
|
||||
process := include && !exclude
|
||||
if !process {
|
||||
if d.IsDir() {
|
||||
if exclude {
|
||||
logVerbose("exclude dir %s", path)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// for a directory, if it is not excluded explicitly, we should walk into
|
||||
return nil
|
||||
}
|
||||
// for a file, we skip it if it shouldn't be processed
|
||||
logVerbose("skip process %s", path)
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
// skip dir, we don't add dirs to the file list now
|
||||
return nil
|
||||
}
|
||||
if len(batch) >= fc.batchSize {
|
||||
res = append(res, batch)
|
||||
batch = nil
|
||||
}
|
||||
batch = append(batch, path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
res = append(res, batch)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// substArgFiles expands the {file-list} to a real file list for commands
|
||||
func substArgFiles(args, files []string) []string {
|
||||
for i, s := range args {
|
||||
if s == "{file-list}" {
|
||||
newArgs := append(args[:i], files...)
|
||||
newArgs = append(newArgs, args[i+1:]...)
|
||||
return newArgs
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func exitWithCmdErrors(subCmd string, subArgs []string, cmdErrors []error) {
|
||||
for _, err := range cmdErrors {
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode := exitError.ExitCode()
|
||||
log.Printf("run command failed (code=%d): %s %v", exitCode, subCmd, subArgs)
|
||||
os.Exit(exitCode)
|
||||
} else {
|
||||
log.Fatalf("run command failed (err=%s) %s %v", err, subCmd, subArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseArgs() (mainOptions map[string]string, subCmd string, subArgs []string) {
|
||||
mainOptions = map[string]string{}
|
||||
for i := 1; i < len(os.Args); i++ {
|
||||
arg := os.Args[i]
|
||||
if arg == "" {
|
||||
break
|
||||
}
|
||||
if arg[0] == '-' {
|
||||
arg = strings.TrimPrefix(arg, "-")
|
||||
arg = strings.TrimPrefix(arg, "-")
|
||||
fields := strings.SplitN(arg, "=", 2)
|
||||
if len(fields) == 1 {
|
||||
mainOptions[fields[0]] = "1"
|
||||
} else {
|
||||
mainOptions[fields[0]] = fields[1]
|
||||
}
|
||||
} else {
|
||||
subCmd = arg
|
||||
subArgs = os.Args[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
return mainOptions, subCmd, subArgs
|
||||
}
|
||||
|
||||
func showUsage() {
|
||||
fmt.Printf(`Usage: %[1]s [options] {command} [arguments]
|
||||
|
||||
Options:
|
||||
--verbose
|
||||
--file-filter=go-own
|
||||
--batch-size=100
|
||||
|
||||
Commands:
|
||||
%[1]s gofmt ...
|
||||
|
||||
Arguments:
|
||||
{file-list} the file list
|
||||
|
||||
Example:
|
||||
%[1]s gofmt -s -d {file-list}
|
||||
|
||||
`, "file-batch-exec")
|
||||
}
|
||||
|
||||
func newFileCollectorFromMainOptions(mainOptions map[string]string) (fc *fileCollector, err error) {
|
||||
fileFilter := mainOptions["file-filter"]
|
||||
if fileFilter == "" {
|
||||
fileFilter = "go-own"
|
||||
}
|
||||
batchSize, _ := strconv.Atoi(mainOptions["batch-size"])
|
||||
if batchSize == 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
|
||||
return newFileCollector(fileFilter, batchSize)
|
||||
}
|
||||
|
||||
func giteaFormatGoImports(files []string, doWriteFile bool) error {
|
||||
for _, file := range files {
|
||||
if err := codeformat.FormatGoImports(file, doWriteFile); err != nil {
|
||||
log.Printf("failed to format go imports: %s, err=%v", file, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
mainOptions, subCmd, subArgs := parseArgs()
|
||||
if subCmd == "" {
|
||||
showUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
optionLogVerbose = mainOptions["verbose"] != ""
|
||||
|
||||
fc, err := newFileCollectorFromMainOptions(mainOptions)
|
||||
if err != nil {
|
||||
log.Fatalf("can not create file collector: %s", err.Error())
|
||||
}
|
||||
|
||||
fileBatches, err := fc.collectFiles()
|
||||
if err != nil {
|
||||
log.Fatalf("can not collect files: %s", err.Error())
|
||||
}
|
||||
|
||||
processed := 0
|
||||
var cmdErrors []error
|
||||
for _, files := range fileBatches {
|
||||
if len(files) == 0 {
|
||||
break
|
||||
}
|
||||
substArgs := substArgFiles(subArgs, files)
|
||||
logVerbose("batch cmd: %s %v", subCmd, substArgs)
|
||||
switch subCmd {
|
||||
case "gitea-fmt":
|
||||
if slices.Contains(subArgs, "-d") {
|
||||
log.Print("the -d option is not supported by gitea-fmt")
|
||||
}
|
||||
cmdErrors = append(cmdErrors, giteaFormatGoImports(files, slices.Contains(subArgs, "-w")))
|
||||
cmdErrors = append(cmdErrors, passThroughCmd("gofmt", append([]string{"-w", "-r", "interface{} -> any"}, substArgs...)))
|
||||
cmdErrors = append(cmdErrors, passThroughCmd("go", append([]string{"run", os.Getenv("GOFUMPT_PACKAGE"), "-extra"}, substArgs...)))
|
||||
default:
|
||||
log.Fatalf("unknown cmd: %s %v", subCmd, subArgs)
|
||||
}
|
||||
processed += len(files)
|
||||
}
|
||||
|
||||
logVerbose("processed %d files", processed)
|
||||
exitWithCmdErrors(subCmd, subArgs, cmdErrors)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package codeformat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var importPackageGroupOrders = map[string]int{
|
||||
"": 1, // internal
|
||||
"code.gitea.io/gitea/": 2,
|
||||
}
|
||||
|
||||
var errInvalidCommentBetweenImports = errors.New("comments between imported packages are invalid, please move comments to the end of the package line")
|
||||
|
||||
var (
|
||||
importBlockBegin = []byte("\nimport (\n")
|
||||
importBlockEnd = []byte("\n)")
|
||||
)
|
||||
|
||||
type importLineParsed struct {
|
||||
group string
|
||||
pkg string
|
||||
content string
|
||||
}
|
||||
|
||||
func parseImportLine(line string) (*importLineParsed, error) {
|
||||
il := &importLineParsed{content: line}
|
||||
p1 := strings.IndexRune(line, '"')
|
||||
if p1 == -1 {
|
||||
return nil, errors.New("invalid import line: " + line)
|
||||
}
|
||||
p1++
|
||||
p := strings.IndexRune(line[p1:], '"')
|
||||
if p == -1 {
|
||||
return nil, errors.New("invalid import line: " + line)
|
||||
}
|
||||
p2 := p1 + p
|
||||
il.pkg = line[p1:p2]
|
||||
|
||||
pDot := strings.IndexRune(il.pkg, '.')
|
||||
pSlash := strings.IndexRune(il.pkg, '/')
|
||||
if pDot != -1 && pDot < pSlash {
|
||||
il.group = "domain-package"
|
||||
}
|
||||
for groupName := range importPackageGroupOrders {
|
||||
if groupName == "" {
|
||||
continue // skip internal
|
||||
}
|
||||
if strings.HasPrefix(il.pkg, groupName) {
|
||||
il.group = groupName
|
||||
}
|
||||
}
|
||||
return il, nil
|
||||
}
|
||||
|
||||
type (
|
||||
importLineGroup []*importLineParsed
|
||||
importLineGroupMap map[string]importLineGroup
|
||||
)
|
||||
|
||||
func formatGoImports(contentBytes []byte) ([]byte, error) {
|
||||
p1 := bytes.Index(contentBytes, importBlockBegin)
|
||||
if p1 == -1 {
|
||||
return nil, nil
|
||||
}
|
||||
p1 += len(importBlockBegin)
|
||||
p := bytes.Index(contentBytes[p1:], importBlockEnd)
|
||||
if p == -1 {
|
||||
return nil, nil
|
||||
}
|
||||
p2 := p1 + p
|
||||
|
||||
importGroups := importLineGroupMap{}
|
||||
r := bytes.NewBuffer(contentBytes[p1:p2])
|
||||
eof := false
|
||||
for !eof {
|
||||
line, err := r.ReadString('\n')
|
||||
eof = err == io.EOF
|
||||
if err != nil && !eof {
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
if strings.HasPrefix(line, "//") || strings.HasPrefix(line, "/*") {
|
||||
return nil, errInvalidCommentBetweenImports
|
||||
}
|
||||
importLine, err := parseImportLine(line)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
importGroups[importLine.group] = append(importGroups[importLine.group], importLine)
|
||||
}
|
||||
}
|
||||
|
||||
var groupNames []string
|
||||
for groupName, importLines := range importGroups {
|
||||
groupNames = append(groupNames, groupName)
|
||||
sort.Slice(importLines, func(i, j int) bool {
|
||||
return strings.Compare(importLines[i].pkg, importLines[j].pkg) < 0
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(groupNames, func(i, j int) bool {
|
||||
n1 := groupNames[i]
|
||||
n2 := groupNames[j]
|
||||
o1 := importPackageGroupOrders[n1]
|
||||
o2 := importPackageGroupOrders[n2]
|
||||
if o1 != 0 && o2 != 0 {
|
||||
return o1 < o2
|
||||
}
|
||||
if o1 == 0 && o2 == 0 {
|
||||
return strings.Compare(n1, n2) < 0
|
||||
}
|
||||
return o1 != 0
|
||||
})
|
||||
|
||||
formattedBlock := bytes.Buffer{}
|
||||
for _, groupName := range groupNames {
|
||||
hasNormalImports := false
|
||||
hasDummyImports := false
|
||||
// non-dummy import comes first
|
||||
for _, importLine := range importGroups[groupName] {
|
||||
if strings.HasPrefix(importLine.content, "_") {
|
||||
hasDummyImports = true
|
||||
} else {
|
||||
formattedBlock.WriteString("\t" + importLine.content + "\n")
|
||||
hasNormalImports = true
|
||||
}
|
||||
}
|
||||
// dummy (_ "pkg") comes later
|
||||
if hasDummyImports {
|
||||
if hasNormalImports {
|
||||
formattedBlock.WriteString("\n")
|
||||
}
|
||||
for _, importLine := range importGroups[groupName] {
|
||||
if strings.HasPrefix(importLine.content, "_") {
|
||||
formattedBlock.WriteString("\t" + importLine.content + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
formattedBlock.WriteString("\n")
|
||||
}
|
||||
formattedBlockBytes := bytes.TrimRight(formattedBlock.Bytes(), "\n")
|
||||
|
||||
var formattedBytes []byte
|
||||
formattedBytes = append(formattedBytes, contentBytes[:p1]...)
|
||||
formattedBytes = append(formattedBytes, formattedBlockBytes...)
|
||||
formattedBytes = append(formattedBytes, contentBytes[p2:]...)
|
||||
return formattedBytes, nil
|
||||
}
|
||||
|
||||
// FormatGoImports format the imports by our rules (see unit tests)
|
||||
func FormatGoImports(file string, doWriteFile bool) error {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var contentBytes []byte
|
||||
{
|
||||
defer f.Close()
|
||||
contentBytes, err = io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
formattedBytes, err := formatGoImports(contentBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if formattedBytes == nil {
|
||||
return nil
|
||||
}
|
||||
if bytes.Equal(contentBytes, formattedBytes) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if doWriteFile {
|
||||
f, err = os.OpenFile(file, os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(formattedBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package codeformat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatImportsSimple(t *testing.T) {
|
||||
formatted, err := formatGoImports([]byte(`
|
||||
package codeformat
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
`))
|
||||
|
||||
expected := `
|
||||
package codeformat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
`
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(formatted))
|
||||
}
|
||||
|
||||
func TestFormatImportsGroup(t *testing.T) {
|
||||
// gofmt/goimports won't group the packages, for example, they produce such code:
|
||||
// "bytes"
|
||||
// "image"
|
||||
// (a blank line)
|
||||
// "fmt"
|
||||
// "image/color/palette"
|
||||
// our formatter does better, and these packages are grouped into one.
|
||||
|
||||
formatted, err := formatGoImports([]byte(`
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
_ "image/gif" // for processing gif images
|
||||
_ "image/jpeg" // for processing jpeg images
|
||||
_ "image/png" // for processing png images
|
||||
|
||||
"code.gitea.io/other/package"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"xorm.io/the/package"
|
||||
|
||||
"github.com/issue9/identicon"
|
||||
"github.com/nfnt/resize"
|
||||
"github.com/oliamb/cutter"
|
||||
)
|
||||
`))
|
||||
|
||||
expected := `
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
_ "image/gif" // for processing gif images
|
||||
_ "image/jpeg" // for processing jpeg images
|
||||
_ "image/png" // for processing png images
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"code.gitea.io/other/package"
|
||||
"github.com/issue9/identicon"
|
||||
"github.com/nfnt/resize"
|
||||
"github.com/oliamb/cutter"
|
||||
"xorm.io/the/package"
|
||||
)
|
||||
`
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(formatted))
|
||||
}
|
||||
|
||||
func TestFormatImportsInvalidComment(t *testing.T) {
|
||||
// why we shouldn't write comments between imports: it breaks the grouping of imports
|
||||
// for example:
|
||||
// "pkg1"
|
||||
// "pkg2"
|
||||
// // a comment
|
||||
// "pkgA"
|
||||
// "pkgB"
|
||||
// the comment splits the packages into two groups, pkg1/2 are sorted separately, pkgA/B are sorted separately
|
||||
// we don't want such code, so the code should be:
|
||||
// "pkg1"
|
||||
// "pkg2"
|
||||
// "pkgA" // a comment
|
||||
// "pkgB"
|
||||
|
||||
_, err := formatGoImports([]byte(`
|
||||
package test
|
||||
|
||||
import (
|
||||
"image/jpeg"
|
||||
// for processing gif images
|
||||
"image/gif"
|
||||
)
|
||||
`))
|
||||
assert.ErrorIs(t, err, errInvalidCommentBetweenImports)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// MIT license, Copyright (c) GitHub, Inc.
|
||||
// https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
import rule from './unescaped-html-literal.ts';
|
||||
import {RuleTester} from 'eslint';
|
||||
|
||||
class VitestRuleTester extends RuleTester {
|
||||
static describe = describe;
|
||||
static it = it;
|
||||
static itOnly = it.only;
|
||||
}
|
||||
|
||||
const ruleTester = new VitestRuleTester();
|
||||
|
||||
ruleTester.run('unescaped-html-literal', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: '`Hello World!`;',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
{
|
||||
code: "'Hello World!'",
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
{
|
||||
code: '"Hello World!"',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
{
|
||||
code: 'const helloTemplate = () => html`<div>Hello World!</div>`;',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
{
|
||||
code: 'const helloTemplate = (name) => html`<div>Hello ${name}!</div>`;',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "const helloHTML = '<div>Hello, World!</div>'",
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const helloHTML = "<h1>Hello, World!</h1>"',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const helloHTML = `<div>Hello ${name}!</div>`',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const helloHTML = ` \n\t<div>Hello ${name}!</div>`',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const helloHTML = foo`<div>Hello ${name}!</div>`',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// MIT license, Copyright (c) GitHub, Inc.
|
||||
// https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js
|
||||
import type {JSRuleDefinition, JSRuleDefinitionTypeOptions} from 'eslint';
|
||||
|
||||
const htmlOpenTag = /^\s*<[a-zA-Z]/;
|
||||
|
||||
const rule: JSRuleDefinition<JSRuleDefinitionTypeOptions> = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
messages: {
|
||||
unescapedHtmlLiteral: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
},
|
||||
|
||||
create: (context) => ({
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string' || !htmlOpenTag.test(node.value)) return;
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'unescapedHtmlLiteral',
|
||||
});
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
const templateStart = node.quasis[0]?.value.raw;
|
||||
if (!templateStart || !htmlOpenTag.test(templateStart)) return;
|
||||
|
||||
const parent = node.parent;
|
||||
if (parent?.type === 'TaggedTemplateExpression' && parent.tag.type === 'Identifier' && parent.tag.name === 'html') return;
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'unescapedHtmlLiteral',
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export default rule;
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
import {load as parseYaml} from 'js-yaml';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {languages as cmLanguages} from '@codemirror/language-data';
|
||||
|
||||
const linguistUrl = 'https://raw.githubusercontent.com/github-linguist/linguist/main/lib/linguist/languages.yml';
|
||||
|
||||
const renames: Record<string, string> = {
|
||||
'Protocol Buffer': 'ProtoBuf',
|
||||
};
|
||||
|
||||
// Languages whose entry is constructed manually in the runtime; skip during generation.
|
||||
const skipNames = new Set(['Dockerfile', 'Markdown']);
|
||||
|
||||
// Extensions claimed by several unrelated languages with no good default; strip globally.
|
||||
const ambiguousExt = new Set(['cgi', 'fcgi', 'inc']);
|
||||
|
||||
// Per-language drops for non-text formats (.frm = binary VB6 forms) or where Linguist's
|
||||
// primary owner conflicts with a more specialised CodeMirror mode (.spec → RPM Spec).
|
||||
const excludeExt: Record<string, string[]> = {
|
||||
'INI': ['frm'],
|
||||
'Python': ['spec'],
|
||||
'Ruby': ['spec'],
|
||||
};
|
||||
|
||||
// Per-CM-language additions for filenames Linguist classifies as separate languages
|
||||
// (.editorconfig, .gitconfig, .npmrc) or omits entirely (Snakefile).
|
||||
const extraFilenames: Record<string, string[]> = {
|
||||
'Properties files': ['.editorconfig', '.gitconfig', '.npmrc'],
|
||||
'Python': ['Snakefile'],
|
||||
};
|
||||
|
||||
// Per-CM-language additions widely used in practice but absent from Linguist's list.
|
||||
const extraExtensions: Record<string, string[]> = {
|
||||
'Properties files': ['conf'],
|
||||
};
|
||||
|
||||
type LinguistEntry = {
|
||||
type: string;
|
||||
extensions?: string[];
|
||||
filenames?: string[];
|
||||
};
|
||||
|
||||
type CmLanguage = {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
filenames: string[];
|
||||
};
|
||||
|
||||
const res = await fetch(linguistUrl);
|
||||
if (!res.ok) throw new Error(`fetch ${linguistUrl} failed: ${res.status}`);
|
||||
const linguist = parseYaml(await res.text()) as Record<string, LinguistEntry>;
|
||||
|
||||
const cmByAlias = new Map<string, string>();
|
||||
// Map of extension -> the CM language that originally owns it. Used to prevent Linguist
|
||||
// from broadening one language's extension claim into another's territory (e.g. Linguist's
|
||||
// PLSQL lists .sql, but CM's SQL is the canonical owner).
|
||||
const cmOriginalExtOwner = new Map<string, string>();
|
||||
for (const lang of cmLanguages) {
|
||||
cmByAlias.set(lang.name.toLowerCase(), lang.name);
|
||||
for (const a of lang.alias) cmByAlias.set(a.toLowerCase(), lang.name);
|
||||
for (const ext of lang.extensions) {
|
||||
if (!cmOriginalExtOwner.has(ext)) cmOriginalExtOwner.set(ext, lang.name);
|
||||
}
|
||||
}
|
||||
|
||||
const out: CmLanguage[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [linguistName, entry] of Object.entries(linguist)) {
|
||||
const cmName = renames[linguistName] ?? cmByAlias.get(linguistName.toLowerCase());
|
||||
// Multiple Linguist entries can alias to the same CM language (e.g. JSON5 → JSON).
|
||||
if (!cmName || skipNames.has(cmName) || seen.has(cmName)) continue;
|
||||
seen.add(cmName);
|
||||
const exExt = new Set(excludeExt[linguistName]);
|
||||
// CodeMirror's matchFilename uses /\.([^.]+)$/, so multi-dot extensions like
|
||||
// ".cmake.in" can't match as extensions and are dropped here.
|
||||
const extensions = (entry.extensions ?? [])
|
||||
.map((e) => e.replace(/^\./, ''))
|
||||
.filter((e) => {
|
||||
if (e.includes('.') || ambiguousExt.has(e) || exExt.has(e)) return false;
|
||||
const owner = cmOriginalExtOwner.get(e);
|
||||
return !owner || owner === cmName;
|
||||
});
|
||||
out.push({
|
||||
name: cmName,
|
||||
extensions: [...extensions, ...(extraExtensions[cmName] ?? [])],
|
||||
filenames: [...(entry.filenames ?? []), ...(extraFilenames[cmName] ?? [])],
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const outPath = new URL('../assets/codemirror-languages.json', import.meta.url);
|
||||
await writeFile(outPath, `${JSON.stringify(out, null, 2)}\n`);
|
||||
console.info(`wrote ${out.length} languages to ${outPath.pathname}`);
|
||||
@@ -7,7 +7,7 @@ import {argv, exit} from 'node:process';
|
||||
async function generate(svg: string, path: string, {size, bg}: {size: number, bg?: boolean}) {
|
||||
const outputFile = new URL(path, import.meta.url);
|
||||
|
||||
if (String(outputFile).endsWith('.svg')) {
|
||||
if (outputFile.href.endsWith('.svg')) {
|
||||
const {data} = optimize(svg, {
|
||||
plugins: [
|
||||
'preset-default',
|
||||
|
||||
@@ -92,7 +92,7 @@ async function processMaterialFileIcons() {
|
||||
}
|
||||
|
||||
// Use VSCode's "Language ID" mapping from its extensions
|
||||
for (const [_, langIdExtMap] of Object.entries(vscodeExtensions)) {
|
||||
for (const langIdExtMap of Object.values(vscodeExtensions)) {
|
||||
for (const [langId, names] of Object.entries(langIdExtMap)) {
|
||||
for (const name of names) {
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func lintGoHeader() bool {
|
||||
headerRE := regexp.MustCompile(`^(// (Copyright [^\n]+|All rights reserved\.)\n)*// Copyright \d{4} (The Gogs Authors|The Gitea Authors|Gitea Authors|Gitea)\.( All rights reserved\.)?\n(// (Copyright [^\n]+|All rights reserved\.)\n)*// SPDX-License-Identifier: [\w.-]+`)
|
||||
generatedRE := regexp.MustCompile(`(?m)^// (Code|This file is) [Gg]enerated.*DO NOT EDIT`)
|
||||
skipDirs := map[string]bool{
|
||||
".git": true,
|
||||
".venv": true,
|
||||
"node_modules": true,
|
||||
"public": true,
|
||||
"vendor": true,
|
||||
"web_src": true,
|
||||
}
|
||||
root, bad := ".", 0
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if rel, _ := filepath.Rel(root, path); skipDirs[filepath.ToSlash(rel)] {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, 512))
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if generatedRE.Match(data) {
|
||||
return nil
|
||||
}
|
||||
if !headerRE.Match(data) {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s: missing or invalid copyright header\n", path)
|
||||
bad++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
return err == nil && bad == 0
|
||||
}
|
||||
|
||||
func runCmd(env []string, name string, args []string) bool {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 'go run' can not have distinct GOOS/GOARCH for its build and run steps,
|
||||
// so install a pre-compiled binary and run it for different target platforms.
|
||||
_, _ = os.Unsetenv("GOOS"), os.Unsetenv("GOARCH")
|
||||
|
||||
envGolangciLintPackage := os.Getenv("GOLANGCI_LINT_PACKAGE")
|
||||
envGo := os.Getenv("GO")
|
||||
if envGo == "" || envGolangciLintPackage == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "Environment variables GO and GOLANGCI_LINT_PACKAGE must be set")
|
||||
os.Exit(1)
|
||||
}
|
||||
if !runCmd(nil, envGo, []string{"install", envGolangciLintPackage}) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "lint go header ...")
|
||||
succeed := lintGoHeader()
|
||||
_, _ = fmt.Fprintln(os.Stdout, "lint for linux ...")
|
||||
succeed = runCmd([]string{"GOOS=linux", "TAGS=bindata"}, "golangci-lint", append([]string{"run", "--build-tags=linux,bindata"}, os.Args[1:]...)) && succeed
|
||||
if os.Getenv("CI") != "" {
|
||||
// only lint for other platforms when in CI, to keep local lint fast
|
||||
_, _ = fmt.Fprintln(os.Stdout, "lint for windows ...")
|
||||
succeed = runCmd([]string{"GOOS=windows", "TAGS=gogit"}, "golangci-lint", append([]string{"run", "--build-tags=windows,gogit"}, os.Args[1:]...)) && succeed
|
||||
}
|
||||
if !succeed {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
|
||||
VERSION=$(echo "$SHELLCHECK_IMAGE" | sed -E 's/.*:v([0-9.]+)@.*/\1/')
|
||||
|
||||
if hash shellcheck 2>/dev/null && shellcheck --version | grep -qx "version: $VERSION"; then
|
||||
exec shellcheck --color=always "$@"
|
||||
else
|
||||
exec "$CONTAINER_RUNTIME" run --rm -v "$PWD":/mnt -w /mnt "$SHELLCHECK_IMAGE" --color=always "$@"
|
||||
fi
|
||||
@@ -1,13 +1,101 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
|
||||
CONTAINER_NAME="gitea-e2e-runner-$$"
|
||||
|
||||
free_port() {
|
||||
node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close()})"
|
||||
}
|
||||
|
||||
detect_playwright_mode() {
|
||||
if [ "${PLAYWRIGHT_MODE:-auto}" = "local" ] || [ "${PLAYWRIGHT_MODE:-auto}" = "container" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
PLAYWRIGHT_MODE="local"
|
||||
|
||||
if [ "$(uname -s)" = "Linux" ]; then
|
||||
# playwright only supports ubuntu/debian officially
|
||||
if ! grep -qE '^ID(_LIKE)?=.*(ubuntu|debian)' /etc/os-release 2>/dev/null; then
|
||||
PLAYWRIGHT_MODE="container"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_container() {
|
||||
local max_wait=30
|
||||
local elapsed=0
|
||||
echo "Waiting for container to start..."
|
||||
while ! (echo > "/dev/tcp/127.0.0.1/$PLAYWRIGHT_SERVER_PORT") 2>/dev/null; do
|
||||
if [ "$("$CONTAINER_RUNTIME" inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" != "true" ]; then
|
||||
echo "Error: container exited before becoming ready." >&2
|
||||
"$CONTAINER_RUNTIME" logs "$CONTAINER_NAME" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
if [ "$elapsed" -ge "$max_wait" ]; then
|
||||
echo "Error: container did not become ready after ${max_wait}s." >&2
|
||||
"$CONTAINER_RUNTIME" logs "$CONTAINER_NAME" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
echo "Container is ready."
|
||||
}
|
||||
|
||||
CMD="${1:-run}"
|
||||
if [ "$CMD" = "install" ] || [ "$CMD" = "run" ]; then
|
||||
[ $# -gt 0 ] && shift
|
||||
else
|
||||
CMD="run"
|
||||
fi
|
||||
|
||||
detect_playwright_mode
|
||||
|
||||
if [ "$PLAYWRIGHT_MODE" = "container" ]; then
|
||||
if ! command -v "$CONTAINER_RUNTIME" >/dev/null 2>&1; then
|
||||
echo "error: PLAYWRIGHT_MODE=container but '$CONTAINER_RUNTIME' is not installed." >&2
|
||||
echo "Install docker/podman or set CONTAINER_RUNTIME to an available runtime." >&2
|
||||
exit 1
|
||||
fi
|
||||
PLAYWRIGHT_VERSION=$(sed -n 's/.*"@playwright\/test"[[:space:]]*:[[:space:]]*"[^[:digit:]]*\([^"]*\)".*/\1/p' package.json)
|
||||
if ! [[ "$PLAYWRIGHT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "error: invalid @playwright/test version in package.json: '${PLAYWRIGHT_VERSION}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
|
||||
fi
|
||||
|
||||
if [ "$CMD" = "install" ]; then
|
||||
if [ "$PLAYWRIGHT_MODE" = "local" ]; then
|
||||
# on GitHub Actions VMs, playwright's system deps are pre-installed
|
||||
if [ -z "${GITHUB_ACTIONS:-}" ]; then
|
||||
# shellcheck disable=SC2086 # flag string
|
||||
pnpm exec playwright install --with-deps chromium firefox ${PLAYWRIGHT_FLAGS:-}
|
||||
else
|
||||
# shellcheck disable=SC2086 # flag string
|
||||
pnpm exec playwright install chromium firefox ${PLAYWRIGHT_FLAGS:-}
|
||||
fi
|
||||
else
|
||||
echo "Running playwright in container as host distro is not supported by playwright directly"
|
||||
if ! "$CONTAINER_RUNTIME" image inspect "$PLAYWRIGHT_IMAGE" >/dev/null 2>&1; then
|
||||
"$CONTAINER_RUNTIME" pull "$PLAYWRIGHT_IMAGE"
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create isolated work directory
|
||||
WORK_DIR=$(mktemp -d)
|
||||
|
||||
# Find a random free port
|
||||
FREE_PORT=$(node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close()})")
|
||||
FREE_PORT=$(free_port)
|
||||
|
||||
cleanup() {
|
||||
if [ "$PLAYWRIGHT_MODE" = "container" ]; then
|
||||
"$CONTAINER_RUNTIME" stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "${SERVER_PID:-}" ]; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
@@ -16,6 +104,16 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ "$PLAYWRIGHT_MODE" = "container" ]; then
|
||||
PLAYWRIGHT_SERVER_PORT=$(free_port)
|
||||
# --network=host: container needs host loopback to reach gitea.
|
||||
"$CONTAINER_RUNTIME" run --network=host --name "$CONTAINER_NAME" -d --rm --init --workdir /home/pwuser --user pwuser "$PLAYWRIGHT_IMAGE" /bin/sh -c "npx -y playwright@${PLAYWRIGHT_VERSION} run-server --port ${PLAYWRIGHT_SERVER_PORT} --host 0.0.0.0"
|
||||
|
||||
if ! wait_for_container; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write config file for isolated instance
|
||||
mkdir -p "$WORK_DIR/custom/conf"
|
||||
cat > "$WORK_DIR/custom/conf/app.ini" <<EOF
|
||||
@@ -95,10 +193,11 @@ GITEA_TEST_E2E_EMAIL="$GITEA_TEST_E2E_USER@$GITEA_TEST_E2E_DOMAIN"
|
||||
--must-change-password=false \
|
||||
--admin
|
||||
|
||||
# timeout multiplier, CI runners are slower
|
||||
# timeout multiplier to make the tests pass on slow CI runners while using
|
||||
# factor 1 on a fast local machine like a MacBook Pro M1+
|
||||
if [ -z "${GITEA_TEST_E2E_TIMEOUT_FACTOR:-}" ]; then
|
||||
if [ -n "${CI:-}" ]; then
|
||||
GITEA_TEST_E2E_TIMEOUT_FACTOR=3
|
||||
GITEA_TEST_E2E_TIMEOUT_FACTOR=4
|
||||
else
|
||||
GITEA_TEST_E2E_TIMEOUT_FACTOR=1
|
||||
fi
|
||||
@@ -111,4 +210,7 @@ export GITEA_TEST_E2E_PASSWORD
|
||||
export GITEA_TEST_E2E_EMAIL
|
||||
export GITEA_TEST_E2E_TIMEOUT_FACTOR
|
||||
|
||||
if [ "$PLAYWRIGHT_MODE" = "container" ]; then
|
||||
export PW_TEST_CONNECT_WS_ENDPOINT="ws://127.0.0.1:${PLAYWRIGHT_SERVER_PORT}/"
|
||||
fi
|
||||
pnpm exec playwright test "$@"
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run a compiled *.test binary. When TEST_SHARD is set, enumerate top-level
|
||||
# tests via -test.list and run only the shard's slice; TestMain skips
|
||||
# environment setup in -test.list mode. Without TEST_SHARD, runs the binary
|
||||
# directly.
|
||||
|
||||
BINARY=${1:?usage: $0 BINARY}
|
||||
|
||||
if [ -z "${TEST_SHARD:-}" ]; then
|
||||
exec "$BINARY"
|
||||
fi
|
||||
|
||||
if ! [[ "${TEST_TOTAL_SHARDS:-}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "TEST_TOTAL_SHARDS must be a positive integer, got: ${TEST_TOTAL_SHARDS:-}" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! [[ "$TEST_SHARD" =~ ^[1-9][0-9]*$ ]] || [ "$TEST_SHARD" -gt "$TEST_TOTAL_SHARDS" ]; then
|
||||
echo "TEST_SHARD must be in [1, $TEST_TOTAL_SHARDS], got: $TEST_SHARD" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
NAMES=$("$BINARY" -test.list='^Test' | LC_ALL=C sort -u | awk -v r=$((TEST_SHARD - 1)) -v t="$TEST_TOTAL_SHARDS" '(NR - 1) % t == r')
|
||||
if [ -z "$NAMES" ]; then
|
||||
echo "shard $TEST_SHARD/$TEST_TOTAL_SHARDS has no tests assigned" >&2
|
||||
exit 1
|
||||
fi
|
||||
PATTERN=$(echo "$NAMES" | paste -sd '|' -)
|
||||
echo "Running shard $TEST_SHARD/$TEST_TOTAL_SHARDS ($(echo "$NAMES" | wc -l | tr -d ' ') tests)"
|
||||
exec "$BINARY" -test.run "^($PATTERN)\$"
|
||||
Reference in New Issue
Block a user