Merge pull request #113 from ludovicm67/patch-colors

Michael Muré created

bug: add label color directly in the core

Change summary

bug/label.go                 |  45 +++-
bug/label_test.go            |  36 +++
graphql/gqlgen.yml           |   2 
graphql/graph/gen_graph.go   | 399 +++++++++++++++++++++++++++++++++++--
graphql/graphql_test.go      |  29 ++
graphql/resolvers/color.go   |  24 ++
graphql/resolvers/label.go   |  22 ++
graphql/resolvers/root.go    |   8 
graphql/schema/types.graphql |  19 +
webui/packed_assets.go       |   7 
webui/src/Label.js           |  48 ++--
webui/src/bug/Bug.js         |   7 
webui/src/bug/LabelChange.js |   9 
webui/src/list/BugRow.js     |   7 
14 files changed, 578 insertions(+), 84 deletions(-)

Detailed changes

bug/label.go 🔗

@@ -1,8 +1,9 @@
 package bug
 
 import (
+	"crypto/sha1"
 	"fmt"
-	"io"
+	"image/color"
 	"strings"
 
 	"github.com/MichaelMure/git-bug/util/text"
@@ -14,21 +15,39 @@ func (l Label) String() string {
 	return string(l)
 }
 
-// UnmarshalGQL implements the graphql.Unmarshaler interface
-func (l *Label) UnmarshalGQL(v interface{}) error {
-	_, ok := v.(string)
-	if !ok {
-		return fmt.Errorf("labels must be strings")
-	}
+// RGBA from a Label computed in a deterministic way
+func (l Label) RGBA() color.RGBA {
+	id := 0
+	hash := sha1.Sum([]byte(l))
 
-	*l = v.(Label)
+	// colors from: https://material-ui.com/style/color/
+	colors := []color.RGBA{
+		color.RGBA{R: 244, G: 67, B: 54, A: 255},   // red
+		color.RGBA{R: 233, G: 30, B: 99, A: 255},   // pink
+		color.RGBA{R: 156, G: 39, B: 176, A: 255},  // purple
+		color.RGBA{R: 103, G: 58, B: 183, A: 255},  // deepPurple
+		color.RGBA{R: 63, G: 81, B: 181, A: 255},   // indigo
+		color.RGBA{R: 33, G: 150, B: 243, A: 255},  // blue
+		color.RGBA{R: 3, G: 169, B: 244, A: 255},   // lightBlue
+		color.RGBA{R: 0, G: 188, B: 212, A: 255},   // cyan
+		color.RGBA{R: 0, G: 150, B: 136, A: 255},   // teal
+		color.RGBA{R: 76, G: 175, B: 80, A: 255},   // green
+		color.RGBA{R: 139, G: 195, B: 74, A: 255},  // lightGreen
+		color.RGBA{R: 205, G: 220, B: 57, A: 255},  // lime
+		color.RGBA{R: 255, G: 235, B: 59, A: 255},  // yellow
+		color.RGBA{R: 255, G: 193, B: 7, A: 255},   // amber
+		color.RGBA{R: 255, G: 152, B: 0, A: 255},   // orange
+		color.RGBA{R: 255, G: 87, B: 34, A: 255},   // deepOrange
+		color.RGBA{R: 121, G: 85, B: 72, A: 255},   // brown
+		color.RGBA{R: 158, G: 158, B: 158, A: 255}, // grey
+		color.RGBA{R: 96, G: 125, B: 139, A: 255},  // blueGrey
+	}
 
-	return nil
-}
+	for _, char := range hash {
+		id = (id + int(char)) % len(colors)
+	}
 
-// MarshalGQL implements the graphql.Marshaler interface
-func (l Label) MarshalGQL(w io.Writer) {
-	_, _ = w.Write([]byte(`"` + l.String() + `"`))
+	return colors[id]
 }
 
 func (l Label) Validate() error {

bug/label_test.go 🔗

@@ -0,0 +1,36 @@
+package bug
+
+import (
+	"image/color"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestLabelRGBA(t *testing.T) {
+	rgba := Label("test").RGBA()
+	expected := color.RGBA{R: 255, G: 87, B: 34, A: 255}
+
+	require.Equal(t, expected, rgba)
+}
+
+func TestLabelRGBASimilar(t *testing.T) {
+	rgba := Label("test1").RGBA()
+	expected := color.RGBA{R: 0, G: 188, B: 212, A: 255}
+
+	require.Equal(t, expected, rgba)
+}
+
+func TestLabelRGBAReverse(t *testing.T) {
+	rgba := Label("tset").RGBA()
+	expected := color.RGBA{R: 233, G: 30, B: 99, A: 255}
+
+	require.Equal(t, expected, rgba)
+}
+
+func TestLabelRGBAEqual(t *testing.T) {
+	color1 := Label("test").RGBA()
+	color2 := Label("test").RGBA()
+
+	require.Equal(t, color1, color2)
+}

graphql/gqlgen.yml 🔗

@@ -11,6 +11,8 @@ models:
     model: github.com/MichaelMure/git-bug/graphql/models.RepositoryMutation
   Bug:
     model: github.com/MichaelMure/git-bug/bug.Snapshot
+  Color:
+    model: image/color.RGBA
   Comment:
     model: github.com/MichaelMure/git-bug/bug.Comment
   Identity:

graphql/graph/gen_graph.go 🔗

@@ -7,6 +7,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"image/color"
 	"strconv"
 	"sync"
 	"sync/atomic"
@@ -43,11 +44,13 @@ type ResolverRoot interface {
 	AddCommentOperation() AddCommentOperationResolver
 	AddCommentTimelineItem() AddCommentTimelineItemResolver
 	Bug() BugResolver
+	Color() ColorResolver
 	CommentHistoryStep() CommentHistoryStepResolver
 	CreateOperation() CreateOperationResolver
 	CreateTimelineItem() CreateTimelineItemResolver
 	EditCommentOperation() EditCommentOperationResolver
 	Identity() IdentityResolver
+	Label() LabelResolver
 	LabelChangeOperation() LabelChangeOperationResolver
 	LabelChangeTimelineItem() LabelChangeTimelineItemResolver
 	Mutation() MutationResolver
@@ -111,6 +114,12 @@ type ComplexityRoot struct {
 		Node   func(childComplexity int) int
 	}
 
+	Color struct {
+		B func(childComplexity int) int
+		G func(childComplexity int) int
+		R func(childComplexity int) int
+	}
+
 	Comment struct {
 		Author  func(childComplexity int) int
 		Files   func(childComplexity int) int
@@ -187,6 +196,11 @@ type ComplexityRoot struct {
 		Node   func(childComplexity int) int
 	}
 
+	Label struct {
+		Color func(childComplexity int) int
+		Name  func(childComplexity int) int
+	}
+
 	LabelChangeOperation struct {
 		Added   func(childComplexity int) int
 		Author  func(childComplexity int) int
@@ -306,6 +320,11 @@ type BugResolver interface {
 	Timeline(ctx context.Context, obj *bug.Snapshot, after *string, before *string, first *int, last *int) (*models.TimelineItemConnection, error)
 	Operations(ctx context.Context, obj *bug.Snapshot, after *string, before *string, first *int, last *int) (*models.OperationConnection, error)
 }
+type ColorResolver interface {
+	R(ctx context.Context, obj *color.RGBA) (int, error)
+	G(ctx context.Context, obj *color.RGBA) (int, error)
+	B(ctx context.Context, obj *color.RGBA) (int, error)
+}
 type CommentHistoryStepResolver interface {
 	Date(ctx context.Context, obj *bug.CommentHistoryStep) (*time.Time, error)
 }
@@ -329,6 +348,10 @@ type IdentityResolver interface {
 	AvatarURL(ctx context.Context, obj *identity.Interface) (*string, error)
 	IsProtected(ctx context.Context, obj *identity.Interface) (bool, error)
 }
+type LabelResolver interface {
+	Name(ctx context.Context, obj *bug.Label) (string, error)
+	Color(ctx context.Context, obj *bug.Label) (*color.RGBA, error)
+}
 type LabelChangeOperationResolver interface {
 	Date(ctx context.Context, obj *bug.LabelChangeOperation) (*time.Time, error)
 }
@@ -642,6 +665,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.BugEdge.Node(childComplexity), true
 
+	case "Color.B":
+		if e.complexity.Color.B == nil {
+			break
+		}
+
+		return e.complexity.Color.B(childComplexity), true
+
+	case "Color.G":
+		if e.complexity.Color.G == nil {
+			break
+		}
+
+		return e.complexity.Color.G(childComplexity), true
+
+	case "Color.R":
+		if e.complexity.Color.R == nil {
+			break
+		}
+
+		return e.complexity.Color.R(childComplexity), true
+
 	case "Comment.author":
 		if e.complexity.Comment.Author == nil {
 			break
@@ -964,6 +1008,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.IdentityEdge.Node(childComplexity), true
 
+	case "Label.color":
+		if e.complexity.Label.Color == nil {
+			break
+		}
+
+		return e.complexity.Label.Color(childComplexity), true
+
+	case "Label.name":
+		if e.complexity.Label.Name == nil {
+			break
+		}
+
+		return e.complexity.Label.Name(childComplexity), true
+
 	case "LabelChangeOperation.added":
 		if e.complexity.LabelChangeOperation.Added == nil {
 			break
@@ -1910,9 +1968,26 @@ type SetTitleTimelineItem implements TimelineItem {
 }
 `},
 	&ast.Source{Name: "schema/types.graphql", Input: `scalar Time
-scalar Label
 scalar Hash
 
+"""Defines a color by red, green and blue components."""
+type Color {
+    """Red component of the color."""
+    R: Int!
+    """Green component of the color."""
+    G: Int!
+    """Blue component of the color."""
+    B: Int!
+}
+
+"""Label for a bug."""
+type Label {
+    """The name of the label."""
+    name: String!
+    """Color of the label."""
+    color: Color!
+}
+
 """Information about pagination in a connection."""
 type PageInfo {
     """When paginating forwards, are there more items?"""
@@ -3434,6 +3509,87 @@ func (ec *executionContext) _BugEdge_node(ctx context.Context, field graphql.Col
 	return ec.marshalNBug2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐSnapshot(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Color_R(ctx context.Context, field graphql.CollectedField, obj *color.RGBA) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object:   "Color",
+		Field:    field,
+		Args:     nil,
+		IsMethod: true,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Color().R(rctx, obj)
+	})
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _Color_G(ctx context.Context, field graphql.CollectedField, obj *color.RGBA) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object:   "Color",
+		Field:    field,
+		Args:     nil,
+		IsMethod: true,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Color().G(rctx, obj)
+	})
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _Color_B(ctx context.Context, field graphql.CollectedField, obj *color.RGBA) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object:   "Color",
+		Field:    field,
+		Args:     nil,
+		IsMethod: true,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Color().B(rctx, obj)
+	})
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Comment_author(ctx context.Context, field graphql.CollectedField, obj *bug.Comment) graphql.Marshaler {
 	ctx = ec.Tracer.StartFieldExecution(ctx, field)
 	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
@@ -4664,6 +4820,60 @@ func (ec *executionContext) _IdentityEdge_node(ctx context.Context, field graphq
 	return ec.marshalNIdentity2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋidentityᚐInterface(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Label_name(ctx context.Context, field graphql.CollectedField, obj *bug.Label) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object:   "Label",
+		Field:    field,
+		Args:     nil,
+		IsMethod: true,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Label().Name(rctx, obj)
+	})
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _Label_color(ctx context.Context, field graphql.CollectedField, obj *bug.Label) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object:   "Label",
+		Field:    field,
+		Args:     nil,
+		IsMethod: true,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Label().Color(rctx, obj)
+	})
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*color.RGBA)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNColor2ᚖimageᚋcolorᚐRGBA(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _LabelChangeOperation_hash(ctx context.Context, field graphql.CollectedField, obj *bug.LabelChangeOperation) graphql.Marshaler {
 	ctx = ec.Tracer.StartFieldExecution(ctx, field)
 	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
@@ -7656,6 +7866,70 @@ func (ec *executionContext) _BugEdge(ctx context.Context, sel ast.SelectionSet,
 	return out
 }
 
+var colorImplementors = []string{"Color"}
+
+func (ec *executionContext) _Color(ctx context.Context, sel ast.SelectionSet, obj *color.RGBA) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.RequestContext, sel, colorImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	var invalids uint32
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("Color")
+		case "R":
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Color_R(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
+		case "G":
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Color_G(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
+		case "B":
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Color_B(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch()
+	if invalids > 0 {
+		return graphql.Null
+	}
+	return out
+}
+
 var commentImplementors = []string{"Comment", "Authored"}
 
 func (ec *executionContext) _Comment(ctx context.Context, sel ast.SelectionSet, obj *bug.Comment) graphql.Marshaler {
@@ -8211,6 +8485,56 @@ func (ec *executionContext) _IdentityEdge(ctx context.Context, sel ast.Selection
 	return out
 }
 
+var labelImplementors = []string{"Label"}
+
+func (ec *executionContext) _Label(ctx context.Context, sel ast.SelectionSet, obj *bug.Label) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.RequestContext, sel, labelImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	var invalids uint32
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("Label")
+		case "name":
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Label_name(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
+		case "color":
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Label_color(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch()
+	if invalids > 0 {
+		return graphql.Null
+	}
+	return out
+}
+
 var labelChangeOperationImplementors = []string{"LabelChangeOperation", "Operation", "Authored"}
 
 func (ec *executionContext) _LabelChangeOperation(ctx context.Context, sel ast.SelectionSet, obj *bug.LabelChangeOperation) graphql.Marshaler {
@@ -9330,6 +9654,20 @@ func (ec *executionContext) marshalNBugEdge2ᚖgithubᚗcomᚋMichaelMureᚋgit
 	return ec._BugEdge(ctx, sel, v)
 }
 
+func (ec *executionContext) marshalNColor2imageᚋcolorᚐRGBA(ctx context.Context, sel ast.SelectionSet, v color.RGBA) graphql.Marshaler {
+	return ec._Color(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNColor2ᚖimageᚋcolorᚐRGBA(ctx context.Context, sel ast.SelectionSet, v *color.RGBA) graphql.Marshaler {
+	if v == nil {
+		if !ec.HasError(graphql.GetResolverContext(ctx)) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	return ec._Color(ctx, sel, v)
+}
+
 func (ec *executionContext) marshalNComment2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐComment(ctx context.Context, sel ast.SelectionSet, v bug.Comment) graphql.Marshaler {
 	return ec._Comment(ctx, sel, &v)
 }
@@ -9645,41 +9983,44 @@ func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.Selecti
 	return res
 }
 
-func (ec *executionContext) unmarshalNLabel2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐLabel(ctx context.Context, v interface{}) (bug.Label, error) {
-	var res bug.Label
-	return res, res.UnmarshalGQL(v)
-}
-
 func (ec *executionContext) marshalNLabel2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐLabel(ctx context.Context, sel ast.SelectionSet, v bug.Label) graphql.Marshaler {
-	return v
-}
-
-func (ec *executionContext) unmarshalNLabel2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐLabel(ctx context.Context, v interface{}) ([]bug.Label, error) {
-	var vSlice []interface{}
-	if v != nil {
-		if tmp1, ok := v.([]interface{}); ok {
-			vSlice = tmp1
-		} else {
-			vSlice = []interface{}{v}
-		}
-	}
-	var err error
-	res := make([]bug.Label, len(vSlice))
-	for i := range vSlice {
-		res[i], err = ec.unmarshalNLabel2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐLabel(ctx, vSlice[i])
-		if err != nil {
-			return nil, err
-		}
-	}
-	return res, nil
+	return ec._Label(ctx, sel, &v)
 }
 
 func (ec *executionContext) marshalNLabel2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐLabel(ctx context.Context, sel ast.SelectionSet, v []bug.Label) graphql.Marshaler {
 	ret := make(graphql.Array, len(v))
-	for i := range v {
-		ret[i] = ec.marshalNLabel2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐLabel(ctx, sel, v[i])
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
 	}
+	for i := range v {
+		i := i
+		rctx := &graphql.ResolverContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithResolverContext(ctx, rctx)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalNLabel2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐLabel(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
 
+	}
+	wg.Wait()
 	return ret
 }
 

graphql/graphql_test.go 🔗

@@ -133,8 +133,22 @@ func TestQueries(t *testing.T) {
                     status
                   }
                   ... on LabelChangeOperation {
-                    added
-                    removed
+                    added {
+                      name
+                      color {
+                        R
+                        G
+                        B
+                      }
+                    }
+                    removed {
+                      name
+                      color {
+                        R
+                        G
+                        B
+                      }
+                    }
                   }
                 }
               }
@@ -152,6 +166,13 @@ func TestQueries(t *testing.T) {
 		DisplayName string `json:"displayName"`
 	}
 
+	type Label struct {
+		Name  string
+		Color struct {
+			R, G, B int
+		}
+	}
+
 	var resp struct {
 		DefaultRepository struct {
 			AllBugs struct {
@@ -193,8 +214,8 @@ func TestQueries(t *testing.T) {
 							Message string
 							Was     string
 							Status  string
-							Added   []string
-							Removed []string
+							Added   []Label
+							Removed []Label
 						}
 					}
 				}

graphql/resolvers/color.go 🔗

@@ -0,0 +1,24 @@
+package resolvers
+
+import (
+	"context"
+	"image/color"
+
+	"github.com/MichaelMure/git-bug/graphql/graph"
+)
+
+var _ graph.ColorResolver = &colorResolver{}
+
+type colorResolver struct{}
+
+func (colorResolver) R(ctx context.Context, obj *color.RGBA) (int, error) {
+	return int(obj.R), nil
+}
+
+func (colorResolver) G(ctx context.Context, obj *color.RGBA) (int, error) {
+	return int(obj.G), nil
+}
+
+func (colorResolver) B(ctx context.Context, obj *color.RGBA) (int, error) {
+	return int(obj.B), nil
+}

graphql/resolvers/label.go 🔗

@@ -0,0 +1,22 @@
+package resolvers
+
+import (
+	"context"
+	"image/color"
+
+	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/graphql/graph"
+)
+
+var _ graph.LabelResolver = &labelResolver{}
+
+type labelResolver struct{}
+
+func (labelResolver) Name(ctx context.Context, obj *bug.Label) (string, error) {
+	return obj.String(), nil
+}
+
+func (labelResolver) Color(ctx context.Context, obj *bug.Label) (*color.RGBA, error) {
+	rgba := obj.RGBA()
+	return &rgba, nil
+}

graphql/resolvers/root.go 🔗

@@ -34,6 +34,14 @@ func (RootResolver) Bug() graph.BugResolver {
 	return &bugResolver{}
 }
 
+func (RootResolver) Color() graph.ColorResolver {
+	return &colorResolver{}
+}
+
+func (RootResolver) Label() graph.LabelResolver {
+	return &labelResolver{}
+}
+
 func (r RootResolver) Identity() graph.IdentityResolver {
 	return &identityResolver{}
 }

graphql/schema/types.graphql 🔗

@@ -1,7 +1,24 @@
 scalar Time
-scalar Label
 scalar Hash
 
+"""Defines a color by red, green and blue components."""
+type Color {
+    """Red component of the color."""
+    R: Int!
+    """Green component of the color."""
+    G: Int!
+    """Blue component of the color."""
+    B: Int!
+}
+
+"""Label for a bug."""
+type Label {
+    """The name of the label."""
+    name: String!
+    """Color of the label."""
+    color: Color!
+}
+
 """Information about pagination in a connection."""
 type PageInfo {
     """When paginating forwards, are there more items?"""

webui/packed_assets.go 🔗

@@ -21,78 +21,92 @@ var WebUIAssets = func() http.FileSystem {
 	fs := vfsgen۰FS{
 		"/": &vfsgen۰DirInfo{
 			name:    "/",
-			modTime: time.Date(2019, 3, 31, 19, 51, 59, 431078583, time.UTC),
+			modTime: time.Date(2019, 5, 22, 18, 40, 14, 550155033, time.UTC),
 		},
 		"/asset-manifest.json": &vfsgen۰CompressedFileInfo{
 			name:             "asset-manifest.json",
-			modTime:          time.Date(2019, 3, 31, 19, 51, 59, 431078583, time.UTC),
-			uncompressedSize: 577,
+			modTime:          time.Date(2019, 5, 22, 18, 40, 14, 551035575, time.UTC),
+			uncompressedSize: 869,
 
-			compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x9c\x90\x5d\x4e\xc3\x30\x10\x84\xdf\x7b\x8a\x28\xcf\xd4\xf1\x4f\x6b\x1a\x6e\xb3\x5d\x6f\x15\x37\xd8\x44\xb6\x03\x48\x08\xce\x8e\x30\x22\x3f\xe0\x08\xc4\xa3\x77\xe7\x9b\xf1\xec\xcb\xae\xaa\x6a\x07\xd6\xb3\x6b\xac\xef\xaa\xba\x89\x09\x92\xc5\xe6\x1a\x9b\x3c\x25\xa5\x34\xf2\xa3\x62\xd8\x8d\xbe\xff\x10\xdd\x2c\x08\xe6\x60\xf8\x13\x95\x85\x99\x0c\xa3\x4f\xd6\xd1\x5b\x39\x73\xb5\x85\x13\xb4\x2d\x3f\xc2\x94\xfa\x8d\x2d\xa4\x6f\xf1\x73\xfe\xac\x95\xcc\x20\x68\x61\x44\x3b\x97\x5b\xbb\x95\x14\xbf\x9b\x14\xbe\xb5\xa5\xca\x66\xd6\x1b\x7a\x66\x5d\x72\xf7\x99\x5a\x3c\xf3\x7a\x08\x84\x80\x1d\xed\x1d\x78\x7b\xa1\x98\x18\x92\xd4\x67\x85\x82\x73\x81\xfa\xf6\x60\x0e\x27\x79\x96\x9a\x73\x23\x85\xbc\x90\xe4\x5f\x45\xfe\x47\x7e\x16\xa4\xf0\x68\x91\xf6\x4f\x0f\xa1\xa7\x30\x5d\xe6\xc7\x74\xf7\xfa\x1e\x00\x00\xff\xff\x66\x1b\x56\xc8\x41\x02\x00\x00"),

webui/src/Label.js 🔗

@@ -1,46 +1,29 @@
 import React from 'react';
+import gql from 'graphql-tag';
 import { makeStyles } from '@material-ui/styles';
 import {
   getContrastRatio,
   darken,
 } from '@material-ui/core/styles/colorManipulator';
-import * as allColors from '@material-ui/core/colors';
 import { common } from '@material-ui/core/colors';
 
-// JS's modulo returns negative numbers sometimes.
-// This ensures the result is positive.
-const mod = (n, m) => ((n % m) + m) % m;
-
 // Minimum contrast between the background and the text color
 const contrastThreshold = 2.5;
 
-// Filter out the "common" color
-const labelColors = Object.entries(allColors)
-  .filter(([key, value]) => value !== common)
-  .map(([key, value]) => value);
-
-// Generate a hash (number) from a string
-const hash = string =>
-  string.split('').reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0);
-
-// Get the background color from the label
-const getColor = label =>
-  labelColors[mod(hash(label), labelColors.length)][500];
-
 // Guess the text color based on the background color
 const getTextColor = background =>
   getContrastRatio(background, common.white) >= contrastThreshold
     ? common.white // White on dark backgrounds
     : common.black; // And black on light ones
 
-const _genStyle = background => ({
-  backgroundColor: background,
-  color: getTextColor(background),
-  borderBottomColor: darken(background, 0.2),
-});
+const _rgb = color => 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
 
-// Generate a style object (text, background and border colors) from the label
-const genStyle = label => _genStyle(getColor(label));
+// Create a style object from the label RGB colors
+const createStyle = color => ({
+  backgroundColor: _rgb(color),
+  color: getTextColor(_rgb(color)),
+  borderBottomColor: darken(_rgb(color), 0.2),
+});
 
 const useStyles = makeStyles(theme => ({
   label: {
@@ -58,10 +41,21 @@ const useStyles = makeStyles(theme => ({
 function Label({ label }) {
   const classes = useStyles();
   return (
-    <span className={classes.label} style={genStyle(label)}>
-      {label}
+    <span className={classes.label} style={createStyle(label.color)}>
+      {label.name}
     </span>
   );
 }
 
+Label.fragment = gql`
+  fragment Label on Label {
+    name
+    color {
+      R
+      G
+      B
+    }
+  }
+`;
+
 export default Label;

webui/src/bug/Bug.js 🔗

@@ -74,7 +74,7 @@ function Bug({ bug }) {
           <ul className={classes.labelList}>
             {bug.labels.map(l => (
               <li className={classes.label}>
-                <Label label={l} key={l} />
+                <Label label={l} key={l.name} />
               </li>
             ))}
           </ul>
@@ -90,7 +90,9 @@ Bug.fragment = gql`
     humanId
     status
     title
-    labels
+    labels {
+      ...Label
+    }
     createdAt
     author {
       email
@@ -98,6 +100,7 @@ Bug.fragment = gql`
       displayName
     }
   }
+  ${Label.fragment}
 `;
 
 export default Bug;

webui/src/bug/LabelChange.js 🔗

@@ -49,10 +49,15 @@ LabelChange.fragment = gql`
         email
         displayName
       }
-      added
-      removed
+      added {
+        ...Label
+      }
+      removed {
+        ...Label
+      }
     }
   }
+  ${Label.fragment}
 `;
 
 export default LabelChange;

webui/src/list/BugRow.js 🔗

@@ -70,7 +70,7 @@ function BugRow({ bug }) {
               {bug.labels.length > 0 && (
                 <span className={classes.labels}>
                   {bug.labels.map(l => (
-                    <Label key={l} label={l} />
+                    <Label key={l.name} label={l} />
                   ))}
                 </span>
               )}
@@ -94,12 +94,15 @@ BugRow.fragment = gql`
     title
     status
     createdAt
-    labels
+    labels {
+      ...Label
+    }
     author {
       name
       displayName
     }
   }
+  ${Label.fragment}
 `;
 
 export default BugRow;