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