191 lines
5.6 KiB
Go
191 lines
5.6 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package commands
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"code.vikunja.io/veans/internal/client"
|
|
"code.vikunja.io/veans/internal/output"
|
|
"code.vikunja.io/veans/internal/status"
|
|
)
|
|
|
|
type listFlags struct {
|
|
ready bool
|
|
mine bool
|
|
branch string
|
|
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, not done, and no incomplete
|
|
"blocked" relation
|
|
--mine only tasks assigned to the veans bot
|
|
--branch [name] only tasks tagged 'veans:branch:<name>' (defaults to the
|
|
current git branch when used without a value)
|
|
--filter <expr> raw Vikunja filter expression (see Vikunja docs); applied
|
|
server-side
|
|
--status <s> filter by status (todo|in-progress|in-review|completed|scrapped),
|
|
may be repeated`,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
rt, err := loadRuntime()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tasks, err := runList(cmd, rt, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(tasks)
|
|
},
|
|
}
|
|
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:<name>' (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=buckets is required for CurrentBucketID() to resolve;
|
|
// the default GET returns bucket_id=0 (xorm:"-" on the model).
|
|
Expand: []string{"buckets"},
|
|
}
|
|
tasks, err := rt.client.ListProjectTasks(cmd.Context(), rt.cfg.ProjectID, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Apply client-side filters AND-style. Pre-allocate as an empty
|
|
// (non-nil) slice so an empty result still encodes as `[]`, not `null` —
|
|
// the agent contract is "raw array".
|
|
out := make([]*client.Task, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
taskBucket := t.CurrentBucketID(rt.cfg.ViewID)
|
|
if f.ready && !isReady(t, rt.cfg.Buckets.Todo, rt.cfg.ViewID) {
|
|
continue
|
|
}
|
|
if f.mine {
|
|
if !taskAssignedTo(t, rt.cfg.Bot.UserID) {
|
|
continue
|
|
}
|
|
}
|
|
if f.branch != "" {
|
|
want := f.branch
|
|
if want == "__auto__" {
|
|
want = currentGitBranch(cmd.Context())
|
|
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 taskBucket == wantBucket {
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// isReady reports whether t is ready to start: in the Todo bucket, not done,
|
|
// and not blocked by any incomplete task. "blocked" is the relation kind on
|
|
// the dependent task — parenttask / subtask have no bearing on readiness.
|
|
func isReady(t *client.Task, todoBucket, viewID int64) bool {
|
|
if t.Done || t.CurrentBucketID(viewID) != todoBucket {
|
|
return false
|
|
}
|
|
for _, b := range t.RelatedTasks["blocked"] {
|
|
if b != nil && !b.Done {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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 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(ctx context.Context) string {
|
|
out, err := runGit(ctx, "rev-parse", "--abbrev-ref", "HEAD")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
out = strings.TrimSpace(out)
|
|
if out == "HEAD" {
|
|
return ""
|
|
}
|
|
return out
|
|
}
|