diff --git a/.github/workflows/preview-seed.yml b/.github/workflows/preview-seed.yml index 7154e53..46dee80 100644 --- a/.github/workflows/preview-seed.yml +++ b/.github/workflows/preview-seed.yml @@ -35,11 +35,41 @@ jobs: uses: actions/github-script@v7 env: DEPLOY_REF: ${{ env.DEPLOYMENT_REF }} + DEPLOY_SHA: ${{ env.DEPLOYMENT_SHA }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const rawRef = process.env.DEPLOY_REF ?? ""; + const rawSha = process.env.DEPLOY_SHA ?? ""; const repo = context.repo; + const isCommitSha = (value) => /^[0-9a-f]{40}$/i.test(value); + + const setPrOutputs = (pr, reason) => { + core.info(`Resolved PR #${pr.number} (${reason}).`); + core.setOutput("skip", "false"); + core.setOutput("number", String(pr.number)); + core.setOutput("title", pr.title ?? ""); + core.setOutput("headSha", pr.head?.sha ?? ""); + }; + + const resolveByCommitSha = async (commitSha) => { + if (!commitSha) { + return null; + } + core.info(`Looking up PR associated with commit ${commitSha}.`); + const { data: associated } = + await github.rest.repos.listPullRequestsAssociatedWithCommit({ + ...repo, + commit_sha: commitSha, + per_page: 20, + }); + const openPr = associated.find((pr) => pr.state === "open"); + if (openPr) { + return openPr; + } + return null; + }; + const match = rawRef.match(/^refs\/pull\/(\d+)\/merge$/); if (match) { const pull_number = Number.parseInt(match[1], 10); @@ -52,11 +82,7 @@ jobs: core.setOutput("skip", "true"); return; } - core.info(`Detected PR #${pull_number} from deployment ref ${rawRef}.`); - core.setOutput("skip", "false"); - core.setOutput("number", String(pr.number)); - core.setOutput("title", pr.title ?? ""); - core.setOutput("headSha", pr.head?.sha ?? ""); + setPrOutputs(pr, `deployment ref ${rawRef}`); return; } @@ -64,28 +90,32 @@ jobs: rawRef.startsWith("refs/heads/") ? rawRef.replace("refs/heads/", "") : rawRef; - if (!normalized) { - core.warning("Empty deployment ref; skipping preview seed."); - core.setOutput("skip", "true"); - return; + if (normalized && !isCommitSha(normalized)) { + const { data: prs } = await github.rest.pulls.list({ + ...repo, + head: `${repo.owner}:${normalized}`, + state: "open", + per_page: 1, + }); + if (prs.length > 0) { + setPrOutputs(prs[0], `branch ${normalized}`); + return; + } + core.info(`No open PR found by branch ${normalized}.`); } - const { data: prs } = await github.rest.pulls.list({ - ...repo, - head: `${repo.owner}:${normalized}`, - state: "open", - per_page: 1, - }); - if (prs.length === 0) { - core.info(`No open PR found for branch ${normalized}; skipping preview seed.`); - core.setOutput("skip", "true"); + + const commitCandidate = + isCommitSha(normalized) ? normalized : (isCommitSha(rawSha) ? rawSha : ""); + const commitPr = await resolveByCommitSha(commitCandidate); + if (commitPr) { + setPrOutputs(commitPr, `commit ${commitCandidate}`); return; } - const pr = prs[0]; - core.info(`Preview deployment belongs to PR #${pr.number} (${pr.title}).`); - core.setOutput("skip", "false"); - core.setOutput("number", String(pr.number)); - core.setOutput("title", pr.title ?? ""); - core.setOutput("headSha", pr.head?.sha ?? ""); + + core.info( + `No open PR found for deployment ref '${rawRef}' and sha '${rawSha}'; skipping preview seed.`, + ); + core.setOutput("skip", "true"); - name: Skip when deployment has no matching PR if: steps.pr.outputs.skip == 'true' diff --git a/README.md b/README.md index deb7851..3e3a5c6 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,7 @@ Web app: `http://localhost:5173` CI (`.github/workflows/ci.yml`) currently runs: - `ultracite check` -- Convex Shopify ingest integration test -- Shopify source integration test +- deterministic manual inference + mapping tests - workspace build - TUI compile smoke test @@ -121,7 +120,11 @@ Environment notes: - `AI_GATEWAY_API_KEY` and `FIRECRAWL_API_KEY` are required only for fallback ingest mode. - Shopify-only extraction path works without those fallback provider keys. - Optional web analytics: `PUBLIC_VERCEL_ANALYTICS_DSN`. -- Preview seed automation exists in `.github/workflows/preview-seed.yml`. +- Preview runtime validation exists in `.github/workflows/preview-seed.yml` (`preview-validation`): + - triggered by Vercel `deployment_status` events + - resolves the preview deployment URL automatically + - seeds Convex preview catalog + - runs manual inference runtime validation and Browserbase QA on deployed preview ## Release Automation diff --git a/apps/web/src/app.css b/apps/web/src/app.css index b862617..a2e1c96 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1192,3 +1192,5 @@ a { transform: translateY(0); } } + +/* CI touchpoint: keep a safe non-doc change location for preview-validation trigger commits. */ diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 2035c0d..f58726f 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -36,6 +36,7 @@ const CONVEX_RECONNECTING_NOTE = "Connection to workspace data is reconnecting. Results will appear once reconnected."; const WORKSPACE_STORAGE_KEY = "cable-intel-workspace-id"; + // Keep this page as a deployment trigger touchpoint for preview validation. const MOBILE_FACET_QUERY = "(max-width: 1179px)"; const FACET_DIMENSIONS = [ "brand", diff --git a/packages/shopify-cable-source/src/source.ts b/packages/shopify-cable-source/src/source.ts index 4b69fb7..31b2b5b 100644 --- a/packages/shopify-cable-source/src/source.ts +++ b/packages/shopify-cable-source/src/source.ts @@ -111,6 +111,8 @@ interface NextDataDocument { }; } +const PRODUCT_COLLECTION_DISCOVERY_PATH = "/collections/cables"; + interface ConnectorPairResult { evidenceSnippet: string; from: string; @@ -1259,6 +1261,61 @@ export const createShopifyCableSource = ( .filter((product) => product.handle && product.title); }; + const fetchCollectionCandidates = async (): Promise< + ShopifyProductCandidate[] + > => { + const collectionUrl = new URL( + PRODUCT_COLLECTION_DISCOVERY_PATH, + template.baseUrl + ); + const response = await fetchWithTimeout(collectionUrl.toString()); + if (!response.ok) { + throw new HttpError( + `Failed to fetch product collection page (${response.status})`, + response.status + ); + } + + const html = await response.text(); + const nextData = parseNextDataFromHtml(html); + const pageProps = + nextData.props?.pageProps ?? nextData.pageProps ?? nextData; + return collectCandidates(pageProps); + }; + + const tryFetchCollectionCandidates = async (): Promise< + ShopifyProductCandidate[] + > => { + try { + return await fetchCollectionCandidates(); + } catch (error) { + if (error instanceof HttpError && error.status === 404) { + return []; + } + throw error; + } + }; + + const fetchProductFromPage = async ( + productPath: string + ): Promise => { + const productUrl = new URL(productPath, template.baseUrl); + const response = await fetchWithTimeout(productUrl.toString()); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new HttpError( + `Failed to fetch product page (${response.status})`, + response.status + ); + } + + const html = await response.text(); + const payload = parseNextDataFromHtml(html); + return getProductFromNextData(payload); + }; + const shouldSkipSupplementalLookup = (product: ShopifyProduct): boolean => { const baseDescription = combineUniqueText( product.description, @@ -1418,6 +1475,10 @@ export const createShopifyCableSource = ( candidates = await fetchSearchSuggestCandidates(); } + if (candidates.length === 0) { + candidates = await tryFetchCollectionCandidates(); + } + const productUrls = candidates .filter((candidate) => template.includeCandidate(candidate)) .map((candidate) => { @@ -1465,6 +1526,10 @@ export const createShopifyCableSource = ( product = await fetchProductJson(handle); } + if (!product) { + product = await fetchProductFromPage(productPath); + } + if (!product) { return null; } diff --git a/scripts/seed-vercel-deployment.ts b/scripts/seed-vercel-deployment.ts index 322f85d..293ae0f 100644 --- a/scripts/seed-vercel-deployment.ts +++ b/scripts/seed-vercel-deployment.ts @@ -42,7 +42,7 @@ interface ManualInferenceSession { const DEFAULT_TEMPLATE_ID = "anker-us"; const DEFAULT_DISCOVER_MAX = 30; -const DEFAULT_SEED_MAX = 20; +const DEFAULT_SEED_MAX = 8; const DEFAULT_ALLOWED_DOMAIN = "anker.com"; const APP_ENTRY_REGEX = /_app\/immutable\/entry\/app\.[^"' )]+\.js/; const NODE_ZERO_REGEX = /\.\.\/nodes\/0\.[^"' )]+\.js/; @@ -290,8 +290,7 @@ const parseTrailingJson = (text: string): T | null => { return match.index ?? -1; }); - for (let cursor = startIndexes.length - 1; cursor >= 0; cursor -= 1) { - const start = startIndexes[cursor]; + for (const start of startIndexes) { if (start < 0) { continue; } @@ -299,10 +298,14 @@ const parseTrailingJson = (text: string): T | null => { if (!candidate) { continue; } + const trailing = text.slice(start + candidate.length).trim(); + if (trailing.length > 0) { + continue; + } try { return JSON.parse(candidate) as T; } catch { - // Keep scanning older candidates. + // Keep scanning candidates. } } @@ -552,6 +555,11 @@ const main = async (): Promise => { { limit: 500 }, backendDir ); + if (!Array.isArray(rows)) { + throw new Error( + `ingestQueries:getTopCables returned non-array payload: ${JSON.stringify(rows)}` + ); + } const quality = analyzeRows(rows); console.log("Quality summary:");