Add draft pagination and draft workflow commands via web forms#118
Add draft pagination and draft workflow commands via web forms#118mikegyi wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
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--draftsupport tohey composeandhey reply. - Improves
hey draftsto 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.
| 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) | ||
| } |
| 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) | ||
| } |
| 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) | ||
| } |
| 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", | ||
| } | ||
| } |
| 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") |
| if c.draft { | ||
| return createReplyDraft(ctx, cmd.OutOrStdout(), topicID, draftFormRequest{ | ||
| Subject: c.subject, | ||
| Content: message, | ||
| }) | ||
| } |
| if c.draft { | ||
| return createMessageDraft(ctx, cmd.OutOrStdout(), draftFormRequest{ | ||
| Subject: c.subject, | ||
| Content: message, | ||
| To: to, | ||
| CC: cc, | ||
| BCC: bcc, | ||
| }) | ||
| } |
| } | ||
|
|
||
| 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") |
| if c.draft { | ||
| return createReplyDraftForEntry(ctx, cmd.OutOrStdout(), latestEntryID, draftFormRequest{ | ||
| Content: message, | ||
| }) | ||
| } |
There was a problem hiding this comment.
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
| return draftResponse{} | ||
| } | ||
| location = strings.TrimRight(location, "/") | ||
| id, _ := strconv.ParseInt(location[strings.LastIndex(location, "/")+1:], 10, 64) |
There was a problem hiding this comment.
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>
| return draftResponse{}, output.ErrNetwork(err) | ||
| } | ||
| defer func() { _ = resp.Body.Close() }() | ||
| body, _ := io.ReadAll(resp.Body) |
There was a problem hiding this comment.
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>
| 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://") { |
There was a problem hiding this comment.
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>
Summary
This adds fuller draft support to
hey-cli.The API-safe part is draft listing pagination:
hey drafts --allhey drafts --limit NLinkheaderIt also adds an experimental draft workflow:
hey draft createhey draft updatehey draft deletehey compose --drafthey reply --draftAPI 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
<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 testSummary by cubic
Adds full draft support to
hey-cli: paginated draft listing and a safe draft workflow. Introduceshey draftcommands and--draftmodes on compose/reply, using authenticated web forms until SDK endpoints exist.New Features
hey draftswith--alland--limit, follows HEY’s Link headers, and shows truncation notices.hey draft create|update|deletecommands (isolated web-form flow, ready to switch to SDK when available).hey compose --draftandhey reply --draft.Bug Fixes
<div>with<div><br></div>for blank lines) to preserve spacing.Written for commit b3cccbe. Summary will update on new commits.
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.