Merge pull request #410 from MichaelMure/output-formatting-2

Michael Muré created

Add formatting options to the 'show' and 'user ls' commands

Change summary

bridge/github/export.go            |   2 
bridge/gitlab/export.go            |   2 
bridge/jira/export.go              |   2 
bug/op_create.go                   |   2 
bug/op_create_test.go              |   2 
bug/operation.go                   |  14 -
bug/snapshot.go                    |  13 -
cache/bug_excerpt.go               |  21 ++
cache/repo_cache.go                |  27 +---
commands/json_common.go            |  55 ++++++++
commands/ls.go                     | 152 ++++++++++++++++--------
commands/show.go                   | 198 +++++++++++++++++++++++++++++--
commands/user_ls.go                |  47 ++++++
doc/man/git-bug-ls.1               |   2 
doc/man/git-bug-show.1             |   8 
doc/man/git-bug-user-ls.1          |   4 
doc/md/git-bug_ls.md               |   2 
doc/md/git-bug_show.md             |   5 
doc/md/git-bug_user_ls.md          |   3 
graphql/models/lazy_bug.go         |   8 
misc/bash_completion/git-bug       |   9 +
misc/powershell_completion/git-bug |  13 +
misc/zsh_completion/git-bug        |   8 
termui/bug_table.go                |   3 
termui/show_bug.go                 |   2 
25 files changed, 457 insertions(+), 147 deletions(-)

Detailed changes

bridge/github/export.go 🔗

@@ -173,7 +173,7 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 
 				// ignore issues created before since date
 				// TODO: compare the Lamport time instead of using the unix time
-				if snapshot.CreatedAt.Before(since) {
+				if snapshot.CreateTime.Before(since) {
 					out <- core.NewExportNothing(b.Id(), "bug created before the since date")
 					continue
 				}

bridge/gitlab/export.go 🔗

@@ -131,7 +131,7 @@ func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 
 				// ignore issues created before since date
 				// TODO: compare the Lamport time instead of using the unix time
-				if snapshot.CreatedAt.Before(since) {
+				if snapshot.CreateTime.Before(since) {
 					out <- core.NewExportNothing(b.Id(), "bug created before the since date")
 					continue
 				}

bridge/jira/export.go 🔗

@@ -165,7 +165,7 @@ func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, si
 
 				// ignore issues whose last modification date is before the query date
 				// TODO: compare the Lamport time instead of using the unix time
-				if snapshot.CreatedAt.Before(since) {
+				if snapshot.CreateTime.Before(since) {
 					out <- core.NewExportNothing(b.Id(), "bug created before the since date")
 					continue
 				}

bug/op_create.go 🔗

@@ -48,7 +48,7 @@ func (op *CreateOperation) Apply(snapshot *Snapshot) {
 
 	snapshot.Comments = []Comment{comment}
 	snapshot.Author = op.Author
-	snapshot.CreatedAt = op.Time()
+	snapshot.CreateTime = op.Time()
 
 	snapshot.Timeline = []TimelineItem{
 		&CreateTimelineItem{

bug/op_create_test.go 🔗

@@ -38,7 +38,7 @@ func TestCreate(t *testing.T) {
 		Author:       rene,
 		Participants: []identity.Interface{rene},
 		Actors:       []identity.Interface{rene},
-		CreatedAt:    create.Time(),
+		CreateTime:   create.Time(),
 		Timeline: []TimelineItem{
 			&CreateTimelineItem{
 				CommentTimelineItem: NewCommentTimelineItem(id, comment),

bug/operation.go 🔗

@@ -36,8 +36,6 @@ type Operation interface {
 	Id() entity.Id
 	// Time return the time when the operation was added
 	Time() time.Time
-	// GetUnixTime return the unix timestamp when the operation was added
-	GetUnixTime() int64
 	// GetFiles return the files needed by this operation
 	GetFiles() []git.Hash
 	// Apply the operation to a Snapshot to create the final state
@@ -89,8 +87,9 @@ func idOperation(op Operation) entity.Id {
 type OpBase struct {
 	OperationType OperationType      `json:"type"`
 	Author        identity.Interface `json:"author"`
-	UnixTime      int64              `json:"timestamp"`
-	Metadata      map[string]string  `json:"metadata,omitempty"`
+	// TODO: part of the data model upgrade, this should eventually be a timestamp + lamport
+	UnixTime int64             `json:"timestamp"`
+	Metadata map[string]string `json:"metadata,omitempty"`
 	// Not serialized. Store the op's id in memory.
 	id entity.Id
 	// Not serialized. Store the extra metadata in memory,
@@ -142,11 +141,6 @@ func (op *OpBase) Time() time.Time {
 	return time.Unix(op.UnixTime, 0)
 }
 
-// GetUnixTime return the unix timestamp when the operation was added
-func (op *OpBase) GetUnixTime() int64 {
-	return op.UnixTime
-}
-
 // GetFiles return the files needed by this operation
 func (op *OpBase) GetFiles() []git.Hash {
 	return nil
@@ -158,7 +152,7 @@ func opBaseValidate(op Operation, opType OperationType) error {
 		return fmt.Errorf("incorrect operation type (expected: %v, actual: %v)", opType, op.base().OperationType)
 	}
 
-	if op.GetUnixTime() == 0 {
+	if op.Time().Unix() == 0 {
 		return fmt.Errorf("time not set")
 	}
 

bug/snapshot.go 🔗

@@ -19,7 +19,7 @@ type Snapshot struct {
 	Author       identity.Interface
 	Actors       []identity.Interface
 	Participants []identity.Interface
-	CreatedAt    time.Time
+	CreateTime   time.Time
 
 	Timeline []TimelineItem
 
@@ -32,7 +32,7 @@ func (snap *Snapshot) Id() entity.Id {
 }
 
 // Return the last time a bug was modified
-func (snap *Snapshot) LastEditTime() time.Time {
+func (snap *Snapshot) EditTime() time.Time {
 	if len(snap.Operations) == 0 {
 		return time.Unix(0, 0)
 	}
@@ -40,15 +40,6 @@ func (snap *Snapshot) LastEditTime() time.Time {
 	return snap.Operations[len(snap.Operations)-1].Time()
 }
 
-// Return the last timestamp a bug was modified
-func (snap *Snapshot) LastEditUnix() int64 {
-	if len(snap.Operations) == 0 {
-		return 0
-	}
-
-	return snap.Operations[len(snap.Operations)-1].GetUnixTime()
-}
-
 // GetCreateMetadata return the creation metadata
 func (snap *Snapshot) GetCreateMetadata(key string) (string, bool) {
 	return snap.Operations[0].GetMetadata(key)

cache/bug_excerpt.go 🔗

@@ -3,6 +3,7 @@ package cache
 import (
 	"encoding/gob"
 	"fmt"
+	"time"
 
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
@@ -22,8 +23,8 @@ type BugExcerpt struct {
 
 	CreateLamportTime lamport.Time
 	EditLamportTime   lamport.Time
-	CreateUnixTime    int64
-	EditUnixTime      int64
+	createUnixTime    int64
+	editUnixTime      int64
 
 	Status       bug.Status
 	Labels       []bug.Label
@@ -79,8 +80,8 @@ func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
 		Id:                b.Id(),
 		CreateLamportTime: b.CreateLamportTime(),
 		EditLamportTime:   b.EditLamportTime(),
-		CreateUnixTime:    b.FirstOp().GetUnixTime(),
-		EditUnixTime:      snap.LastEditUnix(),
+		createUnixTime:    b.FirstOp().Time().Unix(),
+		editUnixTime:      snap.EditTime().Unix(),
 		Status:            snap.Status,
 		Labels:            snap.Labels,
 		Actors:            actorsIds,
@@ -105,6 +106,14 @@ func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
 	return e
 }
 
+func (b *BugExcerpt) CreateTime() time.Time {
+	return time.Unix(b.createUnixTime, 0)
+}
+
+func (b *BugExcerpt) EditTime() time.Time {
+	return time.Unix(b.editUnixTime, 0)
+}
+
 /*
  * Sorting
  */
@@ -144,7 +153,7 @@ func (b BugsByCreationTime) Less(i, j int) bool {
 	// by the first sorting using the logical clock. That means that if users
 	// synchronize their bugs regularly, the timestamp will rarely be used, and
 	// should still provide a kinda accurate sorting when needed.
-	return b[i].CreateUnixTime < b[j].CreateUnixTime
+	return b[i].createUnixTime < b[j].createUnixTime
 }
 
 func (b BugsByCreationTime) Swap(i, j int) {
@@ -172,7 +181,7 @@ func (b BugsByEditTime) Less(i, j int) bool {
 	// by the first sorting using the logical clock. That means that if users
 	// synchronize their bugs regularly, the timestamp will rarely be used, and
 	// should still provide a kinda accurate sorting when needed.
-	return b[i].EditUnixTime < b[j].EditUnixTime
+	return b[i].editUnixTime < b[j].editUnixTime
 }
 
 func (b BugsByEditTime) Swap(i, j int) {

cache/repo_cache.go 🔗

@@ -29,15 +29,8 @@ const identityCacheFile = "identity-cache"
 
 // 1: original format
 // 2: added cache for identities with a reference in the bug cache
-const formatVersion = 2
-
-type ErrInvalidCacheFormat struct {
-	message string
-}
-
-func (e ErrInvalidCacheFormat) Error() string {
-	return e.message
-}
+// 3: CreateUnixTime --> createUnixTime, EditUnixTime --> editUnixTime
+const formatVersion = 3
 
 var _ repository.RepoCommon = &RepoCache{}
 
@@ -99,10 +92,8 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, error
 	if err == nil {
 		return c, nil
 	}
-	if _, ok := err.(ErrInvalidCacheFormat); ok {
-		return nil, err
-	}
 
+	// Cache is either missing, broken or outdated. Rebuilding.
 	err = c.buildCache()
 	if err != nil {
 		return nil, err
@@ -254,10 +245,8 @@ func (c *RepoCache) loadBugCache() error {
 		return err
 	}
 
-	if aux.Version != 2 {
-		return ErrInvalidCacheFormat{
-			message: fmt.Sprintf("unknown cache format version %v", aux.Version),
-		}
+	if aux.Version != formatVersion {
+		return fmt.Errorf("unknown cache format version %v", aux.Version)
 	}
 
 	c.bugExcerpts = aux.Excerpts
@@ -286,10 +275,8 @@ func (c *RepoCache) loadIdentityCache() error {
 		return err
 	}
 
-	if aux.Version != 2 {
-		return ErrInvalidCacheFormat{
-			message: fmt.Sprintf("unknown cache format version %v", aux.Version),
-		}
+	if aux.Version != formatVersion {
+		return fmt.Errorf("unknown cache format version %v", aux.Version)
 	}
 
 	c.identitiesExcerpts = aux.Excerpts

commands/json_common.go 🔗

@@ -0,0 +1,55 @@
+package commands
+
+import (
+	"time"
+
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/util/lamport"
+)
+
+type JSONIdentity struct {
+	Id      string `json:"id"`
+	HumanId string `json:"human_id"`
+	Name    string `json:"name"`
+	Login   string `json:"login"`
+}
+
+func NewJSONIdentity(i identity.Interface) JSONIdentity {
+	return JSONIdentity{
+		Id:      i.Id().String(),
+		HumanId: i.Id().Human(),
+		Name:    i.Name(),
+		Login:   i.Login(),
+	}
+}
+
+func NewJSONIdentityFromExcerpt(excerpt *cache.IdentityExcerpt) JSONIdentity {
+	return JSONIdentity{
+		Id:      excerpt.Id.String(),
+		HumanId: excerpt.Id.Human(),
+		Name:    excerpt.Name,
+		Login:   excerpt.Login,
+	}
+}
+
+func NewJSONIdentityFromLegacyExcerpt(excerpt *cache.LegacyAuthorExcerpt) JSONIdentity {
+	return JSONIdentity{
+		Name:  excerpt.Name,
+		Login: excerpt.Login,
+	}
+}
+
+type JSONTime struct {
+	Timestamp int64        `json:"timestamp"`
+	Time      time.Time    `json:"time"`
+	Lamport   lamport.Time `json:"lamport,omitempty"`
+}
+
+func NewJSONTime(t time.Time, l lamport.Time) JSONTime {
+	return JSONTime{
+		Timestamp: t.Unix(),
+		Time:      t,
+		Lamport:   l,
+	}
+}

commands/ls.go 🔗

@@ -4,7 +4,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"strings"
-	"time"
 
 	text "github.com/MichaelMure/go-term-text"
 	"github.com/spf13/cobra"
@@ -61,6 +60,8 @@ func runLsBug(_ *cobra.Command, args []string) error {
 	}
 
 	switch lsOutputFormat {
+	case "org-mode":
+		return lsOrgmodeFormatter(backend, bugExcerpt)
 	case "plain":
 		return lsPlainFormatter(backend, bugExcerpt)
 	case "json":
@@ -72,11 +73,11 @@ func runLsBug(_ *cobra.Command, args []string) error {
 	}
 }
 
-type JSONBug struct {
-	Id           string    `json:"id"`
-	HumanId      string    `json:"human_id"`
-	CreationTime time.Time `json:"creation_time"`
-	LastEdited   time.Time `json:"last_edited"`
+type JSONBugExcerpt struct {
+	Id         string   `json:"id"`
+	HumanId    string   `json:"human_id"`
+	CreateTime JSONTime `json:"create_time"`
+	EditTime   JSONTime `json:"edit_time"`
 
 	Status       string         `json:"status"`
 	Labels       []bug.Label    `json:"labels"`
@@ -89,29 +90,19 @@ type JSONBug struct {
 	Metadata map[string]string `json:"metadata"`
 }
 
-type JSONIdentity struct {
-	Id      string `json:"id"`
-	HumanId string `json:"human_id"`
-	Name    string `json:"name"`
-	Login   string `json:"login"`
-}
-
 func lsJsonFormatter(backend *cache.RepoCache, bugExcerpts []*cache.BugExcerpt) error {
-	jsonBugs := make([]JSONBug, len(bugExcerpts))
+	jsonBugs := make([]JSONBugExcerpt, len(bugExcerpts))
 	for i, b := range bugExcerpts {
-		jsonBug := JSONBug{
-			b.Id.String(),
-			b.Id.Human(),
-			time.Unix(b.CreateUnixTime, 0),
-			time.Unix(b.EditUnixTime, 0),
-			b.Status.String(),
-			b.Labels,
-			b.Title,
-			[]JSONIdentity{},
-			[]JSONIdentity{},
-			JSONIdentity{},
-			b.LenComments,
-			b.CreateMetadata,
+		jsonBug := JSONBugExcerpt{
+			Id:         b.Id.String(),
+			HumanId:    b.Id.Human(),
+			CreateTime: NewJSONTime(b.CreateTime(), b.CreateLamportTime),
+			EditTime:   NewJSONTime(b.EditTime(), b.EditLamportTime),
+			Status:     b.Status.String(),
+			Labels:     b.Labels,
+			Title:      b.Title,
+			Comments:   b.LenComments,
+			Metadata:   b.CreateMetadata,
 		}
 
 		if b.AuthorId != "" {
@@ -119,41 +110,27 @@ func lsJsonFormatter(backend *cache.RepoCache, bugExcerpts []*cache.BugExcerpt)
 			if err != nil {
 				return err
 			}
-
-			jsonBug.Author.Name = author.DisplayName()
-			jsonBug.Author.Login = author.Login
-			jsonBug.Author.Id = author.Id.String()
-			jsonBug.Author.HumanId = author.Id.Human()
+			jsonBug.Author = NewJSONIdentityFromExcerpt(author)
 		} else {
-			jsonBug.Author.Name = b.LegacyAuthor.DisplayName()
-			jsonBug.Author.Login = b.LegacyAuthor.Login
+			jsonBug.Author = NewJSONIdentityFromLegacyExcerpt(&b.LegacyAuthor)
 		}
 
-		for _, element := range b.Actors {
+		jsonBug.Actors = make([]JSONIdentity, len(b.Actors))
+		for i, element := range b.Actors {
 			actor, err := backend.ResolveIdentityExcerpt(element)
 			if err != nil {
 				return err
 			}
-
-			jsonBug.Actors = append(jsonBug.Actors, JSONIdentity{
-				actor.Id.String(),
-				actor.Id.Human(),
-				actor.Name,
-				actor.Login,
-			})
+			jsonBug.Actors[i] = NewJSONIdentityFromExcerpt(actor)
 		}
 
-		for _, element := range b.Participants {
+		jsonBug.Participants = make([]JSONIdentity, len(b.Participants))
+		for i, element := range b.Participants {
 			participant, err := backend.ResolveIdentityExcerpt(element)
 			if err != nil {
 				return err
 			}
-			jsonBug.Participants = append(jsonBug.Participants, JSONIdentity{
-				participant.Id.String(),
-				participant.Id.Human(),
-				participant.DisplayName(),
-				participant.Login,
-			})
+			jsonBug.Participants[i] = NewJSONIdentityFromExcerpt(participant)
 		}
 
 		jsonBugs[i] = jsonBug
@@ -207,11 +184,84 @@ func lsDefaultFormatter(backend *cache.RepoCache, bugExcerpts []*cache.BugExcerp
 
 func lsPlainFormatter(_ *cache.RepoCache, bugExcerpts []*cache.BugExcerpt) error {
 	for _, b := range bugExcerpts {
-		fmt.Printf("[%s] %s\n", b.Status, b.Title)
+		fmt.Printf("%s [%s] %s\n", b.Id.Human(), b.Status, b.Title)
 	}
 	return nil
 }
 
+func lsOrgmodeFormatter(backend *cache.RepoCache, bugExcerpts []*cache.BugExcerpt) error {
+	fmt.Println("+TODO: OPEN | CLOSED")
+
+	for _, b := range bugExcerpts {
+		status := strings.Title(b.Status.String())
+
+		var title string
+		if link, ok := b.CreateMetadata["github-url"]; ok {
+			title = fmt.Sprintf("[%s][%s]", link, b.Title)
+		} else {
+			title = b.Title
+		}
+
+		var name string
+		if b.AuthorId != "" {
+			author, err := backend.ResolveIdentityExcerpt(b.AuthorId)
+			if err != nil {
+				return err
+			}
+			name = author.DisplayName()
+		} else {
+			name = b.LegacyAuthor.DisplayName()
+		}
+
+		labels := b.Labels
+		var labelsString string
+		if len(labels) > 0 {
+			labelsString = fmt.Sprintf(":%s:", strings.Replace(fmt.Sprint(labels), " ", ":", -1))
+		} else {
+			labelsString = ""
+		}
+
+		fmt.Printf("* %s %s [%s] %s: %s %s\n",
+			b.Id.Human(),
+			status,
+			b.CreateTime(),
+			name,
+			title,
+			labelsString,
+		)
+
+		fmt.Printf("** Last Edited: %s\n", b.EditTime().String())
+
+		fmt.Printf("** Actors:\n")
+		for _, element := range b.Actors {
+			actor, err := backend.ResolveIdentityExcerpt(element)
+			if err != nil {
+				return err
+			}
+
+			fmt.Printf(": %s %s\n",
+				actor.Id.Human(),
+				actor.DisplayName(),
+			)
+		}
+
+		fmt.Printf("** Participants:\n")
+		for _, element := range b.Participants {
+			participant, err := backend.ResolveIdentityExcerpt(element)
+			if err != nil {
+				return err
+			}
+
+			fmt.Printf(": %s %s\n",
+				participant.Id.Human(),
+				participant.DisplayName(),
+			)
+		}
+	}
+
+	return nil
+}
+
 // Finish the command flags transformation into the query.Query
 func completeQuery() error {
 	for _, str := range lsStatusQuery {
@@ -294,5 +344,5 @@ func init() {
 	lsCmd.Flags().StringVarP(&lsSortDirection, "direction", "d", "asc",
 		"Select the sorting direction. Valid values are [asc,desc]")
 	lsCmd.Flags().StringVarP(&lsOutputFormat, "format", "f", "default",
-		"Select the output formatting style. Valid values are [default, plain(text), json]")
+		"Select the output formatting style. Valid values are [default,plain,json,org-mode]")
 }

commands/show.go 🔗

@@ -1,22 +1,26 @@
 package commands
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"strings"
 
+	"github.com/spf13/cobra"
+
+	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	_select "github.com/MichaelMure/git-bug/commands/select"
 	"github.com/MichaelMure/git-bug/util/colors"
 	"github.com/MichaelMure/git-bug/util/interrupt"
-	"github.com/spf13/cobra"
 )
 
 var (
-	showFieldsQuery string
+	showFieldsQuery  string
+	showOutputFormat string
 )
 
-func runShowBug(cmd *cobra.Command, args []string) error {
+func runShowBug(_ *cobra.Command, args []string) error {
 	backend, err := cache.NewRepoCache(repo)
 	if err != nil {
 		return err
@@ -35,16 +39,16 @@ func runShowBug(cmd *cobra.Command, args []string) error {
 		return errors.New("invalid bug: no comment")
 	}
 
-	firstComment := snapshot.Comments[0]
-
 	if showFieldsQuery != "" {
 		switch showFieldsQuery {
 		case "author":
-			fmt.Printf("%s\n", firstComment.Author.DisplayName())
+			fmt.Printf("%s\n", snapshot.Author.DisplayName())
 		case "authorEmail":
-			fmt.Printf("%s\n", firstComment.Author.Email())
+			fmt.Printf("%s\n", snapshot.Author.Email())
 		case "createTime":
-			fmt.Printf("%s\n", firstComment.FormatTime())
+			fmt.Printf("%s\n", snapshot.CreateTime.String())
+		case "lastEdit":
+			fmt.Printf("%s\n", snapshot.EditTime().String())
 		case "humanId":
 			fmt.Printf("%s\n", snapshot.Id().Human())
 		case "id":
@@ -74,16 +78,33 @@ func runShowBug(cmd *cobra.Command, args []string) error {
 		return nil
 	}
 
+	switch showOutputFormat {
+	case "org-mode":
+		return showOrgmodeFormatter(snapshot)
+	case "json":
+		return showJsonFormatter(snapshot)
+	case "default":
+		return showDefaultFormatter(snapshot)
+	default:
+		return fmt.Errorf("unknown format %s", showOutputFormat)
+	}
+}
+
+func showDefaultFormatter(snapshot *bug.Snapshot) error {
 	// Header
-	fmt.Printf("[%s] %s %s\n\n",
-		colors.Yellow(snapshot.Status),
+	fmt.Printf("%s [%s] %s\n\n",
 		colors.Cyan(snapshot.Id().Human()),
+		colors.Yellow(snapshot.Status),
 		snapshot.Title,
 	)
 
-	fmt.Printf("%s opened this issue %s\n\n",
-		colors.Magenta(firstComment.Author.DisplayName()),
-		firstComment.FormatTimeRel(),
+	fmt.Printf("%s opened this issue %s\n",
+		colors.Magenta(snapshot.Author.DisplayName()),
+		snapshot.CreateTime.String(),
+	)
+
+	fmt.Printf("This was last edited at %s\n\n",
+		snapshot.EditTime().String(),
 	)
 
 	// Labels
@@ -143,6 +164,151 @@ func runShowBug(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
+type JSONBugSnapshot struct {
+	Id           string         `json:"id"`
+	HumanId      string         `json:"human_id"`
+	CreateTime   JSONTime       `json:"create_time"`
+	EditTime     JSONTime       `json:"edit_time"`
+	Status       string         `json:"status"`
+	Labels       []bug.Label    `json:"labels"`
+	Title        string         `json:"title"`
+	Author       JSONIdentity   `json:"author"`
+	Actors       []JSONIdentity `json:"actors"`
+	Participants []JSONIdentity `json:"participants"`
+	Comments     []JSONComment  `json:"comments"`
+}
+
+type JSONComment struct {
+	Id      string       `json:"id"`
+	HumanId string       `json:"human_id"`
+	Author  JSONIdentity `json:"author"`
+	Message string       `json:"message"`
+}
+
+func NewJSONComment(comment bug.Comment) JSONComment {
+	return JSONComment{
+		Id:      comment.Id().String(),
+		HumanId: comment.Id().Human(),
+		Author:  NewJSONIdentity(comment.Author),
+		Message: comment.Message,
+	}
+}
+
+func showJsonFormatter(snapshot *bug.Snapshot) error {
+	jsonBug := JSONBugSnapshot{
+		Id:         snapshot.Id().String(),
+		HumanId:    snapshot.Id().Human(),
+		CreateTime: NewJSONTime(snapshot.CreateTime, 0),
+		EditTime:   NewJSONTime(snapshot.EditTime(), 0),
+		Status:     snapshot.Status.String(),
+		Labels:     snapshot.Labels,
+		Title:      snapshot.Title,
+		Author:     NewJSONIdentity(snapshot.Author),
+	}
+
+	jsonBug.Actors = make([]JSONIdentity, len(snapshot.Actors))
+	for i, element := range snapshot.Actors {
+		jsonBug.Actors[i] = NewJSONIdentity(element)
+	}
+
+	jsonBug.Participants = make([]JSONIdentity, len(snapshot.Participants))
+	for i, element := range snapshot.Participants {
+		jsonBug.Participants[i] = NewJSONIdentity(element)
+	}
+
+	jsonBug.Comments = make([]JSONComment, len(snapshot.Comments))
+	for i, comment := range snapshot.Comments {
+		jsonBug.Comments[i] = NewJSONComment(comment)
+	}
+
+	jsonObject, _ := json.MarshalIndent(jsonBug, "", "    ")
+	fmt.Printf("%s\n", jsonObject)
+
+	return nil
+}
+
+func showOrgmodeFormatter(snapshot *bug.Snapshot) error {
+	// Header
+	fmt.Printf("%s [%s] %s\n",
+		snapshot.Id().Human(),
+		snapshot.Status,
+		snapshot.Title,
+	)
+
+	fmt.Printf("* Author: %s\n",
+		snapshot.Author.DisplayName(),
+	)
+
+	fmt.Printf("* Creation Time: %s\n",
+		snapshot.CreateTime.String(),
+	)
+
+	fmt.Printf("* Last Edit: %s\n",
+		snapshot.EditTime().String(),
+	)
+
+	// Labels
+	var labels = make([]string, len(snapshot.Labels))
+	for i, label := range snapshot.Labels {
+		labels[i] = string(label)
+	}
+
+	fmt.Printf("* Labels:\n")
+	if len(labels) > 0 {
+		fmt.Printf("** %s\n",
+			strings.Join(labels, "\n** "),
+		)
+	}
+
+	// Actors
+	var actors = make([]string, len(snapshot.Actors))
+	for i, actor := range snapshot.Actors {
+		actors[i] = fmt.Sprintf("%s %s",
+			actor.Id().Human(),
+			actor.DisplayName(),
+		)
+	}
+
+	fmt.Printf("* Actors:\n** %s\n",
+		strings.Join(actors, "\n** "),
+	)
+
+	// Participants
+	var participants = make([]string, len(snapshot.Participants))
+	for i, participant := range snapshot.Participants {
+		participants[i] = fmt.Sprintf("%s %s",
+			participant.Id().Human(),
+			participant.DisplayName(),
+		)
+	}
+
+	fmt.Printf("* Participants:\n** %s\n",
+		strings.Join(participants, "\n** "),
+	)
+
+	fmt.Printf("* Comments:\n")
+
+	for i, comment := range snapshot.Comments {
+		var message string
+		fmt.Printf("** #%d %s\n",
+			i,
+			comment.Author.DisplayName(),
+		)
+
+		if comment.Message == "" {
+			message = "No description provided."
+		} else {
+			message = strings.ReplaceAll(comment.Message, "\n", "\n: ")
+		}
+
+		fmt.Printf(": %s\n",
+			message,
+		)
+	}
+
+	return nil
+}
+
 var showCmd = &cobra.Command{
 	Use:     "show [<id>]",
 	Short:   "Display the details of a bug.",
@@ -152,6 +318,8 @@ var showCmd = &cobra.Command{
 
 func init() {
 	RootCmd.AddCommand(showCmd)
-	showCmd.Flags().StringVarP(&showFieldsQuery, "field", "f", "",
-		"Select field to display. Valid values are [author,authorEmail,createTime,humanId,id,labels,shortId,status,title,actors,participants]")
+	showCmd.Flags().StringVarP(&showFieldsQuery, "field", "", "",
+		"Select field to display. Valid values are [author,authorEmail,createTime,lastEdit,humanId,id,labels,shortId,status,title,actors,participants]")
+	showCmd.Flags().StringVarP(&showOutputFormat, "format", "f", "default",
+		"Select the output formatting style. Valid values are [default,json,org-mode]")
 }

commands/user_ls.go 🔗

@@ -1,15 +1,21 @@
 package commands
 
 import (
+	"encoding/json"
 	"fmt"
 
+	"github.com/spf13/cobra"
+
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/util/colors"
 	"github.com/MichaelMure/git-bug/util/interrupt"
-	"github.com/spf13/cobra"
 )
 
-func runUserLs(cmd *cobra.Command, args []string) error {
+var (
+	userLsOutputFormat string
+)
+
+func runUserLs(_ *cobra.Command, _ []string) error {
 	backend, err := cache.NewRepoCache(repo)
 	if err != nil {
 		return err
@@ -17,21 +23,48 @@ func runUserLs(cmd *cobra.Command, args []string) error {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	for _, id := range backend.AllIdentityIds() {
-		i, err := backend.ResolveIdentityExcerpt(id)
+	ids := backend.AllIdentityIds()
+	var users []*cache.IdentityExcerpt
+	for _, id := range ids {
+		user, err := backend.ResolveIdentityExcerpt(id)
 		if err != nil {
 			return err
 		}
+		users = append(users, user)
+	}
+
+	switch userLsOutputFormat {
+	case "json":
+		return userLsJsonFormatter(users)
+	case "default":
+		return userLsDefaultFormatter(users)
+	default:
+		return fmt.Errorf("unknown format %s", userLsOutputFormat)
+	}
+}
 
+func userLsDefaultFormatter(users []*cache.IdentityExcerpt) error {
+	for _, user := range users {
 		fmt.Printf("%s %s\n",
-			colors.Cyan(i.Id.Human()),
-			i.DisplayName(),
+			colors.Cyan(user.Id.Human()),
+			user.DisplayName(),
 		)
 	}
 
 	return nil
 }
 
+func userLsJsonFormatter(users []*cache.IdentityExcerpt) error {
+	jsonUsers := make([]JSONIdentity, len(users))
+	for i, user := range users {
+		jsonUsers[i] = NewJSONIdentityFromExcerpt(user)
+	}
+
+	jsonObject, _ := json.MarshalIndent(jsonUsers, "", "    ")
+	fmt.Printf("%s\n", jsonObject)
+	return nil
+}
+
 var userLsCmd = &cobra.Command{
 	Use:     "ls",
 	Short:   "List identities.",
@@ -42,4 +75,6 @@ var userLsCmd = &cobra.Command{
 func init() {
 	userCmd.AddCommand(userLsCmd)
 	userLsCmd.Flags().SortFlags = false
+	userLsCmd.Flags().StringVarP(&userLsOutputFormat, "format", "f", "default",
+		"Select the output formatting style. Valid values are [default,json]")
 }

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

@@ -59,7 +59,7 @@ You can pass an additional query to filter and order the list. This query can be
 
 .PP
 \fB\-f\fP, \fB\-\-format\fP="default"
-	Select the output formatting style. Valid values are [default, plain(text), json]
+	Select the output formatting style. Valid values are [default,plain,json,org\-mode]
 
 .PP
 \fB\-h\fP, \fB\-\-help\fP[=false]

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

@@ -19,8 +19,12 @@ Display the details of a bug.
 
 .SH OPTIONS
 .PP
-\fB\-f\fP, \fB\-\-field\fP=""
-	Select field to display. Valid values are [author,authorEmail,createTime,humanId,id,labels,shortId,status,title,actors,participants]
+\fB\-\-field\fP=""
+	Select field to display. Valid values are [author,authorEmail,createTime,lastEdit,humanId,id,labels,shortId,status,title,actors,participants]
+
+.PP
+\fB\-f\fP, \fB\-\-format\fP="default"
+	Select the output formatting style. Valid values are [default,json,org\-mode]
 
 .PP
 \fB\-h\fP, \fB\-\-help\fP[=false]

doc/man/git-bug-user-ls.1 🔗

@@ -18,6 +18,10 @@ List identities.
 
 
 .SH OPTIONS
+.PP
+\fB\-f\fP, \fB\-\-format\fP="default"
+	Select the output formatting style. Valid values are [default,json]
+
 .PP
 \fB\-h\fP, \fB\-\-help\fP[=false]
 	help for ls

doc/md/git-bug_ls.md 🔗

@@ -35,7 +35,7 @@ git bug ls --status closed --by creation
   -n, --no strings            Filter by absence of something. Valid values are [label]
   -b, --by string             Sort the results by a characteristic. Valid values are [id,creation,edit] (default "creation")
   -d, --direction string      Select the sorting direction. Valid values are [asc,desc] (default "asc")
-  -f, --format string         Select the output formatting style. Valid values are [default, plain(text), json] (default "default")
+  -f, --format string         Select the output formatting style. Valid values are [default,plain,json,org-mode] (default "default")
   -h, --help                  help for ls
 ```
 

doc/md/git-bug_show.md 🔗

@@ -13,8 +13,9 @@ git-bug show [<id>] [flags]
 ### Options
 
 ```
-  -f, --field string   Select field to display. Valid values are [author,authorEmail,createTime,humanId,id,labels,shortId,status,title,actors,participants]
-  -h, --help           help for show
+      --field string    Select field to display. Valid values are [author,authorEmail,createTime,lastEdit,humanId,id,labels,shortId,status,title,actors,participants]
+  -f, --format string   Select the output formatting style. Valid values are [default,json,org-mode] (default "default")
+  -h, --help            help for show
 ```
 
 ### SEE ALSO

doc/md/git-bug_user_ls.md 🔗

@@ -13,7 +13,8 @@ git-bug user ls [flags]
 ### Options
 
 ```
-  -h, --help   help for ls
+  -f, --format string   Select the output formatting style. Valid values are [default,json] (default "default")
+  -h, --help            help for ls
 ```
 
 ### SEE ALSO

graphql/models/lazy_bug.go 🔗

@@ -81,7 +81,7 @@ func (lb *lazyBug) Id() entity.Id {
 }
 
 func (lb *lazyBug) LastEdit() time.Time {
-	return time.Unix(lb.excerpt.EditUnixTime, 0)
+	return lb.excerpt.EditTime()
 }
 
 func (lb *lazyBug) Status() bug.Status {
@@ -133,7 +133,7 @@ func (lb *lazyBug) Participants() ([]IdentityWrapper, error) {
 }
 
 func (lb *lazyBug) CreatedAt() time.Time {
-	return time.Unix(lb.excerpt.CreateUnixTime, 0)
+	return lb.excerpt.CreateTime()
 }
 
 func (lb *lazyBug) Timeline() ([]bug.TimelineItem, error) {
@@ -163,7 +163,7 @@ func NewLoadedBug(snap *bug.Snapshot) *loadedBug {
 }
 
 func (l *loadedBug) LastEdit() time.Time {
-	return l.Snapshot.LastEditTime()
+	return l.Snapshot.EditTime()
 }
 
 func (l *loadedBug) Status() bug.Status {
@@ -203,7 +203,7 @@ func (l *loadedBug) Participants() ([]IdentityWrapper, error) {
 }
 
 func (l *loadedBug) CreatedAt() time.Time {
-	return l.Snapshot.CreatedAt
+	return l.Snapshot.CreateTime
 }
 
 func (l *loadedBug) Timeline() ([]bug.TimelineItem, error) {

misc/bash_completion/git-bug 🔗

@@ -933,8 +933,11 @@ _git-bug_show()
 
     flags+=("--field=")
     two_word_flags+=("--field")
-    two_word_flags+=("-f")
     local_nonpersistent_flags+=("--field=")
+    flags+=("--format=")
+    two_word_flags+=("--format")
+    two_word_flags+=("-f")
+    local_nonpersistent_flags+=("--format=")
 
     must_have_one_flag=()
     must_have_one_noun=()
@@ -1122,6 +1125,10 @@ _git-bug_user_ls()
     flags_with_completion=()
     flags_completion=()
 
+    flags+=("--format=")
+    two_word_flags+=("--format")
+    two_word_flags+=("-f")
+    local_nonpersistent_flags+=("--format=")
 
     must_have_one_flag=()
     must_have_one_noun=()

misc/powershell_completion/git-bug 🔗

@@ -159,8 +159,8 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
             [CompletionResult]::new('--by', 'by', [CompletionResultType]::ParameterName, 'Sort the results by a characteristic. Valid values are [id,creation,edit]')
             [CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Select the sorting direction. Valid values are [asc,desc]')
             [CompletionResult]::new('--direction', 'direction', [CompletionResultType]::ParameterName, 'Select the sorting direction. Valid values are [asc,desc]')
-            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default, plain(text), json]')
-            [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default, plain(text), json]')
+            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,plain,json,org-mode]')
+            [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,plain,json,org-mode]')
             break
         }
         'git-bug;ls-id' {
@@ -179,8 +179,9 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
             break
         }
         'git-bug;show' {
-            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [author,authorEmail,createTime,humanId,id,labels,shortId,status,title,actors,participants]')
-            [CompletionResult]::new('--field', 'field', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [author,authorEmail,createTime,humanId,id,labels,shortId,status,title,actors,participants]')
+            [CompletionResult]::new('--field', 'field', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [author,authorEmail,createTime,lastEdit,humanId,id,labels,shortId,status,title,actors,participants]')
+            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json,org-mode]')
+            [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json,org-mode]')
             break
         }
         'git-bug;status' {
@@ -221,6 +222,8 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
             break
         }
         'git-bug;user;ls' {
+            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json]')
+            [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json]')
             break
         }
         'git-bug;version' {
@@ -242,4 +245,4 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
     })
     $completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
         Sort-Object -Property ListItemText
-}
+}

misc/zsh_completion/git-bug 🔗

@@ -304,7 +304,7 @@ function _git-bug_ls {
     '(*-n *--no)'{\*-n,\*--no}'[Filter by absence of something. Valid values are [label]]:' \
     '(-b --by)'{-b,--by}'[Sort the results by a characteristic. Valid values are [id,creation,edit]]:' \
     '(-d --direction)'{-d,--direction}'[Select the sorting direction. Valid values are [asc,desc]]:' \
-    '(-f --format)'{-f,--format}'[Select the output formatting style. Valid values are [default, plain(text), json]]:'
+    '(-f --format)'{-f,--format}'[Select the output formatting style. Valid values are [default,plain,json,org-mode]]:'
 }
 
 function _git-bug_ls-id {
@@ -329,7 +329,8 @@ function _git-bug_select {
 
 function _git-bug_show {
   _arguments \
-    '(-f --field)'{-f,--field}'[Select field to display. Valid values are [author,authorEmail,createTime,humanId,id,labels,shortId,status,title,actors,participants]]:'
+    '--field[Select field to display. Valid values are [author,authorEmail,createTime,lastEdit,humanId,id,labels,shortId,status,title,actors,participants]]:' \
+    '(-f --format)'{-f,--format}'[Select the output formatting style. Valid values are [default,json,org-mode]]:'
 }
 
 
@@ -443,7 +444,8 @@ function _git-bug_user_create {
 }
 
 function _git-bug_user_ls {
-  _arguments
+  _arguments \
+    '(-f --format)'{-f,--format}'[Select the output formatting style. Valid values are [default,json]]:'
 }
 
 function _git-bug_version {

termui/bug_table.go 🔗

@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"fmt"
 	"strings"
-	"time"
 
 	"github.com/MichaelMure/go-term-text"
 	"github.com/awesome-gocui/gocui"
@@ -315,7 +314,7 @@ func (bt *bugTable) render(v *gocui.View, maxX int) {
 			authorDisplayName = excerpt.LegacyAuthor.DisplayName()
 		}
 
-		lastEditTime := time.Unix(excerpt.EditUnixTime, 0)
+		lastEditTime := excerpt.EditTime()
 
 		id := text.LeftPadMaxLine(excerpt.Id.Human(), columnWidths["id"], 1)
 		status := text.LeftPadMaxLine(excerpt.Status.String(), columnWidths["status"], 1)

termui/show_bug.go 🔗

@@ -219,7 +219,7 @@ func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
 		colors.Bold(snap.Title),
 		colors.Yellow(snap.Status),
 		colors.Magenta(snap.Author.DisplayName()),
-		snap.CreatedAt.Format(timeLayout),
+		snap.CreateTime.Format(timeLayout),
 		edited,
 	)
 	bugHeader, lines := text.Wrap(bugHeader, maxX)