diff --git a/veans/internal/commands/list.go b/veans/internal/commands/list.go new file mode 100644 index 000000000..1097f1432 --- /dev/null +++ b/veans/internal/commands/list.go @@ -0,0 +1,181 @@ +package commands + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/config" + "code.vikunja.io/veans/internal/output" + "code.vikunja.io/veans/internal/status" +) + +type listFlags struct { + ready bool + mine bool + branch string + branchAuto bool + filter string + statuses []string +} + +func newListCmd() *cobra.Command { + f := &listFlags{} + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List tasks in the configured project", + Long: `List tasks in the project configured in .veans.yml. + +Filters can be combined; they're AND-ed together: + --ready ready to start: in Todo with done=false (incomplete-blocker + detection is best-effort, see veans/README.md) + --mine only tasks assigned to the veans bot + --branch [name] only tasks tagged 'veans:branch:' (defaults to the + current git branch when used without a value) + --filter raw Vikunja filter expression (see Vikunja docs); applied + server-side + --status filter by status (todo|in-progress|in-review|completed|scrapped), + may be repeated`, + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := loadRuntime() + if err != nil { + return err + } + tasks, err := runList(cmd, rt, f) + if err != nil { + return err + } + if globals.JSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(tasks) + } + renderTasksHuman(cmd.OutOrStdout(), tasks, rt.cfg) + return nil + }, + } + cmd.Flags().BoolVar(&f.ready, "ready", false, "only ready-to-start tasks (Todo bucket, not done)") + cmd.Flags().BoolVar(&f.mine, "mine", false, "only tasks assigned to the veans bot") + cmd.Flags().StringVar(&f.branch, "branch", "", "only tasks tagged 'veans:branch:' (omit value for current branch)") + cmd.Flags().Lookup("branch").NoOptDefVal = "__auto__" + cmd.Flags().StringVar(&f.filter, "filter", "", "raw Vikunja filter expression, applied server-side") + cmd.Flags().StringSliceVar(&f.statuses, "status", nil, "filter by status (repeatable)") + return cmd +} + +func runList(cmd *cobra.Command, rt *runtime, f *listFlags) ([]*client.Task, error) { + opts := &client.TaskListOptions{ + Filter: f.filter, + Expand: []string{"reactions"}, + } + tasks, err := rt.client.ListProjectTasks(cmd.Context(), rt.cfg.ProjectID, opts) + if err != nil { + return nil, err + } + + // Apply client-side filters AND-style. + var out []*client.Task + for _, t := range tasks { + if f.ready { + if t.Done || t.BucketID != rt.cfg.Buckets.Todo { + continue + } + } + if f.mine { + if !taskAssignedTo(t, rt.cfg.Bot.UserID) { + continue + } + } + if f.branch != "" { + want := f.branch + if want == "__auto__" { + want = currentGitBranch() + if want == "" { + return nil, output.New(output.CodeValidation, + "--branch given without a value but no current git branch detected") + } + } + label := branchLabel(want) + if !taskHasLabel(t, label) { + continue + } + } + if len(f.statuses) > 0 { + ok := false + for _, raw := range f.statuses { + s, err := status.Parse(raw) + if err != nil { + return nil, err + } + wantBucket, _ := status.BucketID(s, rt.cfg.Buckets) + if t.BucketID == wantBucket { + ok = true + break + } + } + if !ok { + continue + } + } + out = append(out, t) + } + return out, nil +} + +func taskAssignedTo(t *client.Task, userID int64) bool { + for _, a := range t.Assignees { + if a != nil && a.ID == userID { + return true + } + } + return false +} + +func taskHasLabel(t *client.Task, title string) bool { + for _, l := range t.Labels { + if l != nil && l.Title == title { + return true + } + } + return false +} + +func renderTasksHuman(w fmtWriter, tasks []*client.Task, cfg *config.Config) { + if len(tasks) == 0 { + fmt.Fprintln(w, "(no tasks)") + return + } + for _, t := range tasks { + s := status.FromBucketID(t.BucketID, cfg.Buckets) + stamp := string(s) + if stamp == "" { + stamp = "-" + } + fmt.Fprintf(w, "%-12s %-10s %s\n", + cfg.FormatTaskID(t.Index), + stamp, + t.Title, + ) + } +} + +func branchLabel(branch string) string { + return "veans:branch:" + branch +} + +// currentGitBranch returns the current git branch as reported by +// `git rev-parse --abbrev-ref HEAD`, or "" if we're not in a git repo or +// HEAD is detached. Failures are silent so callers can decide. +func currentGitBranch() string { + out, err := runGit("rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "" + } + out = strings.TrimSpace(out) + if out == "HEAD" { + return "" + } + return out +} diff --git a/veans/internal/commands/root.go b/veans/internal/commands/root.go index 9c91d0728..5d17a3e87 100644 --- a/veans/internal/commands/root.go +++ b/veans/internal/commands/root.go @@ -35,6 +35,7 @@ func Root(version string) *cobra.Command { root.AddCommand(newVersionCmd(version)) root.AddCommand(newInitCmd()) + root.AddCommand(newListCmd()) return root }