aboutsummaryrefslogtreecommitdiffstats
path: root/entities/bug
diff options
context:
space:
mode:
authorMichael Muré <batolettre@gmail.com>2022-08-20 10:14:09 +0200
committerGitHub <noreply@github.com>2022-08-20 10:14:09 +0200
commit58df94d38d754bff4dcca11e2ae4b99326a9a87e (patch)
tree8701efc87732439f993eb4f1d00585fc419b87ab /entities/bug
parent5ca686b59751e3c87740b84108c54fc675a074cf (diff)
parent5511c230b678a181cc596238bf6669428d1b1902 (diff)
downloadgit-bug-58df94d38d754bff4dcca11e2ae4b99326a9a87e.tar.gz
Merge pull request #852 from MichaelMure/move-around
move {bug,identity} to /entities, move input to /commands
Diffstat (limited to 'entities/bug')
-rw-r--r--entities/bug/bug.go179
-rw-r--r--entities/bug/bug_actions.go74
-rw-r--r--entities/bug/comment.go45
-rw-r--r--entities/bug/err.go17
-rw-r--r--entities/bug/interface.go44
-rw-r--r--entities/bug/label.go95
-rw-r--r--entities/bug/label_test.go35
-rw-r--r--entities/bug/op_add_comment.go93
-rw-r--r--entities/bug/op_add_comment_test.go18
-rw-r--r--entities/bug/op_create.go112
-rw-r--r--entities/bug/op_create_test.go67
-rw-r--r--entities/bug/op_edit_comment.go129
-rw-r--r--entities/bug/op_edit_comment_test.go84
-rw-r--r--entities/bug/op_label_change.go292
-rw-r--r--entities/bug/op_label_change_test.go20
-rw-r--r--entities/bug/op_set_metadata.go21
-rw-r--r--entities/bug/op_set_status.go95
-rw-r--r--entities/bug/op_set_status_test.go14
-rw-r--r--entities/bug/op_set_title.go112
-rw-r--r--entities/bug/op_set_title_test.go14
-rw-r--r--entities/bug/operation.go73
-rw-r--r--entities/bug/operation_test.go131
-rw-r--r--entities/bug/resolver.go21
-rw-r--r--entities/bug/snapshot.go144
-rw-r--r--entities/bug/sorting.go57
-rw-r--r--entities/bug/status.go86
-rw-r--r--entities/bug/timeline.go80
-rw-r--r--entities/bug/with_snapshot.go53
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
+}