feat(calendar): add secondary calendar creation

Adds `gog calendar create-calendar` / `new-calendar` for secondary Google Calendar creation with summary, description, timezone, and location.

Landing polish:
- removed unrelated `.byline` cache files from the PR branch
- validates IANA timezone names locally
- dry-run no longer opens the Calendar service
- includes location in text output
- added request/body, text, dry-run, and validation tests
- README/spec/CHANGELOG entries

Local verification:
- go test ./internal/cmd -run TestCalendarCreateCalendarCmd
- make lint
- make test
- make build
- make ci
- live Calendar smoke with clawdbot@gmail.com: created secondary calendar, verified summary/timezone/location, deleted it via the same auth path

Thanks @alexknowshtml.

Co-authored-by: Alex Hillman <alex@indyhall.org>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Hillman 2026-04-20 12:14:11 -04:00 committed by GitHub
parent a1fd0a8333
commit db3a32432c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 235 additions and 0 deletions

View File

@ -6,6 +6,7 @@
- Auth: add `auth credentials remove` to delete stored OAuth client credentials and associated refresh tokens. (#473) — thanks @yamagucci.
- Drive: convert Markdown uploads to Google Docs and strip leading YAML frontmatter by default, with `--keep-frontmatter` to opt out. (#487) — thanks @johnbenjaminlewis.
- Slides: add `slides thumbnail` / `slides thumb` to fetch rendered slide thumbnail URLs or download PNG/JPEG images. (#498) — thanks @gianpaj.
- Calendar: add `calendar create-calendar` / `new-calendar` to create secondary calendars with description, timezone, and location. (#455) — thanks @alexknowshtml.
- Gmail: add `gmail autoreply` to reply once to matching messages, label the thread for dedupe, and optionally archive/mark read. Includes docs and regression coverage for skip/reply flows.
- Gmail: add `gmail messages search --full` to print complete message bodies instead of truncating text output. (#447) — thanks @GodsBoy.
- Drive: allow `drive share --role commenter` for comment-only sharing. (#443) — thanks @pavelzak.

View File

@ -745,6 +745,7 @@ Docs: `docs/email-tracking.md` (setup/deploy) + `docs/email-tracking-worker.md`
```bash
# Calendars
gog calendar calendars
gog calendar create-calendar "Team Calendar" --timezone Europe/London
gog calendar acl <calendarId> # List access control rules
gog calendar colors # List available event/calendar colors
gog calendar time --timezone America/New_York

View File

@ -201,6 +201,7 @@ Flag aliases:
- `gog drive drives [--max N] [--page TOKEN] [--query Q]`
- `gog slides thumbnail <presentationId> <slideId> [--size small|medium|large] [--format png|jpeg] [--out PATH]`
- `gog calendar calendars`
- `gog calendar create-calendar <summary> [--description D] [--timezone TZ] [--location L]`
- `gog calendar acl <calendarId>`
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
- `gog calendar event|get <calendarId> <eventId>`

View File

@ -3,6 +3,7 @@ package cmd
type CalendarCmd struct {
Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"`
Subscribe CalendarSubscribeCmd `cmd:"" name:"subscribe" aliases:"sub,add-calendar" help:"Add a calendar to your calendar list"`
CreateCalendar CalendarCreateCalendarCmd `cmd:"" name:"create-calendar" aliases:"new-calendar" help:"Create a new secondary calendar"`
ACL CalendarAclCmd `cmd:"" name:"acl" aliases:"permissions,perms" help:"List calendar ACL"`
Alias CalendarAliasCmd `cmd:"" name:"alias" help:"Manage calendar aliases"`
Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list,ls" help:"List events from a calendar or all calendars"`

View File

@ -0,0 +1,78 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"google.golang.org/api/calendar/v3"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type CalendarCreateCalendarCmd struct {
Summary string `arg:"" name:"summary" help:"Calendar display name"`
Description string `name:"description" help:"Calendar description"`
TimeZone string `name:"timezone" aliases:"tz" help:"IANA timezone (e.g., America/New_York)"`
Location string `name:"location" help:"Calendar location"`
}
func (c *CalendarCreateCalendarCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
summary := strings.TrimSpace(c.Summary)
if summary == "" {
return usage("required: calendar name (positional argument)")
}
account, err := requireAccount(flags)
if err != nil {
return err
}
cal := &calendar.Calendar{
Summary: summary,
Description: strings.TrimSpace(c.Description),
TimeZone: strings.TrimSpace(c.TimeZone),
Location: strings.TrimSpace(c.Location),
}
if cal.TimeZone != "" {
if _, tzErr := loadTimezoneLocation(cal.TimeZone); tzErr != nil {
return fmt.Errorf("invalid timezone %q: %w", cal.TimeZone, tzErr)
}
}
if dryRunErr := dryRunExit(ctx, flags, "calendar.create-calendar", map[string]any{
"calendar": cal,
}); dryRunErr != nil {
return dryRunErr
}
svc, err := newCalendarService(ctx, account)
if err != nil {
return err
}
created, err := svc.Calendars.Insert(cal).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create calendar: %w", err)
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"calendar": created})
}
u.Out().Printf("id\t%s", created.Id)
u.Out().Printf("summary\t%s", created.Summary)
if created.TimeZone != "" {
u.Out().Printf("timezone\t%s", created.TimeZone)
}
if created.Description != "" {
u.Out().Printf("description\t%s", created.Description)
}
if created.Location != "" {
u.Out().Printf("location\t%s", created.Location)
}
return nil
}

View File

@ -0,0 +1,153 @@
package cmd
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
)
func TestCalendarCreateCalendarCmd_RunJSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
var got calendar.Calendar
svc, closeSvc := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method != http.MethodPost || path != "/calendars" {
http.NotFound(w, r)
return
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "created@example.com",
"summary": got.Summary,
"description": got.Description,
"timeZone": got.TimeZone,
"location": got.Location,
})
}))
defer closeSvc()
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
cmd := &CalendarCreateCalendarCmd{
Summary: "Team Calendar",
Description: "Planning",
TimeZone: "Europe/London",
Location: "London",
}
out := captureStdout(t, func() {
if err := cmd.Run(newCalendarJSONContext(t), &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("Run: %v", err)
}
})
if got.Summary != "Team Calendar" || got.Description != "Planning" || got.TimeZone != "Europe/London" || got.Location != "London" {
t.Fatalf("unexpected request: %#v", got)
}
var payload struct {
Calendar calendar.Calendar `json:"calendar"`
}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("decode output: %v\nout=%q", err, out)
}
if payload.Calendar.Id != "created@example.com" || payload.Calendar.Summary != "Team Calendar" {
t.Fatalf("unexpected output: %#v", payload.Calendar)
}
}
func TestCalendarCreateCalendarCmd_RunTextIncludesLocation(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
svc, closeSvc := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method != http.MethodPost || path != "/calendars" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "created@example.com",
"summary": "Team Calendar",
"timeZone": "UTC",
"location": "Remote",
})
}))
defer closeSvc()
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
var outBuf bytes.Buffer
ctx := newCalendarOutputContext(t, &outBuf, io.Discard)
err := (&CalendarCreateCalendarCmd{Summary: "Team Calendar", TimeZone: "UTC"}).Run(ctx, &RootFlags{Account: "a@b.com"})
if err != nil {
t.Fatalf("Run: %v", err)
}
out := outBuf.String()
for _, want := range []string{"id\tcreated@example.com", "summary\tTeam Calendar", "timezone\tUTC", "location\tRemote"} {
if !strings.Contains(out, want) {
t.Fatalf("expected %q in output, got %q", want, out)
}
}
}
func TestCalendarCreateCalendarCmd_DryRunDoesNotOpenService(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
newCalendarService = func(context.Context, string) (*calendar.Service, error) {
t.Fatal("newCalendarService should not be called during dry-run")
return nil, errors.New("unexpected calendar service call")
}
out := captureStdout(t, func() {
err := (&CalendarCreateCalendarCmd{
Summary: "Dry Run",
TimeZone: "UTC",
}).Run(newCalendarJSONContext(t), &RootFlags{Account: "a@b.com", DryRun: true})
var exitErr *ExitError
if !errors.As(err, &exitErr) || exitErr.Code != 0 {
t.Fatalf("expected dry-run exit 0, got %v", err)
}
})
var payload struct {
DryRun bool `json:"dry_run"`
Op string `json:"op"`
Request struct {
Calendar calendar.Calendar `json:"calendar"`
} `json:"request"`
}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("decode dry-run: %v\nout=%q", err, out)
}
if !payload.DryRun || payload.Op != "calendar.create-calendar" || payload.Request.Calendar.Summary != "Dry Run" {
t.Fatalf("unexpected dry-run output: %#v", payload)
}
}
func TestCalendarCreateCalendarCmd_InvalidTimezone(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
newCalendarService = func(context.Context, string) (*calendar.Service, error) {
t.Fatal("newCalendarService should not be called for invalid timezone")
return nil, errors.New("unexpected calendar service call")
}
err := (&CalendarCreateCalendarCmd{
Summary: "Bad TZ",
TimeZone: "Nope/Zone",
}).Run(newCalendarJSONContext(t), &RootFlags{Account: "a@b.com"})
if err == nil || !strings.Contains(err.Error(), `invalid timezone "Nope/Zone"`) {
t.Fatalf("expected invalid timezone error, got %v", err)
}
}