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
75 changes: 75 additions & 0 deletions src/lib/percy-api/build-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Shared helpers for the /build-items endpoint.
*
* Pagination: the API returns everything when page[limit] is omitted, but may
* still paginate (meta.pagination.has_more + next_cursor) on very large
* builds or if server-side defaults change. fetchAllBuildItems handles both:
* it starts with an unbounded request and follows next_cursor if the response
* is paginated anyway.
*/

import { percyGet } from "./percy-auth.js";
import { BrowserStackConfig } from "../types.js";

const MAX_PAGES = 50;

export interface BuildItemsResult {
items: any[];
/** True if pagination could not be exhausted (page cap or cursor stall). */
truncated: boolean;
}

/**
* Fetch ALL build items for the given filters, following
* meta.pagination.next_cursor until has_more is false.
*/
export async function fetchAllBuildItems(
config: BrowserStackConfig,
baseParams: Record<string, string | string[]>,
): Promise<BuildItemsResult> {
const items: any[] = [];
let cursor: string | undefined;
let pages = 0;

for (;;) {
const params: Record<string, string | string[]> = { ...baseParams };
if (cursor) params["page[cursor]"] = cursor;

const response = await percyGet("/build-items", config, params);
items.push(...(response?.data || []));
pages += 1;

const pagination = response?.meta?.pagination;
const nextCursor = pagination?.next_cursor;
if (!pagination?.has_more || !nextCursor) {
return { items, truncated: false };
}
// Guard against a stalled cursor or runaway loop.
if (nextCursor === cursor || pages >= MAX_PAGES) {
return { items, truncated: true };
}
cursor = String(nextCursor);
}
}

/**
* Filter params that make filter[category]=changed actually return results.
* The API only maps the changed category to review states via
* filter[subcategories][]; without it the scope resolves to zero rows.
*/
export const CHANGED_CATEGORY_PARAMS: Record<string, string | string[]> = {
"filter[category]": "changed",
"filter[subcategories][]": ["unreviewed", "changes_requested", "approved"],
};

/**
* Format a 0..1 diff ratio as a percentage without hiding small diffs:
* 0 → "0%", tiny → "<0.01%", otherwise two decimals (e.g. "0.02%").
*/
export function formatDiffPercent(ratio: number | null | undefined): string {
if (ratio == null) return "—";
const pct = ratio * 100;
if (pct === 0) return "0%";
if (pct < 0.01) return "<0.01%";
return pct.toFixed(2) + "%";
}
8 changes: 6 additions & 2 deletions src/lib/percy-api/percy-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,18 @@ const PERCY_API_BASE = "https://percy.io/api/v1";
export async function percyGet(
path: string,
config: BrowserStackConfig,
params?: Record<string, string>,
params?: Record<string, string | string[]>,
): Promise<any> {
const headers = getPercyAuthHeaders(config);
const url = new URL(`${PERCY_API_BASE}${path}`);

if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, v));
} else {
url.searchParams.set(key, value);
}
}
}

Expand Down
33 changes: 16 additions & 17 deletions src/tools/percy-mcp/v2/get-build-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
*/

import { percyGet, percyPost } from "../../../lib/percy-api/percy-auth.js";
import {
fetchAllBuildItems,
formatDiffPercent,
CHANGED_CATEGORY_PARAMS,
} from "../../../lib/percy-api/build-items.js";
import { BrowserStackConfig } from "../../../lib/types.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { setActiveBuild } from "../../../lib/percy-api/percy-session.js";
Expand Down Expand Up @@ -320,14 +325,11 @@ async function getChanges(
buildId: string,
config: BrowserStackConfig,
): Promise<CallToolResult> {
const response = await percyGet("/build-items", config, {
const { items, truncated } = await fetchAllBuildItems(config, {
"filter[build-id]": buildId,
"filter[category]": "changed",
"page[limit]": "30",
...CHANGED_CATEGORY_PARAMS,
});

const items = response?.data || [];

if (!items.length) {
return {
content: [
Expand All @@ -347,17 +349,17 @@ async function getChanges(
const name = a["cover-snapshot-name"] || a.coverSnapshotName || "?";
const displayName =
a["cover-snapshot-display-name"] || a.coverSnapshotDisplayName || "";
const diff =
(a["max-diff-ratio"] ?? a.maxDiffRatio) != null
? ((a["max-diff-ratio"] ?? a.maxDiffRatio) * 100).toFixed(1) + "%"
: "—";
const diff = formatDiffPercent(a["max-diff-ratio"] ?? a.maxDiffRatio);
const bugs =
a["max-bug-total-potential-bugs"] ?? a.maxBugTotalPotentialBugs ?? 0;
const review = a["review-state"] || a.reviewState || "?";
const count = a["item-count"] || a.itemCount || 1;
output += `| ${i + 1} | ${name} | ${displayName || "—"} | ${diff} | ${bugs} | ${review} | ${count} |\n`;
});

if (truncated) {
output += `\n⚠️ Result may be incomplete — pagination could not be fully exhausted.\n`;
}
output += `\nUse \`percy_get_snapshot\` with a snapshot ID from above for full details.\n`;

return { content: [{ type: "text", text: output }] };
Expand Down Expand Up @@ -637,13 +639,10 @@ async function getSnapshots(
buildId: string,
config: BrowserStackConfig,
): Promise<CallToolResult> {
const response = await percyGet("/build-items", config, {
const { items, truncated } = await fetchAllBuildItems(config, {
"filter[build-id]": buildId,
"page[limit]": "30",
});

const items = response?.data || [];

if (!items.length) {
return {
content: [
Expand All @@ -670,10 +669,7 @@ async function getSnapshots(
const name = a["cover-snapshot-name"] || a.coverSnapshotName || "?";
const display =
a["cover-snapshot-display-name"] || a.coverSnapshotDisplayName || "—";
const diff =
(a["max-diff-ratio"] ?? a.maxDiffRatio) != null
? ((a["max-diff-ratio"] ?? a.maxDiffRatio) * 100).toFixed(1) + "%"
: "—";
const diff = formatDiffPercent(a["max-diff-ratio"] ?? a.maxDiffRatio);
const bugs =
a["max-bug-total-potential-bugs"] ?? a.maxBugTotalPotentialBugs ?? "—";
const review = a["review-state"] || a.reviewState || "?";
Expand All @@ -686,6 +682,9 @@ async function getSnapshots(
output += `| ${i + 1} | ${name} | ${display} | ${diff} | ${bugs} | ${review} | ${count} | ${snapIds}${more} |\n`;
});

if (truncated) {
output += `\n⚠️ Result may be incomplete — pagination could not be fully exhausted.\n`;
}
output += `\nUse \`percy_get_snapshot\` with a snapshot ID for full comparison details.\n`;

return { content: [{ type: "text", text: output }] };
Expand Down
5 changes: 3 additions & 2 deletions src/tools/percy-mcp/v2/get-comparison.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { percyGet } from "../../../lib/percy-api/percy-auth.js";
import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
import { BrowserStackConfig } from "../../../lib/types.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

Expand Down Expand Up @@ -43,8 +44,8 @@ export async function percyGetComparison(
output += `| **Browser** | ${browserName} |\n`;
output += `| **Width** | ${attrs.width || "?"}px |\n`;
output += `| **State** | ${attrs.state || "?"} |\n`;
output += `| **Diff ratio** | ${attrs["diff-ratio"] != null ? (attrs["diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`;
output += `| **AI diff ratio** | ${attrs["ai-diff-ratio"] != null ? (attrs["ai-diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`;
output += `| **Diff ratio** | ${formatDiffPercent(attrs["diff-ratio"])} |\n`;
output += `| **AI diff ratio** | ${formatDiffPercent(attrs["ai-diff-ratio"])} |\n`;
output += `| **AI state** | ${attrs["ai-processing-state"] || "—"} |\n`;
output += `| **Potential bugs** | ${ai["total-potential-bugs"] ?? "—"} |\n`;
output += `| **AI visual diffs** | ${ai["total-ai-visual-diffs"] ?? "—"} |\n`;
Expand Down
13 changes: 4 additions & 9 deletions src/tools/percy-mcp/v2/get-snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { percyGet } from "../../../lib/percy-api/percy-auth.js";
import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
import { BrowserStackConfig } from "../../../lib/types.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

Expand Down Expand Up @@ -29,7 +30,7 @@ export async function percyGetSnapshot(

output += `| Field | Value |\n|---|---|\n`;
output += `| **Review** | ${attrs["review-state"] || "—"} (${attrs["review-state-reason"] || "—"}) |\n`;
output += `| **Diff ratio** | ${attrs["diff-ratio"] != null ? (attrs["diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`;
output += `| **Diff ratio** | ${formatDiffPercent(attrs["diff-ratio"])} |\n`;
output += `| **Test case** | ${attrs["test-case-name"] || "none"} |\n`;
output += `| **Comments** | ${attrs["total-open-comments"] ?? 0} |\n`;
output += `| **Layout** | ${attrs["enable-layout"] ? "enabled" : "disabled"} |\n`;
Expand Down Expand Up @@ -65,14 +66,8 @@ export async function percyGetSnapshot(
const ca = c.attributes || {};
const browserId = c.relationships?.browser?.data?.id;
const browserName = browsers.get(browserId) || "?";
const diff =
ca["diff-ratio"] != null
? (ca["diff-ratio"] * 100).toFixed(1) + "%"
: "—";
const aiDiff =
ca["ai-diff-ratio"] != null
? (ca["ai-diff-ratio"] * 100).toFixed(1) + "%"
: "—";
const diff = formatDiffPercent(ca["diff-ratio"]);
const aiDiff = formatDiffPercent(ca["ai-diff-ratio"]);
const aiState = ca["ai-processing-state"] || "—";
const bugs = ca["ai-details"]?.["total-potential-bugs"] ?? "—";
output += `| ${browserName} | ${ca.width || "?"}px | ${diff} | ${aiDiff} | ${aiState} | ${bugs} |\n`;
Expand Down
50 changes: 35 additions & 15 deletions src/tools/percy-mcp/v2/search-build-items.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { percyGet } from "../../../lib/percy-api/percy-auth.js";
import {
fetchAllBuildItems,
formatDiffPercent,
CHANGED_CATEGORY_PARAMS,
} from "../../../lib/percy-api/build-items.js";
import { BrowserStackConfig } from "../../../lib/types.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

Expand All @@ -15,25 +20,37 @@ export async function percySearchBuildItems(
},
config: BrowserStackConfig,
): Promise<CallToolResult> {
const params: Record<string, string> = { "filter[build-id]": args.build_id };
if (args.category) params["filter[category]"] = args.category;
const params: Record<string, string | string[]> = {
"filter[build-id]": args.build_id,
};
if (args.category === "changed") {
// The API resolves the changed category through subcategories; without
// them filter[category]=changed matches nothing.
Object.assign(params, CHANGED_CATEGORY_PARAMS);
} else if (args.category) {
params["filter[category]"] = args.category;
}
if (args.sort_by) params["filter[sort_by]"] = args.sort_by;
if (args.limit) params["page[limit]"] = String(args.limit);

// Array filters
if (args.browser_ids)
args.browser_ids.split(",").forEach((id) => {
params[`filter[browser_ids][]`] = id.trim();
});
params["filter[browser_ids][]"] = args.browser_ids
.split(",")
.map((id) => id.trim());
if (args.widths)
args.widths.split(",").forEach((w) => {
params[`filter[widths][]`] = w.trim();
});
params["filter[widths][]"] = args.widths.split(",").map((w) => w.trim());
if (args.os) params["filter[os]"] = args.os;
if (args.device_name) params["filter[device_name]"] = args.device_name;

const response = await percyGet("/build-items", config, params);
const items = response?.data || [];
let items: any[];
let truncated = false;
if (args.limit) {
params["page[limit]"] = String(args.limit);
const response = await percyGet("/build-items", config, params);
items = response?.data || [];
} else {
({ items, truncated } = await fetchAllBuildItems(config, params));
}

if (!items.length) {
return {
Expand All @@ -48,14 +65,17 @@ export async function percySearchBuildItems(
items.forEach((item: any, i: number) => {
const attrs = item.attributes || item;
const name = attrs.coverSnapshotName || attrs["cover-snapshot-name"] || "?";
const diff =
attrs.maxDiffRatio != null
? `${(attrs.maxDiffRatio * 100).toFixed(1)}%`
: "—";
const diff = formatDiffPercent(
attrs.maxDiffRatio ?? attrs["max-diff-ratio"],
);
const review = attrs.reviewState || attrs["review-state"] || "?";
const count = attrs.itemCount || attrs["item-count"] || 1;
output += `| ${i + 1} | ${name} | ${diff} | ${review} | ${count} |\n`;
});

if (truncated) {
output += `\n⚠️ Result may be incomplete — pagination could not be fully exhausted.\n`;
}

return { content: [{ type: "text", text: output }] };
}
5 changes: 3 additions & 2 deletions src/tools/percy-mcp/workflows/auto-triage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PercyClient } from "../../../lib/percy-api/client.js";
import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
import { BrowserStackConfig } from "../../../lib/types.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

Expand Down Expand Up @@ -56,14 +57,14 @@ export async function percyAutoTriage(
if (critical.length > 0) {
output += `### CRITICAL — Potential Bugs (${critical.length})\n`;
critical.forEach((e, i) => {
output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff, ${e.potentialBugs} bug(s)\n`;
output += `${i + 1}. **${e.name}** — ${formatDiffPercent(e.diffRatio)} diff, ${e.potentialBugs} bug(s)\n`;
});
output += "\n";
}
if (reviewRequired.length > 0) {
output += `### REVIEW REQUIRED (${reviewRequired.length})\n`;
reviewRequired.forEach((e, i) => {
output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff\n`;
output += `${i + 1}. **${e.name}** — ${formatDiffPercent(e.diffRatio)} diff\n`;
});
output += "\n";
}
Expand Down
5 changes: 3 additions & 2 deletions src/tools/percy-mcp/workflows/diff-explain.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PercyClient } from "../../../lib/percy-api/client.js";
import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
import { pollUntil } from "../../../lib/percy-api/polling.js";
import { BrowserStackConfig } from "../../../lib/types.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
Expand Down Expand Up @@ -36,9 +37,9 @@ export async function percyDiffExplain(
// Basic diff info
const diffRatio = comparison.diffRatio ?? 0;
const aiDiffRatio = comparison.aiDiffRatio;
output += `**Diff:** ${(diffRatio * 100).toFixed(1)}%`;
output += `**Diff:** ${formatDiffPercent(diffRatio)}`;
if (aiDiffRatio !== null && aiDiffRatio !== undefined) {
output += ` | **AI Diff:** ${(aiDiffRatio * 100).toFixed(1)}%`;
output += ` | **AI Diff:** ${formatDiffPercent(aiDiffRatio)}`;
const reduction =
diffRatio > 0 ? ((1 - aiDiffRatio / diffRatio) * 100).toFixed(0) : "0";
output += ` (${reduction}% noise filtered)`;
Expand Down
7 changes: 4 additions & 3 deletions src/tools/percy-mcp/workflows/pr-visual-report.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PercyClient } from "../../../lib/percy-api/client.js";
import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
import { percyCache } from "../../../lib/percy-api/cache.js";
import { formatBuild } from "../../../lib/percy-api/formatter.js";
import { BrowserStackConfig } from "../../../lib/types.js";
Expand Down Expand Up @@ -162,23 +163,23 @@ export async function percyPrVisualReport(
if (critical.length > 0) {
output += `**CRITICAL — Potential Bugs (${critical.length}):**\n`;
critical.forEach((e, i) => {
output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff, ${e.potentialBugs} bug(s) flagged\n`;
output += `${i + 1}. **${e.name}** — ${formatDiffPercent(e.diffRatio)} diff, ${e.potentialBugs} bug(s) flagged\n`;
});
output += "\n";
}

if (review.length > 0) {
output += `**REVIEW REQUIRED (${review.length}):**\n`;
review.forEach((e, i) => {
output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff\n`;
output += `${i + 1}. **${e.name}** — ${formatDiffPercent(e.diffRatio)} diff\n`;
});
output += "\n";
}

if (expected.length > 0) {
output += `**EXPECTED CHANGES (${expected.length}):**\n`;
expected.forEach((e, i) => {
output += `${i + 1}. ${e.name} — ${(e.diffRatio * 100).toFixed(1)}% diff\n`;
output += `${i + 1}. ${e.name} — ${formatDiffPercent(e.diffRatio)} diff\n`;
});
output += "\n";
}
Expand Down