Skip to content

Commit 330dbd5

Browse files
committed
feat(contacts): scope dedupe to exact resources
1 parent 90aed08 commit 330dbd5

11 files changed

Lines changed: 353 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
### Added
66

7-
- Contacts: add guarded `contacts dedupe --apply` merging with exact dry-run plans, confirmation, full updatable-field preservation, etag checks before deletion, and refusal of ambiguous or unmergeable groups. (#815) — thanks @privatenumber.
7+
- Contacts: add guarded `contacts dedupe --apply` merging with exact dry-run plans, repeatable `--resource` scoping, confirmation, full updatable-field preservation, etag checks before deletion, and refusal of ambiguous or unmergeable groups. (#815) — thanks @privatenumber.
88

99
### Changed
1010

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ gog contacts dedupe --match email,phone,name
323323
# Inspect the mutation plan, then apply with confirmation.
324324
gog contacts dedupe --apply --dry-run --json
325325
gog contacts dedupe --apply
326+
327+
# Scope automation to exact reviewed contact resources.
328+
gog contacts dedupe --resource people/123 --resource people/456 --apply --force --json
326329
```
327330

328331
### Docs

docs/commands/gog-contacts-dedupe.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ gog contacts (contact) dedupe [flags]
3737
| `--max`<br>`--limit` | `int64` | 0 | Max contacts to scan (0 = all) |
3838
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3939
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
40+
| `--resource` | `[]string` | | Limit dedupe to exact contact resource names (people/...); repeatable |
4041
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
4142
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
4243
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |

docs/contacts-dedupe.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ Then inspect the exact mutation plan without changing contacts:
4545
gog contacts dedupe --apply --dry-run --json
4646
```
4747

48+
For automation, copy the contact resource names from the reviewed preview and
49+
scope both dry-run and apply to that exact set:
50+
51+
```bash
52+
gog contacts dedupe \
53+
--resource people/123 \
54+
--resource people/456 \
55+
--apply \
56+
--dry-run \
57+
--json
58+
```
59+
4860
Apply interactively:
4961

5062
```bash
@@ -54,7 +66,12 @@ gog contacts dedupe --apply
5466
Non-interactive automation must explicitly skip confirmation:
5567

5668
```bash
57-
gog contacts dedupe --apply --force --json
69+
gog contacts dedupe \
70+
--resource people/123 \
71+
--resource people/456 \
72+
--apply \
73+
--force \
74+
--json
5875
```
5976

6077
## Output
@@ -82,6 +99,8 @@ Applied output also includes:
8299

83100
- requires confirmation unless `--force` is present
84101
- honors `--dry-run`
102+
- supports repeatable `--resource` scoping so automation can apply an exact
103+
reviewed contact set
85104
- reads contact-source data only
86105
- refreshes each contact before planning
87106
- updates the selected primary before deleting anything

docs/live-testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ available services. Recent-feature coverage includes:
3434
- Drive shortcuts, revisions, and persisted changes polling.
3535
- Gmail thread-aware drafts, attachment metadata preservation/clearing, and
3636
explicit thread archive semantics.
37+
- Contacts duplicate merge dry-run, apply, merged-field readback, and cleanup.
3738
- CLI schema exit codes, Git-style help, output-mode precedence, and early
3839
validation errors.
3940

docs/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ after the bounded retry window, the command exits with retryable code `8`.
370370
- `gog classroom guardian-invitations get <studentId> <invitationId>`
371371
- `gog classroom guardian-invitations create <studentId> --email EMAIL`
372372
- `gog classroom profile [userId]`
373-
- `gog contacts dedupe [--match email,phone,name] [--max N] [--apply]`
373+
- `gog contacts dedupe [--match email,phone,name] [--max N] [--resource people/...] [--apply]`
374374
- `gog gmail search <query> [--max N] [--page TOKEN]`
375375
- `gog gmail messages search <query> [--max N] [--page TOKEN] [--include-body] [--body-format text|html] [--full]`
376376
- `gog gmail autoreply <query> [--max N] [--subject S] [--body B|--body-file PATH|--body-html HTML] [--from addr] [--reply-to addr] [--label L] [--archive] [--mark-read] [--skip-bulk] [--allow-self]`

internal/cmd/contacts_dedupe.go

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import (
1212
)
1313

1414
type ContactsDedupeCmd struct {
15-
Match string `name:"match" help:"Match fields: email,phone,name" default:"email,phone"`
16-
Max int64 `name:"max" aliases:"limit" help:"Max contacts to scan (0 = all)" default:"0"`
17-
Apply bool `name:"apply" aliases:"merge" help:"Merge duplicate groups and delete redundant contacts (requires confirmation)"`
18-
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no duplicates"`
15+
Match string `name:"match" help:"Match fields: email,phone,name" default:"email,phone"`
16+
Max int64 `name:"max" aliases:"limit" help:"Max contacts to scan (0 = all)" default:"0"`
17+
Resources []string `name:"resource" help:"Limit dedupe to exact contact resource names (people/...); repeatable"`
18+
Apply bool `name:"apply" aliases:"merge" help:"Merge duplicate groups and delete redundant contacts (requires confirmation)"`
19+
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no duplicates"`
1920
}
2021

2122
func (c *ContactsDedupeCmd) Run(ctx context.Context, flags *RootFlags) error {
@@ -32,12 +33,24 @@ func (c *ContactsDedupeCmd) Run(ctx context.Context, flags *RootFlags) error {
3233
if c.Max < 0 {
3334
return usage("--max must be >= 0")
3435
}
36+
resources, err := normalizeContactsDedupeResources(c.Resources)
37+
if err != nil {
38+
return err
39+
}
40+
if len(resources) > 0 && c.Max != 0 {
41+
return usage("--max cannot be combined with --resource")
42+
}
3543

3644
svc, err := peopleContactsService(ctx, account)
3745
if err != nil {
3846
return err
3947
}
40-
contacts, err := contactsDedupeList(ctx, svc, c.Max)
48+
var contacts []*people.Person
49+
if len(resources) > 0 {
50+
contacts, err = contactsDedupeGetResources(ctx, svc, resources)
51+
} else {
52+
contacts, err = contactsDedupeList(ctx, svc, c.Max)
53+
}
4154
if err != nil {
4255
return wrapPeopleAPIError(err)
4356
}
@@ -101,6 +114,39 @@ func parseContactsDedupeMatch(value string) (contactsDedupeMatch, error) {
101114
return out, nil
102115
}
103116

117+
func normalizeContactsDedupeResources(values []string) ([]string, error) {
118+
seen := map[string]bool{}
119+
out := make([]string, 0, len(values))
120+
for _, value := range values {
121+
resource := strings.TrimSpace(value)
122+
if !strings.HasPrefix(resource, "people/") || len(resource) == len("people/") {
123+
return nil, usagef("invalid --resource %q (expected people/...)", value)
124+
}
125+
if seen[resource] {
126+
continue
127+
}
128+
seen[resource] = true
129+
out = append(out, resource)
130+
}
131+
return out, nil
132+
}
133+
134+
func contactsDedupeGetResources(ctx context.Context, svc *people.Service, resources []string) ([]*people.Person, error) {
135+
contacts := make([]*people.Person, 0, len(resources))
136+
for _, resource := range resources {
137+
person, err := svc.People.Get(resource).
138+
PersonFields(contactsReadMask).
139+
Sources(contactsDedupeContactSource).
140+
Context(ctx).
141+
Do()
142+
if err != nil {
143+
return nil, err
144+
}
145+
contacts = append(contacts, person)
146+
}
147+
return contacts, nil
148+
}
149+
104150
func contactsDedupeList(ctx context.Context, svc *people.Service, maxResults int64) ([]*people.Person, error) {
105151
var out []*people.Person
106152
pageToken := ""

internal/cmd/contacts_dedupe_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ func TestParseContactsDedupeMatch(t *testing.T) {
2323
}
2424
}
2525

26+
func TestNormalizeContactsDedupeResources(t *testing.T) {
27+
got, err := normalizeContactsDedupeResources([]string{" people/1 ", "people/2", "people/1"})
28+
if err != nil {
29+
t.Fatalf("normalize: %v", err)
30+
}
31+
if !reflect.DeepEqual(got, []string{"people/1", "people/2"}) {
32+
t.Fatalf("resources = %#v", got)
33+
}
34+
if _, err := normalizeContactsDedupeResources([]string{"contacts/1"}); err == nil {
35+
t.Fatal("expected invalid resource error")
36+
}
37+
}
38+
2639
func TestBuildContactsDedupeGroupsTransitive(t *testing.T) {
2740
contacts := []*people.Person{
2841
testDedupePerson("people/1", "Ada One", []string{"[email protected]"}, nil),
@@ -58,6 +71,47 @@ func TestBuildContactsDedupeGroupsNameOptIn(t *testing.T) {
5871
}
5972
}
6073

74+
func TestContactsDedupeExecuteScopedResources(t *testing.T) {
75+
svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+
if r.Method != http.MethodGet || (r.URL.Path != "/v1/people/1" && r.URL.Path != "/v1/people/2") {
77+
http.NotFound(w, r)
78+
return
79+
}
80+
resource := strings.TrimPrefix(r.URL.Path, "/v1/")
81+
if got := r.URL.Query()["sources"]; !reflect.DeepEqual(got, []string{contactsDedupeContactSource}) {
82+
t.Fatalf("sources = %#v", got)
83+
}
84+
_ = json.NewEncoder(w).Encode(map[string]any{
85+
"resourceName": resource,
86+
"names": []map[string]any{{"displayName": "Ada"}},
87+
"emailAddresses": []map[string]any{{"value": "[email protected]"}},
88+
})
89+
}))
90+
defer closeSrv()
91+
92+
result := executeWithPeopleTestServices(
93+
t,
94+
[]string{
95+
"--json", "--account", "[email protected]", "contacts", "dedupe",
96+
"--resource", "people/1", "--resource", "people/2",
97+
},
98+
peopleTestServices{Contacts: fixedPeopleTestService(svc)},
99+
)
100+
if result.err != nil {
101+
t.Fatalf("Execute: %v\nstdout=%s\nstderr=%s", result.err, result.stdout, result.stderr)
102+
}
103+
var payload struct {
104+
Scanned int `json:"scanned"`
105+
Groups []any `json:"groups"`
106+
}
107+
if err := json.Unmarshal([]byte(result.stdout), &payload); err != nil {
108+
t.Fatalf("decode output: %v\n%s", err, result.stdout)
109+
}
110+
if payload.Scanned != 2 || len(payload.Groups) != 1 {
111+
t.Fatalf("output = %#v", payload)
112+
}
113+
}
114+
61115
func TestContactsDedupeExecuteJSON(t *testing.T) {
62116
svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63117
if r.Method != http.MethodGet || r.URL.Path != "/v1/people/me/connections" {

scripts/live-tests/common.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ LIVE_CALENDAR_CLEANUP_EVENTS=()
1313
LIVE_GMAIL_CLEANUP_DRAFTS=()
1414
LIVE_GMAIL_CLEANUP_THREADS=()
1515
LIVE_PHOTOS_PICKER_CLEANUP_IDS=()
16+
LIVE_CONTACT_CLEANUP_IDS=()
1617

1718
skip() {
1819
local key="$1"
@@ -256,6 +257,10 @@ register_photos_picker_cleanup() {
256257
[ -n "${1:-}" ] && LIVE_PHOTOS_PICKER_CLEANUP_IDS+=("$1")
257258
}
258259

260+
register_contact_cleanup() {
261+
[ -n "${1:-}" ] && LIVE_CONTACT_CLEANUP_IDS+=("$1")
262+
}
263+
259264
cleanup_live_resources() {
260265
local entry calendar_id event_id id
261266

@@ -275,6 +280,9 @@ cleanup_live_resources() {
275280
for id in "${LIVE_PHOTOS_PICKER_CLEANUP_IDS[@]}"; do
276281
gog photos picker delete "$id" --force --json >/dev/null 2>&1 || true
277282
done
283+
for id in "${LIVE_CONTACT_CLEANUP_IDS[@]}"; do
284+
gog contacts delete "$id" --force >/dev/null 2>&1 || true
285+
done
278286
for id in "${LIVE_DRIVE_CLEANUP_IDS[@]}"; do
279287
gog drive delete "$id" --force >/dev/null 2>&1 || true
280288
done

scripts/live-tests/contacts.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,85 @@ run_contacts_other_tests() {
1919
gog contacts other search "$other_query" --json --max 1 >/dev/null
2020
}
2121

22+
run_contacts_dedupe_apply_test() {
23+
local email first_json second_json first_id second_id dry_json dry_primary apply_json applied_primary merged_json
24+
25+
email="gogcli-dedupe-$TS@example.com"
26+
first_json=$(gog contacts create \
27+
--given "gogcli" \
28+
--family "dedupe-$TS" \
29+
--email "$email" \
30+
--phone "+15550000001" \
31+
--json)
32+
first_id=$(extract_field "$first_json" resourceName)
33+
[ -n "$first_id" ] || { echo "Failed to parse first dedupe contact resourceName" >&2; exit 1; }
34+
register_contact_cleanup "$first_id"
35+
36+
second_json=$(gog contacts create \
37+
--given "gogcli" \
38+
--family "dedupe-$TS" \
39+
--email "$email" \
40+
--phone "+15550000002" \
41+
--json)
42+
second_id=$(extract_field "$second_json" resourceName)
43+
[ -n "$second_id" ] || { echo "Failed to parse second dedupe contact resourceName" >&2; exit 1; }
44+
register_contact_cleanup "$second_id"
45+
46+
dry_json=$(gog contacts dedupe \
47+
--resource "$first_id" \
48+
--resource "$second_id" \
49+
--match email \
50+
--apply \
51+
--dry-run \
52+
--json)
53+
dry_primary=$($PY -c 'import json,sys
54+
obj=json.load(sys.stdin)
55+
first,second=sys.argv[1:3]
56+
request=obj.get("request") or {}
57+
groups=request.get("groups") or []
58+
if not obj.get("dry_run") or obj.get("op") != "contacts.dedupe.apply":
59+
raise SystemExit("unexpected dedupe dry-run envelope")
60+
if request.get("groups_merged") != 1 or request.get("contacts_deleted") != 1 or len(groups) != 1:
61+
raise SystemExit("dedupe dry-run found unrelated duplicate groups; refusing live apply")
62+
group=groups[0]
63+
primary=(group.get("primary") or {}).get("resource")
64+
deleted=[item.get("resource") for item in group.get("delete") or []]
65+
if primary not in (first,second) or deleted != [second if primary == first else first]:
66+
raise SystemExit("dedupe dry-run did not target only disposable contacts")
67+
print(primary)' "$first_id" "$second_id" <<<"$dry_json")
68+
69+
apply_json=$(gog contacts dedupe \
70+
--resource "$first_id" \
71+
--resource "$second_id" \
72+
--match email \
73+
--apply \
74+
--force \
75+
--json)
76+
applied_primary=$($PY -c 'import json,sys
77+
obj=json.load(sys.stdin)
78+
want=sys.argv[1]
79+
groups=obj.get("groups") or []
80+
if not obj.get("applied") or obj.get("groups_merged") != 1 or obj.get("contacts_deleted") != 1 or len(groups) != 1:
81+
raise SystemExit("unexpected dedupe apply result")
82+
primary=(groups[0].get("primary") or {}).get("resource")
83+
if primary != want:
84+
raise SystemExit("dedupe apply primary changed after dry-run")
85+
print(primary)' "$dry_primary" <<<"$apply_json")
86+
87+
merged_json=$(gog contacts get "$applied_primary" --json)
88+
$PY -c 'import json,re,sys
89+
obj=json.load(sys.stdin)
90+
contact=obj.get("contact") or {}
91+
emails={str(item.get("value","")).strip().lower() for item in contact.get("emailAddresses") or []}
92+
phones={"".join(re.findall(r"\d", str(item.get("value","")))) for item in contact.get("phoneNumbers") or []}
93+
if sys.argv[1].lower() not in emails:
94+
raise SystemExit("merged contact missing dedupe email")
95+
if not {"15550000001","15550000002"}.issubset(phones):
96+
raise SystemExit("merged contact missing dedupe phone values")' "$email" <<<"$merged_json"
97+
98+
run_required "contacts" "contacts dedupe cleanup" gog contacts delete "$applied_primary" --force >/dev/null
99+
}
100+
22101
run_contacts_tests() {
23102
if skip "contacts"; then
24103
echo "==> contacts (skipped)"
@@ -31,6 +110,7 @@ run_contacts_tests() {
31110
contact_json=$(gog contacts create --given "gogcli" --family "smoke-$TS" --email "gogcli-smoke-$TS@example.com" --phone "+1555555$TS" --json)
32111
contact_id=$(extract_field "$contact_json" resourceName)
33112
[ -n "$contact_id" ] || { echo "Failed to parse contact resourceName" >&2; exit 1; }
113+
register_contact_cleanup "$contact_id"
34114

35115
run_required "contacts" "contacts get" gog contacts get "$contact_id" --json >/dev/null
36116
run_required "contacts" "contacts update" gog contacts update "$contact_id" --given "gogcli" --family "smoke-updated-$TS" --email "gogcli-smoke-$TS@example.com" --birthday "1990-05-12" --notes "gogcli smoke $TS" --json >/dev/null
@@ -40,6 +120,7 @@ run_contacts_tests() {
40120
grep -q "EMAIL:gogcli-smoke-$TS@example.com" "$export_path" || { echo "contacts export missing email" >&2; exit 1; }
41121
grep -q "BDAY:19900512" "$export_path" || { echo "contacts export missing birthday" >&2; exit 1; }
42122
run_required "contacts" "contacts delete" gog contacts delete "$contact_id" --force >/dev/null
123+
run_required "contacts" "contacts dedupe apply" run_contacts_dedupe_apply_test
43124

44125
if is_consumer_account "$ACCOUNT"; then
45126
echo "==> contacts directory (skipped; Workspace only)"

0 commit comments

Comments
 (0)