Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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 <n>` caps each repository, branch, and host breakdown to the first N entries.

### Tags
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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 <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.
Expand Down
40 changes: 40 additions & 0 deletions cmd/dispatch/cli_tagfilter.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 18 additions & 1 deletion cmd/dispatch/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions cmd/dispatch/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"testing"

"github.com/jongio/dispatch/internal/config"
"github.com/jongio/dispatch/internal/data"
)

Expand Down Expand Up @@ -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"},
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
})
}
}
Expand Down Expand Up @@ -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 })

Expand Down
16 changes: 16 additions & 0 deletions cmd/dispatch/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions cmd/dispatch/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"
"time"

"github.com/jongio/dispatch/internal/config"
"github.com/jongio/dispatch/internal/data"
)

Expand All @@ -27,6 +28,7 @@ func TestParseSearchArgsQueryAndFilters(t *testing.T) {
"--branch=main",
"--folder", "/code",
"--host", "cli",
"--tag", "Work",
"--deep",
"--limit", "10",
"--sort", "turns",
Expand All @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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{
{
Expand Down
16 changes: 16 additions & 0 deletions cmd/dispatch/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading