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