Merge pull request #602 from GlancingMind/upstream-4-interface-to-edit-issues

Michael Muré created

WebUI: Edit comments and inspect edit history

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 
go.sum                                             |   2 
webui/src/components/CommentInput/CommentInput.tsx |   5 
webui/src/pages/bug/Bug.tsx                        |   3 
webui/src/pages/bug/CommentForm.tsx                |   1 
webui/src/pages/bug/EditCommentForm.graphql        |  16 
webui/src/pages/bug/EditCommentForm.tsx            | 123 +++++
webui/src/pages/bug/Message.tsx                    | 131 +++++
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 
18 files changed, 956 insertions(+), 21 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"""

go.sum 🔗

@@ -535,6 +535,7 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
 golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -687,6 +688,7 @@ golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200825202427-b303f430e36d h1:W07d4xkoAUSNOkOzdzXCdFGxT7o2rW4q8M34tB2i//k=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

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/pages/bug/Bug.tsx 🔗

@@ -65,6 +65,7 @@ const useStyles = makeStyles((theme) => ({
     ...theme.typography.body2,
   },
   commentForm: {
+    marginTop: theme.spacing(2),
     marginLeft: 48,
   },
 }));
@@ -83,7 +84,7 @@ function Bug({ bug }: Props) {
       </div>
       <div className={classes.container}>
         <div className={classes.timeline}>
-          <TimelineQuery id={bug.id} />
+          <TimelineQuery bug={bug} />
           <IfLoggedIn>
             {() => (
               <div className={classes.commentForm}>

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: {},

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: {
@@ -51,30 +59,133 @@ const useStyles = makeStyles((theme) => ({
     ...theme.typography.body2,
     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;