Detailed changes
  
  
    
    @@ -1,6 +1,8 @@
 package board
 
 import (
+	"fmt"
+
 	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entities/identity"
 
@@ -9,7 +11,7 @@ import (
 	"github.com/MichaelMure/git-bug/repository"
 )
 
-var _ entity.Interface = &Board{}
+var _ Interface = &Board{}
 
 // 1: original format
 const formatVersion = 1
@@ -17,12 +19,16 @@ const formatVersion = 1
 var def = dag.Definition{
 	Typename:             "board",
 	Namespace:            "boards",
-	OperationUnmarshaler: operationUnmarshaller,
+	OperationUnmarshaler: operationUnmarshaler,
 	FormatVersion:        formatVersion,
 }
 
 var ClockLoader = dag.ClockLoader(def)
 
+type Interface interface {
+	dag.Interface[*Snapshot, Operation]
+}
+
 // Board holds the data of a project board.
 type Board struct {
 	*dag.Entity
@@ -55,3 +61,75 @@ func ReadWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers, i
 	}
 	return &Board{Entity: e}, nil
 }
+
+// Validate check if the Board data is valid
+func (board *Board) Validate() error {
+	if err := board.Entity.Validate(); err != nil {
+		return err
+	}
+
+	// The very first Op should be a CreateOp
+	firstOp := board.FirstOp()
+	if firstOp == nil || firstOp.Type() != CreateOp {
+		return fmt.Errorf("first operation should be a Create op")
+	}
+
+	// Check that there is no more CreateOp op
+	for i, op := range board.Entity.Operations() {
+		if i == 0 {
+			continue
+		}
+		if op.Type() == CreateOp {
+			return fmt.Errorf("only one Create op allowed")
+		}
+	}
+
+	return nil
+}
+
+// Append add a new Operation to the Board
+func (board *Board) Append(op Operation) {
+	board.Entity.Append(op)
+}
+
+// Operations return the ordered operations
+func (board *Board) Operations() []Operation {
+	source := board.Entity.Operations()
+	result := make([]Operation, len(source))
+	for i, op := range source {
+		result[i] = op.(Operation)
+	}
+	return result
+}
+
+// Compile a board in an easily usable snapshot
+func (board *Board) Compile() *Snapshot {
+	snap := &Snapshot{
+		id: board.Id(),
+	}
+
+	for _, op := range board.Operations() {
+		op.Apply(snap)
+		snap.Operations = append(snap.Operations, op)
+	}
+
+	return snap
+}
+
+// FirstOp lookup for the very first operation of the board.
+// For a valid Board, this operation should be a CreateOp
+func (board *Board) FirstOp() Operation {
+	if fo := board.Entity.FirstOp(); fo != nil {
+		return fo.(Operation)
+	}
+	return nil
+}
+
+// LastOp lookup for the very last operation of the board.
+// For a valid Board, should never be nil
+func (board *Board) LastOp() Operation {
+	if lo := board.Entity.LastOp(); lo != nil {
+		return lo.(Operation)
+	}
+	return nil
+}
  
  
  
    
    @@ -0,0 +1,58 @@
+package board
+
+import (
+	"github.com/MichaelMure/git-bug/entities/identity"
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+// Fetch retrieve updates from a remote
+// This does not change the local board state
+func Fetch(repo repository.Repo, remote string) (string, error) {
+	return dag.Fetch(def, repo, remote)
+}
+
+// Push update a remote with the local changes
+func Push(repo repository.Repo, remote string) (string, error) {
+	return dag.Push(def, repo, remote)
+}
+
+// Pull will do a Fetch + MergeAll
+// This function will return an error if a merge fail
+// Note: an author is necessary for the case where a merge commit is created, as this commit will
+// have an author and may be signed if a signing key is available.
+func Pull(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) error {
+	return dag.Pull(def, repo, resolvers, remote, mergeAuthor)
+}
+
+// MergeAll will merge all the available remote board
+// Note: an author is necessary for the case where a merge commit is created, as this commit will
+// have an author and may be signed if a signing key is available.
+func MergeAll(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult {
+	out := make(chan entity.MergeResult)
+
+	go func() {
+		defer close(out)
+
+		results := dag.MergeAll(def, repo, resolvers, remote, mergeAuthor)
+
+		// wrap the dag.Entity into a complete Bug
+		for result := range results {
+			result := result
+			if result.Entity != nil {
+				result.Entity = &Board{
+					Entity: result.Entity.(*dag.Entity),
+				}
+			}
+			out <- result
+		}
+	}()
+
+	return out
+}
+
+// Remove will remove a local bug from its entity.Id
+func Remove(repo repository.ClockedRepo, id entity.Id) error {
+	return dag.Remove(def, repo, id)
+}
  
  
  
    
    @@ -1,26 +1,39 @@
 package board
 
 import (
+	"github.com/MichaelMure/git-bug/entities/identity"
+	"github.com/MichaelMure/git-bug/entity"
 	"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{}
+var _ Item = &Draft{}
 
 type Draft struct {
+	// combinedId should be the result of entity.CombineIds with the Board id and the id
+	// of the Operation that created the Draft
+	combinedId entity.CombinedId
+
+	author  identity.Interface
 	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) CombinedId() entity.CombinedId {
+	if d.combinedId == "" || d.combinedId == entity.UnsetCombinedId {
+		// simply panic as it would be a coding error (no id provided at construction)
+		panic("no combined id")
+	}
+	return d.combinedId
+}
+
 func (d *Draft) Status() common.Status {
 	// TODO implement me
 	panic("implement me")
  
  
  
    
    @@ -0,0 +1,21 @@
+package board
+
+import (
+	"github.com/MichaelMure/git-bug/entities/bug"
+	"github.com/MichaelMure/git-bug/entity"
+)
+
+var _ Item = &BugItem{}
+
+type BugItem struct {
+	combinedId entity.CombinedId
+	bug        bug.Interface
+}
+
+func (e *BugItem) CombinedId() entity.CombinedId {
+	if e.combinedId == "" || e.combinedId == entity.UnsetCombinedId {
+		// simply panic as it would be a coding error (no id provided at construction)
+		panic("no combined id")
+	}
+	return e.combinedId
+}
  
  
  
    
    @@ -1,28 +1,101 @@
 package board
 
 import (
+	"fmt"
+
+	"github.com/MichaelMure/git-bug/entities/common"
+	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/entity/dag"
+	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/util/text"
+	"github.com/MichaelMure/git-bug/util/timestamp"
 )
 
 var _ Operation = &AddItemDraftOperation{}
 
 type AddItemDraftOperation struct {
 	dag.OpBase
-	Title   string `json:"title"`
-	Message string `json:"message"`
+	ColumnId entity.Id         `json:"column"`
+	Title    string            `json:"title"`
+	Message  string            `json:"message"`
+	Files    []repository.Hash `json:"files"`
 }
 
 func (op *AddItemDraftOperation) Id() entity.Id {
 	return dag.IdOperation(op, &op.OpBase)
 }
 
+func (op *AddItemDraftOperation) GetFiles() []repository.Hash {
+	return op.Files
+}
+
 func (op *AddItemDraftOperation) Validate() error {
-	// TODO implement me
-	panic("implement me")
+	if err := op.OpBase.Validate(op, AddItemDraftOp); err != nil {
+		return err
+	}
+
+	if err := op.ColumnId.Validate(); err != nil {
+		return err
+	}
+
+	if text.Empty(op.Title) {
+		return fmt.Errorf("title is empty")
+	}
+	if !text.SafeOneLine(op.Title) {
+		return fmt.Errorf("title has unsafe characters")
+	}
+
+	if !text.Safe(op.Message) {
+		return fmt.Errorf("message is not fully printable")
+	}
+
+	for _, file := range op.Files {
+		if !file.IsValid() {
+			return fmt.Errorf("invalid file hash")
+		}
+	}
+
+	return nil
 }
 
 func (op *AddItemDraftOperation) Apply(snapshot *Snapshot) {
-	// TODO implement me
-	panic("implement me")
+	snapshot.addActor(op.Author())
+
+	for _, column := range snapshot.Columns {
+		if column.Id == op.ColumnId {
+			column.Items = append(column.Items, &Draft{
+				combinedId: entity.CombineIds(snapshot.id, op.Id()),
+				author:     op.Author(),
+				status:     common.OpenStatus,
+				title:      op.Title,
+				message:    op.Message,
+				unixTime:   timestamp.Timestamp(op.UnixTime),
+			})
+			return
+		}
+	}
+}
+
+func NewAddItemDraftOp(author identity.Interface, unixTime int64, columnId entity.Id, title, message string, files []repository.Hash) *AddItemDraftOperation {
+	return &AddItemDraftOperation{
+		OpBase:   dag.NewOpBase(AddItemDraftOp, author, unixTime),
+		ColumnId: columnId,
+		Title:    title,
+		Message:  message,
+		Files:    files,
+	}
+}
+
+// AddItemDraft is a convenience function to add a draft item to a Board
+func AddItemDraft(b *Board, author identity.Interface, unixTime int64, columnId entity.Id, title, message string, files []repository.Hash, metadata map[string]string) (*AddItemDraftOperation, error) {
+	op := NewAddItemDraftOp(author, unixTime, columnId, title, message, files)
+	for key, val := range metadata {
+		op.SetMetadata(key, val)
+	}
+	if err := op.Validate(); err != nil {
+		return nil, err
+	}
+	b.Append(op)
+	return op, nil
 }
  
  
  
    
    @@ -0,0 +1,19 @@
+package board
+
+import (
+	"testing"
+
+	"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"
+)
+
+func TestAddItemDraftOpSerialize(t *testing.T) {
+	dag.SerializeRoundTripTest(t, operationUnmarshaler, func(author identity.Interface, unixTime int64) (*AddItemDraftOperation, entity.Resolvers) {
+		return NewAddItemDraftOp(author, unixTime, "foo", "title", "message", nil), nil
+	})
+	dag.SerializeRoundTripTest(t, operationUnmarshaler, func(author identity.Interface, unixTime int64) (*AddItemDraftOperation, entity.Resolvers) {
+		return NewAddItemDraftOp(author, unixTime, "foo", "title", "message", []repository.Hash{"hash1", "hash2"}), nil
+	})
+}
  
  
  
    
    @@ -1,16 +1,29 @@
 package board
 
 import (
+	"fmt"
+
+	"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"
 )
 
+// itemEntityType indicate the type of entity board item
+type itemEntityType string
+
+const (
+	entityTypeBug itemEntityType = "bug"
+)
+
 var _ Operation = &AddItemEntityOperation{}
 
 type AddItemEntityOperation struct {
 	dag.OpBase
-	// TODO: entity namespace + id ? or solve https://github.com/MichaelMure/git-bug/pull/664 ?
-	item CardItem
+	ColumnId   entity.Id        `json:"column"`
+	EntityType itemEntityType   `json:"entity_type"`
+	EntityId   entity.Id        `json:"entity_id"`
+	entity     entity.Interface // not serialized
 }
 
 func (op *AddItemEntityOperation) Id() entity.Id {
@@ -18,11 +31,72 @@ func (op *AddItemEntityOperation) Id() entity.Id {
 }
 
 func (op *AddItemEntityOperation) Validate() error {
-	// TODO implement me
-	panic("implement me")
+	if err := op.OpBase.Validate(op, AddItemEntityOp); err != nil {
+		return err
+	}
+
+	if err := op.ColumnId.Validate(); err != nil {
+		return err
+	}
+
+	switch op.EntityType {
+	case entityTypeBug:
+	default:
+		return fmt.Errorf("unknown entity type")
+	}
+
+	if err := op.EntityId.Validate(); err != nil {
+		return err
+	}
+
+	return nil
 }
 
 func (op *AddItemEntityOperation) Apply(snapshot *Snapshot) {
-	// TODO implement me
-	panic("implement me")
+	if op.entity == nil {
+		return
+	}
+
+	snapshot.addActor(op.Author())
+
+	for _, column := range snapshot.Columns {
+		if column.Id == op.ColumnId {
+			switch e := op.entity.(type) {
+			case bug.Interface:
+				column.Items = append(column.Items, &BugItem{
+					combinedId: entity.CombineIds(snapshot.Id(), e.Id()),
+					bug:        e,
+				})
+			}
+			return
+		}
+	}
+}
+
+func NewAddItemEntityOp(author identity.Interface, unixTime int64, columnId entity.Id, e entity.Interface) *AddItemEntityOperation {
+	switch e := e.(type) {
+	case bug.Interface:
+		return &AddItemEntityOperation{
+			OpBase:     dag.NewOpBase(AddItemEntityOp, author, unixTime),
+			ColumnId:   columnId,
+			EntityType: entityTypeBug,
+			EntityId:   e.Id(),
+			entity:     e,
+		}
+	default:
+		panic("invalid entity type")
+	}
+}
+
+// AddItemEntity is a convenience function to add an entity item to a Board
+func AddItemEntity(b *Board, author identity.Interface, unixTime int64, columnId entity.Id, e entity.Interface, metadata map[string]string) (*AddItemEntityOperation, error) {
+	op := NewAddItemEntityOp(author, unixTime, columnId, e)
+	for key, val := range metadata {
+		op.SetMetadata(key, val)
+	}
+	if err := op.Validate(); err != nil {
+		return nil, err
+	}
+	b.Append(op)
+	return op, nil
 }
  
  
  
    
    @@ -0,0 +1,25 @@
+package board
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"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"
+)
+
+func TestAddItemEntityOpSerialize(t *testing.T) {
+	dag.SerializeRoundTripTest(t, operationUnmarshaler, func(author identity.Interface, unixTime int64) (*AddItemEntityOperation, entity.Resolvers) {
+		b, _, err := bug.Create(author, unixTime, "title", "message", nil, nil)
+		require.NoError(t, err)
+
+		resolvers := entity.Resolvers{
+			&bug.Bug{}: entity.MakeResolver(b),
+		}
+
+		return NewAddItemEntityOp(author, unixTime, "foo", b), resolvers
+	})
+}
  
  
  
    
    @@ -62,6 +62,14 @@ func (op *CreateOperation) Validate() error {
 		}
 	}
 
+	set := make(map[string]struct{})
+	for _, column := range op.Columns {
+		set[column] = struct{}{}
+	}
+	if len(set) != len(op.Columns) {
+		return fmt.Errorf("non unique column name")
+	}
+
 	return nil
 }
 
@@ -75,21 +83,28 @@ func (op *CreateOperation) Apply(snap *Snapshot) {
 
 	snap.Title = op.Title
 	snap.Description = op.Description
+	snap.CreateTime = op.Time()
 
 	for _, name := range op.Columns {
-		snap.Columns = append(snap.Columns, Column{
+		// we derive a unique Id from the original column name
+		id := entity.DeriveId([]byte(name))
+
+		snap.Columns = append(snap.Columns, &Column{
+			Id:    id,
 			Name:  name,
-			Cards: nil,
+			Items: nil,
 		})
 	}
 
 	snap.addActor(op.Author())
 }
 
+// CreateDefaultColumns is a convenience function to create a board with the default columns
 func CreateDefaultColumns(author identity.Interface, unixTime int64, title, description string) (*Board, *CreateOperation, error) {
 	return Create(author, unixTime, title, description, DefaultColumns)
 }
 
+// Create is a convenience function to create a board
 func Create(author identity.Interface, unixTime int64, title, description string, columns []string) (*Board, *CreateOperation, error) {
 	b := NewBoard()
 	op := NewCreateOp(author, unixTime, title, description, columns)
  
  
  
    
    @@ -7,6 +7,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/entities/identity"
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/repository"
 )
@@ -50,8 +51,23 @@ func TestCreate(t *testing.T) {
 	}
 }
 
+func TestNonUnique(t *testing.T) {
+	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", []string{
+		"foo", "bar", "foo",
+	})
+
+	require.Error(t, create.Validate())
+}
+
 func TestCreateSerialize(t *testing.T) {
-	dag.SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *CreateOperation {
-		return NewCreateOp(author, unixTime, "title", "description", DefaultColumns)
+	dag.SerializeRoundTripTest(t, operationUnmarshaler, func(author identity.Interface, unixTime int64) (*CreateOperation, entity.Resolvers) {
+		return NewCreateOp(author, unixTime, "title", "description", DefaultColumns), nil
 	})
 }
  
  
  
    
    @@ -4,11 +4,12 @@ import (
 	"testing"
 
 	"github.com/MichaelMure/git-bug/entities/identity"
+	"github.com/MichaelMure/git-bug/entity"
 	"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")
+	dag.SerializeRoundTripTest(t, operationUnmarshaler, func(author identity.Interface, unixTime int64) (*SetDescriptionOperation, entity.Resolvers) {
+		return NewSetDescriptionOp(author, unixTime, "description", "was"), nil
 	})
 }
  
  
  
    
    @@ -4,11 +4,12 @@ import (
 	"testing"
 
 	"github.com/MichaelMure/git-bug/entities/identity"
+	"github.com/MichaelMure/git-bug/entity"
 	"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")
+	dag.SerializeRoundTripTest(t, operationUnmarshaler, func(author identity.Interface, unixTime int64) (*SetTitleOperation, entity.Resolvers) {
+		return NewSetTitleOp(author, unixTime, "title", "was"), nil
 	})
 }
  
  
  
    
    @@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/entity/dag"
 )
@@ -30,7 +31,7 @@ type Operation interface {
 	Apply(snapshot *Snapshot)
 }
 
-func operationUnmarshaller(raw json.RawMessage, resolvers entity.Resolvers) (dag.Operation, error) {
+func operationUnmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (dag.Operation, error) {
 	var t struct {
 		OperationType dag.OperationType `json:"type"`
 	}
@@ -63,8 +64,15 @@ func operationUnmarshaller(raw json.RawMessage, resolvers entity.Resolvers) (dag
 
 	switch op := op.(type) {
 	case *AddItemEntityOperation:
-		// TODO: resolve entity
-		op.item = struct{}{}
+		switch op.EntityType {
+		case entityTypeBug:
+			op.entity, err = entity.Resolve[bug.Interface](resolvers, op.EntityId)
+		default:
+			return nil, fmt.Errorf("unknown entity type")
+		}
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	return op, nil
  
  
  
    
    @@ -5,27 +5,36 @@ import (
 
 	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 )
 
 type Column struct {
+	Id    entity.Id
 	Name  string
-	Cards []CardItem
+	Items []Item
 }
 
-type CardItem interface {
+type Item interface {
+	CombinedId() entity.CombinedId
 	// Status() common.Status
 }
 
+var _ dag.Snapshot = &Snapshot{}
+
 type Snapshot struct {
 	id entity.Id
 
 	Title       string
 	Description string
-	Columns     []Column
+	Columns     []*Column
 	Actors      []identity.Interface
 
 	CreateTime time.Time
-	Operations []Operation
+	Operations []dag.Operation
+}
+
+func (snap *Snapshot) AllOperations() []dag.Operation {
+	return snap.Operations
 }
 
 // Id returns the Board identifier
  
  
  
    
    @@ -28,7 +28,7 @@ type Comment struct {
 }
 
 func (c Comment) CombinedId() entity.CombinedId {
-	if c.combinedId == "" {
+	if c.combinedId == "" || c.combinedId == entity.UnsetCombinedId {
 		// simply panic as it would be a coding error (no id provided at construction)
 		panic("no combined id")
 	}
  
  
  
    
    @@ -63,6 +63,12 @@ func (op *AddCommentOperation) Validate() error {
 		return fmt.Errorf("message is not fully printable")
 	}
 
+	for _, file := range op.Files {
+		if !file.IsValid() {
+			return fmt.Errorf("invalid file hash")
+		}
+	}
+
 	return nil
 }
 
  
  
  
    
    @@ -80,6 +80,12 @@ func (op *CreateOperation) Validate() error {
 		return fmt.Errorf("message is not fully printable")
 	}
 
+	for _, file := range op.Files {
+		if !file.IsValid() {
+			return fmt.Errorf("invalid file hash")
+		}
+	}
+
 	return nil
 }
 
  
  
  
    
    @@ -98,6 +98,12 @@ func (op *EditCommentOperation) Validate() error {
 		return fmt.Errorf("message is not fully printable")
 	}
 
+	for _, file := range op.Files {
+		if !file.IsValid() {
+			return fmt.Errorf("invalid file hash")
+		}
+	}
+
 	return nil
 }