Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcd207cd51 | |||
| 1913b4c8c2 | |||
| 5ca1c888c0 | |||
| 8e0388c9d8 | |||
| cd4c701cb6 | |||
| b72f88e78b | |||
| 1935889f6b | |||
| 9ebe1b26b1 | |||
| 1322b5e905 | |||
| 4d43553c91 | |||
| 3aec6c2cae | |||
| 558b36da8c | |||
| e42214930a | |||
| 539619be2f | |||
| c124527ca2 | |||
| 8fce854f18 | |||
| 6bd9548b2a | |||
| e1b0c74d24 | |||
| 96661dcb7c | |||
| 5665bc545e | |||
| 6c4a4ca819 | |||
| 15188fc0ea | |||
| df58aacc30 | |||
| 659f3f2537 | |||
| 26376b7d11 | |||
| 6575d3fce2 | |||
| b297ea2204 | |||
| 161ca23836 | |||
| d553c87a9d | |||
| f2ec3d5c02 | |||
| b3acbc9789 | |||
| 178e8fffe2 | |||
| ac1a726c3b | |||
| 2f767e91cb | |||
| 5cb5cec7ef | |||
| 5209dea127 | |||
| 5c22bb04b5 | |||
| 73ec0b52f6 | |||
| 49299c6a32 | |||
| d6d0d5a11f | |||
| c948696488 | |||
| 03b3a10541 | |||
| 81aab5d9ea | |||
| 635a13d277 | |||
| 3a836b69d9 | |||
| 3e1e179bf0 | |||
| 6be3e5c879 | |||
| ed909919f6 | |||
| e339646067 | |||
| a86a9afb1a | |||
| eaf581071d | |||
| 7da7e35d89 | |||
| 5a80b8da33 | |||
| 0b21fe859e | |||
| 45af52611c | |||
| 9fff67ab57 | |||
| 3eb649a1a6 | |||
| 06f8ab3d1a | |||
| 3918e8ef9a | |||
| 01d38e13f9 | |||
| 0389410efc | |||
| 3e156e8307 | |||
| 6509bd1eb7 | |||
| 6ac7c0c774 | |||
| 4a687a9438 | |||
| 92ca601aa6 | |||
| e866d16ee6 | |||
| 5a1772b026 | |||
| aba8021344 | |||
| 7cbbfb7505 | |||
| 7f45e98630 | |||
| 42b0ff182c | |||
| 32d5a292c7 | |||
| 33ba1159c3 | |||
| e59837b250 | |||
| 5c1e1cc8cc | |||
| 6ea5dd37aa | |||
| 619295f469 | |||
| eced91be74 | |||
| 902e3b5edd | |||
| b6671ee1f9 | |||
| a52835b8ee | |||
| c64bafbe80 | |||
| 21fb789d3c | |||
| 558bf37fce | |||
| 746f1a5a50 | |||
| 349a326881 | |||
| 7dc598104b | |||
| 402166589b | |||
| 2827fa0a4c | |||
| 979d6f5964 | |||
| 396220368f | |||
| 53b2d5b754 | |||
| 5db84e3932 | |||
| 02cb4ae1a1 | |||
| 7f2aaa84bd | |||
| 6f16459e13 | |||
| 5cf91a12bc | |||
| 0ab3b7dbd7 | |||
| 02495327ee | |||
| 6f5c40716d | |||
| 1eff03ab21 | |||
| e3e2cb4543 | |||
| a15139f70b | |||
| abf961dd1e | |||
| b34381e8da | |||
| f9653411a7 | |||
| cd2e8b4d34 | |||
| acf9b4a4da | |||
| 23af404ae4 | |||
| a5b4f24b48 | |||
| f485f14615 | |||
| b2d2a3b622 | |||
| ba9907ba41 | |||
| b1b64a3b4e | |||
| 3cddb46053 | |||
| 0a0cc16528 | |||
| da41d7072f | |||
| cb3817f5bc | |||
| f657f58fbb | |||
| 1709566fa6 | |||
| 95c136d838 | |||
| 48f32ae961 | |||
| dce87fcb5d | |||
| 7004170d64 | |||
| c045c6abfc | |||
| ce35e3a603 | |||
| 3e4cb4d2e5 | |||
| ba361c609f | |||
| 7aaf8dcbb7 | |||
| 128b120ad9 | |||
| 3f817babd3 | |||
| 6290ff07e4 | |||
| c4e51ff55c | |||
| b707c5aff9 | |||
| 2db1f4eaf6 | |||
| 25499fb183 | |||
| 7c15301228 | |||
| e4718f5036 | |||
| 581bfa5f31 | |||
| 8ae663e15e | |||
| 4bc962adbf | |||
| ca841716db | |||
| 117daf51c3 | |||
| a2e0735a26 | |||
| 1a46a8f14f | |||
| b18519e8b9 | |||
| 94649efed0 | |||
| a52ac1bf61 | |||
| 5da4b3b314 | |||
| 75e2a21b89 | |||
| e82fe7d021 | |||
| 24a9bfb30d | |||
| 257908e083 | |||
| 2c3aad51af | |||
| 66a6a2afc1 | |||
| 74935e3bed | |||
| bc95ecf4d5 | |||
| a35fb4695c | |||
| 70c31a4953 | |||
| 6c913abbda | |||
| 878671ebc9 | |||
| c7cfcf894b | |||
| bbe3e570fe | |||
| 26bbe690fd | |||
| bfa9043bc8 | |||
| b1a9b09f5b |
@@ -120,5 +120,3 @@ prime/
|
||||
|
||||
# A Makefile for custom make targets
|
||||
Makefile.local
|
||||
build/
|
||||
dist/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<name>MokoGitea</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Moko fork of Gitea — adding project board REST API endpoints and custom enhancements</description>
|
||||
<version>05.14.00</version>
|
||||
<version>05.46.00</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 05.14.00
|
||||
# VERSION: 05.46.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+23
-3
@@ -14,8 +14,9 @@ All notable changes to MokoGitea are documented here. Versions follow the format
|
||||
* Domain restriction on packages and keys (comma-separated allowed domains)
|
||||
* RepoScope enforcement — packages scoped to specific repos
|
||||
* Configurable license key prefix per organization
|
||||
* Master key always visible with Regenerate button
|
||||
* Master key auto-generates, sorts first in key list
|
||||
* License package creation at repo level via modal
|
||||
* Key generation modal with licensee name, email, and domain fields
|
||||
* Manual release-to-stream mapping with UI selector
|
||||
* Double confirmation modals for permanent deletion
|
||||
* Combolist channel picker (replaces checkboxes)
|
||||
@@ -35,14 +36,28 @@ All notable changes to MokoGitea are documented here. Versions follow the format
|
||||
* Feed always public — downloads gated separately
|
||||
* Stream-name tags supported alongside version tags
|
||||
* Omit `<client>` for package extension types
|
||||
* No `<downloadkey>` when require_key is off
|
||||
* `<downloadkey>` only when download_gating is prerelease or all
|
||||
* Version extracted from asset filename (matches actual download)
|
||||
* Joomla tag values verified: dev, alpha, beta, rc, stable
|
||||
* feat(orgs): enterprise sub-org hierarchy with parent-child relationships
|
||||
* feat(repos): three-level visibility — Public (200), Private (403), Hidden (404)
|
||||
* feat(settings): Update Server settings page with enable toggle in Advanced Settings
|
||||
* feat(settings): advanced settings on dedicated page with dividing headers
|
||||
* feat(settings): icons on all settings navbars (repo, org, user, admin)
|
||||
* feat(ui): styled 403 Access Denied page with inline login form
|
||||
* feat(issues): custom fields foundation — model, migration, settings UI
|
||||
* feat(issues): custom fields with inline editing in issue sidebar
|
||||
* feat(issues): pre-fill custom fields from issue template YAML frontmatter (#493)
|
||||
* Templates specify `custom_fields:` map (field name → default value)
|
||||
* New issue sidebar shows org-level fields with template defaults pre-selected
|
||||
* API create issue accepts `custom_fields` map by name
|
||||
* feat(updateserver): resolve extension metadata from org-level custom fields (#492)
|
||||
* Cascading fallback: custom fields → config table → repo-derived defaults
|
||||
* All six generators updated (Joomla, WordPress, Composer, Drupal, PrestaShop, WHMCS)
|
||||
* Repos can be migrated to custom fields gradually
|
||||
* feat(ui): two-in-one Update Server / Licenses tab
|
||||
* No gating: shows "Update Server" tab with feed URLs only
|
||||
* Gated: shows "Licenses" tab with full key management
|
||||
* `<downloadkey>` only appears when downloads are gated
|
||||
* SECURITY
|
||||
* fix(security): ownership guards on all API handlers (cross-org prevention)
|
||||
* fix(security): RepoScope JSON parsing (substring matching bug)
|
||||
@@ -62,6 +77,11 @@ All notable changes to MokoGitea are documented here. Versions follow the format
|
||||
* fix(build): permanent fixes for AI migration, feed/file.go, unused imports
|
||||
* fix(updateserver): version extracted from asset filename (not release title)
|
||||
* fix(updateserver): omit `<client>` for package types per Joomla spec
|
||||
* fix(updateserver): `<downloadkey>` only shown when downloads are gated
|
||||
* fix(updateserver): prevent stream name tag from overriding asset-derived version
|
||||
* fix(build): restore build/ directory after accidental deletion
|
||||
* fix(licenses): master key banner removed, master keys sort first in table
|
||||
* fix(issues): issue sidebar loads org-level fields instead of legacy repo-level fields
|
||||
|
||||
## [v1.26.1-moko.05] - 2026-05-31
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 <out-json-file>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Executable
+24
@@ -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
|
||||
Executable
+11
@@ -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 .
|
||||
Executable
+22
@@ -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/
|
||||
@@ -27,14 +27,26 @@ const (
|
||||
CustomFieldTypeURL CustomFieldType = "url"
|
||||
)
|
||||
|
||||
// CustomFieldDef defines a custom field available for issues in a repository.
|
||||
// CustomFieldScope determines where the field appears.
|
||||
type CustomFieldScope string
|
||||
|
||||
const (
|
||||
CustomFieldScopeIssue CustomFieldScope = "issue" // appears in issue sidebar
|
||||
CustomFieldScopeRepo CustomFieldScope = "repo" // appears in repo settings metadata
|
||||
)
|
||||
|
||||
// CustomFieldDef defines a custom field at the org level.
|
||||
// owner_id = org ID, scope = issue or repo.
|
||||
// repo_id is kept for backward compat but 0 for org-level definitions.
|
||||
type CustomFieldDef struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'owner_id'"` // org that owns this field
|
||||
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'repo_id'"` // 0 = org-level (inherited by all repos)
|
||||
Scope CustomFieldScope `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'scope'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
FieldType CustomFieldType `xorm:"VARCHAR(20) NOT NULL 'field_type'"`
|
||||
Description string `xorm:"TEXT"`
|
||||
Options string `xorm:"TEXT"` // JSON array for dropdown options
|
||||
Options string `xorm:"TEXT"` // JSON array for dropdown options
|
||||
Required bool `xorm:"NOT NULL DEFAULT false"`
|
||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||
@@ -46,10 +58,11 @@ func (CustomFieldDef) TableName() string {
|
||||
return "custom_field_def"
|
||||
}
|
||||
|
||||
// CustomFieldValue stores a custom field value for a specific issue.
|
||||
// CustomFieldValue stores a custom field value for an entity (issue or repo).
|
||||
type CustomFieldValue struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL 'issue_id'"`
|
||||
EntityID int64 `xorm:"INDEX NOT NULL 'entity_id'"` // issue ID or repo ID
|
||||
EntityType string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'entity_type'"` // "issue" or "repo"
|
||||
FieldID int64 `xorm:"INDEX NOT NULL 'field_id'"`
|
||||
Value string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
@@ -60,16 +73,55 @@ func (CustomFieldValue) TableName() string {
|
||||
return "custom_field_value"
|
||||
}
|
||||
|
||||
// GetCustomFieldsByRepo returns all active custom field definitions for a repo.
|
||||
func GetCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) {
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Queries for org-level field definitions
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldsByOwner returns all active field definitions for an org with a given scope.
|
||||
func GetCustomFieldsByOwner(ctx context.Context, ownerID int64, scope CustomFieldScope) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND is_active = ?", repoID, true).
|
||||
Where("owner_id = ? AND scope = ? AND is_active = ?", ownerID, scope, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetAllCustomFieldsByRepo returns all custom field definitions including inactive.
|
||||
// GetAllCustomFieldsByOwner returns all field definitions for an org (including inactive).
|
||||
func GetAllCustomFieldsByOwner(ctx context.Context, ownerID int64) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("owner_id = ?", ownerID).
|
||||
OrderBy("scope ASC, sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetCustomFieldsByOwnerAndScope returns all fields for an org filtered by scope.
|
||||
func GetCustomFieldsByOwnerAndScope(ctx context.Context, ownerID int64, scope CustomFieldScope) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("owner_id = ? AND scope = ?", ownerID, scope).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Backward-compatible queries (load by repo's owner)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldsByRepo returns active issue-scoped fields for a repo's org.
|
||||
// This is the main query used by the issue sidebar.
|
||||
func GetCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) {
|
||||
// First try org-level fields (owner_id != 0, repo_id = 0)
|
||||
// Fall back to legacy repo-level fields (repo_id = repoID)
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("((owner_id != 0 AND repo_id = 0) OR repo_id = ?) AND scope = ? AND is_active = ?",
|
||||
repoID, CustomFieldScopeIssue, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetAllCustomFieldsByRepo returns all field definitions for a repo (for settings page).
|
||||
func GetAllCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
@@ -78,6 +130,10 @@ func GetAllCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomField
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Field definition CRUD
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldDefByID returns a single field definition.
|
||||
func GetCustomFieldDefByID(ctx context.Context, id int64) (*CustomFieldDef, error) {
|
||||
field := new(CustomFieldDef)
|
||||
@@ -112,10 +168,14 @@ func DeleteCustomFieldDef(ctx context.Context, id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCustomFieldValuesMap returns field_id -> value for an issue.
|
||||
func GetCustomFieldValuesMap(ctx context.Context, issueID int64) (map[int64]string, error) {
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Field values — generic entity-based (works for issues and repos)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldValuesMap returns field_id -> value for an entity.
|
||||
func GetCustomFieldValuesMap(ctx context.Context, entityID int64) (map[int64]string, error) {
|
||||
values := make([]*CustomFieldValue, 0, 10)
|
||||
if err := db.GetEngine(ctx).Where("issue_id = ?", issueID).Find(&values); err != nil {
|
||||
if err := db.GetEngine(ctx).Where("entity_id = ?", entityID).Find(&values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[int64]string, len(values))
|
||||
@@ -126,9 +186,9 @@ func GetCustomFieldValuesMap(ctx context.Context, issueID int64) (map[int64]stri
|
||||
}
|
||||
|
||||
// SetCustomFieldValue creates or updates a single custom field value.
|
||||
func SetCustomFieldValue(ctx context.Context, issueID, fieldID int64, value string) error {
|
||||
func SetCustomFieldValue(ctx context.Context, entityID, fieldID int64, value string) error {
|
||||
existing := new(CustomFieldValue)
|
||||
has, err := db.GetEngine(ctx).Where("issue_id = ? AND field_id = ?", issueID, fieldID).Get(existing)
|
||||
has, err := db.GetEngine(ctx).Where("entity_id = ? AND field_id = ?", entityID, fieldID).Get(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -138,17 +198,17 @@ func SetCustomFieldValue(ctx context.Context, issueID, fieldID int64, value stri
|
||||
return err
|
||||
}
|
||||
_, err = db.GetEngine(ctx).Insert(&CustomFieldValue{
|
||||
IssueID: issueID,
|
||||
FieldID: fieldID,
|
||||
Value: value,
|
||||
EntityID: entityID,
|
||||
FieldID: fieldID,
|
||||
Value: value,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCustomFieldValues sets multiple custom field values for an issue.
|
||||
func SetCustomFieldValues(ctx context.Context, issueID int64, values map[int64]string) error {
|
||||
// SetCustomFieldValues sets multiple custom field values for an entity.
|
||||
func SetCustomFieldValues(ctx context.Context, entityID int64, values map[int64]string) error {
|
||||
for fieldID, value := range values {
|
||||
if err := SetCustomFieldValue(ctx, issueID, fieldID, value); err != nil {
|
||||
if err := SetCustomFieldValue(ctx, entityID, fieldID, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,10 +130,11 @@ func GetLicenseKeyByID(ctx context.Context, id int64) (*LicenseKey, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ListLicenseKeys returns all keys for the given owner.
|
||||
// ListLicenseKeys returns all keys for the given owner, master keys first.
|
||||
func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
|
||||
keys := make([]*LicenseKey, 0, 20)
|
||||
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&keys)
|
||||
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).
|
||||
OrderBy("is_internal DESC, created_unix DESC").Find(&keys)
|
||||
}
|
||||
|
||||
// SearchLicenseKeys searches keys for an owner by key prefix/raw, licensee, email, or domain.
|
||||
|
||||
@@ -422,6 +422,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(342, "Add is_hidden to repository for three-level visibility", v1_27.AddIsHiddenToRepository),
|
||||
newMigration(343, "Add custom field tables for issue custom fields", v1_27.AddCustomFieldTables),
|
||||
newMigration(344, "Add domain_restriction to license_package table", v1_27.AddDomainRestrictionToLicensePackage),
|
||||
newMigration(345, "Migrate custom fields to org-level with scope", v1_27.MigrateCustomFieldsToOrgLevel),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// MigrateCustomFieldsToOrgLevel adds owner_id, scope to custom_field_def
|
||||
// and renames issue_id to entity_id + adds entity_type in custom_field_value.
|
||||
func MigrateCustomFieldsToOrgLevel(x *xorm.Engine) error {
|
||||
// Add new columns to custom_field_def
|
||||
type CustomFieldDef struct {
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'owner_id'"`
|
||||
Scope string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'scope'"`
|
||||
}
|
||||
if err := x.Sync(new(CustomFieldDef)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add entity_type and entity_id to custom_field_value
|
||||
type CustomFieldValue struct {
|
||||
EntityID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'entity_id'"`
|
||||
EntityType string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'entity_type'"`
|
||||
}
|
||||
if err := x.Sync(new(CustomFieldValue)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Migrate existing data: copy issue_id to entity_id where entity_id is 0
|
||||
if _, err := x.Exec("UPDATE custom_field_value SET entity_id = issue_id WHERE entity_id = 0 AND issue_id != 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set issue_id default to 0 so new inserts don't require it
|
||||
_, err := x.Exec("ALTER TABLE custom_field_value MODIFY COLUMN issue_id bigint NOT NULL DEFAULT 0")
|
||||
return err
|
||||
}
|
||||
@@ -104,6 +104,8 @@ type CreateIssueOption struct {
|
||||
// list of project ids
|
||||
Projects []int64 `json:"projects"`
|
||||
Closed bool `json:"closed"`
|
||||
// custom field values keyed by field name
|
||||
CustomFields map[string]string `json:"custom_fields,omitempty"`
|
||||
}
|
||||
|
||||
// EditIssueOption options for editing an issue
|
||||
@@ -190,15 +192,16 @@ const (
|
||||
// IssueTemplate represents an issue template for a repository
|
||||
// swagger:model
|
||||
type IssueTemplate struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible
|
||||
Labels IssueTemplateStringSlice `json:"labels" yaml:"labels"`
|
||||
Assignees IssueTemplateStringSlice `json:"assignees" yaml:"assignees"`
|
||||
Ref string `json:"ref" yaml:"ref"`
|
||||
Content string `json:"content" yaml:"-"`
|
||||
Fields []*IssueFormField `json:"body" yaml:"body"`
|
||||
FileName string `json:"file_name" yaml:"-"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible
|
||||
Labels IssueTemplateStringSlice `json:"labels" yaml:"labels"`
|
||||
Assignees IssueTemplateStringSlice `json:"assignees" yaml:"assignees"`
|
||||
Ref string `json:"ref" yaml:"ref"`
|
||||
Content string `json:"content" yaml:"-"`
|
||||
Fields []*IssueFormField `json:"body" yaml:"body"`
|
||||
FileName string `json:"file_name" yaml:"-"`
|
||||
CustomFields map[string]string `json:"custom_fields,omitempty" yaml:"custom_fields"`
|
||||
}
|
||||
|
||||
type IssueTemplateStringSlice []string
|
||||
|
||||
@@ -2722,6 +2722,9 @@
|
||||
"repo.settings.support_url": "Support / Product Page URL",
|
||||
"repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.",
|
||||
"repo.settings.custom_fields": "Custom Fields",
|
||||
"repo.settings.metadata": "Metadata",
|
||||
"repo.settings.metadata_saved": "Repository metadata saved.",
|
||||
"repo.settings.metadata_empty": "No metadata fields defined. Org admins can add fields in Organization Settings > Custom Fields.",
|
||||
"repo.settings.custom_field_new": "New Field",
|
||||
"repo.settings.custom_field_create": "Create Field",
|
||||
"repo.settings.custom_field_name": "Field Name",
|
||||
@@ -2895,6 +2898,19 @@
|
||||
"org.form.create_org_not_allowed": "You are not allowed to create an organization.",
|
||||
"org.settings": "Settings",
|
||||
"org.settings.options": "Organization",
|
||||
"org.settings.custom_fields": "Custom Fields",
|
||||
"org.settings.custom_fields_desc": "Define custom fields that appear across all repositories in this organization. Issue fields show in issue sidebars. Repo fields show in repo settings metadata.",
|
||||
"org.settings.custom_fields_empty": "No custom fields defined yet.",
|
||||
"org.settings.custom_field_add": "Add Custom Field",
|
||||
"org.settings.custom_field_name": "Field Name",
|
||||
"org.settings.custom_field_scope": "Scope",
|
||||
"org.settings.custom_field_type": "Type",
|
||||
"org.settings.custom_field_options": "Options (JSON)",
|
||||
"org.settings.custom_field_options_help": "For dropdown fields, enter options as a JSON array.",
|
||||
"org.settings.custom_field_description": "Description",
|
||||
"org.settings.custom_field_created": "Custom field created.",
|
||||
"org.settings.custom_field_updated": "Custom field updated.",
|
||||
"org.settings.custom_field_deleted": "Custom field deleted.",
|
||||
"org.settings.update_streams": "Update Server",
|
||||
"org.settings.licensing": "Update Server",
|
||||
"org.settings.licensing_desc": "Manage update feeds and optional license key gating across all repositories in this organization.",
|
||||
|
||||
+15
-3
@@ -1655,9 +1655,16 @@ func Routes() *web.Router {
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// TODO: custom-fields API routes - handler not yet implemented
|
||||
// m.Group("/custom-fields", func() { ... })
|
||||
// m.Group("/issues/{index}/custom-fields", func() { ... })
|
||||
// Repo metadata (repo-scoped custom fields)
|
||||
m.Group("/metadata", func() {
|
||||
m.Get("", repo.GetRepoMetadata)
|
||||
m.Put("", reqToken(), reqRepoWriter(unit.TypeCode), repo.SetRepoMetadata)
|
||||
})
|
||||
// Issue custom fields
|
||||
m.Group("/issues/{index}/custom-fields", func() {
|
||||
m.Get("", repo.GetIssueCustomFields)
|
||||
m.Put("", reqToken(), reqRepoWriter(unit.TypeIssues), repo.SetIssueCustomFields)
|
||||
})
|
||||
}, repoAssignment(), checkTokenPublicOnly())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
|
||||
|
||||
@@ -1758,6 +1765,11 @@ func Routes() *web.Router {
|
||||
m.Delete("", org.UnblockUser)
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Get("", org.ListOrgCustomFields)
|
||||
m.Post("", reqToken(), reqOrgOwnership(), org.CreateOrgCustomField)
|
||||
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
||||
})
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||
m.Group("/teams/{teamid}", func() {
|
||||
m.Combo("").Get(reqToken(), org.GetTeam).
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
type apiCustomFieldDef struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
FieldType string `json:"field_type"`
|
||||
Description string `json:"description"`
|
||||
Options any `json:"options"`
|
||||
Required bool `json:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func toAPIFieldDef(f *issues_model.CustomFieldDef) apiCustomFieldDef {
|
||||
var opts any
|
||||
if f.Options != "" {
|
||||
var parsed []string
|
||||
if json.Unmarshal([]byte(f.Options), &parsed) == nil {
|
||||
opts = parsed
|
||||
} else {
|
||||
opts = f.Options
|
||||
}
|
||||
}
|
||||
return apiCustomFieldDef{
|
||||
ID: f.ID,
|
||||
OwnerID: f.OwnerID,
|
||||
Scope: string(f.Scope),
|
||||
Name: f.Name,
|
||||
FieldType: string(f.FieldType),
|
||||
Description: f.Description,
|
||||
Options: opts,
|
||||
Required: f.Required,
|
||||
SortOrder: f.SortOrder,
|
||||
IsActive: f.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
// ListOrgCustomFields returns all custom field definitions for an org.
|
||||
func ListOrgCustomFields(ctx *context.APIContext) {
|
||||
fields, err := issues_model.GetAllCustomFieldsByOwner(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
result := make([]apiCustomFieldDef, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
result = append(result, toAPIFieldDef(f))
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// CreateOrgCustomField creates a new custom field definition.
|
||||
func CreateOrgCustomField(ctx *context.APIContext) {
|
||||
var req struct {
|
||||
Scope string `json:"scope" binding:"Required"`
|
||||
Name string `json:"name" binding:"Required"`
|
||||
FieldType string `json:"field_type" binding:"Required"`
|
||||
Description string `json:"description"`
|
||||
Options []string `json:"options"`
|
||||
Required bool `json:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := ctx.Req.ParseForm(); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Scope == "" {
|
||||
ctx.APIError(http.StatusBadRequest, "name and scope are required")
|
||||
return
|
||||
}
|
||||
|
||||
scope := issues_model.CustomFieldScope(req.Scope)
|
||||
if scope != issues_model.CustomFieldScopeIssue && scope != issues_model.CustomFieldScopeRepo {
|
||||
ctx.APIError(http.StatusBadRequest, "scope must be 'issue' or 'repo'")
|
||||
return
|
||||
}
|
||||
|
||||
var optionsJSON string
|
||||
if len(req.Options) > 0 {
|
||||
data, _ := json.Marshal(req.Options)
|
||||
optionsJSON = string(data)
|
||||
}
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
RepoID: 0,
|
||||
Scope: scope,
|
||||
Name: req.Name,
|
||||
FieldType: issues_model.CustomFieldType(req.FieldType),
|
||||
Description: req.Description,
|
||||
Options: optionsJSON,
|
||||
Required: req.Required,
|
||||
SortOrder: req.SortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, toAPIFieldDef(field))
|
||||
}
|
||||
|
||||
// DeleteOrgCustomField deletes a custom field definition.
|
||||
func DeleteOrgCustomField(ctx *context.APIContext) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// GetRepoMetadata returns all repo-scoped custom field values.
|
||||
func GetRepoMetadata(ctx *context.APIContext) {
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
result[f.Name] = values[f.ID]
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetRepoMetadata sets repo-scoped custom field values.
|
||||
func SetRepoMetadata(ctx *context.APIContext) {
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build name->ID map
|
||||
nameToID := make(map[string]int64, len(fields))
|
||||
for _, f := range fields {
|
||||
nameToID[f.Name] = f.ID
|
||||
}
|
||||
|
||||
for name, value := range req {
|
||||
if fieldID, ok := nameToID[name]; ok {
|
||||
if err := issues_model.SetCustomFieldValue(ctx, repoID, fieldID, value); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetIssueCustomFields returns custom field values for an issue.
|
||||
func GetIssueCustomFields(ctx *context.APIContext) {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, issue.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
result[f.Name] = values[f.ID]
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetIssueCustomFields sets custom field values for an issue.
|
||||
func SetIssueCustomFields(ctx *context.APIContext) {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
nameToID := make(map[string]int64, len(fields))
|
||||
for _, f := range fields {
|
||||
nameToID[f.Name] = f.ID
|
||||
}
|
||||
|
||||
for name, value := range req {
|
||||
if fieldID, ok := nameToID[name]; ok {
|
||||
if err := issues_model.SetCustomFieldValue(ctx, issue.ID, fieldID, value); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -702,6 +702,29 @@ func CreateIssue(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save custom field values if provided (resolve field names to IDs).
|
||||
if len(form.CustomFields) > 0 {
|
||||
defs, defErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if defErr != nil {
|
||||
ctx.APIErrorInternal(defErr)
|
||||
return
|
||||
}
|
||||
if len(defs) > 0 {
|
||||
vals := make(map[int64]string)
|
||||
for _, def := range defs {
|
||||
if v, ok := form.CustomFields[def.Name]; ok {
|
||||
vals[def.ID] = v
|
||||
}
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
if setErr := issues_model.SetCustomFieldValues(ctx, issue.ID, vals); setErr != nil {
|
||||
ctx.APIErrorInternal(setErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if form.Closed {
|
||||
if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
if issues_model.IsErrDependenciesLeft(err) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplOrgCustomFields templates.TplName = "org/settings/custom_fields"
|
||||
|
||||
// SettingsCustomFields shows the org-level custom fields management page.
|
||||
func SettingsCustomFields(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("org.settings.custom_fields")
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsSettingsCustomFields"] = true
|
||||
|
||||
fields, err := issues_model.GetAllCustomFieldsByOwner(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllCustomFieldsByOwner", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["CustomFields"] = fields
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgCustomFields)
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsCreatePost creates a new org-level custom field.
|
||||
func SettingsCustomFieldsCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
scope := issues_model.CustomFieldScope(ctx.FormString("scope"))
|
||||
if scope != issues_model.CustomFieldScopeIssue && scope != issues_model.CustomFieldScopeRepo {
|
||||
scope = issues_model.CustomFieldScopeIssue
|
||||
}
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
RepoID: 0, // org-level
|
||||
Scope: scope,
|
||||
Name: ctx.FormString("name"),
|
||||
FieldType: issues_model.CustomFieldType(ctx.FormString("field_type")),
|
||||
Description: ctx.FormString("description"),
|
||||
Options: ctx.FormString("options"),
|
||||
Required: ctx.FormString("required") == "on",
|
||||
SortOrder: sortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if field.Name == "" {
|
||||
ctx.Flash.Error("Field name is required")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("CreateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_created"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsEditPost updates an org-level custom field.
|
||||
func SettingsCustomFieldsEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
field.Name = ctx.FormString("name")
|
||||
field.FieldType = issues_model.CustomFieldType(ctx.FormString("field_type"))
|
||||
field.Description = ctx.FormString("description")
|
||||
field.Options = ctx.FormString("options")
|
||||
field.Required = ctx.FormString("required") == "on"
|
||||
field.IsActive = ctx.FormString("is_active") == "on"
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
field.SortOrder = sortOrder
|
||||
|
||||
if err := issues_model.UpdateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("UpdateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_updated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsDeletePost deletes an org-level custom field.
|
||||
func SettingsCustomFieldsDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_deleted"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
@@ -630,7 +630,7 @@ func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Contex
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
|
||||
_, templateErrs, _ := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
|
||||
if len(templateErrs) > 0 {
|
||||
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ func CheckDownloadGating(ctx *context.Context, tagName string) bool {
|
||||
return true // no download gating configured
|
||||
}
|
||||
|
||||
// Signed-in users with repo access bypass download gating.
|
||||
// The gate is for anonymous/external clients (Joomla update checker).
|
||||
if ctx.IsSigned && ctx.Repo.Permission.HasAnyUnitAccess() {
|
||||
return true
|
||||
}
|
||||
|
||||
// For prerelease-only gating, check if this is a prerelease tag.
|
||||
if gating == "prerelease" && tagName != "" {
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -36,10 +37,11 @@ import (
|
||||
)
|
||||
|
||||
// Tries to load and set an issue template. The first return value indicates if a template was loaded.
|
||||
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error) {
|
||||
// The third return value contains the template's custom_fields map (field name → default value).
|
||||
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error, map[string]string) {
|
||||
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
templateCandidates := make([]string, 0, 1+len(possibleFiles))
|
||||
@@ -84,9 +86,9 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
|
||||
}
|
||||
metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",")
|
||||
|
||||
return true, templateErrs
|
||||
return true, templateErrs, template.CustomFields
|
||||
}
|
||||
return false, templateErrs
|
||||
return false, templateErrs, nil
|
||||
}
|
||||
|
||||
// NewIssue render creating issue page
|
||||
@@ -128,7 +130,7 @@ func NewIssue(ctx *context.Context) {
|
||||
ctx.Data["Tags"] = tags
|
||||
|
||||
ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||
templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
|
||||
templateLoaded, errs, templateCustomFields := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
|
||||
maps.Copy(ret.TemplateErrors, errs)
|
||||
if ctx.Written() {
|
||||
return
|
||||
@@ -140,6 +142,35 @@ func NewIssue(ctx *context.Context) {
|
||||
|
||||
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeIssues)
|
||||
|
||||
// Load org-level issue-scoped custom fields for the new issue sidebar.
|
||||
customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if cfErr != nil {
|
||||
log.Error("NewIssue: GetCustomFieldsByOwner: %v", cfErr)
|
||||
}
|
||||
ctx.Data["CustomFieldDefs"] = customFieldDefs
|
||||
customFieldValues := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(customFieldDefs) > 0 {
|
||||
// Resolve template custom_fields (name → value) to field IDs.
|
||||
if len(templateCustomFields) > 0 {
|
||||
for _, def := range customFieldDefs {
|
||||
if val, ok := templateCustomFields[def.Name]; ok {
|
||||
customFieldValues[def.ID] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range customFieldDefs {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["CustomFieldValues"] = customFieldValues
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
|
||||
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
|
||||
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
|
||||
@@ -377,6 +408,9 @@ func NewIssuePost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save custom field values submitted from the new issue form.
|
||||
saveCustomFieldsFromForm(ctx, repo.OwnerID, issue.ID)
|
||||
|
||||
log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
|
||||
if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 {
|
||||
// When issue is in multiple projects, redirect to first project from form order.
|
||||
@@ -392,3 +426,29 @@ func NewIssuePost(ctx *context.Context) {
|
||||
}
|
||||
ctx.JSONRedirect(issue.Link())
|
||||
}
|
||||
|
||||
// saveCustomFieldsFromForm reads custom field values from the form
|
||||
// (submitted as "custom-field-{fieldID}") and persists them for the issue.
|
||||
func saveCustomFieldsFromForm(ctx *context.Context, ownerID, issueID int64) {
|
||||
defs, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
log.Error("saveCustomFieldsFromForm: GetCustomFieldsByOwner: %v", err)
|
||||
return
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
return
|
||||
}
|
||||
vals := make(map[int64]string)
|
||||
for _, def := range defs {
|
||||
v := ctx.Req.FormValue(fmt.Sprintf("custom-field-%d", def.ID))
|
||||
if v != "" {
|
||||
vals[def.ID] = v
|
||||
}
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
if err := issues_model.SetCustomFieldValues(ctx, issueID, vals); err != nil {
|
||||
log.Error("saveCustomFieldsFromForm: %v", err)
|
||||
ctx.Flash.Error("Failed to save custom field values")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,13 +339,20 @@ func ViewIssue(ctx *context.Context) {
|
||||
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanRead(unit.TypeProjects)
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
|
||||
// Load custom fields for the issue sidebar.
|
||||
customFieldDefs, _ := issues_model.GetCustomFieldsByRepo(ctx, ctx.Repo.Repository.ID)
|
||||
// Load custom fields for the issue sidebar (org-level issue-scoped fields).
|
||||
customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if cfErr != nil {
|
||||
log.Error("ViewIssue: GetCustomFieldsByOwner: %v", cfErr)
|
||||
}
|
||||
ctx.Data["CustomFieldDefs"] = customFieldDefs
|
||||
customFieldValues := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(customFieldDefs) > 0 {
|
||||
customFieldValues, _ = issues_model.GetCustomFieldValuesMap(ctx, issue.ID)
|
||||
var cvErr error
|
||||
customFieldValues, cvErr = issues_model.GetCustomFieldValuesMap(ctx, issue.ID)
|
||||
if cvErr != nil {
|
||||
log.Error("ViewIssue: GetCustomFieldValuesMap: %v", cvErr)
|
||||
}
|
||||
for _, f := range customFieldDefs {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
|
||||
@@ -258,7 +258,12 @@ func LicensesRegenerateMasterKey(ctx *context.Context) {
|
||||
|
||||
// LicensesGenerateKey handles POST to generate a new key from a package.
|
||||
func LicensesGenerateKey(ctx *context.Context) {
|
||||
packageID, _ := strconv.ParseInt(ctx.FormString("package_id"), 10, 64)
|
||||
// Accept package_id from form body or query string (modal sets it via form action URL).
|
||||
pkgIDStr := ctx.FormString("package_id")
|
||||
if pkgIDStr == "" {
|
||||
pkgIDStr = ctx.Req.URL.Query().Get("package_id")
|
||||
}
|
||||
packageID, _ := strconv.ParseInt(pkgIDStr, 10, 64)
|
||||
if packageID == 0 {
|
||||
ctx.Flash.Error("Invalid package")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplSettingsMetadata templates.TplName = "repo/settings/metadata"
|
||||
|
||||
// Metadata displays the repo metadata page (repo-scoped custom field values).
|
||||
func Metadata(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.metadata")
|
||||
ctx.Data["PageIsSettingsMetadata"] = true
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
fields, _ := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
ctx.Data["CustomFieldDefs"] = fields
|
||||
|
||||
values := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(fields) > 0 {
|
||||
values, _ = issues_model.GetCustomFieldValuesMap(ctx, repoID)
|
||||
for _, f := range fields {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["CustomFieldValues"] = values
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsMetadata)
|
||||
}
|
||||
|
||||
// MetadataPost saves repo-scoped custom field values.
|
||||
func MetadataPost(ctx *context.Context) {
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
|
||||
fields, _ := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
for _, f := range fields {
|
||||
val := ctx.Req.FormValue(fmt.Sprintf("field_%d", f.ID))
|
||||
if err := issues_model.SetCustomFieldValue(ctx, repoID, f.ID, val); err != nil {
|
||||
ctx.ServerError("SetCustomFieldValue", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.metadata_saved"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/metadata")
|
||||
}
|
||||
@@ -94,7 +94,9 @@ func ServeUpdatesXML(ctx *context.Context) {
|
||||
}
|
||||
|
||||
repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID)
|
||||
requireKey := repoCfg != nil && repoCfg.RequireKey
|
||||
// Show <downloadkey> only when downloads are gated (prerelease or all).
|
||||
// No gating = no license keys needed = no downloadkey element.
|
||||
requireKey := repoCfg != nil && repoCfg.DownloadGating != "" && repoCfg.DownloadGating != "none"
|
||||
|
||||
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, requireKey, stripDownloads, allowedChannels...)
|
||||
if err != nil {
|
||||
|
||||
+7
-6
@@ -1061,6 +1061,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Get("", org.SettingsUpdateStreams)
|
||||
m.Post("", org.SettingsUpdateStreamsPost)
|
||||
})
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Get("", org.SettingsCustomFields)
|
||||
m.Post("", org.SettingsCustomFieldsCreatePost)
|
||||
m.Post("/{id}/edit", org.SettingsCustomFieldsEditPost)
|
||||
m.Post("/{id}/delete", org.SettingsCustomFieldsDeletePost)
|
||||
})
|
||||
}, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true))
|
||||
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true}))
|
||||
}, reqSignIn)
|
||||
@@ -1187,12 +1193,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Combo("/advanced").Get(repo_setting.AdvancedSettings).Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost)
|
||||
}, repo_setting.SettingsCtxData)
|
||||
m.Combo("/licensing").Get(repo_setting.LicensingSettings).Post(repo_setting.LicensingSettingsPost)
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Get("", repo_setting.CustomFields)
|
||||
m.Post("", repo_setting.CustomFieldsCreatePost)
|
||||
m.Post("/{id}/edit", repo_setting.CustomFieldsEditPost)
|
||||
m.Post("/{id}/delete", repo_setting.CustomFieldsDeletePost)
|
||||
})
|
||||
m.Combo("/metadata").Get(repo_setting.Metadata).Post(repo_setting.MetadataPost)
|
||||
|
||||
m.Group("/collaboration", func() {
|
||||
m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost)
|
||||
|
||||
@@ -663,6 +663,8 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
|
||||
ctx.Data["NumLicensePackages"] = numLicensePackages
|
||||
ctx.Data["EnableLicenses"] = licensingEnabled || numLicensePackages > 0
|
||||
ctx.Data["LicensingEnabled"] = licensingEnabled
|
||||
downloadGated := repoUpdateCfg != nil && repoUpdateCfg.DownloadGating != "" && repoUpdateCfg.DownloadGating != "none"
|
||||
ctx.Data["DownloadGated"] = downloadGated
|
||||
|
||||
// Determine release page access based on feed visibility mode.
|
||||
feedVis := "public"
|
||||
|
||||
@@ -68,13 +68,15 @@ func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, lice
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
// Composer package name: vendor/package
|
||||
// Composer package name: vendor/package (override with resolved extension name if set)
|
||||
packageName := fmt.Sprintf("%s/%s", strings.ToLower(repo.Owner.Name), strings.ToLower(repo.Name))
|
||||
if cfg != nil && cfg.ExtensionName != "" {
|
||||
packageName = cfg.ExtensionName
|
||||
if meta.Element != strings.ToLower(repo.Name) {
|
||||
packageName = meta.Element
|
||||
}
|
||||
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
@@ -84,14 +86,9 @@ func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, lice
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
|
||||
description := ""
|
||||
if cfg != nil && cfg.Description != "" {
|
||||
description = cfg.Description
|
||||
}
|
||||
|
||||
phpMin := ""
|
||||
if cfg != nil && cfg.PHPMinimum != "" {
|
||||
phpMin = ">=" + cfg.PHPMinimum
|
||||
if meta.PHPMinimum != "" {
|
||||
phpMin = ">=" + meta.PHPMinimum
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -66,16 +66,9 @@ func GenerateDrupalXML(ctx context.Context, repo *repo_model.Repository, allowed
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
shortName := strings.ToLower(repo.Name)
|
||||
title := repo.Name
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
shortName = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
title = cfg.DisplayName
|
||||
}
|
||||
}
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
shortName := meta.Element
|
||||
title := meta.DisplayName
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
|
||||
+145
-41
@@ -13,8 +13,10 @@ import (
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
)
|
||||
@@ -120,7 +122,10 @@ func isStreamName(s string, streams []licenses.StreamDef) bool {
|
||||
}
|
||||
|
||||
// joomlaTagName maps internal stream names to Joomla-standard tag values.
|
||||
// Joomla recognizes: dev, alpha, beta, rc, stable.
|
||||
// Joomla's Update.php maps tags via STABILITY_ + strtoupper(tag) constants.
|
||||
// Valid values: dev (0), alpha (1), beta (2), rc (3), stable (4).
|
||||
// Using full names like "development" or "release-candidate" would silently
|
||||
// fall back to STABILITY_STABLE, breaking pre-release channel filtering.
|
||||
func joomlaTagName(channel string) string {
|
||||
switch channel {
|
||||
case ChannelDevelopment:
|
||||
@@ -157,9 +162,121 @@ func NormalizeChannel(ch string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// extensionMetadata holds resolved metadata for feed generation.
|
||||
// Fields are resolved with priority: custom field → config table → default.
|
||||
type extensionMetadata struct {
|
||||
Element string
|
||||
DisplayName string
|
||||
ExtType string
|
||||
TargetVersion string
|
||||
PHPMinimum string
|
||||
Description string
|
||||
SupportURL string
|
||||
DownloadGating string
|
||||
KeyPrefix string
|
||||
}
|
||||
|
||||
// resolveExtensionMetadata loads extension metadata with cascading fallback:
|
||||
// org-level repo-scoped custom fields → update_stream_config → repo-derived defaults.
|
||||
func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, cfg *licenses.UpdateStreamConfig) extensionMetadata {
|
||||
m := extensionMetadata{
|
||||
Element: strings.ToLower(repo.Name),
|
||||
DisplayName: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
|
||||
ExtType: "component",
|
||||
TargetVersion: "(5|6)\\..*",
|
||||
}
|
||||
|
||||
// Apply config table values.
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
m.Element = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
m.DisplayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.ExtensionType != "" {
|
||||
m.ExtType = cfg.ExtensionType
|
||||
}
|
||||
if cfg.TargetVersion != "" {
|
||||
m.TargetVersion = cfg.TargetVersion
|
||||
}
|
||||
if cfg.PHPMinimum != "" {
|
||||
m.PHPMinimum = cfg.PHPMinimum
|
||||
}
|
||||
if cfg.Description != "" {
|
||||
m.Description = cfg.Description
|
||||
}
|
||||
if cfg.SupportURL != "" {
|
||||
m.SupportURL = cfg.SupportURL
|
||||
}
|
||||
if cfg.DownloadGating != "" {
|
||||
m.DownloadGating = cfg.DownloadGating
|
||||
}
|
||||
if cfg.KeyPrefix != "" {
|
||||
m.KeyPrefix = cfg.KeyPrefix
|
||||
}
|
||||
}
|
||||
|
||||
// Override with custom field values (highest priority).
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
log.Error("resolveExtensionMetadata: GetCustomFieldsByOwner for repo %d: %v", repo.ID, err)
|
||||
return m
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return m
|
||||
}
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, repo.ID)
|
||||
if err != nil {
|
||||
log.Error("resolveExtensionMetadata: GetCustomFieldValuesMap for repo %d: %v", repo.ID, err)
|
||||
return m
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return m
|
||||
}
|
||||
|
||||
// Build name → value map from field definitions + values.
|
||||
named := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
if v, ok := values[f.ID]; ok && v != "" {
|
||||
named[f.Name] = v
|
||||
}
|
||||
}
|
||||
|
||||
if v := named["Extension Name"]; v != "" {
|
||||
m.Element = v
|
||||
}
|
||||
if v := named["Display Name"]; v != "" {
|
||||
m.DisplayName = v
|
||||
}
|
||||
if v := named["Extension Type"]; v != "" {
|
||||
m.ExtType = v
|
||||
}
|
||||
if v := named["Target Version"]; v != "" {
|
||||
m.TargetVersion = v
|
||||
}
|
||||
if v := named["PHP Minimum"]; v != "" {
|
||||
m.PHPMinimum = v
|
||||
}
|
||||
if v := named["Support URL"]; v != "" {
|
||||
m.SupportURL = v
|
||||
}
|
||||
if v := named["Description"]; v != "" {
|
||||
m.Description = v
|
||||
}
|
||||
if v := named["Download Gating"]; v != "" {
|
||||
m.DownloadGating = v
|
||||
}
|
||||
if v := named["Key Prefix"]; v != "" {
|
||||
m.KeyPrefix = v
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases.
|
||||
// It returns the raw XML bytes. Extension metadata is read from the update stream config;
|
||||
// falls back to repo name/owner when not configured.
|
||||
// It returns the raw XML bytes. Extension metadata is resolved from custom fields first,
|
||||
// then the update stream config, then repo-derived defaults.
|
||||
// allowedChannels optionally restricts output to specific channels (nil = all).
|
||||
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey, stripDownloads bool, allowedChannels ...string) ([]byte, error) {
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
@@ -182,42 +299,26 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
}
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
// Load extension metadata from config (falls back to repo-derived values).
|
||||
// Load extension metadata with cascading fallback:
|
||||
// custom fields → config table → repo-derived defaults.
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
element := strings.ToLower(repo.Name)
|
||||
if cfg != nil && cfg.ExtensionName != "" {
|
||||
element = cfg.ExtensionName
|
||||
element := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
extType := meta.ExtType
|
||||
targetVersion := meta.TargetVersion
|
||||
phpMinimum := meta.PHPMinimum
|
||||
feedDescription := meta.Description
|
||||
|
||||
// Maintainer and URL always come from the org profile.
|
||||
maintainer := repo.Owner.FullName
|
||||
if maintainer == "" {
|
||||
maintainer = repo.Owner.Name
|
||||
}
|
||||
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
|
||||
if cfg != nil && cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
extType := "component"
|
||||
if cfg != nil && cfg.ExtensionType != "" {
|
||||
extType = cfg.ExtensionType
|
||||
}
|
||||
maintainer := repo.Owner.Name
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
if cfg != nil && cfg.SupportURL != "" {
|
||||
maintainerURL = cfg.SupportURL
|
||||
} else if cfg != nil && cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
targetVersion := "(5|6)\\..*"
|
||||
if cfg != nil && cfg.TargetVersion != "" {
|
||||
targetVersion = cfg.TargetVersion
|
||||
}
|
||||
phpMinimum := ""
|
||||
if cfg != nil && cfg.PHPMinimum != "" {
|
||||
phpMinimum = cfg.PHPMinimum
|
||||
}
|
||||
feedDescription := ""
|
||||
if cfg != nil && cfg.Description != "" {
|
||||
feedDescription = cfg.Description
|
||||
maintainerURL := repo.Owner.Website
|
||||
if maintainerURL == "" {
|
||||
maintainerURL = fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
}
|
||||
|
||||
// Resolve effective streams (repo override → org default → Joomla default).
|
||||
@@ -315,16 +416,19 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
desc = fmt.Sprintf("%s %s build.", displayName, ch)
|
||||
}
|
||||
|
||||
// Info URL: use support_url (product page), fall back to releases page.
|
||||
infoURL := fmt.Sprintf("%s/releases", repoLink)
|
||||
if cfg != nil && cfg.InfoURL != "" {
|
||||
infoURL = cfg.InfoURL
|
||||
if meta.SupportURL != "" {
|
||||
infoURL = meta.SupportURL
|
||||
}
|
||||
|
||||
// Joomla <client> element: only relevant for plugins/modules (site vs administrator).
|
||||
// Packages manage their own sub-extension clients; omit for package type.
|
||||
// Joomla <client> element: packages use client_id=0 in #__extensions,
|
||||
// so we must output <client>0</client> for Joomla to match the update
|
||||
// to the installed extension. Other types default to "site" (client_id=0)
|
||||
// or "administrator" (client_id=1).
|
||||
client := "site"
|
||||
if extType == "package" {
|
||||
client = ""
|
||||
client = "0"
|
||||
}
|
||||
|
||||
u := xmlUpdate{
|
||||
|
||||
@@ -55,23 +55,13 @@ func GeneratePrestaShopXML(ctx context.Context, repo *repo_model.Repository, all
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
moduleName := strings.ToLower(repo.Name)
|
||||
displayName := repo.Name
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
moduleName := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
description := ""
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
moduleName = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg.Description != "" {
|
||||
description = cfg.Description
|
||||
}
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -50,23 +50,18 @@ func GenerateWHMCSJSON(ctx context.Context, repo *repo_model.Repository, license
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
displayName := repo.Name
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
displayName := meta.DisplayName
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
description := ""
|
||||
if cfg != nil {
|
||||
if cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
if cfg.Description != "" {
|
||||
description = cfg.Description
|
||||
}
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -57,36 +57,30 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic
|
||||
baseURL := strings.TrimSuffix(setting.AppURL, "/")
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
// Load extension metadata.
|
||||
// Load extension metadata with cascading fallback:
|
||||
// custom fields → config table → repo-derived defaults.
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
slug := strings.ToLower(repo.Name)
|
||||
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
|
||||
slug := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
requiresPHP := meta.PHPMinimum
|
||||
homepage := repoLink
|
||||
if meta.SupportURL != "" {
|
||||
homepage = meta.SupportURL
|
||||
}
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
homepage := repoLink
|
||||
requiresPHP := ""
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
slug = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
if cfg.SupportURL != "" {
|
||||
homepage = cfg.SupportURL
|
||||
} else if cfg.InfoURL != "" {
|
||||
if homepage == repoLink && cfg.InfoURL != "" {
|
||||
homepage = cfg.InfoURL
|
||||
}
|
||||
if cfg.PHPMinimum != "" {
|
||||
requiresPHP = cfg.PHPMinimum
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve streams and find the latest stable release.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings custom-fields")}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "org.settings.custom_fields"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p class="text grey">{{ctx.Locale.Tr "org.settings.custom_fields_desc"}}</p>
|
||||
|
||||
{{if .CustomFields}}
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_scope"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_type"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_options"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .CustomFields}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||
<td>{{if eq .Scope "issue"}}{{svg "octicon-issue-opened" 14}} Issue{{else}}{{svg "octicon-repo" 14}} Repo{{end}}</td>
|
||||
<td><code>{{.FieldType}}</code></td>
|
||||
<td>{{if .Options}}<code class="tw-text-xs">{{.Options}}</code>{{else}}<span class="text grey">-</span>{{end}}</td>
|
||||
<td class="tw-text-right">
|
||||
<form method="post" action="{{$.OrgLink}}/settings/custom-fields/{{.ID}}/delete" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-placeholder">
|
||||
<p>{{ctx.Locale.Tr "org.settings.custom_fields_empty"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h5>{{ctx.Locale.Tr "org.settings.custom_field_add"}}</h5>
|
||||
<form class="ui form" method="post" action="{{.OrgLink}}/settings/custom-fields">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="three fields">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. Status, Platform, Priority">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_scope"}}</label>
|
||||
<select name="scope" class="ui dropdown">
|
||||
<option value="issue">{{svg "octicon-issue-opened" 14}} Issue (sidebar)</option>
|
||||
<option value="repo">{{svg "octicon-repo" 14}} Repo (metadata)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_type"}}</label>
|
||||
<select name="field_type" class="ui dropdown">
|
||||
<option value="dropdown">Dropdown</option>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="url">URL</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_options"}}</label>
|
||||
<input name="options" placeholder='["Option 1","Option 2","Option 3"]'>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.custom_field_options_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_description"}}</label>
|
||||
<input name="description" placeholder="Help text shown to users">
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "org.settings.custom_field_add"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{template "org/settings/layout_footer" .}}
|
||||
@@ -28,6 +28,9 @@
|
||||
<a class="{{if .PageIsSettingsUpdateStreams}}active {{end}}item" href="{{.OrgLink}}/settings/update-streams">
|
||||
{{svg "octicon-broadcast"}} {{ctx.Locale.Tr "org.settings.update_streams"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsCustomFields}}active {{end}}item" href="{{.OrgLink}}/settings/custom-fields">
|
||||
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "org.settings.custom_fields"}}
|
||||
</a>
|
||||
{{if .EnableActions}}
|
||||
<details class="item toggleable-item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<summary>{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
|
||||
@@ -130,9 +130,10 @@
|
||||
|
||||
{{if and .LicensingEnabled .IsSigned}}
|
||||
<a href="{{.RepoLink}}/licenses" class="{{if .IsLicensesPage}}active {{end}}item">
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
|
||||
{{if .NumLicensePackages}}
|
||||
<span class="ui small label">{{CountFmt .NumLicensePackages}}</span>
|
||||
{{if .DownloadGated}}
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
|
||||
{{else}}
|
||||
{{svg "octicon-broadcast"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
|
||||
{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
@@ -59,6 +59,31 @@
|
||||
{{end}}
|
||||
{{template "repo/issue/sidebar/assignee_list" $.IssuePageMetaData}}
|
||||
|
||||
{{if .CustomFieldDefs}}
|
||||
<div class="divider"></div>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
{{$values := .CustomFieldValues}}
|
||||
{{$fieldOptions := .CustomFieldOptions}}
|
||||
{{range .CustomFieldDefs}}
|
||||
{{$currentVal := index $values .ID}}
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-gap-2">
|
||||
<span class="text grey tw-text-sm" {{if .Description}}title="{{.Description}}"{{end}}>{{.Name}}</span>
|
||||
{{if ne .Options ""}}
|
||||
{{$opts := index $fieldOptions .ID}}
|
||||
<select name="custom-field-{{.ID}}" class="ui compact mini dropdown tw-max-w-48">
|
||||
<option value="">—</option>
|
||||
{{range $opts}}
|
||||
<option value="{{.}}" {{if eq . $currentVal}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else}}
|
||||
<input name="custom-field-{{.ID}}" type="text" class="tw-max-w-48 tw-text-sm" value="{{$currentVal}}" placeholder="—">
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}}
|
||||
<div class="divider"></div>
|
||||
<div class="ui checkbox">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
|
||||
{{if .DownloadGated}}
|
||||
{{if .NewMasterKey}}
|
||||
<div class="ui info message">
|
||||
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
|
||||
@@ -25,27 +26,6 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ── Master Key Info ── */}}
|
||||
{{if and .MasterKey .IsRepoAdmin}}
|
||||
<div class="ui segment tw-flex tw-items-center tw-justify-between tw-gap-4">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>
|
||||
<code>{{.MasterKey.KeyPrefix}}</code>
|
||||
{{if .MasterKey.IsActive}}
|
||||
<span class="ui tiny green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>
|
||||
{{else}}
|
||||
<span class="ui tiny grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<form method="post" action="{{.RepoLink}}/licenses/master-key/regenerate" class="tw-inline">
|
||||
{{.CsrfTokenHtml}}
|
||||
<button class="ui tiny primary button" type="submit" data-tooltip-content="{{ctx.Locale.Tr "repo.licenses.regenerate_master_key_help"}}">
|
||||
{{svg "octicon-sync" 14}} {{ctx.Locale.Tr "repo.licenses.regenerate_master_key"}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ── License Packages ── */}}
|
||||
<h4 class="ui top attached header tw-flex tw-items-center tw-justify-between">
|
||||
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
|
||||
@@ -78,9 +58,7 @@
|
||||
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
|
||||
<button class="ui tiny primary button show-modal"
|
||||
data-modal="#generate-key-modal"
|
||||
data-modal-generate-key-modal-package-id="{{.ID}}"
|
||||
data-modal-generate-key-modal-package-name="{{.Name}}"
|
||||
data-modal-generate-key-modal-package-domain="{{.DomainRestriction}}"
|
||||
data-modal-form.action="{{$.RepoLink}}/licenses/keys/generate?package_id={{.ID}}"
|
||||
title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
|
||||
{{svg "octicon-plus" 14}}
|
||||
</button>
|
||||
@@ -224,6 +202,8 @@
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{end}}{{/* end DownloadGated */}}
|
||||
|
||||
{{/* ── Update Feed URLs ── */}}
|
||||
{{if .LicensingEnabled}}
|
||||
<h4 class="ui top attached header tw-mt-4">
|
||||
@@ -333,13 +313,12 @@
|
||||
{{end}}
|
||||
|
||||
{{/* ── Generate Key Modal ── */}}
|
||||
{{if .IsRepoAdmin}}
|
||||
{{if and .DownloadGated .IsRepoAdmin}}
|
||||
<div class="ui small modal" id="generate-key-modal">
|
||||
<div class="header">{{svg "octicon-plus"}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}</div>
|
||||
<div class="content">
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/licenses/keys/generate">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="package_id" value="">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.licensee_name"}}</label>
|
||||
<input name="licensee_name" placeholder="Customer name">
|
||||
@@ -367,6 +346,7 @@
|
||||
{{end}}
|
||||
|
||||
{{/* ── Delete Package Confirmation Modal ── */}}
|
||||
{{if .DownloadGated}}
|
||||
<div class="ui small modal" id="license-delete-package-modal">
|
||||
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.licenses.delete_package"}}</div>
|
||||
<div class="content">
|
||||
@@ -397,5 +377,6 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}{{/* end DownloadGated for modals */}}
|
||||
|
||||
{{template "base/footer" .}}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings metadata")}}
|
||||
<div class="user-main-content twelve wide column">
|
||||
<h4 class="ui top attached header">
|
||||
{{svg "octicon-list-unordered" 16}} {{ctx.Locale.Tr "repo.settings.metadata"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{if .CustomFieldDefs}}
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/settings/metadata">
|
||||
{{.CsrfTokenHtml}}
|
||||
{{$values := .CustomFieldValues}}
|
||||
{{$options := .CustomFieldOptions}}
|
||||
{{range .CustomFieldDefs}}
|
||||
{{$currentVal := index $values .ID}}
|
||||
<div class="field">
|
||||
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
|
||||
{{if .Options}}
|
||||
{{$opts := index $options .ID}}
|
||||
<select name="field_{{.ID}}" class="ui dropdown">
|
||||
<option value="">—</option>
|
||||
{{range $opts}}
|
||||
<option value="{{.}}" {{if eq . $currentVal}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else if eq (printf "%s" .FieldType) "checkbox"}}
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="field_{{.ID}}" value="true" {{if eq $currentVal "true"}}checked{{end}}>
|
||||
<label></label>
|
||||
</div>
|
||||
{{else if eq (printf "%s" .FieldType) "number"}}
|
||||
<input type="number" name="field_{{.ID}}" value="{{$currentVal}}">
|
||||
{{else if eq (printf "%s" .FieldType) "url"}}
|
||||
<input type="url" name="field_{{.ID}}" value="{{$currentVal}}" placeholder="https://...">
|
||||
{{else if eq (printf "%s" .FieldType) "date"}}
|
||||
<input type="date" name="field_{{.ID}}" value="{{$currentVal}}">
|
||||
{{else}}
|
||||
<input type="text" name="field_{{.ID}}" value="{{$currentVal}}">
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="field tw-mt-4">
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "save"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text grey">{{ctx.Locale.Tr "repo.settings.metadata_empty"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -12,8 +12,8 @@
|
||||
{{svg "octicon-broadcast"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsCustomFields}}active {{end}}item" href="{{.RepoLink}}/settings/custom-fields">
|
||||
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.custom_fields"}}
|
||||
<a class="{{if .PageIsSettingsMetadata}}active {{end}}item" href="{{.RepoLink}}/settings/metadata">
|
||||
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.metadata"}}
|
||||
</a>
|
||||
{{if or .Repository.IsPrivate .Permission.HasAnyUnitPublicAccess}}
|
||||
<a class="{{if .PageIsSettingsPublicAccess}}active {{end}}item" href="{{.RepoLink}}/settings/public_access">
|
||||
|
||||
Reference in New Issue
Block a user