Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 54 additions & 24 deletions .github/workflows/preview-seed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -52,40 +82,40 @@ 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;
}

const normalized =
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'
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1192,3 +1192,5 @@ a {
transform: translateY(0);
}
}

/* CI touchpoint: keep a safe non-doc change location for preview-validation trigger commits. */
1 change: 1 addition & 0 deletions apps/web/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions packages/shopify-cable-source/src/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ interface NextDataDocument {
};
}

const PRODUCT_COLLECTION_DISCOVERY_PATH = "/collections/cables";

interface ConnectorPairResult {
evidenceSnippet: string;
from: string;
Expand Down Expand Up @@ -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<ShopifyProduct | null> => {
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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -1465,6 +1526,10 @@ export const createShopifyCableSource = (
product = await fetchProductJson(handle);
}

if (!product) {
product = await fetchProductFromPage(productPath);
}

if (!product) {
return null;
}
Expand Down
16 changes: 12 additions & 4 deletions scripts/seed-vercel-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Expand Down Expand Up @@ -290,19 +290,22 @@ const parseTrailingJson = <T>(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;
}
const candidate = findBalancedJsonSlice(text, start);
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.
}
}

Expand Down Expand Up @@ -552,6 +555,11 @@ const main = async (): Promise<void> => {
{ 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:");
Expand Down
Loading