From d6c10481d1509198076ae5f7605b5414cd7823f5 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 21:39:13 -0300 Subject: [PATCH 01/28] Add hey event list command --- .surface | 5 ++ internal/cmd/event.go | 110 +++++++++++++++++++++++++++++++++ internal/cmd/event_test.go | 123 +++++++++++++++++++++++++++++++++++++ internal/cmd/root.go | 1 + 4 files changed, 239 insertions(+) create mode 100644 internal/cmd/event.go create mode 100644 internal/cmd/event_test.go diff --git a/.surface b/.surface index c688616..e53b451 100644 --- a/.surface +++ b/.surface @@ -43,6 +43,11 @@ hey doctor hey drafts hey drafts --all hey drafts --limit +hey event +hey event list +hey event list --all +hey event list --calendar +hey event list --limit hey habit hey habit complete hey habit complete --date diff --git a/internal/cmd/event.go b/internal/cmd/event.go new file mode 100644 index 0000000..82b1f2b --- /dev/null +++ b/internal/cmd/event.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/output" +) + +type eventCommand struct { + cmd *cobra.Command +} + +func newEventCommand() *eventCommand { + eventCommand := &eventCommand{} + eventCommand.cmd = &cobra.Command{ + Use: "event", + Short: "Manage calendar events", + Annotations: map[string]string{ + "agent_notes": "Subcommands: list. Lists events from the personal calendar by default, or from --calendar ID.", + }, + } + + eventCommand.cmd.AddCommand(newEventListCommand().cmd) + + return eventCommand +} + +// list + +type eventListCommand struct { + cmd *cobra.Command + limit int + all bool + calendarID int64 +} + +func newEventListCommand() *eventListCommand { + eventListCommand := &eventListCommand{} + eventListCommand.cmd = &cobra.Command{ + Use: "list", + Short: "List calendar events", + Example: ` hey event list + hey event list --limit 10 + hey event list --calendar 123 + hey event list --ids-only`, + RunE: eventListCommand.run, + } + + eventListCommand.cmd.Flags().IntVar(&eventListCommand.limit, "limit", 0, "Maximum number of events to show") + eventListCommand.cmd.Flags().BoolVar(&eventListCommand.all, "all", false, "Fetch all results (override --limit)") + eventListCommand.cmd.Flags().Int64Var(&eventListCommand.calendarID, "calendar", 0, "Calendar ID (defaults to personal calendar)") + + return eventListCommand +} + +func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + ctx := cmd.Context() + + var events []generated.Recording + if c.calendarID != 0 { + resp, err := sdk.Calendars().GetRecordings(ctx, c.calendarID, nil) + if err != nil { + return convertSDKError(err) + } + events = filterRecordingsByType(resp, "Calendar::Event") + } else { + resp, err := listPersonalRecordings(ctx) + if err != nil { + return err + } + events = filterRecordingsByType(resp, "Calendar::Event") + } + + total := len(events) + if c.limit > 0 && !c.all && len(events) > c.limit { + events = events[:c.limit] + } + notice := output.TruncationNotice(len(events), total) + + if writer.IsStyled() { + if len(events) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No events.") + return nil + } + + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "Title", "Starts", "Ends"}) + for _, e := range events { + table.addRow([]string{fmt.Sprintf("%d", e.Id), e.Title, formatTimestamp(e.StartsAt), formatTimestamp(e.EndsAt)}) + } + table.print() + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + } + return nil + } + + return writeOK(events, + output.WithSummary(fmt.Sprintf("%d events", len(events))), + output.WithNotice(notice), + ) +} diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go new file mode 100644 index 0000000..fe90beb --- /dev/null +++ b/internal/cmd/event_test.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func eventServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/calendars.json": + resp := map[string]any{ + "calendars": []map[string]any{ + { + "calendar": map[string]any{ + "id": 42, + "name": "Personal", + "personal": true, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/calendars/") && strings.HasSuffix(r.URL.Path, "/recordings"): + resp := map[string]any{ + "Calendar::Event": []map[string]any{ + { + "id": 101, + "title": "Team standup", + "starts_at": "2024-05-01T09:00:00Z", + "ends_at": "2024-05-01T09:30:00Z", + }, + { + "id": 102, + "title": "Lunch meeting", + "starts_at": "2024-05-02T12:00:00Z", + "ends_at": "2024-05-02T13:00:00Z", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func runEventList(t *testing.T, server *httptest.Server, args ...string) (string, 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) + fullArgs := append([]string{"event", "list", "--base-url", server.URL}, args...) + root.SetArgs(fullArgs) + + err := root.Execute() + return buf.String(), err +} + +func TestEventListDefault(t *testing.T) { + server := eventServer(t) + defer server.Close() + + out, err := runEventList(t, server, "--styled") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "Team standup") { + t.Errorf("output missing event title: %q", out) + } + if !strings.Contains(out, "Lunch meeting") { + t.Errorf("output missing event title: %q", out) + } +} + +func TestEventListLimit(t *testing.T) { + server := eventServer(t) + defer server.Close() + + out, err := runEventList(t, server, "--styled", "--limit", "1") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "Team standup") { + t.Errorf("output missing first event: %q", out) + } + if strings.Contains(out, "Lunch meeting") { + t.Errorf("output should not contain second event when limit=1: %q", out) + } +} + +func TestEventListIdsOnly(t *testing.T) { + server := eventServer(t) + defer server.Close() + + out, err := runEventList(t, server, "--ids-only") + if err != nil { + t.Fatalf("execute: %v", err) + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 ID lines, got %d: %q", len(lines), out) + } + if lines[0] != "101" || lines[1] != "102" { + t.Errorf("unexpected IDs: %v", lines) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 568b3a2..ebd8211 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -126,6 +126,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newCalendarsCommand().cmd) root.AddCommand(newRecordingsCommand().cmd) root.AddCommand(newTodoCommand().cmd) + root.AddCommand(newEventCommand().cmd) root.AddCommand(newHabitCommand().cmd) root.AddCommand(newTimetrackCommand().cmd) root.AddCommand(newJournalCommand().cmd) From 910752cc86b095c5cf1e1794573f3412cb4f8431 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 21:43:15 -0300 Subject: [PATCH 02/28] Add hey event create command --- .surface | 9 ++ internal/cmd/event.go | 179 ++++++++++++++++++++++++++++++++++++- internal/cmd/event_test.go | 141 +++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 1 deletion(-) diff --git a/.surface b/.surface index e53b451..7fe0d20 100644 --- a/.surface +++ b/.surface @@ -44,6 +44,15 @@ hey drafts hey drafts --all hey drafts --limit hey event +hey event create +hey event create --all-day +hey event create --calendar +hey event create --date +hey event create --end +hey event create --reminder +hey event create --start +hey event create --timezone +hey event create --title hey event list hey event list --all hey event list --calendar diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 82b1f2b..c35f864 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -2,10 +2,14 @@ package cmd import ( "fmt" + "strconv" + "strings" + "time" "github.com/spf13/cobra" "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" "github.com/basecamp/hey-cli/internal/output" ) @@ -20,11 +24,12 @@ func newEventCommand() *eventCommand { Use: "event", Short: "Manage calendar events", Annotations: map[string]string{ - "agent_notes": "Subcommands: list. Lists events from the personal calendar by default, or from --calendar ID.", + "agent_notes": "Subcommands: list, create. Lists events from the personal calendar by default, or from --calendar ID.", }, } eventCommand.cmd.AddCommand(newEventListCommand().cmd) + eventCommand.cmd.AddCommand(newEventCreateCommand().cmd) return eventCommand } @@ -108,3 +113,175 @@ func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { output.WithNotice(notice), ) } + +// create + +type eventCreateCommand struct { + cmd *cobra.Command + title string + date string + allDay bool + start string + end string + calendarID int64 + timezone string + reminders []string +} + +func newEventCreateCommand() *eventCreateCommand { + c := &eventCreateCommand{} + c.cmd = &cobra.Command{ + Use: "create", + Short: "Create a calendar event", + Example: ` hey event create --title "Team sync" --date 2024-06-15 --start 09:00 --end 10:00 + hey event create --title "Holiday" --date 2024-06-15 --all-day + hey event create --title "Review" --date 2024-06-15 --start 14:00 --end 15:00 --reminder 30m --reminder 1h`, + RunE: c.run, + } + + c.cmd.Flags().StringVar(&c.title, "title", "", "Event title (required)") + c.cmd.Flags().StringVar(&c.date, "date", "", "Event date YYYY-MM-DD (required)") + c.cmd.Flags().BoolVar(&c.allDay, "all-day", false, "Create as all-day event") + c.cmd.Flags().StringVar(&c.start, "start", "", "Start time HH:MM (required unless --all-day)") + c.cmd.Flags().StringVar(&c.end, "end", "", "End time HH:MM (required unless --all-day)") + c.cmd.Flags().Int64Var(&c.calendarID, "calendar", 0, "Calendar ID (defaults to personal calendar)") + c.cmd.Flags().StringVar(&c.timezone, "timezone", "", "IANA timezone name (defaults to local)") + c.cmd.Flags().StringArrayVar(&c.reminders, "reminder", nil, "Reminder duration (e.g. 30m, 1h, 2d, 1w); repeatable") + + return c +} + +func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(c.title) == "" { + return output.ErrUsage("--title is required") + } + if strings.TrimSpace(c.date) == "" { + return output.ErrUsage("--date is required (YYYY-MM-DD)") + } + if _, err := time.Parse("2006-01-02", c.date); err != nil { + return output.ErrUsage("--date must be in YYYY-MM-DD format") + } + if !c.allDay { + if c.start == "" || c.end == "" { + return output.ErrUsageHint( + "must supply either --all-day or both --start and --end", + "Use --all-day for all-day events, or --start HH:MM --end HH:MM for timed events", + ) + } + if _, err := time.Parse("15:04", c.start); err != nil { + return output.ErrUsage("--start must be in HH:MM format") + } + if _, err := time.Parse("15:04", c.end); err != nil { + return output.ErrUsage("--end must be in HH:MM format") + } + } + + reminders, err := parseReminders(c.reminders) + if err != nil { + return err + } + + if err := requireAuth(); err != nil { + return err + } + + ctx := cmd.Context() + + calID := c.calendarID + if calID == 0 { + payload, err := sdk.Calendars().List(ctx) + if err != nil { + return convertSDKError(err) + } + id, err := findPersonalCalendarID(unwrapCalendars(payload)) + if err != nil { + return output.ErrNotFound("calendar", "personal") + } + calID = id + } + + tz := c.timezone + if tz == "" && !c.allDay { + tz = localTimezoneName() + } + + params := hey.CreateCalendarEventParams{ + CalendarID: calID, + Title: c.title, + StartsAt: c.date, + EndsAt: c.date, + AllDay: c.allDay, + Reminders: reminders, + } + if !c.allDay { + params.StartTime = c.start + params.EndTime = c.end + params.TimeZone = tz + } + + id, err := sdk.CalendarEvents().Create(ctx, params) + if err != nil { + return convertSDKError(err) + } + + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "Event created (id=%d).\n", id) + return nil + } + + return writeOK(map[string]any{"id": id}, output.WithSummary("Event created")) +} + +// parseReminderDuration parses reminder durations like "30m", "1h", "2d", "1w" +// into time.Duration. Supports minutes, hours, days, and weeks. +func parseReminderDuration(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if len(s) < 2 { + return 0, fmt.Errorf("invalid reminder %q: expected a number followed by m, h, d, or w", s) + } + unit := s[len(s)-1] + numStr := s[:len(s)-1] + n, err := strconv.Atoi(numStr) + if err != nil || n < 0 { + return 0, fmt.Errorf("invalid reminder %q: expected a non-negative number followed by m, h, d, or w", s) + } + switch unit { + case 'm': + return time.Duration(n) * time.Minute, nil + case 'h': + return time.Duration(n) * time.Hour, nil + case 'd': + return time.Duration(n) * 24 * time.Hour, nil + case 'w': + return time.Duration(n) * 7 * 24 * time.Hour, nil + default: + return 0, fmt.Errorf("invalid reminder %q: unit must be m, h, d, or w", s) + } +} + +// parseReminders converts a list of reminder strings to durations, returning +// a usage error on the first failure. +func parseReminders(in []string) ([]time.Duration, error) { + if len(in) == 0 { + return nil, nil + } + out := make([]time.Duration, 0, len(in)) + for _, s := range in { + d, err := parseReminderDuration(s) + if err != nil { + return nil, output.ErrUsage(err.Error()) + } + out = append(out, d) + } + return out, nil +} + +// localTimezoneName returns the local IANA timezone name, falling back to +// "UTC" if the runtime didn't resolve one. +func localTimezoneName() string { + name := time.Local.String() + if name == "" || name == "Local" { + return "UTC" + } + return name +} diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index fe90beb..52e13b3 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -3,9 +3,11 @@ package cmd import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" + "sync" "testing" ) @@ -105,6 +107,145 @@ func TestEventListLimit(t *testing.T) { } } +// eventCreateServer captures the POST body so assertions can verify the +// form-encoded payload sent to the server. +type capturedRequest struct { + mu sync.Mutex + body string +} + +func (c *capturedRequest) set(s string) { + c.mu.Lock() + defer c.mu.Unlock() + c.body = s +} + +func (c *capturedRequest) get() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.body +} + +func eventCreateServer(t *testing.T, captured *capturedRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/calendars.json": + resp := map[string]any{ + "calendars": []map[string]any{ + { + "calendar": map[string]any{ + "id": 42, + "name": "Personal", + "personal": true, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + case r.Method == "POST" && r.URL.Path == "/calendar/events": + body, _ := io.ReadAll(r.Body) + captured.set(string(body)) + w.Header().Set("Location", "/calendar/events/999") + w.WriteHeader(http.StatusFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func runEventCreate(t *testing.T, server *httptest.Server, args ...string) (string, 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) + fullArgs := append([]string{"event", "create", "--base-url", server.URL}, args...) + root.SetArgs(fullArgs) + + err := root.Execute() + return buf.String(), err +} + +func TestEventCreateRequiresTitle(t *testing.T) { + captured := &capturedRequest{} + server := eventCreateServer(t, captured) + defer server.Close() + + _, err := runEventCreate(t, server, "--date", "2024-06-15", "--all-day") + if err == nil { + t.Fatalf("expected error when --title missing") + } + if !strings.Contains(strings.ToLower(err.Error()), "title") { + t.Errorf("expected error to mention 'title', got: %v", err) + } +} + +func TestEventCreateTimed(t *testing.T) { + captured := &capturedRequest{} + server := eventCreateServer(t, captured) + defer server.Close() + + _, err := runEventCreate(t, server, + "--title", "Team sync", + "--date", "2024-06-15", + "--start", "09:00", + "--end", "10:00", + "--timezone", "America/New_York", + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + + body := captured.get() + wantFragments := []string{ + "calendar_event%5Bsummary%5D=Team+sync", + "calendar_event%5Bstarts_at%5D=2024-06-15", + "calendar_event%5Bstarts_at_time%5D=09%3A00%3A00", + "calendar_event%5Ball_day%5D=0", + "calendar_event%5Bstarts_at_time_zone_name%5D=America%2FNew_York", + "calendar_event%5Bcalendar_id%5D=42", + } + for _, frag := range wantFragments { + if !strings.Contains(body, frag) { + t.Errorf("body missing fragment %q; body=%s", frag, body) + } + } +} + +func TestEventCreateAllDay(t *testing.T) { + captured := &capturedRequest{} + server := eventCreateServer(t, captured) + defer server.Close() + + _, err := runEventCreate(t, server, + "--title", "Holiday", + "--date", "2024-06-15", + "--all-day", + "--reminder", "1d", + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + + body := captured.get() + if !strings.Contains(body, "calendar_event%5Ball_day%5D=1") { + t.Errorf("body missing all_day=1; body=%s", body) + } + if !strings.Contains(body, "all_day_reminder_durations%5B%5D=86400") { + t.Errorf("body missing reminder 86400; body=%s", body) + } +} + func TestEventListIdsOnly(t *testing.T) { server := eventServer(t) defer server.Close() From c4f646a2ca6b0621ab7becb0fbe5f80495859b82 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 21:45:35 -0300 Subject: [PATCH 03/28] Add hey event edit command --- .surface | 8 +++ internal/cmd/event.go | 122 ++++++++++++++++++++++++++++++++++++- internal/cmd/event_test.go | 85 ++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/.surface b/.surface index 7fe0d20..0f36f2c 100644 --- a/.surface +++ b/.surface @@ -53,6 +53,14 @@ hey event create --reminder hey event create --start hey event create --timezone hey event create --title +hey event edit +hey event edit --all-day +hey event edit --date +hey event edit --end +hey event edit --reminder +hey event edit --start +hey event edit --timezone +hey event edit --title hey event list hey event list --all hey event list --calendar diff --git a/internal/cmd/event.go b/internal/cmd/event.go index c35f864..5676c37 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -24,12 +24,13 @@ func newEventCommand() *eventCommand { Use: "event", Short: "Manage calendar events", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, create. Lists events from the personal calendar by default, or from --calendar ID.", + "agent_notes": "Subcommands: list, create, edit. Lists events from the personal calendar by default, or from --calendar ID.", }, } eventCommand.cmd.AddCommand(newEventListCommand().cmd) eventCommand.cmd.AddCommand(newEventCreateCommand().cmd) + eventCommand.cmd.AddCommand(newEventEditCommand().cmd) return eventCommand } @@ -232,6 +233,125 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { return writeOK(map[string]any{"id": id}, output.WithSummary("Event created")) } +// edit + +type eventEditCommand struct { + cmd *cobra.Command + title string + date string + start string + end string + allDay bool + timezone string + reminders []string +} + +func newEventEditCommand() *eventEditCommand { + c := &eventEditCommand{} + c.cmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a calendar event", + Example: ` hey event edit 123 --title "Updated standup" + hey event edit 123 --date 2024-06-16 --start 10:00 --end 11:00 + hey event edit 123 --all-day + hey event edit 123 --reminder 30m --reminder 1h`, + Args: usageExactOneArg(), + RunE: c.run, + } + + c.cmd.Flags().StringVar(&c.title, "title", "", "New event title") + c.cmd.Flags().StringVar(&c.date, "date", "", "New event date YYYY-MM-DD (applies to both start and end)") + c.cmd.Flags().StringVar(&c.start, "start", "", "New start time HH:MM") + c.cmd.Flags().StringVar(&c.end, "end", "", "New end time HH:MM") + c.cmd.Flags().BoolVar(&c.allDay, "all-day", false, "Set as all-day event") + c.cmd.Flags().StringVar(&c.timezone, "timezone", "", "IANA timezone name") + c.cmd.Flags().StringArrayVar(&c.reminders, "reminder", nil, "Reminder duration (e.g. 30m, 1h, 2d, 1w); repeatable") + + return c +} + +func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage(fmt.Sprintf("invalid event ID: %s", args[0])) + } + + flags := cmd.Flags() + + if flags.Changed("date") { + if _, err := time.Parse("2006-01-02", c.date); err != nil { + return output.ErrUsage("--date must be in YYYY-MM-DD format") + } + } + if flags.Changed("start") { + if _, err := time.Parse("15:04", c.start); err != nil { + return output.ErrUsage("--start must be in HH:MM format") + } + } + if flags.Changed("end") { + if _, err := time.Parse("15:04", c.end); err != nil { + return output.ErrUsage("--end must be in HH:MM format") + } + } + + var reminders []time.Duration + if flags.Changed("reminder") { + reminders, err = parseReminders(c.reminders) + if err != nil { + return err + } + if reminders == nil { + reminders = []time.Duration{} + } + } + + if err := requireAuth(); err != nil { + return err + } + + params := hey.UpdateCalendarEventParams{} + if flags.Changed("title") { + v := c.title + params.Title = &v + } + if flags.Changed("date") { + v := c.date + params.StartsAt = &v + params.EndsAt = &v + } + if flags.Changed("start") { + v := c.start + params.StartTime = &v + } + if flags.Changed("end") { + v := c.end + params.EndTime = &v + } + if flags.Changed("all-day") { + v := c.allDay + params.AllDay = &v + } + if flags.Changed("timezone") { + v := c.timezone + params.TimeZone = &v + } + if flags.Changed("reminder") { + params.Reminders = reminders + } + + ctx := cmd.Context() + if err := sdk.CalendarEvents().Update(ctx, id, params); err != nil { + return convertSDKError(err) + } + + if writer.IsStyled() { + fmt.Fprintln(cmd.OutOrStdout(), "Event updated.") + return nil + } + + return writeOK(nil, output.WithSummary("Event updated")) +} + // parseReminderDuration parses reminder durations like "30m", "1h", "2d", "1w" // into time.Duration. Supports minutes, hours, days, and weeks. func parseReminderDuration(s string) (time.Duration, error) { diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 52e13b3..3b0756a 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -246,6 +246,91 @@ func TestEventCreateAllDay(t *testing.T) { } } +func eventEditServer(t *testing.T, captured *capturedRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "PATCH" && r.URL.Path == "/calendar/events/101": + body, _ := io.ReadAll(r.Body) + captured.set(string(body)) + w.Header().Set("Location", "/calendar/events/101") + w.WriteHeader(http.StatusFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func runEventEdit(t *testing.T, server *httptest.Server, args ...string) (string, 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) + fullArgs := append([]string{"event", "edit", "--base-url", server.URL}, args...) + root.SetArgs(fullArgs) + + err := root.Execute() + return buf.String(), err +} + +func TestEventEdit(t *testing.T) { + captured := &capturedRequest{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEventEdit(t, server, "101", "--title", "Updated standup") + if err != nil { + t.Fatalf("execute: %v", err) + } + + body := captured.get() + if !strings.Contains(body, "calendar_event%5Bsummary%5D=Updated+standup") { + t.Errorf("body missing summary fragment; body=%s", body) + } +} + +func TestEventEditInvalidID(t *testing.T) { + captured := &capturedRequest{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEventEdit(t, server, "notanumber", "--title", "x") + if err == nil { + t.Fatalf("expected error for invalid event ID") + } + if !strings.Contains(err.Error(), "invalid event ID") { + t.Errorf("expected 'invalid event ID' in error, got: %v", err) + } +} + +func TestEventEditOnlyChangedFields(t *testing.T) { + captured := &capturedRequest{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEventEdit(t, server, "101", "--title", "X") + if err != nil { + t.Fatalf("execute: %v", err) + } + + body := captured.get() + forbidden := []string{"starts_at", "ends_at", "all_day", "starts_at_time", "ends_at_time"} + for _, f := range forbidden { + if strings.Contains(body, f) { + t.Errorf("body should not contain %q when not changed; body=%s", f, body) + } + } +} + func TestEventListIdsOnly(t *testing.T) { server := eventServer(t) defer server.Close() From 019882d8b5cb8ddc2c084d2b2d52fd6e7433e6d6 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 21:46:59 -0300 Subject: [PATCH 04/28] Add hey event delete command --- .surface | 1 + internal/cmd/event.go | 44 ++++++++++++++++++++++- internal/cmd/event_test.go | 73 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/.surface b/.surface index 0f36f2c..2d656c0 100644 --- a/.surface +++ b/.surface @@ -53,6 +53,7 @@ hey event create --reminder hey event create --start hey event create --timezone hey event create --title +hey event delete hey event edit hey event edit --all-day hey event edit --date diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 5676c37..5f2668b 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -24,13 +24,14 @@ func newEventCommand() *eventCommand { Use: "event", Short: "Manage calendar events", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, create, edit. Lists events from the personal calendar by default, or from --calendar ID.", + "agent_notes": "Subcommands: list, create, edit, delete. Lists events from the personal calendar by default, or from --calendar ID.", }, } eventCommand.cmd.AddCommand(newEventListCommand().cmd) eventCommand.cmd.AddCommand(newEventCreateCommand().cmd) eventCommand.cmd.AddCommand(newEventEditCommand().cmd) + eventCommand.cmd.AddCommand(newEventDeleteCommand().cmd) return eventCommand } @@ -352,6 +353,47 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { return writeOK(nil, output.WithSummary("Event updated")) } +// delete + +type eventDeleteCommand struct { + cmd *cobra.Command +} + +func newEventDeleteCommand() *eventDeleteCommand { + c := &eventDeleteCommand{} + c.cmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a calendar event", + Example: ` hey event delete 123`, + Args: usageExactOneArg(), + RunE: c.run, + } + return c +} + +func (c *eventDeleteCommand) run(cmd *cobra.Command, args []string) error { + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage(fmt.Sprintf("invalid event ID: %s", args[0])) + } + + if err := requireAuth(); err != nil { + return err + } + + ctx := cmd.Context() + if err := sdk.CalendarEvents().Delete(ctx, id); err != nil { + return convertSDKError(err) + } + + if writer.IsStyled() { + fmt.Fprintln(cmd.OutOrStdout(), "Event deleted.") + return nil + } + + return writeOK(nil, output.WithSummary("Event deleted")) +} + // parseReminderDuration parses reminder durations like "30m", "1h", "2d", "1w" // into time.Duration. Supports minutes, hours, days, and weeks. func parseReminderDuration(s string) (time.Duration, error) { diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 3b0756a..daf80ac 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -331,6 +331,79 @@ func TestEventEditOnlyChangedFields(t *testing.T) { } } +type capturedMethodPath struct { + mu sync.Mutex + method string + path string +} + +func (c *capturedMethodPath) set(method, path string) { + c.mu.Lock() + defer c.mu.Unlock() + c.method = method + c.path = path +} + +func (c *capturedMethodPath) get() (string, string) { + c.mu.Lock() + defer c.mu.Unlock() + return c.method, c.path +} + +func eventDeleteServer(t *testing.T, captured *capturedMethodPath) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "DELETE" && r.URL.Path == "/calendar/events/101": + captured.set(r.Method, r.URL.Path) + w.Header().Set("Location", "/calendar") + w.WriteHeader(http.StatusFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func runEventDelete(t *testing.T, server *httptest.Server, args ...string) (string, 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) + fullArgs := append([]string{"event", "delete", "--base-url", server.URL}, args...) + root.SetArgs(fullArgs) + + err := root.Execute() + return buf.String(), err +} + +func TestEventDelete(t *testing.T) { + captured := &capturedMethodPath{} + server := eventDeleteServer(t, captured) + defer server.Close() + + _, err := runEventDelete(t, server, "101") + if err != nil { + t.Fatalf("execute: %v", err) + } + + method, path := captured.get() + if method != "DELETE" { + t.Errorf("expected DELETE method, got %q", method) + } + if path != "/calendar/events/101" { + t.Errorf("expected path /calendar/events/101, got %q", path) + } +} + func TestEventListIdsOnly(t *testing.T) { server := eventServer(t) defer server.Close() From 4c81bb73acdc27ece85ff534bbad1fed820a056c Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 21:48:34 -0300 Subject: [PATCH 05/28] Document hey event commands --- API-COVERAGE.md | 3 +++ README.md | 14 ++++++++++++++ skills/hey/SKILL.md | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 5ada42e..52700bc 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -32,3 +32,6 @@ The legacy `internal/client/` is used only for HTML-scraping gap operations mark | `/calendar/todos/{id}/completions.json` | POST | SDK `CalendarTodos().Complete` | `hey todo complete ` | covered | | `/calendar/todos/{id}/completions.json` | DELETE | SDK `CalendarTodos().Uncomplete` | `hey todo uncomplete ` | covered | | `/calendar/todos/{id}.json` | DELETE | SDK `CalendarTodos().Delete` | `hey todo delete ` | covered | +| `/calendar/events` | POST | SDK `CalendarEvents().Create` | `hey event create` | covered | +| `/calendar/events/{id}` | PATCH | SDK `CalendarEvents().Update` | `hey event edit ` | covered | +| `/calendar/events/{id}` | DELETE | SDK `CalendarEvents().Delete` | `hey event delete ` | covered | diff --git a/README.md b/README.md index 8525306..b9d3073 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,20 @@ hey calendars # list calendars hey recordings 1 --starts-on 2026-01-01 --ends-on 2026-01-31 # list events in a calendar ``` +### Events + +```bash +hey event list # list events (personal calendar by default) +hey event list --calendar 123 --limit 10 +hey event create --title "Team sync" --date 2026-06-15 --start 09:00 --end 10:00 +hey event create --title "Holiday" --date 2026-06-15 --all-day +hey event create --title "Review" --date 2026-06-15 --start 14:00 --end 15:00 --reminder 30m --reminder 1h +hey event edit 123 --title "Updated standup" +hey event delete 123 +``` + +Reminder durations accept `30m`, `1h`, `2d`, `1w`. + ### Todos ```bash diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 3159689..8fb779e 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -20,6 +20,8 @@ triggers: - hey recordings # Todos - hey todo + # Events + - hey event # Seen/unseen - hey seen - hey unseen @@ -96,6 +98,10 @@ CLI for HEY email: mailboxes, email threads, replies, compose, calendars, todos, | Complete todo | `hey todo complete 123` | | Uncomplete todo | `hey todo uncomplete 123` | | Delete todo | `hey todo delete 123` | +| List events | `hey event list --json` | +| Create event | `hey event create --title "Sync" --date 2024-06-15 --start 09:00 --end 10:00` | +| Edit event | `hey event edit 123 --title "Updated"` | +| Delete event | `hey event delete 123` | | Mark as seen | `hey seen 12345` | | Mark as unseen | `hey unseen 12345` | | Complete habit | `hey habit complete 123` | @@ -219,6 +225,20 @@ hey todo uncomplete 123 # Mark incomplete hey todo delete 123 # Delete a todo ``` +### Events + +```bash +hey event list --json # List events (personal calendar by default) +hey event list --calendar 123 --limit 10 --json # List events in a specific calendar +hey event create --title "Team sync" --date 2024-06-15 --start 09:00 --end 10:00 +hey event create --title "Holiday" --date 2024-06-15 --all-day +hey event create --title "Review" --date 2024-06-15 --start 14:00 --end 15:00 --reminder 30m --reminder 1h +hey event edit 123 --title "Updated standup" # Edit any subset of fields +hey event delete 123 # Delete an event +``` + +Reminder durations: `30m`, `1h`, `2d`, `1w` (repeat `--reminder` for multiple). Timezone defaults to local; override with `--timezone` (IANA name). + ### Habits ```bash From 1b6e392143bfe7afe4443d3134e822fc19d613b7 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:14:30 -0300 Subject: [PATCH 06/28] Pass date range when listing events for a specific calendar The /calendars/:id/recordings endpoint rejects requests without starts_on/ends_on with 400. listPersonalRecordings already handles this via personalRecordingsLookback/Lookahead constants; apply the same window to the explicit --calendar path. --- internal/cmd/event.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 5f2668b..de5306d 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -73,7 +73,11 @@ func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { var events []generated.Recording if c.calendarID != 0 { - resp, err := sdk.Calendars().GetRecordings(ctx, c.calendarID, nil) + now := time.Now() + resp, err := sdk.Calendars().GetRecordings(ctx, c.calendarID, &generated.GetCalendarRecordingsParams{ + StartsOn: now.AddDate(-personalRecordingsLookbackYears, 0, 0).Format("2006-01-02"), + EndsOn: now.AddDate(personalRecordingsLookaheadYears, 0, 0).Format("2006-01-02"), + }) if err != nil { return convertSDKError(err) } From fc7c79504fd629a53e2e2fca3c3b937a462d6be2 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:22:15 -0300 Subject: [PATCH 07/28] Accept calendar name or ID on hey event list/create --calendar now accepts either a numeric ID or a case-insensitive owned calendar name. When a name can't be resolved (or is ambiguous), the CLI surfaces a helpful error pointing to 'hey calendars'. When no --calendar is given and the default personal calendar can't be determined, the error now lists owned calendars so the user can pick one. --- README.md | 2 +- internal/cmd/event.go | 128 +++++++++++++++++++++----- internal/cmd/event_test.go | 183 +++++++++++++++++++++++++++++++++++++ skills/hey/SKILL.md | 2 +- 4 files changed, 291 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b9d3073..5d84f20 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ hey recordings 1 --starts-on 2026-01-01 --ends-on 2026-01-31 # list events in a ```bash hey event list # list events (personal calendar by default) -hey event list --calendar 123 --limit 10 +hey event list --calendar --limit 10 # name matches owned calendars case-insensitively hey event create --title "Team sync" --date 2026-06-15 --start 09:00 --end 10:00 hey event create --title "Holiday" --date 2026-06-15 --all-day hey event create --title "Review" --date 2026-06-15 --start 14:00 --end 15:00 --reminder 30m --reminder 1h diff --git a/internal/cmd/event.go b/internal/cmd/event.go index de5306d..602d671 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -1,7 +1,9 @@ package cmd import ( + "context" "fmt" + "sort" "strconv" "strings" "time" @@ -24,7 +26,7 @@ func newEventCommand() *eventCommand { Use: "event", Short: "Manage calendar events", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, create, edit, delete. Lists events from the personal calendar by default, or from --calendar ID.", + "agent_notes": "Subcommands: list, create, edit, delete. Lists events from the personal calendar by default, or from --calendar (accepts ID or owned calendar name, case-insensitive).", }, } @@ -39,10 +41,10 @@ func newEventCommand() *eventCommand { // list type eventListCommand struct { - cmd *cobra.Command - limit int - all bool - calendarID int64 + cmd *cobra.Command + limit int + all bool + calendar string } func newEventListCommand() *eventListCommand { @@ -52,6 +54,7 @@ func newEventListCommand() *eventListCommand { Short: "List calendar events", Example: ` hey event list hey event list --limit 10 + hey event list --calendar Work hey event list --calendar 123 hey event list --ids-only`, RunE: eventListCommand.run, @@ -59,7 +62,7 @@ func newEventListCommand() *eventListCommand { eventListCommand.cmd.Flags().IntVar(&eventListCommand.limit, "limit", 0, "Maximum number of events to show") eventListCommand.cmd.Flags().BoolVar(&eventListCommand.all, "all", false, "Fetch all results (override --limit)") - eventListCommand.cmd.Flags().Int64Var(&eventListCommand.calendarID, "calendar", 0, "Calendar ID (defaults to personal calendar)") + eventListCommand.cmd.Flags().StringVar(&eventListCommand.calendar, "calendar", "", "Calendar ID or name (defaults to personal calendar; names matched case-insensitively against owned calendars)") return eventListCommand } @@ -72,9 +75,13 @@ func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() var events []generated.Recording - if c.calendarID != 0 { + if c.calendar != "" { + calID, err := resolveCalendarID(ctx, c.calendar) + if err != nil { + return err + } now := time.Now() - resp, err := sdk.Calendars().GetRecordings(ctx, c.calendarID, &generated.GetCalendarRecordingsParams{ + resp, err := sdk.Calendars().GetRecordings(ctx, calID, &generated.GetCalendarRecordingsParams{ StartsOn: now.AddDate(-personalRecordingsLookbackYears, 0, 0).Format("2006-01-02"), EndsOn: now.AddDate(personalRecordingsLookaheadYears, 0, 0).Format("2006-01-02"), }) @@ -123,15 +130,15 @@ func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { // create type eventCreateCommand struct { - cmd *cobra.Command - title string - date string - allDay bool - start string - end string - calendarID int64 - timezone string - reminders []string + cmd *cobra.Command + title string + date string + allDay bool + start string + end string + calendar string + timezone string + reminders []string } func newEventCreateCommand() *eventCreateCommand { @@ -150,7 +157,7 @@ func newEventCreateCommand() *eventCreateCommand { c.cmd.Flags().BoolVar(&c.allDay, "all-day", false, "Create as all-day event") c.cmd.Flags().StringVar(&c.start, "start", "", "Start time HH:MM (required unless --all-day)") c.cmd.Flags().StringVar(&c.end, "end", "", "End time HH:MM (required unless --all-day)") - c.cmd.Flags().Int64Var(&c.calendarID, "calendar", 0, "Calendar ID (defaults to personal calendar)") + c.cmd.Flags().StringVar(&c.calendar, "calendar", "", "Calendar ID or name (defaults to personal calendar; names matched case-insensitively against owned calendars)") c.cmd.Flags().StringVar(&c.timezone, "timezone", "", "IANA timezone name (defaults to local)") c.cmd.Flags().StringArrayVar(&c.reminders, "reminder", nil, "Reminder duration (e.g. 30m, 1h, 2d, 1w); repeatable") @@ -193,15 +200,27 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - calID := c.calendarID - if calID == 0 { + var calID int64 + if c.calendar != "" { + id, err := resolveCalendarID(ctx, c.calendar) + if err != nil { + return err + } + calID = id + } else { payload, err := sdk.Calendars().List(ctx) if err != nil { return convertSDKError(err) } - id, err := findPersonalCalendarID(unwrapCalendars(payload)) + calendars := unwrapCalendars(payload) + id, err := findPersonalCalendarID(calendars) if err != nil { - return output.ErrNotFound("calendar", "personal") + msg := "Couldn't determine default calendar. Pass --calendar ." + list := formatOwnedCalendarList(calendars) + if list != "" { + msg += " Available:\n" + list + } + return output.ErrUsage(msg) } calID = id } @@ -442,6 +461,71 @@ func parseReminders(in []string) ([]time.Duration, error) { return out, nil } +// resolveCalendarID maps user input (numeric ID or calendar name) to a +// calendar ID. Numeric input is returned as-is with no SDK call. Otherwise the +// calendar list is fetched and filtered to Owned == true, matching Name +// case-insensitively. Zero matches or multiple matches yield a usage error. +func resolveCalendarID(ctx context.Context, input string) (int64, error) { + trimmed := strings.TrimSpace(input) + if id, err := strconv.ParseInt(trimmed, 10, 64); err == nil && id > 0 { + return id, nil + } + + payload, err := sdk.Calendars().List(ctx) + if err != nil { + return 0, convertSDKError(err) + } + calendars := unwrapCalendars(payload) + + var matches []generated.Calendar + for _, cal := range calendars { + if !cal.Owned { + continue + } + if strings.EqualFold(cal.Name, trimmed) { + matches = append(matches, cal) + } + } + + switch len(matches) { + case 1: + return matches[0].Id, nil + case 0: + return 0, output.ErrUsageHint( + fmt.Sprintf("no owned calendar named %q", trimmed), + "run 'hey calendars' to list your calendars, then use --calendar ", + ) + default: + var b strings.Builder + fmt.Fprintf(&b, "multiple owned calendars named %q; pick one by ID:\n", trimmed) + sort.Slice(matches, func(i, j int) bool { return matches[i].Id < matches[j].Id }) + for _, cal := range matches { + fmt.Fprintf(&b, " %d\t%s\n", cal.Id, cal.Name) + } + return 0, output.ErrUsage(strings.TrimRight(b.String(), "\n")) + } +} + +// formatOwnedCalendarList renders owned calendars as " ID\tName" lines sorted +// by ID. Returns an empty string when there are no owned calendars. +func formatOwnedCalendarList(calendars []generated.Calendar) string { + owned := make([]generated.Calendar, 0, len(calendars)) + for _, cal := range calendars { + if cal.Owned { + owned = append(owned, cal) + } + } + if len(owned) == 0 { + return "" + } + sort.Slice(owned, func(i, j int) bool { return owned[i].Id < owned[j].Id }) + var b strings.Builder + for _, cal := range owned { + fmt.Fprintf(&b, " %d\t%s\n", cal.Id, cal.Name) + } + return strings.TrimRight(b.String(), "\n") +} + // localTimezoneName returns the local IANA timezone name, falling back to // "UTC" if the runtime didn't resolve one. func localTimezoneName() string { diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index daf80ac..ece80c9 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -9,6 +9,8 @@ import ( "strings" "sync" "testing" + + "github.com/basecamp/hey-cli/internal/apierr" ) func eventServer(t *testing.T) *httptest.Server { @@ -404,6 +406,187 @@ func TestEventDelete(t *testing.T) { } } +// eventCreateMultiCalendarServer returns a calendar list with multiple owned +// calendars. The caller specifies the JSON payload via calendarsPayload so +// tests can model name-match scenarios (unique, ambiguous, missing, no +// personal). +func eventCreateCustomServer(t *testing.T, captured *capturedRequest, calendarsPayload []map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/calendars.json": + resp := map[string]any{"calendars": calendarsPayload} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + case r.Method == "POST" && r.URL.Path == "/calendar/events": + body, _ := io.ReadAll(r.Body) + captured.set(string(body)) + w.Header().Set("Location", "/calendar/events/999") + w.WriteHeader(http.StatusFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func TestEventCreate_CalendarByName(t *testing.T) { + captured := &capturedRequest{} + server := eventCreateCustomServer(t, captured, []map[string]any{ + {"calendar": map[string]any{"id": 42, "name": "Personal", "personal": true, "owned": true}}, + {"calendar": map[string]any{"id": 791879, "name": "Work", "owned": true}}, + }) + defer server.Close() + + _, err := runEventCreate(t, server, + "--calendar", "Work", + "--title", "T", + "--date", "2024-06-15", + "--all-day", + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + body := captured.get() + if !strings.Contains(body, "calendar_event%5Bcalendar_id%5D=791879") { + t.Errorf("body missing calendar_id=791879; body=%s", body) + } +} + +func TestEventCreate_CalendarByNameAmbiguous(t *testing.T) { + captured := &capturedRequest{} + server := eventCreateCustomServer(t, captured, []map[string]any{ + {"calendar": map[string]any{"id": 100, "name": "Personal", "owned": true}}, + {"calendar": map[string]any{"id": 200, "name": "Personal", "owned": true}}, + }) + defer server.Close() + + _, err := runEventCreate(t, server, + "--calendar", "Personal", + "--title", "T", + "--date", "2024-06-15", + "--all-day", + ) + if err == nil { + t.Fatalf("expected error for ambiguous calendar name") + } + msg := err.Error() + if !strings.Contains(msg, "100") || !strings.Contains(msg, "200") { + t.Errorf("error should mention both IDs, got: %v", msg) + } + if !strings.Contains(strings.ToLower(msg), "id") { + t.Errorf("error should say to pick by ID, got: %v", msg) + } +} + +func TestEventCreate_CalendarNotFound(t *testing.T) { + captured := &capturedRequest{} + server := eventCreateCustomServer(t, captured, []map[string]any{ + {"calendar": map[string]any{"id": 42, "name": "Personal", "personal": true, "owned": true}}, + {"calendar": map[string]any{"id": 99, "name": "Work", "owned": true}}, + }) + defer server.Close() + + _, err := runEventCreate(t, server, + "--calendar", "Nope", + "--title", "T", + "--date", "2024-06-15", + "--all-day", + ) + if err == nil { + t.Fatalf("expected error for missing calendar name") + } + ae := apierr.AsError(err) + combined := ae.Message + " " + ae.Hint + if !strings.Contains(combined, "hey calendars") { + t.Errorf("error should hint at 'hey calendars', got msg=%q hint=%q", ae.Message, ae.Hint) + } +} + +func eventListCustomServer(t *testing.T, calendarsPayload []map[string]any, recordingsByID map[int64]map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/calendars.json": + resp := map[string]any{"calendars": calendarsPayload} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/calendars/") && strings.HasSuffix(r.URL.Path, "/recordings"): + // extract id + seg := strings.TrimPrefix(r.URL.Path, "/calendars/") + seg = strings.TrimSuffix(seg, "/recordings") + var id int64 + for _, c := range seg { + if c < '0' || c > '9' { + id = 0 + break + } + id = id*10 + int64(c-'0') + } + resp, ok := recordingsByID[id] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func TestEventList_CalendarByName(t *testing.T) { + server := eventListCustomServer(t, + []map[string]any{ + {"calendar": map[string]any{"id": 791879, "name": "Work", "owned": true}}, + }, + map[int64]map[string]any{ + 791879: { + "Calendar::Event": []map[string]any{ + {"id": 555, "title": "Work meeting", "starts_at": "2024-05-01T09:00:00Z", "ends_at": "2024-05-01T09:30:00Z"}, + }, + }, + }, + ) + defer server.Close() + + out, err := runEventList(t, server, "--styled", "--calendar", "Work") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "Work meeting") { + t.Errorf("expected output to contain event from calendar 791879; out=%q", out) + } +} + +func TestEventCreate_DefaultCalendarFailsShowsList(t *testing.T) { + captured := &capturedRequest{} + server := eventCreateCustomServer(t, captured, []map[string]any{ + {"calendar": map[string]any{"id": 6037, "name": "Maybe", "owned": true}}, + {"calendar": map[string]any{"id": 791879, "name": "Work", "owned": true}}, + }) + defer server.Close() + + _, err := runEventCreate(t, server, + "--title", "T", + "--date", "2024-06-15", + "--all-day", + ) + if err == nil { + t.Fatalf("expected error when no default calendar") + } + msg := err.Error() + if !strings.Contains(msg, "--calendar") { + t.Errorf("error should mention --calendar, got: %v", msg) + } + if !strings.Contains(msg, "6037") || !strings.Contains(msg, "791879") { + t.Errorf("error should list available calendar IDs, got: %v", msg) + } + if !strings.Contains(msg, "Maybe") || !strings.Contains(msg, "Work") { + t.Errorf("error should list available calendar names, got: %v", msg) + } +} + func TestEventListIdsOnly(t *testing.T) { server := eventServer(t) defer server.Close() diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 8fb779e..6e4c050 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -229,7 +229,7 @@ hey todo delete 123 # Delete a todo ```bash hey event list --json # List events (personal calendar by default) -hey event list --calendar 123 --limit 10 --json # List events in a specific calendar +hey event list --calendar --limit 10 --json # List events in a specific calendar (names match owned calendars case-insensitively) hey event create --title "Team sync" --date 2024-06-15 --start 09:00 --end 10:00 hey event create --title "Holiday" --date 2024-06-15 --all-day hey event create --title "Review" --date 2024-06-15 --start 14:00 --end 15:00 --reminder 30m --reminder 1h From 575f7526324c6d51a0c4f066e69b3b9f0e5c8263 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:23:48 -0300 Subject: [PATCH 08/28] Show available calendars on 404 when using default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When findPersonalCalendarID picks a calendar that HEY won't accept new events in (some accounts have an orphaned personal:true calendar), the raw 404 is unhelpful. Fall back to listing owned calendars and suggest --calendar explicitly — only triggered when --calendar was not set. --- internal/cmd/event.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 602d671..589de64 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -246,6 +246,16 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { id, err := sdk.CalendarEvents().Create(ctx, params) if err != nil { + if c.calendar == "" && hey.AsError(err).HTTPStatus == 404 { + payload, lerr := sdk.Calendars().List(ctx) + if lerr == nil { + msg := fmt.Sprintf("Couldn't create event in default calendar (id=%d). Pass --calendar .", calID) + if list := formatOwnedCalendarList(unwrapCalendars(payload)); list != "" { + msg += " Available:\n" + list + } + return output.ErrUsage(msg) + } + } return convertSDKError(err) } From ab1700d556797b8832de2a8bdbc0e157d83c2df0 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:26:23 -0300 Subject: [PATCH 09/28] Restore --ids-only pipe hint in event agent_notes The Task 9 edit replaced the prior hint with --calendar info only; keep both since agents benefit from knowing the pipe-workflow too. --- internal/cmd/event.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 589de64..b97f79b 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -26,7 +26,7 @@ func newEventCommand() *eventCommand { Use: "event", Short: "Manage calendar events", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, create, edit, delete. Lists events from the personal calendar by default, or from --calendar (accepts ID or owned calendar name, case-insensitive).", + "agent_notes": "Subcommands: list, create, edit, delete. Defaults to the personal calendar; pass --calendar (ID or owned calendar name, case-insensitive) to target another. Use list --ids-only to pipe IDs to edit/delete.", }, } From 88d383cde095b483aa87596fc6c4d0ef8bb423a9 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:36:26 -0300 Subject: [PATCH 10/28] Simplify event command and tests Aggregated cleanup from code review: - event.go: reuse default calendar list on 404 fallback, dedupe filter call, rename list-command local to match siblings, drop unreachable reminder nil guard - event_test.go: unify four runEvent* helpers into one, drop redundant server factories in favor of the Custom variants, use strconv.ParseInt, merge capturedRequest and capturedMethodPath --- internal/cmd/event.go | 47 +++-- internal/cmd/event_test.go | 346 +++++++++++++------------------------ 2 files changed, 137 insertions(+), 256 deletions(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index b97f79b..cc11f5c 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -48,8 +48,8 @@ type eventListCommand struct { } func newEventListCommand() *eventListCommand { - eventListCommand := &eventListCommand{} - eventListCommand.cmd = &cobra.Command{ + c := &eventListCommand{} + c.cmd = &cobra.Command{ Use: "list", Short: "List calendar events", Example: ` hey event list @@ -57,14 +57,14 @@ func newEventListCommand() *eventListCommand { hey event list --calendar Work hey event list --calendar 123 hey event list --ids-only`, - RunE: eventListCommand.run, + RunE: c.run, } - eventListCommand.cmd.Flags().IntVar(&eventListCommand.limit, "limit", 0, "Maximum number of events to show") - eventListCommand.cmd.Flags().BoolVar(&eventListCommand.all, "all", false, "Fetch all results (override --limit)") - eventListCommand.cmd.Flags().StringVar(&eventListCommand.calendar, "calendar", "", "Calendar ID or name (defaults to personal calendar; names matched case-insensitively against owned calendars)") + c.cmd.Flags().IntVar(&c.limit, "limit", 0, "Maximum number of events to show") + c.cmd.Flags().BoolVar(&c.all, "all", false, "Fetch all results (override --limit)") + c.cmd.Flags().StringVar(&c.calendar, "calendar", "", "Calendar ID or name (defaults to personal calendar; names matched case-insensitively against owned calendars)") - return eventListCommand + return c } func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { @@ -74,28 +74,28 @@ func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - var events []generated.Recording + var resp *generated.CalendarRecordingsResponse if c.calendar != "" { calID, err := resolveCalendarID(ctx, c.calendar) if err != nil { return err } now := time.Now() - resp, err := sdk.Calendars().GetRecordings(ctx, calID, &generated.GetCalendarRecordingsParams{ + resp, err = sdk.Calendars().GetRecordings(ctx, calID, &generated.GetCalendarRecordingsParams{ StartsOn: now.AddDate(-personalRecordingsLookbackYears, 0, 0).Format("2006-01-02"), EndsOn: now.AddDate(personalRecordingsLookaheadYears, 0, 0).Format("2006-01-02"), }) if err != nil { return convertSDKError(err) } - events = filterRecordingsByType(resp, "Calendar::Event") } else { - resp, err := listPersonalRecordings(ctx) + var err error + resp, err = listPersonalRecordings(ctx) if err != nil { return err } - events = filterRecordingsByType(resp, "Calendar::Event") } + events := filterRecordingsByType(resp, "Calendar::Event") total := len(events) if c.limit > 0 && !c.all && len(events) > c.limit { @@ -200,6 +200,8 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + var defaultCalendars []generated.Calendar // populated only when taking the default branch + var calID int64 if c.calendar != "" { id, err := resolveCalendarID(ctx, c.calendar) @@ -212,12 +214,11 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return convertSDKError(err) } - calendars := unwrapCalendars(payload) - id, err := findPersonalCalendarID(calendars) + defaultCalendars = unwrapCalendars(payload) + id, err := findPersonalCalendarID(defaultCalendars) if err != nil { msg := "Couldn't determine default calendar. Pass --calendar ." - list := formatOwnedCalendarList(calendars) - if list != "" { + if list := formatOwnedCalendarList(defaultCalendars); list != "" { msg += " Available:\n" + list } return output.ErrUsage(msg) @@ -247,14 +248,11 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { id, err := sdk.CalendarEvents().Create(ctx, params) if err != nil { if c.calendar == "" && hey.AsError(err).HTTPStatus == 404 { - payload, lerr := sdk.Calendars().List(ctx) - if lerr == nil { - msg := fmt.Sprintf("Couldn't create event in default calendar (id=%d). Pass --calendar .", calID) - if list := formatOwnedCalendarList(unwrapCalendars(payload)); list != "" { - msg += " Available:\n" + list - } - return output.ErrUsage(msg) + msg := fmt.Sprintf("Couldn't create event in default calendar (id=%d). Pass --calendar .", calID) + if list := formatOwnedCalendarList(defaultCalendars); list != "" { + msg += " Available:\n" + list } + return output.ErrUsage(msg) } return convertSDKError(err) } @@ -334,9 +332,6 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return err } - if reminders == nil { - reminders = []time.Duration{} - } } if err := requireAuth(); err != nil { diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index ece80c9..26b4f41 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "sync" "testing" @@ -13,50 +14,67 @@ import ( "github.com/basecamp/hey-cli/internal/apierr" ) -func eventServer(t *testing.T) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/calendars.json": - resp := map[string]any{ - "calendars": []map[string]any{ - { - "calendar": map[string]any{ - "id": 42, - "name": "Personal", - "personal": true, - }, - }, - }, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/calendars/") && strings.HasSuffix(r.URL.Path, "/recordings"): - resp := map[string]any{ - "Calendar::Event": []map[string]any{ - { - "id": 101, - "title": "Team standup", - "starts_at": "2024-05-01T09:00:00Z", - "ends_at": "2024-05-01T09:30:00Z", - }, - { - "id": 102, - "title": "Lunch meeting", - "starts_at": "2024-05-02T12:00:00Z", - "ends_at": "2024-05-02T13:00:00Z", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - default: - w.WriteHeader(http.StatusNotFound) - } - })) +// capturedHTTP records the method, path, and body of a captured request so +// tests can assert on whichever fields they care about. +type capturedHTTP struct { + mu sync.Mutex + method string + path string + body string +} + +func (c *capturedHTTP) set(method, path, body string) { + c.mu.Lock() + defer c.mu.Unlock() + c.method = method + c.path = path + c.body = body +} + +func (c *capturedHTTP) getBody() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.body +} + +func (c *capturedHTTP) getMethodPath() (string, string) { + c.mu.Lock() + defer c.mu.Unlock() + return c.method, c.path } -func runEventList(t *testing.T, server *httptest.Server, args ...string) (string, error) { +func defaultCalendarsPayload() []map[string]any { + return []map[string]any{ + { + "calendar": map[string]any{ + "id": 42, + "name": "Personal", + "personal": true, + }, + }, + } +} + +func defaultEventRecordings() map[string]any { + return map[string]any{ + "Calendar::Event": []map[string]any{ + { + "id": 101, + "title": "Team standup", + "starts_at": "2024-05-01T09:00:00Z", + "ends_at": "2024-05-01T09:30:00Z", + }, + { + "id": 102, + "title": "Lunch meeting", + "starts_at": "2024-05-02T12:00:00Z", + "ends_at": "2024-05-02T13:00:00Z", + }, + }, + } +} + +func runEvent(t *testing.T, server *httptest.Server, sub string, args ...string) (string, error) { t.Helper() t.Setenv("HEY_TOKEN", "test-token") t.Setenv("HEY_NO_KEYRING", "1") @@ -70,7 +88,7 @@ func runEventList(t *testing.T, server *httptest.Server, args ...string) (string var buf bytes.Buffer root.SetOut(&buf) root.SetErr(&buf) - fullArgs := append([]string{"event", "list", "--base-url", server.URL}, args...) + fullArgs := append([]string{"event", sub, "--base-url", server.URL}, args...) root.SetArgs(fullArgs) err := root.Execute() @@ -78,10 +96,12 @@ func runEventList(t *testing.T, server *httptest.Server, args ...string) (string } func TestEventListDefault(t *testing.T) { - server := eventServer(t) + server := eventListCustomServer(t, defaultCalendarsPayload(), map[int64]map[string]any{ + 42: defaultEventRecordings(), + }) defer server.Close() - out, err := runEventList(t, server, "--styled") + out, err := runEvent(t, server, "list", "--styled") if err != nil { t.Fatalf("execute: %v", err) } @@ -94,10 +114,12 @@ func TestEventListDefault(t *testing.T) { } func TestEventListLimit(t *testing.T) { - server := eventServer(t) + server := eventListCustomServer(t, defaultCalendarsPayload(), map[int64]map[string]any{ + 42: defaultEventRecordings(), + }) defer server.Close() - out, err := runEventList(t, server, "--styled", "--limit", "1") + out, err := runEvent(t, server, "list", "--styled", "--limit", "1") if err != nil { t.Fatalf("execute: %v", err) } @@ -109,46 +131,17 @@ func TestEventListLimit(t *testing.T) { } } -// eventCreateServer captures the POST body so assertions can verify the -// form-encoded payload sent to the server. -type capturedRequest struct { - mu sync.Mutex - body string -} - -func (c *capturedRequest) set(s string) { - c.mu.Lock() - defer c.mu.Unlock() - c.body = s -} - -func (c *capturedRequest) get() string { - c.mu.Lock() - defer c.mu.Unlock() - return c.body -} - -func eventCreateServer(t *testing.T, captured *capturedRequest) *httptest.Server { +func eventCreateCustomServer(t *testing.T, captured *capturedHTTP, calendarsPayload []map[string]any) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/calendars.json": - resp := map[string]any{ - "calendars": []map[string]any{ - { - "calendar": map[string]any{ - "id": 42, - "name": "Personal", - "personal": true, - }, - }, - }, - } + resp := map[string]any{"calendars": calendarsPayload} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) case r.Method == "POST" && r.URL.Path == "/calendar/events": body, _ := io.ReadAll(r.Body) - captured.set(string(body)) + captured.set(r.Method, r.URL.Path, string(body)) w.Header().Set("Location", "/calendar/events/999") w.WriteHeader(http.StatusFound) default: @@ -157,33 +150,12 @@ func eventCreateServer(t *testing.T, captured *capturedRequest) *httptest.Server })) } -func runEventCreate(t *testing.T, server *httptest.Server, args ...string) (string, 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) - fullArgs := append([]string{"event", "create", "--base-url", server.URL}, args...) - root.SetArgs(fullArgs) - - err := root.Execute() - return buf.String(), err -} - func TestEventCreateRequiresTitle(t *testing.T) { - captured := &capturedRequest{} - server := eventCreateServer(t, captured) + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) defer server.Close() - _, err := runEventCreate(t, server, "--date", "2024-06-15", "--all-day") + _, err := runEvent(t, server, "create", "--date", "2024-06-15", "--all-day") if err == nil { t.Fatalf("expected error when --title missing") } @@ -193,11 +165,11 @@ func TestEventCreateRequiresTitle(t *testing.T) { } func TestEventCreateTimed(t *testing.T) { - captured := &capturedRequest{} - server := eventCreateServer(t, captured) + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) defer server.Close() - _, err := runEventCreate(t, server, + _, err := runEvent(t, server, "create", "--title", "Team sync", "--date", "2024-06-15", "--start", "09:00", @@ -208,7 +180,7 @@ func TestEventCreateTimed(t *testing.T) { t.Fatalf("execute: %v", err) } - body := captured.get() + body := captured.getBody() wantFragments := []string{ "calendar_event%5Bsummary%5D=Team+sync", "calendar_event%5Bstarts_at%5D=2024-06-15", @@ -225,11 +197,11 @@ func TestEventCreateTimed(t *testing.T) { } func TestEventCreateAllDay(t *testing.T) { - captured := &capturedRequest{} - server := eventCreateServer(t, captured) + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) defer server.Close() - _, err := runEventCreate(t, server, + _, err := runEvent(t, server, "create", "--title", "Holiday", "--date", "2024-06-15", "--all-day", @@ -239,7 +211,7 @@ func TestEventCreateAllDay(t *testing.T) { t.Fatalf("execute: %v", err) } - body := captured.get() + body := captured.getBody() if !strings.Contains(body, "calendar_event%5Ball_day%5D=1") { t.Errorf("body missing all_day=1; body=%s", body) } @@ -248,13 +220,13 @@ func TestEventCreateAllDay(t *testing.T) { } } -func eventEditServer(t *testing.T, captured *capturedRequest) *httptest.Server { +func eventEditServer(t *testing.T, captured *capturedHTTP) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "PATCH" && r.URL.Path == "/calendar/events/101": body, _ := io.ReadAll(r.Body) - captured.set(string(body)) + captured.set(r.Method, r.URL.Path, string(body)) w.Header().Set("Location", "/calendar/events/101") w.WriteHeader(http.StatusFound) default: @@ -263,49 +235,28 @@ func eventEditServer(t *testing.T, captured *capturedRequest) *httptest.Server { })) } -func runEventEdit(t *testing.T, server *httptest.Server, args ...string) (string, 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) - fullArgs := append([]string{"event", "edit", "--base-url", server.URL}, args...) - root.SetArgs(fullArgs) - - err := root.Execute() - return buf.String(), err -} - func TestEventEdit(t *testing.T) { - captured := &capturedRequest{} + captured := &capturedHTTP{} server := eventEditServer(t, captured) defer server.Close() - _, err := runEventEdit(t, server, "101", "--title", "Updated standup") + _, err := runEvent(t, server, "edit", "101", "--title", "Updated standup") if err != nil { t.Fatalf("execute: %v", err) } - body := captured.get() + body := captured.getBody() if !strings.Contains(body, "calendar_event%5Bsummary%5D=Updated+standup") { t.Errorf("body missing summary fragment; body=%s", body) } } func TestEventEditInvalidID(t *testing.T) { - captured := &capturedRequest{} + captured := &capturedHTTP{} server := eventEditServer(t, captured) defer server.Close() - _, err := runEventEdit(t, server, "notanumber", "--title", "x") + _, err := runEvent(t, server, "edit", "notanumber", "--title", "x") if err == nil { t.Fatalf("expected error for invalid event ID") } @@ -315,16 +266,16 @@ func TestEventEditInvalidID(t *testing.T) { } func TestEventEditOnlyChangedFields(t *testing.T) { - captured := &capturedRequest{} + captured := &capturedHTTP{} server := eventEditServer(t, captured) defer server.Close() - _, err := runEventEdit(t, server, "101", "--title", "X") + _, err := runEvent(t, server, "edit", "101", "--title", "X") if err != nil { t.Fatalf("execute: %v", err) } - body := captured.get() + body := captured.getBody() forbidden := []string{"starts_at", "ends_at", "all_day", "starts_at_time", "ends_at_time"} for _, f := range forbidden { if strings.Contains(body, f) { @@ -333,31 +284,12 @@ func TestEventEditOnlyChangedFields(t *testing.T) { } } -type capturedMethodPath struct { - mu sync.Mutex - method string - path string -} - -func (c *capturedMethodPath) set(method, path string) { - c.mu.Lock() - defer c.mu.Unlock() - c.method = method - c.path = path -} - -func (c *capturedMethodPath) get() (string, string) { - c.mu.Lock() - defer c.mu.Unlock() - return c.method, c.path -} - -func eventDeleteServer(t *testing.T, captured *capturedMethodPath) *httptest.Server { +func eventDeleteServer(t *testing.T, captured *capturedHTTP) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "DELETE" && r.URL.Path == "/calendar/events/101": - captured.set(r.Method, r.URL.Path) + captured.set(r.Method, r.URL.Path, "") w.Header().Set("Location", "/calendar") w.WriteHeader(http.StatusFound) default: @@ -366,38 +298,17 @@ func eventDeleteServer(t *testing.T, captured *capturedMethodPath) *httptest.Ser })) } -func runEventDelete(t *testing.T, server *httptest.Server, args ...string) (string, 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) - fullArgs := append([]string{"event", "delete", "--base-url", server.URL}, args...) - root.SetArgs(fullArgs) - - err := root.Execute() - return buf.String(), err -} - func TestEventDelete(t *testing.T) { - captured := &capturedMethodPath{} + captured := &capturedHTTP{} server := eventDeleteServer(t, captured) defer server.Close() - _, err := runEventDelete(t, server, "101") + _, err := runEvent(t, server, "delete", "101") if err != nil { t.Fatalf("execute: %v", err) } - method, path := captured.get() + method, path := captured.getMethodPath() if method != "DELETE" { t.Errorf("expected DELETE method, got %q", method) } @@ -406,38 +317,15 @@ func TestEventDelete(t *testing.T) { } } -// eventCreateMultiCalendarServer returns a calendar list with multiple owned -// calendars. The caller specifies the JSON payload via calendarsPayload so -// tests can model name-match scenarios (unique, ambiguous, missing, no -// personal). -func eventCreateCustomServer(t *testing.T, captured *capturedRequest, calendarsPayload []map[string]any) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/calendars.json": - resp := map[string]any{"calendars": calendarsPayload} - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - case r.Method == "POST" && r.URL.Path == "/calendar/events": - body, _ := io.ReadAll(r.Body) - captured.set(string(body)) - w.Header().Set("Location", "/calendar/events/999") - w.WriteHeader(http.StatusFound) - default: - w.WriteHeader(http.StatusNotFound) - } - })) -} - func TestEventCreate_CalendarByName(t *testing.T) { - captured := &capturedRequest{} + captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, []map[string]any{ {"calendar": map[string]any{"id": 42, "name": "Personal", "personal": true, "owned": true}}, {"calendar": map[string]any{"id": 791879, "name": "Work", "owned": true}}, }) defer server.Close() - _, err := runEventCreate(t, server, + _, err := runEvent(t, server, "create", "--calendar", "Work", "--title", "T", "--date", "2024-06-15", @@ -446,21 +334,21 @@ func TestEventCreate_CalendarByName(t *testing.T) { if err != nil { t.Fatalf("execute: %v", err) } - body := captured.get() + body := captured.getBody() if !strings.Contains(body, "calendar_event%5Bcalendar_id%5D=791879") { t.Errorf("body missing calendar_id=791879; body=%s", body) } } func TestEventCreate_CalendarByNameAmbiguous(t *testing.T) { - captured := &capturedRequest{} + captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, []map[string]any{ {"calendar": map[string]any{"id": 100, "name": "Personal", "owned": true}}, {"calendar": map[string]any{"id": 200, "name": "Personal", "owned": true}}, }) defer server.Close() - _, err := runEventCreate(t, server, + _, err := runEvent(t, server, "create", "--calendar", "Personal", "--title", "T", "--date", "2024-06-15", @@ -479,14 +367,14 @@ func TestEventCreate_CalendarByNameAmbiguous(t *testing.T) { } func TestEventCreate_CalendarNotFound(t *testing.T) { - captured := &capturedRequest{} + captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, []map[string]any{ {"calendar": map[string]any{"id": 42, "name": "Personal", "personal": true, "owned": true}}, {"calendar": map[string]any{"id": 99, "name": "Work", "owned": true}}, }) defer server.Close() - _, err := runEventCreate(t, server, + _, err := runEvent(t, server, "create", "--calendar", "Nope", "--title", "T", "--date", "2024-06-15", @@ -511,16 +399,12 @@ func eventListCustomServer(t *testing.T, calendarsPayload []map[string]any, reco w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/calendars/") && strings.HasSuffix(r.URL.Path, "/recordings"): - // extract id seg := strings.TrimPrefix(r.URL.Path, "/calendars/") seg = strings.TrimSuffix(seg, "/recordings") - var id int64 - for _, c := range seg { - if c < '0' || c > '9' { - id = 0 - break - } - id = id*10 + int64(c-'0') + id, err := strconv.ParseInt(seg, 10, 64) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return } resp, ok := recordingsByID[id] if !ok { @@ -550,7 +434,7 @@ func TestEventList_CalendarByName(t *testing.T) { ) defer server.Close() - out, err := runEventList(t, server, "--styled", "--calendar", "Work") + out, err := runEvent(t, server, "list", "--styled", "--calendar", "Work") if err != nil { t.Fatalf("execute: %v", err) } @@ -560,14 +444,14 @@ func TestEventList_CalendarByName(t *testing.T) { } func TestEventCreate_DefaultCalendarFailsShowsList(t *testing.T) { - captured := &capturedRequest{} + captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, []map[string]any{ {"calendar": map[string]any{"id": 6037, "name": "Maybe", "owned": true}}, {"calendar": map[string]any{"id": 791879, "name": "Work", "owned": true}}, }) defer server.Close() - _, err := runEventCreate(t, server, + _, err := runEvent(t, server, "create", "--title", "T", "--date", "2024-06-15", "--all-day", @@ -588,10 +472,12 @@ func TestEventCreate_DefaultCalendarFailsShowsList(t *testing.T) { } func TestEventListIdsOnly(t *testing.T) { - server := eventServer(t) + server := eventListCustomServer(t, defaultCalendarsPayload(), map[int64]map[string]any{ + 42: defaultEventRecordings(), + }) defer server.Close() - out, err := runEventList(t, server, "--ids-only") + out, err := runEvent(t, server, "list", "--ids-only") if err != nil { t.Fatalf("execute: %v", err) } From 41630373fc8c820b3c2ba0ba16fe5baf7ee2d6c1 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:52:38 -0300 Subject: [PATCH 11/28] Reject --timezone combined with --all-day on event create and edit Previously --timezone was silently ignored for all-day events, which is inconsistent with the flag help and can surprise users. Fail fast with a usage error instead. --- internal/cmd/event.go | 6 ++++++ internal/cmd/event_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index cc11f5c..268228c 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -187,6 +187,8 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { if _, err := time.Parse("15:04", c.end); err != nil { return output.ErrUsage("--end must be in HH:MM format") } + } else if cmd.Flags().Changed("timezone") { + return output.ErrUsage("--timezone cannot be combined with --all-day") } reminders, err := parseReminders(c.reminders) @@ -310,6 +312,10 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { flags := cmd.Flags() + if flags.Changed("all-day") && c.allDay && flags.Changed("timezone") { + return output.ErrUsage("--timezone cannot be combined with --all-day") + } + if flags.Changed("date") { if _, err := time.Parse("2006-01-02", c.date); err != nil { return output.ErrUsage("--date must be in YYYY-MM-DD format") diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 26b4f41..ed5bfa9 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -164,6 +164,25 @@ func TestEventCreateRequiresTitle(t *testing.T) { } } +func TestEventCreateRejectsTimezoneWithAllDay(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--title", "Holiday", + "--date", "2024-06-15", + "--all-day", + "--timezone", "America/New_York", + ) + if err == nil { + t.Fatalf("expected error when --timezone combined with --all-day") + } + if !strings.Contains(err.Error(), "--timezone") || !strings.Contains(err.Error(), "--all-day") { + t.Errorf("expected error to name both flags, got: %v", err) + } +} + func TestEventCreateTimed(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) @@ -284,6 +303,20 @@ func TestEventEditOnlyChangedFields(t *testing.T) { } } +func TestEventEditRejectsTimezoneWithAllDay(t *testing.T) { + captured := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "edit", "101", "--all-day", "--timezone", "America/New_York") + if err == nil { + t.Fatalf("expected error when --timezone combined with --all-day") + } + if !strings.Contains(err.Error(), "--timezone") || !strings.Contains(err.Error(), "--all-day") { + t.Errorf("expected error to name both flags, got: %v", err) + } +} + func eventDeleteServer(t *testing.T, captured *capturedHTTP) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 7496b6242aac074abc3a73618e85bea95fbf5cbb Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:53:10 -0300 Subject: [PATCH 12/28] Require at least one editable flag on event edit Calling `hey event edit ` with no fields previously issued an empty PATCH request. Fail fast locally with a usage hint listing the editable flags. --- internal/cmd/event.go | 15 +++++++++++++++ internal/cmd/event_test.go | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 268228c..95161f2 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -312,6 +312,21 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { flags := cmd.Flags() + editable := []string{"title", "date", "start", "end", "all-day", "timezone", "reminder"} + anyChanged := false + for _, name := range editable { + if flags.Changed(name) { + anyChanged = true + break + } + } + if !anyChanged { + return output.ErrUsageHint( + "no fields to update", + "pass at least one of --title, --date, --start, --end, --all-day, --timezone, --reminder", + ) + } + if flags.Changed("all-day") && c.allDay && flags.Changed("timezone") { return output.ErrUsage("--timezone cannot be combined with --all-day") } diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index ed5bfa9..9563c3b 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -303,6 +303,23 @@ func TestEventEditOnlyChangedFields(t *testing.T) { } } +func TestEventEditRequiresAtLeastOneFlag(t *testing.T) { + captured := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "edit", "101") + if err == nil { + t.Fatalf("expected error when no editable flags passed") + } + if !strings.Contains(err.Error(), "no fields to update") { + t.Errorf("expected 'no fields to update' error, got: %v", err) + } + if captured.getBody() != "" { + t.Errorf("should not have made HTTP request; got body=%s", captured.getBody()) + } +} + func TestEventEditRejectsTimezoneWithAllDay(t *testing.T) { captured := &capturedHTTP{} server := eventEditServer(t, captured) From 174ab85aaf4299511ecb5d6d7217a074ee81e130 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:54:31 -0300 Subject: [PATCH 13/28] Error when local timezone can't be determined time.Local.String() returns "Local" (not an IANA name) in some Go runtime configurations. Previously the code silently fell back to UTC, which would shift event times by the user's offset. Return an empty string from localTimezoneName in that case and require --timezone from the caller. --- internal/cmd/event.go | 14 +++++++++++--- internal/cmd/event_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 95161f2..b3ed39e 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -231,6 +231,12 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { tz := c.timezone if tz == "" && !c.allDay { tz = localTimezoneName() + if tz == "" { + return output.ErrUsageHint( + "could not determine local timezone", + "pass --timezone explicitly (e.g. --timezone America/New_York)", + ) + } } params := hey.CreateCalendarEventParams{ @@ -552,12 +558,14 @@ func formatOwnedCalendarList(calendars []generated.Calendar) string { return strings.TrimRight(b.String(), "\n") } -// localTimezoneName returns the local IANA timezone name, falling back to -// "UTC" if the runtime didn't resolve one. +// localTimezoneName returns the local IANA timezone name, or "" when the +// runtime can't produce one (e.g. time.Local.String() returns "Local"). +// Silently defaulting to UTC would shift event times, so callers should +// treat "" as "ask the user". func localTimezoneName() string { name := time.Local.String() if name == "" || name == "Local" { - return "UTC" + return "" } return name } diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 9563c3b..abb9122 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/basecamp/hey-cli/internal/apierr" ) @@ -183,6 +184,34 @@ func TestEventCreateRejectsTimezoneWithAllDay(t *testing.T) { } } +func TestEventCreateRequiresTimezoneWhenLocalUnavailable(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + // time.Local.String() returns "UTC" when TZ is explicitly empty/unset + // on most systems, which is fine. We force a "Local" sentinel by + // pointing TZ at a path that won't resolve; Go falls back to UTC so + // we need a different injection. Simpler: directly swap time.Local. + prev := time.Local + time.Local = time.FixedZone("Local", 0) // .String() will be "Local" + defer func() { time.Local = prev }() + + _, err := runEvent(t, server, "create", + "--title", "T", + "--date", "2024-06-15", + "--start", "09:00", + "--end", "10:00", + ) + if err == nil { + t.Fatalf("expected error when local timezone is indeterminate and --timezone is missing") + } + ae := apierr.AsError(err) + if !strings.Contains(ae.Message+" "+ae.Hint, "--timezone") { + t.Errorf("expected hint to mention --timezone, got msg=%q hint=%q", ae.Message, ae.Hint) + } +} + func TestEventCreateTimed(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) From 3b8c8d69d6094ff308cc07203d540803a457b07c Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 22:55:55 -0300 Subject: [PATCH 14/28] Address review feedback on docs and test coverage - README / SKILL: describe the real reminder format (non-negative number followed by m/h/d/w) instead of implying only the four sample values - API-COVERAGE: map 'hey event list' to the GetRecordings row alongside the other list-style consumers - event_test: cover the default-calendar-returns-404 fallback path so regressions in that error branch surface in CI --- API-COVERAGE.md | 2 +- README.md | 2 +- internal/cmd/event_test.go | 41 ++++++++++++++++++++++++++++++++++++++ skills/hey/SKILL.md | 2 +- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 52700bc..1d3f1b3 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -14,7 +14,7 @@ The legacy `internal/client/` is used only for HTML-scraping gap operations mark | `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box laterbox` | covered | | `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box bubblebox` | covered | | `/calendars.json` | GET | SDK `Calendars().List` | `hey calendars` | covered | -| `/calendars/{id}/recordings.json` | GET | SDK `Calendars().GetRecordings` | `hey recordings `, `hey todo list`, `hey timetrack list`, `hey journal list` | covered | +| `/calendars/{id}/recordings.json` | GET | SDK `Calendars().GetRecordings` | `hey recordings `, `hey event list`, `hey todo list`, `hey timetrack list`, `hey journal list` | covered | | `/topics/{id}/entries` | GET (HTML) | Legacy `GetTopicEntries` | `hey threads ` | gap: SDK Entry lacks body | | `/entries/drafts.json` | GET | SDK `Entries().ListDrafts` | `hey drafts` | covered | | `/topics/messages` | POST | SDK `Messages().Create` | `hey compose` | covered | diff --git a/README.md b/README.md index 5d84f20..d9b44a3 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ hey event edit 123 --title "Updated standup" hey event delete 123 ``` -Reminder durations accept `30m`, `1h`, `2d`, `1w`. +Reminder durations accept a non-negative number followed by `m`, `h`, `d`, or `w` (for example `30m`, `1h`, `2d`, `1w`). ### Todos diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index abb9122..2054541 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -445,6 +445,47 @@ func TestEventCreate_CalendarByNameAmbiguous(t *testing.T) { } } +func TestEventCreate_DefaultCalendarReturns404ShowsList(t *testing.T) { + // findPersonalCalendarID succeeds (returns the personal calendar), + // but the server rejects the POST with 404 — reproduces the orphaned + // personal:true calendar that some accounts have. + calendars := []map[string]any{ + {"calendar": map[string]any{"id": 42, "name": "Personal", "personal": true, "owned": true}}, + {"calendar": map[string]any{"id": 100, "name": "Work", "owned": true}}, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/calendars.json": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"calendars": calendars}) + case r.Method == "POST" && r.URL.Path == "/calendar/events": + w.WriteHeader(http.StatusNotFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--title", "T", + "--date", "2024-06-15", + "--all-day", + ) + if err == nil { + t.Fatalf("expected usage error after 404 fallback") + } + msg := err.Error() + if !strings.Contains(msg, "--calendar") { + t.Errorf("error should mention --calendar, got: %v", msg) + } + if !strings.Contains(msg, "id=42") { + t.Errorf("error should mention the default calendar ID, got: %v", msg) + } + if !strings.Contains(msg, "Work") || !strings.Contains(msg, "100") { + t.Errorf("error should list available owned calendars, got: %v", msg) + } +} + func TestEventCreate_CalendarNotFound(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, []map[string]any{ diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 6e4c050..1ff3419 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -237,7 +237,7 @@ hey event edit 123 --title "Updated standup" # Edit any subset of fields hey event delete 123 # Delete an event ``` -Reminder durations: `30m`, `1h`, `2d`, `1w` (repeat `--reminder` for multiple). Timezone defaults to local; override with `--timezone` (IANA name). +Reminder durations: any non-negative number followed by `m`, `h`, `d`, or `w` (e.g. `30m`, `1h`, `2d`, `1w`; repeat `--reminder` for multiple). Timezone defaults to local; override with `--timezone` (IANA name). `--timezone` cannot be combined with `--all-day`. ### Habits From cc90f11c6e713e2421d64dfdea961313bba6bde6 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 23:16:20 -0300 Subject: [PATCH 15/28] Reject --start/--end combined with --all-day on event create and edit Previously the flags were silently ignored when --all-day was also set, letting a user think they scheduled a timed event when they created an all-day one. Fail fast with a usage error, matching the treatment of --timezone + --all-day. --- internal/cmd/event.go | 20 +++++++++++++----- internal/cmd/event_test.go | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index b3ed39e..eebaf4e 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -174,7 +174,14 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { if _, err := time.Parse("2006-01-02", c.date); err != nil { return output.ErrUsage("--date must be in YYYY-MM-DD format") } - if !c.allDay { + if c.allDay { + if c.start != "" || c.end != "" { + return output.ErrUsage("--start/--end cannot be combined with --all-day") + } + if cmd.Flags().Changed("timezone") { + return output.ErrUsage("--timezone cannot be combined with --all-day") + } + } else { if c.start == "" || c.end == "" { return output.ErrUsageHint( "must supply either --all-day or both --start and --end", @@ -187,8 +194,6 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { if _, err := time.Parse("15:04", c.end); err != nil { return output.ErrUsage("--end must be in HH:MM format") } - } else if cmd.Flags().Changed("timezone") { - return output.ErrUsage("--timezone cannot be combined with --all-day") } reminders, err := parseReminders(c.reminders) @@ -333,8 +338,13 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { ) } - if flags.Changed("all-day") && c.allDay && flags.Changed("timezone") { - return output.ErrUsage("--timezone cannot be combined with --all-day") + if flags.Changed("all-day") && c.allDay { + if flags.Changed("start") || flags.Changed("end") { + return output.ErrUsage("--start/--end cannot be combined with --all-day") + } + if flags.Changed("timezone") { + return output.ErrUsage("--timezone cannot be combined with --all-day") + } } if flags.Changed("date") { diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 2054541..0b108cb 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -165,6 +165,30 @@ func TestEventCreateRequiresTitle(t *testing.T) { } } +func TestEventCreateRejectsStartEndWithAllDay(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--title", "Holiday", + "--date", "2024-06-15", + "--all-day", + "--start", "09:00", + "--end", "10:00", + ) + if err == nil { + t.Fatalf("expected error when --start/--end combined with --all-day") + } + if !strings.Contains(err.Error(), "--all-day") { + t.Errorf("expected error to mention --all-day, got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } +} + func TestEventCreateRejectsTimezoneWithAllDay(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) @@ -349,6 +373,24 @@ func TestEventEditRequiresAtLeastOneFlag(t *testing.T) { } } +func TestEventEditRejectsStartEndWithAllDay(t *testing.T) { + captured := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "edit", "101", "--all-day", "--start", "09:00") + if err == nil { + t.Fatalf("expected error when --start combined with --all-day on edit") + } + if !strings.Contains(err.Error(), "--all-day") { + t.Errorf("expected error to mention --all-day, got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } +} + func TestEventEditRejectsTimezoneWithAllDay(t *testing.T) { captured := &capturedHTTP{} server := eventEditServer(t, captured) From 40f7e53f1fa285107ed27a69ac10feb086b3760d Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 23:17:51 -0300 Subject: [PATCH 16/28] Resolve IANA timezone via $TZ and /etc/localtime fallbacks time.Local.String() returns "Local" on systems where the zone was loaded from /etc/localtime (the common case on Linux without $TZ set). Erroring out in that case forced users to always pass --timezone. Try $TZ next, then resolve /etc/localtime's symlink to recover the IANA name. --- internal/cmd/event.go | 41 ++++++++++++++++++++++++++------ internal/cmd/event_test.go | 48 ++++++++++++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index eebaf4e..d38235a 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -3,6 +3,8 @@ package cmd import ( "context" "fmt" + "os" + "path/filepath" "sort" "strconv" "strings" @@ -568,14 +570,39 @@ func formatOwnedCalendarList(calendars []generated.Calendar) string { return strings.TrimRight(b.String(), "\n") } -// localTimezoneName returns the local IANA timezone name, or "" when the -// runtime can't produce one (e.g. time.Local.String() returns "Local"). -// Silently defaulting to UTC would shift event times, so callers should -// treat "" as "ask the user". +// systemTimezonePath is the path consulted by localTimezoneName after +// time.Local and $TZ fail. Overridable for tests. +var systemTimezonePath = "/etc/localtime" + +// localTimezoneName returns the local IANA timezone name, or "" when no +// reasonable candidate can be determined. Silently defaulting to UTC would +// shift event times, so callers should treat "" as "ask the user". +// +// On Linux/macOS, time.Local.String() typically returns "Local" when the +// zone was loaded from /etc/localtime; we fall back to $TZ and to the +// /etc/localtime symlink target to recover an IANA name. func localTimezoneName() string { - name := time.Local.String() - if name == "" || name == "Local" { + if name := time.Local.String(); name != "" && name != "Local" { + return name + } + if tz := os.Getenv("TZ"); tz != "" { + return tz + } + return readSystemTimezoneFrom(systemTimezonePath) +} + +// readSystemTimezoneFrom resolves a symlink like /etc/localtime → +// /usr/share/zoneinfo/America/Sao_Paulo and returns the IANA suffix +// ("America/Sao_Paulo"). Returns "" on any failure. +func readSystemTimezoneFrom(path string) string { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "" + } + const marker = "zoneinfo/" + idx := strings.Index(resolved, marker) + if idx < 0 { return "" } - return name + return resolved[idx+len(marker):] } diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 0b108cb..6721d49 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strconv" "strings" "sync" @@ -208,18 +210,54 @@ func TestEventCreateRejectsTimezoneWithAllDay(t *testing.T) { } } +func TestReadSystemTimezoneFrom(t *testing.T) { + dir := t.TempDir() + zoneinfoDir := filepath.Join(dir, "usr", "share", "zoneinfo", "America") + if err := os.MkdirAll(zoneinfoDir, 0o755); err != nil { + t.Fatalf("mkdir zoneinfo: %v", err) + } + zoneFile := filepath.Join(zoneinfoDir, "Sao_Paulo") + if err := os.WriteFile(zoneFile, []byte("tzif-stub"), 0o644); err != nil { + t.Fatalf("write zone: %v", err) + } + link := filepath.Join(dir, "localtime") + if err := os.Symlink(zoneFile, link); err != nil { + t.Skipf("symlinks not supported on this filesystem: %v", err) + } + + got := readSystemTimezoneFrom(link) + if got != "America/Sao_Paulo" { + t.Errorf("got %q, want America/Sao_Paulo", got) + } + + if got := readSystemTimezoneFrom(filepath.Join(dir, "nope")); got != "" { + t.Errorf("missing path should yield \"\", got %q", got) + } + + plain := filepath.Join(dir, "plain") + if err := os.WriteFile(plain, nil, 0o644); err != nil { + t.Fatalf("write plain: %v", err) + } + if got := readSystemTimezoneFrom(plain); got != "" { + t.Errorf("path outside zoneinfo should yield \"\", got %q", got) + } +} + func TestEventCreateRequiresTimezoneWhenLocalUnavailable(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) defer server.Close() - // time.Local.String() returns "UTC" when TZ is explicitly empty/unset - // on most systems, which is fine. We force a "Local" sentinel by - // pointing TZ at a path that won't resolve; Go falls back to UTC so - // we need a different injection. Simpler: directly swap time.Local. + // Force time.Local.String() to return "Local" (no IANA name) and + // clear both the $TZ fallback and the /etc/localtime path to + // exercise the final error path. prev := time.Local - time.Local = time.FixedZone("Local", 0) // .String() will be "Local" + time.Local = time.FixedZone("Local", 0) defer func() { time.Local = prev }() + t.Setenv("TZ", "") + prevPath := systemTimezonePath + systemTimezonePath = filepath.Join(t.TempDir(), "nope-localtime") + defer func() { systemTimezonePath = prevPath }() _, err := runEvent(t, server, "create", "--title", "T", From 92338f824afb7a0c07a00b068434a294e0fe5101 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 23:18:13 -0300 Subject: [PATCH 17/28] Assert no HTTP request was made in TestEventEditRequiresAtLeastOneFlag Previously only the request body was inspected, which would pass even for an empty PATCH. Check the captured method/path remain empty so the test actually verifies the early return. --- internal/cmd/event_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 6721d49..a905f4a 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -406,8 +406,9 @@ func TestEventEditRequiresAtLeastOneFlag(t *testing.T) { if !strings.Contains(err.Error(), "no fields to update") { t.Errorf("expected 'no fields to update' error, got: %v", err) } - if captured.getBody() != "" { - t.Errorf("should not have made HTTP request; got body=%s", captured.getBody()) + method, path := captured.getMethodPath() + if method != "" || path != "" { + t.Errorf("should not have made HTTP request; got %s %s", method, path) } } From e1b4d8b2dbf2ce85a605b282a37fa37487afceb4 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Tue, 14 Apr 2026 23:38:23 -0300 Subject: [PATCH 18/28] Harden reminder parsing and timezone detection - parseReminderDuration now rejects magnitudes that would overflow time.Duration (e.g. 99999999999w wrapping into a negative); parse via ParseInt and check against MaxInt64 before multiplying. - localTimezoneName validates $TZ via time.LoadLocation and uses the resolved name, so POSIX-only values (EST5EDT,...) fall through to the /etc/localtime fallback instead of being passed to the API. - Split TestReadSystemTimezoneFrom into three subtests so the missing path and non-zoneinfo assertions still run on filesystems without symlink support. --- internal/cmd/event.go | 25 ++++++++--- internal/cmd/event_test.go | 89 +++++++++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index d38235a..205a6c3 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "math" "os" "path/filepath" "sort" @@ -462,7 +463,8 @@ func (c *eventDeleteCommand) run(cmd *cobra.Command, args []string) error { } // parseReminderDuration parses reminder durations like "30m", "1h", "2d", "1w" -// into time.Duration. Supports minutes, hours, days, and weeks. +// into time.Duration. Supports minutes, hours, days, and weeks. Rejects +// magnitudes that would overflow time.Duration. func parseReminderDuration(s string) (time.Duration, error) { s = strings.TrimSpace(s) if len(s) < 2 { @@ -470,22 +472,27 @@ func parseReminderDuration(s string) (time.Duration, error) { } unit := s[len(s)-1] numStr := s[:len(s)-1] - n, err := strconv.Atoi(numStr) + n, err := strconv.ParseInt(numStr, 10, 64) if err != nil || n < 0 { return 0, fmt.Errorf("invalid reminder %q: expected a non-negative number followed by m, h, d, or w", s) } + var perUnit time.Duration switch unit { case 'm': - return time.Duration(n) * time.Minute, nil + perUnit = time.Minute case 'h': - return time.Duration(n) * time.Hour, nil + perUnit = time.Hour case 'd': - return time.Duration(n) * 24 * time.Hour, nil + perUnit = 24 * time.Hour case 'w': - return time.Duration(n) * 7 * 24 * time.Hour, nil + perUnit = 7 * 24 * time.Hour default: return 0, fmt.Errorf("invalid reminder %q: unit must be m, h, d, or w", s) } + if n > int64(time.Duration(math.MaxInt64)/perUnit) { + return 0, fmt.Errorf("invalid reminder %q: value is too large", s) + } + return time.Duration(n) * perUnit, nil } // parseReminders converts a list of reminder strings to durations, returning @@ -586,7 +593,11 @@ func localTimezoneName() string { return name } if tz := os.Getenv("TZ"); tz != "" { - return tz + if loc, err := time.LoadLocation(tz); err == nil { + if name := loc.String(); name != "" && name != "Local" { + return name + } + } } return readSystemTimezoneFrom(systemTimezonePath) } diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index a905f4a..6e765b8 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -210,7 +210,78 @@ func TestEventCreateRejectsTimezoneWithAllDay(t *testing.T) { } } -func TestReadSystemTimezoneFrom(t *testing.T) { +func TestParseReminderDurationOverflow(t *testing.T) { + _, err := parseReminderDuration("99999999999w") + if err == nil { + t.Fatalf("expected overflow error for huge week value") + } + if !strings.Contains(err.Error(), "too large") { + t.Errorf("expected 'too large' in error, got: %v", err) + } +} + +func TestParseReminderDurationBoundaries(t *testing.T) { + cases := map[string]time.Duration{ + "0m": 0, + "1m": time.Minute, + "60m": 60 * time.Minute, + "24h": 24 * time.Hour, + "7d": 7 * 24 * time.Hour, + "52w": 52 * 7 * 24 * time.Hour, + } + for in, want := range cases { + got, err := parseReminderDuration(in) + if err != nil { + t.Errorf("%s: unexpected err: %v", in, err) + continue + } + if got != want { + t.Errorf("%s: got %v, want %v", in, got, want) + } + } +} + +func TestReadSystemTimezoneFrom_MissingPath(t *testing.T) { + if got := readSystemTimezoneFrom(filepath.Join(t.TempDir(), "nope")); got != "" { + t.Errorf("missing path should yield \"\", got %q", got) + } +} + +func TestReadSystemTimezoneFrom_PathOutsideZoneinfo(t *testing.T) { + plain := filepath.Join(t.TempDir(), "plain") + if err := os.WriteFile(plain, nil, 0o644); err != nil { + t.Fatalf("write plain: %v", err) + } + if got := readSystemTimezoneFrom(plain); got != "" { + t.Errorf("path outside zoneinfo should yield \"\", got %q", got) + } +} + +func TestLocalTimezoneName_IgnoresInvalidTZ(t *testing.T) { + prev := time.Local + time.Local = time.FixedZone("Local", 0) + defer func() { time.Local = prev }() + t.Setenv("TZ", "BOGUS_NOT_A_REAL_ZONE") + prevPath := systemTimezonePath + systemTimezonePath = filepath.Join(t.TempDir(), "nope") + defer func() { systemTimezonePath = prevPath }() + + if got := localTimezoneName(); got != "" { + t.Errorf("invalid $TZ should be rejected, got %q", got) + } +} + +func TestLocalTimezoneName_UsesValidTZ(t *testing.T) { + prev := time.Local + time.Local = time.FixedZone("Local", 0) + defer func() { time.Local = prev }() + t.Setenv("TZ", "America/New_York") + if got := localTimezoneName(); got != "America/New_York" { + t.Errorf("got %q, want America/New_York", got) + } +} + +func TestReadSystemTimezoneFrom_SymlinkToZoneinfo(t *testing.T) { dir := t.TempDir() zoneinfoDir := filepath.Join(dir, "usr", "share", "zoneinfo", "America") if err := os.MkdirAll(zoneinfoDir, 0o755); err != nil { @@ -224,23 +295,9 @@ func TestReadSystemTimezoneFrom(t *testing.T) { if err := os.Symlink(zoneFile, link); err != nil { t.Skipf("symlinks not supported on this filesystem: %v", err) } - - got := readSystemTimezoneFrom(link) - if got != "America/Sao_Paulo" { + if got := readSystemTimezoneFrom(link); got != "America/Sao_Paulo" { t.Errorf("got %q, want America/Sao_Paulo", got) } - - if got := readSystemTimezoneFrom(filepath.Join(dir, "nope")); got != "" { - t.Errorf("missing path should yield \"\", got %q", got) - } - - plain := filepath.Join(dir, "plain") - if err := os.WriteFile(plain, nil, 0o644); err != nil { - t.Fatalf("write plain: %v", err) - } - if got := readSystemTimezoneFrom(plain); got != "" { - t.Errorf("path outside zoneinfo should yield \"\", got %q", got) - } } func TestEventCreateRequiresTimezoneWhenLocalUnavailable(t *testing.T) { From 0f9bfebdfd2e3a88af3e59e2d7f4ed425fa5ca4f Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 00:30:33 -0300 Subject: [PATCH 19/28] Trim string inputs and validate --timezone before API calls - Trim whitespace from --title, --date, --start, --end and --timezone on both create and edit; previously only --title and --date were trimmed, so padded time inputs like "09:00 " would fail parsing. - Validate --timezone via time.LoadLocation when set, returning a usage error locally instead of forwarding an invalid zone to the API. --- internal/cmd/event.go | 43 ++++++++++++++++++++++-- internal/cmd/event_test.go | 69 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 205a6c3..bb598e6 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -168,10 +168,16 @@ func newEventCreateCommand() *eventCreateCommand { } func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { - if strings.TrimSpace(c.title) == "" { + c.title = strings.TrimSpace(c.title) + c.date = strings.TrimSpace(c.date) + c.start = strings.TrimSpace(c.start) + c.end = strings.TrimSpace(c.end) + c.timezone = strings.TrimSpace(c.timezone) + + if c.title == "" { return output.ErrUsage("--title is required") } - if strings.TrimSpace(c.date) == "" { + if c.date == "" { return output.ErrUsage("--date is required (YYYY-MM-DD)") } if _, err := time.Parse("2006-01-02", c.date); err != nil { @@ -199,6 +205,12 @@ func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { } } + if cmd.Flags().Changed("timezone") { + if err := validateTimezone(c.timezone); err != nil { + return err + } + } + reminders, err := parseReminders(c.reminders) if err != nil { return err @@ -326,6 +338,12 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { flags := cmd.Flags() + c.title = strings.TrimSpace(c.title) + c.date = strings.TrimSpace(c.date) + c.start = strings.TrimSpace(c.start) + c.end = strings.TrimSpace(c.end) + c.timezone = strings.TrimSpace(c.timezone) + editable := []string{"title", "date", "start", "end", "all-day", "timezone", "reminder"} anyChanged := false for _, name := range editable { @@ -365,6 +383,11 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { return output.ErrUsage("--end must be in HH:MM format") } } + if flags.Changed("timezone") { + if err := validateTimezone(c.timezone); err != nil { + return err + } + } var reminders []time.Duration if flags.Changed("reminder") { @@ -462,6 +485,22 @@ func (c *eventDeleteCommand) run(cmd *cobra.Command, args []string) error { return writeOK(nil, output.WithSummary("Event deleted")) } +// validateTimezone returns a usage error when tz isn't a resolvable IANA +// timezone name. Empty input is also rejected so callers don't need a +// separate check. +func validateTimezone(tz string) error { + if tz == "" { + return output.ErrUsage("--timezone cannot be empty") + } + if _, err := time.LoadLocation(tz); err != nil { + return output.ErrUsageHint( + fmt.Sprintf("invalid --timezone %q", tz), + "use an IANA timezone name (e.g. America/New_York)", + ) + } + return nil +} + // parseReminderDuration parses reminder durations like "30m", "1h", "2d", "1w" // into time.Duration. Supports minutes, hours, days, and weeks. Rejects // magnitudes that would overflow time.Duration. diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 6e765b8..2182cd5 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -191,6 +191,75 @@ func TestEventCreateRejectsStartEndWithAllDay(t *testing.T) { } } +func TestEventCreateRejectsInvalidTimezone(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--title", "T", + "--date", "2024-06-15", + "--start", "09:00", + "--end", "10:00", + "--timezone", "NOT_A_REAL_ZONE", + ) + if err == nil { + t.Fatalf("expected error for invalid timezone") + } + if !strings.Contains(err.Error(), "invalid --timezone") { + t.Errorf("expected 'invalid --timezone' in error, got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } +} + +func TestEventCreateTrimsTimeInputs(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--title", " Padded ", + "--date", " 2024-06-15 ", + "--start", " 09:00 ", + "--end", "10:00 ", + "--timezone", " America/New_York ", + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + body := captured.getBody() + if !strings.Contains(body, "calendar_event%5Bsummary%5D=Padded") { + t.Errorf("title should be trimmed in body; body=%s", body) + } + if !strings.Contains(body, "calendar_event%5Bstarts_at_time%5D=09%3A00%3A00") { + t.Errorf("start should be trimmed; body=%s", body) + } + if !strings.Contains(body, "calendar_event%5Bstarts_at_time_zone_name%5D=America%2FNew_York") { + t.Errorf("timezone should be trimmed; body=%s", body) + } +} + +func TestEventEditRejectsInvalidTimezone(t *testing.T) { + captured := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "edit", "101", "--timezone", "NOT_A_REAL_ZONE") + if err == nil { + t.Fatalf("expected error for invalid timezone") + } + if !strings.Contains(err.Error(), "invalid --timezone") { + t.Errorf("expected 'invalid --timezone' in error, got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } +} + func TestEventCreateRejectsTimezoneWithAllDay(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) From 8e48864c9a7e4eba86cf993f497cfe4a451a2602 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 00:40:27 -0300 Subject: [PATCH 20/28] Update internal/cmd/event.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/cmd/event.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index bb598e6..3ee544a 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -638,7 +638,12 @@ func localTimezoneName() string { } } } - return readSystemTimezoneFrom(systemTimezonePath) + if name := readSystemTimezoneFrom(systemTimezonePath); name != "" { + if _, err := time.LoadLocation(name); err == nil { + return name + } + } + return "" } // readSystemTimezoneFrom resolves a symlink like /etc/localtime → From 46aa1c7e983a1941b8f2b3895cce0d9a16a971df Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 00:52:33 -0300 Subject: [PATCH 21/28] Update internal/cmd/event.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/cmd/event.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 3ee544a..fbec073 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -99,6 +99,9 @@ func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { } } events := filterRecordingsByType(resp, "Calendar::Event") + if events == nil { + events = []generated.CalendarEvent{} + } total := len(events) if c.limit > 0 && !c.all && len(events) > c.limit { From 4edbf44b4cf038bf462b8c1f7e8275b08484477e Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 01:13:25 -0300 Subject: [PATCH 22/28] Fix compile error from accepted suggestion A review suggestion assigned []generated.CalendarEvent{} where filterRecordingsByType returns []generated.Recording, breaking the build. Use the correct type for the nil-to-empty slice fallback. --- internal/cmd/event.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index fbec073..2d294a0 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -100,7 +100,7 @@ func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { } events := filterRecordingsByType(resp, "Calendar::Event") if events == nil { - events = []generated.CalendarEvent{} + events = []generated.Recording{} } total := len(events) From af05038affa3e9d5dfcfce9167a4ca562dd1bd42 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 09:32:48 -0300 Subject: [PATCH 23/28] Pin hey-sdk to the CalendarEvents merge until v0.4.0 is cut The CalendarEventsService landed in basecamp/hey-sdk@212ceb7 on main but has not been tagged. Using a pseudo-version pinned at that commit so CI can build this branch. Bump to v0.4.0 (or whatever the next SDK tag is) once it's released. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fe338e7..fc48aa1 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.2 - github.com/basecamp/hey-sdk/go v0.3.0 + github.com/basecamp/hey-sdk/go v0.3.1-0.20260407122900-212ceb7d1fe6 github.com/mattn/go-runewidth v0.0.22 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index cea7f71..75a44c0 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/basecamp/hey-sdk/go v0.3.0 h1:NnXFYTYS5t5RBNJG/q0r5M8P3Gz4FQOBY+y59krbfyU= -github.com/basecamp/hey-sdk/go v0.3.0/go.mod h1:Mo8DxZT7gmWHePXVDA1Cgy1xRMFDTpeyuKlprEE+srE= +github.com/basecamp/hey-sdk/go v0.3.1-0.20260407122900-212ceb7d1fe6 h1:DjnsoC+k2bsKqzSZ7svfDBc3oqIARArGnuvW/oXfmXQ= +github.com/basecamp/hey-sdk/go v0.3.1-0.20260407122900-212ceb7d1fe6/go.mod h1:Mo8DxZT7gmWHePXVDA1Cgy1xRMFDTpeyuKlprEE+srE= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= From 9c0d70eafcecd48879574090f63e76f4812a2ec8 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 09:32:57 -0300 Subject: [PATCH 24/28] gofmt TestParseReminderDurationBoundaries map literal --- internal/cmd/event_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 2182cd5..85b94a5 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -291,12 +291,12 @@ func TestParseReminderDurationOverflow(t *testing.T) { func TestParseReminderDurationBoundaries(t *testing.T) { cases := map[string]time.Duration{ - "0m": 0, - "1m": time.Minute, - "60m": 60 * time.Minute, - "24h": 24 * time.Hour, - "7d": 7 * 24 * time.Hour, - "52w": 52 * 7 * 24 * time.Hour, + "0m": 0, + "1m": time.Minute, + "60m": 60 * time.Minute, + "24h": 24 * time.Hour, + "7d": 7 * 24 * time.Hour, + "52w": 52 * 7 * 24 * time.Hour, } for in, want := range cases { got, err := parseReminderDuration(in) From af8a26debf3d827063eed7e7c9daa13b93760868 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 09:41:16 -0300 Subject: [PATCH 25/28] Fix data race in TestEventCreateRequiresTimezoneWhenLocalUnavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defers run LIFO, so the previous order (server defer first, time.Local swap second) caused the time.Local restoration to run while the httptest server goroutines were still calling time.Now() — triggering -race. Swap the setup order so server.Close() runs before time.Local is restored. --- internal/cmd/event_test.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 85b94a5..239ede0 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -370,13 +370,10 @@ func TestReadSystemTimezoneFrom_SymlinkToZoneinfo(t *testing.T) { } func TestEventCreateRequiresTimezoneWhenLocalUnavailable(t *testing.T) { - captured := &capturedHTTP{} - server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) - defer server.Close() - - // Force time.Local.String() to return "Local" (no IANA name) and - // clear both the $TZ fallback and the /etc/localtime path to - // exercise the final error path. + // Mutating time.Local must happen before the httptest server starts + // so that the deferred restoration runs *after* server.Close(); the + // server's goroutines call time.Now (which reads time.Local) and + // would race with a concurrent restoration otherwise. prev := time.Local time.Local = time.FixedZone("Local", 0) defer func() { time.Local = prev }() @@ -385,6 +382,10 @@ func TestEventCreateRequiresTimezoneWhenLocalUnavailable(t *testing.T) { systemTimezonePath = filepath.Join(t.TempDir(), "nope-localtime") defer func() { systemTimezonePath = prevPath }() + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + _, err := runEvent(t, server, "create", "--title", "T", "--date", "2024-06-15", From 460a5981fb8cb02349290189bc0f003bc4249075 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 09:58:58 -0300 Subject: [PATCH 26/28] Reject non-positive calendar IDs and fix recordings path in docs - resolveCalendarID now rejects --calendar=0 or --calendar=-1 with a clear usage error instead of falling through to a name lookup that produces a misleading 'no owned calendar named "0"' message and an unnecessary HTTP request. - API-COVERAGE.md: drop the stray .json suffix on /calendars/{id}/ recordings; the SDK hits the path without it. --- API-COVERAGE.md | 2 +- internal/cmd/event.go | 10 +++++++--- internal/cmd/event_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 1d3f1b3..271c9cd 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -14,7 +14,7 @@ The legacy `internal/client/` is used only for HTML-scraping gap operations mark | `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box laterbox` | covered | | `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box bubblebox` | covered | | `/calendars.json` | GET | SDK `Calendars().List` | `hey calendars` | covered | -| `/calendars/{id}/recordings.json` | GET | SDK `Calendars().GetRecordings` | `hey recordings `, `hey event list`, `hey todo list`, `hey timetrack list`, `hey journal list` | covered | +| `/calendars/{id}/recordings` | GET | SDK `Calendars().GetRecordings` | `hey recordings `, `hey event list`, `hey todo list`, `hey timetrack list`, `hey journal list` | covered | | `/topics/{id}/entries` | GET (HTML) | Legacy `GetTopicEntries` | `hey threads ` | gap: SDK Entry lacks body | | `/entries/drafts.json` | GET | SDK `Entries().ListDrafts` | `hey drafts` | covered | | `/topics/messages` | POST | SDK `Messages().Create` | `hey compose` | covered | diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 2d294a0..1a9f03d 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -555,12 +555,16 @@ func parseReminders(in []string) ([]time.Duration, error) { } // resolveCalendarID maps user input (numeric ID or calendar name) to a -// calendar ID. Numeric input is returned as-is with no SDK call. Otherwise the -// calendar list is fetched and filtered to Owned == true, matching Name +// calendar ID. Positive numeric input is returned as-is with no SDK call; +// non-positive numeric input is rejected locally. Otherwise the calendar +// list is fetched and filtered to Owned == true, matching Name // case-insensitively. Zero matches or multiple matches yield a usage error. func resolveCalendarID(ctx context.Context, input string) (int64, error) { trimmed := strings.TrimSpace(input) - if id, err := strconv.ParseInt(trimmed, 10, 64); err == nil && id > 0 { + if id, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + if id <= 0 { + return 0, output.ErrUsage(fmt.Sprintf("calendar ID must be positive, got %d", id)) + } return id, nil } diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 239ede0..1b90ab4 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -694,6 +694,34 @@ func TestEventCreate_DefaultCalendarReturns404ShowsList(t *testing.T) { } } +func TestEventCreate_RejectsNonPositiveCalendarID(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + for _, in := range []string{"0", "-1"} { + t.Run(in, func(t *testing.T) { + captured.set("", "", "") + _, err := runEvent(t, server, "create", + "--calendar", in, + "--title", "T", + "--date", "2024-06-15", + "--all-day", + ) + if err == nil { + t.Fatalf("expected error for calendar=%q", in) + } + if !strings.Contains(err.Error(), "calendar ID must be positive") { + t.Errorf("expected 'calendar ID must be positive', got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } + }) + } +} + func TestEventCreate_CalendarNotFound(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, []map[string]any{ From 6f75b0b228568924f6cbefe2617ecb3f0f3db4f1 Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 10:15:16 -0300 Subject: [PATCH 27/28] Validate whitespace calendar, empty edit title, and non-positive event IDs Pre-empt three adjacent gaps surfaced by the latest reviewer pass: - resolveCalendarID: reject empty/whitespace --calendar with a clear usage error instead of falling through to a name lookup that yields 'no owned calendar named ""'. - event edit: reject --title "" so an accidental blank doesn't silently wipe the event's summary on the server. - event edit/delete: reject non-positive event IDs locally instead of handing them to the API, mirroring what we already do for --calendar. --- internal/cmd/event.go | 12 ++++++ internal/cmd/event_test.go | 87 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index 1a9f03d..b96a255 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -338,6 +338,9 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return output.ErrUsage(fmt.Sprintf("invalid event ID: %s", args[0])) } + if id <= 0 { + return output.ErrUsage(fmt.Sprintf("event ID must be positive, got %d", id)) + } flags := cmd.Flags() @@ -371,6 +374,9 @@ func (c *eventEditCommand) run(cmd *cobra.Command, args []string) error { } } + if flags.Changed("title") && c.title == "" { + return output.ErrUsage("--title cannot be empty") + } if flags.Changed("date") { if _, err := time.Parse("2006-01-02", c.date); err != nil { return output.ErrUsage("--date must be in YYYY-MM-DD format") @@ -470,6 +476,9 @@ func (c *eventDeleteCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return output.ErrUsage(fmt.Sprintf("invalid event ID: %s", args[0])) } + if id <= 0 { + return output.ErrUsage(fmt.Sprintf("event ID must be positive, got %d", id)) + } if err := requireAuth(); err != nil { return err @@ -561,6 +570,9 @@ func parseReminders(in []string) ([]time.Duration, error) { // case-insensitively. Zero matches or multiple matches yield a usage error. func resolveCalendarID(ctx context.Context, input string) (int64, error) { trimmed := strings.TrimSpace(input) + if trimmed == "" { + return 0, output.ErrUsage("--calendar cannot be empty") + } if id, err := strconv.ParseInt(trimmed, 10, 64); err == nil { if id <= 0 { return 0, output.ErrUsage(fmt.Sprintf("calendar ID must be positive, got %d", id)) diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 1b90ab4..342cbdf 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -694,6 +694,93 @@ func TestEventCreate_DefaultCalendarReturns404ShowsList(t *testing.T) { } } +func TestEventCreate_RejectsWhitespaceCalendar(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--calendar", " ", + "--title", "T", + "--date", "2024-06-15", + "--all-day", + ) + if err == nil { + t.Fatalf("expected error for whitespace --calendar") + } + if !strings.Contains(err.Error(), "--calendar cannot be empty") { + t.Errorf("expected '--calendar cannot be empty', got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } +} + +func TestEventEdit_RejectsEmptyTitle(t *testing.T) { + captured := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "edit", "101", "--title", "") + if err == nil { + t.Fatalf("expected error for --title \"\"") + } + if !strings.Contains(err.Error(), "--title cannot be empty") { + t.Errorf("expected '--title cannot be empty', got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } +} + +func TestEventEdit_RejectsNonPositiveID(t *testing.T) { + captured := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + for _, in := range []string{"0", "-5"} { + t.Run(in, func(t *testing.T) { + captured.set("", "", "") + _, err := runEvent(t, server, "edit", "--title", "x", "--", in) + if err == nil { + t.Fatalf("expected error for id=%q", in) + } + if !strings.Contains(err.Error(), "event ID must be positive") { + t.Errorf("expected 'event ID must be positive', got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } + }) + } +} + +func TestEventDelete_RejectsNonPositiveID(t *testing.T) { + captured := &capturedHTTP{} + server := eventDeleteServer(t, captured) + defer server.Close() + + for _, in := range []string{"0", "-7"} { + t.Run(in, func(t *testing.T) { + captured.set("", "", "") + _, err := runEvent(t, server, "delete", "--", in) + if err == nil { + t.Fatalf("expected error for id=%q", in) + } + if !strings.Contains(err.Error(), "event ID must be positive") { + t.Errorf("expected 'event ID must be positive', got: %v", err) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } + }) + } +} + func TestEventCreate_RejectsNonPositiveCalendarID(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) From 82cfd5967ca243059e734e0ef97c146039af684c Mon Sep 17 00:00:00 2001 From: Guilherme Yamakawa de Oliveira Date: Wed, 15 Apr 2026 10:50:24 -0300 Subject: [PATCH 28/28] Reject --timezone "Local" in validateTimezone time.LoadLocation("Local") succeeds (Go treats it as an alias for the process-local zone), so the previous check let the literal string "Local" through to the HEY API where it isn't a meaningful IANA name. Reject it explicitly with a usage hint pointing at real IANA names. --- internal/cmd/event.go | 6 ++++++ internal/cmd/event_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index b96a255..0750155 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -504,6 +504,12 @@ func validateTimezone(tz string) error { if tz == "" { return output.ErrUsage("--timezone cannot be empty") } + if tz == "Local" { + return output.ErrUsageHint( + `--timezone "Local" is not a valid IANA name`, + "pass an IANA timezone name (e.g. America/New_York)", + ) + } if _, err := time.LoadLocation(tz); err != nil { return output.ErrUsageHint( fmt.Sprintf("invalid --timezone %q", tz), diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go index 342cbdf..ee134df 100644 --- a/internal/cmd/event_test.go +++ b/internal/cmd/event_test.go @@ -191,6 +191,32 @@ func TestEventCreateRejectsStartEndWithAllDay(t *testing.T) { } } +func TestEventCreateRejectsLocalTimezone(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--title", "T", + "--date", "2024-06-15", + "--start", "09:00", + "--end", "10:00", + "--timezone", "Local", + ) + if err == nil { + t.Fatalf("expected error for --timezone Local") + } + ae := apierr.AsError(err) + combined := ae.Message + " " + ae.Hint + if !strings.Contains(combined, "IANA") { + t.Errorf("expected error to mention IANA, got msg=%q hint=%q", ae.Message, ae.Hint) + } + method, _ := captured.getMethodPath() + if method != "" { + t.Errorf("should not have made HTTP request; got %s", method) + } +} + func TestEventCreateRejectsInvalidTimezone(t *testing.T) { captured := &capturedHTTP{} server := eventCreateCustomServer(t, captured, defaultCalendarsPayload())