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