From 4a37f4b33e2c7b630d3699f5a33bf3c2d8b8374c Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 19 Jun 2026 10:28:36 +0530 Subject: [PATCH 1/7] fix: getBuildId mcp tool --- src/tools/rca-agent-utils/get-build-id.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tools/rca-agent-utils/get-build-id.ts b/src/tools/rca-agent-utils/get-build-id.ts index 6f3dffe7..51ef937e 100644 --- a/src/tools/rca-agent-utils/get-build-id.ts +++ b/src/tools/rca-agent-utils/get-build-id.ts @@ -9,7 +9,6 @@ export async function getBuildId( ); url.searchParams.append("project_name", projectName); url.searchParams.append("build_name", buildName); - url.searchParams.append("user_name", username); const authHeader = "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); From 442d46860f452acf4061b42ca679b3a108e8b3d6 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 19 Jun 2026 10:59:46 +0530 Subject: [PATCH 2/7] fix: fixed failedTestIds --- src/tools/rca-agent-utils/get-failed-test-id.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/rca-agent-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts index a474b2b6..5af4058f 100644 --- a/src/tools/rca-agent-utils/get-failed-test-id.ts +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -73,7 +73,7 @@ function extractFailedTestIds( let failedTests: FailedTestInfo[] = []; for (const node of hierarchy) { - if (node.details?.status === status && node.details?.run_count) { + if (node.details?.status === status) { if (node.details?.observability_url) { const idMatch = node.details.observability_url.match(/details=(\d+)/); if (idMatch) { From be1f16662d7eb16c9656b734489bdfc13468a4ea Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Mon, 22 Jun 2026 13:56:20 +0530 Subject: [PATCH 3/7] feat: add listBuildId tool for recent build IDs --- src/tools/rca-agent-utils/list-build-ids.ts | 146 ++++++++++++++++++++ src/tools/rca-agent.ts | 73 ++++++++++ tests/tools/list-build-ids.test.ts | 118 ++++++++++++++++ tests/tools/rcaAgent.test.ts | 57 ++++++++ 4 files changed, 394 insertions(+) create mode 100644 src/tools/rca-agent-utils/list-build-ids.ts create mode 100644 tests/tools/list-build-ids.test.ts diff --git a/src/tools/rca-agent-utils/list-build-ids.ts b/src/tools/rca-agent-utils/list-build-ids.ts new file mode 100644 index 00000000..406f05c5 --- /dev/null +++ b/src/tools/rca-agent-utils/list-build-ids.ts @@ -0,0 +1,146 @@ +export interface BuildSummary { + build_id: string; + build_number: number; + status: string; + started_at: string; +} + +const BUILDS_API_BASE = "https://api-automation.browserstack.com/ext/v1"; +const DEFAULT_LIMIT = 5; +const DAY_MS = 24 * 60 * 60 * 1000; +// The project-builds endpoint returns oldest-first within a date_range window +// (the backend hard-codes ascending sort), so the most recent runs sit at the +// end of the window. We anchor the window at the latest build and try +// progressively wider spans, stopping as soon as we have enough runs. Narrow +// windows keep the common (active build) case to a handful of requests; wider +// ones cover infrequently-run builds. +const WINDOW_DAYS = [2, 7, 30, 180, 730]; +// Safety cap on pages walked per window (10 builds/page) to bound pathological +// high-volume windows. +const MAX_PAGES_PER_WINDOW = 60; + +function authHeaders(username: string, accessKey: string) { + return { + Authorization: + "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"), + "Content-Type": "application/json", + }; +} + +/** + * List the most recent build IDs for a given project + build name, newest first. + * + * Unlike getBuildId (which returns only the single latest build and used to + * over-filter by user_name), this lists up to `limit` recent runs of the build + * name with no user restriction, using the project-builds endpoint's + * `unique_build_names` + `date_range` filters. + */ +export async function listBuildIds( + projectName: string, + buildName: string, + username: string, + accessKey: string, + limit: number = DEFAULT_LIMIT, +): Promise { + const headers = authHeaders(username, accessKey); + + // 1. Resolve the project id (and the latest build's timestamp) via the same + // latest-build endpoint used by getBuildId, with no user_name filter. + const latestUrl = new URL(`${BUILDS_API_BASE}/builds/latest`); + latestUrl.searchParams.append("project_name", projectName); + latestUrl.searchParams.append("build_name", buildName); + + const latestResponse = await fetch(latestUrl.toString(), { headers }); + if (!latestResponse.ok) { + throw new Error( + `Failed to resolve project: ${latestResponse.status} ${latestResponse.statusText}`, + ); + } + const latest = await latestResponse.json(); + const projectId = latest?.project_id; + if (!projectId) { + throw new Error( + `No builds found for project "${projectName}" and build "${buildName}"`, + ); + } + + // Anchor the window just after the latest run so it is always included. + const anchorMs = latest.started_at + ? Date.parse(latest.started_at) + DAY_MS + : Date.now() + DAY_MS; + + // 2. Try progressively wider windows; stop once we have `limit` runs. + let collected: BuildSummary[] = []; + for (const windowDays of WINDOW_DAYS) { + collected = await collectBuildsInWindow( + projectId, + buildName, + anchorMs - windowDays * DAY_MS, + anchorMs, + limit, + headers, + ); + if (collected.length >= limit) { + break; + } + } + + // Walk yields oldest-first; present newest-first. + return collected.reverse(); +} + +/** + * Walk the project-builds pages for a single build name within [startMs, endMs], + * returning the last `limit` runs (oldest-first) found in that window. + */ +async function collectBuildsInWindow( + projectId: number, + buildName: string, + startMs: number, + endMs: number, + limit: number, + headers: Record, +): Promise { + const tail: BuildSummary[] = []; + let nextPage: string | null = null; + + for (let page = 0; page < MAX_PAGES_PER_WINDOW; page++) { + const url = new URL(`${BUILDS_API_BASE}/projects/${projectId}/builds`); + url.searchParams.append("unique_build_names", buildName); + url.searchParams.append("date_range", String(startMs)); + url.searchParams.append("date_range", String(endMs)); + if (nextPage) { + url.searchParams.append("next_page", nextPage); + } + + const response = await fetch(url.toString(), { headers }); + if (!response.ok) { + throw new Error( + `Failed to fetch builds: ${response.status} ${response.statusText}`, + ); + } + const data = await response.json(); + + for (const build of data?.builds ?? []) { + if (build.build_id) { + tail.push({ + build_id: build.build_id, + build_number: build.build_number, + status: build.status, + started_at: build.started_at, + }); + // Keep only the most recent `limit` runs (window is oldest-first). + if (tail.length > limit) { + tail.shift(); + } + } + } + + if (!data?.pagination?.has_next || !data.pagination.next_page) { + break; + } + nextPage = data.pagination.next_page; + } + + return tail; +} diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 1d6e6702..385f5e99 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -4,6 +4,7 @@ import logger from "../logger.js"; import { BrowserStackConfig } from "../lib/types.js"; import { getBrowserStackAuth } from "../lib/get-auth.js"; import { getBuildId } from "./rca-agent-utils/get-build-id.js"; +import { listBuildIds } from "./rca-agent-utils/list-build-ids.js"; import { getTestIds } from "./rca-agent-utils/get-failed-test-id.js"; import { getRCAData } from "./rca-agent-utils/rca-data.js"; import { formatRCAData } from "./rca-agent-utils/format-rca.js"; @@ -59,6 +60,59 @@ export async function getBuildIdTool( } } +// Tool function to list recent build IDs for a project + build name +export async function listBuildIdsTool( + args: BuildIdArgs, + config: BrowserStackConfig, +): Promise { + try { + const { browserStackProjectName, browserStackBuildName } = args; + + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + + const builds = await listBuildIds( + browserStackProjectName, + browserStackBuildName, + username, + accessKey, + ); + + if (builds.length === 0) { + return { + content: [ + { + type: "text", + text: `No builds found for project "${browserStackProjectName}" and build "${browserStackBuildName}".`, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(builds, null, 2), + }, + ], + }; + } catch (error) { + logger.error("Error listing build IDs", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Error listing build IDs: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + // Tool function that fetches RCA data export async function fetchRCADataTool( args: { testId: number[] }, @@ -180,6 +234,25 @@ export default function addRCATools( }, ); + tools.listBuildId = server.tool( + "listBuildId", + "List up to 5 recent BrowserStack build IDs for a project and build name.", + GET_BUILD_ID_PARAMS, + async (args) => { + try { + trackMCP( + "listBuildId", + server.server.getClientVersion()!, + undefined, + config, + ); + return await listBuildIdsTool(args, config); + } catch (error) { + return handleMCPError("listBuildId", server, config, error); + } + }, + ); + tools.listTestIds = server.tool( "listTestIds", "List test IDs from a BrowserStack Automate build, optionally filtered by status", diff --git a/tests/tools/list-build-ids.test.ts b/tests/tools/list-build-ids.test.ts new file mode 100644 index 00000000..51656bd4 --- /dev/null +++ b/tests/tools/list-build-ids.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { listBuildIds } from "../../src/tools/rca-agent-utils/list-build-ids"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const LATEST_STARTED = "2026-06-22T07:00:00.000Z"; +const ANCHOR_MS = Date.parse(LATEST_STARTED) + DAY_MS; + +function jsonRes(body: any, ok = true, status = 200, statusText = "OK") { + return { ok, status, statusText, json: async () => body } as any; +} + +function build(n: number, extra: Record = {}) { + return { + build_id: `b${n}`, + build_number: n, + status: "passed", + started_at: `2026-06-2${n}`, + name: "Suite", + ...extra, + }; +} + +// A page of builds in oldest-first order (as the real endpoint returns). +function page(nums: number[], next: string | null = null) { + return jsonRes({ + builds: nums.map((n) => build(n)), + pagination: { has_next: !!next, next_page: next }, + }); +} + +describe("listBuildIds", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("returns newest-first, capped at limit, using name+date filters and no user_name", async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes("/builds/latest")) { + return Promise.resolve( + jsonRes({ project_id: 7, started_at: LATEST_STARTED }), + ); + } + // single window page, oldest-first #1..#7 + return Promise.resolve(page([1, 2, 3, 4, 5, 6, 7])); + }); + + const out = await listBuildIds("Proj", "Suite", "u", "k"); + + // newest 5, newest-first + expect(out.map((b) => b.build_number)).toEqual([7, 6, 5, 4, 3]); + + const latestUrl = fetchMock.mock.calls[0][0] as string; + expect(latestUrl).toContain("/builds/latest"); + expect(latestUrl).not.toContain("user_name"); + + const listUrl = fetchMock.mock.calls[1][0] as string; + expect(listUrl).toContain("unique_build_names=Suite"); + expect(listUrl).toContain("date_range="); + expect(listUrl).not.toContain("build_name=Suite"); + }); + + it("follows pagination within a window", async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes("/builds/latest")) { + return Promise.resolve( + jsonRes({ project_id: 1, started_at: LATEST_STARTED }), + ); + } + if (url.includes("next_page=TOK")) { + return Promise.resolve(page([3, 4, 5, 6])); + } + return Promise.resolve(page([1, 2], "TOK")); + }); + + const out = await listBuildIds("Proj", "Suite", "u", "k"); + + expect(out.map((b) => b.build_number)).toEqual([6, 5, 4, 3, 2]); + }); + + it("widens the window when the narrowest is too sparse", async () => { + const window2Start = ANCHOR_MS - 2 * DAY_MS; + fetchMock.mockImplementation((url: string) => { + if (url.includes("/builds/latest")) { + return Promise.resolve( + jsonRes({ project_id: 1, started_at: LATEST_STARTED }), + ); + } + // 2-day window: only 2 builds -> not enough, must widen + if (url.includes(`date_range=${window2Start}`)) { + return Promise.resolve(page([10, 11])); + } + // wider window: enough builds + return Promise.resolve(page([20, 21, 22, 23, 24, 25])); + }); + + const out = await listBuildIds("Proj", "Suite", "u", "k"); + + expect(out.map((b) => b.build_number)).toEqual([25, 24, 23, 22, 21]); + }); + + it("throws a clear error when the project cannot be resolved", async () => { + fetchMock.mockResolvedValueOnce(jsonRes({})); + await expect(listBuildIds("Proj", "Nope", "u", "k")).rejects.toThrow( + /No builds found/, + ); + }); + + it("throws when the latest-build request fails", async () => { + fetchMock.mockResolvedValueOnce(jsonRes({}, false, 404, "Not Found")); + await expect(listBuildIds("Proj", "X", "u", "k")).rejects.toThrow( + /Failed to resolve project: 404/, + ); + }); +}); diff --git a/tests/tools/rcaAgent.test.ts b/tests/tools/rcaAgent.test.ts index a7a38d8f..3c65841c 100644 --- a/tests/tools/rcaAgent.test.ts +++ b/tests/tools/rcaAgent.test.ts @@ -3,8 +3,10 @@ import { getBuildIdTool, fetchRCADataTool, listTestIdsTool, + listBuildIdsTool, } from "../../src/tools/rca-agent"; import { getBuildId } from "../../src/tools/rca-agent-utils/get-build-id"; +import { listBuildIds } from "../../src/tools/rca-agent-utils/list-build-ids"; import { getTestIds } from "../../src/tools/rca-agent-utils/get-failed-test-id"; import { getRCAData } from "../../src/tools/rca-agent-utils/rca-data"; import { formatRCAData } from "../../src/tools/rca-agent-utils/format-rca"; @@ -13,6 +15,9 @@ import { getBrowserStackAuth } from "../../src/lib/get-auth"; vi.mock("../../src/tools/rca-agent-utils/get-build-id", () => ({ getBuildId: vi.fn(), })); +vi.mock("../../src/tools/rca-agent-utils/list-build-ids", () => ({ + listBuildIds: vi.fn(), +})); vi.mock("../../src/tools/rca-agent-utils/get-failed-test-id", () => ({ getTestIds: vi.fn(), })); @@ -98,6 +103,58 @@ describe("RCA Agent Tools", () => { }); }); + describe("listBuildIdsTool", () => { + it("SUCCESS: returns recent builds as JSON", async () => { + const builds = [ + { build_id: "b5", build_number: 5, status: "passed", started_at: "x" }, + { build_id: "b4", build_number: 4, status: "failed", started_at: "y" }, + ]; + (listBuildIds as Mock).mockResolvedValue(builds); + + const result = await listBuildIdsTool( + { + browserStackProjectName: "MyProject", + browserStackBuildName: "MyBuild", + }, + mockConfig, + ); + + expect(result.isError).toBeFalsy(); + expect(JSON.parse(result.content[0].text)).toEqual(builds); + expect(getBrowserStackAuth).toHaveBeenCalledWith(mockConfig); + }); + + it("SUCCESS: reports when no builds are found", async () => { + (listBuildIds as Mock).mockResolvedValue([]); + + const result = await listBuildIdsTool( + { + browserStackProjectName: "MyProject", + browserStackBuildName: "Missing", + }, + mockConfig, + ); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("No builds found"); + }); + + it("FAIL: returns isError on API failure", async () => { + (listBuildIds as Mock).mockRejectedValue(new Error("boom")); + + const result = await listBuildIdsTool( + { + browserStackProjectName: "Bad", + browserStackBuildName: "Bad", + }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error listing build IDs"); + }); + }); + describe("fetchRCADataTool", () => { it("SUCCESS: returns formatted RCA data", async () => { (getRCAData as Mock).mockResolvedValue({ analysis: "root cause" }); From d7ab77e8bd444a59dfd2d6401e12517d05b00548 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Mon, 22 Jun 2026 17:01:48 +0530 Subject: [PATCH 4/7] feat: updated description and reverted getBuildId changes --- src/tools/rca-agent-utils/get-build-id.ts | 1 + src/tools/rca-agent.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tools/rca-agent-utils/get-build-id.ts b/src/tools/rca-agent-utils/get-build-id.ts index 51ef937e..6f3dffe7 100644 --- a/src/tools/rca-agent-utils/get-build-id.ts +++ b/src/tools/rca-agent-utils/get-build-id.ts @@ -9,6 +9,7 @@ export async function getBuildId( ); url.searchParams.append("project_name", projectName); url.searchParams.append("build_name", buildName); + url.searchParams.append("user_name", username); const authHeader = "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 385f5e99..41d92eb8 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -198,7 +198,7 @@ export default function addRCATools( tools.fetchRCA = server.tool( "fetchRCA", - "Fetch AI Root Cause Analysis for failed BrowserStack Automate/App-Automate tests. Suggests fixes only; never auto-apply, require explicit user approval.", + "Fetch AI Root Cause Analysis for the current user's failed BrowserStack Automate/App-Automate tests. Suggests fixes only; never auto-apply, require explicit user approval.", FETCH_RCA_PARAMS, async (args) => { try { @@ -217,7 +217,7 @@ export default function addRCATools( tools.getBuildId = server.tool( "getBuildId", - "Get the BrowserStack build ID for a given project and build name.", + "Get the BrowserStack build ID for a given project and build name, scoped to the current user's builds.", GET_BUILD_ID_PARAMS, async (args) => { try { @@ -236,7 +236,7 @@ export default function addRCATools( tools.listBuildId = server.tool( "listBuildId", - "List up to 5 recent BrowserStack build IDs for a project and build name.", + "List up to 5 recent build IDs for a project and build name, across all users.", GET_BUILD_ID_PARAMS, async (args) => { try { From cb612fcbde6249a3fbd0287f3f059ba8016cc68e Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Mon, 22 Jun 2026 17:33:42 +0530 Subject: [PATCH 5/7] feat: added test and exported extractFailedTestIds --- .../rca-agent-utils/get-failed-test-id.ts | 3 +- src/tools/rca-agent.ts | 8 +- tests/tools/extract-failed-test-ids.test.ts | 82 +++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 tests/tools/extract-failed-test-ids.test.ts diff --git a/src/tools/rca-agent-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts index 5af4058f..233a1e01 100644 --- a/src/tools/rca-agent-utils/get-failed-test-id.ts +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -65,8 +65,7 @@ export async function getTestIds( } } -// Recursive function to extract failed test IDs from hierarchy -function extractFailedTestIds( +export function extractFailedTestIds( hierarchy: TestDetails[], status?: TestStatus, ): FailedTestInfo[] { diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 41d92eb8..3edcbf8b 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -234,21 +234,21 @@ export default function addRCATools( }, ); - tools.listBuildId = server.tool( - "listBuildId", + tools.listBuildIds = server.tool( + "listBuildIds", "List up to 5 recent build IDs for a project and build name, across all users.", GET_BUILD_ID_PARAMS, async (args) => { try { trackMCP( - "listBuildId", + "listBuildIds", server.server.getClientVersion()!, undefined, config, ); return await listBuildIdsTool(args, config); } catch (error) { - return handleMCPError("listBuildId", server, config, error); + return handleMCPError("listBuildIds", server, config, error); } }, ); diff --git a/tests/tools/extract-failed-test-ids.test.ts b/tests/tools/extract-failed-test-ids.test.ts new file mode 100644 index 00000000..226971a3 --- /dev/null +++ b/tests/tools/extract-failed-test-ids.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { extractFailedTestIds } from "../../src/tools/rca-agent-utils/get-failed-test-id"; +import { TestStatus } from "../../src/tools/rca-agent-utils/types"; + +const node = (details: any, display_name?: string, children: any[] = []) => + ({ details, display_name, children }) as any; + +describe("extractFailedTestIds", () => { + it("includes a status match even when run_count is 0 (the regression this fixes)", () => { + const hierarchy = [ + node( + { + status: TestStatus.FAILED, + run_count: 0, + observability_url: "https://observability.bs.com/x?details=111", + }, + "zero-run failure", + ), + ]; + + const result = extractFailedTestIds(hierarchy, TestStatus.FAILED); + + expect(result).toEqual([ + { test_id: "111", test_name: "zero-run failure" }, + ]); + }); + + it("skips nodes with a non-matching status", () => { + const hierarchy = [ + node({ + status: TestStatus.PASSED, + run_count: 3, + observability_url: "https://observability.bs.com/x?details=222", + }), + ]; + + expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([]); + }); + + it("does not crash and skips a node with missing details", () => { + const hierarchy = [node(undefined, "no details"), node(null, "null")]; + + expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([]); + }); + + it("skips a status match that has no observability_url (no id to extract)", () => { + const hierarchy = [node({ status: TestStatus.FAILED }, "no url")]; + + expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([]); + }); + + it("recurses into children and collects nested matches", () => { + const hierarchy = [ + node({ status: TestStatus.PASSED }, "parent", [ + node( + { + status: TestStatus.FAILED, + observability_url: "https://observability.bs.com/x?details=333", + }, + "nested failure", + ), + ]), + ]; + + expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([ + { test_id: "333", test_name: "nested failure" }, + ]); + }); + + it("falls back to a generated name when display_name is absent", () => { + const hierarchy = [ + node({ + status: TestStatus.FAILED, + observability_url: "https://observability.bs.com/x?details=444", + }), + ]; + + expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([ + { test_id: "444", test_name: "Test 444" }, + ]); + }); +}); From 86ad299796a3da0fec6fb1d038ecae4d570d42e4 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Mon, 22 Jun 2026 20:10:48 +0530 Subject: [PATCH 6/7] fix: updated the comments --- src/tools/rca-agent-utils/list-build-ids.ts | 28 ++++++--------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/tools/rca-agent-utils/list-build-ids.ts b/src/tools/rca-agent-utils/list-build-ids.ts index 406f05c5..33efad92 100644 --- a/src/tools/rca-agent-utils/list-build-ids.ts +++ b/src/tools/rca-agent-utils/list-build-ids.ts @@ -8,15 +8,10 @@ export interface BuildSummary { const BUILDS_API_BASE = "https://api-automation.browserstack.com/ext/v1"; const DEFAULT_LIMIT = 5; const DAY_MS = 24 * 60 * 60 * 1000; -// The project-builds endpoint returns oldest-first within a date_range window -// (the backend hard-codes ascending sort), so the most recent runs sit at the -// end of the window. We anchor the window at the latest build and try -// progressively wider spans, stopping as soon as we have enough runs. Narrow -// windows keep the common (active build) case to a handful of requests; wider -// ones cover infrequently-run builds. +// The project-builds endpoint sorts ascending within a date_range, so the +// newest runs sit at the end of the window. We anchor at the latest build and +// widen the span until we have enough runs. const WINDOW_DAYS = [2, 7, 30, 180, 730]; -// Safety cap on pages walked per window (10 builds/page) to bound pathological -// high-volume windows. const MAX_PAGES_PER_WINDOW = 60; function authHeaders(username: string, accessKey: string) { @@ -29,11 +24,6 @@ function authHeaders(username: string, accessKey: string) { /** * List the most recent build IDs for a given project + build name, newest first. - * - * Unlike getBuildId (which returns only the single latest build and used to - * over-filter by user_name), this lists up to `limit` recent runs of the build - * name with no user restriction, using the project-builds endpoint's - * `unique_build_names` + `date_range` filters. */ export async function listBuildIds( projectName: string, @@ -44,8 +34,7 @@ export async function listBuildIds( ): Promise { const headers = authHeaders(username, accessKey); - // 1. Resolve the project id (and the latest build's timestamp) via the same - // latest-build endpoint used by getBuildId, with no user_name filter. + // Resolve the project id from the latest build (no user_name filter). const latestUrl = new URL(`${BUILDS_API_BASE}/builds/latest`); latestUrl.searchParams.append("project_name", projectName); latestUrl.searchParams.append("build_name", buildName); @@ -64,12 +53,11 @@ export async function listBuildIds( ); } - // Anchor the window just after the latest run so it is always included. + // Anchor just after the latest run so it always falls inside the window. const anchorMs = latest.started_at ? Date.parse(latest.started_at) + DAY_MS : Date.now() + DAY_MS; - // 2. Try progressively wider windows; stop once we have `limit` runs. let collected: BuildSummary[] = []; for (const windowDays of WINDOW_DAYS) { collected = await collectBuildsInWindow( @@ -85,13 +73,12 @@ export async function listBuildIds( } } - // Walk yields oldest-first; present newest-first. return collected.reverse(); } /** - * Walk the project-builds pages for a single build name within [startMs, endMs], - * returning the last `limit` runs (oldest-first) found in that window. + * Walk the project-builds pages within [startMs, endMs], returning the last + * `limit` runs (oldest-first) found in that window. */ async function collectBuildsInWindow( projectId: number, @@ -129,7 +116,6 @@ async function collectBuildsInWindow( status: build.status, started_at: build.started_at, }); - // Keep only the most recent `limit` runs (window is oldest-first). if (tail.length > limit) { tail.shift(); } From 2fe3e3505c7bd5223273f8ae39a35ac0e3b99ad6 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 25 Jun 2026 00:06:32 +0530 Subject: [PATCH 7/7] fix: updated listBuildId --- src/tools/rca-agent-utils/list-build-id.ts | 35 ++++++ src/tools/rca-agent-utils/list-build-ids.ts | 132 -------------------- src/tools/rca-agent.ts | 37 ++---- tests/tools/list-build-ids.test.ts | 118 ----------------- tests/tools/rcaAgent.test.ts | 43 ++----- 5 files changed, 60 insertions(+), 305 deletions(-) create mode 100644 src/tools/rca-agent-utils/list-build-id.ts delete mode 100644 src/tools/rca-agent-utils/list-build-ids.ts delete mode 100644 tests/tools/list-build-ids.test.ts diff --git a/src/tools/rca-agent-utils/list-build-id.ts b/src/tools/rca-agent-utils/list-build-id.ts new file mode 100644 index 00000000..0f37af4d --- /dev/null +++ b/src/tools/rca-agent-utils/list-build-id.ts @@ -0,0 +1,35 @@ +/** + * Same as getBuildId, but without the user_name filter — returns the latest + * build for a project + build name across all users (not just the caller's). + */ +export async function listBuildId( + projectName: string, + buildName: string, + username: string, + accessKey: string, +): Promise { + const url = new URL( + "https://api-automation.browserstack.com/ext/v1/builds/latest", + ); + url.searchParams.append("project_name", projectName); + url.searchParams.append("build_name", buildName); + + const authHeader = + "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); + + const response = await fetch(url.toString(), { + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch build ID: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.build_id; +} diff --git a/src/tools/rca-agent-utils/list-build-ids.ts b/src/tools/rca-agent-utils/list-build-ids.ts deleted file mode 100644 index 33efad92..00000000 --- a/src/tools/rca-agent-utils/list-build-ids.ts +++ /dev/null @@ -1,132 +0,0 @@ -export interface BuildSummary { - build_id: string; - build_number: number; - status: string; - started_at: string; -} - -const BUILDS_API_BASE = "https://api-automation.browserstack.com/ext/v1"; -const DEFAULT_LIMIT = 5; -const DAY_MS = 24 * 60 * 60 * 1000; -// The project-builds endpoint sorts ascending within a date_range, so the -// newest runs sit at the end of the window. We anchor at the latest build and -// widen the span until we have enough runs. -const WINDOW_DAYS = [2, 7, 30, 180, 730]; -const MAX_PAGES_PER_WINDOW = 60; - -function authHeaders(username: string, accessKey: string) { - return { - Authorization: - "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"), - "Content-Type": "application/json", - }; -} - -/** - * List the most recent build IDs for a given project + build name, newest first. - */ -export async function listBuildIds( - projectName: string, - buildName: string, - username: string, - accessKey: string, - limit: number = DEFAULT_LIMIT, -): Promise { - const headers = authHeaders(username, accessKey); - - // Resolve the project id from the latest build (no user_name filter). - const latestUrl = new URL(`${BUILDS_API_BASE}/builds/latest`); - latestUrl.searchParams.append("project_name", projectName); - latestUrl.searchParams.append("build_name", buildName); - - const latestResponse = await fetch(latestUrl.toString(), { headers }); - if (!latestResponse.ok) { - throw new Error( - `Failed to resolve project: ${latestResponse.status} ${latestResponse.statusText}`, - ); - } - const latest = await latestResponse.json(); - const projectId = latest?.project_id; - if (!projectId) { - throw new Error( - `No builds found for project "${projectName}" and build "${buildName}"`, - ); - } - - // Anchor just after the latest run so it always falls inside the window. - const anchorMs = latest.started_at - ? Date.parse(latest.started_at) + DAY_MS - : Date.now() + DAY_MS; - - let collected: BuildSummary[] = []; - for (const windowDays of WINDOW_DAYS) { - collected = await collectBuildsInWindow( - projectId, - buildName, - anchorMs - windowDays * DAY_MS, - anchorMs, - limit, - headers, - ); - if (collected.length >= limit) { - break; - } - } - - return collected.reverse(); -} - -/** - * Walk the project-builds pages within [startMs, endMs], returning the last - * `limit` runs (oldest-first) found in that window. - */ -async function collectBuildsInWindow( - projectId: number, - buildName: string, - startMs: number, - endMs: number, - limit: number, - headers: Record, -): Promise { - const tail: BuildSummary[] = []; - let nextPage: string | null = null; - - for (let page = 0; page < MAX_PAGES_PER_WINDOW; page++) { - const url = new URL(`${BUILDS_API_BASE}/projects/${projectId}/builds`); - url.searchParams.append("unique_build_names", buildName); - url.searchParams.append("date_range", String(startMs)); - url.searchParams.append("date_range", String(endMs)); - if (nextPage) { - url.searchParams.append("next_page", nextPage); - } - - const response = await fetch(url.toString(), { headers }); - if (!response.ok) { - throw new Error( - `Failed to fetch builds: ${response.status} ${response.statusText}`, - ); - } - const data = await response.json(); - - for (const build of data?.builds ?? []) { - if (build.build_id) { - tail.push({ - build_id: build.build_id, - build_number: build.build_number, - status: build.status, - started_at: build.started_at, - }); - if (tail.length > limit) { - tail.shift(); - } - } - } - - if (!data?.pagination?.has_next || !data.pagination.next_page) { - break; - } - nextPage = data.pagination.next_page; - } - - return tail; -} diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 3edcbf8b..1102d697 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -4,7 +4,7 @@ import logger from "../logger.js"; import { BrowserStackConfig } from "../lib/types.js"; import { getBrowserStackAuth } from "../lib/get-auth.js"; import { getBuildId } from "./rca-agent-utils/get-build-id.js"; -import { listBuildIds } from "./rca-agent-utils/list-build-ids.js"; +import { listBuildId } from "./rca-agent-utils/list-build-id.js"; import { getTestIds } from "./rca-agent-utils/get-failed-test-id.js"; import { getRCAData } from "./rca-agent-utils/rca-data.js"; import { formatRCAData } from "./rca-agent-utils/format-rca.js"; @@ -60,8 +60,8 @@ export async function getBuildIdTool( } } -// Tool function to list recent build IDs for a project + build name -export async function listBuildIdsTool( +// Tool function to fetch the build ID across all users (no user scoping) +export async function listBuildIdTool( args: BuildIdArgs, config: BrowserStackConfig, ): Promise { @@ -71,41 +71,30 @@ export async function listBuildIdsTool( const authString = getBrowserStackAuth(config); const [username, accessKey] = authString.split(":"); - const builds = await listBuildIds( + const buildId = await listBuildId( browserStackProjectName, browserStackBuildName, username, accessKey, ); - if (builds.length === 0) { - return { - content: [ - { - type: "text", - text: `No builds found for project "${browserStackProjectName}" and build "${browserStackBuildName}".`, - }, - ], - }; - } - return { content: [ { type: "text", - text: JSON.stringify(builds, null, 2), + text: buildId, }, ], }; } catch (error) { - logger.error("Error listing build IDs", error); + logger.error("Error fetching build ID", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; return { content: [ { type: "text", - text: `Error listing build IDs: ${errorMessage}`, + text: `Error fetching build ID: ${errorMessage}`, }, ], isError: true, @@ -234,21 +223,21 @@ export default function addRCATools( }, ); - tools.listBuildIds = server.tool( - "listBuildIds", - "List up to 5 recent build IDs for a project and build name, across all users.", + tools.listBuildId = server.tool( + "listBuildId", + "Get the latest build ID for a project and build name, across all users (no user filter).", GET_BUILD_ID_PARAMS, async (args) => { try { trackMCP( - "listBuildIds", + "listBuildId", server.server.getClientVersion()!, undefined, config, ); - return await listBuildIdsTool(args, config); + return await listBuildIdTool(args, config); } catch (error) { - return handleMCPError("listBuildIds", server, config, error); + return handleMCPError("listBuildId", server, config, error); } }, ); diff --git a/tests/tools/list-build-ids.test.ts b/tests/tools/list-build-ids.test.ts deleted file mode 100644 index 51656bd4..00000000 --- a/tests/tools/list-build-ids.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { listBuildIds } from "../../src/tools/rca-agent-utils/list-build-ids"; - -const DAY_MS = 24 * 60 * 60 * 1000; -const LATEST_STARTED = "2026-06-22T07:00:00.000Z"; -const ANCHOR_MS = Date.parse(LATEST_STARTED) + DAY_MS; - -function jsonRes(body: any, ok = true, status = 200, statusText = "OK") { - return { ok, status, statusText, json: async () => body } as any; -} - -function build(n: number, extra: Record = {}) { - return { - build_id: `b${n}`, - build_number: n, - status: "passed", - started_at: `2026-06-2${n}`, - name: "Suite", - ...extra, - }; -} - -// A page of builds in oldest-first order (as the real endpoint returns). -function page(nums: number[], next: string | null = null) { - return jsonRes({ - builds: nums.map((n) => build(n)), - pagination: { has_next: !!next, next_page: next }, - }); -} - -describe("listBuildIds", () => { - const fetchMock = vi.fn(); - - beforeEach(() => { - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); - }); - afterEach(() => vi.unstubAllGlobals()); - - it("returns newest-first, capped at limit, using name+date filters and no user_name", async () => { - fetchMock.mockImplementation((url: string) => { - if (url.includes("/builds/latest")) { - return Promise.resolve( - jsonRes({ project_id: 7, started_at: LATEST_STARTED }), - ); - } - // single window page, oldest-first #1..#7 - return Promise.resolve(page([1, 2, 3, 4, 5, 6, 7])); - }); - - const out = await listBuildIds("Proj", "Suite", "u", "k"); - - // newest 5, newest-first - expect(out.map((b) => b.build_number)).toEqual([7, 6, 5, 4, 3]); - - const latestUrl = fetchMock.mock.calls[0][0] as string; - expect(latestUrl).toContain("/builds/latest"); - expect(latestUrl).not.toContain("user_name"); - - const listUrl = fetchMock.mock.calls[1][0] as string; - expect(listUrl).toContain("unique_build_names=Suite"); - expect(listUrl).toContain("date_range="); - expect(listUrl).not.toContain("build_name=Suite"); - }); - - it("follows pagination within a window", async () => { - fetchMock.mockImplementation((url: string) => { - if (url.includes("/builds/latest")) { - return Promise.resolve( - jsonRes({ project_id: 1, started_at: LATEST_STARTED }), - ); - } - if (url.includes("next_page=TOK")) { - return Promise.resolve(page([3, 4, 5, 6])); - } - return Promise.resolve(page([1, 2], "TOK")); - }); - - const out = await listBuildIds("Proj", "Suite", "u", "k"); - - expect(out.map((b) => b.build_number)).toEqual([6, 5, 4, 3, 2]); - }); - - it("widens the window when the narrowest is too sparse", async () => { - const window2Start = ANCHOR_MS - 2 * DAY_MS; - fetchMock.mockImplementation((url: string) => { - if (url.includes("/builds/latest")) { - return Promise.resolve( - jsonRes({ project_id: 1, started_at: LATEST_STARTED }), - ); - } - // 2-day window: only 2 builds -> not enough, must widen - if (url.includes(`date_range=${window2Start}`)) { - return Promise.resolve(page([10, 11])); - } - // wider window: enough builds - return Promise.resolve(page([20, 21, 22, 23, 24, 25])); - }); - - const out = await listBuildIds("Proj", "Suite", "u", "k"); - - expect(out.map((b) => b.build_number)).toEqual([25, 24, 23, 22, 21]); - }); - - it("throws a clear error when the project cannot be resolved", async () => { - fetchMock.mockResolvedValueOnce(jsonRes({})); - await expect(listBuildIds("Proj", "Nope", "u", "k")).rejects.toThrow( - /No builds found/, - ); - }); - - it("throws when the latest-build request fails", async () => { - fetchMock.mockResolvedValueOnce(jsonRes({}, false, 404, "Not Found")); - await expect(listBuildIds("Proj", "X", "u", "k")).rejects.toThrow( - /Failed to resolve project: 404/, - ); - }); -}); diff --git a/tests/tools/rcaAgent.test.ts b/tests/tools/rcaAgent.test.ts index 3c65841c..c5b7d601 100644 --- a/tests/tools/rcaAgent.test.ts +++ b/tests/tools/rcaAgent.test.ts @@ -3,10 +3,10 @@ import { getBuildIdTool, fetchRCADataTool, listTestIdsTool, - listBuildIdsTool, + listBuildIdTool, } from "../../src/tools/rca-agent"; import { getBuildId } from "../../src/tools/rca-agent-utils/get-build-id"; -import { listBuildIds } from "../../src/tools/rca-agent-utils/list-build-ids"; +import { listBuildId } from "../../src/tools/rca-agent-utils/list-build-id"; import { getTestIds } from "../../src/tools/rca-agent-utils/get-failed-test-id"; import { getRCAData } from "../../src/tools/rca-agent-utils/rca-data"; import { formatRCAData } from "../../src/tools/rca-agent-utils/format-rca"; @@ -15,8 +15,8 @@ import { getBrowserStackAuth } from "../../src/lib/get-auth"; vi.mock("../../src/tools/rca-agent-utils/get-build-id", () => ({ getBuildId: vi.fn(), })); -vi.mock("../../src/tools/rca-agent-utils/list-build-ids", () => ({ - listBuildIds: vi.fn(), +vi.mock("../../src/tools/rca-agent-utils/list-build-id", () => ({ + listBuildId: vi.fn(), })); vi.mock("../../src/tools/rca-agent-utils/get-failed-test-id", () => ({ getTestIds: vi.fn(), @@ -103,15 +103,11 @@ describe("RCA Agent Tools", () => { }); }); - describe("listBuildIdsTool", () => { - it("SUCCESS: returns recent builds as JSON", async () => { - const builds = [ - { build_id: "b5", build_number: 5, status: "passed", started_at: "x" }, - { build_id: "b4", build_number: 4, status: "failed", started_at: "y" }, - ]; - (listBuildIds as Mock).mockResolvedValue(builds); + describe("listBuildIdTool", () => { + it("SUCCESS: returns build ID string (all users, no user filter)", async () => { + (listBuildId as Mock).mockResolvedValue("build-xyz-789"); - const result = await listBuildIdsTool( + const result = await listBuildIdTool( { browserStackProjectName: "MyProject", browserStackBuildName: "MyBuild", @@ -120,29 +116,14 @@ describe("RCA Agent Tools", () => { ); expect(result.isError).toBeFalsy(); - expect(JSON.parse(result.content[0].text)).toEqual(builds); + expect(result.content[0].text).toBe("build-xyz-789"); expect(getBrowserStackAuth).toHaveBeenCalledWith(mockConfig); }); - it("SUCCESS: reports when no builds are found", async () => { - (listBuildIds as Mock).mockResolvedValue([]); - - const result = await listBuildIdsTool( - { - browserStackProjectName: "MyProject", - browserStackBuildName: "Missing", - }, - mockConfig, - ); - - expect(result.isError).toBeFalsy(); - expect(result.content[0].text).toContain("No builds found"); - }); - it("FAIL: returns isError on API failure", async () => { - (listBuildIds as Mock).mockRejectedValue(new Error("boom")); + (listBuildId as Mock).mockRejectedValue(new Error("Not found")); - const result = await listBuildIdsTool( + const result = await listBuildIdTool( { browserStackProjectName: "Bad", browserStackBuildName: "Bad", @@ -151,7 +132,7 @@ describe("RCA Agent Tools", () => { ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("Error listing build IDs"); + expect(result.content[0].text).toContain("Error fetching build ID"); }); });