Detailed changes
@@ -0,0 +1,57 @@
+package board
+
+import (
+ "github.com/MichaelMure/git-bug/entities/bug"
+ "github.com/MichaelMure/git-bug/entities/identity"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+var _ entity.Interface = &Board{}
+
+// 1: original format
+const formatVersion = 1
+
+var def = dag.Definition{
+ Typename: "board",
+ Namespace: "boards",
+ OperationUnmarshaler: operationUnmarshaller,
+ FormatVersion: formatVersion,
+}
+
+var ClockLoader = dag.ClockLoader(def)
+
+// Board holds the data of a project board.
+type Board struct {
+ *dag.Entity
+}
+
+// NewBoard create a new Board
+func NewBoard() *Board {
+ return &Board{
+ Entity: dag.New(def),
+ }
+}
+
+func simpleResolvers(repo repository.ClockedRepo) entity.Resolvers {
+ return entity.Resolvers{
+ &identity.Identity{}: identity.NewSimpleResolver(repo),
+ &bug.Bug{}: bug.NewSimpleResolver(repo),
+ }
+}
+
+// Read will read a board from a repository
+func Read(repo repository.ClockedRepo, id entity.Id) (*Board, error) {
+ return ReadWithResolver(repo, simpleResolvers(repo), id)
+}
+
+// ReadWithResolver will read a board from its Id, with a custom identity.Resolver
+func ReadWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (*Board, error) {
+ e, err := dag.Read(def, repo, resolvers, id)
+ if err != nil {
+ return nil, err
+ }
+ return &Board{Entity: e}, nil
+}
@@ -0,0 +1,39 @@
+package board
+
+import (
+ "github.com/dustin/go-humanize"
+
+ "github.com/MichaelMure/git-bug/entities/common"
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/timestamp"
+)
+
+var _ CardItem = &Draft{}
+
+type Draft struct {
+ status common.Status
+ title string
+ message string
+ files []repository.Hash
+
+ // Creation time of the comment.
+ // Should be used only for human display, never for ordering as we can't rely on it in a distributed system.
+ unixTime timestamp.Timestamp
+}
+
+func (d *Draft) Status() common.Status {
+ // TODO implement me
+ panic("implement me")
+}
+
+// FormatTimeRel format the UnixTime of the comment for human consumption
+func (d *Draft) FormatTimeRel() string {
+ return humanize.Time(d.unixTime.Time())
+}
+
+func (d *Draft) FormatTime() string {
+ return d.unixTime.Time().Format("Mon Jan 2 15:04:05 2006 +0200")
+}
+
+// IsAuthored is a sign post method for gqlgen
+func (d *Draft) IsAuthored() {}
@@ -0,0 +1,28 @@
+package board
+
+import (
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+)
+
+var _ Operation = &AddItemDraftOperation{}
+
+type AddItemDraftOperation struct {
+ dag.OpBase
+ Title string `json:"title"`
+ Message string `json:"message"`
+}
+
+func (op *AddItemDraftOperation) Id() entity.Id {
+ return dag.IdOperation(op, &op.OpBase)
+}
+
+func (op *AddItemDraftOperation) Validate() error {
+ // TODO implement me
+ panic("implement me")
+}
+
+func (op *AddItemDraftOperation) Apply(snapshot *Snapshot) {
+ // TODO implement me
+ panic("implement me")
+}
@@ -0,0 +1,28 @@
+package board
+
+import (
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+)
+
+var _ Operation = &AddItemEntityOperation{}
+
+type AddItemEntityOperation struct {
+ dag.OpBase
+ // TODO: entity namespace + id ? or solve https://github.com/MichaelMure/git-bug/pull/664 ?
+ item CardItem
+}
+
+func (op *AddItemEntityOperation) Id() entity.Id {
+ return dag.IdOperation(op, &op.OpBase)
+}
+
+func (op *AddItemEntityOperation) Validate() error {
+ // TODO implement me
+ panic("implement me")
+}
+
+func (op *AddItemEntityOperation) Apply(snapshot *Snapshot) {
+ // TODO implement me
+ panic("implement me")
+}
@@ -0,0 +1,101 @@
+package board
+
+import (
+ "fmt"
+
+ "github.com/MichaelMure/git-bug/entities/identity"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+var DefaultColumns = []string{"To Do", "In Progress", "Done"}
+
+var _ dag.Operation = &CreateOperation{}
+
+type CreateOperation struct {
+ dag.OpBase
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Columns []string `json:"columns"`
+}
+
+func NewCreateOp(author identity.Interface, unixTime int64, title string, description string, columns []string) *CreateOperation {
+ return &CreateOperation{
+ OpBase: dag.NewOpBase(CreateOp, author, unixTime),
+ Title: title,
+ Description: description,
+ Columns: columns,
+ }
+}
+
+func (op *CreateOperation) Id() entity.Id {
+ return dag.IdOperation(op, &op.OpBase)
+}
+
+func (op *CreateOperation) Validate() error {
+ if err := op.OpBase.Validate(op, CreateOp); err != nil {
+ return err
+ }
+
+ if text.Empty(op.Title) {
+ return fmt.Errorf("title is empty")
+ }
+ if !text.SafeOneLine(op.Title) {
+ return fmt.Errorf("title has unsafe characters")
+ }
+
+ if !text.SafeOneLine(op.Description) {
+ return fmt.Errorf("description has unsafe characters")
+ }
+
+ if len(op.Columns) <= 0 {
+ return fmt.Errorf("no columns")
+ }
+ for _, column := range op.Columns {
+ if !text.SafeOneLine(column) {
+ return fmt.Errorf("a columns has unsafe characters")
+ }
+ if len(column) > 100 {
+ return fmt.Errorf("a columns is too long")
+ }
+ }
+
+ return nil
+}
+
+func (op *CreateOperation) Apply(snap *Snapshot) {
+ // sanity check: will fail when adding a second Create
+ if snap.id != "" && snap.id != entity.UnsetId && snap.id != op.Id() {
+ return
+ }
+
+ snap.id = op.Id()
+
+ snap.Title = op.Title
+ snap.Description = op.Description
+
+ for _, name := range op.Columns {
+ snap.Columns = append(snap.Columns, Column{
+ Name: name,
+ Cards: nil,
+ })
+ }
+
+ snap.addActor(op.Author())
+}
+
+func CreateDefaultColumns(author identity.Interface, unixTime int64, title, description string) (*Board, *CreateOperation, error) {
+ return Create(author, unixTime, title, description, DefaultColumns)
+}
+
+func Create(author identity.Interface, unixTime int64, title, description string, columns []string) (*Board, *CreateOperation, error) {
+ b := NewBoard()
+ op := NewCreateOp(author, unixTime, title, description, columns)
+ if err := op.Validate(); err != nil {
+ return nil, op, err
+ }
+ b.Append(op)
+ return b, op, nil
+}
@@ -0,0 +1,57 @@
+package board
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/entities/identity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+func TestCreate(t *testing.T) {
+ snap := Snapshot{}
+
+ repo := repository.NewMockRepo()
+
+ rene, err := identity.NewIdentity(repo, "RenΓ© Descartes", "rene@descartes.fr")
+ require.NoError(t, err)
+
+ unix := time.Now().Unix()
+
+ create := NewCreateOp(rene, unix, "title", "description", DefaultColumns)
+ create.Apply(&snap)
+
+ id := create.Id()
+ require.NoError(t, id.Validate())
+
+ require.Equal(t, id, snap.Id())
+ require.Equal(t, "title", snap.Title)
+ require.Equal(t, "description", snap.Description)
+ require.Len(t, snap.Columns, len(DefaultColumns))
+ for i, column := range DefaultColumns {
+ require.Equal(t, column, snap.Columns[i].Name)
+ }
+
+ // Make sure an extra Create Op doesn't mess things
+ isaac, err := identity.NewIdentity(repo, "Isaac Newton", "isaac@newton.uk")
+ require.NoError(t, err)
+ create2 := NewCreateOp(isaac, unix, "title2", "description2", DefaultColumns)
+ create2.Apply(&snap)
+
+ require.Equal(t, id, snap.Id())
+ require.Equal(t, "title", snap.Title)
+ require.Equal(t, "description", snap.Description)
+ require.Len(t, snap.Columns, len(DefaultColumns))
+ for i, column := range DefaultColumns {
+ require.Equal(t, column, snap.Columns[i].Name)
+ }
+}
+
+func TestCreateSerialize(t *testing.T) {
+ dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *CreateOperation {
+ return NewCreateOp(author, unixTime, "title", "description", DefaultColumns)
+ })
+}
@@ -0,0 +1,82 @@
+package board
+
+import (
+ "fmt"
+
+ "github.com/MichaelMure/git-bug/entities/identity"
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+var _ Operation = &SetDescriptionOperation{}
+
+// SetDescriptionOperation will change the description of a board
+type SetDescriptionOperation struct {
+ dag.OpBase
+ Description string `json:"description"`
+ Was string `json:"was"`
+}
+
+func (op *SetDescriptionOperation) Id() entity.Id {
+ return dag.IdOperation(op, &op.OpBase)
+}
+
+func (op *SetDescriptionOperation) Validate() error {
+ if err := op.OpBase.Validate(op, SetDescriptionOp); err != nil {
+ return err
+ }
+
+ if text.Empty(op.Description) {
+ return fmt.Errorf("description is empty")
+ }
+
+ if !text.SafeOneLine(op.Description) {
+ return fmt.Errorf("description has unsafe characters")
+ }
+
+ if !text.SafeOneLine(op.Was) {
+ return fmt.Errorf("previous description has unsafe characters")
+ }
+
+ return nil
+}
+
+func (op *SetDescriptionOperation) Apply(snapshot *Snapshot) {
+ snapshot.Description = op.Description
+ snapshot.addActor(op.Author())
+}
+
+func NewSetDescriptionOp(author identity.Interface, unixTime int64, description string, was string) *SetDescriptionOperation {
+ return &SetDescriptionOperation{
+ OpBase: dag.NewOpBase(SetDescriptionOp, author, unixTime),
+ Description: description,
+ Was: was,
+ }
+}
+
+// SetDescription is a convenience function to change a board description
+func SetDescription(b *Board, author identity.Interface, unixTime int64, description string) (*SetDescriptionOperation, error) {
+ var lastDescriptionOp *SetDescriptionOperation
+ for _, op := range b.Operations() {
+ switch op := op.(type) {
+ case *SetDescriptionOperation:
+ lastDescriptionOp = op
+ }
+ }
+
+ var was string
+ if lastDescriptionOp != nil {
+ was = lastDescriptionOp.Description
+ } else {
+ was = b.FirstOp().(*CreateOperation).Description
+ }
+
+ op := NewSetDescriptionOp(author, unixTime, description, was)
+ if err := op.Validate(); err != nil {
+ return nil, err
+ }
+
+ b.Append(op)
+ return op, nil
+}
@@ -0,0 +1,14 @@
+package board
+
+import (
+ "testing"
+
+ "github.com/MichaelMure/git-bug/entities/identity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+)
+
+func TestSetDescriptionSerialize(t *testing.T) {
+ dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetDescriptionOperation {
+ return NewSetDescriptionOp(author, unixTime, "description", "was")
+ })
+}
@@ -0,0 +1,82 @@
+package board
+
+import (
+ "fmt"
+
+ "github.com/MichaelMure/git-bug/entities/identity"
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+var _ Operation = &SetTitleOperation{}
+
+// SetTitleOperation will change the title of a board
+type SetTitleOperation struct {
+ dag.OpBase
+ Title string `json:"title"`
+ Was string `json:"was"`
+}
+
+func (op *SetTitleOperation) Id() entity.Id {
+ return dag.IdOperation(op, &op.OpBase)
+}
+
+func (op *SetTitleOperation) Validate() error {
+ if err := op.OpBase.Validate(op, SetTitleOp); err != nil {
+ return err
+ }
+
+ if text.Empty(op.Title) {
+ return fmt.Errorf("title is empty")
+ }
+
+ if !text.SafeOneLine(op.Title) {
+ return fmt.Errorf("title has unsafe characters")
+ }
+
+ if !text.SafeOneLine(op.Was) {
+ return fmt.Errorf("previous title has unsafe characters")
+ }
+
+ return nil
+}
+
+func (op *SetTitleOperation) Apply(snapshot *Snapshot) {
+ snapshot.Title = op.Title
+ snapshot.addActor(op.Author())
+}
+
+func NewSetTitleOp(author identity.Interface, unixTime int64, title string, was string) *SetTitleOperation {
+ return &SetTitleOperation{
+ OpBase: dag.NewOpBase(SetTitleOp, author, unixTime),
+ Title: title,
+ Was: was,
+ }
+}
+
+// SetTitle is a convenience function to change a board title
+func SetTitle(b *Board, author identity.Interface, unixTime int64, title string) (*SetTitleOperation, error) {
+ var lastTitleOp *SetTitleOperation
+ for _, op := range b.Operations() {
+ switch op := op.(type) {
+ case *SetTitleOperation:
+ lastTitleOp = op
+ }
+ }
+
+ var was string
+ if lastTitleOp != nil {
+ was = lastTitleOp.Title
+ } else {
+ was = b.FirstOp().(*CreateOperation).Title
+ }
+
+ op := NewSetTitleOp(author, unixTime, title, was)
+ if err := op.Validate(); err != nil {
+ return nil, err
+ }
+
+ b.Append(op)
+ return op, nil
+}
@@ -0,0 +1,14 @@
+package board
+
+import (
+ "testing"
+
+ "github.com/MichaelMure/git-bug/entities/identity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+)
+
+func TestSetTitleSerialize(t *testing.T) {
+ dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetTitleOperation {
+ return NewSetTitleOp(author, unixTime, "title", "was")
+ })
+}
@@ -0,0 +1,71 @@
+package board
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/entity/dag"
+)
+
+// OperationType is an operation type identifier
+type OperationType dag.OperationType
+
+const (
+ _ dag.OperationType = iota
+ CreateOp
+ SetTitleOp
+ SetDescriptionOp
+ AddItemEntityOp
+ AddItemDraftOp
+ MoveItemOp
+ RemoveItemOp
+
+ // TODO: change columns?
+)
+
+type Operation interface {
+ dag.Operation
+ // Apply the operation to a Snapshot to create the final state
+ Apply(snapshot *Snapshot)
+}
+
+func operationUnmarshaller(raw json.RawMessage, resolvers entity.Resolvers) (dag.Operation, error) {
+ var t struct {
+ OperationType dag.OperationType `json:"type"`
+ }
+
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, err
+ }
+
+ var op dag.Operation
+
+ switch t.OperationType {
+ case CreateOp:
+ op = &CreateOperation{}
+ case SetTitleOp:
+ op = &SetTitleOperation{}
+ case SetDescriptionOp:
+ op = &SetDescriptionOperation{}
+ case AddItemDraftOp:
+ op = &AddItemDraftOperation{}
+ case AddItemEntityOp:
+ op = &AddItemEntityOperation{}
+ default:
+ panic(fmt.Sprintf("unknown operation type %v", t.OperationType))
+ }
+
+ err := json.Unmarshal(raw, &op)
+ if err != nil {
+ return nil, err
+ }
+
+ switch op := op.(type) {
+ case *AddItemEntityOperation:
+ // TODO: resolve entity
+ op.item = struct{}{}
+ }
+
+ return op, nil
+}
@@ -0,0 +1,58 @@
+package board
+
+import (
+ "time"
+
+ "github.com/MichaelMure/git-bug/entities/identity"
+ "github.com/MichaelMure/git-bug/entity"
+)
+
+type Column struct {
+ Name string
+ Cards []CardItem
+}
+
+type CardItem interface {
+ // Status() common.Status
+}
+
+type Snapshot struct {
+ id entity.Id
+
+ Title string
+ Description string
+ Columns []Column
+ Actors []identity.Interface
+
+ CreateTime time.Time
+ Operations []Operation
+}
+
+// Id returns the Board identifier
+func (snap *Snapshot) Id() entity.Id {
+ if snap.id == "" {
+ // simply panic as it would be a coding error (no id provided at construction)
+ panic("no id")
+ }
+ return snap.id
+}
+
+// EditTime returns the last time the board was modified
+func (snap *Snapshot) EditTime() time.Time {
+ if len(snap.Operations) == 0 {
+ return time.Unix(0, 0)
+ }
+
+ return snap.Operations[len(snap.Operations)-1].Time()
+}
+
+// append the operation author to the actors list
+func (snap *Snapshot) addActor(actor identity.Interface) {
+ for _, a := range snap.Actors {
+ if actor.Id() == a.Id() {
+ return
+ }
+ }
+
+ snap.Actors = append(snap.Actors, actor)
+}