Add loading yaml label template files (#22976)

Extract from #11669 and enhancement to #22585 to support exclusive
scoped labels in label templates

* Move label template functionality to label module
* Fix handling of color codes
* Add Advanced label template
This commit is contained in:
Lauris BH 2023-03-02 01:44:23 +02:00 committed by GitHub
parent de6c718b46
commit 58b4143803
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 488 additions and 241 deletions

View File

@ -7,12 +7,12 @@ package issues
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -78,9 +78,6 @@ func (err ErrLabelNotExist) Unwrap() error {
return util.ErrNotExist return util.ErrNotExist
} }
// LabelColorPattern is a regexp witch can validate LabelColor
var LabelColorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
// Label represents a label of repository for issues. // Label represents a label of repository for issues.
type Label struct { type Label struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
@ -109,12 +106,12 @@ func init() {
} }
// CalOpenIssues sets the number of open issues of a label based on the already stored number of closed issues. // CalOpenIssues sets the number of open issues of a label based on the already stored number of closed issues.
func (label *Label) CalOpenIssues() { func (l *Label) CalOpenIssues() {
label.NumOpenIssues = label.NumIssues - label.NumClosedIssues l.NumOpenIssues = l.NumIssues - l.NumClosedIssues
} }
// CalOpenOrgIssues calculates the open issues of a label for a specific repo // CalOpenOrgIssues calculates the open issues of a label for a specific repo
func (label *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) { func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) {
counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{ counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{
RepoID: repoID, RepoID: repoID,
LabelIDs: []int64{labelID}, LabelIDs: []int64{labelID},
@ -122,22 +119,22 @@ func (label *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64)
}) })
for _, count := range counts { for _, count := range counts {
label.NumOpenRepoIssues += count l.NumOpenRepoIssues += count
} }
} }
// LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked // LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) { func (l *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) {
var labelQuerySlice []string var labelQuerySlice []string
labelSelected := false labelSelected := false
labelID := strconv.FormatInt(label.ID, 10) labelID := strconv.FormatInt(l.ID, 10)
labelScope := label.ExclusiveScope() labelScope := l.ExclusiveScope()
for i, s := range currentSelectedLabels { for i, s := range currentSelectedLabels {
if s == label.ID { if s == l.ID {
labelSelected = true labelSelected = true
} else if -s == label.ID { } else if -s == l.ID {
labelSelected = true labelSelected = true
label.IsExcluded = true l.IsExcluded = true
} else if s != 0 { } else if s != 0 {
// Exclude other labels in the same scope from selection // Exclude other labels in the same scope from selection
if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] { if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] {
@ -148,23 +145,23 @@ func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64,
if !labelSelected { if !labelSelected {
labelQuerySlice = append(labelQuerySlice, labelID) labelQuerySlice = append(labelQuerySlice, labelID)
} }
label.IsSelected = labelSelected l.IsSelected = labelSelected
label.QueryString = strings.Join(labelQuerySlice, ",") l.QueryString = strings.Join(labelQuerySlice, ",")
} }
// BelongsToOrg returns true if label is an organization label // BelongsToOrg returns true if label is an organization label
func (label *Label) BelongsToOrg() bool { func (l *Label) BelongsToOrg() bool {
return label.OrgID > 0 return l.OrgID > 0
} }
// BelongsToRepo returns true if label is a repository label // BelongsToRepo returns true if label is a repository label
func (label *Label) BelongsToRepo() bool { func (l *Label) BelongsToRepo() bool {
return label.RepoID > 0 return l.RepoID > 0
} }
// Get color as RGB values in 0..255 range // Get color as RGB values in 0..255 range
func (label *Label) ColorRGB() (float64, float64, float64, error) { func (l *Label) ColorRGB() (float64, float64, float64, error) {
color, err := strconv.ParseUint(label.Color[1:], 16, 64) color, err := strconv.ParseUint(l.Color[1:], 16, 64)
if err != nil { if err != nil {
return 0, 0, 0, err return 0, 0, 0, err
} }
@ -176,9 +173,9 @@ func (label *Label) ColorRGB() (float64, float64, float64, error) {
} }
// Determine if label text should be light or dark to be readable on background color // Determine if label text should be light or dark to be readable on background color
func (label *Label) UseLightTextColor() bool { func (l *Label) UseLightTextColor() bool {
if strings.HasPrefix(label.Color, "#") { if strings.HasPrefix(l.Color, "#") {
if r, g, b, err := label.ColorRGB(); err == nil { if r, g, b, err := l.ColorRGB(); err == nil {
// Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast // Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast
// In the future WCAG 3 APCA may be a better solution // In the future WCAG 3 APCA may be a better solution
brightness := (0.299*r + 0.587*g + 0.114*b) / 255 brightness := (0.299*r + 0.587*g + 0.114*b) / 255
@ -190,40 +187,26 @@ func (label *Label) UseLightTextColor() bool {
} }
// Return scope substring of label name, or empty string if none exists // Return scope substring of label name, or empty string if none exists
func (label *Label) ExclusiveScope() string { func (l *Label) ExclusiveScope() string {
if !label.Exclusive { if !l.Exclusive {
return "" return ""
} }
lastIndex := strings.LastIndex(label.Name, "/") lastIndex := strings.LastIndex(l.Name, "/")
if lastIndex == -1 || lastIndex == 0 || lastIndex == len(label.Name)-1 { if lastIndex == -1 || lastIndex == 0 || lastIndex == len(l.Name)-1 {
return "" return ""
} }
return label.Name[:lastIndex] return l.Name[:lastIndex]
} }
// NewLabel creates a new label // NewLabel creates a new label
func NewLabel(ctx context.Context, label *Label) error { func NewLabel(ctx context.Context, l *Label) error {
if !LabelColorPattern.MatchString(label.Color) { color, err := label.NormalizeColor(l.Color)
return fmt.Errorf("bad color code: %s", label.Color) if err != nil {
return err
} }
l.Color = color
// normalize case return db.Insert(ctx, l)
label.Color = strings.ToLower(label.Color)
// add leading hash
if label.Color[0] != '#' {
label.Color = "#" + label.Color
}
// convert 3-character shorthand into 6-character version
if len(label.Color) == 4 {
r := label.Color[1]
g := label.Color[2]
b := label.Color[3]
label.Color = fmt.Sprintf("#%c%c%c%c%c%c", r, r, g, g, b, b)
}
return db.Insert(ctx, label)
} }
// NewLabels creates new labels // NewLabels creates new labels
@ -234,11 +217,14 @@ func NewLabels(labels ...*Label) error {
} }
defer committer.Close() defer committer.Close()
for _, label := range labels { for _, l := range labels {
if !LabelColorPattern.MatchString(label.Color) { color, err := label.NormalizeColor(l.Color)
return fmt.Errorf("bad color code: %s", label.Color) if err != nil {
return err
} }
if err := db.Insert(ctx, label); err != nil { l.Color = color
if err := db.Insert(ctx, l); err != nil {
return err return err
} }
} }
@ -247,15 +233,18 @@ func NewLabels(labels ...*Label) error {
// UpdateLabel updates label information. // UpdateLabel updates label information.
func UpdateLabel(l *Label) error { func UpdateLabel(l *Label) error {
if !LabelColorPattern.MatchString(l.Color) { color, err := label.NormalizeColor(l.Color)
return fmt.Errorf("bad color code: %s", l.Color) if err != nil {
return err
} }
l.Color = color
return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive") return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive")
} }
// DeleteLabel delete a label // DeleteLabel delete a label
func DeleteLabel(id, labelID int64) error { func DeleteLabel(id, labelID int64) error {
label, err := GetLabelByID(db.DefaultContext, labelID) l, err := GetLabelByID(db.DefaultContext, labelID)
if err != nil { if err != nil {
if IsErrLabelNotExist(err) { if IsErrLabelNotExist(err) {
return nil return nil
@ -271,10 +260,10 @@ func DeleteLabel(id, labelID int64) error {
sess := db.GetEngine(ctx) sess := db.GetEngine(ctx)
if label.BelongsToOrg() && label.OrgID != id { if l.BelongsToOrg() && l.OrgID != id {
return nil return nil
} }
if label.BelongsToRepo() && label.RepoID != id { if l.BelongsToRepo() && l.RepoID != id {
return nil return nil
} }
@ -682,14 +671,14 @@ func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *us
if err = issue.LoadRepo(ctx); err != nil { if err = issue.LoadRepo(ctx); err != nil {
return err return err
} }
for _, label := range labels { for _, l := range labels {
// Don't add already present labels and invalid labels // Don't add already present labels and invalid labels
if HasIssueLabel(ctx, issue.ID, label.ID) || if HasIssueLabel(ctx, issue.ID, l.ID) ||
(label.RepoID != issue.RepoID && label.OrgID != issue.Repo.OwnerID) { (l.RepoID != issue.RepoID && l.OrgID != issue.Repo.OwnerID) {
continue continue
} }
if err = newIssueLabel(ctx, issue, label, doer); err != nil { if err = newIssueLabel(ctx, issue, l, doer); err != nil {
return fmt.Errorf("newIssueLabel: %w", err) return fmt.Errorf("newIssueLabel: %w", err)
} }
} }

View File

@ -15,8 +15,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
// TODO TestGetLabelTemplateFile
func TestLabel_CalOpenIssues(t *testing.T) { func TestLabel_CalOpenIssues(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})

46
modules/label/label.go Normal file
View File

@ -0,0 +1,46 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package label
import (
"fmt"
"regexp"
"strings"
)
// colorPattern is a regexp which can validate label color
var colorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
// Label represents label information loaded from template
type Label struct {
Name string `yaml:"name"`
Color string `yaml:"color"`
Description string `yaml:"description,omitempty"`
Exclusive bool `yaml:"exclusive,omitempty"`
}
// NormalizeColor normalizes a color string to a 6-character hex code
func NormalizeColor(color string) (string, error) {
// normalize case
color = strings.TrimSpace(strings.ToLower(color))
// add leading hash
if len(color) == 6 || len(color) == 3 {
color = "#" + color
}
if !colorPattern.MatchString(color) {
return "", fmt.Errorf("bad color code: %s", color)
}
// convert 3-character shorthand into 6-character version
if len(color) == 4 {
r := color[1]
g := color[2]
b := color[3]
color = fmt.Sprintf("#%c%c%c%c%c%c", r, r, g, g, b, b)
}
return color, nil
}

126
modules/label/parser.go Normal file
View File

@ -0,0 +1,126 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package label
import (
"errors"
"fmt"
"strings"
"code.gitea.io/gitea/modules/options"
"gopkg.in/yaml.v3"
)
type labelFile struct {
Labels []*Label `yaml:"labels"`
}
// ErrTemplateLoad represents a "ErrTemplateLoad" kind of error.
type ErrTemplateLoad struct {
TemplateFile string
OriginalError error
}
// IsErrTemplateLoad checks if an error is a ErrTemplateLoad.
func IsErrTemplateLoad(err error) bool {
_, ok := err.(ErrTemplateLoad)
return ok
}
func (err ErrTemplateLoad) Error() string {
return fmt.Sprintf("Failed to load label template file '%s': %v", err.TemplateFile, err.OriginalError)
}
// GetTemplateFile loads the label template file by given name,
// then parses and returns a list of name-color pairs and optionally description.
func GetTemplateFile(name string) ([]*Label, error) {
data, err := options.GetRepoInitFile("label", name+".yaml")
if err == nil && len(data) > 0 {
return parseYamlFormat(name+".yaml", data)
}
data, err = options.GetRepoInitFile("label", name+".yml")
if err == nil && len(data) > 0 {
return parseYamlFormat(name+".yml", data)
}
data, err = options.GetRepoInitFile("label", name)
if err != nil {
return nil, ErrTemplateLoad{name, fmt.Errorf("GetRepoInitFile: %w", err)}
}
return parseLegacyFormat(name, data)
}
func parseYamlFormat(name string, data []byte) ([]*Label, error) {
lf := &labelFile{}
if err := yaml.Unmarshal(data, lf); err != nil {
return nil, err
}
// Validate label data and fix colors
for _, l := range lf.Labels {
l.Color = strings.TrimSpace(l.Color)
if len(l.Name) == 0 || len(l.Color) == 0 {
return nil, ErrTemplateLoad{name, errors.New("label name and color are required fields")}
}
color, err := NormalizeColor(l.Color)
if err != nil {
return nil, ErrTemplateLoad{name, fmt.Errorf("bad HTML color code '%s' in label: %s", l.Color, l.Name)}
}
l.Color = color
}
return lf.Labels, nil
}
func parseLegacyFormat(name string, data []byte) ([]*Label, error) {
lines := strings.Split(string(data), "\n")
list := make([]*Label, 0, len(lines))
for i := 0; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if len(line) == 0 {
continue
}
parts, description, _ := strings.Cut(line, ";")
color, name, ok := strings.Cut(parts, " ")
if !ok {
return nil, ErrTemplateLoad{name, fmt.Errorf("line is malformed: %s", line)}
}
color, err := NormalizeColor(color)
if err != nil {
return nil, ErrTemplateLoad{name, fmt.Errorf("bad HTML color code '%s' in line: %s", color, line)}
}
list = append(list, &Label{
Name: strings.TrimSpace(name),
Color: color,
Description: strings.TrimSpace(description),
})
}
return list, nil
}
// LoadFormatted loads the labels' list of a template file as a string separated by comma
func LoadFormatted(name string) (string, error) {
var buf strings.Builder
list, err := GetTemplateFile(name)
if err != nil {
return "", err
}
for i := 0; i < len(list); i++ {
if i > 0 {
buf.WriteString(", ")
}
buf.WriteString(list[i].Name)
}
return buf.String(), nil
}

View File

@ -0,0 +1,72 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package label
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestYamlParser(t *testing.T) {
data := []byte(`labels:
- name: priority/low
exclusive: true
color: "#0000ee"
description: "Low priority"
- name: priority/medium
exclusive: true
color: "0e0"
description: "Medium priority"
- name: priority/high
exclusive: true
color: "#ee0000"
description: "High priority"
- name: type/bug
color: "#f00"
description: "Bug"`)
labels, err := parseYamlFormat("test", data)
require.NoError(t, err)
require.Len(t, labels, 4)
assert.Equal(t, "priority/low", labels[0].Name)
assert.True(t, labels[0].Exclusive)
assert.Equal(t, "#0000ee", labels[0].Color)
assert.Equal(t, "Low priority", labels[0].Description)
assert.Equal(t, "priority/medium", labels[1].Name)
assert.True(t, labels[1].Exclusive)
assert.Equal(t, "#00ee00", labels[1].Color)
assert.Equal(t, "Medium priority", labels[1].Description)
assert.Equal(t, "priority/high", labels[2].Name)
assert.True(t, labels[2].Exclusive)
assert.Equal(t, "#ee0000", labels[2].Color)
assert.Equal(t, "High priority", labels[2].Description)
assert.Equal(t, "type/bug", labels[3].Name)
assert.False(t, labels[3].Exclusive)
assert.Equal(t, "#ff0000", labels[3].Color)
assert.Equal(t, "Bug", labels[3].Description)
}
func TestLegacyParser(t *testing.T) {
data := []byte(`#ee0701 bug ; Something is not working
#cccccc duplicate ; This issue or pull request already exists
#84b6eb enhancement`)
labels, err := parseLegacyFormat("test", data)
require.NoError(t, err)
require.Len(t, labels, 3)
assert.Equal(t, "bug", labels[0].Name)
assert.False(t, labels[0].Exclusive)
assert.Equal(t, "#ee0701", labels[0].Color)
assert.Equal(t, "Something is not working", labels[0].Description)
assert.Equal(t, "duplicate", labels[1].Name)
assert.False(t, labels[1].Exclusive)
assert.Equal(t, "#cccccc", labels[1].Color)
assert.Equal(t, "This issue or pull request already exists", labels[1].Description)
assert.Equal(t, "enhancement", labels[2].Name)
assert.False(t, labels[2].Exclusive)
assert.Equal(t, "#84b6eb", labels[2].Color)
assert.Empty(t, labels[2].Description)
}

44
modules/options/repo.go Normal file
View File

@ -0,0 +1,44 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package options
import (
"fmt"
"os"
"path"
"strings"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
// GetRepoInitFile returns repository init files
func GetRepoInitFile(tp, name string) ([]byte, error) {
cleanedName := strings.TrimLeft(path.Clean("/"+name), "/")
relPath := path.Join("options", tp, cleanedName)
// Use custom file when available.
customPath := path.Join(setting.CustomPath, relPath)
isFile, err := util.IsFile(customPath)
if err != nil {
log.Error("Unable to check if %s is a file. Error: %v", customPath, err)
}
if isFile {
return os.ReadFile(customPath)
}
switch tp {
case "readme":
return Readme(cleanedName)
case "gitignore":
return Gitignore(cleanedName)
case "license":
return License(cleanedName)
case "label":
return Labels(cleanedName)
default:
return []byte{}, fmt.Errorf("Invalid init file type")
}
}

View File

@ -23,6 +23,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
@ -189,7 +190,7 @@ func CreateRepository(doer, u *user_model.User, opts CreateRepoOptions) (*repo_m
// Check if label template exist // Check if label template exist
if len(opts.IssueLabels) > 0 { if len(opts.IssueLabels) > 0 {
if _, err := GetLabelTemplateFile(opts.IssueLabels); err != nil { if _, err := label.GetTemplateFile(opts.IssueLabels); err != nil {
return nil, err return nil, err
} }
} }

View File

@ -18,6 +18,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/options" "code.gitea.io/gitea/modules/options"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -40,114 +41,6 @@ var (
LabelTemplates map[string]string LabelTemplates map[string]string
) )
// ErrIssueLabelTemplateLoad represents a "ErrIssueLabelTemplateLoad" kind of error.
type ErrIssueLabelTemplateLoad struct {
TemplateFile string
OriginalError error
}
// IsErrIssueLabelTemplateLoad checks if an error is a ErrIssueLabelTemplateLoad.
func IsErrIssueLabelTemplateLoad(err error) bool {
_, ok := err.(ErrIssueLabelTemplateLoad)
return ok
}
func (err ErrIssueLabelTemplateLoad) Error() string {
return fmt.Sprintf("Failed to load label template file '%s': %v", err.TemplateFile, err.OriginalError)
}
// GetRepoInitFile returns repository init files
func GetRepoInitFile(tp, name string) ([]byte, error) {
cleanedName := strings.TrimLeft(path.Clean("/"+name), "/")
relPath := path.Join("options", tp, cleanedName)
// Use custom file when available.
customPath := path.Join(setting.CustomPath, relPath)
isFile, err := util.IsFile(customPath)
if err != nil {
log.Error("Unable to check if %s is a file. Error: %v", customPath, err)
}
if isFile {
return os.ReadFile(customPath)
}
switch tp {
case "readme":
return options.Readme(cleanedName)
case "gitignore":
return options.Gitignore(cleanedName)
case "license":
return options.License(cleanedName)
case "label":
return options.Labels(cleanedName)
default:
return []byte{}, fmt.Errorf("Invalid init file type")
}
}
// GetLabelTemplateFile loads the label template file by given name,
// then parses and returns a list of name-color pairs and optionally description.
func GetLabelTemplateFile(name string) ([][3]string, error) {
data, err := GetRepoInitFile("label", name)
if err != nil {
return nil, ErrIssueLabelTemplateLoad{name, fmt.Errorf("GetRepoInitFile: %w", err)}
}
lines := strings.Split(string(data), "\n")
list := make([][3]string, 0, len(lines))
for i := 0; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if len(line) == 0 {
continue
}
parts := strings.SplitN(line, ";", 2)
fields := strings.SplitN(parts[0], " ", 2)
if len(fields) != 2 {
return nil, ErrIssueLabelTemplateLoad{name, fmt.Errorf("line is malformed: %s", line)}
}
color := strings.Trim(fields[0], " ")
if len(color) == 6 {
color = "#" + color
}
if !issues_model.LabelColorPattern.MatchString(color) {
return nil, ErrIssueLabelTemplateLoad{name, fmt.Errorf("bad HTML color code in line: %s", line)}
}
var description string
if len(parts) > 1 {
description = strings.TrimSpace(parts[1])
}
fields[1] = strings.TrimSpace(fields[1])
list = append(list, [3]string{fields[1], color, description})
}
return list, nil
}
func loadLabels(labelTemplate string) ([]string, error) {
list, err := GetLabelTemplateFile(labelTemplate)
if err != nil {
return nil, err
}
labels := make([]string, len(list))
for i := 0; i < len(list); i++ {
labels[i] = list[i][0]
}
return labels, nil
}
// LoadLabelsFormatted loads the labels' list of a template file as a string separated by comma
func LoadLabelsFormatted(labelTemplate string) (string, error) {
labels, err := loadLabels(labelTemplate)
return strings.Join(labels, ", "), err
}
// LoadRepoConfig loads the repository config // LoadRepoConfig loads the repository config
func LoadRepoConfig() { func LoadRepoConfig() {
// Load .gitignore and license files and readme templates. // Load .gitignore and license files and readme templates.
@ -158,6 +51,14 @@ func LoadRepoConfig() {
if err != nil { if err != nil {
log.Fatal("Failed to get %s files: %v", t, err) log.Fatal("Failed to get %s files: %v", t, err)
} }
if t == "label" {
for i, f := range files {
ext := strings.ToLower(filepath.Ext(f))
if ext == ".yaml" || ext == ".yml" {
files[i] = f[:len(f)-len(ext)]
}
}
}
customPath := path.Join(setting.CustomPath, "options", t) customPath := path.Join(setting.CustomPath, "options", t)
isDir, err := util.IsDir(customPath) isDir, err := util.IsDir(customPath)
if err != nil { if err != nil {
@ -190,7 +91,7 @@ func LoadRepoConfig() {
// Load label templates // Load label templates
LabelTemplates = make(map[string]string) LabelTemplates = make(map[string]string)
for _, templateFile := range LabelTemplatesFiles { for _, templateFile := range LabelTemplatesFiles {
labels, err := LoadLabelsFormatted(templateFile) labels, err := label.LoadFormatted(templateFile)
if err != nil { if err != nil {
log.Error("Failed to load labels: %v", err) log.Error("Failed to load labels: %v", err)
} }
@ -235,7 +136,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir,
} }
// README // README
data, err := GetRepoInitFile("readme", opts.Readme) data, err := options.GetRepoInitFile("readme", opts.Readme)
if err != nil { if err != nil {
return fmt.Errorf("GetRepoInitFile[%s]: %w", opts.Readme, err) return fmt.Errorf("GetRepoInitFile[%s]: %w", opts.Readme, err)
} }
@ -263,7 +164,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir,
var buf bytes.Buffer var buf bytes.Buffer
names := strings.Split(opts.Gitignores, ",") names := strings.Split(opts.Gitignores, ",")
for _, name := range names { for _, name := range names {
data, err = GetRepoInitFile("gitignore", name) data, err = options.GetRepoInitFile("gitignore", name)
if err != nil { if err != nil {
return fmt.Errorf("GetRepoInitFile[%s]: %w", name, err) return fmt.Errorf("GetRepoInitFile[%s]: %w", name, err)
} }
@ -281,7 +182,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir,
// LICENSE // LICENSE
if len(opts.License) > 0 { if len(opts.License) > 0 {
data, err = GetRepoInitFile("license", opts.License) data, err = options.GetRepoInitFile("license", opts.License)
if err != nil { if err != nil {
return fmt.Errorf("GetRepoInitFile[%s]: %w", opts.License, err) return fmt.Errorf("GetRepoInitFile[%s]: %w", opts.License, err)
} }
@ -443,7 +344,7 @@ func initRepository(ctx context.Context, repoPath string, u *user_model.User, re
// InitializeLabels adds a label set to a repository using a template // InitializeLabels adds a label set to a repository using a template
func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg bool) error { func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg bool) error {
list, err := GetLabelTemplateFile(labelTemplate) list, err := label.GetTemplateFile(labelTemplate)
if err != nil { if err != nil {
return err return err
} }
@ -451,9 +352,10 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg
labels := make([]*issues_model.Label, len(list)) labels := make([]*issues_model.Label, len(list))
for i := 0; i < len(list); i++ { for i := 0; i < len(list); i++ {
labels[i] = &issues_model.Label{ labels[i] = &issues_model.Label{
Name: list[i][0], Name: list[i].Name,
Description: list[i][2], Exclusive: list[i].Exclusive,
Color: list[i][1], Description: list[i].Description,
Color: list[i].Color,
} }
if isOrg { if isOrg {
labels[i].OrgID = id labels[i].OrgID = id

View File

@ -0,0 +1,70 @@
labels:
- name: "Kind/Bug"
color: ee0701
description: Something is not working
- name: "Kind/Feature"
color: 0288d1
description: New functionality
- name: "Kind/Enhancement"
color: 84b6eb
description: Improve existing functionality
- name: "Kind/Security"
color: 9c27b0
description: This is security issue
- name: "Kind/Testing"
color: 795548
description: Issue or pull request related to testing
- name: "Kind/Breaking"
color: c62828
description: Breaking change that won't be backward compatible
- name: "Kind/Documentation"
color: 37474f
description: Documentation changes
- name: "Reviewed/Duplicate"
exclusive: true
color: 616161
description: This issue or pull request already exists
- name: "Reviewed/Invalid"
exclusive: true
color: 546e7a
description: Invalid issue
- name: "Reviewed/Confirmed"
exclusive: true
color: 795548
description: Issue has been confirmed
- name: "Reviewed/Won't Fix"
exclusive: true
color: eeeeee
description: This issue won't be fixed
- name: "Status/Need More Info"
exclusive: true
color: 424242
description: Feedback is required to reproduce issue or to continue work
- name: "Status/Blocked"
exclusive: true
color: 880e4f
description: Something is blocking this issue or pull request
- name: "Status/Abandoned"
exclusive: true
color: "222222"
description: Somebody has started to work on this but abandoned work
- name: "Priority/Critical"
exclusive: true
color: b71c1c
description: The priority is critical
priority: critical
- name: "Priority/High"
exclusive: true
color: d32f2f
description: The priority is high
priority: high
- name: "Priority/Medium"
exclusive: true
color: e64a19
description: The priority is medium
priority: medium
- name: "Priority/Low"
exclusive: true
color: 4caf50
description: The priority is low
priority: low

View File

@ -4,13 +4,13 @@
package org package org
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/label"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/routers/api/v1/utils"
@ -84,13 +84,12 @@ func CreateLabel(ctx *context.APIContext) {
// "$ref": "#/responses/validationError" // "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.CreateLabelOption) form := web.GetForm(ctx).(*api.CreateLabelOption)
form.Color = strings.Trim(form.Color, " ") form.Color = strings.Trim(form.Color, " ")
if len(form.Color) == 6 { color, err := label.NormalizeColor(form.Color)
form.Color = "#" + form.Color if err != nil {
} ctx.Error(http.StatusUnprocessableEntity, "Color", err)
if !issues_model.LabelColorPattern.MatchString(form.Color) {
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", form.Color))
return return
} }
form.Color = color
label := &issues_model.Label{ label := &issues_model.Label{
Name: form.Name, Name: form.Name,
@ -183,7 +182,7 @@ func EditLabel(ctx *context.APIContext) {
// "422": // "422":
// "$ref": "#/responses/validationError" // "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.EditLabelOption) form := web.GetForm(ctx).(*api.EditLabelOption)
label, err := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, ctx.ParamsInt64(":id")) l, err := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, ctx.ParamsInt64(":id"))
if err != nil { if err != nil {
if issues_model.IsErrOrgLabelNotExist(err) { if issues_model.IsErrOrgLabelNotExist(err) {
ctx.NotFound() ctx.NotFound()
@ -194,30 +193,28 @@ func EditLabel(ctx *context.APIContext) {
} }
if form.Name != nil { if form.Name != nil {
label.Name = *form.Name l.Name = *form.Name
} }
if form.Exclusive != nil { if form.Exclusive != nil {
label.Exclusive = *form.Exclusive l.Exclusive = *form.Exclusive
} }
if form.Color != nil { if form.Color != nil {
label.Color = strings.Trim(*form.Color, " ") color, err := label.NormalizeColor(*form.Color)
if len(label.Color) == 6 { if err != nil {
label.Color = "#" + label.Color ctx.Error(http.StatusUnprocessableEntity, "Color", err)
}
if !issues_model.LabelColorPattern.MatchString(label.Color) {
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", label.Color))
return return
} }
l.Color = color
} }
if form.Description != nil { if form.Description != nil {
label.Description = *form.Description l.Description = *form.Description
} }
if err := issues_model.UpdateLabel(label); err != nil { if err := issues_model.UpdateLabel(l); err != nil {
ctx.Error(http.StatusInternalServerError, "UpdateLabel", err) ctx.Error(http.StatusInternalServerError, "UpdateLabel", err)
return return
} }
ctx.JSON(http.StatusOK, convert.ToLabel(label, nil, ctx.Org.Organization.AsUser())) ctx.JSON(http.StatusOK, convert.ToLabel(l, nil, ctx.Org.Organization.AsUser()))
} }
// DeleteLabel delete a label for an organization // DeleteLabel delete a label for an organization

View File

@ -5,13 +5,12 @@
package repo package repo
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/label"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/routers/api/v1/utils"
@ -93,14 +92,14 @@ func GetLabel(ctx *context.APIContext) {
// "$ref": "#/responses/Label" // "$ref": "#/responses/Label"
var ( var (
label *issues_model.Label l *issues_model.Label
err error err error
) )
strID := ctx.Params(":id") strID := ctx.Params(":id")
if intID, err2 := strconv.ParseInt(strID, 10, 64); err2 != nil { if intID, err2 := strconv.ParseInt(strID, 10, 64); err2 != nil {
label, err = issues_model.GetLabelInRepoByName(ctx, ctx.Repo.Repository.ID, strID) l, err = issues_model.GetLabelInRepoByName(ctx, ctx.Repo.Repository.ID, strID)
} else { } else {
label, err = issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, intID) l, err = issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, intID)
} }
if err != nil { if err != nil {
if issues_model.IsErrRepoLabelNotExist(err) { if issues_model.IsErrRepoLabelNotExist(err) {
@ -111,7 +110,7 @@ func GetLabel(ctx *context.APIContext) {
return return
} }
ctx.JSON(http.StatusOK, convert.ToLabel(label, ctx.Repo.Repository, nil)) ctx.JSON(http.StatusOK, convert.ToLabel(l, ctx.Repo.Repository, nil))
} }
// CreateLabel create a label for a repository // CreateLabel create a label for a repository
@ -145,28 +144,27 @@ func CreateLabel(ctx *context.APIContext) {
// "$ref": "#/responses/validationError" // "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.CreateLabelOption) form := web.GetForm(ctx).(*api.CreateLabelOption)
form.Color = strings.Trim(form.Color, " ")
if len(form.Color) == 6 { color, err := label.NormalizeColor(form.Color)
form.Color = "#" + form.Color if err != nil {
} ctx.Error(http.StatusUnprocessableEntity, "StringToColor", err)
if !issues_model.LabelColorPattern.MatchString(form.Color) {
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", form.Color))
return return
} }
form.Color = color
label := &issues_model.Label{ l := &issues_model.Label{
Name: form.Name, Name: form.Name,
Exclusive: form.Exclusive, Exclusive: form.Exclusive,
Color: form.Color, Color: form.Color,
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
Description: form.Description, Description: form.Description,
} }
if err := issues_model.NewLabel(ctx, label); err != nil { if err := issues_model.NewLabel(ctx, l); err != nil {
ctx.Error(http.StatusInternalServerError, "NewLabel", err) ctx.Error(http.StatusInternalServerError, "NewLabel", err)
return return
} }
ctx.JSON(http.StatusCreated, convert.ToLabel(label, ctx.Repo.Repository, nil)) ctx.JSON(http.StatusCreated, convert.ToLabel(l, ctx.Repo.Repository, nil))
} }
// EditLabel modify a label for a repository // EditLabel modify a label for a repository
@ -206,7 +204,7 @@ func EditLabel(ctx *context.APIContext) {
// "$ref": "#/responses/validationError" // "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.EditLabelOption) form := web.GetForm(ctx).(*api.EditLabelOption)
label, err := issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) l, err := issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
if err != nil { if err != nil {
if issues_model.IsErrRepoLabelNotExist(err) { if issues_model.IsErrRepoLabelNotExist(err) {
ctx.NotFound() ctx.NotFound()
@ -217,30 +215,28 @@ func EditLabel(ctx *context.APIContext) {
} }
if form.Name != nil { if form.Name != nil {
label.Name = *form.Name l.Name = *form.Name
} }
if form.Exclusive != nil { if form.Exclusive != nil {
label.Exclusive = *form.Exclusive l.Exclusive = *form.Exclusive
} }
if form.Color != nil { if form.Color != nil {
label.Color = strings.Trim(*form.Color, " ") color, err := label.NormalizeColor(*form.Color)
if len(label.Color) == 6 { if err != nil {
label.Color = "#" + label.Color ctx.Error(http.StatusUnprocessableEntity, "StringToColor", err)
}
if !issues_model.LabelColorPattern.MatchString(label.Color) {
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", label.Color))
return return
} }
l.Color = color
} }
if form.Description != nil { if form.Description != nil {
label.Description = *form.Description l.Description = *form.Description
} }
if err := issues_model.UpdateLabel(label); err != nil { if err := issues_model.UpdateLabel(l); err != nil {
ctx.Error(http.StatusInternalServerError, "UpdateLabel", err) ctx.Error(http.StatusInternalServerError, "UpdateLabel", err)
return return
} }
ctx.JSON(http.StatusOK, convert.ToLabel(label, ctx.Repo.Repository, nil)) ctx.JSON(http.StatusOK, convert.ToLabel(l, ctx.Repo.Repository, nil))
} }
// DeleteLabel delete a label for a repository // DeleteLabel delete a label for a repository

View File

@ -19,6 +19,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -248,7 +249,7 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre
ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.") ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.")
} else if db.IsErrNameReserved(err) || } else if db.IsErrNameReserved(err) ||
db.IsErrNamePatternNotAllowed(err) || db.IsErrNamePatternNotAllowed(err) ||
repo_module.IsErrIssueLabelTemplateLoad(err) { label.IsErrTemplateLoad(err) {
ctx.Error(http.StatusUnprocessableEntity, "", err) ctx.Error(http.StatusUnprocessableEntity, "", err)
} else { } else {
ctx.Error(http.StatusInternalServerError, "CreateRepository", err) ctx.Error(http.StatusInternalServerError, "CreateRepository", err)

View File

@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/label"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
@ -103,8 +104,8 @@ func InitializeLabels(ctx *context.Context) {
} }
if err := repo_module.InitializeLabels(ctx, ctx.Org.Organization.ID, form.TemplateName, true); err != nil { if err := repo_module.InitializeLabels(ctx, ctx.Org.Organization.ID, form.TemplateName, true); err != nil {
if repo_module.IsErrIssueLabelTemplateLoad(err) { if label.IsErrTemplateLoad(err) {
originalErr := err.(repo_module.ErrIssueLabelTemplateLoad).OriginalError originalErr := err.(label.ErrTemplateLoad).OriginalError
ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr))
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") ctx.Redirect(ctx.Org.OrgLink + "/settings/labels")
return return

View File

@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
@ -41,8 +42,8 @@ func InitializeLabels(ctx *context.Context) {
} }
if err := repo_module.InitializeLabels(ctx, ctx.Repo.Repository.ID, form.TemplateName, false); err != nil { if err := repo_module.InitializeLabels(ctx, ctx.Repo.Repository.ID, form.TemplateName, false); err != nil {
if repo_module.IsErrIssueLabelTemplateLoad(err) { if label.IsErrTemplateLoad(err) {
originalErr := err.(repo_module.ErrIssueLabelTemplateLoad).OriginalError originalErr := err.(label.ErrTemplateLoad).OriginalError
ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr))
ctx.Redirect(ctx.Repo.RepoLink + "/labels") ctx.Redirect(ctx.Repo.RepoLink + "/labels")
return return

View File

@ -21,6 +21,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
base "code.gitea.io/gitea/modules/migration" base "code.gitea.io/gitea/modules/migration"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
@ -217,18 +218,20 @@ func (g *GiteaLocalUploader) CreateMilestones(milestones ...*base.Milestone) err
// CreateLabels creates labels // CreateLabels creates labels
func (g *GiteaLocalUploader) CreateLabels(labels ...*base.Label) error { func (g *GiteaLocalUploader) CreateLabels(labels ...*base.Label) error {
lbs := make([]*issues_model.Label, 0, len(labels)) lbs := make([]*issues_model.Label, 0, len(labels))
for _, label := range labels { for _, l := range labels {
// We must validate color here: if color, err := label.NormalizeColor(l.Color); err != nil {
if !issues_model.LabelColorPattern.MatchString("#" + label.Color) { log.Warn("Invalid label color: #%s for label: %s in migration to %s/%s", l.Color, l.Name, g.repoOwner, g.repoName)
log.Warn("Invalid label color: #%s for label: %s in migration to %s/%s", label.Color, label.Name, g.repoOwner, g.repoName) l.Color = "#ffffff"
label.Color = "ffffff" } else {
l.Color = color
} }
lbs = append(lbs, &issues_model.Label{ lbs = append(lbs, &issues_model.Label{
RepoID: g.repo.ID, RepoID: g.repo.ID,
Name: label.Name, Name: l.Name,
Description: label.Description, Exclusive: l.Exclusive,
Color: "#" + label.Color, Description: l.Description,
Color: l.Color,
}) })
} }