diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb582e..a75b4dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Agent safety: add baked safety-profile builds for fail-closed agent binaries, with `agent-safe`, `readonly`, and `full` profiles, filtered help/schema output, docs, and build tooling. (#366, #239) — thanks @drewburchfield. - Calendar: add `--with-meet` to `calendar update` for adding Google Meet conferencing to existing events. (#538) — thanks @alexisperumal. +- Calendar: add `calendar move` / `calendar transfer` to move an event to another calendar and change its organizer. (#448) — thanks @markusbkoch. - Docs: add `docs add-tab`, `docs rename-tab`, and `docs delete-tab` for managing Google Docs tabs. (#547) — thanks @chopenhauer. - Docs: support tab-scoped Markdown append and find-replace flows. (#541) — thanks @donbowman. diff --git a/README.md b/README.md index 7433637..224db64 100644 --- a/README.md +++ b/README.md @@ -989,6 +989,10 @@ gog calendar create \ gog calendar update \ --send-updates externalOnly +# Move an event to another calendar, changing the event organizer. +gog calendar move \ + --send-updates all + # Default: no attendee notifications unless you pass --send-updates. gog calendar delete \ --send-updates all --force diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 56c23e8..96822a5 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -74,6 +74,7 @@ Generated from `gog schema --json`. - `gog calendar (cal) events (list,ls) [ ...] [flags]` - List events from a calendar or all calendars - `gog calendar (cal) focus-time (focus) --from=STRING --to=STRING [] [flags]` - Create a Focus Time block - `gog calendar (cal) freebusy [] [flags]` - Get free/busy +- `gog calendar (cal) move (transfer) [flags]` - Move an event to another calendar - `gog calendar (cal) out-of-office (ooo) --from=STRING --to=STRING [] [flags]` - Create an Out of Office event - `gog calendar (cal) propose-time [flags]` - Generate URL to propose a new meeting time (browser-only feature) - `gog calendar (cal) respond (rsvp,reply) [flags]` - Respond to an event invitation diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index 86d896d..ba5cb6f 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -10,6 +10,7 @@ type CalendarCmd struct { Event CalendarEventCmd `cmd:"" name:"event" aliases:"get,info,show" help:"Get event"` Create CalendarCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create an event"` Update CalendarUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update an event"` + Move CalendarMoveCmd `cmd:"" name:"move" aliases:"transfer" help:"Move an event to another calendar"` Delete CalendarDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete an event"` FreeBusy CalendarFreeBusyCmd `cmd:"" name:"freebusy" help:"Get free/busy"` Respond CalendarRespondCmd `cmd:"" name:"respond" aliases:"rsvp,reply" help:"Respond to an event invitation"` diff --git a/internal/cmd/calendar_move.go b/internal/cmd/calendar_move.go new file mode 100644 index 0000000..a55ba56 --- /dev/null +++ b/internal/cmd/calendar_move.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "context" + "strings" +) + +type CalendarMoveCmd struct { + CalendarID string `arg:"" name:"calendarId" help:"Source calendar ID"` + EventID string `arg:"" name:"eventId" help:"Event ID"` + DestinationCalendarID string `arg:"" name:"destinationCalendarId" help:"Destination calendar ID that becomes the event organizer"` + SendUpdates string `name:"send-updates" help:"Notification mode: all, externalOnly, none (default: none)"` +} + +func (c *CalendarMoveCmd) Run(ctx context.Context, flags *RootFlags) error { + calendarID, err := prepareCalendarID(c.CalendarID, false) + if err != nil { + return err + } + eventID := normalizeCalendarEventID(c.EventID) + if eventID == "" { + return usage("empty eventId") + } + destinationCalendarID, err := prepareCalendarID(c.DestinationCalendarID, false) + if err != nil { + return err + } + if strings.EqualFold(calendarID, destinationCalendarID) { + return usage("destination calendar must differ from source calendar") + } + sendUpdates, err := validateSendUpdates(c.SendUpdates) + if err != nil { + return err + } + + if dryRunErr := dryRunExit(ctx, flags, "calendar.move", map[string]any{ + "calendar_id": calendarID, + "event_id": eventID, + "destination_calendar_id": destinationCalendarID, + "send_updates": sendUpdates, + }); dryRunErr != nil { + return dryRunErr + } + + mutation, err := newCalendarMutationContext(ctx, flags, calendarID) + if err != nil { + return err + } + destinationCalendarID, err = resolveCalendarID(ctx, mutation.svc, destinationCalendarID) + if err != nil { + return err + } + if strings.EqualFold(mutation.calendarID, destinationCalendarID) { + return usage("destination calendar must differ from source calendar") + } + + moved, err := mutation.moveEvent(ctx, eventID, destinationCalendarID, sendUpdates) + if err != nil { + return err + } + mutation.calendarID = destinationCalendarID + return mutation.writeEvent(ctx, moved) +} diff --git a/internal/cmd/calendar_move_test.go b/internal/cmd/calendar_move_test.go new file mode 100644 index 0000000..6c69f87 --- /dev/null +++ b/internal/cmd/calendar_move_test.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "google.golang.org/api/calendar/v3" +) + +func TestCalendarMoveCmd_RunJSON(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + var ( + gotDestination string + gotSendUpdates string + ) + svc, closeSvc := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + switch { + case r.Method == http.MethodPost && path == "/calendars/agent@example.com/events/ev/move": + gotDestination = r.URL.Query().Get("destination") + gotSendUpdates = r.URL.Query().Get("sendUpdates") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "ev", + "summary": "Moved", + "organizer": map[string]any{ + "email": "owner@example.com", + }, + }) + return + case r.Method == http.MethodGet && path == "/calendars/owner@example.com": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "owner@example.com", + "timeZone": "UTC", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer closeSvc() + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } + + ctx := newCalendarJSONContext(t) + + cmd := CalendarMoveCmd{ + CalendarID: "agent@example.com", + EventID: "ev", + DestinationCalendarID: "owner@example.com", + SendUpdates: "all", + } + out := captureStdout(t, func() { + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("CalendarMoveCmd: %v", err) + } + }) + var payload struct { + Event struct { + ID string `json:"id"` + Summary string `json:"summary"` + Organizer struct { + Email string `json:"email"` + } `json:"organizer"` + } `json:"event"` + } + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode output: %v", err) + } + if gotDestination != "owner@example.com" || gotSendUpdates != "all" { + t.Fatalf("unexpected query destination=%q sendUpdates=%q", gotDestination, gotSendUpdates) + } + if payload.Event.ID != "ev" || payload.Event.Organizer.Email != "owner@example.com" { + t.Fatalf("unexpected output: %#v", payload) + } +} + +func TestCalendarMoveCmd_DryRunSkipsService(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + called := false + newCalendarService = func(context.Context, string) (*calendar.Service, error) { + called = true + return nil, errors.New("unexpected service creation") + } + + ctx := newCalendarJSONContext(t) + + cmd := CalendarMoveCmd{ + CalendarID: "agent@example.com", + EventID: "ev", + DestinationCalendarID: "owner@example.com", + } + out := captureStdout(t, func() { + err := cmd.Run(ctx, &RootFlags{Account: "a@b.com", DryRun: true, NoInput: true}) + if ExitCode(err) != 0 { + t.Fatalf("expected dry-run exit, got %v", err) + } + }) + if called { + t.Fatalf("expected no service creation during dry-run") + } + var payload struct { + Op string `json:"op"` + Request map[string]any `json:"request"` + } + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode output: %v", err) + } + if payload.Op != "calendar.move" || payload.Request["destination_calendar_id"] != "owner@example.com" { + t.Fatalf("unexpected dry-run output: %#v", payload) + } +} + +func TestCalendarMoveCmd_RejectsSameCalendar(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + called := false + newCalendarService = func(context.Context, string) (*calendar.Service, error) { + called = true + return nil, errors.New("unexpected service creation") + } + + err := (&CalendarMoveCmd{ + CalendarID: "owner@example.com", + EventID: "ev", + DestinationCalendarID: "owner@example.com", + }).Run(newCalendarJSONContext(t), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "destination calendar must differ") { + t.Fatalf("expected same-calendar error, got %v", err) + } + if called { + t.Fatalf("expected no service creation for same-calendar validation") + } +} diff --git a/internal/cmd/calendar_mutation_helpers.go b/internal/cmd/calendar_mutation_helpers.go index 2ca24b1..ee45205 100644 --- a/internal/cmd/calendar_mutation_helpers.go +++ b/internal/cmd/calendar_mutation_helpers.go @@ -71,6 +71,14 @@ func (m *calendarMutationContext) deleteEvent(ctx context.Context, eventID, send return call.Do() } +func (m *calendarMutationContext) moveEvent(ctx context.Context, eventID, destinationCalendarID, sendUpdates string) (*calendar.Event, error) { + call := m.svc.Events.Move(m.calendarID, eventID, destinationCalendarID).Context(ctx) + if sendUpdates != "" { + call = call.SendUpdates(sendUpdates) + } + return call.Do() +} + func (m *calendarMutationContext) writeEvent(ctx context.Context, event *calendar.Event) error { tz, loc, _ := getCalendarLocation(ctx, m.svc, m.calendarID) if outfmt.IsJSON(ctx) { diff --git a/safety-profiles/agent-safe.yaml b/safety-profiles/agent-safe.yaml index e213cbc..4f09668 100644 --- a/safety-profiles/agent-safe.yaml +++ b/safety-profiles/agent-safe.yaml @@ -62,6 +62,7 @@ calendar: event: true create: true update: true + move: true delete: false freebusy: true respond: false diff --git a/safety-profiles/readonly.yaml b/safety-profiles/readonly.yaml index c326bf0..9cb6d1f 100644 --- a/safety-profiles/readonly.yaml +++ b/safety-profiles/readonly.yaml @@ -67,6 +67,7 @@ calendar: event: true create: false update: false + move: false delete: false freebusy: true respond: false