Skip to content
Open
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
1 change: 1 addition & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <group-id>` lists a group's threads with `next_page` and `total_count`, accepts `--page <next_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 <id>` 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 <id>`, 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 <id>` returns stages in position order; `--ids-only` and `--count` apply to those stages. `hey workflow stage view <workflow-id> <stage-id>` 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 <id>`, 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 <entry-id> --content <text>` 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 <clip-id>` removes it. Clip content is plain text; the source entry ID comes from `hey thread read --json`.

Expand Down
80 changes: 80 additions & 0 deletions internal/cmd/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 <workflow-id> <stage-id>",
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 <workflow-id>.",
},
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 <thread-id>", Description: "Read an email thread"}))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
almirsarajcic marked this conversation as resolved.
}

type workflowStageCreateCommand struct {
cmd *cobra.Command
}
Expand Down
58 changes: 58 additions & 0 deletions internal/cmd/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<section id="container_workflow_stage_5512"><h2>Applied</h2><div id="topic_4471829" data-identifier="91"><h3>Application</h3><p class="card__detail">3 emails</p></div></section>`)
}), "workflow", "stage", "view", "8801", "5512")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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, `<section id="container_workflow_stage_5512"><h2>Applied</h2><div id="topic_4471829" data-identifier="91"><h3>Application</h3><p class="card__detail">3 emails</p></div></section>`)
})

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
Expand Down