From d67929be72672814095f2ef9ddb7c3ee85647172 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:27:23 -0700 Subject: [PATCH] feat(cli): filter sessions by tag Add --tag filters to search, stats, and batch export so CLI scripts can reuse session tags. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 8 +++++-- cmd/dispatch/cli_tagfilter.go | 40 +++++++++++++++++++++++++++++++++++ cmd/dispatch/export.go | 19 ++++++++++++++++- cmd/dispatch/export_test.go | 37 ++++++++++++++++++++++++++++++++ cmd/dispatch/search.go | 16 ++++++++++++++ cmd/dispatch/search_test.go | 27 +++++++++++++++++++++++ cmd/dispatch/stats.go | 16 ++++++++++++++ cmd/dispatch/stats_test.go | 31 ++++++++++++++++++++++++++- 8 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 cmd/dispatch/cli_tagfilter.go diff --git a/README.md b/README.md index cd1e2a2..9898e0b 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,7 @@ dispatch stats --csv dispatch stats --markdown dispatch stats --calendar dispatch stats --repo jongio/dispatch --since 2026-01-01 +dispatch stats --tag work dispatch stats --top 5 ``` @@ -261,7 +262,7 @@ Flags: - `--json` prints the summary as a single JSON object. - `--markdown` prints the summary as Markdown tables for reports and issue comments. - `--calendar` adds a GitHub-style activity heatmap of sessions per day, with an intensity legend. It honors the `--repo`, `--branch`, `--since`, and `--until` filters. -- `--repo`, `--branch`, `--folder`, `--since`, and `--until` narrow which sessions are counted. +- `--repo`, `--branch`, `--folder`, `--tag`, `--since`, and `--until` narrow which sessions are counted. - `--top ` caps each repository, branch, and host breakdown to the first N entries. ### Tags @@ -329,9 +330,10 @@ dispatch export --repo jongio/dispatch --format json --out ./backup dispatch export --repo jongio/dispatch --format text --out ./backup dispatch export --branch main --since 2026-01-01 --until 2026-07-01 dispatch export --query "auth fix" --redact +dispatch export --tag work --out ./work-sessions ``` -Filter flags (`--query`, `--repo`, `--branch`, `--folder`, `--since`, `--until`) replace the session ID. Each matching session is exported to its own file in the output directory. `--stdout` is not supported in batch mode. +Filter flags (`--query`, `--repo`, `--branch`, `--folder`, `--tag`, `--since`, `--until`) replace the session ID. Each matching session is exported to its own file in the output directory. `--stdout` is not supported in batch mode. ### Compare @@ -438,6 +440,7 @@ Query the session store from scripts without opening the TUI. `dispatch search` dispatch search auth dispatch search --query "fix login" --repo jongio/dispatch dispatch search --branch main --since 2026-01-01 --limit 20 +dispatch search --tag work --ids dispatch search --sort turns --order desc --limit 10 dispatch search --deep refactor --json dispatch search auth --format ids @@ -449,6 +452,7 @@ dispatch search auth --csv The query can be passed as a positional argument or with `--query`. Filters mirror the interactive search and the `stats` command: - `--repo`, `--branch`, `--folder`, `--host` narrow by session metadata. +- `--tag ` narrows by tags stored in Dispatch config. - `--since` / `--until` accept `YYYY-MM-DD` or full RFC3339 timestamps. - `--deep` also searches turns, checkpoints, touched files, and refs. - `--sort updated|created|turns|name|folder` and `--order asc|desc` control result order. diff --git a/cmd/dispatch/cli_tagfilter.go b/cmd/dispatch/cli_tagfilter.go new file mode 100644 index 0000000..391d4ee --- /dev/null +++ b/cmd/dispatch/cli_tagfilter.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +func parseSingleTagFilter(value, flag string) (string, error) { + tags := config.ParseTags(value) + if len(tags) != 1 { + return "", fmt.Errorf("%s requires exactly one tag", flag) + } + return tags[0], nil +} + +func loadAndFilterSessionsByTag(sessions []data.Session, tag string) ([]data.Session, error) { + if tag == "" { + return sessions, nil + } + cfg, err := configLoadFn() + if err != nil { + return nil, fmt.Errorf("loading config: %w", err) + } + return filterSessionsByTag(sessions, cfg, tag), nil +} + +func filterSessionsByTag(sessions []data.Session, cfg *config.Config, tag string) []data.Session { + if cfg == nil || tag == "" { + return sessions + } + filtered := make([]data.Session, 0, len(sessions)) + for _, s := range sessions { + if cfg.HasTag(s.ID, tag) { + filtered = append(filtered, s) + } + } + return filtered +} diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go index c094ac9..bacd2e3 100644 --- a/cmd/dispatch/export.go +++ b/cmd/dispatch/export.go @@ -31,6 +31,7 @@ type exportOptions struct { outDir string redact bool filter *data.FilterOptions // non-nil for batch mode + tag string } // runExport writes a session's full content. It writes to the exports directory @@ -170,6 +171,18 @@ func parseExportArgs(args []string) (exportOptions, error) { filter.Folder = v hasFilter = true i = ni + case name == "--tag": + v, ni, err := takeValue(i, "--tag", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + tag, err := parseSingleTagFilter(v, "--tag") + if err != nil { + return exportOptions{}, err + } + opts.tag = tag + hasFilter = true + i = ni case name == "--since": v, ni, err := takeValue(i, "--since", inlineOrEmpty(inline, hasInline)) if err != nil { @@ -205,7 +218,7 @@ func parseExportArgs(args []string) (exportOptions, error) { case len(positionals) == 0 && hasFilter: opts.filter = &filter case len(positionals) == 0 && !hasFilter: - return exportOptions{}, errors.New("export requires a session ID or filter flags (--query, --repo, --branch, --folder, --since, --until)") + return exportOptions{}, errors.New("export requires a session ID or filter flags (--query, --repo, --branch, --folder, --tag, --since, --until)") case len(positionals) == 1 && !hasFilter: opts.id = positionals[0] case len(positionals) == 1 && hasFilter: @@ -364,6 +377,10 @@ func runExportBatch(w io.Writer, opts exportOptions) error { if err != nil { return err } + sessions, err = loadAndFilterSessionsByTag(sessions, opts.tag) + if err != nil { + return err + } if len(sessions) == 0 { fmt.Fprintln(w, "No sessions match the given filters.") return nil diff --git a/cmd/dispatch/export_test.go b/cmd/dispatch/export_test.go index 25e26c7..e46e5f1 100644 --- a/cmd/dispatch/export_test.go +++ b/cmd/dispatch/export_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/jongio/dispatch/internal/config" "github.com/jongio/dispatch/internal/data" ) @@ -50,6 +51,7 @@ func TestParseExportArgs(t *testing.T) { wantOut string wantRedact bool wantFilter bool + wantTag string wantErr bool }{ {name: "id only", args: []string{"export", "abc"}, wantID: "abc", wantFormat: "md"}, @@ -74,6 +76,7 @@ func TestParseExportArgs(t *testing.T) { {name: "batch since until", args: []string{"export", "--since", "2026-01-01", "--until", "2026-02-01"}, wantFormat: "md", wantFilter: true}, {name: "batch with format", args: []string{"export", "--branch", "main", "--format", "json"}, wantFormat: "json", wantFilter: true}, {name: "batch with text format", args: []string{"export", "--branch", "main", "--format", "text"}, wantFormat: "text", wantFilter: true}, + {name: "batch with tag", args: []string{"export", "--tag", "Work"}, wantFormat: "md", wantFilter: true, wantTag: "work"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -108,6 +111,9 @@ func TestParseExportArgs(t *testing.T) { if !tc.wantFilter && opts.filter != nil { t.Errorf("expected filter to be nil, got %+v", opts.filter) } + if opts.tag != tc.wantTag { + t.Errorf("tag = %q, want %q", opts.tag, tc.wantTag) + } }) } } @@ -308,6 +314,37 @@ func TestRunExportBatch_WriteFiles(t *testing.T) { } } +func TestRunExportBatch_TagFilter(t *testing.T) { + sessions := []data.Session{ + {ID: "ses-001", Summary: "First"}, + {ID: "ses-002", Summary: "Second"}, + } + withExportList(t, func(data.FilterOptions) ([]data.Session, error) { return sessions, nil }) + withExportDetail(t, func(id string) (*data.SessionDetail, error) { + return &data.SessionDetail{Session: data.Session{ID: id, Summary: "S " + id}}, nil + }) + prevLoad := configLoadFn + configLoadFn = func() (*config.Config, error) { + return &config.Config{SessionTags: map[string][]string{"ses-002": {"work"}}}, nil + } + t.Cleanup(func() { configLoadFn = prevLoad }) + + dir := t.TempDir() + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "--tag", "work", "--out", dir}); err != nil { + t.Fatalf("runExport batch: %v", err) + } + if strings.Contains(buf.String(), "ses-001") || !strings.Contains(buf.String(), "Exported 1 of 1 sessions") { + t.Errorf("unexpected output:\n%s", buf.String()) + } + if _, err := os.Stat(filepath.Join(dir, "ses-002.md")); err != nil { + t.Fatalf("expected tagged export: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "ses-001.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("untagged session should not be exported, stat err = %v", err) + } +} + func TestRunExportBatch_NoMatches(t *testing.T) { withExportList(t, func(data.FilterOptions) ([]data.Session, error) { return nil, nil }) diff --git a/cmd/dispatch/search.go b/cmd/dispatch/search.go index c628b11..4e6c591 100644 --- a/cmd/dispatch/search.go +++ b/cmd/dispatch/search.go @@ -41,6 +41,7 @@ type searchOptions struct { sort data.SortOptions limit int format searchOutputFormat + tag string } // searchSession is the machine-readable shape emitted for each matching @@ -79,6 +80,10 @@ func runSearch(w io.Writer, args []string) error { if err != nil { return err } + sessions, err = loadAndFilterSessionsByTag(sessions, opts.tag) + if err != nil { + return err + } if opts.format == searchFormatIDs { return writeSearchIDs(w, sessions) @@ -278,6 +283,17 @@ func parseSearchArgs(args []string) (searchOptions, error) { } opts.filter.HostType = v i = ni + case name == "--tag": + v, ni, err := takeValue(i, "--tag", inlineOrEmpty(inline, hasInline)) + if err != nil { + return searchOptions{}, err + } + tag, err := parseSingleTagFilter(v, "--tag") + if err != nil { + return searchOptions{}, err + } + opts.tag = tag + i = ni case name == "--since": v, ni, err := takeValue(i, "--since", inlineOrEmpty(inline, hasInline)) if err != nil { diff --git a/cmd/dispatch/search_test.go b/cmd/dispatch/search_test.go index ae47d23..3462671 100644 --- a/cmd/dispatch/search_test.go +++ b/cmd/dispatch/search_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/jongio/dispatch/internal/config" "github.com/jongio/dispatch/internal/data" ) @@ -27,6 +28,7 @@ func TestParseSearchArgsQueryAndFilters(t *testing.T) { "--branch=main", "--folder", "/code", "--host", "cli", + "--tag", "Work", "--deep", "--limit", "10", "--sort", "turns", @@ -52,6 +54,9 @@ func TestParseSearchArgsQueryAndFilters(t *testing.T) { if opts.filter.HostType != "cli" { t.Errorf("HostType = %q, want cli", opts.filter.HostType) } + if opts.tag != "work" { + t.Errorf("tag = %q, want work", opts.tag) + } if !opts.filter.DeepSearch { t.Error("DeepSearch = false, want true") } @@ -141,6 +146,8 @@ func TestParseSearchArgsErrors(t *testing.T) { {"search", "--sort"}, {"search", "--sort", "attention"}, {"search", "--order", "sideways"}, + {"search", "--tag"}, + {"search", "--tag", "a,b"}, } for _, args := range cases { if _, err := parseSearchArgs(args); err == nil { @@ -149,6 +156,26 @@ func TestParseSearchArgsErrors(t *testing.T) { } } +func TestRunSearchTagFilter(t *testing.T) { + sessions := []data.Session{{ID: "a"}, {ID: "b"}, {ID: "c"}} + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { + return sessions, nil + }) + prevLoad := configLoadFn + configLoadFn = func() (*config.Config, error) { + return &config.Config{SessionTags: map[string][]string{"a": {"work"}, "c": {"other"}}}, nil + } + t.Cleanup(func() { configLoadFn = prevLoad }) + + var buf bytes.Buffer + if err := runSearch(&buf, []string{"search", "--ids", "--tag", "Work"}); err != nil { + t.Fatalf("runSearch returned error: %v", err) + } + if got, want := buf.String(), "a\n"; got != want { + t.Errorf("output = %q, want %q", got, want) + } +} + func TestRunSearchJSONOutput(t *testing.T) { sessions := []data.Session{ { diff --git a/cmd/dispatch/stats.go b/cmd/dispatch/stats.go index c915bd5..8dffdd5 100644 --- a/cmd/dispatch/stats.go +++ b/cmd/dispatch/stats.go @@ -31,6 +31,7 @@ type statsOptions struct { markdown bool calendar bool top int + tag string } // countEntry is one label and count pair in a grouped breakdown. @@ -82,6 +83,10 @@ func runStats(w io.Writer, args []string) error { if err != nil { return err } + sessions, err = loadAndFilterSessionsByTag(sessions, opts.tag) + if err != nil { + return err + } report := buildStatsReport(sessions) if opts.calendar { @@ -171,6 +176,17 @@ func parseStatsArgs(args []string) (statsOptions, error) { } opts.filter.Folder = v i = ni + case name == "--tag": + v, ni, err := takeValue(i, "--tag", inlineOrEmpty(inline, hasInline)) + if err != nil { + return statsOptions{}, err + } + tag, err := parseSingleTagFilter(v, "--tag") + if err != nil { + return statsOptions{}, err + } + opts.tag = tag + i = ni case name == "--since": v, ni, err := takeValue(i, "--since", inlineOrEmpty(inline, hasInline)) if err != nil { diff --git a/cmd/dispatch/stats_test.go b/cmd/dispatch/stats_test.go index 2dfeb6a..cf7008d 100644 --- a/cmd/dispatch/stats_test.go +++ b/cmd/dispatch/stats_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/jongio/dispatch/internal/config" "github.com/jongio/dispatch/internal/data" ) @@ -111,7 +112,7 @@ func TestBuildStatsReportEmpty(t *testing.T) { } func TestParseStatsArgs(t *testing.T) { - opts, err := parseStatsArgs([]string{"stats", "--json", "--repo", "jongio/dispatch", "--branch=main", "--since", "2026-01-01", "--until=2026-07-01", "--top", "2"}) + opts, err := parseStatsArgs([]string{"stats", "--json", "--repo", "jongio/dispatch", "--branch=main", "--tag", "Work", "--since", "2026-01-01", "--until=2026-07-01", "--top", "2"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -124,6 +125,9 @@ func TestParseStatsArgs(t *testing.T) { if opts.filter.Branch != "main" { t.Errorf("Branch = %q", opts.filter.Branch) } + if opts.tag != "work" { + t.Errorf("tag = %q, want work", opts.tag) + } if opts.filter.Since == nil || opts.filter.Until == nil { t.Fatal("expected Since and Until to be set") } @@ -145,6 +149,8 @@ func TestParseStatsArgsErrors(t *testing.T) { {"stats", "--top", "0"}, // not positive {"stats", "--top", "-1"}, // negative {"stats", "--top", "many"}, // not a number + {"stats", "--tag"}, // missing value + {"stats", "--tag", "a,b"}, // one tag only } for _, args := range cases { if _, err := parseStatsArgs(args); err == nil { @@ -153,6 +159,29 @@ func TestParseStatsArgsErrors(t *testing.T) { } } +func TestRunStatsTagFilter(t *testing.T) { + withStatsList(t, func(data.FilterOptions) ([]data.Session, error) { + return sampleSessions(), nil + }) + prevLoad := configLoadFn + configLoadFn = func() (*config.Config, error) { + return &config.Config{SessionTags: map[string][]string{"a": {"work"}, "c": {"other"}}}, nil + } + t.Cleanup(func() { configLoadFn = prevLoad }) + + var buf bytes.Buffer + if err := runStats(&buf, []string{"stats", "--json", "--tag", "work"}); err != nil { + t.Fatalf("runStats: %v", err) + } + var report statsReport + if err := json.Unmarshal(buf.Bytes(), &report); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String()) + } + if report.TotalSessions != 1 || report.TotalTurns != 5 { + t.Errorf("unexpected report: %+v", report) + } +} + func TestApplyStatsTopLimit(t *testing.T) { report := buildStatsReport(sampleSessions()) applyStatsTopLimit(&report, 1)