Skip to content

Commit a44330b

Browse files
feat(calendar): add propose-time command with integration tests
Add propose-time subcommand that generates a browser URL for proposing new meeting times (workaround for Google Calendar API limitation). Includes integration tests for command output and decline functionality. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 24d2469 commit a44330b

2 files changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"runtime"
10+
"strings"
11+
12+
"github.com/steipete/gogcli/internal/outfmt"
13+
"github.com/steipete/gogcli/internal/ui"
14+
)
15+
16+
// CalendarProposeTimeCmd generates a browser URL for proposing a new meeting time.
17+
// This is a workaround for a Google Calendar API limitation (since 2018).
18+
type CalendarProposeTimeCmd struct {
19+
CalendarID string `arg:"" name:"calendarId" help:"Calendar ID"`
20+
EventID string `arg:"" name:"eventId" help:"Event ID"`
21+
Open bool `name:"open" help:"Open the URL in browser automatically"`
22+
Decline bool `name:"decline" help:"Also decline the event (notifies organizer)"`
23+
Comment string `name:"comment" help:"Comment to include with decline (implies --decline)"`
24+
}
25+
26+
func (c *CalendarProposeTimeCmd) Run(ctx context.Context, flags *RootFlags) error {
27+
u := ui.FromContext(ctx)
28+
account, err := requireAccount(flags)
29+
if err != nil {
30+
return err
31+
}
32+
33+
calendarID := strings.TrimSpace(c.CalendarID)
34+
eventID := strings.TrimSpace(c.EventID)
35+
if calendarID == "" {
36+
return usage("empty calendarId")
37+
}
38+
if eventID == "" {
39+
return usage("empty eventId")
40+
}
41+
42+
svc, err := newCalendarService(ctx, account)
43+
if err != nil {
44+
return err
45+
}
46+
47+
// Fetch event to display info and verify it exists
48+
event, err := svc.Events.Get(calendarID, eventID).Do()
49+
if err != nil {
50+
return fmt.Errorf("failed to get event: %w", err)
51+
}
52+
53+
// Generate the propose-time URL
54+
// Format: base64(eventId + " " + calendarId)
55+
payload := eventID + " " + calendarID
56+
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
57+
proposeURL := "https://calendar.google.com/calendar/u/0/r/proposetime/" + encoded
58+
59+
// Handle --comment implies --decline
60+
decline := c.Decline || strings.TrimSpace(c.Comment) != ""
61+
62+
// If declining, update the event response
63+
if decline {
64+
if len(event.Attendees) == 0 {
65+
return fmt.Errorf("event has no attendees, cannot decline")
66+
}
67+
68+
var selfIdx *int
69+
for i, a := range event.Attendees {
70+
if a.Self {
71+
selfIdx = &i
72+
break
73+
}
74+
}
75+
if selfIdx == nil {
76+
return fmt.Errorf("you are not an attendee of this event")
77+
}
78+
if event.Attendees[*selfIdx].Organizer {
79+
return fmt.Errorf("cannot decline your own event (you are the organizer)")
80+
}
81+
82+
event.Attendees[*selfIdx].ResponseStatus = "declined"
83+
if strings.TrimSpace(c.Comment) != "" {
84+
event.Attendees[*selfIdx].Comment = strings.TrimSpace(c.Comment)
85+
}
86+
87+
if _, err := svc.Events.Patch(calendarID, eventID, event).Do(); err != nil {
88+
return fmt.Errorf("failed to decline event: %w", err)
89+
}
90+
}
91+
92+
// JSON output
93+
if outfmt.IsJSON(ctx) {
94+
result := map[string]any{
95+
"event_id": eventID,
96+
"calendar_id": calendarID,
97+
"summary": event.Summary,
98+
"propose_url": proposeURL,
99+
"limitation": "Google Calendar API does not support proposing new times programmatically (since 2018)",
100+
}
101+
if event.Start != nil {
102+
if event.Start.DateTime != "" {
103+
result["current_start"] = event.Start.DateTime
104+
} else {
105+
result["current_start"] = event.Start.Date
106+
}
107+
}
108+
if event.End != nil {
109+
if event.End.DateTime != "" {
110+
result["current_end"] = event.End.DateTime
111+
} else {
112+
result["current_end"] = event.End.Date
113+
}
114+
}
115+
if decline {
116+
result["declined"] = true
117+
if strings.TrimSpace(c.Comment) != "" {
118+
result["comment"] = strings.TrimSpace(c.Comment)
119+
}
120+
}
121+
return outfmt.WriteJSON(os.Stdout, result)
122+
}
123+
124+
// Text output
125+
u.Out().Println("# Google Calendar API limitation (since 2018)")
126+
u.Out().Println("# \"Propose new time\" is only available via browser")
127+
u.Out().Println("")
128+
u.Out().Printf("event\t%s\n", orEmpty(event.Summary, "(no title)"))
129+
if event.Start != nil {
130+
start := event.Start.DateTime
131+
if start == "" {
132+
start = event.Start.Date
133+
}
134+
end := ""
135+
if event.End != nil {
136+
end = event.End.DateTime
137+
if end == "" {
138+
end = event.End.Date
139+
}
140+
}
141+
u.Out().Printf("current\t%s - %s\n", start, end)
142+
}
143+
u.Out().Printf("propose_url\t%s\n", proposeURL)
144+
145+
if decline {
146+
u.Out().Println("")
147+
u.Out().Println("declined\tyes")
148+
if strings.TrimSpace(c.Comment) != "" {
149+
u.Out().Printf("comment\t%s\n", strings.TrimSpace(c.Comment))
150+
}
151+
} else {
152+
u.Out().Println("")
153+
u.Out().Println("Tip: To notify the organizer, decline with a comment:")
154+
u.Out().Printf(" gog calendar propose-time %s %s --decline --comment \"Can we do 5pm instead?\"\n", calendarID, eventID)
155+
}
156+
157+
// Open browser if requested
158+
if c.Open {
159+
u.Out().Println("")
160+
u.Out().Println("Opening browser...")
161+
if err := openProposeTimeBrowser(proposeURL); err != nil {
162+
u.Err().Printf("Failed to open browser: %v\n", err)
163+
u.Err().Println("Please open the propose_url manually.")
164+
}
165+
}
166+
167+
return nil
168+
}
169+
170+
// openProposeTimeBrowser opens the URL in the default browser.
171+
var openProposeTimeBrowser = func(url string) error {
172+
var cmd *exec.Cmd
173+
switch runtime.GOOS {
174+
case "darwin":
175+
cmd = exec.Command("open", url)
176+
case "windows":
177+
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
178+
default:
179+
cmd = exec.Command("xdg-open", url)
180+
}
181+
return cmd.Start()
182+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"os"
11+
"strings"
12+
"testing"
13+
14+
"google.golang.org/api/calendar/v3"
15+
"google.golang.org/api/option"
16+
17+
"github.com/steipete/gogcli/internal/ui"
18+
)
19+
20+
func TestProposeTimeURLGeneration(t *testing.T) {
21+
tests := []struct {
22+
name string
23+
eventID string
24+
calendarID string
25+
wantURL string
26+
}{
27+
{
28+
name: "basic event",
29+
eventID: "rp2rg301pirvlufurh62sfkh74",
30+
calendarID: "vladimir.novosselov@gmail.com",
31+
wantURL: "https://calendar.google.com/calendar/u/0/r/proposetime/cnAycmczMDFwaXJ2bHVmdXJoNjJzZmtoNzQgdmxhZGltaXIubm92b3NzZWxvdkBnbWFpbC5jb20=",
32+
},
33+
{
34+
name: "simple ids",
35+
eventID: "evt123",
36+
calendarID: "test@example.com",
37+
wantURL: "https://calendar.google.com/calendar/u/0/r/proposetime/" + base64.StdEncoding.EncodeToString([]byte("evt123 test@example.com")),
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
payload := tt.eventID + " " + tt.calendarID
44+
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
45+
got := "https://calendar.google.com/calendar/u/0/r/proposetime/" + encoded
46+
47+
if got != tt.wantURL {
48+
t.Errorf("URL mismatch:\ngot: %s\nwant: %s", got, tt.wantURL)
49+
}
50+
})
51+
}
52+
}
53+
54+
func TestCalendarProposeTimeCmd_Text(t *testing.T) {
55+
origNew := newCalendarService
56+
origOpen := openProposeTimeBrowser
57+
t.Cleanup(func() {
58+
newCalendarService = origNew
59+
openProposeTimeBrowser = origOpen
60+
})
61+
62+
// Mock browser open to track if called
63+
var browserOpened string
64+
openProposeTimeBrowser = func(url string) error {
65+
browserOpened = url
66+
return nil
67+
}
68+
69+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70+
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
71+
if strings.Contains(path, "/calendars/cal1/events/evt1") && r.Method == http.MethodGet {
72+
w.Header().Set("Content-Type", "application/json")
73+
_ = json.NewEncoder(w).Encode(map[string]any{
74+
"id": "evt1",
75+
"summary": "Team Meeting",
76+
"start": map[string]string{"dateTime": "2026-01-16T19:30:00-08:00"},
77+
"end": map[string]string{"dateTime": "2026-01-16T20:30:00-08:00"},
78+
"attendees": []map[string]any{
79+
{"email": "a@b.com", "self": true},
80+
{"email": "organizer@b.com", "organizer": true},
81+
},
82+
})
83+
return
84+
}
85+
http.NotFound(w, r)
86+
}))
87+
defer srv.Close()
88+
89+
svc, err := calendar.NewService(context.Background(),
90+
option.WithoutAuthentication(),
91+
option.WithHTTPClient(srv.Client()),
92+
option.WithEndpoint(srv.URL+"/"),
93+
)
94+
if err != nil {
95+
t.Fatalf("NewService: %v", err)
96+
}
97+
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
98+
99+
flags := &RootFlags{Account: "a@b.com"}
100+
out := captureStdout(t, func() {
101+
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
102+
if uiErr != nil {
103+
t.Fatalf("ui.New: %v", uiErr)
104+
}
105+
ctx := ui.WithUI(context.Background(), u)
106+
107+
cmd := &CalendarProposeTimeCmd{}
108+
if err := runKong(t, cmd, []string{"cal1", "evt1", "--open"}, ctx, flags); err != nil {
109+
t.Fatalf("propose-time: %v", err)
110+
}
111+
})
112+
113+
// Verify output contains expected fields
114+
if !strings.Contains(out, "propose_url") {
115+
t.Errorf("output missing propose_url: %q", out)
116+
}
117+
if !strings.Contains(out, "Team Meeting") {
118+
t.Errorf("output missing event summary: %q", out)
119+
}
120+
if !strings.Contains(out, "proposetime/") {
121+
t.Errorf("output missing proposetime URL path: %q", out)
122+
}
123+
124+
// Verify browser was opened
125+
if browserOpened == "" {
126+
t.Error("browser was not opened despite --open flag")
127+
}
128+
if !strings.Contains(browserOpened, "proposetime/") {
129+
t.Errorf("browser URL incorrect: %q", browserOpened)
130+
}
131+
}
132+
133+
func TestCalendarProposeTimeCmd_WithDecline(t *testing.T) {
134+
origNew := newCalendarService
135+
origOpen := openProposeTimeBrowser
136+
t.Cleanup(func() {
137+
newCalendarService = origNew
138+
openProposeTimeBrowser = origOpen
139+
})
140+
openProposeTimeBrowser = func(url string) error { return nil }
141+
142+
var patchCalled bool
143+
var patchedComment string
144+
145+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146+
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
147+
switch {
148+
case strings.Contains(path, "/calendars/cal1/events/evt1") && r.Method == http.MethodGet:
149+
w.Header().Set("Content-Type", "application/json")
150+
_ = json.NewEncoder(w).Encode(map[string]any{
151+
"id": "evt1",
152+
"summary": "Team Meeting",
153+
"start": map[string]string{"dateTime": "2026-01-16T19:30:00-08:00"},
154+
"end": map[string]string{"dateTime": "2026-01-16T20:30:00-08:00"},
155+
"attendees": []map[string]any{
156+
{"email": "a@b.com", "self": true},
157+
{"email": "organizer@b.com", "organizer": true},
158+
},
159+
})
160+
case strings.Contains(path, "/calendars/cal1/events/evt1") && r.Method == http.MethodPatch:
161+
patchCalled = true
162+
var body map[string]any
163+
_ = json.NewDecoder(r.Body).Decode(&body)
164+
if attendees, ok := body["attendees"].([]any); ok && len(attendees) > 0 {
165+
if att, ok := attendees[0].(map[string]any); ok {
166+
if c, ok := att["comment"].(string); ok {
167+
patchedComment = c
168+
}
169+
}
170+
}
171+
w.Header().Set("Content-Type", "application/json")
172+
_ = json.NewEncoder(w).Encode(map[string]any{"id": "evt1", "summary": "Team Meeting"})
173+
default:
174+
http.NotFound(w, r)
175+
}
176+
}))
177+
defer srv.Close()
178+
179+
svc, err := calendar.NewService(context.Background(),
180+
option.WithoutAuthentication(),
181+
option.WithHTTPClient(srv.Client()),
182+
option.WithEndpoint(srv.URL+"/"),
183+
)
184+
if err != nil {
185+
t.Fatalf("NewService: %v", err)
186+
}
187+
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
188+
189+
flags := &RootFlags{Account: "a@b.com"}
190+
out := captureStdout(t, func() {
191+
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
192+
if uiErr != nil {
193+
t.Fatalf("ui.New: %v", uiErr)
194+
}
195+
ctx := ui.WithUI(context.Background(), u)
196+
197+
cmd := &CalendarProposeTimeCmd{}
198+
if err := runKong(t, cmd, []string{"cal1", "evt1", "--comment", "Can we do 5pm instead?"}, ctx, flags); err != nil {
199+
t.Fatalf("propose-time with decline: %v", err)
200+
}
201+
})
202+
203+
if !patchCalled {
204+
t.Error("PATCH was not called despite --comment flag")
205+
}
206+
if patchedComment != "Can we do 5pm instead?" {
207+
t.Errorf("comment not passed correctly, got: %q", patchedComment)
208+
}
209+
if !strings.Contains(out, "declined\tyes") {
210+
t.Errorf("output should show declined status: %q", out)
211+
}
212+
}

0 commit comments

Comments
 (0)