Merge remote-tracking branch 'origin/master' into dag-entity

Michael Muré created

Change summary

api/graphql/graph/gen_graph.go                           | 340 +++++++
api/graphql/models/gen_models.go                         |  24 
api/graphql/resolvers/mutation.go                        |  29 
api/graphql/schema/mutations.graphql                     |  24 
api/graphql/schema/root.graphql                          |   2 
bridge/core/auth/credential.go                           |   6 
cache/filter.go                                          |  18 
cache/repo_cache_bug.go                                  |  21 
cache/repo_cache_test.go                                 |  20 
commands/ls.go                                           |  13 
commands/webui.go                                        |  19 
doc/man/git-bug-ls.1                                     |   4 
doc/man/git-bug-webui.1                                  |  10 
doc/md/git-bug_ls.md                                     |   1 
doc/md/git-bug_webui.md                                  |  12 
doc/queries.md                                           |  19 
go.mod                                                   |   6 
go.sum                                                   |  15 
misc/bash_completion/git-bug                             |  16 
misc/powershell_completion/git-bug                       | 482 ++++-----
query/lexer.go                                           | 108 +
query/lexer_test.go                                      |  28 
query/parser.go                                          |   9 
query/parser_test.go                                     |  19 
query/query.go                                           |   7 
repository/config_mem.go                                 |  19 
repository/config_testing.go                             |  39 
repository/gogit.go                                      |   4 
repository/gogit_config.go                               |   2 
webui/Readme.md                                          |   8 
webui/package-lock.json                                  |  31 
webui/packed_assets.go                                   |   6 
webui/public/logo-alpha-flat-outline.svg                 |   2 
webui/src/App.tsx                                        |   2 
webui/src/components/BackToListButton.tsx                |  36 
webui/src/components/BugTitleForm/BugTitleForm.tsx       |  42 
webui/src/components/BugTitleForm/BugTitleInput.tsx      |  40 
webui/src/components/CloseBugButton/CloseBugButton.tsx   |  13 
webui/src/components/CommentInput/CommentInput.tsx       |   5 
webui/src/components/Content/PreTag.tsx                  |   2 
webui/src/components/Header/Header.tsx                   |  78 +
webui/src/components/ReopenBugButton/ReopenBugButton.tsx |   2 
webui/src/components/Themer.tsx                          |  65 +
webui/src/index.tsx                                      |   9 
webui/src/pages/bug/Bug.tsx                              |  22 
webui/src/pages/bug/BugQuery.tsx                         |   4 
webui/src/pages/bug/CommentForm.tsx                      |   9 
webui/src/pages/bug/EditCommentForm.graphql              |  16 
webui/src/pages/bug/EditCommentForm.tsx                  | 123 ++
webui/src/pages/bug/Message.tsx                          | 141 ++
webui/src/pages/bug/MessageCommentFragment.graphql       |   5 
webui/src/pages/bug/MessageCreateFragment.graphql        |   5 
webui/src/pages/bug/MessageHistory.graphql               |  15 
webui/src/pages/bug/MessageHistoryDialog.tsx             | 235 ++++
webui/src/pages/bug/Timeline.tsx                         |   8 
webui/src/pages/bug/TimelineQuery.tsx                    |   9 
webui/src/pages/list/BugRow.graphql                      |   3 
webui/src/pages/list/BugRow.tsx                          |  16 
webui/src/pages/list/Filter.tsx                          |   4 
webui/src/pages/list/FilterToolbar.tsx                   |   6 
webui/src/pages/list/ListQuery.tsx                       |  32 
webui/src/pages/new/NewBugPage.tsx                       |  40 
webui/src/pages/notfound/NotFoundPage.tsx                |  52 +
webui/src/theme.ts                                       |  11 
webui/src/themes/DefaultDark.ts                          |  26 
webui/src/themes/DefaultLight.ts                         |  26 
webui/src/themes/index.ts                                |   4 
67 files changed, 1,964 insertions(+), 505 deletions(-)

Detailed changes

api/graphql/graph/gen_graph.go 🔗

@@ -193,6 +193,12 @@ type ComplexityRoot struct {
 		Target  func(childComplexity int) int
 	}
 
+	EditCommentPayload struct {
+		Bug              func(childComplexity int) int
+		ClientMutationID func(childComplexity int) int
+		Operation        func(childComplexity int) int
+	}
+
 	Identity struct {
 		AvatarUrl   func(childComplexity int) int
 		DisplayName func(childComplexity int) int
@@ -258,6 +264,7 @@ type ComplexityRoot struct {
 		AddComment   func(childComplexity int, input models.AddCommentInput) int
 		ChangeLabels func(childComplexity int, input *models.ChangeLabelInput) int
 		CloseBug     func(childComplexity int, input models.CloseBugInput) int
+		EditComment  func(childComplexity int, input models.EditCommentInput) int
 		NewBug       func(childComplexity int, input models.NewBugInput) int
 		OpenBug      func(childComplexity int, input models.OpenBugInput) int
 		SetTitle     func(childComplexity int, input models.SetTitleInput) int
@@ -433,6 +440,7 @@ type LabelChangeTimelineItemResolver interface {
 type MutationResolver interface {
 	NewBug(ctx context.Context, input models.NewBugInput) (*models.NewBugPayload, error)
 	AddComment(ctx context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error)
+	EditComment(ctx context.Context, input models.EditCommentInput) (*models.EditCommentPayload, error)
 	ChangeLabels(ctx context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error)
 	OpenBug(ctx context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error)
 	CloseBug(ctx context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error)
@@ -1059,6 +1067,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.EditCommentOperation.Target(childComplexity), true
 
+	case "EditCommentPayload.bug":
+		if e.complexity.EditCommentPayload.Bug == nil {
+			break
+		}
+
+		return e.complexity.EditCommentPayload.Bug(childComplexity), true
+
+	case "EditCommentPayload.clientMutationId":
+		if e.complexity.EditCommentPayload.ClientMutationID == nil {
+			break
+		}
+
+		return e.complexity.EditCommentPayload.ClientMutationID(childComplexity), true
+
+	case "EditCommentPayload.operation":
+		if e.complexity.EditCommentPayload.Operation == nil {
+			break
+		}
+
+		return e.complexity.EditCommentPayload.Operation(childComplexity), true
+
 	case "Identity.avatarUrl":
 		if e.complexity.Identity.AvatarUrl == nil {
 			break
@@ -1333,6 +1362,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Mutation.CloseBug(childComplexity, args["input"].(models.CloseBugInput)), true
 
+	case "Mutation.editComment":
+		if e.complexity.Mutation.EditComment == nil {
+			break
+		}
+
+		args, err := ec.field_Mutation_editComment_args(context.TODO(), rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Mutation.EditComment(childComplexity, args["input"].(models.EditCommentInput)), true
+
 	case "Mutation.newBug":
 		if e.complexity.Mutation.NewBug == nil {
 			break
@@ -2034,6 +2075,30 @@ type AddCommentPayload {
     operation: AddCommentOperation!
 }
 
+input EditCommentInput {
+    """A unique identifier for the client performing the mutation."""
+    clientMutationId: String
+    """"The name of the repository. If not set, the default repository is used."""
+    repoRef: String
+    """The bug ID's prefix."""
+    prefix: String!
+    """The target."""
+    target: String!
+    """The new message to be set."""
+    message: String!
+    """The collection of file's hash required for the first message."""
+    files: [Hash!]
+}
+
+type EditCommentPayload {
+    """A unique identifier for the client performing the mutation."""
+    clientMutationId: String
+    """The affected bug."""
+    bug: Bug!
+    """The resulting operation."""
+    operation: EditCommentOperation!
+}
+
 input ChangeLabelInput {
     """A unique identifier for the client performing the mutation."""
     clientMutationId: String
@@ -2290,6 +2355,8 @@ type Mutation {
     newBug(input: NewBugInput!): NewBugPayload!
     """Add a new comment to a bug"""
     addComment(input: AddCommentInput!): AddCommentPayload!
+    """Change a comment of a bug"""
+    editComment(input: EditCommentInput!): EditCommentPayload!
     """Add or remove a set of label on a bug"""
     changeLabels(input: ChangeLabelInput): ChangeLabelPayload!
     """Change a bug's status to open"""
@@ -2657,6 +2724,20 @@ func (ec *executionContext) field_Mutation_closeBug_args(ctx context.Context, ra
 	return args, nil
 }
 
+func (ec *executionContext) field_Mutation_editComment_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+	var err error
+	args := map[string]interface{}{}
+	var arg0 models.EditCommentInput
+	if tmp, ok := rawArgs["input"]; ok {
+		arg0, err = ec.unmarshalNEditCommentInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentInput(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["input"] = arg0
+	return args, nil
+}
+
 func (ec *executionContext) field_Mutation_newBug_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
 	var err error
 	args := map[string]interface{}{}
@@ -5591,6 +5672,105 @@ func (ec *executionContext) _EditCommentOperation_files(ctx context.Context, fie
 	return ec.marshalNHash2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHashᚄ(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _EditCommentPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:   "EditCommentPayload",
+		Field:    field,
+		Args:     nil,
+		IsMethod: false,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.ClientMutationID, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*string)
+	fc.Result = res
+	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _EditCommentPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:   "EditCommentPayload",
+		Field:    field,
+		Args:     nil,
+		IsMethod: false,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Bug, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(models.BugWrapper)
+	fc.Result = res
+	return ec.marshalNBug2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _EditCommentPayload_operation(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:   "EditCommentPayload",
+		Field:    field,
+		Args:     nil,
+		IsMethod: false,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Operation, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*bug.EditCommentOperation)
+	fc.Result = res
+	return ec.marshalNEditCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Identity_id(ctx context.Context, field graphql.CollectedField, obj models.IdentityWrapper) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -6817,6 +6997,47 @@ func (ec *executionContext) _Mutation_addComment(ctx context.Context, field grap
 	return ec.marshalNAddCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐAddCommentPayload(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Mutation_editComment(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:   "Mutation",
+		Field:    field,
+		Args:     nil,
+		IsMethod: true,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	rawArgs := field.ArgumentMap(ec.Variables)
+	args, err := ec.field_Mutation_editComment_args(ctx, rawArgs)
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	fc.Args = args
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Mutation().EditComment(rctx, args["input"].(models.EditCommentInput))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.EditCommentPayload)
+	fc.Result = res
+	return ec.marshalNEditCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Mutation_changeLabels(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -9971,6 +10192,54 @@ func (ec *executionContext) unmarshalInputCloseBugInput(ctx context.Context, obj
 	return it, nil
 }
 
+func (ec *executionContext) unmarshalInputEditCommentInput(ctx context.Context, obj interface{}) (models.EditCommentInput, error) {
+	var it models.EditCommentInput
+	var asMap = obj.(map[string]interface{})
+
+	for k, v := range asMap {
+		switch k {
+		case "clientMutationId":
+			var err error
+			it.ClientMutationID, err = ec.unmarshalOString2ᚖstring(ctx, v)
+			if err != nil {
+				return it, err
+			}
+		case "repoRef":
+			var err error
+			it.RepoRef, err = ec.unmarshalOString2ᚖstring(ctx, v)
+			if err != nil {
+				return it, err
+			}
+		case "prefix":
+			var err error
+			it.Prefix, err = ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+		case "target":
+			var err error
+			it.Target, err = ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+		case "message":
+			var err error
+			it.Message, err = ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+		case "files":
+			var err error
+			it.Files, err = ec.unmarshalOHash2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHashᚄ(ctx, v)
+			if err != nil {
+				return it, err
+			}
+		}
+	}
+
+	return it, nil
+}
+
 func (ec *executionContext) unmarshalInputNewBugInput(ctx context.Context, obj interface{}) (models.NewBugInput, error) {
 	var it models.NewBugInput
 	var asMap = obj.(map[string]interface{})
@@ -11254,6 +11523,40 @@ func (ec *executionContext) _EditCommentOperation(ctx context.Context, sel ast.S
 	return out
 }
 
+var editCommentPayloadImplementors = []string{"EditCommentPayload"}
+
+func (ec *executionContext) _EditCommentPayload(ctx context.Context, sel ast.SelectionSet, obj *models.EditCommentPayload) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, editCommentPayloadImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	var invalids uint32
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("EditCommentPayload")
+		case "clientMutationId":
+			out.Values[i] = ec._EditCommentPayload_clientMutationId(ctx, field, obj)
+		case "bug":
+			out.Values[i] = ec._EditCommentPayload_bug(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
+		case "operation":
+			out.Values[i] = ec._EditCommentPayload_operation(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch()
+	if invalids > 0 {
+		return graphql.Null
+	}
+	return out
+}
+
 var identityImplementors = []string{"Identity"}
 
 func (ec *executionContext) _Identity(ctx context.Context, sel ast.SelectionSet, obj models.IdentityWrapper) graphql.Marshaler {
@@ -11734,6 +12037,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
 			if out.Values[i] == graphql.Null {
 				invalids++
 			}
+		case "editComment":
+			out.Values[i] = ec._Mutation_editComment(ctx, field)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
 		case "changeLabels":
 			out.Values[i] = ec._Mutation_changeLabels(ctx, field)
 			if out.Values[i] == graphql.Null {
@@ -13130,6 +13438,38 @@ func (ec *executionContext) marshalNCreateOperation2ᚖgithubᚗcomᚋMichaelMur
 	return ec._CreateOperation(ctx, sel, v)
 }
 
+func (ec *executionContext) unmarshalNEditCommentInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentInput(ctx context.Context, v interface{}) (models.EditCommentInput, error) {
+	return ec.unmarshalInputEditCommentInput(ctx, v)
+}
+
+func (ec *executionContext) marshalNEditCommentOperation2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx context.Context, sel ast.SelectionSet, v bug.EditCommentOperation) graphql.Marshaler {
+	return ec._EditCommentOperation(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNEditCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx context.Context, sel ast.SelectionSet, v *bug.EditCommentOperation) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	return ec._EditCommentOperation(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalNEditCommentPayload2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx context.Context, sel ast.SelectionSet, v models.EditCommentPayload) graphql.Marshaler {
+	return ec._EditCommentPayload(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNEditCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx context.Context, sel ast.SelectionSet, v *models.EditCommentPayload) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	return ec._EditCommentPayload(ctx, sel, v)
+}
+
 func (ec *executionContext) unmarshalNHash2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHash(ctx context.Context, v interface{}) (repository.Hash, error) {
 	var res repository.Hash
 	return res, res.UnmarshalGQL(v)

api/graphql/models/gen_models.go 🔗

@@ -111,6 +111,30 @@ type CommentEdge struct {
 	Node   *bug.Comment `json:"node"`
 }
 
+type EditCommentInput struct {
+	// A unique identifier for the client performing the mutation.
+	ClientMutationID *string `json:"clientMutationId"`
+	// "The name of the repository. If not set, the default repository is used.
+	RepoRef *string `json:"repoRef"`
+	// The bug ID's prefix.
+	Prefix string `json:"prefix"`
+	// The target.
+	Target string `json:"target"`
+	// The new message to be set.
+	Message string `json:"message"`
+	// The collection of file's hash required for the first message.
+	Files []repository.Hash `json:"files"`
+}
+
+type EditCommentPayload struct {
+	// A unique identifier for the client performing the mutation.
+	ClientMutationID *string `json:"clientMutationId"`
+	// The affected bug.
+	Bug BugWrapper `json:"bug"`
+	// The resulting operation.
+	Operation *bug.EditCommentOperation `json:"operation"`
+}
+
 type IdentityConnection struct {
 	Edges      []*IdentityEdge   `json:"edges"`
 	Nodes      []IdentityWrapper `json:"nodes"`

api/graphql/resolvers/mutation.go 🔗

@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/api/graphql/graph"
 	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
@@ -89,6 +90,34 @@ func (r mutationResolver) AddComment(ctx context.Context, input models.AddCommen
 	}, nil
 }
 
+func (r mutationResolver) EditComment(ctx context.Context, input models.EditCommentInput) (*models.EditCommentPayload, error) {
+	repo, b, err := r.getBug(input.RepoRef, input.Prefix)
+	if err != nil {
+		return nil, err
+	}
+
+	author, err := auth.UserFromCtx(ctx, repo)
+	if err != nil {
+		return nil, err
+	}
+
+	op, err := b.EditCommentRaw(author, time.Now().Unix(), entity.Id(input.Target), input.Message, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	err = b.Commit()
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.EditCommentPayload{
+		ClientMutationID: input.ClientMutationID,
+		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Operation:        op,
+	}, nil
+}
+
 func (r mutationResolver) ChangeLabels(ctx context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) {
 	repo, b, err := r.getBug(input.RepoRef, input.Prefix)
 	if err != nil {

api/graphql/schema/mutations.graphql 🔗

@@ -42,6 +42,30 @@ type AddCommentPayload {
     operation: AddCommentOperation!
 }
 
+input EditCommentInput {
+    """A unique identifier for the client performing the mutation."""
+    clientMutationId: String
+    """"The name of the repository. If not set, the default repository is used."""
+    repoRef: String
+    """The bug ID's prefix."""
+    prefix: String!
+    """The ID of the comment to be changed."""
+    target: String!
+    """The new message to be set."""
+    message: String!
+    """The collection of file's hash required for the first message."""
+    files: [Hash!]
+}
+
+type EditCommentPayload {
+    """A unique identifier for the client performing the mutation."""
+    clientMutationId: String
+    """The affected bug."""
+    bug: Bug!
+    """The resulting operation."""
+    operation: EditCommentOperation!
+}
+
 input ChangeLabelInput {
     """A unique identifier for the client performing the mutation."""
     clientMutationId: String

api/graphql/schema/root.graphql 🔗

@@ -8,6 +8,8 @@ type Mutation {
     newBug(input: NewBugInput!): NewBugPayload!
     """Add a new comment to a bug"""
     addComment(input: AddCommentInput!): AddCommentPayload!
+    """Change a comment of a bug"""
+    editComment(input: EditCommentInput!): EditCommentPayload!
     """Add or remove a set of label on a bug"""
     changeLabels(input: ChangeLabelInput): ChangeLabelPayload!
     """Change a bug's status to open"""

bridge/core/auth/credential.go 🔗

@@ -3,12 +3,13 @@ package auth
 import (
 	"encoding/base64"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"strconv"
 	"strings"
 	"time"
 
+	"github.com/pkg/errors"
+
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 )
@@ -159,7 +160,8 @@ func List(repo repository.RepoKeyring, opts ...ListOption) ([]Credential, error)
 
 		item, err := repo.Keyring().Get(key)
 		if err != nil {
-			return nil, err
+			// skip unreadable items, nothing much we can do for them anyway
+			continue
 		}
 
 		cred, err := decode(item)

cache/filter.go 🔗

@@ -38,6 +38,16 @@ func AuthorFilter(query string) Filter {
 	}
 }
 
+// MetadataFilter return a Filter that match a bug metadata at creation time
+func MetadataFilter(pair query.StringPair) Filter {
+	return func(excerpt *BugExcerpt, resolver resolver) bool {
+		if value, ok := excerpt.CreateMetadata[pair.Key]; ok {
+			return value == pair.Value
+		}
+		return false
+	}
+}
+
 // LabelFilter return a Filter that match a label
 func LabelFilter(label string) Filter {
 	return func(excerpt *BugExcerpt, resolver resolver) bool {
@@ -109,6 +119,7 @@ func NoLabelFilter() Filter {
 type Matcher struct {
 	Status      []Filter
 	Author      []Filter
+	Metadata    []Filter
 	Actor       []Filter
 	Participant []Filter
 	Label       []Filter
@@ -127,6 +138,9 @@ func compileMatcher(filters query.Filters) *Matcher {
 	for _, value := range filters.Author {
 		result.Author = append(result.Author, AuthorFilter(value))
 	}
+	for _, value := range filters.Metadata {
+		result.Metadata = append(result.Metadata, MetadataFilter(value))
+	}
 	for _, value := range filters.Actor {
 		result.Actor = append(result.Actor, ActorFilter(value))
 	}
@@ -153,6 +167,10 @@ func (f *Matcher) Match(excerpt *BugExcerpt, resolver resolver) bool {
 		return false
 	}
 
+	if match := f.orMatch(f.Metadata, excerpt, resolver); !match {
+		return false
+	}
+
 	if match := f.orMatch(f.Participant, excerpt, resolver); !match {
 		return false
 	}

cache/repo_cache_bug.go 🔗

@@ -8,12 +8,14 @@ import (
 	"sort"
 	"strings"
 	"time"
+	"unicode/utf8"
+
+	"github.com/blevesearch/bleve"
 
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/query"
 	"github.com/MichaelMure/git-bug/repository"
-	"github.com/blevesearch/bleve"
 )
 
 const bugCacheFile = "bug-cache"
@@ -523,11 +525,24 @@ func (c *RepoCache) addBugToSearchIndex(snap *bug.Snapshot) error {
 		Text []string
 	}{}
 
+	// See https://github.com/blevesearch/bleve/issues/1576
+	var sb strings.Builder
+	normalize := func(text string) string {
+		sb.Reset()
+		for _, field := range strings.Fields(text) {
+			if utf8.RuneCountInString(field) < 100 {
+				sb.WriteString(field)
+				sb.WriteRune(' ')
+			}
+		}
+		return sb.String()
+	}
+
 	for _, comment := range snap.Comments {
-		searchableBug.Text = append(searchableBug.Text, comment.Message)
+		searchableBug.Text = append(searchableBug.Text, normalize(comment.Message))
 	}
 
-	searchableBug.Text = append(searchableBug.Text, snap.Title)
+	searchableBug.Text = append(searchableBug.Text, normalize(snap.Title))
 
 	index, err := c.repo.GetBleveIndex("bug")
 	if err != nil {

cache/repo_cache_test.go 🔗

@@ -1,7 +1,9 @@
 package cache
 
 import (
+	"strings"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -278,3 +280,21 @@ func checkBugPresence(t *testing.T, cache *RepoCache, bug *BugCache, presence bo
 		require.Equal(t, bug, b)
 	}
 }
+
+func TestLongDescription(t *testing.T) {
+	// See https://github.com/MichaelMure/git-bug/issues/606
+
+	text := strings.Repeat("x", 65536)
+
+	repo := repository.CreateGoGitTestRepo(false)
+	defer repository.CleanupTestRepos(repo)
+
+	backend, err := NewRepoCache(repo)
+	require.NoError(t, err)
+
+	i, err := backend.NewIdentity("René Descartes", "rene@descartes.fr")
+	require.NoError(t, err)
+
+	_, _, err = backend.NewBugRaw(i, time.Now().Unix(), text, text, nil, nil)
+	require.NoError(t, err)
+}

commands/ls.go 🔗

@@ -19,6 +19,7 @@ import (
 type lsOptions struct {
 	statusQuery      []string
 	authorQuery      []string
+	metadataQuery    []string
 	participantQuery []string
 	actorQuery       []string
 	labelQuery       []string
@@ -65,6 +66,8 @@ git bug ls status:open --by creation "foo bar" baz
 		"Filter by status. Valid values are [open,closed]")
 	flags.StringSliceVarP(&options.authorQuery, "author", "a", nil,
 		"Filter by author")
+	flags.StringSliceVarP(&options.metadataQuery, "metadata", "m", nil,
+		"Filter by metadata. Example: github-url=URL")
 	flags.StringSliceVarP(&options.participantQuery, "participant", "p", nil,
 		"Filter by participant")
 	flags.StringSliceVarP(&options.actorQuery, "actor", "A", nil,
@@ -337,6 +340,16 @@ func completeQuery(q *query.Query, opts lsOptions) error {
 	}
 
 	q.Author = append(q.Author, opts.authorQuery...)
+	for _, str := range opts.metadataQuery {
+		tokens := strings.Split(str, "=")
+		if len(tokens) < 2 {
+			return fmt.Errorf("no \"=\" in key=value metadata markup")
+		}
+		var pair query.StringPair
+		pair.Key = tokens[0]
+		pair.Value = tokens[1]
+		q.Metadata = append(q.Metadata, pair)
+	}
 	q.Participant = append(q.Participant, opts.participantQuery...)
 	q.Actor = append(q.Actor, opts.actorQuery...)
 	q.Label = append(q.Label, opts.labelQuery...)

commands/webui.go 🔗

@@ -4,9 +4,12 @@ import (
 	"context"
 	"fmt"
 	"log"
+	"net"
 	"net/http"
+	"net/url"
 	"os"
 	"os/signal"
+	"strconv"
 	"time"
 
 	"github.com/99designs/gqlgen/graphql/playground"
@@ -27,10 +30,12 @@ import (
 const webUIOpenConfigKey = "git-bug.webui.open"
 
 type webUIOptions struct {
+	host     string
 	port     int
 	open     bool
 	noOpen   bool
 	readOnly bool
+	query    string
 }
 
 func newWebUICommand() *cobra.Command {
@@ -54,10 +59,12 @@ Available git config:
 	flags := cmd.Flags()
 	flags.SortFlags = false
 
+	flags.StringVar(&options.host, "host", "127.0.0.1", "Network address or hostname to listen to (default to 127.0.0.1)")
 	flags.BoolVar(&options.open, "open", false, "Automatically open the web UI in the default browser")
 	flags.BoolVar(&options.noOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
-	flags.IntVarP(&options.port, "port", "p", 0, "Port to listen to (default is random)")
+	flags.IntVarP(&options.port, "port", "p", 0, "Port to listen to (default to random available port)")
 	flags.BoolVar(&options.readOnly, "read-only", false, "Whether to run the web UI in read-only mode")
+	flags.StringVarP(&options.query, "query", "q", "", "The query to open in the web UI bug list")
 
 	return cmd
 }
@@ -71,8 +78,14 @@ func runWebUI(env *Env, opts webUIOptions, args []string) error {
 		}
 	}
 
-	addr := fmt.Sprintf("127.0.0.1:%d", opts.port)
+	addr := net.JoinHostPort(opts.host, strconv.Itoa(opts.port))
 	webUiAddr := fmt.Sprintf("http://%s", addr)
+	toOpen := webUiAddr
+
+	if len(opts.query) > 0 {
+		// Explicitly set the query parameter instead of going with a default one.
+		toOpen = fmt.Sprintf("%s/?q=%s", webUiAddr, url.QueryEscape(opts.query))
+	}
 
 	router := mux.NewRouter()
 
@@ -150,7 +163,7 @@ func runWebUI(env *Env, opts webUIOptions, args []string) error {
 	shouldOpen := (configOpen && !opts.noOpen) || opts.open
 
 	if shouldOpen {
-		err = open.Run(webUiAddr)
+		err = open.Run(toOpen)
 		if err != nil {
 			env.out.Println(err)
 		}

doc/man/git-bug-ls.1 🔗

@@ -28,6 +28,10 @@ You can pass an additional query to filter and order the list. This query can be
 \fB\-a\fP, \fB\-\-author\fP=[]
 	Filter by author
 
+.PP
+\fB\-m\fP, \fB\-\-metadata\fP=[]
+	Filter by metadata. Example: github\-url=URL
+
 .PP
 \fB\-p\fP, \fB\-\-participant\fP=[]
 	Filter by participant

doc/man/git-bug-webui.1 🔗

@@ -21,6 +21,10 @@ Available git config:
 
 
 .SH OPTIONS
+.PP
+\fB\-\-host\fP="127.0.0.1"
+	Network address or hostname to listen to (default to 127.0.0.1)
+
 .PP
 \fB\-\-open\fP[=false]
 	Automatically open the web UI in the default browser
@@ -31,12 +35,16 @@ Available git config:
 
 .PP
 \fB\-p\fP, \fB\-\-port\fP=0
-	Port to listen to (default is random)
+	Port to listen to (default to random available port)
 
 .PP
 \fB\-\-read\-only\fP[=false]
 	Whether to run the web UI in read\-only mode
 
+.PP
+\fB\-q\fP, \fB\-\-query\fP=""
+	The query to open in the web UI bug list
+
 .PP
 \fB\-h\fP, \fB\-\-help\fP[=false]
 	help for webui

doc/md/git-bug_ls.md 🔗

@@ -34,6 +34,7 @@ git bug ls status:open --by creation "foo bar" baz
 ```
   -s, --status strings        Filter by status. Valid values are [open,closed]
   -a, --author strings        Filter by author
+  -m, --metadata strings      Filter by metadata. Example: github-url=URL
   -p, --participant strings   Filter by participant
   -A, --actor strings         Filter by actor
   -l, --label strings         Filter by label

doc/md/git-bug_webui.md 🔗

@@ -17,11 +17,13 @@ git-bug webui [flags]
 ### Options
 
 ```
-      --open        Automatically open the web UI in the default browser
-      --no-open     Prevent the automatic opening of the web UI in the default browser
-  -p, --port int    Port to listen to (default is random)
-      --read-only   Whether to run the web UI in read-only mode
-  -h, --help        help for webui
+      --host string    Network address or hostname to listen to (default to 127.0.0.1) (default "127.0.0.1")
+      --open           Automatically open the web UI in the default browser
+      --no-open        Prevent the automatic opening of the web UI in the default browser
+  -p, --port int       Port to listen to (default to random available port)
+      --read-only      Whether to run the web UI in read-only mode
+  -q, --query string   The query to open in the web UI bug list
+  -h, --help           help for webui
 ```
 
 ### SEE ALSO

doc/queries.md 🔗

@@ -10,8 +10,8 @@ A few tips:
 
 - queries are case insensitive.
 - you can combine as many qualifiers as you want.
-- you can use double quotes for multi-word search terms. For example, `author:"René Descartes"` searches for bugs opened by René Descartes, whereas `author:René Descartes` will throw an error since full-text search is not yet supported.
-- instead of a complete ID, you can use any prefix length. For example `participant=9ed1a`.
+- you can use double quotes for multi-word search terms. For example, `author:"René Descartes"` searches for bugs opened by René Descartes, whereas `author:René Descartes` will search for bug with René as the author and containing Descartes in a text.
+- instead of a complete ID, you can use any prefix length, as long as there is no ambiguity. For example `participant=9ed1a`.
 
 
 ## Filtering
@@ -36,7 +36,7 @@ You can filter based on the person who opened the bug.
 
 ### Filtering by participant
 
-You can filter based on the person who participated in any activity related to the bug (Opened bug or added a comment).
+You can filter based on the person who participated in any activity related to the bug (opened bug or added a comment).
 
 | Qualifier           | Example                                                                                            |
 | ---                 | ---                                                                                                |
@@ -51,7 +51,6 @@ You can filter based on the person who interacted with the bug.
 | ---           | ---                                                                             |
 | `actor:QUERY` | `actor:descartes` matches bugs edited by `René Descartes` or `Robert Descartes` |
 |               | `actor:"rené descartes"` matches bugs edited by `René Descartes`                |
-| `
 
 **NOTE**: interaction with bugs include: opening the bug, adding comments, adding/removing labels etc...
 
@@ -90,8 +89,8 @@ Note: to deal with differently-set clocks on distributed computers, `git-bug` us
 
 ### Sort by Id
 
-| Qualifier                  | Example                                              |
-| ---                        | ---                                                  |
+| Qualifier                  | Example                                               |
+| ---                        | ---                                                   |
 | `sort:id-desc`             | `sort:id-desc` will sort bugs by their descending Ids |
 | `sort:id` or `sort:id-asc` | `sort:id` will sort bugs by their ascending Ids       |
 
@@ -99,8 +98,8 @@ Note: to deal with differently-set clocks on distributed computers, `git-bug` us
 
 You can sort bugs by their creation time.
 
-| Qualifier                               | Example                                                            |
-| ---                                     | ---                                                                |
+| Qualifier                               | Example                                                             |
+| ---                                     | ---                                                                 |
 | `sort:creation` or `sort:creation-desc` | `sort:creation` will sort bugs by their descending creation time    |
 | `sort:creation-asc`                     | `sort:creation-asc` will sort bugs by their ascending creation time |
 
@@ -108,7 +107,7 @@ You can sort bugs by their creation time.
 
 You can sort bugs by their edit time.
 
-| Qualifier                       | Example                                                            |
-| ---                             | ---                                                                |
+| Qualifier                       | Example                                                             |
+| ---                             | ---                                                                 |
 | `sort:edit` or `sort:edit-desc` | `sort:edit` will sort bugs by their descending last edition time    |
 | `sort:edit-asc`                 | `sort:edit-asc` will sort bugs by their ascending last edition time |

go.mod 🔗

@@ -27,16 +27,16 @@ require (
 	github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7
 	github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect
 	github.com/skratchdot/open-golang v0.0.0-20190402232053-79abb63cd66e
-	github.com/spf13/cobra v1.1.1
+	github.com/spf13/cobra v1.1.3
 	github.com/stretchr/testify v1.7.0
 	github.com/vektah/gqlparser v1.3.1
-	github.com/xanzy/go-gitlab v0.40.1
+	github.com/xanzy/go-gitlab v0.44.0
 	github.com/xanzy/ssh-agent v0.3.0 // indirect
 	golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
 	golang.org/x/net v0.0.0-20201024042810-be3efd7ff127 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
 	golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208
-	golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a // indirect
+	golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4
 	golang.org/x/text v0.3.5
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	google.golang.org/appengine v1.6.7 // indirect

go.sum 🔗

@@ -74,6 +74,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
 github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
+github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
+github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4=
 github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ=
 github.com/blevesearch/blevex v1.0.0 h1:pnilj2Qi3YSEGdWgLj1Pn9Io7ukfXPoQcpAI1Bv8n/o=
@@ -425,9 +427,11 @@ github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7 h1:Vk3RiBQpF0Ja+
 github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
 github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
 github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
+github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371 h1:SWV2fHctRpRrp49VXJ6UZja7gU9QLHwRpIPBN89SKEo=
 github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
 github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0 h1:JJV9CsgM9EC9w2iVkwuz+sMx8yRFe89PJRUrv6hPCIA=
 github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
@@ -440,8 +444,8 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4=
-github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
+github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
+github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -478,6 +482,8 @@ github.com/willf/bitset v1.1.10 h1:NotGKqX0KwQ72NUzqrjZq5ipPNDQex9lo3WpaS8L2sc=
 github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
 github.com/xanzy/go-gitlab v0.40.1 h1:jHueLh5Inzv20TL5Yki+CaLmyvtw3Yq7blbWx7GmglQ=
 github.com/xanzy/go-gitlab v0.40.1/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
+github.com/xanzy/go-gitlab v0.44.0 h1:cEiGhqu7EpFGuei2a2etAwB+x6403E5CvpLn35y+GPs=
+github.com/xanzy/go-gitlab v0.44.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
 github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
 github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
 github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
@@ -633,6 +639,8 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34=
 golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 h1:EZ2mChiOa8udjfp6rRmswTbtZN/QzUQp4ptM4rnjHvc=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -793,9 +801,10 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

misc/bash_completion/git-bug 🔗

@@ -884,6 +884,12 @@ _git-bug_ls()
     local_nonpersistent_flags+=("--author")
     local_nonpersistent_flags+=("--author=")
     local_nonpersistent_flags+=("-a")
+    flags+=("--metadata=")
+    two_word_flags+=("--metadata")
+    two_word_flags+=("-m")
+    local_nonpersistent_flags+=("--metadata")
+    local_nonpersistent_flags+=("--metadata=")
+    local_nonpersistent_flags+=("-m")
     flags+=("--participant=")
     two_word_flags+=("--participant")
     two_word_flags+=("-p")
@@ -1358,6 +1364,10 @@ _git-bug_webui()
     flags_with_completion=()
     flags_completion=()
 
+    flags+=("--host=")
+    two_word_flags+=("--host")
+    local_nonpersistent_flags+=("--host")
+    local_nonpersistent_flags+=("--host=")
     flags+=("--open")
     local_nonpersistent_flags+=("--open")
     flags+=("--no-open")
@@ -1370,6 +1380,12 @@ _git-bug_webui()
     local_nonpersistent_flags+=("-p")
     flags+=("--read-only")
     local_nonpersistent_flags+=("--read-only")
+    flags+=("--query=")
+    two_word_flags+=("--query")
+    two_word_flags+=("-q")
+    local_nonpersistent_flags+=("--query")
+    local_nonpersistent_flags+=("--query=")
+    local_nonpersistent_flags+=("-q")
 
     must_have_one_flag=()
     must_have_one_noun=()

misc/powershell_completion/git-bug 🔗

@@ -1,261 +1,225 @@
-using namespace System.Management.Automation
-using namespace System.Management.Automation.Language
-Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
-    param($wordToComplete, $commandAst, $cursorPosition)
-    $commandElements = $commandAst.CommandElements
-    $command = @(
-        'git-bug'
-        for ($i = 1; $i -lt $commandElements.Count; $i++) {
-            $element = $commandElements[$i]
-            if ($element -isnot [StringConstantExpressionAst] -or
-                $element.StringConstantType -ne [StringConstantType]::BareWord -or
-                $element.Value.StartsWith('-')) {
-                break
+# powershell completion for git-bug                              -*- shell-script -*-
+
+function __git-bug_debug {
+    if ($env:BASH_COMP_DEBUG_FILE) {
+        "$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE"
+    }
+}
+
+filter __git-bug_escapeStringWithSpecialChars {
+    $_ -replace '\s|#|@|\$|;|,|''|\{|\}|\(|\)|"|`|\||<|>|&','`$&'
+}
+
+Register-ArgumentCompleter -CommandName 'git-bug' -ScriptBlock {
+    param(
+            $WordToComplete,
+            $CommandAst,
+            $CursorPosition
+        )
+
+    # Get the current command line and convert into a string
+    $Command = $CommandAst.CommandElements
+    $Command = "$Command"
+
+    __git-bug_debug ""
+    __git-bug_debug "========= starting completion logic =========="
+    __git-bug_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition"
+
+    # The user could have moved the cursor backwards on the command-line.
+    # We need to trigger completion from the $CursorPosition location, so we need
+    # to truncate the command-line ($Command) up to the $CursorPosition location.
+    # Make sure the $Command is longer then the $CursorPosition before we truncate.
+    # This happens because the $Command does not include the last space.
+    if ($Command.Length -gt $CursorPosition) {
+        $Command=$Command.Substring(0,$CursorPosition)
+    }
+	__git-bug_debug "Truncated command: $Command"
+
+    $ShellCompDirectiveError=1
+    $ShellCompDirectiveNoSpace=2
+    $ShellCompDirectiveNoFileComp=4
+    $ShellCompDirectiveFilterFileExt=8
+    $ShellCompDirectiveFilterDirs=16
+
+	# Prepare the command to request completions for the program.
+    # Split the command at the first space to separate the program and arguments.
+    $Program,$Arguments = $Command.Split(" ",2)
+    $RequestComp="$Program __completeNoDesc $Arguments"
+    __git-bug_debug "RequestComp: $RequestComp"
+
+    # we cannot use $WordToComplete because it
+    # has the wrong values if the cursor was moved
+    # so use the last argument
+    if ($WordToComplete -ne "" ) {
+        $WordToComplete = $Arguments.Split(" ")[-1]
+    }
+    __git-bug_debug "New WordToComplete: $WordToComplete"
+
+
+    # Check for flag with equal sign
+    $IsEqualFlag = ($WordToComplete -Like "--*=*" )
+    if ( $IsEqualFlag ) {
+        __git-bug_debug "Completing equal sign flag"
+        # Remove the flag part
+        $Flag,$WordToComplete = $WordToComplete.Split("=",2)
+    }
+
+    if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) {
+        # If the last parameter is complete (there is a space following it)
+        # We add an extra empty parameter so we can indicate this to the go method.
+        __git-bug_debug "Adding extra empty parameter"
+        # We need to use `"`" to pass an empty argument a "" or '' does not work!!!
+        $RequestComp="$RequestComp" + ' `"`"' 
+    }
+
+    __git-bug_debug "Calling $RequestComp"
+    #call the command store the output in $out and redirect stderr and stdout to null
+    # $Out is an array contains each line per element
+    Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null
+
+
+    # get directive from last line
+    [int]$Directive = $Out[-1].TrimStart(':')
+    if ($Directive -eq "") {
+        # There is no directive specified
+        $Directive = 0
+    }
+    __git-bug_debug "The completion directive is: $Directive"
+
+    # remove directive (last element) from out
+    $Out = $Out | Where-Object { $_ -ne $Out[-1] }
+    __git-bug_debug "The completions are: $Out"
+
+    if (($Directive -band $ShellCompDirectiveError) -ne 0 ) {
+        # Error code.  No completion.
+        __git-bug_debug "Received error from custom completion go code"
+        return
+    }
+
+    $Longest = 0
+    $Values = $Out | ForEach-Object {
+        #Split the output in name and description
+        $Name, $Description = $_.Split("`t",2)
+        __git-bug_debug "Name: $Name Description: $Description"
+
+        # Look for the longest completion so that we can format things nicely
+        if ($Longest -lt $Name.Length) {
+            $Longest = $Name.Length
+        }
+
+        # Set the description to a one space string if there is none set.
+        # This is needed because the CompletionResult does not accept an empty string as argument
+        if (-Not $Description) {
+            $Description = " "
+        }
+        @{Name="$Name";Description="$Description"}
+    }
+
+
+    $Space = " "
+    if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) {
+        # remove the space here
+        __git-bug_debug "ShellCompDirectiveNoSpace is called"
+        $Space = ""
+    }
+
+    if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) {
+        __git-bug_debug "ShellCompDirectiveNoFileComp is called"
+
+        if ($Values.Length -eq 0) {
+            # Just print an empty string here so the
+            # shell does not start to complete paths.
+            # We cannot use CompletionResult here because
+            # it does not accept an empty string as argument.
+            ""
+            return
+        }
+    }
+
+    if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or
+       (($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 ))  {
+        __git-bug_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported"
+
+        # return here to prevent the completion of the extensions
+        return
+    }
+
+    $Values = $Values | Where-Object {
+        # filter the result
+        $_.Name -like "$WordToComplete*"
+
+        # Join the flag back if we have a equal sign flag
+        if ( $IsEqualFlag ) {
+            __git-bug_debug "Join the equal sign flag back to the completion value"
+            $_.Name = $Flag + "=" + $_.Name
+        }
+    }
+
+    # Get the current mode
+    $Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function
+    __git-bug_debug "Mode: $Mode"
+
+    $Values | ForEach-Object {
+
+        # store temporay because switch will overwrite $_
+        $comp = $_
+
+        # PowerShell supports three different completion modes
+        # - TabCompleteNext (default windows style - on each key press the next option is displayed)
+        # - Complete (works like bash)
+        # - MenuComplete (works like zsh)
+        # You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function <mode>
+
+        # CompletionResult Arguments:
+        # 1) CompletionText text to be used as the auto completion result
+        # 2) ListItemText   text to be displayed in the suggestion list
+        # 3) ResultType     type of completion result
+        # 4) ToolTip        text for the tooltip with details about the object
+
+        switch ($Mode) {
+
+            # bash like
+            "Complete" {
+
+                if ($Values.Length -eq 1) {
+                    __git-bug_debug "Only one completion left"
+
+                    # insert space after value
+                    [System.Management.Automation.CompletionResult]::new($($comp.Name | __git-bug_escapeStringWithSpecialChars) + $Space, "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
+
+                } else {
+                    # Add the proper number of spaces to align the descriptions
+                    while($comp.Name.Length -lt $Longest) {
+                        $comp.Name = $comp.Name + " "
+                    }
+
+                    # Check for empty description and only add parentheses if needed
+                    if ($($comp.Description) -eq " " ) {
+                        $Description = ""
+                    } else {
+                        $Description = "  ($($comp.Description))"
+                    }
+
+                    [System.Management.Automation.CompletionResult]::new("$($comp.Name)$Description", "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)")
+                }
+             }
+
+            # zsh like
+            "MenuComplete" {
+                # insert space after value
+                # MenuComplete will automatically show the ToolTip of
+                # the highlighted value at the bottom of the suggestions.
+                [System.Management.Automation.CompletionResult]::new($($comp.Name | __git-bug_escapeStringWithSpecialChars) + $Space, "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
+            }
+
+            # TabCompleteNext and in case we get something unknown
+            Default {
+                # Like MenuComplete but we don't want to add a space here because
+                # the user need to press space anyway to get the completion.
+                # Description will not be shown because thats not possible with TabCompleteNext
+                [System.Management.Automation.CompletionResult]::new($($comp.Name | __git-bug_escapeStringWithSpecialChars), "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
             }
-            $element.Value
-        }
-    ) -join ';'
-    $completions = @(switch ($command) {
-        'git-bug' {
-            [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Create a new bug.')
-            [CompletionResult]::new('bridge', 'bridge', [CompletionResultType]::ParameterValue, 'Configure and use bridges to other bug trackers.')
-            [CompletionResult]::new('commands', 'commands', [CompletionResultType]::ParameterValue, 'Display available commands.')
-            [CompletionResult]::new('comment', 'comment', [CompletionResultType]::ParameterValue, 'Display or add comments to a bug.')
-            [CompletionResult]::new('deselect', 'deselect', [CompletionResultType]::ParameterValue, 'Clear the implicitly selected bug.')
-            [CompletionResult]::new('label', 'label', [CompletionResultType]::ParameterValue, 'Display, add or remove labels to/from a bug.')
-            [CompletionResult]::new('ls', 'ls', [CompletionResultType]::ParameterValue, 'List bugs.')
-            [CompletionResult]::new('ls-id', 'ls-id', [CompletionResultType]::ParameterValue, 'List bug identifiers.')
-            [CompletionResult]::new('ls-label', 'ls-label', [CompletionResultType]::ParameterValue, 'List valid labels.')
-            [CompletionResult]::new('pull', 'pull', [CompletionResultType]::ParameterValue, 'Pull bugs update from a git remote.')
-            [CompletionResult]::new('push', 'push', [CompletionResultType]::ParameterValue, 'Push bugs update to a git remote.')
-            [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Remove an existing bug.')
-            [CompletionResult]::new('select', 'select', [CompletionResultType]::ParameterValue, 'Select a bug for implicit use in future commands.')
-            [CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Display the details of a bug.')
-            [CompletionResult]::new('status', 'status', [CompletionResultType]::ParameterValue, 'Display or change a bug status.')
-            [CompletionResult]::new('termui', 'termui', [CompletionResultType]::ParameterValue, 'Launch the terminal UI.')
-            [CompletionResult]::new('title', 'title', [CompletionResultType]::ParameterValue, 'Display or change a title of a bug.')
-            [CompletionResult]::new('user', 'user', [CompletionResultType]::ParameterValue, 'Display or change the user identity.')
-            [CompletionResult]::new('version', 'version', [CompletionResultType]::ParameterValue, 'Show git-bug version information.')
-            [CompletionResult]::new('webui', 'webui', [CompletionResultType]::ParameterValue, 'Launch the web UI.')
-            break
-        }
-        'git-bug;add' {
-            [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Provide a title to describe the issue')
-            [CompletionResult]::new('--title', 'title', [CompletionResultType]::ParameterName, 'Provide a title to describe the issue')
-            [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Provide a message to describe the issue')
-            [CompletionResult]::new('--message', 'message', [CompletionResultType]::ParameterName, 'Provide a message to describe the issue')
-            [CompletionResult]::new('-F', 'F', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
-            [CompletionResult]::new('--file', 'file', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
-            break
-        }
-        'git-bug;bridge' {
-            [CompletionResult]::new('auth', 'auth', [CompletionResultType]::ParameterValue, 'List all known bridge authentication credentials.')
-            [CompletionResult]::new('configure', 'configure', [CompletionResultType]::ParameterValue, 'Configure a new bridge.')
-            [CompletionResult]::new('pull', 'pull', [CompletionResultType]::ParameterValue, 'Pull updates.')
-            [CompletionResult]::new('push', 'push', [CompletionResultType]::ParameterValue, 'Push updates.')
-            [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Delete a configured bridge.')
-            break
-        }
-        'git-bug;bridge;auth' {
-            [CompletionResult]::new('add-token', 'add-token', [CompletionResultType]::ParameterValue, 'Store a new token')
-            [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Remove a credential.')
-            [CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Display an authentication credential.')
-            break
-        }
-        'git-bug;bridge;auth;add-token' {
-            [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]')
-            [CompletionResult]::new('--target', 'target', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]')
-            [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'The login in the remote bug-tracker')
-            [CompletionResult]::new('--login', 'login', [CompletionResultType]::ParameterName, 'The login in the remote bug-tracker')
-            [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The user to add the token to. Default is the current user')
-            [CompletionResult]::new('--user', 'user', [CompletionResultType]::ParameterName, 'The user to add the token to. Default is the current user')
-            break
-        }
-        'git-bug;bridge;auth;rm' {
-            break
-        }
-        'git-bug;bridge;auth;show' {
-            break
-        }
-        'git-bug;bridge;configure' {
-            [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'A distinctive name to identify the bridge')
-            [CompletionResult]::new('--name', 'name', [CompletionResultType]::ParameterName, 'A distinctive name to identify the bridge')
-            [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]')
-            [CompletionResult]::new('--target', 'target', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]')
-            [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The URL of the remote repository')
-            [CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The URL of the remote repository')
-            [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'The base URL of your remote issue tracker')
-            [CompletionResult]::new('--base-url', 'base-url', [CompletionResultType]::ParameterName, 'The base URL of your remote issue tracker')
-            [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'The login on your remote issue tracker')
-            [CompletionResult]::new('--login', 'login', [CompletionResultType]::ParameterName, 'The login on your remote issue tracker')
-            [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")')
-            [CompletionResult]::new('--credential', 'credential', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")')
-            [CompletionResult]::new('--token', 'token', [CompletionResultType]::ParameterName, 'A raw authentication token for the remote issue tracker')
-            [CompletionResult]::new('--token-stdin', 'token-stdin', [CompletionResultType]::ParameterName, 'Will read the token from stdin and ignore --token')
-            [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'The owner of the remote repository')
-            [CompletionResult]::new('--owner', 'owner', [CompletionResultType]::ParameterName, 'The owner of the remote repository')
-            [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'The name of the remote repository')
-            [CompletionResult]::new('--project', 'project', [CompletionResultType]::ParameterName, 'The name of the remote repository')
-            break
-        }
-        'git-bug;bridge;pull' {
-            [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'force importing all bugs')
-            [CompletionResult]::new('--no-resume', 'no-resume', [CompletionResultType]::ParameterName, 'force importing all bugs')
-            [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'import only bugs updated after the given date (ex: "200h" or "june 2 2019")')
-            [CompletionResult]::new('--since', 'since', [CompletionResultType]::ParameterName, 'import only bugs updated after the given date (ex: "200h" or "june 2 2019")')
-            break
-        }
-        'git-bug;bridge;push' {
-            break
-        }
-        'git-bug;bridge;rm' {
-            break
-        }
-        'git-bug;commands' {
-            [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Output the command description as well as Markdown compatible comment')
-            [CompletionResult]::new('--pretty', 'pretty', [CompletionResultType]::ParameterName, 'Output the command description as well as Markdown compatible comment')
-            break
-        }
-        'git-bug;comment' {
-            [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add a new comment to a bug.')
-            [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Edit an existing comment on a bug.')
-            break
-        }
-        'git-bug;comment;add' {
-            [CompletionResult]::new('-F', 'F', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
-            [CompletionResult]::new('--file', 'file', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
-            [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Provide the new message from the command line')
-            [CompletionResult]::new('--message', 'message', [CompletionResultType]::ParameterName, 'Provide the new message from the command line')
-            break
-        }
-        'git-bug;comment;edit' {
-            [CompletionResult]::new('-F', 'F', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
-            [CompletionResult]::new('--file', 'file', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
-            [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Provide the new message from the command line')
-            [CompletionResult]::new('--message', 'message', [CompletionResultType]::ParameterName, 'Provide the new message from the command line')
-            break
-        }
-        'git-bug;deselect' {
-            break
-        }
-        'git-bug;label' {
-            [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add a label to a bug.')
-            [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Remove a label from a bug.')
-            break
-        }
-        'git-bug;label;add' {
-            break
-        }
-        'git-bug;label;rm' {
-            break
-        }
-        'git-bug;ls' {
-            [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Filter by status. Valid values are [open,closed]')
-            [CompletionResult]::new('--status', 'status', [CompletionResultType]::ParameterName, 'Filter by status. Valid values are [open,closed]')
-            [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Filter by author')
-            [CompletionResult]::new('--author', 'author', [CompletionResultType]::ParameterName, 'Filter by author')
-            [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Filter by participant')
-            [CompletionResult]::new('--participant', 'participant', [CompletionResultType]::ParameterName, 'Filter by participant')
-            [CompletionResult]::new('-A', 'A', [CompletionResultType]::ParameterName, 'Filter by actor')
-            [CompletionResult]::new('--actor', 'actor', [CompletionResultType]::ParameterName, 'Filter by actor')
-            [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'Filter by label')
-            [CompletionResult]::new('--label', 'label', [CompletionResultType]::ParameterName, 'Filter by label')
-            [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Filter by title')
-            [CompletionResult]::new('--title', 'title', [CompletionResultType]::ParameterName, 'Filter by title')
-            [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Filter by absence of something. Valid values are [label]')
-            [CompletionResult]::new('--no', 'no', [CompletionResultType]::ParameterName, 'Filter by absence of something. Valid values are [label]')
-            [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'Sort the results by a characteristic. Valid values are [id,creation,edit]')
-            [CompletionResult]::new('--by', 'by', [CompletionResultType]::ParameterName, 'Sort the results by a characteristic. Valid values are [id,creation,edit]')
-            [CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Select the sorting direction. Valid values are [asc,desc]')
-            [CompletionResult]::new('--direction', 'direction', [CompletionResultType]::ParameterName, 'Select the sorting direction. Valid values are [asc,desc]')
-            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,plain,json,org-mode]')
-            [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,plain,json,org-mode]')
-            break
-        }
-        'git-bug;ls-id' {
-            break
-        }
-        'git-bug;ls-label' {
-            break
-        }
-        'git-bug;pull' {
-            break
-        }
-        'git-bug;push' {
-            break
-        }
-        'git-bug;rm' {
-            break
-        }
-        'git-bug;select' {
-            break
-        }
-        'git-bug;show' {
-            [CompletionResult]::new('--field', 'field', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [author,authorEmail,createTime,lastEdit,humanId,id,labels,shortId,status,title,actors,participants]')
-            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json,org-mode]')
-            [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json,org-mode]')
-            break
-        }
-        'git-bug;status' {
-            [CompletionResult]::new('close', 'close', [CompletionResultType]::ParameterValue, 'Mark a bug as closed.')
-            [CompletionResult]::new('open', 'open', [CompletionResultType]::ParameterValue, 'Mark a bug as open.')
-            break
-        }
-        'git-bug;status;close' {
-            break
-        }
-        'git-bug;status;open' {
-            break
-        }
-        'git-bug;termui' {
-            break
-        }
-        'git-bug;title' {
-            [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Edit a title of a bug.')
-            break
-        }
-        'git-bug;title;edit' {
-            [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Provide a title to describe the issue')
-            [CompletionResult]::new('--title', 'title', [CompletionResultType]::ParameterName, 'Provide a title to describe the issue')
-            break
-        }
-        'git-bug;user' {
-            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [email,humanId,id,lastModification,lastModificationLamports,login,metadata,name]')
-            [CompletionResult]::new('--field', 'field', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [email,humanId,id,lastModification,lastModificationLamports,login,metadata,name]')
-            [CompletionResult]::new('adopt', 'adopt', [CompletionResultType]::ParameterValue, 'Adopt an existing identity as your own.')
-            [CompletionResult]::new('create', 'create', [CompletionResultType]::ParameterValue, 'Create a new identity.')
-            [CompletionResult]::new('ls', 'ls', [CompletionResultType]::ParameterValue, 'List identities.')
-            break
-        }
-        'git-bug;user;adopt' {
-            break
-        }
-        'git-bug;user;create' {
-            break
-        }
-        'git-bug;user;ls' {
-            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json]')
-            [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json]')
-            break
-        }
-        'git-bug;version' {
-            [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Only show the version number')
-            [CompletionResult]::new('--number', 'number', [CompletionResultType]::ParameterName, 'Only show the version number')
-            [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'Only show the commit hash')
-            [CompletionResult]::new('--commit', 'commit', [CompletionResultType]::ParameterName, 'Only show the commit hash')
-            [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Show all version information')
-            [CompletionResult]::new('--all', 'all', [CompletionResultType]::ParameterName, 'Show all version information')
-            break
-        }
-        'git-bug;webui' {
-            [CompletionResult]::new('--open', 'open', [CompletionResultType]::ParameterName, 'Automatically open the web UI in the default browser')
-            [CompletionResult]::new('--no-open', 'no-open', [CompletionResultType]::ParameterName, 'Prevent the automatic opening of the web UI in the default browser')
-            [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Port to listen to (default is random)')
-            [CompletionResult]::new('--port', 'port', [CompletionResultType]::ParameterName, 'Port to listen to (default is random)')
-            [CompletionResult]::new('--read-only', 'read-only', [CompletionResultType]::ParameterName, 'Whether to run the web UI in read-only mode')
-            break
         }
-    })
-    $completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
-        Sort-Object -Property ListItemText
-}
+
+    }
+}

query/lexer.go 🔗

@@ -11,16 +11,20 @@ type tokenKind int
 const (
 	_ tokenKind = iota
 	tokenKindKV
+	tokenKindKVV
 	tokenKindSearch
 )
 
 type token struct {
 	kind tokenKind
 
-	// KV
+	// KV and KVV
 	qualifier string
 	value     string
 
+	// KVV only
+	subQualifier string
+
 	// Search
 	term string
 }
@@ -33,6 +37,15 @@ func newTokenKV(qualifier, value string) token {
 	}
 }
 
+func newTokenKVV(qualifier, subQualifier, value string) token {
+	return token{
+		kind:         tokenKindKVV,
+		qualifier:    qualifier,
+		subQualifier: subQualifier,
+		value:        value,
+	}
+}
+
 func newTokenSearch(term string) token {
 	return token{
 		kind: tokenKindSearch,
@@ -43,44 +56,68 @@ func newTokenSearch(term string) token {
 // tokenize parse and break a input into tokens ready to be
 // interpreted later by a parser to get the semantic.
 func tokenize(query string) ([]token, error) {
-	fields, err := splitQuery(query)
+	fields, err := splitFunc(query, unicode.IsSpace)
 	if err != nil {
 		return nil, err
 	}
 
 	var tokens []token
 	for _, field := range fields {
-		split := strings.Split(field, ":")
-
-		// full text search
-		if len(split) == 1 {
-			tokens = append(tokens, newTokenSearch(removeQuote(field)))
-			continue
+		chunks, err := splitFunc(field, func(r rune) bool { return r == ':' })
+		if err != nil {
+			return nil, err
 		}
 
-		if len(split) != 2 {
-			return nil, fmt.Errorf("can't tokenize \"%s\"", field)
+		if strings.HasPrefix(field, ":") || strings.HasSuffix(field, ":") {
+			return nil, fmt.Errorf("empty qualifier or value")
 		}
 
-		if len(split[0]) == 0 {
-			return nil, fmt.Errorf("can't tokenize \"%s\": empty qualifier", field)
-		}
-		if len(split[1]) == 0 {
-			return nil, fmt.Errorf("empty value for qualifier \"%s\"", split[0])
+		// pre-process chunks
+		for i, chunk := range chunks {
+			if len(chunk) == 0 {
+				return nil, fmt.Errorf("empty qualifier or value")
+			}
+			chunks[i] = removeQuote(chunk)
 		}
 
-		tokens = append(tokens, newTokenKV(split[0], removeQuote(split[1])))
+		switch len(chunks) {
+		case 1: // full text search
+			tokens = append(tokens, newTokenSearch(chunks[0]))
+
+		case 2: // KV
+			tokens = append(tokens, newTokenKV(chunks[0], chunks[1]))
+
+		case 3: // KVV
+			tokens = append(tokens, newTokenKVV(chunks[0], chunks[1], chunks[2]))
+
+		default:
+			return nil, fmt.Errorf("can't tokenize \"%s\": too many separators", field)
+		}
 	}
 	return tokens, nil
 }
 
-// split the query into chunks by splitting on whitespaces but respecting
+func removeQuote(field string) string {
+	runes := []rune(field)
+	if len(runes) >= 2 {
+		r1 := runes[0]
+		r2 := runes[len(runes)-1]
+
+		if r1 == r2 && isQuote(r1) {
+			return string(runes[1 : len(runes)-1])
+		}
+	}
+	return field
+}
+
+// split the input into chunks by splitting according to separatorFunc but respecting
 // quotes
-func splitQuery(query string) ([]string, error) {
+func splitFunc(input string, separatorFunc func(r rune) bool) ([]string, error) {
 	lastQuote := rune(0)
 	inQuote := false
 
-	isToken := func(r rune) bool {
+	// return true if it's part of a chunk, or false if it's a rune that delimit one, as determined by the separatorFunc.
+	isChunk := func(r rune) bool {
 		switch {
 		case !inQuote && isQuote(r):
 			lastQuote = r
@@ -93,19 +130,19 @@ func splitQuery(query string) ([]string, error) {
 		case inQuote:
 			return true
 		default:
-			return !unicode.IsSpace(r)
+			return !separatorFunc(r)
 		}
 	}
 
 	var result []string
-	var token strings.Builder
-	for _, r := range query {
-		if isToken(r) {
-			token.WriteRune(r)
+	var chunk strings.Builder
+	for _, r := range input {
+		if isChunk(r) {
+			chunk.WriteRune(r)
 		} else {
-			if token.Len() > 0 {
-				result = append(result, token.String())
-				token.Reset()
+			if chunk.Len() > 0 {
+				result = append(result, chunk.String())
+				chunk.Reset()
 			}
 		}
 	}
@@ -114,8 +151,8 @@ func splitQuery(query string) ([]string, error) {
 		return nil, fmt.Errorf("unmatched quote")
 	}
 
-	if token.Len() > 0 {
-		result = append(result, token.String())
+	if chunk.Len() > 0 {
+		result = append(result, chunk.String())
 	}
 
 	return result, nil
@@ -124,16 +161,3 @@ func splitQuery(query string) ([]string, error) {
 func isQuote(r rune) bool {
 	return r == '"' || r == '\''
 }
-
-func removeQuote(field string) string {
-	runes := []rune(field)
-	if len(runes) >= 2 {
-		r1 := runes[0]
-		r2 := runes[len(runes)-1]
-
-		if r1 == r2 && isQuote(r1) {
-			return string(runes[1 : len(runes)-1])
-		}
-	}
-	return field
-}

query/lexer_test.go 🔗

@@ -3,7 +3,7 @@ package query
 import (
 	"testing"
 
-	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestTokenize(t *testing.T) {
@@ -37,6 +37,14 @@ func TestTokenize(t *testing.T) {
 		{`key:'value value`, nil},
 		{`key:value value'`, nil},
 
+		// sub-qualifier positive testing
+		{`key:subkey:"value:value"`, []token{newTokenKVV("key", "subkey", "value:value")}},
+
+		// sub-qualifier negative testing
+		{`key:subkey:value:value`, nil},
+		{`key:subkey:`, nil},
+		{`key:subkey:"value`, nil},
+
 		// full text search
 		{"search", []token{newTokenSearch("search")}},
 		{"search more terms", []token{
@@ -51,13 +59,15 @@ func TestTokenize(t *testing.T) {
 	}
 
 	for _, tc := range tests {
-		tokens, err := tokenize(tc.input)
-		if tc.tokens == nil {
-			assert.Error(t, err)
-			assert.Nil(t, tokens)
-		} else {
-			assert.NoError(t, err)
-			assert.Equal(t, tc.tokens, tokens)
-		}
+		t.Run(tc.input, func(t *testing.T) {
+			tokens, err := tokenize(tc.input)
+			if tc.tokens == nil {
+				require.Error(t, err)
+				require.Nil(t, tokens)
+			} else {
+				require.NoError(t, err)
+				require.Equal(t, tc.tokens, tokens)
+			}
+		})
 	}
 }

query/parser.go 🔗

@@ -67,6 +67,15 @@ func Parse(query string) (*Query, error) {
 			default:
 				return nil, fmt.Errorf("unknown qualifier \"%s\"", t.qualifier)
 			}
+
+		case tokenKindKVV:
+			switch t.qualifier {
+			case "metadata":
+				q.Metadata = append(q.Metadata, StringPair{Key: t.subQualifier, Value: t.value})
+
+			default:
+				return nil, fmt.Errorf("unknown qualifier \"%s:%s\"", t.qualifier, t.subQualifier)
+			}
 		}
 	}
 	return q, nil

query/parser_test.go 🔗

@@ -3,7 +3,7 @@ package query
 import (
 	"testing"
 
-	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/bug"
 )
@@ -62,6 +62,11 @@ func TestParse(t *testing.T) {
 		}},
 		{"sort:unknown", nil},
 
+		// KVV
+		{`metadata:key:"https://www.example.com/"`, &Query{
+			Filters: Filters{Metadata: []StringPair{{"key", "https://www.example.com/"}}},
+		}},
+
 		// Search
 		{"search", &Query{
 			Search: []string{"search"},
@@ -90,17 +95,17 @@ func TestParse(t *testing.T) {
 		t.Run(tc.input, func(t *testing.T) {
 			query, err := Parse(tc.input)
 			if tc.output == nil {
-				assert.Error(t, err)
-				assert.Nil(t, query)
+				require.Error(t, err)
+				require.Nil(t, query)
 			} else {
-				assert.NoError(t, err)
+				require.NoError(t, err)
 				if tc.output.OrderBy != 0 {
-					assert.Equal(t, tc.output.OrderBy, query.OrderBy)
+					require.Equal(t, tc.output.OrderBy, query.OrderBy)
 				}
 				if tc.output.OrderDirection != 0 {
-					assert.Equal(t, tc.output.OrderDirection, query.OrderDirection)
+					require.Equal(t, tc.output.OrderDirection, query.OrderDirection)
 				}
-				assert.Equal(t, tc.output.Filters, query.Filters)
+				require.Equal(t, tc.output.Filters, query.Filters)
 			}
 		})
 	}

query/query.go 🔗

@@ -23,10 +23,17 @@ func NewQuery() *Query {
 
 type Search []string
 
+// StringPair is a key/value pair of strings
+type StringPair struct {
+	Key   string
+	Value string
+}
+
 // Filters is a collection of Filter that implement a complex filter
 type Filters struct {
 	Status      []bug.Status
 	Author      []string
+	Metadata    []StringPair
 	Actor       []string
 	Participant []string
 	Label       []string

repository/config_mem.go 🔗

@@ -20,6 +20,7 @@ func NewMemConfig() *MemConfig {
 }
 
 func (mc *MemConfig) StoreString(key, value string) error {
+	key = normalizeKey(key)
 	mc.config[key] = value
 	return nil
 }
@@ -33,6 +34,7 @@ func (mc *MemConfig) StoreTimestamp(key string, value time.Time) error {
 }
 
 func (mc *MemConfig) ReadAll(keyPrefix string) (map[string]string, error) {
+	keyPrefix = normalizeKey(keyPrefix)
 	result := make(map[string]string)
 	for key, val := range mc.config {
 		if strings.HasPrefix(key, keyPrefix) {
@@ -44,6 +46,7 @@ func (mc *MemConfig) ReadAll(keyPrefix string) (map[string]string, error) {
 
 func (mc *MemConfig) ReadString(key string) (string, error) {
 	// unlike git, the mock can only store one value for the same key
+	key = normalizeKey(key)
 	val, ok := mc.config[key]
 	if !ok {
 		return "", ErrNoConfigEntry
@@ -54,9 +57,9 @@ func (mc *MemConfig) ReadString(key string) (string, error) {
 
 func (mc *MemConfig) ReadBool(key string) (bool, error) {
 	// unlike git, the mock can only store one value for the same key
-	val, ok := mc.config[key]
-	if !ok {
-		return false, ErrNoConfigEntry
+	val, err := mc.ReadString(key)
+	if err != nil {
+		return false, err
 	}
 
 	return strconv.ParseBool(val)
@@ -78,6 +81,7 @@ func (mc *MemConfig) ReadTimestamp(key string) (time.Time, error) {
 
 // RmConfigs remove all key/value pair matching the key prefix
 func (mc *MemConfig) RemoveAll(keyPrefix string) error {
+	keyPrefix = normalizeKey(keyPrefix)
 	found := false
 	for key := range mc.config {
 		if strings.HasPrefix(key, keyPrefix) {
@@ -92,3 +96,12 @@ func (mc *MemConfig) RemoveAll(keyPrefix string) error {
 
 	return nil
 }
+
+func normalizeKey(key string) string {
+	// this feels so wrong, but that's apparently how git behave.
+	// only section and final segment are case insensitive, subsection in between are not.
+	s := strings.Split(key, ".")
+	s[0] = strings.ToLower(s[0])
+	s[len(s)-1] = strings.ToLower(s[len(s)-1])
+	return strings.Join(s, ".")
+}

repository/config_testing.go 🔗

@@ -113,4 +113,43 @@ func testConfig(t *testing.T, config Config) {
 		"section.subsection.subsection.opt1": "foo5",
 		"section.subsection.subsection.opt2": "foo6",
 	}, all)
+
+	// missing section + case insensitive
+	val, err = config.ReadString("section2.opt1")
+	require.Error(t, err)
+
+	val, err = config.ReadString("section.opt1")
+	require.NoError(t, err)
+	require.Equal(t, "foo", val)
+
+	val, err = config.ReadString("SECTION.OPT1")
+	require.NoError(t, err)
+	require.Equal(t, "foo", val)
+
+	_, err = config.ReadString("SECTION2.OPT3")
+	require.Error(t, err)
+
+	// missing subsection + case insensitive
+	val, err = config.ReadString("section.subsection.opt1")
+	require.NoError(t, err)
+	require.Equal(t, "foo3", val)
+
+	// for some weird reason, subsection ARE case sensitive
+	_, err = config.ReadString("SECTION.SUBSECTION.OPT1")
+	require.Error(t, err)
+
+	_, err = config.ReadString("SECTION.SUBSECTION1.OPT1")
+	require.Error(t, err)
+
+	// missing sub-subsection + case insensitive
+	val, err = config.ReadString("section.subsection.subsection.opt1")
+	require.NoError(t, err)
+	require.Equal(t, "foo5", val)
+
+	// for some weird reason, subsection ARE case sensitive
+	_, err = config.ReadString("SECTION.SUBSECTION.SUBSECTION.OPT1")
+	require.Error(t, err)
+
+	_, err = config.ReadString("SECTION.SUBSECTION.SUBSECTION1.OPT1")
+	require.Error(t, err)
 }

repository/gogit.go 🔗

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"io/ioutil"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"sort"
 	"strings"
@@ -21,6 +20,7 @@ import (
 	"github.com/go-git/go-git/v5/plumbing/filemode"
 	"github.com/go-git/go-git/v5/plumbing/object"
 	"golang.org/x/crypto/openpgp"
+	"golang.org/x/sys/execabs"
 
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
@@ -264,7 +264,7 @@ func (repo *GoGitRepo) GetCoreEditor() (string, error) {
 	}
 
 	for _, cmd := range priorities {
-		if _, err = exec.LookPath(cmd); err == nil {
+		if _, err = execabs.LookPath(cmd); err == nil {
 			return cmd, nil
 		}
 

repository/gogit_config.go 🔗

@@ -134,7 +134,7 @@ func (cr *goGitConfigReader) ReadString(key string) (string, error) {
 		}
 		return section.Option(optionName), nil
 	default:
-		subsectionName := strings.Join(split[1:len(split)-2], ".")
+		subsectionName := strings.Join(split[1:len(split)-1], ".")
 		optionName := split[len(split)-1]
 		if !section.HasSubsection(subsectionName) {
 			return "", ErrNoConfigEntry

webui/Readme.md 🔗

@@ -1,5 +1,8 @@
 # git-bug rich web UI
 
+## Prerequisites
+[ReactJS](https://reactjs.org/) | [Material UI](https://material-ui.com/) | [GraphQL](https://graphql.org/) | [Apollo GraphQL](https://www.apollographql.com/docs/react/)
+
 ## How to develop
 
 ### Run GraphQL backend
@@ -26,4 +29,7 @@ The development version of the WebUI is configured to query the backend on the p
 
 ## Bundle the web UI
 
-Once the webUI is good enough for a new release, run `make pack-webui` from the root directory to bundle the compiled js into the go binary.
+Once the webUI is good enough for a new release:
+1. run `make build` from webui folder
+2. run `make pack-webui` from the *root directory* to bundle the compiled js into the go binary.
+   - You must have Go installed on Your machine to run this command.

webui/package-lock.json 🔗

@@ -12572,8 +12572,7 @@
     "growly": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
-      "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
-      "optional": true
+      "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE="
     },
     "gzip-size": {
       "version": "5.1.1",
@@ -16116,10 +16115,9 @@
       "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA="
     },
     "node-notifier": {
-      "version": "8.0.0",
-      "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.0.tgz",
-      "integrity": "sha512-46z7DUmcjoYdaWyXouuFNNfUo6eFa94t23c53c+lG/9Cvauk4a98rAUp9672X5dxGdQmLpPzTxzu8f/OeEPaFA==",
-      "optional": true,
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz",
+      "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==",
       "requires": {
         "growly": "^1.3.0",
         "is-wsl": "^2.2.0",
@@ -16130,22 +16128,22 @@
       },
       "dependencies": {
         "semver": {
-          "version": "7.3.2",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
-          "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
-          "optional": true
+          "version": "7.3.4",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
+          "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
+          "requires": {
+            "lru-cache": "^6.0.0"
+          }
         },
         "uuid": {
-          "version": "8.3.0",
-          "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",
-          "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==",
-          "optional": true
+          "version": "8.3.2",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+          "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
         },
         "which": {
           "version": "2.0.2",
           "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
           "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-          "optional": true,
           "requires": {
             "isexe": "^2.0.0"
           }
@@ -20221,8 +20219,7 @@
     "shellwords": {
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
-      "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
-      "optional": true
+      "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="
     },
     "side-channel": {
       "version": "1.0.3",

webui/packed_assets.go 🔗

@@ -21,92 +21,78 @@ var WebUIAssets = func() http.FileSystem {
 	fs := vfsgen۰FS{
 		"/": &vfsgen۰DirInfo{
 			name:    "/",
-			modTime: time.Date(2020, 6, 27, 21, 4, 34, 651378504, time.UTC),
+			modTime: time.Date(2021, 2, 19, 22, 21, 2, 16455900, time.UTC),
 		},
 		"/asset-manifest.json": &vfsgen۰CompressedFileInfo{
 			name:             "asset-manifest.json",
-			modTime:          time.Date(2020, 6, 27, 21, 4, 34, 651378504, time.UTC),
-			uncompressedSize: 849,
+			modTime:          time.Date(2021, 2, 19, 22, 21, 2, 17421700, time.UTC),
+			uncompressedSize: 683,
 

webui/src/App.tsx 🔗

@@ -5,6 +5,7 @@ import Layout from './components/Header';
 import BugPage from './pages/bug';
 import ListPage from './pages/list';
 import NewBugPage from './pages/new/NewBugPage';
+import NotFoundPage from './pages/notfound/NotFoundPage';
 
 export default function App() {
   return (
@@ -13,6 +14,7 @@ export default function App() {
         <Route path="/" exact component={ListPage} />
         <Route path="/new" exact component={NewBugPage} />
         <Route path="/bug/:id" exact component={BugPage} />
+        <Route component={NotFoundPage} />
       </Switch>
     </Layout>
   );

webui/src/components/BackToListButton.tsx 🔗

@@ -0,0 +1,36 @@
+import React from 'react';
+
+import Button from '@material-ui/core/Button';
+import { makeStyles } from '@material-ui/core/styles';
+import ArrowBackIcon from '@material-ui/icons/ArrowBack';
+
+const useStyles = makeStyles((theme) => ({
+  backButton: {
+    position: 'sticky',
+    top: '80px',
+    backgroundColor: theme.palette.primary.dark,
+    color: theme.palette.primary.contrastText,
+    '&:hover': {
+      backgroundColor: theme.palette.primary.main,
+      color: theme.palette.primary.contrastText,
+    },
+  },
+}));
+
+function BackToListButton() {
+  const classes = useStyles();
+
+  return (
+    <Button
+      variant="contained"
+      className={classes.backButton}
+      aria-label="back to issue list"
+      href="/"
+    >
+      <ArrowBackIcon />
+      Back to List
+    </Button>
+  );
+}
+
+export default BackToListButton;

webui/src/components/BugTitleForm/BugTitleForm.tsx 🔗

@@ -1,12 +1,6 @@
 import React, { useState } from 'react';
 
-import {
-  Button,
-  fade,
-  makeStyles,
-  TextField,
-  Typography,
-} from '@material-ui/core';
+import { Button, makeStyles, Typography } from '@material-ui/core';
 
 import { TimelineDocument } from '../../pages/bug/TimelineQuery.generated';
 import IfLoggedIn from '../IfLoggedIn/IfLoggedIn';
@@ -14,6 +8,7 @@ import Author from 'src/components/Author';
 import Date from 'src/components/Date';
 import { BugFragment } from 'src/pages/bug/Bug.generated';
 
+import BugTitleInput from './BugTitleInput';
 import { useSetTitleMutation } from './SetTitle.generated';
 
 /**
@@ -45,26 +40,16 @@ const useStyles = makeStyles((theme) => ({
     marginLeft: theme.spacing(2),
   },
   greenButton: {
-    marginLeft: '8px',
-    backgroundColor: '#2ea44fd9',
-    color: '#fff',
+    marginLeft: theme.spacing(1),
+    backgroundColor: theme.palette.success.main,
+    color: theme.palette.success.contrastText,
     '&:hover': {
-      backgroundColor: '#2ea44f',
+      backgroundColor: theme.palette.success.dark,
+      color: theme.palette.primary.contrastText,
     },
   },
-  titleInput: {
-    borderRadius: theme.shape.borderRadius,
-    borderColor: fade(theme.palette.primary.main, 0.2),
-    borderStyle: 'solid',
-    borderWidth: '1px',
-    backgroundColor: fade(theme.palette.primary.main, 0.05),
-    padding: theme.spacing(0, 0),
-    minWidth: 336,
-    transition: theme.transitions.create([
-      'width',
-      'borderColor',
-      'backgroundColor',
-    ]),
+  saveButton: {
+    marginRight: theme.spacing(1),
   },
 }));
 
@@ -85,7 +70,7 @@ function BugTitleForm({ bug }: Props) {
 
   function isFormValid() {
     if (issueTitleInput) {
-      return issueTitleInput.value.length > 0 ? true : false;
+      return issueTitleInput.value.length > 0;
     } else {
       return false;
     }
@@ -122,11 +107,11 @@ function BugTitleForm({ bug }: Props) {
   function editableBugTitle() {
     return (
       <form className={classes.headerTitle} onSubmit={submitNewTitle}>
-        <TextField
+        <BugTitleInput
           inputRef={(node) => {
             issueTitleInput = node;
           }}
-          className={classes.titleInput}
+          label="Title"
           variant="outlined"
           fullWidth
           margin="dense"
@@ -135,6 +120,7 @@ function BugTitleForm({ bug }: Props) {
         />
         <div className={classes.editButtonContainer}>
           <Button
+            className={classes.saveButton}
             size="small"
             variant="contained"
             type="submit"
@@ -173,7 +159,7 @@ function BugTitleForm({ bug }: Props) {
                 variant="contained"
                 href="/new"
               >
-                New issue
+                New bug
               </Button>
             </div>
           )}

webui/src/components/BugTitleForm/BugTitleInput.tsx 🔗

@@ -0,0 +1,40 @@
+import { createStyles, fade, withStyles, TextField } from '@material-ui/core';
+import { Theme } from '@material-ui/core/styles';
+
+const BugTitleInput = withStyles((theme: Theme) =>
+  createStyles({
+    root: {
+      '& .MuiInputLabel-outlined': {
+        color: theme.palette.text.primary,
+      },
+      '& input:valid + fieldset': {
+        color: theme.palette.text.primary,
+        borderColor: theme.palette.divider,
+        borderWidth: 2,
+      },
+      '& input:valid:hover + fieldset': {
+        color: theme.palette.text.primary,
+        borderColor: fade(theme.palette.divider, 0.3),
+        borderWidth: 2,
+      },
+      '& input:valid:focus + fieldset': {
+        color: theme.palette.text.primary,
+        borderColor: theme.palette.divider,
+      },
+      '& input:invalid + fieldset': {
+        borderColor: theme.palette.error.main,
+        borderWidth: 2,
+      },
+      '& input:invalid:hover + fieldset': {
+        borderColor: theme.palette.error.main,
+        borderWidth: 2,
+      },
+      '& input:invalid:focus + fieldset': {
+        borderColor: theme.palette.error.main,
+        borderWidth: 2,
+      },
+    },
+  })
+)(TextField);
+
+export default BugTitleInput;

webui/src/components/CloseBugButton/CloseBugButton.tsx 🔗

@@ -1,12 +1,21 @@
 import React from 'react';
 
 import Button from '@material-ui/core/Button';
+import { makeStyles, Theme } from '@material-ui/core/styles';
+import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline';
 
 import { BugFragment } from 'src/pages/bug/Bug.generated';
 import { TimelineDocument } from 'src/pages/bug/TimelineQuery.generated';
 
 import { useCloseBugMutation } from './CloseBug.generated';
 
+const useStyles = makeStyles((theme: Theme) => ({
+  closeIssueIcon: {
+    color: theme.palette.secondary.dark,
+    paddingTop: '0.1rem',
+  },
+}));
+
 interface Props {
   bug: BugFragment;
   disabled: boolean;
@@ -14,6 +23,7 @@ interface Props {
 
 function CloseBugButton({ bug, disabled }: Props) {
   const [closeBug, { loading, error }] = useCloseBugMutation();
+  const classes = useStyles();
 
   function closeBugAction() {
     closeBug({
@@ -45,8 +55,9 @@ function CloseBugButton({ bug, disabled }: Props) {
         variant="contained"
         onClick={() => closeBugAction()}
         disabled={bug.status === 'CLOSED' || disabled}
+        startIcon={<ErrorOutlineIcon className={classes.closeIssueIcon} />}
       >
-        Close issue
+        Close bug
       </Button>
     </div>
   );

webui/src/components/CommentInput/CommentInput.tsx 🔗

@@ -51,6 +51,7 @@ const a11yProps = (index: number) => ({
 
 type Props = {
   inputProps?: any;
+  inputText?: string;
   loading: boolean;
   onChange: (comment: string) => void;
 };
@@ -62,8 +63,8 @@ type Props = {
  * @param loading Disable input when component not ready yet
  * @param onChange Callback to return input value changes
  */
-function CommentInput({ inputProps, loading, onChange }: Props) {
-  const [input, setInput] = useState<string>('');
+function CommentInput({ inputProps, inputText, loading, onChange }: Props) {
+  const [input, setInput] = useState<string>(inputText ? inputText : '');
   const [tab, setTab] = useState(0);
   const classes = useStyles();
 

webui/src/components/Content/PreTag.tsx 🔗

@@ -11,7 +11,7 @@ const useStyles = makeStyles({
 
 const PreTag = (props: React.HTMLProps<HTMLPreElement>) => {
   const classes = useStyles();
-  return <pre className={classes.tag} {...props}></pre>;
+  return <pre className={classes.tag} {...props} />;
 };
 
 export default PreTag;

webui/src/components/Header/Header.tsx 🔗

@@ -1,11 +1,15 @@
 import React from 'react';
-import { Link } from 'react-router-dom';
+import { Link, useLocation } from 'react-router-dom';
 
 import AppBar from '@material-ui/core/AppBar';
+import Tab, { TabProps } from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
 import Toolbar from '@material-ui/core/Toolbar';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
 import { makeStyles } from '@material-ui/core/styles';
 
 import CurrentIdentity from '../CurrentIdentity/CurrentIdentity';
+import { LightSwitch } from '../Themer';
 
 const useStyles = makeStyles((theme) => ({
   offset: {
@@ -14,35 +18,99 @@ const useStyles = makeStyles((theme) => ({
   filler: {
     flexGrow: 1,
   },
+  appBar: {
+    backgroundColor: theme.palette.primary.dark,
+    color: theme.palette.primary.contrastText,
+  },
   appTitle: {
     ...theme.typography.h6,
-    color: 'white',
+    color: theme.palette.primary.contrastText,
     textDecoration: 'none',
     display: 'flex',
     alignItems: 'center',
   },
+  lightSwitch: {
+    padding: '0 20px',
+  },
   logo: {
     height: '42px',
     marginRight: theme.spacing(2),
   },
 }));
 
+function a11yProps(index: any) {
+  return {
+    id: `nav-tab-${index}`,
+    'aria-controls': `nav-tabpanel-${index}`,
+  };
+}
+
+const DisabledTabWithTooltip = (props: TabProps) => {
+  /*The span elements around disabled tabs are needed, as the tooltip
+   * won't be triggered by disabled elements.
+   * See: https://material-ui.com/components/tooltips/#disabled-elements
+   * This must be done in a wrapper component, otherwise the TabS component
+   * cannot pass it styles down to the Tab component. Resulting in (console)
+   * warnings. This wrapper acceps the passed down TabProps and pass it around
+   * the span element to the Tab component.
+   */
+  const msg = `This feature doesn't exist yet. Come help us build it.`;
+  return (
+    <Tooltip title={msg}>
+      <span>
+        <Tab disabled {...props} />
+      </span>
+    </Tooltip>
+  );
+};
+
 function Header() {
   const classes = useStyles();
+  const location = useLocation();
+  const [selectedTab, setTab] = React.useState(location.pathname);
+
+  const handleTabClick = (
+    event: React.ChangeEvent<{}>,
+    newTabValue: string
+  ) => {
+    setTab(newTabValue);
+  };
 
   return (
     <>
-      <AppBar position="fixed" color="primary">
+      <AppBar position="fixed" className={classes.appBar}>
         <Toolbar>
           <Link to="/" className={classes.appTitle}>
-            <img src="/logo.svg" className={classes.logo} alt="git-bug" />
+            <img src="/logo.svg" className={classes.logo} alt="git-bug logo" />
             git-bug
           </Link>
-          <div className={classes.filler}></div>
+          <div className={classes.filler} />
+          <div className={classes.lightSwitch}>
+            <LightSwitch />
+          </div>
           <CurrentIdentity />
         </Toolbar>
       </AppBar>
       <div className={classes.offset} />
+      <Tabs
+        centered
+        value={selectedTab}
+        onChange={handleTabClick}
+        aria-label="nav tabs"
+      >
+        <DisabledTabWithTooltip label="Code" value="/code" {...a11yProps(1)} />
+        <Tab label="Bugs" value="/" component={Link} to="/" {...a11yProps(2)} />
+        <DisabledTabWithTooltip
+          label="Pull Requests"
+          value="/pulls"
+          {...a11yProps(3)}
+        />
+        <DisabledTabWithTooltip
+          label="Settings"
+          value="/settings"
+          {...a11yProps(4)}
+        />
+      </Tabs>
     </>
   );
 }

webui/src/components/Themer.tsx 🔗

@@ -0,0 +1,65 @@
+import React, { createContext, useContext, useState } from 'react';
+
+import { fade, ThemeProvider } from '@material-ui/core';
+import IconButton from '@material-ui/core/IconButton/IconButton';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
+import { Theme } from '@material-ui/core/styles';
+import { NightsStayRounded, WbSunnyRounded } from '@material-ui/icons';
+import { makeStyles } from '@material-ui/styles';
+
+const ThemeContext = createContext({
+  toggleMode: () => {},
+  mode: '',
+});
+
+const useStyles = makeStyles((theme: Theme) => ({
+  iconButton: {
+    color: fade(theme.palette.primary.contrastText, 0.5),
+  },
+}));
+
+const LightSwitch = () => {
+  const { mode, toggleMode } = useContext(ThemeContext);
+  const nextMode = mode === 'light' ? 'dark' : 'light';
+  const description = `Switch to ${nextMode} theme`;
+  const classes = useStyles();
+
+  return (
+    <Tooltip title={description}>
+      <IconButton
+        onClick={toggleMode}
+        aria-label={description}
+        className={classes.iconButton}
+      >
+        {mode === 'light' ? <WbSunnyRounded /> : <NightsStayRounded />}
+      </IconButton>
+    </Tooltip>
+  );
+};
+
+type Props = {
+  children: React.ReactNode;
+  lightTheme: Theme;
+  darkTheme: Theme;
+};
+const Themer = ({ children, lightTheme, darkTheme }: Props) => {
+  const savedMode = localStorage.getItem('themeMode');
+  const preferedMode = savedMode != null ? savedMode : 'light';
+  const [mode, setMode] = useState(preferedMode);
+
+  const toggleMode = () => {
+    const preferedMode = mode === 'light' ? 'dark' : 'light';
+    localStorage.setItem('themeMode', preferedMode);
+    setMode(preferedMode);
+  };
+
+  const preferedTheme = mode === 'dark' ? darkTheme : lightTheme;
+
+  return (
+    <ThemeContext.Provider value={{ toggleMode: toggleMode, mode: mode }}>
+      <ThemeProvider theme={preferedTheme}>{children}</ThemeProvider>
+    </ThemeContext.Provider>
+  );
+};
+
+export { Themer as default, LightSwitch };

webui/src/index.tsx 🔗

@@ -3,18 +3,17 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import { BrowserRouter } from 'react-router-dom';
 
-import ThemeProvider from '@material-ui/styles/ThemeProvider';
-
 import App from './App';
 import apolloClient from './apollo';
-import theme from './theme';
+import Themer from './components/Themer';
+import { defaultLightTheme, defaultDarkTheme } from './themes/index';
 
 ReactDOM.render(
   <ApolloProvider client={apolloClient}>
     <BrowserRouter>
-      <ThemeProvider theme={theme}>
+      <Themer lightTheme={defaultLightTheme} darkTheme={defaultDarkTheme}>
         <App />
-      </ThemeProvider>
+      </Themer>
     </BrowserRouter>
   </ApolloProvider>,
   document.getElementById('root')

webui/src/pages/bug/Bug.tsx 🔗

@@ -18,11 +18,17 @@ const useStyles = makeStyles((theme) => ({
     maxWidth: 1000,
     margin: 'auto',
     marginTop: theme.spacing(4),
-    overflow: 'hidden',
   },
   header: {
-    marginLeft: theme.spacing(3) + 40,
     marginRight: theme.spacing(2),
+    marginLeft: theme.spacing(3) + 40,
+  },
+  title: {
+    ...theme.typography.h5,
+  },
+  id: {
+    ...theme.typography.subtitle1,
+    marginLeft: theme.spacing(1),
   },
   container: {
     display: 'flex',
@@ -36,11 +42,11 @@ const useStyles = makeStyles((theme) => ({
     marginRight: theme.spacing(2),
     minWidth: 400,
   },
-  sidebar: {
+  rightSidebar: {
     marginTop: theme.spacing(2),
     flex: '0 0 200px',
   },
-  sidebarTitle: {
+  rightSidebarTitle: {
     fontWeight: 'bold',
   },
   labelList: {
@@ -59,6 +65,7 @@ const useStyles = makeStyles((theme) => ({
     ...theme.typography.body2,
   },
   commentForm: {
+    marginTop: theme.spacing(2),
     marginLeft: 48,
   },
 }));
@@ -75,10 +82,9 @@ function Bug({ bug }: Props) {
       <div className={classes.header}>
         <BugTitleForm bug={bug} />
       </div>
-
       <div className={classes.container}>
         <div className={classes.timeline}>
-          <TimelineQuery id={bug.id} />
+          <TimelineQuery bug={bug} />
           <IfLoggedIn>
             {() => (
               <div className={classes.commentForm}>
@@ -87,8 +93,8 @@ function Bug({ bug }: Props) {
             )}
           </IfLoggedIn>
         </div>
-        <div className={classes.sidebar}>
-          <span className={classes.sidebarTitle}>Labels</span>
+        <div className={classes.rightSidebar}>
+          <span className={classes.rightSidebarTitle}>Labels</span>
           <ul className={classes.labelList}>
             {bug.labels.length === 0 && (
               <span className={classes.noLabel}>None yet</span>

webui/src/pages/bug/BugQuery.tsx 🔗

@@ -3,6 +3,8 @@ import { RouteComponentProps } from 'react-router-dom';
 
 import CircularProgress from '@material-ui/core/CircularProgress';
 
+import NotFoundPage from '../notfound/NotFoundPage';
+
 import Bug from './Bug';
 import { useGetBugQuery } from './BugQuery.generated';
 
@@ -15,8 +17,8 @@ const BugQuery: React.FC<Props> = ({ match }: Props) => {
     variables: { id: match.params.id },
   });
   if (loading) return <CircularProgress />;
+  if (!data?.repository?.bug) return <NotFoundPage />;
   if (error) return <p>Error: {error}</p>;
-  if (!data?.repository?.bug) return <p>404.</p>;
   return <Bug bug={data.repository.bug} />;
 };
 

webui/src/pages/bug/CommentForm.tsx 🔗

@@ -15,7 +15,6 @@ import { TimelineDocument } from './TimelineQuery.generated';
 type StyleProps = { loading: boolean };
 const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
   container: {
-    margin: theme.spacing(2, 0),
     padding: theme.spacing(0, 2, 2, 2),
   },
   textarea: {},
@@ -28,14 +27,16 @@ const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
   },
   actions: {
     display: 'flex',
+    gap: '1em',
     justifyContent: 'flex-end',
   },
   greenButton: {
     marginLeft: '8px',
-    backgroundColor: '#2ea44fd9',
-    color: '#fff',
+    backgroundColor: theme.palette.success.main,
+    color: theme.palette.success.contrastText,
     '&:hover': {
-      backgroundColor: '#2ea44f',
+      backgroundColor: theme.palette.success.dark,
+      color: theme.palette.primary.contrastText,
     },
   },
 }));

webui/src/pages/bug/EditCommentForm.graphql 🔗

@@ -0,0 +1,16 @@
+#import "./MessageCommentFragment.graphql"
+#import "./MessageCreateFragment.graphql"
+
+mutation EditComment($input: EditCommentInput!) {
+  editComment(input: $input) {
+    bug {
+      id
+      timeline {
+        comments: nodes {
+          ...Create
+          ...AddComment
+        }
+      }
+    }
+  }
+}

webui/src/pages/bug/EditCommentForm.tsx 🔗

@@ -0,0 +1,123 @@
+import React, { useState, useRef } from 'react';
+
+import Button from '@material-ui/core/Button';
+import Paper from '@material-ui/core/Paper';
+import { makeStyles, Theme } from '@material-ui/core/styles';
+
+import CommentInput from '../../components/CommentInput/CommentInput';
+
+import { BugFragment } from './Bug.generated';
+import { useEditCommentMutation } from './EditCommentForm.generated';
+import { AddCommentFragment } from './MessageCommentFragment.generated';
+import { CreateFragment } from './MessageCreateFragment.generated';
+
+type StyleProps = { loading: boolean };
+const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
+  container: {
+    padding: theme.spacing(0, 2, 2, 2),
+  },
+  textarea: {},
+  tabContent: {
+    margin: theme.spacing(2, 0),
+  },
+  preview: {
+    borderBottom: `solid 3px ${theme.palette.grey['200']}`,
+    minHeight: '5rem',
+  },
+  actions: {
+    display: 'flex',
+    justifyContent: 'flex-end',
+  },
+  greenButton: {
+    marginLeft: '8px',
+    backgroundColor: theme.palette.success.main,
+    color: theme.palette.success.contrastText,
+    '&:hover': {
+      backgroundColor: theme.palette.success.dark,
+      color: theme.palette.success.contrastText,
+    },
+  },
+}));
+
+type Props = {
+  bug: BugFragment;
+  comment: AddCommentFragment | CreateFragment;
+  onCancel?: () => void;
+  onPostSubmit?: (comments: any) => void;
+};
+
+function EditCommentForm({ bug, comment, onCancel, onPostSubmit }: Props) {
+  const [editComment, { loading }] = useEditCommentMutation();
+  const [message, setMessage] = useState<string>(comment.message);
+  const [inputProp, setInputProp] = useState<any>('');
+  const classes = useStyles({ loading });
+  const form = useRef<HTMLFormElement>(null);
+
+  const submit = () => {
+    editComment({
+      variables: {
+        input: {
+          prefix: bug.id,
+          message: message,
+          target: comment.id,
+        },
+      },
+    }).then((result) => {
+      const comments = result.data?.editComment.bug.timeline.comments as (
+        | AddCommentFragment
+        | CreateFragment
+      )[];
+      // NOTE Searching for the changed comment could be dropped if GraphQL get
+      // filter by id argument for timelineitems
+      const modifiedComment = comments.find((elem) => elem.id === comment.id);
+      if (onPostSubmit) onPostSubmit(modifiedComment);
+    });
+    resetForm();
+  };
+
+  function resetForm() {
+    setInputProp({
+      value: '',
+    });
+  }
+
+  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    if (message.length > 0) submit();
+  };
+
+  function getCancelButton() {
+    return (
+      <Button onClick={onCancel} variant="contained">
+        Cancel
+      </Button>
+    );
+  }
+
+  return (
+    <Paper className={classes.container}>
+      <form onSubmit={handleSubmit} ref={form}>
+        <CommentInput
+          inputProps={inputProp}
+          loading={loading}
+          onChange={(message: string) => setMessage(message)}
+          inputText={comment.message}
+        />
+        <div className={classes.actions}>
+          {onCancel && getCancelButton()}
+          <Button
+            className={classes.greenButton}
+            variant="contained"
+            color="primary"
+            type="submit"
+            disabled={loading || message.length === 0}
+          >
+            Update Comment
+          </Button>
+        </div>
+      </form>
+    </Paper>
+  );
+}
+
+export default EditCommentForm;

webui/src/pages/bug/Message.tsx 🔗

@@ -1,14 +1,22 @@
-import React from 'react';
+import React, { useState } from 'react';
 
+import IconButton from '@material-ui/core/IconButton';
 import Paper from '@material-ui/core/Paper';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
 import { makeStyles } from '@material-ui/core/styles';
+import EditIcon from '@material-ui/icons/Edit';
+import HistoryIcon from '@material-ui/icons/History';
 
 import Author, { Avatar } from 'src/components/Author';
 import Content from 'src/components/Content';
 import Date from 'src/components/Date';
+import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
 
+import { BugFragment } from './Bug.generated';
+import EditCommentForm from './EditCommentForm';
 import { AddCommentFragment } from './MessageCommentFragment.generated';
 import { CreateFragment } from './MessageCreateFragment.generated';
+import MessageHistoryDialog from './MessageHistoryDialog';
 
 const useStyles = makeStyles((theme) => ({
   author: {
@@ -27,11 +35,13 @@ const useStyles = makeStyles((theme) => ({
   },
   header: {
     ...theme.typography.body1,
-    color: '#444',
     padding: '0.5rem 1rem',
-    borderBottom: '1px solid #ddd',
+    borderBottom: `1px solid ${theme.palette.divider}`,
     display: 'flex',
-    backgroundColor: '#e2f1ff',
+    borderTopRightRadius: theme.shape.borderRadius,
+    borderTopLeftRadius: theme.shape.borderRadius,
+    backgroundColor: theme.palette.info.main,
+    color: theme.palette.info.contrastText,
   },
   title: {
     flex: 1,
@@ -47,32 +57,135 @@ const useStyles = makeStyles((theme) => ({
   },
   body: {
     ...theme.typography.body2,
-    padding: '0 1rem',
+    padding: '0.5rem',
+  },
+  headerActions: {
+    color: theme.palette.info.contrastText,
+    padding: '0rem',
+    marginLeft: theme.spacing(1),
+    fontSize: '0.75rem',
+    '&:hover': {
+      backgroundColor: 'inherit',
+    },
   },
 }));
 
+type HistBtnProps = {
+  bugId: string;
+  commentId: string;
+};
+function HistoryMenuToggleButton({ bugId, commentId }: HistBtnProps) {
+  const classes = useStyles();
+  const [open, setOpen] = React.useState(false);
+
+  const handleClickOpen = () => {
+    setOpen(true);
+  };
+
+  const handleClose = () => {
+    setOpen(false);
+  };
+
+  return (
+    <div>
+      <IconButton
+        aria-label="more"
+        aria-controls="long-menu"
+        aria-haspopup="true"
+        onClick={handleClickOpen}
+        className={classes.headerActions}
+      >
+        <HistoryIcon />
+      </IconButton>
+      {
+        // Render CustomizedDialogs on open to prevent fetching the history
+        // before opening the history menu.
+        open && (
+          <MessageHistoryDialog
+            bugId={bugId}
+            commentId={commentId}
+            open={open}
+            onClose={handleClose}
+          />
+        )
+      }
+    </div>
+  );
+}
+
 type Props = {
+  bug: BugFragment;
   op: AddCommentFragment | CreateFragment;
 };
-
-function Message({ op }: Props) {
+function Message({ bug, op }: Props) {
   const classes = useStyles();
-  return (
-    <article className={classes.container}>
-      <Avatar author={op.author} className={classes.avatar} />
+  const [editMode, switchToEditMode] = useState(false);
+  const [comment, setComment] = useState(op);
+
+  const editComment = (id: String) => {
+    switchToEditMode(true);
+  };
+
+  function readMessageView() {
+    return (
       <Paper elevation={1} className={classes.bubble}>
         <header className={classes.header}>
           <div className={classes.title}>
-            <Author className={classes.author} author={op.author} />
+            <Author className={classes.author} author={comment.author} />
             <span> commented </span>
-            <Date date={op.createdAt} />
+            <Date date={comment.createdAt} />
           </div>
-          {op.edited && <div className={classes.tag}>Edited</div>}
+          {comment.edited && (
+            <HistoryMenuToggleButton bugId={bug.id} commentId={comment.id} />
+          )}
+          <IfLoggedIn>
+            {() => (
+              <Tooltip title="Edit Message" placement="top" arrow={true}>
+                <IconButton
+                  disableRipple
+                  className={classes.headerActions}
+                  aria-label="edit message"
+                  onClick={() => editComment(comment.id)}
+                >
+                  <EditIcon />
+                </IconButton>
+              </Tooltip>
+            )}
+          </IfLoggedIn>
         </header>
         <section className={classes.body}>
-          <Content markdown={op.message} />
+          <Content markdown={comment.message} />
         </section>
       </Paper>
+    );
+  }
+
+  function editMessageView() {
+    const cancelEdition = () => {
+      switchToEditMode(false);
+    };
+
+    const onPostSubmit = (comment: AddCommentFragment | CreateFragment) => {
+      setComment(comment);
+      switchToEditMode(false);
+    };
+
+    return (
+      <div className={classes.bubble}>
+        <EditCommentForm
+          bug={bug}
+          onCancel={cancelEdition}
+          onPostSubmit={onPostSubmit}
+          comment={comment}
+        />
+      </div>
+    );
+  }
+
+  return (
+    <article className={classes.container}>
+      <Avatar author={comment.author} className={classes.avatar} />
+      {editMode ? editMessageView() : readMessageView()}
     </article>
   );
 }

webui/src/pages/bug/MessageHistory.graphql 🔗

@@ -0,0 +1,15 @@
+#import "./MessageCommentFragment.graphql"
+#import "./MessageCreateFragment.graphql"
+
+query MessageHistory($bugIdPrefix: String!) {
+  repository {
+    bug(prefix: $bugIdPrefix) {
+      timeline {
+        comments: nodes {
+          ...Create
+          ...AddComment
+        }
+      }
+    }
+  }
+}

webui/src/pages/bug/MessageHistoryDialog.tsx 🔗

@@ -0,0 +1,235 @@
+import moment from 'moment';
+import React from 'react';
+import Moment from 'react-moment';
+
+import MuiAccordion from '@material-ui/core/Accordion';
+import MuiAccordionDetails from '@material-ui/core/AccordionDetails';
+import MuiAccordionSummary from '@material-ui/core/AccordionSummary';
+import CircularProgress from '@material-ui/core/CircularProgress';
+import Dialog from '@material-ui/core/Dialog';
+import MuiDialogContent from '@material-ui/core/DialogContent';
+import MuiDialogTitle from '@material-ui/core/DialogTitle';
+import Grid from '@material-ui/core/Grid';
+import IconButton from '@material-ui/core/IconButton';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
+import Typography from '@material-ui/core/Typography';
+import {
+  createStyles,
+  Theme,
+  withStyles,
+  WithStyles,
+} from '@material-ui/core/styles';
+import CloseIcon from '@material-ui/icons/Close';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+
+import { AddCommentFragment } from './MessageCommentFragment.generated';
+import { CreateFragment } from './MessageCreateFragment.generated';
+import { useMessageHistoryQuery } from './MessageHistory.generated';
+
+const styles = (theme: Theme) =>
+  createStyles({
+    root: {
+      margin: 0,
+      padding: theme.spacing(2),
+    },
+    closeButton: {
+      position: 'absolute',
+      right: theme.spacing(1),
+      top: theme.spacing(1),
+    },
+  });
+
+export interface DialogTitleProps extends WithStyles<typeof styles> {
+  id: string;
+  children: React.ReactNode;
+  onClose: () => void;
+}
+
+const DialogTitle = withStyles(styles)((props: DialogTitleProps) => {
+  const { children, classes, onClose, ...other } = props;
+  return (
+    <MuiDialogTitle disableTypography className={classes.root} {...other}>
+      <Typography variant="h6">{children}</Typography>
+      {onClose ? (
+        <IconButton
+          aria-label="close"
+          className={classes.closeButton}
+          onClick={onClose}
+        >
+          <CloseIcon />
+        </IconButton>
+      ) : null}
+    </MuiDialogTitle>
+  );
+});
+
+const DialogContent = withStyles((theme: Theme) => ({
+  root: {
+    padding: theme.spacing(2),
+  },
+}))(MuiDialogContent);
+
+const Accordion = withStyles({
+  root: {
+    border: '1px solid rgba(0, 0, 0, .125)',
+    boxShadow: 'none',
+    '&:not(:last-child)': {
+      borderBottom: 0,
+    },
+    '&:before': {
+      display: 'none',
+    },
+    '&$expanded': {
+      margin: 'auto',
+    },
+  },
+  expanded: {},
+})(MuiAccordion);
+
+const AccordionSummary = withStyles((theme) => ({
+  root: {
+    backgroundColor: theme.palette.primary.light,
+    borderBottomWidth: '1px',
+    borderBottomStyle: 'solid',
+    borderBottomColor: theme.palette.divider,
+    marginBottom: -1,
+    minHeight: 56,
+    '&$expanded': {
+      minHeight: 56,
+    },
+  },
+  content: {
+    '&$expanded': {
+      margin: '12px 0',
+    },
+  },
+  expanded: {},
+}))(MuiAccordionSummary);
+
+const AccordionDetails = withStyles((theme) => ({
+  root: {
+    padding: theme.spacing(2),
+  },
+}))(MuiAccordionDetails);
+
+type Props = {
+  bugId: string;
+  commentId: string;
+  open: boolean;
+  onClose: () => void;
+};
+function MessageHistoryDialog({ bugId, commentId, open, onClose }: Props) {
+  const [expanded, setExpanded] = React.useState<string | false>('panel0');
+
+  const { loading, error, data } = useMessageHistoryQuery({
+    variables: { bugIdPrefix: bugId },
+  });
+  if (loading) {
+    return (
+      <Dialog
+        onClose={onClose}
+        aria-labelledby="customized-dialog-title"
+        open={open}
+        fullWidth
+        maxWidth="sm"
+      >
+        <DialogTitle id="customized-dialog-title" onClose={onClose}>
+          Loading...
+        </DialogTitle>
+        <DialogContent dividers>
+          <Grid container justify="center">
+            <CircularProgress />
+          </Grid>
+        </DialogContent>
+      </Dialog>
+    );
+  }
+  if (error) {
+    return (
+      <Dialog
+        onClose={onClose}
+        aria-labelledby="customized-dialog-title"
+        open={open}
+        fullWidth
+        maxWidth="sm"
+      >
+        <DialogTitle id="customized-dialog-title" onClose={onClose}>
+          Something went wrong...
+        </DialogTitle>
+        <DialogContent dividers>
+          <p>Error: {error}</p>
+        </DialogContent>
+      </Dialog>
+    );
+  }
+
+  const comments = data?.repository?.bug?.timeline.comments as (
+    | AddCommentFragment
+    | CreateFragment
+  )[];
+  // NOTE Searching for the changed comment could be dropped if GraphQL get
+  // filter by id argument for timelineitems
+  const comment = comments.find((elem) => elem.id === commentId);
+  // Sort by most recent edit. Must create a copy of constant history as
+  // reverse() modifies inplace.
+  const history = comment?.history.slice().reverse();
+  const editCount = history?.length === undefined ? 0 : history?.length - 1;
+
+  const handleChange = (panel: string) => (
+    event: React.ChangeEvent<{}>,
+    newExpanded: boolean
+  ) => {
+    setExpanded(newExpanded ? panel : false);
+  };
+
+  const getSummary = (index: number, date: Date) => {
+    const desc =
+      index === editCount ? 'Created ' : `#${editCount - index} • Edited `;
+    const mostRecent = index === 0 ? ' (most recent)' : '';
+    return (
+      <>
+        <Tooltip title={moment(date).format('LLLL')}>
+          <span>
+            {desc}
+            <Moment date={date} format="on ll" />
+            {mostRecent}
+          </span>
+        </Tooltip>
+      </>
+    );
+  };
+
+  return (
+    <Dialog
+      onClose={onClose}
+      aria-labelledby="customized-dialog-title"
+      open={open}
+      fullWidth
+      maxWidth="md"
+    >
+      <DialogTitle id="customized-dialog-title" onClose={onClose}>
+        {`Edited ${editCount} ${editCount > 1 ? 'times' : 'time'}.`}
+      </DialogTitle>
+      <DialogContent dividers>
+        {history?.map((edit, index) => (
+          <Accordion
+            square
+            expanded={expanded === 'panel' + index}
+            onChange={handleChange('panel' + index)}
+          >
+            <AccordionSummary
+              expandIcon={<ExpandMoreIcon />}
+              aria-controls="panel1d-content"
+              id="panel1d-header"
+            >
+              <Typography>{getSummary(index, edit.date)}</Typography>
+            </AccordionSummary>
+            <AccordionDetails>{edit.message}</AccordionDetails>
+          </Accordion>
+        ))}
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+export default MessageHistoryDialog;

webui/src/pages/bug/Timeline.tsx 🔗

@@ -2,6 +2,7 @@ import React from 'react';
 
 import { makeStyles } from '@material-ui/core/styles';
 
+import { BugFragment } from './Bug.generated';
 import LabelChange from './LabelChange';
 import Message from './Message';
 import SetStatus from './SetStatus';
@@ -18,9 +19,10 @@ const useStyles = makeStyles((theme) => ({
 
 type Props = {
   ops: Array<TimelineItemFragment>;
+  bug: BugFragment;
 };
 
-function Timeline({ ops }: Props) {
+function Timeline({ bug, ops }: Props) {
   const classes = useStyles();
 
   return (
@@ -28,9 +30,9 @@ function Timeline({ ops }: Props) {
       {ops.map((op, index) => {
         switch (op.__typename) {
           case 'CreateTimelineItem':
-            return <Message key={index} op={op} />;
+            return <Message key={index} op={op} bug={bug} />;
           case 'AddCommentTimelineItem':
-            return <Message key={index} op={op} />;
+            return <Message key={index} op={op} bug={bug} />;
           case 'LabelChangeTimelineItem':
             return <LabelChange key={index} op={op} />;
           case 'SetTitleTimelineItem':

webui/src/pages/bug/TimelineQuery.tsx 🔗

@@ -2,17 +2,18 @@ import React from 'react';
 
 import CircularProgress from '@material-ui/core/CircularProgress';
 
+import { BugFragment } from './Bug.generated';
 import Timeline from './Timeline';
 import { useTimelineQuery } from './TimelineQuery.generated';
 
 type Props = {
-  id: string;
+  bug: BugFragment;
 };
 
-const TimelineQuery = ({ id }: Props) => {
+const TimelineQuery = ({ bug }: Props) => {
   const { loading, error, data } = useTimelineQuery({
     variables: {
-      id,
+      id: bug.id,
       first: 100,
     },
   });
@@ -25,7 +26,7 @@ const TimelineQuery = ({ id }: Props) => {
     return null;
   }
 
-  return <Timeline ops={nodes} />;
+  return <Timeline ops={nodes} bug={bug} />;
 };
 
 export default TimelineQuery;

webui/src/pages/list/BugRow.tsx 🔗

@@ -6,6 +6,7 @@ import TableRow from '@material-ui/core/TableRow/TableRow';
 import Tooltip from '@material-ui/core/Tooltip/Tooltip';
 import { makeStyles } from '@material-ui/core/styles';
 import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
+import CommentOutlinedIcon from '@material-ui/icons/CommentOutlined';
 import ErrorOutline from '@material-ui/icons/ErrorOutline';
 
 import Date from 'src/components/Date';
@@ -74,6 +75,13 @@ const useStyles = makeStyles((theme) => ({
       display: 'inline-block',
     },
   },
+  commentCount: {
+    fontSize: '1rem',
+    marginLeft: theme.spacing(0.5),
+  },
+  commentCountCell: {
+    display: 'inline-flex',
+  },
 }));
 
 type Props = {
@@ -82,6 +90,8 @@ type Props = {
 
 function BugRow({ bug }: Props) {
   const classes = useStyles();
+  // Subtract 1 from totalCount as 1 comment is the bug description
+  const commentCount = bug.comments.totalCount - 1;
   return (
     <TableRow hover>
       <TableCell className={classes.cell}>
@@ -105,6 +115,12 @@ function BugRow({ bug }: Props) {
             &nbsp;by {bug.author.displayName}
           </div>
         </div>
+        {commentCount > 0 && (
+          <span className={classes.commentCountCell}>
+            <CommentOutlinedIcon aria-label="Comment count" />
+            <span className={classes.commentCount}>{commentCount}</span>
+          </span>
+        )}
       </TableCell>
     </TableRow>
   );

webui/src/pages/list/Filter.tsx 🔗

@@ -65,7 +65,7 @@ function stringify(params: Query): string {
 const useStyles = makeStyles((theme) => ({
   element: {
     ...theme.typography.body2,
-    color: '#444',
+    color: theme.palette.text.secondary,
     padding: theme.spacing(0, 1),
     fontWeight: 400,
     textDecoration: 'none',
@@ -75,7 +75,7 @@ const useStyles = makeStyles((theme) => ({
   },
   itemActive: {
     fontWeight: 600,
-    color: '#333',
+    color: theme.palette.text.primary,
   },
   icon: {
     paddingRight: theme.spacing(0.5),

webui/src/pages/list/FilterToolbar.tsx 🔗

@@ -19,8 +19,8 @@ import { useBugCountQuery } from './FilterToolbar.generated';
 
 const useStyles = makeStyles((theme) => ({
   toolbar: {
-    backgroundColor: theme.palette.grey['100'],
-    borderColor: theme.palette.grey['300'],
+    backgroundColor: theme.palette.primary.light,
+    borderColor: theme.palette.divider,
     borderWidth: '1px 0',
     borderStyle: 'solid',
     margin: theme.spacing(0, -1),
@@ -40,7 +40,7 @@ function CountingFilter({ query, children, ...props }: CountingFilterProps) {
     variables: { query },
   });
 
-  var prefix;
+  let prefix;
   if (loading) prefix = '...';
   else if (error || !data?.repository) prefix = '???';
   // TODO: better prefixes & error handling

webui/src/pages/list/ListQuery.tsx 🔗

@@ -6,7 +6,7 @@ import { Button } from '@material-ui/core';
 import IconButton from '@material-ui/core/IconButton';
 import InputBase from '@material-ui/core/InputBase';
 import Paper from '@material-ui/core/Paper';
-import { fade, makeStyles, Theme } from '@material-ui/core/styles';
+import { makeStyles, Theme } from '@material-ui/core/styles';
 import ErrorOutline from '@material-ui/icons/ErrorOutline';
 import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
 import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
@@ -56,10 +56,11 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
   },
   search: {
     borderRadius: theme.shape.borderRadius,
-    borderColor: fade(theme.palette.primary.main, 0.2),
+    color: theme.palette.text.secondary,
+    borderColor: theme.palette.divider,
     borderStyle: 'solid',
     borderWidth: '1px',
-    backgroundColor: fade(theme.palette.primary.main, 0.05),
+    backgroundColor: theme.palette.primary.light,
     padding: theme.spacing(0, 1),
     width: ({ searching }) => (searching ? '20rem' : '15rem'),
     transition: theme.transitions.create([
@@ -69,13 +70,11 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
     ]),
   },
   searchFocused: {
-    borderColor: fade(theme.palette.primary.main, 0.4),
     backgroundColor: theme.palette.background.paper,
-    width: '20rem!important',
   },
   placeholderRow: {
     padding: theme.spacing(1),
-    borderBottomColor: theme.palette.grey['300'],
+    borderBottomColor: theme.palette.divider,
     borderBottomWidth: '1px',
     borderBottomStyle: 'solid',
     display: 'flex',
@@ -91,7 +90,8 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
     ...theme.typography.h5,
     padding: theme.spacing(8),
     textAlign: 'center',
-    borderBottomColor: theme.palette.grey['300'],
+    color: theme.palette.text.hint,
+    borderBottomColor: theme.palette.divider,
     borderBottomWidth: '1px',
     borderBottomStyle: 'solid',
     '& > p': {
@@ -99,21 +99,25 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
     },
   },
   errorBox: {
-    color: theme.palette.error.main,
+    color: theme.palette.error.dark,
     '& > pre': {
       fontSize: '1rem',
       textAlign: 'left',
-      backgroundColor: theme.palette.grey['900'],
-      color: theme.palette.common.white,
+      borderColor: theme.palette.divider,
+      borderWidth: '1px',
+      borderRadius: theme.shape.borderRadius,
+      borderStyle: 'solid',
+      color: theme.palette.text.primary,
       marginTop: theme.spacing(4),
       padding: theme.spacing(2, 3),
     },
   },
   greenButton: {
-    backgroundColor: '#2ea44fd9',
-    color: '#fff',
+    backgroundColor: theme.palette.success.main,
+    color: theme.palette.success.contrastText,
     '&:hover': {
-      backgroundColor: '#2ea44f',
+      backgroundColor: theme.palette.success.dark,
+      color: theme.palette.primary.contrastText,
     },
   },
 }));
@@ -319,7 +323,7 @@ function ListQuery() {
               variant="contained"
               href="/new"
             >
-              New issue
+              New bug
             </Button>
           )}
         </IfLoggedIn>

webui/src/pages/new/NewBugPage.tsx 🔗

@@ -1,10 +1,10 @@
 import React, { FormEvent, useState } from 'react';
+import { useHistory } from 'react-router-dom';
 
-import { Button } from '@material-ui/core';
-import Paper from '@material-ui/core/Paper';
-import TextField from '@material-ui/core/TextField/TextField';
-import { fade, makeStyles, Theme } from '@material-ui/core/styles';
+import { Button, Paper } from '@material-ui/core';
+import { makeStyles, Theme } from '@material-ui/core/styles';
 
+import BugTitleInput from '../../components/BugTitleForm/BugTitleInput';
 import CommentInput from '../../components/CommentInput/CommentInput';
 
 import { useNewBugMutation } from './NewBug.generated';
@@ -21,19 +21,6 @@ const useStyles = makeStyles((theme: Theme) => ({
     padding: theme.spacing(2),
     overflow: 'hidden',
   },
-  titleInput: {
-    borderRadius: theme.shape.borderRadius,
-    borderColor: fade(theme.palette.primary.main, 0.2),
-    borderStyle: 'solid',
-    borderWidth: '1px',
-    backgroundColor: fade(theme.palette.primary.main, 0.05),
-    padding: theme.spacing(0, 0),
-    transition: theme.transitions.create([
-      'width',
-      'borderColor',
-      'backgroundColor',
-    ]),
-  },
   form: {
     display: 'flex',
     flexDirection: 'column',
@@ -43,10 +30,11 @@ const useStyles = makeStyles((theme: Theme) => ({
     justifyContent: 'flex-end',
   },
   greenButton: {
-    backgroundColor: '#2ea44fd9',
-    color: '#fff',
+    backgroundColor: theme.palette.success.main,
+    color: theme.palette.success.contrastText,
     '&:hover': {
-      backgroundColor: '#2ea44f',
+      backgroundColor: theme.palette.success.dark,
+      color: theme.palette.primary.contrastText,
     },
   },
 }));
@@ -59,7 +47,9 @@ function NewBugPage() {
   const [issueTitle, setIssueTitle] = useState('');
   const [issueComment, setIssueComment] = useState('');
   const classes = useStyles();
+
   let issueTitleInput: any;
+  let history = useHistory();
 
   function submitNewIssue(e: FormEvent) {
     e.preventDefault();
@@ -71,12 +61,15 @@ function NewBugPage() {
           message: issueComment,
         },
       },
+    }).then(function (data) {
+      const id = data.data?.newBug.bug.humanId;
+      history.push('/bug/' + id);
     });
     issueTitleInput.value = '';
   }
 
   function isFormValid() {
-    return issueTitle.length > 0 && issueComment.length > 0 ? true : false;
+    return issueTitle.length > 0;
   }
 
   if (loading) return <div>Loading...</div>;
@@ -85,12 +78,11 @@ function NewBugPage() {
   return (
     <Paper className={classes.main}>
       <form className={classes.form} onSubmit={submitNewIssue}>
-        <TextField
+        <BugTitleInput
           inputRef={(node) => {
             issueTitleInput = node;
           }}
           label="Title"
-          className={classes.titleInput}
           variant="outlined"
           fullWidth
           margin="dense"
@@ -107,7 +99,7 @@ function NewBugPage() {
             type="submit"
             disabled={isFormValid() ? false : true}
           >
-            Submit new issue
+            Submit new bug
           </Button>
         </div>
       </form>

webui/src/pages/notfound/NotFoundPage.tsx 🔗

@@ -0,0 +1,52 @@
+import React from 'react';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import BackToListButton from '../../components/BackToListButton';
+
+const useStyles = makeStyles((theme) => ({
+  main: {
+    maxWidth: 1000,
+    margin: 'auto',
+    marginTop: theme.spacing(10),
+  },
+  logo: {
+    height: '350px',
+    display: 'block',
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
+  icon: {
+    display: 'block',
+    marginLeft: 'auto',
+    marginRight: 'auto',
+    fontSize: '80px',
+  },
+  backLink: {
+    marginTop: theme.spacing(1),
+    textAlign: 'center',
+  },
+  header: {
+    fontSize: '30px',
+    textAlign: 'center',
+  },
+}));
+
+function NotFoundPage() {
+  const classes = useStyles();
+  return (
+    <main className={classes.main}>
+      <h1 className={classes.header}>404 – Page not found</h1>
+      <img
+        src="/logo-alpha-flat-outline.svg"
+        className={classes.logo}
+        alt="git-bug Logo"
+      />
+      <div className={classes.backLink}>
+        <BackToListButton />
+      </div>
+    </main>
+  );
+}
+
+export default NotFoundPage;

webui/src/theme.ts 🔗

@@ -1,11 +0,0 @@
-import { createMuiTheme } from '@material-ui/core/styles';
-
-const theme = createMuiTheme({
-  palette: {
-    primary: {
-      main: '#263238',
-    },
-  },
-});
-
-export default theme;

webui/src/themes/DefaultDark.ts 🔗

@@ -0,0 +1,26 @@
+import { createMuiTheme } from '@material-ui/core/styles';
+
+const defaultDarkTheme = createMuiTheme({
+  palette: {
+    type: 'dark',
+    primary: {
+      dark: '#263238',
+      main: '#2a393e',
+      light: '#525252',
+    },
+    error: {
+      main: '#f44336',
+      dark: '#ff4949',
+    },
+    info: {
+      main: '#2a393e',
+      contrastText: '#ffffffb3',
+    },
+    success: {
+      main: '#2ea44fd9',
+      contrastText: '#fff',
+    },
+  },
+});
+
+export default defaultDarkTheme;

webui/src/themes/DefaultLight.ts 🔗

@@ -0,0 +1,26 @@
+import { createMuiTheme } from '@material-ui/core/styles';
+
+const defaultLightTheme = createMuiTheme({
+  palette: {
+    type: 'light',
+    primary: {
+      dark: '#263238',
+      main: '#5a6b73',
+      light: '#f5f5f5',
+      contrastText: '#fff',
+    },
+    info: {
+      main: '#e2f1ff',
+      contrastText: '#555',
+    },
+    success: {
+      main: '#2ea44fd9',
+      contrastText: '#fff',
+    },
+    text: {
+      secondary: '#555',
+    },
+  },
+});
+
+export default defaultLightTheme;

webui/src/themes/index.ts 🔗

@@ -0,0 +1,4 @@
+import defaultDarkTheme from './DefaultDark';
+import defaultLightTheme from './DefaultLight';
+
+export { defaultLightTheme, defaultDarkTheme };