diff --git a/entities/board/board.go b/entities/board/board.go new file mode 100644 index 0000000000000000000000000000000000000000..b8331aeb54825b4df58aa6c624eef6d3ee481979 --- /dev/null +++ b/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 +} diff --git a/entities/board/draft.go b/entities/board/draft.go new file mode 100644 index 0000000000000000000000000000000000000000..f77f64357aadec84917aaf321d44db559104b34e --- /dev/null +++ b/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() {} diff --git a/entities/board/op_add_item_draft.go b/entities/board/op_add_item_draft.go new file mode 100644 index 0000000000000000000000000000000000000000..f925602b7772ac71d99cf1a1ba20dd161b651a06 --- /dev/null +++ b/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") +} diff --git a/entities/board/op_add_item_entity.go b/entities/board/op_add_item_entity.go new file mode 100644 index 0000000000000000000000000000000000000000..7df145278da30a3fe497d23f2be25f1c01c3b759 --- /dev/null +++ b/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") +} diff --git a/entities/board/op_create.go b/entities/board/op_create.go new file mode 100644 index 0000000000000000000000000000000000000000..c956dd7ea4b79651051f3804564821fd89ad548e --- /dev/null +++ b/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 +} diff --git a/entities/board/op_create_test.go b/entities/board/op_create_test.go new file mode 100644 index 0000000000000000000000000000000000000000..efce2dd692d316fa294a47123771cd603a344ed2 --- /dev/null +++ b/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) + }) +} diff --git a/entities/board/op_set_description.go b/entities/board/op_set_description.go new file mode 100644 index 0000000000000000000000000000000000000000..c82e0c7252cf767deeaeb687e004e6c8c8b03291 --- /dev/null +++ b/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 +} diff --git a/entities/board/op_set_description_test.go b/entities/board/op_set_description_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7bed84fbf0f19553d88fab7090112ac963bf0896 --- /dev/null +++ b/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") + }) +} diff --git a/entities/board/op_set_title.go b/entities/board/op_set_title.go new file mode 100644 index 0000000000000000000000000000000000000000..e009ddb6593f21267aae031328d018dc1954763a --- /dev/null +++ b/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 +} diff --git a/entities/board/op_set_title_test.go b/entities/board/op_set_title_test.go new file mode 100644 index 0000000000000000000000000000000000000000..af84c144cd6c01dc106c646ec20c8385d49ce6e7 --- /dev/null +++ b/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") + }) +} diff --git a/entities/board/operation.go b/entities/board/operation.go new file mode 100644 index 0000000000000000000000000000000000000000..a0a26389167c2e74186bdd4fbd97ce031e34f3a1 --- /dev/null +++ b/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 +} diff --git a/entities/board/snapshot.go b/entities/board/snapshot.go new file mode 100644 index 0000000000000000000000000000000000000000..9122a98f7c736b0fc3c02954c22a6c76433a2dc7 --- /dev/null +++ b/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) +}