Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7e2a040
fix(rca): resolve build ids for TRA and Automate tools end to end
SavioBS629 Nov 6, 2026
0d42921
fix(rca): map only the latest run of an Automate build to TRA
SavioBS629 Nov 6, 2026
4211eed
fix(rca): limit build lookup paging
SavioBS629 Nov 6, 2026
5f7df0b
refactor(rca): trim comments in build lookup code
SavioBS629 Nov 6, 2026
8432a03
fix(rca): look up Automate project links in Automate, detect 404 by s…
SavioBS629 Nov 6, 2026
d526eef
fix(rca): treat Automate project links as TRA links
SavioBS629 Nov 6, 2026
222f5cc
chore: restore test and fixture formatting unrelated to this change
SavioBS629 Nov 6, 2026
aa11473
fix(rca): call the Automate name match the first match, not the newest
SavioBS629 Nov 6, 2026
aa3f0a0
fix(rca): report partial project searches and surface Automate outages
SavioBS629 Nov 6, 2026
f383d32
fix(failurelogs): only swap equivalent log types; document getBuildId…
SavioBS629 Nov 6, 2026
0c02920
fix(rca): surface errors from the Automate name search; count project…
SavioBS629 Nov 6, 2026
03965cd
refactor(rca): resolve build ids without pagination
SavioBS629 Nov 6, 2026
e368a2c
fix(automate): surface TRA server errors in listSessions fallback
SavioBS629 Nov 6, 2026
717c654
fix(automate): read one page of test runs to find a build's session
SavioBS629 Nov 6, 2026
7059100
fix(rca): resolve build numbers on the server through the TRA capability
SavioBS629 Nov 6, 2026
e485ee1
perf(rca): load only the TRA capability index for build-number lookups
SavioBS629 Nov 6, 2026
3439f1d
fix(automate): confirm uploaded builds by session count, not more pages
SavioBS629 Nov 6, 2026
7d8bfcd
Revert "fix(automate): confirm uploaded builds by session count, not …
SavioBS629 Nov 6, 2026
c85d05b
Revert "fix(automate): read one page of test runs to find a build's s…
SavioBS629 Nov 6, 2026
557ca81
test: use made-up build ids and names in fixtures
SavioBS629 Nov 6, 2026
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
16 changes: 16 additions & 0 deletions src/lib/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,19 @@ export function formatAxiosError(
isError: true,
};
}

/** A failed HTTP request, keeping the status for callers to branch on. */
export class HttpError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "HttpError";
}
}

export function isNotFoundError(error: unknown): boolean {
if (error instanceof HttpError) return error.status === 404;
return error instanceof AxiosError && error.response?.status === 404;
}
11 changes: 5 additions & 6 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getBrowserStackAuth } from "./get-auth.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { trackMCP } from "../index.js";
import { HttpError } from "./error.js";

export function sanitizeUrlParam(param: string): string {
// Remove any characters that could be used for command injection
Expand Down Expand Up @@ -33,15 +34,13 @@ export async function fetchFromBrowserStackAPI(
const authString = getBrowserStackAuth(config);
const auth = Buffer.from(authString).toString("base64");

const res = await fetch(url, {
headers: {
Authorization: `Basic ${auth}`,
},
});
const init = { headers: { Authorization: `Basic ${auth}` } };
const res = await fetch(url, init).catch(() => fetch(url, init));

if (!res.ok) {
throw new Error(
throw new HttpError(
`Failed to fetch from ${url}: ${res.status} ${res.statusText}`,
res.status,
);
}

Expand Down
75 changes: 55 additions & 20 deletions src/tools/automate-utils/resolve-hashed-build-id.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HttpError } from "../../lib/error.js";
import { SessionType } from "../../lib/constants.js";
import { getBrowserStackAuth } from "../../lib/get-auth.js";
import { BrowserStackConfig } from "../../lib/types.js";
Expand All @@ -7,14 +8,12 @@ import { extractTestIds } from "../rca-agent-utils/get-failed-test-id.js";
import { TestRun } from "../rca-agent-utils/types.js";
import { getRegionalHost, sessionApiHost } from "../../lib/regional-host.js";

// Observability (Test Reporting & Analytics) build ids are usually UUIDs but
// some are 40-char hex, the same shape as Automate / App Automate "hashed ids".
// The two are never interchangeable, and the observability build API does not
// expose the hashed id. The deterministic bridge is any BrowserStack session that belongs to the
// build: the session detail endpoint reports its parent `build_hashed_id`.
// Automate build ids are hex; TRA build ids are base-36 (older ones UUIDs). A TRA
// id can be all hex by chance, so hex only means "probably Automate".
const OBSERVABILITY_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HASHED_BUILD_ID_RE = /^[a-f0-9]{40}$/i;
const TRA_BUILD_ID_RE = /^[a-z0-9]{40}$/i;

// Only the first session id is needed; most builds surface one on page one.
const MAX_TEST_RUN_PAGES = 5;
Expand All @@ -27,6 +26,29 @@ export function isHashedBuildId(id: string): boolean {
return HASHED_BUILD_ID_RE.test(id.trim());
}

export function isTraBuildId(id: string): boolean {
const trimmed = id.trim();
if (OBSERVABILITY_UUID_RE.test(trimmed)) return true;
return TRA_BUILD_ID_RE.test(trimmed) && !HASHED_BUILD_ID_RE.test(trimmed);
}

export function otherSessionType(sessionType: SessionType): SessionType {
return sessionType === SessionType.Automate
? SessionType.AppAutomate
: SessionType.Automate;
}

export class NoSessionsError extends Error {
constructor(buildId: string) {
super(
`TRA build "${buildId}" has no BrowserStack sessions: its results were uploaded ` +
"(e.g. a JUnit report), so it has no Automate sessions, logs or video. " +
"Use listTestIds and fetchRCA for this build instead.",
);
this.name = "NoSessionsError";
}
}

export function sessionDetailsUrl(
sessionType: SessionType,
sessionId: string,
Expand All @@ -45,10 +67,26 @@ export function sessionDetailsUrl(
}
}

export async function detectSession(
sessionId: string,
preferred: SessionType,
config: BrowserStackConfig,
): Promise<{ sessionType: SessionType; hashedBuildId: string } | undefined> {
for (const candidate of [preferred, otherSessionType(preferred)]) {
const hashedBuildId = await resolveBuildIdFromSession(
sessionId,
candidate,
config,
);
if (hashedBuildId) return { sessionType: candidate, hashedBuildId };
}
return undefined;
}

/**
* Resolve the hashed build id that a session belongs to via the Automate /
* App Automate session detail endpoint. Returns undefined when the session
* cannot be fetched or does not report a build.
* App Automate session detail endpoint. Returns undefined when the product does
* not know the session (404) or it reports no build; other failures throw.
*/
export async function resolveBuildIdFromSession(
sessionId: string,
Expand All @@ -67,11 +105,12 @@ export async function resolveBuildIdFromSession(
raise_error: false,
});

if (response.status === 404) return undefined;
if (!response.ok) {
logger.warn(
`Could not resolve build id for ${sessionType} session ${sessionId}: ${response.status}`,
throw new HttpError(
`Failed to read ${sessionType} session "${sessionId}": ${response.status} ${response.statusText}`.trim(),
response.status,
);
return undefined;
}

const session = (response.data as any)?.automation_session;
Expand Down Expand Up @@ -107,9 +146,10 @@ export async function findSessionIdForObservabilityBuild(
});

if (!response.ok) {
throw new Error(
throw new HttpError(
`Failed to fetch test runs for observability build "${observabilityBuildId}": ` +
`${response.status} ${response.statusText}`,
response.status,
);
}

Expand Down Expand Up @@ -144,7 +184,7 @@ export interface ResolvedHashedBuildId {
* build id in two deterministic API calls: pick any session of the build from
* its test runs, then read `build_hashed_id` from that session's details.
*
* When `sessionType` is omitted, Automate is tried first, then App Automate.
* The given `sessionType` is tried first (Automate when omitted), then the other.
*/
export async function resolveHashedBuildId(
observabilityBuildId: string,
Expand All @@ -155,16 +195,11 @@ export async function resolveHashedBuildId(

const sessionId = await findSessionIdForObservabilityBuild(buildId, config);
if (!sessionId) {
throw new Error(
`No BrowserStack sessions found for observability build "${buildId}". ` +
"Only builds that ran on Automate or App Automate have sessions to list; " +
"uploaded-report builds (e.g. JUnit) do not.",
);
throw new NoSessionsError(buildId);
}

const candidates: SessionType[] = sessionType
? [sessionType]
: [SessionType.Automate, SessionType.AppAutomate];
const first = sessionType ?? SessionType.Automate;
const candidates: SessionType[] = [first, otherSessionType(first)];

for (const candidate of candidates) {
const hashedBuildId = await resolveBuildIdFromSession(
Expand Down
159 changes: 159 additions & 0 deletions src/tools/automate-utils/resolve-tra-build-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { SessionType } from "../../lib/constants.js";
import { getBrowserStackAuth } from "../../lib/get-auth.js";
import { BrowserStackConfig } from "../../lib/types.js";
import { apiClient } from "../../lib/apiClient.js";
import { HttpError, isNotFoundError } from "../../lib/error.js";
import { fetchLatestBuildId } from "../rca-agent-utils/tra-builds.js";
import { sessionsListUrl } from "./list-session-ids.js";
import {
resolveHashedBuildId,
sessionDetailsUrl,
} from "./resolve-hashed-build-id.js";

export interface ResolvedTraBuild {
traBuildId: string;
hashedBuildId: string;
sessionType: SessionType;
projectName: string;
buildName: string;
}

export class TraBuildNotFoundError extends Error {
readonly unknownToAutomate: boolean;

constructor(message: string, unknownToAutomate = false) {
super(message);
this.unknownToAutomate = unknownToAutomate;
this.name = "TraBuildNotFoundError";
}
}

async function readAutomateBuild(
hashedBuildId: string,
sessionType: SessionType,
config: BrowserStackConfig,
): Promise<{ projectName?: string; buildName?: string } | undefined> {
const headers = {
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from(getBrowserStackAuth(config)).toString("base64")}`,
};
const response = await apiClient.get({
url: sessionsListUrl(sessionType, hashedBuildId),
headers,
params: { limit: 1 },
raise_error: false,
});
if (response.status === 404) return undefined;
if (!response.ok) {
throw new HttpError(
`Failed to read ${sessionType} build "${hashedBuildId}": ${response.status} ${response.statusText}`.trim(),
response.status,
);
}
const items = Array.isArray(response.data) ? response.data : [];
let session = items[0]?.automation_session;
if (!session) return {};

if ((!session.project_name || !session.build_name) && session.hashed_id) {
const detail = await apiClient.get({
url: sessionDetailsUrl(sessionType, session.hashed_id),
headers,
raise_error: false,
});
if (!detail.ok && detail.status !== 404) {
throw new HttpError(
`Failed to read ${sessionType} session "${session.hashed_id}": ${detail.status} ${detail.statusText}`.trim(),
detail.status,
);
}
if (detail.ok) session = detail.data?.automation_session ?? session;
}
return {
projectName: session.project_name ?? undefined,
buildName: session.build_name ?? undefined,
};
}

export async function resolveTraBuildIdFromHashed(
hashedBuildId: string,
config: BrowserStackConfig,
preferred: SessionType = SessionType.Automate,
): Promise<ResolvedTraBuild> {
const id = hashedBuildId.trim();
const order = [
preferred,
preferred === SessionType.Automate
? SessionType.AppAutomate
: SessionType.Automate,
];

for (const sessionType of order) {
const build = await readAutomateBuild(id, sessionType, config);
if (!build) continue;
if (!build.projectName || !build.buildName) {
throw new TraBuildNotFoundError(
`${sessionType} build "${id}" does not report a project and build name, ` +
"so its TRA build cannot be found. Use listSessions with this id for its sessions.",
);
}

const traBuildId = await fetchLatestBuildId(
build.projectName,
build.buildName,
config,
);
if (!traBuildId) {
throw new TraBuildNotFoundError(
`${sessionType} build "${id}" (project "${build.projectName}", build ` +
`"${build.buildName}") has no TRA build: only runs through the BrowserStack SDK ` +
"report tests to TRA. Use listSessions and getFailureLogs with this id instead.",
);
}

const confirmed = await resolveHashedBuildId(
traBuildId,
config,
sessionType,
).catch((error) => {
if (error instanceof HttpError && !isNotFoundError(error)) throw error;
return undefined;
});
if (confirmed?.hashedBuildId !== id) {
throw new TraBuildNotFoundError(
`${sessionType} build "${id}" is not the latest TRA build named ` +
`"${build.buildName}" in project "${build.projectName}". Call listBuildId with ` +
"that project, build name and the build number from the dashboard.",
);
}

return {
traBuildId,
hashedBuildId: id,
sessionType: confirmed.sessionType,
projectName: build.projectName,
buildName: build.buildName,
};
}

throw new TraBuildNotFoundError(
`No Automate or App Automate build has id "${id}". Use the TRA build id from listBuildId.`,
true,
);
}

export async function mapHashedToTra(
hashedBuildId: string,
original: unknown,
config: BrowserStackConfig,
): Promise<ResolvedTraBuild> {
try {
return await resolveTraBuildIdFromHashed(hashedBuildId, config);
} catch (error) {
if (!(error instanceof TraBuildNotFoundError)) throw error;
if (!error.unknownToAutomate) throw error;
throw new Error(
`${original instanceof Error ? original.message : String(original)}. ` +
"No TRA build has this id; get one with listBuildId.",
);
}
}
Loading
Loading