diff --git a/api/graphql/graph/root_.generated.go b/api/graphql/graph/root_.generated.go index 8f6ae0152bef665a9a36cb1426253ebe4f19a93e..9ffbfa1f8f4e0aef0b02ec05c6769781292080cb 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 { @@ -115,6 +116,11 @@ 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 @@ -371,6 +377,10 @@ type ComplexityRoot struct { UserIdentity func(childComplexity int) int ValidLabels func(childComplexity int, after *string, before *string, first *int, last *int) int } + + Subscription struct { + BugChanged func(childComplexity int, repoRef *string, query *string) int + } } type executableSchema struct { @@ -683,6 +693,20 @@ 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 @@ -1780,6 +1804,18 @@ 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 { + break + } + + args, err := ec.field_Subscription_bugChanged_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Subscription.BugChanged(childComplexity, args["repoRef"].(*string), args["query"].(*string)), true + } return 0, false } @@ -1842,6 +1878,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(), } @@ -2573,6 +2626,20 @@ type Mutation # See each entity mutations OPEN CLOSED } +`, BuiltIn: false}, + {Name: "../schema/subscription.graphql", Input: `type Subscription { + bugChanged(repoRef: String, query: String): BugChange! +} + +enum ChangeType { + CREATED + UPDATED +} + +type BugChange { + type: ChangeType! + bug: Bug! +} `, BuiltIn: false}, {Name: "../schema/types.graphql", Input: `scalar CombinedId scalar Time diff --git a/api/graphql/graph/subscription.generated.go b/api/graphql/graph/subscription.generated.go new file mode 100644 index 0000000000000000000000000000000000000000..a6f99919a1ecd301343173228344cf056d81b035 --- /dev/null +++ b/api/graphql/graph/subscription.generated.go @@ -0,0 +1,382 @@ +// 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/vektah/gqlparser/v2/ast" +) + +// region ************************** generated!.gotpl ************************** + +type SubscriptionResolver interface { + BugChanged(ctx context.Context, repoRef *string, query *string) (<-chan *models.BugChange, 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) { + var err error + args := map[string]any{} + arg0, err := ec.field_Subscription_bugChanged_argsRepoRef(ctx, rawArgs) + if err != nil { + return nil, err + } + args["repoRef"] = arg0 + arg1, err := ec.field_Subscription_bugChanged_argsQuery(ctx, rawArgs) + if err != nil { + return nil, err + } + args["query"] = arg1 + return args, nil +} +func (ec *executionContext) field_Subscription_bugChanged_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_bugChanged_argsQuery( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + if _, ok := rawArgs["query"]; !ok { + var zeroVal *string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("query")) + if tmp, ok := rawArgs["query"]; 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) _BugChange_type(ctx context.Context, field graphql.CollectedField, obj *models.BugChange) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_BugChange_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.ChangeType) + fc.Result = res + return ec.marshalNChangeType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐChangeType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_BugChange_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BugChange", + 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 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) + 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_BugChange_bug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BugChange", + 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) _Subscription_bugChanged(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) { + fc, err := ec.fieldContext_Subscription_bugChanged(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().BugChanged(rctx, fc.Args["repoRef"].(*string), fc.Args["query"].(*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.BugChange): + if !ok { + return nil + } + return graphql.WriterFunc(func(w io.Writer) { + 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) + w.Write([]byte{'}'}) + }) + case <-ctx.Done(): + return nil + } + } +} + +func (ec *executionContext) fieldContext_Subscription_bugChanged(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_BugChange_type(ctx, field) + case "bug": + return ec.fieldContext_BugChange_bug(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type BugChange", 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_bugChanged_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 bugChangeImplementors = []string{"BugChange"} + +func (ec *executionContext) _BugChange(ctx context.Context, sel ast.SelectionSet, obj *models.BugChange) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, bugChangeImplementors) + + 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") + case "type": + out.Values[i] = ec._BugChange_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "bug": + out.Values[i] = ec._BugChange_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 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 "bugChanged": + return ec._Subscription_bugChanged(ctx, fields[0]) + default: + panic("unknown field " + strconv.Quote(fields[0].Name)) + } +} + +// endregion **************************** object.gotpl **************************** + +// 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) marshalNBugChange2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugChange(ctx context.Context, sel ast.SelectionSet, v *models.BugChange) 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) +} + +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 + 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 { + return v +} + +// endregion ***************************** type.gotpl ***************************** diff --git a/api/graphql/handler.go b/api/graphql/handler.go index e45738d270f820f070c4ace154ef8dcc262bc9a0..6504055f6db251232948e6b1e9152ec2120c64bf 100644 --- a/api/graphql/handler.go +++ b/api/graphql/handler.go @@ -23,6 +23,9 @@ type Handler struct { func NewHandler(mrc *cache.MultiRepoCache, errorOut io.Writer) Handler { rootResolver := resolvers.NewRootResolver(mrc) config := graph.Config{Resolvers: rootResolver} + + // TODO? https://gqlgen.com/recipes/subscriptions/ says to configure a WS upgrader, but + // handler.NewDefaultServer doesn't do it. h := handler.NewDefaultServer(graph.NewExecutableSchema(config)) if errorOut != nil { diff --git a/api/graphql/models/gen_models.go b/api/graphql/models/gen_models.go index 36c68c0e691ee52391805fbd8511d828541e5c09..04e0278c545c12bf474399f15e7f8cd0c1ce34f7 100644 --- a/api/graphql/models/gen_models.go +++ b/api/graphql/models/gen_models.go @@ -3,6 +3,11 @@ package models import ( + "bytes" + "fmt" + "io" + "strconv" + "github.com/git-bug/git-bug/entities/bug" "github.com/git-bug/git-bug/entities/common" "github.com/git-bug/git-bug/entity/dag" @@ -84,6 +89,11 @@ 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"` @@ -308,3 +318,61 @@ type PageInfo struct { type Query struct { } + +type Subscription struct { +} + +type ChangeType string + +const ( + ChangeTypeCreated ChangeType = "CREATED" + ChangeTypeUpdated ChangeType = "UPDATED" +) + +var AllChangeType = []ChangeType{ + ChangeTypeCreated, + ChangeTypeUpdated, +} + +func (e ChangeType) IsValid() bool { + switch e { + case ChangeTypeCreated, ChangeTypeUpdated: + return true + } + return false +} + +func (e ChangeType) String() string { + return string(e) +} + +func (e *ChangeType) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ChangeType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ChangeType", str) + } + return nil +} + +func (e ChangeType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *ChangeType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e ChangeType) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} diff --git a/api/graphql/models/lazy_bug.go b/api/graphql/models/lazy_bug.go index 7570b4eaa783698862270b750544d51990e792cd..daf8990882c9521ffad2eb915a9354637256d2a5 100644 --- a/api/graphql/models/lazy_bug.go +++ b/api/graphql/models/lazy_bug.go @@ -33,7 +33,7 @@ type BugWrapper interface { 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 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..72ea18aeb5306ea14e72d441dd3b85877680908e --- /dev/null +++ b/api/graphql/resolvers/subscription.go @@ -0,0 +1,75 @@ +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/entity" +) + +var _ graph.SubscriptionResolver = &subscriptionResolver{} + +type subscriptionResolver struct { + cache *cache.MultiRepoCache +} + +func (s subscriptionResolver) BugChanged(ctx context.Context, repoRef *string, query *string) (<-chan *models.BugChange, error) { + var repo *cache.RepoCache + var err error + + if repoRef == nil { + repo, err = s.cache.DefaultRepo() + } else { + repo, err = s.cache.ResolveRepo(*repoRef) + } + + if err != nil { + return nil, err + } + + out := make(chan *models.BugChange) + sub := bugSubscription{out: out, repo: repo} + repo.RegisterObserver(bug.Typename, sub) + + go func() { + <-ctx.Done() + repo.RegisterObserver(bug.Typename, sub) + }() + + return out, nil +} + +type bugSubscription struct { + out chan *models.BugChange + repo *cache.RepoCache +} + +func (bs bugSubscription) EntityCreated(_ string, id entity.Id) { + excerpt, err := bs.repo.Bugs().ResolveExcerpt(id) + if err != nil { + // Should never happen + fmt.Printf("bug in the cache: could not resolve excerpt for %s: %s\n", id, err) + return + } + bs.out <- &models.BugChange{ + Type: models.ChangeTypeCreated, + Bug: models.NewLazyBug(bs.repo, excerpt), + } +} + +func (bs bugSubscription) EntityUpdated(_ string, id entity.Id) { + excerpt, err := bs.repo.Bugs().ResolveExcerpt(id) + if err != nil { + // Should never happen + fmt.Printf("bug in the cache: could not resolve excerpt for %s: %s\n", id, err) + return + } + bs.out <- &models.BugChange{ + Type: models.ChangeTypeUpdated, + Bug: models.NewLazyBug(bs.repo, excerpt), + } +} diff --git a/api/graphql/schema/subscription.graphql b/api/graphql/schema/subscription.graphql new file mode 100644 index 0000000000000000000000000000000000000000..eb73b7de66e4a7e5e1f852fec823eb727fd2e9f6 --- /dev/null +++ b/api/graphql/schema/subscription.graphql @@ -0,0 +1,13 @@ +type Subscription { + bugChanged(repoRef: String, query: String): BugChange! +} + +enum ChangeType { + CREATED + UPDATED +} + +type BugChange { + type: ChangeType! + bug: Bug! +} 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/repo_cache.go b/cache/repo_cache.go index eb441d9e6acc2d2843030aa19034584787235004..167ae743614643618195886819c42342993a5776 100644 --- a/cache/repo_cache.go +++ b/cache/repo_cache.go @@ -7,6 +7,8 @@ import ( "strconv" "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" @@ -38,18 +40,29 @@ 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 // 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 +114,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() { @@ -137,6 +150,28 @@ func NewRepoCacheNoEvents(r repository.ClockedRepo) (*RepoCache, error) { return cache, nil } +func (c *RepoCache) RegisterObserver(typename string, observer Observer) { + switch typename { + case bug.Typename: + c.bugs.RegisterObserver(observer) + case identity.Typename: + c.identities.RegisterObserver(observer) + default: + panic(fmt.Sprintf("unknown typename %q", typename)) + } +} + +func (c *RepoCache) UnregisterObserver(typename string, observer Observer) { + switch typename { + case bug.Typename: + c.bugs.UnregisterObserver(observer) + case identity.Typename: + c.identities.UnregisterObserver(observer) + default: + panic(fmt.Sprintf("unknown typename %q", typename)) + } +} + // Bugs gives access to the Bug entities func (c *RepoCache) Bugs() *RepoCacheBug { return c.bugs @@ -224,15 +259,15 @@ const ( // 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 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 not particular entity is involved. + // 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 element being built. Set if Event is BuildEventStarted. + // Total is the total number of elements being built. Set if Event is BuildEventStarted. Total int64 - // Progress is the current count of processed element. Set if Event is BuildEventProgress. + // Progress is the current count of processed elements. Set if Event is BuildEventProgress. Progress int64 } diff --git a/cache/subcache.go b/cache/subcache.go index d9b6db8d4e875c8a62920bec05f3f491bc081325..c5c3a88d2bc167a853d71760609c226bee97b0d2 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]struct{} } func NewSubCache[EntityT entity.Interface, ExcerptT Excerpt, CacheT CacheEntity]( @@ -332,6 +335,18 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Close() error { return nil } +func (sc *SubCache[EntityT, ExcerptT, CacheT]) RegisterObserver(observer Observer) { + sc.muObservers.Lock() + defer sc.muObservers.Unlock() + sc.observers[observer] = struct{}{} +} + +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() @@ -460,7 +475,7 @@ 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.entityCreated(e.Id()) if err != nil { return *new(CacheT), err } @@ -582,8 +597,28 @@ 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() + + 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 { + sc.muObservers.RLock() + for observer := range sc.observers { + observer.EntityCreated(sc.typename, id) + } + sc.muObservers.RUnlock() + + return sc.updateExcerptAndIndex(id) +} + +func (sc *SubCache[EntityT, ExcerptT, CacheT]) updateExcerptAndIndex(id entity.Id) error { sc.mu.Lock() e, ok := sc.cached[id] if !ok { @@ -597,7 +632,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 }