From 3a774e12569cb5fd4fb26e7ed704089b43685a72 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 27 Sep 2026 22:14:54 -0400 Subject: [PATCH 1/3] Move an event to another calendar with hey event edit --calendar --calendar on a whole-event edit meant two things: the calendar to file the event on, and the only calendar to look for it on. A move looked for the event on its destination, where it is not yet, so it always answered not found (#512). The event is now looked for over every calendar, as an --occurrence edit already does, and --calendar is only where it goes. A move HEY will not make now says so. HEY files only on a calendar you own or share and answers 404 for any other, which reached the reader as a bare "Form request failed (HTTP 404)"; that is now named as the calendar. And an event you cannot edit, such as an invitation, moves only onto a calendar nobody else is on: HEY otherwise drops the calendar from the update and answers with the event where it was, which the edit reported as updated. The calendar HEY answers with is now checked against the one asked for. --- docs/cli.md | 7 +- internal/cmd/events.go | 75 ++++++++++++++++------ internal/cmd/events_test.go | 106 ++++++++++++++++++++++++++++++- internal/cmd/events_zone_test.go | 18 +++--- skills/hey/SKILL.md | 8 ++- 5 files changed, 181 insertions(+), 33 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index eceee403..faa6d9b0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -497,8 +497,11 @@ changing. Two things cannot survive the round trip. HEY serves notes back as pla saving flattens their formatting; and a countdown is a recording of its own that this edit does not read back, so an edit removes one unless `--countdown` names it again. An event that cannot be read is refused rather than written blind. It is looked for within a year either side of today; pass the day it -starts (`hey event edit 4821 2026-09-02`) for one outside that — `--calendar` only limits -which calendars are read. +starts (`hey event edit 4821 2026-09-02`) for one outside that. Every calendar is read, and +`--calendar` is where the event moves to: `hey event edit 4821 --calendar 9102`. That has to +be a calendar you own or share, not your personal calendar or a subscription. An event you +cannot edit, such as an invitation, moves only onto a calendar nobody else is on; HEY keeps +it where it is otherwise, and the edit fails saying so rather than reporting it updated. An id on its own changes the whole event, a repeating series included. One day of a series is changed with `--occurrence`, which takes the `occurrence_id` that `hey event diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 90a09087..0769bb6b 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "net/http" "strconv" "strings" "time" @@ -272,9 +273,14 @@ the clocks repeat as they go back names two moments, of which HEY takes the dayl one, or the later where neither is; a time you keep at the other, or with seconds, cannot be sent, so the edit refuses until you retype it. All of this holds for an --occurrence edit too. -The event is found by reading the calendars it might be on, which is one request each and +The event is found by reading every calendar it might be on, which is one request each and covers the pages HEY answers with. Give the day it starts as [date] to look on that day -alone, or --calendar to look on one calendar. +alone. + +--calendar moves the event to that calendar, which has to be one you own or share: not +your personal calendar and not a subscription. An event you cannot edit, such as an +invitation, moves only onto a calendar nobody else is on; HEY keeps it where it is +otherwise, and the edit says so rather than reporting it updated. An id alone changes the whole event, a repeating series included. One day of a series is changed with --occurrence, which takes the occurrence_id 'hey event day' and 'hey event @@ -318,6 +324,7 @@ or one day.`, hey event edit 4821 --starts-on 2026-09-04 --start-time 15:00 hey event edit 4821 2026-09-02 --location "Studio, 3rd floor" hey event edit 4821 --circle=false + hey event edit 4821 --calendar 9102 hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to current --start-time 15:00 --json hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to future --repeat every_week --repeat-times 8 --location "Studio, 3rd floor" --allow-plain-notes`, RunE: eventsEditCommand.run, @@ -328,6 +335,7 @@ or one day.`, flags := eventsEditCommand.cmd.Flags() // An edit only falls back to the account's zone for an event that has none of its own. flags.Lookup("time-zone").Usage = "IANA zone the times are written in, such as America/New_York (defaults to the event's own zone, else your HEY account's)" + flags.Lookup("calendar").Usage = "Calendar ID to move the event to" flags.StringVar(&eventsEditCommand.occurrence, "occurrence", "", "One day of a repeating event, by the occurrence_id 'hey event day' serves (_)") flags.StringVar(&eventsEditCommand.applyTo, "apply-to", "", "How much of the series an --occurrence edit reaches: current (that day alone) or future (that day and every one after it)") flags.BoolVar(&eventsEditCommand.allowPlainNotes, "allow-plain-notes", false, "Let an --occurrence edit send notes it is not changing back as plain text, losing their formatting") @@ -433,7 +441,12 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error { result, err := sdk.CalendarEvents().Update(ctx, id, changes) if err != nil { - return apierr.FromSDK(err) + return eventWriteError(err, id, changes.CalendarID) + } + if changes.CalendarID != nil { + if err = checkMoved(id, *changes.CalendarID, result); err != nil { + return err + } } return writeMutationLine(cmd, @@ -442,17 +455,51 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error { result) } +// eventWriteError says what HEY's refusal of a move means. The event was read a moment ago, so +// a not-found is the calendar: HEY files only on a calendar you own or share, and answers 404 +// for any other — the personal calendar and a subscription among them — as it does when an +// event is added there. +func eventWriteError(err error, id int64, calendarID *int64) error { + if calendarID != nil && hey.AsError(err).HTTPStatus == http.StatusNotFound { + return &apierr.Error{ + Code: apierr.CodeNotFound, + Message: fmt.Sprintf("HEY cannot move event %d to calendar %d", id, *calendarID), + Hint: "an event moves onto a calendar you own or share, not your personal calendar or a subscription; hey calendar list lists them", + HTTPStatus: http.StatusNotFound, + Cause: err, + } + } + return apierr.FromSDK(err) +} + +// checkMoved says so when HEY answers a move by leaving the event where it was. HEY does not +// refuse a calendar it will not move an event to: an event you may not edit, such as an +// invitation, moves only onto a calendar nobody else is on, and otherwise HEY drops the +// calendar from the update and answers with the event unmoved. Reporting that as updated +// would be the one wrong answer, so the event HEY answers with is read for its calendar. +func checkMoved(id, calendarID int64, result *generated.Recording) error { + if result == nil || result.Calendar.Id == 0 || result.Calendar.Id == calendarID { + return nil + } + return &apierr.Error{ + Code: apierr.CodeForbidden, + Message: fmt.Sprintf("HEY did not move event %d to calendar %d; it is still on calendar %d", id, calendarID, result.Calendar.Id), + Hint: "an event you cannot edit, such as an invitation, moves only onto a calendar nobody else is on", + } +} + // findEvent reads the event so the update can send back what it is not changing. HEY serves -// no event on its own, so it is looked for among the recordings of the calendars it could be -// on — every one of them, or the one --calendar names, over the day given or a window wide -// enough to cover an event somebody is editing. +// no event on its own, so it is looked for among the recordings of every calendar it could be +// on, over the day given or a window wide enough to cover an event somebody is editing. +// --calendar is not one of them: it is where the event is moving to, which is the one +// calendar it is not on yet. // // The day is read as the window [day, day+1): HEY's recordings window is a pair of // instants, and the degenerate same-day window — midnight to the same midnight — can // never contain a timed event, which is how editing an event by its own day used to // answer not-found. Reading a day too many is harmless here: the event is matched by id. func (c *eventsEditCommand) findEvent(ctx context.Context, id int64, on string) (generated.Recording, error) { - window, err := c.searchWindow(ctx, on) + window, err := eventSearchWindow(ctx, on) if err != nil { return generated.Recording{}, err } @@ -471,22 +518,14 @@ func (c *eventsEditCommand) findEvent(ctx context.Context, id int64, on string) fmt.Sprintf("hey event edit %d reads the day it starts on", id)) } -// searchWindow is where an edit looks for its event: the day given, read as [day, day+1), -// or a window wide enough to cover an event somebody is editing, over the calendar -// --calendar names or every one of them. -func (c *eventsEditCommand) searchWindow(ctx context.Context, on string) (recordingWindow, error) { - return eventSearchWindow(ctx, c.fields.calendar, on) -} - // eventSearchWindow is where an event given by id is looked for: the day given, read as -// [day, day+1), or a year either side of today, over one calendar or every one of them. -func eventSearchWindow(ctx context.Context, calendar int64, on string) (recordingWindow, error) { +// [day, day+1), or a year either side of today, over every calendar. +func eventSearchWindow(ctx context.Context, on string) (recordingWindow, error) { endsOn := on if day, err := time.Parse(dateLayout, on); err == nil { endsOn = day.AddDate(0, 0, 1).Format(dateLayout) } filter := recordingFilter{ - calendar: calendar, startsOn: on, endsOn: endsOn, defaultWindow: func(today time.Time) (time.Time, time.Time) { return today.AddDate(-1, 0, 0), today.AddDate(1, 0, 0) }, @@ -622,7 +661,7 @@ func (c *eventsDeleteCommand) deleteOccurrence(ctx context.Context, cmd *cobra.C // event, so it is looked for where an edit looks, and an id that is not found there is // deleted as it always was: whatever it is, it is not a day of a series within a year. func refuseADayOfASeries(ctx context.Context, cmd *cobra.Command, id int64) error { - window, err := eventSearchWindow(ctx, 0, "") + window, err := eventSearchWindow(ctx, "") if err != nil { return err } diff --git a/internal/cmd/events_test.go b/internal/cmd/events_test.go index d8262ebe..e0a9dcdb 100644 --- a/internal/cmd/events_test.go +++ b/internal/cmd/events_test.go @@ -283,6 +283,8 @@ func TestEventsEditAllDayEventOmitsClockTimes(t *testing.T) { _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}`) case r.URL.Path == "/calendars/9/recordings.json": _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Sarah's birthday","all_day":true,"starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T00:00:00Z"}]}`) case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": @@ -300,7 +302,7 @@ func TestEventsEditAllDayEventOmitsClockTimes(t *testing.T) { t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) http.NotFound(w, r) } - }), "event", "edit", "4821", "2026-09-02", "--calendar", "9", "--title", "Sarah's birthday (edited)") + }), "event", "edit", "4821", "2026-09-02", "--title", "Sarah's birthday (edited)") if err != nil { t.Fatalf("execute all-day event edit: %v", err) } @@ -313,6 +315,8 @@ func TestEventsEditReadsTheDayItIsGiven(t *testing.T) { _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}`) case r.URL.Path == "/calendars/9/recordings.json": if got := r.URL.Query().Get("starts_on"); got != "2026-09-02" { t.Errorf("starts_on = %q", got) @@ -330,12 +334,110 @@ func TestEventsEditReadsTheDayItIsGiven(t *testing.T) { t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) http.NotFound(w, r) } - }), "event", "edit", "4821", "2026-09-02", "--calendar", "9", "--location", "Studio, 3rd floor") + }), "event", "edit", "4821", "2026-09-02", "--location", "Studio, 3rd floor") if err != nil { t.Fatalf("execute events edit: %v", err) } } +// --calendar is where the event moves to, so it is not where the event is looked for: the +// event is found on the calendar it is on now, and the update names the one it moves to. +func TestEventsEditMovesTheEventToAnotherCalendar(t *testing.T) { + var read []string + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Nina","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + read = append(read, r.URL.Path) + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Parent-teacher conference","starts_at":"2026-10-01T15:00:00Z","ends_at":"2026-10-01T15:30:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/12/recordings.json": + read = append(read, r.URL.Path) + _, _ = io.WriteString(w, `{}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + form := eventForm(t, r) + if got := form.Get("calendar_event[calendar_id]"); got != "12" { + t.Errorf("calendar_id = %q, want the calendar it moves to", got) + } + if got := form.Get("calendar_event[summary]"); got != "Parent-teacher conference" { + t.Errorf("summary = %q, want the title kept", got) + } + _, _ = io.WriteString(w, `{"id":4821,"title":"Parent-teacher conference","calendar":{"id":12}}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-01", "--calendar", "12") + if err != nil { + t.Fatalf("execute event edit: %v", err) + } + if !response.OK { + t.Errorf("response = %+v, want ok", response) + } + if len(read) != 2 { + t.Errorf("read %v, want every calendar", read) + } +} + +// HEY does not refuse a move it will not make — an invitation moves only onto a calendar +// nobody else is on — it answers with the event where it was. That is not an update. +func TestEventsEditSaysSoWhenHEYKeepsTheEventWhereItWas(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Family","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Swim meet","starts_at":"2026-10-03T08:00:00Z","ends_at":"2026-10-03T11:00:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/12/recordings.json": + _, _ = io.WriteString(w, `{}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + _, _ = io.WriteString(w, `{"id":4821,"title":"Swim meet","calendar":{"id":9}}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "12") + + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeForbidden { + t.Fatalf("error = %v, want a forbidden refusal", err) + } + if want := "HEY did not move event 4821 to calendar 12; it is still on calendar 9"; apiErr.Message != want { + t.Errorf("message = %q, want %q", apiErr.Message, want) + } +} + +// HEY answers 404 for a calendar it will not file on — the personal calendar, a subscription — +// and the event was just read, so that is what the not-found is about. +func TestEventsEditNamesTheCalendarHEYWillNotFileOn(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":14,"name":"US Holidays","owned":true,"external":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Swim meet","starts_at":"2026-10-03T08:00:00Z","ends_at":"2026-10-03T11:00:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/14/recordings.json": + _, _ = io.WriteString(w, `{}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + http.NotFound(w, r) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "14") + + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeNotFound { + t.Fatalf("error = %v, want a not-found naming the calendar", err) + } + if want := "HEY cannot move event 4821 to calendar 14"; apiErr.Message != want { + t.Errorf("message = %q, want %q", apiErr.Message, want) + } +} + // Writing blind would clear the notes, location, link and reminders of whichever event 4821 // turns out to be, so an event that cannot be read is not written. func TestEventsEditRefusesAnEventItCannotRead(t *testing.T) { diff --git a/internal/cmd/events_zone_test.go b/internal/cmd/events_zone_test.go index 2aef90af..a801546b 100644 --- a/internal/cmd/events_zone_test.go +++ b/internal/cmd/events_zone_test.go @@ -396,7 +396,7 @@ func TestEventsAddDefaultsToTodayInTheAccountZone(t *testing.T) { func runZoneEdit(t *testing.T, fixture zoneFixture, args ...string) *zoneRequests { t.Helper() handler, requests := zoneServer(t, fixture) - _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-10-14", "--calendar", "9"}, args...)...) + _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-10-14"}, args...)...) if err != nil { t.Fatalf("execute event edit: %v", err) } @@ -476,7 +476,7 @@ func TestEventsEditZonedEventKeepsItsZone(t *testing.T) { func TestEventsEditRefusesAStoredZoneItCannotLoad(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{accountZone: "America/New_York", event: strings.ReplaceAll(zonedEventJSON, "Europe/Zagreb", "Mars/Olympus_Mons")}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--calendar", "9", "--title", "Dentist appointment (moved)") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--title", "Dentist appointment (moved)") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "Mars/Olympus_Mons") { t.Fatalf("error = %v, want a usage error naming the zone", err) @@ -525,7 +525,7 @@ func TestEventsEditZonelessSeriesKeepsTheStartItWasNotGiven(t *testing.T) { series := `{"id":4821,"title":"Night shift handover","recurring":true,"starts_at":"2026-11-01T05:30:00Z","ends_at":"2026-11-01T06:30:00Z",` + `"recurrence_schedule":{"kind":"every_week","preset":true}}` handler, requests := zoneServer(t, zoneFixture{accountZone: "America/New_York", event: series}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-11-01", "--calendar", "9", "--end-time", "03:00") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-11-01", "--end-time", "03:00") if err != nil { t.Fatalf("execute event edit: %v", err) } @@ -586,7 +586,7 @@ func TestEventsEditZonelessEventMadeAllDayTakesTheAccountsDate(t *testing.T) { // A zoneless edit that needs a zone and cannot get one is refused, and nothing is written. func TestEventsEditRefusesWithoutAnAccountZone(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{event: zonelessEventJSON}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--calendar", "9", "--start-time", "11:00") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--start-time", "11:00") wantNoZoneRefusal(t, err, "your HEY account has no time zone set") if got := requests.writes.Load(); got != 0 { t.Errorf("writes = %d, want none", got) @@ -694,7 +694,7 @@ func TestEventsEditRefusesToMoveAKeptTimeOutOfTheRepeatedHour(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{accountZone: "America/New_York", event: tt.event}) - _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-11-01", "--calendar", "9"}, tt.args...)...) + _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-11-01"}, tt.args...)...) if tt.want == "" { var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "clocks repeat") { @@ -800,7 +800,7 @@ func TestEventsEditKeepsATimeHEYTakesTheLaterOf(t *testing.T) { event := `{"id":4821,"title":"Late call with Aigerim","starts_at":"` + tt.start + `","ends_at":"2024-02-29T20:00:00Z",` + `"starts_at_time_zone":"Asia/Almaty","ends_at_time_zone":"Asia/Almaty"}` handler, requests := zoneServer(t, zoneFixture{event: event}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2024-02-29", "--calendar", "9", "--title", "Late call with Aigerim (moved)") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2024-02-29", "--title", "Late call with Aigerim (moved)") if !tt.kept { var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "clocks repeat") { @@ -860,7 +860,7 @@ func TestEventsEditRefusesAnEventThatEndsBeforeItStarts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{accountZone: "America/New_York", event: tt.event}) - _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-10-14", "--calendar", "9"}, tt.args...)...) + _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-10-14"}, tt.args...)...) var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, tt.want) { t.Fatalf("error = %v, want a usage error containing %q", err, tt.want) @@ -919,7 +919,7 @@ func TestEventsEditRefusesToDropAKeptTimesSeconds(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{event: tt.event}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--calendar", "9", "--title", "Dentist appointment (moved)") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--title", "Dentist appointment (moved)") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || cliErr.Message != tt.want { t.Fatalf("error = %v, want %q", err, tt.want) @@ -945,7 +945,7 @@ func TestEventsEditSaysHowFarARepeatedTimeWouldMove(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{event: tt.event}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", tt.day, "--calendar", "9", "--title", "Night shift handover (Sam)") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", tt.day, "--title", "Night shift handover (Sam)") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, tt.want) { t.Fatalf("error = %v, want a usage error saying it %s", err, tt.want) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 94317d66..1a34bd84 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -853,8 +853,12 @@ notes come back as plain text, so their formatting is flattened, and a countdown recording of its own that the whole-event edit does not read back, so an edit removes one unless `--countdown` names it again. The event is looked for within a year either side of today and refused rather than written blind if it is not found — pass the day it starts -(`hey event edit 4821 2026-09-02`) for one outside that; `--calendar` only limits which -calendars are read. +(`hey event edit 4821 2026-09-02`) for one outside that. Every calendar is read, and +`--calendar` moves the event there (`hey event edit 4821 --calendar 9102`) — a calendar you +own or share, not the personal calendar or a subscription (`not_found` otherwise). An event +you cannot edit, such as an invitation, moves only onto a calendar nobody else is on; HEY +keeps it where it is otherwise, and the edit fails with `forbidden` rather than reporting +it updated. **An id alone edits the whole series; one day of it is `--occurrence` plus `--apply-to`.** `--occurrence` takes the `occurrence_id` from `day` or `week` exactly as served From e6a2118fe9628a9aa6b4571797d1ef159d9851d4 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 27 Sep 2026 22:30:49 -0400 Subject: [PATCH 2/3] Confirm a move HEY answers without a calendar, and name both 404s An older HEY redirects after an update, and the SDK then hands back only the event's id, so a move went unconfirmed and was reported as updated. The event is now read again on the day it starts to see which calendar it is on. A 404 on a move can be the calendar or an event deleted since it was read, and nothing in the answer says which, so the refusal names both instead of blaming the calendar. --- internal/cmd/events.go | 39 +++++++++++++++++------- internal/cmd/events_test.go | 59 +++++++++++++++++++++++++++++++++++-- skills/hey/SKILL.md | 2 +- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 0769bb6b..62d74180 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -444,7 +444,7 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error { return eventWriteError(err, id, changes.CalendarID) } if changes.CalendarID != nil { - if err = checkMoved(id, *changes.CalendarID, result); err != nil { + if err = c.checkMoved(ctx, id, schedule.startsAt, *changes.CalendarID, result); err != nil { return err } } @@ -455,16 +455,16 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error { result) } -// eventWriteError says what HEY's refusal of a move means. The event was read a moment ago, so -// a not-found is the calendar: HEY files only on a calendar you own or share, and answers 404 -// for any other — the personal calendar and a subscription among them — as it does when an -// event is added there. +// eventWriteError says what HEY's refusal of a move can mean. HEY files only on a calendar you +// own or share, and answers 404 for any other — the personal calendar and a subscription among +// them — as it does when an event is added there. The same 404 answers an event deleted since +// it was read, and nothing in it says which, so the refusal names both. func eventWriteError(err error, id int64, calendarID *int64) error { if calendarID != nil && hey.AsError(err).HTTPStatus == http.StatusNotFound { return &apierr.Error{ Code: apierr.CodeNotFound, - Message: fmt.Sprintf("HEY cannot move event %d to calendar %d", id, *calendarID), - Hint: "an event moves onto a calendar you own or share, not your personal calendar or a subscription; hey calendar list lists them", + Message: fmt.Sprintf("HEY answered not found moving event %d to calendar %d", id, *calendarID), + Hint: "HEY files an event only on a calendar you own or share, not your personal calendar or a subscription, and answers not found for any other or for an event deleted since it was read; hey calendar list lists them", HTTPStatus: http.StatusNotFound, Cause: err, } @@ -477,13 +477,32 @@ func eventWriteError(err error, id int64, calendarID *int64) error { // invitation, moves only onto a calendar nobody else is on, and otherwise HEY drops the // calendar from the update and answers with the event unmoved. Reporting that as updated // would be the one wrong answer, so the event HEY answers with is read for its calendar. -func checkMoved(id, calendarID int64, result *generated.Recording) error { - if result == nil || result.Calendar.Id == 0 || result.Calendar.Id == calendarID { +// +// HEY's JSON answer always names the calendar. An older server redirects instead, and the +// SDK then hands back only the id, so the event is read again on the day it now starts. +func (c *eventsEditCommand) checkMoved(ctx context.Context, id int64, startsOn string, calendarID int64, result *generated.Recording) error { + onCalendar := int64(0) + if result != nil { + onCalendar = result.Calendar.Id + } + if onCalendar == 0 { + event, err := c.findEvent(ctx, id, startsOn) + if err != nil { + return &apierr.Error{ + Code: apierr.CodeAPI, + Message: fmt.Sprintf("HEY saved event %d without saying which calendar it is on, and reading it back failed: %v", id, err), + Hint: fmt.Sprintf("hey event list --calendar %d shows whether it moved", calendarID), + Cause: err, + } + } + onCalendar = event.Calendar.Id + } + if onCalendar == calendarID { return nil } return &apierr.Error{ Code: apierr.CodeForbidden, - Message: fmt.Sprintf("HEY did not move event %d to calendar %d; it is still on calendar %d", id, calendarID, result.Calendar.Id), + Message: fmt.Sprintf("HEY did not move event %d to calendar %d; it is still on calendar %d", id, calendarID, onCalendar), Hint: "an event you cannot edit, such as an invitation, moves only onto a calendar nobody else is on", } } diff --git a/internal/cmd/events_test.go b/internal/cmd/events_test.go index e0a9dcdb..346643bc 100644 --- a/internal/cmd/events_test.go +++ b/internal/cmd/events_test.go @@ -409,8 +409,63 @@ func TestEventsEditSaysSoWhenHEYKeepsTheEventWhereItWas(t *testing.T) { } } +// An older HEY redirects after an update rather than answering with the event, so the answer +// names no calendar; the event is read again on the day it starts to see where it is. +func TestEventsEditReadsAMoveBackWhenHEYDoesNotSayWhereTheEventIs(t *testing.T) { + for _, tt := range []struct { + name, onCalendar, wantErr string + }{ + {name: "moved", onCalendar: "12"}, + {name: "kept where it was", onCalendar: "9", wantErr: "HEY did not move event 4821 to calendar 12; it is still on calendar 9"}, + } { + t.Run(tt.name, func(t *testing.T) { + moved := false + event := func(calendar string) string { + return `{"Calendar::Event":[{"id":4821,"title":"Swim meet","starts_at":"2026-10-03T08:00:00Z","ends_at":"2026-10-03T11:00:00Z","calendar":{"id":` + calendar + `}}]}` + } + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Nina","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + if !moved || tt.onCalendar == "9" { + _, _ = io.WriteString(w, event("9")) + return + } + _, _ = io.WriteString(w, `{}`) + case r.URL.Path == "/calendars/12/recordings.json": + if moved && tt.onCalendar == "12" { + _, _ = io.WriteString(w, event("12")) + return + } + _, _ = io.WriteString(w, `{}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + moved = true + _, _ = io.WriteString(w, `{"id":4821}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "12") + + if tt.wantErr == "" { + if err != nil { + t.Fatalf("execute event edit: %v", err) + } + return + } + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeForbidden || apiErr.Message != tt.wantErr { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + }) + } +} + // HEY answers 404 for a calendar it will not file on — the personal calendar, a subscription — -// and the event was just read, so that is what the not-found is about. +// and for an event deleted since it was read, so the refusal names the move rather than +// guessing which. func TestEventsEditNamesTheCalendarHEYWillNotFileOn(t *testing.T) { _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -433,7 +488,7 @@ func TestEventsEditNamesTheCalendarHEYWillNotFileOn(t *testing.T) { if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeNotFound { t.Fatalf("error = %v, want a not-found naming the calendar", err) } - if want := "HEY cannot move event 4821 to calendar 14"; apiErr.Message != want { + if want := "HEY answered not found moving event 4821 to calendar 14"; apiErr.Message != want { t.Errorf("message = %q, want %q", apiErr.Message, want) } } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 1a34bd84..51fe2b2f 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -855,7 +855,7 @@ unless `--countdown` names it again. The event is looked for within a year eithe today and refused rather than written blind if it is not found — pass the day it starts (`hey event edit 4821 2026-09-02`) for one outside that. Every calendar is read, and `--calendar` moves the event there (`hey event edit 4821 --calendar 9102`) — a calendar you -own or share, not the personal calendar or a subscription (`not_found` otherwise). An event +own or share, not the personal calendar or a subscription (HEY answers `not_found` otherwise). An event you cannot edit, such as an invitation, moves only onto a calendar nobody else is on; HEY keeps it where it is otherwise, and the edit fails with `forbidden` rather than reporting it updated. From 55a0b2aeada43c99799e01be154f94c120b342b3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 27 Sep 2026 23:25:44 -0400 Subject: [PATCH 3/3] Read a move back around both days, and keep its failure's code When HEY answers a move with the event's id alone, the event was read back on the requested start date as one UTC day. An event keeps its own zone's date, so a morning in Tokyo or an evening in Los Angeles sat on the neighbouring UTC day and was missed, as was an event whose date change HEY kept from happening. The read-back now spans the day it started on and the day it was asked to start on, a day wide either side. A read-back that failed after the write went through was always reported as an API error. It keeps its own code now, so an expired sign-in or a rate limit still exits as one. The event is looked for calendar by calendar and the search stops at the one it is on. Reading every calendar meant one that could not be read, listed after the event's, stood in the way of the edit. A refused move says what HEY still saved: an invitee's circle, countdown and reminders go through without the calendar. The help and docs say an event on a subscription does not move at all. --- docs/cli.md | 6 +- internal/cmd/events.go | 106 +++++++++++++++++++++++++++--------- internal/cmd/events_test.go | 93 ++++++++++++++++++++++++++++++- skills/hey/SKILL.md | 7 ++- 4 files changed, 179 insertions(+), 33 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index faa6d9b0..f5ee2732 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -500,8 +500,10 @@ than written blind. It is looked for within a year either side of today; pass th starts (`hey event edit 4821 2026-09-02`) for one outside that. Every calendar is read, and `--calendar` is where the event moves to: `hey event edit 4821 --calendar 9102`. That has to be a calendar you own or share, not your personal calendar or a subscription. An event you -cannot edit, such as an invitation, moves only onto a calendar nobody else is on; HEY keeps -it where it is otherwise, and the edit fails saying so rather than reporting it updated. +cannot edit, such as an invitation, moves only onto a calendar nobody else is on, and an +event on a subscription does not move at all; HEY keeps it where it is otherwise, and the +edit fails saying so rather than reporting it updated. HEY still saves the circle, countdown +and reminders of an event it does not move. An id on its own changes the whole event, a repeating series included. One day of a series is changed with `--occurrence`, which takes the `occurrence_id` that `hey event diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 62d74180..df436516 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "net/http" "strconv" @@ -279,8 +280,10 @@ alone. --calendar moves the event to that calendar, which has to be one you own or share: not your personal calendar and not a subscription. An event you cannot edit, such as an -invitation, moves only onto a calendar nobody else is on; HEY keeps it where it is -otherwise, and the edit says so rather than reporting it updated. +invitation, moves only onto a calendar nobody else is on, and an event on a subscription +does not move at all; HEY keeps it where it is otherwise, and the edit says so rather than +reporting it updated. HEY still saves the circle, countdown and reminders of an event it +does not move. An id alone changes the whole event, a repeating series included. One day of a series is changed with --occurrence, which takes the occurrence_id 'hey event day' and 'hey event @@ -444,7 +447,7 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error { return eventWriteError(err, id, changes.CalendarID) } if changes.CalendarID != nil { - if err = c.checkMoved(ctx, id, schedule.startsAt, *changes.CalendarID, result); err != nil { + if err = checkMoved(ctx, event, schedule.startsAt, *changes.CalendarID, result); err != nil { return err } } @@ -474,26 +477,24 @@ func eventWriteError(err error, id int64, calendarID *int64) error { // checkMoved says so when HEY answers a move by leaving the event where it was. HEY does not // refuse a calendar it will not move an event to: an event you may not edit, such as an -// invitation, moves only onto a calendar nobody else is on, and otherwise HEY drops the -// calendar from the update and answers with the event unmoved. Reporting that as updated -// would be the one wrong answer, so the event HEY answers with is read for its calendar. +// invitation, moves only onto a calendar nobody else is on and never off a subscription, and +// otherwise HEY drops the calendar from the update and answers with the event unmoved. +// Reporting that as updated would be the one wrong answer, so the event HEY answers with is +// read for its calendar. The rest of the update is not dropped with it — the circle, the +// countdown and the reminders are an invitee's to set — so the refusal says so. // // HEY's JSON answer always names the calendar. An older server redirects instead, and the -// SDK then hands back only the id, so the event is read again on the day it now starts. -func (c *eventsEditCommand) checkMoved(ctx context.Context, id int64, startsOn string, calendarID int64, result *generated.Recording) error { +// SDK then hands back only the id, so the event is read again, around the day it started on +// and the day it was asked to start on: HEY may have kept either. +func checkMoved(ctx context.Context, was generated.Recording, startsOn string, calendarID int64, result *generated.Recording) error { onCalendar := int64(0) if result != nil { onCalendar = result.Calendar.Id } if onCalendar == 0 { - event, err := c.findEvent(ctx, id, startsOn) + event, err := readMovedEvent(ctx, was, startsOn) if err != nil { - return &apierr.Error{ - Code: apierr.CodeAPI, - Message: fmt.Sprintf("HEY saved event %d without saying which calendar it is on, and reading it back failed: %v", id, err), - Hint: fmt.Sprintf("hey event list --calendar %d shows whether it moved", calendarID), - Cause: err, - } + return movedReadBackError(was.Id, calendarID, err) } onCalendar = event.Calendar.Id } @@ -502,11 +503,53 @@ func (c *eventsEditCommand) checkMoved(ctx context.Context, id int64, startsOn s } return &apierr.Error{ Code: apierr.CodeForbidden, - Message: fmt.Sprintf("HEY did not move event %d to calendar %d; it is still on calendar %d", id, calendarID, onCalendar), - Hint: "an event you cannot edit, such as an invitation, moves only onto a calendar nobody else is on", + Message: fmt.Sprintf("HEY did not move event %d to calendar %d; it is still on calendar %d", was.Id, calendarID, onCalendar), + Hint: "an event you cannot edit, such as an invitation, moves only onto a calendar nobody else is on and never off a subscription; HEY may still have saved the circle, countdown and reminders this edit sent", } } +// readMovedEvent reads an event back after a write HEY answered with its id alone. The days +// are UTC days, and an event keeps its own zone's date, so each end is read a day wide either +// way: a morning in Tokyo or an evening in Los Angeles sits on the neighbouring UTC day. +func readMovedEvent(ctx context.Context, was generated.Recording, startsOn string) (generated.Recording, error) { + from := calendarDay(was.StartsAt.UTC()) + to := from + if day, err := time.Parse(dateLayout, startsOn); err == nil { + if day.Before(from) { + from = day + } + if day.After(to) { + to = day + } + } + filter := recordingFilter{ + startsOn: from.AddDate(0, 0, -1).Format(dateLayout), + endsOn: to.AddDate(0, 0, 2).Format(dateLayout), + defaultWindow: eventSearchYear, + defaultCalendars: allCalendarIDs, + } + window, err := filter.resolve(ctx) + if err != nil { + return generated.Recording{}, err + } + return findEventIn(ctx, window, was.Id) +} + +// movedReadBackError is a read-back that failed after the write went through. It keeps the +// failure's own code, so an expired sign-in or a rate limit still exits as one, and says the +// write was made so nobody runs it again to find out. +func movedReadBackError(id, calendarID int64, err error) error { + var readErr *apierr.Error + if !errors.As(err, &readErr) { + readErr = &apierr.Error{Code: apierr.CodeAPI, Message: err.Error()} + } + wrapped := *readErr + wrapped.Message = fmt.Sprintf("HEY saved event %d without saying which calendar it is on, and reading it back failed: %s", id, readErr.Message) + wrapped.Hint = fmt.Sprintf("hey event list --calendar %d shows whether it moved", calendarID) + wrapped.Cause = err + return &wrapped +} + // findEvent reads the event so the update can send back what it is not changing. HEY serves // no event on its own, so it is looked for among the recordings of every calendar it could be // on, over the day given or a window wide enough to cover an event somebody is editing. @@ -522,14 +565,21 @@ func (c *eventsEditCommand) findEvent(ctx context.Context, id int64, on string) if err != nil { return generated.Recording{}, err } + return findEventIn(ctx, window, id) +} - events, err := window.read(ctx, recordingTypeEvent) - if err != nil { - return generated.Recording{}, err - } - for _, event := range events { - if event.Id == id { - return event, nil +// findEventIn looks for the event calendar by calendar and stops at the one it is on, so a +// calendar listed after it that cannot be read does not stand in the way of an edit. +func findEventIn(ctx context.Context, window recordingWindow, id int64) (generated.Recording, error) { + for _, calendarID := range window.calendars { + events, err := window.readCalendar(ctx, calendarID, []string{recordingTypeEvent}) + if err != nil { + return generated.Recording{}, err + } + for _, event := range events { + if event.Id == id { + return event, nil + } } } @@ -547,12 +597,18 @@ func eventSearchWindow(ctx context.Context, on string) (recordingWindow, error) filter := recordingFilter{ startsOn: on, endsOn: endsOn, - defaultWindow: func(today time.Time) (time.Time, time.Time) { return today.AddDate(-1, 0, 0), today.AddDate(1, 0, 0) }, + defaultWindow: eventSearchYear, defaultCalendars: allCalendarIDs, } return filter.resolve(ctx) } +// eventSearchYear is a year either side of today, wide enough to cover an event somebody is +// editing without being told the day it is on. +func eventSearchYear(today time.Time) (time.Time, time.Time) { + return today.AddDate(-1, 0, 0), today.AddDate(1, 0, 0) +} + // delete type eventsDeleteCommand struct { diff --git a/internal/cmd/events_test.go b/internal/cmd/events_test.go index 346643bc..e1bde982 100644 --- a/internal/cmd/events_test.go +++ b/internal/cmd/events_test.go @@ -348,7 +348,7 @@ func TestEventsEditMovesTheEventToAnotherCalendar(t *testing.T) { w.Header().Set("Content-Type", "application/json") switch { case r.URL.Path == "/calendars.json": - _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Nina","owned":true}}]}`) + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":12,"name":"Nina","owned":true}},{"calendar":{"id":9,"name":"Elena","owned":true}}]}`) case r.URL.Path == "/calendars/9/recordings.json": read = append(read, r.URL.Path) _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Parent-teacher conference","starts_at":"2026-10-01T15:00:00Z","ends_at":"2026-10-01T15:30:00Z","calendar":{"id":9}}]}`) @@ -375,8 +375,8 @@ func TestEventsEditMovesTheEventToAnotherCalendar(t *testing.T) { if !response.OK { t.Errorf("response = %+v, want ok", response) } - if len(read) != 2 { - t.Errorf("read %v, want every calendar", read) + if want := "/calendars/12/recordings.json /calendars/9/recordings.json"; strings.Join(read, " ") != want { + t.Errorf("read %v, want the calendar it moves to and then the one it is on", read) } } @@ -463,6 +463,93 @@ func TestEventsEditReadsAMoveBackWhenHEYDoesNotSayWhereTheEventIs(t *testing.T) } } +// The read-back reads UTC days, and HEY may have kept the day the event started on as well +// as the one it was asked to start on, so it reads from a day before the earlier to a day +// after the later. +func TestEventsEditReadsAMoveBackAroundBothDays(t *testing.T) { + var window []string + moved := false + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Nina","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json" && !moved: + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"School trip","all_day":true,"starts_at":"2026-10-03T00:00:00Z","ends_at":"2026-10-03T00:00:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + window = []string{r.URL.Query().Get("starts_on"), r.URL.Query().Get("ends_on")} + _, _ = io.WriteString(w, `{}`) + case r.URL.Path == "/calendars/12/recordings.json" && moved: + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"School trip","all_day":true,"starts_at":"2026-10-10T00:00:00Z","ends_at":"2026-10-10T00:00:00Z","calendar":{"id":12}}]}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + moved = true + _, _ = io.WriteString(w, `{"id":4821}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "12", "--starts-on", "2026-10-10", "--ends-on", "2026-10-10") + if err != nil { + t.Fatalf("execute event edit: %v", err) + } + if want := []string{"2026-10-02", "2026-10-12"}; strings.Join(window, " ") != strings.Join(want, " ") { + t.Errorf("read back %v, want %v", window, want) + } +} + +// A read-back that fails after the write went through keeps its own code, so a caller can +// tell an expired sign-in or a missing calendar from HEY refusing the move. +func TestEventsEditKeepsTheCodeOfAFailedReadBack(t *testing.T) { + moved := false + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json" && !moved: + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Swim meet","all_day":true,"starts_at":"2026-10-03T00:00:00Z","ends_at":"2026-10-03T00:00:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + http.NotFound(w, r) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + moved = true + _, _ = io.WriteString(w, `{"id":4821}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "12") + + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeNotFound { + t.Fatalf("error = %v, want the read-back's own not-found", err) + } + if want := "HEY saved event 4821 without saying which calendar it is on"; !strings.HasPrefix(apiErr.Message, want) { + t.Errorf("message = %q, want it to start %q", apiErr.Message, want) + } +} + +// The event is looked for calendar by calendar, so a calendar after the one it is on that +// cannot be read is never asked. +func TestEventsEditStopsAtTheCalendarTheEventIsOn(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Nina","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Swim meet","all_day":true,"starts_at":"2026-10-03T00:00:00Z","ends_at":"2026-10-03T00:00:00Z","calendar":{"id":9}}]}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + _, _ = io.WriteString(w, `{"id":4821,"title":"Swim meet (moved)","calendar":{"id":9}}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--title", "Swim meet (moved)") + if err != nil { + t.Fatalf("execute event edit: %v", err) + } +} + // HEY answers 404 for a calendar it will not file on — the personal calendar, a subscription — // and for an event deleted since it was read, so the refusal names the move rather than // guessing which. diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 51fe2b2f..a32272bd 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -856,9 +856,10 @@ today and refused rather than written blind if it is not found — pass the day (`hey event edit 4821 2026-09-02`) for one outside that. Every calendar is read, and `--calendar` moves the event there (`hey event edit 4821 --calendar 9102`) — a calendar you own or share, not the personal calendar or a subscription (HEY answers `not_found` otherwise). An event -you cannot edit, such as an invitation, moves only onto a calendar nobody else is on; HEY -keeps it where it is otherwise, and the edit fails with `forbidden` rather than reporting -it updated. +you cannot edit, such as an invitation, moves only onto a calendar nobody else is on, and +an event on a subscription does not move at all; HEY keeps it where it is otherwise, and +the edit fails with `forbidden` rather than reporting it updated (the circle, countdown and +reminders it sent may still have been saved). **An id alone edits the whole series; one day of it is `--occurrence` plus `--apply-to`.** `--occurrence` takes the `occurrence_id` from `day` or `week` exactly as served