graphql: WIP, a bunch of fixes, generalization, cleanups

Michael MurĂŠ created

Change summary

api/graphql/gqlgen.yml                      |   1 
api/graphql/graph/bug.generated.go          |   2 
api/graphql/graph/identity.generated.go     |   2 
api/graphql/graph/operation.generated.go    |  16 
api/graphql/graph/root_.generated.go        | 149 ++++-
api/graphql/graph/subscription.generated.go | 608 +++++++++++++++++++++-
api/graphql/graph/types.generated.go        |  26 
api/graphql/models/gen_models.go            |  69 +
api/graphql/models/lazy_bug.go              |  18 
api/graphql/models/lazy_identity.go         |   9 
api/graphql/resolvers/subscription.go       |  32 +
api/graphql/schema/bug.graphql              |   2 
api/graphql/schema/identity.graphql         |   2 
api/graphql/schema/subscription.graphql     |  24 
api/graphql/schema/types.graphql            |   9 
cache/events.go                             |  47 +
cache/identity_subcache.go                  |  14 
cache/multi_repo_cache.go                   |  10 
cache/repo_cache.go                         |  49 -
cache/repo_cache_test.go                    |  61 ++
cache/subcache.go                           |  61 +
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 
28 files changed, 1,017 insertions(+), 209 deletions(-)

Detailed changes

api/graphql/gqlgen.yml 🔗

@@ -11,7 +11,6 @@ autobind:
   - "github.com/git-bug/git-bug/api/graphql/models"
   - "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/operation.generated.go 🔗

@@ -107,9 +107,9 @@ func (ec *executionContext) _OperationConnection_nodes(ctx context.Context, fiel
 		}
 		return graphql.Null
 	}
-	res := resTmp.([]dag.Operation)
+	res := resTmp.([]dag.OperationWithApply[*bug.Snapshot])
 	fc.Result = res
-	return ec.marshalNOperation2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperationᚄ(ctx, field.Selections, res)
+	return ec.marshalNOperation2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperationWithApplyᚄ(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) fieldContext_OperationConnection_nodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -293,9 +293,9 @@ func (ec *executionContext) _OperationEdge_node(ctx context.Context, field graph
 		}
 		return graphql.Null
 	}
-	res := resTmp.(dag.Operation)
+	res := resTmp.(dag.OperationWithApply[*bug.Snapshot])
 	fc.Result = res
-	return ec.marshalNOperation2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperation(ctx, field.Selections, res)
+	return ec.marshalNOperation2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperationWithApply(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) fieldContext_OperationEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -319,7 +319,7 @@ func (ec *executionContext) fieldContext_OperationEdge_node(_ context.Context, f
 
 // region    ************************** interface.gotpl ***************************
 
-func (ec *executionContext) _Operation(ctx context.Context, sel ast.SelectionSet, obj dag.Operation) graphql.Marshaler {
+func (ec *executionContext) _Operation(ctx context.Context, sel ast.SelectionSet, obj dag.OperationWithApply[*bug.Snapshot]) graphql.Marshaler {
 	switch obj := (obj).(type) {
 	case nil:
 		return graphql.Null
@@ -464,7 +464,7 @@ func (ec *executionContext) _OperationEdge(ctx context.Context, sel ast.Selectio
 
 // region    ***************************** type.gotpl *****************************
 
-func (ec *executionContext) marshalNOperation2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperation(ctx context.Context, sel ast.SelectionSet, v dag.Operation) graphql.Marshaler {
+func (ec *executionContext) marshalNOperation2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperationWithApply(ctx context.Context, sel ast.SelectionSet, v dag.OperationWithApply[*bug.Snapshot]) 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")
@@ -474,7 +474,7 @@ func (ec *executionContext) marshalNOperation2githubᚗcomᚋgitᚑbugᚋgitᚑb
 	return ec._Operation(ctx, sel, v)
 }
 
-func (ec *executionContext) marshalNOperation2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperationᚄ(ctx context.Context, sel ast.SelectionSet, v []dag.Operation) graphql.Marshaler {
+func (ec *executionContext) marshalNOperation2ᚕgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperationWithApplyᚄ(ctx context.Context, sel ast.SelectionSet, v []dag.OperationWithApply[*bug.Snapshot]) graphql.Marshaler {
 	ret := make(graphql.Array, len(v))
 	var wg sync.WaitGroup
 	isLen1 := len(v) == 1
@@ -498,7 +498,7 @@ func (ec *executionContext) marshalNOperation2ᚕgithubᚗcomᚋgitᚑbugᚋgit
 			if !isLen1 {
 				defer wg.Done()
 			}
-			ret[i] = ec.marshalNOperation2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperation(ctx, sel, v[i])
+			ret[i] = ec.marshalNOperation2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋentityᚋdagᚐOperationWithApply(ctx, sel, v[i])
 		}
 		if isLen1 {
 			f(i)

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

@@ -116,11 +116,6 @@ type ComplexityRoot struct {
 		MessageIsEmpty func(childComplexity int) int
 	}
 
-	BugChange struct {
-		Bug  func(childComplexity int) int
-		Type func(childComplexity int) int
-	}
-
 	BugChangeLabelPayload struct {
 		Bug              func(childComplexity int) int
 		ClientMutationID func(childComplexity int) int
@@ -206,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
@@ -288,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
@@ -311,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
@@ -379,7 +389,9 @@ type ComplexityRoot struct {
 	}
 
 	Subscription struct {
-		BugChanged func(childComplexity int, repoRef *string, query *string) int
+		AllEvents      func(childComplexity int, repoFilter *string) int
+		BugEvents      func(childComplexity int, repoFilter *string, query *string) int
+		IdentityEvents func(childComplexity int, repoFilter *string) int
 	}
 }
 
@@ -693,20 +705,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.BugAddCommentTimelineItem.MessageIsEmpty(childComplexity), true
 
-	case "BugChange.bug":
-		if e.complexity.BugChange.Bug == nil {
-			break
-		}
-
-		return e.complexity.BugChange.Bug(childComplexity), true
-
-	case "BugChange.type":
-		if e.complexity.BugChange.Type == nil {
-			break
-		}
-
-		return e.complexity.BugChange.Type(childComplexity), true
-
 	case "BugChangeLabelPayload.bug":
 		if e.complexity.BugChangeLabelPayload.Bug == nil {
 			break
@@ -1050,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
@@ -1372,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
@@ -1470,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
@@ -1804,17 +1844,41 @@ 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.bugChanged":
-		if e.complexity.Subscription.BugChanged == nil {
+	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["repoFilter"].(*string)), true
+
+	case "Subscription.bugEvents":
+		if e.complexity.Subscription.BugEvents == nil {
 			break
 		}
 
-		args, err := ec.field_Subscription_bugChanged_args(ctx, rawArgs)
+		args, err := ec.field_Subscription_bugEvents_args(ctx, rawArgs)
 		if err != nil {
 			return 0, false
 		}
 
-		return e.complexity.Subscription.BugChanged(childComplexity, args["repoRef"].(*string), args["query"].(*string)), true
+		return e.complexity.Subscription.BugEvents(childComplexity, args["repoFilter"].(*string), args["query"].(*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["repoFilter"].(*string)), true
 
 	}
 	return 0, false
@@ -1947,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"""
@@ -2472,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"""
@@ -2628,16 +2692,32 @@ type Mutation # See each entity mutations
 }
 `, BuiltIn: false},
 	{Name: "../schema/subscription.graphql", Input: `type Subscription {
-  bugChanged(repoRef: String, query: String): BugChange!
+  """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(repoFilter: 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(repoFilter: 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(repoFilter: String, query: String): BugEvent!
 }
 
-enum ChangeType {
+enum EventType {
   CREATED
   UPDATED
+  REMOVED
 }
 
-type BugChange {
-  type: ChangeType!
+type EntityEvent {
+  type: EventType!
+  entity: Entity
+}
+
+type IdentityEvent {
+  type: EventType!
+  identity: Identity!
+}
+
+type BugEvent {
+  type: EventType!
   bug: Bug!
 }
 `, BuiltIn: false},
@@ -2672,6 +2752,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 🔗

@@ -18,39 +18,69 @@ import (
 // region    ************************** generated!.gotpl **************************
 
 type SubscriptionResolver interface {
-	BugChanged(ctx context.Context, repoRef *string, query *string) (<-chan *models.BugChange, error)
+	AllEvents(ctx context.Context, repoFilter *string) (<-chan *models.EntityEvent, error)
+	IdentityEvents(ctx context.Context, repoFilter *string) (<-chan *models.IdentityEvent, error)
+	BugEvents(ctx context.Context, repoFilter *string, query *string) (<-chan *models.BugEvent, error)
 }
 
 // endregion ************************** generated!.gotpl **************************
 
 // region    ***************************** args.gotpl *****************************
 
-func (ec *executionContext) field_Subscription_bugChanged_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+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_bugChanged_argsRepoRef(ctx, rawArgs)
+	arg0, err := ec.field_Subscription_allEvents_argsRepoFilter(ctx, rawArgs)
 	if err != nil {
 		return nil, err
 	}
-	args["repoRef"] = arg0
-	arg1, err := ec.field_Subscription_bugChanged_argsQuery(ctx, rawArgs)
+	args["repoFilter"] = arg0
+	return args, nil
+}
+func (ec *executionContext) field_Subscription_allEvents_argsRepoFilter(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["repoFilter"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("repoFilter"))
+	if tmp, ok := rawArgs["repoFilter"]; 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_argsRepoFilter(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["repoFilter"] = arg0
+	arg1, err := ec.field_Subscription_bugEvents_argsQuery(ctx, rawArgs)
 	if err != nil {
 		return nil, err
 	}
 	args["query"] = arg1
 	return args, nil
 }
-func (ec *executionContext) field_Subscription_bugChanged_argsRepoRef(
+func (ec *executionContext) field_Subscription_bugEvents_argsRepoFilter(
 	ctx context.Context,
 	rawArgs map[string]any,
 ) (*string, error) {
-	if _, ok := rawArgs["repoRef"]; !ok {
+	if _, ok := rawArgs["repoFilter"]; !ok {
 		var zeroVal *string
 		return zeroVal, nil
 	}
 
-	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("repoRef"))
-	if tmp, ok := rawArgs["repoRef"]; ok {
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("repoFilter"))
+	if tmp, ok := rawArgs["repoFilter"]; ok {
 		return ec.unmarshalOString2ᚖstring(ctx, tmp)
 	}
 
@@ -58,7 +88,7 @@ func (ec *executionContext) field_Subscription_bugChanged_argsRepoRef(
 	return zeroVal, nil
 }
 
-func (ec *executionContext) field_Subscription_bugChanged_argsQuery(
+func (ec *executionContext) field_Subscription_bugEvents_argsQuery(
 	ctx context.Context,
 	rawArgs map[string]any,
 ) (*string, error) {
@@ -76,6 +106,34 @@ func (ec *executionContext) field_Subscription_bugChanged_argsQuery(
 	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_argsRepoFilter(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["repoFilter"] = arg0
+	return args, nil
+}
+func (ec *executionContext) field_Subscription_identityEvents_argsRepoFilter(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["repoFilter"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("repoFilter"))
+	if tmp, ok := rawArgs["repoFilter"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
 // endregion ***************************** args.gotpl *****************************
 
 // region    ************************** directives.gotpl **************************
@@ -84,8 +142,8 @@ func (ec *executionContext) field_Subscription_bugChanged_argsQuery(
 
 // region    **************************** field.gotpl *****************************
 
-func (ec *executionContext) _BugChange_type(ctx context.Context, field graphql.CollectedField, obj *models.BugChange) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_BugChange_type(ctx, field)
+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
 	}
@@ -110,26 +168,26 @@ func (ec *executionContext) _BugChange_type(ctx context.Context, field graphql.C
 		}
 		return graphql.Null
 	}
-	res := resTmp.(models.ChangeType)
+	res := resTmp.(models.EventType)
 	fc.Result = res
-	return ec.marshalNChangeType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐChangeType(ctx, field.Selections, res)
+	return ec.marshalNEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEventType(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) fieldContext_BugChange_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+func (ec *executionContext) fieldContext_BugEvent_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
 	fc = &graphql.FieldContext{
-		Object:     "BugChange",
+		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 ChangeType does not have child fields")
+			return nil, errors.New("field of type EventType does not have child fields")
 		},
 	}
 	return fc, nil
 }
 
-func (ec *executionContext) _BugChange_bug(ctx context.Context, field graphql.CollectedField, obj *models.BugChange) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_BugChange_bug(ctx, field)
+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
 	}
@@ -159,9 +217,9 @@ func (ec *executionContext) _BugChange_bug(ctx context.Context, field graphql.Co
 	return ec.marshalNBug2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) fieldContext_BugChange_bug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+func (ec *executionContext) fieldContext_BugEvent_bug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
 	fc = &graphql.FieldContext{
-		Object:     "BugChange",
+		Object:     "BugEvent",
 		Field:      field,
 		IsMethod:   false,
 		IsResolver: false,
@@ -200,8 +258,349 @@ func (ec *executionContext) fieldContext_BugChange_bug(_ context.Context, field
 	return fc, nil
 }
 
-func (ec *executionContext) _Subscription_bugChanged(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) {
-	fc, err := ec.fieldContext_Subscription_bugChanged(ctx, field)
+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.(models.EventType)
+	fc.Result = res
+	return ec.marshalNEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEventType(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 EventType 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.(models.EventType)
+	fc.Result = res
+	return ec.marshalNEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEventType(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 EventType 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["repoFilter"].(*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["repoFilter"].(*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
 	}
@@ -214,7 +613,7 @@ func (ec *executionContext) _Subscription_bugChanged(ctx context.Context, field
 	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
 		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Subscription().BugChanged(rctx, fc.Args["repoRef"].(*string), fc.Args["query"].(*string))
+		return ec.resolvers.Subscription().BugEvents(rctx, fc.Args["repoFilter"].(*string), fc.Args["query"].(*string))
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -228,7 +627,7 @@ func (ec *executionContext) _Subscription_bugChanged(ctx context.Context, field
 	}
 	return func(ctx context.Context) graphql.Marshaler {
 		select {
-		case res, ok := <-resTmp.(<-chan *models.BugChange):
+		case res, ok := <-resTmp.(<-chan *models.BugEvent):
 			if !ok {
 				return nil
 			}
@@ -236,7 +635,7 @@ func (ec *executionContext) _Subscription_bugChanged(ctx context.Context, field
 				w.Write([]byte{'{'})
 				graphql.MarshalString(field.Alias).MarshalGQL(w)
 				w.Write([]byte{':'})
-				ec.marshalNBugChange2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugChange(ctx, field.Selections, res).MarshalGQL(w)
+				ec.marshalNBugEvent2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugEvent(ctx, field.Selections, res).MarshalGQL(w)
 				w.Write([]byte{'}'})
 			})
 		case <-ctx.Done():
@@ -245,7 +644,7 @@ func (ec *executionContext) _Subscription_bugChanged(ctx context.Context, field
 	}
 }
 
-func (ec *executionContext) fieldContext_Subscription_bugChanged(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+func (ec *executionContext) fieldContext_Subscription_bugEvents(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
 	fc = &graphql.FieldContext{
 		Object:     "Subscription",
 		Field:      field,
@@ -254,11 +653,11 @@ func (ec *executionContext) fieldContext_Subscription_bugChanged(ctx context.Con
 		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
 			switch field.Name {
 			case "type":
-				return ec.fieldContext_BugChange_type(ctx, field)
+				return ec.fieldContext_BugEvent_type(ctx, field)
 			case "bug":
-				return ec.fieldContext_BugChange_bug(ctx, field)
+				return ec.fieldContext_BugEvent_bug(ctx, field)
 			}
-			return nil, fmt.Errorf("no field named %q was found under type BugChange", field.Name)
+			return nil, fmt.Errorf("no field named %q was found under type BugEvent", field.Name)
 		},
 	}
 	defer func() {
@@ -268,7 +667,7 @@ func (ec *executionContext) fieldContext_Subscription_bugChanged(ctx context.Con
 		}
 	}()
 	ctx = graphql.WithFieldContext(ctx, fc)
-	if fc.Args, err = ec.field_Subscription_bugChanged_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+	if fc.Args, err = ec.field_Subscription_bugEvents_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
 		ec.Error(ctx, err)
 		return fc, err
 	}
@@ -287,24 +686,109 @@ func (ec *executionContext) fieldContext_Subscription_bugChanged(ctx context.Con
 
 // region    **************************** object.gotpl ****************************
 
-var bugChangeImplementors = []string{"BugChange"}
+var bugEventImplementors = []string{"BugEvent"}
 
-func (ec *executionContext) _BugChange(ctx context.Context, sel ast.SelectionSet, obj *models.BugChange) graphql.Marshaler {
-	fields := graphql.CollectFields(ec.OperationContext, sel, bugChangeImplementors)
+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("BugChange")
+			out.Values[i] = graphql.MarshalString("BugEvent")
 		case "type":
-			out.Values[i] = ec._BugChange_type(ctx, field, obj)
+			out.Values[i] = ec._BugEvent_type(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
 				out.Invalids++
 			}
 		case "bug":
-			out.Values[i] = ec._BugChange_bug(ctx, field, obj)
+			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++
 			}
@@ -344,8 +828,12 @@ func (ec *executionContext) _Subscription(ctx context.Context, sel ast.Selection
 	}
 
 	switch fields[0].Name {
-	case "bugChanged":
-		return ec._Subscription_bugChanged(ctx, fields[0])
+	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))
 	}
@@ -355,28 +843,56 @@ func (ec *executionContext) _Subscription(ctx context.Context, sel ast.Selection
 
 // region    ***************************** type.gotpl *****************************
 
-func (ec *executionContext) marshalNBugChange2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugChange(ctx context.Context, sel ast.SelectionSet, v models.BugChange) graphql.Marshaler {
-	return ec._BugChange(ctx, sel, &v)
+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) marshalNBugChange2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugChange(ctx context.Context, sel ast.SelectionSet, v *models.BugChange) graphql.Marshaler {
+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._BugChange(ctx, sel, v)
+	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) unmarshalNChangeType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐChangeType(ctx context.Context, v any) (models.ChangeType, error) {
-	var res models.ChangeType
+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) unmarshalNEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEventType(ctx context.Context, v any) (models.EventType, error) {
+	var res models.EventType
 	err := res.UnmarshalGQL(v)
 	return res, graphql.ErrorOnPath(ctx, err)
 }
 
-func (ec *executionContext) marshalNChangeType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐChangeType(ctx context.Context, sel ast.SelectionSet, v models.ChangeType) graphql.Marshaler {
+func (ec *executionContext) marshalNEventType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEventType(ctx context.Context, sel ast.SelectionSet, v models.EventType) 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/models/gen_models.go 🔗

@@ -19,6 +19,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"`
@@ -89,11 +94,6 @@ type BugAddCommentPayload struct {
 	Operation *bug.AddCommentOperation `json:"operation"`
 }
 
-type BugChange struct {
-	Type ChangeType `json:"type"`
-	Bug  BugWrapper `json:"bug"`
-}
-
 type BugChangeLabelInput struct {
 	// A unique identifier for the client performing the mutation.
 	ClientMutationID *string `json:"clientMutationId,omitempty"`
@@ -193,6 +193,11 @@ type BugEditCommentPayload struct {
 	Operation *bug.EditCommentOperation `json:"operation"`
 }
 
+type BugEvent struct {
+	Type EventType  `json:"type"`
+	Bug  BugWrapper `json:"bug"`
+}
+
 type BugSetTitleInput struct {
 	// A unique identifier for the client performing the mutation.
 	ClientMutationID *string `json:"clientMutationId,omitempty"`
@@ -263,6 +268,11 @@ type BugTimelineItemEdge struct {
 	Node   bug.TimelineItem `json:"node"`
 }
 
+type EntityEvent struct {
+	Type   EventType `json:"type"`
+	Entity Entity    `json:"entity,omitempty"`
+}
+
 type IdentityConnection struct {
 	Edges      []*IdentityEdge   `json:"edges"`
 	Nodes      []IdentityWrapper `json:"nodes"`
@@ -275,6 +285,11 @@ type IdentityEdge struct {
 	Node   IdentityWrapper `json:"node"`
 }
 
+type IdentityEvent struct {
+	Type     EventType       `json:"type"`
+	Identity IdentityWrapper `json:"identity"`
+}
+
 type LabelConnection struct {
 	Edges      []*LabelEdge   `json:"edges"`
 	Nodes      []common.Label `json:"nodes"`
@@ -292,16 +307,16 @@ type Mutation struct {
 
 // The connection type for an Operation
 type OperationConnection struct {
-	Edges      []*OperationEdge `json:"edges"`
-	Nodes      []dag.Operation  `json:"nodes"`
-	PageInfo   *PageInfo        `json:"pageInfo"`
-	TotalCount int              `json:"totalCount"`
+	Edges      []*OperationEdge                        `json:"edges"`
+	Nodes      []dag.OperationWithApply[*bug.Snapshot] `json:"nodes"`
+	PageInfo   *PageInfo                               `json:"pageInfo"`
+	TotalCount int                                     `json:"totalCount"`
 }
 
 // Represent an Operation
 type OperationEdge struct {
-	Cursor string        `json:"cursor"`
-	Node   dag.Operation `json:"node"`
+	Cursor string                                `json:"cursor"`
+	Node   dag.OperationWithApply[*bug.Snapshot] `json:"node"`
 }
 
 // Information about pagination in a connection.
@@ -322,48 +337,50 @@ type Query struct {
 type Subscription struct {
 }
 
-type ChangeType string
+type EventType string
 
 const (
-	ChangeTypeCreated ChangeType = "CREATED"
-	ChangeTypeUpdated ChangeType = "UPDATED"
+	EventTypeCreated EventType = "CREATED"
+	EventTypeUpdated EventType = "UPDATED"
+	EventTypeRemoved EventType = "REMOVED"
 )
 
-var AllChangeType = []ChangeType{
-	ChangeTypeCreated,
-	ChangeTypeUpdated,
+var AllEventType = []EventType{
+	EventTypeCreated,
+	EventTypeUpdated,
+	EventTypeRemoved,
 }
 
-func (e ChangeType) IsValid() bool {
+func (e EventType) IsValid() bool {
 	switch e {
-	case ChangeTypeCreated, ChangeTypeUpdated:
+	case EventTypeCreated, EventTypeUpdated, EventTypeRemoved:
 		return true
 	}
 	return false
 }
 
-func (e ChangeType) String() string {
+func (e EventType) String() string {
 	return string(e)
 }
 
-func (e *ChangeType) UnmarshalGQL(v any) error {
+func (e *EventType) UnmarshalGQL(v any) error {
 	str, ok := v.(string)
 	if !ok {
 		return fmt.Errorf("enums must be strings")
 	}
 
-	*e = ChangeType(str)
+	*e = EventType(str)
 	if !e.IsValid() {
-		return fmt.Errorf("%s is not a valid ChangeType", str)
+		return fmt.Errorf("%s is not a valid EventType", str)
 	}
 	return nil
 }
 
-func (e ChangeType) MarshalGQL(w io.Writer) {
+func (e EventType) MarshalGQL(w io.Writer) {
 	fmt.Fprint(w, strconv.Quote(e.String()))
 }
 
-func (e *ChangeType) UnmarshalJSON(b []byte) error {
+func (e *EventType) UnmarshalJSON(b []byte) error {
 	s, err := strconv.Unquote(string(b))
 	if err != nil {
 		return err
@@ -371,7 +388,7 @@ func (e *ChangeType) UnmarshalJSON(b []byte) error {
 	return e.UnmarshalGQL(s)
 }
 
-func (e ChangeType) MarshalJSON() ([]byte, error) {
+func (e EventType) MarshalJSON() ([]byte, error) {
 	var buf bytes.Buffer
 	e.MarshalGQL(&buf)
 	return buf.Bytes(), nil

api/graphql/models/lazy_bug.go 🔗

@@ -28,7 +28,10 @@ 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{}
@@ -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/subscription.go 🔗

@@ -17,6 +17,38 @@ type subscriptionResolver struct {
 	cache *cache.MultiRepoCache
 }
 
+func (s subscriptionResolver) AllEvents(ctx context.Context, repoFilter *string) (<-chan *models.EntityEvent, error) {
+	// TODO implement me
+	panic("implement me")
+}
+
+var _ cache.Observer = &subscription[any]{}
+
+type subscription[T any] struct {
+	out             chan *T
+	filter          func(T) bool
+	excerptResolver func(id entity.Id) cache.Excerpt
+	repo            *cache.RepoCache
+}
+
+func (s subscription[T]) EntityEvent(event cache.EntityEventType, typename string, id entity.Id) {
+
+}
+
+func (s subscriptionResolver) IdentityEvents(ctx context.Context, repoFilter *string) (<-chan *models.IdentityEvent, error) {
+	out := make(chan *models.IdentityEvent)
+	sub := &subscription[models.IdentityEvent]{
+		out: out,
+		filter: func(e models.IdentityEvent) bool { return true},
+		excerptResolver: s.cache.
+	}
+}
+
+func (s subscriptionResolver) BugEvents(ctx context.Context, repoFilter *string, query *string) (<-chan *models.BugEvent, error) {
+	// TODO implement me
+	panic("implement me")
+}
+
 func (s subscriptionResolver) BugChanged(ctx context.Context, repoRef *string, query *string) (<-chan *models.BugChange, error) {
 	var repo *cache.RepoCache
 	var err error

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/subscription.graphql 🔗

@@ -1,13 +1,29 @@
 type Subscription {
-  bugChanged(repoRef: String, query: String): BugChange!
+  """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(repoFilter: 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(repoFilter: 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(repoFilter: String, query: String): BugEvent!
 }
 
-enum ChangeType {
+enum EventType {
   CREATED
   UPDATED
+  REMOVED
 }
 
-type BugChange {
-  type: ChangeType!
+type EntityEvent {
+  type: EventType!
+  entity: Entity
+}
+
+type IdentityEvent {
+  type: EventType!
+  identity: Identity!
+}
+
+type BugEvent {
+  type: EventType!
   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,47 @@
+package cache
+
+import "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, repoRef string, typename string, id entity.Id)
+}

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 🔗

@@ -24,7 +24,7 @@ func NewMultiRepoCache() *MultiRepoCache {
 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)
@@ -69,6 +69,14 @@ func (c *MultiRepoCache) ResolveRepo(name string) (*RepoCache, error) {
 	return r, nil
 }
 
+func (c *MultiRepoCache) RegisterObserver(observer Observer) {
+
+}
+
+func (c *MultiRepoCache) UnregisterObserver(observer 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 🔗

@@ -40,17 +40,6 @@ type cacheMgmt interface {
 	Close() error
 }
 
-// Observer gets notified of changes in entities in the cache
-type Observer interface {
-	// EntityCreated notifies that an entity has been created.
-	// The body of that function should NOT block.
-	EntityCreated(typename string, id entity.Id)
-
-	// EntityUpdated notifies that an entity has been updated.
-	// The body of that function should NOT block.
-	EntityUpdated(typename string, id entity.Id)
-}
-
 // RepoCache is a cache for a Repository. This cache has multiple functions:
 //
 //  1. After being loaded, a Bug is kept in memory in the cache, allowing for fast
@@ -150,18 +139,18 @@ func NewRepoCacheNoEvents(r repository.ClockedRepo) (*RepoCache, error) {
 	return cache, nil
 }
 
-func (c *RepoCache) RegisterObserver(typename string, observer Observer) {
+func (c *RepoCache) registerObserver(repoRef string, typename string, observer Observer) {
 	switch typename {
 	case bug.Typename:
-		c.bugs.RegisterObserver(observer)
+		c.bugs.RegisterObserver(repoRef, observer)
 	case identity.Typename:
-		c.identities.RegisterObserver(observer)
+		c.identities.RegisterObserver(repoRef, observer)
 	default:
 		panic(fmt.Sprintf("unknown typename %q", typename))
 	}
 }
 
-func (c *RepoCache) UnregisterObserver(typename string, observer Observer) {
+func (c *RepoCache) unregisterObserver(typename string, observer Observer) {
 	switch typename {
 	case bug.Typename:
 		c.bugs.UnregisterObserver(observer)
@@ -241,36 +230,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 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
-}
-
 func (c *RepoCache) buildCache(events chan BuildEvent) {
 	events <- BuildEvent{Event: BuildEventCacheIsBuilt}
 

cache/repo_cache_test.go 🔗

@@ -1,6 +1,7 @@
 package cache
 
 import (
+	"fmt"
 	"strings"
 	"testing"
 	"time"
@@ -16,6 +17,31 @@ import (
 	"github.com/git-bug/git-bug/repository"
 )
 
+type observerEvent struct {
+	typename string
+	id       entity.Id
+}
+
+type observer struct {
+	created []observerEvent
+	updated []observerEvent
+	removed []observerEvent
+}
+
+func (o *observer) EntityEvent(event EntityEventType, typename string, id entity.Id) {
+	switch event {
+	case EntityEventCreated:
+		fmt.Printf("Created %s: %s\n", typename, id.Human())
+		o.created = append(o.created, observerEvent{typename, id})
+	case EntityEventUpdated:
+		fmt.Printf("Updated %s: %s\n", typename, id.Human())
+		o.updated = append(o.updated, observerEvent{typename, id})
+	case EntityEventRemoved:
+		fmt.Printf("Removed %s: %s\n", typename, id.Human())
+		o.removed = append(o.removed, observerEvent{typename, id})
+	}
+}
+
 func TestCache(t *testing.T) {
 	f := test.NewFlaky(t, &test.FlakyOptions{
 		MaxAttempts: 5,
@@ -23,23 +49,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
+		cache.RegisterObserver(identity.Typename, &obsIdentity)
+		cache.RegisterObserver(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 +86,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 +102,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())
@@ -117,6 +160,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)
+		cache.RegisterObserver(identity.Typename, &obsIdentity)
+		cache.RegisterObserver(bug.Typename, &obsBug)
 
 		require.Len(t, cache.bugs.cached, 0)
 		require.Len(t, cache.bugs.excerpts, 2)
@@ -150,8 +195,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, 0, 0)
 		err = cache.Bugs().Remove(bug1.Id().String()[:10])
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 2, 0, 1)
+		assertOberserverEvent(obsBug, 2, 0, 1)
 		require.Len(t, cache.bugs.cached, 0)
 		require.Len(t, cache.bugs.excerpts, 1)
 		require.Len(t, cache.identities.cached, 0)
@@ -161,11 +210,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, 0, 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, 0, 1)
 
 		err = cache.RemoveAll()
 		require.NoError(t, err)
+		assertOberserverEvent(obsIdentity, 3, 0, 3)
+		assertOberserverEvent(obsBug, 3, 0, 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 🔗

@@ -338,6 +338,9 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Close() error {
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) RegisterObserver(observer Observer) {
 	sc.muObservers.Lock()
 	defer sc.muObservers.Unlock()
+	if sc.observers == nil {
+		sc.observers = make(map[Observer]struct{})
+	}
 	sc.observers[observer] = struct{}{}
 }
 
@@ -407,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()
@@ -420,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 {
@@ -428,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 {
@@ -475,11 +479,14 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) add(e EntityT) (CacheT, error) {
 	sc.evictIfNeeded()
 
 	// force the write of the excerpt
-	err := sc.entityCreated(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
 }
 
@@ -513,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()
 }
 
@@ -525,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)
@@ -545,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()
 }
 
@@ -570,7 +591,7 @@ 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)
 
@@ -579,6 +600,19 @@ 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(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)
+
+				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(EntityEventUpdated, result.Id)
 			}
 		}
 
@@ -593,29 +627,24 @@ 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
 }
 
-func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityCreated(id entity.Id) error {
-	sc.muObservers.RLock()
-	for observer := range sc.observers {
-		observer.EntityCreated(sc.typename, id)
-	}
-	sc.muObservers.RUnlock()
-
+// 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)
 }
 
-// entityUpdated is a callback to trigger when the excerpt of an entity changed
-func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityUpdated(id entity.Id) error {
+// 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 := range sc.observers {
-		observer.EntityCreated(sc.typename, id)
+		observer.EntityEvent(event, sc.repo.sc.typename, id)
 	}
 	sc.muObservers.RUnlock()
-
-	return sc.updateExcerptAndIndex(id)
 }
 
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) updateExcerptAndIndex(id entity.Id) error {

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() {}