diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index 4dea8add1..174345ff5 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -287,3 +287,20 @@ created_unix: 1602935696 updated_unix: 1602935696 is_locked: false + +- + id: 18 + repo_id: 55 + index: 1 + poster_id: 2 + original_author_id: 0 + name: issue for scoped labels + content: content + milestone_id: 0 + priority: 0 + is_closed: false + is_pull: false + num_comments: 0 + created_unix: 946684830 + updated_unix: 978307200 + is_locked: false diff --git a/models/fixtures/label.yml b/models/fixtures/label.yml index 57bf80445..ab4d5ef94 100644 --- a/models/fixtures/label.yml +++ b/models/fixtures/label.yml @@ -4,6 +4,7 @@ org_id: 0 name: label1 color: '#abcdef' + exclusive: false num_issues: 2 num_closed_issues: 0 @@ -13,6 +14,7 @@ org_id: 0 name: label2 color: '#000000' + exclusive: false num_issues: 1 num_closed_issues: 1 @@ -22,6 +24,7 @@ org_id: 3 name: orglabel3 color: '#abcdef' + exclusive: false num_issues: 0 num_closed_issues: 0 @@ -31,6 +34,7 @@ org_id: 3 name: orglabel4 color: '#000000' + exclusive: false num_issues: 1 num_closed_issues: 0 @@ -40,5 +44,46 @@ org_id: 0 name: pull-test-label color: '#000000' + exclusive: false + num_issues: 0 + num_closed_issues: 0 + +- + id: 6 + repo_id: 55 + org_id: 0 + name: unscoped_label + color: '#000000' + exclusive: false + num_issues: 0 + num_closed_issues: 0 + +- + id: 7 + repo_id: 55 + org_id: 0 + name: scope/label1 + color: '#000000' + exclusive: true + num_issues: 0 + num_closed_issues: 0 + +- + id: 8 + repo_id: 55 + org_id: 0 + name: scope/label2 + color: '#000000' + exclusive: true + num_issues: 0 + num_closed_issues: 0 + +- + id: 9 + repo_id: 55 + org_id: 0 + name: scope/subscope/label2 + color: '#000000' + exclusive: true num_issues: 0 num_closed_issues: 0 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index ef3cfbbbe..58f9b919a 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -1622,3 +1622,15 @@ is_archived: false is_private: true status: 0 + +- + id: 55 + owner_id: 2 + owner_name: user2 + lower_name: scoped_label + name: scoped_label + is_empty: false + is_archived: false + is_private: true + num_issues: 1 + status: 0 diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 63a5e0f89..0a1d85b48 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -66,7 +66,7 @@ num_followers: 2 num_following: 1 num_stars: 2 - num_repos: 10 + num_repos: 11 num_teams: 0 num_members: 0 visibility: 0 diff --git a/models/issues/issue.go b/models/issues/issue.go index b1c7fdbf7..9d7dea017 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -538,6 +538,31 @@ func (ts labelSorter) Swap(i, j int) { []*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i] } +// Ensure only one label of a given scope exists, with labels at the end of the +// array getting preference over earlier ones. +func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label { + validLabels := make([]*Label, 0, len(labels)) + + for i, label := range labels { + scope := label.ExclusiveScope() + if scope != "" { + foundOther := false + for _, otherLabel := range labels[i+1:] { + if otherLabel.ExclusiveScope() == scope { + foundOther = true + break + } + } + if foundOther { + continue + } + } + validLabels = append(validLabels, label) + } + + return validLabels +} + // ReplaceIssueLabels removes all current labels and add new labels to the issue. // Triggers appropriate WebHooks, if any. func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { @@ -555,6 +580,8 @@ func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (e return err } + labels = RemoveDuplicateExclusiveLabels(labels) + sort.Sort(labelSorter(labels)) sort.Sort(labelSorter(issue.Labels)) diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index de1da19ab..3a83d8d2b 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -25,7 +25,7 @@ import ( func TestIssue_ReplaceLabels(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - testSuccess := func(issueID int64, labelIDs []int64) { + testSuccess := func(issueID int64, labelIDs, expectedLabelIDs []int64) { issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -35,15 +35,20 @@ func TestIssue_ReplaceLabels(t *testing.T) { labels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID, RepoID: repo.ID}) } assert.NoError(t, issues_model.ReplaceIssueLabels(issue, labels, doer)) - unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(labelIDs)) - for _, labelID := range labelIDs { + unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(expectedLabelIDs)) + for _, labelID := range expectedLabelIDs { unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID}) } } - testSuccess(1, []int64{2}) - testSuccess(1, []int64{1, 2}) - testSuccess(1, []int64{}) + testSuccess(1, []int64{2}, []int64{2}) + testSuccess(1, []int64{1, 2}, []int64{1, 2}) + testSuccess(1, []int64{}, []int64{}) + + // mutually exclusive scoped labels 7 and 8 + testSuccess(18, []int64{6, 7}, []int64{6, 7}) + testSuccess(18, []int64{7, 8}, []int64{8}) + testSuccess(18, []int64{6, 8, 7}, []int64{6, 7}) } func Test_GetIssueIDsByRepoID(t *testing.T) { @@ -523,5 +528,5 @@ func TestCountIssues(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{}) assert.NoError(t, err) - assert.EqualValues(t, 17, count) + assert.EqualValues(t, 18, count) } diff --git a/models/issues/label.go b/models/issues/label.go index dbb7a139e..0dd12fb5c 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -7,8 +7,6 @@ package issues import ( "context" "fmt" - "html/template" - "math" "regexp" "strconv" "strings" @@ -89,6 +87,7 @@ type Label struct { RepoID int64 `xorm:"INDEX"` OrgID int64 `xorm:"INDEX"` Name string + Exclusive bool Description string Color string `xorm:"VARCHAR(7)"` NumIssues int @@ -128,18 +127,22 @@ func (label *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) } // LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked -func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64) { +func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) { var labelQuerySlice []string labelSelected := false labelID := strconv.FormatInt(label.ID, 10) - for _, s := range currentSelectedLabels { + labelScope := label.ExclusiveScope() + for i, s := range currentSelectedLabels { if s == label.ID { labelSelected = true } else if -s == label.ID { labelSelected = true label.IsExcluded = true } else if s != 0 { - labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10)) + // Exclude other labels in the same scope from selection + if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] { + labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10)) + } } } if !labelSelected { @@ -159,49 +162,43 @@ func (label *Label) BelongsToRepo() bool { return label.RepoID > 0 } -// SrgbToLinear converts a component of an sRGB color to its linear intensity -// See: https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation_(sRGB_to_CIE_XYZ) -func SrgbToLinear(color uint8) float64 { - flt := float64(color) / 255 - if flt <= 0.04045 { - return flt / 12.92 +// 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) + if err != nil { + return 0, 0, 0, err } - return math.Pow((flt+0.055)/1.055, 2.4) + + r := float64(uint8(0xFF & (uint32(color) >> 16))) + g := float64(uint8(0xFF & (uint32(color) >> 8))) + b := float64(uint8(0xFF & uint32(color))) + return r, g, b, nil } -// Luminance returns the luminance of an sRGB color -func Luminance(color uint32) float64 { - r := SrgbToLinear(uint8(0xFF & (color >> 16))) - g := SrgbToLinear(uint8(0xFF & (color >> 8))) - b := SrgbToLinear(uint8(0xFF & color)) - - // luminance ratios for sRGB - return 0.2126*r + 0.7152*g + 0.0722*b -} - -// LuminanceThreshold is the luminance at which white and black appear to have the same contrast -// i.e. x such that 1.05 / (x + 0.05) = (x + 0.05) / 0.05 -// i.e. math.Sqrt(1.05*0.05) - 0.05 -const LuminanceThreshold float64 = 0.179 - -// ForegroundColor calculates the text color for labels based -// on their background color. -func (label *Label) ForegroundColor() template.CSS { +// 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 color, err := strconv.ParseUint(label.Color[1:], 16, 64); err == nil { - // NOTE: see web_src/js/components/ContextPopup.vue for similar implementation - luminance := Luminance(uint32(color)) - - // prefer white or black based upon contrast - if luminance < LuminanceThreshold { - return template.CSS("#fff") - } - return template.CSS("#000") + if r, g, b, err := label.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 + return brightness < 0.35 } } - // default to black - return template.CSS("#000") + return false +} + +// Return scope substring of label name, or empty string if none exists +func (label *Label) ExclusiveScope() string { + if !label.Exclusive { + return "" + } + lastIndex := strings.LastIndex(label.Name, "/") + if lastIndex == -1 || lastIndex == 0 || lastIndex == len(label.Name)-1 { + return "" + } + return label.Name[:lastIndex] } // NewLabel creates a new label @@ -253,7 +250,7 @@ func UpdateLabel(l *Label) error { if !LabelColorPattern.MatchString(l.Color) { return fmt.Errorf("bad color code: %s", l.Color) } - return updateLabelCols(db.DefaultContext, l, "name", "description", "color") + return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive") } // DeleteLabel delete a label @@ -620,6 +617,29 @@ func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_m return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") } +// Remove all issue labels in the given exclusive scope +func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { + scope := label.ExclusiveScope() + if scope == "" { + return nil + } + + var toRemove []*Label + for _, issueLabel := range issue.Labels { + if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope { + toRemove = append(toRemove, issueLabel) + } + } + + for _, issueLabel := range toRemove { + if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil { + return err + } + } + + return nil +} + // NewIssueLabel creates a new issue-label relation. func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) { if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) { @@ -641,6 +661,10 @@ func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error return nil } + if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil { + return nil + } + if err = newIssueLabel(ctx, issue, label, doer); err != nil { return err } diff --git a/models/issues/label_test.go b/models/issues/label_test.go index 239e328d4..0e45e0db0 100644 --- a/models/issues/label_test.go +++ b/models/issues/label_test.go @@ -4,7 +4,6 @@ package issues_test import ( - "html/template" "testing" "code.gitea.io/gitea/models/db" @@ -25,13 +24,22 @@ func TestLabel_CalOpenIssues(t *testing.T) { assert.EqualValues(t, 2, label.NumOpenIssues) } -func TestLabel_ForegroundColor(t *testing.T) { +func TestLabel_TextColor(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) - assert.Equal(t, template.CSS("#000"), label.ForegroundColor()) + assert.False(t, label.UseLightTextColor()) label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2}) - assert.Equal(t, template.CSS("#fff"), label.ForegroundColor()) + assert.True(t, label.UseLightTextColor()) +} + +func TestLabel_ExclusiveScope(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7}) + assert.Equal(t, "scope", label.ExclusiveScope()) + + label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 9}) + assert.Equal(t, "scope/subscope", label.ExclusiveScope()) } func TestNewLabels(t *testing.T) { @@ -266,6 +274,7 @@ func TestUpdateLabel(t *testing.T) { Color: "#ffff00", Name: "newLabelName", Description: label.Description, + Exclusive: false, } label.Color = update.Color label.Name = update.Name @@ -323,6 +332,34 @@ func TestNewIssueLabel(t *testing.T) { unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{}) } +func TestNewIssueExclusiveLabel(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 18}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + otherLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 6}) + exclusiveLabelA := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7}) + exclusiveLabelB := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8}) + + // coexisting regular and exclusive label + assert.NoError(t, issues_model.NewIssueLabel(issue, otherLabel, doer)) + assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer)) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID}) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID}) + + // exclusive label replaces existing one + assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelB, doer)) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID}) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID}) + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID}) + + // exclusive label replaces existing one again + assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer)) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID}) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID}) + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID}) +} + func TestNewIssueLabels(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) label1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) diff --git a/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml b/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml index d651c87d5..085b7f088 100644 --- a/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml +++ b/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml @@ -4,6 +4,7 @@ org_id: 0 name: label1 color: '#abcdef' + exclusive: false num_issues: 2 num_closed_issues: 0 @@ -13,6 +14,7 @@ org_id: 0 name: label2 color: '#000000' + exclusive: false num_issues: 1 num_closed_issues: 1 - @@ -21,6 +23,7 @@ org_id: 3 name: orglabel3 color: '#abcdef' + exclusive: false num_issues: 0 num_closed_issues: 0 @@ -30,6 +33,7 @@ org_id: 3 name: orglabel4 color: '#000000' + exclusive: false num_issues: 1 num_closed_issues: 0 @@ -39,5 +43,6 @@ org_id: 0 name: pull-test-label color: '#000000' + exclusive: false num_issues: 0 num_closed_issues: 0 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 73c44f008..c7497becd 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -459,6 +459,8 @@ var migrations = []Migration{ NewMigration("Add card_type column to project table", v1_19.AddCardTypeToProjectTable), // v242 -> v243 NewMigration("Alter gpg_key_import content TEXT field to MEDIUMTEXT", v1_19.AlterPublicGPGKeyImportContentFieldToMediumText), + // v243 -> v244 + NewMigration("Add exclusive label", v1_19.AddExclusiveLabel), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_19/v244.go b/models/migrations/v1_19/v244.go new file mode 100644 index 000000000..55bbfafb2 --- /dev/null +++ b/models/migrations/v1_19/v244.go @@ -0,0 +1,16 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_19 //nolint + +import ( + "xorm.io/xorm" +) + +func AddExclusiveLabel(x *xorm.Engine) error { + type Label struct { + Exclusive bool + } + + return x.Sync(new(Label)) +} diff --git a/modules/migration/label.go b/modules/migration/label.go index 38f0eb10d..4927be3c0 100644 --- a/modules/migration/label.go +++ b/modules/migration/label.go @@ -9,4 +9,5 @@ type Label struct { Name string `json:"name"` Color string `json:"color"` Description string `json:"description"` + Exclusive bool `json:"exclusive"` } diff --git a/modules/structs/issue_label.go b/modules/structs/issue_label.go index 5c622797f..5bb6cc3b8 100644 --- a/modules/structs/issue_label.go +++ b/modules/structs/issue_label.go @@ -9,6 +9,8 @@ package structs type Label struct { ID int64 `json:"id"` Name string `json:"name"` + // example: false + Exclusive bool `json:"exclusive"` // example: 00aabb Color string `json:"color"` Description string `json:"description"` @@ -19,6 +21,8 @@ type Label struct { type CreateLabelOption struct { // required:true Name string `json:"name" binding:"Required"` + // example: false + Exclusive bool `json:"exclusive"` // required:true // example: #00aabb Color string `json:"color" binding:"Required"` @@ -27,7 +31,10 @@ type CreateLabelOption struct { // EditLabelOption options for editing a label type EditLabelOption struct { - Name *string `json:"name"` + Name *string `json:"name"` + // example: false + Exclusive *bool `json:"exclusive"` + // example: #00aabb Color *string `json:"color"` Description *string `json:"description"` } diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 8f8f565c1..4ffd0a5de 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -7,10 +7,12 @@ package templates import ( "bytes" "context" + "encoding/hex" "errors" "fmt" "html" "html/template" + "math" "mime" "net/url" "path/filepath" @@ -382,6 +384,9 @@ func NewFuncMap() []template.FuncMap { // the table is NOT sorted with this header return "" }, + "RenderLabel": func(label *issues_model.Label) template.HTML { + return template.HTML(RenderLabel(label)) + }, "RenderLabels": func(labels []*issues_model.Label, repoLink string) template.HTML { htmlCode := `` for _, label := range labels { @@ -389,8 +394,8 @@ func NewFuncMap() []template.FuncMap { if label == nil { continue } - htmlCode += fmt.Sprintf("%s ", - repoLink, label.ID, label.ForegroundColor(), label.Color, html.EscapeString(label.Description), RenderEmoji(label.Name)) + htmlCode += fmt.Sprintf("%s ", + repoLink, label.ID, RenderLabel(label)) } htmlCode += "" return template.HTML(htmlCode) @@ -801,6 +806,67 @@ func RenderIssueTitle(ctx context.Context, text, urlPrefix string, metas map[str return template.HTML(renderedText) } +// RenderLabel renders a label +func RenderLabel(label *issues_model.Label) string { + labelScope := label.ExclusiveScope() + + textColor := "#111" + if label.UseLightTextColor() { + textColor = "#eee" + } + + description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) + + if labelScope == "" { + // Regular label + return fmt.Sprintf("
scope/item
to make it mutually exclusive with other scope/
labels.
+issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request.
issues.label_count = %d labels
issues.label_open_issues = %d open issues/pull requests
issues.label_edit = Edit
diff --git a/routers/api/v1/org/label.go b/routers/api/v1/org/label.go
index 5d0455cdd..938fe79df 100644
--- a/routers/api/v1/org/label.go
+++ b/routers/api/v1/org/label.go
@@ -94,6 +94,7 @@ func CreateLabel(ctx *context.APIContext) {
label := &issues_model.Label{
Name: form.Name,
+ Exclusive: form.Exclusive,
Color: form.Color,
OrgID: ctx.Org.Organization.ID,
Description: form.Description,
@@ -195,6 +196,9 @@ func EditLabel(ctx *context.APIContext) {
if form.Name != nil {
label.Name = *form.Name
}
+ if form.Exclusive != nil {
+ label.Exclusive = *form.Exclusive
+ }
if form.Color != nil {
label.Color = strings.Trim(*form.Color, " ")
if len(label.Color) == 6 {
diff --git a/routers/api/v1/repo/label.go b/routers/api/v1/repo/label.go
index 411c0274e..a06d26e83 100644
--- a/routers/api/v1/repo/label.go
+++ b/routers/api/v1/repo/label.go
@@ -156,6 +156,7 @@ func CreateLabel(ctx *context.APIContext) {
label := &issues_model.Label{
Name: form.Name,
+ Exclusive: form.Exclusive,
Color: form.Color,
RepoID: ctx.Repo.Repository.ID,
Description: form.Description,
@@ -218,6 +219,9 @@ func EditLabel(ctx *context.APIContext) {
if form.Name != nil {
label.Name = *form.Name
}
+ if form.Exclusive != nil {
+ label.Exclusive = *form.Exclusive
+ }
if form.Color != nil {
label.Color = strings.Trim(*form.Color, " ")
if len(label.Color) == 6 {
diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go
index 1c910a93a..e96627762 100644
--- a/routers/web/org/org_labels.go
+++ b/routers/web/org/org_labels.go
@@ -45,6 +45,7 @@ func NewLabel(ctx *context.Context) {
l := &issues_model.Label{
OrgID: ctx.Org.Organization.ID,
Name: form.Title,
+ Exclusive: form.Exclusive,
Description: form.Description,
Color: form.Color,
}
@@ -70,6 +71,7 @@ func UpdateLabel(ctx *context.Context) {
}
l.Name = form.Title
+ l.Exclusive = form.Exclusive
l.Description = form.Description
l.Color = form.Color
if err := issues_model.UpdateLabel(l); err != nil {
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 62565af50..05ba26a70 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -332,8 +332,24 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
labels = append(labels, orgLabels...)
}
+ // Get the exclusive scope for every label ID
+ labelExclusiveScopes := make([]string, 0, len(labelIDs))
+ for _, labelID := range labelIDs {
+ foundExclusiveScope := false
+ for _, label := range labels {
+ if label.ID == labelID || label.ID == -labelID {
+ labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
+ foundExclusiveScope = true
+ break
+ }
+ }
+ if !foundExclusiveScope {
+ labelExclusiveScopes = append(labelExclusiveScopes, "")
+ }
+ }
+
for _, l := range labels {
- l.LoadSelectedLabelsAfterClick(labelIDs)
+ l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)
diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go
index 66e8920bd..d4fece9f0 100644
--- a/routers/web/repo/issue_label.go
+++ b/routers/web/repo/issue_label.go
@@ -113,6 +113,7 @@ func NewLabel(ctx *context.Context) {
l := &issues_model.Label{
RepoID: ctx.Repo.Repository.ID,
Name: form.Title,
+ Exclusive: form.Exclusive,
Description: form.Description,
Color: form.Color,
}
@@ -138,6 +139,7 @@ func UpdateLabel(ctx *context.Context) {
}
l.Name = form.Title
+ l.Exclusive = form.Exclusive
l.Description = form.Description
l.Color = form.Color
if err := issues_model.UpdateLabel(l); err != nil {
@@ -175,7 +177,7 @@ func UpdateIssueLabel(ctx *context.Context) {
return
}
}
- case "attach", "detach", "toggle":
+ case "attach", "detach", "toggle", "toggle-alt":
label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id"))
if err != nil {
if issues_model.IsErrRepoLabelNotExist(err) {
@@ -189,12 +191,18 @@ func UpdateIssueLabel(ctx *context.Context) {
if action == "toggle" {
// detach if any issues already have label, otherwise attach
action = "attach"
- for _, issue := range issues {
- if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) {
- action = "detach"
- break
+ if label.ExclusiveScope() == "" {
+ for _, issue := range issues {
+ if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) {
+ action = "detach"
+ break
+ }
}
}
+ } else if action == "toggle-alt" {
+ // always detach with alt key pressed, to be able to remove
+ // scoped labels
+ action = "detach"
}
if action == "attach" {
diff --git a/services/convert/issue.go b/services/convert/issue.go
index dccf2f372..e79fcfccc 100644
--- a/services/convert/issue.go
+++ b/services/convert/issue.go
@@ -182,6 +182,7 @@ func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_m
result := &api.Label{
ID: label.ID,
Name: label.Name,
+ Exclusive: label.Exclusive,
Color: strings.TrimLeft(label.Color, "#"),
Description: label.Description,
}
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index c1b580096..ff0916f8e 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -564,6 +564,7 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b
type CreateLabelForm struct {
ID int64
Title string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
+ Exclusive bool `form:"exclusive"`
Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
Color string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
}
diff --git a/services/migrations/main_test.go b/services/migrations/main_test.go
index 30875f6e5..42c433fb0 100644
--- a/services/migrations/main_test.go
+++ b/services/migrations/main_test.go
@@ -59,6 +59,7 @@ func assertCommentsEqual(t *testing.T, expected, actual []*base.Comment) {
func assertLabelEqual(t *testing.T, expected, actual *base.Label) {
assert.Equal(t, expected.Name, actual.Name)
+ assert.Equal(t, expected.Exclusive, actual.Exclusive)
assert.Equal(t, expected.Color, actual.Color)
assert.Equal(t, expected.Description, actual.Description)
}
diff --git a/services/repository/template.go b/services/repository/template.go
index 13e074986..8c75948c4 100644
--- a/services/repository/template.go
+++ b/services/repository/template.go
@@ -31,6 +31,7 @@ func GenerateIssueLabels(ctx context.Context, templateRepo, generateRepo *repo_m
newLabels = append(newLabels, &issues_model.Label{
RepoID: generateRepo.ID,
Name: templateLabel.Name,
+ Exclusive: templateLabel.Exclusive,
Description: templateLabel.Description,
Color: templateLabel.Color,
})
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 112e6be7c..b25cf2526 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -234,7 +234,7 @@
{{if or .Labels .Assignees}}