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