Backport #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 Co-authored-by: Lauris BH <lauris@nix.lv>tags/v1.19.0-rc1
@@ -7,12 +7,12 @@ package issues | |||
import ( | |||
"context" | |||
"fmt" | |||
"regexp" | |||
"strconv" | |||
"strings" | |||
"code.gitea.io/gitea/models/db" | |||
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/util" | |||
@@ -78,9 +78,6 @@ func (err ErrLabelNotExist) Unwrap() error { | |||
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. | |||
type Label struct { | |||
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. | |||
func (label *Label) CalOpenIssues() { | |||
label.NumOpenIssues = label.NumIssues - label.NumClosedIssues | |||
func (l *Label) CalOpenIssues() { | |||
l.NumOpenIssues = l.NumIssues - l.NumClosedIssues | |||
} | |||
// 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{ | |||
RepoID: repoID, | |||
LabelIDs: []int64{labelID}, | |||
@@ -122,22 +119,22 @@ func (label *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) | |||
}) | |||
for _, count := range counts { | |||
label.NumOpenRepoIssues += count | |||
l.NumOpenRepoIssues += count | |||
} | |||
} | |||
// 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 | |||
labelSelected := false | |||
labelID := strconv.FormatInt(label.ID, 10) | |||
labelScope := label.ExclusiveScope() | |||
labelID := strconv.FormatInt(l.ID, 10) | |||
labelScope := l.ExclusiveScope() | |||
for i, s := range currentSelectedLabels { | |||
if s == label.ID { | |||
if s == l.ID { | |||
labelSelected = true | |||
} else if -s == label.ID { | |||
} else if -s == l.ID { | |||
labelSelected = true | |||
label.IsExcluded = true | |||
l.IsExcluded = true | |||
} else if s != 0 { | |||
// Exclude other labels in the same scope from selection | |||
if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] { | |||
@@ -148,23 +145,23 @@ func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, | |||
if !labelSelected { | |||
labelQuerySlice = append(labelQuerySlice, labelID) | |||
} | |||
label.IsSelected = labelSelected | |||
label.QueryString = strings.Join(labelQuerySlice, ",") | |||
l.IsSelected = labelSelected | |||
l.QueryString = strings.Join(labelQuerySlice, ",") | |||
} | |||
// BelongsToOrg returns true if label is an organization label | |||
func (label *Label) BelongsToOrg() bool { | |||
return label.OrgID > 0 | |||
func (l *Label) BelongsToOrg() bool { | |||
return l.OrgID > 0 | |||
} | |||
// BelongsToRepo returns true if label is a repository label | |||
func (label *Label) BelongsToRepo() bool { | |||
return label.RepoID > 0 | |||
func (l *Label) BelongsToRepo() bool { | |||
return l.RepoID > 0 | |||
} | |||
// Get color as RGB values in 0..255 range | |||
func (label *Label) ColorRGB() (float64, float64, float64, error) { | |||
color, err := strconv.ParseUint(label.Color[1:], 16, 64) | |||
func (l *Label) ColorRGB() (float64, float64, float64, error) { | |||
color, err := strconv.ParseUint(l.Color[1:], 16, 64) | |||
if err != nil { | |||
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 | |||
func (label *Label) UseLightTextColor() bool { | |||
if strings.HasPrefix(label.Color, "#") { | |||
if r, g, b, err := label.ColorRGB(); err == nil { | |||
func (l *Label) UseLightTextColor() bool { | |||
if strings.HasPrefix(l.Color, "#") { | |||
if r, g, b, err := l.ColorRGB(); err == nil { | |||
// Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast | |||
// In the future WCAG 3 APCA may be a better solution | |||
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 | |||
func (label *Label) ExclusiveScope() string { | |||
if !label.Exclusive { | |||
func (l *Label) ExclusiveScope() string { | |||
if !l.Exclusive { | |||
return "" | |||
} | |||
lastIndex := strings.LastIndex(label.Name, "/") | |||
if lastIndex == -1 || lastIndex == 0 || lastIndex == len(label.Name)-1 { | |||
lastIndex := strings.LastIndex(l.Name, "/") | |||
if lastIndex == -1 || lastIndex == 0 || lastIndex == len(l.Name)-1 { | |||
return "" | |||
} | |||
return label.Name[:lastIndex] | |||
return l.Name[:lastIndex] | |||
} | |||
// NewLabel creates a new label | |||
func NewLabel(ctx context.Context, label *Label) error { | |||
if !LabelColorPattern.MatchString(label.Color) { | |||
return fmt.Errorf("bad color code: %s", label.Color) | |||
} | |||
// normalize case | |||
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) | |||
func NewLabel(ctx context.Context, l *Label) error { | |||
color, err := label.NormalizeColor(l.Color) | |||
if err != nil { | |||
return err | |||
} | |||
l.Color = color | |||
return db.Insert(ctx, label) | |||
return db.Insert(ctx, l) | |||
} | |||
// NewLabels creates new labels | |||
@@ -234,11 +217,14 @@ func NewLabels(labels ...*Label) error { | |||
} | |||
defer committer.Close() | |||
for _, label := range labels { | |||
if !LabelColorPattern.MatchString(label.Color) { | |||
return fmt.Errorf("bad color code: %s", label.Color) | |||
for _, l := range labels { | |||
color, err := label.NormalizeColor(l.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 | |||
} | |||
} | |||
@@ -247,15 +233,18 @@ func NewLabels(labels ...*Label) error { | |||
// UpdateLabel updates label information. | |||
func UpdateLabel(l *Label) error { | |||
if !LabelColorPattern.MatchString(l.Color) { | |||
return fmt.Errorf("bad color code: %s", l.Color) | |||
color, err := label.NormalizeColor(l.Color) | |||
if err != nil { | |||
return err | |||
} | |||
l.Color = color | |||
return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive") | |||
} | |||
// DeleteLabel delete a label | |||
func DeleteLabel(id, labelID int64) error { | |||
label, err := GetLabelByID(db.DefaultContext, labelID) | |||
l, err := GetLabelByID(db.DefaultContext, labelID) | |||
if err != nil { | |||
if IsErrLabelNotExist(err) { | |||
return nil | |||
@@ -271,10 +260,10 @@ func DeleteLabel(id, labelID int64) error { | |||
sess := db.GetEngine(ctx) | |||
if label.BelongsToOrg() && label.OrgID != id { | |||
if l.BelongsToOrg() && l.OrgID != id { | |||
return nil | |||
} | |||
if label.BelongsToRepo() && label.RepoID != id { | |||
if l.BelongsToRepo() && l.RepoID != id { | |||
return nil | |||
} | |||
@@ -682,14 +671,14 @@ func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *us | |||
if err = issue.LoadRepo(ctx); err != nil { | |||
return err | |||
} | |||
for _, label := range labels { | |||
for _, l := range labels { | |||
// Don't add already present labels and invalid labels | |||
if HasIssueLabel(ctx, issue.ID, label.ID) || | |||
(label.RepoID != issue.RepoID && label.OrgID != issue.Repo.OwnerID) { | |||
if HasIssueLabel(ctx, issue.ID, l.ID) || | |||
(l.RepoID != issue.RepoID && l.OrgID != issue.Repo.OwnerID) { | |||
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) | |||
} | |||
} |
@@ -15,8 +15,6 @@ import ( | |||
"github.com/stretchr/testify/assert" | |||
) | |||
// TODO TestGetLabelTemplateFile | |||
func TestLabel_CalOpenIssues(t *testing.T) { | |||
assert.NoError(t, unittest.PrepareTestDatabase()) | |||
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) |
@@ -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 | |||
} |
@@ -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 | |||
} |
@@ -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) | |||
} |
@@ -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") | |||
} | |||
} |
@@ -23,6 +23,7 @@ import ( | |||
user_model "code.gitea.io/gitea/models/user" | |||
"code.gitea.io/gitea/models/webhook" | |||
"code.gitea.io/gitea/modules/git" | |||
"code.gitea.io/gitea/modules/label" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/setting" | |||
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 | |||
if len(opts.IssueLabels) > 0 { | |||
if _, err := GetLabelTemplateFile(opts.IssueLabels); err != nil { | |||
if _, err := label.GetTemplateFile(opts.IssueLabels); err != nil { | |||
return nil, err | |||
} | |||
} |
@@ -18,6 +18,7 @@ import ( | |||
repo_model "code.gitea.io/gitea/models/repo" | |||
user_model "code.gitea.io/gitea/models/user" | |||
"code.gitea.io/gitea/modules/git" | |||
"code.gitea.io/gitea/modules/label" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/options" | |||
"code.gitea.io/gitea/modules/setting" | |||
@@ -40,114 +41,6 @@ var ( | |||
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 | |||
func LoadRepoConfig() { | |||
// Load .gitignore and license files and readme templates. | |||
@@ -158,6 +51,14 @@ func LoadRepoConfig() { | |||
if err != nil { | |||
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) | |||
isDir, err := util.IsDir(customPath) | |||
if err != nil { | |||
@@ -190,7 +91,7 @@ func LoadRepoConfig() { | |||
// Load label templates | |||
LabelTemplates = make(map[string]string) | |||
for _, templateFile := range LabelTemplatesFiles { | |||
labels, err := LoadLabelsFormatted(templateFile) | |||
labels, err := label.LoadFormatted(templateFile) | |||
if err != nil { | |||
log.Error("Failed to load labels: %v", err) | |||
} | |||
@@ -235,7 +136,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir, | |||
} | |||
// README | |||
data, err := GetRepoInitFile("readme", opts.Readme) | |||
data, err := options.GetRepoInitFile("readme", opts.Readme) | |||
if err != nil { | |||
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 | |||
names := strings.Split(opts.Gitignores, ",") | |||
for _, name := range names { | |||
data, err = GetRepoInitFile("gitignore", name) | |||
data, err = options.GetRepoInitFile("gitignore", name) | |||
if err != nil { | |||
return fmt.Errorf("GetRepoInitFile[%s]: %w", name, err) | |||
} | |||
@@ -281,7 +182,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir, | |||
// LICENSE | |||
if len(opts.License) > 0 { | |||
data, err = GetRepoInitFile("license", opts.License) | |||
data, err = options.GetRepoInitFile("license", opts.License) | |||
if err != nil { | |||
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 | |||
func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg bool) error { | |||
list, err := GetLabelTemplateFile(labelTemplate) | |||
list, err := label.GetTemplateFile(labelTemplate) | |||
if err != nil { | |||
return err | |||
} | |||
@@ -451,9 +352,10 @@ func InitializeLabels(ctx context.Context, id int64, labelTemplate string, isOrg | |||
labels := make([]*issues_model.Label, len(list)) | |||
for i := 0; i < len(list); i++ { | |||
labels[i] = &issues_model.Label{ | |||
Name: list[i][0], | |||
Description: list[i][2], | |||
Color: list[i][1], | |||
Name: list[i].Name, | |||
Exclusive: list[i].Exclusive, | |||
Description: list[i].Description, | |||
Color: list[i].Color, | |||
} | |||
if isOrg { | |||
labels[i].OrgID = id |
@@ -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 |
@@ -4,13 +4,13 @@ | |||
package org | |||
import ( | |||
"fmt" | |||
"net/http" | |||
"strconv" | |||
"strings" | |||
issues_model "code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/label" | |||
api "code.gitea.io/gitea/modules/structs" | |||
"code.gitea.io/gitea/modules/web" | |||
"code.gitea.io/gitea/routers/api/v1/utils" | |||
@@ -84,13 +84,12 @@ func CreateLabel(ctx *context.APIContext) { | |||
// "$ref": "#/responses/validationError" | |||
form := web.GetForm(ctx).(*api.CreateLabelOption) | |||
form.Color = strings.Trim(form.Color, " ") | |||
if len(form.Color) == 6 { | |||
form.Color = "#" + form.Color | |||
} | |||
if !issues_model.LabelColorPattern.MatchString(form.Color) { | |||
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", form.Color)) | |||
color, err := label.NormalizeColor(form.Color) | |||
if err != nil { | |||
ctx.Error(http.StatusUnprocessableEntity, "Color", err) | |||
return | |||
} | |||
form.Color = color | |||
label := &issues_model.Label{ | |||
Name: form.Name, | |||
@@ -183,7 +182,7 @@ func EditLabel(ctx *context.APIContext) { | |||
// "422": | |||
// "$ref": "#/responses/validationError" | |||
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 issues_model.IsErrOrgLabelNotExist(err) { | |||
ctx.NotFound() | |||
@@ -194,30 +193,28 @@ func EditLabel(ctx *context.APIContext) { | |||
} | |||
if form.Name != nil { | |||
label.Name = *form.Name | |||
l.Name = *form.Name | |||
} | |||
if form.Exclusive != nil { | |||
label.Exclusive = *form.Exclusive | |||
l.Exclusive = *form.Exclusive | |||
} | |||
if form.Color != nil { | |||
label.Color = strings.Trim(*form.Color, " ") | |||
if len(label.Color) == 6 { | |||
label.Color = "#" + label.Color | |||
} | |||
if !issues_model.LabelColorPattern.MatchString(label.Color) { | |||
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", label.Color)) | |||
color, err := label.NormalizeColor(*form.Color) | |||
if err != nil { | |||
ctx.Error(http.StatusUnprocessableEntity, "Color", err) | |||
return | |||
} | |||
l.Color = color | |||
} | |||
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) | |||
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 |
@@ -5,13 +5,12 @@ | |||
package repo | |||
import ( | |||
"fmt" | |||
"net/http" | |||
"strconv" | |||
"strings" | |||
issues_model "code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/label" | |||
api "code.gitea.io/gitea/modules/structs" | |||
"code.gitea.io/gitea/modules/web" | |||
"code.gitea.io/gitea/routers/api/v1/utils" | |||
@@ -93,14 +92,14 @@ func GetLabel(ctx *context.APIContext) { | |||
// "$ref": "#/responses/Label" | |||
var ( | |||
label *issues_model.Label | |||
err error | |||
l *issues_model.Label | |||
err error | |||
) | |||
strID := ctx.Params(":id") | |||
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 { | |||
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 issues_model.IsErrRepoLabelNotExist(err) { | |||
@@ -111,7 +110,7 @@ func GetLabel(ctx *context.APIContext) { | |||
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 | |||
@@ -145,28 +144,27 @@ func CreateLabel(ctx *context.APIContext) { | |||
// "$ref": "#/responses/validationError" | |||
form := web.GetForm(ctx).(*api.CreateLabelOption) | |||
form.Color = strings.Trim(form.Color, " ") | |||
if len(form.Color) == 6 { | |||
form.Color = "#" + form.Color | |||
} | |||
if !issues_model.LabelColorPattern.MatchString(form.Color) { | |||
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", form.Color)) | |||
color, err := label.NormalizeColor(form.Color) | |||
if err != nil { | |||
ctx.Error(http.StatusUnprocessableEntity, "StringToColor", err) | |||
return | |||
} | |||
form.Color = color | |||
label := &issues_model.Label{ | |||
l := &issues_model.Label{ | |||
Name: form.Name, | |||
Exclusive: form.Exclusive, | |||
Color: form.Color, | |||
RepoID: ctx.Repo.Repository.ID, | |||
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) | |||
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 | |||
@@ -206,7 +204,7 @@ func EditLabel(ctx *context.APIContext) { | |||
// "$ref": "#/responses/validationError" | |||
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 issues_model.IsErrRepoLabelNotExist(err) { | |||
ctx.NotFound() | |||
@@ -217,30 +215,28 @@ func EditLabel(ctx *context.APIContext) { | |||
} | |||
if form.Name != nil { | |||
label.Name = *form.Name | |||
l.Name = *form.Name | |||
} | |||
if form.Exclusive != nil { | |||
label.Exclusive = *form.Exclusive | |||
l.Exclusive = *form.Exclusive | |||
} | |||
if form.Color != nil { | |||
label.Color = strings.Trim(*form.Color, " ") | |||
if len(label.Color) == 6 { | |||
label.Color = "#" + label.Color | |||
} | |||
if !issues_model.LabelColorPattern.MatchString(label.Color) { | |||
ctx.Error(http.StatusUnprocessableEntity, "ColorPattern", fmt.Errorf("bad color code: %s", label.Color)) | |||
color, err := label.NormalizeColor(*form.Color) | |||
if err != nil { | |||
ctx.Error(http.StatusUnprocessableEntity, "StringToColor", err) | |||
return | |||
} | |||
l.Color = color | |||
} | |||
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) | |||
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 |
@@ -19,6 +19,7 @@ import ( | |||
user_model "code.gitea.io/gitea/models/user" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/git" | |||
"code.gitea.io/gitea/modules/label" | |||
"code.gitea.io/gitea/modules/log" | |||
repo_module "code.gitea.io/gitea/modules/repository" | |||
"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.") | |||
} else if db.IsErrNameReserved(err) || | |||
db.IsErrNamePatternNotAllowed(err) || | |||
repo_module.IsErrIssueLabelTemplateLoad(err) { | |||
label.IsErrTemplateLoad(err) { | |||
ctx.Error(http.StatusUnprocessableEntity, "", err) | |||
} else { | |||
ctx.Error(http.StatusInternalServerError, "CreateRepository", err) |
@@ -9,6 +9,7 @@ import ( | |||
"code.gitea.io/gitea/models/db" | |||
issues_model "code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/label" | |||
repo_module "code.gitea.io/gitea/modules/repository" | |||
"code.gitea.io/gitea/modules/web" | |||
"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 repo_module.IsErrIssueLabelTemplateLoad(err) { | |||
originalErr := err.(repo_module.ErrIssueLabelTemplateLoad).OriginalError | |||
if label.IsErrTemplateLoad(err) { | |||
originalErr := err.(label.ErrTemplateLoad).OriginalError | |||
ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) | |||
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") | |||
return |
@@ -11,6 +11,7 @@ import ( | |||
"code.gitea.io/gitea/models/organization" | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/label" | |||
"code.gitea.io/gitea/modules/log" | |||
repo_module "code.gitea.io/gitea/modules/repository" | |||
"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 repo_module.IsErrIssueLabelTemplateLoad(err) { | |||
originalErr := err.(repo_module.ErrIssueLabelTemplateLoad).OriginalError | |||
if label.IsErrTemplateLoad(err) { | |||
originalErr := err.(label.ErrTemplateLoad).OriginalError | |||
ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) | |||
ctx.Redirect(ctx.Repo.RepoLink + "/labels") | |||
return |
@@ -21,6 +21,7 @@ import ( | |||
repo_model "code.gitea.io/gitea/models/repo" | |||
user_model "code.gitea.io/gitea/models/user" | |||
"code.gitea.io/gitea/modules/git" | |||
"code.gitea.io/gitea/modules/label" | |||
"code.gitea.io/gitea/modules/log" | |||
base "code.gitea.io/gitea/modules/migration" | |||
repo_module "code.gitea.io/gitea/modules/repository" | |||
@@ -217,18 +218,20 @@ func (g *GiteaLocalUploader) CreateMilestones(milestones ...*base.Milestone) err | |||
// CreateLabels creates labels | |||
func (g *GiteaLocalUploader) CreateLabels(labels ...*base.Label) error { | |||
lbs := make([]*issues_model.Label, 0, len(labels)) | |||
for _, label := range labels { | |||
// We must validate color here: | |||
if !issues_model.LabelColorPattern.MatchString("#" + label.Color) { | |||
log.Warn("Invalid label color: #%s for label: %s in migration to %s/%s", label.Color, label.Name, g.repoOwner, g.repoName) | |||
label.Color = "ffffff" | |||
for _, l := range labels { | |||
if color, err := label.NormalizeColor(l.Color); err != nil { | |||
log.Warn("Invalid label color: #%s for label: %s in migration to %s/%s", l.Color, l.Name, g.repoOwner, g.repoName) | |||
l.Color = "#ffffff" | |||
} else { | |||
l.Color = color | |||
} | |||
lbs = append(lbs, &issues_model.Label{ | |||
RepoID: g.repo.ID, | |||
Name: label.Name, | |||
Description: label.Description, | |||
Color: "#" + label.Color, | |||
Name: l.Name, | |||
Exclusive: l.Exclusive, | |||
Description: l.Description, | |||
Color: l.Color, | |||
}) | |||
} | |||