identity: more progress and fixes

Michael Muré created

Change summary

bridge/github/import.go                | 138 +++++++++++++++++++--------
bridge/launchpad/import.go             |  40 ++++++-
bug/op_create_test.go                  |   2 
bug/op_edit_comment_test.go            |   2 
bug/op_set_metadata_test.go            |   2 
bug/operation_iterator_test.go         |   2 
bug/operation_test.go                  |  10 +-
cache/bug_cache.go                     |   4 
cache/multi_repo_cache.go              |   1 
cache/repo_cache.go                    | 138 ++++++++++++++++++---------
commands/ls.go                         |   4 
commands/show.go                       |   2 
doc/man/git-bug-id.1                   |  29 +++++
doc/md/git-bug_id.md                   |  22 ++++
graphql/resolvers/identity.go          |  36 +++++++
graphql/resolvers/root.go              |   4 
identity/identity.go                   |  10 ++
misc/bash_completion/git-bug           |  21 ++++
misc/random_bugs/create_random_bugs.go |   2 
misc/zsh_completion/git-bug            |   2 
20 files changed, 364 insertions(+), 107 deletions(-)

Detailed changes

bridge/github/import.go 🔗

@@ -21,14 +21,13 @@ const keyGithubLogin = "github-login"
 type githubImporter struct {
 	client *githubv4.Client
 	conf   core.Configuration
-	ghost  identity.Interface
 }
 
 func (gi *githubImporter) Init(conf core.Configuration) error {
 	gi.conf = conf
 	gi.client = buildClient(conf)
 
-	return gi.fetchGhost()
+	return nil
 }
 
 func (gi *githubImporter) ImportAll(repo *cache.RepoCache) error {
@@ -71,7 +70,7 @@ func (gi *githubImporter) ImportAll(repo *cache.RepoCache) error {
 		}
 
 		for _, itemEdge := range q.Repository.Issues.Nodes[0].Timeline.Edges {
-			err = gi.ensureTimelineItem(b, itemEdge.Cursor, itemEdge.Node, variables)
+			err = gi.ensureTimelineItem(repo, b, itemEdge.Cursor, itemEdge.Node, variables)
 			if err != nil {
 				return err
 			}
@@ -114,6 +113,11 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 		return nil, err
 	}
 
+	author, err := gi.makePerson(repo, issue.Author)
+	if err != nil {
+		return nil, err
+	}
+
 	// if there is no edit, the UserContentEdits given by github is empty. That
 	// means that the original message is given by the issue message.
 	//
@@ -128,7 +132,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 	if len(issue.UserContentEdits.Nodes) == 0 {
 		if err == bug.ErrBugNotExist {
 			b, err = repo.NewBugRaw(
-				gi.makePerson(issue.Author),
+				author,
 				issue.CreatedAt.Unix(),
 				// Todo: this might not be the initial title, we need to query the
 				// timeline to be sure
@@ -140,7 +144,6 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 					keyGithubUrl: issue.Url.String(),
 				},
 			)
-
 			if err != nil {
 				return nil, err
 			}
@@ -166,7 +169,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 
 			// we create the bug as soon as we have a legit first edition
 			b, err = repo.NewBugRaw(
-				gi.makePerson(issue.Author),
+				author,
 				issue.CreatedAt.Unix(),
 				// Todo: this might not be the initial title, we need to query the
 				// timeline to be sure
@@ -189,7 +192,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 			return nil, err
 		}
 
-		err = gi.ensureCommentEdit(b, target, edit)
+		err = gi.ensureCommentEdit(repo, b, target, edit)
 		if err != nil {
 			return nil, err
 		}
@@ -199,7 +202,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 		// if we still didn't get a legit edit, create the bug from the issue data
 		if b == nil {
 			return repo.NewBugRaw(
-				gi.makePerson(issue.Author),
+				author,
 				issue.CreatedAt.Unix(),
 				// Todo: this might not be the initial title, we need to query the
 				// timeline to be sure
@@ -248,7 +251,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 
 				// we create the bug as soon as we have a legit first edition
 				b, err = repo.NewBugRaw(
-					gi.makePerson(issue.Author),
+					author,
 					issue.CreatedAt.Unix(),
 					// Todo: this might not be the initial title, we need to query the
 					// timeline to be sure
@@ -271,7 +274,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 				return nil, err
 			}
 
-			err = gi.ensureCommentEdit(b, target, edit)
+			err = gi.ensureCommentEdit(repo, b, target, edit)
 			if err != nil {
 				return nil, err
 			}
@@ -289,7 +292,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 	// if we still didn't get a legit edit, create the bug from the issue data
 	if b == nil {
 		return repo.NewBugRaw(
-			gi.makePerson(issue.Author),
+			author,
 			issue.CreatedAt.Unix(),
 			// Todo: this might not be the initial title, we need to query the
 			// timeline to be sure
@@ -306,12 +309,12 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 	return b, nil
 }
 
-func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4.String, item timelineItem, rootVariables map[string]interface{}) error {
+func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.BugCache, cursor githubv4.String, item timelineItem, rootVariables map[string]interface{}) error {
 	fmt.Printf("import %s\n", item.Typename)
 
 	switch item.Typename {
 	case "IssueComment":
-		return gi.ensureComment(b, cursor, item.IssueComment, rootVariables)
+		return gi.ensureComment(repo, b, cursor, item.IssueComment, rootVariables)
 
 	case "LabeledEvent":
 		id := parseId(item.LabeledEvent.Id)
@@ -319,8 +322,12 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4.
 		if err != cache.ErrNoMatchingOp {
 			return err
 		}
+		author, err := gi.makePerson(repo, item.LabeledEvent.Actor)
+		if err != nil {
+			return err
+		}
 		_, err = b.ChangeLabelsRaw(
-			gi.makePerson(item.LabeledEvent.Actor),
+			author,
 			item.LabeledEvent.CreatedAt.Unix(),
 			[]string{
 				string(item.LabeledEvent.Label.Name),
@@ -336,8 +343,12 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4.
 		if err != cache.ErrNoMatchingOp {
 			return err
 		}
+		author, err := gi.makePerson(repo, item.UnlabeledEvent.Actor)
+		if err != nil {
+			return err
+		}
 		_, err = b.ChangeLabelsRaw(
-			gi.makePerson(item.UnlabeledEvent.Actor),
+			author,
 			item.UnlabeledEvent.CreatedAt.Unix(),
 			nil,
 			[]string{
@@ -353,8 +364,12 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4.
 		if err != cache.ErrNoMatchingOp {
 			return err
 		}
+		author, err := gi.makePerson(repo, item.ClosedEvent.Actor)
+		if err != nil {
+			return err
+		}
 		return b.CloseRaw(
-			gi.makePerson(item.ClosedEvent.Actor),
+			author,
 			item.ClosedEvent.CreatedAt.Unix(),
 			map[string]string{keyGithubId: id},
 		)
@@ -365,8 +380,12 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4.
 		if err != cache.ErrNoMatchingOp {
 			return err
 		}
+		author, err := gi.makePerson(repo, item.ReopenedEvent.Actor)
+		if err != nil {
+			return err
+		}
 		return b.OpenRaw(
-			gi.makePerson(item.ReopenedEvent.Actor),
+			author,
 			item.ReopenedEvent.CreatedAt.Unix(),
 			map[string]string{keyGithubId: id},
 		)
@@ -377,8 +396,12 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4.
 		if err != cache.ErrNoMatchingOp {
 			return err
 		}
+		author, err := gi.makePerson(repo, item.RenamedTitleEvent.Actor)
+		if err != nil {
+			return err
+		}
 		return b.SetTitleRaw(
-			gi.makePerson(item.RenamedTitleEvent.Actor),
+			author,
 			item.RenamedTitleEvent.CreatedAt.Unix(),
 			string(item.RenamedTitleEvent.CurrentTitle),
 			map[string]string{keyGithubId: id},
@@ -391,13 +414,18 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4.
 	return nil
 }
 
-func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.String, comment issueComment, rootVariables map[string]interface{}) error {
+func (gi *githubImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, cursor githubv4.String, comment issueComment, rootVariables map[string]interface{}) error {
 	target, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(comment.Id))
 	if err != nil && err != cache.ErrNoMatchingOp {
 		// real error
 		return err
 	}
 
+	author, err := gi.makePerson(repo, comment.Author)
+	if err != nil {
+		return err
+	}
+
 	// if there is no edit, the UserContentEdits given by github is empty. That
 	// means that the original message is given by the comment message.
 	//
@@ -412,7 +440,7 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin
 	if len(comment.UserContentEdits.Nodes) == 0 {
 		if err == cache.ErrNoMatchingOp {
 			err = b.AddCommentRaw(
-				gi.makePerson(comment.Author),
+				author,
 				comment.CreatedAt.Unix(),
 				cleanupText(string(comment.Body)),
 				nil,
@@ -445,7 +473,7 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin
 			}
 
 			err = b.AddCommentRaw(
-				gi.makePerson(comment.Author),
+				author,
 				comment.CreatedAt.Unix(),
 				cleanupText(string(*edit.Diff)),
 				nil,
@@ -459,7 +487,7 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin
 			}
 		}
 
-		err := gi.ensureCommentEdit(b, target, edit)
+		err := gi.ensureCommentEdit(repo, b, target, edit)
 		if err != nil {
 			return err
 		}
@@ -501,7 +529,7 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin
 				continue
 			}
 
-			err := gi.ensureCommentEdit(b, target, edit)
+			err := gi.ensureCommentEdit(repo, b, target, edit)
 			if err != nil {
 				return err
 			}
@@ -519,7 +547,7 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin
 	return nil
 }
 
-func (gi *githubImporter) ensureCommentEdit(b *cache.BugCache, target git.Hash, edit userContentEdit) error {
+func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugCache, target git.Hash, edit userContentEdit) error {
 	if edit.Diff == nil {
 		// this happen if the event is older than early 2018, Github doesn't have the data before that.
 		// Best we can do is to ignore the event.
@@ -542,6 +570,11 @@ func (gi *githubImporter) ensureCommentEdit(b *cache.BugCache, target git.Hash,
 
 	fmt.Println("import edition")
 
+	editor, err := gi.makePerson(repo, edit.Editor)
+	if err != nil {
+		return err
+	}
+
 	switch {
 	case edit.DeletedAt != nil:
 		// comment deletion, not supported yet
@@ -549,7 +582,7 @@ func (gi *githubImporter) ensureCommentEdit(b *cache.BugCache, target git.Hash,
 	case edit.DeletedAt == nil:
 		// comment edition
 		err := b.EditCommentRaw(
-			gi.makePerson(edit.Editor),
+			editor,
 			edit.CreatedAt.Unix(),
 			target,
 			cleanupText(string(*edit.Diff)),
@@ -566,10 +599,22 @@ func (gi *githubImporter) ensureCommentEdit(b *cache.BugCache, target git.Hash,
 }
 
 // makePerson create a bug.Person from the Github data
-func (gi *githubImporter) makePerson(actor *actor) identity.Interface {
+func (gi *githubImporter) makePerson(repo *cache.RepoCache, actor *actor) (*identity.Identity, error) {
+	// When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
+	// in it's UI. So we need a special case to get it.
 	if actor == nil {
-		return gi.ghost
+		return gi.getGhost(repo)
+	}
+
+	// Look first in the cache
+	i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, string(actor.Login))
+	if err == nil {
+		return i, nil
+	}
+	if _, ok := err.(identity.ErrMultipleMatch); ok {
+		return nil, err
 	}
+
 	var name string
 	var email string
 
@@ -589,24 +634,36 @@ func (gi *githubImporter) makePerson(actor *actor) identity.Interface {
 	case "Bot":
 	}
 
-	return bug.Person{
-		Name:      name,
-		Email:     email,
-		Login:     string(actor.Login),
-		AvatarUrl: string(actor.AvatarUrl),
-	}
+	return repo.NewIdentityRaw(
+		name,
+		email,
+		string(actor.Login),
+		string(actor.AvatarUrl),
+		map[string]string{
+			keyGithubLogin: string(actor.Login),
+		},
+	)
 }
 
-func (gi *githubImporter) fetchGhost() error {
+func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*identity.Identity, error) {
+	// Look first in the cache
+	i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, "ghost")
+	if err == nil {
+		return i, nil
+	}
+	if _, ok := err.(identity.ErrMultipleMatch); ok {
+		return nil, err
+	}
+
 	var q userQuery
 
 	variables := map[string]interface{}{
 		"login": githubv4.String("ghost"),
 	}
 
-	err := gi.client.Query(context.TODO(), &q, variables)
+	err = gi.client.Query(context.TODO(), &q, variables)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	var name string
@@ -614,14 +671,15 @@ func (gi *githubImporter) fetchGhost() error {
 		name = string(*q.User.Name)
 	}
 
-	gi.ghost = identity.NewIdentityFull(
+	return repo.NewIdentityRaw(
 		name,
+		string(q.User.Email),
 		string(q.User.Login),
 		string(q.User.AvatarUrl),
-		string(q.User.Email),
+		map[string]string{
+			keyGithubLogin: string(q.User.Login),
+		},
 	)
-
-	return nil
 }
 
 // parseId convert the unusable githubv4.ID (an interface{}) into a string

bridge/launchpad/import.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/pkg/errors"
 )
 
@@ -20,14 +21,27 @@ func (li *launchpadImporter) Init(conf core.Configuration) error {
 }
 
 const keyLaunchpadID = "launchpad-id"
+const keyLaunchpadLogin = "launchpad-login"
 
-func (li *launchpadImporter) makePerson(owner LPPerson) bug.Person {
-	return bug.Person{
-		Name:      owner.Name,
-		Email:     "",
-		Login:     owner.Login,
-		AvatarUrl: "",
+func (li *launchpadImporter) makePerson(repo *cache.RepoCache, owner LPPerson) (*identity.Identity, error) {
+	// Look first in the cache
+	i, err := repo.ResolveIdentityImmutableMetadata(keyLaunchpadLogin, owner.Login)
+	if err == nil {
+		return i, nil
 	}
+	if _, ok := err.(identity.ErrMultipleMatch); ok {
+		return nil, err
+	}
+
+	return repo.NewIdentityRaw(
+		owner.Name,
+		"",
+		owner.Login,
+		"",
+		map[string]string{
+			keyLaunchpadLogin: owner.Login,
+		},
+	)
 }
 
 func (li *launchpadImporter) ImportAll(repo *cache.RepoCache) error {
@@ -53,10 +67,15 @@ func (li *launchpadImporter) ImportAll(repo *cache.RepoCache) error {
 			return err
 		}
 
+		owner, err := li.makePerson(repo, lpBug.Owner)
+		if err != nil {
+			return err
+		}
+
 		if err == bug.ErrBugNotExist {
 			createdAt, _ := time.Parse(time.RFC3339, lpBug.CreatedAt)
 			b, err = repo.NewBugRaw(
-				li.makePerson(lpBug.Owner),
+				owner,
 				createdAt.Unix(),
 				lpBug.Title,
 				lpBug.Description,
@@ -94,10 +113,15 @@ func (li *launchpadImporter) ImportAll(repo *cache.RepoCache) error {
 				continue
 			}
 
+			owner, err := li.makePerson(repo, lpMessage.Owner)
+			if err != nil {
+				return err
+			}
+
 			// This is a new comment, we can add it.
 			createdAt, _ := time.Parse(time.RFC3339, lpMessage.CreatedAt)
 			err = b.AddCommentRaw(
-				li.makePerson(lpMessage.Owner),
+				owner,
 				createdAt.Unix(),
 				lpMessage.Content,
 				nil,

bug/op_create_test.go 🔗

@@ -11,7 +11,7 @@ import (
 func TestCreate(t *testing.T) {
 	snapshot := Snapshot{}
 
-	var rene = identity.NewBare("René Descartes", "rene@descartes.fr")
+	var rene = identity.NewIdentity("René Descartes", "rene@descartes.fr")
 
 	unix := time.Now().Unix()
 

bug/op_edit_comment_test.go 🔗

@@ -11,7 +11,7 @@ import (
 func TestEdit(t *testing.T) {
 	snapshot := Snapshot{}
 
-	var rene = identity.NewBare("René Descartes", "rene@descartes.fr")
+	var rene = identity.NewIdentity("René Descartes", "rene@descartes.fr")
 
 	unix := time.Now().Unix()
 

bug/op_set_metadata_test.go 🔗

@@ -11,7 +11,7 @@ import (
 func TestSetMetadata(t *testing.T) {
 	snapshot := Snapshot{}
 
-	var rene = identity.NewBare("René Descartes", "rene@descartes.fr")
+	var rene = identity.NewIdentity("René Descartes", "rene@descartes.fr")
 
 	unix := time.Now().Unix()
 

bug/operation_iterator_test.go 🔗

@@ -8,7 +8,7 @@ import (
 )
 
 var (
-	rene = identity.NewBare("René Descartes", "rene@descartes.fr")
+	rene = identity.NewIdentity("René Descartes", "rene@descartes.fr")
 	unix = time.Now().Unix()
 
 	createOp      = NewCreateOp(rene, unix, "title", "message", nil)

bug/operation_test.go 🔗

@@ -26,11 +26,11 @@ func TestValidate(t *testing.T) {
 
 	bad := []Operation{
 		// opbase
-		NewSetStatusOp(identity.NewBare("", "rene@descartes.fr"), unix, ClosedStatus),
-		NewSetStatusOp(identity.NewBare("René Descartes\u001b", "rene@descartes.fr"), unix, ClosedStatus),
-		NewSetStatusOp(identity.NewBare("René Descartes", "rene@descartes.fr\u001b"), unix, ClosedStatus),
-		NewSetStatusOp(identity.NewBare("René \nDescartes", "rene@descartes.fr"), unix, ClosedStatus),
-		NewSetStatusOp(identity.NewBare("René Descartes", "rene@\ndescartes.fr"), unix, ClosedStatus),
+		NewSetStatusOp(identity.NewIdentity("", "rene@descartes.fr"), unix, ClosedStatus),
+		NewSetStatusOp(identity.NewIdentity("René Descartes\u001b", "rene@descartes.fr"), unix, ClosedStatus),
+		NewSetStatusOp(identity.NewIdentity("René Descartes", "rene@descartes.fr\u001b"), unix, ClosedStatus),
+		NewSetStatusOp(identity.NewIdentity("René \nDescartes", "rene@descartes.fr"), unix, ClosedStatus),
+		NewSetStatusOp(identity.NewIdentity("René Descartes", "rene@\ndescartes.fr"), unix, ClosedStatus),
 		&CreateOperation{OpBase: OpBase{
 			Author:        rene,
 			UnixTime:      0,

cache/bug_cache.go 🔗

@@ -11,6 +11,10 @@ import (
 	"github.com/MichaelMure/git-bug/util/git"
 )
 
+// BugCache is a wrapper around a Bug. It provide multiple functions:
+//
+// 1. Provide a higher level API to use than the raw API from Bug.
+// 2. Maintain an up to date Snapshot available.
 type BugCache struct {
 	repoCache *RepoCache
 	bug       *bug.WithSnapshot

cache/multi_repo_cache.go 🔗

@@ -8,6 +8,7 @@ import (
 
 const lockfile = "lock"
 
+// MultiRepoCache is the root cache, holding multiple RepoCache.
 type MultiRepoCache struct {
 	repos map[string]*RepoCache
 }

cache/repo_cache.go 🔗

@@ -23,6 +23,20 @@ import (
 const cacheFile = "cache"
 const formatVersion = 1
 
+// RepoCache is a cache for a Repository. This cache has multiple functions:
+//
+// 1. After being loaded, a Bug is kept in memory in the cache, allowing for fast
+// 		access later.
+// 2. The cache maintain on memory and on disk a pre-digested excerpt for each bug,
+// 		allowing for fast querying the whole set of bugs without having to load
+//		them individually.
+// 3. The cache guarantee that a single instance of a Bug is loaded at once, avoiding
+// 		loss of data that we could have with multiple copies in the same process.
+// 4. The same way, the cache maintain in memory a single copy of the loaded identities.
+//
+// The cache also protect the on-disk data by locking the git repository for its
+// own usage, by writing a lock file. Of course, normal git operations are not
+// affected, only git-bug related one.
 type RepoCache struct {
 	// the underlying repo
 	repo repository.ClockedRepo
@@ -406,9 +420,14 @@ func (c *RepoCache) NewBugRaw(author *identity.Identity, unixTime int64, title s
 		return nil, err
 	}
 
+	if _, has := c.bugs[b.Id()]; has {
+		return nil, fmt.Errorf("bug %s already exist in the cache", b.Id())
+	}
+
 	cached := NewBugCache(c, b)
 	c.bugs[b.Id()] = cached
 
+	// force the write of the excerpt
 	err = c.bugUpdated(b.Id())
 	if err != nil {
 		return nil, err
@@ -546,52 +565,81 @@ func (c *RepoCache) ResolveIdentity(id string) (*identity.Identity, error) {
 	return i, nil
 }
 
-// ResolveIdentityPrefix retrieve an Identity matching an id prefix. It fails if multiple
-// bugs match.
-// func (c *RepoCache) ResolveIdentityPrefix(prefix string) (*BugCache, error) {
-// 	// preallocate but empty
-// 	matching := make([]string, 0, 5)
-//
-// 	for id := range c.excerpts {
-// 		if strings.HasPrefix(id, prefix) {
-// 			matching = append(matching, id)
-// 		}
-// 	}
-//
-// 	if len(matching) > 1 {
-// 		return nil, bug.ErrMultipleMatch{Matching: matching}
-// 	}
-//
-// 	if len(matching) == 0 {
-// 		return nil, bug.ErrBugNotExist
-// 	}
-//
-// 	return c.ResolveBug(matching[0])
-// }
+// ResolveIdentityPrefix retrieve an Identity matching an id prefix.
+// It fails if multiple identities match.
+func (c *RepoCache) ResolveIdentityPrefix(prefix string) (*identity.Identity, error) {
+	// preallocate but empty
+	matching := make([]string, 0, 5)
+
+	for id := range c.identities {
+		if strings.HasPrefix(id, prefix) {
+			matching = append(matching, id)
+		}
+	}
+
+	if len(matching) > 1 {
+		return nil, identity.ErrMultipleMatch{Matching: matching}
+	}
+
+	if len(matching) == 0 {
+		return nil, identity.ErrIdentityNotExist
+	}
+
+	return c.ResolveIdentity(matching[0])
+}
 
 // ResolveIdentityImmutableMetadata retrieve an Identity that has the exact given metadata on
 // one of it's version. If multiple version have the same key, the first defined take precedence.
-func (c *RepoCache) ResolveIdentityImmutableMetadata(key string, value string) (*BugCache, error) {
-	// // preallocate but empty
-	// matching := make([]string, 0, 5)
-	//
-	// for id, excerpt := range c.excerpts {
-	// 	if excerpt.CreateMetadata[key] == value {
-	// 		matching = append(matching, id)
-	// 	}
-	// }
-	//
-	// if len(matching) > 1 {
-	// 	return nil, bug.ErrMultipleMatch{Matching: matching}
-	// }
-	//
-	// if len(matching) == 0 {
-	// 	return nil, bug.ErrBugNotExist
-	// }
-	//
-	// return c.ResolveBug(matching[0])
-
-	// TODO
-
-	return nil, nil
+func (c *RepoCache) ResolveIdentityImmutableMetadata(key string, value string) (*identity.Identity, error) {
+	// preallocate but empty
+	matching := make([]string, 0, 5)
+
+	for id, i := range c.identities {
+		if i.ImmutableMetadata()[key] == value {
+			matching = append(matching, id)
+		}
+	}
+
+	if len(matching) > 1 {
+		return nil, identity.ErrMultipleMatch{Matching: matching}
+	}
+
+	if len(matching) == 0 {
+		return nil, identity.ErrIdentityNotExist
+	}
+
+	return c.ResolveIdentity(matching[0])
+}
+
+// NewIdentity create a new identity
+// The new identity is written in the repository (commit)
+func (c *RepoCache) NewIdentity(name string, email string) (*identity.Identity, error) {
+	return c.NewIdentityRaw(name, email, "", "", nil)
+}
+
+// NewIdentityFull create a new identity
+// The new identity is written in the repository (commit)
+func (c *RepoCache) NewIdentityFull(name string, email string, login string, avatarUrl string) (*identity.Identity, error) {
+	return c.NewIdentityRaw(name, email, login, avatarUrl, nil)
+}
+
+func (c *RepoCache) NewIdentityRaw(name string, email string, login string, avatarUrl string, metadata map[string]string) (*identity.Identity, error) {
+	i := identity.NewIdentityFull(name, email, login, avatarUrl)
+
+	for key, value := range metadata {
+		i.SetMetadata(key, value)
+	}
+
+	err := i.Commit(c.repo)
+	if err != nil {
+		return nil, err
+	}
+
+	if _, has := c.identities[i.Id()]; has {
+		return nil, fmt.Errorf("identity %s already exist in the cache", i.Id())
+	}
+
+	c.identities[i.Id()] = i
+
+	return i, nil
 }

commands/ls.go 🔗

@@ -4,8 +4,8 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/colors"
 	"github.com/MichaelMure/git-bug/util/interrupt"
 	"github.com/spf13/cobra"
@@ -52,7 +52,7 @@ func runLsBug(cmd *cobra.Command, args []string) error {
 
 		snapshot := b.Snapshot()
 
-		var author bug.Person
+		var author identity.Interface
 
 		if len(snapshot.Comments) > 0 {
 			create := snapshot.Comments[0]

commands/show.go 🔗

@@ -93,7 +93,7 @@ func runShowBug(cmd *cobra.Command, args []string) error {
 			indent,
 			i,
 			comment.Author.DisplayName(),
-			comment.Author.Email,
+			comment.Author.Email(),
 		)
 
 		if comment.Message == "" {

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

@@ -0,0 +1,29 @@
+.TH "GIT-BUG" "1" "Jan 2019" "Generated from git-bug's source code" "" 
+.nh
+.ad l
+
+
+.SH NAME
+.PP
+git\-bug\-id \- Display or change the user identity
+
+
+.SH SYNOPSIS
+.PP
+\fBgit\-bug id [<id>] [flags]\fP
+
+
+.SH DESCRIPTION
+.PP
+Display or change the user identity
+
+
+.SH OPTIONS
+.PP
+\fB\-h\fP, \fB\-\-help\fP[=false]
+    help for id
+
+
+.SH SEE ALSO
+.PP
+\fBgit\-bug(1)\fP

doc/md/git-bug_id.md 🔗

@@ -0,0 +1,22 @@
+## git-bug id
+
+Display or change the user identity
+
+### Synopsis
+
+Display or change the user identity
+
+```
+git-bug id [<id>] [flags]
+```
+
+### Options
+
+```
+  -h, --help   help for id
+```
+
+### SEE ALSO
+
+* [git-bug](git-bug.md)	 - A bug tracker embedded in Git
+

graphql/resolvers/identity.go 🔗

@@ -0,0 +1,36 @@
+package resolvers
+
+import (
+	"context"
+
+	"github.com/MichaelMure/git-bug/identity"
+)
+
+type identityResolver struct{}
+
+func (identityResolver) Name(ctx context.Context, obj *identity.Interface) (*string, error) {
+	return nilIfEmpty((*obj).Name())
+}
+
+func (identityResolver) Email(ctx context.Context, obj *identity.Interface) (*string, error) {
+	return nilIfEmpty((*obj).Email())
+}
+
+func (identityResolver) Login(ctx context.Context, obj *identity.Interface) (*string, error) {
+	return nilIfEmpty((*obj).Login())
+}
+
+func (identityResolver) DisplayName(ctx context.Context, obj *identity.Interface) (string, error) {
+	return (*obj).DisplayName(), nil
+}
+
+func (identityResolver) AvatarURL(ctx context.Context, obj *identity.Interface) (*string, error) {
+	return nilIfEmpty((*obj).AvatarUrl())
+}
+
+func nilIfEmpty(s string) (*string, error) {
+	if s == "" {
+		return nil, nil
+	}
+	return &s, nil
+}

graphql/resolvers/root.go 🔗

@@ -32,6 +32,10 @@ func (RootResolver) Bug() graph.BugResolver {
 	return &bugResolver{}
 }
 
+func (r RootResolver) Identity() graph.IdentityResolver {
+	return &identityResolver{}
+}
+
 func (RootResolver) CommentHistoryStep() graph.CommentHistoryStepResolver {
 	return &commentHistoryStepResolver{}
 }

identity/identity.go 🔗

@@ -18,6 +18,16 @@ const identityConfigKey = "git-bug.identity"
 
 var ErrIdentityNotExist = errors.New("identity doesn't exist")
 
+type ErrMultipleMatch struct {
+	Matching []string
+}
+
+func (e ErrMultipleMatch) Error() string {
+	return fmt.Sprintf("Multiple matching identities found:\n%s", strings.Join(e.Matching, "\n"))
+}
+
+var _ Interface = &Identity{}
+
 type Identity struct {
 	id       string
 	Versions []*Version

misc/bash_completion/git-bug 🔗

@@ -450,6 +450,26 @@ _git-bug_deselect()
     noun_aliases=()
 }
 
+_git-bug_id()
+{
+    last_command="git-bug_id"
+
+    command_aliases=()
+
+    commands=()
+
+    flags=()
+    two_word_flags=()
+    local_nonpersistent_flags=()
+    flags_with_completion=()
+    flags_completion=()
+
+
+    must_have_one_flag=()
+    must_have_one_noun=()
+    noun_aliases=()
+}
+
 _git-bug_label_add()
 {
     last_command="git-bug_label_add"
@@ -863,6 +883,7 @@ _git-bug_root_command()
     commands+=("commands")
     commands+=("comment")
     commands+=("deselect")
+    commands+=("id")
     commands+=("label")
     commands+=("ls")
     commands+=("ls-id")

misc/random_bugs/create_random_bugs.go 🔗

@@ -138,7 +138,7 @@ func GenerateRandomOperationPacksWithSeed(packNumber int, opNumber int, seed int
 }
 
 func person() identity.Interface {
-	return identity.NewBare(fake.FullName(), fake.EmailAddress())
+	return identity.NewIdentity(fake.FullName(), fake.EmailAddress())
 }
 
 var persons []identity.Interface

misc/zsh_completion/git-bug 🔗

@@ -8,7 +8,7 @@ case $state in
   level1)
     case $words[1] in
       git-bug)
-        _arguments '1: :(add bridge commands comment deselect label ls ls-label pull push select show status termui title version webui)'
+        _arguments '1: :(add bridge commands comment deselect id label ls ls-label pull push select show status termui title version webui)'
       ;;
       *)
         _arguments '*: :_files'