aboutsummaryrefslogtreecommitdiffstats
path: root/commands/bug
diff options
context:
space:
mode:
Diffstat (limited to 'commands/bug')
-rw-r--r--commands/bug/bug.go468
-rw-r--r--commands/bug/bug_comment.go52
-rw-r--r--commands/bug/bug_comment_add.go80
-rw-r--r--commands/bug/bug_comment_add_test.go18
-rw-r--r--commands/bug/bug_comment_edit.go77
-rw-r--r--commands/bug/bug_comment_edit_test.go23
-rw-r--r--commands/bug/bug_comment_test.go164
-rw-r--r--commands/bug/bug_deselect.go37
-rw-r--r--commands/bug/bug_label.go43
-rw-r--r--commands/bug/bug_label_new.go47
-rw-r--r--commands/bug/bug_label_rm.go47
-rw-r--r--commands/bug/bug_new.go77
-rw-r--r--commands/bug/bug_new_test.go21
-rw-r--r--commands/bug/bug_rm.go46
-rw-r--r--commands/bug/bug_rm_test.go19
-rw-r--r--commands/bug/bug_select.go62
-rw-r--r--commands/bug/bug_show.go329
-rw-r--r--commands/bug/bug_status.go41
-rw-r--r--commands/bug/bug_status_close.go39
-rw-r--r--commands/bug/bug_status_open.go39
-rw-r--r--commands/bug/bug_test.go103
-rw-r--r--commands/bug/bug_title.go40
-rw-r--r--commands/bug/bug_title_edit.go76
-rw-r--r--commands/bug/select/select.go129
-rw-r--r--commands/bug/select/select_test.go79
-rw-r--r--commands/bug/testdata/comment/add-0-golden.txt3
-rw-r--r--commands/bug/testdata/comment/add-1-golden.txt6
-rw-r--r--commands/bug/testdata/comment/edit-0-golden.txt3
-rw-r--r--commands/bug/testdata/comment/edit-1-golden.txt6
-rw-r--r--commands/bug/testdata/comment/message-only-0-golden.txt3
-rw-r--r--commands/bug/testenv/testenv.go63
31 files changed, 2240 insertions, 0 deletions
diff --git a/commands/bug/bug.go b/commands/bug/bug.go
new file mode 100644
index 00000000..04bf8980
--- /dev/null
+++ b/commands/bug/bug.go
@@ -0,0 +1,468 @@
+package bugcmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ text "github.com/MichaelMure/go-term-text"
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/cache"
+ "github.com/MichaelMure/git-bug/commands/cmdjson"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/entities/bug"
+ "github.com/MichaelMure/git-bug/entities/common"
+ "github.com/MichaelMure/git-bug/query"
+ "github.com/MichaelMure/git-bug/util/colors"
+)
+
+type bugOptions struct {
+ statusQuery []string
+ authorQuery []string
+ metadataQuery []string
+ participantQuery []string
+ actorQuery []string
+ labelQuery []string
+ titleQuery []string
+ noQuery []string
+ sortBy string
+ sortDirection string
+ outputFormat string
+}
+
+func NewBugCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bugOptions{}
+
+ cmd := &cobra.Command{
+ Use: "bug [QUERY]",
+ Short: "List bugs",
+ Long: `Display a summary of each bugs.
+
+You can pass an additional query to filter and order the list. This query can be expressed either with a simple query language, flags, a natural language full text search, or a combination of the aforementioned.`,
+ Example: `List open bugs sorted by last edition with a query:
+git bug status:open sort:edit-desc
+
+List closed bugs sorted by creation with flags:
+git bug --status closed --by creation
+
+Do a full text search of all bugs:
+git bug "foo bar" baz
+
+Use queries, flags, and full text search:
+git bug status:open --by creation "foo bar" baz
+`,
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBug(env, options, args)
+ }),
+ ValidArgsFunction: completion.Ls(env),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVarP(&options.statusQuery, "status", "s", nil,
+ "Filter by status. Valid values are [open,closed]")
+ cmd.RegisterFlagCompletionFunc("status", completion.From([]string{"open", "closed"}))
+ flags.StringSliceVarP(&options.authorQuery, "author", "a", nil,
+ "Filter by author")
+ flags.StringSliceVarP(&options.metadataQuery, "metadata", "m", nil,
+ "Filter by metadata. Example: github-url=URL")
+ cmd.RegisterFlagCompletionFunc("author", completion.UserForQuery(env))
+ flags.StringSliceVarP(&options.participantQuery, "participant", "p", nil,
+ "Filter by participant")
+ cmd.RegisterFlagCompletionFunc("participant", completion.UserForQuery(env))
+ flags.StringSliceVarP(&options.actorQuery, "actor", "A", nil,
+ "Filter by actor")
+ cmd.RegisterFlagCompletionFunc("actor", completion.UserForQuery(env))
+ flags.StringSliceVarP(&options.labelQuery, "label", "l", nil,
+ "Filter by label")
+ cmd.RegisterFlagCompletionFunc("label", completion.Label(env))
+ flags.StringSliceVarP(&options.titleQuery, "title", "t", nil,
+ "Filter by title")
+ flags.StringSliceVarP(&options.noQuery, "no", "n", nil,
+ "Filter by absence of something. Valid values are [label]")
+ cmd.RegisterFlagCompletionFunc("no", completion.Label(env))
+ flags.StringVarP(&options.sortBy, "by", "b", "creation",
+ "Sort the results by a characteristic. Valid values are [id,creation,edit]")
+ cmd.RegisterFlagCompletionFunc("by", completion.From([]string{"id", "creation", "edit"}))
+ flags.StringVarP(&options.sortDirection, "direction", "d", "asc",
+ "Select the sorting direction. Valid values are [asc,desc]")
+ cmd.RegisterFlagCompletionFunc("direction", completion.From([]string{"asc", "desc"}))
+ flags.StringVarP(&options.outputFormat, "format", "f", "default",
+ "Select the output formatting style. Valid values are [default,plain,compact,id,json,org-mode]")
+ cmd.RegisterFlagCompletionFunc("format",
+ completion.From([]string{"default", "plain", "compact", "id", "json", "org-mode"}))
+
+ const selectGroup = "select"
+ cmd.AddGroup(&cobra.Group{ID: selectGroup, Title: "Implicit selection"})
+
+ addCmdWithGroup := func(child *cobra.Command, groupID string) {
+ cmd.AddCommand(child)
+ child.GroupID = groupID
+ }
+
+ addCmdWithGroup(newBugDeselectCommand(), selectGroup)
+ addCmdWithGroup(newBugSelectCommand(), selectGroup)
+
+ cmd.AddCommand(newBugCommentCommand())
+ cmd.AddCommand(newBugLabelCommand())
+ cmd.AddCommand(newBugNewCommand())
+ cmd.AddCommand(newBugRmCommand())
+ cmd.AddCommand(newBugShowCommand())
+ cmd.AddCommand(newBugStatusCommand())
+ cmd.AddCommand(newBugTitleCommand())
+
+ return cmd
+}
+
+func runBug(env *execenv.Env, opts bugOptions, args []string) error {
+ var q *query.Query
+ var err error
+
+ if len(args) >= 1 {
+ // either the shell or cobra remove the quotes, we need them back for the query parsing
+ assembled := repairQuery(args)
+
+ q, err = query.Parse(assembled)
+ if err != nil {
+ return err
+ }
+ } else {
+ q = query.NewQuery()
+ }
+
+ err = completeQuery(q, opts)
+ if err != nil {
+ return err
+ }
+
+ allIds, err := env.Backend.QueryBugs(q)
+ if err != nil {
+ return err
+ }
+
+ bugExcerpt := make([]*cache.BugExcerpt, len(allIds))
+ for i, id := range allIds {
+ b, err := env.Backend.ResolveBugExcerpt(id)
+ if err != nil {
+ return err
+ }
+ bugExcerpt[i] = b
+ }
+
+ switch opts.outputFormat {
+ case "org-mode":
+ return bugsOrgmodeFormatter(env, bugExcerpt)
+ case "plain":
+ return bugsPlainFormatter(env, bugExcerpt)
+ case "json":
+ return bugsJsonFormatter(env, bugExcerpt)
+ case "compact":
+ return bugsCompactFormatter(env, bugExcerpt)
+ case "id":
+ return bugsIDFormatter(env, bugExcerpt)
+ case "default":
+ return bugsDefaultFormatter(env, bugExcerpt)
+ default:
+ return fmt.Errorf("unknown format %s", opts.outputFormat)
+ }
+}
+
+func repairQuery(args []string) string {
+ for i, arg := range args {
+ split := strings.Split(arg, ":")
+ for j, s := range split {
+ if strings.Contains(s, " ") {
+ split[j] = fmt.Sprintf("\"%s\"", s)
+ }
+ }
+ args[i] = strings.Join(split, ":")
+ }
+ return strings.Join(args, " ")
+}
+
+type JSONBugExcerpt struct {
+ Id string `json:"id"`
+ HumanId string `json:"human_id"`
+ CreateTime cmdjson.Time `json:"create_time"`
+ EditTime cmdjson.Time `json:"edit_time"`
+
+ Status string `json:"status"`
+ Labels []bug.Label `json:"labels"`
+ Title string `json:"title"`
+ Actors []cmdjson.Identity `json:"actors"`
+ Participants []cmdjson.Identity `json:"participants"`
+ Author cmdjson.Identity `json:"author"`
+
+ Comments int `json:"comments"`
+ Metadata map[string]string `json:"metadata"`
+}
+
+func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
+ jsonBugs := make([]JSONBugExcerpt, len(bugExcerpts))
+ for i, b := range bugExcerpts {
+ jsonBug := JSONBugExcerpt{
+ Id: b.Id.String(),
+ HumanId: b.Id.Human(),
+ CreateTime: cmdjson.NewTime(b.CreateTime(), b.CreateLamportTime),
+ EditTime: cmdjson.NewTime(b.EditTime(), b.EditLamportTime),
+ Status: b.Status.String(),
+ Labels: b.Labels,
+ Title: b.Title,
+ Comments: b.LenComments,
+ Metadata: b.CreateMetadata,
+ }
+
+ author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId)
+ if err != nil {
+ return err
+ }
+ jsonBug.Author = cmdjson.NewIdentityFromExcerpt(author)
+
+ jsonBug.Actors = make([]cmdjson.Identity, len(b.Actors))
+ for i, element := range b.Actors {
+ actor, err := env.Backend.ResolveIdentityExcerpt(element)
+ if err != nil {
+ return err
+ }
+ jsonBug.Actors[i] = cmdjson.NewIdentityFromExcerpt(actor)
+ }
+
+ jsonBug.Participants = make([]cmdjson.Identity, len(b.Participants))
+ for i, element := range b.Participants {
+ participant, err := env.Backend.ResolveIdentityExcerpt(element)
+ if err != nil {
+ return err
+ }
+ jsonBug.Participants[i] = cmdjson.NewIdentityFromExcerpt(participant)
+ }
+
+ jsonBugs[i] = jsonBug
+ }
+ jsonObject, _ := json.MarshalIndent(jsonBugs, "", " ")
+ env.Out.Printf("%s\n", jsonObject)
+ return nil
+}
+
+func bugsCompactFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
+ for _, b := range bugExcerpts {
+ author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId)
+ if err != nil {
+ return err
+ }
+
+ var labelsTxt strings.Builder
+ for _, l := range b.Labels {
+ lc256 := l.Color().Term256()
+ labelsTxt.WriteString(lc256.Escape())
+ labelsTxt.WriteString("◼")
+ labelsTxt.WriteString(lc256.Unescape())
+ }
+
+ env.Out.Printf("%s %s %s %s %s\n",
+ colors.Cyan(b.Id.Human()),
+ colors.Yellow(b.Status),
+ text.LeftPadMaxLine(strings.TrimSpace(b.Title), 40, 0),
+ text.LeftPadMaxLine(labelsTxt.String(), 5, 0),
+ colors.Magenta(text.TruncateMax(author.DisplayName(), 15)),
+ )
+ }
+ return nil
+}
+
+func bugsIDFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
+ for _, b := range bugExcerpts {
+ env.Out.Println(b.Id.String())
+ }
+
+ return nil
+}
+
+func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
+ for _, b := range bugExcerpts {
+ author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId)
+ if err != nil {
+ return err
+ }
+
+ var labelsTxt strings.Builder
+ for _, l := range b.Labels {
+ lc256 := l.Color().Term256()
+ labelsTxt.WriteString(lc256.Escape())
+ labelsTxt.WriteString(" ◼")
+ labelsTxt.WriteString(lc256.Unescape())
+ }
+
+ // truncate + pad if needed
+ labelsFmt := text.TruncateMax(labelsTxt.String(), 10)
+ titleFmt := text.LeftPadMaxLine(strings.TrimSpace(b.Title), 50-text.Len(labelsFmt), 0)
+ authorFmt := text.LeftPadMaxLine(author.DisplayName(), 15, 0)
+
+ comments := fmt.Sprintf("%3d 💬", b.LenComments-1)
+ if b.LenComments-1 <= 0 {
+ comments = ""
+ }
+ if b.LenComments-1 > 999 {
+ comments = " ∞ 💬"
+ }
+
+ env.Out.Printf("%s\t%s\t%s\t%s\t%s\n",
+ colors.Cyan(b.Id.Human()),
+ colors.Yellow(b.Status),
+ titleFmt+labelsFmt,
+ colors.Magenta(authorFmt),
+ comments,
+ )
+ }
+ return nil
+}
+
+func bugsPlainFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
+ for _, b := range bugExcerpts {
+ env.Out.Printf("%s [%s] %s\n", b.Id.Human(), b.Status, strings.TrimSpace(b.Title))
+ }
+ return nil
+}
+
+func bugsOrgmodeFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
+ // see https://orgmode.org/manual/Tags.html
+ orgTagRe := regexp.MustCompile("[^[:alpha:]_@]")
+ formatTag := func(l bug.Label) string {
+ return orgTagRe.ReplaceAllString(l.String(), "_")
+ }
+
+ formatTime := func(time time.Time) string {
+ return time.Format("[2006-01-02 Mon 15:05]")
+ }
+
+ env.Out.Println("#+TODO: OPEN | CLOSED")
+
+ for _, b := range bugExcerpts {
+ status := strings.ToUpper(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
+ }
+
+ author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId)
+ if err != nil {
+ return err
+ }
+
+ var labels strings.Builder
+ labels.WriteString(":")
+ for i, l := range b.Labels {
+ if i > 0 {
+ labels.WriteString(":")
+ }
+ labels.WriteString(formatTag(l))
+ }
+ labels.WriteString(":")
+
+ env.Out.Printf("* %-6s %s %s %s: %s %s\n",
+ status,
+ b.Id.Human(),
+ formatTime(b.CreateTime()),
+ author.DisplayName(),
+ title,
+ labels.String(),
+ )
+
+ env.Out.Printf("** Last Edited: %s\n", formatTime(b.EditTime()))
+
+ env.Out.Printf("** Actors:\n")
+ for _, element := range b.Actors {
+ actor, err := env.Backend.ResolveIdentityExcerpt(element)
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf(": %s %s\n",
+ actor.Id.Human(),
+ actor.DisplayName(),
+ )
+ }
+
+ env.Out.Printf("** Participants:\n")
+ for _, element := range b.Participants {
+ participant, err := env.Backend.ResolveIdentityExcerpt(element)
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf(": %s %s\n",
+ participant.Id.Human(),
+ participant.DisplayName(),
+ )
+ }
+ }
+
+ return nil
+}
+
+// Finish the command flags transformation into the query.Query
+func completeQuery(q *query.Query, opts bugOptions) error {
+ for _, str := range opts.statusQuery {
+ status, err := common.StatusFromString(str)
+ if err != nil {
+ return err
+ }
+ q.Status = append(q.Status, status)
+ }
+
+ q.Author = append(q.Author, opts.authorQuery...)
+ for _, str := range opts.metadataQuery {
+ tokens := strings.Split(str, "=")
+ if len(tokens) < 2 {
+ return fmt.Errorf("no \"=\" in key=value metadata markup")
+ }
+ var pair query.StringPair
+ pair.Key = tokens[0]
+ pair.Value = tokens[1]
+ q.Metadata = append(q.Metadata, pair)
+ }
+ q.Participant = append(q.Participant, opts.participantQuery...)
+ q.Actor = append(q.Actor, opts.actorQuery...)
+ q.Label = append(q.Label, opts.labelQuery...)
+ q.Title = append(q.Title, opts.titleQuery...)
+
+ for _, no := range opts.noQuery {
+ switch no {
+ case "label":
+ q.NoLabel = true
+ default:
+ return fmt.Errorf("unknown \"no\" filter %s", no)
+ }
+ }
+
+ switch opts.sortBy {
+ case "id":
+ q.OrderBy = query.OrderById
+ case "creation":
+ q.OrderBy = query.OrderByCreation
+ case "edit":
+ q.OrderBy = query.OrderByEdit
+ default:
+ return fmt.Errorf("unknown sort flag %s", opts.sortBy)
+ }
+
+ switch opts.sortDirection {
+ case "asc":
+ q.OrderDirection = query.OrderAscending
+ case "desc":
+ q.OrderDirection = query.OrderDescending
+ default:
+ return fmt.Errorf("unknown sort direction %s", opts.sortDirection)
+ }
+
+ return nil
+}
diff --git a/commands/bug/bug_comment.go b/commands/bug/bug_comment.go
new file mode 100644
index 00000000..bc665f0d
--- /dev/null
+++ b/commands/bug/bug_comment.go
@@ -0,0 +1,52 @@
+package bugcmd
+
+import (
+ text "github.com/MichaelMure/go-term-text"
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/util/colors"
+)
+
+func newBugCommentCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "comment [BUG_ID]",
+ Short: "List a bug's comments",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugComment(env, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ cmd.AddCommand(newBugCommentNewCommand())
+ cmd.AddCommand(newBugCommentEditCommand())
+
+ return cmd
+}
+
+func runBugComment(env *execenv.Env, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ snap := b.Snapshot()
+
+ for i, comment := range snap.Comments {
+ if i != 0 {
+ env.Out.Println()
+ }
+
+ env.Out.Printf("Author: %s\n", colors.Magenta(comment.Author.DisplayName()))
+ env.Out.Printf("Id: %s\n", colors.Cyan(comment.CombinedId().Human()))
+ env.Out.Printf("Date: %s\n\n", comment.FormatTime())
+ env.Out.Println(text.LeftPadLines(comment.Message, 4))
+ }
+
+ return nil
+}
diff --git a/commands/bug/bug_comment_add.go b/commands/bug/bug_comment_add.go
new file mode 100644
index 00000000..b676db3a
--- /dev/null
+++ b/commands/bug/bug_comment_add.go
@@ -0,0 +1,80 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/commands/input"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+type bugCommentNewOptions struct {
+ messageFile string
+ message string
+ nonInteractive bool
+}
+
+func newBugCommentNewCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bugCommentNewOptions{}
+
+ cmd := &cobra.Command{
+ Use: "new [BUG_ID]",
+ Short: "Add a new comment to a bug",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugCommentNew(env, options, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVarP(&options.messageFile, "file", "F", "",
+ "Take the message from the given file. Use - to read the message from the standard input")
+
+ flags.StringVarP(&options.message, "message", "m", "",
+ "Provide the new message from the command line")
+ flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input")
+
+ return cmd
+}
+
+func runBugCommentNew(env *execenv.Env, opts bugCommentNewOptions, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ if opts.messageFile != "" && opts.message == "" {
+ opts.message, err = input.BugCommentFileInput(opts.messageFile)
+ if err != nil {
+ return err
+ }
+ }
+
+ if opts.messageFile == "" && opts.message == "" {
+ if opts.nonInteractive {
+ env.Err.Println("No message given. Use -m or -F option to specify a message. Aborting.")
+ return nil
+ }
+ opts.message, err = input.BugCommentEditorInput(env.Backend, "")
+ if err == input.ErrEmptyMessage {
+ env.Err.Println("Empty message, aborting.")
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ _, _, err = b.AddComment(text.Cleanup(opts.message))
+ if err != nil {
+ return err
+ }
+
+ return b.Commit()
+}
diff --git a/commands/bug/bug_comment_add_test.go b/commands/bug/bug_comment_add_test.go
new file mode 100644
index 00000000..55e285f4
--- /dev/null
+++ b/commands/bug/bug_comment_add_test.go
@@ -0,0 +1,18 @@
+package bugcmd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/commands/bug/testenv"
+)
+
+func TestBugCommentNew(t *testing.T) {
+ const golden = "testdata/comment/add"
+
+ env, bugID, _ := testenv.NewTestEnvAndBugWithComment(t)
+
+ require.NoError(t, runBugComment(env, []string{bugID.String()}))
+ requireCommentsEqual(t, golden, env)
+}
diff --git a/commands/bug/bug_comment_edit.go b/commands/bug/bug_comment_edit.go
new file mode 100644
index 00000000..8be7cb80
--- /dev/null
+++ b/commands/bug/bug_comment_edit.go
@@ -0,0 +1,77 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/commands/input"
+)
+
+type bugCommentEditOptions struct {
+ messageFile string
+ message string
+ nonInteractive bool
+}
+
+func newBugCommentEditCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bugCommentEditOptions{}
+
+ cmd := &cobra.Command{
+ Use: "edit [COMMENT_ID]",
+ Short: "Edit an existing comment on a bug",
+ Args: cobra.ExactArgs(1),
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugCommentEdit(env, options, args)
+ }),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVarP(&options.messageFile, "file", "F", "",
+ "Take the message from the given file. Use - to read the message from the standard input")
+
+ flags.StringVarP(&options.message, "message", "m", "",
+ "Provide the new message from the command line")
+ flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input")
+
+ return cmd
+}
+
+func runBugCommentEdit(env *execenv.Env, opts bugCommentEditOptions, args []string) error {
+ b, commentId, err := env.Backend.ResolveComment(args[0])
+ if err != nil {
+ return err
+ }
+
+ if opts.messageFile != "" && opts.message == "" {
+ opts.message, err = input.BugCommentFileInput(opts.messageFile)
+ if err != nil {
+ return err
+ }
+ }
+
+ if opts.messageFile == "" && opts.message == "" {
+ if opts.nonInteractive {
+ env.Err.Println("No message given. Use -m or -F option to specify a message. Aborting.")
+ return nil
+ }
+ opts.message, err = input.BugCommentEditorInput(env.Backend, "")
+ if err == input.ErrEmptyMessage {
+ env.Err.Println("Empty message, aborting.")
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ _, err = b.EditComment(commentId, opts.message)
+ if err != nil {
+ return err
+ }
+
+ return b.Commit()
+}
diff --git a/commands/bug/bug_comment_edit_test.go b/commands/bug/bug_comment_edit_test.go
new file mode 100644
index 00000000..9e110a3b
--- /dev/null
+++ b/commands/bug/bug_comment_edit_test.go
@@ -0,0 +1,23 @@
+package bugcmd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/commands/bug/testenv"
+)
+
+func TestBugCommentEdit(t *testing.T) {
+ const golden = "testdata/comment/edit"
+
+ env, bugID, commentID := testenv.NewTestEnvAndBugWithComment(t)
+
+ opts := bugCommentEditOptions{
+ message: "this is an altered bug comment",
+ }
+ require.NoError(t, runBugCommentEdit(env, opts, []string{commentID.Human()}))
+
+ require.NoError(t, runBugComment(env, []string{bugID.Human()}))
+ requireCommentsEqual(t, golden, env)
+}
diff --git a/commands/bug/bug_comment_test.go b/commands/bug/bug_comment_test.go
new file mode 100644
index 00000000..c1dc9952
--- /dev/null
+++ b/commands/bug/bug_comment_test.go
@@ -0,0 +1,164 @@
+package bugcmd
+
+import (
+ "fmt"
+ "io/ioutil"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/commands/bug/testenv"
+ "github.com/MichaelMure/git-bug/commands/cmdtest"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func TestBugComment(t *testing.T) {
+ const golden = "testdata/comment/message-only"
+
+ env, bug := testenv.NewTestEnvAndBug(t)
+
+ require.NoError(t, runBugComment(env, []string{bug.Human()}))
+
+ requireCommentsEqual(t, golden, env)
+}
+
+const gitDateFormat = "Mon Jan 2 15:04:05 2006 -0700"
+
+type parsedComment struct {
+ author string
+ id string
+ date time.Time
+ message string
+}
+
+type parseFunc func(*parsedComment, string)
+
+type commentParser struct {
+ t *testing.T
+ fn parseFunc
+ comments []parsedComment
+}
+
+func parseComments(t *testing.T, env *execenv.Env) []parsedComment {
+ t.Helper()
+
+ parser := &commentParser{
+ t: t,
+ comments: []parsedComment{},
+ }
+
+ comment := &parsedComment{}
+ parser.fn = parser.parseAuthor
+
+ for _, line := range strings.Split(env.Out.String(), "\n") {
+ parser.fn(comment, line)
+ }
+
+ parser.comments = append(parser.comments, *comment)
+
+ return parser.comments
+}
+
+func (p *commentParser) parseAuthor(comment *parsedComment, line string) {
+ p.t.Helper()
+
+ tkns := strings.Split(line, ": ")
+ require.Len(p.t, tkns, 2)
+ require.Equal(p.t, "Author", tkns[0])
+
+ comment.author = tkns[1]
+ p.fn = p.parseID
+}
+
+func (p *commentParser) parseID(comment *parsedComment, line string) {
+ p.t.Helper()
+
+ tkns := strings.Split(line, ": ")
+ require.Len(p.t, tkns, 2)
+ require.Equal(p.t, "Id", tkns[0])
+
+ comment.id = tkns[1]
+ p.fn = p.parseDate
+}
+
+func (p *commentParser) parseDate(comment *parsedComment, line string) {
+ p.t.Helper()
+
+ tkns := strings.Split(line, ": ")
+ require.Len(p.t, tkns, 2)
+ require.Equal(p.t, "Date", tkns[0])
+
+ date, err := time.Parse(gitDateFormat, tkns[1])
+ require.NoError(p.t, err)
+
+ comment.date = date
+ p.fn = p.parseMessage
+}
+
+func (p *commentParser) parseMessage(comment *parsedComment, line string) {
+ p.t.Helper()
+
+ if strings.HasPrefix(line, "Author: ") {
+ p.comments = append(p.comments, *comment)
+ comment = &parsedComment{}
+ p.parseAuthor(comment, line)
+
+ return
+ }
+
+ require.True(p.t, line == "" || strings.HasPrefix(line, " "))
+
+ comment.message = strings.Join([]string{comment.message, line}, "\n")
+}
+
+func normalizeParsedComments(t *testing.T, comments []parsedComment) []parsedComment {
+ t.Helper()
+
+ prefix := 0x1234567
+ date, err := time.Parse(gitDateFormat, "Fri Aug 19 07:00:00 2022 +1900")
+ require.NoError(t, err)
+
+ var out []parsedComment
+
+ for i, comment := range comments {
+ comment.id = fmt.Sprintf("%7x", prefix+i)
+ comment.date = date.Add(time.Duration(i) * time.Minute)
+ out = append(out, comment)
+ }
+
+ return out
+}
+
+func requireCommentsEqual(t *testing.T, golden string, env *execenv.Env) {
+ t.Helper()
+
+ const goldenFilePattern = "%s-%d-golden.txt"
+
+ comments := parseComments(t, env)
+ comments = normalizeParsedComments(t, comments)
+
+ if *cmdtest.Update {
+ t.Log("Got here")
+ for i, comment := range comments {
+ fileName := fmt.Sprintf(goldenFilePattern, golden, i)
+ require.NoError(t, ioutil.WriteFile(fileName, []byte(comment.message), 0644))
+ }
+ }
+
+ prefix := 0x1234567
+ date, err := time.Parse(gitDateFormat, "Fri Aug 19 07:00:00 2022 +1900")
+ require.NoError(t, err)
+
+ for i, comment := range comments {
+ require.Equal(t, "John Doe", comment.author)
+ require.Equal(t, fmt.Sprintf("%7x", prefix+i), comment.id)
+ require.Equal(t, date.Add(time.Duration(i)*time.Minute), comment.date)
+
+ fileName := fmt.Sprintf(goldenFilePattern, golden, i)
+ exp, err := ioutil.ReadFile(fileName)
+ require.NoError(t, err)
+ require.Equal(t, strings.ReplaceAll(string(exp), "\r", ""), strings.ReplaceAll(comment.message, "\r", ""))
+ }
+}
diff --git a/commands/bug/bug_deselect.go b/commands/bug/bug_deselect.go
new file mode 100644
index 00000000..7e2a86c9
--- /dev/null
+++ b/commands/bug/bug_deselect.go
@@ -0,0 +1,37 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBugDeselectCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "deselect",
+ Short: "Clear the implicitly selected bug",
+ Example: `git bug select 2f15
+git bug comment
+git bug status
+git bug deselect
+`,
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugDeselect(env)
+ }),
+ }
+
+ return cmd
+}
+
+func runBugDeselect(env *execenv.Env) error {
+ err := _select.Clear(env.Backend)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/commands/bug/bug_label.go b/commands/bug/bug_label.go
new file mode 100644
index 00000000..657fa2ca
--- /dev/null
+++ b/commands/bug/bug_label.go
@@ -0,0 +1,43 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBugLabelCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "label [BUG_ID]",
+ Short: "Display labels of a bug",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugLabel(env, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ cmd.AddCommand(newBugLabelNewCommand())
+ cmd.AddCommand(newBugLabelRmCommand())
+
+ return cmd
+}
+
+func runBugLabel(env *execenv.Env, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ snap := b.Snapshot()
+
+ for _, l := range snap.Labels {
+ env.Out.Println(l)
+ }
+
+ return nil
+}
diff --git a/commands/bug/bug_label_new.go b/commands/bug/bug_label_new.go
new file mode 100644
index 00000000..f94d3dc8
--- /dev/null
+++ b/commands/bug/bug_label_new.go
@@ -0,0 +1,47 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+func newBugLabelNewCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "new [BUG_ID] LABEL...",
+ Short: "Add a label to a bug",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugLabelNew(env, args)
+ }),
+ ValidArgsFunction: completion.BugAndLabels(env, true),
+ }
+
+ return cmd
+}
+
+func runBugLabelNew(env *execenv.Env, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ added := args
+
+ changes, _, err := b.ChangeLabels(text.CleanupOneLineArray(added), nil)
+
+ for _, change := range changes {
+ env.Out.Println(change)
+ }
+
+ if err != nil {
+ return err
+ }
+
+ return b.Commit()
+}
diff --git a/commands/bug/bug_label_rm.go b/commands/bug/bug_label_rm.go
new file mode 100644
index 00000000..13ce4b81
--- /dev/null
+++ b/commands/bug/bug_label_rm.go
@@ -0,0 +1,47 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+func newBugLabelRmCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "rm [BUG_ID] LABEL...",
+ Short: "Remove a label from a bug",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugLabelRm(env, args)
+ }),
+ ValidArgsFunction: completion.BugAndLabels(env, false),
+ }
+
+ return cmd
+}
+
+func runBugLabelRm(env *execenv.Env, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ removed := args
+
+ changes, _, err := b.ChangeLabels(nil, text.CleanupOneLineArray(removed))
+
+ for _, change := range changes {
+ env.Out.Println(change)
+ }
+
+ if err != nil {
+ return err
+ }
+
+ return b.Commit()
+}
diff --git a/commands/bug/bug_new.go b/commands/bug/bug_new.go
new file mode 100644
index 00000000..4f73a09c
--- /dev/null
+++ b/commands/bug/bug_new.go
@@ -0,0 +1,77 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/commands/input"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+type bugNewOptions struct {
+ title string
+ message string
+ messageFile string
+ nonInteractive bool
+}
+
+func newBugNewCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bugNewOptions{}
+
+ cmd := &cobra.Command{
+ Use: "new",
+ Short: "Create a new bug",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugNew(env, options)
+ }),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVarP(&options.title, "title", "t", "",
+ "Provide a title to describe the issue")
+ flags.StringVarP(&options.message, "message", "m", "",
+ "Provide a message to describe the issue")
+ flags.StringVarP(&options.messageFile, "file", "F", "",
+ "Take the message from the given file. Use - to read the message from the standard input")
+ flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input")
+
+ return cmd
+}
+
+func runBugNew(env *execenv.Env, opts bugNewOptions) error {
+ var err error
+ if opts.messageFile != "" && opts.message == "" {
+ opts.title, opts.message, err = input.BugCreateFileInput(opts.messageFile)
+ if err != nil {
+ return err
+ }
+ }
+
+ if !opts.nonInteractive && opts.messageFile == "" && (opts.message == "" || opts.title == "") {
+ opts.title, opts.message, err = input.BugCreateEditorInput(env.Backend, opts.title, opts.message)
+
+ if err == input.ErrEmptyTitle {
+ env.Out.Println("Empty title, aborting.")
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ b, _, err := env.Backend.NewBug(
+ text.CleanupOneLine(opts.title),
+ text.Cleanup(opts.message),
+ )
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf("%s created\n", b.Id().Human())
+
+ return nil
+}
diff --git a/commands/bug/bug_new_test.go b/commands/bug/bug_new_test.go
new file mode 100644
index 00000000..210a4b0b
--- /dev/null
+++ b/commands/bug/bug_new_test.go
@@ -0,0 +1,21 @@
+package bugcmd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/commands/bug/testenv"
+)
+
+func TestBugNew(t *testing.T) {
+ env, _ := testenv.NewTestEnvAndUser(t)
+
+ err := runBugNew(env, bugNewOptions{
+ nonInteractive: true,
+ message: "message",
+ title: "title",
+ })
+ require.NoError(t, err)
+ require.Regexp(t, "^[0-9A-Fa-f]{7} created\n$", env.Out.String())
+}
diff --git a/commands/bug/bug_rm.go b/commands/bug/bug_rm.go
new file mode 100644
index 00000000..1d2a7524
--- /dev/null
+++ b/commands/bug/bug_rm.go
@@ -0,0 +1,46 @@
+package bugcmd
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBugRmCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "rm BUG_ID",
+ Short: "Remove an existing bug",
+ Long: "Remove an existing bug in the local repository. Note removing bugs that were imported from bridges will not remove the bug on the remote, and will only remove the local copy of the bug.",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugRm(env, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ return cmd
+}
+
+func runBugRm(env *execenv.Env, args []string) (err error) {
+ if len(args) == 0 {
+ return errors.New("you must provide a bug prefix to remove")
+ }
+
+ err = env.Backend.RemoveBug(args[0])
+
+ if err != nil {
+ return
+ }
+
+ env.Out.Printf("bug %s removed\n", args[0])
+
+ return
+}
diff --git a/commands/bug/bug_rm_test.go b/commands/bug/bug_rm_test.go
new file mode 100644
index 00000000..e0c2bbc5
--- /dev/null
+++ b/commands/bug/bug_rm_test.go
@@ -0,0 +1,19 @@
+package bugcmd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/commands/bug/testenv"
+)
+
+func TestBugRm(t *testing.T) {
+ env, bugID := testenv.NewTestEnvAndBug(t)
+
+ exp := "bug " + bugID.Human() + " removed\n"
+
+ require.NoError(t, runBugRm(env, []string{bugID.Human()}))
+ require.Equal(t, exp, env.Out.String())
+ env.Out.Reset()
+}
diff --git a/commands/bug/bug_select.go b/commands/bug/bug_select.go
new file mode 100644
index 00000000..0b1cb15c
--- /dev/null
+++ b/commands/bug/bug_select.go
@@ -0,0 +1,62 @@
+package bugcmd
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBugSelectCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "select BUG_ID",
+ Short: "Select a bug for implicit use in future commands",
+ Example: `git bug select 2f15
+git bug comment
+git bug status
+`,
+ Long: `Select a bug for implicit use in future commands.
+
+This command allows you to omit any bug ID argument, for example:
+ git bug show
+instead of
+ git bug show 2f153ca
+
+The complementary command is "git bug deselect" performing the opposite operation.
+`,
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugSelect(env, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ return cmd
+}
+
+func runBugSelect(env *execenv.Env, args []string) error {
+ if len(args) == 0 {
+ return errors.New("You must provide a bug id")
+ }
+
+ prefix := args[0]
+
+ b, err := env.Backend.ResolveBugPrefix(prefix)
+ if err != nil {
+ return err
+ }
+
+ err = _select.Select(env.Backend, b.Id())
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf("selected bug %s: %s\n", b.Id().Human(), b.Snapshot().Title)
+
+ return nil
+}
diff --git a/commands/bug/bug_show.go b/commands/bug/bug_show.go
new file mode 100644
index 00000000..105b1150
--- /dev/null
+++ b/commands/bug/bug_show.go
@@ -0,0 +1,329 @@
+package bugcmd
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/cmdjson"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/entities/bug"
+ "github.com/MichaelMure/git-bug/util/colors"
+)
+
+type bugShowOptions struct {
+ fields string
+ format string
+}
+
+func newBugShowCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bugShowOptions{}
+
+ cmd := &cobra.Command{
+ Use: "show [BUG_ID]",
+ Short: "Display the details of a bug",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugShow(env, options, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ fields := []string{"author", "authorEmail", "createTime", "lastEdit", "humanId",
+ "id", "labels", "shortId", "status", "title", "actors", "participants"}
+ flags.StringVarP(&options.fields, "field", "", "",
+ "Select field to display. Valid values are ["+strings.Join(fields, ",")+"]")
+ cmd.RegisterFlagCompletionFunc("by", completion.From(fields))
+ flags.StringVarP(&options.format, "format", "f", "default",
+ "Select the output formatting style. Valid values are [default,json,org-mode]")
+
+ return cmd
+}
+
+func runBugShow(env *execenv.Env, opts bugShowOptions, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ snap := b.Snapshot()
+
+ if len(snap.Comments) == 0 {
+ return errors.New("invalid bug: no comment")
+ }
+
+ if opts.fields != "" {
+ switch opts.fields {
+ case "author":
+ env.Out.Printf("%s\n", snap.Author.DisplayName())
+ case "authorEmail":
+ env.Out.Printf("%s\n", snap.Author.Email())
+ case "createTime":
+ env.Out.Printf("%s\n", snap.CreateTime.String())
+ case "lastEdit":
+ env.Out.Printf("%s\n", snap.EditTime().String())
+ case "humanId":
+ env.Out.Printf("%s\n", snap.Id().Human())
+ case "id":
+ env.Out.Printf("%s\n", snap.Id())
+ case "labels":
+ for _, l := range snap.Labels {
+ env.Out.Printf("%s\n", l.String())
+ }
+ case "actors":
+ for _, a := range snap.Actors {
+ env.Out.Printf("%s\n", a.DisplayName())
+ }
+ case "participants":
+ for _, p := range snap.Participants {
+ env.Out.Printf("%s\n", p.DisplayName())
+ }
+ case "shortId":
+ env.Out.Printf("%s\n", snap.Id().Human())
+ case "status":
+ env.Out.Printf("%s\n", snap.Status)
+ case "title":
+ env.Out.Printf("%s\n", snap.Title)
+ default:
+ return fmt.Errorf("\nUnsupported field: %s\n", opts.fields)
+ }
+
+ return nil
+ }
+
+ switch opts.format {
+ case "org-mode":
+ return showOrgModeFormatter(env, snap)
+ case "json":
+ return showJsonFormatter(env, snap)
+ case "default":
+ return showDefaultFormatter(env, snap)
+ default:
+ return fmt.Errorf("unknown format %s", opts.format)
+ }
+}
+
+func showDefaultFormatter(env *execenv.Env, snapshot *bug.Snapshot) error {
+ // Header
+ env.Out.Printf("%s [%s] %s\n\n",
+ colors.Cyan(snapshot.Id().Human()),
+ colors.Yellow(snapshot.Status),
+ snapshot.Title,
+ )
+
+ env.Out.Printf("%s opened this issue %s\n",
+ colors.Magenta(snapshot.Author.DisplayName()),
+ snapshot.CreateTime.String(),
+ )
+
+ env.Out.Printf("This was last edited at %s\n\n",
+ snapshot.EditTime().String(),
+ )
+
+ // Labels
+ var labels = make([]string, len(snapshot.Labels))
+ for i := range snapshot.Labels {
+ labels[i] = string(snapshot.Labels[i])
+ }
+
+ env.Out.Printf("labels: %s\n",
+ strings.Join(labels, ", "),
+ )
+
+ // Actors
+ var actors = make([]string, len(snapshot.Actors))
+ for i := range snapshot.Actors {
+ actors[i] = snapshot.Actors[i].DisplayName()
+ }
+
+ env.Out.Printf("actors: %s\n",
+ strings.Join(actors, ", "),
+ )
+
+ // Participants
+ var participants = make([]string, len(snapshot.Participants))
+ for i := range snapshot.Participants {
+ participants[i] = snapshot.Participants[i].DisplayName()
+ }
+
+ env.Out.Printf("participants: %s\n\n",
+ strings.Join(participants, ", "),
+ )
+
+ // Comments
+ indent := " "
+
+ for i, comment := range snapshot.Comments {
+ var message string
+ env.Out.Printf("%s%s #%d %s <%s>\n\n",
+ indent,
+ comment.CombinedId().Human(),
+ i,
+ comment.Author.DisplayName(),
+ comment.Author.Email(),
+ )
+
+ if comment.Message == "" {
+ message = colors.BlackBold(colors.WhiteBg("No description provided."))
+ } else {
+ message = comment.Message
+ }
+
+ env.Out.Printf("%s%s\n\n\n",
+ indent,
+ message,
+ )
+ }
+
+ return nil
+}
+
+type JSONBugSnapshot struct {
+ Id string `json:"id"`
+ HumanId string `json:"human_id"`
+ CreateTime cmdjson.Time `json:"create_time"`
+ EditTime cmdjson.Time `json:"edit_time"`
+ Status string `json:"status"`
+ Labels []bug.Label `json:"labels"`
+ Title string `json:"title"`
+ Author cmdjson.Identity `json:"author"`
+ Actors []cmdjson.Identity `json:"actors"`
+ Participants []cmdjson.Identity `json:"participants"`
+ Comments []JSONBugComment `json:"comments"`
+}
+
+type JSONBugComment struct {
+ Id string `json:"id"`
+ HumanId string `json:"human_id"`
+ Author cmdjson.Identity `json:"author"`
+ Message string `json:"message"`
+}
+
+func NewJSONComment(comment bug.Comment) JSONBugComment {
+ return JSONBugComment{
+ Id: comment.CombinedId().String(),
+ HumanId: comment.CombinedId().Human(),
+ Author: cmdjson.NewIdentity(comment.Author),
+ Message: comment.Message,
+ }
+}
+
+func showJsonFormatter(env *execenv.Env, snapshot *bug.Snapshot) error {
+ jsonBug := JSONBugSnapshot{
+ Id: snapshot.Id().String(),
+ HumanId: snapshot.Id().Human(),
+ CreateTime: cmdjson.NewTime(snapshot.CreateTime, 0),
+ EditTime: cmdjson.NewTime(snapshot.EditTime(), 0),
+ Status: snapshot.Status.String(),
+ Labels: snapshot.Labels,
+ Title: snapshot.Title,
+ Author: cmdjson.NewIdentity(snapshot.Author),
+ }
+
+ jsonBug.Actors = make([]cmdjson.Identity, len(snapshot.Actors))
+ for i, element := range snapshot.Actors {
+ jsonBug.Actors[i] = cmdjson.NewIdentity(element)
+ }
+
+ jsonBug.Participants = make([]cmdjson.Identity, len(snapshot.Participants))
+ for i, element := range snapshot.Participants {
+ jsonBug.Participants[i] = cmdjson.NewIdentity(element)
+ }
+
+ jsonBug.Comments = make([]JSONBugComment, len(snapshot.Comments))
+ for i, comment := range snapshot.Comments {
+ jsonBug.Comments[i] = NewJSONComment(comment)
+ }
+
+ jsonObject, _ := json.MarshalIndent(jsonBug, "", " ")
+ env.Out.Printf("%s\n", jsonObject)
+
+ return nil
+}
+
+func showOrgModeFormatter(env *execenv.Env, snapshot *bug.Snapshot) error {
+ // Header
+ env.Out.Printf("%s [%s] %s\n",
+ snapshot.Id().Human(),
+ snapshot.Status,
+ snapshot.Title,
+ )
+
+ env.Out.Printf("* Author: %s\n",
+ snapshot.Author.DisplayName(),
+ )
+
+ env.Out.Printf("* Creation Time: %s\n",
+ snapshot.CreateTime.String(),
+ )
+
+ env.Out.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)
+ }
+
+ env.Out.Printf("* Labels:\n")
+ if len(labels) > 0 {
+ env.Out.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(),
+ )
+ }
+
+ env.Out.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(),
+ )
+ }
+
+ env.Out.Printf("* Participants:\n** %s\n",
+ strings.Join(participants, "\n** "),
+ )
+
+ env.Out.Printf("* Comments:\n")
+
+ for i, comment := range snapshot.Comments {
+ var message string
+ env.Out.Printf("** #%d %s\n",
+ i, comment.Author.DisplayName())
+
+ if comment.Message == "" {
+ message = "No description provided."
+ } else {
+ message = strings.ReplaceAll(comment.Message, "\n", "\n: ")
+ }
+
+ env.Out.Printf(": %s\n", message)
+ }
+
+ return nil
+}
diff --git a/commands/bug/bug_status.go b/commands/bug/bug_status.go
new file mode 100644
index 00000000..b05f862c
--- /dev/null
+++ b/commands/bug/bug_status.go
@@ -0,0 +1,41 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBugStatusCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "status [BUG_ID]",
+ Short: "Display the status of a bug",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugStatus(env, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ cmd.AddCommand(newBugStatusCloseCommand())
+ cmd.AddCommand(newBugStatusOpenCommand())
+
+ return cmd
+}
+
+func runBugStatus(env *execenv.Env, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ snap := b.Snapshot()
+
+ env.Out.Println(snap.Status)
+
+ return nil
+}
diff --git a/commands/bug/bug_status_close.go b/commands/bug/bug_status_close.go
new file mode 100644
index 00000000..fcd47922
--- /dev/null
+++ b/commands/bug/bug_status_close.go
@@ -0,0 +1,39 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBugStatusCloseCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "close [BUG_ID]",
+ Short: "Mark a bug as closed",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugStatusClose(env, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ return cmd
+}
+
+func runBugStatusClose(env *execenv.Env, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ _, err = b.Close()
+ if err != nil {
+ return err
+ }
+
+ return b.Commit()
+}
diff --git a/commands/bug/bug_status_open.go b/commands/bug/bug_status_open.go
new file mode 100644
index 00000000..e686add1
--- /dev/null
+++ b/commands/bug/bug_status_open.go
@@ -0,0 +1,39 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBugStatusOpenCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "open [BUG_ID]",
+ Short: "Mark a bug as open",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugStatusOpen(env, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ return cmd
+}
+
+func runBugStatusOpen(env *execenv.Env, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ _, err = b.Open()
+ if err != nil {
+ return err
+ }
+
+ return b.Commit()
+}
diff --git a/commands/bug/bug_test.go b/commands/bug/bug_test.go
new file mode 100644
index 00000000..aef0346d
--- /dev/null
+++ b/commands/bug/bug_test.go
@@ -0,0 +1,103 @@
+package bugcmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/commands/bug/testenv"
+)
+
+func Test_repairQuery(t *testing.T) {
+ cases := []struct {
+ args []string
+ output string
+ }{
+ {
+ []string{""},
+ "",
+ },
+ {
+ []string{"foo"},
+ "foo",
+ },
+ {
+ []string{"foo", "bar"},
+ "foo bar",
+ },
+ {
+ []string{"foo bar", "baz"},
+ "\"foo bar\" baz",
+ },
+ {
+ []string{"foo:bar", "baz"},
+ "foo:bar baz",
+ },
+ {
+ []string{"foo:bar boo", "baz"},
+ "foo:\"bar boo\" baz",
+ },
+ }
+
+ for _, tc := range cases {
+ require.Equal(t, tc.output, repairQuery(tc.args))
+ }
+}
+
+func TestBug_Format(t *testing.T) {
+ const expOrgMode = `^#+TODO: OPEN | CLOSED
+[*] OPEN [0-9a-f]{7} \[\d\d\d\d-\d\d-\d\d [[:alpha:]]{3} \d\d:\d\d\] John Doe: this is a bug title ::
+[*]{2} Last Edited: \[\d\d\d\d-\d\d-\d\d [[:alpha:]]{3} \d\d:\d\d\]
+[*]{2} Actors:
+: [0-9a-f]{7} John Doe
+[*]{2} Participants:
+: [0-9a-f]{7} John Doe
+$`
+
+ cases := []struct {
+ format string
+ exp string
+ }{
+ {"default", "^[0-9a-f]{7}\topen\tthis is a bug title \tJohn Doe \t\n$"},
+ {"plain", "^[0-9a-f]{7} \\[open\\] this is a bug title\n$"},
+ {"compact", "^[0-9a-f]{7} open this is a bug title John Doe\n$"},
+ {"id", "^[0-9a-f]{64}\n$"},
+ {"org-mode", expOrgMode},
+ }
+
+ for _, testcase := range cases {
+ opts := bugOptions{
+ sortDirection: "asc",
+ sortBy: "creation",
+ outputFormat: testcase.format,
+ }
+
+ name := fmt.Sprintf("with %s format", testcase.format)
+
+ t.Run(name, func(t *testing.T) {
+ env, _ := testenv.NewTestEnvAndBug(t)
+
+ require.NoError(t, runBug(env, opts, []string{}))
+ require.Regexp(t, testcase.exp, env.Out.String())
+ })
+ }
+
+ t.Run("with JSON format", func(t *testing.T) {
+ opts := bugOptions{
+ sortDirection: "asc",
+ sortBy: "creation",
+ outputFormat: "json",
+ }
+
+ env, _ := testenv.NewTestEnvAndBug(t)
+
+ require.NoError(t, runBug(env, opts, []string{}))
+
+ var bugs []JSONBugExcerpt
+ require.NoError(t, json.Unmarshal(env.Out.Bytes(), &bugs))
+
+ require.Len(t, bugs, 1)
+ })
+}
diff --git a/commands/bug/bug_title.go b/commands/bug/bug_title.go
new file mode 100644
index 00000000..98809b60
--- /dev/null
+++ b/commands/bug/bug_title.go
@@ -0,0 +1,40 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBugTitleCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "title [BUG_ID]",
+ Short: "Display the title of a bug",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugTitle(env, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ cmd.AddCommand(newBugTitleEditCommand())
+
+ return cmd
+}
+
+func runBugTitle(env *execenv.Env, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ snap := b.Snapshot()
+
+ env.Out.Println(snap.Title)
+
+ return nil
+}
diff --git a/commands/bug/bug_title_edit.go b/commands/bug/bug_title_edit.go
new file mode 100644
index 00000000..e71330a1
--- /dev/null
+++ b/commands/bug/bug_title_edit.go
@@ -0,0 +1,76 @@
+package bugcmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/commands/bug/select"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/commands/input"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+type bugTitleEditOptions struct {
+ title string
+ nonInteractive bool
+}
+
+func newBugTitleEditCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bugTitleEditOptions{}
+
+ cmd := &cobra.Command{
+ Use: "edit [BUG_ID]",
+ Short: "Edit a title of a bug",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBugTitleEdit(env, options, args)
+ }),
+ ValidArgsFunction: completion.Bug(env),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVarP(&options.title, "title", "t", "",
+ "Provide a title to describe the issue",
+ )
+ flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input")
+
+ return cmd
+}
+
+func runBugTitleEdit(env *execenv.Env, opts bugTitleEditOptions, args []string) error {
+ b, args, err := _select.ResolveBug(env.Backend, args)
+ if err != nil {
+ return err
+ }
+
+ snap := b.Snapshot()
+
+ if opts.title == "" {
+ if opts.nonInteractive {
+ env.Err.Println("No title given. Use -m or -F option to specify a title. Aborting.")
+ return nil
+ }
+ opts.title, err = input.BugTitleEditorInput(env.Repo, snap.Title)
+ if err == input.ErrEmptyTitle {
+ env.Out.Println("Empty title, aborting.")
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ if opts.title == snap.Title {
+ env.Err.Println("No change, aborting.")
+ }
+
+ _, err = b.SetTitle(text.CleanupOneLine(opts.title))
+ if err != nil {
+ return err
+ }
+
+ return b.Commit()
+}
diff --git a/commands/bug/select/select.go b/commands/bug/select/select.go
new file mode 100644
index 00000000..908ad58c
--- /dev/null
+++ b/commands/bug/select/select.go
@@ -0,0 +1,129 @@
+package _select
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+
+ "github.com/pkg/errors"
+
+ "github.com/MichaelMure/git-bug/cache"
+ "github.com/MichaelMure/git-bug/entities/bug"
+ "github.com/MichaelMure/git-bug/entity"
+)
+
+const selectFile = "select"
+
+var ErrNoValidId = errors.New("you must provide a bug id or use the \"select\" command first")
+
+// ResolveBug first try to resolve a bug using the first argument of the command
+// line. If it fails, it fallback to the select mechanism.
+//
+// Returns:
+// - the bug if any
+// - the new list of command line arguments with the bug prefix removed if it
+// has been used
+// - an error if the process failed
+func ResolveBug(repo *cache.RepoCache, args []string) (*cache.BugCache, []string, error) {
+ // At first, try to use the first argument as a bug prefix
+ if len(args) > 0 {
+ b, err := repo.ResolveBugPrefix(args[0])
+
+ if err == nil {
+ return b, args[1:], nil
+ }
+
+ if err != bug.ErrBugNotExist {
+ return nil, nil, err
+ }
+ }
+
+ // first arg is not a valid bug prefix, we can safely use the preselected bug if any
+
+ b, err := selected(repo)
+
+ // selected bug is invalid
+ if err == bug.ErrBugNotExist {
+ // we clear the selected bug
+ err = Clear(repo)
+ if err != nil {
+ return nil, nil, err
+ }
+ return nil, nil, ErrNoValidId
+ }
+
+ // another error when reading the bug
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // bug is successfully retrieved
+ if b != nil {
+ return b, args, nil
+ }
+
+ // no selected bug and no valid first argument
+ return nil, nil, ErrNoValidId
+}
+
+// Select will select a bug for future use
+func Select(repo *cache.RepoCache, id entity.Id) error {
+ f, err := repo.LocalStorage().OpenFile(selectFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
+ if err != nil {
+ return err
+ }
+
+ _, err = f.Write([]byte(id.String()))
+ if err != nil {
+ return err
+ }
+
+ return f.Close()
+}
+
+// Clear will clear the selected bug, if any
+func Clear(repo *cache.RepoCache) error {
+ return repo.LocalStorage().Remove(selectFile)
+}
+
+func selected(repo *cache.RepoCache) (*cache.BugCache, error) {
+ f, err := repo.LocalStorage().Open(selectFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ } else {
+ return nil, err
+ }
+ }
+
+ buf, err := ioutil.ReadAll(io.LimitReader(f, 100))
+ if err != nil {
+ return nil, err
+ }
+ if len(buf) == 100 {
+ return nil, fmt.Errorf("the select file should be < 100 bytes")
+ }
+
+ id := entity.Id(buf)
+ if err := id.Validate(); err != nil {
+ err = repo.LocalStorage().Remove(selectFile)
+ if err != nil {
+ return nil, errors.Wrap(err, "error while removing invalid select file")
+ }
+
+ return nil, fmt.Errorf("select file in invalid, removing it")
+ }
+
+ b, err := repo.ResolveBug(id)
+ if err != nil {
+ return nil, err
+ }
+
+ err = f.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
diff --git a/commands/bug/select/select_test.go b/commands/bug/select/select_test.go
new file mode 100644
index 00000000..702700f4
--- /dev/null
+++ b/commands/bug/select/select_test.go
@@ -0,0 +1,79 @@
+package _select
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/cache"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+func TestSelect(t *testing.T) {
+ repo := repository.CreateGoGitTestRepo(t, false)
+
+ repoCache, err := cache.NewRepoCache(repo)
+ require.NoError(t, err)
+
+ _, _, err = ResolveBug(repoCache, []string{})
+ require.Equal(t, ErrNoValidId, err)
+
+ err = Select(repoCache, "invalid")
+ require.NoError(t, err)
+
+ // Resolve without a pattern should fail when no bug is selected
+ _, _, err = ResolveBug(repoCache, []string{})
+ require.Error(t, err)
+
+ // generate a bunch of bugs
+
+ rene, err := repoCache.NewIdentity("René Descartes", "rene@descartes.fr")
+ require.NoError(t, err)
+
+ for i := 0; i < 10; i++ {
+ _, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
+ require.NoError(t, err)
+ }
+
+ // and two more for testing
+ b1, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
+ require.NoError(t, err)
+ b2, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
+ require.NoError(t, err)
+
+ err = Select(repoCache, b1.Id())
+ require.NoError(t, err)
+
+ // normal select without args
+ b3, _, err := ResolveBug(repoCache, []string{})
+ require.NoError(t, err)
+ require.Equal(t, b1.Id(), b3.Id())
+
+ // override selection with same id
+ b4, _, err := ResolveBug(repoCache, []string{b1.Id().String()})
+ require.NoError(t, err)
+ require.Equal(t, b1.Id(), b4.Id())
+
+ // override selection with a prefix
+ b5, _, err := ResolveBug(repoCache, []string{b1.Id().Human()})
+ require.NoError(t, err)
+ require.Equal(t, b1.Id(), b5.Id())
+
+ // args that shouldn't override
+ b6, _, err := ResolveBug(repoCache, []string{"arg"})
+ require.NoError(t, err)
+ require.Equal(t, b1.Id(), b6.Id())
+
+ // override with a different id
+ b7, _, err := ResolveBug(repoCache, []string{b2.Id().String()})
+ require.NoError(t, err)
+ require.Equal(t, b2.Id(), b7.Id())
+
+ err = Clear(repoCache)
+ require.NoError(t, err)
+
+ // Resolve without a pattern should error again after clearing the selected bug
+ _, _, err = ResolveBug(repoCache, []string{})
+ require.Error(t, err)
+}
diff --git a/commands/bug/testdata/comment/add-0-golden.txt b/commands/bug/testdata/comment/add-0-golden.txt
new file mode 100644
index 00000000..44ae0c1a
--- /dev/null
+++ b/commands/bug/testdata/comment/add-0-golden.txt
@@ -0,0 +1,3 @@
+
+
+ this is a bug message
diff --git a/commands/bug/testdata/comment/add-1-golden.txt b/commands/bug/testdata/comment/add-1-golden.txt
new file mode 100644
index 00000000..bcf127c0
--- /dev/null
+++ b/commands/bug/testdata/comment/add-1-golden.txt
@@ -0,0 +1,6 @@
+
+
+ this is a bug message
+
+
+ this is a bug comment
diff --git a/commands/bug/testdata/comment/edit-0-golden.txt b/commands/bug/testdata/comment/edit-0-golden.txt
new file mode 100644
index 00000000..44ae0c1a
--- /dev/null
+++ b/commands/bug/testdata/comment/edit-0-golden.txt
@@ -0,0 +1,3 @@
+
+
+ this is a bug message
diff --git a/commands/bug/testdata/comment/edit-1-golden.txt b/commands/bug/testdata/comment/edit-1-golden.txt
new file mode 100644
index 00000000..3d83c02b
--- /dev/null
+++ b/commands/bug/testdata/comment/edit-1-golden.txt
@@ -0,0 +1,6 @@
+
+
+ this is a bug message
+
+
+ this is an altered bug comment
diff --git a/commands/bug/testdata/comment/message-only-0-golden.txt b/commands/bug/testdata/comment/message-only-0-golden.txt
new file mode 100644
index 00000000..44ae0c1a
--- /dev/null
+++ b/commands/bug/testdata/comment/message-only-0-golden.txt
@@ -0,0 +1,3 @@
+
+
+ this is a bug message
diff --git a/commands/bug/testenv/testenv.go b/commands/bug/testenv/testenv.go
new file mode 100644
index 00000000..10f20950
--- /dev/null
+++ b/commands/bug/testenv/testenv.go
@@ -0,0 +1,63 @@
+package testenv
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/entity"
+)
+
+const (
+ testUserName = "John Doe"
+ testUserEmail = "jdoe@example.com"
+)
+
+func NewTestEnvAndUser(t *testing.T) (*execenv.Env, entity.Id) {
+ t.Helper()
+
+ testEnv := execenv.NewTestEnv(t)
+
+ i, err := testEnv.Backend.NewIdentity(testUserName, testUserEmail)
+ require.NoError(t, err)
+
+ err = testEnv.Backend.SetUserIdentity(i)
+ require.NoError(t, err)
+
+ return testEnv, i.Id()
+}
+
+const (
+ testBugTitle = "this is a bug title"
+ testBugMessage = "this is a bug message"
+)
+
+func NewTestEnvAndBug(t *testing.T) (*execenv.Env, entity.Id) {
+ t.Helper()
+
+ testEnv, _ := NewTestEnvAndUser(t)
+
+ b, _, err := testEnv.Backend.NewBug(testBugTitle, testBugMessage)
+ require.NoError(t, err)
+
+ return testEnv, b.Id()
+}
+
+const (
+ testCommentMessage = "this is a bug comment"
+)
+
+func NewTestEnvAndBugWithComment(t *testing.T) (*execenv.Env, entity.Id, entity.CombinedId) {
+ t.Helper()
+
+ env, bugID := NewTestEnvAndBug(t)
+
+ b, err := env.Backend.ResolveBug(bugID)
+ require.NoError(t, err)
+
+ commentId, _, err := b.AddComment(testCommentMessage)
+ require.NoError(t, err)
+
+ return env, bugID, commentId
+}