feat(calendar): add event ownership transfer
This commit is contained in:
parent
c453cbd5a0
commit
6fd874075e
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
63
internal/cmd/calendar_move.go
Normal file
63
internal/cmd/calendar_move.go
Normal 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)
|
||||
}
|
||||
144
internal/cmd/calendar_move_test.go
Normal file
144
internal/cmd/calendar_move_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -62,6 +62,7 @@ calendar:
|
||||
event: true
|
||||
create: true
|
||||
update: true
|
||||
move: true
|
||||
delete: false
|
||||
freebusy: true
|
||||
respond: false
|
||||
|
||||
@ -67,6 +67,7 @@ calendar:
|
||||
event: true
|
||||
create: false
|
||||
update: false
|
||||
move: false
|
||||
delete: false
|
||||
freebusy: true
|
||||
respond: false
|
||||
|
||||
Loading…
Reference in New Issue
Block a user