From 58b414380371a4419f909491700673d43ae6b4ff Mon Sep 17 00:00:00 2001 From: Lauris BH Date: Thu, 2 Mar 2023 01:44:23 +0200 Subject: [PATCH] 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 --- models/issues/label.go | 115 ++++++++++------------ models/issues/label_test.go | 2 - modules/label/label.go | 46 +++++++++ modules/label/parser.go | 126 ++++++++++++++++++++++++ modules/label/parser_test.go | 72 ++++++++++++++ modules/options/repo.go | 44 +++++++++ modules/repository/create.go | 3 +- modules/repository/init.go | 134 ++++---------------------- options/label/Advanced.yaml | 70 ++++++++++++++ routers/api/v1/org/label.go | 33 +++---- routers/api/v1/repo/label.go | 52 +++++----- routers/api/v1/repo/repo.go | 3 +- routers/web/org/org_labels.go | 5 +- routers/web/repo/issue_label.go | 5 +- services/migrations/gitea_uploader.go | 19 ++-- 15 files changed, 488 insertions(+), 241 deletions(-) create mode 100644 modules/label/label.go create mode 100644 modules/label/parser.go create mode 100644 modules/label/parser_test.go create mode 100644 modules/options/repo.go create mode 100644 options/label/Advanced.yaml diff --git a/models/issues/label.go b/models/issues/label.go index 90e4eb458f8..35c649e8f24 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -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) } } diff --git a/models/issues/label_test.go b/models/issues/label_test.go index 0e45e0db0ba..1f6ce4f42ee 100644 --- a/models/issues/label_test.go +++ b/models/issues/label_test.go @@ -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}) diff --git a/modules/label/label.go b/modules/label/label.go new file mode 100644 index 00000000000..d3ef0e1dc96 --- /dev/null +++ b/modules/label/label.go @@ -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 +} diff --git a/modules/label/parser.go b/modules/label/parser.go new file mode 100644 index 00000000000..768c72a61b0 --- /dev/null +++ b/modules/label/parser.go @@ -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 +} diff --git a/modules/label/parser_test.go b/modules/label/parser_test.go new file mode 100644 index 00000000000..5c8042f6686 --- /dev/null +++ b/modules/label/parser_test.go @@ -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) +} diff --git a/modules/options/repo.go b/modules/options/repo.go new file mode 100644 index 00000000000..1480f780817 --- /dev/null +++ b/modules/options/repo.go @@ -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") + } +} diff --git a/modules/repository/create.go b/modules/repository/create.go index 1704ea792cb..6a1fa41b6b8 100644 --- a/modules/repository/create.go +++ b/modules/repository/create.go @@ -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 } } diff --git a/modules/repository/init.go b/modules/repository/init.go index 771b68a4916..49c8d2a904d 100644 --- a/modules/repository/init.go +++ b/modules/repository/init.go @@ -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 diff --git a/options/label/Advanced.yaml b/options/label/Advanced.yaml new file mode 100644 index 00000000000..27b2c146372 --- /dev/null +++ b/options/label/Advanced.yaml @@ -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 diff --git a/routers/api/v1/org/label.go b/routers/api/v1/org/label.go index 938fe79df64..183c1e6cc8a 100644 --- a/routers/api/v1/org/label.go +++ b/routers/api/v1/org/label.go @@ -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 diff --git a/routers/api/v1/repo/label.go b/routers/api/v1/repo/label.go index a06d26e8373..6cb231f596c 100644 --- a/routers/api/v1/repo/label.go +++ b/routers/api/v1/repo/label.go @@ -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 diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 2f32ea956f1..397600dc50a 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -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) diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go index e96627762bd..9ce05680d7b 100644 --- a/routers/web/org/org_labels.go +++ b/routers/web/org/org_labels.go @@ -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 diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go index d4fece9f014..31bf85fedb2 100644 --- a/routers/web/repo/issue_label.go +++ b/routers/web/repo/issue_label.go @@ -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 diff --git a/services/migrations/gitea_uploader.go b/services/migrations/gitea_uploader.go index 20370d99f98..8b259a362b1 100644 --- a/services/migrations/gitea_uploader.go +++ b/services/migrations/gitea_uploader.go @@ -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, }) }