From 0dfcb0f3fe6db144f02506df95e68707d9b5091c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 31 May 2026 10:07:41 +0300 Subject: ask: add completed sub-command to list completed tasks --- internal/askcli/command_list.go | 4 ++++ internal/askcli/command_list_test.go | 38 ++++++++++++++++++++++++++++++++++++ internal/askcli/command_watch.go | 2 +- internal/askcli/commands_registry.go | 7 +++++++ internal/askcli/completion_test.go | 2 +- internal/askcli/dispatch.go | 1 + internal/askcli/dispatch_test.go | 9 +++++++-- 7 files changed, 59 insertions(+), 4 deletions(-) (limited to 'internal/askcli') diff --git a/internal/askcli/command_list.go b/internal/askcli/command_list.go index 99ffcc8..ebe26da 100644 --- a/internal/askcli/command_list.go +++ b/internal/askcli/command_list.go @@ -21,6 +21,10 @@ func (d *Dispatcher) handleReady(ctx context.Context, args []string, stdout, std return d.handleListWithFilters(ctx, []string{"+READY"}, args[1:], stdout, stderr) } +func (d *Dispatcher) handleCompleted(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { + return d.handleListWithFilters(ctx, []string{"status:completed"}, args[1:], stdout, stderr) +} + // handleListWithFilters is the shared implementation for list/all/ready. // initialFilters seeds the taskwarrior filter; extraArgs are user-supplied // filter modifiers (limit:, sort:, +tag, started). diff --git a/internal/askcli/command_list_test.go b/internal/askcli/command_list_test.go index e25293e..c7c7b8a 100644 --- a/internal/askcli/command_list_test.go +++ b/internal/askcli/command_list_test.go @@ -188,6 +188,44 @@ func TestHandleReady_Success(t *testing.T) { } } +func TestHandleCompleted_Success(t *testing.T) { + dir := t.TempDir() + oldRoot := taskAliasCacheRoot + oldNow := nowTaskAliasCache + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } + defer func() { + taskAliasCacheRoot = oldRoot + nowTaskAliasCache = oldNow + }() + + writeTaskAliasCacheForTest(t, taskAliasCache{ + NextID: 1, + Entries: []taskAliasCacheEntry{ + {UUID: "uuid-done", Alias: "0", CreatedAt: nowTaskAliasCache()}, + }, + }) + + jsonData := `[{"uuid":"uuid-done","description":"Done task","status":"completed","priority":"M","tags":[],"urgency":0.0,"depends":[]}]` + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + for _, arg := range args { + if arg == "export" { + _, _ = io.WriteString(stdout, jsonData) + return 0, nil + } + } + return 0, nil + }}) + var stdout, stderr bytes.Buffer + code, _ := d.Dispatch(context.Background(), []string{"completed"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("completed code = %d, want 0", code) + } + if !strings.Contains(stdout.String(), "0") || strings.Contains(stdout.String(), "uuid-done") { + t.Fatalf("output should show alias only: %s", stdout.String()) + } +} + func TestHandleList_PassesFilters(t *testing.T) { var capturedArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { diff --git a/internal/askcli/command_watch.go b/internal/askcli/command_watch.go index 33b3bbd..0e326ef 100644 --- a/internal/askcli/command_watch.go +++ b/internal/askcli/command_watch.go @@ -40,7 +40,7 @@ func (d *Dispatcher) handleWatch(ctx context.Context, args []string, stdout, std watchArgs = []string{"list"} } if !watchCommandAllowed(watchArgs) { - _, _ = io.WriteString(stderr, "error: ask watch supports read-only subcommands: list, all, ready, info, dep list, urgency, help\n") + _, _ = io.WriteString(stderr, "error: ask watch supports read-only subcommands: list, all, ready, completed, info, dep list, urgency, help\n") return 1, nil } diff --git a/internal/askcli/commands_registry.go b/internal/askcli/commands_registry.go index b288fd4..c755560 100644 --- a/internal/askcli/commands_registry.go +++ b/internal/askcli/commands_registry.go @@ -99,6 +99,13 @@ var commandRegistry = newCommandTable([]commandEntry{ includeInCompletion: true, readOnly: true, }, + { + name: "completed", + description: "List completed tasks", + handler: wrapSimpleCommand((*Dispatcher).handleCompleted), + includeInCompletion: true, + readOnly: true, + }, { name: "info", description: "Show task details", diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index 015c7d5..3cc3a24 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -12,7 +12,7 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { t.Fatalf("script missing scope completion for %q", name) } } - for _, name := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "fish", "help"} { + for _, name := range []string{"add", "list", "all", "ready", "completed", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "fish", "help"} { if !strings.Contains(script, " -a '"+name+"' ") { t.Fatalf("script missing root completion for %q", name) } diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index f6c503b..81b8944 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -82,6 +82,7 @@ func (d *Dispatcher) help(w io.Writer) (int, error) { _, _ = io.WriteString(w, " ask add [mods...] [depends:,...] Create a new task and print created task \n") _, _ = io.WriteString(w, " ask list [filters] List active tasks (default)\n") _, _ = io.WriteString(w, " ask ready List READY tasks (not blocked)\n") + _, _ = io.WriteString(w, " ask completed List completed tasks\n") _, _ = io.WriteString(w, " ask all [filters] List all tasks including completed/deleted\n") _, _ = io.WriteString(w, " ask info [id|uuid] Show task details or current started task\n") _, _ = io.WriteString(w, " ask annotate \"note\" Add annotation to task\n") diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index c7c7086..2b00a0e 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -203,7 +203,7 @@ func TestDispatcher_LongHelp(t *testing.T) { var stdout bytes.Buffer d.Dispatch(context.Background(), []string{"help"}, nil, &stdout, io.Discard) output := stdout.String() - for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "watch", "projects", "modify", "denotate", "delete", "fish"} { + for _, sub := range []string{"add", "list", "all", "ready", "completed", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "watch", "projects", "modify", "denotate", "delete", "fish"} { if !strings.Contains(output, "ask "+sub) { t.Errorf("help missing subcommand: ask %s", sub) } @@ -481,6 +481,11 @@ func TestDispatcher_AllSubcommandsReachExecutor(t *testing.T) { args: []string{"ready"}, wantCalls: [][]string{{"+READY", "export"}}, }, + { + name: "completed", + args: []string{"completed"}, + wantCalls: [][]string{{"status:completed", "export"}}, + }, { name: "urgency", args: []string{"urgency"}, @@ -574,7 +579,7 @@ func TestDispatcher_AllSubcommandsReachExecutor(t *testing.T) { d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { calls = append(calls, append([]string(nil), args...)) switch strings.Join(args, " ") { - case "export", "status:pending export", "+READY export": + case "export", "status:pending export", "+READY export", "status:completed export": _, _ = io.WriteString(stdout, taskJSONFor("test-uuid")) case "uuid:test-uuid export": _, _ = io.WriteString(stdout, taskJSONFor("test-uuid")) -- cgit v1.2.3