diff options
author | Michael Muré <batolettre@gmail.com> | 2022-08-18 23:34:05 +0200 |
---|---|---|
committer | Michael Muré <batolettre@gmail.com> | 2022-08-18 23:44:06 +0200 |
commit | 5511c230b678a181cc596238bf6669428d1b1902 (patch) | |
tree | 8701efc87732439f993eb4f1d00585fc419b87ab /entities/bug | |
parent | 5ca686b59751e3c87740b84108c54fc675a074cf (diff) | |
download | git-bug-5511c230b678a181cc596238bf6669428d1b1902.tar.gz |
move {bug,identity} to /entities, move input to /commands
Diffstat (limited to 'entities/bug')
28 files changed, 2205 insertions, 0 deletions
diff --git a/entities/bug/bug.go b/entities/bug/bug.go new file mode 100644 index 00000000..213a4ca4 --- /dev/null +++ b/entities/bug/bug.go @@ -0,0 +1,179 @@ +// Package bug contains the bug data model and low-level related functions +package bug + +import ( + "fmt" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" +) + +var _ Interface = &Bug{} +var _ entity.Interface = &Bug{} + +// 1: original format +// 2: no more legacy identities +// 3: Ids are generated from the create operation serialized data instead of from the first git commit +// 4: with DAG entity framework +const formatVersion = 4 + +var def = dag.Definition{ + Typename: "bug", + Namespace: "bugs", + OperationUnmarshaler: operationUnmarshaller, + FormatVersion: formatVersion, +} + +var ClockLoader = dag.ClockLoader(def) + +// 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. +type Bug struct { + *dag.Entity +} + +// NewBug create a new Bug +func NewBug() *Bug { + return &Bug{ + Entity: dag.New(def), + } +} + +func simpleResolvers(repo repository.ClockedRepo) entity.Resolvers { + return entity.Resolvers{ + &identity.Identity{}: identity.NewSimpleResolver(repo), + } +} + +// Read will read a bug from a repository +func Read(repo repository.ClockedRepo, id entity.Id) (*Bug, error) { + return ReadWithResolver(repo, simpleResolvers(repo), id) +} + +// ReadWithResolver will read a bug from its Id, with custom resolvers +func ReadWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (*Bug, error) { + e, err := dag.Read(def, repo, resolvers, id) + if err != nil { + return nil, err + } + return &Bug{Entity: e}, nil +} + +type StreamedBug struct { + Bug *Bug + Err error +} + +// ReadAll read and parse all local bugs +func ReadAll(repo repository.ClockedRepo) <-chan StreamedBug { + return readAll(repo, simpleResolvers(repo)) +} + +// ReadAllWithResolver read and parse all local bugs +func ReadAllWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan StreamedBug { + return readAll(repo, resolvers) +} + +// Read and parse all available bug with a given ref prefix +func readAll(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan StreamedBug { + out := make(chan StreamedBug) + + go func() { + defer close(out) + + for streamedEntity := range dag.ReadAll(def, repo, resolvers) { + if streamedEntity.Err != nil { + out <- StreamedBug{ + Err: streamedEntity.Err, + } + } else { + out <- StreamedBug{ + Bug: &Bug{Entity: streamedEntity.Entity}, + } + } + } + }() + + return out +} + +// ListLocalIds list all the available local bug ids +func ListLocalIds(repo repository.Repo) ([]entity.Id, error) { + return dag.ListLocalIds(def, repo) +} + +// Validate check if the Bug data is valid +func (bug *Bug) Validate() error { + if err := bug.Entity.Validate(); err != nil { + return err + } + + // The very first Op should be a CreateOp + firstOp := bug.FirstOp() + if firstOp == nil || firstOp.Type() != CreateOp { + return fmt.Errorf("first operation should be a Create op") + } + + // Check that there is no more CreateOp op + for i, op := range bug.Operations() { + if i == 0 { + continue + } + if op.Type() == CreateOp { + return fmt.Errorf("only one Create op allowed") + } + } + + return nil +} + +// Append add a new Operation to the Bug +func (bug *Bug) Append(op Operation) { + bug.Entity.Append(op) +} + +// Operations return the ordered operations +func (bug *Bug) Operations() []Operation { + source := bug.Entity.Operations() + result := make([]Operation, len(source)) + for i, op := range source { + result[i] = op.(Operation) + } + return result +} + +// Compile a bug in a easily usable snapshot +func (bug *Bug) Compile() *Snapshot { + snap := &Snapshot{ + id: bug.Id(), + Status: OpenStatus, + } + + for _, op := range bug.Operations() { + op.Apply(snap) + snap.Operations = append(snap.Operations, op) + } + + return snap +} + +// FirstOp lookup for the very first operation of the bug. +// For a valid Bug, this operation should be a CreateOp +func (bug *Bug) FirstOp() Operation { + if fo := bug.Entity.FirstOp(); fo != nil { + return fo.(Operation) + } + return nil +} + +// LastOp lookup for the very last operation of the bug. +// For a valid Bug, should never be nil +func (bug *Bug) LastOp() Operation { + if lo := bug.Entity.LastOp(); lo != nil { + return lo.(Operation) + } + return nil +} diff --git a/entities/bug/bug_actions.go b/entities/bug/bug_actions.go new file mode 100644 index 00000000..864c2052 --- /dev/null +++ b/entities/bug/bug_actions.go @@ -0,0 +1,74 @@ +package bug + +import ( + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" +) + +// Fetch retrieve updates from a remote +// This does not change the local bugs state +func Fetch(repo repository.Repo, remote string) (string, error) { + return dag.Fetch(def, repo, remote) +} + +// Push update a remote with the local changes +func Push(repo repository.Repo, remote string) (string, error) { + return dag.Push(def, repo, remote) +} + +// Pull will do a Fetch + MergeAll +// This function will return an error if a merge fail +// Note: an author is necessary for the case where a merge commit is created, as this commit will +// have an author and may be signed if a signing key is available. +func Pull(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) error { + _, err := Fetch(repo, remote) + if err != nil { + return err + } + + for merge := range MergeAll(repo, resolvers, remote, mergeAuthor) { + if merge.Err != nil { + return merge.Err + } + if merge.Status == entity.MergeStatusInvalid { + return errors.Errorf("merge failure: %s", merge.Reason) + } + } + + return nil +} + +// MergeAll will merge all the available remote bug +// Note: an author is necessary for the case where a merge commit is created, as this commit will +// have an author and may be signed if a signing key is available. +func MergeAll(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult { + out := make(chan entity.MergeResult) + + go func() { + defer close(out) + + results := dag.MergeAll(def, repo, resolvers, remote, mergeAuthor) + + // wrap the dag.Entity into a complete Bug + for result := range results { + result := result + if result.Entity != nil { + result.Entity = &Bug{ + Entity: result.Entity.(*dag.Entity), + } + } + out <- result + } + }() + + return out +} + +// RemoveBug will remove a local bug from its entity.Id +func RemoveBug(repo repository.ClockedRepo, id entity.Id) error { + return dag.Remove(def, repo, id) +} diff --git a/entities/bug/comment.go b/entities/bug/comment.go new file mode 100644 index 00000000..fcf501ab --- /dev/null +++ b/entities/bug/comment.go @@ -0,0 +1,45 @@ +package bug + +import ( + "github.com/dustin/go-humanize" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +// Comment represent a comment in a Bug +type Comment struct { + // id should be the result of entity.CombineIds with the Bug id and the id + // of the Operation that created the comment + id entity.Id + Author identity.Interface + Message string + Files []repository.Hash + + // Creation time of the comment. + // Should be used only for human display, never for ordering as we can't rely on it in a distributed system. + UnixTime timestamp.Timestamp +} + +// Id return the Comment identifier +func (c Comment) Id() entity.Id { + if c.id == "" { + // simply panic as it would be a coding error (no id provided at construction) + panic("no id") + } + return c.id +} + +// FormatTimeRel format the UnixTime of the comment for human consumption +func (c Comment) FormatTimeRel() string { + return humanize.Time(c.UnixTime.Time()) +} + +func (c Comment) FormatTime() string { + return c.UnixTime.Time().Format("Mon Jan 2 15:04:05 2006 +0200") +} + +// IsAuthored is a sign post method for gqlgen +func (c Comment) IsAuthored() {} diff --git a/entities/bug/err.go b/entities/bug/err.go new file mode 100644 index 00000000..1bd174bb --- /dev/null +++ b/entities/bug/err.go @@ -0,0 +1,17 @@ +package bug + +import ( + "errors" + + "github.com/MichaelMure/git-bug/entity" +) + +var ErrBugNotExist = errors.New("bug doesn't exist") + +func NewErrMultipleMatchBug(matching []entity.Id) *entity.ErrMultipleMatch { + return entity.NewErrMultipleMatch("bug", matching) +} + +func NewErrMultipleMatchOp(matching []entity.Id) *entity.ErrMultipleMatch { + return entity.NewErrMultipleMatch("operation", matching) +} diff --git a/entities/bug/interface.go b/entities/bug/interface.go new file mode 100644 index 00000000..2ae31fd1 --- /dev/null +++ b/entities/bug/interface.go @@ -0,0 +1,44 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/lamport" +) + +type Interface interface { + // Id returns the Bug identifier + Id() entity.Id + + // Validate checks if the Bug data is valid + Validate() error + + // Append an operation into the staging area, to be committed later + Append(op Operation) + + // Operations returns the ordered operations + Operations() []Operation + + // NeedCommit indicates that the in-memory state changed and need to be commit in the repository + NeedCommit() bool + + // Commit writes the staging area in Git and move the operations to the packs + Commit(repo repository.ClockedRepo) error + + // FirstOp lookup for the very first operation of the bug. + // For a valid Bug, this operation should be a CreateOp + FirstOp() Operation + + // LastOp lookup for the very last operation of the bug. + // For a valid Bug, should never be nil + LastOp() Operation + + // Compile a bug in an easily usable snapshot + Compile() *Snapshot + + // CreateLamportTime return the Lamport time of creation + CreateLamportTime() lamport.Time + + // EditLamportTime return the Lamport time of the last edit + EditLamportTime() lamport.Time +} diff --git a/entities/bug/label.go b/entities/bug/label.go new file mode 100644 index 00000000..79b5f591 --- /dev/null +++ b/entities/bug/label.go @@ -0,0 +1,95 @@ +package bug + +import ( + "crypto/sha256" + "fmt" + "image/color" + + fcolor "github.com/fatih/color" + + "github.com/MichaelMure/git-bug/util/text" +) + +type Label string + +func (l Label) String() string { + return string(l) +} + +// RGBA from a Label computed in a deterministic way +func (l Label) Color() LabelColor { + // colors from: https://material-ui.com/style/color/ + colors := []LabelColor{ + {R: 244, G: 67, B: 54, A: 255}, // red + {R: 233, G: 30, B: 99, A: 255}, // pink + {R: 156, G: 39, B: 176, A: 255}, // purple + {R: 103, G: 58, B: 183, A: 255}, // deepPurple + {R: 63, G: 81, B: 181, A: 255}, // indigo + {R: 33, G: 150, B: 243, A: 255}, // blue + {R: 3, G: 169, B: 244, A: 255}, // lightBlue + {R: 0, G: 188, B: 212, A: 255}, // cyan + {R: 0, G: 150, B: 136, A: 255}, // teal + {R: 76, G: 175, B: 80, A: 255}, // green + {R: 139, G: 195, B: 74, A: 255}, // lightGreen + {R: 205, G: 220, B: 57, A: 255}, // lime + {R: 255, G: 235, B: 59, A: 255}, // yellow + {R: 255, G: 193, B: 7, A: 255}, // amber + {R: 255, G: 152, B: 0, A: 255}, // orange + {R: 255, G: 87, B: 34, A: 255}, // deepOrange + {R: 121, G: 85, B: 72, A: 255}, // brown + {R: 158, G: 158, B: 158, A: 255}, // grey + {R: 96, G: 125, B: 139, A: 255}, // blueGrey + } + + id := 0 + hash := sha256.Sum256([]byte(l)) + for _, char := range hash { + id = (id + int(char)) % len(colors) + } + + return colors[id] +} + +func (l Label) Validate() error { + str := string(l) + + if text.Empty(str) { + return fmt.Errorf("empty") + } + + if !text.SafeOneLine(str) { + return fmt.Errorf("label has unsafe characters") + } + + return nil +} + +type LabelColor color.RGBA + +func (lc LabelColor) RGBA() color.RGBA { + return color.RGBA(lc) +} + +func (lc LabelColor) Term256() Term256 { + red := Term256(lc.R) * 6 / 256 + green := Term256(lc.G) * 6 / 256 + blue := Term256(lc.B) * 6 / 256 + + return red*36 + green*6 + blue + 16 +} + +type Term256 int + +func (t Term256) Escape() string { + if fcolor.NoColor { + return "" + } + return fmt.Sprintf("\x1b[38;5;%dm", t) +} + +func (t Term256) Unescape() string { + if fcolor.NoColor { + return "" + } + return "\x1b[0m" +} diff --git a/entities/bug/label_test.go b/entities/bug/label_test.go new file mode 100644 index 00000000..49401c49 --- /dev/null +++ b/entities/bug/label_test.go @@ -0,0 +1,35 @@ +package bug + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLabelRGBA(t *testing.T) { + rgba := Label("test1").Color() + expected := LabelColor{R: 0, G: 150, B: 136, A: 255} + + require.Equal(t, expected, rgba) +} + +func TestLabelRGBASimilar(t *testing.T) { + rgba := Label("test2").Color() + expected := LabelColor{R: 3, G: 169, B: 244, A: 255} + + require.Equal(t, expected, rgba) +} + +func TestLabelRGBAReverse(t *testing.T) { + rgba := Label("tset").Color() + expected := LabelColor{R: 63, G: 81, B: 181, A: 255} + + require.Equal(t, expected, rgba) +} + +func TestLabelRGBAEqual(t *testing.T) { + color1 := Label("test").Color() + color2 := Label("test").Color() + + require.Equal(t, color1, color2) +} diff --git a/entities/bug/op_add_comment.go b/entities/bug/op_add_comment.go new file mode 100644 index 00000000..2e6a39f9 --- /dev/null +++ b/entities/bug/op_add_comment.go @@ -0,0 +1,93 @@ +package bug + +import ( + "fmt" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/text" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +var _ Operation = &AddCommentOperation{} +var _ dag.OperationWithFiles = &AddCommentOperation{} + +// AddCommentOperation will add a new comment in the bug +type AddCommentOperation struct { + dag.OpBase + Message string `json:"message"` + // TODO: change for a map[string]util.hash to store the filename ? + Files []repository.Hash `json:"files"` +} + +func (op *AddCommentOperation) Id() entity.Id { + return dag.IdOperation(op, &op.OpBase) +} + +func (op *AddCommentOperation) Apply(snapshot *Snapshot) { + snapshot.addActor(op.Author()) + snapshot.addParticipant(op.Author()) + + comment := Comment{ + id: entity.CombineIds(snapshot.Id(), op.Id()), + Message: op.Message, + Author: op.Author(), + Files: op.Files, + UnixTime: timestamp.Timestamp(op.UnixTime), + } + + snapshot.Comments = append(snapshot.Comments, comment) + + item := &AddCommentTimelineItem{ + CommentTimelineItem: NewCommentTimelineItem(comment), + } + + snapshot.Timeline = append(snapshot.Timeline, item) +} + +func (op *AddCommentOperation) GetFiles() []repository.Hash { + return op.Files +} + +func (op *AddCommentOperation) Validate() error { + if err := op.OpBase.Validate(op, AddCommentOp); err != nil { + return err + } + + if !text.Safe(op.Message) { + return fmt.Errorf("message is not fully printable") + } + + return nil +} + +func NewAddCommentOp(author identity.Interface, unixTime int64, message string, files []repository.Hash) *AddCommentOperation { + return &AddCommentOperation{ + OpBase: dag.NewOpBase(AddCommentOp, author, unixTime), + Message: message, + Files: files, + } +} + +// AddCommentTimelineItem hold a comment in the timeline +type AddCommentTimelineItem struct { + CommentTimelineItem +} + +// IsAuthored is a sign post method for gqlgen +func (a *AddCommentTimelineItem) IsAuthored() {} + +// AddComment is a convenience function to add a comment to a bug +func AddComment(b Interface, author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (*AddCommentOperation, error) { + op := NewAddCommentOp(author, unixTime, message, files) + for key, val := range metadata { + op.SetMetadata(key, val) + } + if err := op.Validate(); err != nil { + return nil, err + } + b.Append(op) + return op, nil +} diff --git a/entities/bug/op_add_comment_test.go b/entities/bug/op_add_comment_test.go new file mode 100644 index 00000000..6f29cb01 --- /dev/null +++ b/entities/bug/op_add_comment_test.go @@ -0,0 +1,18 @@ +package bug + +import ( + "testing" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" +) + +func TestAddCommentSerialize(t *testing.T) { + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *AddCommentOperation { + return NewAddCommentOp(author, unixTime, "message", nil) + }) + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *AddCommentOperation { + return NewAddCommentOp(author, unixTime, "message", []repository.Hash{"hash1", "hash2"}) + }) +} diff --git a/entities/bug/op_create.go b/entities/bug/op_create.go new file mode 100644 index 00000000..fdfa131b --- /dev/null +++ b/entities/bug/op_create.go @@ -0,0 +1,112 @@ +package bug + +import ( + "fmt" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/text" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +var _ Operation = &CreateOperation{} +var _ dag.OperationWithFiles = &CreateOperation{} + +// CreateOperation define the initial creation of a bug +type CreateOperation struct { + dag.OpBase + Title string `json:"title"` + Message string `json:"message"` + Files []repository.Hash `json:"files"` +} + +func (op *CreateOperation) Id() entity.Id { + return dag.IdOperation(op, &op.OpBase) +} + +func (op *CreateOperation) Apply(snapshot *Snapshot) { + // sanity check: will fail when adding a second Create + if snapshot.id != "" && snapshot.id != entity.UnsetId && snapshot.id != op.Id() { + return + } + + snapshot.id = op.Id() + + snapshot.addActor(op.Author()) + snapshot.addParticipant(op.Author()) + + snapshot.Title = op.Title + + comment := Comment{ + id: entity.CombineIds(snapshot.Id(), op.Id()), + Message: op.Message, + Author: op.Author(), + UnixTime: timestamp.Timestamp(op.UnixTime), + } + + snapshot.Comments = []Comment{comment} + snapshot.Author = op.Author() + snapshot.CreateTime = op.Time() + + snapshot.Timeline = []TimelineItem{ + &CreateTimelineItem{ + CommentTimelineItem: NewCommentTimelineItem(comment), + }, + } +} + +func (op *CreateOperation) GetFiles() []repository.Hash { + return op.Files +} + +func (op *CreateOperation) Validate() error { + if err := op.OpBase.Validate(op, CreateOp); err != nil { + return err + } + + if text.Empty(op.Title) { + return fmt.Errorf("title is empty") + } + if !text.SafeOneLine(op.Title) { + return fmt.Errorf("title has unsafe characters") + } + + if !text.Safe(op.Message) { + return fmt.Errorf("message is not fully printable") + } + + return nil +} + +func NewCreateOp(author identity.Interface, unixTime int64, title, message string, files []repository.Hash) *CreateOperation { + return &CreateOperation{ + OpBase: dag.NewOpBase(CreateOp, author, unixTime), + Title: title, + Message: message, + Files: files, + } +} + +// CreateTimelineItem replace a Create operation in the Timeline and hold its edition history +type CreateTimelineItem struct { + CommentTimelineItem +} + +// IsAuthored is a sign post method for gqlgen +func (c *CreateTimelineItem) IsAuthored() {} + +// Create is a convenience function to create a bug +func Create(author identity.Interface, unixTime int64, title, message string, files []repository.Hash, metadata map[string]string) (*Bug, *CreateOperation, error) { + b := NewBug() + op := NewCreateOp(author, unixTime, title, message, files) + for key, val := range metadata { + op.SetMetadata(key, val) + } + if err := op.Validate(); err != nil { + return nil, op, err + } + b.Append(op) + return b, op, nil +} diff --git a/entities/bug/op_create_test.go b/entities/bug/op_create_test.go new file mode 100644 index 00000000..f2c9e675 --- /dev/null +++ b/entities/bug/op_create_test.go @@ -0,0 +1,67 @@ +package bug + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +func TestCreate(t *testing.T) { + snapshot := Snapshot{} + + repo := repository.NewMockRepoClock() + + rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr") + require.NoError(t, err) + + unix := time.Now().Unix() + + create := NewCreateOp(rene, unix, "title", "message", nil) + + create.Apply(&snapshot) + + id := create.Id() + require.NoError(t, id.Validate()) + + comment := Comment{ + id: entity.CombineIds(create.Id(), create.Id()), + Author: rene, + Message: "message", + UnixTime: timestamp.Timestamp(create.UnixTime), + } + + expected := Snapshot{ + id: create.Id(), + Title: "title", + Comments: []Comment{ + comment, + }, + Author: rene, + Participants: []identity.Interface{rene}, + Actors: []identity.Interface{rene}, + CreateTime: create.Time(), + Timeline: []TimelineItem{ + &CreateTimelineItem{ + CommentTimelineItem: NewCommentTimelineItem(comment), + }, + }, + } + + require.Equal(t, expected, snapshot) +} + +func TestCreateSerialize(t *testing.T) { + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *CreateOperation { + return NewCreateOp(author, unixTime, "title", "message", nil) + }) + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *CreateOperation { + return NewCreateOp(author, unixTime, "title", "message", []repository.Hash{"hash1", "hash2"}) + }) +} diff --git a/entities/bug/op_edit_comment.go b/entities/bug/op_edit_comment.go new file mode 100644 index 00000000..41079f45 --- /dev/null +++ b/entities/bug/op_edit_comment.go @@ -0,0 +1,129 @@ +package bug + +import ( + "fmt" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/timestamp" + + "github.com/MichaelMure/git-bug/util/text" +) + +var _ Operation = &EditCommentOperation{} +var _ dag.OperationWithFiles = &EditCommentOperation{} + +// EditCommentOperation will change a comment in the bug +type EditCommentOperation struct { + dag.OpBase + Target entity.Id `json:"target"` + Message string `json:"message"` + Files []repository.Hash `json:"files"` +} + +func (op *EditCommentOperation) Id() entity.Id { + return dag.IdOperation(op, &op.OpBase) +} + +func (op *EditCommentOperation) Apply(snapshot *Snapshot) { + // Todo: currently any message can be edited, even by a different author + // crypto signature are needed. + + // Recreate the Comment Id to match on + commentId := entity.CombineIds(snapshot.Id(), op.Target) + + var target TimelineItem + for i, item := range snapshot.Timeline { + if item.Id() == commentId { + target = snapshot.Timeline[i] + break + } + } + + if target == nil { + // Target not found, edit is a no-op + return + } + + comment := Comment{ + id: commentId, + Message: op.Message, + Files: op.Files, + UnixTime: timestamp.Timestamp(op.UnixTime), + } + + switch target := target.(type) { + case *CreateTimelineItem: + target.Append(comment) + case *AddCommentTimelineItem: + target.Append(comment) + default: + // somehow, the target matched on something that is not a comment + // we make the op a no-op + return + } + + snapshot.addActor(op.Author()) + + // Updating the corresponding comment + + for i := range snapshot.Comments { + if snapshot.Comments[i].Id() == commentId { + snapshot.Comments[i].Message = op.Message + snapshot.Comments[i].Files = op.Files + break + } + } +} + +func (op *EditCommentOperation) GetFiles() []repository.Hash { + return op.Files +} + +func (op *EditCommentOperation) Validate() error { + if err := op.OpBase.Validate(op, EditCommentOp); err != nil { + return err + } + + if err := op.Target.Validate(); err != nil { + return errors.Wrap(err, "target hash is invalid") + } + + if !text.Safe(op.Message) { + return fmt.Errorf("message is not fully printable") + } + + return nil +} + +func NewEditCommentOp(author identity.Interface, unixTime int64, target entity.Id, message string, files []repository.Hash) *EditCommentOperation { + return &EditCommentOperation{ + OpBase: dag.NewOpBase(EditCommentOp, author, unixTime), + Target: target, + Message: message, + Files: files, + } +} + +// EditComment is a convenience function to apply the operation +func EditComment(b Interface, author identity.Interface, unixTime int64, target entity.Id, message string, files []repository.Hash, metadata map[string]string) (*EditCommentOperation, error) { + op := NewEditCommentOp(author, unixTime, target, message, files) + for key, val := range metadata { + op.SetMetadata(key, val) + } + if err := op.Validate(); err != nil { + return nil, err + } + b.Append(op) + return op, nil +} + +// EditCreateComment is a convenience function to edit the body of a bug (the first comment) +func EditCreateComment(b Interface, author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (*EditCommentOperation, error) { + createOp := b.FirstOp().(*CreateOperation) + return EditComment(b, author, unixTime, createOp.Id(), message, files, metadata) +} diff --git a/entities/bug/op_edit_comment_test.go b/entities/bug/op_edit_comment_test.go new file mode 100644 index 00000000..1b649cd1 --- /dev/null +++ b/entities/bug/op_edit_comment_test.go @@ -0,0 +1,84 @@ +package bug + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" +) + +func TestEdit(t *testing.T) { + snapshot := Snapshot{} + + repo := repository.NewMockRepo() + + rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr") + require.NoError(t, err) + + unix := time.Now().Unix() + + create := NewCreateOp(rene, unix, "title", "create", nil) + create.Apply(&snapshot) + + require.NoError(t, create.Id().Validate()) + + comment1 := NewAddCommentOp(rene, unix, "comment 1", nil) + comment1.Apply(&snapshot) + + require.NoError(t, comment1.Id().Validate()) + + // add another unrelated op in between + setTitle := NewSetTitleOp(rene, unix, "edited title", "title") + setTitle.Apply(&snapshot) + + comment2 := NewAddCommentOp(rene, unix, "comment 2", nil) + comment2.Apply(&snapshot) + + require.NoError(t, comment2.Id().Validate()) + + edit := NewEditCommentOp(rene, unix, create.Id(), "create edited", nil) + edit.Apply(&snapshot) + + require.Len(t, snapshot.Timeline, 4) + require.Len(t, snapshot.Timeline[0].(*CreateTimelineItem).History, 2) + require.Len(t, snapshot.Timeline[1].(*AddCommentTimelineItem).History, 1) + require.Len(t, snapshot.Timeline[3].(*AddCommentTimelineItem).History, 1) + require.Equal(t, snapshot.Comments[0].Message, "create edited") + require.Equal(t, snapshot.Comments[1].Message, "comment 1") + require.Equal(t, snapshot.Comments[2].Message, "comment 2") + + edit2 := NewEditCommentOp(rene, unix, comment1.Id(), "comment 1 edited", nil) + edit2.Apply(&snapshot) + + require.Len(t, snapshot.Timeline, 4) + require.Len(t, snapshot.Timeline[0].(*CreateTimelineItem).History, 2) + require.Len(t, snapshot.Timeline[1].(*AddCommentTimelineItem).History, 2) + require.Len(t, snapshot.Timeline[3].(*AddCommentTimelineItem).History, 1) + require.Equal(t, snapshot.Comments[0].Message, "create edited") + require.Equal(t, snapshot.Comments[1].Message, "comment 1 edited") + require.Equal(t, snapshot.Comments[2].Message, "comment 2") + + edit3 := NewEditCommentOp(rene, unix, comment2.Id(), "comment 2 edited", nil) + edit3.Apply(&snapshot) + + require.Len(t, snapshot.Timeline, 4) + require.Len(t, snapshot.Timeline[0].(*CreateTimelineItem).History, 2) + require.Len(t, snapshot.Timeline[1].(*AddCommentTimelineItem).History, 2) + require.Len(t, snapshot.Timeline[3].(*AddCommentTimelineItem).History, 2) + require.Equal(t, snapshot.Comments[0].Message, "create edited") + require.Equal(t, snapshot.Comments[1].Message, "comment 1 edited") + require.Equal(t, snapshot.Comments[2].Message, "comment 2 edited") +} + +func TestEditCommentSerialize(t *testing.T) { + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *EditCommentOperation { + return NewEditCommentOp(author, unixTime, "target", "message", nil) + }) + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *EditCommentOperation { + return NewEditCommentOp(author, unixTime, "target", "message", []repository.Hash{"hash1", "hash2"}) + }) +} diff --git a/entities/bug/op_label_change.go b/entities/bug/op_label_change.go new file mode 100644 index 00000000..45441f7c --- /dev/null +++ b/entities/bug/op_label_change.go @@ -0,0 +1,292 @@ +package bug + +import ( + "fmt" + "io" + "sort" + "strconv" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +var _ Operation = &LabelChangeOperation{} + +// LabelChangeOperation define a Bug operation to add or remove labels +type LabelChangeOperation struct { + dag.OpBase + Added []Label `json:"added"` + Removed []Label `json:"removed"` +} + +func (op *LabelChangeOperation) Id() entity.Id { + return dag.IdOperation(op, &op.OpBase) +} + +// Apply applies the operation +func (op *LabelChangeOperation) Apply(snapshot *Snapshot) { + snapshot.addActor(op.Author()) + + // Add in the set +AddLoop: + for _, added := range op.Added { + for _, label := range snapshot.Labels { + if label == added { + // Already exist + continue AddLoop + } + } + + snapshot.Labels = append(snapshot.Labels, added) + } + + // Remove in the set + for _, removed := range op.Removed { + for i, label := range snapshot.Labels { + if label == removed { + snapshot.Labels[i] = snapshot.Labels[len(snapshot.Labels)-1] + snapshot.Labels = snapshot.Labels[:len(snapshot.Labels)-1] + } + } + } + + // Sort + sort.Slice(snapshot.Labels, func(i, j int) bool { + return string(snapshot.Labels[i]) < string(snapshot.Labels[j]) + }) + + item := &LabelChangeTimelineItem{ + id: op.Id(), + Author: op.Author(), + UnixTime: timestamp.Timestamp(op.UnixTime), + Added: op.Added, + Removed: op.Removed, + } + + snapshot.Timeline = append(snapshot.Timeline, item) +} + +func (op *LabelChangeOperation) Validate() error { + if err := op.OpBase.Validate(op, LabelChangeOp); err != nil { + return err + } + + for _, l := range op.Added { + if err := l.Validate(); err != nil { + return errors.Wrap(err, "added label") + } + } + + for _, l := range op.Removed { + if err := l.Validate(); err != nil { + return errors.Wrap(err, "removed label") + } + } + + if len(op.Added)+len(op.Removed) <= 0 { + return fmt.Errorf("no label change") + } + + return nil +} + +func NewLabelChangeOperation(author identity.Interface, unixTime int64, added, removed []Label) *LabelChangeOperation { + return &LabelChangeOperation{ + OpBase: dag.NewOpBase(LabelChangeOp, author, unixTime), + Added: added, + Removed: removed, + } +} + +type LabelChangeTimelineItem struct { + id entity.Id + Author identity.Interface + UnixTime timestamp.Timestamp + Added []Label + Removed []Label +} + +func (l LabelChangeTimelineItem) Id() entity.Id { + return l.id +} + +// IsAuthored is a sign post method for gqlgen +func (l LabelChangeTimelineItem) IsAuthored() {} + +// ChangeLabels is a convenience function to change labels on a bug +func ChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string, metadata map[string]string) ([]LabelChangeResult, *LabelChangeOperation, error) { + var added, removed []Label + var results []LabelChangeResult + + snap := b.Compile() + + for _, str := range add { + label := Label(str) + + // check for duplicate + if labelExist(added, label) { + results = append(results, LabelChangeResult{Label: label, Status: LabelChangeDuplicateInOp}) + continue + } + + // check that the label doesn't already exist + if labelExist(snap.Labels, label) { + results = append(results, LabelChangeResult{Label: label, Status: LabelChangeAlreadySet}) + continue + } + + added = append(added, label) + results = append(results, LabelChangeResult{Label: label, Status: LabelChangeAdded}) + } + + for _, str := range remove { + label := Label(str) + + // check for duplicate + if labelExist(removed, label) { + results = append(results, LabelChangeResult{Label: label, Status: LabelChangeDuplicateInOp}) + continue + } + + // check that the label actually exist + if !labelExist(snap.Labels, label) { + results = append(results, LabelChangeResult{Label: label, Status: LabelChangeDoesntExist}) + continue + } + + removed = append(removed, label) + results = append(results, LabelChangeResult{Label: label, Status: LabelChangeRemoved}) + } + + if len(added) == 0 && len(removed) == 0 { + return results, nil, fmt.Errorf("no label added or removed") + } + + op := NewLabelChangeOperation(author, unixTime, added, removed) + for key, val := range metadata { + op.SetMetadata(key, val) + } + if err := op.Validate(); err != nil { + return nil, nil, err + } + + b.Append(op) + + return results, op, nil +} + +// ForceChangeLabels is a convenience function to apply the operation +// The difference with ChangeLabels is that no checks of deduplications are done. You are entirely +// responsible for what you are doing. In the general case, you want to use ChangeLabels instead. +// The intended use of this function is to allow importers to create legal but unexpected label changes, +// like removing a label with no information of when it was added before. +func ForceChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string, metadata map[string]string) (*LabelChangeOperation, error) { + added := make([]Label, len(add)) + for i, str := range add { + added[i] = Label(str) + } + + removed := make([]Label, len(remove)) + for i, str := range remove { + removed[i] = Label(str) + } + + op := NewLabelChangeOperation(author, unixTime, added, removed) + + for key, val := range metadata { + op.SetMetadata(key, val) + } + if err := op.Validate(); err != nil { + return nil, err + } + + b.Append(op) + + return op, nil +} + +func labelExist(labels []Label, label Label) bool { + for _, l := range labels { + if l == label { + return true + } + } + + return false +} + +type LabelChangeStatus int + +const ( + _ LabelChangeStatus = iota + LabelChangeAdded + LabelChangeRemoved + LabelChangeDuplicateInOp + LabelChangeAlreadySet + LabelChangeDoesntExist +) + +func (l LabelChangeStatus) MarshalGQL(w io.Writer) { + switch l { + case LabelChangeAdded: + _, _ = fmt.Fprintf(w, strconv.Quote("ADDED")) + case LabelChangeRemoved: + _, _ = fmt.Fprintf(w, strconv.Quote("REMOVED")) + case LabelChangeDuplicateInOp: + _, _ = fmt.Fprintf(w, strconv.Quote("DUPLICATE_IN_OP")) + case LabelChangeAlreadySet: + _, _ = fmt.Fprintf(w, strconv.Quote("ALREADY_EXIST")) + case LabelChangeDoesntExist: + _, _ = fmt.Fprintf(w, strconv.Quote("DOESNT_EXIST")) + default: + panic("missing case") + } +} + +func (l *LabelChangeStatus) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + switch str { + case "ADDED": + *l = LabelChangeAdded + case "REMOVED": + *l = LabelChangeRemoved + case "DUPLICATE_IN_OP": + *l = LabelChangeDuplicateInOp + case "ALREADY_EXIST": + *l = LabelChangeAlreadySet + case "DOESNT_EXIST": + *l = LabelChangeDoesntExist + default: + return fmt.Errorf("%s is not a valid LabelChangeStatus", str) + } + return nil +} + +type LabelChangeResult struct { + Label Label + Status LabelChangeStatus +} + +func (l LabelChangeResult) String() string { + switch l.Status { + case LabelChangeAdded: + return fmt.Sprintf("label %s added", l.Label) + case LabelChangeRemoved: + return fmt.Sprintf("label %s removed", l.Label) + case LabelChangeDuplicateInOp: + return fmt.Sprintf("label %s is a duplicate", l.Label) + case LabelChangeAlreadySet: + return fmt.Sprintf("label %s was already set", l.Label) + case LabelChangeDoesntExist: + return fmt.Sprintf("label %s doesn't exist on this bug", l.Label) + default: + panic(fmt.Sprintf("unknown label change status %v", l.Status)) + } +} diff --git a/entities/bug/op_label_change_test.go b/entities/bug/op_label_change_test.go new file mode 100644 index 00000000..edbe4714 --- /dev/null +++ b/entities/bug/op_label_change_test.go @@ -0,0 +1,20 @@ +package bug + +import ( + "testing" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity/dag" +) + +func TestLabelChangeSerialize(t *testing.T) { + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *LabelChangeOperation { + return NewLabelChangeOperation(author, unixTime, []Label{"added"}, []Label{"removed"}) + }) + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *LabelChangeOperation { + return NewLabelChangeOperation(author, unixTime, []Label{"added"}, nil) + }) + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *LabelChangeOperation { + return NewLabelChangeOperation(author, unixTime, nil, []Label{"removed"}) + }) +} diff --git a/entities/bug/op_set_metadata.go b/entities/bug/op_set_metadata.go new file mode 100644 index 00000000..b4aab78c --- /dev/null +++ b/entities/bug/op_set_metadata.go @@ -0,0 +1,21 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" +) + +func NewSetMetadataOp(author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *dag.SetMetadataOperation[*Snapshot] { + return dag.NewSetMetadataOp[*Snapshot](SetMetadataOp, author, unixTime, target, newMetadata) +} + +// SetMetadata is a convenience function to add metadata on another operation +func SetMetadata(b Interface, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*Snapshot], error) { + op := NewSetMetadataOp(author, unixTime, target, newMetadata) + if err := op.Validate(); err != nil { + return nil, err + } + b.Append(op) + return op, nil +} diff --git a/entities/bug/op_set_status.go b/entities/bug/op_set_status.go new file mode 100644 index 00000000..5e73d982 --- /dev/null +++ b/entities/bug/op_set_status.go @@ -0,0 +1,95 @@ +package bug + +import ( + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +var _ Operation = &SetStatusOperation{} + +// SetStatusOperation will change the status of a bug +type SetStatusOperation struct { + dag.OpBase + Status Status `json:"status"` +} + +func (op *SetStatusOperation) Id() entity.Id { + return dag.IdOperation(op, &op.OpBase) +} + +func (op *SetStatusOperation) Apply(snapshot *Snapshot) { + snapshot.Status = op.Status + snapshot.addActor(op.Author()) + + item := &SetStatusTimelineItem{ + id: op.Id(), + Author: op.Author(), + UnixTime: timestamp.Timestamp(op.UnixTime), + Status: op.Status, + } + + snapshot.Timeline = append(snapshot.Timeline, item) +} + +func (op *SetStatusOperation) Validate() error { + if err := op.OpBase.Validate(op, SetStatusOp); err != nil { + return err + } + + if err := op.Status.Validate(); err != nil { + return errors.Wrap(err, "status") + } + + return nil +} + +func NewSetStatusOp(author identity.Interface, unixTime int64, status Status) *SetStatusOperation { + return &SetStatusOperation{ + OpBase: dag.NewOpBase(SetStatusOp, author, unixTime), + Status: status, + } +} + +type SetStatusTimelineItem struct { + id entity.Id + Author identity.Interface + UnixTime timestamp.Timestamp + Status Status +} + +func (s SetStatusTimelineItem) Id() entity.Id { + return s.id +} + +// IsAuthored is a sign post method for gqlgen +func (s SetStatusTimelineItem) IsAuthored() {} + +// Open is a convenience function to change a bugs state to Open +func Open(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*SetStatusOperation, error) { + op := NewSetStatusOp(author, unixTime, OpenStatus) + for key, value := range metadata { + op.SetMetadata(key, value) + } + if err := op.Validate(); err != nil { + return nil, err + } + b.Append(op) + return op, nil +} + +// Close is a convenience function to change a bugs state to Close +func Close(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*SetStatusOperation, error) { + op := NewSetStatusOp(author, unixTime, ClosedStatus) + for key, value := range metadata { + op.SetMetadata(key, value) + } + if err := op.Validate(); err != nil { + return nil, err + } + b.Append(op) + return op, nil +} diff --git a/entities/bug/op_set_status_test.go b/entities/bug/op_set_status_test.go new file mode 100644 index 00000000..7ec78704 --- /dev/null +++ b/entities/bug/op_set_status_test.go @@ -0,0 +1,14 @@ +package bug + +import ( + "testing" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity/dag" +) + +func TestSetStatusSerialize(t *testing.T) { + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetStatusOperation { + return NewSetStatusOp(author, unixTime, ClosedStatus) + }) +} diff --git a/entities/bug/op_set_title.go b/entities/bug/op_set_title.go new file mode 100644 index 00000000..75efd08e --- /dev/null +++ b/entities/bug/op_set_title.go @@ -0,0 +1,112 @@ +package bug + +import ( + "fmt" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/util/timestamp" + + "github.com/MichaelMure/git-bug/util/text" +) + +var _ Operation = &SetTitleOperation{} + +// SetTitleOperation will change the title of a bug +type SetTitleOperation struct { + dag.OpBase + Title string `json:"title"` + Was string `json:"was"` +} + +func (op *SetTitleOperation) Id() entity.Id { + return dag.IdOperation(op, &op.OpBase) +} + +func (op *SetTitleOperation) Apply(snapshot *Snapshot) { + snapshot.Title = op.Title + snapshot.addActor(op.Author()) + + item := &SetTitleTimelineItem{ + id: op.Id(), + Author: op.Author(), + UnixTime: timestamp.Timestamp(op.UnixTime), + Title: op.Title, + Was: op.Was, + } + + snapshot.Timeline = append(snapshot.Timeline, item) +} + +func (op *SetTitleOperation) Validate() error { + if err := op.OpBase.Validate(op, SetTitleOp); err != nil { + return err + } + + if text.Empty(op.Title) { + return fmt.Errorf("title is empty") + } + + if !text.SafeOneLine(op.Title) { + return fmt.Errorf("title has unsafe characters") + } + + if !text.SafeOneLine(op.Was) { + return fmt.Errorf("previous title has unsafe characters") + } + + return nil +} + +func NewSetTitleOp(author identity.Interface, unixTime int64, title string, was string) *SetTitleOperation { + return &SetTitleOperation{ + OpBase: dag.NewOpBase(SetTitleOp, author, unixTime), + Title: title, + Was: was, + } +} + +type SetTitleTimelineItem struct { + id entity.Id + Author identity.Interface + UnixTime timestamp.Timestamp + Title string + Was string +} + +func (s SetTitleTimelineItem) Id() entity.Id { + return s.id +} + +// IsAuthored is a sign post method for gqlgen +func (s SetTitleTimelineItem) IsAuthored() {} + +// SetTitle is a convenience function to change a bugs title +func SetTitle(b Interface, author identity.Interface, unixTime int64, title string, metadata map[string]string) (*SetTitleOperation, error) { + var lastTitleOp *SetTitleOperation + for _, op := range b.Operations() { + switch op := op.(type) { + case *SetTitleOperation: + lastTitleOp = op + } + } + + var was string + if lastTitleOp != nil { + was = lastTitleOp.Title + } else { + was = b.FirstOp().(*CreateOperation).Title + } + + op := NewSetTitleOp(author, unixTime, title, was) + for key, value := range metadata { + op.SetMetadata(key, value) + } + if err := op.Validate(); err != nil { + return nil, err + } + + b.Append(op) + return op, nil +} diff --git a/entities/bug/op_set_title_test.go b/entities/bug/op_set_title_test.go new file mode 100644 index 00000000..7960ec4f --- /dev/null +++ b/entities/bug/op_set_title_test.go @@ -0,0 +1,14 @@ +package bug + +import ( + "testing" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity/dag" +) + +func TestSetTitleSerialize(t *testing.T) { + dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetTitleOperation { + return NewSetTitleOp(author, unixTime, "title", "was") + }) +} diff --git a/entities/bug/operation.go b/entities/bug/operation.go new file mode 100644 index 00000000..a02fc780 --- /dev/null +++ b/entities/bug/operation.go @@ -0,0 +1,73 @@ +package bug + +import ( + "encoding/json" + "fmt" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" +) + +const ( + _ dag.OperationType = iota + CreateOp + SetTitleOp + AddCommentOp + SetStatusOp + LabelChangeOp + EditCommentOp + NoOpOp + SetMetadataOp +) + +// Operation define the interface to fulfill for an edit operation of a Bug +type Operation interface { + dag.Operation + + // Apply the operation to a Snapshot to create the final state + Apply(snapshot *Snapshot) +} + +// make sure that package external operations do conform to our interface +var _ Operation = &dag.NoOpOperation[*Snapshot]{} +var _ Operation = &dag.SetMetadataOperation[*Snapshot]{} + +func operationUnmarshaller(raw json.RawMessage, resolvers entity.Resolvers) (dag.Operation, error) { + var t struct { + OperationType dag.OperationType `json:"type"` + } + + if err := json.Unmarshal(raw, &t); err != nil { + return nil, err + } + + var op dag.Operation + + switch t.OperationType { + case AddCommentOp: + op = &AddCommentOperation{} + case CreateOp: + op = &CreateOperation{} + case EditCommentOp: + op = &EditCommentOperation{} + case LabelChangeOp: + op = &LabelChangeOperation{} + case NoOpOp: + op = &dag.NoOpOperation[*Snapshot]{} + case SetMetadataOp: + op = &dag.SetMetadataOperation[*Snapshot]{} + case SetStatusOp: + op = &SetStatusOperation{} + case SetTitleOp: + op = &SetTitleOperation{} + default: + panic(fmt.Sprintf("unknown operation type %v", t.OperationType)) + } + + err := json.Unmarshal(raw, &op) + if err != nil { + return nil, err + } + + return op, nil +} diff --git a/entities/bug/operation_test.go b/entities/bug/operation_test.go new file mode 100644 index 00000000..fe8080c3 --- /dev/null +++ b/entities/bug/operation_test.go @@ -0,0 +1,131 @@ +package bug + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity/dag" + "github.com/MichaelMure/git-bug/repository" +) + +// TODO: move to entity/dag? + +func TestValidate(t *testing.T) { + repo := repository.NewMockRepoClock() + + makeIdentity := func(t *testing.T, name, email string) *identity.Identity { + i, err := identity.NewIdentity(repo, name, email) + require.NoError(t, err) + return i + } + + rene := makeIdentity(t, "René Descartes", "rene@descartes.fr") + + unix := time.Now().Unix() + + good := []Operation{ + NewCreateOp(rene, unix, "title", "message", nil), + NewSetTitleOp(rene, unix, "title2", "title1"), + NewAddCommentOp(rene, unix, "message2", nil), + NewSetStatusOp(rene, unix, ClosedStatus), + NewLabelChangeOperation(rene, unix, []Label{"added"}, []Label{"removed"}), + } + + for _, op := range good { + if err := op.Validate(); err != nil { + t.Fatal(err) + } + } + + bad := []Operation{ + // opbase + NewSetStatusOp(makeIdentity(t, "", "rene@descartes.fr"), unix, ClosedStatus), + NewSetStatusOp(makeIdentity(t, "René Descartes\u001b", "rene@descartes.fr"), unix, ClosedStatus), + NewSetStatusOp(makeIdentity(t, "René Descartes", "rene@descartes.fr\u001b"), unix, ClosedStatus), + NewSetStatusOp(makeIdentity(t, "René \nDescartes", "rene@descartes.fr"), unix, ClosedStatus), + NewSetStatusOp(makeIdentity(t, "René Descartes", "rene@\ndescartes.fr"), unix, ClosedStatus), + &CreateOperation{OpBase: dag.NewOpBase(CreateOp, rene, 0), + Title: "title", + Message: "message", + }, + + NewCreateOp(rene, unix, "multi\nline", "message", nil), + NewCreateOp(rene, unix, "title", "message", []repository.Hash{repository.Hash("invalid")}), + NewCreateOp(rene, unix, "title\u001b", "message", nil), + NewCreateOp(rene, unix, "title", "message\u001b", nil), + NewSetTitleOp(rene, unix, "multi\nline", "title1"), + NewSetTitleOp(rene, unix, "title", "multi\nline"), + NewSetTitleOp(rene, unix, "title\u001b", "title2"), + NewSetTitleOp(rene, unix, "title", "title2\u001b"), + NewAddCommentOp(rene, unix, "message\u001b", nil), + NewAddCommentOp(rene, unix, "message", []repository.Hash{repository.Hash("invalid")}), + NewSetStatusOp(rene, unix, 1000), + NewSetStatusOp(rene, unix, 0), + NewLabelChangeOperation(rene, unix, []Label{}, []Label{}), + NewLabelChangeOperation(rene, unix, []Label{"multi\nline"}, []Label{}), + } + + for i, op := range bad { + if err := op.Validate(); err == nil { + t.Fatal("validation should have failed", i, op) + } + } +} + +func TestMetadata(t *testing.T) { + repo := repository.NewMockRepoClock() + + rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr") + require.NoError(t, err) + + op := NewCreateOp(rene, time.Now().Unix(), "title", "message", nil) + + op.SetMetadata("key", "value") + + val, ok := op.GetMetadata("key") + require.True(t, ok) + require.Equal(t, val, "value") +} + +func TestID(t *testing.T) { + repo := repository.CreateGoGitTestRepo(t, false) + + repos := []repository.ClockedRepo{ + repository.NewMockRepo(), + repo, + } + + for _, repo := range repos { + rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr") + require.NoError(t, err) + err = rene.Commit(repo) + require.NoError(t, err) + + b, op, err := Create(rene, time.Now().Unix(), "title", "message", nil, nil) + require.NoError(t, err) + + id1 := op.Id() + require.NoError(t, id1.Validate()) + + err = b.Commit(repo) + require.NoError(t, err) + + op2 := b.FirstOp() + + id2 := op2.Id() + require.NoError(t, id2.Validate()) + require.Equal(t, id1, id2) + + b2, err := Read(repo, b.Id()) + require.NoError(t, err) + + op3 := b2.FirstOp() + + id3 := op3.Id() + require.NoError(t, id3.Validate()) + require.Equal(t, id1, id3) + } +} diff --git a/entities/bug/resolver.go b/entities/bug/resolver.go new file mode 100644 index 00000000..e7beb0e4 --- /dev/null +++ b/entities/bug/resolver.go @@ -0,0 +1,21 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" +) + +var _ entity.Resolver = &SimpleResolver{} + +// SimpleResolver is a Resolver loading Bugs directly from a Repo +type SimpleResolver struct { + repo repository.ClockedRepo +} + +func NewSimpleResolver(repo repository.ClockedRepo) *SimpleResolver { + return &SimpleResolver{repo: repo} +} + +func (r *SimpleResolver) Resolve(id entity.Id) (entity.Interface, error) { + return Read(r.repo, id) +} diff --git a/entities/bug/snapshot.go b/entities/bug/snapshot.go new file mode 100644 index 00000000..cece09b8 --- /dev/null +++ b/entities/bug/snapshot.go @@ -0,0 +1,144 @@ +package bug + +import ( + "fmt" + "time" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/entity/dag" +) + +var _ dag.Snapshot = &Snapshot{} + +// Snapshot is a compiled form of the Bug data structure used for storage and merge +type Snapshot struct { + id entity.Id + + Status Status + Title string + Comments []Comment + Labels []Label + Author identity.Interface + Actors []identity.Interface + Participants []identity.Interface + CreateTime time.Time + + Timeline []TimelineItem + + Operations []dag.Operation +} + +// Id returns the Bug identifier +func (snap *Snapshot) Id() entity.Id { + if snap.id == "" { + // simply panic as it would be a coding error (no id provided at construction) + panic("no id") + } + return snap.id +} + +func (snap *Snapshot) AllOperations() []dag.Operation { + return snap.Operations +} + +// EditTime returns the last time a bug was modified +func (snap *Snapshot) EditTime() time.Time { + if len(snap.Operations) == 0 { + return time.Unix(0, 0) + } + + return snap.Operations[len(snap.Operations)-1].Time() +} + +// GetCreateMetadata return the creation metadata +func (snap *Snapshot) GetCreateMetadata(key string) (string, bool) { + return snap.Operations[0].GetMetadata(key) +} + +// SearchTimelineItem will search in the timeline for an item matching the given hash +func (snap *Snapshot) SearchTimelineItem(id entity.Id) (TimelineItem, error) { + for i := range snap.Timeline { + if snap.Timeline[i].Id() == id { + return snap.Timeline[i], nil + } + } + + return nil, fmt.Errorf("timeline item not found") +} + +// SearchComment will search for a comment matching the given hash +func (snap *Snapshot) SearchComment(id entity.Id) (*Comment, error) { + for _, c := range snap.Comments { + if c.id == id { + return &c, nil + } + } + + return nil, fmt.Errorf("comment 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) +} + +// HasParticipant return true if the id is a participant +func (snap *Snapshot) HasParticipant(id entity.Id) bool { + for _, p := range snap.Participants { + if p.Id() == id { + return true + } + } + return false +} + +// HasAnyParticipant return true if one of the ids is a participant +func (snap *Snapshot) HasAnyParticipant(ids ...entity.Id) bool { + for _, id := range ids { + if snap.HasParticipant(id) { + return true + } + } + return false +} + +// HasActor return true if the id is a actor +func (snap *Snapshot) HasActor(id entity.Id) bool { + for _, p := range snap.Actors { + if p.Id() == id { + return true + } + } + return false +} + +// HasAnyActor return true if one of the ids is a actor +func (snap *Snapshot) HasAnyActor(ids ...entity.Id) bool { + for _, id := range ids { + if snap.HasActor(id) { + return true + } + } + return false +} + +// IsAuthored is a sign post method for gqlgen +func (snap *Snapshot) IsAuthored() {} diff --git a/entities/bug/sorting.go b/entities/bug/sorting.go new file mode 100644 index 00000000..2e64b92d --- /dev/null +++ b/entities/bug/sorting.go @@ -0,0 +1,57 @@ +package bug + +type BugsByCreationTime []*Bug + +func (b BugsByCreationTime) Len() int { + return len(b) +} + +func (b BugsByCreationTime) Less(i, j int) bool { + if b[i].CreateLamportTime() < b[j].CreateLamportTime() { + return true + } + + if b[i].CreateLamportTime() > b[j].CreateLamportTime() { + return false + } + + // When the logical clocks are identical, that means we had a concurrent + // edition. In this case we rely on the timestamp. While the timestamp might + // be incorrect due to a badly set clock, the drift in sorting is bounded + // by the first sorting using the logical clock. That means that if users + // synchronize their bugs regularly, the timestamp will rarely be used, and + // should still provide a kinda accurate sorting when needed. + return b[i].FirstOp().Time().Before(b[j].FirstOp().Time()) +} + +func (b BugsByCreationTime) Swap(i, j int) { + b[i], b[j] = b[j], b[i] +} + +type BugsByEditTime []*Bug + +func (b BugsByEditTime) Len() int { + return len(b) +} + +func (b BugsByEditTime) Less(i, j int) bool { + if b[i].EditLamportTime() < b[j].EditLamportTime() { + return true + } + + if b[i].EditLamportTime() > b[j].EditLamportTime() { + return false + } + + // When the logical clocks are identical, that means we had a concurrent + // edition. In this case we rely on the timestamp. While the timestamp might + // be incorrect due to a badly set clock, the drift in sorting is bounded + // by the first sorting using the logical clock. That means that if users + // synchronize their bugs regularly, the timestamp will rarely be used, and + // should still provide a kinda accurate sorting when needed. + return b[i].LastOp().Time().Before(b[j].LastOp().Time()) +} + +func (b BugsByEditTime) Swap(i, j int) { + b[i], b[j] = b[j], b[i] +} diff --git a/entities/bug/status.go b/entities/bug/status.go new file mode 100644 index 00000000..b8fba609 --- /dev/null +++ b/entities/bug/status.go @@ -0,0 +1,86 @@ +package bug + +import ( + "fmt" + "io" + "strconv" + "strings" +) + +type Status int + +const ( + _ Status = iota + OpenStatus + ClosedStatus +) + +func (s Status) String() string { + switch s { + case OpenStatus: + return "open" + case ClosedStatus: + return "closed" + default: + return "unknown status" + } +} + +func (s Status) Action() string { + switch s { + case OpenStatus: + return "opened" + case ClosedStatus: + return "closed" + default: + return "unknown status" + } +} + +func StatusFromString(str string) (Status, error) { + cleaned := strings.ToLower(strings.TrimSpace(str)) + + switch cleaned { + case "open": + return OpenStatus, nil + case "closed": + return ClosedStatus, nil + default: + return 0, fmt.Errorf("unknown status") + } +} + +func (s Status) Validate() error { + if s != OpenStatus && s != ClosedStatus { + return fmt.Errorf("invalid") + } + + return nil +} + +func (s Status) MarshalGQL(w io.Writer) { + switch s { + case OpenStatus: + _, _ = fmt.Fprintf(w, strconv.Quote("OPEN")) + case ClosedStatus: + _, _ = fmt.Fprintf(w, strconv.Quote("CLOSED")) + default: + panic("missing case") + } +} + +func (s *Status) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + switch str { + case "OPEN": + *s = OpenStatus + case "CLOSED": + *s = ClosedStatus + default: + return fmt.Errorf("%s is not a valid Status", str) + } + return nil +} diff --git a/entities/bug/timeline.go b/entities/bug/timeline.go new file mode 100644 index 00000000..d7f042db --- /dev/null +++ b/entities/bug/timeline.go @@ -0,0 +1,80 @@ +package bug + +import ( + "strings" + + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +type TimelineItem interface { + // Id return the identifier of the item + Id() entity.Id +} + +// CommentHistoryStep hold one version of a message in the history +type CommentHistoryStep struct { + // The author of the edition, not necessarily the same as the author of the + // original comment + Author identity.Interface + // The new message + Message string + UnixTime timestamp.Timestamp +} + +// CommentTimelineItem is a TimelineItem that holds a Comment and its edition history +type CommentTimelineItem struct { + // id should be the same as in Comment + id entity.Id + Author identity.Interface + Message string + Files []repository.Hash + CreatedAt timestamp.Timestamp + LastEdit timestamp.Timestamp + History []CommentHistoryStep +} + +func NewCommentTimelineItem(comment Comment) CommentTimelineItem { + return CommentTimelineItem{ + id: comment.id, + Author: comment.Author, + Message: comment.Message, + Files: comment.Files, + CreatedAt: comment.UnixTime, + LastEdit: comment.UnixTime, + History: []CommentHistoryStep{ + { + Message: comment.Message, + UnixTime: comment.UnixTime, + }, + }, + } +} + +func (c *CommentTimelineItem) Id() entity.Id { + return c.id +} + +// Append will append a new comment in the history and update the other values +func (c *CommentTimelineItem) Append(comment Comment) { + c.Message = comment.Message + c.Files = comment.Files + c.LastEdit = comment.UnixTime + c.History = append(c.History, CommentHistoryStep{ + Author: comment.Author, + Message: comment.Message, + UnixTime: comment.UnixTime, + }) +} + +// Edited say if the comment was edited +func (c *CommentTimelineItem) Edited() bool { + return len(c.History) > 1 +} + +// MessageIsEmpty return true is the message is empty or only made of spaces +func (c *CommentTimelineItem) MessageIsEmpty() bool { + return len(strings.TrimSpace(c.Message)) == 0 +} diff --git a/entities/bug/with_snapshot.go b/entities/bug/with_snapshot.go new file mode 100644 index 00000000..0474cac7 --- /dev/null +++ b/entities/bug/with_snapshot.go @@ -0,0 +1,53 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/repository" +) + +var _ Interface = &WithSnapshot{} + +// WithSnapshot encapsulate a Bug and maintain the corresponding Snapshot efficiently +type WithSnapshot struct { + *Bug + snap *Snapshot +} + +func (b *WithSnapshot) Compile() *Snapshot { + if b.snap == nil { + snap := b.Bug.Compile() + b.snap = snap + } + return b.snap +} + +// Append intercept Bug.Append() to update the snapshot efficiently +func (b *WithSnapshot) Append(op Operation) { + b.Bug.Append(op) + + if b.snap == nil { + return + } + + op.Apply(b.snap) + b.snap.Operations = append(b.snap.Operations, op) +} + +// Commit intercept Bug.Commit() to update the snapshot efficiently +func (b *WithSnapshot) Commit(repo repository.ClockedRepo) error { + err := b.Bug.Commit(repo) + + if err != nil { + b.snap = nil + return err + } + + // Commit() shouldn't change anything of the bug state apart from the + // initial ID set + + if b.snap == nil { + return nil + } + + b.snap.id = b.Bug.Id() + return nil +} |