WIP

Michael Muré created

Change summary

cache/board_cache.go                 | 30 ++++++++--
cache/board_subcache.go              | 45 +++++++++++++++
cache/repo_cache.go                  | 20 ++++--
commands/board/board.go              |  1 
commands/board/board_addbug.go       | 86 ++++++++++++++++++++++++++++++
commands/board/board_adddraft.go     | 36 ++++++++----
commands/board/completion.go         | 59 ++++++++++++++++----
commands/bug/completion.go           |  6 +-
commands/cmdjson/board.go            |  4 
commands/select/select.go            | 18 +++---
doc/man/git-bug-board.1              |  2 
doc/md/git-bug_board.md              |  1 
entities/board/op_add_item_draft.go  |  7 +
entities/board/op_add_item_entity.go | 49 ++++++++--------
entities/board/op_create.go          | 17 ++++-
entities/board/operation.go          |  2 
entities/board/snapshot.go           | 21 ++++++-
entity/resolver.go                   |  3 
18 files changed, 317 insertions(+), 90 deletions(-)

Detailed changes

cache/board_cache.go 🔗

@@ -29,7 +29,7 @@ func NewBoardCache(b *board.Board, repo repository.ClockedRepo, getUserIdentity
 	}
 }
 
-func (c *BoardCache) AddItemDraft(columnId entity.Id, title, message string, files []repository.Hash) (entity.CombinedId, *board.AddItemDraftOperation, error) {
+func (c *BoardCache) AddItemDraft(columnId entity.CombinedId, title, message string, files []repository.Hash) (entity.CombinedId, *board.AddItemDraftOperation, error) {
 	author, err := c.getUserIdentity()
 	if err != nil {
 		return entity.UnsetCombinedId, nil, err
@@ -38,9 +38,14 @@ func (c *BoardCache) AddItemDraft(columnId entity.Id, title, message string, fil
 	return c.AddItemDraftRaw(author, time.Now().Unix(), columnId, title, message, files, nil)
 }
 
-func (c *BoardCache) AddItemDraftRaw(author identity.Interface, unixTime int64, columnId entity.Id, title, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *board.AddItemDraftOperation, error) {
+func (c *BoardCache) AddItemDraftRaw(author identity.Interface, unixTime int64, columnId entity.CombinedId, title, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *board.AddItemDraftOperation, error) {
+	column, err := c.Snapshot().SearchColumn(columnId)
+	if err != nil {
+		return entity.UnsetCombinedId, nil, err
+	}
+
 	c.mu.Lock()
-	itemId, op, err := board.AddItemDraft(c.entity, author, unixTime, columnId, title, message, files, metadata)
+	itemId, op, err := board.AddItemDraft(c.entity, author, unixTime, column.Id, title, message, files, metadata)
 	c.mu.Unlock()
 	if err != nil {
 		return entity.UnsetCombinedId, nil, err
@@ -48,7 +53,7 @@ func (c *BoardCache) AddItemDraftRaw(author identity.Interface, unixTime int64,
 	return itemId, op, c.notifyUpdated()
 }
 
-func (c *BoardCache) AddItemEntity(columnId entity.Id, e entity.Interface) (entity.CombinedId, *board.AddItemEntityOperation, error) {
+func (c *BoardCache) AddItemEntity(columnId entity.CombinedId, e entity.Interface) (entity.CombinedId, *board.AddItemEntityOperation, error) {
 	author, err := c.getUserIdentity()
 	if err != nil {
 		return entity.UnsetCombinedId, nil, err
@@ -57,9 +62,22 @@ func (c *BoardCache) AddItemEntity(columnId entity.Id, e entity.Interface) (enti
 	return c.AddItemEntityRaw(author, time.Now().Unix(), columnId, e, nil)
 }
 
-func (c *BoardCache) AddItemEntityRaw(author identity.Interface, unixTime int64, columnId entity.Id, e entity.Interface, metadata map[string]string) (entity.CombinedId, *board.AddItemEntityOperation, error) {
+func (c *BoardCache) AddItemEntityRaw(author identity.Interface, unixTime int64, columnId entity.CombinedId, e entity.Interface, metadata map[string]string) (entity.CombinedId, *board.AddItemEntityOperation, error) {
+	column, err := c.Snapshot().SearchColumn(columnId)
+	if err != nil {
+		return entity.UnsetCombinedId, nil, err
+	}
+
+	var entityType board.ItemEntityType
+	switch e.(type) {
+	case *BugCache:
+		entityType = board.EntityTypeBug
+	default:
+		panic("unknown entity type")
+	}
+
 	c.mu.Lock()
-	itemId, op, err := board.AddItemEntity(c.entity, author, unixTime, columnId, e, metadata)
+	itemId, op, err := board.AddItemEntity(c.entity, author, unixTime, column.Id, entityType, e, metadata)
 	c.mu.Unlock()
 	if err != nil {
 		return entity.UnsetCombinedId, nil, err

cache/board_subcache.go 🔗

@@ -1,6 +1,7 @@
 package cache
 
 import (
+	"errors"
 	"time"
 
 	"github.com/git-bug/git-bug/entities/board"
@@ -43,6 +44,50 @@ func NewRepoCacheBoard(repo repository.ClockedRepo,
 	return &RepoCacheBoard{SubCache: sc}
 }
 
+func (c *RepoCacheBoard) ResolveColumn(prefix string) (*BoardCache, entity.CombinedId, error) {
+	boardPrefix, _ := entity.SeparateIds(prefix)
+	boardCandidate := make([]entity.Id, 0, 5)
+
+	// build a list of possible matching boards
+	c.mu.RLock()
+	for _, excerpt := range c.excerpts {
+		if excerpt.Id().HasPrefix(boardPrefix) {
+			boardCandidate = append(boardCandidate, excerpt.Id())
+		}
+	}
+	c.mu.RUnlock()
+
+	matchingBoardIds := make([]entity.Id, 0, 5)
+	matchingColumnId := entity.UnsetCombinedId
+	var matchingBoard *BoardCache
+
+	// search for matching columns
+	// searching every board candidate allow for some collision with the board prefix only,
+	// before being refined with the full column prefix
+	for _, boardId := range boardCandidate {
+		b, err := c.Resolve(boardId)
+		if err != nil {
+			return nil, entity.UnsetCombinedId, err
+		}
+
+		for _, column := range b.Snapshot().Columns {
+			if column.CombinedId.HasPrefix(prefix) {
+				matchingBoardIds = append(matchingBoardIds, boardId)
+				matchingBoard = b
+				matchingColumnId = column.CombinedId
+			}
+		}
+	}
+
+	if len(matchingBoardIds) > 1 {
+		return nil, entity.UnsetCombinedId, entity.NewErrMultipleMatch("board/column", matchingBoardIds)
+	} else if len(matchingBoardIds) == 0 {
+		return nil, entity.UnsetCombinedId, errors.New("column doesn't exist")
+	}
+
+	return matchingBoard, matchingColumnId, nil
+}
+
 func (c *RepoCacheBoard) New(title, description string, columns []string) (*BoardCache, *board.CreateOperation, error) {
 	author, err := c.getUserIdentity()
 	if err != nil {

cache/repo_cache.go 🔗

@@ -7,6 +7,9 @@ import (
 	"strconv"
 	"sync"
 
+	"github.com/git-bug/git-bug/entities/board"
+	"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/repository"
 	"github.com/git-bug/git-bug/util/multierr"
@@ -99,12 +102,17 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan
 	c.subcaches = append(c.subcaches, c.boards)
 
 	c.resolvers = entity.Resolvers{
-		&IdentityCache{}:   entity.ResolverFunc[*IdentityCache](c.identities.Resolve),
-		&IdentityExcerpt{}: entity.ResolverFunc[*IdentityExcerpt](c.identities.ResolveExcerpt),
-		&BugCache{}:        entity.ResolverFunc[*BugCache](c.bugs.Resolve),
-		&BugExcerpt{}:      entity.ResolverFunc[*BugExcerpt](c.bugs.ResolveExcerpt),
-		&BoardCache{}:      entity.ResolverFunc[*BoardCache](c.boards.Resolve),
-		&BoardExcerpt{}:    entity.ResolverFunc[*BoardExcerpt](c.boards.ResolveExcerpt),
+		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.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),
+		&BoardCache{}:           entity.ResolverFunc[*BoardCache](c.boards.Resolve),
+		&BoardExcerpt{}:         entity.ResolverFunc[*BoardExcerpt](c.boards.ResolveExcerpt),
 	}
 
 	// small buffer so that below functions can emit an event without blocking

commands/board/board.go 🔗

@@ -67,6 +67,7 @@ func NewBoardCommand() *cobra.Command {
 	cmd.AddCommand(newBoardDescriptionCommand())
 	cmd.AddCommand(newBoardTitleCommand())
 	cmd.AddCommand(newBoardAddDraftCommand())
+	cmd.AddCommand(newBoardAddBugCommand())
 
 	return cmd
 }

commands/board/board_addbug.go 🔗

@@ -0,0 +1,86 @@
+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 {
+	column string
+}
+
+func newBoardAddBugCommand() *cobra.Command {
+	env := execenv.NewEnv()
+	options := boardAddBugOptions{}
+
+	cmd := &cobra.Command{
+		Use:     "add-bug [BOARD_ID] [BUG_ID]",
+		Short:   "Add a bug to a board",
+		PreRunE: execenv.LoadBackend(env),
+		RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+			return runBoardAddBug(env, options, args)
+		}),
+		ValidArgsFunction: BoardAndBugCompletion(env),
+	}
+
+	flags := cmd.Flags()
+	flags.SortFlags = false
+
+	flags.StringVarP(&options.column, "column", "c", "1",
+		"The column to add to. Either a column Id or prefix, or the column number starting from 1.")
+	_ = cmd.RegisterFlagCompletionFunc("column", ColumnCompletion(env))
+
+	return cmd
+}
+
+func runBoardAddBug(env *execenv.Env, opts boardAddBugOptions, args []string) error {
+	board, args, err := ResolveSelected(env.Backend, 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)
+	if err != nil {
+		return err
+	}
+
+	env.Out.Printf("%s created\n", id.Human())
+
+	return board.Commit()
+}

commands/board/board_adddraft.go 🔗

@@ -1,12 +1,14 @@
 package boardcmd
 
 import (
+	"fmt"
 	"strconv"
 
 	"github.com/spf13/cobra"
 
 	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"
 	"github.com/git-bug/git-bug/entity"
 )
 
@@ -43,7 +45,6 @@ func newBoardAddDraftCommand() *cobra.Command {
 		"Take the message from the given file. Use - to read the message from the standard input")
 	flags.StringVarP(&options.column, "column", "c", "1",
 		"The column to add to. Either a column Id or prefix, or the column number starting from 1.")
-	// _ = cmd.MarkFlagRequired("column")
 	_ = cmd.RegisterFlagCompletionFunc("column", ColumnCompletion(env))
 	flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input")
 
@@ -52,18 +53,29 @@ func newBoardAddDraftCommand() *cobra.Command {
 
 func runBoardAddDraft(env *execenv.Env, opts boardAddDraftOptions, args []string) error {
 	b, args, err := ResolveSelected(env.Backend, args)
-	if err != nil {
-		return err
-	}
 
-	var columnId entity.Id
-
-	index, err := strconv.Atoi(opts.column)
-	if err == nil && index-1 >= 0 && index-1 < len(b.Snapshot().Columns) {
-		columnId = b.Snapshot().Columns[index-1].Id
-	} else {
-		// TODO: ID or combined ID?
-		// TODO: resolve
+	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
+		return err
 	}
 
 	if opts.messageFile != "" && opts.message == "" {

commands/board/completion.go 🔗

@@ -5,8 +5,11 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"github.com/git-bug/git-bug/cache"
+	bugcmd "github.com/git-bug/git-bug/commands/bug"
 	"github.com/git-bug/git-bug/commands/completion"
 	"github.com/git-bug/git-bug/commands/execenv"
+	_select "github.com/git-bug/git-bug/commands/select"
 )
 
 // BoardCompletion complete a board id
@@ -19,20 +22,25 @@ func BoardCompletion(env *execenv.Env) completion.ValidArgsFunction {
 			_ = env.Backend.Close()
 		}()
 
-		for _, id := range env.Backend.Boards().AllIds() {
-			if strings.Contains(id.String(), strings.TrimSpace(toComplete)) {
-				excerpt, err := env.Backend.Boards().ResolveExcerpt(id)
-				if err != nil {
-					return completion.HandleError(err)
-				}
-				completions = append(completions, id.Human()+"\t"+excerpt.Title)
+		return boardWithBackend(env.Backend, toComplete)
+	}
+}
+
+func boardWithBackend(backend *cache.RepoCache, toComplete string) (completions []string, directives cobra.ShellCompDirective) {
+	for _, id := range backend.Boards().AllIds() {
+		if strings.Contains(id.String(), strings.TrimSpace(toComplete)) {
+			excerpt, err := backend.Boards().ResolveExcerpt(id)
+			if err != nil {
+				return completion.HandleError(err)
 			}
+			completions = append(completions, id.Human()+"\t"+excerpt.Title)
 		}
-
-		return completions, cobra.ShellCompDirectiveNoFileComp
 	}
+
+	return completions, cobra.ShellCompDirectiveNoFileComp
 }
 
+// ColumnCompletion complete a board's column id
 func ColumnCompletion(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 {
@@ -43,14 +51,39 @@ func ColumnCompletion(env *execenv.Env) completion.ValidArgsFunction {
 		}()
 
 		b, _, err := ResolveSelected(env.Backend, args)
-		if err != nil {
+		switch {
+		case _select.IsErrNoValidId(err):
+			// no completion
+		case err == nil:
+			for _, column := range b.Snapshot().Columns {
+				completions = append(completions, column.CombinedId.Human()+"\t"+column.Name)
+			}
+		default:
 			return completion.HandleError(err)
 		}
 
-		for _, column := range b.Snapshot().Columns {
-			completions = append(completions, column.Id.Human()+"\t"+column.Name)
+		return completions, cobra.ShellCompDirectiveNoFileComp
+	}
+}
+
+func BoardAndBugCompletion(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 {
+			return completion.HandleError(err)
+		}
+		defer func() {
+			_ = env.Backend.Close()
+		}()
+
+		_, _, err := ResolveSelected(env.Backend, args)
+		switch {
+		case _select.IsErrNoValidId(err):
+			return boardWithBackend(env.Backend, toComplete)
+		case err == nil:
+			return bugcmd.BugWithBackend(env.Backend, toComplete)
+		default:
+			return completion.HandleError(err)
 		}
 
-		return completions, cobra.ShellCompDirectiveNoFileComp
 	}
 }

commands/bug/completion.go 🔗

@@ -22,11 +22,11 @@ func BugCompletion(env *execenv.Env) completion.ValidArgsFunction {
 			_ = env.Backend.Close()
 		}()
 
-		return bugWithBackend(env.Backend, toComplete)
+		return BugWithBackend(env.Backend, toComplete)
 	}
 }
 
-func bugWithBackend(backend *cache.RepoCache, toComplete string) (completions []string, directives cobra.ShellCompDirective) {
+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)) {
 			excerpt, err := backend.Bugs().ResolveExcerpt(id)
@@ -53,7 +53,7 @@ func BugAndLabelsCompletion(env *execenv.Env, addOrRemove bool) completion.Valid
 		b, cleanArgs, err := ResolveSelected(env.Backend, args)
 		if _select.IsErrNoValidId(err) {
 			// we need a bug first to complete labels
-			return bugWithBackend(env.Backend, toComplete)
+			return BugWithBackend(env.Backend, toComplete)
 		}
 		if err != nil {
 			return completion.HandleError(err)

commands/cmdjson/board.go 🔗

@@ -49,8 +49,8 @@ type BoardColumn struct {
 
 func NewBoardColumn(column *board.Column) BoardColumn {
 	jsonColumn := BoardColumn{
-		Id:      column.Id.String(),
-		HumanId: column.Id.Human(),
+		Id:      column.CombinedId.String(),
+		HumanId: column.CombinedId.Human(),
 		Name:    column.Name,
 	}
 	jsonColumn.Items = make([]any, len(column.Items))

commands/select/select.go 🔗

@@ -38,10 +38,10 @@ type Resolver[CacheT cache.CacheEntity] interface {
 // line. If it fails, it falls back to the select mechanism.
 //
 // Returns:
-//   - the entity if any
-//   - the new list of command line arguments with the entity prefix removed if it
-//     has been used
-//   - an error if the process failed
+//
+// Contrary to golang convention, the list of args returned is still correct even in
+// case of error, which allows to keep going and decide to handle the failure case more
+// naturally.
 func Resolve[CacheT cache.CacheEntity](repo *cache.RepoCache,
 	typename string, namespace string, resolver Resolver[CacheT],
 	args []string) (CacheT, []string, error) {
@@ -54,7 +54,7 @@ func Resolve[CacheT cache.CacheEntity](repo *cache.RepoCache,
 		}
 
 		if !entity.IsErrNotFound(err) {
-			return *new(CacheT), nil, err
+			return *new(CacheT), args, err
 		}
 	}
 
@@ -67,14 +67,14 @@ func Resolve[CacheT cache.CacheEntity](repo *cache.RepoCache,
 		// we clear the selected bug
 		err = Clear(repo, namespace)
 		if err != nil {
-			return *new(CacheT), nil, err
+			return *new(CacheT), args, err
 		}
-		return *new(CacheT), nil, NewErrNoValidId(typename)
+		return *new(CacheT), args, NewErrNoValidId(typename)
 	}
 
 	// another error when reading the entity
 	if err != nil {
-		return *new(CacheT), nil, err
+		return *new(CacheT), args, err
 	}
 
 	// entity is successfully retrieved
@@ -83,7 +83,7 @@ func Resolve[CacheT cache.CacheEntity](repo *cache.RepoCache,
 	}
 
 	// no selected bug and no valid first argument
-	return *new(CacheT), nil, NewErrNoValidId(typename)
+	return *new(CacheT), args, NewErrNoValidId(typename)
 }
 
 func selectFileName(namespace string) string {

doc/man/git-bug-board.1 🔗

@@ -35,4 +35,4 @@ List boards
 
 
 .SH SEE ALSO
-\fBgit-bug(1)\fP, \fBgit-bug-board-add-draft(1)\fP, \fBgit-bug-board-description(1)\fP, \fBgit-bug-board-deselect(1)\fP, \fBgit-bug-board-new(1)\fP, \fBgit-bug-board-rm(1)\fP, \fBgit-bug-board-select(1)\fP, \fBgit-bug-board-show(1)\fP, \fBgit-bug-board-title(1)\fP
+\fBgit-bug(1)\fP, \fBgit-bug-board-add-bug(1)\fP, \fBgit-bug-board-add-draft(1)\fP, \fBgit-bug-board-description(1)\fP, \fBgit-bug-board-deselect(1)\fP, \fBgit-bug-board-new(1)\fP, \fBgit-bug-board-rm(1)\fP, \fBgit-bug-board-select(1)\fP, \fBgit-bug-board-show(1)\fP, \fBgit-bug-board-title(1)\fP

doc/md/git-bug_board.md 🔗

@@ -19,6 +19,7 @@ git-bug board [flags]
 ### SEE ALSO
 
 * [git-bug](git-bug.md)	 - A bug tracker embedded in Git
+* [git-bug board add-bug](git-bug_board_add-bug.md)	 - Add a bug to a board
 * [git-bug board add-draft](git-bug_board_add-draft.md)	 - Add a draft item to a board
 * [git-bug board description](git-bug_board_description.md)	 - Display the description of a board
 * [git-bug board deselect](git-bug_board_deselect.md)	 - Clear the implicitly selected board

entities/board/op_add_item_draft.go 🔗

@@ -59,10 +59,11 @@ func (op *AddItemDraftOperation) Validate() error {
 }
 
 func (op *AddItemDraftOperation) Apply(snapshot *Snapshot) {
-	snapshot.addParticipant(op.Author())
+	// Recreate the combined Id to match on
+	combinedId := entity.CombineIds(snapshot.Id(), op.ColumnId)
 
 	for _, column := range snapshot.Columns {
-		if column.Id == op.ColumnId {
+		if column.CombinedId == combinedId {
 			column.Items = append(column.Items, &Draft{
 				combinedId: entity.CombineIds(snapshot.id, op.Id()),
 				Author:     op.Author(),
@@ -70,6 +71,8 @@ func (op *AddItemDraftOperation) Apply(snapshot *Snapshot) {
 				Message:    op.Message,
 				unixTime:   timestamp.Timestamp(op.UnixTime),
 			})
+
+			snapshot.addParticipant(op.Author())
 			return
 		}
 	}

entities/board/op_add_item_entity.go 🔗

@@ -9,11 +9,11 @@ import (
 	"github.com/git-bug/git-bug/entity/dag"
 )
 
-// itemEntityType indicate the type of entity board item
-type itemEntityType string
+// ItemEntityType indicate the type of entity board item
+type ItemEntityType string
 
 const (
-	entityTypeBug itemEntityType = "bug"
+	EntityTypeBug ItemEntityType = "bug"
 )
 
 var _ Operation = &AddItemEntityOperation{}
@@ -21,7 +21,7 @@ var _ Operation = &AddItemEntityOperation{}
 type AddItemEntityOperation struct {
 	dag.OpBase
 	ColumnId   entity.Id        `json:"column"`
-	EntityType itemEntityType   `json:"entity_type"`
+	EntityType ItemEntityType   `json:"entity_type"`
 	EntityId   entity.Id        `json:"entity_id"`
 	entity     entity.Interface // not serialized
 }
@@ -40,7 +40,7 @@ func (op *AddItemEntityOperation) Validate() error {
 	}
 
 	switch op.EntityType {
-	case entityTypeBug:
+	case EntityTypeBug:
 	default:
 		return fmt.Errorf("unknown entity type")
 	}
@@ -57,40 +57,39 @@ func (op *AddItemEntityOperation) Apply(snapshot *Snapshot) {
 		return
 	}
 
-	snapshot.addParticipant(op.Author())
+	// Recreate the combined Id to match on
+	combinedId := entity.CombineIds(snapshot.Id(), op.ColumnId)
 
 	for _, column := range snapshot.Columns {
-		if column.Id == op.ColumnId {
-			switch e := op.entity.(type) {
-			case bug.Interface:
+		if column.CombinedId == combinedId {
+			switch op.EntityType {
+			case EntityTypeBug:
 				column.Items = append(column.Items, &BugItem{
-					combinedId: entity.CombineIds(snapshot.Id(), e.Id()),
-					Bug:        e,
+					combinedId: entity.CombineIds(snapshot.Id(), op.entity.Id()),
+					Bug:        op.entity.(bug.Interface),
 				})
 			}
+			snapshot.addParticipant(op.Author())
 			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")
+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;
+	// proceed with caution!
+	return &AddItemEntityOperation{
+		OpBase:     dag.NewOpBase(AddItemEntityOp, author, unixTime),
+		ColumnId:   columnId,
+		EntityType: entityType,
+		EntityId:   e.Id(),
+		entity:     e,
 	}
 }
 
 // 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, e entity.Interface, metadata map[string]string) (entity.CombinedId, *AddItemEntityOperation, error) {
-	op := NewAddItemEntityOp(author, unixTime, columnId, e)
+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) {
+	op := NewAddItemEntityOp(author, unixTime, columnId, entityType, e)
 	for key, val := range metadata {
 		op.SetMetadata(key, val)
 	}

entities/board/op_create.go 🔗

@@ -86,13 +86,20 @@ func (op *CreateOperation) Apply(snap *Snapshot) {
 	snap.CreateTime = op.Time()
 
 	for _, name := range op.Columns {
-		// we derive a unique Id from the original column name
-		id := entity.DeriveId([]byte(name))
+		// we derive a unique ID from:
+		// - the ID of the operation that created the column
+		// - the original column name
+		id := entity.DeriveId(append([]byte(op.Id()), []byte(name)...))
+
+		// we derived the combined ID by interleaving the board ID (the same in
+		// this case).
+		combinedID := entity.CombineIds(snap.id, id)
 
 		snap.Columns = append(snap.Columns, &Column{
-			Id:    id,
-			Name:  name,
-			Items: nil,
+			Id:         id,
+			CombinedId: combinedID,
+			Name:       name,
+			Items:      nil,
 		})
 	}
 

entities/board/operation.go 🔗

@@ -65,7 +65,7 @@ func operationUnmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (dag.
 	switch op := op.(type) {
 	case *AddItemEntityOperation:
 		switch op.EntityType {
-		case entityTypeBug:
+		case EntityTypeBug:
 			op.entity, err = entity.Resolve[bug.Interface](resolvers, op.EntityId)
 		default:
 			return nil, fmt.Errorf("unknown entity type")

entities/board/snapshot.go 🔗

@@ -1,6 +1,7 @@
 package board
 
 import (
+	"fmt"
 	"time"
 
 	"github.com/git-bug/git-bug/entities/identity"
@@ -9,9 +10,12 @@ import (
 )
 
 type Column struct {
-	Id    entity.Id
-	Name  string
-	Items []Item
+	// id is the identifier of the column within the board context
+	Id entity.Id
+	// CombinedId is the global identifier of the column
+	CombinedId entity.CombinedId
+	Name       string
+	Items      []Item
 }
 
 type Item interface {
@@ -60,6 +64,17 @@ func (snap *Snapshot) EditTime() time.Time {
 	return snap.Operations[len(snap.Operations)-1].Time()
 }
 
+// SearchColumn will search for a column matching the given id
+func (snap *Snapshot) SearchColumn(id entity.CombinedId) (*Column, error) {
+	for _, column := range snap.Columns {
+		if column.CombinedId == id {
+			return column, nil
+		}
+	}
+
+	return nil, fmt.Errorf("column not found")
+}
+
 // append the operation author to the participants list
 func (snap *Snapshot) addParticipant(participant identity.Interface) {
 	for _, p := range snap.Participants {

entity/resolver.go 🔗

@@ -24,8 +24,7 @@ type Resolvers map[Resolved]Resolver
 func Resolve[T Resolved](rs Resolvers, id Id) (T, error) {
 	var zero T
 	for t, resolver := range rs {
-		switch t.(type) {
-		case T:
+		if _, ok := t.(T); ok {
 			val, err := resolver.(Resolver).Resolve(id)
 			if err != nil {
 				return zero, err