diff --git a/.surface b/.surface index 3a7cedfd..b18eb082 100644 --- a/.surface +++ b/.surface @@ -36,6 +36,8 @@ hey box --limit hey boxes hey boxes --all hey boxes --limit +hey bubble-up-now +hey bubble-up-now --topic-id hey bulk-reply hey bulk-reply preview hey bulk-reply send @@ -118,6 +120,8 @@ hey labels --all hey labels --limit hey move hey move --to +hey pop +hey pop --topic-id hey recordings hey recordings --all hey recordings --ends-on diff --git a/API-COVERAGE.md b/API-COVERAGE.md index ee2b94d1..a9c3bc39 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -12,7 +12,9 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are | `/trailbox.json` | GET | SDK `Boxes().GetTrailbox` | `hey box trailbox` | covered | | `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box asidebox` | covered | | `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box laterbox` | covered | -| `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box bubblebox` | covered | +| `/bubble_up.json` | GET | SDK `Boxes().GetBubblebox` | `hey box bubblebox`, Bubble Up target verification | covered | +| `/postings/bulk_bubble_up_now.json` | POST | SDK `Postings().BubbleUpNow` | `hey bubble-up-now --topic-id ` | covered: exact-pair preflight + verification reads | +| `/postings/bubble_up.json` | DELETE | SDK `Postings().CancelBubbleUp` | `hey pop --topic-id ` | covered: exact-pair preflight + verification reads | | `/my/navigation.json` | GET | SDK `Identity().GetNavigation` | `hey labels`, Mail TUI navigation | covered | | `/folders/{id}.json` | GET | SDK `Folders().GetPage` | `hey label `, Mail TUI labels | covered | | `/postings/filings.json` | POST | SDK `Postings().File` | `hey label add`, TUI `g` | covered | diff --git a/README.md b/README.md index ea58a470..86da52fb 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,8 @@ hey boxes --quiet --jq '.[].id' ```bash hey boxes # list mailboxes hey box imbox # list email threads in a box (by name or ID) +hey bubble-up-now 123 --topic-id 456 # keep one exact posting at the top of the Imbox +hey pop 123 --topic-id 456 # remove that exact posting from Bubble Up hey labels # list labels and their IDs hey label 789 --all # list all email threads with a label hey label add 12345 --to 789 # add a label to a thread @@ -267,7 +269,9 @@ The Screener is where first-time senders wait. `hey screener list` returns clear `--attach` is repeatable on `hey compose`, `hey reply`, and `hey bulk-reply send`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachments ` returns stable message-and-position IDs such as `456:1`; pass an ID to `hey attachments save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set. -Organization actions take the `id` values returned by `hey box --json`, `hey label --json`, or `hey search --json`. Label IDs come from `hey labels`; `hey label` returns `next_page` and `total_count`, accepts `--page ` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`. +`hey bubble-up-now --topic-id ` and `hey pop --topic-id ` require both IDs from the same posting returned by `hey box imbox --all` (the topic ID is in `app_url`). Each command reads the complete Imbox and Bubble Up listings before acting, calls the generated posting operation through the HEY SDK, and verifies the exact result with fresh reads. Safe repeats return structured `changed: false`, `no_op: true` results. If a listing is transiently incomplete, the command fails without mutating and asks the caller to retry only after the exact pair is visible. + +Organization actions take the `id` values returned by `hey box --json`, `hey label --json`, or `hey search --json`. Label IDs come from `hey labels`; `hey label` returns `next_page` and `total_count`, accepts `--page ` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail; Bubble Up is handled separately by the exact-target commands above. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`. ### Watching for changes diff --git a/internal/cmd/bubble_up.go b/internal/cmd/bubble_up.go new file mode 100644 index 00000000..f2a6ea24 --- /dev/null +++ b/internal/cmd/bubble_up.go @@ -0,0 +1,384 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/output" +) + +const ( + bubbleVerificationAttempts = 8 + bubbleVerificationDelay = 250 * time.Millisecond +) + +type bubbleAction string + +const ( + bubbleNowAction bubbleAction = "bubble_up_now" + popAction bubbleAction = "pop" +) + +type bubbleActionCommand struct { + cmd *cobra.Command + action bubbleAction + topicID int64 +} + +// bubblePostingState is deliberately limited to the two boxes that establish +// whether an exact posting is actively or prospectively bubbled. It never +// guesses state from a missing first page. +type bubblePostingState struct { + PostingID int64 `json:"posting_id"` + TopicID int64 `json:"topic_id"` + Present bool `json:"present"` + InImbox bool `json:"in_imbox"` + + InBubbleUp bool `json:"in_bubble_up"` + BubbledUp bool `json:"bubbled_up"` + Scheduled bool `json:"scheduled"` +} + +type bubbleActionResult struct { + Action string `json:"action"` + PostingID int64 `json:"posting_id"` + TopicID int64 `json:"topic_id"` + Changed bool `json:"changed"` + NoOp bool `json:"no_op"` + Verified bool `json:"verified"` + Reason string `json:"reason,omitempty"` + Before bubblePostingState `json:"before"` + After bubblePostingState `json:"after"` +} + +func newBubbleUpNowCommand() *bubbleActionCommand { + return newBubbleActionCommand(bubbleNowAction) +} + +func newPopCommand() *bubbleActionCommand { + return newBubbleActionCommand(popAction) +} + +func newBubbleActionCommand(action bubbleAction) *bubbleActionCommand { + c := &bubbleActionCommand{action: action} + use := "bubble-up-now --topic-id " + short := "Bubble one exact posting to the top of the Imbox now" + long := "Immediately Bubble Up one exact posting. Both posting and topic IDs are required and verified before any mutation." + example := " hey bubble-up-now 1232578819 --topic-id 2101829422\n hey bubble-up-now 1232578819 --topic-id 2101829422 --json" + agentNotes := "Requires an exact posting/topic pair. Reads Imbox and Bubble Up fully, uses the SDK posting operation, and verifies the resulting state. Repeating an already-applied action is a no-op." + if action == popAction { + use = "pop --topic-id " + short = "Pop one exact posting out of Bubble Up" + long = "Remove one exact posting from Bubble Up. Both posting and topic IDs are required and verified before any mutation." + example = " hey pop 1232578819 --topic-id 2101829422\n hey pop 1232578819 --topic-id 2101829422 --json" + } + + c.cmd = &cobra.Command{ + Use: use, + Short: short, + Long: long, + Example: example, + Annotations: map[string]string{ + "agent_notes": agentNotes, + }, + Args: usageExactOneArg(), + RunE: c.run, + } + c.cmd.Flags().Int64Var(&c.topicID, "topic-id", 0, "Exact HEY topic ID paired with the posting (required)") + return c +} + +func (c *bubbleActionCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + postingID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || postingID <= 0 { + return output.ErrUsageHint("invalid posting ID: must be a positive integer", "Pass the posting ID shown by `hey box imbox --all`.") + } + if c.topicID <= 0 { + return output.ErrUsageHint("invalid or missing --topic-id: must be a positive integer", "Pair the posting with the topic ID parsed from its app_url.") + } + + ctx := cmd.Context() + before, err := readExactBubblePostingState(ctx, postingID, c.topicID) + if err != nil { + return err + } + if !before.Present { + return bubbleTargetNotFoundError(postingID, c.topicID) + } + + if reason := c.noOpReason(before); reason != "" { + result := bubbleActionResult{ + Action: string(c.action), + PostingID: postingID, + TopicID: c.topicID, + NoOp: true, + Verified: true, + Reason: reason, + Before: before, + After: before, + } + return c.writeResult(cmd, result) + } + + mutationErr := c.mutate(ctx, postingID) + if mutationErr != nil { + // Bubble Up Now is non-idempotent and the generated SDK does not retry it. + // A read can still resolve an ambiguous response without replaying it. + after, verified, _ := c.verify(ctx, postingID) + if verified { + result := bubbleActionResult{ + Action: string(c.action), + PostingID: postingID, + TopicID: c.topicID, + Changed: true, + Verified: true, + Reason: "state verified after an ambiguous mutation response", + Before: before, + After: after, + } + return c.writeResult(cmd, result) + } + return &output.Error{ + Code: "mutation_unconfirmed", + Message: fmt.Sprintf("%s was not confirmed: %v", c.displayName(), mutationErr), + Hint: "The CLI did not replay the operation. Re-run this command; it will return a no-op if HEY already applied it.", + Retryable: true, + Cause: mutationErr, + } + } + + after, verified, verifyErr := c.verify(ctx, postingID) + if !verified { + message := fmt.Sprintf("%s returned success, but the exact posting/topic state was not confirmed", c.displayName()) + if verifyErr != nil { + message += ": " + verifyErr.Error() + } + return &output.Error{ + Code: "verification_failed", + Message: message, + Hint: "The CLI did not replay the operation. Re-run this command; it will return a no-op if HEY already applied it.", + Retryable: true, + Cause: verifyErr, + } + } + + result := bubbleActionResult{ + Action: string(c.action), + PostingID: postingID, + TopicID: c.topicID, + Changed: true, + Verified: true, + Before: before, + After: after, + } + return c.writeResult(cmd, result) +} + +func (c *bubbleActionCommand) mutate(ctx context.Context, postingID int64) error { + if c.action == popAction { + return sdk.Postings().CancelBubbleUp(ctx, postingID) + } + return sdk.Postings().BubbleUpNow(ctx, postingID) +} + +func (c *bubbleActionCommand) verify(ctx context.Context, postingID int64) (bubblePostingState, bool, error) { + var last bubblePostingState + var lastErr error + for attempt := 0; attempt < bubbleVerificationAttempts; attempt++ { + if attempt > 0 { + timer := time.NewTimer(bubbleVerificationDelay) + select { + case <-ctx.Done(): + timer.Stop() + return last, false, ctx.Err() + case <-timer.C: + } + } + + state, err := readExactBubblePostingState(ctx, postingID, c.topicID) + if err != nil { + lastErr = err + continue + } + last = state + lastErr = nil + if c.action == bubbleNowAction && state.Present && state.InImbox && state.BubbledUp { + return state, true, nil + } + if c.action == popAction && state.Present && state.InImbox && !state.InBubbleUp && !state.BubbledUp && !state.Scheduled { + return state, true, nil + } + } + return last, false, lastErr +} + +func (c *bubbleActionCommand) noOpReason(state bubblePostingState) string { + if c.action == bubbleNowAction && state.InImbox && state.BubbledUp { + return "posting is already bubbled to the top of the Imbox" + } + if c.action == popAction && state.InImbox && !state.InBubbleUp && !state.BubbledUp && !state.Scheduled { + return "posting is already out of Bubble Up" + } + return "" +} + +func (c *bubbleActionCommand) displayName() string { + if c.action == popAction { + return "Pop" + } + return "Bubble Up Now" +} + +func (c *bubbleActionCommand) writeResult(cmd *cobra.Command, result bubbleActionResult) error { + summary := fmt.Sprintf("%s verified for posting %d / topic %d", c.displayName(), result.PostingID, result.TopicID) + if result.NoOp { + summary = fmt.Sprintf("No-op: %s", result.Reason) + } + if writer.IsStyled() { + fmt.Fprintln(cmd.OutOrStdout(), summary) + return nil + } + return writeOK(result, output.WithSummary(summary)) +} + +type bubbleBoxSource struct { + name string + fetch func(context.Context) (*generated.BoxShowResponse, error) +} + +func readExactBubblePostingState(ctx context.Context, postingID, topicID int64) (bubblePostingState, error) { + state := bubblePostingState{PostingID: postingID, TopicID: topicID} + var conflictPostingID int64 + var conflictTopicID int64 + incompletePosting := false + sources := []bubbleBoxSource{ + { + name: "Imbox", + fetch: func(ctx context.Context) (*generated.BoxShowResponse, error) { + resp, err := sdk.Boxes().GetImbox(ctx, nil) + if err != nil { + return nil, convertSDKError(err) + } + return resp, nil + }, + }, + { + name: "Bubble Up", + fetch: func(ctx context.Context) (*generated.BoxShowResponse, error) { + resp, err := sdk.Boxes().GetBubblebox(ctx, nil) + if err != nil { + return nil, convertSDKError(err) + } + return resp, nil + }, + }, + } + + for _, source := range sources { + resp, err := source.fetch(ctx) + if err != nil { + return state, err + } + postings, nextURL, err := paginateBoxPostings(ctx, resp, 0, true, fetchNextBoxPage) + if err != nil { + return state, err + } + if nextURL != "" { + return state, &output.Error{ + Code: "target_lookup_incomplete", + Message: fmt.Sprintf("%s pagination ended before every posting was read", source.name), + Hint: "No mutation was attempted. Retry the command after HEY can return the complete box history.", + Retryable: true, + } + } + for _, posting := range postings { + resolvedTopicID := exactPostingTopicID(posting) + if posting.Id == postingID && resolvedTopicID == 0 { + incompletePosting = true + continue + } + if posting.Id != postingID || resolvedTopicID != topicID { + if conflictPostingID == 0 && (posting.Id == postingID || resolvedTopicID == topicID) { + conflictPostingID = posting.Id + conflictTopicID = resolvedTopicID + } + continue + } + + state.Present = true + state.BubbledUp = state.BubbledUp || posting.BubbledUp + if source.name == "Imbox" { + state.InImbox = true + } else { + state.InBubbleUp = true + } + } + } + state.Scheduled = state.InBubbleUp && !state.BubbledUp + if state.Present { + return state, nil + } + if incompletePosting { + return state, bubbleTargetIncompleteError(postingID, topicID) + } + if conflictPostingID != 0 { + return state, bubbleTargetMismatchError(postingID, topicID, conflictPostingID, conflictTopicID) + } + return state, nil +} + +func exactPostingTopicID(posting generated.Posting) int64 { + marker := "/topics/" + index := strings.LastIndex(posting.AppUrl, marker) + if index < 0 { + return 0 + } + segment := posting.AppUrl[index+len(marker):] + if end := strings.IndexAny(segment, "/?#"); end >= 0 { + segment = segment[:end] + } + topicID, err := strconv.ParseInt(segment, 10, 64) + if err != nil || topicID <= 0 { + return 0 + } + return topicID +} + +func bubbleTargetMismatchError(wantPostingID, wantTopicID, foundPostingID, foundTopicID int64) error { + return &output.Error{ + Code: "target_mismatch", + Message: fmt.Sprintf("posting/topic pair does not match: requested %d/%d, HEY returned %d/%d", wantPostingID, wantTopicID, foundPostingID, foundTopicID), + Hint: "No mutation was attempted. Copy both IDs from the same posting returned by `hey box imbox --all`.", + HTTPStatus: 409, + } +} + +func bubbleTargetIncompleteError(postingID, topicID int64) error { + return &output.Error{ + Code: "target_incomplete", + Message: fmt.Sprintf("posting %d was present, but its topic ID was missing while looking for topic %d", postingID, topicID), + Hint: "No mutation was attempted. Retry only after a complete box read shows this posting with its /topics/ app_url.", + HTTPStatus: 409, + Retryable: true, + } +} + +func bubbleTargetNotFoundError(postingID, topicID int64) error { + return &output.Error{ + Code: "not_found", + Message: fmt.Sprintf("posting %d / topic %d was not found in Imbox or Bubble Up", postingID, topicID), + Hint: "HEY box reads can be transiently incomplete. Retry only after `hey box imbox --all` or `hey box bubblebox --all` shows this exact pair.", + HTTPStatus: 404, + Retryable: true, + } +} diff --git a/internal/cmd/bubble_up_test.go b/internal/cmd/bubble_up_test.go new file mode 100644 index 00000000..22f15f2e --- /dev/null +++ b/internal/cmd/bubble_up_test.go @@ -0,0 +1,485 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/basecamp/hey-cli/internal/output" +) + +const ( + bubbleTestPostingID int64 = 1232578819 + bubbleTestTopicID int64 = 2101829422 +) + +type bubbleTestState string + +const ( + bubbleStateUnbubbled bubbleTestState = "unbubbled" + bubbleStateBubbled bubbleTestState = "bubbled" + bubbleStateScheduled bubbleTestState = "scheduled" + bubbleStateMissing bubbleTestState = "missing" + bubbleStateMismatch bubbleTestState = "mismatch" + bubbleStateSharedTopic bubbleTestState = "shared_topic" + bubbleStateTopicConflict bubbleTestState = "topic_conflict" + bubbleStateIncompletePosting bubbleTestState = "incomplete_posting" + bubbleStatePaginated bubbleTestState = "paginated" + bubbleStateIncomplete bubbleTestState = "incomplete" +) + +type bubbleTestService struct { + t *testing.T + server *httptest.Server + mu sync.Mutex + state bubbleTestState + nowPOST int + popDELETE int + mutationStatus int + skipMutationTransition bool +} + +func newBubbleTestService(t *testing.T, initial bubbleTestState) *bubbleTestService { + t.Helper() + service := &bubbleTestService{t: t, state: initial} + service.server = httptest.NewServer(http.HandlerFunc(service.serveHTTP)) + return service +} + +func (s *bubbleTestService) close() { + s.server.Close() +} + +func (s *bubbleTestService) counts() (nowPOST, popDELETE int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.nowPOST, s.popDELETE +} + +func (s *bubbleTestService) serveHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + s.t.Helper() + s.t.Logf("%s %s", r.Method, r.URL.Path) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/imbox.json": + s.writeBox(w, "imbox") + case r.Method == http.MethodGet && r.URL.Path == "/bubble_up.json": + s.writeBox(w, "bubble_up") + case r.Method == http.MethodGet && r.URL.Path == "/imbox/history.json": + switch s.state { + case bubbleStatePaginated: + s.writeBoxPayload(w, "imbox", true, false, bubbleTestTopicID, "") + case bubbleStateIncomplete: + s.writeBoxPayload(w, "imbox", false, false, bubbleTestTopicID, s.server.URL+"/imbox/history-2.json") + default: + s.t.Errorf("unexpected history request in state %q", s.state) + w.WriteHeader(http.StatusNotFound) + } + case r.Method == http.MethodPost && r.URL.Path == "/postings/bulk_bubble_up_now.json": + s.nowPOST++ + s.assertBubbleUpNowRequest(r) + if !s.skipMutationTransition { + s.state = bubbleStateBubbled + } + s.writeMutationStatus(w) + case r.Method == http.MethodDelete && r.URL.Path == "/postings/bubble_up.json": + s.popDELETE++ + if got := r.URL.Query().Get("posting_ids"); got != strconvInt64(bubbleTestPostingID) { + s.t.Errorf("posting_ids = %q, want %d", got, bubbleTestPostingID) + } + if !s.skipMutationTransition { + s.state = bubbleStateUnbubbled + } + s.writeMutationStatus(w) + default: + s.t.Errorf("unexpected request: %s %s", r.Method, r.URL.String()) + w.WriteHeader(http.StatusNotFound) + } +} + +func (s *bubbleTestService) writeMutationStatus(w http.ResponseWriter) { + status := s.mutationStatus + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) +} + +func (s *bubbleTestService) assertBubbleUpNowRequest(r *http.Request) { + s.t.Helper() + var payload struct { + PostingIDs []int64 `json:"posting_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + s.t.Errorf("decode Bubble Up Now request: %v", err) + return + } + if len(payload.PostingIDs) != 1 || payload.PostingIDs[0] != bubbleTestPostingID { + s.t.Errorf("posting_ids = %v, want [%d]", payload.PostingIDs, bubbleTestPostingID) + } +} + +func (s *bubbleTestService) writeBox(w http.ResponseWriter, kind string) { + s.t.Helper() + include := false + bubbled := false + topicID := bubbleTestTopicID + nextURL := "" + + switch kind { + case "imbox": + include = s.state == bubbleStateUnbubbled || s.state == bubbleStateBubbled || s.state == bubbleStateMismatch || s.state == bubbleStateSharedTopic || s.state == bubbleStateIncompletePosting + bubbled = s.state == bubbleStateBubbled + if s.state == bubbleStateMismatch { + topicID++ + } + if s.state == bubbleStateIncompletePosting { + topicID = 0 + } + if s.state == bubbleStatePaginated || s.state == bubbleStateIncomplete { + nextURL = s.server.URL + "/imbox/history.json" + } + case "bubble_up": + include = s.state == bubbleStateScheduled + } + if s.state == bubbleStateMissing { + include = false + } + s.writeBoxPayload(w, kind, include, bubbled, topicID, nextURL) +} + +func (s *bubbleTestService) writeBoxPayload(w http.ResponseWriter, kind string, include, bubbled bool, topicID int64, nextURL string) { + s.t.Helper() + postings := []map[string]any{} + if kind == "imbox" && (s.state == bubbleStateSharedTopic || s.state == bubbleStateTopicConflict) { + postings = append(postings, map[string]any{ + "id": bubbleTestPostingID + 1, + "kind": "topic", + "app_url": fmt.Sprintf("%s/topics/%d", s.server.URL, bubbleTestTopicID), + "box_id": 1, + "bubbled_up": false, + }) + } + if include { + appURL := "" + if topicID > 0 { + appURL = fmt.Sprintf("%s/topics/%d", s.server.URL, topicID) + } + postings = append(postings, map[string]any{ + "id": bubbleTestPostingID, + "kind": "topic", + "app_url": appURL, + "box_id": 1, + "bubbled_up": bubbled, + }) + } + + w.Header().Set("Content-Type", "application/json") + payload := map[string]any{ + "id": 1, + "kind": kind, + "name": kind, + "postings": postings, + } + if nextURL != "" { + payload["next_history_url"] = nextURL + } + _ = json.NewEncoder(w).Encode(payload) +} + +func TestBubbleUpNowMutatesOnceAndVerifiesExactPair(t *testing.T) { + service := newBubbleTestService(t, bubbleStateUnbubbled) + defer service.close() + + response, err := runBubbleCommand(t, service.server.URL, "bubble-up-now") + if err != nil { + t.Fatalf("bubble-up-now: %v", err) + } + result := decodeBubbleResult(t, response) + if !result.Changed || result.NoOp || !result.Verified { + t.Errorf("result = %+v", result) + } + if result.Action != string(bubbleNowAction) || result.PostingID != bubbleTestPostingID || result.TopicID != bubbleTestTopicID { + t.Errorf("target result = %+v", result) + } + if !result.After.InImbox || !result.After.BubbledUp || result.After.Scheduled { + t.Errorf("after = %+v", result.After) + } + nowPOST, popDELETE := service.counts() + if nowPOST != 1 || popDELETE != 0 { + t.Errorf("counts now/pop = %d/%d", nowPOST, popDELETE) + } +} + +func TestPopMutatesOnceAndVerifiesExactPair(t *testing.T) { + service := newBubbleTestService(t, bubbleStateBubbled) + defer service.close() + + response, err := runBubbleCommand(t, service.server.URL, "pop") + if err != nil { + t.Fatalf("pop: %v", err) + } + result := decodeBubbleResult(t, response) + if !result.Changed || result.NoOp || !result.Verified { + t.Errorf("result = %+v", result) + } + if result.Action != string(popAction) || result.After.BubbledUp || result.After.Scheduled || !result.After.InImbox { + t.Errorf("result = %+v", result) + } + nowPOST, popDELETE := service.counts() + if nowPOST != 0 || popDELETE != 1 { + t.Errorf("counts now/pop = %d/%d", nowPOST, popDELETE) + } +} + +func TestBubbleActionsHandleScheduledPosting(t *testing.T) { + for _, command := range []string{"bubble-up-now", "pop"} { + t.Run(command, func(t *testing.T) { + service := newBubbleTestService(t, bubbleStateScheduled) + defer service.close() + + response, err := runBubbleCommand(t, service.server.URL, command) + if err != nil { + t.Fatalf("%s scheduled posting: %v", command, err) + } + result := decodeBubbleResult(t, response) + if !result.Before.Scheduled || !result.Changed || !result.Verified { + t.Errorf("result = %+v", result) + } + }) + } +} + +func TestBubbleActionsReturnVerifiedNoOp(t *testing.T) { + for _, tc := range []struct { + name string + command string + state bubbleTestState + }{ + {name: "already bubbled", command: "bubble-up-now", state: bubbleStateBubbled}, + {name: "already popped", command: "pop", state: bubbleStateUnbubbled}, + } { + t.Run(tc.name, func(t *testing.T) { + service := newBubbleTestService(t, tc.state) + defer service.close() + + response, err := runBubbleCommand(t, service.server.URL, tc.command) + if err != nil { + t.Fatalf("%s: %v", tc.command, err) + } + result := decodeBubbleResult(t, response) + if result.Changed || !result.NoOp || !result.Verified || result.Reason == "" { + t.Errorf("result = %+v", result) + } + nowPOST, popDELETE := service.counts() + if nowPOST != 0 || popDELETE != 0 { + t.Errorf("no-op requests now/pop = %d/%d", nowPOST, popDELETE) + } + }) + } +} + +func TestBubbleUpNowVerifiesAppliedStateAfterAmbiguousResponse(t *testing.T) { + service := newBubbleTestService(t, bubbleStateUnbubbled) + service.mutationStatus = http.StatusInternalServerError + defer service.close() + + response, err := runBubbleCommand(t, service.server.URL, "bubble-up-now") + if err != nil { + t.Fatalf("bubble-up-now after ambiguous response: %v", err) + } + result := decodeBubbleResult(t, response) + if !result.Changed || !result.Verified || result.Reason == "" || !result.After.BubbledUp { + t.Errorf("result = %+v", result) + } + nowPOST, _ := service.counts() + if nowPOST != 1 { + t.Errorf("Bubble Up Now POSTs = %d, want 1", nowPOST) + } +} + +func TestBubbleUpNowDoesNotReplayUnconfirmedMutation(t *testing.T) { + service := newBubbleTestService(t, bubbleStateUnbubbled) + service.mutationStatus = http.StatusInternalServerError + service.skipMutationTransition = true + defer service.close() + + _, err := runBubbleCommand(t, service.server.URL, "bubble-up-now") + if err == nil { + t.Fatal("expected unconfirmed mutation to fail") + } + if got := output.AsError(err).Code; got != "mutation_unconfirmed" { + t.Errorf("code = %q, want mutation_unconfirmed (error: %v)", got, err) + } + nowPOST, _ := service.counts() + if nowPOST != 1 { + t.Errorf("Bubble Up Now POSTs = %d, want 1", nowPOST) + } +} + +func TestBubbleActionsFailClosedOnTargetMismatchOrAbsence(t *testing.T) { + for _, tc := range []struct { + name string + state bubbleTestState + wantCode string + }{ + {name: "mismatch", state: bubbleStateMismatch, wantCode: "target_mismatch"}, + {name: "topic conflict", state: bubbleStateTopicConflict, wantCode: "target_mismatch"}, + {name: "incomplete posting", state: bubbleStateIncompletePosting, wantCode: "target_incomplete"}, + {name: "missing", state: bubbleStateMissing, wantCode: "not_found"}, + } { + t.Run(tc.name, func(t *testing.T) { + service := newBubbleTestService(t, tc.state) + defer service.close() + + _, err := runBubbleCommand(t, service.server.URL, "bubble-up-now") + if err == nil { + t.Fatal("expected command to fail") + } + if got := output.AsError(err).Code; got != tc.wantCode { + t.Errorf("code = %q, want %q (error: %v)", got, tc.wantCode, err) + } + nowPOST, popDELETE := service.counts() + if nowPOST != 0 || popDELETE != 0 { + t.Errorf("failed target requests now/pop = %d/%d", nowPOST, popDELETE) + } + }) + } +} + +func TestBubbleTargetLookupPrefersExactPairWhenAnotherPostingSharesTopic(t *testing.T) { + service := newBubbleTestService(t, bubbleStateSharedTopic) + defer service.close() + + response, err := runBubbleCommand(t, service.server.URL, "bubble-up-now") + if err != nil { + t.Fatalf("bubble-up-now with shared topic: %v", err) + } + result := decodeBubbleResult(t, response) + if !result.Before.Present || !result.Changed || !result.Verified { + t.Errorf("result = %+v", result) + } + nowPOST, popDELETE := service.counts() + if nowPOST != 1 || popDELETE != 0 { + t.Errorf("shared-topic requests now/pop = %d/%d", nowPOST, popDELETE) + } +} + +func TestBubbleTargetLookupFollowsEveryPageBeforeMutating(t *testing.T) { + service := newBubbleTestService(t, bubbleStatePaginated) + defer service.close() + + response, err := runBubbleCommand(t, service.server.URL, "bubble-up-now") + if err != nil { + t.Fatalf("bubble-up-now with paginated target: %v", err) + } + result := decodeBubbleResult(t, response) + if !result.Before.Present || !result.Changed || !result.Verified { + t.Errorf("result = %+v", result) + } + nowPOST, _ := service.counts() + if nowPOST != 1 { + t.Errorf("Bubble Up Now POSTs = %d, want 1", nowPOST) + } +} + +func TestBubbleTargetLookupRejectsIncompletePagination(t *testing.T) { + service := newBubbleTestService(t, bubbleStateIncomplete) + defer service.close() + + _, err := runBubbleCommand(t, service.server.URL, "bubble-up-now") + if err == nil { + t.Fatal("expected incomplete pagination to fail") + } + if got := output.AsError(err).Code; got != "target_lookup_incomplete" { + t.Errorf("code = %q, want target_lookup_incomplete (error: %v)", got, err) + } + nowPOST, popDELETE := service.counts() + if nowPOST != 0 || popDELETE != 0 { + t.Errorf("incomplete lookup requests now/pop = %d/%d", nowPOST, popDELETE) + } +} + +func TestBubbleActionsRequirePositiveExactIDs(t *testing.T) { + service := newBubbleTestService(t, bubbleStateUnbubbled) + defer service.close() + + for _, tc := range []struct { + name string + args []string + }{ + {name: "missing topic", args: []string{"bubble-up-now", fmt.Sprint(bubbleTestPostingID)}}, + {name: "zero posting", args: []string{"bubble-up-now", "0", "--topic-id", fmt.Sprint(bubbleTestTopicID)}}, + {name: "nonnumeric posting", args: []string{"bubble-up-now", "nope", "--topic-id", fmt.Sprint(bubbleTestTopicID)}}, + {name: "zero topic", args: []string{"pop", fmt.Sprint(bubbleTestPostingID), "--topic-id", "0"}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := runBubbleArgs(t, service.server.URL, tc.args...) + if err == nil { + t.Fatal("expected invalid IDs to fail") + } + if got := output.AsError(err).Code; got != "usage" { + t.Errorf("code = %q, want usage", got) + } + }) + } + nowPOST, popDELETE := service.counts() + if nowPOST != 0 || popDELETE != 0 { + t.Errorf("invalid target requests now/pop = %d/%d", nowPOST, popDELETE) + } +} + +func runBubbleCommand(t *testing.T, serverURL, command string) (output.Response, error) { + t.Helper() + return runBubbleArgs(t, serverURL, command, strconvInt64(bubbleTestPostingID), "--topic-id", strconvInt64(bubbleTestTopicID)) +} + +func runBubbleArgs(t *testing.T, serverURL string, args ...string) (output.Response, error) { + t.Helper() + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + commandArgs := make([]string, 0, 3+len(args)) + commandArgs = append(commandArgs, "--json", "--base-url", serverURL) + commandArgs = append(commandArgs, args...) + root.SetArgs(commandArgs) + + err := root.Execute() + var response output.Response + if buf.Len() > 0 { + _ = json.Unmarshal(buf.Bytes(), &response) + } + return response, err +} + +func decodeBubbleResult(t *testing.T, response output.Response) bubbleActionResult { + t.Helper() + data, err := json.Marshal(response.Data) + if err != nil { + t.Fatalf("marshal response data: %v", err) + } + var result bubbleActionResult + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("unmarshal response data: %v", err) + } + return result +} + +func strconvInt64(value int64) string { + return fmt.Sprintf("%d", value) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 38e0cccc..7aadddfb 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -169,6 +169,8 @@ func newRootCmd() *cobra.Command { root.AddCommand(newWatchCommand().cmd) root.AddCommand(newSeenCommand().cmd) root.AddCommand(newUnseenCommand().cmd) + root.AddCommand(newBubbleUpNowCommand().cmd) + root.AddCommand(newPopCommand().cmd) root.AddCommand(newMoveCommand().cmd) root.AddCommand(newTrashCommand().cmd) root.AddCommand(newSpamCommand().cmd) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index cbf15506..d952e26f 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -21,6 +21,8 @@ triggers: - hey forward - hey compose - hey drafts + - hey bubble-up-now + - hey pop # Calendar actions - hey calendars - hey recordings @@ -105,6 +107,7 @@ CLI for HEY: mailboxes, labels, email threads, contacts, replies, compose, calen 3. **HTML output** is available via `--html` for commands that return HTML content 4. **Linked mail accounts share one login** — use `hey accounts list --json`, then `--account ` when a task must target one account 5. **Local HEY configuration requires human trust** — never run `hey config trust-local` without the user's explicit approval +6. **Require the exact posting/topic pair for Bubble Up mutations** — read `hey box imbox --all --json`, pair `id` with the topic ID in that posting's `app_url`, and never infer one from another result ## Output Filtering @@ -151,6 +154,8 @@ hey boxes --quiet --jq '.[].name' | Compose email | `hey compose --to user@example.com --subject "Hello"` | | Compose with CC/BCC | `hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello"` | | List drafts | `hey drafts --json` | +| Bubble Up now | `hey bubble-up-now --topic-id --json` | +| Pop from Bubble Up | `hey pop --topic-id --json` | | List calendars | `hey calendars --json` | | List calendar events | `hey recordings 123 --json` | | List todos | `hey todo list --json` | @@ -196,6 +201,8 @@ Want to read email? ├── Read full thread? → hey threads --json ├── Mark as seen? → hey seen ├── Mark as unseen? → hey unseen +├── Keep exact email at top? → hey bubble-up-now --topic-id --json +├── Remove exact email from Bubble Up? → hey pop --topic-id --json ├── Move to another box? → hey move --to ├── Move to Trash? → hey trash ├── Mark as spam? → hey spam @@ -400,6 +407,22 @@ two can overlap; `--run-sync` waits for each and runs them in order. Both get th stdin and the fields as `HEY_CHANGE`, `HEY_AT`, `HEY_BOX_ID`, `HEY_BOX_KIND`, `HEY_BOX_NAME`, `HEY_POSTING_ID` and `HEY_THREAD_ID`, and both take over stdout. +### Email - Bubble Up + +```bash +hey box imbox --all --json +hey bubble-up-now --topic-id --json +hey pop --topic-id --json +``` + +Use the posting ID and topic ID from the same posting (`app_url` contains +`/topics/`). These commands fully read Imbox and Bubble Up before +the write, invoke the generated posting operation through the HEY SDK, and +verify with fresh reads. A successful JSON result reports `changed`, `no_op`, +`verified`, `before`, and `after`. If HEY temporarily omits the exact +posting from a valid box response, the command returns a retryable not-found +error and does not mutate; wait until the exact pair is visible before retrying. + ### Drafts ```bash diff --git a/tests/smoke/bubble_up_test.go b/tests/smoke/bubble_up_test.go new file mode 100644 index 00000000..b1e44405 --- /dev/null +++ b/tests/smoke/bubble_up_test.go @@ -0,0 +1,89 @@ +package smoke_test + +import ( + "fmt" + "strconv" + "testing" +) + +func TestBubbleUpNowAndPopExactPosting(t *testing.T) { + type posting struct { + ID int64 `json:"id"` + AppURL string `json:"app_url"` + BubbledUp bool `json:"bubbled_up"` + } + type boxResponse struct { + Postings []posting `json:"postings"` + } + type actionResult struct { + PostingID int64 `json:"posting_id"` + TopicID int64 `json:"topic_id"` + Changed bool `json:"changed"` + NoOp bool `json:"no_op"` + Verified bool `json:"verified"` + After struct { + InImbox bool `json:"in_imbox"` + BubbledUp bool `json:"bubbled_up"` + } `json:"after"` + } + + box := dataAs[boxResponse](t, heyJSON(t, "box", "imbox", "--all")) + var target posting + var topicID string + for _, candidate := range box.Postings { + candidateTopicID := extractTopicID(candidate.AppURL) + if candidate.ID > 0 && candidateTopicID != "" && !candidate.BubbledUp { + target = candidate + topicID = candidateTopicID + break + } + } + if target.ID == 0 { + t.Skip("no unbubbled Imbox posting available for Bubble Up smoke test") + } + if _, err := strconv.ParseInt(topicID, 10, 64); err != nil { + t.Fatalf("invalid topic ID %q from %q", topicID, target.AppURL) + } + + postingID := fmt.Sprintf("%d", target.ID) + cleanupNeeded := true + t.Cleanup(func() { + if !cleanupNeeded { + return + } + _, stderr, code := hey(t, "pop", postingID, "--topic-id", topicID, "--json") + if code != 0 { + t.Logf("Bubble Up smoke cleanup failed (exit %d): %s", code, stderr) + } + }) + + now := dataAs[actionResult](t, heyJSON(t, "bubble-up-now", postingID, "--topic-id", topicID)) + if now.PostingID != target.ID || fmt.Sprintf("%d", now.TopicID) != topicID || !now.Verified || !now.After.InImbox || !now.After.BubbledUp { + t.Fatalf("unexpected Bubble Up Now result: %+v", now) + } + if !now.Changed && !now.NoOp { + t.Fatalf("Bubble Up Now was neither changed nor no-op: %+v", now) + } + + // Independently verify through the public box read before cleaning up. + box = dataAs[boxResponse](t, heyJSON(t, "box", "imbox", "--all")) + foundBubbled := false + for _, candidate := range box.Postings { + if candidate.ID == target.ID && extractTopicID(candidate.AppURL) == topicID && candidate.BubbledUp { + foundBubbled = true + break + } + } + if !foundBubbled { + t.Fatalf("exact posting %d / topic %s was not independently observed as bubbled", target.ID, topicID) + } + + popped := dataAs[actionResult](t, heyJSON(t, "pop", postingID, "--topic-id", topicID)) + if popped.PostingID != target.ID || fmt.Sprintf("%d", popped.TopicID) != topicID || !popped.Verified || !popped.After.InImbox || popped.After.BubbledUp { + t.Fatalf("unexpected Pop result: %+v", popped) + } + if !popped.Changed && !popped.NoOp { + t.Fatalf("Pop was neither changed nor no-op: %+v", popped) + } + cleanupNeeded = false +}