Skip to content

Chore/merge upstream - #55

Closed
Wade-Randel wants to merge 50 commits into
kLOsk:mainfrom
alexrich700:chore/merge-upstream
Closed

Chore/merge upstream#55
Wade-Randel wants to merge 50 commits into
kLOsk:mainfrom
alexrich700:chore/merge-upstream

Conversation

@Wade-Randel

Copy link
Copy Markdown

Catches the fork up to upstream kLOsk/adloop (109 commits): Search Console,
GTM, Merchant Center, PageSpeed, toolset filtering, two-phase apply, and
currency generalization — combined with the fork's PMax read/write, labels,
insights, and campaign-builder tools (union of both toolsets).

Conflict resolution (8 files):

  • pyproject: google-ads>=31.1.0
  • read.py: upstream currency-parameterized _enrich_cost_fields + fork helpers
  • server.py / crossref.py: union; big tool-registration blocks reconstructed
    from clean branches
  • write.py: union + upstream two-phase-apply and _extract_resource_name
  • RSA pinning standardized on pinned_field (the real Google Ads API field)
  • rules docs unioned/deduped + regenerated via sync-rules.py

Apply layer reconciled to upstream's dry-run model (local-marker + two-phase);
fork tools tagged for toolset filtering; _config -> current_config();
final_url_suffix (auto-UTM) re-grafted onto upstream's apply bodies; a
cross-platform CLI JSON-escaping fix for Windows paths.

Full test suite green: 632 passed.

alexrich700 and others added 30 commits March 20, 2026 19:59
This script performs a series of pre-flight checks for the GAds-MCP setup, including OS detection, Homebrew installation, Python version check, Git installation, and network connectivity.
This script installs GAds-MCP (AdLoop) and connects it to Claude, checking prerequisites, installing dependencies, and configuring settings.
Adds the ability to create additional ad groups within existing campaigns,
closing the gap where only draft_campaign could create the initial ad group.
The new tool follows the same two-step draft/confirm pattern with BROAD match
safety checks, keyword validation, and atomic execution via GoogleAdsService.mutate().

https://claude.ai/code/session_01NNbaDSsxSFVeTEPgRWqVVM
…e, CPC bid

Three new safety checks before creating an ad group:
1. Reject non-SEARCH campaigns (DISPLAY/SHOPPING) since ad group type is SEARCH_STANDARD
2. Warn if an ad group with the same name already exists (duplicate name confusion)
3. Warn if cpc_bid_micros is set on a Smart Bidding campaign (bid will be ignored)

Also consolidates the broad match safety check into the same preflight function,
reducing API calls by combining campaign info into a single GAQL query.

https://claude.ai/code/session_01NNbaDSsxSFVeTEPgRWqVVM
- Renumber check comments sequentially (1, 2, 3, 4) and align docstring
- Fix CLAUDE.md tool count: actual @mcp.tool registrations is 29, not 27

https://claude.ai/code/session_01NNbaDSsxSFVeTEPgRWqVVM
- Use `(kw.get("match_type") or "").upper()` to handle null/None match_type
  from MCP/JSON callers instead of raising AttributeError
- Replace blanket `except Exception: pass` in preflight checks with a
  warning so users know validations were skipped
- Add tests for null and missing match_type keys

https://claude.ai/code/session_01NNbaDSsxSFVeTEPgRWqVVM
…ufYuh

Add draft_ad_group tool for creating ad groups in existing campaigns
…tion

SEARCH campaigns created via draft_campaign now auto-set the Final URL
Suffix with standard UTM parameters using Google Ads ValueTrack macros:
utm_source=google&utm_medium=cpc&utm_campaign={campaignid}&utm_content={adgroupid}&utm_term={keyword}

- Add _DEFAULT_FINAL_URL_SUFFIX constant in write.py
- Add final_url_suffix param to draft_campaign (auto-applied for SEARCH, pass "" to disable)
- Set campaign.final_url_suffix on the proto in _apply_create_campaign
- Add final_url_suffix param to update_campaign for changing/clearing it
- Apply final_url_suffix via field mask in _apply_update_campaign
- Expose final_url_suffix in both MCP tool registrations in server.py
- Update orchestration docs in .claude/rules and .cursor/rules

https://claude.ai/code/session_01SE2vhVvPdaWTGvm2T1mpoF
…x-LO09F

Add automatic Final URL Suffix (UTM tracking) to search campaign creation
The Google Ads API does not reliably support in-place updates to RSA
headlines/descriptions (IMMUTABLE_FIELD errors are common). This adds a
composite tool that automates the industry-standard workaround: create a
new RSA with updated copy and pause (or optionally remove) the old one.

- _fetch_existing_rsa: GAQL helper to retrieve current ad details
- draft_rsa_replacement: validates new copy, shows old-vs-new diff preview
- _apply_replace_rsa: execution handler (create new + pause/remove old)
- Inherits ad_group_id and final_url from the old ad automatically
- remove_old=true triggers double confirmation (default: pause)
- 12 unit tests covering validation, inheritance, and safety checks
- Orchestration rules added for both .claude/ and .cursor/ rule files

https://claude.ai/code/session_01DXyhvDjrZE96YUEUA9ia5c
This tool is for fixing broken ads, not A/B testing. Defaulting to
remove_old=True ensures the broken ad cannot be accidentally re-enabled.
Docstrings and orchestration rules now clearly distinguish:
- draft_rsa_replacement: fix issues with existing ads (removes old)
- draft_responsive_search_ad: create new ad variants for testing

https://claude.ai/code/session_01DXyhvDjrZE96YUEUA9ia5c
New read-only MCP tools: get_impression_share, get_change_history,
get_device_performance, get_location_performance, get_quality_score_details,
get_bid_strategy_status, get_budget_pacing, get_ad_schedule_performance,
get_auction_insights. Includes 32 tests, orchestration rules, and updated
analyze-performance command.

https://claude.ai/code/session_01EtMthH7NLzMFcy6hsDUfQt
… command

Simplify get_change_history to use a single code path instead of
duplicate query construction with redundant date clauses. Remove
the unused _change_event_date_clause helper. Add missing conditional
tool references (change_history, location, schedule, auction) to the
analyze-performance slash command.

https://claude.ai/code/session_01EtMthH7NLzMFcy6hsDUfQt
Codex review feedback:
- P1: change_date_time is timestamp-based, so BETWEEN with date-only
  end bound truncates at midnight. Now appends 23:59:59 to bare dates.
- P2: change_event has a hard 10,000 row API limit. Now clamps caller-
  provided limit to 1..10000 before interpolating into the query.

https://claude.ai/code/session_01EtMthH7NLzMFcy6hsDUfQt
…-2apH1

Add 9 Google Ads insights tools for performance analysis
- run_ga4_report: add dimension_filter parameter for server-side filtering
  (e.g. sessionSource=google + sessionMedium=cpc for paid traffic isolation)
- get_keyword_performance: add ad_group.id and criterion_id to GAQL SELECT
  so callers can construct entity_id strings for pause_entity
- get_search_terms: add optional campaign_id filter parameter
- get_ad_schedule_performance: add conversion_rate enrichment via
  existing _enrich_conversion_rate function
- validate_tracking: add optional customer_id to cross-reference GA4 events
  with Google Ads conversion actions
- Update server.py wrappers, docstrings, and orchestration rules
- Add tests for all new functionality (101 tests passing)

https://claude.ai/code/session_01Hbuiiebx4iQBDnnUtcknFP
- get_search_terms: add campaign.id to GAQL SELECT so callers can pass
  it directly to add_negative_keywords without a separate lookup
- analyze_campaign_conversions: add campaign_id and
  conversion_discrepancy_pct fields to each per-campaign row
- Update server.py docstrings and orchestration rules
- Add tests for crossref tool and search terms campaign.id field

Items already done from first round (no changes needed):
- get_ad_schedule_performance conversion_rate + CPA (already enriched)
- get_keyword_performance ad_group.name (already in GAQL SELECT)

https://claude.ai/code/session_01Hbuiiebx4iQBDnnUtcknFP
… docstring

- Add _campaign_filter_clause helper that validates campaign_id is numeric
  before interpolating into GAQL queries (prevents injection)
- Fix _enrich_conversion_rate to always set metrics.conversion_rate (0.0
  when clicks=0 instead of omitting the field)
- Fix server.py docstring: ad_group_id -> ad_group.id to match actual
  GAQL field names returned
- Add tests for invalid campaign_id and zero-click conversion_rate

https://claude.ai/code/session_01Hbuiiebx4iQBDnnUtcknFP
…-EclYk

Claude/research google ads api ecl yk
…onfusion

The phrase "local service businesses" in device and hourly performance
tool descriptions was causing Claude to incorrectly associate the MCP
with Local Service Ads (LSA). Simplified to "businesses" / "service
businesses" to eliminate the ambiguity.

https://claude.ai/code/session_01SMZJ8aG5M8u2EX2RJods52
…07YV

Remove "local service" phrasing from tool docstrings to prevent LSA c…
Ad URLs were normalized (trailing slash stripped) but GA4 pagePath values
were not, causing false "orphaned page" warnings when both had trailing
slashes (e.g. /locations/plano-tx/ in ads vs GA4).

https://claude.ai/code/session_01B7ykudEJX1LWdMA4ZkbTGY
…ash-dGZCz

Fix trailing slash mismatch in landing_page_analysis URL matching
MCP clients (like Claude) don't handle Pydantic's anyOf JSON schemas from
`list[str] | None = None` unions — they serialize arrays/dicts as strings
instead of native types. Changed 10 optional list/dict parameters across 6
tools from `X | None = None` to `X = []` (or `X = {}`) so Pydantic generates
schemas with a direct "type": "array" / "type": "object" field.

For update_campaign's sentinel parameters (geo_target_ids, language_ids),
empty list is converted back to None at the server.py boundary to preserve
the "don't change" semantics in the implementation layer.

https://claude.ai/code/session_01SDbWis2165nVMS1nszVFjm
Fix GA4 tool schema types: replace anyOf unions with direct type fields
Support optional pinning of RSA headlines and descriptions to specific
positions (HEADLINE_1/2/3, DESCRIPTION_1/2) via the Google Ads API
AdTextAsset.pinned_field. Headlines/descriptions now accept either plain
strings (unpinned, backward compatible) or dicts with {"text": "...",
"pinned_to": "HEADLINE_1"}.

Changes:
- gaql.py: _to_python preserves pinned_field on AdTextAsset reads
- write.py: Add _normalize_assets helper, update _validate_rsa for
  pinning validation, update draft/apply functions to handle dicts
- server.py: Update tool signatures and docstrings for pinning format
- Rules: Document pinning in orchestration rules (.claude + .cursor)
- Tests: Update RSA replacement tests for normalized dict format

https://claude.ai/code/session_01QX9Uk947P69wPMfeFufQF7
- Fix _apply_create_rsa: use getattr() instead of bracket notation for
  ServedAssetFieldTypeEnum, matching the pattern used everywhere else in
  the module. Bracket notation fails on proto-plus enums at runtime.
- Fix _to_python: use explicit `is not None` check instead of truthiness
  for pin_name to avoid silently skipping edge cases.
- Improve _format_table/_format_csv: render pinned assets as
  "Text [HEADLINE_1]" instead of raw dict repr.
- Add 15 new tests covering: _normalize_assets, pinning validation
  (valid/invalid pins, cross-type rejection, missing text), pinned
  headlines/descriptions in draft plans and diffs.

https://claude.ai/code/session_01QX9Uk947P69wPMfeFufQF7
claude and others added 20 commits March 27, 2026 23:27
When a dict asset has a non-string text value (e.g. {"text": 123}),
_normalize_assets now coerces it to str instead of passing it through
to _validate_rsa where len() would raise TypeError.

https://claude.ai/code/session_01QX9Uk947P69wPMfeFufQF7
…g-9rHil

Claude/research ads api pinning 9r hil
Phase 1 of PMax support — diagnostics only, no write paths yet.

- 7 PMax read tools in src/adloop/ads/pmax_read.py: get_pmax_campaigns,
  get_pmax_channel_breakdown, get_asset_groups, get_asset_group_assets,
  get_asset_group_signals, get_asset_group_top_combinations,
  get_pmax_search_terms (v23.2+ campaign_search_term_insight with fallback).
- analyze_pmax_performance in crossref.py — single call returns campaign
  metrics + asset groups + asset performance labels + channel breakdown +
  GA4 paid sessions, with auto-generated insights[] for POOR/AVERAGE ad
  strength, LOW assets, channel skew, zero-conversion campaigns, and GDPR
  consent gaps.
- All 8 tools registered in server.py.
- Pinned Google Ads API to v24, bumped google-ads dep to >=30.0.0.
- Rules file (.cursor/rules/adloop.mdc) extended with Performance Max
  read-tool inventory, PMax-specific orchestration pattern, GAQL resource
  reference for asset_group/asset_group_asset/asset_group_signal, example
  PMax queries, and a marketing best-practices note.
- 30 new tests covering each tool's filters, enrichment, error paths, and
  the cross-ref aggregation. Full suite: 154 passed.

https://claude.ai/code/session_019r7TECd9gTVkcUZqmQivwz
- get_pmax_channel_breakdown: docstring claimed it filters with
  segments.asset_interaction_target.interaction_on_this_asset = false,
  but the query just uses segments.ad_network_type. Corrected to match
  actual behavior.
- get_pmax_search_terms: docstring claimed a fallback path that doesn't
  exist in the code (we just return a structured error). Corrected.
- analyze_pmax_performance: LOW-asset insight referenced draft_pmax_assets
  and replace_pmax_asset which don't exist yet (Phase 2/3). Made the
  message agnostic so users can act on it today.
- analyze_pmax_performance: GA4 failures were silently swallowed. Now
  surfaced as a single warning in insights[] so the user knows why
  click-to-session and conversion comparisons are missing, while PMax
  metrics still render.
- CLAUDE.md tool count: claimed 37 (was 29 before, also wrong); actual
  count is 47 @mcp.tool registrations. Pattern count corrected to 16.

Tests: 154 passed.

https://claude.ai/code/session_019r7TECd9gTVkcUZqmQivwz
When run_ga4_report fails, the per-campaign loop was still falling back to
{"sessions": 0, "conversions": 0} and emitting that as ga4_paid — making
"GA4 unavailable" indistinguishable from "campaign has zero paid sessions"
for any consumer reading the structured fields.

Now ga4_paid is None whenever the GA4 fetch failed (error dict or
exception), and the warning continues to surface in insights[]. Two
regression tests cover both the error-dict and exception paths.

https://claude.ai/code/session_019r7TECd9gTVkcUZqmQivwz
…i-ittO6

Add Performance Max read tools and cross-ref diagnostic
A live MCP test exposed broken queries and a malformed PMax create flow
after the v24 pin. Five of eight PMax read tools failed, draft_campaign's
PMax path was structurally wrong, and confirm_and_apply(dry_run=true)
returned DRY_RUN_SUCCESS for plans the API would reject. This commit
fixes those issues, adds the missing PMax write surface, and adds label
tools that were previously absent.

API v24 read-tool fixes:
- get_pmax_campaigns: drop campaign.url_expansion_opt_out (removed in v24)
- get_asset_group_assets: drop asset_group_asset.performance_label (removed
  in v24); return policy_summary.review_status instead. Update docstring
  to point users to asset_group_top_combination_view / asset_field_type_view
  for per-asset performance.
- get_asset_group_top_combinations: drop metrics.* fields and ORDER BY
  metrics.impressions; the resource exposes no metrics in v24.
- get_pmax_search_terms: drop metrics.cost_micros / conversions /
  conversions_value; campaign_search_term_insight rejects them with
  PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Surface a `note` so callers
  know cost is not available.
- analyze_pmax_performance: replace the LOW-asset insight with a
  missing-asset-minimums insight that flags asset groups below Google's
  documented PMax minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION,
  1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO).

confirm_and_apply dry_run safety:
- dry_run=true now plumbs validate_only=True through each _apply_*
  helper to the Google Ads API. The API runs full server-side validation
  (PMax network_settings rules, enum values, references, etc.) and
  commits nothing. A failing validation now returns
  DRY_RUN_VALIDATION_FAILED with the actual API error rather than a
  false DRY_RUN_SUCCESS.

PMax write tools (new ads/pmax_write.py):
- draft_pmax_campaign: creates a CampaignBudget + Campaign (no
  network_settings, no advertising_channel_sub_type) + geo/language
  CampaignCriteria + AssetGroup + every Asset + every AssetGroupAsset
  link + every AssetGroupSignal in one bulk mutate. PMax requires this
  all-in-one shape per Google's documentation.
- draft_asset_group: adds an asset group + assets + signals to an
  existing PMax campaign.
- draft_asset_group_assets: extends an asset group with more headlines /
  descriptions / images / videos.
- draft_asset_group_signal: adds one search theme or audience signal.
- Smart-Bidding-only enforcement (rejects MANUAL_CPC and TARGET_SPEND).
- Per-field-type asset minimums and char limits validated before
  drafting; image/logo assets must be pre-uploaded resource_names
  (binary upload is out of scope for this MCP).

draft_campaign now rejects channel_type=PERFORMANCE_MAX with a clear
error pointing to draft_pmax_campaign — the previous behavior produced
a malformed mutate that the API rejected with four cascading errors.

Asset group support in pause/enable/remove. AssetGroupService is now
wired into the existing _apply_status_change and _apply_remove helpers;
entity_type='asset_group' works for all three.

Label tools (new ads/labels.py):
- list_labels (read), draft_label, apply_label, unapply_label.
- remove_entity entity_type='label' deletes the Label resource itself
  (cascades to all assignments).

Rules and docs:
- .cursor/rules/adloop.mdc rewritten for v24 reality:
  * removed claims about url_expansion_opt_out and performance_label
  * documented the prohibited metrics on campaign_search_term_insight
    and asset_group_top_combination_view
  * added "Removed in API v24" reference section
  * added the new write tools to the inventory table
  * added orchestration patterns for "create new PMax campaign", "add
    asset group / assets / signals to existing PMax campaign", and
    "add labels"
  * documented the new validate_only-backed dry_run behavior
- scripts/sync-rules.py run to sync .claude/rules/adloop.md.
- CLAUDE.md tool counts updated (47 -> 55).

Tests: 192 passing (was 159). New test_pmax_write.py covers the four
new draft tools' validation paths and the draft_campaign-rejects-PMax
behavior. New test_labels.py covers list/draft/apply/unapply.
test_pmax_read.py updated for the dropped fields and the new
missing_asset_minimums insight.

https://claude.ai/code/session_018uRgkMdVJKZcfCV6NE2MSP
Findings from a self-review and an independent reviewer pass over the
PMax + label changes from 2779ede.

Code:
- pmax_write.py: drop the `_LIMITS["CALL_TO_ACTION_SELECTION"]` entry
  (never read) and the unreachable `ASSET_MAXIMUMS` keys for
  LANDSCAPE_LOGO / PORTRAIT_MARKETING_IMAGE / YOUTUBE_VIDEO (the
  validator never iterates over them).
- pmax_write.py: drop unused `asset_service` and `asset_group_service`
  locals from `_apply_create_pmax_campaign` — `_build_asset_group_operations`
  fetches its own.
- pmax_write.py: rename `text_field_type` to `field_type_enum` in
  `_apply_create_asset_group_assets` — it's the same `AssetFieldTypeEnum`
  used for text, video, image, and logo field types.
- pmax_write.py: restructure `_apply_create_asset_group_assets` to emit
  all `Asset.create` operations first and all `AssetGroupAsset.create`
  links after, matching `_build_asset_group_operations` and Google's
  documented "AssetOperations consecutive, before AssetGroupAssets" rule.
  Image/logo links are now built alongside text/video links instead of
  in a separate trailing loop.
- crossref.py: replace the in-loop `_ASSET_MINIMUMS` literal in
  `analyze_pmax_performance` with an import from `pmax_write.ASSET_MINIMUMS`
  — single source of truth, no per-iteration redeclaration.
- crossref.py: when `campaign.brand_guidelines_enabled` is true, skip
  the BUSINESS_NAME and LOGO minimum checks at the asset-group level
  (those assets live on the campaign in that mode, so flagging them as
  "missing" is a false positive).
- server.py: move the four label tools out from between
  `draft_asset_group_assets` and `draft_asset_group_signal`. PMax write
  block now stays contiguous; labels live in their own section header.

Tests:
- test_pmax_write.py: replace the convoluted
  `"MANUAL_CPC" not in details or "PMax" in details` assertion with two
  direct checks on the rejection message.

Rules:
- .cursor/rules/adloop.mdc (synced to .claude/rules/adloop.md):
  drop the stale "drill into LOW-labeled assets" instruction (those
  arrays no longer exist after `performance_label` was removed in v24)
  and replace with guidance pointing at `asset_counts_by_type`,
  `missing_asset_minimums`, and `get_asset_group_top_combinations`.
- Same file: revise the Performance Max marketing best-practice bullet
  to drop the stale LOW-asset language, mention the asset-type minimums
  flagged by `analyze_pmax_performance`, and document explicitly that
  image/logo upload happens in the Google Ads UI (the MCP only accepts
  pre-uploaded resource_names).

Tests still 192 passing.

https://claude.ai/code/session_018uRgkMdVJKZcfCV6NE2MSP
Two validation gaps surfaced by the PR review.

P1: BUSINESS_NAME was added to text_groups only when business_name was
non-empty, so an omitted business_name silently skipped its minimum
check (declared as 1 in ASSET_MINIMUMS). The asset group passed draft
validation and the failure was deferred to apply-time API validation.
Fix: always include BUSINESS_NAME in text_groups with an empty list
when business_name is empty so the minimum check fires.

P2: Image/logo lists (marketing_image_assets, square_marketing_image_assets,
logo_assets) were validated against ASSET_MINIMUMS but never against
ASSET_MAXIMUMS. Drafts could accept oversized lists that the API would
later reject. Fix: mirror the text-group pattern — also enforce the
per-field-type maximum and emit a clear error when exceeded.

Tests: two new regression tests cover both paths
(test_requires_business_name_when_omitted, test_rejects_too_many_marketing_images).
Full suite 194 passing.

https://claude.ai/code/session_018uRgkMdVJKZcfCV6NE2MSP
Add Performance Max write tools and label management
Every Google Ads service-client mutate method was being called with
validate_only as a Python kwarg (e.g. service.mutate_labels(customer_id=cid,
operations=[...], validate_only=True)). The gapic-generated methods do not
accept validate_only as a kwarg — it is a field on the request proto. This
raised a TypeError before any network round-trip, blocking the entire write
surface (draft_pmax_campaign, draft_asset_group, draft_asset_group_assets,
draft_asset_group_signal, draft_label, apply_label, unapply_label, plus the
older Search write paths and remove/status-change calls).

Fix: rewrite all 29 mutate call sites across write.py, pmax_write.py, and
labels.py to use the request={...} dict form, which the gapic client coerces
into the appropriate request proto with validate_only set as a field.

Also tighten the DRY_RUN_VALIDATION_FAILED message in confirm_and_apply to
distinguish a real Google Ads API rejection from an internal Python error
that never reached the API — the previous message ("Google Ads rejected the
plan") was misleading when the failure was a server-side bug.

https://claude.ai/code/session_013NS9Ru7b2RSFVpA8vKxhed
…BqQux

Refactor Google Ads API calls to use request dict parameter
The Google Ads API rejects asset_group creates that are missing any of
MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO with
ASSET_GROUP_NOT_ENOUGH_*_ASSET, so PMax campaigns cannot be drafted
text-only and patched later. draft_image_asset closes the gap by letting
users upload local JPG/PNG/GIF files (≤5 MB, magic-byte checked, batch
upload supported) through the same draft → confirm_and_apply flow as
every other write tool. The apply helper reads bytes fresh at apply time
and feeds AssetService.MutateAssets, returning resource_names ready for
draft_pmax_campaign / draft_asset_group / draft_asset_group_assets.

Also tightens the missing-asset error messages in the existing PMax
draft tools to name draft_image_asset as the upload path, and updates
the orchestration rules with a dedicated upload pattern.

https://claude.ai/code/session_01WjDCCdg7BRfa6HZR4uUi2X
…l removal

Three production bugs surfaced by Cowork's end-to-end PMax test in
Round 3 are now closed on the same branch as draft_image_asset.

1. WhichOneof proto-plus method-vs-field bug. proto-plus's wrapper of
   MutateOperationResponse does not always expose WhichOneof as a
   callable method — calling it raised "Unknown field for
   MutateOperationResponse: WhichOneof" *after* the mutate had
   succeeded. The asset group existed on Google's side but the apply
   pipeline reported a failure, so a retry would create a silent
   duplicate. Fixed by dropping to the underlying protobuf via
   type(resp).pb(resp).WhichOneof("response") in all five apply
   helpers (search create_campaign, search create_ad_group, PMax
   create_pmax_campaign, create_asset_group, create_asset_group_assets).

2. Brand Guidelines required CampaignAsset links. New PMax campaigns
   default to brand_guidelines_enabled=True on Google's side and the
   API rejects the create mutate with
   REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED /
   REQUIRED_LOGO_ASSET_NOT_LINKED unless the business name and a logo
   are linked at the *campaign* level, not just on the asset group.
   draft_pmax_campaign now defaults brand_guidelines_enabled=True to
   match Google's default and auto-generates the two CampaignAsset
   create operations (BUSINESS_NAME from the inline text asset, LOGO
   from the first pre-uploaded logo) in the same atomic mutate. Pass
   brand_guidelines_enabled=False to opt out — the tool then sets the
   campaign field to False and skips the CampaignAsset wiring.

3. remove_entity didn't accept asset_group_signal. draft_asset_group_signal
   creates signals; remove_entity couldn't unmake them, leaving an
   asymmetric API and orphaned test signals in user accounts. Added
   "asset_group_signal" to _REMOVABLE_ENTITY_TYPES with the composite
   {assetGroupId~criterionId} id format (matches what
   get_asset_group_signals returns) and an AssetGroupSignalService
   remove branch in _apply_remove.

Tests: 209 passing (4 new — brand_guidelines default + opt-out,
asset_group_signal removal allowlist).

https://claude.ai/code/session_01WjDCCdg7BRfa6HZR4uUi2X
Codex review on PR #13 flagged a content-swap hole in
_apply_upload_image_asset: the apply step only checked
os.path.getsize() against the draft-time size, so a file replaced with
different bytes of the same length between draft and confirm_and_apply
would silently upload the wrong asset.

draft_image_asset now hashes the file at draft time and the apply step
re-hashes before sending to AssetService.MutateAssets. A mismatch raises
ValueError ("sha256 mismatch — same byte count, different bytes") with
guidance to re-draft. The pre-mutate verification was also lifted out
of the apply loop into _read_image_unchanged so the whole batch is
verified before any image reaches the API — partial batch uploads on a
swap are no longer possible.

https://claude.ai/code/session_01WjDCCdg7BRfa6HZR4uUi2X
…B7ngd

Add image asset upload and brand guidelines support for PMax
Catches the fork up to upstream kLOsk/adloop (109 commits): Search Console,
GTM, Merchant Center, PageSpeed, toolset filtering, two-phase apply, and
currency generalization — combined with the fork's PMax read/write, labels,
insights, and campaign-builder tools (union of both toolsets). All 8
conflicts resolved:

- pyproject: google-ads>=31.1.0
- read.py: upstream currency-parameterized _enrich_cost_fields + fork helpers
- crossref.py / server.py: union; big tool-registration blocks reconstructed
  from clean branches
- write.py: union + upstream two-phase-apply and _extract_resource_name
- RSA pinning standardized on `pinned_field` (the real Google Ads API field)
- rules docs unioned/deduped + regenerated via sync-rules.py
- fork tools tagged for toolset filtering; _config -> current_config()

Follow-up commits reconcile the apply layer to upstream's dry-run model,
re-graft final_url_suffix (auto-UTM), and clean up. Full suite green (632).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the upstream merge — resolves the 20 post-merge test
failures. Full suite now passes (632 passed).

- Adopt upstream's dry-run architecture for the apply layer: drop the
  fork's validate_only threading; _execute_plan + apply handlers use
  upstream's mutate(customer_id=, mutate_operations=) convention;
  confirm_and_apply uses upstream's two-phase/local-marker model.
  (Decision: simpler + aligns with upstream; dry-run is a local marker.)
- Take upstream's _apply_create_campaign/_apply_update_campaign/
  _apply_remove/_apply_campaign_assets; drop duplicate _apply_create_ad_group.
- Tag all fork tools with a toolset tag (upstream toolset filtering);
  _config -> current_config() in fork tools; remove duplicate _VALID_*_PINS.
- draft_rsa_replacement: unpack _validate_urls 2-tuple; restore empty-text
  validation in _validate_rsa; fix stale test mocks to 2-tuple shape.
- cli: json.dumps the python path so Windows backslash paths stay valid
  JSON in generated MCP snippets (cross-platform fix).

Known follow-up: final_url_suffix (UTM) no longer applied on create/update
since we took upstream's apply bodies — needs re-grafting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Taking upstream's _apply_create_campaign/_apply_update_campaign in the
merge dropped the fork's auto-UTM handling. Re-add it on top of upstream's
bodies: set campaign.final_url_suffix on create when non-empty, and on
update add it to the field mask (empty string clears, None = no change).
draft_campaign already records it in plan.changes. Suite still green (632).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- rules doc: drop duplicate GAQL "Common Resources" rows for asset_group /
  asset_group_asset / asset_group_top_combination_view left by the union
  merge (kept the detailed, v24-accurate rows); regenerated derived docs.
- pmax_read: rename metrics.average_cpc_eur -> metrics.average_cpc_amount
  to match read.py's currency-agnostic convention; update its test.

Suite green (632 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Wade-Randel
Wade-Randel force-pushed the chore/merge-upstream branch from 7041f72 to c345606 Compare July 27, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants