diff --git a/.gitignore b/.gitignore index d997c141b9..76a7578646 100644 --- a/.gitignore +++ b/.gitignore @@ -120,5 +120,3 @@ prime/ # A Makefile for custom make targets Makefile.local -build/ -dist/ diff --git a/build/generate-bindata.go b/build/generate-bindata.go new file mode 100644 index 0000000000..9ae9c1b15e --- /dev/null +++ b/build/generate-bindata.go @@ -0,0 +1,27 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "fmt" + "os" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/assetfs" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("usage: ./generate-bindata {local-directory} {bindata-filename}") + os.Exit(1) + } + + dir, filename := os.Args[1], os.Args[2] + fmt.Printf("generating bindata for %s to %s\n", dir, filename) + if err := assetfs.GenerateEmbedBindata(dir, filename); err != nil { + fmt.Printf("failed: %s\n", err.Error()) + os.Exit(1) + } +} diff --git a/build/generate-emoji.go b/build/generate-emoji.go new file mode 100644 index 0000000000..f276c9e6f8 --- /dev/null +++ b/build/generate-emoji.go @@ -0,0 +1,219 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Copyright 2015 Kenneth Shaw +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "flag" + "fmt" + "go/format" + "io" + "log" + "net/http" + "os" + "regexp" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" +) + +const ( + gemojiURL = "https://raw.githubusercontent.com/rhysd/gemoji/537ff2d7e0496e9964824f7f73ec7ece88c9765a/db/emoji.json" + maxUnicodeVersion = 16 +) + +var flagOut = flag.String("o", "modules/emoji/emoji_data.go", "out") + +// Gemoji is a set of emoji data. +type Gemoji []Emoji + +// Emoji represents a single emoji and associated data. +type Emoji struct { + Emoji string `json:"emoji"` + Description string `json:"description,omitempty"` + Aliases []string `json:"aliases"` + UnicodeVersion string `json:"unicode_version,omitempty"` + SkinTones bool `json:"skin_tones,omitempty"` +} + +// Don't include some fields in JSON +func (e Emoji) MarshalJSON() ([]byte, error) { + type emoji Emoji + x := emoji(e) + x.UnicodeVersion = "" + x.Description = "" + x.SkinTones = false + return json.Marshal(x) +} + +func main() { + flag.Parse() + + // generate data + buf, err := generate() + if err != nil { + log.Fatalf("generate err: %v", err) + } + + // write + err = os.WriteFile(*flagOut, buf, 0o644) + if err != nil { + log.Fatalf("WriteFile err: %v", err) + } +} + +var replacer = strings.NewReplacer( + "main.Gemoji", "Gemoji", + "main.Emoji", "\n", + "}}", "},\n}", + ", Description:", ", ", + ", Aliases:", ", ", + ", UnicodeVersion:", ", ", + ", SkinTones:", ", ", +) + +var emojiRE = regexp.MustCompile(`\{Emoji:"([^"]*)"`) + +func generate() ([]byte, error) { + // load gemoji data + res, err := http.Get(gemojiURL) + if err != nil { + return nil, err + } + defer res.Body.Close() + + // read all + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + // unmarshal + var data Gemoji + err = json.Unmarshal(body, &data) + if err != nil { + return nil, err + } + + skinTones := make(map[string]string) + + skinTones["\U0001f3fb"] = "Light Skin Tone" + skinTones["\U0001f3fc"] = "Medium-Light Skin Tone" + skinTones["\U0001f3fd"] = "Medium Skin Tone" + skinTones["\U0001f3fe"] = "Medium-Dark Skin Tone" + skinTones["\U0001f3ff"] = "Dark Skin Tone" + + var tmp Gemoji + + // filter out emoji that require greater than max unicode version + for i := range data { + val, _ := strconv.ParseFloat(data[i].UnicodeVersion, 64) + if int(val) <= maxUnicodeVersion { + tmp = append(tmp, data[i]) + } + } + data = tmp + + sort.Slice(data, func(i, j int) bool { + return data[i].Aliases[0] < data[j].Aliases[0] + }) + + aliasMap := make(map[string]int, len(data)) + + for i, e := range data { + if e.Emoji == "" || len(e.Aliases) == 0 { + continue + } + for _, a := range e.Aliases { + if a == "" { + continue + } + aliasMap[a] = i + } + } + + // gitea customizations + i, ok := aliasMap["tada"] + if ok { + data[i].Aliases = append(data[i].Aliases, "hooray") + } + i, ok = aliasMap["laughing"] + if ok { + data[i].Aliases = append(data[i].Aliases, "laugh") + } + + // write a JSON file to use with tribute (write before adding skin tones since we can't support them there yet) + file, _ := json.MarshalIndent(data, "", " ") + _ = os.WriteFile("assets/emoji.json", append(file, '\n'), 0o644) + + // Add skin tones to emoji that support it + var ( + s []string + newEmoji string + newDescription string + newData Emoji + ) + + for i := range data { + if data[i].SkinTones { + for k, v := range skinTones { + s = strings.Split(data[i].Emoji, "") + + if utf8.RuneCountInString(data[i].Emoji) == 1 { + s = append(s, k) + } else { + // insert into slice after first element because all emoji that support skin tones + // have that modifier placed at this spot + s = append(s, "") + copy(s[2:], s[1:]) + s[1] = k + } + + newEmoji = strings.Join(s, "") + newDescription = data[i].Description + ": " + v + newAlias := data[i].Aliases[0] + "_" + strings.ReplaceAll(v, " ", "_") + + newData = Emoji{newEmoji, newDescription, []string{newAlias}, "12.0", false} + data = append(data, newData) + } + } + } + + sort.Slice(data, func(i, j int) bool { + return data[i].Aliases[0] < data[j].Aliases[0] + }) + + // add header + str := replacer.Replace(fmt.Sprintf(hdr, gemojiURL, data)) + + // change the format of the unicode string + str = emojiRE.ReplaceAllStringFunc(str, func(s string) string { + var err error + s, err = strconv.Unquote(s[len("{Emoji:"):]) + if err != nil { + panic(err) + } + return "{" + strconv.QuoteToASCII(s) + }) + + // format + return format.Source([]byte(str)) +} + +const hdr = ` +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + + +package emoji + +// Code generated by build/generate-emoji.go. DO NOT EDIT. +// Sourced from %s +var GemojiData = %#v +` diff --git a/build/generate-gitignores.go b/build/generate-gitignores.go new file mode 100644 index 0000000000..7c29ed69fe --- /dev/null +++ b/build/generate-gitignores.go @@ -0,0 +1,126 @@ +//go:build ignore + +package main + +import ( + "archive/tar" + "compress/gzip" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "path" + "path/filepath" + "strings" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util" +) + +func main() { + var ( + prefix = "gitea-gitignore" + url = "https://api.github.com/repos/github/gitignore/tarball" + githubApiToken = "" + githubUsername = "" + destination = "" + ) + + flag.StringVar(&destination, "dest", "options/gitignore/", "destination for the gitignores") + flag.StringVar(&githubUsername, "username", "", "github username") + flag.StringVar(&githubApiToken, "token", "", "github api token") + flag.Parse() + + file, err := os.CreateTemp(os.TempDir(), prefix) + if err != nil { + log.Fatalf("Failed to create temp file. %s", err) + } + + defer util.Remove(file.Name()) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.Fatalf("Failed to download archive. %s", err) + } + + if len(githubApiToken) > 0 && len(githubUsername) > 0 { + req.SetBasicAuth(githubUsername, githubApiToken) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatalf("Failed to download archive. %s", err) + } + defer resp.Body.Close() + + if _, err := io.Copy(file, resp.Body); err != nil { + log.Fatalf("Failed to copy archive to file. %s", err) + } + + if _, err := file.Seek(0, 0); err != nil { + log.Fatalf("Failed to reset seek on archive. %s", err) + } + + gz, err := gzip.NewReader(file) + if err != nil { + log.Fatalf("Failed to gunzip the archive. %s", err) + } + + tr := tar.NewReader(gz) + + filesToCopy := make(map[string]string, 0) + + for { + hdr, err := tr.Next() + + if err == io.EOF { + break + } + + if err != nil { + log.Fatalf("Failed to iterate archive. %s", err) + } + + if filepath.Ext(hdr.Name) != ".gitignore" { + continue + } + + if hdr.Typeflag == tar.TypeSymlink { + fmt.Printf("Found symlink %s -> %s\n", hdr.Name, hdr.Linkname) + filesToCopy[strings.TrimSuffix(filepath.Base(hdr.Name), ".gitignore")] = strings.TrimSuffix(filepath.Base(hdr.Linkname), ".gitignore") + continue + } + + out, err := os.Create(path.Join(destination, strings.TrimSuffix(filepath.Base(hdr.Name), ".gitignore"))) + if err != nil { + log.Fatalf("Failed to create new file. %s", err) + } + + defer out.Close() + + if _, err := io.Copy(out, tr); err != nil { + log.Fatalf("Failed to write new file. %s", err) + } else { + fmt.Printf("Written %s\n", out.Name()) + } + } + + for dst, src := range filesToCopy { + // Read all content of src to data + src = path.Join(destination, src) + data, err := os.ReadFile(src) + if err != nil { + log.Fatalf("Failed to read src file. %s", err) + } + // Write data to dst + dst = path.Join(destination, dst) + err = os.WriteFile(dst, data, 0o644) + if err != nil { + log.Fatalf("Failed to write new file. %s", err) + } + fmt.Printf("Written (copy of %s) %s\n", src, dst) + } + + fmt.Println("Done") +} diff --git a/build/generate-go-licenses.go b/build/generate-go-licenses.go new file mode 100644 index 0000000000..eee01cf371 --- /dev/null +++ b/build/generate-go-licenses.go @@ -0,0 +1,239 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" +) + +// regexp is based on go-license, excluding README and NOTICE +// https://github.com/google/go-licenses/blob/master/licenses/find.go +// also defined in vite.config.ts +var licenseRe = regexp.MustCompile(`^(?i)((UN)?LICEN(S|C)E|COPYING).*$`) + +// primaryLicenseRe matches exact primary license filenames without suffixes. +// When a directory has both primary and variant files (e.g. LICENSE and +// LICENSE.docs), only the primary files are kept. +var primaryLicenseRe = regexp.MustCompile(`^(?i)(LICEN[SC]E|COPYING)$`) + +// ignoredNames are LicenseEntry.Name values to exclude from the output. +var ignoredNames = map[string]bool{ + "code.gitea.io/gitea": true, + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/options/license": true, +} + +var excludedExt = map[string]bool{ + ".gitignore": true, + ".go": true, + ".mod": true, + ".sum": true, + ".toml": true, + ".yaml": true, + ".yml": true, +} + +type ModuleInfo struct { + Path string + Dir string + PkgDirs []string // directories of packages imported from this module +} + +type LicenseEntry struct { + Name string `json:"name"` + Path string `json:"path"` + LicenseText string `json:"licenseText"` +} + +// getModules returns all dependency modules with their local directory paths +// and the package directories used from each module. +func getModules(goCmd string) []ModuleInfo { + cmd := exec.Command(goCmd, "list", "-deps", "-f", + "{{if .Module}}{{.Module.Path}}\t{{.Module.Dir}}\t{{.Dir}}{{end}}", "./...") + cmd.Stderr = os.Stderr + // Use GOOS=linux with CGO to ensure we capture all platform-specific + // dependencies, matching the CI environment. + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=1") + output, err := cmd.Output() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to run 'go list -deps': %v\n", err) + os.Exit(1) + } + + var modules []ModuleInfo + seen := make(map[string]int) // module path -> index in modules + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) != 3 { + continue + } + modPath, modDir, pkgDir := parts[0], parts[1], parts[2] + if idx, ok := seen[modPath]; ok { + modules[idx].PkgDirs = append(modules[idx].PkgDirs, pkgDir) + } else { + seen[modPath] = len(modules) + modules = append(modules, ModuleInfo{ + Path: modPath, + Dir: modDir, + PkgDirs: []string{pkgDir}, + }) + } + } + return modules +} + +// findLicenseFiles scans a module's root directory and its used package +// directories for license files. It also walks up from each package directory +// to the module root, scanning intermediate directories. Subdirectory licenses +// are only included if their text differs from the root license(s). +func findLicenseFiles(mod ModuleInfo) []LicenseEntry { + var entries []LicenseEntry + seenTexts := make(map[string]bool) + + // First, collect root-level license files. + entries = append(entries, scanDirForLicenses(mod.Dir, mod.Path, "")...) + for _, e := range entries { + seenTexts[e.LicenseText] = true + } + + // Then check each package directory and all intermediate parent directories + // up to the module root for license files with unique text. + seenDirs := map[string]bool{mod.Dir: true} + for _, pkgDir := range mod.PkgDirs { + for dir := pkgDir; dir != mod.Dir && strings.HasPrefix(dir, mod.Dir); dir = filepath.Dir(dir) { + if seenDirs[dir] { + continue + } + seenDirs[dir] = true + for _, e := range scanDirForLicenses(dir, mod.Path, mod.Dir) { + if !seenTexts[e.LicenseText] { + seenTexts[e.LicenseText] = true + entries = append(entries, e) + } + } + } + } + return entries +} + +// scanDirForLicenses reads a single directory for license files and returns entries. +// If moduleRoot is non-empty, paths are made relative to it. +func scanDirForLicenses(dir, modulePath, moduleRoot string) []LicenseEntry { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var entries []LicenseEntry + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !licenseRe.MatchString(name) { + continue + } + if excludedExt[strings.ToLower(filepath.Ext(name))] { + continue + } + + content, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + + entryName := modulePath + entryPath := modulePath + "/" + name + if moduleRoot != "" { + rel, _ := filepath.Rel(moduleRoot, dir) + if rel != "." { + relSlash := filepath.ToSlash(rel) + entryName = modulePath + "/" + relSlash + entryPath = modulePath + "/" + relSlash + "/" + name + } + } + + entries = append(entries, LicenseEntry{ + Name: entryName, + Path: entryPath, + LicenseText: string(content), + }) + } + + // When multiple license files exist, prefer primary files (e.g. LICENSE) + // over variants with suffixes (e.g. LICENSE.docs, LICENSE-2.0.txt). + // If no primary file exists, keep only the first variant. + if len(entries) > 1 { + var primary []LicenseEntry + for _, e := range entries { + fileName := e.Path[strings.LastIndex(e.Path, "/")+1:] + if primaryLicenseRe.MatchString(fileName) { + primary = append(primary, e) + } + } + if len(primary) > 0 { + return primary + } + return entries[:1] + } + + return entries +} + +func main() { + if len(os.Args) != 2 { + fmt.Println("usage: go run generate-go-licenses.go ") + os.Exit(1) + } + + out := os.Args[1] + + goCmd := "go" + if env := os.Getenv("GO"); env != "" { + goCmd = env + } + + modules := getModules(goCmd) + + var entries []LicenseEntry + for _, mod := range modules { + entries = append(entries, findLicenseFiles(mod)...) + } + + entries = slices.DeleteFunc(entries, func(e LicenseEntry) bool { + return ignoredNames[e.Name] + }) + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Path < entries[j].Path + }) + + jsonBytes, err := json.MarshalIndent(entries, "", " ") + if err != nil { + panic(err) + } + + // Ensure file has a final newline + if jsonBytes[len(jsonBytes)-1] != '\n' { + jsonBytes = append(jsonBytes, '\n') + } + + err = os.WriteFile(out, jsonBytes, 0o644) + if err != nil { + panic(err) + } +} diff --git a/build/generate-openapi.go b/build/generate-openapi.go new file mode 100644 index 0000000000..d0eab54269 --- /dev/null +++ b/build/generate-openapi.go @@ -0,0 +1,102 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// generate-openapi converts Gitea's Swagger 2.0 spec into an OpenAPI 3.0 spec. +// +// Gitea generates a Swagger 2.0 spec from code annotations (make generate-swagger). +// This tool converts it to OAS3 so that SDK generators and tools that require +// OAS3 (e.g. progenitor for Rust) can consume it directly. The conversion also +// deduplicates inline enum definitions into named schema components, producing +// cleaner SDK output with proper enum types instead of anonymous strings. +// +// Run: go run build/generate-openapi.go +// Output: templates/swagger/v1_openapi3_json.tmpl + +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "regexp" + "sort" + "strings" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/build/openapi3gen" + + "github.com/getkin/kin-openapi/openapi3" +) + +const ( + swaggerSpecPath = "templates/swagger/v1_json.tmpl" + openapi3OutPath = "templates/swagger/v1_openapi3_json.tmpl" + + appSubUrlVar = "{{.SwaggerAppSubUrl}}" + appVerVar = "{{.SwaggerAppVer}}" + appNameVar = "{{.SwaggerAppName}}" + + appSubUrlPlaceholder = "GITEA_APP_SUB_URL_PLACEHOLDER" + appVerPlaceholder = "0.0.0-gitea-placeholder" + appNamePlaceholder = "GiteaAppNamePlaceholder" +) + +var ( + appSubUrlRe = regexp.MustCompile(regexp.QuoteMeta(appSubUrlVar)) + appVerRe = regexp.MustCompile(regexp.QuoteMeta(appVerVar)) + appNameRe = regexp.MustCompile(regexp.QuoteMeta(appNameVar)) + + enumScanDirs = []string{ + "modules/structs", + "modules/commitstatus", + } +) + +func main() { + astEnumMap, err := openapi3gen.ScanSwaggerEnumTypes(enumScanDirs) + if err != nil { + log.Fatalf("scanning swagger:enum annotations: %v", err) + } + names := make([]string, 0, len(astEnumMap)) + for _, n := range astEnumMap { + names = append(names, n) + } + sort.Strings(names) + fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", ")) + + data, err := os.ReadFile(swaggerSpecPath) + if err != nil { + log.Fatalf("reading swagger spec: %v", err) + } + + cleaned := appSubUrlRe.ReplaceAll(data, []byte(appSubUrlPlaceholder)) + cleaned = appVerRe.ReplaceAll(cleaned, []byte(appVerPlaceholder)) + cleaned = appNameRe.ReplaceAll(cleaned, []byte(appNamePlaceholder)) + + oas3, err := openapi3gen.Convert(cleaned, astEnumMap) + if err != nil { + log.Fatalf("converting to openapi 3.0: %v", err) + } + + oas3.Servers = openapi3.Servers{ + {URL: appSubUrlPlaceholder + "/api/v1"}, + } + + out, err := json.MarshalIndent(oas3, "", " ") + if err != nil { + log.Fatalf("marshaling openapi 3.0: %v", err) + } + + result := strings.ReplaceAll(string(out), appSubUrlPlaceholder, appSubUrlVar) + result = strings.ReplaceAll(result, appVerPlaceholder, appVerVar) + result = strings.ReplaceAll(result, appNamePlaceholder, appNameVar) + result = strings.TrimSpace(result) + + if err := os.WriteFile(openapi3OutPath, []byte(result), 0o644); err != nil { + log.Fatalf("writing openapi 3.0 spec: %v", err) + } + + fmt.Printf("Generated %s\n", openapi3OutPath) +} diff --git a/build/openapi3gen/convert.go b/build/openapi3gen/convert.go new file mode 100644 index 0000000000..312f7e444e --- /dev/null +++ b/build/openapi3gen/convert.go @@ -0,0 +1,281 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openapi3gen + +import ( + "fmt" + "regexp" + "strings" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" + "github.com/getkin/kin-openapi/openapi3" +) + +// rxDeprecated matches "deprecated" as a word at the start of a description +// or preceded by whitespace/punctuation that indicates a leading marker (e.g. +// "Deprecated: true", "deprecated (use X instead)"). Rejects negated phrases +// like "not deprecated" or "previously deprecated, now supported". +var rxDeprecated = regexp.MustCompile(`(?i)(?:^|[\n.;])\s*deprecated\b`) + +// Convert parses a Swagger 2.0 spec and returns an OAS3 spec, applying +// Gitea-specific post-processing: file-schema fixups, URI formats, +// deprecated flags, and shared-enum extraction. +// +// astEnumMap is a value-set-key → Go-type-name map (built by +// ScanSwaggerEnumTypes). If a shared enum in the spec has no entry in the +// map, Convert returns an error — no fallback naming. +func Convert(swaggerJSON []byte, astEnumMap map[string]string) (*openapi3.T, error) { + var swagger2 openapi2.T + if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil { + return nil, fmt.Errorf("parsing swagger 2.0: %w", err) + } + + oas3, err := openapi2conv.ToV3(&swagger2) + if err != nil { + return nil, fmt.Errorf("converting to openapi 3.0: %w", err) + } + + fixFileSchemas(oas3) + addURIFormats(oas3) + addDeprecatedFlags(oas3) + if err := extractSharedEnums(oas3, astEnumMap); err != nil { + return nil, err + } + return oas3, nil +} + +func fixFileSchemas(doc *openapi3.T) { + for _, pathItem := range doc.Paths.Map() { + for _, op := range []*openapi3.Operation{ + pathItem.Get, pathItem.Post, pathItem.Put, pathItem.Patch, + pathItem.Delete, pathItem.Head, pathItem.Options, pathItem.Trace, + } { + if op == nil { + continue + } + for _, resp := range op.Responses.Map() { + if resp.Value == nil { + continue + } + for _, mediaType := range resp.Value.Content { + fixSchema(mediaType.Schema) + } + } + if op.RequestBody != nil && op.RequestBody.Value != nil { + for _, mediaType := range op.RequestBody.Value.Content { + fixSchema(mediaType.Schema) + } + } + } + } +} + +// fixSchema rewrites any "type: file" schemas to the OAS3 equivalent +// (type: string, format: binary), recursing into Properties, Items, and +// AllOf/OneOf/AnyOf/Not branches. $ref nodes are skipped so shared schemas +// are rewritten exactly once when visited through their declaration. +func fixSchema(ref *openapi3.SchemaRef) { + if ref == nil || ref.Value == nil || ref.Ref != "" { + return + } + s := ref.Value + if s.Type.Is("file") { + s.Type = &openapi3.Types{"string"} + s.Format = "binary" + } + for _, p := range s.Properties { + fixSchema(p) + } + fixSchema(s.Items) + for _, sub := range s.AllOf { + fixSchema(sub) + } + for _, sub := range s.OneOf { + fixSchema(sub) + } + for _, sub := range s.AnyOf { + fixSchema(sub) + } + fixSchema(s.Not) +} + +// addURIFormats sets format: uri on string properties whose names indicate +// they hold URLs. This information is lost in Swagger 2.0 but is valuable +// for code generators. +func addURIFormats(doc *openapi3.T) { + if doc.Components == nil { + return + } + for _, schemaRef := range doc.Components.Schemas { + if schemaRef.Value == nil { + continue + } + for propName, propRef := range schemaRef.Value.Properties { + if propRef == nil || propRef.Value == nil || propRef.Ref != "" { + continue + } + prop := propRef.Value + if !prop.Type.Is("string") || prop.Format != "" { + continue + } + if isURLProperty(propName) { + prop.Format = "uri" + } + } + } +} + +func isURLProperty(name string) bool { + if strings.HasSuffix(name, "_url") { + return true + } + switch name { + case "url", "html_url", "clone_url": + return true + } + return false +} + +// addDeprecatedFlags sets deprecated: true on schema properties whose +// description starts with a "deprecated" marker (e.g. "Deprecated: true" +// or "deprecated (use X instead)"). Does not match negated phrases. +func addDeprecatedFlags(doc *openapi3.T) { + if doc.Components == nil { + return + } + for _, schemaRef := range doc.Components.Schemas { + if schemaRef.Value == nil { + continue + } + for _, propRef := range schemaRef.Value.Properties { + if propRef == nil || propRef.Value == nil || propRef.Ref != "" { + continue + } + if rxDeprecated.MatchString(propRef.Value.Description) { + propRef.Value.Deprecated = true + } + } + } +} + +type enumUsage struct { + schemaName string + propName string + propRef *openapi3.SchemaRef + inItems bool +} + +// extractSharedEnums finds identical enum arrays used by multiple schema +// properties, creates a standalone named schema for each, and replaces +// the inline enums with $ref pointers. +// +// If the derived enum name collides with an existing component schema, or +// no // swagger:enum annotation matches the value set, generation aborts +// with an actionable error — there are no silent fallbacks. +func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error { + if doc.Components == nil { + return nil + } + + enumGroups := map[string][]enumUsage{} + + for schemaName, schemaRef := range doc.Components.Schemas { + if schemaRef.Value == nil { + continue + } + for propName, propRef := range schemaRef.Value.Properties { + if propRef == nil || propRef.Value == nil || propRef.Ref != "" { + continue + } + if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") { + key := EnumKey(propRef.Value.Enum) + enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, false}) + } + if propRef.Value.Type.Is("array") && propRef.Value.Items != nil && + propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" && + len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") { + key := EnumKey(propRef.Value.Items.Value.Enum) + enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, true}) + } + } + } + + for key, usages := range enumGroups { + if len(usages) < 2 { + continue + } + + enumName, err := deriveEnumName(key, usages, astEnumMap) + if err != nil { + return err + } + if _, exists := doc.Components.Schemas[enumName]; exists { + return fmt.Errorf("enum name collision: %s already exists as a component schema", enumName) + } + + var enumValues []any + if usages[0].inItems { + enumValues = usages[0].propRef.Value.Items.Value.Enum + } else { + enumValues = usages[0].propRef.Value.Enum + } + + doc.Components.Schemas[enumName] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: enumValues, + }, + } + + ref := "#/components/schemas/" + enumName + + for _, usage := range usages { + if usage.inItems { + usage.propRef.Value.Items = &openapi3.SchemaRef{Ref: ref} + } else { + old := usage.propRef.Value + if old.Description == "" && !old.Deprecated && old.Format == "" { + usage.propRef.Ref = ref + usage.propRef.Value = nil + } else { + usage.propRef.Value = &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{ + {Ref: ref}, + }, + Description: old.Description, + Deprecated: old.Deprecated, + Format: old.Format, + } + } + } + } + } + return nil +} + +// deriveEnumName looks up a shared enum's Go type name from astEnumMap by +// value-set key. If no annotation matches, returns an error identifying the +// offending properties and the fix. +func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string) (string, error) { + if name, ok := astEnumMap[key]; ok { + return name, nil + } + + props := map[string]bool{} + for _, u := range usages { + props[fmt.Sprintf("%s.%s", u.schemaName, u.propName)] = true + } + propList := make([]string, 0, len(props)) + for p := range props { + propList = append(propList, p) + } + return "", fmt.Errorf( + "no swagger:enum annotation matches value-set %q used by %d properties: %v; "+ + "fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus", + key, len(usages), propList, + ) +} diff --git a/build/openapi3gen/convert_test.go b/build/openapi3gen/convert_test.go new file mode 100644 index 0000000000..a9a715e6c2 --- /dev/null +++ b/build/openapi3gen/convert_test.go @@ -0,0 +1,170 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openapi3gen + +import ( + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestDeriveEnumName_hit(t *testing.T) { + key := EnumKey([]any{"red", "green", "blue"}) + astMap := map[string]string{key: "Color"} + usages := []enumUsage{{schemaName: "Paint", propName: "color"}} + got, err := deriveEnumName(key, usages, astMap) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "Color" { + t.Fatalf("got %q, want %q", got, "Color") + } +} + +func TestDeriveEnumName_miss(t *testing.T) { + key := EnumKey([]any{"x", "y"}) + usages := []enumUsage{{schemaName: "Thing", propName: "kind"}} + _, err := deriveEnumName(key, usages, map[string]string{}) + if err == nil { + t.Fatal("expected miss error, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "Thing.kind") { + t.Fatalf("error %q should list the missing usage", msg) + } + if !strings.Contains(msg, "swagger:enum") { + t.Fatalf("error %q should hint at the fix", msg) + } +} + +func TestExtractSharedEnums_usesASTMap(t *testing.T) { + doc := &openapi3.T{ + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "A": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "color": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"red", "green", "blue"}, + }}, + }, + }}, + "B": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "color": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"red", "green", "blue"}, + }}, + }, + }}, + }, + }, + } + astMap := map[string]string{EnumKey([]any{"red", "green", "blue"}): "Color"} + if err := extractSharedEnums(doc, astMap); err != nil { + t.Fatalf("extractSharedEnums: %v", err) + } + if _, ok := doc.Components.Schemas["Color"]; !ok { + t.Fatalf("expected Color schema to be extracted") + } +} + +func TestFixFileSchemas_recursesIntoNested(t *testing.T) { + fileType := func() *openapi3.SchemaRef { + return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"file"}}} + } + doc := &openapi3.T{ + Paths: openapi3.NewPaths(), + } + doc.Paths.Set("/upload", &openapi3.PathItem{ + Post: &openapi3.Operation{ + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "multipart/form-data": { + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "attachment": fileType(), + "items": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: fileType(), + }}, + "alt": {Value: &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{fileType()}, + }}, + "one": {Value: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{fileType()}, + }}, + "any": {Value: &openapi3.Schema{ + AnyOf: openapi3.SchemaRefs{fileType()}, + }}, + "not": {Value: &openapi3.Schema{ + Not: fileType(), + }}, + }, + }}, + }, + }, + }, + }, + Responses: openapi3.NewResponses(), + }, + }) + + fixFileSchemas(doc) + + props := doc.Paths.Value("/upload").Post.RequestBody.Value.Content["multipart/form-data"].Schema.Value.Properties + if !props["attachment"].Value.Type.Is("string") || props["attachment"].Value.Format != "binary" { + t.Errorf("nested property not fixed: %+v", props["attachment"].Value) + } + if !props["items"].Value.Items.Value.Type.Is("string") || props["items"].Value.Items.Value.Format != "binary" { + t.Errorf("array items not fixed: %+v", props["items"].Value.Items.Value) + } + if !props["alt"].Value.AllOf[0].Value.Type.Is("string") || props["alt"].Value.AllOf[0].Value.Format != "binary" { + t.Errorf("allOf branch not fixed: %+v", props["alt"].Value.AllOf[0].Value) + } + if !props["one"].Value.OneOf[0].Value.Type.Is("string") || props["one"].Value.OneOf[0].Value.Format != "binary" { + t.Errorf("oneOf branch not fixed: %+v", props["one"].Value.OneOf[0].Value) + } + if !props["any"].Value.AnyOf[0].Value.Type.Is("string") || props["any"].Value.AnyOf[0].Value.Format != "binary" { + t.Errorf("anyOf branch not fixed: %+v", props["any"].Value.AnyOf[0].Value) + } + if !props["not"].Value.Not.Value.Type.Is("string") || props["not"].Value.Not.Value.Format != "binary" { + t.Errorf("not branch not fixed: %+v", props["not"].Value.Not.Value) + } +} + +func TestExtractSharedEnums_missReturnsError(t *testing.T) { + doc := &openapi3.T{ + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "A": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "color": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"red", "green"}, + }}, + }, + }}, + "B": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "color": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"red", "green"}, + }}, + }, + }}, + }, + }, + } + if err := extractSharedEnums(doc, map[string]string{}); err == nil { + t.Fatal("expected miss error") + } +} diff --git a/build/openapi3gen/enumscan.go b/build/openapi3gen/enumscan.go new file mode 100644 index 0000000000..dd11620549 --- /dev/null +++ b/build/openapi3gen/enumscan.go @@ -0,0 +1,188 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Package openapi3gen converts Gitea's Swagger 2.0 spec to an OpenAPI 3.0 +// spec. It discovers Go enum type names by scanning swagger:enum annotations +// in the source tree, then names extracted shared-enum schemas accordingly. +package openapi3gen + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// EnumKey returns a canonical key for a set of enum values: values are +// stringified, sorted, and joined with "|". Used to match enum value sets +// across spec properties and scanned Go type declarations. +func EnumKey(values []any) string { + strs := make([]string, len(values)) + for i, v := range values { + strs[i] = fmt.Sprintf("%v", v) + } + sort.Strings(strs) + return strings.Join(strs, "|") +} + +var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`) + +// ScanSwaggerEnumTypes walks .go files under each dir and returns a map from +// a canonical value-set key (see EnumKey) to the Go type name declared with +// // swagger:enum TypeName. +// +// Returns an error on parse failure, on an annotation for a type whose +// constants can't be extracted, or on value-set collisions between two +// different enum types. +func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) { + fset := token.NewFileSet() + parsed := []*ast.File{} + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", dir, err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + if strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + path := filepath.Join(dir, entry.Name()) + file, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + parsed = append(parsed, file) + } + } + + enumTypes := map[string]string{} // typeName → "" (presence marker) + enumValues := map[string][]any{} // typeName → values + + // Pass 1: collect every // swagger:enum TypeName declaration. + for _, file := range parsed { + for _, decl := range file.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + if err := collectEnumType(gd, enumTypes); err != nil { + return nil, fmt.Errorf("%s: %w", fset.Position(gd.Pos()).Filename, err) + } + } + } + + // Pass 2: collect const values; now every annotated type is visible. + for _, file := range parsed { + for _, decl := range file.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.CONST { + continue + } + collectEnumValues(gd, enumTypes, enumValues) + } + } + + result := map[string]string{} + for typeName := range enumTypes { + values, ok := enumValues[typeName] + if !ok || len(values) == 0 { + return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName) + } + key := EnumKey(values) + if existing, ok := result[key]; ok && existing != typeName { + return nil, fmt.Errorf("swagger:enum value-set collision: %s and %s both use %q", existing, typeName, key) + } + result[key] = typeName + } + return result, nil +} + +// collectEnumType scans a `type` GenDecl for // swagger:enum annotations, +// handling both the lone form (`// swagger:enum Foo\n type Foo string`) +// where the comment group is attached to the GenDecl, and the grouped form: +// +// type ( +// // swagger:enum Foo +// Foo string +// ) +// +// where the comment group is attached to each TypeSpec. Caveat: Go's parser +// only attaches a CommentGroup when it is immediately adjacent to the decl. +// A blank line (not a `//` continuation line) between the comment and the +// declaration drops the Doc, so annotations MUST sit directly above their +// type. All current annotated files obey this — the rule is noted here so +// a future edit that inserts a blank line fails fast rather than silently. +func collectEnumType(gd *ast.GenDecl, enumTypes map[string]string) error { + if err := registerEnumAnnotation(gd.Doc, gd.Specs, enumTypes); err != nil { + return err + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Doc == nil { + continue + } + if err := registerEnumAnnotation(ts.Doc, []ast.Spec{ts}, enumTypes); err != nil { + return err + } + } + return nil +} + +func registerEnumAnnotation(doc *ast.CommentGroup, specs []ast.Spec, enumTypes map[string]string) error { + if doc == nil { + return nil + } + matches := rxSwaggerEnum.FindStringSubmatch(doc.Text()) + if len(matches) < 2 { + return nil + } + annotated := matches[1] + for _, spec := range specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if ts.Name.Name == annotated { + enumTypes[annotated] = "" + return nil + } + } + return fmt.Errorf("swagger:enum %s: no type declaration with that name in the same decl group; check for a typo", annotated) +} + +func collectEnumValues(gd *ast.GenDecl, enumTypes map[string]string, enumValues map[string][]any) { + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || vs.Type == nil { + continue + } + ident, ok := vs.Type.(*ast.Ident) + if !ok { + continue + } + if _, isEnum := enumTypes[ident.Name]; !isEnum { + continue + } + for _, val := range vs.Values { + lit, ok := val.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + unquoted, err := strconv.Unquote(lit.Value) + if err != nil { + continue + } + enumValues[ident.Name] = append(enumValues[ident.Name], unquoted) + } + } +} diff --git a/build/openapi3gen/enumscan_test.go b/build/openapi3gen/enumscan_test.go new file mode 100644 index 0000000000..2e5fe99db0 --- /dev/null +++ b/build/openapi3gen/enumscan_test.go @@ -0,0 +1,239 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openapi3gen + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnumKey_sortsAndJoins(t *testing.T) { + key := EnumKey([]any{"b", "a", "c"}) + if key != "a|b|c" { + t.Fatalf("EnumKey = %q, want %q", key, "a|b|c") + } +} + +func TestEnumKey_handlesNonStringValues(t *testing.T) { + key := EnumKey([]any{2, 1, 3}) + if key != "1|2|3" { + t.Fatalf("EnumKey = %q, want %q", key, "1|2|3") + } +} + +func TestScanSwaggerEnumTypes_basic(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +// Color is a primary color. +// swagger:enum Color +type Color string + +const ( + ColorRed Color = "red" + ColorGreen Color = "green" + ColorBlue Color = "blue" +) +` + if err := os.WriteFile(filepath.Join(dir, "color.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + wantKey := EnumKey([]any{"red", "green", "blue"}) + if got[wantKey] != "Color" { + t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color") + } +} + +func TestScanSwaggerEnumTypes_orphanAnnotation(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +// swagger:enum Sttype +type StateType string + +const ( + StateOpen StateType = "open" +) +` + if err := os.WriteFile(filepath.Join(dir, "typo.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ScanSwaggerEnumTypes([]string{dir}) + if err == nil { + t.Fatal("expected error for annotation referencing a non-matching type name") + } + if !strings.Contains(err.Error(), "Sttype") { + t.Fatalf("error %q should mention the typo'd name Sttype", err.Error()) + } +} + +func TestScanSwaggerEnumTypes_collision(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +// swagger:enum Alpha +type Alpha string +const ( + AlphaX Alpha = "x" + AlphaY Alpha = "y" +) + +// swagger:enum Beta +type Beta string +const ( + BetaX Beta = "x" + BetaY Beta = "y" +) +` + if err := os.WriteFile(filepath.Join(dir, "dup.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ScanSwaggerEnumTypes([]string{dir}) + if err == nil { + t.Fatal("expected collision error, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "Alpha") || !strings.Contains(msg, "Beta") { + t.Fatalf("error %q should mention both Alpha and Beta", msg) + } +} + +func TestScanSwaggerEnumTypes_parseFailure(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "bad.go"), []byte("package fixture\nfunc Foo() {"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ScanSwaggerEnumTypes([]string{dir}) + if err == nil { + t.Fatal("expected parse error, got nil") + } +} + +func TestScanSwaggerEnumTypes_annotationWithoutConsts(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +// swagger:enum Lonely +type Lonely string +` + if err := os.WriteFile(filepath.Join(dir, "lonely.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ScanSwaggerEnumTypes([]string{dir}) + if err == nil { + t.Fatal("expected error for annotation without consts") + } + if !strings.Contains(err.Error(), "Lonely") { + t.Fatalf("error %q should mention Lonely", err.Error()) + } +} + +func TestScanSwaggerEnumTypes_constsAndTypeInDifferentFiles(t *testing.T) { + dir := t.TempDir() + // Name ordering: `a_consts.go` < `b_type.go`, so readdir returns consts first. + // Old single-pass scanner would miss the values; two-pass must not. + constsSrc := `package fixture + +const ( + HueA Hue = "a" + HueB Hue = "b" +) +` + typeSrc := `package fixture + +// swagger:enum Hue +type Hue string +` + if err := os.WriteFile(filepath.Join(dir, "a_consts.go"), []byte(constsSrc), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "b_type.go"), []byte(typeSrc), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + wantKey := EnumKey([]any{"a", "b"}) + if got[wantKey] != "Hue" { + t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue") + } +} + +func TestScanSwaggerEnumTypes_constsBeforeType(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +const ( + ShadeDark Shade = "dark" + ShadeLight Shade = "light" +) + +// swagger:enum Shade +type Shade string +` + if err := os.WriteFile(filepath.Join(dir, "shade.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + wantKey := EnumKey([]any{"dark", "light"}) + if got[wantKey] != "Shade" { + t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade") + } +} + +func TestScanSwaggerEnumTypes_groupedTypeDecl(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +type ( + // swagger:enum Color + Color string + // swagger:enum Shade + Shade string +) + +const ( + ColorRed Color = "red" + ColorBlue Color = "blue" +) + +const ( + ShadeDark Shade = "dark" + ShadeLight Shade = "light" +) +` + if err := os.WriteFile(filepath.Join(dir, "grouped.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + colorKey := EnumKey([]any{"red", "blue"}) + shadeKey := EnumKey([]any{"dark", "light"}) + if got[colorKey] != "Color" { + t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color") + } + if got[shadeKey] != "Shade" { + t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade") + } +} diff --git a/build/test-env-check.sh b/build/test-env-check.sh new file mode 100755 index 0000000000..38e5a28823 --- /dev/null +++ b/build/test-env-check.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +set -e + +if [ ! -f ./build/test-env-check.sh ]; then + echo "${0} can only be executed in gitea source root directory" + exit 1 +fi + + +echo "check uid ..." + +# the uid of gitea defined in "https://gitea.com/gitea/test-env" is 1000 +gitea_uid=$(id -u gitea) +if [ "$gitea_uid" != "1000" ]; then + echo "The uid of linux user 'gitea' is expected to be 1000, but it is $gitea_uid" + exit 1 +fi + +cur_uid=$(id -u) +if [ "$cur_uid" != "0" -a "$cur_uid" != "$gitea_uid" ]; then + echo "The uid of current linux user is expected to be 0 or $gitea_uid, but it is $cur_uid" + exit 1 +fi diff --git a/build/test-env-prepare.sh b/build/test-env-prepare.sh new file mode 100755 index 0000000000..0c5bc25f11 --- /dev/null +++ b/build/test-env-prepare.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -e + +if [ ! -f ./build/test-env-prepare.sh ]; then + echo "${0} can only be executed in gitea source root directory" + exit 1 +fi + +echo "change the owner of files to gitea ..." +chown -R gitea:gitea . diff --git a/build/update-locales.sh b/build/update-locales.sh new file mode 100755 index 0000000000..5316746f30 --- /dev/null +++ b/build/update-locales.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# this script runs in alpine image which only has `sh` shell +if [ ! -f ./options/locale/locale_en-US.json ]; then + echo "please run this script in the root directory of the project" + exit 1 +fi + +mv ./options/locale/locale_en-US.json ./options/ + +# Remove translation under 25% of en_us +baselines=$(cat "./options/locale_en-US.json" | wc -l) +baselines=$((baselines / 4)) +for filename in ./options/locale/*.json; do + lines=$(cat "$filename" | wc -l) + if [ "$lines" -lt "$baselines" ]; then + echo "Removing $filename: $lines/$baselines" + rm "$filename" + fi +done + +mv ./options/locale_en-US.json ./options/locale/