feat(api): add a subscription for bugs (#1456)

Michael MurĂŠ created

Change summary

api/graphql/gqlgen.yml                      |   2 
api/graphql/graph/bug.generated.go          |   2 
api/graphql/graph/identity.generated.go     |   2 
api/graphql/graph/root_.generated.go        | 163 ++++
api/graphql/graph/subscription.generated.go | 899 +++++++++++++++++++++++
api/graphql/graph/types.generated.go        |  26 
api/graphql/graphql_test.go                 |  33 
api/graphql/handler.go                      |  27 
api/graphql/models/gen_models.go            |  24 
api/graphql/models/lazy_bug.go              |  20 
api/graphql/models/lazy_identity.go         |   9 
api/graphql/resolvers/root.go               |   6 
api/graphql/resolvers/subscription.go       | 148 +++
api/graphql/schema/bug.graphql              |   2 
api/graphql/schema/identity.graphql         |   2 
api/graphql/schema/operation.graphql        |   3 
api/graphql/schema/subscription.graphql     |  29 
api/graphql/schema/types.graphql            |   9 
cache/events.go                             |  84 ++
cache/filter.go                             |  20 
cache/identity_subcache.go                  |  14 
cache/multi_repo_cache.go                   |  50 +
cache/repo_cache.go                         |  73 
cache/repo_cache_test.go                    |  65 +
cache/subcache.go                           |  73 +
entities/bug/bug.go                         |   2 
entities/bug/comment.go                     |   2 
entities/bug/op_add_comment.go              |   2 
entities/bug/op_create.go                   |   2 
entities/bug/op_label_change.go             |   2 
entities/bug/op_set_status.go               |   2 
entities/bug/op_set_title.go                |   2 
entities/bug/snapshot.go                    |   3 
33 files changed, 1,707 insertions(+), 95 deletions(-)

Detailed changes

api/graphql/gqlgen.yml 🔗

@@ -9,9 +9,9 @@ model:
 
 autobind:
   - "github.com/git-bug/git-bug/api/graphql/models"
+  - "github.com/git-bug/git-bug/cache"
   - "github.com/git-bug/git-bug/repository"
   - "github.com/git-bug/git-bug/entity"
-  - "github.com/git-bug/git-bug/entity/dag"
   - "github.com/git-bug/git-bug/entities/common"
   - "github.com/git-bug/git-bug/entities/bug"
   - "github.com/git-bug/git-bug/entities/identity"

api/graphql/graph/bug.generated.go 🔗

@@ -1576,7 +1576,7 @@ func (ec *executionContext) fieldContext_BugEdge_node(_ context.Context, field g
 
 // region    **************************** object.gotpl ****************************
 
-var bugImplementors = []string{"Bug", "Authored"}
+var bugImplementors = []string{"Bug", "Authored", "Entity"}
 
 func (ec *executionContext) _Bug(ctx context.Context, sel ast.SelectionSet, obj models.BugWrapper) graphql.Marshaler {
 	fields := graphql.CollectFields(ec.OperationContext, sel, bugImplementors)

api/graphql/graph/identity.generated.go 🔗

@@ -702,7 +702,7 @@ func (ec *executionContext) fieldContext_IdentityEdge_node(_ context.Context, fi
 
 // region    **************************** object.gotpl ****************************
 
-var identityImplementors = []string{"Identity"}
+var identityImplementors = []string{"Identity", "Entity"}
 
 func (ec *executionContext) _Identity(ctx context.Context, sel ast.SelectionSet, obj models.IdentityWrapper) graphql.Marshaler {
 	fields := graphql.CollectFields(ec.OperationContext, sel, identityImplementors)

api/graphql/graph/root_.generated.go 🔗

@@ -53,6 +53,7 @@ type ResolverRoot interface {
 	Mutation() MutationResolver
 	Query() QueryResolver
 	Repository() RepositoryResolver
+	Subscription() SubscriptionResolver
 }
 
 type DirectiveRoot struct {
@@ -200,6 +201,11 @@ type ComplexityRoot struct {
 		Operation        func(childComplexity int) int
 	}
 
+	BugEvent struct {
+		Bug  func(childComplexity int) int
+		Type func(childComplexity int) int
+	}
+
 	BugLabelChangeOperation struct {
 		Added   func(childComplexity int) int
 		Author  func(childComplexity int) int
@@ -282,6 +288,11 @@ type ComplexityRoot struct {
 		R func(childComplexity int) int
 	}
 
+	EntityEvent struct {
+		Entity func(childComplexity int) int
+		Type   func(childComplexity int) int
+	}
+
 	Identity struct {
 		AvatarUrl   func(childComplexity int) int
 		DisplayName func(childComplexity int) int
@@ -305,6 +316,11 @@ type ComplexityRoot struct {
 		Node   func(childComplexity int) int
 	}
 
+	IdentityEvent struct {
+		Identity func(childComplexity int) int
+		Type     func(childComplexity int) int
+	}
+
 	Label struct {
 		Color func(childComplexity int) int
 		Name  func(childComplexity int) int
@@ -371,6 +387,12 @@ type ComplexityRoot struct {
 		UserIdentity  func(childComplexity int) int
 		ValidLabels   func(childComplexity int, after *string, before *string, first *int, last *int) int
 	}
+
+	Subscription struct {
+		AllEvents      func(childComplexity int, repoRef *string, typename *string) int
+		BugEvents      func(childComplexity int, repoRef *string) int
+		IdentityEvents func(childComplexity int, repoRef *string) int
+	}
 }
 
 type executableSchema struct {
@@ -1026,6 +1048,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.BugEditCommentPayload.Operation(childComplexity), true
 
+	case "BugEvent.bug":
+		if e.complexity.BugEvent.Bug == nil {
+			break
+		}
+
+		return e.complexity.BugEvent.Bug(childComplexity), true
+
+	case "BugEvent.type":
+		if e.complexity.BugEvent.Type == nil {
+			break
+		}
+
+		return e.complexity.BugEvent.Type(childComplexity), true
+
 	case "BugLabelChangeOperation.added":
 		if e.complexity.BugLabelChangeOperation.Added == nil {
 			break
@@ -1348,6 +1384,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Color.R(childComplexity), true
 
+	case "EntityEvent.entity":
+		if e.complexity.EntityEvent.Entity == nil {
+			break
+		}
+
+		return e.complexity.EntityEvent.Entity(childComplexity), true
+
+	case "EntityEvent.type":
+		if e.complexity.EntityEvent.Type == nil {
+			break
+		}
+
+		return e.complexity.EntityEvent.Type(childComplexity), true
+
 	case "Identity.avatarUrl":
 		if e.complexity.Identity.AvatarUrl == nil {
 			break
@@ -1446,6 +1496,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.IdentityEdge.Node(childComplexity), true
 
+	case "IdentityEvent.identity":
+		if e.complexity.IdentityEvent.Identity == nil {
+			break
+		}
+
+		return e.complexity.IdentityEvent.Identity(childComplexity), true
+
+	case "IdentityEvent.type":
+		if e.complexity.IdentityEvent.Type == nil {
+			break
+		}
+
+		return e.complexity.IdentityEvent.Type(childComplexity), true
+
 	case "Label.color":
 		if e.complexity.Label.Color == nil {
 			break
@@ -1780,6 +1844,42 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.ValidLabels(childComplexity, args["after"].(*string), args["before"].(*string), args["first"].(*int), args["last"].(*int)), true
 
+	case "Subscription.allEvents":
+		if e.complexity.Subscription.AllEvents == nil {
+			break
+		}
+
+		args, err := ec.field_Subscription_allEvents_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Subscription.AllEvents(childComplexity, args["repoRef"].(*string), args["typename"].(*string)), true
+
+	case "Subscription.bugEvents":
+		if e.complexity.Subscription.BugEvents == nil {
+			break
+		}
+
+		args, err := ec.field_Subscription_bugEvents_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Subscription.BugEvents(childComplexity, args["repoRef"].(*string)), true
+
+	case "Subscription.identityEvents":
+		if e.complexity.Subscription.IdentityEvents == nil {
+			break
+		}
+
+		args, err := ec.field_Subscription_identityEvents_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Subscription.IdentityEvents(childComplexity, args["repoRef"].(*string)), true
+
 	}
 	return 0, false
 }
@@ -1842,6 +1942,23 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
 			var buf bytes.Buffer
 			data.MarshalGQL(&buf)
 
+			return &graphql.Response{
+				Data: buf.Bytes(),
+			}
+		}
+	case ast.Subscription:
+		next := ec._Subscription(ctx, opCtx.Operation.SelectionSet)
+
+		var buf bytes.Buffer
+		return func(ctx context.Context) *graphql.Response {
+			buf.Reset()
+			data := next(ctx)
+
+			if data == nil {
+				return nil
+			}
+			data.MarshalGQL(&buf)
+
 			return &graphql.Response{
 				Data: buf.Bytes(),
 			}
@@ -1894,7 +2011,7 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er
 }
 
 var sources = []*ast.Source{
-	{Name: "../schema/bug.graphql", Input: `type Bug implements Authored {
+	{Name: "../schema/bug.graphql", Input: `type Bug implements Authored & Entity {
   """The identifier for this bug"""
   id: ID!
   """The human version (truncated) identifier for this bug"""
@@ -2419,7 +2536,7 @@ directive @goTag(
 ) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
 `, BuiltIn: false},
 	{Name: "../schema/identity.graphql", Input: `"""Represents an identity"""
-type Identity {
+type Identity implements Entity {
     """The identifier for this identity"""
     id: ID!
     """The human version (truncated) identifier for this identity"""
@@ -2487,7 +2604,8 @@ type LabelChangeResult {
 }
 `, BuiltIn: false},
 	{Name: "../schema/operation.graphql", Input: `"""An operation applied to an entity."""
-interface Operation {
+interface Operation
+@goModel(model: "github.com/git-bug/git-bug/entity/dag.Operation") {
     """The identifier of the operation"""
     id: ID!
     """The operations author."""
@@ -2573,6 +2691,36 @@ type Mutation # See each entity mutations
     OPEN
     CLOSED
 }
+`, BuiltIn: false},
+	{Name: "../schema/subscription.graphql", Input: `type Subscription {
+  """Subscribe to events on all entities. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events."""
+  allEvents(repoRef: String, typename: String): EntityEvent!
+  """Subscribe to identity entity events. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events."""
+  identityEvents(repoRef: String): IdentityEvent!
+  """Subscribe to bug entity events. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events."""
+  bugEvents(repoRef: String): BugEvent!
+}
+
+enum EntityEventType {
+  CREATED
+  UPDATED
+  REMOVED
+}
+
+type EntityEvent {
+  type: EntityEventType!
+  entity: Entity
+}
+
+type IdentityEvent {
+  type: EntityEventType!
+  identity: Identity!
+}
+
+type BugEvent {
+  type: EntityEventType!
+  bug: Bug!
+}
 `, BuiltIn: false},
 	{Name: "../schema/types.graphql", Input: `scalar CombinedId
 scalar Time
@@ -2605,6 +2753,15 @@ interface Authored {
     """The author of this object."""
     author: Identity!
 }
+
+
+"""An entity (identity, bug, ...)."""
+interface Entity {
+  """The identifier for this entity"""
+  id: ID!
+  """The human version (truncated) identifier for this entity"""
+  humanId: String!
+}
 `, BuiltIn: false},
 }
 var parsedSchema = gqlparser.MustLoadSchema(sources...)

api/graphql/graph/subscription.generated.go 🔗

@@ -0,0 +1,899 @@
+// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
+
+package graph
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"strconv"
+	"sync/atomic"
+
+	"github.com/99designs/gqlgen/graphql"
+	"github.com/git-bug/git-bug/api/graphql/models"
+	"github.com/git-bug/git-bug/cache"
+	"github.com/vektah/gqlparser/v2/ast"
+)
+
+// region    ************************** generated!.gotpl **************************
+
+type SubscriptionResolver interface {
+	AllEvents(ctx context.Context, repoRef *string, typename *string) (<-chan *models.EntityEvent, error)
+	IdentityEvents(ctx context.Context, repoRef *string) (<-chan *models.IdentityEvent, error)
+	BugEvents(ctx context.Context, repoRef *string) (<-chan *models.BugEvent, error)
+}
+
+// endregion ************************** generated!.gotpl **************************
+
+// region    ***************************** args.gotpl *****************************
+
+func (ec *executionContext) field_Subscription_allEvents_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Subscription_allEvents_argsRepoRef(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["repoRef"] = arg0
+	arg1, err := ec.field_Subscription_allEvents_argsTypename(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["typename"] = arg1
+	return args, nil
+}
+func (ec *executionContext) field_Subscription_allEvents_argsRepoRef(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["repoRef"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("repoRef"))
+	if tmp, ok := rawArgs["repoRef"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Subscription_allEvents_argsTypename(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["typename"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("typename"))
+	if tmp, ok := rawArgs["typename"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Subscription_bugEvents_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Subscription_bugEvents_argsRepoRef(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["repoRef"] = arg0
+	return args, nil
+}
+func (ec *executionContext) field_Subscription_bugEvents_argsRepoRef(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["repoRef"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("repoRef"))
+	if tmp, ok := rawArgs["repoRef"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Subscription_identityEvents_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Subscription_identityEvents_argsRepoRef(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["repoRef"] = arg0
+	return args, nil
+}
+func (ec *executionContext) field_Subscription_identityEvents_argsRepoRef(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["repoRef"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("repoRef"))
+	if tmp, ok := rawArgs["repoRef"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+// endregion ***************************** args.gotpl *****************************
+
+// region    ************************** directives.gotpl **************************
+
+// endregion ************************** directives.gotpl **************************
+
+// region    **************************** field.gotpl *****************************
+
+func (ec *executionContext) _BugEvent_type(ctx context.Context, field graphql.CollectedField, obj *models.BugEvent) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BugEvent_type(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) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Type, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(cache.EntityEventType)
+	fc.Result = res
+	return ec.marshalNEntityEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋcacheᚐEntityEventType(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BugEvent_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BugEvent",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type EntityEventType does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _BugEvent_bug(ctx context.Context, field graphql.CollectedField, obj *models.BugEvent) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BugEvent_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) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Bug, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(models.BugWrapper)
+	fc.Result = res
+	return ec.marshalNBug2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BugEvent_bug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BugEvent",
+		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) _EntityEvent_type(ctx context.Context, field graphql.CollectedField, obj *models.EntityEvent) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_EntityEvent_type(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) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Type, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(cache.EntityEventType)
+	fc.Result = res
+	return ec.marshalNEntityEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋcacheᚐEntityEventType(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_EntityEvent_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "EntityEvent",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type EntityEventType does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _EntityEvent_entity(ctx context.Context, field graphql.CollectedField, obj *models.EntityEvent) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_EntityEvent_entity(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) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Entity, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(models.Entity)
+	fc.Result = res
+	return ec.marshalOEntity2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEntity(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_EntityEvent_entity(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "EntityEvent",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("FieldContext.Child cannot be called on type INTERFACE")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _IdentityEvent_type(ctx context.Context, field graphql.CollectedField, obj *models.IdentityEvent) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_IdentityEvent_type(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) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Type, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(cache.EntityEventType)
+	fc.Result = res
+	return ec.marshalNEntityEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋcacheᚐEntityEventType(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_IdentityEvent_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "IdentityEvent",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type EntityEventType does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _IdentityEvent_identity(ctx context.Context, field graphql.CollectedField, obj *models.IdentityEvent) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_IdentityEvent_identity(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) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Identity, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(models.IdentityWrapper)
+	fc.Result = res
+	return ec.marshalNIdentity2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐIdentityWrapper(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_IdentityEvent_identity(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "IdentityEvent",
+		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_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) _Subscription_allEvents(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) {
+	fc, err := ec.fieldContext_Subscription_allEvents(ctx, field)
+	if err != nil {
+		return nil
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = nil
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Subscription().AllEvents(rctx, fc.Args["repoRef"].(*string), fc.Args["typename"].(*string))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return nil
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return nil
+	}
+	return func(ctx context.Context) graphql.Marshaler {
+		select {
+		case res, ok := <-resTmp.(<-chan *models.EntityEvent):
+			if !ok {
+				return nil
+			}
+			return graphql.WriterFunc(func(w io.Writer) {
+				w.Write([]byte{'{'})
+				graphql.MarshalString(field.Alias).MarshalGQL(w)
+				w.Write([]byte{':'})
+				ec.marshalNEntityEvent2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEntityEvent(ctx, field.Selections, res).MarshalGQL(w)
+				w.Write([]byte{'}'})
+			})
+		case <-ctx.Done():
+			return nil
+		}
+	}
+}
+
+func (ec *executionContext) fieldContext_Subscription_allEvents(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Subscription",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "type":
+				return ec.fieldContext_EntityEvent_type(ctx, field)
+			case "entity":
+				return ec.fieldContext_EntityEvent_entity(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type EntityEvent", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Subscription_allEvents_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _Subscription_identityEvents(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) {
+	fc, err := ec.fieldContext_Subscription_identityEvents(ctx, field)
+	if err != nil {
+		return nil
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = nil
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Subscription().IdentityEvents(rctx, fc.Args["repoRef"].(*string))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return nil
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return nil
+	}
+	return func(ctx context.Context) graphql.Marshaler {
+		select {
+		case res, ok := <-resTmp.(<-chan *models.IdentityEvent):
+			if !ok {
+				return nil
+			}
+			return graphql.WriterFunc(func(w io.Writer) {
+				w.Write([]byte{'{'})
+				graphql.MarshalString(field.Alias).MarshalGQL(w)
+				w.Write([]byte{':'})
+				ec.marshalNIdentityEvent2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐIdentityEvent(ctx, field.Selections, res).MarshalGQL(w)
+				w.Write([]byte{'}'})
+			})
+		case <-ctx.Done():
+			return nil
+		}
+	}
+}
+
+func (ec *executionContext) fieldContext_Subscription_identityEvents(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Subscription",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "type":
+				return ec.fieldContext_IdentityEvent_type(ctx, field)
+			case "identity":
+				return ec.fieldContext_IdentityEvent_identity(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type IdentityEvent", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Subscription_identityEvents_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _Subscription_bugEvents(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) {
+	fc, err := ec.fieldContext_Subscription_bugEvents(ctx, field)
+	if err != nil {
+		return nil
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = nil
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Subscription().BugEvents(rctx, fc.Args["repoRef"].(*string))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return nil
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return nil
+	}
+	return func(ctx context.Context) graphql.Marshaler {
+		select {
+		case res, ok := <-resTmp.(<-chan *models.BugEvent):
+			if !ok {
+				return nil
+			}
+			return graphql.WriterFunc(func(w io.Writer) {
+				w.Write([]byte{'{'})
+				graphql.MarshalString(field.Alias).MarshalGQL(w)
+				w.Write([]byte{':'})
+				ec.marshalNBugEvent2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugEvent(ctx, field.Selections, res).MarshalGQL(w)
+				w.Write([]byte{'}'})
+			})
+		case <-ctx.Done():
+			return nil
+		}
+	}
+}
+
+func (ec *executionContext) fieldContext_Subscription_bugEvents(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Subscription",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "type":
+				return ec.fieldContext_BugEvent_type(ctx, field)
+			case "bug":
+				return ec.fieldContext_BugEvent_bug(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type BugEvent", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Subscription_bugEvents_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+// endregion **************************** field.gotpl *****************************
+
+// region    **************************** input.gotpl *****************************
+
+// endregion **************************** input.gotpl *****************************
+
+// region    ************************** interface.gotpl ***************************
+
+// endregion ************************** interface.gotpl ***************************
+
+// region    **************************** object.gotpl ****************************
+
+var bugEventImplementors = []string{"BugEvent"}
+
+func (ec *executionContext) _BugEvent(ctx context.Context, sel ast.SelectionSet, obj *models.BugEvent) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, bugEventImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("BugEvent")
+		case "type":
+			out.Values[i] = ec._BugEvent_type(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "bug":
+			out.Values[i] = ec._BugEvent_bug(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var entityEventImplementors = []string{"EntityEvent"}
+
+func (ec *executionContext) _EntityEvent(ctx context.Context, sel ast.SelectionSet, obj *models.EntityEvent) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, entityEventImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("EntityEvent")
+		case "type":
+			out.Values[i] = ec._EntityEvent_type(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "entity":
+			out.Values[i] = ec._EntityEvent_entity(ctx, field, obj)
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var identityEventImplementors = []string{"IdentityEvent"}
+
+func (ec *executionContext) _IdentityEvent(ctx context.Context, sel ast.SelectionSet, obj *models.IdentityEvent) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, identityEventImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("IdentityEvent")
+		case "type":
+			out.Values[i] = ec._IdentityEvent_type(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "identity":
+			out.Values[i] = ec._IdentityEvent_identity(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var subscriptionImplementors = []string{"Subscription"}
+
+func (ec *executionContext) _Subscription(ctx context.Context, sel ast.SelectionSet) func(ctx context.Context) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, subscriptionImplementors)
+	ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{
+		Object: "Subscription",
+	})
+	if len(fields) != 1 {
+		ec.Errorf(ctx, "must subscribe to exactly one stream")
+		return nil
+	}
+
+	switch fields[0].Name {
+	case "allEvents":
+		return ec._Subscription_allEvents(ctx, fields[0])
+	case "identityEvents":
+		return ec._Subscription_identityEvents(ctx, fields[0])
+	case "bugEvents":
+		return ec._Subscription_bugEvents(ctx, fields[0])
+	default:
+		panic("unknown field " + strconv.Quote(fields[0].Name))
+	}
+}
+
+// endregion **************************** object.gotpl ****************************
+
+// region    ***************************** type.gotpl *****************************
+
+func (ec *executionContext) marshalNBugEvent2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugEvent(ctx context.Context, sel ast.SelectionSet, v models.BugEvent) graphql.Marshaler {
+	return ec._BugEvent(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNBugEvent2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugEvent(ctx context.Context, sel ast.SelectionSet, v *models.BugEvent) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._BugEvent(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalNEntityEvent2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEntityEvent(ctx context.Context, sel ast.SelectionSet, v models.EntityEvent) graphql.Marshaler {
+	return ec._EntityEvent(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNEntityEvent2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEntityEvent(ctx context.Context, sel ast.SelectionSet, v *models.EntityEvent) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._EntityEvent(ctx, sel, v)
+}
+
+func (ec *executionContext) unmarshalNEntityEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋcacheᚐEntityEventType(ctx context.Context, v any) (cache.EntityEventType, error) {
+	var res cache.EntityEventType
+	err := res.UnmarshalGQL(v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNEntityEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋcacheᚐEntityEventType(ctx context.Context, sel ast.SelectionSet, v cache.EntityEventType) graphql.Marshaler {
+	return v
+}
+
+func (ec *executionContext) marshalNIdentityEvent2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐIdentityEvent(ctx context.Context, sel ast.SelectionSet, v models.IdentityEvent) graphql.Marshaler {
+	return ec._IdentityEvent(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNIdentityEvent2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐIdentityEvent(ctx context.Context, sel ast.SelectionSet, v *models.IdentityEvent) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._IdentityEvent(ctx, sel, v)
+}
+
+// endregion ***************************** type.gotpl *****************************

api/graphql/graph/types.generated.go 🔗

@@ -414,6 +414,11 @@ func (ec *executionContext) _Authored(ctx context.Context, sel ast.SelectionSet,
 			return graphql.Null
 		}
 		return ec._BugAddCommentOperation(ctx, sel, obj)
+	case models.BugWrapper:
+		if obj == nil {
+			return graphql.Null
+		}
+		return ec._Bug(ctx, sel, obj)
 	case bug.Comment:
 		return ec._BugComment(ctx, sel, &obj)
 	case *bug.Comment:
@@ -421,11 +426,25 @@ func (ec *executionContext) _Authored(ctx context.Context, sel ast.SelectionSet,
 			return graphql.Null
 		}
 		return ec._BugComment(ctx, sel, obj)
+	default:
+		panic(fmt.Errorf("unexpected type %T", obj))
+	}
+}
+
+func (ec *executionContext) _Entity(ctx context.Context, sel ast.SelectionSet, obj models.Entity) graphql.Marshaler {
+	switch obj := (obj).(type) {
+	case nil:
+		return graphql.Null
 	case models.BugWrapper:
 		if obj == nil {
 			return graphql.Null
 		}
 		return ec._Bug(ctx, sel, obj)
+	case models.IdentityWrapper:
+		if obj == nil {
+			return graphql.Null
+		}
+		return ec._Identity(ctx, sel, obj)
 	default:
 		panic(fmt.Errorf("unexpected type %T", obj))
 	}
@@ -747,6 +766,13 @@ func (ec *executionContext) marshalNTime2ᚖtimeᚐTime(ctx context.Context, sel
 	return res
 }
 
+func (ec *executionContext) marshalOEntity2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEntity(ctx context.Context, sel ast.SelectionSet, v models.Entity) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return ec._Entity(ctx, sel, v)
+}
+
 func (ec *executionContext) unmarshalOHash2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋrepositoryᚐHashᚄ(ctx context.Context, v any) ([]repository.Hash, error) {
 	if v == nil {
 		return nil, nil

api/graphql/graphql_test.go 🔗

@@ -219,3 +219,36 @@ func TestQueries(t *testing.T) {
 	err := c.Post(query, &resp)
 	assert.NoError(t, err)
 }
+
+func TestBugEventsSubscription(t *testing.T) {
+	repo := repository.CreateGoGitTestRepo(t, false)
+
+	mrc := cache.NewMultiRepoCache()
+	rc, events := mrc.RegisterDefaultRepository(repo)
+	for event := range events {
+		require.NoError(t, event.Err)
+	}
+
+	h := NewHandler(mrc, nil)
+	c := client.New(h)
+
+	sub := c.Websocket(`subscription { bugEvents { type bug { id } } }`)
+	t.Cleanup(func() { _ = sub.Close() })
+
+	rene, err := rc.Identities().New("RenĂŠ Descartes", "rene@descartes.fr")
+	require.NoError(t, err)
+	require.NoError(t, rc.SetUserIdentity(rene))
+
+	b, _, err := rc.Bugs().New("test subscription", "body")
+	require.NoError(t, err)
+
+	var resp struct {
+		BugEvents struct {
+			Type string
+			Bug  struct{ Id string }
+		}
+	}
+	require.NoError(t, sub.Next(&resp))
+	assert.Equal(t, "CREATED", resp.BugEvents.Type)
+	assert.Equal(t, b.Id().String(), resp.BugEvents.Bug.Id)
+}

api/graphql/handler.go 🔗

@@ -6,8 +6,14 @@ package graphql
 import (
 	"io"
 	"net/http"
+	"time"
 
 	"github.com/99designs/gqlgen/graphql/handler"
+	"github.com/99designs/gqlgen/graphql/handler/extension"
+	"github.com/99designs/gqlgen/graphql/handler/lru"
+	"github.com/99designs/gqlgen/graphql/handler/transport"
+	"github.com/gorilla/websocket"
+	"github.com/vektah/gqlparser/v2/ast"
 
 	"github.com/git-bug/git-bug/api/graphql/graph"
 	"github.com/git-bug/git-bug/api/graphql/resolvers"
@@ -23,7 +29,26 @@ type Handler struct {
 func NewHandler(mrc *cache.MultiRepoCache, errorOut io.Writer) Handler {
 	rootResolver := resolvers.NewRootResolver(mrc)
 	config := graph.Config{Resolvers: rootResolver}
-	h := handler.NewDefaultServer(graph.NewExecutableSchema(config))
+
+	h := handler.New(graph.NewExecutableSchema(config))
+
+	h.AddTransport(transport.Websocket{
+		KeepAlivePingInterval: 10 * time.Second,
+		Upgrader: websocket.Upgrader{
+			CheckOrigin: func(r *http.Request) bool { return true },
+		},
+	})
+	h.AddTransport(transport.Options{})
+	h.AddTransport(transport.GET{})
+	h.AddTransport(transport.POST{})
+	h.AddTransport(transport.MultipartForm{})
+
+	h.SetQueryCache(lru.New[*ast.QueryDocument](1000))
+
+	h.Use(extension.Introspection{})
+	h.Use(extension.AutomaticPersistedQuery{
+		Cache: lru.New[string](100),
+	})
 
 	if errorOut != nil {
 		h.Use(&Tracer{Out: errorOut})

api/graphql/models/gen_models.go 🔗

@@ -3,6 +3,7 @@
 package models
 
 import (
+	"github.com/git-bug/git-bug/cache"
 	"github.com/git-bug/git-bug/entities/bug"
 	"github.com/git-bug/git-bug/entities/common"
 	"github.com/git-bug/git-bug/entity/dag"
@@ -14,6 +15,11 @@ type Authored interface {
 	IsAuthored()
 }
 
+// An entity (identity, bug, ...).
+type Entity interface {
+	IsEntity()
+}
+
 type BugAddCommentAndCloseInput struct {
 	// A unique identifier for the client performing the mutation.
 	ClientMutationID *string `json:"clientMutationId,omitempty"`
@@ -183,6 +189,11 @@ type BugEditCommentPayload struct {
 	Operation *bug.EditCommentOperation `json:"operation"`
 }
 
+type BugEvent struct {
+	Type cache.EntityEventType `json:"type"`
+	Bug  BugWrapper            `json:"bug"`
+}
+
 type BugSetTitleInput struct {
 	// A unique identifier for the client performing the mutation.
 	ClientMutationID *string `json:"clientMutationId,omitempty"`
@@ -253,6 +264,11 @@ type BugTimelineItemEdge struct {
 	Node   bug.TimelineItem `json:"node"`
 }
 
+type EntityEvent struct {
+	Type   cache.EntityEventType `json:"type"`
+	Entity Entity                `json:"entity,omitempty"`
+}
+
 type IdentityConnection struct {
 	Edges      []*IdentityEdge   `json:"edges"`
 	Nodes      []IdentityWrapper `json:"nodes"`
@@ -265,6 +281,11 @@ type IdentityEdge struct {
 	Node   IdentityWrapper `json:"node"`
 }
 
+type IdentityEvent struct {
+	Type     cache.EntityEventType `json:"type"`
+	Identity IdentityWrapper       `json:"identity"`
+}
+
 type LabelConnection struct {
 	Edges      []*LabelEdge   `json:"edges"`
 	Nodes      []common.Label `json:"nodes"`
@@ -308,3 +329,6 @@ type PageInfo struct {
 
 type Query struct {
 }
+
+type Subscription struct {
+}

api/graphql/models/lazy_bug.go 🔗

@@ -28,12 +28,15 @@ type BugWrapper interface {
 	Timeline() ([]bug.TimelineItem, error)
 	Operations() ([]dag.Operation, error)
 
+	// IsAuthored is a sign-post method for gqlgen, to mark compliance to an interface.
 	IsAuthored()
+	// IsEntity is a sign post-method for gqlgen, to mark compliance to an interface.
+	IsEntity()
 }
 
 var _ BugWrapper = &lazyBug{}
 
-// lazyBug is a lazy-loading wrapper that fetch data from the cache (BugExcerpt) in priority,
+// lazyBug is a lazy-loading wrapper that fetches data from the cache (BugExcerpt) in priority,
 // and load the complete bug and snapshot only when necessary.
 type lazyBug struct {
 	cache   *cache.RepoCache
@@ -75,9 +78,6 @@ func (lb *lazyBug) identity(id entity.Id) (IdentityWrapper, error) {
 	return &lazyIdentity{cache: lb.cache, excerpt: i}, nil
 }
 
-// Sign post method for gqlgen
-func (lb *lazyBug) IsAuthored() {}
-
 func (lb *lazyBug) Id() entity.Id {
 	return lb.excerpt.Id()
 }
@@ -154,6 +154,12 @@ func (lb *lazyBug) Operations() ([]dag.Operation, error) {
 	return lb.snap.Operations, nil
 }
 
+// IsAuthored is a sign-post method for gqlgen, to mark compliance to an interface.
+func (lb *lazyBug) IsAuthored() {}
+
+// IsEntity is a sign post-method for gqlgen, to mark compliance to an interface.
+func (lb *lazyBug) IsEntity() {}
+
 var _ BugWrapper = &loadedBug{}
 
 type loadedBug struct {
@@ -215,3 +221,9 @@ func (l *loadedBug) Timeline() ([]bug.TimelineItem, error) {
 func (l *loadedBug) Operations() ([]dag.Operation, error) {
 	return l.Snapshot.Operations, nil
 }
+
+// IsAuthored is a sign-post method for gqlgen, to mark compliance to an interface.
+func (l *loadedBug) IsAuthored() {}
+
+// IsEntity is a sign post-method for gqlgen, to mark compliance to an interface.
+func (l *loadedBug) IsEntity() {}

api/graphql/models/lazy_identity.go 🔗

@@ -21,6 +21,9 @@ type IdentityWrapper interface {
 	Keys() ([]*identity.Key, error)
 	DisplayName() string
 	IsProtected() (bool, error)
+
+	// IsEntity is a sign post-method for gqlgen, to mark compliance to an interface.
+	IsEntity()
 }
 
 var _ IdentityWrapper = &lazyIdentity{}
@@ -108,6 +111,9 @@ func (li *lazyIdentity) IsProtected() (bool, error) {
 	return id.IsProtected(), nil
 }
 
+// IsEntity is a sign post-method for gqlgen, to mark compliance to an interface.
+func (li *lazyIdentity) IsEntity() {}
+
 var _ IdentityWrapper = &loadedIdentity{}
 
 type loadedIdentity struct {
@@ -137,3 +143,6 @@ func (l loadedIdentity) Keys() ([]*identity.Key, error) {
 func (l loadedIdentity) IsProtected() (bool, error) {
 	return l.Interface.IsProtected(), nil
 }
+
+// IsEntity is a sign post-method for gqlgen, to mark compliance to an interface.
+func (l loadedIdentity) IsEntity() {}

api/graphql/resolvers/root.go 🔗

@@ -31,6 +31,12 @@ func (r RootResolver) Mutation() graph.MutationResolver {
 	}
 }
 
+func (r RootResolver) Subscription() graph.SubscriptionResolver {
+	return &subscriptionResolver{
+		cache: r.MultiRepoCache,
+	}
+}
+
 func (RootResolver) Color() graph.ColorResolver {
 	return &colorResolver{}
 }

api/graphql/resolvers/subscription.go 🔗

@@ -0,0 +1,148 @@
+package resolvers
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/git-bug/git-bug/api/graphql/graph"
+	"github.com/git-bug/git-bug/api/graphql/models"
+	"github.com/git-bug/git-bug/cache"
+	"github.com/git-bug/git-bug/entities/bug"
+	"github.com/git-bug/git-bug/entities/identity"
+	"github.com/git-bug/git-bug/entity"
+)
+
+var _ graph.SubscriptionResolver = &subscriptionResolver{}
+
+type subscriptionResolver struct {
+	cache *cache.MultiRepoCache
+}
+
+func (s subscriptionResolver) AllEvents(ctx context.Context, repoRef *string, typename *string) (<-chan *models.EntityEvent, error) {
+	out := make(chan *models.EntityEvent)
+	sub := &subscription[models.EntityEvent]{
+		cache: s.cache,
+		out:   out,
+		makeEvent: func(repo *cache.RepoCache, excerpt cache.Excerpt, eventType cache.EntityEventType) *models.EntityEvent {
+			switch excerpt := excerpt.(type) {
+			case *cache.BugExcerpt:
+				return &models.EntityEvent{Type: eventType, Entity: models.NewLazyBug(repo, excerpt)}
+			case *cache.IdentityExcerpt:
+				return &models.EntityEvent{Type: eventType, Entity: models.NewLazyIdentity(repo, excerpt)}
+			default:
+				panic(fmt.Sprintf("unknown excerpt type: %T", excerpt))
+			}
+		},
+	}
+
+	var repoRefStr string
+	if repoRef != nil {
+		repoRefStr = *repoRef
+	}
+
+	var typenameStr string
+	if typename != nil {
+		typenameStr = *typename
+	}
+
+	err := s.cache.RegisterObserver(sub, repoRefStr, typenameStr)
+	if err != nil {
+		return nil, err
+	}
+
+	go func() {
+		<-ctx.Done()
+		s.cache.UnregisterObserver(sub)
+	}()
+
+	return out, nil
+}
+
+func (s subscriptionResolver) BugEvents(ctx context.Context, repoRef *string) (<-chan *models.BugEvent, error) {
+	out := make(chan *models.BugEvent)
+	sub := &subscription[models.BugEvent]{
+		cache: s.cache,
+		out:   out,
+		makeEvent: func(repo *cache.RepoCache, excerpt cache.Excerpt, event cache.EntityEventType) *models.BugEvent {
+			return &models.BugEvent{Type: event, Bug: models.NewLazyBug(repo, excerpt.(*cache.BugExcerpt))}
+		},
+	}
+
+	var repoRefStr string
+	if repoRef != nil {
+		repoRefStr = *repoRef
+	}
+
+	err := s.cache.RegisterObserver(sub, repoRefStr, bug.Typename)
+	if err != nil {
+		return nil, err
+	}
+
+	go func() {
+		<-ctx.Done()
+		s.cache.UnregisterObserver(sub)
+	}()
+
+	return out, nil
+}
+
+func (s subscriptionResolver) IdentityEvents(ctx context.Context, repoRef *string) (<-chan *models.IdentityEvent, error) {
+	out := make(chan *models.IdentityEvent)
+	sub := &subscription[models.IdentityEvent]{
+		cache: s.cache,
+		out:   out,
+		makeEvent: func(repo *cache.RepoCache, excerpt cache.Excerpt, event cache.EntityEventType) *models.IdentityEvent {
+			return &models.IdentityEvent{Type: event, Identity: models.NewLazyIdentity(repo, excerpt.(*cache.IdentityExcerpt))}
+		},
+	}
+
+	var repoRefStr string
+	if repoRef != nil {
+		repoRefStr = *repoRef
+	}
+
+	err := s.cache.RegisterObserver(sub, repoRefStr, identity.Typename)
+	if err != nil {
+		return nil, err
+	}
+
+	go func() {
+		<-ctx.Done()
+		s.cache.UnregisterObserver(sub)
+	}()
+
+	return out, nil
+}
+
+var _ cache.Observer = &subscription[any]{}
+
+type subscription[eventT any] struct {
+	cache     *cache.MultiRepoCache
+	out       chan *eventT
+	filter    func(cache.Excerpt) bool
+	makeEvent func(repo *cache.RepoCache, excerpt cache.Excerpt, event cache.EntityEventType) *eventT
+}
+
+func (s subscription[eventT]) EntityEvent(event cache.EntityEventType, repoName string, typename string, id entity.Id) {
+	repo, err := s.cache.ResolveRepo(repoName)
+	if err != nil {
+		// something terrible happened
+		return
+	}
+	var excerpt cache.Excerpt
+	switch typename {
+	case bug.Typename:
+		excerpt, err = repo.Bugs().ResolveExcerpt(id)
+	case identity.Typename:
+		excerpt, err = repo.Identities().ResolveExcerpt(id)
+	default:
+		panic(fmt.Sprintf("unknown typename: %s", typename))
+	}
+	if err != nil {
+		return
+	}
+	if s.filter != nil && !s.filter(excerpt) {
+		return
+	}
+	s.out <- s.makeEvent(repo, excerpt, event)
+}

api/graphql/schema/bug.graphql 🔗

@@ -1,4 +1,4 @@
-type Bug implements Authored {
+type Bug implements Authored & Entity {
   """The identifier for this bug"""
   id: ID!
   """The human version (truncated) identifier for this bug"""

api/graphql/schema/identity.graphql 🔗

@@ -1,5 +1,5 @@
 """Represents an identity"""
-type Identity {
+type Identity implements Entity {
     """The identifier for this identity"""
     id: ID!
     """The human version (truncated) identifier for this identity"""

api/graphql/schema/operation.graphql 🔗

@@ -1,5 +1,6 @@
 """An operation applied to an entity."""
-interface Operation {
+interface Operation
+@goModel(model: "github.com/git-bug/git-bug/entity/dag.Operation") {
     """The identifier of the operation"""
     id: ID!
     """The operations author."""

api/graphql/schema/subscription.graphql 🔗

@@ -0,0 +1,29 @@
+type Subscription {
+  """Subscribe to events on all entities. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events."""
+  allEvents(repoRef: String, typename: String): EntityEvent!
+  """Subscribe to identity entity events. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events."""
+  identityEvents(repoRef: String): IdentityEvent!
+  """Subscribe to bug entity events. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events."""
+  bugEvents(repoRef: String): BugEvent!
+}
+
+enum EntityEventType {
+  CREATED
+  UPDATED
+  REMOVED
+}
+
+type EntityEvent {
+  type: EntityEventType!
+  entity: Entity
+}
+
+type IdentityEvent {
+  type: EntityEventType!
+  identity: Identity!
+}
+
+type BugEvent {
+  type: EntityEventType!
+  bug: Bug!
+}

api/graphql/schema/types.graphql 🔗

@@ -29,3 +29,12 @@ interface Authored {
     """The author of this object."""
     author: Identity!
 }
+
+
+"""An entity (identity, bug, ...)."""
+interface Entity {
+  """The identifier for this entity"""
+  id: ID!
+  """The human version (truncated) identifier for this entity"""
+  humanId: String!
+}

cache/events.go 🔗

@@ -0,0 +1,84 @@
+package cache
+
+import (
+	"fmt"
+	"io"
+	"strconv"
+
+	"github.com/git-bug/git-bug/entity"
+)
+
+type BuildEventType int
+
+const (
+	_ BuildEventType = iota
+	// BuildEventCacheIsBuilt signal that the cache is being built (aka, not skipped)
+	BuildEventCacheIsBuilt
+	// BuildEventRemoveLock signal that an old repo lock has been cleaned
+	BuildEventRemoveLock
+	// BuildEventStarted signal the beginning of a cache build for an entity
+	BuildEventStarted
+	// BuildEventProgress signal progress in the cache building for an entity
+	BuildEventProgress
+	// BuildEventFinished signal the end of a cache build for an entity
+	BuildEventFinished
+)
+
+// BuildEvent carry an event happening during the cache build process.
+type BuildEvent struct {
+	// Err carry an error if the build process failed. If set, no other field matters.
+	Err error
+	// Typename is the name of the entity of which the event relate to. Can be empty if no particular entity is involved.
+	Typename string
+	// Event is the type of the event.
+	Event BuildEventType
+	// Total is the total number of elements being built. Set if Event is BuildEventStarted.
+	Total int64
+	// Progress is the current count of processed elements. Set if Event is BuildEventProgress.
+	Progress int64
+}
+
+type EntityEventType int
+
+const (
+	_ EntityEventType = iota
+	EntityEventCreated
+	EntityEventUpdated
+	EntityEventRemoved
+)
+
+// Observer gets notified of changes in entities in the cache
+type Observer interface {
+	EntityEvent(event EntityEventType, repoName string, typename string, id entity.Id)
+}
+
+func (e EntityEventType) MarshalGQL(w io.Writer) {
+	switch e {
+	case EntityEventCreated:
+		_, _ = w.Write([]byte(strconv.Quote("CREATED")))
+	case EntityEventUpdated:
+		_, _ = w.Write([]byte(strconv.Quote("UPDATED")))
+	case EntityEventRemoved:
+		_, _ = w.Write([]byte(strconv.Quote("REMOVED")))
+	default:
+		panic("missing case")
+	}
+}
+
+func (e *EntityEventType) UnmarshalGQL(v interface{}) error {
+	str, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("enums must be strings")
+	}
+	switch str {
+	case "CREATED":
+		*e = EntityEventCreated
+	case "UPDATED":
+		*e = EntityEventUpdated
+	case "REMOVED":
+		*e = EntityEventRemoved
+	default:
+		return fmt.Errorf("%s is not a valid EntityEventType", str)
+	}
+	return nil
+}

cache/filter.go 🔗

@@ -8,17 +8,17 @@ import (
 	"github.com/git-bug/git-bug/query"
 )
 
-// Filter is a predicate that match a subset of bugs
+// Filter is a predicate that matches a subset of bugs
 type Filter func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool
 
-// StatusFilter return a Filter that match a bug status
+// StatusFilter return a Filter that matches a bug status
 func StatusFilter(status common.Status) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return excerpt.Status == status
 	}
 }
 
-// AuthorFilter return a Filter that match a bug author
+// AuthorFilter return a Filter that matches a bug author
 func AuthorFilter(query string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
@@ -32,7 +32,7 @@ func AuthorFilter(query string) Filter {
 	}
 }
 
-// MetadataFilter return a Filter that match a bug metadata at creation time
+// MetadataFilter return a Filter that matches a bug metadata at creation time
 func MetadataFilter(pair query.StringPair) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		if value, ok := excerpt.CreateMetadata[pair.Key]; ok {
@@ -42,7 +42,7 @@ func MetadataFilter(pair query.StringPair) Filter {
 	}
 }
 
-// LabelFilter return a Filter that match a label
+// LabelFilter return a Filter that matches a label
 func LabelFilter(label string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		for _, l := range excerpt.Labels {
@@ -54,7 +54,7 @@ func LabelFilter(label string) Filter {
 	}
 }
 
-// ActorFilter return a Filter that match a bug actor
+// ActorFilter return a Filter that matches a bug actor
 func ActorFilter(query string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
@@ -73,7 +73,7 @@ func ActorFilter(query string) Filter {
 	}
 }
 
-// ParticipantFilter return a Filter that match a bug participant
+// ParticipantFilter return a Filter that matches a bug participant
 func ParticipantFilter(query string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
@@ -92,7 +92,7 @@ func ParticipantFilter(query string) Filter {
 	}
 }
 
-// TitleFilter return a Filter that match if the title contains the given query
+// TitleFilter return a Filter that matches if the title contains the given query
 func TitleFilter(query string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return strings.Contains(
@@ -102,7 +102,7 @@ func TitleFilter(query string) Filter {
 	}
 }
 
-// NoLabelFilter return a Filter that match the absence of labels
+// NoLabelFilter return a Filter that matches the absence of labels
 func NoLabelFilter() Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return len(excerpt.Labels) == 0
@@ -154,7 +154,7 @@ func compileMatcher(filters query.Filters) *Matcher {
 	return result
 }
 
-// Match check if a bug match the set of filters
+// Match check if a bug matches the set of filters
 func (f *Matcher) Match(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 	if match := f.orMatch(f.Status, excerpt, resolvers); !match {
 		return false

cache/identity_subcache.go 🔗

@@ -1,8 +1,6 @@
 package cache
 
 import (
-	"fmt"
-
 	"github.com/git-bug/git-bug/entities/identity"
 	"github.com/git-bug/git-bug/entity"
 	"github.com/git-bug/git-bug/repository"
@@ -106,17 +104,7 @@ func (c *RepoCacheIdentity) finishIdentity(i *identity.Identity, metadata map[st
 		return nil, err
 	}
 
-	c.mu.Lock()
-	if _, has := c.cached[i.Id()]; has {
-		return nil, fmt.Errorf("identity %s already exist in the cache", i.Id())
-	}
-
-	cached := NewIdentityCache(i, c.repo, c.entityUpdated)
-	c.cached[i.Id()] = cached
-	c.mu.Unlock()
-
-	// force the write of the excerpt
-	err = c.entityUpdated(i.Id())
+	cached, err := c.add(i)
 	if err != nil {
 		return nil, err
 	}

cache/multi_repo_cache.go 🔗

@@ -20,11 +20,11 @@ func NewMultiRepoCache() *MultiRepoCache {
 	}
 }
 
-// RegisterRepository register a named repository. Use this for multi-repo setup
+// RegisterRepository registers a named repository. Use this for multi-repo setup
 func (c *MultiRepoCache) RegisterRepository(repo repository.ClockedRepo, name string) (*RepoCache, chan BuildEvent) {
 	r, events := NewNamedRepoCache(repo, name)
 
-	// intercept events to make sure the cache building process succeed properly
+	// intercept events to make sure the cache building process succeeds properly
 	out := make(chan BuildEvent)
 	go func() {
 		defer close(out)
@@ -42,12 +42,12 @@ func (c *MultiRepoCache) RegisterRepository(repo repository.ClockedRepo, name st
 	return r, out
 }
 
-// RegisterDefaultRepository register an unnamed repository. Use this for single-repo setup
+// RegisterDefaultRepository registers an unnamed repository. Use this for single-repo setup
 func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) (*RepoCache, chan BuildEvent) {
 	return c.RegisterRepository(repo, defaultRepoName)
 }
 
-// DefaultRepo retrieve the default repository
+// DefaultRepo retrieves the default repository
 func (c *MultiRepoCache) DefaultRepo() (*RepoCache, error) {
 	if len(c.repos) != 1 {
 		return nil, fmt.Errorf("repository is not unique")
@@ -60,7 +60,7 @@ func (c *MultiRepoCache) DefaultRepo() (*RepoCache, error) {
 	panic("unreachable")
 }
 
-// ResolveRepo retrieve a repository by name
+// ResolveRepo retrieves a repository by name
 func (c *MultiRepoCache) ResolveRepo(name string) (*RepoCache, error) {
 	r, ok := c.repos[name]
 	if !ok {
@@ -69,6 +69,46 @@ func (c *MultiRepoCache) ResolveRepo(name string) (*RepoCache, error) {
 	return r, nil
 }
 
+// RegisterObserver registers an Observer on repo and entity, according to nameFilter and typename.
+// - if nameFilter is empty, the observer is registered on all available repo
+// - if nameFilter is not empty, the observer is registered on the repo with the matching name
+// - if typename is empty, the observer is registered on all available entities
+// - if typename is not empty, the observer is registered on the matching entity type only
+func (c *MultiRepoCache) RegisterObserver(observer Observer, nameFilter string, typename string) error {
+	if nameFilter == "" {
+		for repoName, repo := range c.repos {
+			if typename == "" {
+				repo.registerAllObservers(repoName, observer)
+			} else {
+				if err := repo.registerObserver(repoName, typename, observer); err != nil {
+					return err
+				}
+			}
+		}
+		return nil
+	}
+
+	r, err := c.ResolveRepo(nameFilter)
+	if err != nil {
+		return err
+	}
+	if typename == "" {
+		r.registerAllObservers(r.Name(), observer)
+	} else {
+		if err := r.registerObserver(r.Name(), typename, observer); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// UnregisterObserver deregisters the observer from all repos and all entity types.
+func (c *MultiRepoCache) UnregisterObserver(observer Observer) {
+	for _, repo := range c.repos {
+		repo.unregisterAllObservers(observer)
+	}
+}
+
 // Close will do anything that is needed to close the cache properly
 func (c *MultiRepoCache) Close() error {
 	for _, cachedRepo := range c.repos {

cache/repo_cache.go 🔗

@@ -5,8 +5,11 @@ import (
 	"io"
 	"os"
 	"strconv"
+	"strings"
 	"sync"
 
+	"github.com/git-bug/git-bug/entities/bug"
+	"github.com/git-bug/git-bug/entities/identity"
 	"github.com/git-bug/git-bug/entity"
 	"github.com/git-bug/git-bug/repository"
 	"github.com/git-bug/git-bug/util/multierr"
@@ -35,6 +38,8 @@ type cacheMgmt interface {
 	RemoveAll() error
 	MergeAll(remote string) <-chan entity.MergeResult
 	GetNamespace() string
+	RegisterObserver(repoName string, observer Observer)
+	UnregisterObserver(observer Observer)
 	Close() error
 }
 
@@ -42,14 +47,14 @@ type cacheMgmt interface {
 //
 //  1. After being loaded, a Bug is kept in memory in the cache, allowing for fast
 //     access later.
-//  2. The cache maintain in memory and on disk a pre-digested excerpt for each bug,
+//  2. The cache maintains in memory and on disk a pre-digested excerpt for each bug,
 //     allowing for fast querying the whole set of bugs without having to load
 //     them individually.
-//  3. The cache guarantee that a single instance of a Bug is loaded at once, avoiding
+//  3. The cache guarantees that a single instance of a Bug is loaded at once, avoiding
 //     loss of data that we could have with multiple copies in the same process.
-//  4. The same way, the cache maintain in memory a single copy of the loaded identities.
+//  4. The same way, the cache maintains in memory a single copy of the loaded identities.
 //
-// The cache also protect the on-disk data by locking the git repository for its
+// The cache also protects the on-disk data by locking the git repository for its
 // own usage, by writing a lock file. Of course, normal git operations are not
 // affected, only git-bug related one.
 type RepoCache struct {
@@ -101,7 +106,7 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan
 		&BugExcerpt{}:      entity.ResolverFunc[*BugExcerpt](c.bugs.ResolveExcerpt),
 	}
 
-	// small buffer so that below functions can emit an event without blocking
+	// small buffer so that the functions below can emit an event without blocking
 	events := make(chan BuildEvent)
 
 	go func() {
@@ -206,36 +211,6 @@ func (c *RepoCache) Close() error {
 	return c.repo.LocalStorage().Remove(lockfile)
 }
 
-type BuildEventType int
-
-const (
-	_ BuildEventType = iota
-	// BuildEventCacheIsBuilt signal that the cache is being built (aka, not skipped)
-	BuildEventCacheIsBuilt
-	// BuildEventRemoveLock signal that an old repo lock has been cleaned
-	BuildEventRemoveLock
-	// BuildEventStarted signal the beginning of a cache build for an entity
-	BuildEventStarted
-	// BuildEventProgress signal progress in the cache building for an entity
-	BuildEventProgress
-	// BuildEventFinished signal the end of a cache build for an entity
-	BuildEventFinished
-)
-
-// BuildEvent carry an event happening during the cache build process.
-type BuildEvent struct {
-	// Err carry an error if the build process failed. If set, no other field matter.
-	Err error
-	// Typename is the name of the entity of which the event relate to. Can be empty if not particular entity is involved.
-	Typename string
-	// Event is the type of the event.
-	Event BuildEventType
-	// Total is the total number of element being built. Set if Event is BuildEventStarted.
-	Total int64
-	// Progress is the current count of processed element. Set if Event is BuildEventProgress.
-	Progress int64
-}
-
 func (c *RepoCache) buildCache(events chan BuildEvent) {
 	events <- BuildEvent{Event: BuildEventCacheIsBuilt}
 
@@ -257,6 +232,34 @@ func (c *RepoCache) buildCache(events chan BuildEvent) {
 	wg.Wait()
 }
 
+func (c *RepoCache) registerObserver(repoName string, typename string, observer Observer) error {
+	switch typename {
+	case bug.Typename:
+		c.bugs.RegisterObserver(repoName, observer)
+	case identity.Typename:
+		c.identities.RegisterObserver(repoName, observer)
+	default:
+		var allTypenames []string
+		for _, subcache := range c.subcaches {
+			allTypenames = append(allTypenames, subcache.Typename())
+		}
+		return fmt.Errorf("unknown typename `%s`, available types are [%s]", typename, strings.Join(allTypenames, ", "))
+	}
+	return nil
+}
+
+func (c *RepoCache) registerAllObservers(repoName string, observer Observer) {
+	for _, subcache := range c.subcaches {
+		subcache.RegisterObserver(repoName, observer)
+	}
+}
+
+func (c *RepoCache) unregisterAllObservers(observer Observer) {
+	for _, subcache := range c.subcaches {
+		subcache.UnregisterObserver(observer)
+	}
+}
+
 // repoIsAvailable check is the given repository is locked by a Cache.
 // Note: this is a smart function that will clean the lock file if the
 // corresponding process is not there anymore.

cache/repo_cache_test.go 🔗

@@ -16,6 +16,30 @@ import (
 	"github.com/git-bug/git-bug/repository"
 )
 
+type observerEvent struct {
+	typename string
+	id       entity.Id
+}
+
+var _ Observer = &observer{}
+
+type observer struct {
+	created []observerEvent
+	updated []observerEvent
+	removed []observerEvent
+}
+
+func (o *observer) EntityEvent(event EntityEventType, _ string, typename string, id entity.Id) {
+	switch event {
+	case EntityEventCreated:
+		o.created = append(o.created, observerEvent{typename, id})
+	case EntityEventUpdated:
+		o.updated = append(o.updated, observerEvent{typename, id})
+	case EntityEventRemoved:
+		o.removed = append(o.removed, observerEvent{typename, id})
+	}
+}
+
 func TestCache(t *testing.T) {
 	f := test.NewFlaky(t, &test.FlakyOptions{
 		MaxAttempts: 5,
@@ -23,23 +47,34 @@ func TestCache(t *testing.T) {
 
 	f.Run(func(t testing.TB) {
 		repo := repository.CreateGoGitTestRepo(t, false)
+
 		indexCount := func(t testing.TB, name string) uint64 {
 			t.Helper()
-
 			idx, err := repo.GetIndex(name)
 			require.NoError(t, err)
 			count, err := idx.DocCount()
 			require.NoError(t, err)
-
 			return count
 		}
+		assertOberserverEvent := func(obs observer, created, updated, removed int) {
+			t.Helper()
+			require.Len(t, obs.created, created)
+			require.Len(t, obs.updated, updated)
+			require.Len(t, obs.removed, removed)
+		}
 
 		cache, err := NewRepoCacheNoEvents(repo)
 		require.NoError(t, err)
 
+		var obsIdentity, obsBug observer
+		require.NoError(t, cache.registerObserver("repotest", identity.Typename, &obsIdentity))
+		require.NoError(t, cache.registerObserver("repotest", bug.Typename, &obsBug))
+
 		// Create, set and get user identity
 		iden1, err := cache.Identities().New("RenĂŠ Descartes", "rene@descartes.fr")
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 1, 0, 0)
+		assertOberserverEvent(obsBug, 0, 0, 0)
 		err = cache.SetUserIdentity(iden1)
 		require.NoError(t, err)
 		userIden, err := cache.GetUserIdentity()
@@ -49,11 +84,13 @@ func TestCache(t *testing.T) {
 		// it's possible to create two identical identities
 		iden2, err := cache.Identities().New("RenĂŠ Descartes", "rene@descartes.fr")
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 2, 0, 0)
+		assertOberserverEvent(obsBug, 0, 0, 0)
 
 		// Two identical identities yield a different id
 		require.NotEqual(t, iden1.Id(), iden2.Id())
 
-		// There is now two identities in the cache
+		// There are now two identities in the cache
 		require.Len(t, cache.Identities().AllIds(), 2)
 		require.Len(t, cache.identities.excerpts, 2)
 		require.Len(t, cache.identities.cached, 2)
@@ -63,10 +100,14 @@ func TestCache(t *testing.T) {
 		// Create a bug
 		bug1, _, err := cache.Bugs().New("title", "message")
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 2, 0, 0)
+		assertOberserverEvent(obsBug, 1, 0, 0)
 
 		// It's possible to create two identical bugs
 		bug2, _, err := cache.Bugs().New("title", "marker")
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 2, 0, 0)
+		assertOberserverEvent(obsBug, 2, 0, 0)
 
 		// two identical bugs yield a different id
 		require.NotEqual(t, bug1.Id(), bug2.Id())
@@ -106,6 +147,12 @@ func TestCache(t *testing.T) {
 		require.NoError(t, err)
 		require.Len(t, res, 1)
 
+		// Updating
+		_, _, err = bug1.AddComment("new comment")
+		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 2, 0, 0)
+		assertOberserverEvent(obsBug, 2, 1, 0)
+
 		// Close
 		require.NoError(t, cache.Close())
 		require.Empty(t, cache.bugs.cached)
@@ -117,6 +164,8 @@ func TestCache(t *testing.T) {
 		// to check the signatures, we also load the identity used above
 		cache, err = NewRepoCacheNoEvents(repo)
 		require.NoError(t, err)
+		require.NoError(t, cache.registerObserver("repotest", identity.Typename, &obsIdentity))
+		require.NoError(t, cache.registerObserver("repotest", bug.Typename, &obsBug))
 
 		require.Len(t, cache.bugs.cached, 0)
 		require.Len(t, cache.bugs.excerpts, 2)
@@ -150,8 +199,12 @@ func TestCache(t *testing.T) {
 		// Remove + RemoveAll
 		err = cache.Identities().Remove(iden1.Id().String()[:10])
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 2, 0, 1)
+		assertOberserverEvent(obsBug, 2, 1, 0)
 		err = cache.Bugs().Remove(bug1.Id().String()[:10])
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 2, 0, 1)
+		assertOberserverEvent(obsBug, 2, 1, 1)
 		require.Len(t, cache.bugs.cached, 0)
 		require.Len(t, cache.bugs.excerpts, 1)
 		require.Len(t, cache.identities.cached, 0)
@@ -161,11 +214,17 @@ func TestCache(t *testing.T) {
 
 		_, err = cache.Identities().New("RenĂŠ Descartes", "rene@descartes.fr")
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 3, 0, 1)
+		assertOberserverEvent(obsBug, 2, 1, 1)
 		_, _, err = cache.Bugs().NewRaw(iden2, time.Now().Unix(), "title", "message", nil, nil)
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 3, 0, 1)
+		assertOberserverEvent(obsBug, 3, 1, 1)
 
 		err = cache.RemoveAll()
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 3, 0, 3)
+		assertOberserverEvent(obsBug, 3, 1, 3)
 		require.Len(t, cache.bugs.cached, 0)
 		require.Len(t, cache.bugs.excerpts, 0)
 		require.Len(t, cache.identities.cached, 0)

cache/subcache.go 🔗

@@ -59,6 +59,9 @@ type SubCache[EntityT entity.Interface, ExcerptT Excerpt, CacheT CacheEntity] st
 	excerpts map[entity.Id]ExcerptT
 	cached   map[entity.Id]CacheT
 	lru      lruIdCache
+
+	muObservers sync.RWMutex
+	observers   map[Observer]string // observer --> repo name
 }
 
 func NewSubCache[EntityT entity.Interface, ExcerptT Excerpt, CacheT CacheEntity](
@@ -332,6 +335,21 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Close() error {
 	return nil
 }
 
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) RegisterObserver(repoName string, observer Observer) {
+	sc.muObservers.Lock()
+	defer sc.muObservers.Unlock()
+	if sc.observers == nil {
+		sc.observers = make(map[Observer]string)
+	}
+	sc.observers[observer] = repoName
+}
+
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) UnregisterObserver(observer Observer) {
+	sc.muObservers.Lock()
+	defer sc.muObservers.Unlock()
+	delete(sc.observers, observer)
+}
+
 // AllIds return all known bug ids
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) AllIds() []entity.Id {
 	sc.mu.RLock()
@@ -392,7 +410,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveMatcher(f func(ExcerptT) b
 	return sc.Resolve(id)
 }
 
-// ResolveExcerpt retrieve an Excerpt matching the exact given id
+// ResolveExcerpt retrieves an Excerpt matching the exact given id
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerpt(id entity.Id) (ExcerptT, error) {
 	sc.mu.RLock()
 	defer sc.mu.RUnlock()
@@ -405,7 +423,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerpt(id entity.Id) (Exc
 	return excerpt, nil
 }
 
-// ResolveExcerptPrefix retrieve an Excerpt matching an id prefix. It fails if multiple
+// ResolveExcerptPrefix retrieves an Excerpt matching an id prefix. It fails if multiple
 // entities match.
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerptPrefix(prefix string) (ExcerptT, error) {
 	return sc.ResolveExcerptMatcher(func(excerpt ExcerptT) bool {
@@ -413,6 +431,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerptPrefix(prefix strin
 	})
 }
 
+// ResolveExcerptMatcher retrieves an Excerpt selected by the given matcher function.
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerptMatcher(f func(ExcerptT) bool) (ExcerptT, error) {
 	id, err := sc.resolveMatcher(f)
 	if err != nil {
@@ -460,11 +479,14 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) add(e EntityT) (CacheT, error) {
 	sc.evictIfNeeded()
 
 	// force the write of the excerpt
-	err := sc.entityUpdated(e.Id())
+	err := sc.updateExcerptAndIndex(e.Id())
 	if err != nil {
 		return *new(CacheT), err
 	}
 
+	// defer to notify after the release of the mutex
+	defer sc.notifyObservers(EntityEventCreated, e.Id())
+
 	return cached, nil
 }
 
@@ -498,6 +520,9 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Remove(prefix string) error {
 		return err
 	}
 
+	// defer to notify after the release of the mutex
+	defer sc.notifyObservers(EntityEventRemoved, e.Id())
+
 	return sc.write()
 }
 
@@ -510,12 +535,16 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) RemoveAll() error {
 		return err
 	}
 
+	ids := make(map[entity.Id]struct{})
+
 	for id, _ := range sc.cached {
 		delete(sc.cached, id)
 		sc.lru.Remove(id)
+		ids[id] = struct{}{}
 	}
 	for id, _ := range sc.excerpts {
 		delete(sc.excerpts, id)
+		ids[id] = struct{}{}
 	}
 
 	index, err := sc.repo.GetIndex(sc.namespace)
@@ -530,6 +559,13 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) RemoveAll() error {
 		return err
 	}
 
+	// defer to notify after the release of the mutex
+	defer func() {
+		for id := range ids {
+			sc.notifyObservers(EntityEventRemoved, id)
+		}
+	}()
+
 	return sc.write()
 }
 
@@ -555,7 +591,19 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) MergeAll(remote string) <-chan en
 			}
 
 			switch result.Status {
-			case entity.MergeStatusNew, entity.MergeStatusUpdated:
+			case entity.MergeStatusNew:
+				e := result.Entity.(EntityT)
+				cached := sc.makeCached(e, sc.entityUpdated)
+
+				sc.mu.Lock()
+				sc.excerpts[result.Id] = sc.makeExcerpt(cached)
+				// might as well keep them in memory
+				sc.cached[result.Id] = cached
+				sc.mu.Unlock()
+				sc.notifyObservers(EntityEventCreated, result.Id)
+
+			case entity.MergeStatusUpdated:
+				// TODO: can that result in multiple copy of the same entity?
 				e := result.Entity.(EntityT)
 				cached := sc.makeCached(e, sc.entityUpdated)
 
@@ -564,6 +612,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) MergeAll(remote string) <-chan en
 				// might as well keep them in memory
 				sc.cached[result.Id] = cached
 				sc.mu.Unlock()
+				sc.notifyObservers(EntityEventUpdated, result.Id)
 			}
 		}
 
@@ -578,12 +627,27 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) MergeAll(remote string) <-chan en
 
 }
 
+// GetNamespace expose the namespace in git where entities are located.
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) GetNamespace() string {
 	return sc.namespace
 }
 
 // entityUpdated is a callback to trigger when the excerpt of an entity changed
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityUpdated(id entity.Id) error {
+	sc.notifyObservers(EntityEventUpdated, id)
+	return sc.updateExcerptAndIndex(id)
+}
+
+// notifyObservers notifies all the observers when something happening for an entity
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) notifyObservers(event EntityEventType, id entity.Id) {
+	sc.muObservers.RLock()
+	for observer, repoName := range sc.observers {
+		observer.EntityEvent(event, repoName, sc.typename, id)
+	}
+	sc.muObservers.RUnlock()
+}
+
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) updateExcerptAndIndex(id entity.Id) error {
 	sc.mu.Lock()
 	e, ok := sc.cached[id]
 	if !ok {
@@ -597,7 +661,6 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityUpdated(id entity.Id) error
 		return errors.New("entity missing from cache")
 	}
 	sc.lru.Get(id)
-	// sc.excerpts[id] = bug2.NewBugExcerpt(b.bug, b.Snapshot())
 	sc.excerpts[id] = sc.makeExcerpt(e)
 	sc.mu.Unlock()
 

entities/bug/bug.go 🔗

@@ -38,7 +38,7 @@ type Interface interface {
 
 // Bug holds the data of a bug thread, organized in a way close to
 // how it will be persisted inside Git. This is the data structure
-// used to merge two different version of the same Bug.
+// used to merge two different versions of the same Bug.
 type Bug struct {
 	*dag.Entity
 }

entities/bug/comment.go 🔗

@@ -48,5 +48,5 @@ func (c Comment) FormatTime() string {
 	return c.unixTime.Time().Format("Mon Jan 2 15:04:05 2006 -0700")
 }
 
-// IsAuthored is a sign post method for gqlgen
+// IsAuthored is a sign-post method for gqlgen, to mark compliance to an interface.
 func (c Comment) IsAuthored() {}

entities/bug/op_add_comment.go 🔗

@@ -79,7 +79,7 @@ type AddCommentTimelineItem struct {
 	CommentTimelineItem
 }
 
-// IsAuthored is a sign post method for gqlgen
+// IsAuthored is a sign post-method for gqlgen, to mark compliance to an interface.
 func (a *AddCommentTimelineItem) IsAuthored() {}
 
 // AddComment is a convenience function to add a comment to a bug

entities/bug/op_create.go 🔗

@@ -97,7 +97,7 @@ type CreateTimelineItem struct {
 	CommentTimelineItem
 }
 
-// IsAuthored is a sign post method for gqlgen
+// IsAuthored is a sign post-method for gqlgen, to mark compliance to an interface.
 func (c *CreateTimelineItem) IsAuthored() {}
 
 // Create is a convenience function to create a bug

entities/bug/op_label_change.go 🔗

@@ -117,7 +117,7 @@ func (l LabelChangeTimelineItem) CombinedId() entity.CombinedId {
 	return l.combinedId
 }
 
-// IsAuthored is a sign post method for gqlgen
+// IsAuthored is a sign post-method for gqlgen, to mark compliance to an interface.
 func (l *LabelChangeTimelineItem) IsAuthored() {}
 
 // ChangeLabels is a convenience function to change labels on a bug

entities/bug/op_set_status.go 🔗

@@ -68,7 +68,7 @@ func (s SetStatusTimelineItem) CombinedId() entity.CombinedId {
 	return s.combinedId
 }
 
-// IsAuthored is a sign post method for gqlgen
+// IsAuthored is a sign post-method for gqlgen, to mark compliance to an interface.
 func (s *SetStatusTimelineItem) IsAuthored() {}
 
 // Open is a convenience function to change a bugs state to Open

entities/bug/op_set_title.go 🔗

@@ -80,7 +80,7 @@ func (s SetTitleTimelineItem) CombinedId() entity.CombinedId {
 	return s.combinedId
 }
 
-// IsAuthored is a sign post method for gqlgen
+// IsAuthored is a sign post-method for gqlgen, to mark compliance to an interface.
 func (s *SetTitleTimelineItem) IsAuthored() {}
 
 // SetTitle is a convenience function to change a bugs title

entities/bug/snapshot.go 🔗

@@ -155,6 +155,3 @@ func (snap *Snapshot) HasAnyActor(ids ...entity.Id) bool {
 	}
 	return false
 }
-
-// IsAuthored is a sign post method for gqlgen
-func (snap *Snapshot) IsAuthored() {}