feat(calendar): add event ownership transfer

This commit is contained in:
Peter Steinberger 2026-05-04 05:50:28 +01:00
parent c453cbd5a0
commit 6fd874075e
No known key found for this signature in database
9 changed files with 224 additions and 0 deletions

View File

@ -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.

View File

@ -989,6 +989,10 @@ gog calendar create <calendarId> \
gog calendar update <calendarId> <eventId> \
--send-updates externalOnly
# Move an event to another calendar, changing the event organizer.
gog calendar move <calendarId> <eventId> <destinationCalendarId> \
--send-updates all
# Default: no attendee notifications unless you pass --send-updates.
gog calendar delete <calendarId> <eventId> \
--send-updates all --force

View File

@ -74,6 +74,7 @@ Generated from `gog schema --json`.
- `gog calendar (cal) events (list,ls) [<calendarId> ...] [flags]` - List events from a calendar or all calendars
- `gog calendar (cal) focus-time (focus) --from=STRING --to=STRING [<calendarId>] [flags]` - Create a Focus Time block
- `gog calendar (cal) freebusy [<calendarIds>] [flags]` - Get free/busy
- `gog calendar (cal) move (transfer) <calendarId> <eventId> <destinationCalendarId> [flags]` - Move an event to another calendar
- `gog calendar (cal) out-of-office (ooo) --from=STRING --to=STRING [<calendarId>] [flags]` - Create an Out of Office event
- `gog calendar (cal) propose-time <calendarId> <eventId> [flags]` - Generate URL to propose a new meeting time (browser-only feature)
- `gog calendar (cal) respond (rsvp,reply) <calendarId> <eventId> [flags]` - Respond to an event invitation

View File

@ -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"`

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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) {

View File

@ -62,6 +62,7 @@ calendar:
event: true
create: true
update: true
move: true
delete: false
freebusy: true
respond: false

View File

@ -67,6 +67,7 @@ calendar:
event: true
create: false
update: false
move: false
delete: false
freebusy: true
respond: false