implement media hosting in git for comments + API for the webui

Michael Muré created

Change summary

bug/bug.go                     |  25 +++++-
bug/comment.go                 |   2 
bug/label.go                   |   2 
bug/operation.go               |   6 +
bug/operations/add_comment.go  |  16 +++
bug/operations/create.go       |  15 +++
bug/operations/label_change.go |   5 +
bug/operations/set_status.go   |   5 +
bug/operations/set_title.go    |   5 +
cache/cache.go                 |  15 +++
commands/webui.go              |  16 ---
graphql/gqlgen.yml             |   2 
graphql/graph/gen_graph.go     | 135 ++++++++++++++++++++++++++++++++---
graphql/resolvers/mutation.go  |   9 +-
graphql/schema.graphql         |  10 ++
termui/show_bug.go             |   2 
util/hash.go                   |  40 ++++++++++
17 files changed, 265 insertions(+), 45 deletions(-)

Detailed changes

bug/bug.go 🔗

@@ -259,7 +259,7 @@ func (bug *Bug) Append(op Operation) {
 // Write the staging area in Git and move the operations to the packs
 func (bug *Bug) Commit(repo repository.Repo) error {
 	if bug.staging.IsEmpty() {
-		return fmt.Errorf("can't commit an empty bug")
+		return fmt.Errorf("can't commit a bug with no pending operation")
 	}
 
 	// Write the Ops as a Git blob containing the serialized array
@@ -272,14 +272,31 @@ func (bug *Bug) Commit(repo repository.Repo) error {
 		bug.rootPack = hash
 	}
 
-	// Write a Git tree referencing this blob
-	hash, err = repo.StoreTree([]repository.TreeEntry{
+	// Make a Git tree referencing this blob and all needed files
+	tree := []repository.TreeEntry{
 		// the last pack of ops
 		{ObjectType: repository.Blob, Hash: hash, Name: opsEntryName},
 		// always the first pack of ops (might be the same)
 		{ObjectType: repository.Blob, Hash: bug.rootPack, Name: rootEntryName},
-	})
+	}
+
+	counter := 0
+	added := make(map[util.Hash]interface{})
+	for _, ops := range bug.staging.Operations {
+		for _, file := range ops.Files() {
+			if _, has := added[file]; !has {
+				tree = append(tree, repository.TreeEntry{
+					ObjectType: repository.Blob,
+					Hash:       file,
+					Name:       fmt.Sprintf("file%d", counter),
+				})
+				counter++
+				added[file] = struct{}{}
+			}
+		}
+	}
 
+	hash, err = repo.StoreTree(tree)
 	if err != nil {
 		return err
 	}

bug/comment.go 🔗

@@ -1,6 +1,7 @@
 package bug
 
 import (
+	"github.com/MichaelMure/git-bug/util"
 	"github.com/dustin/go-humanize"
 	"time"
 )
@@ -8,6 +9,7 @@ import (
 type Comment struct {
 	Author  Person
 	Message string
+	Files   []util.Hash
 
 	// Creation time of the comment.
 	// Should be used only for human display, never for ordering as we can't rely on it in a distributed system.

bug/label.go 🔗

@@ -11,7 +11,7 @@ func (l Label) String() string {
 	return string(l)
 }
 
-// UnmarshalGQL implements the graphql.Marshaler interface
+// UnmarshalGQL implements the graphql.Unmarshaler interface
 func (l *Label) UnmarshalGQL(v interface{}) error {
 	_, ok := v.(string)
 	if !ok {

bug/operation.go 🔗

@@ -1,6 +1,9 @@
 package bug
 
-import "time"
+import (
+	"github.com/MichaelMure/git-bug/util"
+	"time"
+)
 
 type OperationType int
 
@@ -17,6 +20,7 @@ type Operation interface {
 	OpType() OperationType
 	Time() time.Time
 	Apply(snapshot Snapshot) Snapshot
+	Files() []util.Hash
 
 	// TODO: data validation (ex: a title is a single line)
 	// Validate() bool

bug/operations/add_comment.go 🔗

@@ -2,6 +2,7 @@ package operations
 
 import (
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/util"
 )
 
 // AddCommentOperation will add a new comment in the bug
@@ -11,12 +12,14 @@ var _ bug.Operation = AddCommentOperation{}
 type AddCommentOperation struct {
 	bug.OpBase
 	Message string
+	files   []util.Hash
 }
 
 func (op AddCommentOperation) Apply(snapshot bug.Snapshot) bug.Snapshot {
 	comment := bug.Comment{
 		Message:  op.Message,
 		Author:   op.Author,
+		Files:    op.files,
 		UnixTime: op.UnixTime,
 	}
 
@@ -25,15 +28,24 @@ func (op AddCommentOperation) Apply(snapshot bug.Snapshot) bug.Snapshot {
 	return snapshot
 }
 
-func NewAddCommentOp(author bug.Person, message string) AddCommentOperation {
+func (op AddCommentOperation) Files() []util.Hash {
+	return op.files
+}
+
+func NewAddCommentOp(author bug.Person, message string, files []util.Hash) AddCommentOperation {
 	return AddCommentOperation{
 		OpBase:  bug.NewOpBase(bug.AddCommentOp, author),
 		Message: message,
+		files:   files,
 	}
 }
 
 // Convenience function to apply the operation
 func Comment(b *bug.Bug, author bug.Person, message string) {
-	addCommentOp := NewAddCommentOp(author, message)
+	CommentWithFiles(b, author, message, nil)
+}
+
+func CommentWithFiles(b *bug.Bug, author bug.Person, message string, files []util.Hash) {
+	addCommentOp := NewAddCommentOp(author, message, files)
 	b.Append(addCommentOp)
 }

bug/operations/create.go 🔗

@@ -2,6 +2,7 @@ package operations
 
 import (
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/util"
 )
 
 // CreateOperation define the initial creation of a bug
@@ -12,6 +13,7 @@ type CreateOperation struct {
 	bug.OpBase
 	Title   string
 	Message string
+	files   []util.Hash
 }
 
 func (op CreateOperation) Apply(snapshot bug.Snapshot) bug.Snapshot {
@@ -28,18 +30,27 @@ func (op CreateOperation) Apply(snapshot bug.Snapshot) bug.Snapshot {
 	return snapshot
 }
 
-func NewCreateOp(author bug.Person, title, message string) CreateOperation {
+func (op CreateOperation) Files() []util.Hash {
+	return op.files
+}
+
+func NewCreateOp(author bug.Person, title, message string, files []util.Hash) CreateOperation {
 	return CreateOperation{
 		OpBase:  bug.NewOpBase(bug.CreateOp, author),
 		Title:   title,
 		Message: message,
+		files:   files,
 	}
 }
 
 // Convenience function to apply the operation
 func Create(author bug.Person, title, message string) (*bug.Bug, error) {
+	return CreateWithFiles(author, title, message, nil)
+}
+
+func CreateWithFiles(author bug.Person, title, message string, files []util.Hash) (*bug.Bug, error) {
 	newBug := bug.NewBug()
-	createOp := NewCreateOp(author, title, message)
+	createOp := NewCreateOp(author, title, message, files)
 	newBug.Append(createOp)
 
 	return newBug, nil

bug/operations/label_change.go 🔗

@@ -3,6 +3,7 @@ package operations
 import (
 	"fmt"
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/util"
 	"io"
 	"io/ioutil"
 	"sort"
@@ -50,6 +51,10 @@ AddLoop:
 	return snapshot
 }
 
+func (op LabelChangeOperation) Files() []util.Hash {
+	return nil
+}
+
 func NewLabelChangeOperation(author bug.Person, added, removed []bug.Label) LabelChangeOperation {
 	return LabelChangeOperation{
 		OpBase:  bug.NewOpBase(bug.LabelChangeOp, author),

bug/operations/set_status.go 🔗

@@ -2,6 +2,7 @@ package operations
 
 import (
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/util"
 )
 
 // SetStatusOperation will change the status of a bug
@@ -19,6 +20,10 @@ func (op SetStatusOperation) Apply(snapshot bug.Snapshot) bug.Snapshot {
 	return snapshot
 }
 
+func (op SetStatusOperation) Files() []util.Hash {
+	return nil
+}
+
 func NewSetStatusOp(author bug.Person, status bug.Status) SetStatusOperation {
 	return SetStatusOperation{
 		OpBase: bug.NewOpBase(bug.SetStatusOp, author),

bug/operations/set_title.go 🔗

@@ -2,6 +2,7 @@ package operations
 
 import (
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/util"
 )
 
 // SetTitleOperation will change the title of a bug
@@ -19,6 +20,10 @@ func (op SetTitleOperation) Apply(snapshot bug.Snapshot) bug.Snapshot {
 	return snapshot
 }
 
+func (op SetTitleOperation) Files() []util.Hash {
+	return nil
+}
+
 func NewSetTitleOp(author bug.Person, title string) SetTitleOperation {
 	return SetTitleOperation{
 		OpBase: bug.NewOpBase(bug.SetTitleOp, author),

cache/cache.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/bug/operations"
 	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/util"
 )
 
 type Cacher interface {
@@ -26,6 +27,7 @@ type RepoCacher interface {
 
 	// Mutations
 	NewBug(title string, message string) (BugCacher, error)
+	NewBugWithFiles(title string, message string, files []util.Hash) (BugCacher, error)
 }
 
 type BugCacher interface {
@@ -34,6 +36,7 @@ type BugCacher interface {
 
 	// Mutations
 	AddComment(message string) error
+	AddCommentWithFiles(message string, files []util.Hash) error
 	ChangeLabels(added []string, removed []string) error
 	Open() error
 	Close() error
@@ -159,12 +162,16 @@ func (c *RepoCache) ClearAllBugs() {
 }
 
 func (c *RepoCache) NewBug(title string, message string) (BugCacher, error) {
+	return c.NewBugWithFiles(title, message, nil)
+}
+
+func (c *RepoCache) NewBugWithFiles(title string, message string, files []util.Hash) (BugCacher, error) {
 	author, err := bug.GetUser(c.repo)
 	if err != nil {
 		return nil, err
 	}
 
-	b, err := operations.Create(author, title, message)
+	b, err := operations.CreateWithFiles(author, title, message, files)
 	if err != nil {
 		return nil, err
 	}
@@ -208,12 +215,16 @@ func (c *BugCache) ClearSnapshot() {
 }
 
 func (c *BugCache) AddComment(message string) error {
+	return c.AddCommentWithFiles(message, nil)
+}
+
+func (c *BugCache) AddCommentWithFiles(message string, files []util.Hash) error {
 	author, err := bug.GetUser(c.repo)
 	if err != nil {
 		return err
 	}
 
-	operations.Comment(c.bug, author, message)
+	operations.CommentWithFiles(c.bug, author, message, files)
 
 	// TODO: perf --> the snapshot could simply be updated with the new op
 	c.ClearSnapshot()

commands/webui.go 🔗

@@ -64,9 +64,9 @@ func newGitFileHandler(repo repository.Repo) http.Handler {
 }
 
 func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
-	hash := mux.Vars(r)["hash"]
+	hash := util.Hash(mux.Vars(r)["hash"])
 
-	if !isGitHash(hash) {
+	if !hash.IsValid() {
 		http.Error(rw, "invalid git hash", http.StatusBadRequest)
 		return
 	}
@@ -144,18 +144,6 @@ func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Requ
 	rw.Write(js)
 }
 
-func isGitHash(s string) bool {
-	if len(s) != 40 {
-		return false
-	}
-	for _, r := range s {
-		if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
-			return false
-		}
-	}
-	return true
-}
-
 var webUICmd = &cobra.Command{
 	Use:   "webui",
 	Short: "Launch the web UI",

graphql/gqlgen.yml 🔗

@@ -17,6 +17,8 @@ models:
     model: github.com/MichaelMure/git-bug/bug.Person
   Label:
     model: github.com/MichaelMure/git-bug/bug.Label
+  Hash:
+    model: github.com/MichaelMure/git-bug/util.Hash
   Operation:
     model: github.com/MichaelMure/git-bug/bug.Operation
   CreateOperation:

graphql/graph/gen_graph.go 🔗

@@ -12,6 +12,7 @@ import (
 	bug "github.com/MichaelMure/git-bug/bug"
 	operations "github.com/MichaelMure/git-bug/bug/operations"
 	models "github.com/MichaelMure/git-bug/graphql/models"
+	util "github.com/MichaelMure/git-bug/util"
 	graphql "github.com/vektah/gqlgen/graphql"
 	introspection "github.com/vektah/gqlgen/neelance/introspection"
 	query "github.com/vektah/gqlgen/neelance/query"
@@ -40,8 +41,8 @@ type Resolvers interface {
 
 	LabelChangeOperation_date(ctx context.Context, obj *operations.LabelChangeOperation) (time.Time, error)
 
-	Mutation_newBug(ctx context.Context, repoRef *string, title string, message string) (bug.Snapshot, error)
-	Mutation_addComment(ctx context.Context, repoRef *string, prefix string, message string) (bug.Snapshot, error)
+	Mutation_newBug(ctx context.Context, repoRef *string, title string, message string, files []util.Hash) (bug.Snapshot, error)
+	Mutation_addComment(ctx context.Context, repoRef *string, prefix string, message string, files []util.Hash) (bug.Snapshot, error)
 	Mutation_changeLabels(ctx context.Context, repoRef *string, prefix string, added []string, removed []string) (bug.Snapshot, error)
 	Mutation_open(ctx context.Context, repoRef *string, prefix string) (bug.Snapshot, error)
 	Mutation_close(ctx context.Context, repoRef *string, prefix string) (bug.Snapshot, error)
@@ -87,8 +88,8 @@ type LabelChangeOperationResolver interface {
 	Date(ctx context.Context, obj *operations.LabelChangeOperation) (time.Time, error)
 }
 type MutationResolver interface {
-	NewBug(ctx context.Context, repoRef *string, title string, message string) (bug.Snapshot, error)
-	AddComment(ctx context.Context, repoRef *string, prefix string, message string) (bug.Snapshot, error)
+	NewBug(ctx context.Context, repoRef *string, title string, message string, files []util.Hash) (bug.Snapshot, error)
+	AddComment(ctx context.Context, repoRef *string, prefix string, message string, files []util.Hash) (bug.Snapshot, error)
 	ChangeLabels(ctx context.Context, repoRef *string, prefix string, added []string, removed []string) (bug.Snapshot, error)
 	Open(ctx context.Context, repoRef *string, prefix string) (bug.Snapshot, error)
 	Close(ctx context.Context, repoRef *string, prefix string) (bug.Snapshot, error)
@@ -139,12 +140,12 @@ func (s shortMapper) LabelChangeOperation_date(ctx context.Context, obj *operati
 	return s.r.LabelChangeOperation().Date(ctx, obj)
 }
 
-func (s shortMapper) Mutation_newBug(ctx context.Context, repoRef *string, title string, message string) (bug.Snapshot, error) {
-	return s.r.Mutation().NewBug(ctx, repoRef, title, message)
+func (s shortMapper) Mutation_newBug(ctx context.Context, repoRef *string, title string, message string, files []util.Hash) (bug.Snapshot, error) {
+	return s.r.Mutation().NewBug(ctx, repoRef, title, message, files)
 }
 
-func (s shortMapper) Mutation_addComment(ctx context.Context, repoRef *string, prefix string, message string) (bug.Snapshot, error) {
-	return s.r.Mutation().AddComment(ctx, repoRef, prefix, message)
+func (s shortMapper) Mutation_addComment(ctx context.Context, repoRef *string, prefix string, message string, files []util.Hash) (bug.Snapshot, error) {
+	return s.r.Mutation().AddComment(ctx, repoRef, prefix, message, files)
 }
 
 func (s shortMapper) Mutation_changeLabels(ctx context.Context, repoRef *string, prefix string, added []string, removed []string) (bug.Snapshot, error) {
@@ -264,6 +265,8 @@ func (ec *executionContext) _AddCommentOperation(ctx context.Context, sel []quer
 			out.Values[i] = ec._AddCommentOperation_date(ctx, field, obj)
 		case "message":
 			out.Values[i] = ec._AddCommentOperation_message(ctx, field, obj)
+		case "files":
+			out.Values[i] = ec._AddCommentOperation_files(ctx, field, obj)
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -324,6 +327,26 @@ func (ec *executionContext) _AddCommentOperation_message(ctx context.Context, fi
 	return graphql.MarshalString(res)
 }
 
+func (ec *executionContext) _AddCommentOperation_files(ctx context.Context, field graphql.CollectedField, obj *operations.AddCommentOperation) graphql.Marshaler {
+	rctx := graphql.GetResolverContext(ctx)
+	rctx.Object = "AddCommentOperation"
+	rctx.Args = nil
+	rctx.Field = field
+	rctx.PushField(field.Alias)
+	defer rctx.Pop()
+	res := obj.Files()
+	arr1 := graphql.Array{}
+	for idx1 := range res {
+		arr1 = append(arr1, func() graphql.Marshaler {
+			rctx := graphql.GetResolverContext(ctx)
+			rctx.PushIndex(idx1)
+			defer rctx.Pop()
+			return res[idx1]
+		}())
+	}
+	return arr1
+}
+
 var bugImplementors = []string{"Bug"}
 
 // nolint: gocyclo, errcheck, gas, goconst
@@ -818,6 +841,8 @@ func (ec *executionContext) _Comment(ctx context.Context, sel []query.Selection,
 			out.Values[i] = ec._Comment_author(ctx, field, obj)
 		case "message":
 			out.Values[i] = ec._Comment_message(ctx, field, obj)
+		case "files":
+			out.Values[i] = ec._Comment_files(ctx, field, obj)
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -848,6 +873,26 @@ func (ec *executionContext) _Comment_message(ctx context.Context, field graphql.
 	return graphql.MarshalString(res)
 }
 
+func (ec *executionContext) _Comment_files(ctx context.Context, field graphql.CollectedField, obj *bug.Comment) graphql.Marshaler {
+	rctx := graphql.GetResolverContext(ctx)
+	rctx.Object = "Comment"
+	rctx.Args = nil
+	rctx.Field = field
+	rctx.PushField(field.Alias)
+	defer rctx.Pop()
+	res := obj.Files
+	arr1 := graphql.Array{}
+	for idx1 := range res {
+		arr1 = append(arr1, func() graphql.Marshaler {
+			rctx := graphql.GetResolverContext(ctx)
+			rctx.PushIndex(idx1)
+			defer rctx.Pop()
+			return res[idx1]
+		}())
+	}
+	return arr1
+}
+
 var commentConnectionImplementors = []string{"CommentConnection"}
 
 // nolint: gocyclo, errcheck, gas, goconst
@@ -1007,6 +1052,8 @@ func (ec *executionContext) _CreateOperation(ctx context.Context, sel []query.Se
 			out.Values[i] = ec._CreateOperation_title(ctx, field, obj)
 		case "message":
 			out.Values[i] = ec._CreateOperation_message(ctx, field, obj)
+		case "files":
+			out.Values[i] = ec._CreateOperation_files(ctx, field, obj)
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -1078,6 +1125,26 @@ func (ec *executionContext) _CreateOperation_message(ctx context.Context, field
 	return graphql.MarshalString(res)
 }
 
+func (ec *executionContext) _CreateOperation_files(ctx context.Context, field graphql.CollectedField, obj *operations.CreateOperation) graphql.Marshaler {
+	rctx := graphql.GetResolverContext(ctx)
+	rctx.Object = "CreateOperation"
+	rctx.Args = nil
+	rctx.Field = field
+	rctx.PushField(field.Alias)
+	defer rctx.Pop()
+	res := obj.Files()
+	arr1 := graphql.Array{}
+	for idx1 := range res {
+		arr1 = append(arr1, func() graphql.Marshaler {
+			rctx := graphql.GetResolverContext(ctx)
+			rctx.PushIndex(idx1)
+			defer rctx.Pop()
+			return res[idx1]
+		}())
+	}
+	return arr1
+}
+
 var labelChangeOperationImplementors = []string{"LabelChangeOperation", "Operation", "Authored"}
 
 // nolint: gocyclo, errcheck, gas, goconst
@@ -1264,6 +1331,25 @@ func (ec *executionContext) _Mutation_newBug(ctx context.Context, field graphql.
 		}
 	}
 	args["message"] = arg2
+	var arg3 []util.Hash
+	if tmp, ok := field.Args["files"]; ok {
+		var err error
+		var rawIf1 []interface{}
+		if tmp != nil {
+			if tmp1, ok := tmp.([]interface{}); ok {
+				rawIf1 = tmp1
+			}
+		}
+		arg3 = make([]util.Hash, len(rawIf1))
+		for idx1 := range rawIf1 {
+			err = (&arg3[idx1]).UnmarshalGQL(rawIf1[idx1])
+		}
+		if err != nil {
+			ec.Error(ctx, err)
+			return graphql.Null
+		}
+	}
+	args["files"] = arg3
 	rctx := graphql.GetResolverContext(ctx)
 	rctx.Object = "Mutation"
 	rctx.Args = args
@@ -1271,7 +1357,7 @@ func (ec *executionContext) _Mutation_newBug(ctx context.Context, field graphql.
 	rctx.PushField(field.Alias)
 	defer rctx.Pop()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) {
-		return ec.resolvers.Mutation_newBug(ctx, args["repoRef"].(*string), args["title"].(string), args["message"].(string))
+		return ec.resolvers.Mutation_newBug(ctx, args["repoRef"].(*string), args["title"].(string), args["message"].(string), args["files"].([]util.Hash))
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -1321,6 +1407,25 @@ func (ec *executionContext) _Mutation_addComment(ctx context.Context, field grap
 		}
 	}
 	args["message"] = arg2
+	var arg3 []util.Hash
+	if tmp, ok := field.Args["files"]; ok {
+		var err error
+		var rawIf1 []interface{}
+		if tmp != nil {
+			if tmp1, ok := tmp.([]interface{}); ok {
+				rawIf1 = tmp1
+			}
+		}
+		arg3 = make([]util.Hash, len(rawIf1))
+		for idx1 := range rawIf1 {
+			err = (&arg3[idx1]).UnmarshalGQL(rawIf1[idx1])
+		}
+		if err != nil {
+			ec.Error(ctx, err)
+			return graphql.Null
+		}
+	}
+	args["files"] = arg3
 	rctx := graphql.GetResolverContext(ctx)
 	rctx.Object = "Mutation"
 	rctx.Args = args
@@ -1328,7 +1433,7 @@ func (ec *executionContext) _Mutation_addComment(ctx context.Context, field grap
 	rctx.PushField(field.Alias)
 	defer rctx.Pop()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) {
-		return ec.resolvers.Mutation_addComment(ctx, args["repoRef"].(*string), args["prefix"].(string), args["message"].(string))
+		return ec.resolvers.Mutation_addComment(ctx, args["repoRef"].(*string), args["prefix"].(string), args["message"].(string), args["files"].([]util.Hash))
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -3108,6 +3213,7 @@ func (ec *executionContext) introspectType(name string) *introspection.Type {
 
 var parsedSchema = schema.MustParse(`scalar Time
 scalar Label
+scalar Hash
 
 # Information about pagination in a connection.
 type PageInfo {
@@ -3149,6 +3255,9 @@ type Comment implements Authored {
 
   # The message of this comment.
   message: String!
+
+  # All media's hash referenced in this comment
+  files: [Hash!]!
 }
 
 enum Status {
@@ -3188,6 +3297,7 @@ type CreateOperation implements Operation, Authored {
 
   title: String!
   message: String!
+  files: [Hash!]!
 }
 
 type SetTitleOperation implements Operation, Authored {
@@ -3202,6 +3312,7 @@ type AddCommentOperation implements Operation, Authored {
   date: Time!
 
   message: String!
+  files: [Hash!]!
 }
 
 type SetStatusOperation implements Operation, Authored {
@@ -3291,9 +3402,9 @@ type Query {
 }
 
 type Mutation {
-  newBug(repoRef: String, title: String!, message: String!): Bug!
+  newBug(repoRef: String, title: String!, message: String!, files: [Hash!]): Bug!
 
-  addComment(repoRef: String, prefix: String!, message: String!): Bug!
+  addComment(repoRef: String, prefix: String!, message: String!, files: [Hash!]): Bug!
   changeLabels(repoRef: String, prefix: String!, added: [String!], removed: [String!]): Bug!
   open(repoRef: String, prefix: String!): Bug!
   close(repoRef: String, prefix: String!): Bug!

graphql/resolvers/mutation.go 🔗

@@ -5,6 +5,7 @@ import (
 
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/util"
 )
 
 type mutationResolver struct {
@@ -19,13 +20,13 @@ func (r mutationResolver) getRepo(repoRef *string) (cache.RepoCacher, error) {
 	return r.cache.DefaultRepo()
 }
 
-func (r mutationResolver) NewBug(ctx context.Context, repoRef *string, title string, message string) (bug.Snapshot, error) {
+func (r mutationResolver) NewBug(ctx context.Context, repoRef *string, title string, message string, files []util.Hash) (bug.Snapshot, error) {
 	repo, err := r.getRepo(repoRef)
 	if err != nil {
 		return bug.Snapshot{}, err
 	}
 
-	b, err := repo.NewBug(title, message)
+	b, err := repo.NewBugWithFiles(title, message, files)
 	if err != nil {
 		return bug.Snapshot{}, err
 	}
@@ -56,7 +57,7 @@ func (r mutationResolver) Commit(ctx context.Context, repoRef *string, prefix st
 	return *snap, nil
 }
 
-func (r mutationResolver) AddComment(ctx context.Context, repoRef *string, prefix string, message string) (bug.Snapshot, error) {
+func (r mutationResolver) AddComment(ctx context.Context, repoRef *string, prefix string, message string, files []util.Hash) (bug.Snapshot, error) {
 	repo, err := r.getRepo(repoRef)
 	if err != nil {
 		return bug.Snapshot{}, err
@@ -67,7 +68,7 @@ func (r mutationResolver) AddComment(ctx context.Context, repoRef *string, prefi
 		return bug.Snapshot{}, err
 	}
 
-	err = b.AddComment(message)
+	err = b.AddCommentWithFiles(message, files)
 	if err != nil {
 		return bug.Snapshot{}, err
 	}

graphql/schema.graphql 🔗

@@ -1,5 +1,6 @@
 scalar Time
 scalar Label
+scalar Hash
 
 # Information about pagination in a connection.
 type PageInfo {
@@ -41,6 +42,9 @@ type Comment implements Authored {
 
   # The message of this comment.
   message: String!
+
+  # All media's hash referenced in this comment
+  files: [Hash!]!
 }
 
 enum Status {
@@ -80,6 +84,7 @@ type CreateOperation implements Operation, Authored {
 
   title: String!
   message: String!
+  files: [Hash!]!
 }
 
 type SetTitleOperation implements Operation, Authored {
@@ -94,6 +99,7 @@ type AddCommentOperation implements Operation, Authored {
   date: Time!
 
   message: String!
+  files: [Hash!]!
 }
 
 type SetStatusOperation implements Operation, Authored {
@@ -183,9 +189,9 @@ type Query {
 }
 
 type Mutation {
-  newBug(repoRef: String, title: String!, message: String!): Bug!
+  newBug(repoRef: String, title: String!, message: String!, files: [Hash!]): Bug!
 
-  addComment(repoRef: String, prefix: String!, message: String!): Bug!
+  addComment(repoRef: String, prefix: String!, message: String!, files: [Hash!]): Bug!
   changeLabels(repoRef: String, prefix: String!, added: [String!], removed: [String!]): Bug!
   open(repoRef: String, prefix: String!): Bug!
   close(repoRef: String, prefix: String!): Bug!

termui/show_bug.go 🔗

@@ -64,7 +64,7 @@ func (sb *showBug) layout(g *gocui.Gui) error {
 		v.Frame = false
 		v.BgColor = gocui.ColorBlue
 
-		fmt.Fprintf(v, "[q] Return [c] Add comment [t] Change title")
+		fmt.Fprintf(v, "[q] Return [c] Comment [t] Change title")
 	}
 
 	_, err = g.SetCurrentView(showBugView)

util/hash.go 🔗

@@ -1,3 +1,43 @@
 package util
 
+import (
+	"fmt"
+	"io"
+)
+
 type Hash string
+
+func (h Hash) String() string {
+	return string(h)
+}
+
+func (h *Hash) UnmarshalGQL(v interface{}) error {
+	_, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("labels must be strings")
+	}
+
+	*h = v.(Hash)
+
+	if !h.IsValid() {
+		return fmt.Errorf("invalid hash")
+	}
+
+	return nil
+}
+
+func (h Hash) MarshalGQL(w io.Writer) {
+	w.Write([]byte(`"` + h.String() + `"`))
+}
+
+func (h *Hash) IsValid() bool {
+	if len(*h) != 40 {
+		return false
+	}
+	for _, r := range *h {
+		if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
+			return false
+		}
+	}
+	return true
+}