aboutsummaryrefslogtreecommitdiffstats
path: root/bug
diff options
context:
space:
mode:
Diffstat (limited to 'bug')
-rw-r--r--bug/bug.go6
-rw-r--r--bug/bug_actions_test.go388
-rw-r--r--bug/bug_test.go94
-rw-r--r--bug/comment.go9
-rw-r--r--bug/op_add_comment.go88
-rw-r--r--bug/op_create.go107
-rw-r--r--bug/op_create_test.go47
-rw-r--r--bug/op_edit_comment.go125
-rw-r--r--bug/op_edit_comment_test.go53
-rw-r--r--bug/op_label_change.go194
-rw-r--r--bug/op_set_status.go66
-rw-r--r--bug/op_set_title.go96
-rw-r--r--bug/operation.go77
-rw-r--r--bug/operation_iterator_test.go59
-rw-r--r--bug/operation_pack.go58
-rw-r--r--bug/operation_pack_test.go53
-rw-r--r--bug/operation_test.go70
-rw-r--r--bug/snapshot.go30
-rw-r--r--bug/time.go9
-rw-r--r--bug/timeline.go87
-rw-r--r--bug/with_snapshot.go6
21 files changed, 1655 insertions, 67 deletions
diff --git a/bug/bug.go b/bug/bug.go
index 993d3d7c..f29c04b4 100644
--- a/bug/bug.go
+++ b/bug/bug.go
@@ -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