From 1ec45c3680c057eacd86a7adf8c3f488185cb8f2 Mon Sep 17 00:00:00 2001 From: Daniel Vydra Date: Thu, 7 May 2026 13:54:28 +1000 Subject: [PATCH] dispatch: add --me and --author flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new flags to `entire dispatch` for narrowing the recap to a single contributor: entire dispatch --me # just my work entire dispatch --repos owner/repo --me entire dispatch --repos owner/repo --author teammate@example.com --since 14d entire dispatch --local --all-branches --me Both flags are wired through `dispatch.Options.{Me, Author}` and flow into: - Cloud mode (`mode_cloud.go`): passed through in `CreateDispatchRequest` to POST /api/v1/dispatches/generate. Server resolves them to a `commit_author_github_id` filter (with username fallback for rows where the github_login → id mapping hasn't backfilled). See entirehq/entire.io#1840 for the server-side change this depends on. - Local mode (`mode_local.go`): `resolveLocalAuthorFilter` lowercases the input email; `--me` resolves to `git config user.email`. The metadata-branch commit author email is matched case-insensitively via `loadCommitAuthorsByCheckpoint`. `--me` and `--author` are mutually exclusive (`ResolveOptions` returns "--author and --me are mutually exclusive"). The interactive wizard gains an "Author scope" question with three options: Everyone (default), Just me, Specific person — the third prompts for an email. Tests cover: - Flag plumbing through `ResolveOptions` (PropagatesAuthor, PropagatesMe, RejectsAuthorAndMeTogether, WhitespaceAuthorWithMeIsAllowed) - Cloud client serialisation (SendsAuthorAndMeWhenSet) - Local mode filter resolution (resolveLocalAuthorFilter cases) and candidate filtering (filterCandidatesByAuthor) - Wizard scope selection and option mapping Author semantics differ slightly between modes: local matches against commit email, cloud matches against the requester's GitHub login (the server doesn't currently resolve emails to logins). Future work can unify these by adding email lookup server-side. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 1bd236e74bbd --- cmd/entire/cli/dispatch.go | 16 +- cmd/entire/cli/dispatch/cloud.go | 4 + cmd/entire/cli/dispatch/cloud_test.go | 109 +++++++++++++ cmd/entire/cli/dispatch/dispatch.go | 10 ++ cmd/entire/cli/dispatch/mode_cloud.go | 2 + cmd/entire/cli/dispatch/mode_local.go | 125 +++++++++++++++ cmd/entire/cli/dispatch/mode_local_test.go | 171 ++++++++++++++++++++- cmd/entire/cli/dispatch/options.go | 9 ++ cmd/entire/cli/dispatch/options_test.go | 112 ++++++++++++++ cmd/entire/cli/dispatch_test.go | 14 ++ cmd/entire/cli/dispatch_wizard.go | 54 +++++++ cmd/entire/cli/dispatch_wizard_test.go | 87 +++++++++++ 12 files changed, 710 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/dispatch.go b/cmd/entire/cli/dispatch.go index 52804b859a..1510f1d084 100644 --- a/cmd/entire/cli/dispatch.go +++ b/cmd/entire/cli/dispatch.go @@ -28,6 +28,8 @@ func newDispatchCmd() *cobra.Command { flagRepos []string flagVoice string flagInsecureHTTPAuth bool + flagAuthor string + flagMe bool ) cmd := &cobra.Command{ @@ -39,6 +41,8 @@ Examples: entire dispatch entire dispatch --local --all-branches entire dispatch --repos entireio/cli + entire dispatch --repos entireio/cli --me + entire dispatch --repos entireio/cli --author teammate@example.com entire dispatch --voice neutral`, RunE: func(cmd *cobra.Command, _ []string) error { var ( @@ -49,7 +53,7 @@ Examples: if shouldRunDispatchWizard(cmd.Flags().NFlag(), isTerminalStdin(os.Stdin), interactive.IsTerminalWriter(cmd.OutOrStdout())) { opts, err = runDispatchWizard(cmd) } else { - opts, err = parseDispatchFlags(cmd, flagLocal, flagSince, flagUntil, flagAllBranches, flagRepos, flagVoice, flagInsecureHTTPAuth) + opts, err = parseDispatchFlags(cmd, flagLocal, flagSince, flagUntil, flagAllBranches, flagRepos, flagVoice, flagInsecureHTTPAuth, flagAuthor, flagMe) } if err != nil { if errors.Is(err, errDispatchCancelled) { @@ -74,6 +78,8 @@ Examples: cmd.Flags().BoolVar(&flagAllBranches, "all-branches", false, "include every existing local branch (--local only; renamed or deleted branches are skipped)") cmd.Flags().StringSliceVar(&flagRepos, "repos", nil, fmt.Sprintf("cloud repo slugs, up to %d (for example entireio/cli)", dispatchpkg.CloudRepoLimit)) cmd.Flags().StringVar(&flagVoice, "voice", "", "voice preset name or literal description") + cmd.Flags().StringVar(&flagAuthor, "author", "", "filter to checkpoints authored by this email") + cmd.Flags().BoolVar(&flagMe, "me", false, "filter to your own checkpoints (alias for --author )") cmd.Flags().BoolVar(&flagInsecureHTTPAuth, "insecure-http-auth", false, "Allow authentication over plain HTTP (insecure, for local development only)") if err := cmd.Flags().MarkHidden("insecure-http-auth"); err != nil { panic(fmt.Sprintf("hide insecure-http-auth flag: %v", err)) @@ -125,6 +131,8 @@ func parseDispatchFlags( flagRepos []string, flagVoice string, flagInsecureHTTPAuth bool, + flagAuthor string, + flagMe bool, ) (dispatchpkg.Options, error) { return resolveDispatchOptions( flagLocal, @@ -134,6 +142,8 @@ func parseDispatchFlags( flagRepos, flagVoice, flagInsecureHTTPAuth, + flagAuthor, + flagMe, func() (string, error) { return GetCurrentBranch(cmd.Context()) }, @@ -149,6 +159,8 @@ func resolveDispatchOptions( flagRepos []string, flagVoice string, flagInsecureHTTPAuth bool, + flagAuthor string, + flagMe bool, currentBranch func() (string, error), ) (dispatchpkg.Options, error) { return dispatchpkg.ResolveOptions( @@ -159,6 +171,8 @@ func resolveDispatchOptions( flagRepos, flagVoice, flagInsecureHTTPAuth, + flagAuthor, + flagMe, currentBranch, ) } diff --git a/cmd/entire/cli/dispatch/cloud.go b/cmd/entire/cli/dispatch/cloud.go index 83c52840c3..8077d0ad46 100644 --- a/cmd/entire/cli/dispatch/cloud.go +++ b/cmd/entire/cli/dispatch/cloud.go @@ -61,6 +61,10 @@ type CreateDispatchRequest struct { Until string `json:"until"` Generate bool `json:"generate"` Voice string `json:"voice,omitempty"` + // Author filters checkpoints to a specific author email. + Author string `json:"author,omitempty"` + // Me asks the server to filter to the bearer-token user's checkpoints. + Me bool `json:"me,omitempty"` } type CreateDispatchResponse struct { diff --git a/cmd/entire/cli/dispatch/cloud_test.go b/cmd/entire/cli/dispatch/cloud_test.go index 7b5220386f..a45255d748 100644 --- a/cmd/entire/cli/dispatch/cloud_test.go +++ b/cmd/entire/cli/dispatch/cloud_test.go @@ -301,6 +301,115 @@ func TestCloudClient_CreateDispatch_AcceptsVoiceResponseField(t *testing.T) { } } +func TestCloudClient_CreateDispatch_SendsAuthorAndMeWhenSet(t *testing.T) { + t.Parallel() + + type bodyAssertion struct { + expectAuthor string + expectMe bool + } + cases := []struct { + name string + req CreateDispatchRequest + want bodyAssertion + }{ + { + name: "author only", + req: CreateDispatchRequest{ + Repos: []string{"entireio/cli"}, + Since: "2026-04-09T00:00:00Z", + Until: "2026-04-16T00:00:00Z", + Generate: true, + Author: "teammate@example.com", + }, + want: bodyAssertion{expectAuthor: "teammate@example.com"}, + }, + { + name: "me only", + req: CreateDispatchRequest{ + Repos: []string{"entireio/cli"}, + Since: "2026-04-09T00:00:00Z", + Until: "2026-04-16T00:00:00Z", + Generate: true, + Me: true, + }, + want: bodyAssertion{expectMe: true}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if tc.want.expectAuthor != "" { + got, ok := body["author"].(string) + if !ok || got != tc.want.expectAuthor { + t.Fatalf("expected author %q in body, got %v", tc.want.expectAuthor, body["author"]) + } + } else if _, ok := body["author"]; ok { + t.Fatalf("did not expect author in body, got %v", body["author"]) + } + if tc.want.expectMe { + got, ok := body["me"].(bool) + if !ok || !got { + t.Fatalf("expected me=true in body, got %v", body["me"]) + } + } else if _, ok := body["me"]; ok { + t.Fatalf("did not expect me in body, got %v", body["me"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response + })) + defer srv.Close() + + client := NewCloudClient(CloudConfig{BaseURL: srv.URL, Token: "t"}) + if _, err := client.CreateDispatch(ctx, tc.req); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestCloudClient_CreateDispatch_OmitsAuthorAndMeWhenUnset(t *testing.T) { + t.Parallel() + + ctx := context.Background() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if _, ok := body["author"]; ok { + t.Fatalf("did not expect author in unset body, got %v", body["author"]) + } + if _, ok := body["me"]; ok { + t.Fatalf("did not expect me in unset body, got %v", body["me"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response + })) + defer srv.Close() + + client := NewCloudClient(CloudConfig{BaseURL: srv.URL, Token: "t"}) + if _, err := client.CreateDispatch(ctx, CreateDispatchRequest{ + Repos: []string{"entireio/cli"}, + Since: "2026-04-09T00:00:00Z", + Until: "2026-04-16T00:00:00Z", + Generate: true, + }); err != nil { + t.Fatal(err) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/cmd/entire/cli/dispatch/dispatch.go b/cmd/entire/cli/dispatch/dispatch.go index 73dcd23c2f..3bc30d1d67 100644 --- a/cmd/entire/cli/dispatch/dispatch.go +++ b/cmd/entire/cli/dispatch/dispatch.go @@ -33,6 +33,16 @@ type Options struct { ImplicitCurrentBranch bool Voice string InsecureHTTPAuth bool + + // Author filters checkpoints to a specific author email. + // Cloud mode passes it through to the server; local mode matches it + // case-insensitively against the metadata-branch commit author. + Author string + + // Me requests filtering to the current operator. Cloud mode delegates + // resolution to the bearer-token user; local mode resolves it to + // `git config user.email` at run time. + Me bool } // CloudRepoLimit caps how many repos the cloud mode may query in one request. diff --git a/cmd/entire/cli/dispatch/mode_cloud.go b/cmd/entire/cli/dispatch/mode_cloud.go index 3c5fa93b3e..12fb52e597 100644 --- a/cmd/entire/cli/dispatch/mode_cloud.go +++ b/cmd/entire/cli/dispatch/mode_cloud.go @@ -76,6 +76,8 @@ func runServer(ctx context.Context, opts Options) (*Dispatch, error) { Until: normalizedUntil.Format(time.RFC3339), Generate: true, Voice: resolvedDispatchVoicePreference(opts.Voice), + Author: opts.Author, + Me: opts.Me, } response, err := cloud.CreateDispatch(ctx, reqBody) if err != nil { diff --git a/cmd/entire/cli/dispatch/mode_local.go b/cmd/entire/cli/dispatch/mode_local.go index 58186ac6e1..67b8e918ea 100644 --- a/cmd/entire/cli/dispatch/mode_local.go +++ b/cmd/entire/cli/dispatch/mode_local.go @@ -50,6 +50,11 @@ func runLocal(ctx context.Context, opts Options) (*Dispatch, error) { return nil, err } + authorFilter, err := resolveLocalAuthorFilter(ctx, opts, repoRoots) + if err != nil { + return nil, err + } + allCandidates := make([]candidate, 0) var candidatesMu sync.Mutex group, groupCtx := errgroup.WithContext(ctx) @@ -59,6 +64,12 @@ func runLocal(ctx context.Context, opts Options) (*Dispatch, error) { if err != nil { return err } + if authorFilter != "" { + candidates, err = filterCandidatesByAuthor(groupCtx, repoRoot, candidates, authorFilter) + if err != nil { + return err + } + } candidatesMu.Lock() allCandidates = append(allCandidates, candidates...) candidatesMu.Unlock() @@ -69,6 +80,10 @@ func runLocal(ctx context.Context, opts Options) (*Dispatch, error) { return nil, fmt.Errorf("enumerate repo candidates: %w", err) } + if authorFilter != "" && len(allCandidates) == 0 { + return nil, fmt.Errorf("no checkpoints in window for author %q", authorFilter) + } + fallback := applyFallbackChain(allCandidates) dispatch := &Dispatch{ CoveredRepos: coveredRepos(allCandidates), @@ -351,6 +366,116 @@ func localBranchNames(repo *git.Repository) ([]string, error) { return names, nil } +// resolveLocalAuthorFilter computes the lowercased email used to filter +// candidates in local mode. Returns "" when no filter is requested. --me +// resolves to `git config user.email` from the first repo (cross-repo email +// mismatches are rare; pass --author to override). +func resolveLocalAuthorFilter(ctx context.Context, opts Options, repoRoots []string) (string, error) { + if opts.Author != "" { + return strings.ToLower(opts.Author), nil + } + if !opts.Me { + return "", nil + } + if len(repoRoots) == 0 { + return "", errors.New("--me requires a git repository") + } + email, err := readGitUserEmail(ctx, repoRoots[0]) + if err != nil { + return "", err + } + if email == "" { + return "", errors.New("--me requires git config user.email; set it or pass --author ") + } + return strings.ToLower(email), nil +} + +func readGitUserEmail(ctx context.Context, repoRoot string) (string, error) { + out, err := exec.CommandContext(ctx, "git", "-C", repoRoot, "config", "user.email").Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", nil + } + return "", fmt.Errorf("read git user.email: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// filterCandidatesByAuthor returns the subset of candidates whose +// entire/checkpoints/v1 metadata commit was authored by authorEmailLower. +func filterCandidatesByAuthor(ctx context.Context, repoRoot string, candidates []candidate, authorEmailLower string) ([]candidate, error) { + if len(candidates) == 0 { + return candidates, nil + } + authors, err := loadCommitAuthorsByCheckpoint(ctx, repoRoot) + if err != nil { + return nil, err + } + filtered := make([]candidate, 0, len(candidates)) + for _, cand := range candidates { + email, ok := authors[cand.CheckpointID] + if !ok { + continue + } + if strings.ToLower(email) != authorEmailLower { + continue + } + filtered = append(filtered, cand) + } + return filtered, nil +} + +// loadCommitAuthorsByCheckpoint maps checkpoint ID → author email by reading +// commits on entire/checkpoints/v1 (subject is "Checkpoint: "). Returns an +// empty map when the metadata branch does not exist yet. +func loadCommitAuthorsByCheckpoint(ctx context.Context, repoRoot string) (map[string]string, error) { + cmd := exec.CommandContext( + ctx, + "git", + "-C", + repoRoot, + "log", + "entire/checkpoints/v1", + "--format=%ae%x00%s%x00%x00", + ) + output, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return map[string]string{}, nil + } + return nil, fmt.Errorf("git log entire/checkpoints/v1: %w", err) + } + + authors := make(map[string]string) + const prefix = "Checkpoint: " + for _, record := range strings.Split(string(output), "\x00\x00") { + record = strings.TrimSuffix(record, "\x00") + if record == "" { + continue + } + parts := strings.SplitN(record, "\x00", 2) + if len(parts) != 2 { + continue + } + email := strings.TrimSpace(parts[0]) + subject := strings.TrimSpace(parts[1]) + if !strings.HasPrefix(subject, prefix) { + continue + } + id := strings.TrimSpace(strings.TrimPrefix(subject, prefix)) + if id == "" { + continue + } + if _, exists := authors[id]; exists { + continue + } + authors[id] = email + } + return authors, nil +} + func loadCommitSubjectsByCheckpoint(ctx context.Context, repoRoot string, since time.Time) (map[string]string, error) { cmd := exec.CommandContext( ctx, diff --git a/cmd/entire/cli/dispatch/mode_local_test.go b/cmd/entire/cli/dispatch/mode_local_test.go index 719b74f174..3c9d2fee78 100644 --- a/cmd/entire/cli/dispatch/mode_local_test.go +++ b/cmd/entire/cli/dispatch/mode_local_test.go @@ -708,6 +708,163 @@ func TestLoadCommitSubjectsByCheckpoint_UsesSingleWindowedLogScan(t *testing.T) } } +func TestLocalMode_AuthorFilterKeepsMatchingCheckpoints(t *testing.T) { + dir := t.TempDir() + stubGeneratedLocalDispatch(t) + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, "a.txt", "x") + testutil.GitAdd(t, dir, "a.txt") + testutil.GitCommit(t, dir, "initial") + addOriginRemote(t, dir) + + createdAt := time.Now().UTC() + seedCommittedCheckpoint(t, dir, seededCheckpoint{ + id: "aaaaaaaaaaaa", + branch: "main", + createdAt: createdAt, + filesTouched: []string{"a.txt"}, + outcome: "mine", + authorName: "Me", + authorEmail: "me@example.com", + }) + seedCommittedCheckpoint(t, dir, seededCheckpoint{ + id: "bbbbbbbbbbbb", + branch: "main", + createdAt: createdAt, + filesTouched: []string{"a.txt"}, + outcome: "theirs", + authorName: "Them", + authorEmail: "them@example.com", + }) + + oldNow := nowUTC + nowUTC = func() time.Time { return createdAt.Add(2 * time.Hour) } + t.Cleanup(func() { nowUTC = oldNow }) + + t.Chdir(dir) + + got, err := Run(context.Background(), Options{ + Mode: ModeLocal, + Since: "7d", + Branches: []string{"main"}, + Author: "ME@EXAMPLE.COM", + }) + if err != nil { + t.Fatal(err) + } + if len(got.Repos) != 1 { + t.Fatalf("expected 1 repo group, got %d", len(got.Repos)) + } + bullets := got.Repos[0].Sections[0].Bullets + if len(bullets) != 1 { + t.Fatalf("expected exactly 1 bullet after author filter, got %d (%+v)", len(bullets), bullets) + } + if bullets[0].Text != "mine" { + t.Fatalf("expected mine bullet to survive filter, got %q", bullets[0].Text) + } +} + +func TestLocalMode_AuthorFilterEmptyResultErrors(t *testing.T) { + dir := t.TempDir() + stubGeneratedLocalDispatch(t) + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, "a.txt", "x") + testutil.GitAdd(t, dir, "a.txt") + testutil.GitCommit(t, dir, "initial") + addOriginRemote(t, dir) + + createdAt := time.Now().UTC() + seedCommittedCheckpoint(t, dir, seededCheckpoint{ + id: testCheckpointID, + branch: "main", + createdAt: createdAt, + filesTouched: []string{"a.txt"}, + outcome: "theirs", + authorEmail: "them@example.com", + }) + + oldNow := nowUTC + nowUTC = func() time.Time { return createdAt.Add(2 * time.Hour) } + t.Cleanup(func() { nowUTC = oldNow }) + + t.Chdir(dir) + + _, err := Run(context.Background(), Options{ + Mode: ModeLocal, + Since: "7d", + Branches: []string{"main"}, + Author: "nobody@example.com", + }) + if err == nil { + t.Fatal("expected error when author filter yields no checkpoints") + } + if !strings.Contains(err.Error(), `no checkpoints in window for author "nobody@example.com"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLocalMode_MeResolvesToGitConfigUserEmail(t *testing.T) { + dir := t.TempDir() + stubGeneratedLocalDispatch(t) + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, "a.txt", "x") + testutil.GitAdd(t, dir, "a.txt") + testutil.GitCommit(t, dir, "initial") + addOriginRemote(t, dir) + + // testutil.InitRepo sets user.email to a deterministic value; align our + // seeded checkpoint with it so --me matches. + out, gitErr := exec.CommandContext(context.Background(), "git", "-C", dir, "config", "user.email").Output() + if gitErr != nil { + t.Fatal(gitErr) + } + gitEmail := strings.TrimSpace(string(out)) + if gitEmail == "" { + t.Fatal("expected testutil.InitRepo to set git config user.email") + } + + createdAt := time.Now().UTC() + seedCommittedCheckpoint(t, dir, seededCheckpoint{ + id: "aaaaaaaaaaaa", + branch: "main", + createdAt: createdAt, + filesTouched: []string{"a.txt"}, + outcome: "mine", + authorEmail: gitEmail, + }) + seedCommittedCheckpoint(t, dir, seededCheckpoint{ + id: "bbbbbbbbbbbb", + branch: "main", + createdAt: createdAt, + filesTouched: []string{"a.txt"}, + outcome: "theirs", + authorEmail: "them@example.com", + }) + + oldNow := nowUTC + nowUTC = func() time.Time { return createdAt.Add(2 * time.Hour) } + t.Cleanup(func() { nowUTC = oldNow }) + + t.Chdir(dir) + + got, err := Run(context.Background(), Options{ + Mode: ModeLocal, + Since: "7d", + Branches: []string{"main"}, + Me: true, + }) + if err != nil { + t.Fatal(err) + } + if len(got.Repos) != 1 { + t.Fatalf("expected 1 repo group, got %d", len(got.Repos)) + } + bullets := got.Repos[0].Sections[0].Bullets + if len(bullets) != 1 || bullets[0].Text != "mine" { + t.Fatalf("expected only `mine` bullet to survive --me filter, got %+v", bullets) + } +} + func mustCheckpointID(t *testing.T, value string) checkpointid.CheckpointID { t.Helper() @@ -749,6 +906,8 @@ type seededCheckpoint struct { createdAt time.Time filesTouched []string outcome string + authorName string + authorEmail string } func stubGeneratedLocalDispatch(t *testing.T) { @@ -777,6 +936,14 @@ func seedCommittedCheckpoint(t *testing.T, repoDir string, cp seededCheckpoint) t.Fatal(err) } + authorName := cp.authorName + if authorName == "" { + authorName = "Test User" + } + authorEmail := cp.authorEmail + if authorEmail == "" { + authorEmail = "test@example.com" + } err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ CheckpointID: cpID, SessionID: "session-1", @@ -790,8 +957,8 @@ func seedCommittedCheckpoint(t *testing.T, repoDir string, cp seededCheckpoint) Summary: &checkpoint.Summary{ Outcome: cp.outcome, }, - AuthorName: "Test User", - AuthorEmail: "test@example.com", + AuthorName: authorName, + AuthorEmail: authorEmail, }) if err != nil { t.Fatal(err) diff --git a/cmd/entire/cli/dispatch/options.go b/cmd/entire/cli/dispatch/options.go index 1ec759a98d..e24ec4ecbc 100644 --- a/cmd/entire/cli/dispatch/options.go +++ b/cmd/entire/cli/dispatch/options.go @@ -17,6 +17,8 @@ func ResolveOptions( flagRepos []string, flagVoice string, flagInsecureHTTPAuth bool, + flagAuthor string, + flagMe bool, currentBranch func() (string, error), ) (Options, error) { flagRepos = normalizeScopeValues(flagRepos) @@ -36,6 +38,11 @@ func ResolveOptions( return Options{}, fmt.Errorf("--repos supports at most %d repos per dispatch", CloudRepoLimit) } + flagAuthor = strings.TrimSpace(flagAuthor) + if flagAuthor != "" && flagMe { + return Options{}, errors.New("--author and --me are mutually exclusive") + } + mode := ModeServer if flagLocal { mode = ModeLocal @@ -62,6 +69,8 @@ func ResolveOptions( ImplicitCurrentBranch: implicitCurrentBranch, Voice: flagVoice, InsecureHTTPAuth: flagInsecureHTTPAuth, + Author: flagAuthor, + Me: flagMe, }, nil } diff --git a/cmd/entire/cli/dispatch/options_test.go b/cmd/entire/cli/dispatch/options_test.go index bf1d1ba737..e4bcb08d0e 100644 --- a/cmd/entire/cli/dispatch/options_test.go +++ b/cmd/entire/cli/dispatch/options_test.go @@ -16,6 +16,8 @@ func TestResolveOptions_NormalizesScopeValues(t *testing.T) { []string{" entireio/cli ", "", "entireio/cli"}, "", false, + "", + false, func() (string, error) { return testDefaultBranchName, nil }, ) if err != nil { @@ -40,6 +42,8 @@ func TestResolveOptions_CloudRejectsAllBranches(t *testing.T) { []string{"entireio/cli"}, "", false, + "", + false, func() (string, error) { return testDefaultBranchName, nil }, ) if err == nil || !strings.Contains(err.Error(), "--all-branches only applies to --local") { @@ -59,6 +63,8 @@ func TestResolveOptions_CloudCapsReposAtFive(t *testing.T) { repos, "", false, + "", + false, func() (string, error) { return testDefaultBranchName, nil }, ) if err == nil || !strings.Contains(err.Error(), "supports at most 5") { @@ -77,6 +83,8 @@ func TestResolveOptions_LocalSetsImplicitCurrentBranch(t *testing.T) { nil, "", false, + "", + false, func() (string, error) { return "my-feature", nil }, ) if err != nil { @@ -101,6 +109,8 @@ func TestResolveOptions_ForwardsInsecureHTTPAuth(t *testing.T) { []string{"entireio/cli"}, "", true, + "", + false, func() (string, error) { return testDefaultBranchName, nil }, ) if err != nil { @@ -122,6 +132,8 @@ func TestResolveOptions_LocalAllBranchesSkipsImplicit(t *testing.T) { nil, "", false, + "", + false, func() (string, error) { return "", nil }, ) if err != nil { @@ -138,6 +150,104 @@ func TestResolveOptions_LocalAllBranchesSkipsImplicit(t *testing.T) { } } +func TestResolveOptions_PropagatesAuthor(t *testing.T) { + t.Parallel() + + opts, err := ResolveOptions( + false, + "7d", + "", + false, + []string{"entireio/cli"}, + "", + false, + " Teammate@Example.com ", + false, + func() (string, error) { return testDefaultBranchName, nil }, + ) + if err != nil { + t.Fatal(err) + } + if opts.Author != "Teammate@Example.com" { + t.Fatalf("expected trimmed Author, got %q", opts.Author) + } + if opts.Me { + t.Fatal("did not expect Me=true when only --author was set") + } +} + +func TestResolveOptions_PropagatesMe(t *testing.T) { + t.Parallel() + + opts, err := ResolveOptions( + false, + "7d", + "", + false, + []string{"entireio/cli"}, + "", + false, + "", + true, + func() (string, error) { return testDefaultBranchName, nil }, + ) + if err != nil { + t.Fatal(err) + } + if !opts.Me { + t.Fatal("expected Me=true to propagate") + } + if opts.Author != "" { + t.Fatalf("expected empty Author when --me is set, got %q", opts.Author) + } +} + +func TestResolveOptions_RejectsAuthorAndMeTogether(t *testing.T) { + t.Parallel() + + _, err := ResolveOptions( + false, + "7d", + "", + false, + []string{"entireio/cli"}, + "", + false, + "someone@example.com", + true, + func() (string, error) { return testDefaultBranchName, nil }, + ) + if err == nil || !strings.Contains(err.Error(), "--author and --me are mutually exclusive") { + t.Fatalf("expected mutual-exclusion error, got %v", err) + } +} + +func TestResolveOptions_WhitespaceAuthorWithMeIsAllowed(t *testing.T) { + t.Parallel() + + opts, err := ResolveOptions( + false, + "7d", + "", + false, + []string{"entireio/cli"}, + "", + false, + " ", + true, + func() (string, error) { return testDefaultBranchName, nil }, + ) + if err != nil { + t.Fatalf("whitespace --author should normalize to empty and not collide with --me, got %v", err) + } + if !opts.Me { + t.Fatal("expected Me=true to propagate") + } + if opts.Author != "" { + t.Fatalf("expected empty Author after trimming whitespace, got %q", opts.Author) + } +} + func TestResolveOptions_CloudRejectsInvalidRepoSlug(t *testing.T) { t.Parallel() @@ -149,6 +259,8 @@ func TestResolveOptions_CloudRejectsInvalidRepoSlug(t *testing.T) { []string{"../../etc/passwd"}, "", false, + "", + false, func() (string, error) { return testDefaultBranchName, nil }, ) if err == nil || !strings.Contains(err.Error(), `invalid repo "../../etc/passwd": expected owner/repo`) { diff --git a/cmd/entire/cli/dispatch_test.go b/cmd/entire/cli/dispatch_test.go index 5b16ba988a..d1be194f78 100644 --- a/cmd/entire/cli/dispatch_test.go +++ b/cmd/entire/cli/dispatch_test.go @@ -23,6 +23,8 @@ func TestParseDispatchFlags_ServerReposAreAllowed(t *testing.T) { []string{"entireio/cli", "entireio/entire.io"}, "", false, + "", + false, ) if err != nil { t.Fatal(err) @@ -53,6 +55,8 @@ func TestParseDispatchFlags_NormalizesRepoScopeValues(t *testing.T) { []string{" entireio/cli ", "", "entireio/cli", " otherco/service ", " "}, "", false, + "", + false, ) if err != nil { t.Fatal(err) @@ -77,6 +81,8 @@ func TestParseDispatchFlags_LocalRejectsRepos(t *testing.T) { []string{"entireio/cli"}, "", false, + "", + false, ) if err == nil { t.Fatal("expected error") @@ -98,6 +104,8 @@ func TestParseDispatchFlags_CloudRejectsAllBranches(t *testing.T) { []string{"entireio/cli"}, "", false, + "", + false, ) if err == nil { t.Fatal("expected error for --all-branches in cloud mode") @@ -120,6 +128,8 @@ func TestParseDispatchFlags_CloudCapsReposAtFive(t *testing.T) { repos, "", false, + "", + false, ) if err == nil { t.Fatal("expected error for too many repos") @@ -141,6 +151,8 @@ func TestParseDispatchFlags_LocalAllBranchesFlag(t *testing.T) { nil, "", false, + "", + false, ) if err != nil { t.Fatal(err) @@ -165,6 +177,8 @@ func TestParseDispatchFlags_InsecureHTTPAuthFlag(t *testing.T) { []string{"entireio/cli"}, "", true, + "", + false, ) if err != nil { t.Fatal(err) diff --git a/cmd/entire/cli/dispatch_wizard.go b/cmd/entire/cli/dispatch_wizard.go index dadd60ca40..3a62f03cb8 100644 --- a/cmd/entire/cli/dispatch_wizard.go +++ b/cmd/entire/cli/dispatch_wizard.go @@ -50,6 +50,10 @@ const ( dispatchWizardBranchAll = "all" dispatchWizardVoiceCustom = "custom" + + dispatchWizardScopeEveryone = "everyone" + dispatchWizardScopeMe = "me" + dispatchWizardScopeAuthor = "author" ) type dispatchWizardState struct { @@ -61,6 +65,8 @@ type dispatchWizardState struct { selectedRepos []string voicePreset string voiceCustom string + scopeChoice string + authorInput string confirmRun bool } @@ -70,10 +76,22 @@ func newDispatchWizardState() dispatchWizardState { timeWindowPreset: "7d", localBranchMode: dispatchWizardBranchCurrent, voicePreset: "neutral", + scopeChoice: dispatchWizardScopeEveryone, confirmRun: true, } } +func (s dispatchWizardState) authorEmail() string { + if s.scopeChoice == dispatchWizardScopeAuthor { + return strings.TrimSpace(s.authorInput) + } + return "" +} + +func (s dispatchWizardState) showAuthorInput() bool { + return s.scopeChoice == dispatchWizardScopeAuthor +} + func (s dispatchWizardState) isLocal() bool { return s.modeChoice != dispatchWizardModeServer } @@ -126,6 +144,8 @@ func (s dispatchWizardState) resolve() (dispatchpkg.Options, error) { s.resolveCloudRepos(), s.voiceValue(), false, + s.authorEmail(), + s.scopeChoice == dispatchWizardScopeMe, func() (string, error) { return s.currentBranch, nil }, @@ -163,10 +183,19 @@ func buildDispatchWizardSummary(opts dispatchpkg.Options, scope string) string { mode = "local" } + author := "everyone" + switch { + case opts.Me: + author = "just me" + case opts.Author != "": + author = opts.Author + } + return strings.Join([]string{ "Mode: " + mode, "Scope: " + scope, "Branches: " + branches, + "Author: " + author, }, "\n") } @@ -196,6 +225,8 @@ func buildDispatchCommand(opts dispatchpkg.Options) string { mapBoolToFlag(opts.AllBranches, "--all-branches"), renderStringFlag("--repos", strings.Join(opts.RepoPaths, ",")), renderStringFlag("--voice", strings.TrimSpace(opts.Voice)), + mapBoolToFlag(opts.Me, "--me"), + renderStringFlag("--author", strings.TrimSpace(opts.Author)), }), " ") } @@ -300,6 +331,29 @@ func runDispatchWizard(cmd *cobra.Command) (dispatchpkg.Options, error) { WithHideFunc(func() bool { return !state.showLocalBranchMode() }), + huh.NewGroup( + huh.NewSelect[string](). + Options( + huh.NewOption("Everyone", dispatchWizardScopeEveryone), + huh.NewOption("Just me", dispatchWizardScopeMe), + huh.NewOption("Specific person...", dispatchWizardScopeAuthor), + ). + Value(&state.scopeChoice), + ).Title("Author scope").Description("Filter checkpoints by author."), + huh.NewGroup( + huh.NewInput(). + Placeholder("teammate@example.com"). + Value(&state.authorInput). + Validate(func(value string) error { + if state.showAuthorInput() && strings.TrimSpace(value) == "" { + return errors.New("enter an author email") + } + return nil + }), + ).Title("Author email").Description("Filter to checkpoints by this email."). + WithHideFunc(func() bool { + return !state.showAuthorInput() + }), huh.NewGroup( huh.NewSelect[string](). Options( diff --git a/cmd/entire/cli/dispatch_wizard_test.go b/cmd/entire/cli/dispatch_wizard_test.go index 7bb924e6cf..4d24de836d 100644 --- a/cmd/entire/cli/dispatch_wizard_test.go +++ b/cmd/entire/cli/dispatch_wizard_test.go @@ -286,6 +286,93 @@ func TestBuildDispatchCommand_AllBranches(t *testing.T) { } } +func TestDispatchWizardState_ResolveScopeMe(t *testing.T) { + t.Parallel() + + state := newDispatchWizardState() + state.modeChoice = dispatchWizardModeServer + state.currentBranch = testDispatchPreviewBranch + state.selectedRepos = []string{"entireio/cli"} + state.scopeChoice = dispatchWizardScopeMe + + opts, err := state.resolve() + if err != nil { + t.Fatal(err) + } + if !opts.Me { + t.Fatal("expected scope=me to set opts.Me=true") + } + if opts.Author != "" { + t.Fatalf("did not expect Author to be set when scope=me, got %q", opts.Author) + } +} + +func TestDispatchWizardState_ResolveScopeAuthor(t *testing.T) { + t.Parallel() + + state := newDispatchWizardState() + state.modeChoice = dispatchWizardModeServer + state.currentBranch = testDispatchPreviewBranch + state.selectedRepos = []string{"entireio/cli"} + state.scopeChoice = dispatchWizardScopeAuthor + state.authorInput = " teammate@example.com " + + opts, err := state.resolve() + if err != nil { + t.Fatal(err) + } + if opts.Me { + t.Fatal("did not expect Me=true when scope=author") + } + if opts.Author != "teammate@example.com" { + t.Fatalf("expected trimmed author, got %q", opts.Author) + } +} + +func TestDispatchWizardState_ResolveScopeEveryoneIsDefault(t *testing.T) { + t.Parallel() + + state := newDispatchWizardState() + state.modeChoice = dispatchWizardModeServer + state.currentBranch = testDispatchPreviewBranch + state.selectedRepos = []string{"entireio/cli"} + + opts, err := state.resolve() + if err != nil { + t.Fatal(err) + } + if opts.Me { + t.Fatal("did not expect Me=true for default scope") + } + if opts.Author != "" { + t.Fatalf("did not expect Author set for default scope, got %q", opts.Author) + } +} + +func TestBuildDispatchCommand_RendersAuthorScopeFlags(t *testing.T) { + t.Parallel() + + command := buildDispatchCommand(dispatchpkg.Options{ + Mode: dispatchpkg.ModeServer, + Since: "7d", + RepoPaths: []string{"entireio/cli"}, + Me: true, + }) + if !strings.Contains(command, "--me") { + t.Fatalf("expected --me flag in rendered command, got %q", command) + } + + command = buildDispatchCommand(dispatchpkg.Options{ + Mode: dispatchpkg.ModeServer, + Since: "7d", + RepoPaths: []string{"entireio/cli"}, + Author: "teammate@example.com", + }) + if !strings.Contains(command, "--author teammate@example.com") { + t.Fatalf("expected --author flag in rendered command, got %q", command) + } +} + func TestBuildDispatchRepoOptions_DedupesAndPreservesOrder(t *testing.T) { t.Parallel()