Skip to content

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

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

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

Conversation

@mikegyi
Copy link
Copy Markdown

@mikegyi mikegyi commented Jun 1, 2026

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 draft listing and a safe draft workflow. Introduces hey draft commands and --draft modes on compose/reply, using authenticated web forms until SDK endpoints exist.

  • New Features

    • Paginated hey drafts with --all and --limit, follows HEY’s Link headers, and shows truncation notices.
    • New hey draft create|update|delete commands (isolated web-form flow, ready to switch to SDK when available).
    • Draft mode for send commands: hey compose --draft and hey reply --draft.
  • Bug Fixes

    • Draft updates keep unspecified fields; reply drafts use reply-form recipients.
    • CSRF and form parsing fail closed; cross-origin pagination URLs are rejected.
    • Plain-text bodies are formatted as Trix blocks (<div> with <div><br></div> for blank lines) to preserve spacing.

Written for commit b3cccbe. 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
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,
})
}
Copy link
Copy Markdown
Contributor

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

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

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/cmd/draft.go">

<violation number="1" location="internal/cmd/draft.go:463">
P2: `io.ReadAll(resp.Body)` discards its error. A truncated or failed read will produce an empty/partial `body`, causing the subsequent error message to be misleading. Capture and handle the read error (e.g., fall back to `resp.Status`).</violation>

<violation number="2" location="internal/cmd/draft.go:490">
P2: Extracting the ID via `location[strings.LastIndex(location, "/")+1:]` will fail if the Location header contains a query string or fragment (e.g., `/messages/123?param=1`), resulting in `ParseInt` returning 0. Use `net/url.Parse` and extract the final path segment to handle this robustly.</violation>
</file>

<file name="internal/cmd/drafts.go">

<violation number="1" location="internal/cmd/drafts.go:81">
P2: The cross-origin guard only triggers on lowercase `http://` or `https://` prefixes. Scheme-relative URLs (`//host/path`) or mixed-case schemes (`HTTP://...`) bypass validation entirely. Parse `pageURL` with `net/url` and validate same-origin whenever the parsed URL has a host component.</violation>
</file>

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

Re-trigger cubic

Comment thread internal/cmd/draft.go
return draftResponse{}
}
location = strings.TrimRight(location, "/")
id, _ := strconv.ParseInt(location[strings.LastIndex(location, "/")+1:], 10, 64)
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.

P2: Extracting the ID via location[strings.LastIndex(location, "/")+1:] will fail if the Location header contains a query string or fragment (e.g., /messages/123?param=1), resulting in ParseInt returning 0. Use net/url.Parse and extract the final path segment to handle this robustly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/cmd/draft.go, line 490:

<comment>Extracting the ID via `location[strings.LastIndex(location, "/")+1:]` will fail if the Location header contains a query string or fragment (e.g., `/messages/123?param=1`), resulting in `ParseInt` returning 0. Use `net/url.Parse` and extract the final path segment to handle this robustly.</comment>

<file context>
@@ -0,0 +1,635 @@
+		return draftResponse{}
+	}
+	location = strings.TrimRight(location, "/")
+	id, _ := strconv.ParseInt(location[strings.LastIndex(location, "/")+1:], 10, 64)
+	return draftResponse{
+		ID:      id,
</file context>

Comment thread internal/cmd/draft.go
return draftResponse{}, output.ErrNetwork(err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
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.

P2: io.ReadAll(resp.Body) discards its error. A truncated or failed read will produce an empty/partial body, causing the subsequent error message to be misleading. Capture and handle the read error (e.g., fall back to resp.Status).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/cmd/draft.go, line 463:

<comment>`io.ReadAll(resp.Body)` discards its error. A truncated or failed read will produce an empty/partial `body`, causing the subsequent error message to be misleading. Capture and handle the read error (e.g., fall back to `resp.Status`).</comment>

<file context>
@@ -0,0 +1,635 @@
+		return draftResponse{}, output.ErrNetwork(err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+	body, _ := io.ReadAll(resp.Body)
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 400 {
</file context>

Comment thread internal/cmd/drafts.go
type draftPageFetcher func(ctx context.Context, pageURL string) ([]generated.DraftMessage, string, int, error)

func fetchDraftsPage(ctx context.Context, pageURL string) ([]generated.DraftMessage, string, int, error) {
if strings.HasPrefix(pageURL, "http://") || strings.HasPrefix(pageURL, "https://") {
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.

P2: The cross-origin guard only triggers on lowercase http:// or https:// prefixes. Scheme-relative URLs (//host/path) or mixed-case schemes (HTTP://...) bypass validation entirely. Parse pageURL with net/url and validate same-origin whenever the parsed URL has a host component.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/cmd/drafts.go, line 81:

<comment>The cross-origin guard only triggers on lowercase `http://` or `https://` prefixes. Scheme-relative URLs (`//host/path`) or mixed-case schemes (`HTTP://...`) bypass validation entirely. Parse `pageURL` with `net/url` and validate same-origin whenever the parsed URL has a host component.</comment>

<file context>
@@ -80,3 +74,103 @@ func (c *draftsCommand) run(cmd *cobra.Command, args []string) error {
+type draftPageFetcher func(ctx context.Context, pageURL string) ([]generated.DraftMessage, string, int, error)
+
+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
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants