diff --git a/.surface b/.surface index 7d35c367..be998ef9 100644 --- a/.surface +++ b/.surface @@ -434,6 +434,7 @@ hey workflow stage create hey workflow stage delete hey workflow stage update hey workflow stage update --name +hey workflow stage view hey workflow update hey workflow update --name hey workflow view diff --git a/docs/cli.md b/docs/cli.md index 830e78bf..b99efed7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -177,6 +177,7 @@ hey set-aside group remove 12345 # take a thread out of its group hey set-aside group delete 42 # break a group up (threads go to Previously Seen) hey workflow list # list workflows, account IDs, and workflow IDs hey workflow view 654 # list a workflow's stages and stage IDs +hey workflow stage view 654 321 # list threads currently in a stage hey workflow create "Hiring" --account 12345 hey workflow update 654 --name "Recruiting" hey workflow stage create 654 # add an Untitled stage @@ -281,7 +282,7 @@ Collection IDs come from `hey collection list`. `hey collection view` returns bo Set Aside groups have no name in HEY; a group is its ID and its threads. `hey set-aside view` lists the same threads as `hey box view set-aside` and adds each thread's `box_group_id` (a `Group` column in the styled table). HEY's group index answers with IDs alone, so `hey set-aside group list` reads each group once for its thread count. `hey set-aside group view ` lists a group's threads with `next_page` and `total_count`, accepts `--page ` and `--all` like the other listings, and answers `not_found` for a group that is gone; HEY removes a group itself once its last thread leaves it. `group create`, `group add` and `group remove` take posting `id` values; `group view`, `group add --to` and `group delete` take a group ID from `group list`. `hey set-aside group create` and `hey set-aside group add` move threads into Set Aside if they are elsewhere, and then complete the move the way `hey move --to set-aside` does: the thread is marked seen, which also clears a bubble-up, since HEY keeps "bubbled up" as a seen state rather than a flag and a thread cannot be set aside and bubbled up at once. A group HEY refuses fails the command before any thread is changed. `hey set-aside group remove` leaves threads in Set Aside outside any group, while `hey set-aside group delete` sends the group's threads to Previously Seen, which is what HEY does when a group is dissolved in the web app. -Workflow IDs come from `hey workflow list`, which includes the linked account ID for each workflow. `hey workflow view ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow view `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. +Workflow IDs come from `hey workflow list`, which includes the linked account ID for each workflow. `hey workflow view ` returns stages in position order; `--ids-only` and `--count` apply to those stages. `hey workflow stage view ` returns every thread currently in that stage, including its `topic_id` for `hey thread read`; `--ids-only` returns those topic IDs and `--count` returns the number of threads. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow view `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. Clips are passages saved from existing email entries. `hey clip list` lists the selected account's newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; passages are capped at 64 KiB and source-message validation at 1 MiB. HEY's web UI remains authoritative for stylesheet-driven visibility. HEY assigns a created clip to its source entry's account and resolves deletion by identity-owned clip ID across linked accounts; `--account` selects list presentation. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey thread read --json`. diff --git a/internal/cmd/workflow.go b/internal/cmd/workflow.go index 01a5dcac..6a0d6944 100644 --- a/internal/cmd/workflow.go +++ b/internal/cmd/workflow.go @@ -27,6 +27,18 @@ type workflowStageView struct { Name string `json:"name"` } +type workflowStageTopicsView struct { + ID int64 `json:"id"` + Name string `json:"name"` + Topics []workflowStageTopicView `json:"topics"` +} + +type workflowStageTopicView struct { + TopicID int64 `json:"topic_id"` + Subject string `json:"subject"` + EntryCount int `json:"entry_count"` +} + type workflowDetailView struct { ID int64 `json:"id"` Name string `json:"name"` @@ -434,11 +446,79 @@ func newWorkflowStageCommand() *workflowStageCommand { }, } workflowStageCommand.cmd.AddCommand(newWorkflowStageCreateCommand().cmd) + workflowStageCommand.cmd.AddCommand(newWorkflowStageViewCommand().cmd) workflowStageCommand.cmd.AddCommand(newWorkflowStageUpdateCommand().cmd) workflowStageCommand.cmd.AddCommand(newWorkflowStageDeleteCommand().cmd) return workflowStageCommand } +type workflowStageViewCommand struct{ cmd *cobra.Command } + +func newWorkflowStageViewCommand() *workflowStageViewCommand { + c := &workflowStageViewCommand{} + c.cmd = &cobra.Command{ + Use: "view ", + Short: "List email threads in a workflow stage", + Example: " hey workflow stage view 123 456 --json", + Annotations: map[string]string{ + "agent_notes": "Returns every thread currently in this workflow stage. Get workflow and stage IDs from hey workflow view .", + }, + Args: usageExactArgs(2), + RunE: c.run, + } + return c +} + +func (c *workflowStageViewCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + workflowID, err := parsePositiveID(args[0], "workflow") + if err != nil { + return err + } + stageID, err := parsePositiveID(args[1], "stage") + if err != nil { + return err + } + stage, err := sdk.Workflows().GetStage(cmd.Context(), workflowID, stageID) + if err != nil { + return apierr.FromSDK(err) + } + view := workflowStageTopicsView{ID: stage.ID, Name: stage.Name, Topics: make([]workflowStageTopicView, 0, len(stage.Topics))} + for _, topic := range stage.Topics { + view.Topics = append(view.Topics, workflowStageTopicView{ + TopicID: topic.TopicID, + Subject: topic.Subject, + EntryCount: topic.EntryCount, + }) + } + if writer.IsStyled() { + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"Thread", "Subject", "Emails"}) + for _, topic := range view.Topics { + table.addRow([]string{fmt.Sprintf("%d", topic.TopicID), terminal.SanitizeLine(topic.Subject), fmt.Sprintf("%d", topic.EntryCount)}) + } + table.print() + return nil + } + format := writer.EffectiveFormat() + if format == output.FormatMarkdown { + return writeOK(view.Topics) + } + if format == output.FormatIDs { + for _, topic := range view.Topics { + fmt.Fprintln(cmd.OutOrStdout(), topic.TopicID) + } + return nil + } + if format == output.FormatCount { + fmt.Fprintln(cmd.OutOrStdout(), len(view.Topics)) + return nil + } + return writeOK(view, output.WithSummary(fmt.Sprintf("%d %s in workflow stage %s", len(view.Topics), threadNoun(len(view.Topics)), terminal.SanitizeLine(view.Name))), output.WithBreadcrumbs(output.Breadcrumb{Action: "read", Command: "hey thread read ", Description: "Read an email thread"})) +} + type workflowStageCreateCommand struct { cmd *cobra.Command } diff --git a/internal/cmd/workflow_test.go b/internal/cmd/workflow_test.go index 8d46cf36..6fabd8e0 100644 --- a/internal/cmd/workflow_test.go +++ b/internal/cmd/workflow_test.go @@ -158,6 +158,64 @@ func TestWorkflowCommandOutputFormats(t *testing.T) { } } +func TestWorkflowStageViewListsThreads(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/workflows/8801/stages/5512" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.Header.Get("Accept"); got != "text/html" { + t.Errorf("Accept = %q, want text/html", got) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, `

Applied

Application

3 emails

`) + }), "workflow", "stage", "view", "8801", "5512") + if err != nil { + t.Fatalf("execute workflow stage view: %v", err) + } + if response.Summary != "1 thread in workflow stage Applied" { + t.Errorf("summary = %q", response.Summary) + } + stage, ok := response.Data.(map[string]any) + if !ok || stage["id"] != float64(5512) || stage["name"] != "Applied" { + t.Fatalf("stage = %#v", response.Data) + } + topics, ok := stage["topics"].([]any) + if !ok || len(topics) != 1 { + t.Fatalf("topics = %#v", stage["topics"]) + } + topic := topics[0].(map[string]any) + if topic["topic_id"] != float64(4471829) || topic["entry_count"] != float64(3) || topic["subject"] != "Application" { + t.Errorf("topic = %#v", topic) + } +} + +func TestWorkflowStageViewOutputFormats(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, `

Applied

Application

3 emails

`) + }) + + ids, err := runFormattedCommand(t, handler, []string{"--ids-only"}, "workflow", "stage", "view", "8801", "5512") + if err != nil || ids != "4471829\n" { + t.Errorf("ids = %q, err = %v", ids, err) + } + count, err := runFormattedCommand(t, handler, []string{"--count"}, "workflow", "stage", "view", "8801", "5512") + if err != nil || count != "1\n" { + t.Errorf("count = %q, err = %v", count, err) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "workflow", "stage", "view", "8801", "5512") + wantMarkdown := "| entry_count | subject | topic_id |\n| --- | --- | --- |\n| 3 | Application | 4471829 |\n" + if err != nil || markdown != wantMarkdown { + t.Errorf("markdown = %q, err = %v", markdown, err) + } + styled, err := runStyledCommand(t, handler, "workflow", "stage", "view", "8801", "5512") + if err != nil || !strings.Contains(styled, "Thread") || !strings.Contains(styled, "Application") || !strings.Contains(styled, "3") { + t.Errorf("styled = %q, err = %v", styled, err) + } +} + func TestWorkflowMarkdownEscapesMetadata(t *testing.T) { cmd := newWorkflowCommand().cmd var output strings.Builder