Skip to content

Add draft pagination and draft workflow commands via web forms#118

Open
mikegyi wants to merge 17 commits into
basecamp:mainfrom
mikegyi:mike/draft-workflows
Open

Add draft pagination and draft workflow commands via web forms#118
mikegyi wants to merge 17 commits into
basecamp:mainfrom
mikegyi:mike/draft-workflows

Conversation

@mikegyi

@mikegyi mikegyi commented Jun 1, 2026

Copy link
Copy Markdown

Summary

This adds fuller draft support to hey-cli.

The API-safe part is draft listing pagination:

  • hey drafts --all
  • hey drafts --limit N
  • pagination via HEY’s Link header
  • truncation notices when only part of the draft list is shown

It also adds an experimental draft workflow:

  • hey draft create
  • hey draft update
  • hey draft delete
  • hey compose --draft
  • hey reply --draft

API surface note

Draft listing uses HEY’s JSON endpoint.

Draft create/update/delete currently use HEY’s authenticated web form flow because the SDK does not expose first-class draft mutation methods yet. I kept that code isolated so it can be replaced with SDK/API calls if those endpoints become available.

This means the mutation commands are useful as a working implementation and command-shape proposal, but they are less stable than the listing/pagination work.

Safety notes

  • Draft form parsing fails closed if required form fields or CSRF tokens are missing.
  • Draft updates preserve omitted existing fields instead of blanking them.
  • Reply drafts use HEY’s reply form recipient state rather than broad topic-level recipients.
  • Plain-text draft bodies are converted to escaped HTML with <br> line breaks so HEY preserves spacing.

Authoring note

This was authored by Mike Gyi with Codex 5.5.

The intent was to explore a practical draft workflow for hey-cli, using Basecamp’s own CLI taste as the reference point: small commands, direct behavior, clear failure modes, and no hidden send action.

Testing

  • go test ./internal/cmd -run 'TestDraftValues|TestWithReplyFormRecipients|TestParseDraftForm|TestReply|TestCreateReplyDraft'
  • make build && make test
  • Manual smoke test for draft reply creation confirmed safe recipient state and preserved line breaks.

Summary by cubic

Adds full draft support to hey-cli: paginated drafts, hey draft commands, and --draft on compose/reply, with unified message input, safer pagination/recipients, and clearer draft error messages.
This lets you save, update, and delete drafts from the CLI without sending.

  • New Features

    • Paginated hey drafts with --all and --limit; follows Link rel=next (absolute or scheme-relative), reads X-Total-Count, and caps traversal with a clear limit notice.
    • Draft workflow: hey draft create|update|delete, hey draft create --thread-id for reply drafts, plus hey compose --draft and hey reply --draft.
  • Bug Fixes

    • Replies (send and draft) use reply-form recipients, avoiding unsafe topic-level defaults.
    • Draft updates preserve unspecified fields; reject no-op updates; CSRF and form parsing fail closed and server error messages are normalized and trimmed (500-rune, multibyte-safe).
    • Pagination URLs are normalized and must be same-origin (case-insensitive scheme/host); scheme-relative same-host links are allowed; absolute URLs without a host are rejected.
    • Unified message handling across compose/reply/draft; plain text becomes Trix blocks with preserved blank lines; explicit empty bodies are allowed and saved as a blank block when requested.

Written for commit d222206. Summary will update on new commits.

Review in cubic

Manual testing scope

I have started using the draft workflow against my own HEY account, but it has not had broad real-world testing yet.

So far the live testing covered two reply drafts. That surfaced two important bugs: reply drafts could inherit unsafe recipient state from a broader thread, and plain-text line breaks collapsed in HEY. Both are fixed in this PR.

I plan to keep using this workflow myself and will keep fixing issues as they come up.

Copilot AI review requested due to automatic review settings June 1, 2026 10:59
@github-actions github-actions Bot added the enhancement New feature or request label Jun 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds first-class draft workflows to the HEY CLI, enabling users (and agents/scripts) to create/update/delete drafts and to save drafts from existing compose/reply flows without sending.

Changes:

  • Introduces hey draft {create,update,delete} and adds --draft support to hey compose and hey reply.
  • Improves hey drafts to paginate through results and provide more accurate truncation messaging.
  • Updates command help surfacing and documentation (README, SKILL doc, surface file) to reflect the new draft capabilities.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
skills/hey/SKILL.md Documents new draft commands and --draft usage for compose/reply.
internal/cmd/root.go Exposes an HTTP client for draft web-form submission and registers the new draft command.
internal/cmd/reply.go Adds --draft to save reply drafts instead of sending immediately.
internal/cmd/compose.go Adds --draft to save drafts for new messages or thread posts.
internal/cmd/help.go Includes draft in curated help categories.
internal/cmd/drafts.go Reworks drafts listing to paginate and parse Link headers safely.
internal/cmd/drafts_test.go Adds unit tests for pagination, link parsing, origin checks, and truncation notices.
internal/cmd/draft.go Implements draft create/update/delete via authenticated web form flows (CSRF parsing, submission).
internal/cmd/draft_test.go Adds unit tests for form value generation, parsing, and update validation behavior.
README.md Adds examples for --draft and hey draft ... usage.
DRAFT_SUPPORT_NOTE.md Adds implementation rationale and constraints for draft support.
.surface Updates surfaced CLI command/flag inventory for draft-related commands.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/cmd/drafts.go
Comment on lines +80 to +90
func fetchDraftsPage(ctx context.Context, pageURL string) ([]generated.DraftMessage, string, int, error) {
if strings.HasPrefix(pageURL, "http://") || strings.HasPrefix(pageURL, "https://") {
if err := validateSameOrigin(sdk.Config().BaseURL, pageURL); err != nil {
return nil, "", 0, err
}
}

resp, err := sdk.Get(ctx, pageURL)
if err != nil {
return nil, "", 0, convertSDKError(err)
}
Comment thread internal/cmd/drafts.go Outdated
Comment on lines +87 to +95
resp, err := sdk.Get(ctx, pageURL)
if err != nil {
return nil, "", 0, convertSDKError(err)
}

var drafts []generated.DraftMessage
if err := resp.UnmarshalData(&drafts); err != nil {
return nil, "", 0, fmt.Errorf("failed to parse drafts response: %w", err)
}
Comment thread internal/cmd/draft.go
Comment on lines +462 to +471
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)

if resp.StatusCode < 200 || resp.StatusCode >= 400 {
msg := strings.TrimSpace(string(body))
if msg == "" {
msg = resp.Status
}
return draftResponse{}, output.ErrAPI(resp.StatusCode, msg)
}
Comment thread internal/cmd/draft.go
Comment on lines +485 to +496
func draftResponseFromLocation(location string) draftResponse {
if location == "" {
return draftResponse{}
}
location = strings.TrimRight(location, "/")
id, _ := strconv.ParseInt(location[strings.LastIndex(location, "/")+1:], 10, 64)
return draftResponse{
ID: id,
URL: location,
EditURL: location + "/edit",
}
}
Comment thread internal/cmd/compose.go
composeCommand.cmd.Flags().StringVar(&composeCommand.subject, "subject", "", "Message subject (required)")
composeCommand.cmd.Flags().StringVarP(&composeCommand.message, "message", "m", "", "Message body (or opens $EDITOR)")
composeCommand.cmd.Flags().StringVar(&composeCommand.threadID, "thread-id", "", "Thread ID to post message to")
composeCommand.cmd.Flags().BoolVar(&composeCommand.draft, "draft", false, "Save as draft instead of sending")
Comment thread internal/cmd/compose.go
Comment on lines +91 to +96
if c.draft {
return createReplyDraft(ctx, cmd.OutOrStdout(), topicID, draftFormRequest{
Subject: c.subject,
Content: message,
})
}
Comment thread internal/cmd/compose.go
Comment on lines +104 to +112
if c.draft {
return createMessageDraft(ctx, cmd.OutOrStdout(), draftFormRequest{
Subject: c.subject,
Content: message,
To: to,
CC: cc,
BCC: bcc,
})
}
Comment thread internal/cmd/reply.go
}

replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message (or opens $EDITOR)")
replyCommand.cmd.Flags().BoolVar(&replyCommand.draft, "draft", false, "Save as draft instead of sending")
Comment thread internal/cmd/reply.go
Comment on lines +86 to +90
if c.draft {
return createReplyDraftForEntry(ctx, cmd.OutOrStdout(), latestEntryID, draftFormRequest{
Content: message,
})
}

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 12 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread internal/cmd/draft.go Outdated
Comment thread internal/cmd/draft.go Outdated
Comment thread internal/cmd/drafts.go Outdated
Copilot AI review requested due to automatic review settings June 16, 2026 08:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Comment thread internal/cmd/draft.go
Comment on lines +81 to +89
func (c *draftCreateCommand) run(cmd *cobra.Command, args []string) error {
if err := requireAuth(); err != nil {
return err
}

message, err := draftMessage(c.message)
if err != nil {
return err
}
Comment thread internal/cmd/drafts.go
Comment on lines +107 to +114
func normalizeDraftsPageURL(baseURL, pageURL string) (string, error) {
parsed, err := url.Parse(pageURL)
if err != nil {
return "", fmt.Errorf("invalid pagination URL: %w", err)
}
if parsed.Host == "" {
return pageURL, nil
}
Comment thread internal/cmd/draft.go
Comment on lines +469 to +477
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
msg := resp.Status
if readErr == nil {
if bodyText := strings.TrimSpace(string(body)); bodyText != "" {
msg = bodyText
}
}
return draftResponse{}, output.ErrAPI(resp.StatusCode, msg)
}
Comment thread internal/cmd/box.go
Comment on lines +227 to 233
if target.Scheme == "" && target.Host != "" {
target.Scheme = base.Scheme
}
if !strings.EqualFold(base.Scheme, target.Scheme) || base.Host != target.Host {
return fmt.Errorf("pagination URL origin %s://%s does not match base %s://%s",
target.Scheme, target.Host, base.Scheme, base.Host)
}
Copilot AI review requested due to automatic review settings June 16, 2026 08:13
@github-actions

Copy link
Copy Markdown

⚠️ Potential breaking changes detected:

  • Addition of 'draft' command, which is not a breaking change itself, but introduces new subcommands ('create', 'update', 'delete') that will require users to be aware of their functionality changes.
  • Modification of the 'compose' command's behavior with a new '--draft' flag; this changes its functionality, where the command can now result in saving a draft instead of sending a message—a potential script-breaking change if prior cases assumed messages are always sent.
  • Modification of the 'reply' command's behavior with the addition of '--draft' flag, which alters its behavior to save a draft instead of sending a reply—potentially impacting scripts relying on replies being sent.
  • Help command indexes now include 'draft' instead of 'drafts'; updating related curated help categories.

Review carefully before merging. Consider a major version bump.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

jmcascalheira pushed a commit to jmcascalheira/hey-cli that referenced this pull request Jun 18, 2026
…) — revert when merged upstream

Squash of mikegyi:mike/draft-workflows as of commit d222206 (the state at
backport time). Adds:

  hey draft create [--to/--cc/--bcc <addrs>] [--subject S] [-m BODY]
                   [--thread-id <topic-id>] [--all]
  hey draft update <id> [--to/--cc/--bcc] [--subject] [-m BODY]
  hey draft delete <id>
  hey drafts --all / --limit N  (paginated listing)

Backed by HEY's authenticated web-form endpoints (Turbo-style), same pattern
the calendar-events backport uses. Marked experimental upstream — author has
manually tested two reply drafts; broader real-world testing is ongoing.

Local adjustments on top of the squashed PR:
- Did NOT take internal/cmd/compose.go or internal/cmd/reply.go modifications
  (those files remain deleted per the no-send fork policy). The PR's
  '--draft' flag additions to compose/reply are therefore not applicable.
- Created internal/cmd/addresses.go containing parseAddresses() — extracted
  from the deleted compose.go because draft.go needs the helper.
- Deleted internal/cmd/draft_command_test.go: it tested the --draft flag on
  compose/reply, which we don't ship.
- Kept all other new test files (draft_test.go, drafts_test.go).
- Edited skills/hey/SKILL.md: replaced the 'Sending Email — Not supported'
  paragraph with a 'Sending Email — pre-write as drafts' workflow now that
  drafts work; preserved the no-send disclaimer.
- internal/cmd/help.go: added 'draft' to the curated EMAIL list.

Live-tested end-to-end against my own HEY account: created a draft, confirmed
it appeared in 'hey drafts' listing, deleted it. No send occurred at any step.

WHEN basecamp#118 MERGES UPSTREAM:
  1. git log --oneline | grep TEMPORARY     # find this commit's SHA
  2. git revert <sha>                       # also reverts the PR basecamp#79 backport
                                            # if not already reverted — keep
                                            # both reverts in sequence
  3. git checkout main && git fetch upstream && git merge --ff-only upstream/main
  4. git push origin main
  5. git checkout nosend && git merge main
     # During this merge, re-resolve compose.go / reply.go as deleted
     # (upstream's compose.go modifications, including the --draft flag,
     # are not wanted by the no-send fork).
  6. make build && make test
  7. git push origin nosend

The official version may include review-feedback tweaks not present here.
Reverting this commit first ensures a clean swap.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants