diff options
Diffstat (limited to 'bug')
-rw-r--r-- | bug/bug.go | 6 | ||||
-rw-r--r-- | bug/bug_actions_test.go | 388 | ||||
-rw-r--r-- | bug/bug_test.go | 94 | ||||
-rw-r--r-- | bug/comment.go | 9 | ||||
-rw-r--r-- | bug/op_add_comment.go | 88 | ||||
-rw-r--r-- | bug/op_create.go | 107 | ||||
-rw-r--r-- | bug/op_create_test.go | 47 | ||||
-rw-r--r-- | bug/op_edit_comment.go | 125 | ||||
-rw-r--r-- | bug/op_edit_comment_test.go | 53 | ||||
-rw-r--r-- | bug/op_label_change.go | 194 | ||||
-rw-r--r-- | bug/op_set_status.go | 66 | ||||
-rw-r--r-- | bug/op_set_title.go | 96 | ||||
-rw-r--r-- | bug/operation.go | 77 | ||||
-rw-r--r-- | bug/operation_iterator_test.go | 59 | ||||
-rw-r--r-- | bug/operation_pack.go | 58 | ||||
-rw-r--r-- | bug/operation_pack_test.go | 53 | ||||
-rw-r--r-- | bug/operation_test.go | 70 | ||||
-rw-r--r-- | bug/snapshot.go | 30 | ||||
-rw-r--r-- | bug/time.go | 9 | ||||
-rw-r--r-- | bug/timeline.go | 87 | ||||
-rw-r--r-- | bug/with_snapshot.go | 6 |
21 files changed, 1655 insertions, 67 deletions
@@ -300,7 +300,7 @@ func (bug *Bug) Validate() error { // The very first Op should be a CreateOp firstOp := bug.FirstOp() - if firstOp == nil || firstOp.OpType() != CreateOp { + if firstOp == nil || firstOp.base().OperationType != CreateOp { return fmt.Errorf("first operation should be a Create op") } @@ -308,7 +308,7 @@ func (bug *Bug) Validate() error { it := NewOperationIterator(bug) createCount := 0 for it.Next() { - if it.Value().OpType() == CreateOp { + if it.Value().base().OperationType == CreateOp { createCount++ } } @@ -641,7 +641,7 @@ func (bug *Bug) Compile() Snapshot { for it.Next() { op := it.Value() - snap = op.Apply(snap) + op.Apply(&snap) snap.Operations = append(snap.Operations, op) } diff --git a/bug/bug_actions_test.go b/bug/bug_actions_test.go new file mode 100644 index 00000000..ee9fbd72 --- /dev/null +++ b/bug/bug_actions_test.go @@ -0,0 +1,388 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/repository" + "github.com/stretchr/testify/assert" + + "io/ioutil" + "log" + "os" + "testing" +) + +func createRepo(bare bool) *repository.GitRepo { + dir, err := ioutil.TempDir("", "") + if err != nil { + log.Fatal(err) + } + + // fmt.Println("Creating repo:", dir) + + var creator func(string) (*repository.GitRepo, error) + + if bare { + creator = repository.InitBareGitRepo + } else { + creator = repository.InitGitRepo + } + + repo, err := creator(dir) + if err != nil { + log.Fatal(err) + } + + return repo +} + +func cleanupRepo(repo repository.Repo) error { + path := repo.GetPath() + // fmt.Println("Cleaning repo:", path) + return os.RemoveAll(path) +} + +func setupRepos(t testing.TB) (repoA, repoB, remote *repository.GitRepo) { + repoA = createRepo(false) + repoB = createRepo(false) + remote = createRepo(true) + + remoteAddr := "file://" + remote.GetPath() + + err := repoA.AddRemote("origin", remoteAddr) + if err != nil { + t.Fatal(err) + } + + err = repoB.AddRemote("origin", remoteAddr) + if err != nil { + t.Fatal(err) + } + + return repoA, repoB, remote +} + +func cleanupRepos(repoA, repoB, remote *repository.GitRepo) { + cleanupRepo(repoA) + cleanupRepo(repoB) + cleanupRepo(remote) +} + +func TestPushPull(t *testing.T) { + repoA, repoB, remote := setupRepos(t) + defer cleanupRepos(repoA, repoB, remote) + + bug1, err := Create(rene, unix, "bug1", "message") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + // A --> remote --> B + _, err = Push(repoA, "origin") + assert.Nil(t, err) + + err = Pull(repoB, "origin") + assert.Nil(t, err) + + bugs := allBugs(t, ReadAllLocalBugs(repoB)) + + if len(bugs) != 1 { + t.Fatal("Unexpected number of bugs") + } + + // B --> remote --> A + bug2, err := Create(rene, unix, "bug2", "message") + assert.Nil(t, err) + err = bug2.Commit(repoB) + assert.Nil(t, err) + + _, err = Push(repoB, "origin") + assert.Nil(t, err) + + err = Pull(repoA, "origin") + assert.Nil(t, err) + + bugs = allBugs(t, ReadAllLocalBugs(repoA)) + + if len(bugs) != 2 { + t.Fatal("Unexpected number of bugs") + } +} + +func allBugs(t testing.TB, bugs <-chan StreamedBug) []*Bug { + var result []*Bug + for streamed := range bugs { + if streamed.Err != nil { + t.Fatal(streamed.Err) + } + result = append(result, streamed.Bug) + } + return result +} + +func TestRebaseTheirs(t *testing.T) { + _RebaseTheirs(t) +} + +func BenchmarkRebaseTheirs(b *testing.B) { + for n := 0; n < b.N; n++ { + _RebaseTheirs(b) + } +} + +func _RebaseTheirs(t testing.TB) { + repoA, repoB, remote := setupRepos(t) + defer cleanupRepos(repoA, repoB, remote) + + bug1, err := Create(rene, unix, "bug1", "message") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + // A --> remote + _, err = Push(repoA, "origin") + assert.Nil(t, err) + + // remote --> B + err = Pull(repoB, "origin") + assert.Nil(t, err) + + bug2, err := ReadLocalBug(repoB, bug1.Id()) + assert.Nil(t, err) + + err = AddComment(bug2, rene, unix, "message2") + assert.Nil(t, err) + err = AddComment(bug2, rene, unix, "message3") + assert.Nil(t, err) + err = AddComment(bug2, rene, unix, "message4") + assert.Nil(t, err) + err = bug2.Commit(repoB) + assert.Nil(t, err) + + // B --> remote + _, err = Push(repoB, "origin") + assert.Nil(t, err) + + // remote --> A + err = Pull(repoA, "origin") + assert.Nil(t, err) + + bugs := allBugs(t, ReadAllLocalBugs(repoB)) + + if len(bugs) != 1 { + t.Fatal("Unexpected number of bugs") + } + + bug3, err := ReadLocalBug(repoA, bug1.Id()) + assert.Nil(t, err) + + if nbOps(bug3) != 4 { + t.Fatal("Unexpected number of operations") + } +} + +func TestRebaseOurs(t *testing.T) { + _RebaseOurs(t) +} + +func BenchmarkRebaseOurs(b *testing.B) { + for n := 0; n < b.N; n++ { + _RebaseOurs(b) + } +} + +func _RebaseOurs(t testing.TB) { + repoA, repoB, remote := setupRepos(t) + defer cleanupRepos(repoA, repoB, remote) + + bug1, err := Create(rene, unix, "bug1", "message") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + // A --> remote + _, err = Push(repoA, "origin") + assert.Nil(t, err) + + // remote --> B + err = Pull(repoB, "origin") + assert.Nil(t, err) + + err = AddComment(bug1, rene, unix, "message2") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message3") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message4") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + err = AddComment(bug1, rene, unix, "message5") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message6") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message7") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + err = AddComment(bug1, rene, unix, "message8") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message9") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message10") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + // remote --> A + err = Pull(repoA, "origin") + assert.Nil(t, err) + + bugs := allBugs(t, ReadAllLocalBugs(repoA)) + + if len(bugs) != 1 { + t.Fatal("Unexpected number of bugs") + } + + bug2, err := ReadLocalBug(repoA, bug1.Id()) + assert.Nil(t, err) + + if nbOps(bug2) != 10 { + t.Fatal("Unexpected number of operations") + } +} + +func nbOps(b *Bug) int { + it := NewOperationIterator(b) + counter := 0 + for it.Next() { + counter++ + } + return counter +} + +func TestRebaseConflict(t *testing.T) { + _RebaseConflict(t) +} + +func BenchmarkRebaseConflict(b *testing.B) { + for n := 0; n < b.N; n++ { + _RebaseConflict(b) + } +} + +func _RebaseConflict(t testing.TB) { + repoA, repoB, remote := setupRepos(t) + defer cleanupRepos(repoA, repoB, remote) + + bug1, err := Create(rene, unix, "bug1", "message") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + // A --> remote + _, err = Push(repoA, "origin") + assert.Nil(t, err) + + // remote --> B + err = Pull(repoB, "origin") + assert.Nil(t, err) + + err = AddComment(bug1, rene, unix, "message2") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message3") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message4") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + err = AddComment(bug1, rene, unix, "message5") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message6") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message7") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + err = AddComment(bug1, rene, unix, "message8") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message9") + assert.Nil(t, err) + err = AddComment(bug1, rene, unix, "message10") + assert.Nil(t, err) + err = bug1.Commit(repoA) + assert.Nil(t, err) + + bug2, err := ReadLocalBug(repoB, bug1.Id()) + assert.Nil(t, err) + + err = AddComment(bug2, rene, unix, "message11") + assert.Nil(t, err) + err = AddComment(bug2, rene, unix, "message12") + assert.Nil(t, err) + err = AddComment(bug2, rene, unix, "message13") + assert.Nil(t, err) + err = bug2.Commit(repoB) + assert.Nil(t, err) + + err = AddComment(bug2, rene, unix, "message14") + assert.Nil(t, err) + err = AddComment(bug2, rene, unix, "message15") + assert.Nil(t, err) + err = AddComment(bug2, rene, unix, "message16") + assert.Nil(t, err) + err = bug2.Commit(repoB) + assert.Nil(t, err) + + err = AddComment(bug2, rene, unix, "message17") + assert.Nil(t, err) + err = AddComment(bug2, rene, unix, "message18") + assert.Nil(t, err) + err = AddComment(bug2, rene, unix, "message19") + assert.Nil(t, err) + err = bug2.Commit(repoB) + assert.Nil(t, err) + + // A --> remote + _, err = Push(repoA, "origin") + assert.Nil(t, err) + + // remote --> B + err = Pull(repoB, "origin") + assert.Nil(t, err) + + bugs := allBugs(t, ReadAllLocalBugs(repoB)) + + if len(bugs) != 1 { + t.Fatal("Unexpected number of bugs") + } + + bug3, err := ReadLocalBug(repoB, bug1.Id()) + assert.Nil(t, err) + + if nbOps(bug3) != 19 { + t.Fatal("Unexpected number of operations") + } + + // B --> remote + _, err = Push(repoB, "origin") + assert.Nil(t, err) + + // remote --> A + err = Pull(repoA, "origin") + assert.Nil(t, err) + + bugs = allBugs(t, ReadAllLocalBugs(repoA)) + + if len(bugs) != 1 { + t.Fatal("Unexpected number of bugs") + } + + bug4, err := ReadLocalBug(repoA, bug1.Id()) + assert.Nil(t, err) + + if nbOps(bug4) != 19 { + t.Fatal("Unexpected number of operations") + } +} diff --git a/bug/bug_test.go b/bug/bug_test.go new file mode 100644 index 00000000..0fd373d5 --- /dev/null +++ b/bug/bug_test.go @@ -0,0 +1,94 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/repository" + "github.com/go-test/deep" + "github.com/stretchr/testify/assert" + + "testing" +) + +func TestBugId(t *testing.T) { + mockRepo := repository.NewMockRepoForTest() + + bug1 := NewBug() + + bug1.Append(createOp) + + err := bug1.Commit(mockRepo) + + if err != nil { + t.Fatal(err) + } + + bug1.Id() +} + +func TestBugValidity(t *testing.T) { + mockRepo := repository.NewMockRepoForTest() + + bug1 := NewBug() + + if bug1.Validate() == nil { + t.Fatal("Empty bug should be invalid") + } + + bug1.Append(createOp) + + if bug1.Validate() != nil { + t.Fatal("Bug with just a CreateOp should be valid") + } + + err := bug1.Commit(mockRepo) + if err != nil { + t.Fatal(err) + } + + bug1.Append(createOp) + + if bug1.Validate() == nil { + t.Fatal("Bug with multiple CreateOp should be invalid") + } + + err = bug1.Commit(mockRepo) + if err == nil { + t.Fatal("Invalid bug should not commit") + } +} + +func TestBugSerialisation(t *testing.T) { + bug1 := NewBug() + + bug1.Append(createOp) + bug1.Append(setTitleOp) + bug1.Append(setTitleOp) + bug1.Append(addCommentOp) + + repo := repository.NewMockRepoForTest() + + err := bug1.Commit(repo) + assert.Nil(t, err) + + bug2, err := ReadLocalBug(repo, bug1.Id()) + if err != nil { + t.Error(err) + } + + // ignore some fields + bug2.packs[0].commitHash = bug1.packs[0].commitHash + for i := range bug1.packs[0].Operations { + bug2.packs[0].Operations[i].base().hash = bug1.packs[0].Operations[i].base().hash + } + + // check hashes + for i := range bug1.packs[0].Operations { + if !bug2.packs[0].Operations[i].base().hash.IsValid() { + t.Fatal("invalid hash") + } + } + + deep.CompareUnexportedFields = true + if diff := deep.Equal(bug1, bug2); diff != nil { + t.Fatal(diff) + } +} diff --git a/bug/comment.go b/bug/comment.go index c7168275..db5cc45e 100644 --- a/bug/comment.go +++ b/bug/comment.go @@ -3,7 +3,6 @@ package bug import ( "github.com/MichaelMure/git-bug/util/git" "github.com/dustin/go-humanize" - "time" ) // Comment represent a comment in a Bug @@ -14,16 +13,14 @@ type Comment struct { // 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 int64 + UnixTime Timestamp } // FormatTimeRel format the UnixTime of the comment for human consumption func (c Comment) FormatTimeRel() string { - t := time.Unix(c.UnixTime, 0) - return humanize.Time(t) + return humanize.Time(c.UnixTime.Time()) } func (c Comment) FormatTime() string { - t := time.Unix(c.UnixTime, 0) - return t.Format("Mon Jan 2 15:04:05 2006 +0200") + return c.UnixTime.Time().Format("Mon Jan 2 15:04:05 2006 +0200") } diff --git a/bug/op_add_comment.go b/bug/op_add_comment.go new file mode 100644 index 00000000..7f8b8b5b --- /dev/null +++ b/bug/op_add_comment.go @@ -0,0 +1,88 @@ +package bug + +import ( + "fmt" + + "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/text" +) + +var _ Operation = &AddCommentOperation{} + +// AddCommentOperation will add a new comment in the bug +type AddCommentOperation struct { + *OpBase + Message string `json:"message"` + // TODO: change for a map[string]util.hash to store the filename ? + Files []git.Hash `json:"files"` +} + +func (op *AddCommentOperation) base() *OpBase { + return op.OpBase +} + +func (op *AddCommentOperation) Hash() (git.Hash, error) { + return hashOperation(op) +} + +func (op *AddCommentOperation) Apply(snapshot *Snapshot) { + comment := Comment{ + Message: op.Message, + Author: op.Author, + Files: op.Files, + UnixTime: Timestamp(op.UnixTime), + } + + snapshot.Comments = append(snapshot.Comments, comment) + + hash, err := op.Hash() + if err != nil { + // Should never error unless a programming error happened + // (covered in OpBase.Validate()) + panic(err) + } + + snapshot.Timeline = append(snapshot.Timeline, NewCommentTimelineItem(hash, comment)) +} + +func (op *AddCommentOperation) GetFiles() []git.Hash { + return op.Files +} + +func (op *AddCommentOperation) Validate() error { + if err := opBaseValidate(op, AddCommentOp); err != nil { + return err + } + + if text.Empty(op.Message) { + return fmt.Errorf("message is empty") + } + + if !text.Safe(op.Message) { + return fmt.Errorf("message is not fully printable") + } + + return nil +} + +func NewAddCommentOp(author Person, unixTime int64, message string, files []git.Hash) *AddCommentOperation { + return &AddCommentOperation{ + OpBase: newOpBase(AddCommentOp, author, unixTime), + Message: message, + Files: files, + } +} + +// Convenience function to apply the operation +func AddComment(b Interface, author Person, unixTime int64, message string) error { + return AddCommentWithFiles(b, author, unixTime, message, nil) +} + +func AddCommentWithFiles(b Interface, author Person, unixTime int64, message string, files []git.Hash) error { + addCommentOp := NewAddCommentOp(author, unixTime, message, files) + if err := addCommentOp.Validate(); err != nil { + return err + } + b.Append(addCommentOp) + return nil +} diff --git a/bug/op_create.go b/bug/op_create.go new file mode 100644 index 00000000..0553137f --- /dev/null +++ b/bug/op_create.go @@ -0,0 +1,107 @@ +package bug + +import ( + "fmt" + "strings" + + "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/text" +) + +var _ Operation = &CreateOperation{} + +// CreateOperation define the initial creation of a bug +type CreateOperation struct { + *OpBase + Title string `json:"title"` + Message string `json:"message"` + Files []git.Hash `json:"files"` +} + +func (op *CreateOperation) base() *OpBase { + return op.OpBase +} + +func (op *CreateOperation) Hash() (git.Hash, error) { + return hashOperation(op) +} + +func (op *CreateOperation) Apply(snapshot *Snapshot) { + snapshot.Title = op.Title + + comment := Comment{ + Message: op.Message, + Author: op.Author, + UnixTime: Timestamp(op.UnixTime), + } + + snapshot.Comments = []Comment{comment} + snapshot.Author = op.Author + snapshot.CreatedAt = op.Time() + + hash, err := op.Hash() + if err != nil { + // Should never error unless a programming error happened + // (covered in OpBase.Validate()) + panic(err) + } + + snapshot.Timeline = []TimelineItem{ + NewCreateTimelineItem(hash, comment), + } +} + +func (op *CreateOperation) GetFiles() []git.Hash { + return op.Files +} + +func (op *CreateOperation) Validate() error { + if err := opBaseValidate(op, CreateOp); err != nil { + return err + } + + if text.Empty(op.Title) { + return fmt.Errorf("title is empty") + } + + if strings.Contains(op.Title, "\n") { + return fmt.Errorf("title should be a single line") + } + + if !text.Safe(op.Title) { + return fmt.Errorf("title is not fully printable") + } + + if !text.Safe(op.Message) { + return fmt.Errorf("message is not fully printable") + } + + return nil +} + +func NewCreateOp(author Person, unixTime int64, title, message string, files []git.Hash) *CreateOperation { + return &CreateOperation{ + OpBase: newOpBase(CreateOp, author, unixTime), + Title: title, + Message: message, + Files: files, + } +} + +// Convenience function to apply the operation +func Create(author Person, unixTime int64, title, message string) (*Bug, error) { + return CreateWithFiles(author, unixTime, title, message, nil) +} + +func CreateWithFiles(author Person, unixTime int64, title, message string, files []git.Hash) (*Bug, error) { + newBug := NewBug() + createOp := NewCreateOp(author, unixTime, title, message, files) + + if err := createOp.Validate(); err != nil { + return nil, err + } + + newBug.Append(createOp) + + return newBug, nil +} diff --git a/bug/op_create_test.go b/bug/op_create_test.go new file mode 100644 index 00000000..f27f6ee0 --- /dev/null +++ b/bug/op_create_test.go @@ -0,0 +1,47 @@ +package bug + +import ( + "testing" + "time" + + "github.com/go-test/deep" +) + +func TestCreate(t *testing.T) { + snapshot := Snapshot{} + + var rene = Person{ + Name: "René Descartes", + Email: "rene@descartes.fr", + } + + unix := time.Now().Unix() + + create := NewCreateOp(rene, unix, "title", "message", nil) + + create.Apply(&snapshot) + + hash, err := create.Hash() + if err != nil { + t.Fatal(err) + } + + comment := Comment{Author: rene, Message: "message", UnixTime: Timestamp(create.UnixTime)} + + expected := Snapshot{ + Title: "title", + Comments: []Comment{ + comment, + }, + Author: rene, + CreatedAt: create.Time(), + Timeline: []TimelineItem{ + NewCreateTimelineItem(hash, comment), + }, + } + + deep.CompareUnexportedFields = true + if diff := deep.Equal(snapshot, expected); diff != nil { + t.Fatal(diff) + } +} diff --git a/bug/op_edit_comment.go b/bug/op_edit_comment.go new file mode 100644 index 00000000..cb4a2216 --- /dev/null +++ b/bug/op_edit_comment.go @@ -0,0 +1,125 @@ +package bug + +import ( + "fmt" + + "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/text" +) + +var _ Operation = &EditCommentOperation{} + +// EditCommentOperation will change a comment in the bug +type EditCommentOperation struct { + *OpBase + Target git.Hash `json:"target"` + Message string `json:"message"` + Files []git.Hash `json:"files"` +} + +func (op *EditCommentOperation) base() *OpBase { + return op.OpBase +} + +func (op *EditCommentOperation) Hash() (git.Hash, error) { + return hashOperation(op) +} + +func (op *EditCommentOperation) Apply(snapshot *Snapshot) { + // Todo: currently any message can be edited, even by a different author + // crypto signature are needed. + + var target TimelineItem + var commentIndex int + + for i, item := range snapshot.Timeline { + h, err := item.Hash() + + if err != nil { + // Should never happen, we control what goes into the timeline + panic(err) + } + + if h == op.Target { + target = snapshot.Timeline[i] + break + } + + // Track the index in the []Comment + switch item.(type) { + case *CreateTimelineItem, *CommentTimelineItem: + commentIndex++ + } + } + + if target == nil { + // Target not found, edit is a no-op + return + } + + comment := Comment{ + Message: op.Message, + Files: op.Files, + UnixTime: Timestamp(op.UnixTime), + } + + switch target.(type) { + case *CreateTimelineItem: + item := target.(*CreateTimelineItem) + item.Append(comment) + + case *CommentTimelineItem: + item := target.(*CommentTimelineItem) + item.Append(comment) + } + + snapshot.Comments[commentIndex].Message = op.Message + snapshot.Comments[commentIndex].Files = op.Files +} + +func (op *EditCommentOperation) GetFiles() []git.Hash { + return op.Files +} + +func (op *EditCommentOperation) Validate() error { + if err := opBaseValidate(op, EditCommentOp); err != nil { + return err + } + + if !op.Target.IsValid() { + return fmt.Errorf("target hash is invalid") + } + + if text.Empty(op.Message) { + return fmt.Errorf("message is empty") + } + + if !text.Safe(op.Message) { + return fmt.Errorf("message is not fully printable") + } + + return nil +} + +func NewEditCommentOp(author Person, unixTime int64, target git.Hash, message string, files []git.Hash) *EditCommentOperation { + return &EditCommentOperation{ + OpBase: newOpBase(EditCommentOp, author, unixTime), + Target: target, + Message: message, + Files: files, + } +} + +// Convenience function to apply the operation +func EditComment(b Interface, author Person, unixTime int64, target git.Hash, message string) error { + return EditCommentWithFiles(b, author, unixTime, target, message, nil) +} + +func EditCommentWithFiles(b Interface, author Person, unixTime int64, target git.Hash, message string, files []git.Hash) error { + editCommentOp := NewEditCommentOp(author, unixTime, target, message, files) + if err := editCommentOp.Validate(); err != nil { + return err + } + b.Append(editCommentOp) + return nil +} diff --git a/bug/op_edit_comment_test.go b/bug/op_edit_comment_test.go new file mode 100644 index 00000000..9c32051d --- /dev/null +++ b/bug/op_edit_comment_test.go @@ -0,0 +1,53 @@ +package bug + +import ( + "testing" + "time" + + "gotest.tools/assert" +) + +func TestEdit(t *testing.T) { + snapshot := Snapshot{} + + var rene = Person{ + Name: "René Descartes", + Email: "rene@descartes.fr", + } + + unix := time.Now().Unix() + + create := NewCreateOp(rene, unix, "title", "create", nil) + create.Apply(&snapshot) + + hash1, err := create.Hash() + if err != nil { + t.Fatal(err) + } + + comment := NewAddCommentOp(rene, unix, "comment", nil) + comment.Apply(&snapshot) + + hash2, err := comment.Hash() + if err != nil { + t.Fatal(err) + } + + edit := NewEditCommentOp(rene, unix, hash1, "create edited", nil) + edit.Apply(&snapshot) + + assert.Equal(t, len(snapshot.Timeline), 2) + assert.Equal(t, len(snapshot.Timeline[0].(*CreateTimelineItem).History), 2) + assert.Equal(t, len(snapshot.Timeline[1].(*CommentTimelineItem).History), 1) + assert.Equal(t, snapshot.Comments[0].Message, "create edited") + assert.Equal(t, snapshot.Comments[1].Message, "comment") + + edit2 := NewEditCommentOp(rene, unix, hash2, "comment edited", nil) + edit2.Apply(&snapshot) + + assert.Equal(t, len(snapshot.Timeline), 2) + assert.Equal(t, len(snapshot.Timeline[0].(*CreateTimelineItem).History), 2) + assert.Equal(t, len(snapshot.Timeline[1].(*CommentTimelineItem).History), 2) + assert.Equal(t, snapshot.Comments[0].Message, "create edited") + assert.Equal(t, snapshot.Comments[1].Message, "comment edited") +} diff --git a/bug/op_label_change.go b/bug/op_label_change.go new file mode 100644 index 00000000..5f2dbd6f --- /dev/null +++ b/bug/op_label_change.go @@ -0,0 +1,194 @@ +package bug + +import ( + "fmt" + "sort" + + "github.com/MichaelMure/git-bug/util/git" + "github.com/pkg/errors" +) + +var _ Operation = &LabelChangeOperation{} + +// LabelChangeOperation define a Bug operation to add or remove labels +type LabelChangeOperation struct { + *OpBase + Added []Label `json:"added"` + Removed []Label `json:"removed"` +} + +func (op *LabelChangeOperation) base() *OpBase { + return op.OpBase +} + +func (op *LabelChangeOperation) Hash() (git.Hash, error) { + return hashOperation(op) +} + +// Apply apply the operation +func (op *LabelChangeOperation) Apply(snapshot *Snapshot) { + // 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]) + }) + + snapshot.Timeline = append(snapshot.Timeline, op) +} + +func (op *LabelChangeOperation) Validate() error { + if err := opBaseValidate(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 Person, unixTime int64, added, removed []Label) *LabelChangeOperation { + return &LabelChangeOperation{ + OpBase: newOpBase(LabelChangeOp, author, unixTime), + Added: added, + Removed: removed, + } +} + +// ChangeLabels is a convenience function to apply the operation +func ChangeLabels(b Interface, author Person, unixTime int64, add, remove []string) ([]LabelChangeResult, 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, fmt.Errorf("no label added or removed") + } + + labelOp := NewLabelChangeOperation(author, unixTime, added, removed) + + if err := labelOp.Validate(); err != nil { + return nil, err + } + + b.Append(labelOp) + + return results, 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 +) + +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/bug/op_set_status.go b/bug/op_set_status.go new file mode 100644 index 00000000..cdfa25e7 --- /dev/null +++ b/bug/op_set_status.go @@ -0,0 +1,66 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/util/git" + "github.com/pkg/errors" +) + +var _ Operation = &SetStatusOperation{} + +// SetStatusOperation will change the status of a bug +type SetStatusOperation struct { + *OpBase + Status Status `json:"status"` +} + +func (op *SetStatusOperation) base() *OpBase { + return op.OpBase +} + +func (op *SetStatusOperation) Hash() (git.Hash, error) { + return hashOperation(op) +} + +func (op *SetStatusOperation) Apply(snapshot *Snapshot) { + snapshot.Status = op.Status + snapshot.Timeline = append(snapshot.Timeline, op) +} + +func (op *SetStatusOperation) Validate() error { + if err := opBaseValidate(op, SetStatusOp); err != nil { + return err + } + + if err := op.Status.Validate(); err != nil { + return errors.Wrap(err, "status") + } + + return nil +} + +func NewSetStatusOp(author Person, unixTime int64, status Status) *SetStatusOperation { + return &SetStatusOperation{ + OpBase: newOpBase(SetStatusOp, author, unixTime), + Status: status, + } +} + +// Convenience function to apply the operation +func Open(b Interface, author Person, unixTime int64) error { + op := NewSetStatusOp(author, unixTime, OpenStatus) + if err := op.Validate(); err != nil { + return err + } + b.Append(op) + return nil +} + +// Convenience function to apply the operation +func Close(b Interface, author Person, unixTime int64) error { + op := NewSetStatusOp(author, unixTime, ClosedStatus) + if err := op.Validate(); err != nil { + return err + } + b.Append(op) + return nil +} diff --git a/bug/op_set_title.go b/bug/op_set_title.go new file mode 100644 index 00000000..74467ec2 --- /dev/null +++ b/bug/op_set_title.go @@ -0,0 +1,96 @@ +package bug + +import ( + "fmt" + "strings" + + "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/text" +) + +var _ Operation = &SetTitleOperation{} + +// SetTitleOperation will change the title of a bug +type SetTitleOperation struct { + *OpBase + Title string `json:"title"` + Was string `json:"was"` +} + +func (op *SetTitleOperation) base() *OpBase { + return op.OpBase +} + +func (op *SetTitleOperation) Hash() (git.Hash, error) { + return hashOperation(op) +} + +func (op *SetTitleOperation) Apply(snapshot *Snapshot) { + snapshot.Title = op.Title + snapshot.Timeline = append(snapshot.Timeline, op) +} + +func (op *SetTitleOperation) Validate() error { + if err := opBaseValidate(op, SetTitleOp); err != nil { + return err + } + + if text.Empty(op.Title) { + return fmt.Errorf("title is empty") + } + + if strings.Contains(op.Title, "\n") { + return fmt.Errorf("title should be a single line") + } + + if !text.Safe(op.Title) { + return fmt.Errorf("title should be fully printable") + } + + if strings.Contains(op.Was, "\n") { + return fmt.Errorf("previous title should be a single line") + } + + if !text.Safe(op.Was) { + return fmt.Errorf("previous title should be fully printable") + } + + return nil +} + +func NewSetTitleOp(author Person, unixTime int64, title string, was string) *SetTitleOperation { + return &SetTitleOperation{ + OpBase: newOpBase(SetTitleOp, author, unixTime), + Title: title, + Was: was, + } +} + +// Convenience function to apply the operation +func SetTitle(b Interface, author Person, unixTime int64, title string) error { + it := NewOperationIterator(b) + + var lastTitleOp Operation + for it.Next() { + op := it.Value() + if op.base().OperationType == SetTitleOp { + lastTitleOp = op + } + } + + var was string + if lastTitleOp != nil { + was = lastTitleOp.(*SetTitleOperation).Title + } else { + was = b.FirstOp().(*CreateOperation).Title + } + + setTitleOp := NewSetTitleOp(author, unixTime, title, was) + + if err := setTitleOp.Validate(); err != nil { + return err + } + + b.Append(setTitleOp) + return nil +} diff --git a/bug/operation.go b/bug/operation.go index a408e167..91b77e56 100644 --- a/bug/operation.go +++ b/bug/operation.go @@ -1,11 +1,13 @@ package bug import ( - "github.com/MichaelMure/git-bug/util/git" - "github.com/pkg/errors" - + "crypto/sha256" + "encoding/json" "fmt" "time" + + "github.com/MichaelMure/git-bug/util/git" + "github.com/pkg/errors" ) // OperationType is an operation type identifier @@ -18,22 +20,23 @@ const ( AddCommentOp SetStatusOp LabelChangeOp + EditCommentOp ) // Operation define the interface to fulfill for an edit operation of a Bug type Operation interface { - // OpType return the type of operation - OpType() OperationType + // base return the OpBase of the Operation, for package internal use + base() *OpBase + // Hash return the hash of the operation, to be used for back references + Hash() (git.Hash, error) // Time return the time when the operation was added Time() time.Time // GetUnixTime return the unix timestamp when the operation was added GetUnixTime() int64 - // GetAuthor return the author of the operation - GetAuthor() Person // GetFiles return the files needed by this operation GetFiles() []git.Hash // Apply the operation to a Snapshot to create the final state - Apply(snapshot Snapshot) Snapshot + Apply(snapshot *Snapshot) // Validate check if the operation is valid (ex: a title is a single line) Validate() error // SetMetadata store arbitrary metadata about the operation @@ -42,16 +45,42 @@ type Operation interface { GetMetadata(key string) (string, bool) } +func hashRaw(data []byte) git.Hash { + hasher := sha256.New() + // Write can't fail + _, _ = hasher.Write(data) + return git.Hash(fmt.Sprintf("%x", hasher.Sum(nil))) +} + +// hash compute the hash of the serialized operation +func hashOperation(op Operation) (git.Hash, error) { + base := op.base() + + if base.hash != "" { + return base.hash, nil + } + + data, err := json.Marshal(op) + if err != nil { + return "", err + } + + base.hash = hashRaw(data) + + return base.hash, nil +} + // OpBase implement the common code for all operations type OpBase struct { - OperationType OperationType `json:"type"` - Author Person `json:"author"` - UnixTime int64 `json:"timestamp"` + OperationType OperationType `json:"type"` + Author Person `json:"author"` + UnixTime int64 `json:"timestamp"` + hash git.Hash Metadata map[string]string `json:"metadata,omitempty"` } -// NewOpBase is the constructor for an OpBase -func NewOpBase(opType OperationType, author Person, unixTime int64) *OpBase { +// newOpBase is the constructor for an OpBase +func newOpBase(opType OperationType, author Person, unixTime int64) *OpBase { return &OpBase{ OperationType: opType, Author: author, @@ -59,11 +88,6 @@ func NewOpBase(opType OperationType, author Person, unixTime int64) *OpBase { } } -// OpType return the type of operation -func (op *OpBase) OpType() OperationType { - return op.OperationType -} - // Time return the time when the operation was added func (op *OpBase) Time() time.Time { return time.Unix(op.UnixTime, 0) @@ -74,27 +98,26 @@ func (op *OpBase) GetUnixTime() int64 { return op.UnixTime } -// GetAuthor return the author of the operation -func (op *OpBase) GetAuthor() Person { - return op.Author -} - // GetFiles return the files needed by this operation func (op *OpBase) GetFiles() []git.Hash { return nil } // Validate check the OpBase for errors -func OpBaseValidate(op Operation, opType OperationType) error { - if op.OpType() != opType { - return fmt.Errorf("incorrect operation type (expected: %v, actual: %v)", opType, op.OpType()) +func opBaseValidate(op Operation, opType OperationType) error { + if op.base().OperationType != opType { + return fmt.Errorf("incorrect operation type (expected: %v, actual: %v)", opType, op.base().OperationType) + } + + if _, err := op.Hash(); err != nil { + return errors.Wrap(err, "op is not serializable") } if op.GetUnixTime() == 0 { return fmt.Errorf("time not set") } - if err := op.GetAuthor().Validate(); err != nil { + if err := op.base().Author.Validate(); err != nil { return errors.Wrap(err, "author") } diff --git a/bug/operation_iterator_test.go b/bug/operation_iterator_test.go new file mode 100644 index 00000000..506cc94f --- /dev/null +++ b/bug/operation_iterator_test.go @@ -0,0 +1,59 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/repository" + "testing" + "time" +) + +var ( + rene = Person{ + Name: "René Descartes", + Email: "rene@descartes.fr", + } + + unix = time.Now().Unix() + + createOp = NewCreateOp(rene, unix, "title", "message", nil) + setTitleOp = NewSetTitleOp(rene, unix, "title2", "title1") + addCommentOp = NewAddCommentOp(rene, unix, "message2", nil) + setStatusOp = NewSetStatusOp(rene, unix, ClosedStatus) + labelChangeOp = NewLabelChangeOperation(rene, unix, []Label{"added"}, []Label{"removed"}) +) + +func TestOpIterator(t *testing.T) { + mockRepo := repository.NewMockRepoForTest() + + bug1 := NewBug() + + // first pack + bug1.Append(createOp) + bug1.Append(setTitleOp) + bug1.Append(addCommentOp) + bug1.Append(setStatusOp) + bug1.Append(labelChangeOp) + bug1.Commit(mockRepo) + + // second pack + bug1.Append(setTitleOp) + bug1.Append(setTitleOp) + bug1.Append(setTitleOp) + bug1.Commit(mockRepo) + + // staging + bug1.Append(setTitleOp) + bug1.Append(setTitleOp) + bug1.Append(setTitleOp) + + it := NewOperationIterator(bug1) + + counter := 0 + for it.Next() { + _ = it.Value() + counter++ + } + + if counter != 11 { + t.Fatalf("Wrong count of value iterated (%d instead of 8)", counter) + } +} diff --git a/bug/operation_pack.go b/bug/operation_pack.go index 03d538d5..f33d94bf 100644 --- a/bug/operation_pack.go +++ b/bug/operation_pack.go @@ -3,7 +3,6 @@ package bug import ( "encoding/json" "fmt" - "reflect" "github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/util/git" @@ -25,17 +24,6 @@ type OperationPack struct { commitHash git.Hash } -// hold the different operation type to instantiate to parse JSON -var operations map[OperationType]reflect.Type - -// Register will register a new type of Operation to be able to parse the corresponding JSON -func Register(t OperationType, op interface{}) { - if operations == nil { - operations = make(map[OperationType]reflect.Type) - } - operations[t] = reflect.TypeOf(op) -} - func (opp *OperationPack) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Version uint `json:"version"` @@ -69,25 +57,51 @@ func (opp *OperationPack) UnmarshalJSON(data []byte) error { return err } - opType, ok := operations[t.OperationType] - if !ok { - return fmt.Errorf("unknown operation type %v", t.OperationType) - } - - op := reflect.New(opType).Interface() - - if err := json.Unmarshal(raw, op); err != nil { + op, err := opp.unmarshalOp(raw, t.OperationType) + if err != nil { return err } - deref := reflect.ValueOf(op).Elem().Interface() + // Compute the hash of the operation + op.base().hash = hashRaw(raw) - opp.Operations = append(opp.Operations, deref.(Operation)) + opp.Operations = append(opp.Operations, op) } return nil } +func (opp *OperationPack) unmarshalOp(raw []byte, _type OperationType) (Operation, error) { + switch _type { + case CreateOp: + op := &CreateOperation{} + err := json.Unmarshal(raw, &op) + return op, err + case SetTitleOp: + op := &SetTitleOperation{} + err := json.Unmarshal(raw, &op) + return op, err + case AddCommentOp: + op := &AddCommentOperation{} + err := json.Unmarshal(raw, &op) + return op, err + case SetStatusOp: + op := &SetStatusOperation{} + err := json.Unmarshal(raw, &op) + return op, err + case LabelChangeOp: + op := &LabelChangeOperation{} + err := json.Unmarshal(raw, &op) + return op, err + case EditCommentOp: + op := &EditCommentOperation{} + err := json.Unmarshal(raw, &op) + return op, err + default: + return nil, fmt.Errorf("unknown operation type %v", _type) + } +} + // Append a new operation to the pack func (opp *OperationPack) Append(op Operation) { opp.Operations = append(opp.Operations, op) diff --git a/bug/operation_pack_test.go b/bug/operation_pack_test.go new file mode 100644 index 00000000..48f9f80c --- /dev/null +++ b/bug/operation_pack_test.go @@ -0,0 +1,53 @@ +package bug + +import ( + "encoding/json" + "testing" + + "github.com/MichaelMure/git-bug/util/git" + "github.com/go-test/deep" +) + +func TestOperationPackSerialize(t *testing.T) { + opp := &OperationPack{} + + opp.Append(createOp) + opp.Append(setTitleOp) + opp.Append(addCommentOp) + opp.Append(setStatusOp) + opp.Append(labelChangeOp) + + opMeta := NewCreateOp(rene, unix, "title", "message", nil) + opMeta.SetMetadata("key", "value") + opp.Append(opMeta) + + if len(opMeta.Metadata) != 1 { + t.Fatal() + } + + opFile := NewCreateOp(rene, unix, "title", "message", []git.Hash{ + "abcdef", + "ghijkl", + }) + opp.Append(opFile) + + if len(opFile.Files) != 2 { + t.Fatal() + } + + data, err := json.Marshal(opp) + if err != nil { + t.Fatal(err) + } + + var opp2 *OperationPack + err = json.Unmarshal(data, &opp2) + if err != nil { + t.Fatal(err) + } + + deep.CompareUnexportedFields = false + if diff := deep.Equal(opp, opp2); diff != nil { + t.Fatal(diff) + } +} diff --git a/bug/operation_test.go b/bug/operation_test.go new file mode 100644 index 00000000..098cf138 --- /dev/null +++ b/bug/operation_test.go @@ -0,0 +1,70 @@ +package bug + +import ( + "testing" + "time" + + "github.com/MichaelMure/git-bug/util/git" +) + +func TestValidate(t *testing.T) { + rene := Person{ + Name: "René Descartes", + Email: "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(Person{Name: "", Email: "rene@descartes.fr"}, unix, ClosedStatus), + NewSetStatusOp(Person{Name: "René Descartes\u001b", Email: "rene@descartes.fr"}, unix, ClosedStatus), + NewSetStatusOp(Person{Name: "René Descartes", Email: "rene@descartes.fr\u001b"}, unix, ClosedStatus), + NewSetStatusOp(Person{Name: "René \nDescartes", Email: "rene@descartes.fr"}, unix, ClosedStatus), + NewSetStatusOp(Person{Name: "René Descartes", Email: "rene@\ndescartes.fr"}, unix, ClosedStatus), + &CreateOperation{OpBase: &OpBase{ + Author: rene, + UnixTime: 0, + OperationType: CreateOp, + }, + Title: "title", + Message: "message", + }, + + NewCreateOp(rene, unix, "multi\nline", "message", nil), + NewCreateOp(rene, unix, "title", "message", []git.Hash{git.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, "", nil), + NewAddCommentOp(rene, unix, "message\u001b", nil), + NewAddCommentOp(rene, unix, "message", []git.Hash{git.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) + } + } +} diff --git a/bug/snapshot.go b/bug/snapshot.go index 59dcae7e..df39ff46 100644 --- a/bug/snapshot.go +++ b/bug/snapshot.go @@ -3,6 +3,8 @@ package bug import ( "fmt" "time" + + "github.com/MichaelMure/git-bug/util/git" ) // Snapshot is a compiled form of the Bug data structure used for storage and merge @@ -16,20 +18,22 @@ type Snapshot struct { Author Person CreatedAt time.Time + Timeline []TimelineItem + Operations []Operation } // Return the Bug identifier -func (snap Snapshot) Id() string { +func (snap *Snapshot) Id() string { return snap.id } // Return the Bug identifier truncated for human consumption -func (snap Snapshot) HumanId() string { +func (snap *Snapshot) HumanId() string { return fmt.Sprintf("%.8s", snap.id) } -func (snap Snapshot) Summary() string { +func (snap *Snapshot) Summary() string { return fmt.Sprintf("C:%d L:%d", len(snap.Comments)-1, len(snap.Labels), @@ -37,7 +41,7 @@ func (snap Snapshot) Summary() string { } // Return the last time a bug was modified -func (snap Snapshot) LastEditTime() time.Time { +func (snap *Snapshot) LastEditTime() time.Time { if len(snap.Operations) == 0 { return time.Unix(0, 0) } @@ -46,10 +50,26 @@ func (snap Snapshot) LastEditTime() time.Time { } // Return the last timestamp a bug was modified -func (snap Snapshot) LastEditUnix() int64 { +func (snap *Snapshot) LastEditUnix() int64 { if len(snap.Operations) == 0 { return 0 } return snap.Operations[len(snap.Operations)-1].GetUnixTime() } + +// SearchTimelineItem will search in the timeline for an item matching the given hash +func (snap *Snapshot) SearchTimelineItem(hash git.Hash) (TimelineItem, error) { + for i := range snap.Timeline { + h, err := snap.Timeline[i].Hash() + if err != nil { + return nil, err + } + + if h == hash { + return snap.Timeline[i], nil + } + } + + return nil, fmt.Errorf("timeline item not found") +} diff --git a/bug/time.go b/bug/time.go new file mode 100644 index 00000000..a085e8e9 --- /dev/null +++ b/bug/time.go @@ -0,0 +1,9 @@ +package bug + +import "time" + +type Timestamp int64 + +func (t Timestamp) Time() time.Time { + return time.Unix(int64(t), 0) +} diff --git a/bug/timeline.go b/bug/timeline.go new file mode 100644 index 00000000..d734e18b --- /dev/null +++ b/bug/timeline.go @@ -0,0 +1,87 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/util/git" +) + +type TimelineItem interface { + // Hash return the hash of the item + Hash() (git.Hash, error) +} + +type CommentHistoryStep struct { + Message string + UnixTime Timestamp +} + +// CreateTimelineItem replace a Create operation in the Timeline and hold its edition history +type CreateTimelineItem struct { + CommentTimelineItem +} + +func NewCreateTimelineItem(hash git.Hash, comment Comment) *CreateTimelineItem { + return &CreateTimelineItem{ + CommentTimelineItem: CommentTimelineItem{ + hash: hash, + Author: comment.Author, + Message: comment.Message, + Files: comment.Files, + CreatedAt: comment.UnixTime, + LastEdit: comment.UnixTime, + History: []CommentHistoryStep{ + { + Message: comment.Message, + UnixTime: comment.UnixTime, + }, + }, + }, + } +} + +// CommentTimelineItem replace a Comment in the Timeline and hold its edition history +type CommentTimelineItem struct { + hash git.Hash + Author Person + Message string + Files []git.Hash + CreatedAt Timestamp + LastEdit Timestamp + History []CommentHistoryStep +} + +func NewCommentTimelineItem(hash git.Hash, comment Comment) *CommentTimelineItem { + return &CommentTimelineItem{ + hash: hash, + 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) Hash() (git.Hash, error) { + return c.hash, nil +} + +// 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{ + Message: comment.Message, + UnixTime: comment.UnixTime, + }) +} + +// Edited say if the comment was edited +func (c *CommentTimelineItem) Edited() bool { + return len(c.History) > 1 +} diff --git a/bug/with_snapshot.go b/bug/with_snapshot.go index 48274ed5..2b2439df 100644 --- a/bug/with_snapshot.go +++ b/bug/with_snapshot.go @@ -27,10 +27,8 @@ func (b *WithSnapshot) Append(op Operation) { return } - snap := op.Apply(*b.snap) - snap.Operations = append(snap.Operations, op) - - b.snap = &snap + op.Apply(b.snap) + b.snap.Operations = append(b.snap.Operations, op) } // Commit intercept Bug.Commit() to update the snapshot efficiently |