board: make the basic commands work

Michael Muré created

Also rework of entity generic interfaces, in a way that allows to handle low level or cache entities the same way (ReadOnly), and another ReadWrite interface that also allow to mutate the entity. This should fix some long long standing issue around that, and notably fix the resolvers.

This is the first time an entity really load another one, which is what required that fix. Hopefully this opens the way for Identities to use the dag framework.

Change summary

cache/board_cache.go                      |  4 +-
cache/board_subcache.go                   | 18 ++++++++
cache/bug_cache.go                        |  2 
cache/cached.go                           |  9 +++-
cache/repo_cache.go                       | 10 ++--
cache/subcache.go                         | 47 +++++++++++++++++++++--
cache/with_snapshot.go                    | 16 +++++---
commands/board/board_addbug.go            | 35 +----------------
commands/board/board_adddraft.go          | 50 +++++++++++++-----------
commands/bug/completion.go                |  3 +
commands/cmdjson/board.go                 | 27 ++++++++++++-
entities/board/board.go                   | 13 +++---
entities/board/board_actions.go           |  8 +++
entities/board/item_draft.go              | 12 +++++-
entities/board/item_entity.go             | 12 +++++
entities/board/op_add_item_draft.go       |  7 ++-
entities/board/op_add_item_entity.go      | 10 +++--
entities/board/op_add_item_entity_test.go |  2 
entities/board/op_set_description.go      |  2 
entities/board/op_set_metadata.go         | 21 ++++++++++
entities/board/op_set_title.go            |  2 
entities/board/operation.go               |  5 ++
entities/board/snapshot.go                |  5 ++
entities/bug/bug.go                       | 12 +++---
entities/bug/op_add_comment.go            |  2 
entities/bug/op_create_test.go            |  2 
entities/bug/op_edit_comment.go           |  4 +-
entities/bug/op_label_change.go           |  6 +-
entities/bug/op_set_metadata.go           |  2 
entities/bug/op_set_status.go             |  4 +-
entities/bug/op_set_title.go              |  2 
entity/dag/example_test.go                |  6 +-
entity/dag/interface.go                   | 48 +++++++++++++----------
entity/interface.go                       |  1 
34 files changed, 266 insertions(+), 143 deletions(-)

Detailed changes

cache/board_cache.go 🔗

@@ -11,7 +11,7 @@ import (
 
 // BoardCache is a wrapper around a Board. It provides multiple functions:
 //
-// 1. Provide a higher level API to use than the raw API from Board.
+// 1. Provides a higher level API to use than the raw API from Board.
 // 2. Maintain an up-to-date Snapshot available.
 // 3. Deal with concurrency.
 type BoardCache struct {
@@ -24,7 +24,7 @@ func NewBoardCache(b *board.Board, repo repository.ClockedRepo, getUserIdentity
 			repo:            repo,
 			entityUpdated:   entityUpdated,
 			getUserIdentity: getUserIdentity,
-			entity:          &withSnapshot[*board.Snapshot, board.Operation]{Interface: b},
+			entity:          newWithSnapshot[*board.Snapshot, board.Operation](b),
 		},
 	}
 }

cache/board_subcache.go 🔗

@@ -31,6 +31,7 @@ func NewRepoCacheBoard(repo repository.ClockedRepo,
 		ReadWithResolver:    board.ReadWithResolver,
 		ReadAllWithResolver: board.ReadAllWithResolver,
 		Remove:              board.Remove,
+		RemoveAll:           board.RemoveAll,
 		MergeAll:            board.MergeAll,
 	}
 
@@ -44,6 +45,15 @@ func NewRepoCacheBoard(repo repository.ClockedRepo,
 	return &RepoCacheBoard{SubCache: sc}
 }
 
+// ResolveBoardCreateMetadata retrieve a board that has the exact given metadata on its Create operation, that is, the first operation.
+// It fails if multiple bugs match.
+func (c *RepoCacheBoard) ResolveBoardCreateMetadata(key string, value string) (*BoardCache, error) {
+	return c.ResolveMatcher(func(excerpt *BoardExcerpt) bool {
+		return excerpt.CreateMetadata[key] == value
+	})
+}
+
+// ResolveColumn finds the board and column id that matches the given prefix.
 func (c *RepoCacheBoard) ResolveColumn(prefix string) (*BoardCache, entity.CombinedId, error) {
 	boardPrefix, _ := entity.SeparateIds(prefix)
 	boardCandidate := make([]entity.Id, 0, 5)
@@ -88,6 +98,10 @@ func (c *RepoCacheBoard) ResolveColumn(prefix string) (*BoardCache, entity.Combi
 	return matchingBoard, matchingColumnId, nil
 }
 
+// TODO: resolve item?
+
+// New creates a new board.
+// The new board is written in the repository (commit)
 func (c *RepoCacheBoard) New(title, description string, columns []string) (*BoardCache, *board.CreateOperation, error) {
 	author, err := c.getUserIdentity()
 	if err != nil {
@@ -97,11 +111,13 @@ func (c *RepoCacheBoard) New(title, description string, columns []string) (*Boar
 	return c.NewRaw(author, time.Now().Unix(), title, description, columns, nil)
 }
 
+// NewDefaultColumns creates a new board with the default columns.
+// The new board is written in the repository (commit)
 func (c *RepoCacheBoard) NewDefaultColumns(title, description string) (*BoardCache, *board.CreateOperation, error) {
 	return c.New(title, description, board.DefaultColumns)
 }
 
-// NewRaw create a new board with the given title, description and columns.
+// NewRaw create a new board with the given title, description, and columns.
 // The new board is written in the repository (commit).
 func (c *RepoCacheBoard) NewRaw(author identity.Interface, unixTime int64, title, description string, columns []string, metadata map[string]string) (*BoardCache, *board.CreateOperation, error) {
 	b, op, err := board.Create(author, unixTime, title, description, columns, metadata)

cache/bug_cache.go 🔗

@@ -28,7 +28,7 @@ func NewBugCache(b *bug.Bug, repo repository.ClockedRepo, getUserIdentity getUse
 			repo:            repo,
 			entityUpdated:   entityUpdated,
 			getUserIdentity: getUserIdentity,
-			entity:          &withSnapshot[*bug.Snapshot, bug.Operation]{Interface: b},
+			entity:          newWithSnapshot[*bug.Snapshot, bug.Operation](b),
 		},
 	}
 }

cache/cached.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/git-bug/git-bug/util/lamport"
 )
 
+var _ dag.ReadOnly[dag.Snapshot, dag.Operation] = &CachedEntityBase[dag.Snapshot, dag.Operation]{}
 var _ CacheEntity = &CachedEntityBase[dag.Snapshot, dag.Operation]{}
 
 // CachedEntityBase provide the base function of an entity managed by the cache.
@@ -18,7 +19,7 @@ type CachedEntityBase[SnapT dag.Snapshot, OpT dag.Operation] struct {
 	getUserIdentity getUserIdentityFunc
 
 	mu     sync.RWMutex
-	entity dag.Interface[SnapT, OpT]
+	entity dag.ReadWrite[SnapT, OpT]
 }
 
 func (e *CachedEntityBase[SnapT, OpT]) Id() entity.Id {
@@ -28,7 +29,7 @@ func (e *CachedEntityBase[SnapT, OpT]) Id() entity.Id {
 func (e *CachedEntityBase[SnapT, OpT]) Snapshot() SnapT {
 	e.mu.RLock()
 	defer e.mu.RUnlock()
-	return e.entity.Compile()
+	return e.entity.Snapshot()
 }
 
 func (e *CachedEntityBase[SnapT, OpT]) notifyUpdated() error {
@@ -109,3 +110,7 @@ func (e *CachedEntityBase[SnapT, OpT]) EditLamportTime() lamport.Time {
 func (e *CachedEntityBase[SnapT, OpT]) FirstOp() OpT {
 	return e.entity.FirstOp()
 }
+
+func (e *CachedEntityBase[SnapT, OpT]) LastOp() OpT {
+	return e.entity.LastOp()
+}

cache/repo_cache.go 🔗

@@ -105,12 +105,12 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan
 		identity.Interface(nil): entity.ResolverFunc[*IdentityCache](c.identities.Resolve),
 		&IdentityCache{}:        entity.ResolverFunc[*IdentityCache](c.identities.Resolve),
 		&IdentityExcerpt{}:      entity.ResolverFunc[*IdentityExcerpt](c.identities.ResolveExcerpt),
-		bug.Interface(nil):      entity.ResolverFunc[*BugCache](c.bugs.Resolve),
+		bug.ReadOnly(nil):       entity.ResolverFunc[*BugCache](c.bugs.Resolve),
 		&bug.Bug{}:              entity.ResolverFunc[*BugCache](c.bugs.Resolve),
 		&BugCache{}:             entity.ResolverFunc[*BugCache](c.bugs.Resolve),
 		&BugExcerpt{}:           entity.ResolverFunc[*BugExcerpt](c.bugs.ResolveExcerpt),
-		board.Interface(nil):    entity.ResolverFunc[*BoardCache](c.boards.Resolve),
-		&bug.Bug{}:              entity.ResolverFunc[*BoardCache](c.boards.Resolve),
+		board.ReadOnly(nil):     entity.ResolverFunc[*BoardCache](c.boards.Resolve),
+		&board.Board{}:          entity.ResolverFunc[*BoardCache](c.boards.Resolve),
 		&BoardCache{}:           entity.ResolverFunc[*BoardCache](c.boards.Resolve),
 		&BoardExcerpt{}:         entity.ResolverFunc[*BoardExcerpt](c.boards.ResolveExcerpt),
 	}
@@ -249,9 +249,9 @@ type BuildEvent struct {
 	Typename string
 	// Event is the type of the event.
 	Event BuildEventType
-	// Total is the total number of element being built. Set if Event is BuildEventStarted.
+	// Total is the total number of elements being built. Set if Event is BuildEventStarted.
 	Total int64
-	// Progress is the current count of processed element. Set if Event is BuildEventProgress.
+	// Progress is the current count of processed elements. Set if Event is BuildEventProgress.
 	Progress int64
 }
 

cache/subcache.go 🔗

@@ -40,6 +40,7 @@ type Actions[EntityT entity.Interface] struct {
 
 var _ cacheMgmt = &SubCache[entity.Interface, Excerpt, CacheEntity]{}
 
+// SubCache provides caching management for one type of Entity.
 type SubCache[EntityT entity.Interface, ExcerptT Excerpt, CacheT CacheEntity] struct {
 	repo      repository.ClockedRepo
 	resolvers func() entity.Resolvers
@@ -348,7 +349,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) AllIds() []entity.Id {
 	return result
 }
 
-// Resolve retrieve an entity matching the exact given id
+// Resolve retrieves an entity matching the exact given id
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) Resolve(id entity.Id) (CacheT, error) {
 	sc.mu.RLock()
 	cached, ok := sc.cached[id]
@@ -376,14 +377,14 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Resolve(id entity.Id) (CacheT, er
 	return cached, nil
 }
 
-// ResolvePrefix retrieve an entity matching an id prefix. It fails if multiple
-// entities match.
+// ResolvePrefix retrieves an entity matching an id prefix. It fails if multiple entities match.
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolvePrefix(prefix string) (CacheT, error) {
 	return sc.ResolveMatcher(func(excerpt ExcerptT) bool {
 		return excerpt.Id().HasPrefix(prefix)
 	})
 }
 
+// ResolveMatcher retrieves an entity matching the given matched. It fails if multiple entities match.
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveMatcher(f func(ExcerptT) bool) (CacheT, error) {
 	id, err := sc.resolveMatcher(f)
 	if err != nil {
@@ -405,14 +406,14 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerpt(id entity.Id) (Exc
 	return excerpt, nil
 }
 
-// ResolveExcerptPrefix retrieve an Excerpt matching an id prefix. It fails if multiple
-// entities match.
+// ResolveExcerptPrefix retrieve an Excerpt matching an id prefix. It fails if multiple entities match.
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerptPrefix(prefix string) (ExcerptT, error) {
 	return sc.ResolveExcerptMatcher(func(excerpt ExcerptT) bool {
 		return excerpt.Id().HasPrefix(prefix)
 	})
 }
 
+// ResolveExcerptMatcher retrieve an Excerpt matching a given matcher. It fails if multiple entities match.
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerptMatcher(f func(ExcerptT) bool) (ExcerptT, error) {
 	id, err := sc.resolveMatcher(f)
 	if err != nil {
@@ -421,6 +422,23 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) ResolveExcerptMatcher(f func(Exce
 	return sc.ResolveExcerpt(id)
 }
 
+// QueryExcerptMatcher finds all the Excerpt matching the given matcher.
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) QueryExcerptMatcher(f func(ExcerptT) bool) ([]ExcerptT, error) {
+	ids, err := sc.queryMatcher(f)
+	if err != nil {
+		return nil, err
+	}
+	res := make([]ExcerptT, len(ids))
+	for i, id := range ids {
+		res[i], err = sc.ResolveExcerpt(id)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return res, nil
+}
+
+// resolveMatcher finds the id of the entity matching the given matcher. It fails if multiple entities match.
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) resolveMatcher(f func(ExcerptT) bool) (entity.Id, error) {
 	sc.mu.RLock()
 	defer sc.mu.RUnlock()
@@ -445,6 +463,25 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) resolveMatcher(f func(ExcerptT) b
 	return matching[0], nil
 }
 
+// queryMatcher find the ids of all the entities matching the given matcher.
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) queryMatcher(f func(ExcerptT) bool) ([]entity.Id, error) {
+	// TODO: this might use some pagination, or better: a go1.23 iterator?
+
+	sc.mu.RLock()
+	defer sc.mu.RUnlock()
+
+	// preallocate but empty
+	matching := make([]entity.Id, 0, 5)
+
+	for _, excerpt := range sc.excerpts {
+		if f(excerpt) {
+			matching = append(matching, excerpt.Id())
+		}
+	}
+
+	return matching, nil
+}
+
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) add(e EntityT) (CacheT, error) {
 	sc.mu.Lock()
 	if _, has := sc.cached[e.Id()]; has {

cache/with_snapshot.go 🔗

@@ -7,20 +7,24 @@ import (
 	"github.com/git-bug/git-bug/repository"
 )
 
-var _ dag.Interface[dag.Snapshot, dag.OperationWithApply[dag.Snapshot]] = &withSnapshot[dag.Snapshot, dag.OperationWithApply[dag.Snapshot]]{}
+var _ dag.ReadWrite[dag.Snapshot, dag.OperationWithApply[dag.Snapshot]] = &withSnapshot[dag.Snapshot, dag.OperationWithApply[dag.Snapshot]]{}
 
 // withSnapshot encapsulate an entity and maintain a snapshot efficiently.
 type withSnapshot[SnapT dag.Snapshot, OpT dag.OperationWithApply[SnapT]] struct {
-	dag.Interface[SnapT, OpT]
+	dag.ReadWrite[SnapT, OpT]
 	mu   sync.Mutex
 	snap *SnapT
 }
 
-func (ws *withSnapshot[SnapT, OpT]) Compile() SnapT {
+func newWithSnapshot[SnapT dag.Snapshot, OpT dag.OperationWithApply[SnapT]](readWrite dag.ReadWrite[SnapT, OpT]) *withSnapshot[SnapT, OpT] {
+	return &withSnapshot[SnapT, OpT]{ReadWrite: readWrite}
+}
+
+func (ws *withSnapshot[SnapT, OpT]) Snapshot() SnapT {
 	ws.mu.Lock()
 	defer ws.mu.Unlock()
 	if ws.snap == nil {
-		snap := ws.Interface.Compile()
+		snap := ws.ReadWrite.Snapshot()
 		ws.snap = &snap
 	}
 	return *ws.snap
@@ -31,7 +35,7 @@ func (ws *withSnapshot[SnapT, OpT]) Append(op OpT) {
 	ws.mu.Lock()
 	defer ws.mu.Unlock()
 
-	ws.Interface.Append(op)
+	ws.ReadWrite.Append(op)
 
 	if ws.snap == nil {
 		return
@@ -46,7 +50,7 @@ func (ws *withSnapshot[SnapT, OpT]) Commit(repo repository.ClockedRepo) error {
 	ws.mu.Lock()
 	defer ws.mu.Unlock()
 
-	err := ws.Interface.Commit(repo)
+	err := ws.ReadWrite.Commit(repo)
 	if err != nil {
 		ws.snap = nil
 		return err

commands/board/board_addbug.go 🔗

@@ -1,15 +1,10 @@
 package boardcmd
 
 import (
-	"fmt"
-	"strconv"
-
 	"github.com/spf13/cobra"
 
 	bugcmd "github.com/git-bug/git-bug/commands/bug"
 	"github.com/git-bug/git-bug/commands/execenv"
-	_select "github.com/git-bug/git-bug/commands/select"
-	"github.com/git-bug/git-bug/entity"
 )
 
 type boardAddBugOptions struct {
@@ -41,46 +36,22 @@ func newBoardAddBugCommand() *cobra.Command {
 }
 
 func runBoardAddBug(env *execenv.Env, opts boardAddBugOptions, args []string) error {
-	board, args, err := ResolveSelected(env.Backend, args)
+	b, columnId, err := resolveColumnId(env, opts.column, args)
 	if err != nil {
 		return err
 	}
 
-	var columnId entity.CombinedId
-
-	switch {
-	case err == nil:
-		// try to parse as column number
-		index, err := strconv.Atoi(opts.column)
-		if err == nil {
-			if index-1 >= 0 && index-1 < len(board.Snapshot().Columns) {
-				columnId = board.Snapshot().Columns[index-1].CombinedId
-			} else {
-				return fmt.Errorf("invalid column")
-			}
-		}
-		fallthrough // could be an Id
-	case _select.IsErrNoValidId(err):
-		board, columnId, err = env.Backend.Boards().ResolveColumn(opts.column)
-		if err != nil {
-			return err
-		}
-	default:
-		// actual error
-		return err
-	}
-
 	bug, _, err := bugcmd.ResolveSelected(env.Backend, args)
 	if err != nil {
 		return err
 	}
 
-	id, _, err := board.AddItemEntity(columnId, bug)
+	id, _, err := b.AddItemEntity(columnId, bug)
 	if err != nil {
 		return err
 	}
 
 	env.Out.Printf("%s created\n", id.Human())
 
-	return board.Commit()
+	return b.Commit()
 }

commands/board/board_adddraft.go 🔗

@@ -6,6 +6,7 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"github.com/git-bug/git-bug/cache"
 	buginput "github.com/git-bug/git-bug/commands/bug/input"
 	"github.com/git-bug/git-bug/commands/execenv"
 	_select "github.com/git-bug/git-bug/commands/select"
@@ -52,29 +53,8 @@ func newBoardAddDraftCommand() *cobra.Command {
 }
 
 func runBoardAddDraft(env *execenv.Env, opts boardAddDraftOptions, args []string) error {
-	b, args, err := ResolveSelected(env.Backend, args)
-
-	var columnId entity.CombinedId
-
-	switch {
-	case err == nil:
-		// try to parse as column number
-		index, err := strconv.Atoi(opts.column)
-		if err == nil {
-			if index-1 >= 0 && index-1 < len(b.Snapshot().Columns) {
-				columnId = b.Snapshot().Columns[index-1].CombinedId
-			} else {
-				return fmt.Errorf("invalid column")
-			}
-		}
-		fallthrough // could be an Id
-	case _select.IsErrNoValidId(err):
-		b, columnId, err = env.Backend.Boards().ResolveColumn(opts.column)
-		if err != nil {
-			return err
-		}
-	default:
-		// actual error
+	b, columnId, err := resolveColumnId(env, opts.column, args)
+	if err != nil {
 		return err
 	}
 
@@ -106,3 +86,27 @@ func runBoardAddDraft(env *execenv.Env, opts boardAddDraftOptions, args []string
 
 	return b.Commit()
 }
+
+func resolveColumnId(env *execenv.Env, column string, args []string) (*cache.BoardCache, entity.CombinedId, error) {
+	if column == "" {
+		return nil, entity.UnsetCombinedId, fmt.Errorf("flag --column is required")
+	}
+
+	b, args, err := ResolveSelected(env.Backend, args)
+
+	switch {
+	case err == nil:
+		// we have a pre-selected board, try to parse as column number
+		index, err := strconv.Atoi(column)
+		if err == nil && index-1 >= 0 && index-1 < len(b.Snapshot().Columns) {
+			return b, b.Snapshot().Columns[index-1].CombinedId, nil
+		}
+		fallthrough // could be an Id
+	case _select.IsErrNoValidId(err):
+		return env.Backend.Boards().ResolveColumn(column)
+
+	default:
+		// actual error
+		return nil, entity.UnsetCombinedId, err
+	}
+}

commands/bug/completion.go 🔗

@@ -12,7 +12,7 @@ import (
 	"github.com/git-bug/git-bug/entities/common"
 )
 
-// BugCompletion complete a bug id
+// BugCompletion perform bug completion (id, title) on the environment backend
 func BugCompletion(env *execenv.Env) completion.ValidArgsFunction {
 	return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) {
 		if err := execenv.LoadBackend(env)(cmd, args); err != nil {
@@ -26,6 +26,7 @@ func BugCompletion(env *execenv.Env) completion.ValidArgsFunction {
 	}
 }
 
+// BugWithBackend perform bug completion (id, title) on the given backend
 func BugWithBackend(backend *cache.RepoCache, toComplete string) (completions []string, directives cobra.ShellCompDirective) {
 	for _, id := range backend.Bugs().AllIds() {
 		if strings.Contains(id.String(), strings.TrimSpace(toComplete)) {

commands/cmdjson/board.go 🔗

@@ -59,7 +59,7 @@ func NewBoardColumn(column *board.Column) BoardColumn {
 		case *board.Draft:
 			jsonColumn.Items[j] = NewBoardDraftItem(item)
 		case *board.BugItem:
-			jsonColumn.Items[j] = NewBugSnapshot(item.Bug.Compile())
+			jsonColumn.Items[j] = NewBoardBugItem(item)
 		default:
 			panic("unknown item type")
 		}
@@ -68,6 +68,7 @@ func NewBoardColumn(column *board.Column) BoardColumn {
 }
 
 type BoardDraftItem struct {
+	Type    string   `json:"type"`
 	Id      string   `json:"id"`
 	HumanId string   `json:"human_id"`
 	Author  Identity `json:"author"`
@@ -77,14 +78,34 @@ type BoardDraftItem struct {
 
 func NewBoardDraftItem(item *board.Draft) BoardDraftItem {
 	return BoardDraftItem{
+		Type:    "draft",
 		Id:      item.CombinedId().String(),
 		HumanId: item.CombinedId().Human(),
-		Author:  NewIdentity(item.Author),
-		Title:   item.Title,
+		Author:  NewIdentity(item.Author()),
+		Title:   item.Title(),
 		Message: item.Message,
 	}
 }
 
+type BoardBugItem struct {
+	Type    string   `json:"type"`
+	Id      string   `json:"id"`
+	HumanId string   `json:"human_id"`
+	Author  Identity `json:"author"`
+	BugId   string   `json:"bug_id"`
+}
+
+func NewBoardBugItem(item *board.BugItem) BoardBugItem {
+	return BoardBugItem{
+		Type:    "bug",
+		Id:      item.CombinedId().String(),
+		HumanId: item.CombinedId().Human(),
+		Author:  NewIdentity(item.Author()),
+		BugId:   item.Bug.Snapshot().Id().String(),
+		// TODO: add more?
+	}
+}
+
 type BoardExcerpt struct {
 	Id         string `json:"id"`
 	HumanId    string `json:"human_id"`

entities/board/board.go 🔗

@@ -11,7 +11,9 @@ import (
 	"github.com/git-bug/git-bug/repository"
 )
 
-var _ Interface = &Board{}
+var _ ReadOnly = &Board{}
+var _ ReadWrite = &Board{}
+var _ entity.Interface = &Board{}
 
 // 1: original format
 const formatVersion = 1
@@ -28,9 +30,8 @@ var def = dag.Definition{
 
 var ClockLoader = dag.ClockLoader(def)
 
-type Interface interface {
-	dag.Interface[*Snapshot, Operation]
-}
+type ReadOnly dag.ReadOnly[*Snapshot, Operation]
+type ReadWrite dag.ReadWrite[*Snapshot, Operation]
 
 // Board holds the data of a project board.
 type Board struct {
@@ -115,8 +116,8 @@ func (board *Board) Operations() []Operation {
 	return result
 }
 
-// Compile a board in an easily usable snapshot
-func (board *Board) Compile() *Snapshot {
+// Snapshot compiles a board in an easily usable snapshot
+func (board *Board) Snapshot() *Snapshot {
 	snap := &Snapshot{
 		id: board.Id(),
 	}

entities/board/board_actions.go 🔗

@@ -33,7 +33,13 @@ func MergeAll(repo repository.ClockedRepo, resolvers entity.Resolvers, remote st
 	return dag.MergeAll(def, wrapper, repo, resolvers, remote, mergeAuthor)
 }
 
-// Remove will remove a local bug from its entity.Id
+// Remove will remove a local board from its entity.Id
 func Remove(repo repository.ClockedRepo, id entity.Id) error {
 	return dag.Remove(def, repo, id)
 }
+
+// RemoveAll will remove all local boards.
+// RemoveAll is idempotent.
+func RemoveAll(repo repository.ClockedRepo) error {
+	return dag.RemoveAll(def, repo)
+}

entities/board/item_draft.go 🔗

@@ -16,8 +16,8 @@ type Draft struct {
 	// of the Operation that created the Draft
 	combinedId entity.CombinedId
 
-	Author  identity.Interface
-	Title   string
+	author  identity.Interface
+	title   string
 	Message string
 
 	// Creation time of the comment.
@@ -33,6 +33,14 @@ func (d *Draft) CombinedId() entity.CombinedId {
 	return d.combinedId
 }
 
+func (d *Draft) Author() identity.Interface {
+	return d.author
+}
+
+func (d *Draft) Title() string {
+	return d.title
+}
+
 // FormatTimeRel format the UnixTime of the comment for human consumption
 func (d *Draft) FormatTimeRel() string {
 	return humanize.Time(d.unixTime.Time())

entities/board/item_entity.go 🔗

@@ -2,14 +2,16 @@ package board
 
 import (
 	"github.com/git-bug/git-bug/entities/bug"
+	"github.com/git-bug/git-bug/entities/identity"
 	"github.com/git-bug/git-bug/entity"
+	"github.com/git-bug/git-bug/entity/dag"
 )
 
 var _ Item = &BugItem{}
 
 type BugItem struct {
 	combinedId entity.CombinedId
-	Bug        bug.Interface
+	Bug        dag.CompileTo[*bug.Snapshot]
 }
 
 func (e *BugItem) CombinedId() entity.CombinedId {
@@ -19,3 +21,11 @@ func (e *BugItem) CombinedId() entity.CombinedId {
 	}
 	return e.combinedId
 }
+
+func (e *BugItem) Author() identity.Interface {
+	return e.Bug.Snapshot().Author
+}
+
+func (e *BugItem) Title() string {
+	return e.Bug.Snapshot().Title
+}

entities/board/op_add_item_draft.go 🔗

@@ -62,12 +62,13 @@ func (op *AddItemDraftOperation) Apply(snapshot *Snapshot) {
 	// Recreate the combined Id to match on
 	combinedId := entity.CombineIds(snapshot.Id(), op.ColumnId)
 
+	// search the column
 	for _, column := range snapshot.Columns {
 		if column.CombinedId == combinedId {
 			column.Items = append(column.Items, &Draft{
 				combinedId: entity.CombineIds(snapshot.id, op.Id()),
-				Author:     op.Author(),
-				Title:      op.Title,
+				author:     op.Author(),
+				title:      op.Title,
 				Message:    op.Message,
 				unixTime:   timestamp.Timestamp(op.UnixTime),
 			})
@@ -89,7 +90,7 @@ func NewAddItemDraftOp(author identity.Interface, unixTime int64, columnId entit
 }
 
 // AddItemDraft is a convenience function to add a draft item to a Board
-func AddItemDraft(b Interface, author identity.Interface, unixTime int64, columnId entity.Id, title, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *AddItemDraftOperation, error) {
+func AddItemDraft(b ReadWrite, author identity.Interface, unixTime int64, columnId entity.Id, title, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *AddItemDraftOperation, error) {
 	op := NewAddItemDraftOp(author, unixTime, columnId, title, message, files)
 	for key, val := range metadata {
 		op.SetMetadata(key, val)

entities/board/op_add_item_entity.go 🔗

@@ -54,19 +54,21 @@ func (op *AddItemEntityOperation) Validate() error {
 
 func (op *AddItemEntityOperation) Apply(snapshot *Snapshot) {
 	if op.entity == nil {
+		// entity was not found while unmarshalling/resolving
 		return
 	}
 
 	// Recreate the combined Id to match on
 	combinedId := entity.CombineIds(snapshot.Id(), op.ColumnId)
 
+	// search the column
 	for _, column := range snapshot.Columns {
 		if column.CombinedId == combinedId {
 			switch op.EntityType {
 			case EntityTypeBug:
 				column.Items = append(column.Items, &BugItem{
-					combinedId: entity.CombineIds(snapshot.Id(), op.entity.Id()),
-					Bug:        op.entity.(bug.Interface),
+					combinedId: entity.CombineIds(snapshot.Id(), op.Id()),
+					Bug:        op.entity.(dag.CompileTo[*bug.Snapshot]),
 				})
 			}
 			snapshot.addParticipant(op.Author())
@@ -76,7 +78,7 @@ func (op *AddItemEntityOperation) Apply(snapshot *Snapshot) {
 }
 
 func NewAddItemEntityOp(author identity.Interface, unixTime int64, columnId entity.Id, entityType ItemEntityType, e entity.Interface) *AddItemEntityOperation {
-	// Note: due to import cycle we are not able to properly check the type of the entity here;
+	// Note: due to import cycle we are not able to sanity check the type of the entity here;
 	// proceed with caution!
 	return &AddItemEntityOperation{
 		OpBase:     dag.NewOpBase(AddItemEntityOp, author, unixTime),
@@ -88,7 +90,7 @@ func NewAddItemEntityOp(author identity.Interface, unixTime int64, columnId enti
 }
 
 // AddItemEntity is a convenience function to add an entity item to a Board
-func AddItemEntity(b Interface, author identity.Interface, unixTime int64, columnId entity.Id, entityType ItemEntityType, e entity.Interface, metadata map[string]string) (entity.CombinedId, *AddItemEntityOperation, error) {
+func AddItemEntity(b ReadWrite, author identity.Interface, unixTime int64, columnId entity.Id, entityType ItemEntityType, e entity.Interface, metadata map[string]string) (entity.CombinedId, *AddItemEntityOperation, error) {
 	op := NewAddItemEntityOp(author, unixTime, columnId, entityType, e)
 	for key, val := range metadata {
 		op.SetMetadata(key, val)

entities/board/op_add_item_entity_test.go 🔗

@@ -20,6 +20,6 @@ func TestAddItemEntityOpSerialize(t *testing.T) {
 			&bug.Bug{}: entity.MakeResolver(b),
 		}
 
-		return NewAddItemEntityOp(author, unixTime, "foo", b), resolvers
+		return NewAddItemEntityOp(author, unixTime, "foo", EntityTypeBug, b), resolvers
 	})
 }

entities/board/op_set_description.go 🔗

@@ -56,7 +56,7 @@ func NewSetDescriptionOp(author identity.Interface, unixTime int64, description
 }
 
 // SetDescription is a convenience function to change a board description
-func SetDescription(b Interface, author identity.Interface, unixTime int64, description string, metadata map[string]string) (*SetDescriptionOperation, error) {
+func SetDescription(b ReadWrite, author identity.Interface, unixTime int64, description string, metadata map[string]string) (*SetDescriptionOperation, error) {
 	var lastDescriptionOp *SetDescriptionOperation
 	for _, op := range b.Operations() {
 		switch op := op.(type) {

entities/board/op_set_metadata.go 🔗

@@ -0,0 +1,21 @@
+package board
+
+import (
+	"github.com/git-bug/git-bug/entities/identity"
+	"github.com/git-bug/git-bug/entity"
+	"github.com/git-bug/git-bug/entity/dag"
+)
+
+func NewSetMetadataOp(author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *dag.SetMetadataOperation[*Snapshot] {
+	return dag.NewSetMetadataOp[*Snapshot](SetMetadataOp, author, unixTime, target, newMetadata)
+}
+
+// SetMetadata is a convenience function to add metadata on another operation
+func SetMetadata(b ReadWrite, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*Snapshot], error) {
+	op := NewSetMetadataOp(author, unixTime, target, newMetadata)
+	if err := op.Validate(); err != nil {
+		return nil, err
+	}
+	b.Append(op)
+	return op, nil
+}

entities/board/op_set_title.go 🔗

@@ -56,7 +56,7 @@ func NewSetTitleOp(author identity.Interface, unixTime int64, title string, was
 }
 
 // SetTitle is a convenience function to change a board title
-func SetTitle(b Interface, author identity.Interface, unixTime int64, title string, metadata map[string]string) (*SetTitleOperation, error) {
+func SetTitle(b ReadWrite, author identity.Interface, unixTime int64, title string, metadata map[string]string) (*SetTitleOperation, error) {
 	var lastTitleOp *SetTitleOperation
 	for _, op := range b.Operations() {
 		switch op := op.(type) {

entities/board/operation.go 🔗

@@ -15,6 +15,7 @@ type OperationType dag.OperationType
 const (
 	_ dag.OperationType = iota
 	CreateOp
+	SetMetadataOp
 	SetTitleOp
 	SetDescriptionOp
 	AddItemEntityOp
@@ -45,6 +46,8 @@ func operationUnmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (dag.
 	switch t.OperationType {
 	case CreateOp:
 		op = &CreateOperation{}
+	case SetMetadataOp:
+		op = &dag.SetMetadataOperation[*Snapshot]{}
 	case SetTitleOp:
 		op = &SetTitleOperation{}
 	case SetDescriptionOp:
@@ -66,7 +69,7 @@ func operationUnmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (dag.
 	case *AddItemEntityOperation:
 		switch op.EntityType {
 		case EntityTypeBug:
-			op.entity, err = entity.Resolve[bug.Interface](resolvers, op.EntityId)
+			op.entity, err = entity.Resolve[bug.ReadOnly](resolvers, op.EntityId)
 		default:
 			return nil, fmt.Errorf("unknown entity type")
 		}

entities/board/snapshot.go 🔗

@@ -20,6 +20,10 @@ type Column struct {
 
 type Item interface {
 	CombinedId() entity.CombinedId
+
+	Author() identity.Interface
+	Title() string
+
 	// TODO: all items have status?
 	// Status() common.Status
 }
@@ -106,6 +110,7 @@ func (snap *Snapshot) HasAnyParticipant(ids ...entity.Id) bool {
 	return false
 }
 
+// ItemCount returns the number of items (draft, entity) in the board.
 func (snap *Snapshot) ItemCount() int {
 	var count int
 	for _, column := range snap.Columns {

entities/bug/bug.go 🔗

@@ -11,7 +11,8 @@ import (
 	"github.com/git-bug/git-bug/repository"
 )
 
-var _ Interface = &Bug{}
+var _ ReadOnly = &Bug{}
+var _ ReadWrite = &Bug{}
 var _ entity.Interface = &Bug{}
 
 // 1: original format
@@ -32,9 +33,8 @@ var def = dag.Definition{
 
 var ClockLoader = dag.ClockLoader(def)
 
-type Interface interface {
-	dag.Interface[*Snapshot, Operation]
-}
+type ReadOnly dag.ReadOnly[*Snapshot, Operation]
+type ReadWrite dag.ReadWrite[*Snapshot, Operation]
 
 // Bug holds the data of a bug thread, organized in a way close to
 // how it will be persisted inside Git. This is the data structure
@@ -123,8 +123,8 @@ func (bug *Bug) Operations() []Operation {
 	return result
 }
 
-// Compile a bug in an easily usable snapshot
-func (bug *Bug) Compile() *Snapshot {
+// Snapshot compiles a bug in an easily usable snapshot
+func (bug *Bug) Snapshot() *Snapshot {
 	snap := &Snapshot{
 		id:     bug.Id(),
 		Status: common.OpenStatus,

entities/bug/op_add_comment.go 🔗

@@ -89,7 +89,7 @@ type AddCommentTimelineItem struct {
 func (a *AddCommentTimelineItem) IsAuthored() {}
 
 // AddComment is a convenience function to add a comment to a bug
-func AddComment(b Interface, author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *AddCommentOperation, error) {
+func AddComment(b ReadWrite, author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *AddCommentOperation, error) {
 	op := NewAddCommentOp(author, unixTime, message, files)
 	for key, val := range metadata {
 		op.SetMetadata(key, val)

entities/bug/op_create_test.go 🔗

@@ -26,7 +26,7 @@ func TestCreate(t *testing.T) {
 	require.Equal(t, "message", op.Message)
 
 	// Create generate the initial operation and create a new timeline item
-	snap := b.Compile()
+	snap := b.Snapshot()
 	require.Equal(t, common.OpenStatus, snap.Status)
 	require.Equal(t, rene, snap.Author)
 	require.Equal(t, "title", snap.Title)

entities/bug/op_edit_comment.go 🔗

@@ -117,7 +117,7 @@ func NewEditCommentOp(author identity.Interface, unixTime int64, target entity.I
 }
 
 // EditComment is a convenience function to apply the operation
-func EditComment(b Interface, author identity.Interface, unixTime int64, target entity.Id, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *EditCommentOperation, error) {
+func EditComment(b ReadWrite, author identity.Interface, unixTime int64, target entity.Id, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *EditCommentOperation, error) {
 	op := NewEditCommentOp(author, unixTime, target, message, files)
 	for key, val := range metadata {
 		op.SetMetadata(key, val)
@@ -130,7 +130,7 @@ func EditComment(b Interface, author identity.Interface, unixTime int64, target
 }
 
 // EditCreateComment is a convenience function to edit the body of a bug (the first comment)
-func EditCreateComment(b Interface, author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *EditCommentOperation, error) {
+func EditCreateComment(b ReadWrite, author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *EditCommentOperation, error) {
 	createOp := b.FirstOp().(*CreateOperation)
 	return EditComment(b, author, unixTime, createOp.Id(), message, files, metadata)
 }

entities/bug/op_label_change.go 🔗

@@ -121,11 +121,11 @@ func (l LabelChangeTimelineItem) CombinedId() entity.CombinedId {
 func (l *LabelChangeTimelineItem) IsAuthored() {}
 
 // ChangeLabels is a convenience function to change labels on a bug
-func ChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string, metadata map[string]string) ([]LabelChangeResult, *LabelChangeOperation, error) {
+func ChangeLabels(b ReadWrite, author identity.Interface, unixTime int64, add, remove []string, metadata map[string]string) ([]LabelChangeResult, *LabelChangeOperation, error) {
 	var added, removed []common.Label
 	var results []LabelChangeResult
 
-	snap := b.Compile()
+	snap := b.Snapshot()
 
 	for _, str := range add {
 		label := common.Label(str)
@@ -187,7 +187,7 @@ func ChangeLabels(b Interface, author identity.Interface, unixTime int64, add, r
 // responsible for what you are doing. In the general case, you want to use ChangeLabels instead.
 // The intended use of this function is to allow importers to create legal but unexpected label changes,
 // like removing a label with no information of when it was added before.
-func ForceChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string, metadata map[string]string) (*LabelChangeOperation, error) {
+func ForceChangeLabels(b ReadWrite, author identity.Interface, unixTime int64, add, remove []string, metadata map[string]string) (*LabelChangeOperation, error) {
 	added := make([]common.Label, len(add))
 	for i, str := range add {
 		added[i] = common.Label(str)

entities/bug/op_set_metadata.go 🔗

@@ -11,7 +11,7 @@ func NewSetMetadataOp(author identity.Interface, unixTime int64, target entity.I
 }
 
 // SetMetadata is a convenience function to add metadata on another operation
-func SetMetadata(b Interface, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*Snapshot], error) {
+func SetMetadata(b ReadWrite, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*Snapshot], error) {
 	op := NewSetMetadataOp(author, unixTime, target, newMetadata)
 	if err := op.Validate(); err != nil {
 		return nil, err

entities/bug/op_set_status.go 🔗

@@ -72,7 +72,7 @@ func (s SetStatusTimelineItem) CombinedId() entity.CombinedId {
 func (s *SetStatusTimelineItem) IsAuthored() {}
 
 // Open is a convenience function to change a bugs state to Open
-func Open(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*SetStatusOperation, error) {
+func Open(b ReadWrite, author identity.Interface, unixTime int64, metadata map[string]string) (*SetStatusOperation, error) {
 	op := NewSetStatusOp(author, unixTime, common.OpenStatus)
 	for key, value := range metadata {
 		op.SetMetadata(key, value)
@@ -85,7 +85,7 @@ func Open(b Interface, author identity.Interface, unixTime int64, metadata map[s
 }
 
 // Close is a convenience function to change a bugs state to Close
-func Close(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*SetStatusOperation, error) {
+func Close(b ReadWrite, author identity.Interface, unixTime int64, metadata map[string]string) (*SetStatusOperation, error) {
 	op := NewSetStatusOp(author, unixTime, common.ClosedStatus)
 	for key, value := range metadata {
 		op.SetMetadata(key, value)

entities/bug/op_set_title.go 🔗

@@ -84,7 +84,7 @@ func (s SetTitleTimelineItem) CombinedId() entity.CombinedId {
 func (s *SetTitleTimelineItem) IsAuthored() {}
 
 // SetTitle is a convenience function to change a bugs title
-func SetTitle(b Interface, author identity.Interface, unixTime int64, title string, metadata map[string]string) (*SetTitleOperation, error) {
+func SetTitle(b ReadWrite, author identity.Interface, unixTime int64, title string, metadata map[string]string) (*SetTitleOperation, error) {
 	var lastTitleOp *SetTitleOperation
 	for _, op := range b.Operations() {
 		switch op := op.(type) {

entity/dag/example_test.go 🔗

@@ -269,9 +269,9 @@ func operationUnmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (dag.
 	return op, nil
 }
 
-// Compile compute a view of the final state. This is what we would use to display the state
+// Snapshot computes a view of the final state. This is what we would use to display the state
 // in a user interface.
-func (pc ProjectConfig) Compile() *Snapshot {
+func (pc ProjectConfig) Snapshot() *Snapshot {
 	// Note: this would benefit from caching, but it's a simple example
 	snap := &Snapshot{
 		// default value
@@ -335,7 +335,7 @@ func Example_entity() {
 	confIsaac, _ := Read(repoIsaac, confRene.Id())
 
 	// Compile gives the current state of the config
-	snapshot := confIsaac.Compile()
+	snapshot := confIsaac.Snapshot()
 	for admin, _ := range snapshot.Administrator {
 		fmt.Println(admin.DisplayName())
 	}

entity/dag/interface.go 🔗

@@ -6,42 +6,48 @@ import (
 	"github.com/git-bug/git-bug/util/lamport"
 )
 
-// Interface define the extended interface of a dag.Entity
-type Interface[SnapT Snapshot, OpT Operation] interface {
-	entity.Interface
-
-	// Validate checks if the Entity data is valid
-	Validate() error
+type CompileTo[SnapT Snapshot] interface {
+	// Snapshot compiles an Entity in an easily usable snapshot
+	Snapshot() SnapT
+}
 
-	// Append an operation into the staging area, to be committed later
-	Append(op OpT)
+// ReadOnly defines the extended read-only interface of a dag.Entity
+type ReadOnly[SnapT Snapshot, OpT Operation] interface {
+	entity.Interface
 
-	// Operations returns the ordered operations
-	Operations() []OpT
+	CompileTo[SnapT]
 
 	// NeedCommit indicates that the in-memory state changed and need to be committed in the repository
 	NeedCommit() bool
 
-	// Commit writes the staging area in Git and move the operations to the packs
-	Commit(repo repository.ClockedRepo) error
-
-	// CommitAsNeeded execute a Commit only if necessary. This function is useful to avoid getting an error if the Entity
-	// is already in sync with the repository.
-	CommitAsNeeded(repo repository.ClockedRepo) error
-
 	// FirstOp lookup for the very first operation of the Entity.
 	FirstOp() OpT
 
 	// LastOp lookup for the very last operation of the Entity.
-	// For a valid Entity, should never be nil
+	// For a valid Entity, it should never be nil.
 	LastOp() OpT
 
-	// Compile an Entity in an easily usable snapshot
-	Compile() SnapT
-
 	// CreateLamportTime return the Lamport time of creation
 	CreateLamportTime() lamport.Time
 
 	// EditLamportTime return the Lamport time of the last edit
 	EditLamportTime() lamport.Time
 }
+
+// ReadWrite is an entity interface that includes the direct manipulation of operations.
+type ReadWrite[SnapT Snapshot, OpT Operation] interface {
+	ReadOnly[SnapT, OpT]
+
+	// Commit writes the staging area in Git and move the operations to the packs
+	Commit(repo repository.ClockedRepo) error
+
+	// CommitAsNeeded execute a Commit only if necessary. This function is useful to avoid getting an error if the Entity
+	// is already in sync with the repository.
+	CommitAsNeeded(repo repository.ClockedRepo) error
+
+	// Append an operation into the staging area, to be committed later
+	Append(op OpT)
+
+	// Operations return the ordered operations
+	Operations() []OpT
+}

entity/interface.go 🔗

@@ -9,6 +9,7 @@ type Interface interface {
 	// the root of the entity.
 	// It is acceptable to use such a hash and keep mutating that data as long as Id() is not called.
 	Id() Id
+
 	// Validate check if the Entity data is valid
 	Validate() error
 }