Skip to content

Commit 836d8cd

Browse files
committed
fix(cli): harden dry-run and live smoke tests
1 parent 9ebdc01 commit 836d8cd

11 files changed

Lines changed: 419 additions & 96 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
### Fixed
1919

20+
- CLI: make mutating dry-runs for contacts, Docs, Drive, Meet, and Slides stop before auth/API calls while still validating local inputs; harden live smoke tests for self-sharing, disabled Meet, Gmail filter labels, and forced batch deletes.
2021
- Auth: keep fresh OAuth saves working even when old file-keyring token entries are unreadable, and clarify that `--services all` means all user OAuth services while Workspace-only services use service accounts.
2122
- Gmail: reject off-palette `gmail labels style` colors locally instead of forwarding an opaque Gmail API error.
2223
- Drive: make `drive share --dry-run` stop before permission creation for user and domain shares, including `--notify`.

internal/cmd/contacts_crud.go

Lines changed: 155 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -353,21 +353,38 @@ func anyFlagProvided(kctx *kong.Context, names ...string) bool {
353353
return false
354354
}
355355

356+
func flagValue[T any](set bool, value T) any {
357+
if !set {
358+
return nil
359+
}
360+
return value
361+
}
362+
363+
func (c *ContactsUpdateCmd) validateFlagUpdateInputs(wantBirthday, wantCustom, wantRelation bool) error {
364+
if wantCustom {
365+
if _, _, err := parseCustomUserDefined(c.Custom, true); err != nil {
366+
return usage(err.Error())
367+
}
368+
}
369+
if wantRelation {
370+
if _, _, err := parseRelations(c.Relation, true); err != nil {
371+
return usage(err.Error())
372+
}
373+
}
374+
if wantBirthday && strings.TrimSpace(c.Birthday) != "" {
375+
if _, err := parseYYYYMMDD(strings.TrimSpace(c.Birthday)); err != nil {
376+
return usage("invalid --birthday (expected YYYY-MM-DD)")
377+
}
378+
}
379+
return nil
380+
}
381+
356382
func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
357383
u := ui.FromContext(ctx)
358-
account, err := requireAccount(flags)
359-
if err != nil {
360-
return err
361-
}
362384
if strings.TrimSpace(c.Given) == "" {
363385
return usage("required: --given")
364386
}
365387

366-
svc, err := newPeopleContactsService(ctx, account)
367-
if err != nil {
368-
return err
369-
}
370-
371388
p := &people.Person{
372389
Names: []*people.Name{{
373390
GivenName: strings.TrimSpace(c.Given),
@@ -421,6 +438,19 @@ func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
421438
}
422439
}
423440

441+
if err := dryRunExit(ctx, flags, "contacts.create", map[string]any{"contact": p}); err != nil {
442+
return err
443+
}
444+
445+
account, err := requireAccount(flags)
446+
if err != nil {
447+
return err
448+
}
449+
svc, err := newPeopleContactsService(ctx, account)
450+
if err != nil {
451+
return err
452+
}
453+
424454
created, err := svc.People.CreateContact(p).Do()
425455
if err != nil {
426456
return err
@@ -454,22 +484,55 @@ type ContactsUpdateCmd struct {
454484
Notes string `name:"notes" help:"Notes (stored as People API biography; empty clears)"`
455485
}
456486

487+
type contactsUpdateFieldFlags struct {
488+
given bool
489+
family bool
490+
email bool
491+
phone bool
492+
org bool
493+
title bool
494+
url bool
495+
note bool
496+
address bool
497+
gender bool
498+
birthday bool
499+
notes bool
500+
custom bool
501+
relation bool
502+
}
503+
504+
func contactsUpdateFieldFlagsFromKong(kctx *kong.Context) contactsUpdateFieldFlags {
505+
return contactsUpdateFieldFlags{
506+
given: flagProvided(kctx, "given"),
507+
family: flagProvided(kctx, "family"),
508+
email: flagProvided(kctx, "email"),
509+
phone: flagProvided(kctx, "phone"),
510+
org: flagProvided(kctx, "org"),
511+
title: flagProvided(kctx, "title"),
512+
url: flagProvided(kctx, "url"),
513+
note: flagProvided(kctx, "note"),
514+
address: flagProvided(kctx, "address"),
515+
gender: flagProvided(kctx, "gender"),
516+
birthday: flagProvided(kctx, "birthday"),
517+
notes: flagProvided(kctx, "notes"),
518+
custom: flagProvided(kctx, "custom"),
519+
relation: flagProvided(kctx, "relation"),
520+
}
521+
}
522+
523+
func (w contactsUpdateFieldFlags) any() bool {
524+
return w.given || w.family || w.email || w.phone || w.org || w.title ||
525+
w.url || w.note || w.address || w.gender || w.birthday || w.notes ||
526+
w.custom || w.relation
527+
}
528+
457529
func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error {
458530
u := ui.FromContext(ctx)
459-
account, err := requireAccount(flags)
460-
if err != nil {
461-
return err
462-
}
463531
resourceName := strings.TrimSpace(c.ResourceName)
464532
if !strings.HasPrefix(resourceName, "people/") {
465533
return usage("resourceName must start with people/")
466534
}
467535

468-
svc, err := newPeopleContactsService(ctx, account)
469-
if err != nil {
470-
return err
471-
}
472-
473536
if strings.TrimSpace(c.FromFile) != "" {
474537
if anyFlagProvided(kctx,
475538
"given", "family", "email", "phone",
@@ -479,56 +542,101 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
479542
) {
480543
return usage("can't combine --from-file with other update flags")
481544
}
545+
if flags != nil && flags.DryRun {
546+
inputPerson, updateFields, err := c.readUpdateJSONInput(resourceName)
547+
if err != nil {
548+
return err
549+
}
550+
if err := dryRunExit(ctx, flags, "contacts.update", map[string]any{
551+
"resourceName": resourceName,
552+
"from_file": strings.TrimSpace(c.FromFile),
553+
"updateFields": updateFields,
554+
"contact": inputPerson,
555+
}); err != nil {
556+
return err
557+
}
558+
}
559+
account, err := requireAccount(flags)
560+
if err != nil {
561+
return err
562+
}
563+
svc, err := newPeopleContactsService(ctx, account)
564+
if err != nil {
565+
return err
566+
}
482567
return c.updateFromJSON(ctx, svc, resourceName, u)
483568
}
484569

570+
want := contactsUpdateFieldFlagsFromKong(kctx)
571+
if !want.any() {
572+
return usage("no updates provided")
573+
}
574+
if err := c.validateFlagUpdateInputs(want.birthday, want.custom, want.relation); err != nil {
575+
return err
576+
}
577+
578+
if err := dryRunExit(ctx, flags, "contacts.update", map[string]any{
579+
"resourceName": resourceName,
580+
"fields": map[string]any{
581+
"given": flagValue(want.given, c.Given),
582+
"family": flagValue(want.family, c.Family),
583+
"email": flagValue(want.email, c.Email),
584+
"phone": flagValue(want.phone, c.Phone),
585+
"organization": flagValue(want.org, c.Organization),
586+
"title": flagValue(want.title, c.Title),
587+
"url": flagValue(want.url, c.URL),
588+
"note": flagValue(want.note, c.Note),
589+
"address": flagValue(want.address, c.Address),
590+
"gender": flagValue(want.gender, c.Gender),
591+
"birthday": flagValue(want.birthday, c.Birthday),
592+
"notes": flagValue(want.notes, c.Notes),
593+
"custom": flagValue(want.custom, c.Custom),
594+
"relation": flagValue(want.relation, c.Relation),
595+
},
596+
}); err != nil {
597+
return err
598+
}
599+
600+
account, err := requireAccount(flags)
601+
if err != nil {
602+
return err
603+
}
604+
svc, err := newPeopleContactsService(ctx, account)
605+
if err != nil {
606+
return err
607+
}
485608
existing, err := svc.People.Get(resourceName).PersonFields(contactsUpdateReadMask).Do()
486609
if err != nil {
487610
return err
488611
}
489612

490613
updateFields := make([]string, 0, 8)
491614

492-
wantGiven := flagProvided(kctx, "given")
493-
wantFamily := flagProvided(kctx, "family")
494-
wantEmail := flagProvided(kctx, "email")
495-
wantPhone := flagProvided(kctx, "phone")
496-
wantOrg := flagProvided(kctx, "org")
497-
wantTitle := flagProvided(kctx, "title")
498-
wantURL := flagProvided(kctx, "url")
499-
wantNote := flagProvided(kctx, "note")
500-
wantAddress := flagProvided(kctx, "address")
501-
wantGender := flagProvided(kctx, "gender")
502-
wantBirthday := flagProvided(kctx, "birthday")
503-
wantNotes := flagProvided(kctx, "notes")
504-
wantCustom := flagProvided(kctx, "custom")
505-
wantRelation := flagProvided(kctx, "relation")
506-
507-
if wantGiven || wantFamily {
508-
contactsApplyPersonName(existing, wantGiven, c.Given, wantFamily, c.Family)
615+
if want.given || want.family {
616+
contactsApplyPersonName(existing, want.given, c.Given, want.family, c.Family)
509617
updateFields = append(updateFields, "names")
510618
}
511-
if wantEmail {
619+
if want.email {
512620
if strings.TrimSpace(c.Email) == "" {
513621
existing.EmailAddresses = nil // will be forced to [] for patch
514622
} else {
515623
existing.EmailAddresses = []*people.EmailAddress{{Value: strings.TrimSpace(c.Email)}}
516624
}
517625
updateFields = append(updateFields, "emailAddresses")
518626
}
519-
if wantPhone {
627+
if want.phone {
520628
if strings.TrimSpace(c.Phone) == "" {
521629
existing.PhoneNumbers = nil // will be forced to [] for patch
522630
} else {
523631
existing.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(c.Phone)}}
524632
}
525633
updateFields = append(updateFields, "phoneNumbers")
526634
}
527-
if wantOrg || wantTitle {
528-
contactsApplyPersonOrganization(existing, wantOrg, c.Organization, wantTitle, c.Title)
635+
if want.org || want.title {
636+
contactsApplyPersonOrganization(existing, want.org, c.Organization, want.title, c.Title)
529637
updateFields = append(updateFields, "organizations")
530638
}
531-
if wantURL {
639+
if want.url {
532640
urls := contactsURLs(c.URL)
533641
if len(urls) == 0 {
534642
existing.Urls = nil
@@ -537,15 +645,15 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
537645
}
538646
updateFields = append(updateFields, "urls")
539647
}
540-
if wantNote {
648+
if want.note {
541649
if strings.TrimSpace(c.Note) == "" {
542650
existing.Biographies = nil
543651
} else {
544652
existing.Biographies = []*people.Biography{{Value: strings.TrimSpace(c.Note)}}
545653
}
546654
updateFields = append(updateFields, "biographies")
547655
}
548-
if wantAddress {
656+
if want.address {
549657
addrs := contactsAddresses(c.Address)
550658
if len(addrs) == 0 {
551659
existing.Addresses = nil // will be forced to [] for patch
@@ -554,7 +662,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
554662
}
555663
updateFields = append(updateFields, "addresses")
556664
}
557-
if wantGender {
665+
if want.gender {
558666
genders := contactsGenders(c.Gender)
559667
if len(genders) == 0 {
560668
existing.Genders = nil // will be forced to [] for patch
@@ -563,7 +671,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
563671
}
564672
updateFields = append(updateFields, "genders")
565673
}
566-
if wantCustom {
674+
if want.custom {
567675
userDefined, clearAll, parseErr := parseCustomUserDefined(c.Custom, true)
568676
if parseErr != nil {
569677
return usage(parseErr.Error())
@@ -575,7 +683,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
575683
}
576684
updateFields = append(updateFields, "userDefined")
577685
}
578-
if wantRelation {
686+
if want.relation {
579687
relations, clearAll, parseErr := parseRelations(c.Relation, true)
580688
if parseErr != nil {
581689
return usage(parseErr.Error())
@@ -588,7 +696,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
588696
updateFields = append(updateFields, "relations")
589697
}
590698

591-
if wantBirthday {
699+
if want.birthday {
592700
if strings.TrimSpace(c.Birthday) == "" {
593701
existing.Birthdays = nil // will be forced to [] for patch
594702
} else {
@@ -604,7 +712,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
604712
updateFields = append(updateFields, "birthdays")
605713
}
606714

607-
if wantNotes {
715+
if want.notes {
608716
if strings.TrimSpace(c.Notes) == "" {
609717
existing.Biographies = nil // will be forced to [] for patch
610718
} else {

internal/cmd/contacts_update_json.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -339,30 +339,43 @@ func contactsUpdateMaskFromKeys(keys map[string]json.RawMessage) ([]string, erro
339339
return update, nil
340340
}
341341

342-
func (c *ContactsUpdateCmd) updateFromJSON(ctx context.Context, svc *people.Service, resourceName string, u *ui.UI) error {
342+
func (c *ContactsUpdateCmd) readUpdateJSONInput(resourceName string) (*people.Person, []string, error) {
343343
reader, closeFn, err := openFileOrStdin(strings.TrimSpace(c.FromFile))
344344
if err != nil {
345-
return err
345+
return nil, nil, err
346346
}
347347
if closeFn != nil {
348348
defer closeFn()
349349
}
350350
data, err := io.ReadAll(reader)
351351
if err != nil {
352-
return fmt.Errorf("read JSON: %w", err)
352+
return nil, nil, fmt.Errorf("read JSON: %w", err)
353353
}
354354

355355
inputPerson, presentKeys, err := parseContactsUpdateJSON(data)
356356
if err != nil {
357-
return err
357+
return nil, nil, err
358358
}
359359

360360
updateFields, err := contactsUpdateMaskFromKeys(presentKeys)
361361
if err != nil {
362-
return err
362+
return nil, nil, err
363363
}
364364
if len(updateFields) == 0 {
365-
return usage("no updatable fields found in JSON (needs one of updatePersonFields fields like urls, biographies, ...)")
365+
return nil, nil, usage("no updatable fields found in JSON (needs one of updatePersonFields fields like urls, biographies, ...)")
366+
}
367+
368+
if strings.TrimSpace(inputPerson.ResourceName) != "" && strings.TrimSpace(inputPerson.ResourceName) != resourceName {
369+
return nil, nil, usage("resourceName in JSON does not match CLI argument")
370+
}
371+
372+
return inputPerson, updateFields, nil
373+
}
374+
375+
func (c *ContactsUpdateCmd) updateFromJSON(ctx context.Context, svc *people.Service, resourceName string, u *ui.UI) error {
376+
inputPerson, updateFields, err := c.readUpdateJSONInput(resourceName)
377+
if err != nil {
378+
return err
366379
}
367380

368381
// Fetch current metadata/etag (required by updateContact).
@@ -378,10 +391,6 @@ func (c *ContactsUpdateCmd) updateFromJSON(ctx context.Context, svc *people.Serv
378391
return usage("etag mismatch (contact changed). Re-run `gog contacts get ... --json`, re-apply edits, retry (or pass --ignore-etag).")
379392
}
380393

381-
if strings.TrimSpace(inputPerson.ResourceName) != "" && strings.TrimSpace(inputPerson.ResourceName) != resourceName {
382-
return usage("resourceName in JSON does not match CLI argument")
383-
}
384-
385394
// Enforce resourceName and required metadata.
386395
inputPerson.ResourceName = resourceName
387396
inputPerson.Metadata = cur.Metadata

0 commit comments

Comments
 (0)