Merge pull request #835 from MichaelMure/op-base

Michael Muré created

entity/dag: proper base operation for simplified implementation

Change summary

.github/workflows/go.yml                 |   4 
api/graphql/connections/connections.go   |   2 
api/graphql/connections/gen_operation.go |  12 
api/graphql/gen_graphql.go               |   2 
api/graphql/gqlgen.yml                   |   2 
api/graphql/graph/gen_graph.go           | 667 +++++++++++++++++++------
api/graphql/models/gen_models.go         |   5 
api/graphql/models/lazy_bug.go           |   7 
api/graphql/resolvers/bug.go             |   5 
bridge/github/export.go                  |   3 
bridge/github/export_test.go             |   4 
bridge/github/import_test.go             |  19 
bridge/gitlab/export.go                  |   3 
bridge/gitlab/export_test.go             |   7 
bridge/gitlab/import_test.go             |   9 
bridge/jira/export.go                    |   3 
bridge/jira/import.go                    |   3 
bug/bug.go                               |  16 
bug/comment.go                           |   2 
bug/interface.go                         |  29 
bug/op_add_comment.go                    |  68 -
bug/op_add_comment_test.go               |  33 -
bug/op_create.go                         | 103 ---
bug/op_create_test.go                    |  30 
bug/op_edit_comment.go                   |  70 --
bug/op_edit_comment_test.go              |  30 
bug/op_label_change.go                   |  81 +--
bug/op_label_change_test.go              |  37 -
bug/op_noop.go                           |  77 ---
bug/op_noop_test.go                      |  39 -
bug/op_set_metadata.go                   | 105 ---
bug/op_set_metadata_test.go              | 126 ----
bug/op_set_status.go                     |  61 -
bug/op_set_status_test.go                |  31 -
bug/op_set_title.go                      |  64 -
bug/op_set_title_test.go                 |  31 -
bug/operation.go                         | 240 ---------
bug/operation_test.go                    |  11 
bug/snapshot.go                          |  11 
bug/with_snapshot.go                     |   9 
cache/bug_cache.go                       | 105 ---
cache/repo_cache.go                      |   6 
cache/repo_cache_bug.go                  |   6 
cache/repo_cache_common.go               |   4 
entity/dag/common_test.go                |  77 +-
entity/dag/entity.go                     |   2 
entity/dag/entity_test.go                |   2 
entity/dag/example_test.go               | 142 +---
entity/dag/op_noop.go                    |  39 +
entity/dag/op_noop_test.go               |  13 
entity/dag/op_set_metadata.go            |  68 ++
entity/dag/op_set_metadata_test.go       | 106 ++++
entity/dag/operation.go                  | 232 ++++++++
entity/dag/operation_pack.go             |   7 
entity/dag/operation_pack_test.go        |  62 -
entity/dag/operation_testing.go          |  57 ++
go.mod                                   |  78 ++
go.sum                                   |  51 -
misc/random_bugs/create_random_bugs.go   |  11 
repository/gogit.go                      |   2 
repository/repo.go                       |   4 
webui/debug_assets.go                    |   2 
webui/pack_webui.go                      |   2 
webui/packed_assets.go                   |   2 
64 files changed, 1,511 insertions(+), 1,630 deletions(-)

Detailed changes

.github/workflows/go.yml 🔗

@@ -12,7 +12,7 @@ jobs:
 
     strategy:
       matrix:
-        go-version: [1.16.x]
+        go-version: [1.18.x]
         platform: [ubuntu-latest, macos-latest, windows-latest]
 
     runs-on: ${{ matrix.platform }}
@@ -46,7 +46,7 @@ jobs:
       - name: Install Go
         uses: actions/setup-go@v2
         with:
-          go-version: 1.16.x
+          go-version: 1.18.x
 
       - name: Checkout code
         uses: actions/checkout@v2

api/graphql/connections/connections.go 🔗

@@ -1,7 +1,7 @@
 //go:generate genny -in=connection_template.go -out=gen_lazy_bug.go gen "Name=LazyBug NodeType=entity.Id EdgeType=LazyBugEdge ConnectionType=models.BugConnection"
 //go:generate genny -in=connection_template.go -out=gen_lazy_identity.go gen "Name=LazyIdentity NodeType=entity.Id EdgeType=LazyIdentityEdge ConnectionType=models.IdentityConnection"
 //go:generate genny -in=connection_template.go -out=gen_identity.go gen "Name=Identity NodeType=models.IdentityWrapper EdgeType=models.IdentityEdge ConnectionType=models.IdentityConnection"
-//go:generate genny -in=connection_template.go -out=gen_operation.go gen "Name=Operation NodeType=bug.Operation EdgeType=models.OperationEdge ConnectionType=models.OperationConnection"
+//go:generate genny -in=connection_template.go -out=gen_operation.go gen "Name=Operation NodeType=dag.Operation EdgeType=models.OperationEdge ConnectionType=models.OperationConnection"
 //go:generate genny -in=connection_template.go -out=gen_comment.go gen "Name=Comment NodeType=bug.Comment EdgeType=models.CommentEdge ConnectionType=models.CommentConnection"
 //go:generate genny -in=connection_template.go -out=gen_timeline.go gen "Name=TimelineItem NodeType=bug.TimelineItem EdgeType=models.TimelineItemEdge ConnectionType=models.TimelineItemConnection"
 //go:generate genny -in=connection_template.go -out=gen_label.go gen "Name=Label NodeType=bug.Label EdgeType=models.LabelEdge ConnectionType=models.LabelConnection"

api/graphql/connections/gen_operation.go 🔗

@@ -8,23 +8,23 @@ import (
 	"fmt"
 
 	"github.com/MichaelMure/git-bug/api/graphql/models"
-	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/entity/dag"
 )
 
-// BugOperationEdgeMaker define a function that take a bug.Operation and an offset and
+// DagOperationEdgeMaker define a function that take a dag.Operation and an offset and
 // create an Edge.
-type OperationEdgeMaker func(value bug.Operation, offset int) Edge
+type OperationEdgeMaker func(value dag.Operation, offset int) Edge
 
 // OperationConMaker define a function that create a models.OperationConnection
 type OperationConMaker func(
 	edges []*models.OperationEdge,
-	nodes []bug.Operation,
+	nodes []dag.Operation,
 	info *models.PageInfo,
 	totalCount int) (*models.OperationConnection, error)
 
 // OperationCon will paginate a source according to the input of a relay connection
-func OperationCon(source []bug.Operation, edgeMaker OperationEdgeMaker, conMaker OperationConMaker, input models.ConnectionInput) (*models.OperationConnection, error) {
-	var nodes []bug.Operation
+func OperationCon(source []dag.Operation, edgeMaker OperationEdgeMaker, conMaker OperationConMaker, input models.ConnectionInput) (*models.OperationConnection, error) {
+	var nodes []dag.Operation
 	var edges []*models.OperationEdge
 	var cursors []string
 	var pageInfo = &models.PageInfo{}

api/graphql/gqlgen.yml 🔗

@@ -33,7 +33,7 @@ models:
   Hash:
     model: github.com/MichaelMure/git-bug/repository.Hash
   Operation:
-    model: github.com/MichaelMure/git-bug/bug.Operation
+    model: github.com/MichaelMure/git-bug/entity/dag.Operation
   CreateOperation:
     model: github.com/MichaelMure/git-bug/bug.CreateOperation
   SetTitleOperation:

api/graphql/graph/gen_graph.go 🔗

@@ -17,6 +17,7 @@ import (
 	"github.com/99designs/gqlgen/graphql/introspection"
 	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/repository"
 	gqlparser "github.com/vektah/gqlparser/v2"
 	"github.com/vektah/gqlparser/v2/ast"
@@ -1902,6 +1903,17 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
 	rc := graphql.GetOperationContext(ctx)
 	ec := executionContext{rc, e}
+	inputUnmarshalMap := graphql.BuildUnmarshalerMap(
+		ec.unmarshalInputAddCommentAndCloseBugInput,
+		ec.unmarshalInputAddCommentAndReopenBugInput,
+		ec.unmarshalInputAddCommentInput,
+		ec.unmarshalInputChangeLabelInput,
+		ec.unmarshalInputCloseBugInput,
+		ec.unmarshalInputEditCommentInput,
+		ec.unmarshalInputNewBugInput,
+		ec.unmarshalInputOpenBugInput,
+		ec.unmarshalInputSetTitleInput,
+	)
 	first := true
 
 	switch rc.Operation.Operation {
@@ -1911,6 +1923,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
 				return nil
 			}
 			first = false
+			ctx = graphql.WithUnmarshalerMap(ctx, inputUnmarshalMap)
 			data := ec._Query(ctx, rc.Operation.SelectionSet)
 			var buf bytes.Buffer
 			data.MarshalGQL(&buf)
@@ -1925,6 +1938,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
 				return nil
 			}
 			first = false
+			ctx = graphql.WithUnmarshalerMap(ctx, inputUnmarshalMap)
 			data := ec._Mutation(ctx, rc.Operation.SelectionSet)
 			var buf bytes.Buffer
 			data.MarshalGQL(&buf)
@@ -1959,7 +1973,7 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er
 }
 
 var sources = []*ast.Source{
-	{Name: "schema/bug.graphql", Input: `"""Represents a comment on a bug."""
+	{Name: "../schema/bug.graphql", Input: `"""Represents a comment on a bug."""
 type Comment implements Authored {
   """The author of this comment."""
   author: Identity!
@@ -2078,7 +2092,7 @@ type BugEdge {
   node: Bug!
 }
 `, BuiltIn: false},
-	{Name: "schema/identity.graphql", Input: `"""Represents an identity"""
+	{Name: "../schema/identity.graphql", Input: `"""Represents an identity"""
 type Identity {
     """The identifier for this identity"""
     id: String!
@@ -2110,7 +2124,7 @@ type IdentityEdge {
     cursor: String!
     node: Identity!
 }`, BuiltIn: false},
-	{Name: "schema/label.graphql", Input: `"""Label for a bug."""
+	{Name: "../schema/label.graphql", Input: `"""Label for a bug."""
 type Label {
     """The name of the label."""
     name: String!
@@ -2129,7 +2143,7 @@ type LabelEdge {
     cursor: String!
     node: Label!
 }`, BuiltIn: false},
-	{Name: "schema/mutations.graphql", Input: `input NewBugInput {
+	{Name: "../schema/mutations.graphql", Input: `input NewBugInput {
     """A unique identifier for the client performing the mutation."""
     clientMutationId: String
     """The name of the repository. If not set, the default repository is used."""
@@ -2340,7 +2354,7 @@ type SetTitlePayload {
     operation: SetTitleOperation!
 }
 `, BuiltIn: false},
-	{Name: "schema/operations.graphql", Input: `"""An operation applied to a bug."""
+	{Name: "../schema/operations.graphql", Input: `"""An operation applied to a bug."""
 interface Operation {
     """The identifier of the operation"""
     id: String!
@@ -2441,7 +2455,7 @@ type LabelChangeOperation implements Operation & Authored {
     removed: [Label!]!
 }
 `, BuiltIn: false},
-	{Name: "schema/repository.graphql", Input: `
+	{Name: "../schema/repository.graphql", Input: `
 type Repository {
     """The name of the repository"""
     name: String
@@ -2492,7 +2506,7 @@ type Repository {
     ): LabelConnection!
 }
 `, BuiltIn: false},
-	{Name: "schema/root.graphql", Input: `type Query {
+	{Name: "../schema/root.graphql", Input: `type Query {
     """Access a repository by reference/name. If no ref is given, the default repository is returned if any."""
     repository(ref: String): Repository
 }
@@ -2518,7 +2532,7 @@ type Mutation {
     setTitle(input: SetTitleInput!): SetTitlePayload!
 }
 `, BuiltIn: false},
-	{Name: "schema/timeline.graphql", Input: `"""An item in the timeline of events"""
+	{Name: "../schema/timeline.graphql", Input: `"""An item in the timeline of events"""
 interface TimelineItem {
     """The identifier of the source operation"""
     id: String!
@@ -2605,7 +2619,7 @@ type SetTitleTimelineItem implements TimelineItem & Authored {
     was: String!
 }
 `, BuiltIn: false},
-	{Name: "schema/types.graphql", Input: `scalar Time
+	{Name: "../schema/types.graphql", Input: `scalar Time
 scalar Hash
 
 """Defines a color by red, green and blue components."""
@@ -3222,21 +3236,17 @@ func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArg
 // region    **************************** field.gotpl *****************************
 
 func (ec *executionContext) _AddCommentAndCloseBugPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndCloseBugPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentAndCloseBugPayload_clientMutationId(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentAndCloseBugPayload",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   false,
-		IsResolver: 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
@@ -3253,22 +3263,31 @@ func (ec *executionContext) _AddCommentAndCloseBugPayload_clientMutationId(ctx c
 	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _AddCommentAndCloseBugPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndCloseBugPayload) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
+func (ec *executionContext) fieldContext_AddCommentAndCloseBugPayload_clientMutationId(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
 		Object:     "AddCommentAndCloseBugPayload",
 		Field:      field,
-		Args:       nil,
 		IsMethod:   false,
 		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
 	}
+	return fc, nil
+}
 
+func (ec *executionContext) _AddCommentAndCloseBugPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndCloseBugPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentAndCloseBugPayload_bug(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
 	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
 		return obj.Bug, nil
@@ -3288,22 +3307,59 @@ func (ec *executionContext) _AddCommentAndCloseBugPayload_bug(ctx context.Contex
 	return ec.marshalNBug2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentAndCloseBugPayload_bug(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentAndCloseBugPayload",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Bug_id(ctx, field)
+			case "humanId":
+				return ec.fieldContext_Bug_humanId(ctx, field)
+			case "status":
+				return ec.fieldContext_Bug_status(ctx, field)
+			case "title":
+				return ec.fieldContext_Bug_title(ctx, field)
+			case "labels":
+				return ec.fieldContext_Bug_labels(ctx, field)
+			case "author":
+				return ec.fieldContext_Bug_author(ctx, field)
+			case "createdAt":
+				return ec.fieldContext_Bug_createdAt(ctx, field)
+			case "lastEdit":
+				return ec.fieldContext_Bug_lastEdit(ctx, field)
+			case "actors":
+				return ec.fieldContext_Bug_actors(ctx, field)
+			case "participants":
+				return ec.fieldContext_Bug_participants(ctx, field)
+			case "comments":
+				return ec.fieldContext_Bug_comments(ctx, field)
+			case "timeline":
+				return ec.fieldContext_Bug_timeline(ctx, field)
+			case "operations":
+				return ec.fieldContext_Bug_operations(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Bug", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentAndCloseBugPayload_commentOperation(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndCloseBugPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentAndCloseBugPayload_commentOperation(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentAndCloseBugPayload",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   false,
-		IsResolver: 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.CommentOperation, nil
@@ -3323,22 +3379,43 @@ func (ec *executionContext) _AddCommentAndCloseBugPayload_commentOperation(ctx c
 	return ec.marshalNAddCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐAddCommentOperation(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _AddCommentAndCloseBugPayload_statusOperation(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndCloseBugPayload) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
+func (ec *executionContext) fieldContext_AddCommentAndCloseBugPayload_commentOperation(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
 		Object:     "AddCommentAndCloseBugPayload",
 		Field:      field,
-		Args:       nil,
 		IsMethod:   false,
 		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_AddCommentOperation_id(ctx, field)
+			case "author":
+				return ec.fieldContext_AddCommentOperation_author(ctx, field)
+			case "date":
+				return ec.fieldContext_AddCommentOperation_date(ctx, field)
+			case "message":
+				return ec.fieldContext_AddCommentOperation_message(ctx, field)
+			case "files":
+				return ec.fieldContext_AddCommentOperation_files(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type AddCommentOperation", field.Name)
+		},
 	}
+	return fc, nil
+}
 
+func (ec *executionContext) _AddCommentAndCloseBugPayload_statusOperation(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndCloseBugPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentAndCloseBugPayload_statusOperation(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
 	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
 		return obj.StatusOperation, nil
@@ -3358,22 +3435,41 @@ func (ec *executionContext) _AddCommentAndCloseBugPayload_statusOperation(ctx co
 	return ec.marshalNSetStatusOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐSetStatusOperation(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentAndCloseBugPayload_statusOperation(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentAndCloseBugPayload",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_SetStatusOperation_id(ctx, field)
+			case "author":
+				return ec.fieldContext_SetStatusOperation_author(ctx, field)
+			case "date":
+				return ec.fieldContext_SetStatusOperation_date(ctx, field)
+			case "status":
+				return ec.fieldContext_SetStatusOperation_status(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type SetStatusOperation", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentAndReopenBugPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndReopenBugPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentAndReopenBugPayload_clientMutationId(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentAndReopenBugPayload",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   false,
-		IsResolver: 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
@@ -3390,22 +3486,31 @@ func (ec *executionContext) _AddCommentAndReopenBugPayload_clientMutationId(ctx
 	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _AddCommentAndReopenBugPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndReopenBugPayload) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
+func (ec *executionContext) fieldContext_AddCommentAndReopenBugPayload_clientMutationId(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
 		Object:     "AddCommentAndReopenBugPayload",
 		Field:      field,
-		Args:       nil,
 		IsMethod:   false,
 		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
 	}
+	return fc, nil
+}
 
+func (ec *executionContext) _AddCommentAndReopenBugPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndReopenBugPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentAndReopenBugPayload_bug(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
 	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
 		return obj.Bug, nil
@@ -3425,22 +3530,59 @@ func (ec *executionContext) _AddCommentAndReopenBugPayload_bug(ctx context.Conte
 	return ec.marshalNBug2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentAndReopenBugPayload_bug(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentAndReopenBugPayload",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Bug_id(ctx, field)
+			case "humanId":
+				return ec.fieldContext_Bug_humanId(ctx, field)
+			case "status":
+				return ec.fieldContext_Bug_status(ctx, field)
+			case "title":
+				return ec.fieldContext_Bug_title(ctx, field)
+			case "labels":
+				return ec.fieldContext_Bug_labels(ctx, field)
+			case "author":
+				return ec.fieldContext_Bug_author(ctx, field)
+			case "createdAt":
+				return ec.fieldContext_Bug_createdAt(ctx, field)
+			case "lastEdit":
+				return ec.fieldContext_Bug_lastEdit(ctx, field)
+			case "actors":
+				return ec.fieldContext_Bug_actors(ctx, field)
+			case "participants":
+				return ec.fieldContext_Bug_participants(ctx, field)
+			case "comments":
+				return ec.fieldContext_Bug_comments(ctx, field)
+			case "timeline":
+				return ec.fieldContext_Bug_timeline(ctx, field)
+			case "operations":
+				return ec.fieldContext_Bug_operations(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Bug", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentAndReopenBugPayload_commentOperation(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndReopenBugPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentAndReopenBugPayload_commentOperation(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentAndReopenBugPayload",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   false,
-		IsResolver: 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.CommentOperation, nil
@@ -3460,22 +3602,43 @@ func (ec *executionContext) _AddCommentAndReopenBugPayload_commentOperation(ctx
 	return ec.marshalNAddCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐAddCommentOperation(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _AddCommentAndReopenBugPayload_statusOperation(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndReopenBugPayload) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
+func (ec *executionContext) fieldContext_AddCommentAndReopenBugPayload_commentOperation(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
 		Object:     "AddCommentAndReopenBugPayload",
 		Field:      field,
-		Args:       nil,
 		IsMethod:   false,
 		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_AddCommentOperation_id(ctx, field)
+			case "author":
+				return ec.fieldContext_AddCommentOperation_author(ctx, field)
+			case "date":
+				return ec.fieldContext_AddCommentOperation_date(ctx, field)
+			case "message":
+				return ec.fieldContext_AddCommentOperation_message(ctx, field)
+			case "files":
+				return ec.fieldContext_AddCommentOperation_files(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type AddCommentOperation", field.Name)
+		},
 	}
+	return fc, nil
+}
 
+func (ec *executionContext) _AddCommentAndReopenBugPayload_statusOperation(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentAndReopenBugPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentAndReopenBugPayload_statusOperation(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
 	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
 		return obj.StatusOperation, nil
@@ -3495,22 +3658,41 @@ func (ec *executionContext) _AddCommentAndReopenBugPayload_statusOperation(ctx c
 	return ec.marshalNSetStatusOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐSetStatusOperation(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentAndReopenBugPayload_statusOperation(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentAndReopenBugPayload",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_SetStatusOperation_id(ctx, field)
+			case "author":
+				return ec.fieldContext_SetStatusOperation_author(ctx, field)
+			case "date":
+				return ec.fieldContext_SetStatusOperation_date(ctx, field)
+			case "status":
+				return ec.fieldContext_SetStatusOperation_status(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type SetStatusOperation", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentOperation_id(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentOperation) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentOperation_id(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentOperation",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   true,
-		IsResolver: true,
-	}
-
-	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 ec.resolvers.AddCommentOperation().ID(rctx, obj)
@@ -3530,22 +3712,31 @@ func (ec *executionContext) _AddCommentOperation_id(ctx context.Context, field g
 	return ec.marshalNString2string(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _AddCommentOperation_author(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentOperation) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
+func (ec *executionContext) fieldContext_AddCommentOperation_id(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
 		Object:     "AddCommentOperation",
 		Field:      field,
-		Args:       nil,
 		IsMethod:   true,
 		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
 	}
+	return fc, nil
+}
 
+func (ec *executionContext) _AddCommentOperation_author(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentOperation) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentOperation_author(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
 	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
 		return ec.resolvers.AddCommentOperation().Author(rctx, obj)
@@ -3565,22 +3756,49 @@ func (ec *executionContext) _AddCommentOperation_author(ctx context.Context, fie
 	return ec.marshalNIdentity2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐIdentityWrapper(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentOperation_author(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentOperation",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Identity_id(ctx, field)
+			case "humanId":
+				return ec.fieldContext_Identity_humanId(ctx, field)
+			case "name":
+				return ec.fieldContext_Identity_name(ctx, field)
+			case "email":
+				return ec.fieldContext_Identity_email(ctx, field)
+			case "login":
+				return ec.fieldContext_Identity_login(ctx, field)
+			case "displayName":
+				return ec.fieldContext_Identity_displayName(ctx, field)
+			case "avatarUrl":
+				return ec.fieldContext_Identity_avatarUrl(ctx, field)
+			case "isProtected":
+				return ec.fieldContext_Identity_isProtected(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Identity", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentOperation_date(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentOperation) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentOperation_date(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentOperation",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   true,
-		IsResolver: true,
-	}
-
-	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 ec.resolvers.AddCommentOperation().Date(rctx, obj)
@@ -3600,22 +3818,31 @@ func (ec *executionContext) _AddCommentOperation_date(ctx context.Context, field
 	return ec.marshalNTime2ᚖtimeᚐTime(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentOperation_date(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentOperation",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Time does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentOperation_message(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentOperation) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentOperation_message(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentOperation",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   false,
-		IsResolver: 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.Message, nil
@@ -3635,22 +3862,31 @@ func (ec *executionContext) _AddCommentOperation_message(ctx context.Context, fi
 	return ec.marshalNString2string(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _AddCommentOperation_files(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentOperation) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
+func (ec *executionContext) fieldContext_AddCommentOperation_message(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
 		Object:     "AddCommentOperation",
 		Field:      field,
-		Args:       nil,
 		IsMethod:   false,
 		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
 	}
+	return fc, nil
+}
 
+func (ec *executionContext) _AddCommentOperation_files(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentOperation) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentOperation_files(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
 	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
 		return obj.Files, nil
@@ -3670,22 +3906,31 @@ func (ec *executionContext) _AddCommentOperation_files(ctx context.Context, fiel
 	return ec.marshalNHash2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHashᚄ(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentOperation_files(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentOperation",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Hash does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentPayload_clientMutationId(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentPayload",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   false,
-		IsResolver: 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
@@ -3702,23 +3947,32 @@ func (ec *executionContext) _AddCommentPayload_clientMutationId(ctx context.Cont
 	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _AddCommentPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentPayload) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
+func (ec *executionContext) fieldContext_AddCommentPayload_clientMutationId(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
 		Object:     "AddCommentPayload",
 		Field:      field,
-		Args:       nil,
 		IsMethod:   false,
 		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
 	}
+	return fc, nil
+}
 
-	ctx = graphql.WithFieldContext(ctx, fc)
-	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+func (ec *executionContext) _AddCommentPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentPayload_bug(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
 		return obj.Bug, nil
 	})
@@ -3737,22 +3991,59 @@ func (ec *executionContext) _AddCommentPayload_bug(ctx context.Context, field gr
 	return ec.marshalNBug2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentPayload_bug(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentPayload",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Bug_id(ctx, field)
+			case "humanId":
+				return ec.fieldContext_Bug_humanId(ctx, field)
+			case "status":
+				return ec.fieldContext_Bug_status(ctx, field)
+			case "title":
+				return ec.fieldContext_Bug_title(ctx, field)
+			case "labels":
+				return ec.fieldContext_Bug_labels(ctx, field)
+			case "author":
+				return ec.fieldContext_Bug_author(ctx, field)
+			case "createdAt":
+				return ec.fieldContext_Bug_createdAt(ctx, field)
+			case "lastEdit":
+				return ec.fieldContext_Bug_lastEdit(ctx, field)
+			case "actors":
+				return ec.fieldContext_Bug_actors(ctx, field)
+			case "participants":
+				return ec.fieldContext_Bug_participants(ctx, field)
+			case "comments":
+				return ec.fieldContext_Bug_comments(ctx, field)
+			case "timeline":
+				return ec.fieldContext_Bug_timeline(ctx, field)
+			case "operations":
+				return ec.fieldContext_Bug_operations(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Bug", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentPayload_operation(ctx context.Context, field graphql.CollectedField, obj *models.AddCommentPayload) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentPayload_operation(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentPayload",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   false,
-		IsResolver: 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
@@ -3772,22 +4063,43 @@ func (ec *executionContext) _AddCommentPayload_operation(ctx context.Context, fi
 	return ec.marshalNAddCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐAddCommentOperation(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) fieldContext_AddCommentPayload_operation(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "AddCommentPayload",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_AddCommentOperation_id(ctx, field)
+			case "author":
+				return ec.fieldContext_AddCommentOperation_author(ctx, field)
+			case "date":
+				return ec.fieldContext_AddCommentOperation_date(ctx, field)
+			case "message":
+				return ec.fieldContext_AddCommentOperation_message(ctx, field)
+			case "files":
+				return ec.fieldContext_AddCommentOperation_files(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type AddCommentOperation", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _AddCommentTimelineItem_id(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentTimelineItem) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentTimelineItem_id(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
 	defer func() {
 		if r := recover(); r != nil {
 			ec.Error(ctx, ec.Recover(ctx, r))
 			ret = graphql.Null
 		}
 	}()
-	fc := &graphql.FieldContext{
-		Object:     "AddCommentTimelineItem",
-		Field:      field,
-		Args:       nil,
-		IsMethod:   true,
-		IsResolver: true,
-	}
-
-	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 ec.resolvers.AddCommentTimelineItem().ID(rctx, obj)
@@ -3807,22 +4119,31 @@ func (ec *executionContext) _AddCommentTimelineItem_id(ctx context.Context, fiel
 	return ec.marshalNString2string(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _AddCommentTimelineItem_author(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentTimelineItem) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
+func (ec *executionContext) fieldContext_AddCommentTimelineItem_id(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
 		Object:     "AddCommentTimelineItem",
 		Field:      field,
-		Args:       nil,
 		IsMethod:   true,
 		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
 	}
+	return fc, nil
+}
 
+func (ec *executionContext) _AddCommentTimelineItem_author(ctx context.Context, field graphql.CollectedField, obj *bug.AddCommentTimelineItem) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_AddCommentTimelineItem_author(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
 	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
 		return ec.resolvers.AddCommentTimelineItem().Author(rctx, obj)

api/graphql/models/gen_models.go 🔗

@@ -8,6 +8,7 @@ import (
 	"strconv"
 
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
@@ -250,7 +251,7 @@ type OpenBugPayload struct {
 // The connection type for an Operation
 type OperationConnection struct {
 	Edges      []*OperationEdge `json:"edges"`
-	Nodes      []bug.Operation  `json:"nodes"`
+	Nodes      []dag.Operation  `json:"nodes"`
 	PageInfo   *PageInfo        `json:"pageInfo"`
 	TotalCount int              `json:"totalCount"`
 }
@@ -258,7 +259,7 @@ type OperationConnection struct {
 // Represent an Operation
 type OperationEdge struct {
 	Cursor string        `json:"cursor"`
-	Node   bug.Operation `json:"node"`
+	Node   dag.Operation `json:"node"`
 }
 
 // Information about pagination in a connection.

api/graphql/models/lazy_bug.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 )
 
 // BugWrapper is an interface used by the GraphQL resolvers to handle a bug.
@@ -24,7 +25,7 @@ type BugWrapper interface {
 	Participants() ([]IdentityWrapper, error)
 	CreatedAt() time.Time
 	Timeline() ([]bug.TimelineItem, error)
-	Operations() ([]bug.Operation, error)
+	Operations() ([]dag.Operation, error)
 
 	IsAuthored()
 }
@@ -144,7 +145,7 @@ func (lb *lazyBug) Timeline() ([]bug.TimelineItem, error) {
 	return lb.snap.Timeline, nil
 }
 
-func (lb *lazyBug) Operations() ([]bug.Operation, error) {
+func (lb *lazyBug) Operations() ([]dag.Operation, error) {
 	err := lb.load()
 	if err != nil {
 		return nil, err
@@ -210,6 +211,6 @@ func (l *loadedBug) Timeline() ([]bug.TimelineItem, error) {
 	return l.Snapshot.Timeline, nil
 }
 
-func (l *loadedBug) Operations() ([]bug.Operation, error) {
+func (l *loadedBug) Operations() ([]dag.Operation, error) {
 	return l.Snapshot.Operations, nil
 }

api/graphql/resolvers/bug.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/MichaelMure/git-bug/api/graphql/graph"
 	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/entity/dag"
 )
 
 var _ graph.BugResolver = &bugResolver{}
@@ -69,14 +70,14 @@ func (bugResolver) Operations(_ context.Context, obj models.BugWrapper, after *s
 		Last:   last,
 	}
 
-	edger := func(op bug.Operation, offset int) connections.Edge {
+	edger := func(op dag.Operation, offset int) connections.Edge {
 		return models.OperationEdge{
 			Node:   op,
 			Cursor: connections.OffsetToCursor(offset),
 		}
 	}
 
-	conMaker := func(edges []*models.OperationEdge, nodes []bug.Operation, info *models.PageInfo, totalCount int) (*models.OperationConnection, error) {
+	conMaker := func(edges []*models.OperationEdge, nodes []dag.Operation, info *models.PageInfo, totalCount int) (*models.OperationConnection, error) {
 		return &models.OperationConnection{
 			Edges:      edges,
 			Nodes:      nodes,

bridge/github/export.go 🔗

@@ -20,6 +20,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 )
 
@@ -288,7 +289,7 @@ func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, out
 
 	for _, op := range snapshot.Operations[1:] {
 		// ignore SetMetadata operations
-		if _, ok := op.(*bug.SetMetadataOperation); ok {
+		if _, ok := op.(dag.OperationDoesntChangeSnapshot); ok {
 			continue
 		}
 

bridge/github/export_test.go 🔗

@@ -15,8 +15,8 @@ import (
 
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
-	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/interrupt"
 )
@@ -245,7 +245,7 @@ func TestGithubPushPull(t *testing.T) {
 			// verify operation have correct metadata
 			for _, op := range tt.bug.Snapshot().Operations {
 				// Check if the originals operations (*not* SetMetadata) are tagged properly
-				if _, ok := op.(*bug.SetMetadataOperation); !ok {
+				if _, ok := op.(dag.OperationDoesntChangeSnapshot); !ok {
 					_, haveIDMetadata := op.GetMetadata(metaKeyGithubId)
 					require.True(t, haveIDMetadata)
 

bridge/github/import_test.go 🔗

@@ -13,6 +13,7 @@ import (
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/interrupt"
@@ -44,7 +45,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "simple issue",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/1",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "simple issue", "initial comment", nil),
 					bug.NewAddCommentOp(author, 0, "first comment", nil),
 					bug.NewAddCommentOp(author, 0, "second comment", nil),
@@ -55,7 +56,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "empty issue",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/2",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "empty issue", "", nil),
 				},
 			},
@@ -64,7 +65,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "complex issue",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/3",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "complex issue", "initial comment", nil),
 					bug.NewLabelChangeOperation(author, 0, []bug.Label{"bug"}, []bug.Label{}),
 					bug.NewLabelChangeOperation(author, 0, []bug.Label{"duplicate"}, []bug.Label{}),
@@ -81,7 +82,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "editions",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/4",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "editions", "initial comment edited", nil),
 					bug.NewEditCommentOp(author, 0, "", "erased then edited again", nil),
 					bug.NewAddCommentOp(author, 0, "first comment", nil),
@@ -93,7 +94,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "comment deletion",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/5",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "comment deletion", "", nil),
 				},
 			},
@@ -102,7 +103,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "edition deletion",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/6",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "edition deletion", "initial comment", nil),
 					bug.NewEditCommentOp(author, 0, "", "initial comment edited again", nil),
 					bug.NewAddCommentOp(author, 0, "first comment", nil),
@@ -114,7 +115,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "hidden comment",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/7",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "hidden comment", "initial comment", nil),
 					bug.NewAddCommentOp(author, 0, "first comment", nil),
 				},
@@ -124,7 +125,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "transfered issue",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/8",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "transfered issue", "", nil),
 				},
 			},
@@ -133,7 +134,7 @@ func TestGithubImporter(t *testing.T) {
 			name: "unicode control characters",
 			url:  "https://github.com/MichaelMure/git-bug-test-github-bridge/issues/10",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "unicode control characters", "u0000: \nu0001: \nu0002: \nu0003: \nu0004: \nu0005: \nu0006: \nu0007: \nu0008: \nu0009: \t\nu0010: \nu0011: \nu0012: \nu0013: \nu0014: \nu0015: \nu0016: \nu0017: \nu0018: \nu0019:", nil),
 				},
 			},

bridge/gitlab/export.go 🔗

@@ -15,6 +15,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 )
 
@@ -256,7 +257,7 @@ func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, out
 	labelSet := make(map[string]struct{})
 	for _, op := range snapshot.Operations[1:] {
 		// ignore SetMetadata operations
-		if _, ok := op.(*bug.SetMetadataOperation); ok {
+		if _, ok := op.(dag.OperationDoesntChangeSnapshot); ok {
 			continue
 		}
 

bridge/gitlab/export_test.go 🔗

@@ -11,11 +11,12 @@ import (
 
 	"github.com/xanzy/go-gitlab"
 
+	"github.com/MichaelMure/git-bug/entity/dag"
+
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
-	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/interrupt"
@@ -247,7 +248,7 @@ func TestGitlabPushPull(t *testing.T) {
 			// verify operation have correct metadata
 			for _, op := range tt.bug.Snapshot().Operations {
 				// Check if the originals operations (*not* SetMetadata) are tagged properly
-				if _, ok := op.(*bug.SetMetadataOperation); !ok {
+				if _, ok := op.(dag.OperationDoesntChangeSnapshot); !ok {
 					_, haveIDMetadata := op.GetMetadata(metaKeyGitlabId)
 					require.True(t, haveIDMetadata)
 
@@ -272,7 +273,7 @@ func TestGitlabPushPull(t *testing.T) {
 			require.True(t, ok)
 			require.Equal(t, issueOrigin, target)
 
-			//TODO: maybe more tests to ensure bug final state
+			// TODO: maybe more tests to ensure bug final state
 		})
 	}
 }

bridge/gitlab/import_test.go 🔗

@@ -13,6 +13,7 @@ import (
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/interrupt"
@@ -49,7 +50,7 @@ func TestGitlabImport(t *testing.T) {
 			name: "simple issue",
 			url:  "https://gitlab.com/git-bug/test/-/issues/1",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "simple issue", "initial comment", nil),
 					bug.NewAddCommentOp(author, 0, "first comment", nil),
 					bug.NewAddCommentOp(author, 0, "second comment", nil),
@@ -60,7 +61,7 @@ func TestGitlabImport(t *testing.T) {
 			name: "empty issue",
 			url:  "https://gitlab.com/git-bug/test/-/issues/2",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "empty issue", "", nil),
 				},
 			},
@@ -69,7 +70,7 @@ func TestGitlabImport(t *testing.T) {
 			name: "complex issue",
 			url:  "https://gitlab.com/git-bug/test/-/issues/3",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "complex issue", "initial comment", nil),
 					bug.NewAddCommentOp(author, 0, "### header\n\n**bold**\n\n_italic_\n\n> with quote\n\n`inline code`\n\n```\nmultiline code\n```\n\n- bulleted\n- list\n\n1. numbered\n1. list\n\n- [ ] task\n- [x] list\n\n@MichaelMure mention\n\n#2 reference issue\n#3 auto-reference issue", nil),
 					bug.NewSetTitleOp(author, 0, "complex issue edited", "complex issue"),
@@ -86,7 +87,7 @@ func TestGitlabImport(t *testing.T) {
 			name: "editions",
 			url:  "https://gitlab.com/git-bug/test/-/issues/4",
 			bug: &bug.Snapshot{
-				Operations: []bug.Operation{
+				Operations: []dag.Operation{
 					bug.NewCreateOp(author, 0, "editions", "initial comment edited", nil),
 					bug.NewAddCommentOp(author, 0, "first comment edited", nil),
 				},

bridge/jira/export.go 🔗

@@ -15,6 +15,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 )
 
@@ -297,7 +298,7 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, out ch
 
 	for _, op := range snapshot.Operations[1:] {
 		// ignore SetMetadata operations
-		if _, ok := op.(*bug.SetMetadataOperation); ok {
+		if _, ok := op.(dag.OperationDoesntChangeSnapshot); ok {
 			continue
 		}
 

bridge/jira/import.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/util/text"
 )
 
@@ -377,7 +378,7 @@ func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool {
 
 // Create a bug.Operation (or a series of operations) from a JIRA changelog
 // entry
-func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, potentialOp bug.Operation) error {
+func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, potentialOp dag.Operation) error {
 
 	// If we have an operation which is already mapped to the entire changelog
 	// entry then that means this changelog entry was induced by an export

bug/bug.go 🔗

@@ -44,11 +44,7 @@ func NewBug() *Bug {
 
 // Read will read a bug from a repository
 func Read(repo repository.ClockedRepo, id entity.Id) (*Bug, error) {
-	e, err := dag.Read(def, repo, identity.NewSimpleResolver(repo), id)
-	if err != nil {
-		return nil, err
-	}
-	return &Bug{Entity: e}, nil
+	return ReadWithResolver(repo, identity.NewSimpleResolver(repo), id)
 }
 
 // ReadWithResolver will read a bug from its Id, with a custom identity.Resolver
@@ -144,21 +140,21 @@ func (bug *Bug) Operations() []Operation {
 }
 
 // Compile a bug in a easily usable snapshot
-func (bug *Bug) Compile() Snapshot {
-	snap := Snapshot{
+func (bug *Bug) Compile() *Snapshot {
+	snap := &Snapshot{
 		id:     bug.Id(),
 		Status: OpenStatus,
 	}
 
 	for _, op := range bug.Operations() {
-		op.Apply(&snap)
+		op.Apply(snap)
 		snap.Operations = append(snap.Operations, op)
 	}
 
 	return snap
 }
 
-// Lookup for the very first operation of the bug.
+// FirstOp lookup for the very first operation of the bug.
 // For a valid Bug, this operation should be a CreateOp
 func (bug *Bug) FirstOp() Operation {
 	if fo := bug.Entity.FirstOp(); fo != nil {
@@ -167,7 +163,7 @@ func (bug *Bug) FirstOp() Operation {
 	return nil
 }
 
-// Lookup for the very last operation of the bug.
+// LastOp lookup for the very last operation of the bug.
 // For a valid Bug, should never be nil
 func (bug *Bug) LastOp() Operation {
 	if lo := bug.Entity.LastOp(); lo != nil {

bug/comment.go 🔗

@@ -41,5 +41,5 @@ func (c Comment) FormatTime() string {
 	return c.UnixTime.Time().Format("Mon Jan 2 15:04:05 2006 +0200")
 }
 
-// Sign post method for gqlgen
+// IsAuthored is a sign post method for gqlgen
 func (c Comment) IsAuthored() {}

bug/interface.go 🔗

@@ -7,34 +7,34 @@ import (
 )
 
 type Interface interface {
-	// Id return the Bug identifier
+	// Id returns the Bug identifier
 	Id() entity.Id
 
-	// Validate check if the Bug data is valid
+	// Validate checks if the Bug data is valid
 	Validate() error
 
 	// Append an operation into the staging area, to be committed later
 	Append(op Operation)
 
-	// Operations return the ordered operations
+	// Operations returns the ordered operations
 	Operations() []Operation
 
-	// Indicate that the in-memory state changed and need to be commit in the repository
+	// NeedCommit indicates that the in-memory state changed and need to be commit in the repository
 	NeedCommit() bool
 
-	// Commit write the staging area in Git and move the operations to the packs
+	// Commit writes the staging area in Git and move the operations to the packs
 	Commit(repo repository.ClockedRepo) error
 
-	// Lookup for the very first operation of the bug.
+	// FirstOp lookup for the very first operation of the bug.
 	// For a valid Bug, this operation should be a CreateOp
 	FirstOp() Operation
 
-	// Lookup for the very last operation of the bug.
+	// LastOp lookup for the very last operation of the bug.
 	// For a valid Bug, should never be nil
 	LastOp() Operation
 
-	// Compile a bug in a easily usable snapshot
-	Compile() Snapshot
+	// Compile a bug in an easily usable snapshot
+	Compile() *Snapshot
 
 	// CreateLamportTime return the Lamport time of creation
 	CreateLamportTime() lamport.Time
@@ -42,14 +42,3 @@ type Interface interface {
 	// EditLamportTime return the Lamport time of the last edit
 	EditLamportTime() lamport.Time
 }
-
-func bugFromInterface(bug Interface) *Bug {
-	switch bug := bug.(type) {
-	case *Bug:
-		return bug
-	case *WithSnapshot:
-		return bug.Bug
-	default:
-		panic("missing type case")
-	}
-}

bug/op_add_comment.go 🔗

@@ -1,7 +1,6 @@
 package bug
 
 import (
-	"encoding/json"
 	"fmt"
 
 	"github.com/MichaelMure/git-bug/entity"
@@ -17,24 +16,24 @@ var _ dag.OperationWithFiles = &AddCommentOperation{}
 
 // AddCommentOperation will add a new comment in the bug
 type AddCommentOperation struct {
-	OpBase
+	dag.OpBase
 	Message string `json:"message"`
 	// TODO: change for a map[string]util.hash to store the filename ?
 	Files []repository.Hash `json:"files"`
 }
 
 func (op *AddCommentOperation) Id() entity.Id {
-	return idOperation(op, &op.OpBase)
+	return dag.IdOperation(op, &op.OpBase)
 }
 
 func (op *AddCommentOperation) Apply(snapshot *Snapshot) {
-	snapshot.addActor(op.Author_)
-	snapshot.addParticipant(op.Author_)
+	snapshot.addActor(op.Author())
+	snapshot.addParticipant(op.Author())
 
 	comment := Comment{
 		id:       entity.CombineIds(snapshot.Id(), op.Id()),
 		Message:  op.Message,
-		Author:   op.Author_,
+		Author:   op.Author(),
 		Files:    op.Files,
 		UnixTime: timestamp.Timestamp(op.UnixTime),
 	}
@@ -64,64 +63,31 @@ func (op *AddCommentOperation) Validate() error {
 	return nil
 }
 
-// UnmarshalJSON is a two-steps JSON unmarshalling
-// This workaround is necessary to avoid the inner OpBase.MarshalJSON
-// overriding the outer op's MarshalJSON
-func (op *AddCommentOperation) UnmarshalJSON(data []byte) error {
-	// Unmarshal OpBase and the op separately
-
-	base := OpBase{}
-	err := json.Unmarshal(data, &base)
-	if err != nil {
-		return err
-	}
-
-	aux := struct {
-		Message string            `json:"message"`
-		Files   []repository.Hash `json:"files"`
-	}{}
-
-	err = json.Unmarshal(data, &aux)
-	if err != nil {
-		return err
-	}
-
-	op.OpBase = base
-	op.Message = aux.Message
-	op.Files = aux.Files
-
-	return nil
-}
-
-// Sign post method for gqlgen
-func (op *AddCommentOperation) IsAuthored() {}
-
 func NewAddCommentOp(author identity.Interface, unixTime int64, message string, files []repository.Hash) *AddCommentOperation {
 	return &AddCommentOperation{
-		OpBase:  newOpBase(AddCommentOp, author, unixTime),
+		OpBase:  dag.NewOpBase(AddCommentOp, author, unixTime),
 		Message: message,
 		Files:   files,
 	}
 }
 
-// CreateTimelineItem replace a AddComment operation in the Timeline and hold its edition history
+// AddCommentTimelineItem hold a comment in the timeline
 type AddCommentTimelineItem struct {
 	CommentTimelineItem
 }
 
-// Sign post method for gqlgen
+// IsAuthored is a sign post method for gqlgen
 func (a *AddCommentTimelineItem) IsAuthored() {}
 
-// Convenience function to apply the operation
-func AddComment(b Interface, author identity.Interface, unixTime int64, message string) (*AddCommentOperation, error) {
-	return AddCommentWithFiles(b, author, unixTime, message, nil)
-}
-
-func AddCommentWithFiles(b Interface, author identity.Interface, unixTime int64, message string, files []repository.Hash) (*AddCommentOperation, error) {
-	addCommentOp := NewAddCommentOp(author, unixTime, message, files)
-	if err := addCommentOp.Validate(); err != nil {
+// AddComment is a convenience function to add a comment to a bug
+func AddComment(b Interface, author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (*AddCommentOperation, error) {
+	op := NewAddCommentOp(author, unixTime, message, files)
+	for key, val := range metadata {
+		op.SetMetadata(key, val)
+	}
+	if err := op.Validate(); err != nil {
 		return nil, err
 	}
-	b.Append(addCommentOp)
-	return addCommentOp, nil
+	b.Append(op)
+	return op, nil
 }

bug/op_add_comment_test.go 🔗

@@ -1,37 +1,18 @@
 package bug
 
 import (
-	"encoding/json"
 	"testing"
-	"time"
-
-	"github.com/stretchr/testify/require"
 
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
 func TestAddCommentSerialize(t *testing.T) {
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-	before := NewAddCommentOp(rene, unix, "message", nil)
-
-	data, err := json.Marshal(before)
-	require.NoError(t, err)
-
-	var after AddCommentOperation
-	err = json.Unmarshal(data, &after)
-	require.NoError(t, err)
-
-	// enforce creating the ID
-	before.Id()
-
-	// Replace the identity as it's not serialized
-	after.Author_ = rene
-
-	require.Equal(t, before, &after)
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *AddCommentOperation {
+		return NewAddCommentOp(author, unixTime, "message", nil)
+	})
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *AddCommentOperation {
+		return NewAddCommentOp(author, unixTime, "message", []repository.Hash{"hash1", "hash2"})
+	})
 }

bug/op_create.go 🔗

@@ -1,7 +1,6 @@
 package bug
 
 import (
-	"encoding/json"
 	"fmt"
 
 	"github.com/MichaelMure/git-bug/entity"
@@ -17,53 +16,38 @@ var _ dag.OperationWithFiles = &CreateOperation{}
 
 // CreateOperation define the initial creation of a bug
 type CreateOperation struct {
-	OpBase
+	dag.OpBase
 	Title   string            `json:"title"`
 	Message string            `json:"message"`
 	Files   []repository.Hash `json:"files"`
 }
 
 func (op *CreateOperation) Id() entity.Id {
-	return idOperation(op, &op.OpBase)
-}
-
-// OVERRIDE
-func (op *CreateOperation) SetMetadata(key string, value string) {
-	// sanity check: we make sure we are not in the following scenario:
-	// - the bug is created with a first operation
-	// - Id() is used
-	// - metadata are added, which will change the Id
-	// - Id() is used again
-
-	if op.id != entity.UnsetId {
-		panic("usage of Id() after changing the first operation")
-	}
-
-	op.OpBase.SetMetadata(key, value)
+	return dag.IdOperation(op, &op.OpBase)
 }
 
 func (op *CreateOperation) Apply(snapshot *Snapshot) {
 	// sanity check: will fail when adding a second Create
 	if snapshot.id != "" && snapshot.id != entity.UnsetId && snapshot.id != op.Id() {
-		panic("adding a second Create operation")
+		return
 	}
 
 	snapshot.id = op.Id()
 
-	snapshot.addActor(op.Author_)
-	snapshot.addParticipant(op.Author_)
+	snapshot.addActor(op.Author())
+	snapshot.addParticipant(op.Author())
 
 	snapshot.Title = op.Title
 
 	comment := Comment{
 		id:       entity.CombineIds(snapshot.Id(), op.Id()),
 		Message:  op.Message,
-		Author:   op.Author_,
+		Author:   op.Author(),
 		UnixTime: timestamp.Timestamp(op.UnixTime),
 	}
 
 	snapshot.Comments = []Comment{comment}
-	snapshot.Author = op.Author_
+	snapshot.Author = op.Author()
 	snapshot.CreateTime = op.Time()
 
 	snapshot.Timeline = []TimelineItem{
@@ -82,13 +66,6 @@ func (op *CreateOperation) Validate() error {
 		return err
 	}
 
-	if len(op.Nonce) > 64 {
-		return fmt.Errorf("create nonce is too big")
-	}
-	if len(op.Nonce) < 20 {
-		return fmt.Errorf("create nonce is too small")
-	}
-
 	if text.Empty(op.Title) {
 		return fmt.Errorf("title is empty")
 	}
@@ -103,45 +80,9 @@ func (op *CreateOperation) Validate() error {
 	return nil
 }
 
-// UnmarshalJSON is a two step JSON unmarshalling
-// This workaround is necessary to avoid the inner OpBase.MarshalJSON
-// overriding the outer op's MarshalJSON
-func (op *CreateOperation) UnmarshalJSON(data []byte) error {
-	// Unmarshal OpBase and the op separately
-
-	base := OpBase{}
-	err := json.Unmarshal(data, &base)
-	if err != nil {
-		return err
-	}
-
-	aux := struct {
-		Nonce   []byte            `json:"nonce"`
-		Title   string            `json:"title"`
-		Message string            `json:"message"`
-		Files   []repository.Hash `json:"files"`
-	}{}
-
-	err = json.Unmarshal(data, &aux)
-	if err != nil {
-		return err
-	}
-
-	op.OpBase = base
-	op.Nonce = aux.Nonce
-	op.Title = aux.Title
-	op.Message = aux.Message
-	op.Files = aux.Files
-
-	return nil
-}
-
-// Sign post method for gqlgen
-func (op *CreateOperation) IsAuthored() {}
-
 func NewCreateOp(author identity.Interface, unixTime int64, title, message string, files []repository.Hash) *CreateOperation {
 	return &CreateOperation{
-		OpBase:  newOpBase(CreateOp, author, unixTime),
+		OpBase:  dag.NewOpBase(CreateOp, author, unixTime),
 		Title:   title,
 		Message: message,
 		Files:   files,
@@ -153,23 +94,19 @@ type CreateTimelineItem struct {
 	CommentTimelineItem
 }
 
-// Sign post method for gqlgen
+// IsAuthored is a sign post method for gqlgen
 func (c *CreateTimelineItem) IsAuthored() {}
 
-// Convenience function to apply the operation
-func Create(author identity.Interface, unixTime int64, title, message string) (*Bug, *CreateOperation, error) {
-	return CreateWithFiles(author, unixTime, title, message, nil)
-}
-
-func CreateWithFiles(author identity.Interface, unixTime int64, title, message string, files []repository.Hash) (*Bug, *CreateOperation, error) {
-	newBug := NewBug()
-	createOp := NewCreateOp(author, unixTime, title, message, files)
-
-	if err := createOp.Validate(); err != nil {
-		return nil, createOp, err
+// Create is a convenience function to create a bug
+func Create(author identity.Interface, unixTime int64, title, message string, files []repository.Hash, metadata map[string]string) (*Bug, *CreateOperation, error) {
+	b := NewBug()
+	op := NewCreateOp(author, unixTime, title, message, files)
+	for key, val := range metadata {
+		op.SetMetadata(key, val)
 	}
-
-	newBug.Append(createOp)
-
-	return newBug, createOp, nil
+	if err := op.Validate(); err != nil {
+		return nil, op, err
+	}
+	b.Append(op)
+	return b, op, nil
 }

bug/op_create_test.go 🔗

@@ -1,13 +1,13 @@
 package bug
 
 import (
-	"encoding/json"
 	"testing"
 	"time"
 
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/timestamp"
@@ -58,26 +58,10 @@ func TestCreate(t *testing.T) {
 }
 
 func TestCreateSerialize(t *testing.T) {
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-	before := NewCreateOp(rene, unix, "title", "message", nil)
-
-	data, err := json.Marshal(before)
-	require.NoError(t, err)
-
-	var after CreateOperation
-	err = json.Unmarshal(data, &after)
-	require.NoError(t, err)
-
-	// enforce creating the ID
-	before.Id()
-
-	// Replace the identity as it's not serialized
-	after.Author_ = rene
-
-	require.Equal(t, before, &after)
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *CreateOperation {
+		return NewCreateOp(author, unixTime, "title", "message", nil)
+	})
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *CreateOperation {
+		return NewCreateOp(author, unixTime, "title", "message", []repository.Hash{"hash1", "hash2"})
+	})
 }

bug/op_edit_comment.go 🔗

@@ -1,7 +1,6 @@
 package bug
 
 import (
-	"encoding/json"
 	"fmt"
 
 	"github.com/pkg/errors"
@@ -20,14 +19,14 @@ var _ dag.OperationWithFiles = &EditCommentOperation{}
 
 // EditCommentOperation will change a comment in the bug
 type EditCommentOperation struct {
-	OpBase
+	dag.OpBase
 	Target  entity.Id         `json:"target"`
 	Message string            `json:"message"`
 	Files   []repository.Hash `json:"files"`
 }
 
 func (op *EditCommentOperation) Id() entity.Id {
-	return idOperation(op, &op.OpBase)
+	return dag.IdOperation(op, &op.OpBase)
 }
 
 func (op *EditCommentOperation) Apply(snapshot *Snapshot) {
@@ -68,7 +67,7 @@ func (op *EditCommentOperation) Apply(snapshot *Snapshot) {
 		return
 	}
 
-	snapshot.addActor(op.Author_)
+	snapshot.addActor(op.Author())
 
 	// Updating the corresponding comment
 
@@ -101,43 +100,9 @@ func (op *EditCommentOperation) Validate() error {
 	return nil
 }
 
-// UnmarshalJSON is two steps JSON unmarshalling
-// This workaround is necessary to avoid the inner OpBase.MarshalJSON
-// overriding the outer op's MarshalJSON
-func (op *EditCommentOperation) UnmarshalJSON(data []byte) error {
-	// Unmarshal OpBase and the op separately
-
-	base := OpBase{}
-	err := json.Unmarshal(data, &base)
-	if err != nil {
-		return err
-	}
-
-	aux := struct {
-		Target  entity.Id         `json:"target"`
-		Message string            `json:"message"`
-		Files   []repository.Hash `json:"files"`
-	}{}
-
-	err = json.Unmarshal(data, &aux)
-	if err != nil {
-		return err
-	}
-
-	op.OpBase = base
-	op.Target = aux.Target
-	op.Message = aux.Message
-	op.Files = aux.Files
-
-	return nil
-}
-
-// Sign post method for gqlgen
-func (op *EditCommentOperation) IsAuthored() {}
-
 func NewEditCommentOp(author identity.Interface, unixTime int64, target entity.Id, message string, files []repository.Hash) *EditCommentOperation {
 	return &EditCommentOperation{
-		OpBase:  newOpBase(EditCommentOp, author, unixTime),
+		OpBase:  dag.NewOpBase(EditCommentOp, author, unixTime),
 		Target:  target,
 		Message: message,
 		Files:   files,
@@ -145,27 +110,20 @@ func NewEditCommentOp(author identity.Interface, unixTime int64, target entity.I
 }
 
 // EditComment is a convenience function to apply the operation
-func EditComment(b Interface, author identity.Interface, unixTime int64, target entity.Id, message string) (*EditCommentOperation, error) {
-	return EditCommentWithFiles(b, author, unixTime, target, message, nil)
-}
-
-func EditCommentWithFiles(b Interface, author identity.Interface, unixTime int64, target entity.Id, message string, files []repository.Hash) (*EditCommentOperation, error) {
-	editCommentOp := NewEditCommentOp(author, unixTime, target, message, files)
-	if err := editCommentOp.Validate(); err != nil {
+func EditComment(b Interface, author identity.Interface, unixTime int64, target entity.Id, message string, files []repository.Hash, metadata map[string]string) (*EditCommentOperation, error) {
+	op := NewEditCommentOp(author, unixTime, target, message, files)
+	for key, val := range metadata {
+		op.SetMetadata(key, val)
+	}
+	if err := op.Validate(); err != nil {
 		return nil, err
 	}
-	b.Append(editCommentOp)
-	return editCommentOp, nil
+	b.Append(op)
+	return op, nil
 }
 
 // EditCreateComment is a convenience function to edit the body of a bug (the first comment)
-func EditCreateComment(b Interface, author identity.Interface, unixTime int64, message string) (*EditCommentOperation, error) {
-	createOp := b.FirstOp().(*CreateOperation)
-	return EditComment(b, author, unixTime, createOp.Id(), message)
-}
-
-// EditCreateCommentWithFiles is a convenience function to edit the body of a bug (the first comment)
-func EditCreateCommentWithFiles(b Interface, author identity.Interface, unixTime int64, message string, files []repository.Hash) (*EditCommentOperation, error) {
+func EditCreateComment(b Interface, author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (*EditCommentOperation, error) {
 	createOp := b.FirstOp().(*CreateOperation)
-	return EditCommentWithFiles(b, author, unixTime, createOp.Id(), message, files)
+	return EditComment(b, author, unixTime, createOp.Id(), message, files, metadata)
 }

bug/op_edit_comment_test.go 🔗

@@ -1,12 +1,12 @@
 package bug
 
 import (
-	"encoding/json"
 	"testing"
 	"time"
 
 	"github.com/stretchr/testify/require"
 
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 )
@@ -75,26 +75,10 @@ func TestEdit(t *testing.T) {
 }
 
 func TestEditCommentSerialize(t *testing.T) {
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-	before := NewEditCommentOp(rene, unix, "target", "message", nil)
-
-	data, err := json.Marshal(before)
-	require.NoError(t, err)
-
-	var after EditCommentOperation
-	err = json.Unmarshal(data, &after)
-	require.NoError(t, err)
-
-	// enforce creating the ID
-	before.Id()
-
-	// Replace the identity as it's not serialized
-	after.Author_ = rene
-
-	require.Equal(t, before, &after)
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *EditCommentOperation {
+		return NewEditCommentOp(author, unixTime, "target", "message", nil)
+	})
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *EditCommentOperation {
+		return NewEditCommentOp(author, unixTime, "target", "message", []repository.Hash{"hash1", "hash2"})
+	})
 }

bug/op_label_change.go 🔗

@@ -1,13 +1,13 @@
 package bug
 
 import (
-	"encoding/json"
 	"fmt"
 	"sort"
 
 	"github.com/pkg/errors"
 
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/timestamp"
 )
@@ -16,18 +16,18 @@ var _ Operation = &LabelChangeOperation{}
 
 // LabelChangeOperation define a Bug operation to add or remove labels
 type LabelChangeOperation struct {
-	OpBase
+	dag.OpBase
 	Added   []Label `json:"added"`
 	Removed []Label `json:"removed"`
 }
 
 func (op *LabelChangeOperation) Id() entity.Id {
-	return idOperation(op, &op.OpBase)
+	return dag.IdOperation(op, &op.OpBase)
 }
 
-// Apply apply the operation
+// Apply applies the operation
 func (op *LabelChangeOperation) Apply(snapshot *Snapshot) {
-	snapshot.addActor(op.Author_)
+	snapshot.addActor(op.Author())
 
 	// Add in the set
 AddLoop:
@@ -59,7 +59,7 @@ AddLoop:
 
 	item := &LabelChangeTimelineItem{
 		id:       op.Id(),
-		Author:   op.Author_,
+		Author:   op.Author(),
 		UnixTime: timestamp.Timestamp(op.UnixTime),
 		Added:    op.Added,
 		Removed:  op.Removed,
@@ -92,41 +92,9 @@ func (op *LabelChangeOperation) Validate() error {
 	return nil
 }
 
-// UnmarshalJSON is a two step JSON unmarshalling
-// This workaround is necessary to avoid the inner OpBase.MarshalJSON
-// overriding the outer op's MarshalJSON
-func (op *LabelChangeOperation) UnmarshalJSON(data []byte) error {
-	// Unmarshal OpBase and the op separately
-
-	base := OpBase{}
-	err := json.Unmarshal(data, &base)
-	if err != nil {
-		return err
-	}
-
-	aux := struct {
-		Added   []Label `json:"added"`
-		Removed []Label `json:"removed"`
-	}{}
-
-	err = json.Unmarshal(data, &aux)
-	if err != nil {
-		return err
-	}
-
-	op.OpBase = base
-	op.Added = aux.Added
-	op.Removed = aux.Removed
-
-	return nil
-}
-
-// Sign post method for gqlgen
-func (op *LabelChangeOperation) IsAuthored() {}
-
 func NewLabelChangeOperation(author identity.Interface, unixTime int64, added, removed []Label) *LabelChangeOperation {
 	return &LabelChangeOperation{
-		OpBase:  newOpBase(LabelChangeOp, author, unixTime),
+		OpBase:  dag.NewOpBase(LabelChangeOp, author, unixTime),
 		Added:   added,
 		Removed: removed,
 	}
@@ -144,11 +112,11 @@ func (l LabelChangeTimelineItem) Id() entity.Id {
 	return l.id
 }
 
-// Sign post method for gqlgen
-func (l *LabelChangeTimelineItem) IsAuthored() {}
+// IsAuthored is a sign post method for gqlgen
+func (l LabelChangeTimelineItem) IsAuthored() {}
 
-// ChangeLabels is a convenience function to apply the operation
-func ChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string) ([]LabelChangeResult, *LabelChangeOperation, error) {
+// ChangeLabels is a convenience function to change labels on a bug
+func ChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string, metadata map[string]string) ([]LabelChangeResult, *LabelChangeOperation, error) {
 	var added, removed []Label
 	var results []LabelChangeResult
 
@@ -196,23 +164,25 @@ func ChangeLabels(b Interface, author identity.Interface, unixTime int64, add, r
 		return results, nil, fmt.Errorf("no label added or removed")
 	}
 
-	labelOp := NewLabelChangeOperation(author, unixTime, added, removed)
-
-	if err := labelOp.Validate(); err != nil {
+	op := NewLabelChangeOperation(author, unixTime, added, removed)
+	for key, val := range metadata {
+		op.SetMetadata(key, val)
+	}
+	if err := op.Validate(); err != nil {
 		return nil, nil, err
 	}
 
-	b.Append(labelOp)
+	b.Append(op)
 
-	return results, labelOp, nil
+	return results, op, nil
 }
 
 // ForceChangeLabels is a convenience function to apply the operation
 // The difference with ChangeLabels is that no checks of deduplications are done. You are entirely
-// responsible of what you are doing. In the general case, you want to use ChangeLabels instead.
+// responsible for what you are doing. In the general case, you want to use ChangeLabels instead.
 // The intended use of this function is to allow importers to create legal but unexpected label changes,
 // like removing a label with no information of when it was added before.
-func ForceChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string) (*LabelChangeOperation, error) {
+func ForceChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string, metadata map[string]string) (*LabelChangeOperation, error) {
 	added := make([]Label, len(add))
 	for i, str := range add {
 		added[i] = Label(str)
@@ -223,15 +193,18 @@ func ForceChangeLabels(b Interface, author identity.Interface, unixTime int64, a
 		removed[i] = Label(str)
 	}
 
-	labelOp := NewLabelChangeOperation(author, unixTime, added, removed)
+	op := NewLabelChangeOperation(author, unixTime, added, removed)
 
-	if err := labelOp.Validate(); err != nil {
+	for key, val := range metadata {
+		op.SetMetadata(key, val)
+	}
+	if err := op.Validate(); err != nil {
 		return nil, err
 	}
 
-	b.Append(labelOp)
+	b.Append(op)
 
-	return labelOp, nil
+	return op, nil
 }
 
 func labelExist(labels []Label, label Label) bool {

bug/op_label_change_test.go 🔗

@@ -1,37 +1,20 @@
 package bug
 
 import (
-	"encoding/json"
 	"testing"
-	"time"
-
-	"github.com/stretchr/testify/require"
 
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
-	"github.com/MichaelMure/git-bug/repository"
 )
 
 func TestLabelChangeSerialize(t *testing.T) {
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-	before := NewLabelChangeOperation(rene, unix, []Label{"added"}, []Label{"removed"})
-
-	data, err := json.Marshal(before)
-	require.NoError(t, err)
-
-	var after LabelChangeOperation
-	err = json.Unmarshal(data, &after)
-	require.NoError(t, err)
-
-	// enforce creating the ID
-	before.Id()
-
-	// Replace the identity as it's not serialized
-	after.Author_ = rene
-
-	require.Equal(t, before, &after)
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *LabelChangeOperation {
+		return NewLabelChangeOperation(author, unixTime, []Label{"added"}, []Label{"removed"})
+	})
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *LabelChangeOperation {
+		return NewLabelChangeOperation(author, unixTime, []Label{"added"}, nil)
+	})
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *LabelChangeOperation {
+		return NewLabelChangeOperation(author, unixTime, nil, []Label{"removed"})
+	})
 }

bug/op_noop.go 🔗

@@ -1,77 +0,0 @@
-package bug
-
-import (
-	"encoding/json"
-
-	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/identity"
-)
-
-var _ Operation = &NoOpOperation{}
-
-// NoOpOperation is an operation that does not change the bug state. It can
-// however be used to store arbitrary metadata in the bug history, for example
-// to support a bridge feature.
-type NoOpOperation struct {
-	OpBase
-}
-
-func (op *NoOpOperation) Id() entity.Id {
-	return idOperation(op, &op.OpBase)
-}
-
-func (op *NoOpOperation) Apply(snapshot *Snapshot) {
-	// Nothing to do
-}
-
-func (op *NoOpOperation) Validate() error {
-	return op.OpBase.Validate(op, NoOpOp)
-}
-
-// UnmarshalJSON is a two step JSON unmarshalling
-// This workaround is necessary to avoid the inner OpBase.MarshalJSON
-// overriding the outer op's MarshalJSON
-func (op *NoOpOperation) UnmarshalJSON(data []byte) error {
-	// Unmarshal OpBase and the op separately
-
-	base := OpBase{}
-	err := json.Unmarshal(data, &base)
-	if err != nil {
-		return err
-	}
-
-	aux := struct{}{}
-
-	err = json.Unmarshal(data, &aux)
-	if err != nil {
-		return err
-	}
-
-	op.OpBase = base
-
-	return nil
-}
-
-// Sign post method for gqlgen
-func (op *NoOpOperation) IsAuthored() {}
-
-func NewNoOpOp(author identity.Interface, unixTime int64) *NoOpOperation {
-	return &NoOpOperation{
-		OpBase: newOpBase(NoOpOp, author, unixTime),
-	}
-}
-
-// Convenience function to apply the operation
-func NoOp(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*NoOpOperation, error) {
-	op := NewNoOpOp(author, unixTime)
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
-	if err := op.Validate(); err != nil {
-		return nil, err
-	}
-	b.Append(op)
-	return op, nil
-}

bug/op_noop_test.go 🔗

@@ -1,39 +0,0 @@
-package bug
-
-import (
-	"encoding/json"
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/require"
-
-	"github.com/MichaelMure/git-bug/identity"
-	"github.com/MichaelMure/git-bug/repository"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestNoopSerialize(t *testing.T) {
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-	before := NewNoOpOp(rene, unix)
-
-	data, err := json.Marshal(before)
-	assert.NoError(t, err)
-
-	var after NoOpOperation
-	err = json.Unmarshal(data, &after)
-	assert.NoError(t, err)
-
-	// enforce creating the ID
-	before.Id()
-
-	// Replace the identity as it's not serialized
-	after.Author_ = rene
-
-	assert.Equal(t, before, &after)
-}

bug/op_set_metadata.go 🔗

@@ -1,108 +1,21 @@
 package bug
 
 import (
-	"encoding/json"
-	"fmt"
-
-	"github.com/pkg/errors"
-
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
-	"github.com/MichaelMure/git-bug/util/text"
 )
 
-var _ Operation = &SetMetadataOperation{}
-
-type SetMetadataOperation struct {
-	OpBase
-	Target      entity.Id         `json:"target"`
-	NewMetadata map[string]string `json:"new_metadata"`
-}
-
-func (op *SetMetadataOperation) Id() entity.Id {
-	return idOperation(op, &op.OpBase)
-}
-
-func (op *SetMetadataOperation) Apply(snapshot *Snapshot) {
-	for _, target := range snapshot.Operations {
-		if target.Id() == op.Target {
-			// Apply the metadata in an immutable way: if a metadata already
-			// exist, it's not possible to override it.
-			for key, value := range op.NewMetadata {
-				target.setExtraMetadataImmutable(key, value)
-			}
-			return
-		}
-	}
-}
-
-func (op *SetMetadataOperation) Validate() error {
-	if err := op.OpBase.Validate(op, SetMetadataOp); err != nil {
-		return err
-	}
-
-	if err := op.Target.Validate(); err != nil {
-		return errors.Wrap(err, "target invalid")
-	}
-
-	for key, val := range op.NewMetadata {
-		if !text.SafeOneLine(key) {
-			return fmt.Errorf("metadata key is unsafe")
-		}
-		if !text.Safe(val) {
-			return fmt.Errorf("metadata value is not fully printable")
-		}
-	}
-
-	return nil
-}
-
-// UnmarshalJSON is a two step JSON unmarshalling
-// This workaround is necessary to avoid the inner OpBase.MarshalJSON
-// overriding the outer op's MarshalJSON
-func (op *SetMetadataOperation) UnmarshalJSON(data []byte) error {
-	// Unmarshal OpBase and the op separately
-
-	base := OpBase{}
-	err := json.Unmarshal(data, &base)
-	if err != nil {
-		return err
-	}
-
-	aux := struct {
-		Target      entity.Id         `json:"target"`
-		NewMetadata map[string]string `json:"new_metadata"`
-	}{}
-
-	err = json.Unmarshal(data, &aux)
-	if err != nil {
-		return err
-	}
-
-	op.OpBase = base
-	op.Target = aux.Target
-	op.NewMetadata = aux.NewMetadata
-
-	return nil
-}
-
-// Sign post method for gqlgen
-func (op *SetMetadataOperation) IsAuthored() {}
-
-func NewSetMetadataOp(author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *SetMetadataOperation {
-	return &SetMetadataOperation{
-		OpBase:      newOpBase(SetMetadataOp, author, unixTime),
-		Target:      target,
-		NewMetadata: newMetadata,
-	}
+func NewSetMetadataOp(author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *dag.SetMetadataOperation[*Snapshot] {
+	return dag.NewSetMetadataOp[*Snapshot](SetMetadataOp, author, unixTime, target, newMetadata)
 }
 
-// Convenience function to apply the operation
-func SetMetadata(b Interface, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*SetMetadataOperation, error) {
-	SetMetadataOp := NewSetMetadataOp(author, unixTime, target, newMetadata)
-	if err := SetMetadataOp.Validate(); err != nil {
+// SetMetadata is a convenience function to add metadata on another operation
+func SetMetadata(b Interface, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*Snapshot], error) {
+	op := NewSetMetadataOp(author, unixTime, target, newMetadata)
+	if err := op.Validate(); err != nil {
 		return nil, err
 	}
-	b.Append(SetMetadataOp)
-	return SetMetadataOp, nil
+	b.Append(op)
+	return op, nil
 }

bug/op_set_metadata_test.go 🔗

@@ -1,126 +0,0 @@
-package bug
-
-import (
-	"encoding/json"
-	"testing"
-	"time"
-
-	"github.com/MichaelMure/git-bug/identity"
-	"github.com/MichaelMure/git-bug/repository"
-
-	"github.com/stretchr/testify/require"
-)
-
-func TestSetMetadata(t *testing.T) {
-	snapshot := Snapshot{}
-
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-
-	create := NewCreateOp(rene, unix, "title", "create", nil)
-	create.SetMetadata("key", "value")
-	create.Apply(&snapshot)
-	snapshot.Operations = append(snapshot.Operations, create)
-
-	id1 := create.Id()
-	require.NoError(t, id1.Validate())
-
-	comment := NewAddCommentOp(rene, unix, "comment", nil)
-	comment.SetMetadata("key2", "value2")
-	comment.Apply(&snapshot)
-	snapshot.Operations = append(snapshot.Operations, comment)
-
-	id2 := comment.Id()
-	require.NoError(t, id2.Validate())
-
-	op1 := NewSetMetadataOp(rene, unix, id1, map[string]string{
-		"key":  "override",
-		"key2": "value",
-	})
-
-	op1.Apply(&snapshot)
-	snapshot.Operations = append(snapshot.Operations, op1)
-
-	createMetadata := snapshot.Operations[0].AllMetadata()
-	require.Len(t, createMetadata, 2)
-	// original key is not overrided
-	require.Equal(t, createMetadata["key"], "value")
-	// new key is set
-	require.Equal(t, createMetadata["key2"], "value")
-
-	commentMetadata := snapshot.Operations[1].AllMetadata()
-	require.Len(t, commentMetadata, 1)
-	require.Equal(t, commentMetadata["key2"], "value2")
-
-	op2 := NewSetMetadataOp(rene, unix, id2, map[string]string{
-		"key2": "value",
-		"key3": "value3",
-	})
-
-	op2.Apply(&snapshot)
-	snapshot.Operations = append(snapshot.Operations, op2)
-
-	createMetadata = snapshot.Operations[0].AllMetadata()
-	require.Len(t, createMetadata, 2)
-	require.Equal(t, createMetadata["key"], "value")
-	require.Equal(t, createMetadata["key2"], "value")
-
-	commentMetadata = snapshot.Operations[1].AllMetadata()
-	require.Len(t, commentMetadata, 2)
-	// original key is not overrided
-	require.Equal(t, commentMetadata["key2"], "value2")
-	// new key is set
-	require.Equal(t, commentMetadata["key3"], "value3")
-
-	op3 := NewSetMetadataOp(rene, unix, id1, map[string]string{
-		"key":  "override",
-		"key2": "override",
-	})
-
-	op3.Apply(&snapshot)
-	snapshot.Operations = append(snapshot.Operations, op3)
-
-	createMetadata = snapshot.Operations[0].AllMetadata()
-	require.Len(t, createMetadata, 2)
-	// original key is not overrided
-	require.Equal(t, createMetadata["key"], "value")
-	// previously set key is not overrided
-	require.Equal(t, createMetadata["key2"], "value")
-
-	commentMetadata = snapshot.Operations[1].AllMetadata()
-	require.Len(t, commentMetadata, 2)
-	require.Equal(t, commentMetadata["key2"], "value2")
-	require.Equal(t, commentMetadata["key3"], "value3")
-}
-
-func TestSetMetadataSerialize(t *testing.T) {
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-	before := NewSetMetadataOp(rene, unix, "message", map[string]string{
-		"key1": "value1",
-		"key2": "value2",
-	})
-
-	data, err := json.Marshal(before)
-	require.NoError(t, err)
-
-	var after SetMetadataOperation
-	err = json.Unmarshal(data, &after)
-	require.NoError(t, err)
-
-	// enforce creating the ID
-	before.Id()
-
-	// Replace the identity as it's not serialized
-	after.Author_ = rene
-
-	require.Equal(t, before, &after)
-}

bug/op_set_status.go 🔗

@@ -1,11 +1,10 @@
 package bug
 
 import (
-	"encoding/json"
-
 	"github.com/pkg/errors"
 
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/timestamp"
 )
@@ -14,21 +13,21 @@ var _ Operation = &SetStatusOperation{}
 
 // SetStatusOperation will change the status of a bug
 type SetStatusOperation struct {
-	OpBase
+	dag.OpBase
 	Status Status `json:"status"`
 }
 
 func (op *SetStatusOperation) Id() entity.Id {
-	return idOperation(op, &op.OpBase)
+	return dag.IdOperation(op, &op.OpBase)
 }
 
 func (op *SetStatusOperation) Apply(snapshot *Snapshot) {
 	snapshot.Status = op.Status
-	snapshot.addActor(op.Author_)
+	snapshot.addActor(op.Author())
 
 	item := &SetStatusTimelineItem{
 		id:       op.Id(),
-		Author:   op.Author_,
+		Author:   op.Author(),
 		UnixTime: timestamp.Timestamp(op.UnixTime),
 		Status:   op.Status,
 	}
@@ -48,39 +47,9 @@ func (op *SetStatusOperation) Validate() error {
 	return nil
 }
 
-// UnmarshalJSON is a two step JSON unmarshalling
-// This workaround is necessary to avoid the inner OpBase.MarshalJSON
-// overriding the outer op's MarshalJSON
-func (op *SetStatusOperation) UnmarshalJSON(data []byte) error {
-	// Unmarshal OpBase and the op separately
-
-	base := OpBase{}
-	err := json.Unmarshal(data, &base)
-	if err != nil {
-		return err
-	}
-
-	aux := struct {
-		Status Status `json:"status"`
-	}{}
-
-	err = json.Unmarshal(data, &aux)
-	if err != nil {
-		return err
-	}
-
-	op.OpBase = base
-	op.Status = aux.Status
-
-	return nil
-}
-
-// Sign post method for gqlgen
-func (op *SetStatusOperation) IsAuthored() {}
-
 func NewSetStatusOp(author identity.Interface, unixTime int64, status Status) *SetStatusOperation {
 	return &SetStatusOperation{
-		OpBase: newOpBase(SetStatusOp, author, unixTime),
+		OpBase: dag.NewOpBase(SetStatusOp, author, unixTime),
 		Status: status,
 	}
 }
@@ -96,12 +65,15 @@ func (s SetStatusTimelineItem) Id() entity.Id {
 	return s.id
 }
 
-// Sign post method for gqlgen
-func (s *SetStatusTimelineItem) IsAuthored() {}
+// IsAuthored is a sign post method for gqlgen
+func (s SetStatusTimelineItem) IsAuthored() {}
 
-// Convenience function to apply the operation
-func Open(b Interface, author identity.Interface, unixTime int64) (*SetStatusOperation, error) {
+// Open is a convenience function to change a bugs state to Open
+func Open(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*SetStatusOperation, error) {
 	op := NewSetStatusOp(author, unixTime, OpenStatus)
+	for key, value := range metadata {
+		op.SetMetadata(key, value)
+	}
 	if err := op.Validate(); err != nil {
 		return nil, err
 	}
@@ -109,9 +81,12 @@ func Open(b Interface, author identity.Interface, unixTime int64) (*SetStatusOpe
 	return op, nil
 }
 
-// Convenience function to apply the operation
-func Close(b Interface, author identity.Interface, unixTime int64) (*SetStatusOperation, error) {
+// Close is a convenience function to change a bugs state to Close
+func Close(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*SetStatusOperation, error) {
 	op := NewSetStatusOp(author, unixTime, ClosedStatus)
+	for key, value := range metadata {
+		op.SetMetadata(key, value)
+	}
 	if err := op.Validate(); err != nil {
 		return nil, err
 	}

bug/op_set_status_test.go 🔗

@@ -1,37 +1,14 @@
 package bug
 
 import (
-	"encoding/json"
 	"testing"
-	"time"
-
-	"github.com/stretchr/testify/require"
 
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
-	"github.com/MichaelMure/git-bug/repository"
 )
 
 func TestSetStatusSerialize(t *testing.T) {
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-	before := NewSetStatusOp(rene, unix, ClosedStatus)
-
-	data, err := json.Marshal(before)
-	require.NoError(t, err)
-
-	var after SetStatusOperation
-	err = json.Unmarshal(data, &after)
-	require.NoError(t, err)
-
-	// enforce creating the ID
-	before.Id()
-
-	// Replace the identity as it's not serialized
-	after.Author_ = rene
-
-	require.Equal(t, before, &after)
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetStatusOperation {
+		return NewSetStatusOp(author, unixTime, ClosedStatus)
+	})
 }

bug/op_set_title.go 🔗

@@ -1,10 +1,10 @@
 package bug
 
 import (
-	"encoding/json"
 	"fmt"
 
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/timestamp"
 
@@ -15,22 +15,22 @@ var _ Operation = &SetTitleOperation{}
 
 // SetTitleOperation will change the title of a bug
 type SetTitleOperation struct {
-	OpBase
+	dag.OpBase
 	Title string `json:"title"`
 	Was   string `json:"was"`
 }
 
 func (op *SetTitleOperation) Id() entity.Id {
-	return idOperation(op, &op.OpBase)
+	return dag.IdOperation(op, &op.OpBase)
 }
 
 func (op *SetTitleOperation) Apply(snapshot *Snapshot) {
 	snapshot.Title = op.Title
-	snapshot.addActor(op.Author_)
+	snapshot.addActor(op.Author())
 
 	item := &SetTitleTimelineItem{
 		id:       op.Id(),
-		Author:   op.Author_,
+		Author:   op.Author(),
 		UnixTime: timestamp.Timestamp(op.UnixTime),
 		Title:    op.Title,
 		Was:      op.Was,
@@ -59,41 +59,9 @@ func (op *SetTitleOperation) Validate() error {
 	return nil
 }
 
-// UnmarshalJSON is a two step JSON unmarshalling
-// This workaround is necessary to avoid the inner OpBase.MarshalJSON
-// overriding the outer op's MarshalJSON
-func (op *SetTitleOperation) UnmarshalJSON(data []byte) error {
-	// Unmarshal OpBase and the op separately
-
-	base := OpBase{}
-	err := json.Unmarshal(data, &base)
-	if err != nil {
-		return err
-	}
-
-	aux := struct {
-		Title string `json:"title"`
-		Was   string `json:"was"`
-	}{}
-
-	err = json.Unmarshal(data, &aux)
-	if err != nil {
-		return err
-	}
-
-	op.OpBase = base
-	op.Title = aux.Title
-	op.Was = aux.Was
-
-	return nil
-}
-
-// Sign post method for gqlgen
-func (op *SetTitleOperation) IsAuthored() {}
-
 func NewSetTitleOp(author identity.Interface, unixTime int64, title string, was string) *SetTitleOperation {
 	return &SetTitleOperation{
-		OpBase: newOpBase(SetTitleOp, author, unixTime),
+		OpBase: dag.NewOpBase(SetTitleOp, author, unixTime),
 		Title:  title,
 		Was:    was,
 	}
@@ -111,11 +79,11 @@ func (s SetTitleTimelineItem) Id() entity.Id {
 	return s.id
 }
 
-// Sign post method for gqlgen
-func (s *SetTitleTimelineItem) IsAuthored() {}
+// IsAuthored is a sign post method for gqlgen
+func (s SetTitleTimelineItem) IsAuthored() {}
 
-// Convenience function to apply the operation
-func SetTitle(b Interface, author identity.Interface, unixTime int64, title string) (*SetTitleOperation, error) {
+// SetTitle is a convenience function to change a bugs title
+func SetTitle(b Interface, author identity.Interface, unixTime int64, title string, metadata map[string]string) (*SetTitleOperation, error) {
 	var lastTitleOp *SetTitleOperation
 	for _, op := range b.Operations() {
 		switch op := op.(type) {
@@ -131,12 +99,14 @@ func SetTitle(b Interface, author identity.Interface, unixTime int64, title stri
 		was = b.FirstOp().(*CreateOperation).Title
 	}
 
-	setTitleOp := NewSetTitleOp(author, unixTime, title, was)
-
-	if err := setTitleOp.Validate(); err != nil {
+	op := NewSetTitleOp(author, unixTime, title, was)
+	for key, value := range metadata {
+		op.SetMetadata(key, value)
+	}
+	if err := op.Validate(); err != nil {
 		return nil, err
 	}
 
-	b.Append(setTitleOp)
-	return setTitleOp, nil
+	b.Append(op)
+	return op, nil
 }

bug/op_set_title_test.go 🔗

@@ -1,37 +1,14 @@
 package bug
 
 import (
-	"encoding/json"
 	"testing"
-	"time"
-
-	"github.com/stretchr/testify/require"
 
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
-	"github.com/MichaelMure/git-bug/repository"
 )
 
 func TestSetTitleSerialize(t *testing.T) {
-	repo := repository.NewMockRepo()
-
-	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
-	require.NoError(t, err)
-
-	unix := time.Now().Unix()
-	before := NewSetTitleOp(rene, unix, "title", "was")
-
-	data, err := json.Marshal(before)
-	require.NoError(t, err)
-
-	var after SetTitleOperation
-	err = json.Unmarshal(data, &after)
-	require.NoError(t, err)
-
-	// enforce creating the ID
-	before.Id()
-
-	// Replace the identity as it's not serialized
-	after.Author_ = rene
-
-	require.Equal(t, before, &after)
+	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetTitleOperation {
+		return NewSetTitleOp(author, unixTime, "title", "was")
+	})
 }

bug/operation.go 🔗

@@ -1,23 +1,15 @@
 package bug
 
 import (
-	"crypto/rand"
 	"encoding/json"
 	"fmt"
-	"time"
 
-	"github.com/pkg/errors"
-
-	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 )
 
-// OperationType is an operation type identifier
-type OperationType int
-
 const (
-	_ OperationType = iota
+	_ dag.OperationType = iota
 	CreateOp
 	SetTitleOp
 	AddCommentOp
@@ -32,55 +24,24 @@ const (
 type Operation interface {
 	dag.Operation
 
-	// Type return the type of the operation
-	Type() OperationType
-
-	// Time return the time when the operation was added
-	Time() time.Time
 	// Apply the operation to a Snapshot to create the final state
 	Apply(snapshot *Snapshot)
-
-	// SetMetadata store arbitrary metadata about the operation
-	SetMetadata(key string, value string)
-	// GetMetadata retrieve arbitrary metadata about the operation
-	GetMetadata(key string) (string, bool)
-	// AllMetadata return all metadata for this operation
-	AllMetadata() map[string]string
-
-	setExtraMetadataImmutable(key string, value string)
 }
 
-func idOperation(op Operation, base *OpBase) entity.Id {
-	if base.id == "" {
-		// something went really wrong
-		panic("op's id not set")
-	}
-	if base.id == entity.UnsetId {
-		// This means we are trying to get the op's Id *before* it has been stored, for instance when
-		// adding multiple ops in one go in an OperationPack.
-		// As the Id is computed based on the actual bytes written on the disk, we are going to predict
-		// those and then get the Id. This is safe as it will be the exact same code writing on disk later.
-
-		data, err := json.Marshal(op)
-		if err != nil {
-			panic(err)
-		}
-
-		base.id = entity.DeriveId(data)
-	}
-	return base.id
-}
+// make sure that package external operations do conform to our interface
+var _ Operation = &dag.NoOpOperation[*Snapshot]{}
+var _ Operation = &dag.SetMetadataOperation[*Snapshot]{}
 
-func operationUnmarshaller(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) {
+func operationUnmarshaller(raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) {
 	var t struct {
-		OperationType OperationType `json:"type"`
+		OperationType dag.OperationType `json:"type"`
 	}
 
 	if err := json.Unmarshal(raw, &t); err != nil {
 		return nil, err
 	}
 
-	var op Operation
+	var op dag.Operation
 
 	switch t.OperationType {
 	case AddCommentOp:
@@ -92,9 +53,9 @@ func operationUnmarshaller(author identity.Interface, raw json.RawMessage, resol
 	case LabelChangeOp:
 		op = &LabelChangeOperation{}
 	case NoOpOp:
-		op = &NoOpOperation{}
+		op = &dag.NoOpOperation[*Snapshot]{}
 	case SetMetadataOp:
-		op = &SetMetadataOperation{}
+		op = &dag.SetMetadataOperation[*Snapshot]{}
 	case SetStatusOp:
 		op = &SetStatusOperation{}
 	case SetTitleOp:
@@ -108,188 +69,5 @@ func operationUnmarshaller(author identity.Interface, raw json.RawMessage, resol
 		return nil, err
 	}
 
-	switch op := op.(type) {
-	case *AddCommentOperation:
-		op.Author_ = author
-	case *CreateOperation:
-		op.Author_ = author
-	case *EditCommentOperation:
-		op.Author_ = author
-	case *LabelChangeOperation:
-		op.Author_ = author
-	case *NoOpOperation:
-		op.Author_ = author
-	case *SetMetadataOperation:
-		op.Author_ = author
-	case *SetStatusOperation:
-		op.Author_ = author
-	case *SetTitleOperation:
-		op.Author_ = author
-	default:
-		panic(fmt.Sprintf("unknown operation type %T", op))
-	}
-
 	return op, nil
 }
-
-// OpBase implement the common code for all operations
-type OpBase struct {
-	OperationType OperationType      `json:"type"`
-	Author_       identity.Interface `json:"-"` // not serialized
-	// TODO: part of the data model upgrade, this should eventually be a timestamp + lamport
-	UnixTime int64             `json:"timestamp"`
-	Metadata map[string]string `json:"metadata,omitempty"`
-
-	// mandatory random bytes to ensure a better randomness of the data used to later generate the ID
-	// len(Nonce) should be > 20 and < 64 bytes
-	// It has no functional purpose and should be ignored.
-	Nonce []byte `json:"nonce"`
-
-	// Not serialized. Store the op's id in memory.
-	id entity.Id
-	// Not serialized. Store the extra metadata in memory,
-	// compiled from SetMetadataOperation.
-	extraMetadata map[string]string
-}
-
-// newOpBase is the constructor for an OpBase
-func newOpBase(opType OperationType, author identity.Interface, unixTime int64) OpBase {
-	return OpBase{
-		OperationType: opType,
-		Author_:       author,
-		UnixTime:      unixTime,
-		Nonce:         makeNonce(20),
-		id:            entity.UnsetId,
-	}
-}
-
-func makeNonce(len int) []byte {
-	result := make([]byte, len)
-	_, err := rand.Read(result)
-	if err != nil {
-		panic(err)
-	}
-	return result
-}
-
-func (base *OpBase) UnmarshalJSON(data []byte) error {
-	// Compute the Id when loading the op from disk.
-	base.id = entity.DeriveId(data)
-
-	aux := struct {
-		OperationType OperationType     `json:"type"`
-		UnixTime      int64             `json:"timestamp"`
-		Metadata      map[string]string `json:"metadata,omitempty"`
-		Nonce         []byte            `json:"nonce"`
-	}{}
-
-	if err := json.Unmarshal(data, &aux); err != nil {
-		return err
-	}
-
-	base.OperationType = aux.OperationType
-	base.UnixTime = aux.UnixTime
-	base.Metadata = aux.Metadata
-	base.Nonce = aux.Nonce
-
-	return nil
-}
-
-func (base *OpBase) Type() OperationType {
-	return base.OperationType
-}
-
-// Time return the time when the operation was added
-func (base *OpBase) Time() time.Time {
-	return time.Unix(base.UnixTime, 0)
-}
-
-// Validate check the OpBase for errors
-func (base *OpBase) Validate(op Operation, opType OperationType) error {
-	if base.OperationType != opType {
-		return fmt.Errorf("incorrect operation type (expected: %v, actual: %v)", opType, base.OperationType)
-	}
-
-	if op.Time().Unix() == 0 {
-		return fmt.Errorf("time not set")
-	}
-
-	if base.Author_ == nil {
-		return fmt.Errorf("author not set")
-	}
-
-	if err := op.Author().Validate(); err != nil {
-		return errors.Wrap(err, "author")
-	}
-
-	if op, ok := op.(dag.OperationWithFiles); ok {
-		for _, hash := range op.GetFiles() {
-			if !hash.IsValid() {
-				return fmt.Errorf("file with invalid hash %v", hash)
-			}
-		}
-	}
-
-	if len(base.Nonce) > 64 {
-		return fmt.Errorf("nonce is too big")
-	}
-	if len(base.Nonce) < 20 {
-		return fmt.Errorf("nonce is too small")
-	}
-
-	return nil
-}
-
-// SetMetadata store arbitrary metadata about the operation
-func (base *OpBase) SetMetadata(key string, value string) {
-	if base.Metadata == nil {
-		base.Metadata = make(map[string]string)
-	}
-
-	base.Metadata[key] = value
-	base.id = entity.UnsetId
-}
-
-// GetMetadata retrieve arbitrary metadata about the operation
-func (base *OpBase) GetMetadata(key string) (string, bool) {
-	val, ok := base.Metadata[key]
-
-	if ok {
-		return val, true
-	}
-
-	// extraMetadata can't replace the original operations value if any
-	val, ok = base.extraMetadata[key]
-
-	return val, ok
-}
-
-// AllMetadata return all metadata for this operation
-func (base *OpBase) AllMetadata() map[string]string {
-	result := make(map[string]string)
-
-	for key, val := range base.extraMetadata {
-		result[key] = val
-	}
-
-	// Original metadata take precedence
-	for key, val := range base.Metadata {
-		result[key] = val
-	}
-
-	return result
-}
-
-func (base *OpBase) setExtraMetadataImmutable(key string, value string) {
-	if base.extraMetadata == nil {
-		base.extraMetadata = make(map[string]string)
-	}
-	if _, exist := base.extraMetadata[key]; !exist {
-		base.extraMetadata[key] = value
-	}
-}
-
-// Author return author identity
-func (base *OpBase) Author() identity.Interface {
-	return base.Author_
-}

bug/operation_test.go 🔗

@@ -6,10 +6,13 @@ import (
 
 	"github.com/stretchr/testify/require"
 
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
+// TODO: move to entity/dag?
+
 func TestValidate(t *testing.T) {
 	repo := repository.NewMockRepoClock()
 
@@ -44,11 +47,7 @@ func TestValidate(t *testing.T) {
 		NewSetStatusOp(makeIdentity(t, "René Descartes", "rene@descartes.fr\u001b"), unix, ClosedStatus),
 		NewSetStatusOp(makeIdentity(t, "René \nDescartes", "rene@descartes.fr"), unix, ClosedStatus),
 		NewSetStatusOp(makeIdentity(t, "René Descartes", "rene@\ndescartes.fr"), unix, ClosedStatus),
-		&CreateOperation{OpBase: OpBase{
-			Author_:       rene,
-			UnixTime:      0,
-			OperationType: CreateOp,
-		},
+		&CreateOperation{OpBase: dag.NewOpBase(CreateOp, rene, 0),
 			Title:   "title",
 			Message: "message",
 		},
@@ -105,7 +104,7 @@ func TestID(t *testing.T) {
 		err = rene.Commit(repo)
 		require.NoError(t, err)
 
-		b, op, err := Create(rene, time.Now().Unix(), "title", "message")
+		b, op, err := Create(rene, time.Now().Unix(), "title", "message", nil, nil)
 		require.NoError(t, err)
 
 		id1 := op.Id()

bug/snapshot.go 🔗

@@ -5,9 +5,12 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 )
 
+var _ dag.Snapshot = &Snapshot{}
+
 // Snapshot is a compiled form of the Bug data structure used for storage and merge
 type Snapshot struct {
 	id entity.Id
@@ -23,7 +26,7 @@ type Snapshot struct {
 
 	Timeline []TimelineItem
 
-	Operations []Operation
+	Operations []dag.Operation
 }
 
 // Id returns the Bug identifier
@@ -35,6 +38,10 @@ func (snap *Snapshot) Id() entity.Id {
 	return snap.id
 }
 
+func (snap *Snapshot) AllOperations() []dag.Operation {
+	return snap.Operations
+}
+
 // EditTime returns the last time a bug was modified
 func (snap *Snapshot) EditTime() time.Time {
 	if len(snap.Operations) == 0 {
@@ -133,5 +140,5 @@ func (snap *Snapshot) HasAnyActor(ids ...entity.Id) bool {
 	return false
 }
 
-// Sign post method for gqlgen
+// IsAuthored is a sign post method for gqlgen
 func (snap *Snapshot) IsAuthored() {}

bug/with_snapshot.go 🔗

@@ -1,6 +1,8 @@
 package bug
 
-import "github.com/MichaelMure/git-bug/repository"
+import (
+	"github.com/MichaelMure/git-bug/repository"
+)
 
 var _ Interface = &WithSnapshot{}
 
@@ -10,11 +12,10 @@ type WithSnapshot struct {
 	snap *Snapshot
 }
 
-// Snapshot return the current snapshot
-func (b *WithSnapshot) Snapshot() *Snapshot {
+func (b *WithSnapshot) Compile() *Snapshot {
 	if b.snap == nil {
 		snap := b.Bug.Compile()
-		b.snap = &snap
+		b.snap = snap
 	}
 	return b.snap
 }

cache/bug_cache.go 🔗

@@ -7,6 +7,7 @@ import (
 
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
@@ -33,7 +34,7 @@ func NewBugCache(repoCache *RepoCache, b *bug.Bug) *BugCache {
 func (c *BugCache) Snapshot() *bug.Snapshot {
 	c.mu.RLock()
 	defer c.mu.RUnlock()
-	return c.bug.Snapshot()
+	return c.bug.Compile()
 }
 
 func (c *BugCache) Id() entity.Id {
@@ -84,18 +85,11 @@ func (c *BugCache) AddCommentWithFiles(message string, files []repository.Hash)
 
 func (c *BugCache) AddCommentRaw(author *IdentityCache, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (*bug.AddCommentOperation, error) {
 	c.mu.Lock()
-	op, err := bug.AddCommentWithFiles(c.bug, author.Identity, unixTime, message, files)
+	op, err := bug.AddComment(c.bug, author, unixTime, message, files, metadata)
+	c.mu.Unlock()
 	if err != nil {
-		c.mu.Unlock()
 		return nil, err
 	}
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
-	c.mu.Unlock()
-
 	return op, c.notifyUpdated()
 }
 
@@ -110,24 +104,12 @@ func (c *BugCache) ChangeLabels(added []string, removed []string) ([]bug.LabelCh
 
 func (c *BugCache) ChangeLabelsRaw(author *IdentityCache, unixTime int64, added []string, removed []string, metadata map[string]string) ([]bug.LabelChangeResult, *bug.LabelChangeOperation, error) {
 	c.mu.Lock()
-	changes, op, err := bug.ChangeLabels(c.bug, author.Identity, unixTime, added, removed)
-	if err != nil {
-		c.mu.Unlock()
-		return changes, nil, err
-	}
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
+	changes, op, err := bug.ChangeLabels(c.bug, author.Identity, unixTime, added, removed, metadata)
 	c.mu.Unlock()
-
-	err = c.notifyUpdated()
 	if err != nil {
-		return nil, nil, err
+		return changes, nil, err
 	}
-
-	return changes, op, nil
+	return changes, op, c.notifyUpdated()
 }
 
 func (c *BugCache) ForceChangeLabels(added []string, removed []string) (*bug.LabelChangeOperation, error) {
@@ -141,23 +123,12 @@ func (c *BugCache) ForceChangeLabels(added []string, removed []string) (*bug.Lab
 
 func (c *BugCache) ForceChangeLabelsRaw(author *IdentityCache, unixTime int64, added []string, removed []string, metadata map[string]string) (*bug.LabelChangeOperation, error) {
 	c.mu.Lock()
-	op, err := bug.ForceChangeLabels(c.bug, author.Identity, unixTime, added, removed)
-	if err != nil {
-		c.mu.Unlock()
-		return nil, err
-	}
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
+	op, err := bug.ForceChangeLabels(c.bug, author.Identity, unixTime, added, removed, metadata)
 	c.mu.Unlock()
-	err = c.notifyUpdated()
 	if err != nil {
 		return nil, err
 	}
-
-	return op, nil
+	return op, c.notifyUpdated()
 }
 
 func (c *BugCache) Open() (*bug.SetStatusOperation, error) {
@@ -171,17 +142,11 @@ func (c *BugCache) Open() (*bug.SetStatusOperation, error) {
 
 func (c *BugCache) OpenRaw(author *IdentityCache, unixTime int64, metadata map[string]string) (*bug.SetStatusOperation, error) {
 	c.mu.Lock()
-	op, err := bug.Open(c.bug, author.Identity, unixTime)
+	op, err := bug.Open(c.bug, author.Identity, unixTime, metadata)
+	c.mu.Unlock()
 	if err != nil {
-		c.mu.Unlock()
 		return nil, err
 	}
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
-	c.mu.Unlock()
 	return op, c.notifyUpdated()
 }
 
@@ -196,17 +161,11 @@ func (c *BugCache) Close() (*bug.SetStatusOperation, error) {
 
 func (c *BugCache) CloseRaw(author *IdentityCache, unixTime int64, metadata map[string]string) (*bug.SetStatusOperation, error) {
 	c.mu.Lock()
-	op, err := bug.Close(c.bug, author.Identity, unixTime)
+	op, err := bug.Close(c.bug, author.Identity, unixTime, metadata)
+	c.mu.Unlock()
 	if err != nil {
-		c.mu.Unlock()
 		return nil, err
 	}
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
-	c.mu.Unlock()
 	return op, c.notifyUpdated()
 }
 
@@ -221,17 +180,11 @@ func (c *BugCache) SetTitle(title string) (*bug.SetTitleOperation, error) {
 
 func (c *BugCache) SetTitleRaw(author *IdentityCache, unixTime int64, title string, metadata map[string]string) (*bug.SetTitleOperation, error) {
 	c.mu.Lock()
-	op, err := bug.SetTitle(c.bug, author.Identity, unixTime, title)
+	op, err := bug.SetTitle(c.bug, author.Identity, unixTime, title, metadata)
+	c.mu.Unlock()
 	if err != nil {
-		c.mu.Unlock()
 		return nil, err
 	}
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
-	c.mu.Unlock()
 	return op, c.notifyUpdated()
 }
 
@@ -248,17 +201,11 @@ func (c *BugCache) EditCreateComment(body string) (*bug.EditCommentOperation, er
 // EditCreateCommentRaw is a convenience function to edit the body of a bug (the first comment)
 func (c *BugCache) EditCreateCommentRaw(author *IdentityCache, unixTime int64, body string, metadata map[string]string) (*bug.EditCommentOperation, error) {
 	c.mu.Lock()
-	op, err := bug.EditCreateComment(c.bug, author.Identity, unixTime, body)
+	op, err := bug.EditCreateComment(c.bug, author.Identity, unixTime, body, nil, metadata)
+	c.mu.Unlock()
 	if err != nil {
-		c.mu.Unlock()
 		return nil, err
 	}
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
-	c.mu.Unlock()
 	return op, c.notifyUpdated()
 }
 
@@ -273,21 +220,15 @@ func (c *BugCache) EditComment(target entity.Id, message string) (*bug.EditComme
 
 func (c *BugCache) EditCommentRaw(author *IdentityCache, unixTime int64, target entity.Id, message string, metadata map[string]string) (*bug.EditCommentOperation, error) {
 	c.mu.Lock()
-	op, err := bug.EditComment(c.bug, author.Identity, unixTime, target, message)
+	op, err := bug.EditComment(c.bug, author.Identity, unixTime, target, message, nil, metadata)
+	c.mu.Unlock()
 	if err != nil {
-		c.mu.Unlock()
 		return nil, err
 	}
-
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
-	c.mu.Unlock()
 	return op, c.notifyUpdated()
 }
 
-func (c *BugCache) SetMetadata(target entity.Id, newMetadata map[string]string) (*bug.SetMetadataOperation, error) {
+func (c *BugCache) SetMetadata(target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*bug.Snapshot], error) {
 	author, err := c.repoCache.GetUserIdentity()
 	if err != nil {
 		return nil, err
@@ -296,15 +237,13 @@ func (c *BugCache) SetMetadata(target entity.Id, newMetadata map[string]string)
 	return c.SetMetadataRaw(author, time.Now().Unix(), target, newMetadata)
 }
 
-func (c *BugCache) SetMetadataRaw(author *IdentityCache, unixTime int64, target entity.Id, newMetadata map[string]string) (*bug.SetMetadataOperation, error) {
+func (c *BugCache) SetMetadataRaw(author *IdentityCache, unixTime int64, target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*bug.Snapshot], error) {
 	c.mu.Lock()
 	op, err := bug.SetMetadata(c.bug, author.Identity, unixTime, target, newMetadata)
+	c.mu.Unlock()
 	if err != nil {
-		c.mu.Unlock()
 		return nil, err
 	}
-
-	c.mu.Unlock()
 	return op, c.notifyUpdated()
 }
 

cache/repo_cache.go 🔗

@@ -209,9 +209,9 @@ func (c *RepoCache) buildCache() error {
 		}
 
 		snap := b.Bug.Compile()
-		c.bugExcerpts[b.Bug.Id()] = NewBugExcerpt(b.Bug, &snap)
+		c.bugExcerpts[b.Bug.Id()] = NewBugExcerpt(b.Bug, snap)
 
-		if err := c.addBugToSearchIndex(&snap); err != nil {
+		if err := c.addBugToSearchIndex(snap); err != nil {
 			return err
 		}
 	}
@@ -222,7 +222,7 @@ func (c *RepoCache) buildCache() error {
 }
 
 // repoIsAvailable check is the given repository is locked by a Cache.
-// Note: this is a smart function that will cleanup the lock file if the
+// Note: this is a smart function that will clean the lock file if the
 // corresponding process is not there anymore.
 // If no error is returned, the repo is free to edit.
 func repoIsAvailable(repo repository.RepoStorage) error {

cache/repo_cache_bug.go 🔗

@@ -461,15 +461,11 @@ func (c *RepoCache) NewBugWithFiles(title string, message string, files []reposi
 // well as metadata for the Create operation.
 // The new bug is written in the repository (commit)
 func (c *RepoCache) NewBugRaw(author *IdentityCache, unixTime int64, title string, message string, files []repository.Hash, metadata map[string]string) (*BugCache, *bug.CreateOperation, error) {
-	b, op, err := bug.CreateWithFiles(author.Identity, unixTime, title, message, files)
+	b, op, err := bug.Create(author.Identity, unixTime, title, message, files, metadata)
 	if err != nil {
 		return nil, nil, err
 	}
 
-	for key, value := range metadata {
-		op.SetMetadata(key, value)
-	}
-
 	err = b.Commit(c.repo)
 	if err != nil {
 		return nil, nil, err

cache/repo_cache_common.go 🔗

@@ -36,7 +36,7 @@ func (c *RepoCache) Keyring() repository.Keyring {
 	return c.repo.Keyring()
 }
 
-// GetUserName returns the name the the user has used to configure git
+// GetUserName returns the name the user has used to configure git
 func (c *RepoCache) GetUserName() (string, error) {
 	return c.repo.GetUserName()
 }
@@ -131,7 +131,7 @@ func (c *RepoCache) MergeAll(remote string) <-chan entity.MergeResult {
 				b := result.Entity.(*bug.Bug)
 				snap := b.Compile()
 				c.muBug.Lock()
-				c.bugExcerpts[result.Id] = NewBugExcerpt(b, &snap)
+				c.bugExcerpts[result.Id] = NewBugExcerpt(b, snap)
 				c.muBug.Unlock()
 			}
 		}

entity/dag/common_test.go 🔗

@@ -18,78 +18,73 @@ import (
  Operations
 */
 
-type op1 struct {
-	author identity.Interface
+const (
+	_ OperationType = iota
+	Op1
+	Op2
+)
 
-	OperationType int               `json:"type"`
-	Field1        string            `json:"field_1"`
-	Files         []repository.Hash `json:"files"`
+type op1 struct {
+	OpBase
+	Field1 string            `json:"field_1"`
+	Files  []repository.Hash `json:"files"`
 }
 
 func newOp1(author identity.Interface, field1 string, files ...repository.Hash) *op1 {
-	return &op1{author: author, OperationType: 1, Field1: field1, Files: files}
+	return &op1{OpBase: NewOpBase(Op1, author, 0), Field1: field1, Files: files}
 }
 
-func (o *op1) Id() entity.Id {
-	data, _ := json.Marshal(o)
-	return entity.DeriveId(data)
+func (op *op1) Id() entity.Id {
+	return IdOperation(op, &op.OpBase)
 }
 
-func (o *op1) Validate() error { return nil }
+func (op *op1) Validate() error { return nil }
 
-func (o *op1) Author() identity.Interface {
-	return o.author
-}
-
-func (o *op1) GetFiles() []repository.Hash {
-	return o.Files
+func (op *op1) GetFiles() []repository.Hash {
+	return op.Files
 }
 
 type op2 struct {
-	author identity.Interface
-
-	OperationType int    `json:"type"`
-	Field2        string `json:"field_2"`
+	OpBase
+	Field2 string `json:"field_2"`
 }
 
 func newOp2(author identity.Interface, field2 string) *op2 {
-	return &op2{author: author, OperationType: 2, Field2: field2}
+	return &op2{OpBase: NewOpBase(Op2, author, 0), Field2: field2}
 }
 
-func (o *op2) Id() entity.Id {
-	data, _ := json.Marshal(o)
-	return entity.DeriveId(data)
+func (op *op2) Id() entity.Id {
+	return IdOperation(op, &op.OpBase)
 }
 
-func (o *op2) Validate() error { return nil }
-
-func (o *op2) Author() identity.Interface {
-	return o.author
-}
+func (op *op2) Validate() error { return nil }
 
-func unmarshaler(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (Operation, error) {
+func unmarshaler(raw json.RawMessage, resolver identity.Resolver) (Operation, error) {
 	var t struct {
-		OperationType int `json:"type"`
+		OperationType OperationType `json:"type"`
 	}
 
 	if err := json.Unmarshal(raw, &t); err != nil {
 		return nil, err
 	}
 
+	var op Operation
+
 	switch t.OperationType {
-	case 1:
-		op := &op1{}
-		err := json.Unmarshal(raw, &op)
-		op.author = author
-		return op, err
-	case 2:
-		op := &op2{}
-		err := json.Unmarshal(raw, &op)
-		op.author = author
-		return op, err
+	case Op1:
+		op = &op1{}
+	case Op2:
+		op = &op2{}
 	default:
 		return nil, fmt.Errorf("unknown operation type %v", t.OperationType)
 	}
+
+	err := json.Unmarshal(raw, &op)
+	if err != nil {
+		return nil, err
+	}
+
+	return op, nil
 }
 
 /*

entity/dag/entity.go 🔗

@@ -26,7 +26,7 @@ type Definition struct {
 	// the Namespace in git references (bugs, prs, ...)
 	Namespace string
 	// a function decoding a JSON message into an Operation
-	OperationUnmarshaler func(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (Operation, error)
+	OperationUnmarshaler func(raw json.RawMessage, resolver identity.Resolver) (Operation, error)
 	// the expected format version number, that can be used for data migration/upgrade
 	FormatVersion uint
 }

entity/dag/entity_test.go 🔗

@@ -50,6 +50,8 @@ func TestWriteReadMultipleAuthor(t *testing.T) {
 }
 
 func assertEqualEntities(t *testing.T, a, b *Entity) {
+	t.Helper()
+
 	// testify doesn't support comparing functions and systematically fail if they are not nil
 	// so we have to set them to nil temporarily
 

entity/dag/example_test.go 🔗

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+	"time"
 
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/entity/dag"
@@ -64,10 +65,8 @@ type Operation interface {
 	Apply(snapshot *Snapshot)
 }
 
-type OperationType int
-
 const (
-	_ OperationType = iota
+	_ dag.OperationType = iota
 	SetSignatureRequiredOp
 	AddAdministratorOp
 	RemoveAdministratorOp
@@ -75,37 +74,30 @@ const (
 
 // SetSignatureRequired is an operation to set/unset if git signature are required.
 type SetSignatureRequired struct {
-	author        identity.Interface
-	OperationType OperationType `json:"type"`
-	Value         bool          `json:"value"`
+	dag.OpBase
+	Value bool `json:"value"`
 }
 
 func NewSetSignatureRequired(author identity.Interface, value bool) *SetSignatureRequired {
-	return &SetSignatureRequired{author: author, OperationType: SetSignatureRequiredOp, Value: value}
+	return &SetSignatureRequired{
+		OpBase: dag.NewOpBase(SetSignatureRequiredOp, author, time.Now().Unix()),
+		Value:  value,
+	}
 }
 
 func (ssr *SetSignatureRequired) Id() entity.Id {
 	// the Id of the operation is the hash of the serialized data.
-	// we could memorize the Id when deserializing, but that will do
-	data, _ := json.Marshal(ssr)
-	return entity.DeriveId(data)
+	return dag.IdOperation(ssr, &ssr.OpBase)
 }
 
 func (ssr *SetSignatureRequired) Validate() error {
-	if ssr.author == nil {
-		return fmt.Errorf("author not set")
-	}
-	return ssr.author.Validate()
-}
-
-func (ssr *SetSignatureRequired) Author() identity.Interface {
-	return ssr.author
+	return ssr.OpBase.Validate(ssr, SetSignatureRequiredOp)
 }
 
 // Apply is the function that makes changes on the snapshot
 func (ssr *SetSignatureRequired) Apply(snapshot *Snapshot) {
 	// check that we are allowed to change the config
-	if _, ok := snapshot.Administrator[ssr.author]; !ok {
+	if _, ok := snapshot.Administrator[ssr.Author()]; !ok {
 		return
 	}
 	snapshot.SignatureRequired = ssr.Value
@@ -113,24 +105,20 @@ func (ssr *SetSignatureRequired) Apply(snapshot *Snapshot) {
 
 // AddAdministrator is an operation to add a new administrator in the set
 type AddAdministrator struct {
-	author        identity.Interface
-	OperationType OperationType        `json:"type"`
-	ToAdd         []identity.Interface `json:"to_add"`
-}
-
-// addAdministratorJson is a helper struct to deserialize identities with a concrete type.
-type addAdministratorJson struct {
-	ToAdd []identity.IdentityStub `json:"to_add"`
+	dag.OpBase
+	ToAdd []identity.Interface `json:"to_add"`
 }
 
 func NewAddAdministratorOp(author identity.Interface, toAdd ...identity.Interface) *AddAdministrator {
-	return &AddAdministrator{author: author, OperationType: AddAdministratorOp, ToAdd: toAdd}
+	return &AddAdministrator{
+		OpBase: dag.NewOpBase(AddAdministratorOp, author, time.Now().Unix()),
+		ToAdd:  toAdd,
+	}
 }
 
 func (aa *AddAdministrator) Id() entity.Id {
-	// we could memorize the Id when deserializing, but that will do
-	data, _ := json.Marshal(aa)
-	return entity.DeriveId(data)
+	// the Id of the operation is the hash of the serialized data.
+	return dag.IdOperation(aa, &aa.OpBase)
 }
 
 func (aa *AddAdministrator) Validate() error {
@@ -138,20 +126,13 @@ func (aa *AddAdministrator) Validate() error {
 	if len(aa.ToAdd) == 0 {
 		return fmt.Errorf("nothing to add")
 	}
-	if aa.author == nil {
-		return fmt.Errorf("author not set")
-	}
-	return aa.author.Validate()
-}
-
-func (aa *AddAdministrator) Author() identity.Interface {
-	return aa.author
+	return aa.OpBase.Validate(aa, AddAdministratorOp)
 }
 
 // Apply is the function that makes changes on the snapshot
 func (aa *AddAdministrator) Apply(snapshot *Snapshot) {
 	// check that we are allowed to change the config ... or if there is no admin yet
-	if !snapshot.HasAdministrator(aa.author) && len(snapshot.Administrator) != 0 {
+	if !snapshot.HasAdministrator(aa.Author()) && len(snapshot.Administrator) != 0 {
 		return
 	}
 	for _, toAdd := range aa.ToAdd {
@@ -161,25 +142,20 @@ func (aa *AddAdministrator) Apply(snapshot *Snapshot) {
 
 // RemoveAdministrator is an operation to remove an administrator from the set
 type RemoveAdministrator struct {
-	author        identity.Interface
-	OperationType OperationType        `json:"type"`
-	ToRemove      []identity.Interface `json:"to_remove"`
-}
-
-// removeAdministratorJson is a helper struct to deserialize identities with a concrete type.
-type removeAdministratorJson struct {
+	dag.OpBase
 	ToRemove []identity.Interface `json:"to_remove"`
 }
 
 func NewRemoveAdministratorOp(author identity.Interface, toRemove ...identity.Interface) *RemoveAdministrator {
-	return &RemoveAdministrator{author: author, OperationType: RemoveAdministratorOp, ToRemove: toRemove}
+	return &RemoveAdministrator{
+		OpBase:   dag.NewOpBase(RemoveAdministratorOp, author, time.Now().Unix()),
+		ToRemove: toRemove,
+	}
 }
 
 func (ra *RemoveAdministrator) Id() entity.Id {
 	// the Id of the operation is the hash of the serialized data.
-	// we could memorize the Id when deserializing, but that will do
-	data, _ := json.Marshal(ra)
-	return entity.DeriveId(data)
+	return dag.IdOperation(ra, &ra.OpBase)
 }
 
 func (ra *RemoveAdministrator) Validate() error {
@@ -188,26 +164,19 @@ func (ra *RemoveAdministrator) Validate() error {
 	if len(ra.ToRemove) == 0 {
 		return fmt.Errorf("nothing to remove")
 	}
-	if ra.author == nil {
-		return fmt.Errorf("author not set")
-	}
-	return ra.author.Validate()
-}
-
-func (ra *RemoveAdministrator) Author() identity.Interface {
-	return ra.author
+	return ra.OpBase.Validate(ra, RemoveAdministratorOp)
 }
 
 // Apply is the function that makes changes on the snapshot
 func (ra *RemoveAdministrator) Apply(snapshot *Snapshot) {
 	// check if we are allowed to make changes
-	if !snapshot.HasAdministrator(ra.author) {
+	if !snapshot.HasAdministrator(ra.Author()) {
 		return
 	}
 	// special rule: we can't end up with no administrator
 	stillSome := false
 	for admin, _ := range snapshot.Administrator {
-		if admin != ra.author {
+		if admin != ra.Author() {
 			stillSome = true
 			break
 		}
@@ -245,71 +214,52 @@ var def = dag.Definition{
 
 // operationUnmarshaller is a function doing the de-serialization of the JSON data into our own
 // concrete Operations. If needed, we can use the resolver to connect to other entities.
-func operationUnmarshaller(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) {
+func operationUnmarshaller(raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) {
 	var t struct {
-		OperationType OperationType `json:"type"`
+		OperationType dag.OperationType `json:"type"`
 	}
 
 	if err := json.Unmarshal(raw, &t); err != nil {
 		return nil, err
 	}
 
-	var value interface{}
+	var op dag.Operation
 
 	switch t.OperationType {
 	case AddAdministratorOp:
-		value = &addAdministratorJson{}
+		op = &AddAdministrator{}
 	case RemoveAdministratorOp:
-		value = &removeAdministratorJson{}
+		op = &RemoveAdministrator{}
 	case SetSignatureRequiredOp:
-		value = &SetSignatureRequired{}
+		op = &SetSignatureRequired{}
 	default:
 		panic(fmt.Sprintf("unknown operation type %v", t.OperationType))
 	}
 
-	err := json.Unmarshal(raw, &value)
+	err := json.Unmarshal(raw, &op)
 	if err != nil {
 		return nil, err
 	}
 
-	var op Operation
-
-	switch value := value.(type) {
-	case *SetSignatureRequired:
-		value.author = author
-		op = value
-	case *addAdministratorJson:
-		// We need something less straightforward to deserialize and resolve identities
-		aa := &AddAdministrator{
-			author:        author,
-			OperationType: AddAdministratorOp,
-			ToAdd:         make([]identity.Interface, len(value.ToAdd)),
-		}
-		for i, stub := range value.ToAdd {
+	switch op := op.(type) {
+	case *AddAdministrator:
+		// We need to resolve identities
+		for i, stub := range op.ToAdd {
 			iden, err := resolver.ResolveIdentity(stub.Id())
 			if err != nil {
 				return nil, err
 			}
-			aa.ToAdd[i] = iden
+			op.ToAdd[i] = iden
 		}
-		op = aa
-	case *removeAdministratorJson:
-		// We need something less straightforward to deserialize and resolve identities
-		ra := &RemoveAdministrator{
-			author:        author,
-			OperationType: RemoveAdministratorOp,
-			ToRemove:      make([]identity.Interface, len(value.ToRemove)),
-		}
-		for i, stub := range value.ToRemove {
+	case *RemoveAdministrator:
+		// We need to resolve identities
+		for i, stub := range op.ToRemove {
 			iden, err := resolver.ResolveIdentity(stub.Id())
 			if err != nil {
 				return nil, err
 			}
-			ra.ToRemove[i] = iden
+			op.ToRemove[i] = iden
 		}
-		op = ra
-	default:
-		panic(fmt.Sprintf("unknown operation type %T", value))
 	}
 
 	return op, nil

entity/dag/op_noop.go 🔗

@@ -0,0 +1,39 @@
+package dag
+
+import (
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/identity"
+)
+
+var _ Operation = &NoOpOperation[Snapshot]{}
+var _ OperationDoesntChangeSnapshot = &NoOpOperation[Snapshot]{}
+
+// NoOpOperation is an operation that does not change the entity state. It can
+// however be used to store arbitrary metadata in the entity history, for example
+// to support a bridge feature.
+type NoOpOperation[SnapT Snapshot] struct {
+	OpBase
+}
+
+func NewNoOpOp[SnapT Snapshot](opType OperationType, author identity.Interface, unixTime int64) *NoOpOperation[SnapT] {
+	return &NoOpOperation[SnapT]{
+		OpBase: NewOpBase(opType, author, unixTime),
+	}
+}
+
+func (op *NoOpOperation[SnapT]) Id() entity.Id {
+	return IdOperation(op, &op.OpBase)
+}
+
+func (op *NoOpOperation[SnapT]) Apply(snapshot SnapT) {
+	// Nothing to do
+}
+
+func (op *NoOpOperation[SnapT]) Validate() error {
+	if err := op.OpBase.Validate(op, op.OperationType); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (op *NoOpOperation[SnapT]) DoesntChangeSnapshot() {}

entity/dag/op_noop_test.go 🔗

@@ -0,0 +1,13 @@
+package dag
+
+import (
+	"testing"
+
+	"github.com/MichaelMure/git-bug/identity"
+)
+
+func TestNoopSerialize(t *testing.T) {
+	SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *NoOpOperation[*snapshotMock] {
+		return NewNoOpOp[*snapshotMock](1, author, unixTime)
+	})
+}

entity/dag/op_set_metadata.go 🔗

@@ -0,0 +1,68 @@
+package dag
+
+import (
+	"fmt"
+
+	"github.com/pkg/errors"
+
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/util/text"
+)
+
+var _ Operation = &SetMetadataOperation[Snapshot]{}
+var _ OperationDoesntChangeSnapshot = &SetMetadataOperation[Snapshot]{}
+
+type SetMetadataOperation[SnapT Snapshot] struct {
+	OpBase
+	Target      entity.Id         `json:"target"`
+	NewMetadata map[string]string `json:"new_metadata"`
+}
+
+func NewSetMetadataOp[SnapT Snapshot](opType OperationType, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *SetMetadataOperation[SnapT] {
+	return &SetMetadataOperation[SnapT]{
+		OpBase:      NewOpBase(opType, author, unixTime),
+		Target:      target,
+		NewMetadata: newMetadata,
+	}
+}
+
+func (op *SetMetadataOperation[SnapT]) Id() entity.Id {
+	return IdOperation(op, &op.OpBase)
+}
+
+func (op *SetMetadataOperation[SnapT]) Apply(snapshot SnapT) {
+	for _, target := range snapshot.AllOperations() {
+		if target.Id() == op.Target {
+			// Apply the metadata in an immutable way: if a metadata already
+			// exist, it's not possible to override it.
+			for key, value := range op.NewMetadata {
+				target.setExtraMetadataImmutable(key, value)
+			}
+			return
+		}
+	}
+}
+
+func (op *SetMetadataOperation[SnapT]) Validate() error {
+	if err := op.OpBase.Validate(op, op.OperationType); err != nil {
+		return err
+	}
+
+	if err := op.Target.Validate(); err != nil {
+		return errors.Wrap(err, "target invalid")
+	}
+
+	for key, val := range op.NewMetadata {
+		if !text.SafeOneLine(key) {
+			return fmt.Errorf("metadata key is unsafe")
+		}
+		if !text.Safe(val) {
+			return fmt.Errorf("metadata value is not fully printable")
+		}
+	}
+
+	return nil
+}
+
+func (op *SetMetadataOperation[SnapT]) DoesntChangeSnapshot() {}

entity/dag/op_set_metadata_test.go 🔗

@@ -0,0 +1,106 @@
+package dag
+
+import (
+	"testing"
+	"time"
+
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/repository"
+
+	"github.com/stretchr/testify/require"
+)
+
+type snapshotMock struct {
+	ops []Operation
+}
+
+func (s *snapshotMock) AllOperations() []Operation {
+	return s.ops
+}
+
+func TestSetMetadata(t *testing.T) {
+	snap := &snapshotMock{}
+
+	repo := repository.NewMockRepo()
+
+	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
+	require.NoError(t, err)
+
+	unix := time.Now().Unix()
+
+	target1 := NewNoOpOp[*snapshotMock](1, rene, unix)
+	target1.SetMetadata("key", "value")
+	snap.ops = append(snap.ops, target1)
+
+	target2 := NewNoOpOp[*snapshotMock](1, rene, unix)
+	target2.SetMetadata("key2", "value2")
+	snap.ops = append(snap.ops, target2)
+
+	op1 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target1.Id(), map[string]string{
+		"key":  "override",
+		"key2": "value",
+	})
+
+	op1.Apply(snap)
+	snap.ops = append(snap.ops, op1)
+
+	target1Metadata := snap.AllOperations()[0].AllMetadata()
+	require.Len(t, target1Metadata, 2)
+	// original key is not overrided
+	require.Equal(t, target1Metadata["key"], "value")
+	// new key is set
+	require.Equal(t, target1Metadata["key2"], "value")
+
+	target2Metadata := snap.AllOperations()[1].AllMetadata()
+	require.Len(t, target2Metadata, 1)
+	require.Equal(t, target2Metadata["key2"], "value2")
+
+	op2 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target2.Id(), map[string]string{
+		"key2": "value",
+		"key3": "value3",
+	})
+
+	op2.Apply(snap)
+	snap.ops = append(snap.ops, op2)
+
+	target1Metadata = snap.AllOperations()[0].AllMetadata()
+	require.Len(t, target1Metadata, 2)
+	require.Equal(t, target1Metadata["key"], "value")
+	require.Equal(t, target1Metadata["key2"], "value")
+
+	target2Metadata = snap.AllOperations()[1].AllMetadata()
+	require.Len(t, target2Metadata, 2)
+	// original key is not overrided
+	require.Equal(t, target2Metadata["key2"], "value2")
+	// new key is set
+	require.Equal(t, target2Metadata["key3"], "value3")
+
+	op3 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target1.Id(), map[string]string{
+		"key":  "override",
+		"key2": "override",
+	})
+
+	op3.Apply(snap)
+	snap.ops = append(snap.ops, op3)
+
+	target1Metadata = snap.AllOperations()[0].AllMetadata()
+	require.Len(t, target1Metadata, 2)
+	// original key is not overrided
+	require.Equal(t, target1Metadata["key"], "value")
+	// previously set key is not overrided
+	require.Equal(t, target1Metadata["key2"], "value")
+
+	target2Metadata = snap.AllOperations()[1].AllMetadata()
+	require.Len(t, target2Metadata, 2)
+	require.Equal(t, target2Metadata["key2"], "value2")
+	require.Equal(t, target2Metadata["key3"], "value3")
+}
+
+func TestSetMetadataSerialize(t *testing.T) {
+	SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetMetadataOperation[*snapshotMock] {
+		return NewSetMetadataOp[*snapshotMock](1, author, unixTime, "message", map[string]string{
+			"key1": "value1",
+			"key2": "value2",
+		})
+	})
+}

entity/dag/operation.go 🔗

@@ -1,11 +1,21 @@
 package dag
 
 import (
+	"crypto/rand"
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"github.com/pkg/errors"
+
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
+// OperationType is an operation type identifier
+type OperationType int
+
 // Operation is a piece of data defining a change to reflect on the state of an Entity.
 // What this Operation or Entity's state looks like is not of the resort of this package as it only deals with the
 // data structure and storage.
@@ -22,23 +32,39 @@ type Operation interface {
 	//   a minimal amount of entropy and avoid collision.
 	//
 	//   Author's note: I tried to find a clever way around that inelegance (stuffing random useless data into the stored
-	//   structure is not exactly elegant) but I failed to find a proper way. Essentially, anything that would reuse some
+	//   structure is not exactly elegant), but I failed to find a proper way. Essentially, anything that would reuse some
 	//   other data (parent operation's Id, lamport clock) or the graph structure (depth) impose that the Id would only
 	//   make sense in the context of the graph and yield some deep coupling between Entity and Operation. This in turn
 	//   make the whole thing even less elegant.
 	//
 	// A common way to derive an Id will be to use the entity.DeriveId() function on the serialized operation data.
 	Id() entity.Id
+	// Type return the type of the operation
+	Type() OperationType
 	// Validate check if the Operation data is valid
 	Validate() error
 	// Author returns the author of this operation
 	Author() identity.Interface
+	// Time return the time when the operation was added
+	Time() time.Time
+
+	// SetMetadata store arbitrary metadata about the operation
+	SetMetadata(key string, value string)
+	// GetMetadata retrieve arbitrary metadata about the operation
+	GetMetadata(key string) (string, bool)
+	// AllMetadata return all metadata for this operation
+	AllMetadata() map[string]string
+
+	// setId allow to set the Id, used when unmarshalling only
+	setId(id entity.Id)
+	// setAuthor allow to set the author, used when unmarshalling only
+	setAuthor(author identity.Interface)
+	// setExtraMetadataImmutable add a metadata not carried by the operation itself on the operation
+	setExtraMetadataImmutable(key string, value string)
 }
 
-// OperationWithFiles is an extended Operation that has files dependency, stored in git.
+// OperationWithFiles is an optional extension for an Operation that has files dependency, stored in git.
 type OperationWithFiles interface {
-	Operation
-
 	// GetFiles return the files needed by this operation
 	// This implies that the Operation maintain and store internally the references to those files. This is how
 	// this information is read later, when loading from storage.
@@ -46,3 +72,201 @@ type OperationWithFiles interface {
 	// hash).
 	GetFiles() []repository.Hash
 }
+
+// OperationDoesntChangeSnapshot is an interface signaling that the Operation implementing it doesn't change the
+// snapshot, for example a metadata operation that act on other operations.
+type OperationDoesntChangeSnapshot interface {
+	DoesntChangeSnapshot()
+}
+
+// Snapshot is the minimal interface that a snapshot need to implement
+type Snapshot interface {
+	// AllOperations returns all the operations that have been applied to that snapshot, in order
+	AllOperations() []Operation
+}
+
+// OpBase implement the common feature that every Operation should support.
+type OpBase struct {
+	// Not serialized. Store the op's id in memory.
+	id entity.Id
+	// Not serialized
+	author identity.Interface
+
+	OperationType OperationType `json:"type"`
+	UnixTime      int64         `json:"timestamp"`
+
+	// mandatory random bytes to ensure a better randomness of the data used to later generate the ID
+	// len(Nonce) should be > 20 and < 64 bytes
+	// It has no functional purpose and should be ignored.
+	Nonce []byte `json:"nonce"`
+
+	Metadata map[string]string `json:"metadata,omitempty"`
+	// Not serialized. Store the extra metadata in memory,
+	// compiled from SetMetadataOperation.
+	extraMetadata map[string]string
+}
+
+func NewOpBase(opType OperationType, author identity.Interface, unixTime int64) OpBase {
+	return OpBase{
+		OperationType: opType,
+		author:        author,
+		UnixTime:      unixTime,
+		Nonce:         makeNonce(20),
+		id:            entity.UnsetId,
+	}
+}
+
+func makeNonce(len int) []byte {
+	result := make([]byte, len)
+	_, err := rand.Read(result)
+	if err != nil {
+		panic(err)
+	}
+	return result
+}
+
+func IdOperation(op Operation, base *OpBase) entity.Id {
+	if base.id == "" {
+		// something went really wrong
+		panic("op's id not set")
+	}
+	if base.id == entity.UnsetId {
+		// This means we are trying to get the op's Id *before* it has been stored, for instance when
+		// adding multiple ops in one go in an OperationPack.
+		// As the Id is computed based on the actual bytes written on the disk, we are going to predict
+		// those and then get the Id. This is safe as it will be the exact same code writing on disk later.
+
+		data, err := json.Marshal(op)
+		if err != nil {
+			panic(err)
+		}
+
+		base.id = entity.DeriveId(data)
+	}
+	return base.id
+}
+
+func (base *OpBase) Type() OperationType {
+	return base.OperationType
+}
+
+// Time return the time when the operation was added
+func (base *OpBase) Time() time.Time {
+	return time.Unix(base.UnixTime, 0)
+}
+
+// Validate check the OpBase for errors
+func (base *OpBase) Validate(op Operation, opType OperationType) error {
+	if base.OperationType == 0 {
+		return fmt.Errorf("operation type unset")
+	}
+	if base.OperationType != opType {
+		return fmt.Errorf("incorrect operation type (expected: %v, actual: %v)", opType, base.OperationType)
+	}
+
+	if op.Time().Unix() == 0 {
+		return fmt.Errorf("time not set")
+	}
+
+	if base.author == nil {
+		return fmt.Errorf("author not set")
+	}
+
+	if err := op.Author().Validate(); err != nil {
+		return errors.Wrap(err, "author")
+	}
+
+	if op, ok := op.(OperationWithFiles); ok {
+		for _, hash := range op.GetFiles() {
+			if !hash.IsValid() {
+				return fmt.Errorf("file with invalid hash %v", hash)
+			}
+		}
+	}
+
+	if len(base.Nonce) > 64 {
+		return fmt.Errorf("nonce is too big")
+	}
+	if len(base.Nonce) < 20 {
+		return fmt.Errorf("nonce is too small")
+	}
+
+	return nil
+}
+
+// IsAuthored is a sign post method for gqlgen
+func (base *OpBase) IsAuthored() {}
+
+// Author return author identity
+func (base *OpBase) Author() identity.Interface {
+	return base.author
+}
+
+// IdIsSet returns true if the id has been set already
+func (base *OpBase) IdIsSet() bool {
+	return base.id != "" && base.id != entity.UnsetId
+}
+
+// SetMetadata store arbitrary metadata about the operation
+func (base *OpBase) SetMetadata(key string, value string) {
+	if base.IdIsSet() {
+		panic("set metadata on an operation with already an Id")
+	}
+
+	if base.Metadata == nil {
+		base.Metadata = make(map[string]string)
+	}
+	base.Metadata[key] = value
+}
+
+// GetMetadata retrieve arbitrary metadata about the operation
+func (base *OpBase) GetMetadata(key string) (string, bool) {
+	val, ok := base.Metadata[key]
+
+	if ok {
+		return val, true
+	}
+
+	// extraMetadata can't replace the original operations value if any
+	val, ok = base.extraMetadata[key]
+
+	return val, ok
+}
+
+// AllMetadata return all metadata for this operation
+func (base *OpBase) AllMetadata() map[string]string {
+	result := make(map[string]string)
+
+	for key, val := range base.extraMetadata {
+		result[key] = val
+	}
+
+	// Original metadata take precedence
+	for key, val := range base.Metadata {
+		result[key] = val
+	}
+
+	return result
+}
+
+// setId allow to set the Id, used when unmarshalling only
+func (base *OpBase) setId(id entity.Id) {
+	if base.id != "" && base.id != entity.UnsetId {
+		panic("trying to set id again")
+	}
+	base.id = id
+}
+
+// setAuthor allow to set the author, used when unmarshalling only
+func (base *OpBase) setAuthor(author identity.Interface) {
+	base.author = author
+}
+
+func (base *OpBase) setExtraMetadataImmutable(key string, value string) {
+	if base.extraMetadata == nil {
+		base.extraMetadata = make(map[string]string)
+	}
+	if _, exist := base.extraMetadata[key]; !exist {
+		base.extraMetadata[key] = value
+	}
+}

entity/dag/operation_pack.go 🔗

@@ -314,10 +314,15 @@ func unmarshallPack(def Definition, resolver identity.Resolver, data []byte) ([]
 
 	for _, raw := range aux.Operations {
 		// delegate to specialized unmarshal function
-		op, err := def.OperationUnmarshaler(author, raw, resolver)
+		op, err := def.OperationUnmarshaler(raw, resolver)
 		if err != nil {
 			return nil, nil, err
 		}
+		// Set the id from the serialized data
+		op.setId(entity.DeriveId(raw))
+		// Set the author, taken from the OperationPack
+		op.setAuthor(author)
+
 		ops = append(ops, op)
 	}
 

entity/dag/operation_pack_test.go 🔗

@@ -11,13 +11,13 @@ import (
 )
 
 func TestOperationPackReadWrite(t *testing.T) {
-	repo, id1, _, resolver, def := makeTestContext()
+	repo, author, _, resolver, def := makeTestContext()
 
 	opp := &operationPack{
-		Author: id1,
+		Author: author,
 		Operations: []Operation{
-			newOp1(id1, "foo"),
-			newOp2(id1, "bar"),
+			newOp1(author, "foo"),
+			newOp2(author, "bar"),
 		},
 		CreateTime: 123,
 		EditTime:   456,
@@ -32,34 +32,26 @@ func TestOperationPackReadWrite(t *testing.T) {
 	opp2, err := readOperationPack(def, repo, resolver, commit)
 	require.NoError(t, err)
 
-	require.Equal(t, opp, opp2)
-
-	// make sure we get the same Id with the same data
-	opp3 := &operationPack{
-		Author: id1,
-		Operations: []Operation{
-			newOp1(id1, "foo"),
-			newOp2(id1, "bar"),
-		},
-		CreateTime: 123,
-		EditTime:   456,
+	for _, op := range opp.Operations {
+		// force the creation of the id
+		op.Id()
 	}
-	require.Equal(t, opp.Id(), opp3.Id())
+	require.Equal(t, opp, opp2)
 }
 
 func TestOperationPackSignedReadWrite(t *testing.T) {
-	repo, id1, _, resolver, def := makeTestContext()
+	repo, author, _, resolver, def := makeTestContext()
 
-	err := id1.(*identity.Identity).Mutate(repo, func(orig *identity.Mutator) {
+	err := author.(*identity.Identity).Mutate(repo, func(orig *identity.Mutator) {
 		orig.Keys = append(orig.Keys, identity.GenerateKey())
 	})
 	require.NoError(t, err)
 
 	opp := &operationPack{
-		Author: id1,
+		Author: author,
 		Operations: []Operation{
-			newOp1(id1, "foo"),
-			newOp2(id1, "bar"),
+			newOp1(author, "foo"),
+			newOp2(author, "bar"),
 		},
 		CreateTime: 123,
 		EditTime:   456,
@@ -74,23 +66,15 @@ func TestOperationPackSignedReadWrite(t *testing.T) {
 	opp2, err := readOperationPack(def, repo, resolver, commit)
 	require.NoError(t, err)
 
-	require.Equal(t, opp, opp2)
-
-	// make sure we get the same Id with the same data
-	opp3 := &operationPack{
-		Author: id1,
-		Operations: []Operation{
-			newOp1(id1, "foo"),
-			newOp2(id1, "bar"),
-		},
-		CreateTime: 123,
-		EditTime:   456,
+	for _, op := range opp.Operations {
+		// force the creation of the id
+		op.Id()
 	}
-	require.Equal(t, opp.Id(), opp3.Id())
+	require.Equal(t, opp, opp2)
 }
 
 func TestOperationPackFiles(t *testing.T) {
-	repo, id1, _, resolver, def := makeTestContext()
+	repo, author, _, resolver, def := makeTestContext()
 
 	blobHash1, err := repo.StoreData(randomData())
 	require.NoError(t, err)
@@ -99,10 +83,10 @@ func TestOperationPackFiles(t *testing.T) {
 	require.NoError(t, err)
 
 	opp := &operationPack{
-		Author: id1,
+		Author: author,
 		Operations: []Operation{
-			newOp1(id1, "foo", blobHash1, blobHash2),
-			newOp1(id1, "foo", blobHash2),
+			newOp1(author, "foo", blobHash1, blobHash2),
+			newOp1(author, "foo", blobHash2),
 		},
 		CreateTime: 123,
 		EditTime:   456,
@@ -117,6 +101,10 @@ func TestOperationPackFiles(t *testing.T) {
 	opp2, err := readOperationPack(def, repo, resolver, commit)
 	require.NoError(t, err)
 
+	for _, op := range opp.Operations {
+		// force the creation of the id
+		op.Id()
+	}
 	require.Equal(t, opp, opp2)
 
 	require.ElementsMatch(t, opp2.Operations[0].(OperationWithFiles).GetFiles(), []repository.Hash{

entity/dag/operation_testing.go 🔗

@@ -0,0 +1,57 @@
+package dag
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+// SerializeRoundTripTest realize a marshall/unmarshall round-trip in the same condition as with OperationPack,
+// and check if the recovered operation is identical.
+func SerializeRoundTripTest[OpT Operation](t *testing.T, maker func(author identity.Interface, unixTime int64) OpT) {
+	repo := repository.NewMockRepo()
+
+	rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
+	require.NoError(t, err)
+
+	op := maker(rene, time.Now().Unix())
+	// enforce having an id
+	op.Id()
+
+	rdt := &roundTripper[OpT]{Before: op, author: rene}
+
+	data, err := json.Marshal(rdt)
+	require.NoError(t, err)
+
+	err = json.Unmarshal(data, &rdt)
+	require.NoError(t, err)
+
+	require.Equal(t, op, rdt.after)
+}
+
+type roundTripper[OpT Operation] struct {
+	Before OpT
+	author identity.Interface
+	after  OpT
+}
+
+func (r *roundTripper[OpT]) MarshalJSON() ([]byte, error) {
+	return json.Marshal(r.Before)
+}
+
+func (r *roundTripper[OpT]) UnmarshalJSON(data []byte) error {
+	if err := json.Unmarshal(data, &r.after); err != nil {
+		return err
+	}
+	// Set the id from the serialized data
+	r.after.setId(entity.DeriveId(data))
+	// Set the author, as OperationPack would do
+	r.after.setAuthor(r.author)
+	return nil
+}

go.mod 🔗

@@ -1,17 +1,16 @@
 module github.com/MichaelMure/git-bug
 
-go 1.16
+go 1.18
 
 require (
-	github.com/99designs/gqlgen v0.17.1
+	github.com/99designs/gqlgen v0.17.13
 	github.com/99designs/keyring v1.2.1
 	github.com/MichaelMure/go-term-text v0.3.1
 	github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7
 	github.com/araddon/dateparse v0.0.0-20190622164848-0fb0a474d195
 	github.com/awesome-gocui/gocui v1.1.0
 	github.com/blevesearch/bleve v1.0.14
-	github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9
-	github.com/corpix/uarand v0.1.1 // indirect
+	github.com/cheekybits/genny v1.0.0
 	github.com/dustin/go-humanize v1.0.0
 	github.com/fatih/color v1.13.0
 	github.com/go-git/go-billy/v5 v5.3.1
@@ -23,11 +22,10 @@ require (
 	github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5
 	github.com/pkg/errors v0.9.1
 	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.4.0
 	github.com/stretchr/testify v1.7.2
-	github.com/vektah/gqlparser/v2 v2.4.1
+	github.com/vektah/gqlparser/v2 v2.4.6
 	github.com/xanzy/go-gitlab v0.68.0
 	golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838
 	golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5
@@ -35,3 +33,71 @@ require (
 	golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a
 	golang.org/x/text v0.3.7
 )
+
+require (
+	github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
+	github.com/Microsoft/go-winio v0.4.16 // indirect
+	github.com/RoaringBitmap/roaring v0.4.23 // indirect
+	github.com/acomagu/bufpipe v1.0.3 // indirect
+	github.com/agnivade/levenshtein v1.1.1 // indirect
+	github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
+	github.com/blevesearch/mmap-go v1.0.2 // indirect
+	github.com/blevesearch/segment v0.9.0 // indirect
+	github.com/blevesearch/snowballstem v0.9.0 // indirect
+	github.com/blevesearch/zap/v11 v11.0.14 // indirect
+	github.com/blevesearch/zap/v12 v12.0.14 // indirect
+	github.com/blevesearch/zap/v13 v13.0.6 // indirect
+	github.com/blevesearch/zap/v14 v14.0.5 // indirect
+	github.com/blevesearch/zap/v15 v15.0.3 // indirect
+	github.com/corpix/uarand v0.1.1 // indirect
+	github.com/couchbase/vellum v1.0.2 // indirect
+	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
+	github.com/danieljoos/wincred v1.1.2 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
+	github.com/emirpasic/gods v1.12.0 // indirect
+	github.com/gdamore/encoding v1.0.0 // indirect
+	github.com/gdamore/tcell/v2 v2.4.0 // indirect
+	github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2 // indirect
+	github.com/go-git/gcfg v1.5.0 // indirect
+	github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
+	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/golang/snappy v0.0.1 // indirect
+	github.com/google/go-querystring v1.1.0 // indirect
+	github.com/gorilla/websocket v1.5.0 // indirect
+	github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
+	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+	github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
+	github.com/imdario/mergo v0.3.12 // indirect
+	github.com/inconshreveable/mousetrap v1.0.0 // indirect
+	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+	github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
+	github.com/lucasb-eyer/go-colorful v1.0.3 // indirect
+	github.com/mattn/go-colorable v0.1.12 // indirect
+	github.com/mattn/go-runewidth v0.0.12 // indirect
+	github.com/mitchellh/go-homedir v1.1.0 // indirect
+	github.com/mitchellh/mapstructure v1.3.1 // indirect
+	github.com/mschoch/smat v0.2.0 // indirect
+	github.com/mtibben/percent v0.2.1 // indirect
+	github.com/philhofer/fwd v1.0.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/rivo/uniseg v0.1.0 // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
+	github.com/sergi/go-diff v1.1.0 // indirect
+	github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect
+	github.com/spf13/pflag v1.0.5 // indirect
+	github.com/steveyen/gtreap v0.1.0 // indirect
+	github.com/stretchr/objx v0.3.0 // indirect
+	github.com/tinylib/msgp v1.1.0 // indirect
+	github.com/willf/bitset v1.1.10 // indirect
+	github.com/xanzy/ssh-agent v0.3.0 // indirect
+	go.etcd.io/bbolt v1.3.5 // indirect
+	golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 // indirect
+	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
+	golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect
+	google.golang.org/appengine v1.6.7 // indirect
+	google.golang.org/protobuf v1.28.0 // indirect
+	gopkg.in/warnings.v0 v0.1.2 // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

go.sum 🔗

@@ -33,11 +33,12 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
 github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
-github.com/99designs/gqlgen v0.17.1 h1:i2qQMPKHQjHgBWYIpO4TsaQpPqMHCPK1+h95ipvH8VU=
-github.com/99designs/gqlgen v0.17.1/go.mod h1:K5fzLKwtph+FFgh9j7nFbRUdBKvTcGnsta51fsMTn3o=
+github.com/99designs/gqlgen v0.17.13 h1:ETUEqvRg5Zvr1lXtpoRdj026fzVay0ZlJPwI33qXLIw=
+github.com/99designs/gqlgen v0.17.13/go.mod h1:w1brbeOdqVyNJI553BGwtwdVcYu1LKeYE1opLWN9RgQ=
 github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o=
 github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/Masterminds/glide v0.13.2/go.mod h1:STyF5vcenH/rUqTEv+/hBXlSTo7KYwg2oc2f4tzPWic=
 github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
@@ -54,8 +55,8 @@ github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06
 github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
 github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
-github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=
-github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
+github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
+github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
@@ -101,8 +102,8 @@ github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9L
 github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY=
 github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9 h1:a1zrFsLFac2xoM6zG1u72DWJwZG3ayttYLfmLbxVETk=
-github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
+github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE=
+github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -118,9 +119,7 @@ github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiG
 github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
 github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw=
 github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
-github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
 github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -236,8 +235,8 @@ github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKp
 github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
 github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
@@ -289,11 +288,10 @@ github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
-github.com/matryer/moq v0.2.3/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
+github.com/matryer/moq v0.2.7/go.mod h1:kITsx543GOENm48TUAQyJ9+SAvFSr7iGQXPoth/VUBk=
 github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
@@ -303,8 +301,8 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.2.3 h1:f/MjBEBDLttYCGfRaKBbKSRVF5aV2O6fnBpzknuE3jU=
-github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.3.1 h1:cCBH2gTD2K0OtLlv/Y5H01VQCqmlDxz30kS5Y5bqfLA=
+github.com/mitchellh/mapstructure v1.3.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
 github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
 github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
@@ -331,9 +329,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq
 github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
@@ -342,7 +338,6 @@ 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/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
 github.com/skratchdot/open-golang v0.0.0-20190402232053-79abb63cd66e h1:VAzdS5Nw68fbf5RZ8RDVlUvPXNU6Z3jtPCK/qvm4FoQ=
 github.com/skratchdot/open-golang v0.0.0-20190402232053-79abb63cd66e/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
@@ -366,6 +361,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
 github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
 github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
@@ -376,10 +372,9 @@ github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c/go.mod h1:ahpPrc7
 github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU=
 github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
-github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
-github.com/vektah/gqlparser/v2 v2.4.0/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
-github.com/vektah/gqlparser/v2 v2.4.1 h1:QOyEn8DAPMUMARGMeshKDkDgNmVoEaEGiDB0uWxcSlQ=
-github.com/vektah/gqlparser/v2 v2.4.1/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
+github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY=
+github.com/vektah/gqlparser/v2 v2.4.6 h1:Yjzp66g6oVq93Jihbi0qhGnf/6zIWjcm8H6gA27zstE=
+github.com/vektah/gqlparser/v2 v2.4.6/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
 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.68.0 h1:b2iMQHgZ1V+NyRqLRJVv6RFfr4xnd/AASeS/PETYL0Y=
@@ -387,6 +382,7 @@ github.com/xanzy/go-gitlab v0.68.0/go.mod h1:o4yExCtdaqlM8YGdDJWuZoBmfxBsmA9TPEj
 github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
 github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -407,6 +403,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 h1:71vQrMauZZhcTVK6KdYM+rklehEEwb3E+ZhaE5jrPrE=
 golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -440,6 +437,7 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
 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=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -470,7 +468,6 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 h1:NWy5+hlRbC7HK+PmcXVUmW1IMyFce7to56IUvhUFm7Y=
 golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
@@ -497,7 +494,6 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -532,6 +528,7 @@ golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a h1:ppl5mZgokTT8uPkmYOyEUmPTr3ypaKkg5eFOGrAmxxE=
@@ -592,9 +589,9 @@ 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-20200815165600-90abf76919f3/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
+golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
 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=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -693,14 +690,12 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 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.3/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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

misc/random_bugs/create_random_bugs.go 🔗

@@ -87,6 +87,7 @@ func generateRandomBugsWithSeed(opts Options, seed int64) []*bug.Bug {
 			time.Now().Unix(),
 			fake.Sentence(),
 			paragraphs(),
+			nil, nil,
 		)
 
 		if err != nil {
@@ -143,19 +144,19 @@ func paragraphs() string {
 }
 
 func comment(b bug.Interface, p identity.Interface, timestamp int64) {
-	_, _ = bug.AddComment(b, p, timestamp, paragraphs())
+	_, _ = bug.AddComment(b, p, timestamp, paragraphs(), nil, nil)
 }
 
 func title(b bug.Interface, p identity.Interface, timestamp int64) {
-	_, _ = bug.SetTitle(b, p, timestamp, fake.Sentence())
+	_, _ = bug.SetTitle(b, p, timestamp, fake.Sentence(), nil)
 }
 
 func open(b bug.Interface, p identity.Interface, timestamp int64) {
-	_, _ = bug.Open(b, p, timestamp)
+	_, _ = bug.Open(b, p, timestamp, nil)
 }
 
 func close(b bug.Interface, p identity.Interface, timestamp int64) {
-	_, _ = bug.Close(b, p, timestamp)
+	_, _ = bug.Close(b, p, timestamp, nil)
 }
 
 var addedLabels []string
@@ -182,5 +183,5 @@ func labels(b bug.Interface, p identity.Interface, timestamp int64) {
 	// ignore error
 	// if the randomisation produce no changes, no op
 	// is added to the bug
-	_, _, _ = bug.ChangeLabels(b, p, timestamp, added, removed)
+	_, _, _ = bug.ChangeLabels(b, p, timestamp, added, removed, nil)
 }

repository/gogit.go 🔗

@@ -235,7 +235,7 @@ func (repo *GoGitRepo) Keyring() Keyring {
 	return repo.keyring
 }
 
-// GetUserName returns the name the the user has used to configure git
+// GetUserName returns the name the user has used to configure git
 func (repo *GoGitRepo) GetUserName() (string, error) {
 	return repo.AnyConfig().ReadString("user.name")
 }

repository/repo.go 🔗

@@ -60,9 +60,9 @@ type RepoKeyring interface {
 	Keyring() Keyring
 }
 
-// RepoCommon represent the common function the we want all the repo to implement
+// RepoCommon represent the common function we want all repos to implement
 type RepoCommon interface {
-	// GetUserName returns the name the the user has used to configure git
+	// GetUserName returns the name the user has used to configure git
 	GetUserName() (string, error)
 
 	// GetUserEmail returns the email address that the user has used to configure git.

webui/packed_assets.go 🔗

@@ -1,6 +1,6 @@
 // Code generated by vfsgen; DO NOT EDIT.
 
-// +build !debugwebui
+//go:build !debugwebui
 
 package webui