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