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
}