1package commands
2
3import (
4 "encoding/json"
5 "fmt"
6 "regexp"
7 "strings"
8 "time"
9
10 text "github.com/MichaelMure/go-term-text"
11 "github.com/spf13/cobra"
12
13 "github.com/MichaelMure/git-bug/bug"
14 "github.com/MichaelMure/git-bug/cache"
15 "github.com/MichaelMure/git-bug/query"
16 "github.com/MichaelMure/git-bug/util/colors"
17)
18
19type lsOptions struct {
20 statusQuery []string
21 authorQuery []string
22 metadataQuery []string
23 participantQuery []string
24 actorQuery []string
25 labelQuery []string
26 titleQuery []string
27 noQuery []string
28 sortBy string
29 sortDirection string
30 outputFormat string
31}
32
33func newLsCommand() *cobra.Command {
34 env := newEnv()
35 options := lsOptions{}
36
37 cmd := &cobra.Command{
38 Use: "ls [QUERY]",
39 Short: "List bugs.",
40 Long: `Display a summary of each bugs.
41
42You 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.`,
43 Example: `List open bugs sorted by last edition with a query:
44git bug ls status:open sort:edit-desc
45
46List closed bugs sorted by creation with flags:
47git bug ls --status closed --by creation
48
49Do a full text search of all bugs:
50git bug ls "foo bar" baz
51
52Use queries, flags, and full text search:
53git bug ls status:open --by creation "foo bar" baz
54`,
55 PreRunE: loadBackend(env),
56 RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error {
57 return runLs(env, options, args)
58 }),
59 ValidArgsFunction: completeLs(env),
60 }
61
62 flags := cmd.Flags()
63 flags.SortFlags = false
64
65 flags.StringSliceVarP(&options.statusQuery, "status", "s", nil,
66 "Filter by status. Valid values are [open,closed]")
67 cmd.RegisterFlagCompletionFunc("status", completeFrom([]string{"open", "closed"}))
68 flags.StringSliceVarP(&options.authorQuery, "author", "a", nil,
69 "Filter by author")
70 flags.StringSliceVarP(&options.metadataQuery, "metadata", "m", nil,
71 "Filter by metadata. Example: github-url=URL")
72 cmd.RegisterFlagCompletionFunc("author", completeUserForQuery(env))
73 flags.StringSliceVarP(&options.participantQuery, "participant", "p", nil,
74 "Filter by participant")
75 cmd.RegisterFlagCompletionFunc("participant", completeUserForQuery(env))
76 flags.StringSliceVarP(&options.actorQuery, "actor", "A", nil,
77 "Filter by actor")
78 cmd.RegisterFlagCompletionFunc("actor", completeUserForQuery(env))
79 flags.StringSliceVarP(&options.labelQuery, "label", "l", nil,
80 "Filter by label")
81 cmd.RegisterFlagCompletionFunc("label", completeLabel(env))
82 flags.StringSliceVarP(&options.titleQuery, "title", "t", nil,
83 "Filter by title")
84 flags.StringSliceVarP(&options.noQuery, "no", "n", nil,
85 "Filter by absence of something. Valid values are [label]")
86 cmd.RegisterFlagCompletionFunc("no", completeLabel(env))
87 flags.StringVarP(&options.sortBy, "by", "b", "creation",
88 "Sort the results by a characteristic. Valid values are [id,creation,edit]")
89 cmd.RegisterFlagCompletionFunc("by", completeFrom([]string{"id", "creation", "edit"}))
90 flags.StringVarP(&options.sortDirection, "direction", "d", "asc",
91 "Select the sorting direction. Valid values are [asc,desc]")
92 cmd.RegisterFlagCompletionFunc("direction", completeFrom([]string{"asc", "desc"}))
93 flags.StringVarP(&options.outputFormat, "format", "f", "default",
94 "Select the output formatting style. Valid values are [default,plain,compact,json,org-mode]")
95 cmd.RegisterFlagCompletionFunc("format",
96 completeFrom([]string{"default", "plain", "compact", "json", "org-mode"}))
97
98 return cmd
99}
100
101func runLs(env *Env, opts lsOptions, args []string) error {
102 var q *query.Query
103 var err error
104
105 if len(args) >= 1 {
106 // either the shell or cobra remove the quotes, we need them back for the query parsing
107 assembled := repairQuery(args)
108
109 q, err = query.Parse(assembled)
110 if err != nil {
111 return err
112 }
113 } else {
114 q = query.NewQuery()
115 }
116
117 err = completeQuery(q, opts)
118 if err != nil {
119 return err
120 }
121
122 allIds, err := env.backend.QueryBugs(q)
123 if err != nil {
124 return err
125 }
126
127 bugExcerpt := make([]*cache.BugExcerpt, len(allIds))
128 for i, id := range allIds {
129 b, err := env.backend.ResolveBugExcerpt(id)
130 if err != nil {
131 return err
132 }
133 bugExcerpt[i] = b
134 }
135
136 switch opts.outputFormat {
137 case "org-mode":
138 return lsOrgmodeFormatter(env, bugExcerpt)
139 case "plain":
140 return lsPlainFormatter(env, bugExcerpt)
141 case "json":
142 return lsJsonFormatter(env, bugExcerpt)
143 case "compact":
144 return lsCompactFormatter(env, bugExcerpt)
145 case "default":
146 return lsDefaultFormatter(env, bugExcerpt)
147 default:
148 return fmt.Errorf("unknown format %s", opts.outputFormat)
149 }
150}
151
152func repairQuery(args []string) string {
153 for i, arg := range args {
154 split := strings.Split(arg, ":")
155 for j, s := range split {
156 if strings.Contains(s, " ") {
157 split[j] = fmt.Sprintf("\"%s\"", s)
158 }
159 }
160 args[i] = strings.Join(split, ":")
161 }
162 return strings.Join(args, " ")
163}
164
165type JSONBugExcerpt struct {
166 Id string `json:"id"`
167 HumanId string `json:"human_id"`
168 CreateTime JSONTime `json:"create_time"`
169 EditTime JSONTime `json:"edit_time"`
170
171 Status string `json:"status"`
172 Labels []bug.Label `json:"labels"`
173 Title string `json:"title"`
174 Actors []JSONIdentity `json:"actors"`
175 Participants []JSONIdentity `json:"participants"`
176 Author JSONIdentity `json:"author"`
177
178 Comments int `json:"comments"`
179 Metadata map[string]string `json:"metadata"`
180}
181
182func lsJsonFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
183 jsonBugs := make([]JSONBugExcerpt, len(bugExcerpts))
184 for i, b := range bugExcerpts {
185 jsonBug := JSONBugExcerpt{
186 Id: b.Id.String(),
187 HumanId: b.Id.Human(),
188 CreateTime: NewJSONTime(b.CreateTime(), b.CreateLamportTime),
189 EditTime: NewJSONTime(b.EditTime(), b.EditLamportTime),
190 Status: b.Status.String(),
191 Labels: b.Labels,
192 Title: b.Title,
193 Comments: b.LenComments,
194 Metadata: b.CreateMetadata,
195 }
196
197 author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId)
198 if err != nil {
199 return err
200 }
201 jsonBug.Author = NewJSONIdentityFromExcerpt(author)
202
203 jsonBug.Actors = make([]JSONIdentity, len(b.Actors))
204 for i, element := range b.Actors {
205 actor, err := env.backend.ResolveIdentityExcerpt(element)
206 if err != nil {
207 return err
208 }
209 jsonBug.Actors[i] = NewJSONIdentityFromExcerpt(actor)
210 }
211
212 jsonBug.Participants = make([]JSONIdentity, len(b.Participants))
213 for i, element := range b.Participants {
214 participant, err := env.backend.ResolveIdentityExcerpt(element)
215 if err != nil {
216 return err
217 }
218 jsonBug.Participants[i] = NewJSONIdentityFromExcerpt(participant)
219 }
220
221 jsonBugs[i] = jsonBug
222 }
223 jsonObject, _ := json.MarshalIndent(jsonBugs, "", " ")
224 env.out.Printf("%s\n", jsonObject)
225 return nil
226}
227
228func lsCompactFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
229 for _, b := range bugExcerpts {
230 author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId)
231 if err != nil {
232 return err
233 }
234
235 var labelsTxt strings.Builder
236 for _, l := range b.Labels {
237 lc256 := l.Color().Term256()
238 labelsTxt.WriteString(lc256.Escape())
239 labelsTxt.WriteString("◼")
240 labelsTxt.WriteString(lc256.Unescape())
241 }
242
243 env.out.Printf("%s %s %s %s %s\n",
244 colors.Cyan(b.Id.Human()),
245 colors.Yellow(b.Status),
246 text.LeftPadMaxLine(strings.TrimSpace(b.Title), 40, 0),
247 text.LeftPadMaxLine(labelsTxt.String(), 5, 0),
248 colors.Magenta(text.TruncateMax(author.DisplayName(), 15)),
249 )
250 }
251 return nil
252}
253
254func lsDefaultFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
255 for _, b := range bugExcerpts {
256 author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId)
257 if err != nil {
258 return err
259 }
260
261 var labelsTxt strings.Builder
262 for _, l := range b.Labels {
263 lc256 := l.Color().Term256()
264 labelsTxt.WriteString(lc256.Escape())
265 labelsTxt.WriteString(" ◼")
266 labelsTxt.WriteString(lc256.Unescape())
267 }
268
269 // truncate + pad if needed
270 labelsFmt := text.TruncateMax(labelsTxt.String(), 10)
271 titleFmt := text.LeftPadMaxLine(strings.TrimSpace(b.Title), 50-text.Len(labelsFmt), 0)
272 authorFmt := text.LeftPadMaxLine(author.DisplayName(), 15, 0)
273
274 comments := fmt.Sprintf("%3d 💬", b.LenComments-1)
275 if b.LenComments-1 <= 0 {
276 comments = ""
277 }
278 if b.LenComments-1 > 999 {
279 comments = " ∞ 💬"
280 }
281
282 env.out.Printf("%s %s\t%s\t%s\t%s\n",
283 colors.Cyan(b.Id.Human()),
284 colors.Yellow(b.Status),
285 titleFmt+labelsFmt,
286 colors.Magenta(authorFmt),
287 comments,
288 )
289 }
290 return nil
291}
292
293func lsPlainFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
294 for _, b := range bugExcerpts {
295 env.out.Printf("%s [%s] %s\n", b.Id.Human(), b.Status, strings.TrimSpace(b.Title))
296 }
297 return nil
298}
299
300func lsOrgmodeFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
301 // see https://orgmode.org/manual/Tags.html
302 orgTagRe := regexp.MustCompile("[^[:alpha:]_@]")
303 formatTag := func(l bug.Label) string {
304 return orgTagRe.ReplaceAllString(l.String(), "_")
305 }
306
307 formatTime := func(time time.Time) string {
308 return time.Format("[2006-01-02 Mon 15:05]")
309 }
310
311 env.out.Println("#+TODO: OPEN | CLOSED")
312
313 for _, b := range bugExcerpts {
314 status := strings.ToUpper(b.Status.String())
315
316 var title string
317 if link, ok := b.CreateMetadata["github-url"]; ok {
318 title = fmt.Sprintf("[[%s][%s]]", link, b.Title)
319 } else {
320 title = b.Title
321 }
322
323 author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId)
324 if err != nil {
325 return err
326 }
327
328 var labels strings.Builder
329 labels.WriteString(":")
330 for i, l := range b.Labels {
331 if i > 0 {
332 labels.WriteString(":")
333 }
334 labels.WriteString(formatTag(l))
335 }
336 labels.WriteString(":")
337
338 env.out.Printf("* %-6s %s %s %s: %s %s\n",
339 status,
340 b.Id.Human(),
341 formatTime(b.CreateTime()),
342 author.DisplayName(),
343 title,
344 labels.String(),
345 )
346
347 env.out.Printf("** Last Edited: %s\n", formatTime(b.EditTime()))
348
349 env.out.Printf("** Actors:\n")
350 for _, element := range b.Actors {
351 actor, err := env.backend.ResolveIdentityExcerpt(element)
352 if err != nil {
353 return err
354 }
355
356 env.out.Printf(": %s %s\n",
357 actor.Id.Human(),
358 actor.DisplayName(),
359 )
360 }
361
362 env.out.Printf("** Participants:\n")
363 for _, element := range b.Participants {
364 participant, err := env.backend.ResolveIdentityExcerpt(element)
365 if err != nil {
366 return err
367 }
368
369 env.out.Printf(": %s %s\n",
370 participant.Id.Human(),
371 participant.DisplayName(),
372 )
373 }
374 }
375
376 return nil
377}
378
379// Finish the command flags transformation into the query.Query
380func completeQuery(q *query.Query, opts lsOptions) error {
381 for _, str := range opts.statusQuery {
382 status, err := bug.StatusFromString(str)
383 if err != nil {
384 return err
385 }
386 q.Status = append(q.Status, status)
387 }
388
389 q.Author = append(q.Author, opts.authorQuery...)
390 for _, str := range opts.metadataQuery {
391 tokens := strings.Split(str, "=")
392 if len(tokens) < 2 {
393 return fmt.Errorf("no \"=\" in key=value metadata markup")
394 }
395 var pair query.StringPair
396 pair.Key = tokens[0]
397 pair.Value = tokens[1]
398 q.Metadata = append(q.Metadata, pair)
399 }
400 q.Participant = append(q.Participant, opts.participantQuery...)
401 q.Actor = append(q.Actor, opts.actorQuery...)
402 q.Label = append(q.Label, opts.labelQuery...)
403 q.Title = append(q.Title, opts.titleQuery...)
404
405 for _, no := range opts.noQuery {
406 switch no {
407 case "label":
408 q.NoLabel = true
409 default:
410 return fmt.Errorf("unknown \"no\" filter %s", no)
411 }
412 }
413
414 switch opts.sortBy {
415 case "id":
416 q.OrderBy = query.OrderById
417 case "creation":
418 q.OrderBy = query.OrderByCreation
419 case "edit":
420 q.OrderBy = query.OrderByEdit
421 default:
422 return fmt.Errorf("unknown sort flag %s", opts.sortBy)
423 }
424
425 switch opts.sortDirection {
426 case "asc":
427 q.OrderDirection = query.OrderAscending
428 case "desc":
429 q.OrderDirection = query.OrderDescending
430 default:
431 return fmt.Errorf("unknown sort direction %s", opts.sortDirection)
432 }
433
434 return nil
435}