Skip to content

Commit 52ff465

Browse files
authored
feat(calendar): support resource attendees (#881)
Co-authored-by: Titus <titus7490@users.noreply.github.com>
1 parent e022bb9 commit 52ff465

10 files changed

Lines changed: 109 additions & 26 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 0.31.1 - Unreleased
44

5+
- Calendar: add a `;resource` attendee modifier for event create, attendee replacement, and additive attendee updates. (#881) — thanks @titus7490.
6+
57
## 0.31.0 - 2026-06-24
68

79
- Calendar: add `calendar changed` for listing recently modified events, including cancellations, across one or more calendars. (#875) — thanks @sorenisanerd.

docs/commands/gog-calendar-create.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ gog calendar (cal) create (add,new) <calendarId> [flags]
2222
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands |
2323
| `--all-day` | `bool` | | All-day event (use date-only in --from/--to) |
2424
| `--attachment` | `[]string` | | File attachment URL (can be repeated) |
25-
| `--attendees` | `string` | | Comma-separated attendee emails |
25+
| `--attendees` | `string` | | Comma-separated attendee emails; modifiers: ;optional, ;resource, ;comment=TEXT |
2626
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
2727
| `--color` | `string` | auto | Color output: auto\|always\|never |
2828
| `--description` | `string` | | Description |

docs/commands/gog-calendar-update.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ gog calendar (cal) update (edit,set) <calendarId> <eventId> [flags]
2020
| --- | --- | --- | --- |
2121
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
2222
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands |
23-
| `--add-attendee` | `string` | | Comma-separated attendee emails to add (preserves existing attendees) |
23+
| `--add-attendee` | `string` | | Comma-separated attendee emails to add (preserves existing attendees); modifiers: ;optional, ;resource, ;comment=TEXT |
2424
| `--all-day` | `bool` | | All-day event (use date-only in --from/--to) |
2525
| `--attachment` | `[]string` | | File attachment URL (can be repeated; replaces all; set empty to clear) |
26-
| `--attendees` | `string` | | Comma-separated attendee emails (replaces all; set empty to clear) |
26+
| `--attendees` | `string` | | Comma-separated attendee emails (replaces all; set empty to clear); modifiers: ;optional, ;resource, ;comment=TEXT |
2727
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
2828
| `--color` | `string` | auto | Color output: auto\|always\|never |
2929
| `--description` | `string` | | New description (set empty to clear) |

internal/cmd/calendar_add_attendee_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,23 @@ func TestMergeAttendeesNewHaveNeedsAction(t *testing.T) {
9898
t.Error("new attendee not found in result")
9999
}
100100

101+
func TestMergeAttendeesPreservesNewAttendeeModifiers(t *testing.T) {
102+
got := mergeAttendees(nil, "room@resource.calendar.google.com;resource;optional;comment=Project room")
103+
if len(got) != 1 {
104+
t.Fatalf("expected 1 attendee, got %d", len(got))
105+
}
106+
attendee := got[0]
107+
if attendee.Email != "room@resource.calendar.google.com" {
108+
t.Fatalf("unexpected email: %#v", attendee)
109+
}
110+
if !attendee.Resource || !attendee.Optional || attendee.Comment != "Project room" {
111+
t.Fatalf("expected resource, optional, and comment modifiers: %#v", attendee)
112+
}
113+
if attendee.ResponseStatus != "needsAction" {
114+
t.Fatalf("expected needsAction response status, got %q", attendee.ResponseStatus)
115+
}
116+
}
117+
101118
func TestMergeAttendeesWithChange(t *testing.T) {
102119
existing := []*calendar.EventAttendee{
103120
{Email: "existing@test.com", ResponseStatus: "accepted"},

internal/cmd/calendar_attendees.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ func mergeAttendees(existing []*calendar.EventAttendee, addCSV string) []*calend
3030

3131
// mergeAttendeesWithChange returns the merged attendees and whether at least one attendee was added.
3232
func mergeAttendeesWithChange(existing []*calendar.EventAttendee, addCSV string) ([]*calendar.EventAttendee, bool) {
33-
newEmails := splitCSV(addCSV)
34-
if len(newEmails) == 0 {
33+
newAttendees := buildAttendees(addCSV)
34+
if len(newAttendees) == 0 {
3535
return existing, false
3636
}
3737

@@ -44,17 +44,19 @@ func mergeAttendeesWithChange(existing []*calendar.EventAttendee, addCSV string)
4444
}
4545

4646
// Start with existing attendees (preserving all metadata)
47-
out := make([]*calendar.EventAttendee, 0, len(existing)+len(newEmails))
47+
out := make([]*calendar.EventAttendee, 0, len(existing)+len(newAttendees))
4848
out = append(out, existing...)
4949

5050
// Add new attendees that don't already exist
5151
added := false
52-
for _, email := range newEmails {
52+
for _, attendee := range newAttendees {
53+
if attendee == nil || attendee.Email == "" {
54+
continue
55+
}
56+
email := attendee.Email
5357
if !existingEmails[strings.ToLower(email)] {
54-
out = append(out, &calendar.EventAttendee{
55-
Email: email,
56-
ResponseStatus: "needsAction",
57-
})
58+
attendee.ResponseStatus = taskStatusNeedsAction
59+
out = append(out, attendee)
5860
existingEmails[strings.ToLower(email)] = true
5961
added = true
6062
}
@@ -81,6 +83,10 @@ func parseAttendee(s string) *calendar.EventAttendee {
8183
attendee.Optional = true
8284
continue
8385
}
86+
if lower == "resource" {
87+
attendee.Resource = true
88+
continue
89+
}
8490
if strings.HasPrefix(lower, "comment=") {
8591
attendee.Comment = strings.TrimSpace(raw[len("comment="):])
8692
}

internal/cmd/calendar_create_update_test.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ func TestCalendarUpdateCmd_WithMeetScopeFutureExistingConferenceIsIdempotent(t *
719719
}
720720

721721
func TestCalendarUpdateCmd_AddAttendee(t *testing.T) {
722-
var patchedAttendees int
722+
var patchedAttendees []*calendar.EventAttendee
723723
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
724724
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
725725
switch {
@@ -735,7 +735,7 @@ func TestCalendarUpdateCmd_AddAttendee(t *testing.T) {
735735
case r.Method == http.MethodPatch && path == "/calendars/cal@example.com/events/ev":
736736
var body calendar.Event
737737
_ = json.NewDecoder(r.Body).Decode(&body)
738-
patchedAttendees = len(body.Attendees)
738+
patchedAttendees = body.Attendees
739739
w.Header().Set("Content-Type", "application/json")
740740
_ = json.NewEncoder(w).Encode(map[string]any{
741741
"id": "ev",
@@ -762,13 +762,20 @@ func TestCalendarUpdateCmd_AddAttendee(t *testing.T) {
762762
if err := runKong(t, cmd, []string{
763763
"cal@example.com",
764764
"ev",
765-
"--add-attendee", "b@example.com",
765+
"--add-attendee", "room@resource.calendar.google.com;resource;optional;comment=Project room",
766766
"--scope", "all",
767767
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
768768
t.Fatalf("runKong: %v", err)
769769
}
770-
if patchedAttendees < 2 {
771-
t.Fatalf("expected merged attendees, got %d", patchedAttendees)
770+
if len(patchedAttendees) < 2 {
771+
t.Fatalf("expected merged attendees, got %d", len(patchedAttendees))
772+
}
773+
added := patchedAttendees[len(patchedAttendees)-1]
774+
if added.Email != "room@resource.calendar.google.com" || !added.Resource || !added.Optional || added.Comment != "Project room" {
775+
t.Fatalf("unexpected added resource attendee: %#v", added)
776+
}
777+
if added.ResponseStatus != "needsAction" {
778+
t.Fatalf("expected needsAction response status, got %q", added.ResponseStatus)
772779
}
773780
}
774781

internal/cmd/calendar_edit.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type CalendarCreateCmd struct {
2929
PlaceID string `name:"place-id" help:"Resolve a Google Places ID and use it as event location"`
3030
PlaceLanguage string `name:"place-language" help:"Places API language code for location lookup"`
3131
PlaceRegion string `name:"place-region" help:"Places API region code for location lookup"`
32-
Attendees string `name:"attendees" help:"Comma-separated attendee emails"`
32+
Attendees string `name:"attendees" help:"Comma-separated attendee emails; modifiers: ;optional, ;resource, ;comment=TEXT"`
3333
AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"`
3434
Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated." sep:"none"`
3535
Reminders []string `name:"reminder" help:"Custom reminders as method:duration (e.g., popup:30m, email:1d). Can be repeated (max 5)."`
@@ -188,8 +188,8 @@ type CalendarUpdateCmd struct {
188188
PlaceID string `name:"place-id" help:"Resolve a Google Places ID and use it as event location"`
189189
PlaceLanguage string `name:"place-language" help:"Places API language code for location lookup"`
190190
PlaceRegion string `name:"place-region" help:"Places API region code for location lookup"`
191-
Attendees string `name:"attendees" help:"Comma-separated attendee emails (replaces all; set empty to clear)"`
192-
AddAttendee string `name:"add-attendee" help:"Comma-separated attendee emails to add (preserves existing attendees)"`
191+
Attendees string `name:"attendees" help:"Comma-separated attendee emails (replaces all; set empty to clear); modifiers: ;optional, ;resource, ;comment=TEXT"`
192+
AddAttendee string `name:"add-attendee" help:"Comma-separated attendee emails to add (preserves existing attendees); modifiers: ;optional, ;resource, ;comment=TEXT"`
193193
Attachments []string `name:"attachment" help:"File attachment URL (can be repeated; replaces all; set empty to clear)"`
194194
AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"`
195195
Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated. Set empty to clear." sep:"none"`

internal/cmd/calendar_edit_patch_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,34 @@ func TestCalendarUpdateBuildPatch(t *testing.T) {
5555
}
5656
}
5757

58+
func TestCalendarUpdateBuildPatchResourceAttendee(t *testing.T) {
59+
cmd := &CalendarUpdateCmd{}
60+
parser, err := kong.New(cmd, kong.Writers(io.Discard, io.Discard))
61+
if err != nil {
62+
t.Fatalf("kong.New: %v", err)
63+
}
64+
kctx, err := parser.Parse([]string{
65+
"cal1",
66+
"evt1",
67+
"--attendees", "room@resource.calendar.google.com;resource;optional;comment=Project room",
68+
})
69+
if err != nil {
70+
t.Fatalf("parse: %v", err)
71+
}
72+
73+
patch, changed, err := buildCalendarUpdatePatch(calendarUpdateInputFromCommand(cmd), calendarUpdateFieldsFromKong(kctx))
74+
if err != nil {
75+
t.Fatalf("buildUpdatePatch: %v", err)
76+
}
77+
if !changed || len(patch.Attendees) != 1 {
78+
t.Fatalf("unexpected attendees patch: %#v", patch)
79+
}
80+
attendee := patch.Attendees[0]
81+
if attendee.Email != "room@resource.calendar.google.com" || !attendee.Resource || !attendee.Optional || attendee.Comment != "Project room" {
82+
t.Fatalf("unexpected resource attendee: %#v", attendee)
83+
}
84+
}
85+
5886
func TestCalendarUpdateBuildPatch_ClearFields(t *testing.T) {
5987
cmd := &CalendarUpdateCmd{}
6088
parser, err := kong.New(cmd, kong.Writers(io.Discard, io.Discard))

internal/cmd/calendar_event_plan_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,26 @@ func TestBuildCalendarCreatePlan(t *testing.T) {
147147
}
148148
}
149149

150+
func TestBuildCalendarCreatePlanResourceAttendee(t *testing.T) {
151+
plan, err := buildCalendarCreatePlan(defaultConfigStoreForTest(t), calendarCreateInput{
152+
CalendarID: "primary",
153+
Summary: "Room booking",
154+
From: "2026-05-10T10:00:00Z",
155+
To: "2026-05-10T11:00:00Z",
156+
Attendees: "room@resource.calendar.google.com;resource;comment=Project room",
157+
}, calendarCreateFields{})
158+
if err != nil {
159+
t.Fatalf("buildCalendarCreatePlan: %v", err)
160+
}
161+
if len(plan.Event.Attendees) != 1 {
162+
t.Fatalf("expected 1 attendee, got %#v", plan.Event.Attendees)
163+
}
164+
attendee := plan.Event.Attendees[0]
165+
if attendee.Email != "room@resource.calendar.google.com" || !attendee.Resource || attendee.Comment != "Project room" {
166+
t.Fatalf("unexpected resource attendee: %#v", attendee)
167+
}
168+
}
169+
150170
func TestBuildCalendarCreatePlanRejectsConferenceConflict(t *testing.T) {
151171
_, err := buildCalendarCreatePlan(defaultConfigStoreForTest(t), calendarCreateInput{
152172
CalendarID: "primary",

internal/cmd/calendar_test.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,18 @@ func TestParseAttendee(t *testing.T) {
151151
input string
152152
email string
153153
optional bool
154+
resource bool
154155
comment string
155156
isNil bool
156157
}{
157-
{"alice@example.com", "alice@example.com", false, "", false},
158-
{"bob@example.com;optional", "bob@example.com", true, "", false},
159-
{"carol@example.com;comment=FYI only", "carol@example.com", false, "FYI only", false},
160-
{"dave@example.com;OPTIONAL;comment=Hi", "dave@example.com", true, "Hi", false},
161-
{";optional", "", false, "", true},
162-
{"", "", false, "", true},
158+
{"alice@example.com", "alice@example.com", false, false, "", false},
159+
{"bob@example.com;optional", "bob@example.com", true, false, "", false},
160+
{"carol@example.com;comment=FYI only", "carol@example.com", false, false, "FYI only", false},
161+
{"dave@example.com;OPTIONAL;comment=Hi", "dave@example.com", true, false, "Hi", false},
162+
{"room@example.com;RESOURCE", "room@example.com", false, true, "", false},
163+
{"room@example.com;resource;optional;comment=Project room", "room@example.com", true, true, "Project room", false},
164+
{";optional", "", false, false, "", true},
165+
{"", "", false, false, "", true},
163166
}
164167
for _, tt := range tests {
165168
t.Run(tt.input, func(t *testing.T) {
@@ -174,7 +177,7 @@ func TestParseAttendee(t *testing.T) {
174177
t.Fatalf("expected attendee, got nil")
175178
return
176179
}
177-
if got.Email != tt.email || got.Optional != tt.optional || got.Comment != tt.comment {
180+
if got.Email != tt.email || got.Optional != tt.optional || got.Resource != tt.resource || got.Comment != tt.comment {
178181
t.Fatalf("unexpected attendee: %#v", got)
179182
}
180183
})

0 commit comments

Comments
 (0)