refactor(serenity): skip the brand's own primary domain in brand URLs#2748
Conversation
… writing them Brand URLs were pushed to Semrush AIO benchmarks verbatim (e.g. https://www.lovesac.com), which does not match the canonical benchmark Semrush stores (lovesac.com) — creating duplicate www-vs-apex entries. Resolve website URLs through Project Engine's GET /v1/url/resolve and write the returned canonical primary_url instead (serenity-docs#25 §2). - bump @adobe/spacecat-shared-project-engine-client 1.7.1 -> 1.8.0 (adds the url/resolve endpoint to the typed client + mock) - rest-transport: add resolveUrl(primaryUrl) (typed GET /v1/url/resolve) - brand-urls: add resolveWebsiteEntries — resolves type:'website' entries only (social/earned are identity handles, passed through), falls back to the raw url on is_valid:false (never writes the empty value), re-de-dups www-vs-apex collapses, and lets transport errors propagate (CREATE best-effort / EDIT hard-fail semantics unchanged) - wire it into attachBrandUrlsToProject (CREATE) and syncBrandUrlsAcrossMarkets (EDIT, before the diff) so the diff compares Semrush's canonical form on both sides — idempotent after the first (self-correcting) migration re-sync - toEntry's https-only guard is left unchanged: resolution is a post-collection step, so it only ever validates raw inputs Verified: unit tests (resolveWebsiteEntries, attach/sync normalization, idempotency, legacy-migration diff); serenity IT green against the 1.8.0 mock; type-check + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Hey @rainer-friederich,
Verdict: Request changes - one convention fix needed before merge.
Complexity: HIGH - medium diff; dependency risk signal.
Changes: Adds URL canonicalization via Semrush's url/resolve before writing brand URLs, preventing www-vs-apex duplicates on both the create and edit paths (6 files).
Note: CI checks are currently pending.
Must fix before merge
- [Important] Version specifier
^1.8.0is inconsistent with the project's exact-pin convention -package.json:96(details inline)
Non-blocking (4): minor issues and suggestions
- suggestion: Resolve brand-wide website URLs once before the per-market loop in
syncBrandUrlsAcrossMarketsto avoid redundant HTTP calls (the resolve result is deterministic and region-less) -src/support/serenity/brand-urls.js:409 - nit: De-dup in
resolveWebsiteEntrieskeys onurlalone; a type-qualified key (${type}:${url}) would prevent a theoretical cross-type collision -src/support/serenity/brand-urls.js:155 - nit: The warn message says "url/resolve returned is_valid:false" even when the actual condition is
is_valid:truewith emptyprimary_url-src/support/serenity/brand-urls.js:131 - suggestion: Add a unit test for
resolveUrlreturningnull/undefinedto document the optional-chaining defense -test/support/serenity/brand-urls.test.js
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 2m 23s | Cost: $5.53 | Commit: 7067b67edce8ac302d2291bc43c6f2ab4ea4ba1c
If this code review was useful, please react with 👍. Otherwise, react with 👎.
…e-once cache)
- package.json: pin @adobe/spacecat-shared-project-engine-client to exact 1.8.0
(was ^1.8.0), matching every other spacecat-shared dependency
- resolveWebsiteEntries: accept an optional per-sync resolve cache and share one
across the syncBrandUrlsAcrossMarkets market loop — website urls are region-less,
so each distinct one is resolved once instead of per market
- correct the fallback warn message ("no usable primary_url"), which also covers
the defensive is_valid:true-but-empty-primary_url case, not just is_valid:false
- tests: null/undefined resolve response, shared-cache resolve-once (helper + the
cross-market sync path)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
|
Addressed the review in Must fix:
Non-blocking:
|
There was a problem hiding this comment.
Hey @rainer-friederich,
Verdict: Approve - all prior findings addressed, no new blocking issues.
Complexity: HIGH - medium diff; dependency risk signal.
Changes: Adds URL canonicalization via Semrush's url/resolve before writing brand URLs, preventing www-vs-apex duplicates on both the create and edit paths (6 files).
Note: CI checks are currently pending.
Previously flagged, now resolved
- Version pin now exact
1.8.0(was^1.8.0), consistent with all other shared dependencies - Shared resolve cache across markets eliminates redundant HTTP calls for region-less website URLs
- Warn message reworded to accurately cover both
is_valid:falseand emptyprimary_urlcases - Unit test for
resolveUrlreturningnull/undefinednow present
Non-blocking (3): minor issues and suggestions
- nit: No test for cache-hit with a
null(unresolvable) cached value - a test pre-seeding the cache withnew Map([['https://bad.com', null]])and asserting no re-resolve and no re-warn would pin the once-per-url contract explicitly -src/support/serenity/brand-urls.js:157 - nit:
attachBrandUrlsToProjecthas no integration-level test verifying the de-dup effect of resolution (two raw URLs collapsing to one canonical), though the unit-level coverage inresolveWebsiteEntriestests is solid -test/support/serenity/brand-urls.test.js - suggestion: Sequential resolution in
resolveWebsiteEntries(oneawaitper URL) is fine for current cardinality (1-3 website URLs per brand) but would serialize round-trips if a brand ever carries 20+ URLs - a boundedPromise.allwould be a future optimization if that threshold is reached -src/support/serenity/brand-urls.js:130
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 6s | Cost: $6.54 | Commit: b7ae9ad37c0f04eab258412cb31e8361486b2346
If this code review was useful, please react with 👍. Otherwise, react with 👎.
The new rest-transport resolveUrl wrapper had no direct unit test — the brand-urls tests stub it — so codecov/patch flagged the uncovered method. Add a test asserting it GETs /v1/url/resolve with the primary_url query param and returns the body, mirroring listGlobalAiModels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Follow-up in |
There was a problem hiding this comment.
Hey @rainer-friederich,
Verdict: Approve - test-only commit, no new concerns.
Complexity: HIGH - medium diff; dependency risk signal.
Changes: Adds a unit test for the resolveUrl transport wrapper to close the Codecov patch coverage gap (7 files).
Note: CI checks are currently pending.
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 13s | Cost: $3.69 | Commit: 2a5e04923c1661a3cac2d9bb57b1ee7857c9b3b2
If this code review was useful, please react with 👍. Otherwise, react with 👎.
# [1.624.0](v1.623.0...v1.624.0) (2026-07-06) ### Features * **serenity:** resolve brand URLs to Semrush's canonical form before writing them ([#2748](#2748)) ([a064a84](a064a84)), closes [Hi#level](https://github.com/Hi/issues/level)
|
🎉 This PR is included in version 1.624.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
…10, slim CR16 (#1772) <!-- mysticat-pr-skill --> ## 1. Abstract Re-vendors the refreshed Semrush Project Engine public swagger in the project-engine-client package and reconciles the correction overlay against it, retiring the corrections the refresh has caught up on. ## 2. Reasoning Semrush shipped a new revision of the Project Engine public swagger. Several of our overlay corrections (which align the vendored spec with the live API) exist only because the vendored spec lagged the live API; once upstream ships the same shape, the correction becomes a redundant no-op that the overlay freshness gate rejects. Re-vendoring lets us drop that now-dead maintenance and pull in the new upstream surface, while keeping the corrections upstream still hasn't fixed. ## 3. High-level overview of the changes The overlay drops from 16 corrections to 14; the vendored swagger and the generated TS/pydantic types are regenerated from the new upstream revision. - CR9 (`AISettings.primary_url`) — now present upstream, dropped. - CR10 (`AIOBenchmarkWithCounters.primary_url` + `root_domain`) — now present upstream, dropped. - CR16 — `GET /v1/url/resolve` and its response schema (`model.ResolveURLResponse`) are now vendored natively. The correction collapses from "add the path + add a custom schema" to a single action marking the three always-present fields (`domain`, `primary_url`, `is_valid`) `required` (upstream still omits that, and the mock always populates them — the same faithful-`required` convention as CR5). The internal type reference is renamed `model.UrlResolveResponse` -> `model.ResolveURLResponse`. - CR1–CR8 and CR11–CR15 are unchanged — the refresh did not fix them. The refresh also brings new upstream surface into the generated types: new endpoints (`managed-ai-models`, `crawls/stop`, `apply_interactive_unignore_issue_rules`), new fields (`primary_url` on `ProjectRequest`/`ProjectUpdateRequest`/`AISettings`, `main_brand`+`primary_url` on `AIOBenchmarkRequest`), a breaking reshape of `ApplyInteractiveIgnoreIssueRulesRequest`, and removal of the Site-Intelligence `allow_more_parallel_instances` endpoint + `SIProjectSettingsRequest`. None of the removed/reshaped shapes are referenced by the mock or client source, so nothing in this package breaks. The renamed resolve response type is internal to this package: the merged consumer keys off the path string `/v1/url/resolve` (openapi-fetch), not the type name, so it is unaffected and will pick up the new types on the next client release. ## 4. Required information - Spec: adobe/serenity-docs#25 - Other: consumer already merged — adobe/spacecat-api-service#2748 ; original CR16 endpoint + mock (released as 1.8.0) — #1771 ## 5. Affected / used mysticat-workspace projects - `spacecat-api-service` — consumed / contract: uses `url/resolve` via the typed client (merged in PR 2748). This PR renames the resolve response type internally; the consumer keys off the path, not the type name, so it is not broken and only re-bumps the client on the next release. - `mysticat-data-service` — related, not touched: the Serenity migration CLI performs the same `url/resolve` canonicalization on its own write path. ## 6. Additional information outside the code - Ran `npm run generate` end to end against the re-vendored spec: swagger2openapi conversion, the overlay apply (all remaining actions applied, none reported stale), and the TS + pydantic codegen all completed. - Booted the Counterfact mock and exercised the resolve endpoint through the typed client (canonicalization, `is_valid:false` empty-string fallback at HTTP 200, and the missing-vs-empty `primary_url` behaviour), confirming Counterfact response validation accepts the now-`required` `model.ResolveURLResponse`. ## 7. Test plan - (a) Local: the overlay freshness gate (asserts every remaining correction still applies and still changes the converted spec), the mock factory/context/url-resolve unit tests, and the resolve e2e against the booted mock were run locally and are green. Type-check against the regenerated types passes. - (b) Per-env: no service or public-API change here — this is a shared client package. After merge and release, bump `@adobe/spacecat-shared-project-engine-client` in the consumers and confirm `url/resolve` still resolves against the released mock image (the api-service serenity IT harness pins the mock tag to the installed client version). ## 8. Deployment & merge order - Related (not blocking): adobe/spacecat-api-service#2748 — the resolve consumer, already merged on client 1.8.0. It keeps working on 1.8.0; it picks up the renamed resolve type only when re-bumped to the release this PR produces. Ordered sequence: merge + release this PR, then re-bump the client in consumers at their own pace. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1778) <!-- mysticat-pr-skill --> ## 1. Abstract The vendored Project Engine mock now enforces the live Semrush validation on `POST .../brand_urls`: a brand URL must be a literal, lower-case `https://` URL, and the mock answers a non-conforming entry with the same 400 (and the same go-validator tag) the live gateway returns, instead of silently accepting it. ## 2. Reasoning While reworking the serenity-docs#25 brand-URL feature, a prod write-probe showed `create_brand_urls` REQUIRES a literal `https://`: a scheme-less value 400s on the go-validator `url` tag, and a valid non-https URL (`http://`, `ftp://`) 400s on the `startswith=https://` tag. The mock accepted all of these, so an integration test could go green over a write the live gateway rejects — exactly the fidelity gap that let the earlier "resolve brand URLs to their scheme-less form before writing" mis-design ship (the scheme-less resolve value cannot be written as a brand URL at all). Because the point of this gate is fidelity, the contract was probed rather than inferred: ~40 edge cases were POSTed to the live API and the mock reproduces the observed matrix, including the places where go's semantics differ from JavaScript's and a `URL`-based check silently gets the wrong answer. ## 3. High-level overview of the changes Before: the mock's `POST .../brand_urls` handler created a row for any `{ url, type }` entry, regardless of scheme, and always returned 200. After: each entry's `url` is validated against the live contract before any row is created. A non-conforming entry short-circuits the whole batch with the gateway's 400 `BasicResponse`, naming the tag live names. `BrandURLRequest.URL` carries `required,url,startswith=https://`, evaluated in that order: - `required` — an empty (or null/missing) value; reported before `url` is ever evaluated. - `url` — the value must parse, under go's rules: a non-empty scheme plus a host, a fragment, or opaque content. A scheme-less value (`lovesac.com`, `www.x.com`, `//x.com`), a scheme with only a rooted path (`https:/x.com`), a bare `https://`, and any value containing whitespace fail here. - `startswith=https://` — a literal, case-SENSITIVE prefix check against the raw value, reached by anything that parsed. `http://`, `ftp://`, an upper- or mixed-case scheme (`HTTPS://X`, `Https://x`), every `file:` form, and the hostless-but-opaque forms (`mailto:`, `tel:`, `https:x.com`) fail here — not on `url`. A conforming value is accepted and stored verbatim (no scheme or `www.` normalization), unchanged from today; an upper-case host, an IDN host, an IP, and a port/path/query are all accepted, as is `https://#frag` (a non-empty fragment satisfies `url` even with no host). The batch is atomic: one bad entry creates nothing. The check lives in a pure `brandUrlHttpsTag` helper exposed on the request context (same convention as the existing `resolveUrl` / `tagId` lib helpers), so it is unit-tested independently of the coverage-excluded route handler. It reproduces go's parse by hand rather than delegating to JavaScript's `URL`, whose WHATWG parsing disagrees with go exactly where it matters: it trims surrounding whitespace, and it reports no host for the opaque forms go accepts. ## 4. Required information - Jira / issue: serenity-docs#25 — adobe/serenity-docs#25 - Other: write-probe of the live `create_brand_urls` contract (2026-07-13, throwaway benchmarks in a dev workspace, cleaned up in the same run) ## 5. Affected / used mysticat-workspace projects - spacecat-api-service (consumer) — its serenity brand-URL write path (PR #2748) writes only `https://` brand URLs after the skip-primary-domain redesign; this mock keeps its integration tests honest to the live 400. - mysticat-data-service (consumer) — the serenity_migration CLI (PR #737) likewise writes only `https://` brand URLs; same fidelity guarantee. ## 6. Additional information outside the code The contract was captured by a live write-probe against prod `adobe-hackathon.semrush.com` on 2026-07-13: throwaway competitor benchmarks were created in the LLMO-Dev-2 dev sub-workspace, ~40 candidate values were POSTed one per request, the accept/reject verdict and go-validator tag were recorded for each, and every benchmark and row created was deleted in the same run. No customer data was touched. Two results are worth calling out because they contradict what the code would otherwise assume: - go-validator's `startswith` is `strings.HasPrefix` against the raw value, so an upper-case scheme (`HTTPS://…`) is rejected, while an upper-case host (`https://EXAMPLE.NET`) is fine. Only the scheme must be literal. - every `file:` form clears the `url` tag and fails on `startswith` — including `file://` and `file:///`, which go-validator's current source would reject on `url`. The deployed validator predates that path check, so the probed behaviour is what the mock models; the code says so at the point it matters. ## 7. Test plan - Unit: the probed matrix is recorded case by case against the pure helper — accepts, the case-sensitivity of the scheme, scheme-less, non-https, opaque/hostless, `file:`, degenerate and whitespace forms, `required`, and the non-string unmarshal failure. The package's 100% branch-coverage gate is met. - E2E (`test:e2e`, which boots the real mock server): the route answers the live 400 and tag over real HTTP for the scheme-less, `http://`, upper-case-scheme, `mailto:` and empty-url forms, a missing url is rejected by request validation, the batch is proven atomic (a good entry alongside a bad one is not created), and the existing create/list/delete e2e (which uses an `https://` URL) still passes through the new gate. - No environment rollout applies — this is a test-only mock artifact in a shared package; it ships in the next `spacecat-shared-project-engine-client` release and is picked up by consumers when they bump. ## 8. Deployment & merge order - serenity-docs#25 redesign is a coordinated set: this mock fix hardens the contract that spacecat-api-service PR #2748 (adobe/spacecat-api-service#2748) and mysticat-data-service PR #737 (adobe/mysticat-data-service#737) rely on. It is independent to merge (the consumers do not import it at runtime), but merging it first and cutting a release lets those PRs' integration tests run against the tightened mock. Suggested order: this PR (+ release) → then #2748 / #737. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1. Abstract
Fixes the duplicate brand-domain entry a Serenity brand shows in Semrush by not adding the brand's own primary domain as a brand URL (it already lives on the project's own-brand benchmark). Replaces the earlier "resolve brand URLs to their scheme-less form before writing" approach, which a prod re-capture showed was invalid.
2. Reasoning
A brand shows its primary domain twice in Semrush (e.g.
lovesac.com): once as the own-brand benchmark's ownprimary_url(which Semrush stores scheme-less), and once as an explicitwebsitebrand-URL row (https://lovesac.com) the write path added. A prod re-capture (2026-07-06) of the live contract established two facts that invalidate the original url/resolve approach this PR opened with:create_brand_urlsREQUIRES a literalhttps://value — it 400s on the scheme-less resolveprimary_url('url'tag) and onhttp://('startswith'tag). So the resolved scheme-less form cannot be written as a brand URL at all.create_benchmarksalready normalizes the benchmarkdomainserver-side, sourl/resolvewas redundant for the one field where a scheme-less value is valid.Because a
brand_urlsrow must keep itshttps://scheme and the benchmark domain is scheme-less, the two can never collapse — the only way to remove the visible duplicate is to not emit the primary domain as a brand URL in the first place.3. High-level overview of the changes
Before: every brand-URL entry was pushed to the own-brand benchmark, and (in the reverted revision) each was first run through
url/resolve.After: the brand's own primary domain is dropped from the
websiteset; every other brand URL (secondary sites, socials, earned) is written verbatim as anhttps://value.collectBrandUrlEntriestakes the project's own-brand domain and skips anywebsiteURL whose host matches it. The match is host-wise via the existingnormalizeBenchmarkDomain, so both the apex andwww.forms of the primary are dropped.resolveUrltransport method and theresolveBrandUrlEntriesstep — brand URLs are now written exactly as collected (the upstream stores them as-is and does not collapsewww.-vs-apex, so no normalization is applied).Net user-visible effect: a Serenity brand no longer double-lists its primary domain in Semrush; socials and any secondary sites are unchanged.
4. Required information
5. Affected / used mysticat-workspace projects
https://requirement so this repo's IT can't go green over a prod-400: fix(project-engine-client): enforce https:// on the brand_urls mock spacecat-shared#17786. Additional information outside the code
The live contract behind this change was re-captured against prod
adobe-hackathon.semrush.comon 2026-07-06: a read-onlyurl/resolveedge matrix, plus a write-probe ofcreate_brand_urls/create_benchmarkson a throwaway benchmark created and deleted in an authorized test workspace. That probe is what established thehttps://-only requirement and the server-side benchmark-domain normalization. The paid customer's live data was inspected read-only (no writes) and confirmed to carry the duplicate this PR prevents.7. Test plan
collectBrandUrlEntries(primary-domain skip in apex + www forms; all-kept when no primary given) and both write paths (create attaches the non-primary set; edit diffs verbatim with no churn). The serenity IT exercises the create/edit write paths against the mock.https://<domain>brand-URL row), while socials/secondary sites still attach.8. Deployment & merge order
🤖 Generated with Claude Code