diff --git a/.surface b/.surface index c688616..2d656c0 100644 --- a/.surface +++ b/.surface @@ -43,6 +43,29 @@ hey doctor 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 delete +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 +hey event list --limit hey habit hey habit complete hey habit complete --date diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 5ada42e..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 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 | @@ -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..d9b44a3 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 --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 +hey event edit 123 --title "Updated standup" +hey event delete 123 +``` + +Reminder durations accept a non-negative number followed by `m`, `h`, `d`, or `w` (for example `30m`, `1h`, `2d`, `1w`). + ### Todos ```bash 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= diff --git a/internal/cmd/event.go b/internal/cmd/event.go new file mode 100644 index 0000000..0750155 --- /dev/null +++ b/internal/cmd/event.go @@ -0,0 +1,688 @@ +package cmd + +import ( + "context" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "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" +) + +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, 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.", + }, + } + + eventCommand.cmd.AddCommand(newEventListCommand().cmd) + eventCommand.cmd.AddCommand(newEventCreateCommand().cmd) + eventCommand.cmd.AddCommand(newEventEditCommand().cmd) + eventCommand.cmd.AddCommand(newEventDeleteCommand().cmd) + + return eventCommand +} + +// list + +type eventListCommand struct { + cmd *cobra.Command + limit int + all bool + calendar string +} + +func newEventListCommand() *eventListCommand { + c := &eventListCommand{} + c.cmd = &cobra.Command{ + Use: "list", + 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: c.run, + } + + 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 c +} + +func (c *eventListCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + ctx := cmd.Context() + + 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{ + 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) + } + } else { + var err error + resp, err = listPersonalRecordings(ctx) + if err != nil { + return err + } + } + events := filterRecordingsByType(resp, "Calendar::Event") + if events == nil { + events = []generated.Recording{} + } + + 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), + ) +} + +// create + +type eventCreateCommand struct { + cmd *cobra.Command + title string + date string + allDay bool + start string + end string + calendar string + 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().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") + + return c +} + +func (c *eventCreateCommand) run(cmd *cobra.Command, args []string) error { + 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 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.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", + "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") + } + } + + if cmd.Flags().Changed("timezone") { + if err := validateTimezone(c.timezone); err != nil { + return err + } + } + + reminders, err := parseReminders(c.reminders) + if err != nil { + return err + } + + if err := requireAuth(); err != nil { + return err + } + + 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) + if err != nil { + return err + } + calID = id + } else { + payload, err := sdk.Calendars().List(ctx) + if err != nil { + return convertSDKError(err) + } + defaultCalendars = unwrapCalendars(payload) + id, err := findPersonalCalendarID(defaultCalendars) + if err != nil { + msg := "Couldn't determine default calendar. Pass --calendar ." + if list := formatOwnedCalendarList(defaultCalendars); list != "" { + msg += " Available:\n" + list + } + return output.ErrUsage(msg) + } + calID = id + } + + 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{ + 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 { + if c.calendar == "" && hey.AsError(err).HTTPStatus == 404 { + 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) + } + + 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")) +} + +// 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])) + } + if id <= 0 { + return output.ErrUsage(fmt.Sprintf("event ID must be positive, got %d", id)) + } + + 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 { + 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 { + 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("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") + } + } + 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") + } + } + if flags.Changed("timezone") { + if err := validateTimezone(c.timezone); err != nil { + return err + } + } + + var reminders []time.Duration + if flags.Changed("reminder") { + reminders, err = parseReminders(c.reminders) + if err != nil { + return err + } + } + + 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")) +} + +// 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 id <= 0 { + return output.ErrUsage(fmt.Sprintf("event ID must be positive, got %d", id)) + } + + 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")) +} + +// 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 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), + "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. +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.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': + perUnit = time.Minute + case 'h': + perUnit = time.Hour + case 'd': + perUnit = 24 * time.Hour + case 'w': + 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 +// 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 +} + +// resolveCalendarID maps user input (numeric ID or calendar name) to a +// 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 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)) + } + 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") +} + +// 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 { + if name := time.Local.String(); name != "" && name != "Local" { + return name + } + if tz := os.Getenv("TZ"); tz != "" { + if loc, err := time.LoadLocation(tz); err == nil { + if name := loc.String(); name != "" && name != "Local" { + return name + } + } + } + if name := readSystemTimezoneFrom(systemTimezonePath); name != "" { + if _, err := time.LoadLocation(name); err == nil { + return name + } + } + return "" +} + +// 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 resolved[idx+len(marker):] +} diff --git a/internal/cmd/event_test.go b/internal/cmd/event_test.go new file mode 100644 index 0000000..ee134df --- /dev/null +++ b/internal/cmd/event_test.go @@ -0,0 +1,960 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +// 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 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") + 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", sub, "--base-url", server.URL}, args...) + root.SetArgs(fullArgs) + + err := root.Execute() + return buf.String(), err +} + +func TestEventListDefault(t *testing.T) { + server := eventListCustomServer(t, defaultCalendarsPayload(), map[int64]map[string]any{ + 42: defaultEventRecordings(), + }) + defer server.Close() + + out, err := runEvent(t, server, "list", "--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 := eventListCustomServer(t, defaultCalendarsPayload(), map[int64]map[string]any{ + 42: defaultEventRecordings(), + }) + defer server.Close() + + out, err := runEvent(t, server, "list", "--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 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": 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(r.Method, r.URL.Path, string(body)) + w.Header().Set("Location", "/calendar/events/999") + w.WriteHeader(http.StatusFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func TestEventCreateRequiresTitle(t *testing.T) { + captured := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + _, err := runEvent(t, server, "create", "--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 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 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()) + 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()) + 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 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 { + 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) + } + if got := readSystemTimezoneFrom(link); got != "America/Sao_Paulo" { + t.Errorf("got %q, want America/Sao_Paulo", got) + } +} + +func TestEventCreateRequiresTimezoneWhenLocalUnavailable(t *testing.T) { + // 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 }() + t.Setenv("TZ", "") + prevPath := systemTimezonePath + 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", + "--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()) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--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.getBody() + 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 := &capturedHTTP{} + server := eventCreateCustomServer(t, captured, defaultCalendarsPayload()) + defer server.Close() + + _, err := runEvent(t, server, "create", + "--title", "Holiday", + "--date", "2024-06-15", + "--all-day", + "--reminder", "1d", + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + + body := captured.getBody() + 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 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(r.Method, r.URL.Path, string(body)) + w.Header().Set("Location", "/calendar/events/101") + w.WriteHeader(http.StatusFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func TestEventEdit(t *testing.T) { + captured := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "edit", "101", "--title", "Updated standup") + if err != nil { + t.Fatalf("execute: %v", err) + } + + 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 := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "edit", "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 := &capturedHTTP{} + server := eventEditServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "edit", "101", "--title", "X") + if err != nil { + t.Fatalf("execute: %v", err) + } + + 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) { + t.Errorf("body should not contain %q when not changed; body=%s", f, body) + } + } +} + +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) + } + method, path := captured.getMethodPath() + if method != "" || path != "" { + t.Errorf("should not have made HTTP request; got %s %s", method, path) + } +} + +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) + 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) { + 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 TestEventDelete(t *testing.T) { + captured := &capturedHTTP{} + server := eventDeleteServer(t, captured) + defer server.Close() + + _, err := runEvent(t, server, "delete", "101") + if err != nil { + t.Fatalf("execute: %v", err) + } + + method, path := captured.getMethodPath() + 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 TestEventCreate_CalendarByName(t *testing.T) { + 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 := runEvent(t, server, "create", + "--calendar", "Work", + "--title", "T", + "--date", "2024-06-15", + "--all-day", + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + 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 := &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 := runEvent(t, server, "create", + "--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_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_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()) + 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{ + {"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 := runEvent(t, server, "create", + "--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"): + seg := strings.TrimPrefix(r.URL.Path, "/calendars/") + seg = strings.TrimSuffix(seg, "/recordings") + id, err := strconv.ParseInt(seg, 10, 64) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + 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 := runEvent(t, server, "list", "--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 := &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 := runEvent(t, server, "create", + "--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 := eventListCustomServer(t, defaultCalendarsPayload(), map[int64]map[string]any{ + 42: defaultEventRecordings(), + }) + defer server.Close() + + out, err := runEvent(t, server, "list", "--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) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 3159689..1ff3419 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 --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 +hey event edit 123 --title "Updated standup" # Edit any subset of fields +hey event delete 123 # Delete an event +``` + +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 ```bash