WIP identity in git

Michael Murรฉ created

Change summary

Gopkg.lock                             |   1 
bridge/core/bridge.go                  |   6 
bug/bug_actions.go                     |   5 
bug/bug_actions_test.go                |   2 
bug/comment.go                         |   3 
bug/op_add_comment.go                  |   8 
bug/op_create.go                       |   8 
bug/op_create_test.go                  |   6 
bug/op_edit_comment.go                 |   8 
bug/op_edit_comment_test.go            |   6 
bug/op_label_change.go                 |   8 
bug/op_noop.go                         |   9 
bug/op_set_metadata.go                 |   9 
bug/op_set_metadata_test.go            |   6 
bug/op_set_status.go                   |   9 
bug/op_set_title.go                    |   8 
bug/operation.go                       |  50 +
bug/operation_iterator_test.go         |   7 
bug/operation_pack.go                  |  31 
bug/operation_test.go                  |  11 
bug/person.go                          |  95 ----
bug/snapshot.go                        |   3 
bug/timeline.go                        |   5 
cache/bug_cache.go                     |  26 
cache/bug_excerpt.go                   |   4 
cache/repo_cache.go                    |   5 
commands/id.go                         |  35 +
graphql/gqlgen.yml                     |  15 
graphql/graph/gen_graph.go             | 658 +++++++++++++++------------
graphql/graphql_test.go                |   8 
graphql/resolvers/person.go            |  37 -
graphql/resolvers/root.go              |   4 
graphql/schema/bug.graphql             |  18 
graphql/schema/identity.graphql        |  13 
graphql/schema/operations.graphql      |  14 
graphql/schema/root.graphql            |   2 
graphql/schema/timeline.graphql        |  10 
identity/bare.go                       | 144 ++++++
identity/identity.go                   | 285 ++++++++++++
identity/identity_test.go              | 145 ++++++
identity/interface.go                  |  30 +
identity/key.go                        |   7 
identity/version.go                    | 105 ++++
misc/random_bugs/create_random_bugs.go |  26 
termui/bug_table.go                    |   3 
tests/read_bugs_test.go                |  52 --
util/test/repo.go                      |  52 ++
47 files changed, 1,366 insertions(+), 636 deletions(-)

Detailed changes

Gopkg.lock ๐Ÿ”—

@@ -469,6 +469,7 @@
     "github.com/go-test/deep",
     "github.com/gorilla/mux",
     "github.com/icrowley/fake",
+    "github.com/mattn/go-runewidth",
     "github.com/phayes/freeport",
     "github.com/pkg/errors",
     "github.com/shurcooL/githubv4",

bridge/core/bridge.go ๐Ÿ”—

@@ -15,6 +15,8 @@ import (
 var ErrImportNorSupported = errors.New("import is not supported")
 var ErrExportNorSupported = errors.New("export is not supported")
 
+const bridgeConfigKeyPrefix = "git-bug.bridge"
+
 var bridgeImpl map[string]reflect.Type
 
 // Bridge is a wrapper around a BridgeImpl that will bind low-level
@@ -114,12 +116,12 @@ func splitFullName(fullName string) (string, string, error) {
 // ConfiguredBridges return the list of bridge that are configured for the given
 // repo
 func ConfiguredBridges(repo repository.RepoCommon) ([]string, error) {
-	configs, err := repo.ReadConfigs("git-bug.bridge.")
+	configs, err := repo.ReadConfigs(bridgeConfigKeyPrefix + ".")
 	if err != nil {
 		return nil, errors.Wrap(err, "can't read configured bridges")
 	}
 
-	re, err := regexp.Compile(`git-bug.bridge.([^.]+\.[^.]+)`)
+	re, err := regexp.Compile(bridgeConfigKeyPrefix + `.([^.]+\.[^.]+)`)
 	if err != nil {
 		panic(err)
 	}

bug/bug_actions.go ๐Ÿ”—

@@ -35,6 +35,11 @@ func Pull(repo repository.ClockedRepo, remote string) error {
 		if merge.Err != nil {
 			return merge.Err
 		}
+		if merge.Status == MergeStatusInvalid {
+			// Not awesome: simply output the merge failure here as this function
+			// is only used in tests for now.
+			fmt.Println(merge)
+		}
 	}
 
 	return nil

bug/bug_actions_test.go ๐Ÿ”—

@@ -50,7 +50,7 @@ func cleanupRepo(repo repository.Repo) error {
 func setupRepos(t testing.TB) (repoA, repoB, remote *repository.GitRepo) {
 	repoA = createRepo(false)
 	repoB = createRepo(false)
-	remote = createRepo(true)
+	remote = createRepo(false)
 
 	remoteAddr := "file://" + remote.GetPath()
 

bug/comment.go ๐Ÿ”—

@@ -1,13 +1,14 @@
 package bug
 
 import (
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/dustin/go-humanize"
 )
 
 // Comment represent a comment in a Bug
 type Comment struct {
-	Author  Person
+	Author  identity.Interface
 	Message string
 	Files   []git.Hash
 

bug/op_add_comment.go ๐Ÿ”—

@@ -3,6 +3,8 @@ package bug
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/identity"
+
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/text"
 )
@@ -68,7 +70,7 @@ func (op *AddCommentOperation) Validate() error {
 // Sign post method for gqlgen
 func (op *AddCommentOperation) IsAuthored() {}
 
-func NewAddCommentOp(author Person, unixTime int64, message string, files []git.Hash) *AddCommentOperation {
+func NewAddCommentOp(author identity.Interface, unixTime int64, message string, files []git.Hash) *AddCommentOperation {
 	return &AddCommentOperation{
 		OpBase:  newOpBase(AddCommentOp, author, unixTime),
 		Message: message,
@@ -82,11 +84,11 @@ type AddCommentTimelineItem struct {
 }
 
 // Convenience function to apply the operation
-func AddComment(b Interface, author Person, unixTime int64, message string) (*AddCommentOperation, error) {
+func AddComment(b Interface, author identity.Interface, unixTime int64, message string) (*AddCommentOperation, error) {
 	return AddCommentWithFiles(b, author, unixTime, message, nil)
 }
 
-func AddCommentWithFiles(b Interface, author Person, unixTime int64, message string, files []git.Hash) (*AddCommentOperation, error) {
+func AddCommentWithFiles(b Interface, author identity.Interface, unixTime int64, message string, files []git.Hash) (*AddCommentOperation, error) {
 	addCommentOp := NewAddCommentOp(author, unixTime, message, files)
 	if err := addCommentOp.Validate(); err != nil {
 		return nil, err

bug/op_create.go ๐Ÿ”—

@@ -4,6 +4,8 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/MichaelMure/git-bug/identity"
+
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/text"
 )
@@ -84,7 +86,7 @@ func (op *CreateOperation) Validate() error {
 // Sign post method for gqlgen
 func (op *CreateOperation) IsAuthored() {}
 
-func NewCreateOp(author Person, unixTime int64, title, message string, files []git.Hash) *CreateOperation {
+func NewCreateOp(author identity.Interface, unixTime int64, title, message string, files []git.Hash) *CreateOperation {
 	return &CreateOperation{
 		OpBase:  newOpBase(CreateOp, author, unixTime),
 		Title:   title,
@@ -99,11 +101,11 @@ type CreateTimelineItem struct {
 }
 
 // Convenience function to apply the operation
-func Create(author Person, unixTime int64, title, message string) (*Bug, *CreateOperation, error) {
+func Create(author identity.Interface, unixTime int64, title, message string) (*Bug, *CreateOperation, error) {
 	return CreateWithFiles(author, unixTime, title, message, nil)
 }
 
-func CreateWithFiles(author Person, unixTime int64, title, message string, files []git.Hash) (*Bug, *CreateOperation, error) {
+func CreateWithFiles(author identity.Interface, unixTime int64, title, message string, files []git.Hash) (*Bug, *CreateOperation, error) {
 	newBug := NewBug()
 	createOp := NewCreateOp(author, unixTime, title, message, files)
 

bug/op_create_test.go ๐Ÿ”—

@@ -4,16 +4,14 @@ import (
 	"testing"
 	"time"
 
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/go-test/deep"
 )
 
 func TestCreate(t *testing.T) {
 	snapshot := Snapshot{}
 
-	var rene = Person{
-		Name:  "Renรฉ Descartes",
-		Email: "rene@descartes.fr",
-	}
+	var rene = identity.NewBare("Renรฉ Descartes", "rene@descartes.fr")
 
 	unix := time.Now().Unix()
 

bug/op_edit_comment.go ๐Ÿ”—

@@ -3,6 +3,8 @@ package bug
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/identity"
+
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/text"
 )
@@ -95,7 +97,7 @@ func (op *EditCommentOperation) Validate() error {
 // Sign post method for gqlgen
 func (op *EditCommentOperation) IsAuthored() {}
 
-func NewEditCommentOp(author Person, unixTime int64, target git.Hash, message string, files []git.Hash) *EditCommentOperation {
+func NewEditCommentOp(author identity.Interface, unixTime int64, target git.Hash, message string, files []git.Hash) *EditCommentOperation {
 	return &EditCommentOperation{
 		OpBase:  newOpBase(EditCommentOp, author, unixTime),
 		Target:  target,
@@ -105,11 +107,11 @@ func NewEditCommentOp(author Person, unixTime int64, target git.Hash, message st
 }
 
 // Convenience function to apply the operation
-func EditComment(b Interface, author Person, unixTime int64, target git.Hash, message string) (*EditCommentOperation, error) {
+func EditComment(b Interface, author identity.Interface, unixTime int64, target git.Hash, message string) (*EditCommentOperation, error) {
 	return EditCommentWithFiles(b, author, unixTime, target, message, nil)
 }
 
-func EditCommentWithFiles(b Interface, author Person, unixTime int64, target git.Hash, message string, files []git.Hash) (*EditCommentOperation, error) {
+func EditCommentWithFiles(b Interface, author identity.Interface, unixTime int64, target git.Hash, message string, files []git.Hash) (*EditCommentOperation, error) {
 	editCommentOp := NewEditCommentOp(author, unixTime, target, message, files)
 	if err := editCommentOp.Validate(); err != nil {
 		return nil, err

bug/op_edit_comment_test.go ๐Ÿ”—

@@ -4,16 +4,14 @@ import (
 	"testing"
 	"time"
 
+	"github.com/MichaelMure/git-bug/identity"
 	"gotest.tools/assert"
 )
 
 func TestEdit(t *testing.T) {
 	snapshot := Snapshot{}
 
-	var rene = Person{
-		Name:  "Renรฉ Descartes",
-		Email: "rene@descartes.fr",
-	}
+	var rene = identity.NewBare("Renรฉ Descartes", "rene@descartes.fr")
 
 	unix := time.Now().Unix()
 

bug/op_label_change.go ๐Ÿ”—

@@ -4,6 +4,8 @@ import (
 	"fmt"
 	"sort"
 
+	"github.com/MichaelMure/git-bug/identity"
+
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/pkg/errors"
 )
@@ -100,7 +102,7 @@ func (op *LabelChangeOperation) Validate() error {
 // Sign post method for gqlgen
 func (op *LabelChangeOperation) IsAuthored() {}
 
-func NewLabelChangeOperation(author Person, unixTime int64, added, removed []Label) *LabelChangeOperation {
+func NewLabelChangeOperation(author identity.Interface, unixTime int64, added, removed []Label) *LabelChangeOperation {
 	return &LabelChangeOperation{
 		OpBase:  newOpBase(LabelChangeOp, author, unixTime),
 		Added:   added,
@@ -110,7 +112,7 @@ func NewLabelChangeOperation(author Person, unixTime int64, added, removed []Lab
 
 type LabelChangeTimelineItem struct {
 	hash     git.Hash
-	Author   Person
+	Author   identity.Interface
 	UnixTime Timestamp
 	Added    []Label
 	Removed  []Label
@@ -121,7 +123,7 @@ func (l LabelChangeTimelineItem) Hash() git.Hash {
 }
 
 // ChangeLabels is a convenience function to apply the operation
-func ChangeLabels(b Interface, author Person, unixTime int64, add, remove []string) ([]LabelChangeResult, *LabelChangeOperation, error) {
+func ChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string) ([]LabelChangeResult, *LabelChangeOperation, error) {
 	var added, removed []Label
 	var results []LabelChangeResult
 

bug/op_noop.go ๐Ÿ”—

@@ -1,6 +1,9 @@
 package bug
 
-import "github.com/MichaelMure/git-bug/util/git"
+import (
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/util/git"
+)
 
 var _ Operation = &NoOpOperation{}
 
@@ -30,14 +33,14 @@ func (op *NoOpOperation) Validate() error {
 // Sign post method for gqlgen
 func (op *NoOpOperation) IsAuthored() {}
 
-func NewNoOpOp(author Person, unixTime int64) *NoOpOperation {
+func NewNoOpOp(author identity.Interface, unixTime int64) *NoOpOperation {
 	return &NoOpOperation{
 		OpBase: newOpBase(NoOpOp, author, unixTime),
 	}
 }
 
 // Convenience function to apply the operation
-func NoOp(b Interface, author Person, unixTime int64, metadata map[string]string) (*NoOpOperation, error) {
+func NoOp(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*NoOpOperation, error) {
 	op := NewNoOpOp(author, unixTime)
 
 	for key, value := range metadata {

bug/op_set_metadata.go ๐Ÿ”—

@@ -1,6 +1,9 @@
 package bug
 
-import "github.com/MichaelMure/git-bug/util/git"
+import (
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/util/git"
+)
 
 var _ Operation = &SetMetadataOperation{}
 
@@ -56,7 +59,7 @@ func (op *SetMetadataOperation) Validate() error {
 // Sign post method for gqlgen
 func (op *SetMetadataOperation) IsAuthored() {}
 
-func NewSetMetadataOp(author Person, unixTime int64, target git.Hash, newMetadata map[string]string) *SetMetadataOperation {
+func NewSetMetadataOp(author identity.Interface, unixTime int64, target git.Hash, newMetadata map[string]string) *SetMetadataOperation {
 	return &SetMetadataOperation{
 		OpBase:      newOpBase(SetMetadataOp, author, unixTime),
 		Target:      target,
@@ -65,7 +68,7 @@ func NewSetMetadataOp(author Person, unixTime int64, target git.Hash, newMetadat
 }
 
 // Convenience function to apply the operation
-func SetMetadata(b Interface, author Person, unixTime int64, target git.Hash, newMetadata map[string]string) (*SetMetadataOperation, error) {
+func SetMetadata(b Interface, author identity.Interface, unixTime int64, target git.Hash, newMetadata map[string]string) (*SetMetadataOperation, error) {
 	SetMetadataOp := NewSetMetadataOp(author, unixTime, target, newMetadata)
 	if err := SetMetadataOp.Validate(); err != nil {
 		return nil, err

bug/op_set_metadata_test.go ๐Ÿ”—

@@ -4,16 +4,14 @@ import (
 	"testing"
 	"time"
 
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestSetMetadata(t *testing.T) {
 	snapshot := Snapshot{}
 
-	var rene = Person{
-		Name:  "Renรฉ Descartes",
-		Email: "rene@descartes.fr",
-	}
+	var rene = identity.NewBare("Renรฉ Descartes", "rene@descartes.fr")
 
 	unix := time.Now().Unix()
 

bug/op_set_status.go ๐Ÿ”—

@@ -1,6 +1,7 @@
 package bug
 
 import (
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/pkg/errors"
 )
@@ -56,7 +57,7 @@ func (op *SetStatusOperation) Validate() error {
 // Sign post method for gqlgen
 func (op *SetStatusOperation) IsAuthored() {}
 
-func NewSetStatusOp(author Person, unixTime int64, status Status) *SetStatusOperation {
+func NewSetStatusOp(author identity.Interface, unixTime int64, status Status) *SetStatusOperation {
 	return &SetStatusOperation{
 		OpBase: newOpBase(SetStatusOp, author, unixTime),
 		Status: status,
@@ -65,7 +66,7 @@ func NewSetStatusOp(author Person, unixTime int64, status Status) *SetStatusOper
 
 type SetStatusTimelineItem struct {
 	hash     git.Hash
-	Author   Person
+	Author   identity.Interface
 	UnixTime Timestamp
 	Status   Status
 }
@@ -75,7 +76,7 @@ func (s SetStatusTimelineItem) Hash() git.Hash {
 }
 
 // Convenience function to apply the operation
-func Open(b Interface, author Person, unixTime int64) (*SetStatusOperation, error) {
+func Open(b Interface, author identity.Interface, unixTime int64) (*SetStatusOperation, error) {
 	op := NewSetStatusOp(author, unixTime, OpenStatus)
 	if err := op.Validate(); err != nil {
 		return nil, err
@@ -85,7 +86,7 @@ func Open(b Interface, author Person, unixTime int64) (*SetStatusOperation, erro
 }
 
 // Convenience function to apply the operation
-func Close(b Interface, author Person, unixTime int64) (*SetStatusOperation, error) {
+func Close(b Interface, author identity.Interface, unixTime int64) (*SetStatusOperation, error) {
 	op := NewSetStatusOp(author, unixTime, ClosedStatus)
 	if err := op.Validate(); err != nil {
 		return nil, err

bug/op_set_title.go ๐Ÿ”—

@@ -4,6 +4,8 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/MichaelMure/git-bug/identity"
+
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/text"
 )
@@ -77,7 +79,7 @@ func (op *SetTitleOperation) Validate() error {
 // Sign post method for gqlgen
 func (op *SetTitleOperation) IsAuthored() {}
 
-func NewSetTitleOp(author Person, unixTime int64, title string, was string) *SetTitleOperation {
+func NewSetTitleOp(author identity.Interface, unixTime int64, title string, was string) *SetTitleOperation {
 	return &SetTitleOperation{
 		OpBase: newOpBase(SetTitleOp, author, unixTime),
 		Title:  title,
@@ -87,7 +89,7 @@ func NewSetTitleOp(author Person, unixTime int64, title string, was string) *Set
 
 type SetTitleTimelineItem struct {
 	hash     git.Hash
-	Author   Person
+	Author   identity.Interface
 	UnixTime Timestamp
 	Title    string
 	Was      string
@@ -98,7 +100,7 @@ func (s SetTitleTimelineItem) Hash() git.Hash {
 }
 
 // Convenience function to apply the operation
-func SetTitle(b Interface, author Person, unixTime int64, title string) (*SetTitleOperation, error) {
+func SetTitle(b Interface, author identity.Interface, unixTime int64, title string) (*SetTitleOperation, error) {
 	it := NewOperationIterator(b)
 
 	var lastTitleOp Operation

bug/operation.go ๐Ÿ”—

@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/MichaelMure/git-bug/identity"
+
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/pkg/errors"
 )
@@ -74,21 +76,23 @@ func hashOperation(op Operation) (git.Hash, error) {
 	return base.hash, nil
 }
 
+// TODO: serialization with identity
+
 // OpBase implement the common code for all operations
 type OpBase struct {
-	OperationType OperationType     `json:"type"`
-	Author        Person            `json:"author"`
-	UnixTime      int64             `json:"timestamp"`
-	Metadata      map[string]string `json:"metadata,omitempty"`
+	OperationType OperationType
+	Author        identity.Interface
+	UnixTime      int64
+	Metadata      map[string]string
 	// Not serialized. Store the op's hash in memory.
 	hash git.Hash
-	// Not serialized. Store the extra metadata compiled from SetMetadataOperation
-	// in memory.
+	// Not serialized. Store the extra metadata in memory,
+	// compiled from SetMetadataOperation.
 	extraMetadata map[string]string
 }
 
 // newOpBase is the constructor for an OpBase
-func newOpBase(opType OperationType, author Person, unixTime int64) OpBase {
+func newOpBase(opType OperationType, author identity.Interface, unixTime int64) OpBase {
 	return OpBase{
 		OperationType: opType,
 		Author:        author,
@@ -96,6 +100,34 @@ func newOpBase(opType OperationType, author Person, unixTime int64) OpBase {
 	}
 }
 
+type opBaseJson struct {
+	OperationType OperationType     `json:"type"`
+	UnixTime      int64             `json:"timestamp"`
+	Metadata      map[string]string `json:"metadata,omitempty"`
+}
+
+func (op *OpBase) MarshalJSON() ([]byte, error) {
+	return json.Marshal(opBaseJson{
+		OperationType: op.OperationType,
+		UnixTime:      op.UnixTime,
+		Metadata:      op.Metadata,
+	})
+}
+
+func (op *OpBase) UnmarshalJSON(data []byte) error {
+	aux := opBaseJson{}
+
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	op.OperationType = aux.OperationType
+	op.UnixTime = aux.UnixTime
+	op.Metadata = aux.Metadata
+
+	return nil
+}
+
 // Time return the time when the operation was added
 func (op *OpBase) Time() time.Time {
 	return time.Unix(op.UnixTime, 0)
@@ -125,6 +157,10 @@ func opBaseValidate(op Operation, opType OperationType) error {
 		return fmt.Errorf("time not set")
 	}
 
+	if op.base().Author == nil {
+		return fmt.Errorf("author not set")
+	}
+
 	if err := op.base().Author.Validate(); err != nil {
 		return errors.Wrap(err, "author")
 	}

bug/operation_iterator_test.go ๐Ÿ”—

@@ -1,17 +1,14 @@
 package bug
 
 import (
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"testing"
 	"time"
 )
 
 var (
-	rene = Person{
-		Name:  "Renรฉ Descartes",
-		Email: "rene@descartes.fr",
-	}
-
+	rene = identity.NewBare("Renรฉ Descartes", "rene@descartes.fr")
 	unix = time.Now().Unix()
 
 	createOp      = NewCreateOp(rene, unix, "title", "message", nil)

bug/operation_pack.go ๐Ÿ”—

@@ -20,7 +20,7 @@ const formatVersion = 1
 type OperationPack struct {
 	Operations []Operation
 
-	// Private field so not serialized by gob
+	// Private field so not serialized
 	commitHash git.Hash
 }
 
@@ -57,6 +57,7 @@ func (opp *OperationPack) UnmarshalJSON(data []byte) error {
 			return err
 		}
 
+		// delegate to specialized unmarshal function
 		op, err := opp.unmarshalOp(raw, t.OperationType)
 		if err != nil {
 			return err
@@ -73,28 +74,36 @@ func (opp *OperationPack) UnmarshalJSON(data []byte) error {
 
 func (opp *OperationPack) unmarshalOp(raw []byte, _type OperationType) (Operation, error) {
 	switch _type {
+	case AddCommentOp:
+		op := &AddCommentOperation{}
+		err := json.Unmarshal(raw, &op)
+		return op, err
 	case CreateOp:
 		op := &CreateOperation{}
 		err := json.Unmarshal(raw, &op)
 		return op, err
-	case SetTitleOp:
-		op := &SetTitleOperation{}
+	case EditCommentOp:
+		op := &EditCommentOperation{}
 		err := json.Unmarshal(raw, &op)
 		return op, err
-	case AddCommentOp:
-		op := &AddCommentOperation{}
+	case LabelChangeOp:
+		op := &LabelChangeOperation{}
 		err := json.Unmarshal(raw, &op)
 		return op, err
-	case SetStatusOp:
-		op := &SetStatusOperation{}
+	case NoOpOp:
+		op := &NoOpOperation{}
 		err := json.Unmarshal(raw, &op)
 		return op, err
-	case LabelChangeOp:
-		op := &LabelChangeOperation{}
+	case SetMetadataOp:
+		op := &SetMetadataOperation{}
 		err := json.Unmarshal(raw, &op)
 		return op, err
-	case EditCommentOp:
-		op := &EditCommentOperation{}
+	case SetStatusOp:
+		op := &SetStatusOperation{}
+		err := json.Unmarshal(raw, &op)
+		return op, err
+	case SetTitleOp:
+		op := &SetTitleOperation{}
 		err := json.Unmarshal(raw, &op)
 		return op, err
 	default:

bug/operation_test.go ๐Ÿ”—

@@ -3,6 +3,7 @@ package bug
 import (
 	"testing"
 
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/stretchr/testify/require"
@@ -25,11 +26,11 @@ func TestValidate(t *testing.T) {
 
 	bad := []Operation{
 		// opbase
-		NewSetStatusOp(Person{Name: "", Email: "rene@descartes.fr"}, unix, ClosedStatus),
-		NewSetStatusOp(Person{Name: "Renรฉ Descartes\u001b", Email: "rene@descartes.fr"}, unix, ClosedStatus),
-		NewSetStatusOp(Person{Name: "Renรฉ Descartes", Email: "rene@descartes.fr\u001b"}, unix, ClosedStatus),
-		NewSetStatusOp(Person{Name: "Renรฉ \nDescartes", Email: "rene@descartes.fr"}, unix, ClosedStatus),
-		NewSetStatusOp(Person{Name: "Renรฉ Descartes", Email: "rene@\ndescartes.fr"}, unix, ClosedStatus),
+		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),
 		&CreateOperation{OpBase: OpBase{
 			Author:        rene,
 			UnixTime:      0,

bug/person.go ๐Ÿ”—

@@ -1,95 +0,0 @@
-package bug
-
-import (
-	"errors"
-	"fmt"
-	"strings"
-
-	"github.com/MichaelMure/git-bug/repository"
-	"github.com/MichaelMure/git-bug/util/text"
-)
-
-type Person struct {
-	Name      string `json:"name"`
-	Email     string `json:"email"`
-	Login     string `json:"login"`
-	AvatarUrl string `json:"avatar_url"`
-}
-
-// GetUser will query the repository for user detail and build the corresponding Person
-func GetUser(repo repository.Repo) (Person, error) {
-	name, err := repo.GetUserName()
-	if err != nil {
-		return Person{}, err
-	}
-	if name == "" {
-		return Person{}, errors.New("User name is not configured in git yet. Please use `git config --global user.name \"John Doe\"`")
-	}
-
-	email, err := repo.GetUserEmail()
-	if err != nil {
-		return Person{}, err
-	}
-	if email == "" {
-		return Person{}, errors.New("User name is not configured in git yet. Please use `git config --global user.email johndoe@example.com`")
-	}
-
-	return Person{Name: name, Email: email}, nil
-}
-
-// Match tell is the Person match the given query string
-func (p Person) Match(query string) bool {
-	query = strings.ToLower(query)
-
-	return strings.Contains(strings.ToLower(p.Name), query) ||
-		strings.Contains(strings.ToLower(p.Login), query)
-}
-
-func (p Person) Validate() error {
-	if text.Empty(p.Name) && text.Empty(p.Login) {
-		return fmt.Errorf("either name or login should be set")
-	}
-
-	if strings.Contains(p.Name, "\n") {
-		return fmt.Errorf("name should be a single line")
-	}
-
-	if !text.Safe(p.Name) {
-		return fmt.Errorf("name is not fully printable")
-	}
-
-	if strings.Contains(p.Login, "\n") {
-		return fmt.Errorf("login should be a single line")
-	}
-
-	if !text.Safe(p.Login) {
-		return fmt.Errorf("login is not fully printable")
-	}
-
-	if strings.Contains(p.Email, "\n") {
-		return fmt.Errorf("email should be a single line")
-	}
-
-	if !text.Safe(p.Email) {
-		return fmt.Errorf("email is not fully printable")
-	}
-
-	if p.AvatarUrl != "" && !text.ValidUrl(p.AvatarUrl) {
-		return fmt.Errorf("avatarUrl is not a valid URL")
-	}
-
-	return nil
-}
-
-func (p Person) DisplayName() string {
-	switch {
-	case p.Name == "" && p.Login != "":
-		return p.Login
-	case p.Name != "" && p.Login == "":
-		return p.Name
-	case p.Name != "" && p.Login != "":
-		return fmt.Sprintf("%s (%s)", p.Name, p.Login)
-	}
-
-	panic("invalid person data")
-}

bug/snapshot.go ๐Ÿ”—

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 )
 
@@ -15,7 +16,7 @@ type Snapshot struct {
 	Title     string
 	Comments  []Comment
 	Labels    []Label
-	Author    Person
+	Author    identity.Interface
 	CreatedAt time.Time
 
 	Timeline []TimelineItem

bug/timeline.go ๐Ÿ”—

@@ -3,6 +3,7 @@ package bug
 import (
 	"strings"
 
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 )
 
@@ -15,7 +16,7 @@ type TimelineItem interface {
 type CommentHistoryStep struct {
 	// The author of the edition, not necessarily the same as the author of the
 	// original comment
-	Author Person
+	Author identity.Interface
 	// The new message
 	Message  string
 	UnixTime Timestamp
@@ -24,7 +25,7 @@ type CommentHistoryStep struct {
 // CommentTimelineItem is a TimelineItem that holds a Comment and its edition history
 type CommentTimelineItem struct {
 	hash      git.Hash
-	Author    Person
+	Author    identity.Interface
 	Message   string
 	Files     []git.Hash
 	CreatedAt Timestamp

cache/bug_cache.go ๐Ÿ”—

@@ -5,6 +5,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/MichaelMure/git-bug/identity"
+
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/util/git"
 )
@@ -87,7 +89,7 @@ func (c *BugCache) AddComment(message string) error {
 }
 
 func (c *BugCache) AddCommentWithFiles(message string, files []git.Hash) error {
-	author, err := bug.GetUser(c.repoCache.repo)
+	author, err := identity.GetIdentity(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
@@ -95,7 +97,7 @@ func (c *BugCache) AddCommentWithFiles(message string, files []git.Hash) error {
 	return c.AddCommentRaw(author, time.Now().Unix(), message, files, nil)
 }
 
-func (c *BugCache) AddCommentRaw(author bug.Person, unixTime int64, message string, files []git.Hash, metadata map[string]string) error {
+func (c *BugCache) AddCommentRaw(author *identity.Identity, unixTime int64, message string, files []git.Hash, metadata map[string]string) error {
 	op, err := bug.AddCommentWithFiles(c.bug, author, unixTime, message, files)
 	if err != nil {
 		return err
@@ -109,7 +111,7 @@ func (c *BugCache) AddCommentRaw(author bug.Person, unixTime int64, message stri
 }
 
 func (c *BugCache) ChangeLabels(added []string, removed []string) ([]bug.LabelChangeResult, error) {
-	author, err := bug.GetUser(c.repoCache.repo)
+	author, err := identity.GetIdentity(c.repoCache.repo)
 	if err != nil {
 		return nil, err
 	}
@@ -117,7 +119,7 @@ func (c *BugCache) ChangeLabels(added []string, removed []string) ([]bug.LabelCh
 	return c.ChangeLabelsRaw(author, time.Now().Unix(), added, removed, nil)
 }
 
-func (c *BugCache) ChangeLabelsRaw(author bug.Person, unixTime int64, added []string, removed []string, metadata map[string]string) ([]bug.LabelChangeResult, error) {
+func (c *BugCache) ChangeLabelsRaw(author *identity.Identity, unixTime int64, added []string, removed []string, metadata map[string]string) ([]bug.LabelChangeResult, error) {
 	changes, op, err := bug.ChangeLabels(c.bug, author, unixTime, added, removed)
 	if err != nil {
 		return changes, err
@@ -136,7 +138,7 @@ func (c *BugCache) ChangeLabelsRaw(author bug.Person, unixTime int64, added []st
 }
 
 func (c *BugCache) Open() error {
-	author, err := bug.GetUser(c.repoCache.repo)
+	author, err := identity.GetIdentity(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
@@ -144,7 +146,7 @@ func (c *BugCache) Open() error {
 	return c.OpenRaw(author, time.Now().Unix(), nil)
 }
 
-func (c *BugCache) OpenRaw(author bug.Person, unixTime int64, metadata map[string]string) error {
+func (c *BugCache) OpenRaw(author *identity.Identity, unixTime int64, metadata map[string]string) error {
 	op, err := bug.Open(c.bug, author, unixTime)
 	if err != nil {
 		return err
@@ -158,7 +160,7 @@ func (c *BugCache) OpenRaw(author bug.Person, unixTime int64, metadata map[strin
 }
 
 func (c *BugCache) Close() error {
-	author, err := bug.GetUser(c.repoCache.repo)
+	author, err := identity.GetIdentity(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
@@ -166,7 +168,7 @@ func (c *BugCache) Close() error {
 	return c.CloseRaw(author, time.Now().Unix(), nil)
 }
 
-func (c *BugCache) CloseRaw(author bug.Person, unixTime int64, metadata map[string]string) error {
+func (c *BugCache) CloseRaw(author *identity.Identity, unixTime int64, metadata map[string]string) error {
 	op, err := bug.Close(c.bug, author, unixTime)
 	if err != nil {
 		return err
@@ -180,7 +182,7 @@ func (c *BugCache) CloseRaw(author bug.Person, unixTime int64, metadata map[stri
 }
 
 func (c *BugCache) SetTitle(title string) error {
-	author, err := bug.GetUser(c.repoCache.repo)
+	author, err := identity.GetIdentity(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
@@ -188,7 +190,7 @@ func (c *BugCache) SetTitle(title string) error {
 	return c.SetTitleRaw(author, time.Now().Unix(), title, nil)
 }
 
-func (c *BugCache) SetTitleRaw(author bug.Person, unixTime int64, title string, metadata map[string]string) error {
+func (c *BugCache) SetTitleRaw(author *identity.Identity, unixTime int64, title string, metadata map[string]string) error {
 	op, err := bug.SetTitle(c.bug, author, unixTime, title)
 	if err != nil {
 		return err
@@ -202,7 +204,7 @@ func (c *BugCache) SetTitleRaw(author bug.Person, unixTime int64, title string,
 }
 
 func (c *BugCache) EditComment(target git.Hash, message string) error {
-	author, err := bug.GetUser(c.repoCache.repo)
+	author, err := identity.GetIdentity(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
@@ -210,7 +212,7 @@ func (c *BugCache) EditComment(target git.Hash, message string) error {
 	return c.EditCommentRaw(author, time.Now().Unix(), target, message, nil)
 }
 
-func (c *BugCache) EditCommentRaw(author bug.Person, unixTime int64, target git.Hash, message string, metadata map[string]string) error {
+func (c *BugCache) EditCommentRaw(author *identity.Identity, unixTime int64, target git.Hash, message string, metadata map[string]string) error {
 	op, err := bug.EditComment(c.bug, author, unixTime, target, message)
 	if err != nil {
 		return err

cache/bug_excerpt.go ๐Ÿ”—

@@ -3,6 +3,8 @@ package cache
 import (
 	"encoding/gob"
 
+	"github.com/MichaelMure/git-bug/identity"
+
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
@@ -18,7 +20,7 @@ type BugExcerpt struct {
 	EditUnixTime      int64
 
 	Status bug.Status
-	Author bug.Person
+	Author *identity.Identity
 	Labels []bug.Label
 
 	CreateMetadata map[string]string

cache/repo_cache.go ๐Ÿ”—

@@ -14,6 +14,7 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/process"
@@ -376,7 +377,7 @@ func (c *RepoCache) NewBug(title string, message string) (*BugCache, error) {
 // NewBugWithFiles create a new bug with attached files for the message
 // The new bug is written in the repository (commit)
 func (c *RepoCache) NewBugWithFiles(title string, message string, files []git.Hash) (*BugCache, error) {
-	author, err := bug.GetUser(c.repo)
+	author, err := identity.GetIdentity(c.repo)
 	if err != nil {
 		return nil, err
 	}
@@ -387,7 +388,7 @@ func (c *RepoCache) NewBugWithFiles(title string, message string, files []git.Ha
 // NewBugWithFilesMeta create a new bug with attached files for the message, as
 // well as metadata for the Create operation.
 // The new bug is written in the repository (commit)
-func (c *RepoCache) NewBugRaw(author bug.Person, unixTime int64, title string, message string, files []git.Hash, metadata map[string]string) (*BugCache, error) {
+func (c *RepoCache) NewBugRaw(author *identity.Identity, unixTime int64, title string, message string, files []git.Hash, metadata map[string]string) (*BugCache, error) {
 	b, op, err := bug.CreateWithFiles(author, unixTime, title, message, files)
 	if err != nil {
 		return nil, err

commands/id.go ๐Ÿ”—

@@ -0,0 +1,35 @@
+package commands
+
+import (
+	"fmt"
+
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/spf13/cobra"
+)
+
+func runId(cmd *cobra.Command, args []string) error {
+	id, err := identity.GetIdentity(repo)
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf("Id: %s\n", id.Id())
+	fmt.Printf("Identity: %s\n", id.DisplayName())
+	fmt.Printf("Name: %s\n", id.Name())
+	fmt.Printf("Login: %s\n", id.Login())
+	fmt.Printf("Email: %s\n", id.Email())
+	fmt.Printf("Protected: %v\n", id.IsProtected())
+
+	return nil
+}
+
+var idCmd = &cobra.Command{
+	Use:     "id",
+	Short:   "Display or change the user identity",
+	PreRunE: loadRepo,
+	RunE:    runId,
+}
+
+func init() {
+	RootCmd.AddCommand(idCmd)
+}

graphql/gqlgen.yml ๐Ÿ”—

@@ -1,4 +1,4 @@
-schema: "*.graphql"
+schema: "schema/*.graphql"
 exec:
   filename: graph/gen_graph.go
 model:
@@ -13,17 +13,8 @@ models:
     model: github.com/MichaelMure/git-bug/bug.Snapshot
   Comment:
     model: github.com/MichaelMure/git-bug/bug.Comment
-  Person:
-    model: github.com/MichaelMure/git-bug/bug.Person
-    fields:
-      name:
-        resolver: true
-      email:
-        resolver: true
-      login:
-        resolver: true
-      avatarUrl:
-        resolver: true
+  Identity:
+    model: github.com/MichaelMure/git-bug/identity.Identity
   Label:
     model: github.com/MichaelMure/git-bug/bug.Label
   Hash:

graphql/graph/gen_graph.go ๐Ÿ”—

@@ -15,6 +15,7 @@ import (
 	"github.com/99designs/gqlgen/graphql/introspection"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/vektah/gqlparser"
 	"github.com/vektah/gqlparser/ast"
@@ -46,7 +47,6 @@ type ResolverRoot interface {
 	LabelChangeOperation() LabelChangeOperationResolver
 	LabelChangeTimelineItem() LabelChangeTimelineItemResolver
 	Mutation() MutationResolver
-	Person() PersonResolver
 	Query() QueryResolver
 	Repository() RepositoryResolver
 	SetStatusOperation() SetStatusOperationResolver
@@ -158,6 +158,14 @@ type ComplexityRoot struct {
 		Files   func(childComplexity int) int
 	}
 
+	Identity struct {
+		Name        func(childComplexity int) int
+		Email       func(childComplexity int) int
+		Login       func(childComplexity int) int
+		DisplayName func(childComplexity int) int
+		AvatarUrl   func(childComplexity int) int
+	}
+
 	LabelChangeOperation struct {
 		Hash    func(childComplexity int) int
 		Author  func(childComplexity int) int
@@ -203,14 +211,6 @@ type ComplexityRoot struct {
 		EndCursor       func(childComplexity int) int
 	}
 
-	Person struct {
-		Name        func(childComplexity int) int
-		Email       func(childComplexity int) int
-		Login       func(childComplexity int) int
-		DisplayName func(childComplexity int) int
-		AvatarUrl   func(childComplexity int) int
-	}
-
 	Query struct {
 		DefaultRepository func(childComplexity int) int
 		Repository        func(childComplexity int, id string) int
@@ -307,13 +307,6 @@ type MutationResolver interface {
 	SetTitle(ctx context.Context, repoRef *string, prefix string, title string) (bug.Snapshot, error)
 	Commit(ctx context.Context, repoRef *string, prefix string) (bug.Snapshot, error)
 }
-type PersonResolver interface {
-	Name(ctx context.Context, obj *bug.Person) (*string, error)
-	Email(ctx context.Context, obj *bug.Person) (*string, error)
-	Login(ctx context.Context, obj *bug.Person) (*string, error)
-
-	AvatarURL(ctx context.Context, obj *bug.Person) (*string, error)
-}
 type QueryResolver interface {
 	DefaultRepository(ctx context.Context) (*models.Repository, error)
 	Repository(ctx context.Context, id string) (*models.Repository, error)
@@ -1453,6 +1446,41 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.EditCommentOperation.Files(childComplexity), true
 
+	case "Identity.name":
+		if e.complexity.Identity.Name == nil {
+			break
+		}
+
+		return e.complexity.Identity.Name(childComplexity), true
+
+	case "Identity.email":
+		if e.complexity.Identity.Email == nil {
+			break
+		}
+
+		return e.complexity.Identity.Email(childComplexity), true
+
+	case "Identity.login":
+		if e.complexity.Identity.Login == nil {
+			break
+		}
+
+		return e.complexity.Identity.Login(childComplexity), true
+
+	case "Identity.displayName":
+		if e.complexity.Identity.DisplayName == nil {
+			break
+		}
+
+		return e.complexity.Identity.DisplayName(childComplexity), true
+
+	case "Identity.avatarUrl":
+		if e.complexity.Identity.AvatarUrl == nil {
+			break
+		}
+
+		return e.complexity.Identity.AvatarUrl(childComplexity), true
+
 	case "LabelChangeOperation.hash":
 		if e.complexity.LabelChangeOperation.Hash == nil {
 			break
@@ -1677,41 +1705,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.PageInfo.EndCursor(childComplexity), true
 
-	case "Person.name":
-		if e.complexity.Person.Name == nil {
-			break
-		}
-
-		return e.complexity.Person.Name(childComplexity), true
-
-	case "Person.email":
-		if e.complexity.Person.Email == nil {
-			break
-		}
-
-		return e.complexity.Person.Email(childComplexity), true
-
-	case "Person.login":
-		if e.complexity.Person.Login == nil {
-			break
-		}
-
-		return e.complexity.Person.Login(childComplexity), true
-
-	case "Person.displayName":
-		if e.complexity.Person.DisplayName == nil {
-			break
-		}
-
-		return e.complexity.Person.DisplayName(childComplexity), true
-
-	case "Person.avatarUrl":
-		if e.complexity.Person.AvatarUrl == nil {
-			break
-		}
-
-		return e.complexity.Person.AvatarUrl(childComplexity), true
-
 	case "Query.defaultRepository":
 		if e.complexity.Query.DefaultRepository == nil {
 			break
@@ -2072,11 +2065,18 @@ func (ec *executionContext) _AddCommentOperation_author(ctx context.Context, fie
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -2296,11 +2296,18 @@ func (ec *executionContext) _AddCommentTimelineItem_author(ctx context.Context,
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -2800,11 +2807,18 @@ func (ec *executionContext) _Bug_author(ctx context.Context, field graphql.Colle
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -3334,11 +3348,18 @@ func (ec *executionContext) _Comment_author(ctx context.Context, field graphql.C
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -3916,11 +3937,18 @@ func (ec *executionContext) _CreateOperation_author(ctx context.Context, field g
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -4167,11 +4195,18 @@ func (ec *executionContext) _CreateTimelineItem_author(ctx context.Context, fiel
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -4513,11 +4548,18 @@ func (ec *executionContext) _EditCommentOperation_author(ctx context.Context, fi
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -4637,6 +4679,167 @@ func (ec *executionContext) _EditCommentOperation_files(ctx context.Context, fie
 	return arr1
 }
 
+var identityImplementors = []string{"Identity"}
+
+// nolint: gocyclo, errcheck, gas, goconst
+func (ec *executionContext) _Identity(ctx context.Context, sel ast.SelectionSet, obj *identity.Identity) graphql.Marshaler {
+	fields := graphql.CollectFields(ctx, sel, identityImplementors)
+
+	out := graphql.NewOrderedMap(len(fields))
+	invalid := false
+	for i, field := range fields {
+		out.Keys[i] = field.Alias
+
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("Identity")
+		case "name":
+			out.Values[i] = ec._Identity_name(ctx, field, obj)
+		case "email":
+			out.Values[i] = ec._Identity_email(ctx, field, obj)
+		case "login":
+			out.Values[i] = ec._Identity_login(ctx, field, obj)
+		case "displayName":
+			out.Values[i] = ec._Identity_displayName(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				invalid = true
+			}
+		case "avatarUrl":
+			out.Values[i] = ec._Identity_avatarUrl(ctx, field, obj)
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+
+	if invalid {
+		return graphql.Null
+	}
+	return out
+}
+
+// nolint: vetshadow
+func (ec *executionContext) _Identity_name(ctx context.Context, field graphql.CollectedField, obj *identity.Identity) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object: "Identity",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Name(), nil
+	})
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return graphql.MarshalString(res)
+}
+
+// nolint: vetshadow
+func (ec *executionContext) _Identity_email(ctx context.Context, field graphql.CollectedField, obj *identity.Identity) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object: "Identity",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Email(), nil
+	})
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return graphql.MarshalString(res)
+}
+
+// nolint: vetshadow
+func (ec *executionContext) _Identity_login(ctx context.Context, field graphql.CollectedField, obj *identity.Identity) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object: "Identity",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Login(), nil
+	})
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return graphql.MarshalString(res)
+}
+
+// nolint: vetshadow
+func (ec *executionContext) _Identity_displayName(ctx context.Context, field graphql.CollectedField, obj *identity.Identity) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object: "Identity",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.DisplayName(), nil
+	})
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return graphql.MarshalString(res)
+}
+
+// nolint: vetshadow
+func (ec *executionContext) _Identity_avatarUrl(ctx context.Context, field graphql.CollectedField, obj *identity.Identity) graphql.Marshaler {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
+	rctx := &graphql.ResolverContext{
+		Object: "Identity",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.AvatarURL(), nil
+	})
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return graphql.MarshalString(res)
+}
+
 var labelChangeOperationImplementors = []string{"LabelChangeOperation", "Operation", "Authored"}
 
 // nolint: gocyclo, errcheck, gas, goconst
@@ -4740,11 +4943,18 @@ func (ec *executionContext) _LabelChangeOperation_author(ctx context.Context, fi
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -4949,11 +5159,18 @@ func (ec *executionContext) _LabelChangeTimelineItem_author(ctx context.Context,
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -5820,200 +6037,6 @@ func (ec *executionContext) _PageInfo_endCursor(ctx context.Context, field graph
 	return graphql.MarshalString(res)
 }
 
-var personImplementors = []string{"Person"}
-
-// nolint: gocyclo, errcheck, gas, goconst
-func (ec *executionContext) _Person(ctx context.Context, sel ast.SelectionSet, obj *bug.Person) graphql.Marshaler {
-	fields := graphql.CollectFields(ctx, sel, personImplementors)
-
-	var wg sync.WaitGroup
-	out := graphql.NewOrderedMap(len(fields))
-	invalid := false
-	for i, field := range fields {
-		out.Keys[i] = field.Alias
-
-		switch field.Name {
-		case "__typename":
-			out.Values[i] = graphql.MarshalString("Person")
-		case "name":
-			wg.Add(1)
-			go func(i int, field graphql.CollectedField) {
-				out.Values[i] = ec._Person_name(ctx, field, obj)
-				wg.Done()
-			}(i, field)
-		case "email":
-			wg.Add(1)
-			go func(i int, field graphql.CollectedField) {
-				out.Values[i] = ec._Person_email(ctx, field, obj)
-				wg.Done()
-			}(i, field)
-		case "login":
-			wg.Add(1)
-			go func(i int, field graphql.CollectedField) {
-				out.Values[i] = ec._Person_login(ctx, field, obj)
-				wg.Done()
-			}(i, field)
-		case "displayName":
-			out.Values[i] = ec._Person_displayName(ctx, field, obj)
-			if out.Values[i] == graphql.Null {
-				invalid = true
-			}
-		case "avatarUrl":
-			wg.Add(1)
-			go func(i int, field graphql.CollectedField) {
-				out.Values[i] = ec._Person_avatarUrl(ctx, field, obj)
-				wg.Done()
-			}(i, field)
-		default:
-			panic("unknown field " + strconv.Quote(field.Name))
-		}
-	}
-	wg.Wait()
-	if invalid {
-		return graphql.Null
-	}
-	return out
-}
-
-// nolint: vetshadow
-func (ec *executionContext) _Person_name(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
-	ctx = ec.Tracer.StartFieldExecution(ctx, field)
-	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
-	rctx := &graphql.ResolverContext{
-		Object: "Person",
-		Args:   nil,
-		Field:  field,
-	}
-	ctx = graphql.WithResolverContext(ctx, rctx)
-	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
-	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
-		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Person().Name(rctx, obj)
-	})
-	if resTmp == nil {
-		return graphql.Null
-	}
-	res := resTmp.(*string)
-	rctx.Result = res
-	ctx = ec.Tracer.StartFieldChildExecution(ctx)
-
-	if res == nil {
-		return graphql.Null
-	}
-	return graphql.MarshalString(*res)
-}
-
-// nolint: vetshadow
-func (ec *executionContext) _Person_email(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
-	ctx = ec.Tracer.StartFieldExecution(ctx, field)
-	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
-	rctx := &graphql.ResolverContext{
-		Object: "Person",
-		Args:   nil,
-		Field:  field,
-	}
-	ctx = graphql.WithResolverContext(ctx, rctx)
-	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
-	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
-		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Person().Email(rctx, obj)
-	})
-	if resTmp == nil {
-		return graphql.Null
-	}
-	res := resTmp.(*string)
-	rctx.Result = res
-	ctx = ec.Tracer.StartFieldChildExecution(ctx)
-
-	if res == nil {
-		return graphql.Null
-	}
-	return graphql.MarshalString(*res)
-}
-
-// nolint: vetshadow
-func (ec *executionContext) _Person_login(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
-	ctx = ec.Tracer.StartFieldExecution(ctx, field)
-	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
-	rctx := &graphql.ResolverContext{
-		Object: "Person",
-		Args:   nil,
-		Field:  field,
-	}
-	ctx = graphql.WithResolverContext(ctx, rctx)
-	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
-	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
-		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Person().Login(rctx, obj)
-	})
-	if resTmp == nil {
-		return graphql.Null
-	}
-	res := resTmp.(*string)
-	rctx.Result = res
-	ctx = ec.Tracer.StartFieldChildExecution(ctx)
-
-	if res == nil {
-		return graphql.Null
-	}
-	return graphql.MarshalString(*res)
-}
-
-// nolint: vetshadow
-func (ec *executionContext) _Person_displayName(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
-	ctx = ec.Tracer.StartFieldExecution(ctx, field)
-	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
-	rctx := &graphql.ResolverContext{
-		Object: "Person",
-		Args:   nil,
-		Field:  field,
-	}
-	ctx = graphql.WithResolverContext(ctx, rctx)
-	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
-	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
-		ctx = rctx // use context from middleware stack in children
-		return obj.DisplayName(), nil
-	})
-	if resTmp == nil {
-		if !ec.HasError(rctx) {
-			ec.Errorf(ctx, "must not be null")
-		}
-		return graphql.Null
-	}
-	res := resTmp.(string)
-	rctx.Result = res
-	ctx = ec.Tracer.StartFieldChildExecution(ctx)
-	return graphql.MarshalString(res)
-}
-
-// nolint: vetshadow
-func (ec *executionContext) _Person_avatarUrl(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
-	ctx = ec.Tracer.StartFieldExecution(ctx, field)
-	defer func() { ec.Tracer.EndFieldExecution(ctx) }()
-	rctx := &graphql.ResolverContext{
-		Object: "Person",
-		Args:   nil,
-		Field:  field,
-	}
-	ctx = graphql.WithResolverContext(ctx, rctx)
-	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
-	resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) {
-		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Person().AvatarURL(rctx, obj)
-	})
-	if resTmp == nil {
-		return graphql.Null
-	}
-	res := resTmp.(*string)
-	rctx.Result = res
-	ctx = ec.Tracer.StartFieldChildExecution(ctx)
-
-	if res == nil {
-		return graphql.Null
-	}
-	return graphql.MarshalString(*res)
-}
-
 var queryImplementors = []string{"Query"}
 
 // nolint: gocyclo, errcheck, gas, goconst
@@ -6400,11 +6423,18 @@ func (ec *executionContext) _SetStatusOperation_author(ctx context.Context, fiel
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -6563,11 +6593,18 @@ func (ec *executionContext) _SetStatusTimelineItem_author(ctx context.Context, f
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -6727,11 +6764,18 @@ func (ec *executionContext) _SetTitleOperation_author(ctx context.Context, field
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -6918,11 +6962,18 @@ func (ec *executionContext) _SetTitleTimelineItem_author(ctx context.Context, fi
 		}
 		return graphql.Null
 	}
-	res := resTmp.(bug.Person)
+	res := resTmp.(*identity.Identity)
 	rctx.Result = res
 	ctx = ec.Tracer.StartFieldChildExecution(ctx)
 
-	return ec._Person(ctx, field.Selections, &res)
+	if res == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+
+	return ec._Identity(ctx, field.Selections, res)
 }
 
 // nolint: vetshadow
@@ -8862,24 +8913,10 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er
 }
 
 var parsedSchema = gqlparser.MustLoadSchema(
-	&ast.Source{Name: "bug.graphql", Input: `"""Represents an person"""
-type Person {
-  """The name of the person, if known."""
-  name: String
-  """The email of the person, if known."""
-  email: String
-  """The login of the person, if known."""
-  login: String
-  """A string containing the either the name of the person, its login or both"""
-  displayName: String!
-  """An url to an avatar"""
-  avatarUrl: String
-}
-
-"""Represents a comment on a bug."""
+	&ast.Source{Name: "schema/bug.graphql", Input: `"""Represents a comment on a bug."""
 type Comment implements Authored {
   """The author of this comment."""
-  author: Person!
+  author: Identity!
 
   """The message of this comment."""
   message: String!
@@ -8911,7 +8948,7 @@ type Bug {
   status: Status!
   title: String!
   labels: [Label!]!
-  author: Person!
+  author: Identity!
   createdAt: Time!
   lastEdit: Time!
 
@@ -8985,12 +9022,25 @@ type Repository {
 }
 
 `},
-	&ast.Source{Name: "operations.graphql", Input: `"""An operation applied to a bug."""
+	&ast.Source{Name: "schema/identity.graphql", Input: `"""Represents an identity"""
+type Identity {
+    """The name of the person, if known."""
+    name: String
+    """The email of the person, if known."""
+    email: String
+    """The login of the person, if known."""
+    login: String
+    """A string containing the either the name of the person, its login or both"""
+    displayName: String!
+    """An url to an avatar"""
+    avatarUrl: String
+}`},
+	&ast.Source{Name: "schema/operations.graphql", Input: `"""An operation applied to a bug."""
 interface Operation {
     """The hash of the operation"""
     hash: Hash!
     """The operations author."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 }
@@ -9017,7 +9067,7 @@ type CreateOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -9030,7 +9080,7 @@ type SetTitleOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -9042,7 +9092,7 @@ type AddCommentOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -9054,7 +9104,7 @@ type EditCommentOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -9067,7 +9117,7 @@ type SetStatusOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -9078,14 +9128,14 @@ type LabelChangeOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
     added: [Label!]!
     removed: [Label!]!
 }`},
-	&ast.Source{Name: "root.graphql", Input: `scalar Time
+	&ast.Source{Name: "schema/root.graphql", Input: `scalar Time
 scalar Label
 scalar Hash
 
@@ -9104,7 +9154,7 @@ type PageInfo {
 """An object that has an author."""
 interface Authored {
     """The author of this object."""
-    author: Person!
+    author: Identity!
 }
 
 type Query {
@@ -9123,7 +9173,7 @@ type Mutation {
 
     commit(repoRef: String, prefix: String!): Bug!
 }`},
-	&ast.Source{Name: "timeline.graphql", Input: `"""An item in the timeline of events"""
+	&ast.Source{Name: "schema/timeline.graphql", Input: `"""An item in the timeline of events"""
 interface TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
@@ -9157,7 +9207,7 @@ type TimelineItemEdge {
 type CreateTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     message: String!
     messageIsEmpty: Boolean!
     files: [Hash!]!
@@ -9171,7 +9221,7 @@ type CreateTimelineItem implements TimelineItem {
 type AddCommentTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     message: String!
     messageIsEmpty: Boolean!
     files: [Hash!]!
@@ -9185,7 +9235,7 @@ type AddCommentTimelineItem implements TimelineItem {
 type LabelChangeTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     date: Time!
     added: [Label!]!
     removed: [Label!]!
@@ -9195,7 +9245,7 @@ type LabelChangeTimelineItem implements TimelineItem {
 type SetStatusTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     date: Time!
     status: Status!
 }
@@ -9204,7 +9254,7 @@ type SetStatusTimelineItem implements TimelineItem {
 type SetTitleTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     date: Time!
     title: String!
     was: String!

tests/graphql_test.go โ†’ graphql/graphql_test.go ๐Ÿ”—

@@ -1,18 +1,18 @@
-package tests
+package graphql
 
 import (
 	"net/http/httptest"
 	"testing"
 
-	"github.com/MichaelMure/git-bug/graphql"
 	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/util/test"
 	"github.com/vektah/gqlgen/client"
 )
 
 func TestQueries(t *testing.T) {
-	repo := createFilledRepo(10)
+	repo := test.CreateFilledRepo(10)
 
-	handler, err := graphql.NewHandler(repo)
+	handler, err := NewHandler(repo)
 	if err != nil {
 		t.Fatal(err)
 	}

graphql/resolvers/person.go ๐Ÿ”—

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

graphql/resolvers/root.go ๐Ÿ”—

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

graphql/bug.graphql โ†’ graphql/schema/bug.graphql ๐Ÿ”—

@@ -1,21 +1,7 @@
-"""Represents an person"""
-type Person {
-  """The name of the person, if known."""
-  name: String
-  """The email of the person, if known."""
-  email: String
-  """The login of the person, if known."""
-  login: String
-  """A string containing the either the name of the person, its login or both"""
-  displayName: String!
-  """An url to an avatar"""
-  avatarUrl: String
-}
-
 """Represents a comment on a bug."""
 type Comment implements Authored {
   """The author of this comment."""
-  author: Person!
+  author: Identity!
 
   """The message of this comment."""
   message: String!
@@ -47,7 +33,7 @@ type Bug {
   status: Status!
   title: String!
   labels: [Label!]!
-  author: Person!
+  author: Identity!
   createdAt: Time!
   lastEdit: Time!
 

graphql/schema/identity.graphql ๐Ÿ”—

@@ -0,0 +1,13 @@
+"""Represents an identity"""
+type Identity {
+    """The name of the person, if known."""
+    name: String
+    """The email of the person, if known."""
+    email: String
+    """The login of the person, if known."""
+    login: String
+    """A string containing the either the name of the person, its login or both"""
+    displayName: String!
+    """An url to an avatar"""
+    avatarUrl: String
+}

graphql/operations.graphql โ†’ graphql/schema/operations.graphql ๐Ÿ”—

@@ -3,7 +3,7 @@ interface Operation {
     """The hash of the operation"""
     hash: Hash!
     """The operations author."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 }
@@ -30,7 +30,7 @@ type CreateOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -43,7 +43,7 @@ type SetTitleOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -55,7 +55,7 @@ type AddCommentOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -67,7 +67,7 @@ type EditCommentOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -80,7 +80,7 @@ type SetStatusOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 
@@ -91,7 +91,7 @@ type LabelChangeOperation implements Operation & Authored {
     """The hash of the operation"""
     hash: Hash!
     """The author of this object."""
-    author: Person!
+    author: Identity!
     """The datetime when this operation was issued."""
     date: Time!
 

graphql/root.graphql โ†’ graphql/schema/root.graphql ๐Ÿ”—

@@ -17,7 +17,7 @@ type PageInfo {
 """An object that has an author."""
 interface Authored {
     """The author of this object."""
-    author: Person!
+    author: Identity!
 }
 
 type Query {

graphql/timeline.graphql โ†’ graphql/schema/timeline.graphql ๐Ÿ”—

@@ -32,7 +32,7 @@ type TimelineItemEdge {
 type CreateTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     message: String!
     messageIsEmpty: Boolean!
     files: [Hash!]!
@@ -46,7 +46,7 @@ type CreateTimelineItem implements TimelineItem {
 type AddCommentTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     message: String!
     messageIsEmpty: Boolean!
     files: [Hash!]!
@@ -60,7 +60,7 @@ type AddCommentTimelineItem implements TimelineItem {
 type LabelChangeTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     date: Time!
     added: [Label!]!
     removed: [Label!]!
@@ -70,7 +70,7 @@ type LabelChangeTimelineItem implements TimelineItem {
 type SetStatusTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     date: Time!
     status: Status!
 }
@@ -79,7 +79,7 @@ type SetStatusTimelineItem implements TimelineItem {
 type SetTitleTimelineItem implements TimelineItem {
     """The hash of the source operation"""
     hash: Hash!
-    author: Person!
+    author: Identity!
     date: Time!
     title: String!
     was: String!

identity/bare.go ๐Ÿ”—

@@ -0,0 +1,144 @@
+package identity
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/MichaelMure/git-bug/util/lamport"
+	"github.com/MichaelMure/git-bug/util/text"
+)
+
+type Bare struct {
+	name      string
+	email     string
+	login     string
+	avatarUrl string
+}
+
+func NewBare(name string, email string) *Bare {
+	return &Bare{name: name, email: email}
+}
+
+func NewBareFull(name string, email string, login string, avatarUrl string) *Bare {
+	return &Bare{name: name, email: email, login: login, avatarUrl: avatarUrl}
+}
+
+type bareIdentityJson struct {
+	Name      string `json:"name,omitempty"`
+	Email     string `json:"email,omitempty"`
+	Login     string `json:"login,omitempty"`
+	AvatarUrl string `json:"avatar_url,omitempty"`
+}
+
+func (i Bare) MarshalJSON() ([]byte, error) {
+	return json.Marshal(bareIdentityJson{
+		Name:      i.name,
+		Email:     i.email,
+		Login:     i.login,
+		AvatarUrl: i.avatarUrl,
+	})
+}
+
+func (i Bare) UnmarshalJSON(data []byte) error {
+	aux := bareIdentityJson{}
+
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	i.name = aux.Name
+	i.email = aux.Email
+	i.login = aux.Login
+	i.avatarUrl = aux.AvatarUrl
+
+	return nil
+}
+
+func (i Bare) Name() string {
+	return i.name
+}
+
+func (i Bare) Email() string {
+	return i.email
+}
+
+func (i Bare) Login() string {
+	return i.login
+}
+
+func (i Bare) AvatarUrl() string {
+	return i.avatarUrl
+}
+
+func (i Bare) Keys() []Key {
+	return []Key{}
+}
+
+func (i Bare) ValidKeysAtTime(time lamport.Time) []Key {
+	return []Key{}
+}
+
+// DisplayName return a non-empty string to display, representing the
+// identity, based on the non-empty values.
+func (i Bare) DisplayName() string {
+	switch {
+	case i.name == "" && i.login != "":
+		return i.login
+	case i.name != "" && i.login == "":
+		return i.name
+	case i.name != "" && i.login != "":
+		return fmt.Sprintf("%s (%s)", i.name, i.login)
+	}
+
+	panic("invalid person data")
+}
+
+// Match tell is the Person match the given query string
+func (i Bare) Match(query string) bool {
+	query = strings.ToLower(query)
+
+	return strings.Contains(strings.ToLower(i.name), query) ||
+		strings.Contains(strings.ToLower(i.login), query)
+}
+
+// Validate check if the Identity data is valid
+func (i Bare) Validate() error {
+	if text.Empty(i.name) && text.Empty(i.login) {
+		return fmt.Errorf("either name or login should be set")
+	}
+
+	if strings.Contains(i.name, "\n") {
+		return fmt.Errorf("name should be a single line")
+	}
+
+	if !text.Safe(i.name) {
+		return fmt.Errorf("name is not fully printable")
+	}
+
+	if strings.Contains(i.login, "\n") {
+		return fmt.Errorf("login should be a single line")
+	}
+
+	if !text.Safe(i.login) {
+		return fmt.Errorf("login is not fully printable")
+	}
+
+	if strings.Contains(i.email, "\n") {
+		return fmt.Errorf("email should be a single line")
+	}
+
+	if !text.Safe(i.email) {
+		return fmt.Errorf("email is not fully printable")
+	}
+
+	if i.avatarUrl != "" && !text.ValidUrl(i.avatarUrl) {
+		return fmt.Errorf("avatarUrl is not a valid URL")
+	}
+
+	return nil
+}
+
+func (i Bare) IsProtected() bool {
+	return false
+}

identity/identity.go ๐Ÿ”—

@@ -0,0 +1,285 @@
+// Package identity contains the identity data model and low-level related functions
+package identity
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/util/git"
+	"github.com/MichaelMure/git-bug/util/lamport"
+)
+
+const identityRefPattern = "refs/identities/"
+const versionEntryName = "version"
+const identityConfigKey = "git-bug.identity"
+
+type Identity struct {
+	id       string
+	Versions []Version
+}
+
+func NewIdentity(name string, email string) (*Identity, error) {
+	return &Identity{
+		Versions: []Version{
+			{
+				Name:  name,
+				Email: email,
+				Nonce: makeNonce(20),
+			},
+		},
+	}, nil
+}
+
+type identityJson struct {
+	Id string `json:"id"`
+}
+
+// TODO: marshal/unmarshal identity + load/write from OpBase
+
+func Read(repo repository.Repo, id string) (*Identity, error) {
+	// Todo
+	return &Identity{}, nil
+}
+
+// NewFromGitUser will query the repository for user detail and
+// build the corresponding Identity
+/*func NewFromGitUser(repo repository.Repo) (*Identity, error) {
+	name, err := repo.GetUserName()
+	if err != nil {
+		return nil, err
+	}
+	if name == "" {
+		return nil, errors.New("User name is not configured in git yet. Please use `git config --global user.name \"John Doe\"`")
+	}
+
+	email, err := repo.GetUserEmail()
+	if err != nil {
+		return nil, err
+	}
+	if email == "" {
+		return nil, errors.New("User name is not configured in git yet. Please use `git config --global user.email johndoe@example.com`")
+	}
+
+	return NewIdentity(name, email)
+}*/
+
+//
+func BuildFromGit(repo repository.Repo) *Identity {
+	version := Version{}
+
+	name, err := repo.GetUserName()
+	if err == nil {
+		version.Name = name
+	}
+
+	email, err := repo.GetUserEmail()
+	if err == nil {
+		version.Email = email
+	}
+
+	return &Identity{
+		Versions: []Version{
+			version,
+		},
+	}
+}
+
+// SetIdentity store the user identity's id in the git config
+func SetIdentity(repo repository.RepoCommon, identity Identity) error {
+	return repo.StoreConfig(identityConfigKey, identity.Id())
+}
+
+// GetIdentity read the current user identity, set with a git config entry
+func GetIdentity(repo repository.Repo) (*Identity, error) {
+	configs, err := repo.ReadConfigs(identityConfigKey)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(configs) == 0 {
+		return nil, fmt.Errorf("no identity set")
+	}
+
+	if len(configs) > 1 {
+		return nil, fmt.Errorf("multiple identity config exist")
+	}
+
+	var id string
+	for _, val := range configs {
+		id = val
+	}
+
+	return Read(repo, id)
+}
+
+func (i *Identity) AddVersion(version Version) {
+	i.Versions = append(i.Versions, version)
+}
+
+func (i *Identity) Commit(repo repository.ClockedRepo) error {
+	// Todo: check for mismatch between memory and commited data
+
+	var lastCommit git.Hash = ""
+
+	for _, v := range i.Versions {
+		if v.commitHash != "" {
+			lastCommit = v.commitHash
+			// ignore already commited versions
+			continue
+		}
+
+		blobHash, err := v.Write(repo)
+		if err != nil {
+			return err
+		}
+
+		// Make a git tree referencing the blob
+		tree := []repository.TreeEntry{
+			{ObjectType: repository.Blob, Hash: blobHash, Name: versionEntryName},
+		}
+
+		treeHash, err := repo.StoreTree(tree)
+		if err != nil {
+			return err
+		}
+
+		var commitHash git.Hash
+		if lastCommit != "" {
+			commitHash, err = repo.StoreCommitWithParent(treeHash, lastCommit)
+		} else {
+			commitHash, err = repo.StoreCommit(treeHash)
+		}
+
+		if err != nil {
+			return err
+		}
+
+		lastCommit = commitHash
+
+		// if it was the first commit, use the commit hash as the Identity id
+		if i.id == "" {
+			i.id = string(commitHash)
+		}
+	}
+
+	if i.id == "" {
+		panic("identity with no id")
+	}
+
+	ref := fmt.Sprintf("%s%s", identityRefPattern, i.id)
+	err := repo.UpdateRef(ref, lastCommit)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Validate check if the Identity data is valid
+func (i *Identity) Validate() error {
+	lastTime := lamport.Time(0)
+
+	for _, v := range i.Versions {
+		if err := v.Validate(); err != nil {
+			return err
+		}
+
+		if v.Time < lastTime {
+			return fmt.Errorf("non-chronological version (%d --> %d)", lastTime, v.Time)
+		}
+
+		lastTime = v.Time
+	}
+
+	return nil
+}
+
+func (i *Identity) LastVersion() Version {
+	if len(i.Versions) <= 0 {
+		panic("no version at all")
+	}
+
+	return i.Versions[len(i.Versions)-1]
+}
+
+// Id return the Identity identifier
+func (i *Identity) Id() string {
+	if i.id == "" {
+		// simply panic as it would be a coding error
+		// (using an id of an identity not stored yet)
+		panic("no id yet")
+	}
+	return i.id
+}
+
+// Name return the last version of the name
+func (i *Identity) Name() string {
+	return i.LastVersion().Name
+}
+
+// Email return the last version of the email
+func (i *Identity) Email() string {
+	return i.LastVersion().Email
+}
+
+// Login return the last version of the login
+func (i *Identity) Login() string {
+	return i.LastVersion().Login
+}
+
+// Login return the last version of the Avatar URL
+func (i *Identity) AvatarUrl() string {
+	return i.LastVersion().AvatarUrl
+}
+
+// Login return the last version of the valid keys
+func (i *Identity) Keys() []Key {
+	return i.LastVersion().Keys
+}
+
+// IsProtected return true if the chain of git commits started to be signed.
+// If that's the case, only signed commit with a valid key for this identity can be added.
+func (i *Identity) IsProtected() bool {
+	// Todo
+	return false
+}
+
+// ValidKeysAtTime return the set of keys valid at a given lamport time
+func (i *Identity) ValidKeysAtTime(time lamport.Time) []Key {
+	var result []Key
+
+	for _, v := range i.Versions {
+		if v.Time > time {
+			return result
+		}
+
+		result = v.Keys
+	}
+
+	return result
+}
+
+// Match tell is the Identity match the given query string
+func (i *Identity) Match(query string) bool {
+	query = strings.ToLower(query)
+
+	return strings.Contains(strings.ToLower(i.Name()), query) ||
+		strings.Contains(strings.ToLower(i.Login()), query)
+}
+
+// DisplayName return a non-empty string to display, representing the
+// identity, based on the non-empty values.
+func (i *Identity) DisplayName() string {
+	switch {
+	case i.Name() == "" && i.Login() != "":
+		return i.Login()
+	case i.Name() != "" && i.Login() == "":
+		return i.Name()
+	case i.Name() != "" && i.Login() != "":
+		return fmt.Sprintf("%s (%s)", i.Name(), i.Login())
+	}
+
+	panic("invalid person data")
+}

identity/identity_test.go ๐Ÿ”—

@@ -0,0 +1,145 @@
+package identity
+
+import (
+	"testing"
+
+	"github.com/MichaelMure/git-bug/repository"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIdentityCommit(t *testing.T) {
+	mockRepo := repository.NewMockRepoForTest()
+
+	// single version
+
+	identity := Identity{
+		Versions: []Version{
+			{
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+			},
+		},
+	}
+
+	err := identity.Commit(mockRepo)
+
+	assert.Nil(t, err)
+	assert.NotEmpty(t, identity.id)
+
+	// multiple version
+
+	identity = Identity{
+		Versions: []Version{
+			{
+				Time:  100,
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+				Keys: []Key{
+					{PubKey: "pubkeyA"},
+				},
+			},
+			{
+				Time:  200,
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+				Keys: []Key{
+					{PubKey: "pubkeyB"},
+				},
+			},
+			{
+				Time:  201,
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+				Keys: []Key{
+					{PubKey: "pubkeyC"},
+				},
+			},
+		},
+	}
+
+	err = identity.Commit(mockRepo)
+
+	assert.Nil(t, err)
+	assert.NotEmpty(t, identity.id)
+
+	// add more version
+
+	identity.AddVersion(Version{
+		Time:  201,
+		Name:  "Renรฉ Descartes",
+		Email: "rene.descartes@example.com",
+		Keys: []Key{
+			{PubKey: "pubkeyD"},
+		},
+	})
+
+	identity.AddVersion(Version{
+		Time:  300,
+		Name:  "Renรฉ Descartes",
+		Email: "rene.descartes@example.com",
+		Keys: []Key{
+			{PubKey: "pubkeyE"},
+		},
+	})
+
+	err = identity.Commit(mockRepo)
+
+	assert.Nil(t, err)
+	assert.NotEmpty(t, identity.id)
+}
+
+func TestIdentity_ValidKeysAtTime(t *testing.T) {
+	identity := Identity{
+		Versions: []Version{
+			{
+				Time:  100,
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+				Keys: []Key{
+					{PubKey: "pubkeyA"},
+				},
+			},
+			{
+				Time:  200,
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+				Keys: []Key{
+					{PubKey: "pubkeyB"},
+				},
+			},
+			{
+				Time:  201,
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+				Keys: []Key{
+					{PubKey: "pubkeyC"},
+				},
+			},
+			{
+				Time:  201,
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+				Keys: []Key{
+					{PubKey: "pubkeyD"},
+				},
+			},
+			{
+				Time:  300,
+				Name:  "Renรฉ Descartes",
+				Email: "rene.descartes@example.com",
+				Keys: []Key{
+					{PubKey: "pubkeyE"},
+				},
+			},
+		},
+	}
+
+	assert.Nil(t, identity.ValidKeysAtTime(10))
+	assert.Equal(t, identity.ValidKeysAtTime(100), []Key{{PubKey: "pubkeyA"}})
+	assert.Equal(t, identity.ValidKeysAtTime(140), []Key{{PubKey: "pubkeyA"}})
+	assert.Equal(t, identity.ValidKeysAtTime(200), []Key{{PubKey: "pubkeyB"}})
+	assert.Equal(t, identity.ValidKeysAtTime(201), []Key{{PubKey: "pubkeyD"}})
+	assert.Equal(t, identity.ValidKeysAtTime(202), []Key{{PubKey: "pubkeyD"}})
+	assert.Equal(t, identity.ValidKeysAtTime(300), []Key{{PubKey: "pubkeyE"}})
+	assert.Equal(t, identity.ValidKeysAtTime(3000), []Key{{PubKey: "pubkeyE"}})
+}

identity/interface.go ๐Ÿ”—

@@ -0,0 +1,30 @@
+package identity
+
+import "github.com/MichaelMure/git-bug/util/lamport"
+
+type Interface interface {
+	Name() string
+	Email() string
+	Login() string
+	AvatarUrl() string
+
+	// Login return the last version of the valid keys
+	Keys() []Key
+
+	// ValidKeysAtTime return the set of keys valid at a given lamport time
+	ValidKeysAtTime(time lamport.Time) []Key
+
+	// DisplayName return a non-empty string to display, representing the
+	// identity, based on the non-empty values.
+	DisplayName() string
+
+	// Match tell is the Person match the given query string
+	Match(query string) bool
+
+	// Validate check if the Identity data is valid
+	Validate() error
+
+	// IsProtected return true if the chain of git commits started to be signed.
+	// If that's the case, only signed commit with a valid key for this identity can be added.
+	IsProtected() bool
+}

identity/key.go ๐Ÿ”—

@@ -0,0 +1,7 @@
+package identity
+
+type Key struct {
+	// The GPG fingerprint of the key
+	Fingerprint string `json:"fingerprint"`
+	PubKey      string `json:"pub_key"`
+}

identity/version.go ๐Ÿ”—

@@ -0,0 +1,105 @@
+package identity
+
+import (
+	"crypto/rand"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/util/git"
+
+	"github.com/MichaelMure/git-bug/util/lamport"
+	"github.com/MichaelMure/git-bug/util/text"
+)
+
+type Version struct {
+	// Private field so not serialized
+	commitHash git.Hash
+
+	// The lamport time at which this version become effective
+	// The reference time is the bug edition lamport clock
+	Time lamport.Time `json:"time"`
+
+	Name      string `json:"name"`
+	Email     string `json:"email"`
+	Login     string `json:"login"`
+	AvatarUrl string `json:"avatar_url"`
+
+	// The set of keys valid at that time, from this version onward, until they get removed
+	// in a new version. This allow to have multiple key for the same identity (e.g. one per
+	// device) as well as revoke key.
+	Keys []Key `json:"pub_keys"`
+
+	// This optional array is here to ensure a better randomness of the identity id to avoid collisions.
+	// It has no functional purpose and should be ignored.
+	// It is advised to fill this array if there is not enough entropy, e.g. if there is no keys.
+	Nonce []byte `json:"nonce,omitempty"`
+}
+
+func (v *Version) Validate() error {
+	if text.Empty(v.Name) && text.Empty(v.Login) {
+		return fmt.Errorf("either name or login should be set")
+	}
+
+	if strings.Contains(v.Name, "\n") {
+		return fmt.Errorf("name should be a single line")
+	}
+
+	if !text.Safe(v.Name) {
+		return fmt.Errorf("name is not fully printable")
+	}
+
+	if strings.Contains(v.Login, "\n") {
+		return fmt.Errorf("login should be a single line")
+	}
+
+	if !text.Safe(v.Login) {
+		return fmt.Errorf("login is not fully printable")
+	}
+
+	if strings.Contains(v.Email, "\n") {
+		return fmt.Errorf("email should be a single line")
+	}
+
+	if !text.Safe(v.Email) {
+		return fmt.Errorf("email is not fully printable")
+	}
+
+	if v.AvatarUrl != "" && !text.ValidUrl(v.AvatarUrl) {
+		return fmt.Errorf("avatarUrl is not a valid URL")
+	}
+
+	if len(v.Nonce) > 64 {
+		return fmt.Errorf("nonce is too big")
+	}
+
+	return nil
+}
+
+// Write will serialize and store the Version as a git blob and return
+// its hash
+func (v *Version) Write(repo repository.Repo) (git.Hash, error) {
+	data, err := json.Marshal(v)
+
+	if err != nil {
+		return "", err
+	}
+
+	hash, err := repo.StoreData(data)
+
+	if err != nil {
+		return "", err
+	}
+
+	return hash, nil
+}
+
+func makeNonce(len int) []byte {
+	result := make([]byte, len)
+	_, err := rand.Read(result)
+	if err != nil {
+		panic(err)
+	}
+	return result
+}

misc/random_bugs/create_random_bugs.go ๐Ÿ”—

@@ -6,11 +6,12 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/icrowley/fake"
 )
 
-type opsGenerator func(bug.Interface, bug.Person)
+type opsGenerator func(bug.Interface, identity.Interface)
 
 type Options struct {
 	BugNumber    int
@@ -136,18 +137,15 @@ func GenerateRandomOperationPacksWithSeed(packNumber int, opNumber int, seed int
 	return result
 }
 
-func person() bug.Person {
-	return bug.Person{
-		Name:  fake.FullName(),
-		Email: fake.EmailAddress(),
-	}
+func person() identity.Interface {
+	return identity.NewBare(fake.FullName(), fake.EmailAddress())
 }
 
-var persons []bug.Person
+var persons []identity.Interface
 
-func randomPerson(personNumber int) bug.Person {
+func randomPerson(personNumber int) identity.Interface {
 	if len(persons) == 0 {
-		persons = make([]bug.Person, personNumber)
+		persons = make([]identity.Interface, personNumber)
 		for i := range persons {
 			persons[i] = person()
 		}
@@ -162,25 +160,25 @@ func paragraphs() string {
 	return strings.Replace(p, "\t", "\n\n", -1)
 }
 
-func comment(b bug.Interface, p bug.Person) {
+func comment(b bug.Interface, p identity.Interface) {
 	_, _ = bug.AddComment(b, p, time.Now().Unix(), paragraphs())
 }
 
-func title(b bug.Interface, p bug.Person) {
+func title(b bug.Interface, p identity.Interface) {
 	_, _ = bug.SetTitle(b, p, time.Now().Unix(), fake.Sentence())
 }
 
-func open(b bug.Interface, p bug.Person) {
+func open(b bug.Interface, p identity.Interface) {
 	_, _ = bug.Open(b, p, time.Now().Unix())
 }
 
-func close(b bug.Interface, p bug.Person) {
+func close(b bug.Interface, p identity.Interface) {
 	_, _ = bug.Close(b, p, time.Now().Unix())
 }
 
 var addedLabels []string
 
-func labels(b bug.Interface, p bug.Person) {
+func labels(b bug.Interface, p identity.Interface) {
 	var removed []string
 	nbRemoved := rand.Intn(3)
 	for nbRemoved > 0 && len(addedLabels) > 0 {

termui/bug_table.go ๐Ÿ”—

@@ -6,6 +6,7 @@ import (
 
 	"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/text"
 	"github.com/MichaelMure/gocui"
@@ -289,7 +290,7 @@ func (bt *bugTable) render(v *gocui.View, maxX int) {
 	columnWidths := bt.getColumnWidths(maxX)
 
 	for _, b := range bt.bugs {
-		person := bug.Person{}
+		person := &identity.Identity{}
 		snap := b.Snapshot()
 		if len(snap.Comments) > 0 {
 			create := snap.Comments[0]

tests/read_bugs_test.go ๐Ÿ”—

@@ -1,60 +1,14 @@
 package tests
 
 import (
-	"io/ioutil"
-	"log"
 	"testing"
 
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/misc/random_bugs"
-	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/util/test"
 )
 
-func createRepo(bare bool) *repository.GitRepo {
-	dir, err := ioutil.TempDir("", "")
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	// fmt.Println("Creating repo:", dir)
-
-	var creator func(string) (*repository.GitRepo, error)
-
-	if bare {
-		creator = repository.InitBareGitRepo
-	} else {
-		creator = repository.InitGitRepo
-	}
-
-	repo, err := creator(dir)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	if err := repo.StoreConfig("user.name", "testuser"); err != nil {
-		log.Fatal("failed to set user.name for test repository: ", err)
-	}
-	if err := repo.StoreConfig("user.email", "testuser@example.com"); err != nil {
-		log.Fatal("failed to set user.email for test repository: ", err)
-	}
-
-	return repo
-}
-
-func createFilledRepo(bugNumber int) repository.ClockedRepo {
-	repo := createRepo(false)
-
-	var seed int64 = 42
-	options := random_bugs.DefaultOptions()
-
-	options.BugNumber = bugNumber
-
-	random_bugs.CommitRandomBugsWithSeed(repo, options, seed)
-	return repo
-}
-
 func TestReadBugs(t *testing.T) {
-	repo := createFilledRepo(15)
+	repo := test.CreateFilledRepo(15)
 	bugs := bug.ReadAllLocalBugs(repo)
 	for b := range bugs {
 		if b.Err != nil {
@@ -64,7 +18,7 @@ func TestReadBugs(t *testing.T) {
 }
 
 func benchmarkReadBugs(bugNumber int, t *testing.B) {
-	repo := createFilledRepo(bugNumber)
+	repo := test.CreateFilledRepo(bugNumber)
 	t.ResetTimer()
 
 	for n := 0; n < t.N; n++ {

util/test/repo.go ๐Ÿ”—

@@ -0,0 +1,52 @@
+package test
+
+import (
+	"io/ioutil"
+	"log"
+
+	"github.com/MichaelMure/git-bug/misc/random_bugs"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+func CreateRepo(bare bool) *repository.GitRepo {
+	dir, err := ioutil.TempDir("", "")
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// fmt.Println("Creating repo:", dir)
+
+	var creator func(string) (*repository.GitRepo, error)
+
+	if bare {
+		creator = repository.InitBareGitRepo
+	} else {
+		creator = repository.InitGitRepo
+	}
+
+	repo, err := creator(dir)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	if err := repo.StoreConfig("user.name", "testuser"); err != nil {
+		log.Fatal("failed to set user.name for test repository: ", err)
+	}
+	if err := repo.StoreConfig("user.email", "testuser@example.com"); err != nil {
+		log.Fatal("failed to set user.email for test repository: ", err)
+	}
+
+	return repo
+}
+
+func CreateFilledRepo(bugNumber int) repository.ClockedRepo {
+	repo := CreateRepo(false)
+
+	var seed int64 = 42
+	options := random_bugs.DefaultOptions()
+
+	options.BugNumber = bugNumber
+
+	random_bugs.CommitRandomBugsWithSeed(repo, options, seed)
+	return repo
+}