diff --git a/api/graphql/gqlgen.yml b/api/graphql/gqlgen.yml index 8d6daf4f388175146c845c844b6d3b72e60a4de1..4bca6ec2dc996ee5fda0622b56c69b6dcaa9a98a 100644 --- a/api/graphql/gqlgen.yml +++ b/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" diff --git a/api/graphql/graph/bug.generated.go b/api/graphql/graph/bug.generated.go index 0a6762a8be83417988168e0250e51b983570b841..12c7ae47566104268737893a7f43557649ef1e2c 100644 --- a/api/graphql/graph/bug.generated.go +++ b/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) diff --git a/api/graphql/graph/identity.generated.go b/api/graphql/graph/identity.generated.go index bdd411c4e2cf6193171bffc7551449bd5f702a5c..d2d99ee738a52668e086ab5174dd3f23cd8114ee 100644 --- a/api/graphql/graph/identity.generated.go +++ b/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) diff --git a/api/graphql/graph/root_.generated.go b/api/graphql/graph/root_.generated.go index 8f6ae0152bef665a9a36cb1426253ebe4f19a93e..5fed0daf75c51de15f387319ebd8724ceddf0d91 100644 --- a/api/graphql/graph/root_.generated.go +++ b/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...) diff --git a/api/graphql/graph/subscription.generated.go b/api/graphql/graph/subscription.generated.go new file mode 100644 index 0000000000000000000000000000000000000000..0050bab3c40703a15f60521544227918ce6bbe10 --- /dev/null +++ b/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 ***************************** diff --git a/api/graphql/graph/types.generated.go b/api/graphql/graph/types.generated.go index 2ab0ad686ea0a374988f71a74362dd6a366b97cf..43f9354b0dcb28b7452adcc2cff506953659a8e8 100644 --- a/api/graphql/graph/types.generated.go +++ b/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 diff --git a/api/graphql/graphql_test.go b/api/graphql/graphql_test.go index 5c6a0fc510fccd026fe09b0c5f41fbaafb8297f3..a9ba7a756c1aadbe4c3ea851c17f04ad336dcbbd 100644 --- a/api/graphql/graphql_test.go +++ b/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) +} diff --git a/api/graphql/handler.go b/api/graphql/handler.go index e45738d270f820f070c4ace154ef8dcc262bc9a0..201bea8c62e10d06da4d76a56c1583774d9fefbc 100644 --- a/api/graphql/handler.go +++ b/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}) diff --git a/api/graphql/models/gen_models.go b/api/graphql/models/gen_models.go index 36c68c0e691ee52391805fbd8511d828541e5c09..5a9affaa72d30449118e6fffb2f5d57e75bde9c1 100644 --- a/api/graphql/models/gen_models.go +++ b/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 { +} diff --git a/api/graphql/models/lazy_bug.go b/api/graphql/models/lazy_bug.go index 7570b4eaa783698862270b750544d51990e792cd..2340dcf39376b0ab010f842ec7da2192fbb9885b 100644 --- a/api/graphql/models/lazy_bug.go +++ b/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() {} diff --git a/api/graphql/models/lazy_identity.go b/api/graphql/models/lazy_identity.go index a131d450e7c81a092dbc568e3347b1262dcb6188..feb646db287e9e6b99eeedf74383203e07e5439b 100644 --- a/api/graphql/models/lazy_identity.go +++ b/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() {} diff --git a/api/graphql/resolvers/root.go b/api/graphql/resolvers/root.go index e0fd47eb331a1c782c4d8b773058e5b3d6a28248..e422a93f79646106e7ff4614a9bc0e907f5ec111 100644 --- a/api/graphql/resolvers/root.go +++ b/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{} } diff --git a/api/graphql/resolvers/subscription.go b/api/graphql/resolvers/subscription.go new file mode 100644 index 0000000000000000000000000000000000000000..529be1ab563984f8f87510e57d0ab0e0ec30eec8 --- /dev/null +++ b/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) +} diff --git a/api/graphql/schema/bug.graphql b/api/graphql/schema/bug.graphql index f06f2f8421d09361e8184206947f84a184d13cfe..9b4dbc412436ddf6707f767984b4389356ec5961 100644 --- a/api/graphql/schema/bug.graphql +++ b/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""" diff --git a/api/graphql/schema/identity.graphql b/api/graphql/schema/identity.graphql index 502b0a4f355437e9f8bd45c258d3fbd0f54167b8..54b1333c83c3cacee6236696096211fdd254cb8e 100644 --- a/api/graphql/schema/identity.graphql +++ b/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""" diff --git a/api/graphql/schema/operation.graphql b/api/graphql/schema/operation.graphql index ed952c06d9280758d0491e713593e98d311a3a4b..46455ccd6e1f431ab54ee656286e28a4d300c99e 100644 --- a/api/graphql/schema/operation.graphql +++ b/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.""" diff --git a/api/graphql/schema/subscription.graphql b/api/graphql/schema/subscription.graphql new file mode 100644 index 0000000000000000000000000000000000000000..7843afa268baa4a3b8bad53e0df5b25614a7c462 --- /dev/null +++ b/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! +} diff --git a/api/graphql/schema/types.graphql b/api/graphql/schema/types.graphql index f4284b2deed0ace34c2e847ae0c094a1a1bd8390..0fbf950f16708bb825b47874cb25e8f38037a68c 100644 --- a/api/graphql/schema/types.graphql +++ b/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! +} diff --git a/cache/events.go b/cache/events.go new file mode 100644 index 0000000000000000000000000000000000000000..3fb041fa2d6b486ff62eeffce89fcf0337f2aa0f --- /dev/null +++ b/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 +} diff --git a/cache/filter.go b/cache/filter.go index 199e17b3380ce09652d76342d4cf9f43f9479bcb..c9e6059b8462a22e14837e8cab57bceddd8f452f 100644 --- a/cache/filter.go +++ b/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 diff --git a/cache/identity_subcache.go b/cache/identity_subcache.go index 42b4d6865078a56c3361b82fd482b281d7b2ea3e..f531217518625a66064c2453a5b37287215f5b1e 100644 --- a/cache/identity_subcache.go +++ b/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 } diff --git a/cache/multi_repo_cache.go b/cache/multi_repo_cache.go index 91ca34b41c4a23c6fc902c36d79d899038667512..333fb34008c94d44624126b4f526518864ee6765 100644 --- a/cache/multi_repo_cache.go +++ b/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 { diff --git a/cache/repo_cache.go b/cache/repo_cache.go index eb441d9e6acc2d2843030aa19034584787235004..c43cc0a1c7cd15066f699e0d0c3776f447954bab 100644 --- a/cache/repo_cache.go +++ b/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. diff --git a/cache/repo_cache_test.go b/cache/repo_cache_test.go index e974a3c5077113b858b39b0e84f1cb1914c08507..19445b409b513d97663c1df28fc81a2f4f2d53db 100644 --- a/cache/repo_cache_test.go +++ b/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) diff --git a/cache/subcache.go b/cache/subcache.go index d9b6db8d4e875c8a62920bec05f3f491bc081325..c9aa5f68444d632dd2d951119d90e0760eb54626 100644 --- a/cache/subcache.go +++ b/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() diff --git a/entities/bug/bug.go b/entities/bug/bug.go index 8958fbd0e1b93498dd46631b3f3696fb421bf5b7..4dc533d8c72ecdc985a90bf76cbe7e17e99d1af4 100644 --- a/entities/bug/bug.go +++ b/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 } diff --git a/entities/bug/comment.go b/entities/bug/comment.go index 32209fcb0da4f5619973e9aa447ee408d46a242d..bd7c315fe2e482a3c67c2ff3578c8a84a858b09b 100644 --- a/entities/bug/comment.go +++ b/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() {} diff --git a/entities/bug/op_add_comment.go b/entities/bug/op_add_comment.go index ea7093972bc2f2887ae8d81cae1fe3b8555020f5..633116aac2506600cefbe6999c36f74552d490c3 100644 --- a/entities/bug/op_add_comment.go +++ b/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 diff --git a/entities/bug/op_create.go b/entities/bug/op_create.go index c7b6bada38d290110cab186c35bd979d6b59ae69..b817a8feccc6e35a76d1dc6094a528810c728b6f 100644 --- a/entities/bug/op_create.go +++ b/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 diff --git a/entities/bug/op_label_change.go b/entities/bug/op_label_change.go index cf8adfb75bacf5d0477b1be027d0531a26bb227c..96c57127beb58d2ea2b8e1ff1b6105d79e288c1c 100644 --- a/entities/bug/op_label_change.go +++ b/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 diff --git a/entities/bug/op_set_status.go b/entities/bug/op_set_status.go index 641065a03879a0ac5803251e68c34ae7f487c3ec..318e252311c5c7ddf1db0942f03d84a041aa839e 100644 --- a/entities/bug/op_set_status.go +++ b/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 diff --git a/entities/bug/op_set_title.go b/entities/bug/op_set_title.go index 7ec98281cd7234e19bb94f7ae2415f7df94ac91d..3233e1c4d55331cae3c6b4e6d86c093cfc2389ff 100644 --- a/entities/bug/op_set_title.go +++ b/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 diff --git a/entities/bug/snapshot.go b/entities/bug/snapshot.go index 7f9e7e580298a30baccc97a6e722b18bd56046b8..258312d719a3e7e0033025b6af80695ab0649815 100644 --- a/entities/bug/snapshot.go +++ b/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() {}