Merge pull request #117 from A-Hilaly/bug-participants

Michael Muré created

Implement participants and actors functionalities

Change summary

bug/op_add_comment.go      |   3 
bug/op_create.go           |   3 
bug/op_create_test.go      |   6 
bug/op_edit_comment.go     |   2 
bug/op_label_change.go     |   2 
bug/op_set_status.go       |   1 
bug/op_set_title.go        |   1 
bug/snapshot.go            |  36 ++++++-
cache/bug_excerpt.go       |  22 +++
cache/filter.go            |  62 ++++++++++++-
cache/query.go             |  10 +
cache/query_test.go        |   3 
doc/queries.md             |  21 ++++
graphql/graph/gen_graph.go | 189 +++++++++++++++++++++++++++++++++++++--
graphql/graphql_test.go    |  30 ++++-
graphql/resolvers/bug.go   |  21 ++++
graphql/schema/bug.graphql |   2 
17 files changed, 376 insertions(+), 38 deletions(-)

Detailed changes

bug/op_add_comment.go 🔗

@@ -29,6 +29,9 @@ func (op *AddCommentOperation) Hash() (git.Hash, error) {
 }
 
 func (op *AddCommentOperation) Apply(snapshot *Snapshot) {
+	snapshot.addActor(op.Author)
+	snapshot.addParticipant(op.Author)
+
 	hash, err := op.Hash()
 	if err != nil {
 		// Should never error unless a programming error happened

bug/op_create.go 🔗

@@ -30,6 +30,9 @@ func (op *CreateOperation) Hash() (git.Hash, error) {
 }
 
 func (op *CreateOperation) Apply(snapshot *Snapshot) {
+	snapshot.addActor(op.Author)
+	snapshot.addParticipant(op.Author)
+
 	hash, err := op.Hash()
 	if err != nil {
 		// Should never error unless a programming error happened

bug/op_create_test.go 🔗

@@ -35,8 +35,10 @@ func TestCreate(t *testing.T) {
 		Comments: []Comment{
 			comment,
 		},
-		Author:    rene,
-		CreatedAt: create.Time(),
+		Author:       rene,
+		Participants: []identity.Interface{rene},
+		Actors:       []identity.Interface{rene},
+		CreatedAt:    create.Time(),
 		Timeline: []TimelineItem{
 			&CreateTimelineItem{
 				CommentTimelineItem: NewCommentTimelineItem(hash, comment),

bug/op_edit_comment.go 🔗

@@ -33,6 +33,8 @@ func (op *EditCommentOperation) Apply(snapshot *Snapshot) {
 	// Todo: currently any message can be edited, even by a different author
 	// crypto signature are needed.
 
+	snapshot.addActor(op.Author)
+
 	var target TimelineItem
 	var commentIndex int
 

bug/op_label_change.go 🔗

@@ -31,6 +31,8 @@ func (op *LabelChangeOperation) Hash() (git.Hash, error) {
 
 // Apply apply the operation
 func (op *LabelChangeOperation) Apply(snapshot *Snapshot) {
+	snapshot.addActor(op.Author)
+
 	// Add in the set
 AddLoop:
 	for _, added := range op.Added {

bug/op_set_status.go 🔗

@@ -27,6 +27,7 @@ func (op *SetStatusOperation) Hash() (git.Hash, error) {
 
 func (op *SetStatusOperation) Apply(snapshot *Snapshot) {
 	snapshot.Status = op.Status
+	snapshot.addActor(op.Author)
 
 	hash, err := op.Hash()
 	if err != nil {

bug/op_set_title.go 🔗

@@ -31,6 +31,7 @@ func (op *SetTitleOperation) Hash() (git.Hash, error) {
 
 func (op *SetTitleOperation) Apply(snapshot *Snapshot) {
 	snapshot.Title = op.Title
+	snapshot.addActor(op.Author)
 
 	hash, err := op.Hash()
 	if err != nil {

bug/snapshot.go 🔗

@@ -12,12 +12,14 @@ import (
 type Snapshot struct {
 	id string
 
-	Status    Status
-	Title     string
-	Comments  []Comment
-	Labels    []Label
-	Author    identity.Interface
-	CreatedAt time.Time
+	Status       Status
+	Title        string
+	Comments     []Comment
+	Labels       []Label
+	Author       identity.Interface
+	Actors       []identity.Interface
+	Participants []identity.Interface
+	CreatedAt    time.Time
 
 	Timeline []TimelineItem
 
@@ -62,3 +64,25 @@ func (snap *Snapshot) SearchTimelineItem(hash git.Hash) (TimelineItem, error) {
 
 	return nil, fmt.Errorf("timeline item not found")
 }
+
+// append the operation author to the actors list
+func (snap *Snapshot) addActor(actor identity.Interface) {
+	for _, a := range snap.Actors {
+		if actor.Id() == a.Id() {
+			return
+		}
+	}
+
+	snap.Actors = append(snap.Actors, actor)
+}
+
+// append the operation author to the participants list
+func (snap *Snapshot) addParticipant(participant identity.Interface) {
+	for _, p := range snap.Participants {
+		if participant.Id() == p.Id() {
+			return
+		}
+	}
+
+	snap.Participants = append(snap.Participants, participant)
+}

cache/bug_excerpt.go 🔗

@@ -23,10 +23,12 @@ type BugExcerpt struct {
 	CreateUnixTime    int64
 	EditUnixTime      int64
 
-	Status      bug.Status
-	Labels      []bug.Label
-	Title       string
-	LenComments int
+	Status       bug.Status
+	Labels       []bug.Label
+	Title        string
+	LenComments  int
+	Actors       []string
+	Participants []string
 
 	// If author is identity.Bare, LegacyAuthor is set
 	// If author is identity.Identity, AuthorId is set and data is deported
@@ -44,6 +46,16 @@ type LegacyAuthorExcerpt struct {
 }
 
 func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
+	participantsIds := make([]string, len(snap.Participants))
+	for i, participant := range snap.Participants {
+		participantsIds[i] = participant.Id()
+	}
+
+	actorsIds := make([]string, len(snap.Actors))
+	for i, actor := range snap.Actors {
+		actorsIds[i] = actor.Id()
+	}
+
 	e := &BugExcerpt{
 		Id:                b.Id(),
 		CreateLamportTime: b.CreateLamportTime(),
@@ -52,6 +64,8 @@ func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
 		EditUnixTime:      snap.LastEditUnix(),
 		Status:            snap.Status,
 		Labels:            snap.Labels,
+		Actors:            actorsIds,
+		Participants:      participantsIds,
 		Title:             snap.Title,
 		LenComments:       len(snap.Comments),
 		CreateMetadata:    b.FirstOp().AllMetadata(),

cache/filter.go 🔗

@@ -55,6 +55,48 @@ func LabelFilter(label string) Filter {
 	}
 }
 
+// ActorFilter return a Filter that match a bug actor
+func ActorFilter(query string) Filter {
+	return func(repoCache *RepoCache, excerpt *BugExcerpt) bool {
+		query = strings.ToLower(query)
+
+		for _, id := range excerpt.Actors {
+			identityExcerpt, ok := repoCache.identitiesExcerpts[id]
+			if !ok {
+				panic("missing identity in the cache")
+			}
+
+			if query == identityExcerpt.Id ||
+				strings.Contains(strings.ToLower(identityExcerpt.Name), query) ||
+				query == strings.ToLower(identityExcerpt.Login) {
+				return true
+			}
+		}
+		return false
+	}
+}
+
+// ParticipantFilter return a Filter that match a bug participant
+func ParticipantFilter(query string) Filter {
+	return func(repoCache *RepoCache, excerpt *BugExcerpt) bool {
+		query = strings.ToLower(query)
+
+		for _, id := range excerpt.Participants {
+			identityExcerpt, ok := repoCache.identitiesExcerpts[id]
+			if !ok {
+				panic("missing identity in the cache")
+			}
+
+			if query == identityExcerpt.Id ||
+				strings.Contains(strings.ToLower(identityExcerpt.Name), query) ||
+				query == strings.ToLower(identityExcerpt.Login) {
+				return true
+			}
+		}
+		return false
+	}
+}
+
 // TitleFilter return a Filter that match if the title contains the given query
 func TitleFilter(query string) Filter {
 	return func(repo *RepoCache, excerpt *BugExcerpt) bool {
@@ -74,11 +116,13 @@ func NoLabelFilter() Filter {
 
 // Filters is a collection of Filter that implement a complex filter
 type Filters struct {
-	Status    []Filter
-	Author    []Filter
-	Label     []Filter
-	Title     []Filter
-	NoFilters []Filter
+	Status      []Filter
+	Author      []Filter
+	Actor       []Filter
+	Participant []Filter
+	Label       []Filter
+	Title       []Filter
+	NoFilters   []Filter
 }
 
 // Match check if a bug match the set of filters
@@ -91,6 +135,14 @@ func (f *Filters) Match(repoCache *RepoCache, excerpt *BugExcerpt) bool {
 		return false
 	}
 
+	if match := f.orMatch(f.Participant, repoCache, excerpt); !match {
+		return false
+	}
+
+	if match := f.orMatch(f.Actor, repoCache, excerpt); !match {
+		return false
+	}
+
 	if match := f.andMatch(f.Label, repoCache, excerpt); !match {
 		return false
 	}

cache/query.go 🔗

@@ -56,13 +56,21 @@ func ParseQuery(query string) (*Query, error) {
 			f := AuthorFilter(qualifierQuery)
 			result.Author = append(result.Author, f)
 
+		case "actor":
+			f := ActorFilter(qualifierQuery)
+			result.Actor = append(result.Actor, f)
+
+		case "participant":
+			f := ParticipantFilter(qualifierQuery)
+			result.Participant = append(result.Participant, f)
+
 		case "label":
 			f := LabelFilter(qualifierQuery)
 			result.Label = append(result.Label, f)
 
 		case "title":
 			f := TitleFilter(qualifierQuery)
-			result.Label = append(result.Title, f)
+			result.Title = append(result.Title, f)
 
 		case "no":
 			err := result.parseNoFilter(qualifierQuery)

cache/query_test.go 🔗

@@ -19,6 +19,9 @@ func TestQueryParse(t *testing.T) {
 		{"author:rene", true},
 		{`author:"René Descartes"`, true},
 
+		{"actor:bernhard", true},
+		{"participant:leonhard", true},
+
 		{"label:hello", true},
 		{`label:"Good first issue"`, true},
 

doc/queries.md 🔗

@@ -33,6 +33,27 @@ You can filter based on the person who opened the bug.
 | `author:QUERY` | `author:descartes` matches bugs opened by `René Descartes` or `Robert Descartes` |
 |                | `author:"rené descartes"` matches bugs opened by `René Descartes`                |
 
+### Filtering by participant
+
+You can filter based on the person who participated in any activity related to the bug (Opened bug or added a comment).
+
+| Qualifier           | Example                                                                                            |
+| ---                 | ---                                                                                                |
+| `participant:QUERY` | `participant:descartes` matches bugs opened or commented by `René Descartes` or `Robert Descartes` |
+|                     | `participant:"rené descartes"` matches bugs opened or commented by `René Descartes`                |
+
+### Filtering by actor
+
+You can filter based on the person who interacted with the bug.
+
+| Qualifier     | Example                                                                         |
+| ---           | ---                                                                             |
+| `actor:QUERY` | `actor:descartes` matches bugs edited by `René Descartes` or `Robert Descartes` |
+|               | `actor:"rené descartes"` matches bugs edited by `René Descartes`                |
+| `
+
+**NOTE**: interaction with bugs include: opening the bug, adding comments, adding/removing labels etc...
+
 ### Filtering by label
 
 You can filter based on the bug's label.

graphql/graph/gen_graph.go 🔗

@@ -81,17 +81,19 @@ type ComplexityRoot struct {
 	}
 
 	Bug struct {
-		Id         func(childComplexity int) int
-		HumanId    func(childComplexity int) int
-		Status     func(childComplexity int) int
-		Title      func(childComplexity int) int
-		Labels     func(childComplexity int) int
-		Author     func(childComplexity int) int
-		CreatedAt  func(childComplexity int) int
-		LastEdit   func(childComplexity int) int
-		Comments   func(childComplexity int, after *string, before *string, first *int, last *int) int
-		Timeline   func(childComplexity int, after *string, before *string, first *int, last *int) int
-		Operations func(childComplexity int, after *string, before *string, first *int, last *int) int
+		Id           func(childComplexity int) int
+		HumanId      func(childComplexity int) int
+		Status       func(childComplexity int) int
+		Title        func(childComplexity int) int
+		Labels       func(childComplexity int) int
+		Author       func(childComplexity int) int
+		Actors       func(childComplexity int) int
+		Participants func(childComplexity int) int
+		CreatedAt    func(childComplexity int) int
+		LastEdit     func(childComplexity int) int
+		Comments     func(childComplexity int, after *string, before *string, first *int, last *int) int
+		Timeline     func(childComplexity int, after *string, before *string, first *int, last *int) int
+		Operations   func(childComplexity int, after *string, before *string, first *int, last *int) int
 	}
 
 	BugConnection struct {
@@ -293,6 +295,9 @@ type AddCommentTimelineItemResolver interface {
 type BugResolver interface {
 	Status(ctx context.Context, obj *bug.Snapshot) (models.Status, error)
 
+	Actors(ctx context.Context, obj *bug.Snapshot) ([]*identity.Interface, error)
+	Participants(ctx context.Context, obj *bug.Snapshot) ([]*identity.Interface, error)
+
 	LastEdit(ctx context.Context, obj *bug.Snapshot) (time.Time, error)
 	Comments(ctx context.Context, obj *bug.Snapshot, after *string, before *string, first *int, last *int) (models.CommentConnection, error)
 	Timeline(ctx context.Context, obj *bug.Snapshot, after *string, before *string, first *int, last *int) (models.TimelineItemConnection, error)
@@ -1239,6 +1244,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Bug.Author(childComplexity), true
 
+	case "Bug.actors":
+		if e.complexity.Bug.Actors == nil {
+			break
+		}
+
+		return e.complexity.Bug.Actors(childComplexity), true
+
+	case "Bug.participants":
+		if e.complexity.Bug.Participants == nil {
+			break
+		}
+
+		return e.complexity.Bug.Participants(childComplexity), true
+
 	case "Bug.createdAt":
 		if e.complexity.Bug.CreatedAt == nil {
 			break
@@ -2779,6 +2798,24 @@ func (ec *executionContext) _Bug(ctx context.Context, sel ast.SelectionSet, obj
 			if out.Values[i] == graphql.Null {
 				invalid = true
 			}
+		case "actors":
+			wg.Add(1)
+			go func(i int, field graphql.CollectedField) {
+				out.Values[i] = ec._Bug_actors(ctx, field, obj)
+				if out.Values[i] == graphql.Null {
+					invalid = true
+				}
+				wg.Done()
+			}(i, field)
+		case "participants":
+			wg.Add(1)
+			go func(i int, field graphql.CollectedField) {
+				out.Values[i] = ec._Bug_participants(ctx, field, obj)
+				if out.Values[i] == graphql.Null {
+					invalid = true
+				}
+				wg.Done()
+			}(i, field)
 		case "createdAt":
 			out.Values[i] = ec._Bug_createdAt(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
@@ -3003,6 +3040,134 @@ func (ec *executionContext) _Bug_author(ctx context.Context, field graphql.Colle
 	return ec._Identity(ctx, field.Selections, &res)
 }
 
+// nolint: vetshadow
+func (ec *executionContext) _Bug_actors(ctx context.Context, field graphql.CollectedField, obj *bug.Snapshot) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object: "Bug",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Bug().Actors(rctx, obj)
+	})
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*identity.Interface)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+
+	arr1 := make(graphql.Array, len(res))
+	var wg sync.WaitGroup
+
+	isLen1 := len(res) == 1
+	if !isLen1 {
+		wg.Add(len(res))
+	}
+
+	for idx1 := range res {
+		idx1 := idx1
+		rctx := &graphql.ResolverContext{
+			Index:  &idx1,
+			Result: res[idx1],
+		}
+		ctx := graphql.WithResolverContext(ctx, rctx)
+		f := func(idx1 int) {
+			if !isLen1 {
+				defer wg.Done()
+			}
+			arr1[idx1] = func() graphql.Marshaler {
+
+				if res[idx1] == nil {
+					return graphql.Null
+				}
+
+				return ec._Identity(ctx, field.Selections, res[idx1])
+			}()
+		}
+		if isLen1 {
+			f(idx1)
+		} else {
+			go f(idx1)
+		}
+
+	}
+	wg.Wait()
+	return arr1
+}
+
+// nolint: vetshadow
+func (ec *executionContext) _Bug_participants(ctx context.Context, field graphql.CollectedField, obj *bug.Snapshot) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object: "Bug",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Bug().Participants(rctx, obj)
+	})
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*identity.Interface)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+
+	arr1 := make(graphql.Array, len(res))
+	var wg sync.WaitGroup
+
+	isLen1 := len(res) == 1
+	if !isLen1 {
+		wg.Add(len(res))
+	}
+
+	for idx1 := range res {
+		idx1 := idx1
+		rctx := &graphql.ResolverContext{
+			Index:  &idx1,
+			Result: res[idx1],
+		}
+		ctx := graphql.WithResolverContext(ctx, rctx)
+		f := func(idx1 int) {
+			if !isLen1 {
+				defer wg.Done()
+			}
+			arr1[idx1] = func() graphql.Marshaler {
+
+				if res[idx1] == nil {
+					return graphql.Null
+				}
+
+				return ec._Identity(ctx, field.Selections, res[idx1])
+			}()
+		}
+		if isLen1 {
+			f(idx1)
+		} else {
+			go f(idx1)
+		}
+
+	}
+	wg.Wait()
+	return arr1
+}
+
 // nolint: vetshadow
 func (ec *executionContext) _Bug_createdAt(ctx context.Context, field graphql.CollectedField, obj *bug.Snapshot) graphql.Marshaler {
 	ctx = ec.Tracer.StartFieldExecution(ctx, field)
@@ -9637,6 +9802,8 @@ type Bug {
   title: String!
   labels: [Label!]!
   author: Identity!
+  actors: [Identity]!
+  participants: [Identity]!
   createdAt: Time!
   lastEdit: Time!
 

graphql/graphql_test.go 🔗

@@ -50,6 +50,16 @@ func TestQueries(t *testing.T) {
                 email
                 avatarUrl
               }
+              actors {
+                name
+                email
+                avatarUrl
+              }
+              participants {
+                name
+                email
+                avatarUrl
+              }
       
               createdAt
               humanId
@@ -112,7 +122,7 @@ func TestQueries(t *testing.T) {
         }
       }`
 
-	type Person struct {
+	type Identity struct {
 		Name      string `json:"name"`
 		Email     string `json:"email"`
 		AvatarUrl string `json:"avatarUrl"`
@@ -123,13 +133,15 @@ func TestQueries(t *testing.T) {
 			AllBugs struct {
 				PageInfo models.PageInfo
 				Nodes    []struct {
-					Author    Person
-					CreatedAt string `json:"createdAt"`
-					HumanId   string `json:"humanId"`
-					Id        string
-					LastEdit  string `json:"lastEdit"`
-					Status    string
-					Title     string
+					Author       Identity
+					Actors       []Identity
+					Participants []Identity
+					CreatedAt    string `json:"createdAt"`
+					HumanId      string `json:"humanId"`
+					Id           string
+					LastEdit     string `json:"lastEdit"`
+					Status       string
+					Title        string
 
 					Comments struct {
 						PageInfo models.PageInfo
@@ -142,7 +154,7 @@ func TestQueries(t *testing.T) {
 					Operations struct {
 						PageInfo models.PageInfo
 						Nodes    []struct {
-							Author  Person
+							Author  Identity
 							Date    string
 							Title   string
 							Files   []string

graphql/resolvers/bug.go 🔗

@@ -8,6 +8,7 @@ import (
 	"github.com/MichaelMure/git-bug/graphql/connections"
 	"github.com/MichaelMure/git-bug/graphql/graph"
 	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/identity"
 )
 
 var _ graph.BugResolver = &bugResolver{}
@@ -102,3 +103,23 @@ func (bugResolver) Timeline(ctx context.Context, obj *bug.Snapshot, after *strin
 func (bugResolver) LastEdit(ctx context.Context, obj *bug.Snapshot) (time.Time, error) {
 	return obj.LastEditTime(), nil
 }
+
+func (bugResolver) Actors(ctx context.Context, obj *bug.Snapshot) ([]*identity.Interface, error) {
+	actorsp := make([]*identity.Interface, len(obj.Actors))
+
+	for i, actor := range obj.Actors {
+		actorsp[i] = &actor
+	}
+
+	return actorsp, nil
+}
+
+func (bugResolver) Participants(ctx context.Context, obj *bug.Snapshot) ([]*identity.Interface, error) {
+	participantsp := make([]*identity.Interface, len(obj.Participants))
+
+	for i, participant := range obj.Participants {
+		participantsp[i] = &participant
+	}
+
+	return participantsp, nil
+}

graphql/schema/bug.graphql 🔗

@@ -36,6 +36,8 @@ type Bug {
   title: String!
   labels: [Label!]!
   author: Identity!
+  actors: [Identity]!
+  participants: [Identity]!
   createdAt: Time!
   lastEdit: Time!