diff --git a/.surface b/.surface index 086bc0ce..d173c958 100644 --- a/.surface +++ b/.surface @@ -43,6 +43,8 @@ hey box view hey box view --all hey box view --limit hey box view --page +hey bubble-up-now +hey bubble-up-now --topic-id hey bulk-reply hey bulk-reply preview hey bulk-reply send @@ -231,6 +233,8 @@ hey login --token hey logout hey move hey move --to +hey pop +hey pop --topic-id hey reply hey reply --attach hey reply --message diff --git a/API-COVERAGE.md b/API-COVERAGE.md index c9580a02..31716405 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -27,7 +27,9 @@ since `Topics().Get` already carries the entry list. | `/trailbox.json` | GET | SDK `Boxes().GetTrailbox` | `hey box view trailbox` | covered | | `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box view asidebox` | covered | | `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box view laterbox` | covered | -| `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box view bubblebox` | covered | +| `/bubble_up.json` | GET | SDK `Boxes().GetBubblebox` | `hey box view bubblebox`, `hey bubble-up-now`, `hey pop` verification | covered | +| `/postings/bulk_bubble_up_now.json` | POST | SDK `Postings().BubbleUpNow` | `hey bubble-up-now` | covered | +| `/postings/bubble_up.json` | DELETE | SDK `Postings().CancelBubbleUp` | `hey pop` | covered | | `/my/navigation.json` | GET | SDK `Identity().GetNavigation` | `hey label list`, Mail TUI navigation | covered | | `/folders/{id}.json` | GET | SDK `Folders().GetPage` | `hey label view `, Mail TUI labels | covered | | `/postings/filings.json` | POST | SDK `Postings().File` | `hey label add`, TUI `b/B` | covered | diff --git a/README.md b/README.md index 8504be0e..93dd5662 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,8 @@ hey compose --to alice@example.com --subject "Newsletter draft" --message-html " hey draft list # list drafts hey seen 12345 # mark a thread as seen hey unseen 12345 67890 # mark threads as unseen +hey bubble-up-now 12345 --topic-id 987 # Bubble one exact thread to the top now +hey pop 12345 --topic-id 987 # remove that exact thread from Bubble Up hey move 12345 --to feed # move a thread to another box hey move 12345 67890 --to "paper trail" # move multiple threads hey trash 12345 # move a thread to Trash @@ -439,6 +441,8 @@ The Screener is where first-time senders wait. `hey screener list` returns clear Organization actions take the `id` values returned by `hey box view --json`, `hey label view --json`, or `hey search --json`. Reading, replying to, and forwarding a thread take its `topic_id` instead, which `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry alongside `id`. `hey box view` also returns `next_page` and accepts `--page ` to continue a box listing; it keeps `next_history_url` for the sync clients that read it, and `--page` accepts that URL as readily as the cursor inside it. Label IDs come from `hey label list`; `hey label view` 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. +`hey bubble-up-now` and `hey pop` are intentionally stricter: each takes one box item `id` plus the `topic_id` from the same row. They read every Imbox and Bubble Up page before changing anything and verify the exact pair afterward. A missing, mismatched, incomplete, or truncated target fails closed; repeating an already-applied action returns a verified no-op. Bubble Up Now is not replayed after an ambiguous response — rerun the high-level command so its fresh preflight can safely resolve the state. + Collection IDs come from `hey collection list`. `hey collection view` returns both each posting `id` and its `topic_id`, plus `next_page` and `total_count`. Collection membership commands take `topic_id`; posting organization commands continue to take `id`. Creating a collection returns a confirmed mutation, and `hey collection list` provides its ID for subsequent commands. Collection updates accept a non-empty name, summary, or both. 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. @@ -449,7 +453,7 @@ Snippets are named reusable email content, separate from clips saved out of rece `hey box view `, `hey label view ` and `hey collection view ` list the same postings and answer the same formats: `--json`, `--styled`, `--markdown`, `--ids-only`, and `--count`. The data-only formats print the pagination notice and any `next_page` cursor on stderr, so the IDs on stdout stay pipeable. `--json` differs only in what wraps the postings: a box answers with HEY's box payload, a label and a collection with the source and its `total_count`. -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`. +Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Scheduled Bubble Up is not available through `hey move`; `hey bubble-up-now` raises one exact thread immediately and `hey pop` removes it from Bubble Up. 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..dc0c8e4d --- /dev/null +++ b/internal/cmd/bubble_up.go @@ -0,0 +1,357 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/mail" +) + +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 or partial 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 thread to the top of the Imbox now" + long := "Immediately Bubble Up one exact HEY box item. Its box item ID and thread ID are both required and verified before any change." + example := " hey bubble-up-now 731245 --topic-id 912876\n hey bubble-up-now 731245 --topic-id 912876 --json" + agentNotes := "Requires the exact box item ID and thread ID from one row returned by `hey box view imbox --all --json`. Reads Imbox and Bubble Up completely, uses the SDK operation, and verifies the result. Repeating an already-applied action is a no-op." + if action == popAction { + use = "pop --topic-id " + short = "Remove one exact thread from Bubble Up" + long = "Remove one exact HEY box item from Bubble Up. Its box item ID and thread ID are both required and verified before any change." + example = " hey pop 731245 --topic-id 912876\n hey pop 731245 --topic-id 912876 --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 thread ID paired with the box item (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 apierr.ErrUsageHint("invalid box item ID: must be a positive integer", "Pass the ID from one row returned by `hey box view imbox --all --json`.") + } + if c.topicID <= 0 { + return apierr.ErrUsageHint("invalid or missing --topic-id: must be a positive integer", "Pass the topic_id from the same box item row.") + } + + 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 != "" { + return c.writeResult(cmd, bubbleActionResult{ + Action: string(c.action), + PostingID: postingID, + TopicID: c.topicID, + NoOp: true, + Verified: true, + Reason: reason, + Before: before, + After: before, + }) + } + + 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 { + return c.writeResult(cmd, 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, + }) + } + converted := apierr.AsError(apierr.FromSDK(mutationErr)) + return &apierr.Error{ + Code: "mutation_unconfirmed", + Message: fmt.Sprintf("%s was not confirmed: %s", c.displayName(), converted.Message), + Hint: "The CLI did not replay the operation. Re-run this command; it will return a no-op if HEY already applied it.", + Cause: mutationErr, + Meta: map[string]any{"retryable": true}, + } + } + + after, verified, verifyErr := c.verify(ctx, postingID) + if !verified { + message := fmt.Sprintf("%s returned success, but the exact box item/thread state was not confirmed", c.displayName()) + if verifyErr != nil { + message += ": " + verifyErr.Error() + } + return &apierr.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.", + Cause: verifyErr, + Meta: map[string]any{"retryable": true}, + } + } + + return c.writeResult(cmd, bubbleActionResult{ + Action: string(c.action), + PostingID: postingID, + TopicID: c.topicID, + Changed: true, + Verified: true, + Before: before, + After: after, + }) +} + +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 "box item is already bubbled to the top of the Imbox" + } + if c.action == popAction && state.InImbox && !state.InBubbleUp && !state.BubbledUp && !state.Scheduled { + return "box item 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 box item %d / thread %d", c.displayName(), result.PostingID, result.TopicID) + if result.NoOp { + summary = fmt.Sprintf("No-op: %s", result.Reason) + } + return writeMutation(cmd, summary, result) +} + +type bubbleBoxSource struct { + name string + key string +} + +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", key: "imbox"}, + {name: "Bubble Up", key: "bubblebox"}, + } + + for _, source := range sources { + resp, err := resolveBox(ctx, source.key, "") + if err != nil { + return state, err + } + if resp == nil { + return state, bubbleLookupIncompleteError(source.name, "HEY returned no box page") + } + + mailSource := mail.BoxSource(resp) + collected, err := collectPages(ctx, + pageResult[generated.Posting]{Items: resp.Postings, Cursor: resp.NextHistoryUrl}, + pageRequest{All: true, MaxPages: maxPostingPages}, + readSourcePage(mailSource), + ) + if err != nil { + return state, err + } + if collected.Truncated || collected.Cursor != "" { + return state, bubbleLookupIncompleteError(source.name, "pagination ended before every box item was read") + } + + for _, posting := range collected.Items { + resolvedTopicID := resolvePostingTopicID(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.key == "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 bubbleLookupIncompleteError(source, detail string) error { + return &apierr.Error{ + Code: "target_lookup_incomplete", + Message: fmt.Sprintf("%s lookup was incomplete: %s", source, detail), + Hint: "No change was attempted. Retry after HEY can return the complete box history.", + Meta: map[string]any{"retryable": true}, + } +} + +func bubbleTargetMismatchError(wantPostingID, wantTopicID, foundPostingID, foundTopicID int64) error { + return &apierr.Error{ + Code: "target_mismatch", + Message: fmt.Sprintf("box item/thread pair does not match: requested %d/%d, HEY returned %d/%d", wantPostingID, wantTopicID, foundPostingID, foundTopicID), + Hint: "No change was attempted. Copy both IDs from the same row returned by `hey box view imbox --all --json`.", + HTTPStatus: 409, + } +} + +func bubbleTargetIncompleteError(postingID, topicID int64) error { + return &apierr.Error{ + Code: "target_incomplete", + Message: fmt.Sprintf("box item %d was present, but its thread ID was missing while looking for topic %d", postingID, topicID), + Hint: "No change was attempted. Retry only after a complete box read shows this row with its topic_id.", + HTTPStatus: 409, + Meta: map[string]any{"retryable": true}, + } +} + +func bubbleTargetNotFoundError(postingID, topicID int64) error { + return &apierr.Error{ + Code: apierr.CodeNotFound, + Message: fmt.Sprintf("box item %d / thread %d was not found in Imbox or Bubble Up", postingID, topicID), + Hint: "HEY box reads can be transiently incomplete. Retry only after `hey box view imbox --all --json` or `hey box view bubblebox --all --json` shows this exact pair.", + HTTPStatus: 404, + Meta: map[string]any{"retryable": true}, + } +} diff --git a/internal/cmd/bubble_up_test.go b/internal/cmd/bubble_up_test.go new file mode 100644 index 00000000..3f0c7f9c --- /dev/null +++ b/internal/cmd/bubble_up_test.go @@ -0,0 +1,496 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/output" +) + +const ( + bubbleTestPostingID int64 = 731245 + bubbleTestTopicID int64 = 912876 +) + +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.RequestURI()) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/imbox.json": + s.writeImboxPage(w, r.URL.Query().Get("page")) + case r.Method == http.MethodGet && r.URL.Path == "/bubble_up.json": + s.writeBox(w, "bubblebox") + 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) writeImboxPage(w http.ResponseWriter, page string) { + s.t.Helper() + switch page { + case "target": + s.writeBoxPayload(w, "imbox", true, false, bubbleTestTopicID, "") + case "incomplete": + s.writeBoxPayload(w, "imbox", false, false, bubbleTestTopicID, s.server.URL+"/imbox.json?page=still-incomplete") + default: + if page != "" { + s.t.Errorf("unexpected Imbox page %q", page) + w.WriteHeader(http.StatusNotFound) + return + } + s.writeBox(w, "imbox") + } +} + +func (s *bubbleTestService) writeMutationStatus(w http.ResponseWriter) { + status := s.mutationStatus + if status == 0 { + status = http.StatusNoContent + } + 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 { + nextURL = s.server.URL + "/imbox.json?page=target" + } + if s.state == bubbleStateIncomplete { + nextURL = s.server.URL + "/imbox.json?page=incomplete" + } + case "bubblebox": + 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 TestBubbleActionsHandleScheduledBoxItem(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 box item: %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 := apierr.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 box item", state: bubbleStateIncompletePosting, wantCode: "target_incomplete"}, + {name: "missing", state: bubbleStateMissing, wantCode: apierr.CodeNotFound}, + } { + 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 := apierr.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 TestBubbleTargetLookupPrefersExactPairWhenAnotherBoxItemSharesThread(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 thread: %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-thread 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 := apierr.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", strconvInt64(bubbleTestPostingID)}}, + {name: "zero box item", args: []string{"bubble-up-now", "0", "--topic-id", strconvInt64(bubbleTestTopicID)}}, + {name: "nonnumeric box item", args: []string{"bubble-up-now", "nope", "--topic-id", strconvInt64(bubbleTestTopicID)}}, + {name: "zero topic", args: []string{"pop", strconvInt64(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 := apierr.AsError(err).Code; got != apierr.CodeUsage { + 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/help.go b/internal/cmd/help.go index 46776cb7..14217b01 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -33,7 +33,7 @@ var curatedCategories = []struct { }, { heading: "ORGANIZE", - names: []string{"label", "collection", "workflow", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"}, + names: []string{"label", "collection", "workflow", "seen", "unseen", "bubble-up-now", "pop", "move", "trash", "spam", "ignore", "stop-ignoring"}, }, { heading: "CALENDAR & TASKS", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 01a06b5f..b9e0a556 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -34,7 +34,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) { func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) { root := newRootCmd() - for _, name := range []string{"box", "label", "workflow", "clip", "snippet", "thread", "attachment", "bulk-reply", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} { + for _, name := range []string{"box", "label", "workflow", "clip", "snippet", "thread", "attachment", "bulk-reply", "search", "seen", "unseen", "bubble-up-now", "pop", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} { t.Run(name, func(t *testing.T) { command, _, err := root.Find([]string{name}) if err != nil { @@ -137,6 +137,8 @@ ORGANIZE workflow List and manage email workflows seen Mark email threads as seen unseen Mark email threads as unseen + bubble-up-now Bubble one exact thread to the top of the Imbox now + pop Remove one exact thread from Bubble Up move Move email threads to another box trash Move email threads to Trash spam Mark email threads as spam diff --git a/internal/cmd/root.go b/internal/cmd/root.go index deec40b3..4920567b 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -211,6 +211,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 d21f3b83..9bdb123c 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -38,6 +38,8 @@ triggers: # Seen/unseen - hey seen - hey unseen + - hey bubble-up-now + - hey pop - hey move - hey trash - hey spam @@ -52,6 +54,8 @@ triggers: - mark as seen - mark as unseen - mark as unread + - bubble up email + - pop email from bubble up # Habits - hey habit # Time tracking @@ -116,6 +120,7 @@ CLI for HEY: mailboxes, labels, collections, email threads, contacts, replies, c 3. **HTML output** is available via `--html` for commands that return HTML content 4. **Linked mail accounts share one login** — use `hey account 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. **Bubble Up changes require one exact pair** — copy the box item `id` and `topic_id` from the same complete box row; never infer one from a subject or a different row ## Output Filtering @@ -206,6 +211,8 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | Follow every change | `hey watch` | | Mark as seen | `hey seen 12345` | | Mark as unseen | `hey unseen 12345` | +| Bubble one thread now | `hey bubble-up-now 12345 --topic-id 987` | +| Remove one thread from Bubble Up | `hey pop 12345 --topic-id 987` | | Move email threads | `hey move 12345 --to feed` | | Move email threads to Trash | `hey trash 12345` | | Mark email threads as spam | `hey spam 12345` | @@ -252,6 +259,8 @@ Want to read email? ├── Turn off the sharing link? → hey unshare ├── Mark as seen? → hey seen ├── Mark as unseen? → hey unseen +├── Bubble one exact thread now? → hey bubble-up-now --topic-id +├── Remove one exact thread from Bubble Up? → hey pop --topic-id ├── Move to another box? → hey move --to ├── Move to Trash? → hey trash ├── Mark as spam? → hey spam @@ -307,7 +316,7 @@ hey box view imbox --page next-cursor --json # Continue from an earlier listing Box names: `imbox`, `feedbox`, `trailbox`, `asidebox`, `laterbox`, `bubblebox` -**Response format:** `hey box view --json` returns the box itself — `id`, `kind`, `name`, `app_url`, `next_history_url`, `next_page` — with a `postings` array of the email threads in it. Each posting has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`, `visible_entry_count`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`, and `topic_id` for `hey thread read`, `hey reply`, `hey forward`, `hey share` and `hey attachment list`. A box item `id` passed to `hey thread read` answers `not_found`, and so does a `topic_id` passed to `hey move`. +**Response format:** `hey box view --json` returns the box itself — `id`, `kind`, `name`, `app_url`, `next_history_url`, `next_page` — with a `postings` array of the email threads in it. Each posting has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`, `visible_entry_count`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`, and `topic_id` for `hey thread read`, `hey reply`, `hey forward`, `hey share` and `hey attachment list`. `hey bubble-up-now` and `hey pop` require both values from the same row. A box item `id` passed to `hey thread read` answers `not_found`, and so does a `topic_id` passed to `hey move`. A posting that bundles a contact's mail into one row can **omit `topic_id`**: a bundle names its sender rather than a thread, and its `name` joins the bundled subjects with `•`. A bundle that does carry a `topic_id` opens as that thread — its one unseen thread — and `hey threads` reads it as usual. For a bundle without one, never substitute the box item `id` (`hey threads ` answers `not_found`); there is no command that lists the threads inside a bundle, so run `hey contacts unbundle ` — the contact is in the posting's `contacts` — to list that sender's mail as separate rows, or direct the user to open the bundle in HEY. @@ -397,7 +406,7 @@ on an entry; use `hey reply`, which works the addressing out itself. `hey share` returns a URL that shows the entire thread and future emails or replies sent to it. Anyone with the link can open it. `hey unshare` turns off the sharing link. -**ID note:** Every email thread has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect `id`. `hey thread read`, `hey share`, `hey unshare`, `hey attachment list`, `hey reply`, `hey forward`, `hey collection add`, and `hey collection remove` expect `topic_id`. Passing the wrong one answers `not_found`, not a redirect. +**ID note:** Every email thread has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect `id`. `hey thread read`, `hey share`, `hey unshare`, `hey attachment list`, `hey reply`, `hey forward`, `hey collection add`, and `hey collection remove` expect `topic_id`. `hey bubble-up-now` and `hey pop` require both from one exact row. Passing the wrong one answers `not_found`, not a redirect. `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry both — except a bundle posting, which can omit `topic_id` (see the Boxes section). @@ -493,6 +502,16 @@ hey unseen 12345 67890 # Mark multiple threads as unseen Takes box item IDs (the `id` field from `hey box view` output). +### Email - Bubble Up + +```bash +hey box view imbox --all --json # Read a complete row with both IDs +hey bubble-up-now 12345 --topic-id 987 # Bubble that exact thread now +hey pop 12345 --topic-id 987 # Remove that exact thread from Bubble Up +``` + +Both commands require one box item `id` and its paired `topic_id` from the same row. They read every Imbox and Bubble Up page before changing anything, then verify the exact pair after the SDK operation. A missing, mismatched, incomplete, or truncated target fails before any change. Repeating an already-applied action returns `verified: true` with `no_op: true`. Bubble Up Now is non-idempotent and is never replayed after an ambiguous response; rerun the complete command so its fresh preflight can resolve whether HEY already applied it. + ### Email - Moving Threads ```bash @@ -500,7 +519,7 @@ hey move 12345 --to imbox # Move one thread hey move 12345 67890 --to "paper trail" # Move multiple threads ``` -Takes box item IDs (the `id` field from `hey box view --json`). `--to` accepts a box name, kind, or ID. Supported destinations are Imbox, The Feed, Set Aside, Reply Later, and Paper Trail. Bubble Up requires a scheduled date and is not supported by this command. +Takes box item IDs (the `id` field from `hey box view --json`). `--to` accepts a box name, kind, or ID. Supported destinations are Imbox, The Feed, Set Aside, Reply Later, and Paper Trail. Scheduled Bubble Up is not supported by this command; use the exact-pair `hey bubble-up-now` command to raise a thread immediately or `hey pop` to remove it from Bubble Up. ### Email - Trash and Spam diff --git a/tests/smoke/bubble_up_test.go b/tests/smoke/bubble_up_test.go new file mode 100644 index 00000000..c3d16665 --- /dev/null +++ b/tests/smoke/bubble_up_test.go @@ -0,0 +1,106 @@ +package smoke_test + +import ( + "fmt" + "testing" +) + +type bubbleSmokePosting struct { + ID int64 `json:"id"` + TopicID int64 `json:"topic_id"` + BubbledUp bool `json:"bubbled_up"` +} + +func TestBubbleUpNowAndPopExactBoxItem(t *testing.T) { + type boxResponse struct { + Postings []bubbleSmokePosting `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"` + InBubbleUp bool `json:"in_bubble_up"` + BubbledUp bool `json:"bubbled_up"` + Scheduled bool `json:"scheduled"` + } `json:"after"` + } + + box := dataAs[boxResponse](t, heyJSON(t, "box", "view", "imbox", "--all")) + var target bubbleSmokePosting + for _, candidate := range box.Postings { + if candidate.ID > 0 && candidate.TopicID > 0 && !candidate.BubbledUp { + target = candidate + break + } + } + if target.ID == 0 { + skipf(t, "no unbubbled Imbox box item available for Bubble Up smoke test") + } + + postingID := fmt.Sprintf("%d", target.ID) + topicID := fmt.Sprintf("%d", target.TopicID) + cleanupNeeded := true + t.Cleanup(func() { + if !cleanupNeeded { + return + } + _, stderr, code := hey(t, "pop", postingID, "--topic-id", topicID, "--json") + if code != 0 { + t.Errorf("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 || now.TopicID != target.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", "view", "imbox", "--all")) + if !hasBubblePosting(box.Postings, target.ID, target.TopicID, true) { + t.Fatalf("exact box item %d / thread %d was not independently observed as bubbled", target.ID, target.TopicID) + } + + popped := dataAs[actionResult](t, heyJSON(t, "pop", postingID, "--topic-id", topicID)) + if popped.PostingID != target.ID || popped.TopicID != target.TopicID || !popped.Verified || !popped.After.InImbox || popped.After.InBubbleUp || popped.After.BubbledUp || popped.After.Scheduled { + t.Fatalf("unexpected Pop result: %+v", popped) + } + if !popped.Changed && !popped.NoOp { + t.Fatalf("Pop was neither changed nor no-op: %+v", popped) + } + + box = dataAs[boxResponse](t, heyJSON(t, "box", "view", "imbox", "--all")) + if !hasBubblePosting(box.Postings, target.ID, target.TopicID, false) { + t.Fatalf("exact box item %d / thread %d was not independently observed as popped in Imbox", target.ID, target.TopicID) + } + bubbleBox := dataAs[boxResponse](t, heyJSON(t, "box", "view", "bubblebox", "--all")) + if hasExactBubblePosting(bubbleBox.Postings, target.ID, target.TopicID) { + t.Fatalf("exact box item %d / thread %d remained in Bubble Up after Pop", target.ID, target.TopicID) + } + cleanupNeeded = false +} + +func hasBubblePosting(postings []bubbleSmokePosting, postingID, topicID int64, bubbled bool) bool { + for _, candidate := range postings { + if candidate.ID == postingID && candidate.TopicID == topicID && candidate.BubbledUp == bubbled { + return true + } + } + return false +} + +func hasExactBubblePosting(postings []bubbleSmokePosting, postingID, topicID int64) bool { + for _, candidate := range postings { + if candidate.ID == postingID && candidate.TopicID == topicID { + return true + } + } + return false +}