board: WIP

Michael MurΓ© created

Change summary

entities/board/board.go                   |  57 ++++++++++++++
entities/board/draft.go                   |  39 +++++++++
entities/board/op_add_item_draft.go       |  28 ++++++
entities/board/op_add_item_entity.go      |  28 ++++++
entities/board/op_create.go               | 101 +++++++++++++++++++++++++
entities/board/op_create_test.go          |  57 ++++++++++++++
entities/board/op_set_description.go      |  82 ++++++++++++++++++++
entities/board/op_set_description_test.go |  14 +++
entities/board/op_set_title.go            |  82 ++++++++++++++++++++
entities/board/op_set_title_test.go       |  14 +++
entities/board/operation.go               |  71 +++++++++++++++++
entities/board/snapshot.go                |  58 ++++++++++++++
12 files changed, 631 insertions(+)

Detailed changes

entities/board/board.go πŸ”—

@@ -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
+}

entities/board/draft.go πŸ”—

@@ -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() {}

entities/board/op_add_item_draft.go πŸ”—

@@ -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")
+}

entities/board/op_add_item_entity.go πŸ”—

@@ -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")
+}

entities/board/op_create.go πŸ”—

@@ -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
+}

entities/board/op_create_test.go πŸ”—

@@ -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)
+	})
+}

entities/board/op_set_description.go πŸ”—

@@ -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
+}

entities/board/op_set_description_test.go πŸ”—

@@ -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")
+	})
+}

entities/board/op_set_title.go πŸ”—

@@ -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
+}

entities/board/op_set_title_test.go πŸ”—

@@ -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")
+	})
+}

entities/board/operation.go πŸ”—

@@ -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
+}

entities/board/snapshot.go πŸ”—

@@ -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)
+}