Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scoped labels #22585

Merged
merged 14 commits into from
Feb 18, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 17 additions & 0 deletions models/fixtures/issue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 45 additions & 0 deletions models/fixtures/label.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
org_id: 0
name: label1
color: '#abcdef'
exclusive: false
num_issues: 2
num_closed_issues: 0

Expand All @@ -13,6 +14,7 @@
org_id: 0
name: label2
color: '#000000'
exclusive: false
num_issues: 1
num_closed_issues: 1

Expand All @@ -22,6 +24,7 @@
org_id: 3
name: orglabel3
color: '#abcdef'
exclusive: false
num_issues: 0
num_closed_issues: 0

Expand All @@ -31,6 +34,7 @@
org_id: 3
name: orglabel4
color: '#000000'
exclusive: false
num_issues: 1
num_closed_issues: 0

Expand All @@ -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
12 changes: 12 additions & 0 deletions models/fixtures/repository.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion models/fixtures/user.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))

Expand Down
19 changes: 12 additions & 7 deletions models/issues/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
106 changes: 65 additions & 41 deletions models/issues/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ package issues
import (
"context"
"fmt"
"html/template"
"math"
"regexp"
"strconv"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}

// 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
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
}

// 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 ""
}
index := strings.Index(label.Name, "/")
brechtvl marked this conversation as resolved.
Show resolved Hide resolved
if index == -1 || index == 0 || index == len(label.Name)-1 {
return ""
}
return label.Name[:index]
}

// NewLabel creates a new label
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
Expand Down