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,json,org-mode]")
95 cmd.RegisterFlagCompletionFunc("format",
96 completeFrom([]string{"default", "plain", "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 parsing
107 for i, arg := range args {
108 if strings.Contains(arg, " ") {
109 args[i] = fmt.Sprintf("\"%s\"", arg)
110 }
111 }
112 assembled := strings.Join(args, " ")
113 q, err = query.Parse(assembled)
114 if err != nil {
115 return err
116 }
117 } else {
118 q = query.NewQuery()
119 }
120
121 err = completeQuery(q, opts)
122 if err != nil {
123 return err
124 }
125
126 allIds, err := env.backend.QueryBugs(q)
127 if err != nil {
128 return err
129 }
130
131 bugExcerpt := make([]*cache.BugExcerpt, len(allIds))
132 for i, id := range allIds {
133 b, err := env.backend.ResolveBugExcerpt(id)
134 if err != nil {
135 return err
136 }
137 bugExcerpt[i] = b
138 }
139
140 switch opts.outputFormat {
141 case "org-mode":
142 return lsOrgmodeFormatter(env, bugExcerpt)
143 case "plain":
144 return lsPlainFormatter(env, bugExcerpt)
145 case "json":
146 return lsJsonFormatter(env, bugExcerpt)
147 case "compact":
148 return lsCompactFormatter(env, bugExcerpt)
149 case "default":
150 return lsDefaultFormatter(env, bugExcerpt)
151 default:
152 return fmt.Errorf("unknown format %s", opts.outputFormat)
153 }
154}
155
156type JSONBugExcerpt struct {
157 Id string `json:"id"`
158 HumanId string `json:"human_id"`
159 CreateTime JSONTime `json:"create_time"`
160 EditTime JSONTime `json:"edit_time"`
161
162 Status string `json:"status"`
163 Labels []bug.Label `json:"labels"`
164 Title string `json:"title"`
165 Actors []JSONIdentity `json:"actors"`
166 Participants []JSONIdentity `json:"participants"`
167 Author JSONIdentity `json:"author"`
168
169 Comments int `json:"comments"`
170 Metadata map[string]string `json:"metadata"`
171}
172
173func lsJsonFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
174 jsonBugs := make([]JSONBugExcerpt, len(bugExcerpts))
175 for i, b := range bugExcerpts {
176 jsonBug := JSONBugExcerpt{
177 Id: b.Id.String(),
178 HumanId: b.Id.Human(),
179 CreateTime: NewJSONTime(b.CreateTime(), b.CreateLamportTime),
180 EditTime: NewJSONTime(b.EditTime(), b.EditLamportTime),
181 Status: b.Status.String(),
182 Labels: b.Labels,
183 Title: b.Title,
184 Comments: b.LenComments,
185 Metadata: b.CreateMetadata,
186 }
187
188 author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId)
189 if err != nil {
190 return err
191 }
192 jsonBug.Author = NewJSONIdentityFromExcerpt(author)
193
194 jsonBug.Actors = make([]JSONIdentity, len(b.Actors))
195 for i, element := range b.Actors {
196 actor, err := env.backend.ResolveIdentityExcerpt(element)
197 if err != nil {
198 return err
199 }
200 jsonBug.Actors[i] = NewJSONIdentityFromExcerpt(actor)
201 }
202
203 jsonBug.Participants = make([]JSONIdentity, len(b.Participants))
204 for i, element := range b.Participants {
205 participant, err := env.backend.ResolveIdentityExcerpt(element)
206 if err != nil {
207 return err
208 }
209 jsonBug.Participants[i] = NewJSONIdentityFromExcerpt(participant)
210 }
211
212 jsonBugs[i] = jsonBug
213 }
214 jsonObject, _ := json.MarshalIndent(jsonBugs, "", " ")
215 env.out.Printf("%s\n", jsonObject)
216 return nil
217}
218
219func lsCompactFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
220 for _, b := range bugExcerpts {
221 author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId)
222 if err != nil {
223 return err
224 }
225
226 var labelsTxt strings.Builder
227 for _, l := range b.Labels {
228 lc256 := l.Color().Term256()
229 labelsTxt.WriteString(lc256.Escape())
230 labelsTxt.WriteString("◼")
231 labelsTxt.WriteString(lc256.Unescape())
232 }
233
234 env.out.Printf("%s %s %s %s %s\n",
235 colors.Cyan(b.Id.Human()),
236 colors.Yellow(b.Status),
237 text.LeftPadMaxLine(strings.TrimSpace(b.Title), 40, 0),
238 text.LeftPadMaxLine(labelsTxt.String(), 5, 0),
239 colors.Magenta(text.TruncateMax(author.DisplayName(), 15)),
240 )
241 }
242 return nil
243}
244
245func lsDefaultFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
246 for _, b := range bugExcerpts {
247 author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId)
248 if err != nil {
249 return err
250 }
251
252 var labelsTxt strings.Builder
253 for _, l := range b.Labels {
254 lc256 := l.Color().Term256()
255 labelsTxt.WriteString(lc256.Escape())
256 labelsTxt.WriteString(" ◼")
257 labelsTxt.WriteString(lc256.Unescape())
258 }
259
260 // truncate + pad if needed
261 labelsFmt := text.TruncateMax(labelsTxt.String(), 10)
262 titleFmt := text.LeftPadMaxLine(strings.TrimSpace(b.Title), 50-text.Len(labelsFmt), 0)
263 authorFmt := text.LeftPadMaxLine(author.DisplayName(), 15, 0)
264
265 comments := fmt.Sprintf("%3d 💬", b.LenComments-1)
266 if b.LenComments-1 <= 0 {
267 comments = ""
268 }
269 if b.LenComments-1 > 999 {
270 comments = " ∞ 💬"
271 }
272
273 env.out.Printf("%s %s\t%s\t%s\t%s\n",
274 colors.Cyan(b.Id.Human()),
275 colors.Yellow(b.Status),
276 titleFmt+labelsFmt,
277 colors.Magenta(authorFmt),
278 comments,
279 )
280 }
281 return nil
282}
283
284func lsPlainFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
285 for _, b := range bugExcerpts {
286 env.out.Printf("%s [%s] %s\n", b.Id.Human(), b.Status, strings.TrimSpace(b.Title))
287 }
288 return nil
289}
290
291func lsOrgmodeFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error {
292 // see https://orgmode.org/manual/Tags.html
293 orgTagRe := regexp.MustCompile("[^[:alpha:]_@]")
294 formatTag := func(l bug.Label) string {
295 return orgTagRe.ReplaceAllString(l.String(), "_")
296 }
297
298 formatTime := func(time time.Time) string {
299 return time.Format("[2006-01-02 Mon 15:05]")
300 }
301
302 env.out.Println("#+TODO: OPEN | CLOSED")
303
304 for _, b := range bugExcerpts {
305 status := strings.ToUpper(b.Status.String())
306
307 var title string
308 if link, ok := b.CreateMetadata["github-url"]; ok {
309 title = fmt.Sprintf("[[%s][%s]]", link, b.Title)
310 } else {
311 title = b.Title
312 }
313
314 author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId)
315 if err != nil {
316 return err
317 }
318
319 var labels strings.Builder
320 labels.WriteString(":")
321 for i, l := range b.Labels {
322 if i > 0 {
323 labels.WriteString(":")
324 }
325 labels.WriteString(formatTag(l))
326 }
327 labels.WriteString(":")
328
329 env.out.Printf("* %-6s %s %s %s: %s %s\n",
330 status,
331 b.Id.Human(),
332 formatTime(b.CreateTime()),
333 author.DisplayName(),
334 title,
335 labels.String(),
336 )
337
338 env.out.Printf("** Last Edited: %s\n", formatTime(b.EditTime()))
339
340 env.out.Printf("** Actors:\n")
341 for _, element := range b.Actors {
342 actor, err := env.backend.ResolveIdentityExcerpt(element)
343 if err != nil {
344 return err
345 }
346
347 env.out.Printf(": %s %s\n",
348 actor.Id.Human(),
349 actor.DisplayName(),
350 )
351 }
352
353 env.out.Printf("** Participants:\n")
354 for _, element := range b.Participants {
355 participant, err := env.backend.ResolveIdentityExcerpt(element)
356 if err != nil {
357 return err
358 }
359
360 env.out.Printf(": %s %s\n",
361 participant.Id.Human(),
362 participant.DisplayName(),
363 )
364 }
365 }
366
367 return nil
368}
369
370// Finish the command flags transformation into the query.Query
371func completeQuery(q *query.Query, opts lsOptions) error {
372 for _, str := range opts.statusQuery {
373 status, err := bug.StatusFromString(str)
374 if err != nil {
375 return err
376 }
377 q.Status = append(q.Status, status)
378 }
379
380 q.Author = append(q.Author, opts.authorQuery...)
381 for _, str := range opts.metadataQuery {
382 tokens := strings.Split(str, "=")
383 if len(tokens) < 2 {
384 return fmt.Errorf("no \"=\" in key=value metadata markup")
385 }
386 var pair query.StringPair
387 pair.Key = tokens[0]
388 pair.Value = tokens[1]
389 q.Metadata = append(q.Metadata, pair)
390 }
391 q.Participant = append(q.Participant, opts.participantQuery...)
392 q.Actor = append(q.Actor, opts.actorQuery...)
393 q.Label = append(q.Label, opts.labelQuery...)
394 q.Title = append(q.Title, opts.titleQuery...)
395
396 for _, no := range opts.noQuery {
397 switch no {
398 case "label":
399 q.NoLabel = true
400 default:
401 return fmt.Errorf("unknown \"no\" filter %s", no)
402 }
403 }
404
405 switch opts.sortBy {
406 case "id":
407 q.OrderBy = query.OrderById
408 case "creation":
409 q.OrderBy = query.OrderByCreation
410 case "edit":
411 q.OrderBy = query.OrderByEdit
412 default:
413 return fmt.Errorf("unknown sort flag %s", opts.sortBy)
414 }
415
416 switch opts.sortDirection {
417 case "asc":
418 q.OrderDirection = query.OrderAscending
419 case "desc":
420 q.OrderDirection = query.OrderDescending
421 default:
422 return fmt.Errorf("unknown sort direction %s", opts.sortDirection)
423 }
424
425 return nil
426}