@browserstack/mcp-server 1.3.0 → 1.3.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +4 -1
- package/dist/config.js +23 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/tools/build-insights.js +10 -3
- package/dist/tools/rca-agent-utils/constants.d.ts +2 -0
- package/dist/tools/rca-agent-utils/constants.js +8 -0
- package/dist/tools/rca-agent-utils/get-failed-test-id.d.ts +3 -2
- package/dist/tools/rca-agent-utils/get-failed-test-id.js +94 -25
- package/dist/tools/rca-agent-utils/types.d.ts +10 -0
- package/dist/tools/rca-agent.d.ts +1 -0
- package/dist/tools/rca-agent.js +3 -3
- package/dist/tools/tfa-rca-collaboration.d.ts +15 -0
- package/dist/tools/tfa-rca-collaboration.js +162 -0
- package/dist/tools/tfa-rca-utils/build-failure-themes.d.ts +61 -0
- package/dist/tools/tfa-rca-utils/build-failure-themes.js +133 -0
- package/dist/tools/tfa-rca-utils/constants.d.ts +67 -0
- package/dist/tools/tfa-rca-utils/constants.js +103 -0
- package/dist/tools/tfa-rca-utils/submit-turn.d.ts +29 -0
- package/dist/tools/tfa-rca-utils/submit-turn.js +188 -0
- package/dist/tools/tfa-rca-utils/trigger-report.d.ts +22 -0
- package/dist/tools/tfa-rca-utils/trigger-report.js +61 -0
- package/dist/tools/tfa-rca-utils/turn-result.d.ts +14 -0
- package/dist/tools/tfa-rca-utils/turn-result.js +131 -0
- package/dist/tools/tfa-rca-utils/types.d.ts +61 -0
- package/dist/tools/tfa-rca-utils/types.js +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { apiClient } from "../../lib/apiClient.js";
|
|
2
|
+
import { AI_FAILURES_FLAT_PATH, AI_FAILURES_PATH, BUILD_THEMES_FAILURE_STATUSES, BUILD_THEMES_POLL_INTERVAL_MS, BUILD_THEMES_POLL_MAX_WAIT_MS, BUILD_THEMES_SUCCESS_STATUS, getO11yBaseUrl, } from "./constants.js";
|
|
3
|
+
import { buildAuthHeader } from "./turn-result.js";
|
|
4
|
+
export class BuildFailureThemesError extends Error {
|
|
5
|
+
}
|
|
6
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
function authHeaders(config) {
|
|
8
|
+
return {
|
|
9
|
+
"Content-Type": "application/json",
|
|
10
|
+
Authorization: buildAuthHeader(config),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
// Normalize a response body to a plain object — a malformed 200 is treated as "no data".
|
|
14
|
+
function asObject(data) {
|
|
15
|
+
return typeof data === "object" && data !== null && !Array.isArray(data)
|
|
16
|
+
? data
|
|
17
|
+
: {};
|
|
18
|
+
}
|
|
19
|
+
function failuresUrl(buildUuid) {
|
|
20
|
+
return (getO11yBaseUrl() +
|
|
21
|
+
AI_FAILURES_PATH.replace("{buildUuid}", encodeURIComponent(buildUuid)));
|
|
22
|
+
}
|
|
23
|
+
async function fetchFailuresOnce(buildUuid, config) {
|
|
24
|
+
return apiClient.get({
|
|
25
|
+
url: failuresUrl(buildUuid),
|
|
26
|
+
headers: authHeaders(config),
|
|
27
|
+
raise_error: false,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// Same URL as the GET read, different verb — kicks off build-level theme computation.
|
|
31
|
+
async function triggerFailuresOnce(buildUuid, config) {
|
|
32
|
+
return apiClient.post({
|
|
33
|
+
url: failuresUrl(buildUuid),
|
|
34
|
+
headers: authHeaders(config),
|
|
35
|
+
body: {},
|
|
36
|
+
raise_error: false,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// Fetch a build's server-computed failure-theme clusters, triggering computation
|
|
40
|
+
// if nothing has run yet. Triggers at most once per call, then polls until
|
|
41
|
+
// SUCCESS or BUILD_THEMES_POLL_MAX_WAIT_MS is spent — never blocks past that.
|
|
42
|
+
export async function fetchBuildFailureThemes(buildUuid, config) {
|
|
43
|
+
const startTime = Date.now();
|
|
44
|
+
let lastStatus;
|
|
45
|
+
let triggered = false;
|
|
46
|
+
/** Attempts the one-time trigger POST; returns whether it succeeded. */
|
|
47
|
+
const triggerOnce = async () => {
|
|
48
|
+
triggered = true;
|
|
49
|
+
const triggerResponse = await triggerFailuresOnce(buildUuid, config);
|
|
50
|
+
return triggerResponse.ok;
|
|
51
|
+
};
|
|
52
|
+
while (true) {
|
|
53
|
+
const response = await fetchFailuresOnce(buildUuid, config);
|
|
54
|
+
if (response.ok) {
|
|
55
|
+
const data = asObject(response.data);
|
|
56
|
+
const status = data.buildThemeWorkflow?.status;
|
|
57
|
+
if (status === BUILD_THEMES_SUCCESS_STATUS) {
|
|
58
|
+
return {
|
|
59
|
+
ready: true,
|
|
60
|
+
status,
|
|
61
|
+
buildId: data.buildId,
|
|
62
|
+
buildThemes: data.buildThemes ?? [],
|
|
63
|
+
buildWorkflows: data.buildWorkflows ?? [],
|
|
64
|
+
stats: data.stats,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (status && BUILD_THEMES_FAILURE_STATUSES.includes(status)) {
|
|
68
|
+
if (triggered) {
|
|
69
|
+
// Already retried once and it failed again — a real failure, not
|
|
70
|
+
// async lag. Nothing left to do but report it.
|
|
71
|
+
return { ready: false, status };
|
|
72
|
+
}
|
|
73
|
+
if (!(await triggerOnce())) {
|
|
74
|
+
return { ready: false, status: "trigger-unavailable" };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// status undefined (never computed) — trigger once. An in-progress
|
|
79
|
+
// value (PENDING/PROCESSING) just keeps polling either way.
|
|
80
|
+
if (!status && !triggered && !(await triggerOnce())) {
|
|
81
|
+
return { ready: false, status: "trigger-unavailable" };
|
|
82
|
+
}
|
|
83
|
+
lastStatus = status ?? lastStatus;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (response.status === 404) {
|
|
87
|
+
if (!triggered && !(await triggerOnce())) {
|
|
88
|
+
return { ready: false, status: "trigger-unavailable" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
throw new BuildFailureThemesError(`failed to fetch build failure themes (status ${response.status})`);
|
|
93
|
+
}
|
|
94
|
+
if (Date.now() - startTime >= BUILD_THEMES_POLL_MAX_WAIT_MS) {
|
|
95
|
+
return { ready: false, status: lastStatus ?? "PENDING" };
|
|
96
|
+
}
|
|
97
|
+
await delay(BUILD_THEMES_POLL_INTERVAL_MS);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Paginated test-run membership for one failure theme or workflow. */
|
|
101
|
+
export async function fetchTestsInFailureTheme(args, config) {
|
|
102
|
+
const params = {
|
|
103
|
+
limit: args.limit ?? 50,
|
|
104
|
+
};
|
|
105
|
+
if (args.themeId !== undefined)
|
|
106
|
+
params.buildFailureThemeId = args.themeId;
|
|
107
|
+
if (args.workflowId !== undefined)
|
|
108
|
+
params.buildFailureWorkflowId = args.workflowId;
|
|
109
|
+
if (args.cursor)
|
|
110
|
+
params.searchAfter = args.cursor;
|
|
111
|
+
const url = getO11yBaseUrl() +
|
|
112
|
+
AI_FAILURES_FLAT_PATH.replace("{buildUuid}", encodeURIComponent(args.buildUuid));
|
|
113
|
+
const response = await apiClient.get({
|
|
114
|
+
url,
|
|
115
|
+
headers: authHeaders(config),
|
|
116
|
+
params,
|
|
117
|
+
raise_error: false,
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new BuildFailureThemesError(`failed to list tests in failure theme (status ${response.status})`);
|
|
121
|
+
}
|
|
122
|
+
const data = asObject(response.data);
|
|
123
|
+
const testRuns = data.testRuns ?? [];
|
|
124
|
+
return {
|
|
125
|
+
tests: testRuns.map((t) => ({
|
|
126
|
+
testRunId: t?.details?.id ?? t?.id,
|
|
127
|
+
title: t?.title,
|
|
128
|
+
status: t?.details?.status,
|
|
129
|
+
raw: t,
|
|
130
|
+
})),
|
|
131
|
+
nextCursor: data.nextCursor ?? data.searchAfter ?? data.next_search_after,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare function getO11yBaseUrl(): string;
|
|
3
|
+
export declare function getO11yUiBaseUrl(): string;
|
|
4
|
+
export declare const O11Y_UI_BUILD_PATH = "/builds/{buildUuid}?tab=ai_report&subTab=aitfa";
|
|
5
|
+
/** Human-facing TRA UI link for one build's full report. */
|
|
6
|
+
export declare function getO11yUiBuildUrl(buildUuid: string): string;
|
|
7
|
+
export declare function getRcaViewGuidance(): string;
|
|
8
|
+
/** Trigger (or read, when already complete) a build's Release Readiness report. */
|
|
9
|
+
export declare const RELEASE_READINESS_TRIGGER_PATH = "/ext/v1/ai/builds/{buildUuid}/releaseReadiness/trigger";
|
|
10
|
+
/** Read a build's server-computed failure-theme clusters. */
|
|
11
|
+
export declare const AI_FAILURES_PATH = "/ext/v1/ai/failures/{buildUuid}";
|
|
12
|
+
/** Paginated test-run membership for one failure theme / workflow. */
|
|
13
|
+
export declare const AI_FAILURES_FLAT_PATH = "/ext/v1/ai/failures/{buildUuid}/flat";
|
|
14
|
+
export declare const BUILD_THEMES_SUCCESS_STATUS = "SUCCESS";
|
|
15
|
+
export declare const BUILD_THEMES_FAILURE_STATUSES: string[];
|
|
16
|
+
/** Interval between in-call polls of the build-failure-themes readiness. */
|
|
17
|
+
export declare const BUILD_THEMES_POLL_INTERVAL_MS = 3000;
|
|
18
|
+
export declare const BUILD_THEMES_POLL_MAX_WAIT_MS: number;
|
|
19
|
+
export declare const GET_BUILD_FAILURE_THEMES_PARAMS: {
|
|
20
|
+
buildUuid: z.ZodString;
|
|
21
|
+
};
|
|
22
|
+
export declare const LIST_TESTS_IN_FAILURE_THEME_PARAMS: {
|
|
23
|
+
buildUuid: z.ZodString;
|
|
24
|
+
themeId: z.ZodOptional<z.ZodNumber>;
|
|
25
|
+
workflowId: z.ZodOptional<z.ZodNumber>;
|
|
26
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
27
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
28
|
+
};
|
|
29
|
+
/** Submit one collaborative turn for a test run. */
|
|
30
|
+
export declare const RCA_CHAT_SUBMIT_PATH = "/ext/v1/testRuns/{testRunId}/rcaChat";
|
|
31
|
+
/** Poll a submitted turn to completion. */
|
|
32
|
+
export declare const RCA_CHAT_POLL_PATH = "/ext/v1/testRuns/{testRunId}/rcaChat/{turnId}";
|
|
33
|
+
/** Initial wait before the first poll GET. */
|
|
34
|
+
export declare const POLL_INITIAL_DELAY_MS = 2000;
|
|
35
|
+
/** Interval between poll GETs. */
|
|
36
|
+
export declare const POLL_INTERVAL_MS = 3000;
|
|
37
|
+
/** Wall-clock cap for the in-call poll; exceeding it yields a soft PENDING. */
|
|
38
|
+
export declare const POLL_MAX_WAIT_MS: number;
|
|
39
|
+
/** Max length of the digest message, matching o11y's request `@Size`. */
|
|
40
|
+
export declare const MESSAGE_MAX_LENGTH = 5000;
|
|
41
|
+
/** Max chars of `root_cause` surfaced in the RESOLVED glimpse. */
|
|
42
|
+
export declare const RCA_GLIMPSE_ROOT_CAUSE_MAX = 220;
|
|
43
|
+
export declare const TFA_RCA_TURN_PARAMS: {
|
|
44
|
+
testRunId: z.ZodString;
|
|
45
|
+
message: z.ZodString;
|
|
46
|
+
threadId: z.ZodOptional<z.ZodString>;
|
|
47
|
+
turnId: z.ZodOptional<z.ZodString>;
|
|
48
|
+
prDetails: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
49
|
+
repo: z.ZodString;
|
|
50
|
+
number: z.ZodNumber;
|
|
51
|
+
title: z.ZodString;
|
|
52
|
+
author: z.ZodString;
|
|
53
|
+
link: z.ZodString;
|
|
54
|
+
tag: z.ZodEnum<{
|
|
55
|
+
latent: "latent";
|
|
56
|
+
regression: "regression";
|
|
57
|
+
}>;
|
|
58
|
+
}, z.core.$strip>>>;
|
|
59
|
+
};
|
|
60
|
+
export declare const GET_TFA_TURN_RESULT_PARAMS: {
|
|
61
|
+
testRunId: z.ZodString;
|
|
62
|
+
turnId: z.ZodString;
|
|
63
|
+
};
|
|
64
|
+
export declare const TRIGGER_RCA_REPORT_PARAMS: {
|
|
65
|
+
buildUuid: z.ZodString;
|
|
66
|
+
force: z.ZodOptional<z.ZodBoolean>;
|
|
67
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import appConfig from "../../config.js";
|
|
3
|
+
export function getO11yBaseUrl() {
|
|
4
|
+
return appConfig.O11Y_TFA_RCA_BASE_URL;
|
|
5
|
+
}
|
|
6
|
+
export function getO11yUiBaseUrl() {
|
|
7
|
+
return appConfig.BROWSERSTACK_O11Y_UI_BASE_URL;
|
|
8
|
+
}
|
|
9
|
+
// TRA UI deep-link for a build's AI-TFA sub-tab.
|
|
10
|
+
export const O11Y_UI_BUILD_PATH = "/builds/{buildUuid}?tab=ai_report&subTab=aitfa";
|
|
11
|
+
/** Human-facing TRA UI link for one build's full report. */
|
|
12
|
+
export function getO11yUiBuildUrl(buildUuid) {
|
|
13
|
+
return (getO11yUiBaseUrl() +
|
|
14
|
+
O11Y_UI_BUILD_PATH.replace("{buildUuid}", encodeURIComponent(buildUuid)));
|
|
15
|
+
}
|
|
16
|
+
// Generic TRA UI pointer for RESOLVED turns where only a testRunId is known.
|
|
17
|
+
export function getRcaViewGuidance() {
|
|
18
|
+
return `${getO11yUiBaseUrl()} — open the build's AI report (tab=ai_report, subTab=aitfa) to view the full RCA`;
|
|
19
|
+
}
|
|
20
|
+
/** Trigger (or read, when already complete) a build's Release Readiness report. */
|
|
21
|
+
export const RELEASE_READINESS_TRIGGER_PATH = "/ext/v1/ai/builds/{buildUuid}/releaseReadiness/trigger";
|
|
22
|
+
/** Read a build's server-computed failure-theme clusters. */
|
|
23
|
+
export const AI_FAILURES_PATH = "/ext/v1/ai/failures/{buildUuid}";
|
|
24
|
+
/** Paginated test-run membership for one failure theme / workflow. */
|
|
25
|
+
export const AI_FAILURES_FLAT_PATH = "/ext/v1/ai/failures/{buildUuid}/flat";
|
|
26
|
+
export const BUILD_THEMES_SUCCESS_STATUS = "SUCCESS";
|
|
27
|
+
export const BUILD_THEMES_FAILURE_STATUSES = ["FAILED", "ERROR"];
|
|
28
|
+
/** Interval between in-call polls of the build-failure-themes readiness. */
|
|
29
|
+
export const BUILD_THEMES_POLL_INTERVAL_MS = 3000;
|
|
30
|
+
export const BUILD_THEMES_POLL_MAX_WAIT_MS = 90 * 1000;
|
|
31
|
+
export const GET_BUILD_FAILURE_THEMES_PARAMS = {
|
|
32
|
+
buildUuid: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe("Automate build UUID to fetch failure themes for."),
|
|
35
|
+
};
|
|
36
|
+
export const LIST_TESTS_IN_FAILURE_THEME_PARAMS = {
|
|
37
|
+
buildUuid: z
|
|
38
|
+
.string()
|
|
39
|
+
.describe("Automate build UUID the theme/workflow belongs to."),
|
|
40
|
+
themeId: z.number().optional().describe("buildFailureThemeId to filter by."),
|
|
41
|
+
workflowId: z
|
|
42
|
+
.number()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("buildFailureWorkflowId to filter by."),
|
|
45
|
+
limit: z.number().optional().describe("Max tests to return, default 50."),
|
|
46
|
+
cursor: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("searchAfter cursor from a prior call."),
|
|
50
|
+
};
|
|
51
|
+
/** Submit one collaborative turn for a test run. */
|
|
52
|
+
export const RCA_CHAT_SUBMIT_PATH = "/ext/v1/testRuns/{testRunId}/rcaChat";
|
|
53
|
+
/** Poll a submitted turn to completion. */
|
|
54
|
+
export const RCA_CHAT_POLL_PATH = "/ext/v1/testRuns/{testRunId}/rcaChat/{turnId}";
|
|
55
|
+
/** Initial wait before the first poll GET. */
|
|
56
|
+
export const POLL_INITIAL_DELAY_MS = 2000;
|
|
57
|
+
/** Interval between poll GETs. */
|
|
58
|
+
export const POLL_INTERVAL_MS = 3000;
|
|
59
|
+
/** Wall-clock cap for the in-call poll; exceeding it yields a soft PENDING. */
|
|
60
|
+
export const POLL_MAX_WAIT_MS = 90 * 1000;
|
|
61
|
+
/** Max length of the digest message, matching o11y's request `@Size`. */
|
|
62
|
+
export const MESSAGE_MAX_LENGTH = 5000;
|
|
63
|
+
/** Max chars of `root_cause` surfaced in the RESOLVED glimpse. */
|
|
64
|
+
export const RCA_GLIMPSE_ROOT_CAUSE_MAX = 220;
|
|
65
|
+
export const TFA_RCA_TURN_PARAMS = {
|
|
66
|
+
testRunId: z.string().describe("Test run id to run RCA collaboration on."),
|
|
67
|
+
message: z
|
|
68
|
+
.string()
|
|
69
|
+
.max(MESSAGE_MAX_LENGTH)
|
|
70
|
+
.describe("Digested analysis to send this turn; no raw logs."),
|
|
71
|
+
threadId: z
|
|
72
|
+
.string()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Thread id from prior turn; omit on first turn."),
|
|
75
|
+
turnId: z
|
|
76
|
+
.string()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe("Turn id to resume a pending poll; usually omit."),
|
|
79
|
+
prDetails: z
|
|
80
|
+
.array(z.object({
|
|
81
|
+
repo: z.string().describe("owner/name, e.g. browserstack/ai-sdk."),
|
|
82
|
+
number: z.number().describe("PR number (unique only within repo)."),
|
|
83
|
+
title: z.string().describe("PR title."),
|
|
84
|
+
author: z.string().describe("PR author."),
|
|
85
|
+
link: z
|
|
86
|
+
.string()
|
|
87
|
+
.describe("Canonical URL: https://github.com/<repo>/pull/<number>."),
|
|
88
|
+
tag: z.enum(["latent", "regression"]).describe("latent | regression."),
|
|
89
|
+
}))
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("Suspect PRs; identity is repo+number. Each needs repo, number, title, author, link, tag."),
|
|
92
|
+
};
|
|
93
|
+
export const GET_TFA_TURN_RESULT_PARAMS = {
|
|
94
|
+
testRunId: z.string().describe("Test run id the turn was submitted on."),
|
|
95
|
+
turnId: z.string().describe("Turn id returned by tfaRcaTurn."),
|
|
96
|
+
};
|
|
97
|
+
export const TRIGGER_RCA_REPORT_PARAMS = {
|
|
98
|
+
buildUuid: z.string().describe("Automate build UUID to analyze."),
|
|
99
|
+
force: z
|
|
100
|
+
.boolean()
|
|
101
|
+
.optional()
|
|
102
|
+
.describe("Re-run even if a completed report exists."),
|
|
103
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { BrowserStackConfig } from "../../lib/types.js";
|
|
2
|
+
import { TfaRcaTurnResult } from "./types.js";
|
|
3
|
+
import { TfaRcaTurnError } from "./turn-result.js";
|
|
4
|
+
export { TfaRcaTurnError };
|
|
5
|
+
interface TurnContext {
|
|
6
|
+
sendNotification?: (notification: any) => Promise<void>;
|
|
7
|
+
_meta?: {
|
|
8
|
+
progressToken?: string | number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export type PrTag = "latent" | "regression";
|
|
12
|
+
export interface PrDetail {
|
|
13
|
+
repo: string;
|
|
14
|
+
number: number;
|
|
15
|
+
title: string;
|
|
16
|
+
author: string;
|
|
17
|
+
link: string;
|
|
18
|
+
tag: PrTag;
|
|
19
|
+
}
|
|
20
|
+
export interface TfaRcaTurnArgs {
|
|
21
|
+
testRunId: string;
|
|
22
|
+
message: string;
|
|
23
|
+
/** Suspect PRs as context. Optional, but each entry must satisfy PrDetail. */
|
|
24
|
+
prDetails?: PrDetail[];
|
|
25
|
+
threadId?: string;
|
|
26
|
+
/** Resume polling an already-submitted turn without re-submitting. */
|
|
27
|
+
turnId?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function submitTfaRcaTurn(args: TfaRcaTurnArgs, config: BrowserStackConfig, context?: TurnContext): Promise<TfaRcaTurnResult>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { apiClient } from "../../lib/apiClient.js";
|
|
2
|
+
import { getO11yBaseUrl, MESSAGE_MAX_LENGTH, POLL_INITIAL_DELAY_MS, POLL_INTERVAL_MS, POLL_MAX_WAIT_MS, RCA_CHAT_SUBMIT_PATH, } from "./constants.js";
|
|
3
|
+
import { PENDING_STATUS } from "./types.js";
|
|
4
|
+
import { buildAuthHeader, buildPollUrl, readStructuredTurn, TfaRcaTurnError, toTrimmedResult, } from "./turn-result.js";
|
|
5
|
+
// Re-exported so existing importers keep a single entry point for the error type.
|
|
6
|
+
export { TfaRcaTurnError };
|
|
7
|
+
const PR_TAGS = ["latent", "regression"];
|
|
8
|
+
// Any PR entry present MUST carry every field; a partial one is rejected.
|
|
9
|
+
function validatePrDetails(prDetails) {
|
|
10
|
+
prDetails.forEach((pr, i) => {
|
|
11
|
+
if (!pr || typeof pr !== "object") {
|
|
12
|
+
throw new TfaRcaTurnError(`prDetails[${i}] is not an object`);
|
|
13
|
+
}
|
|
14
|
+
const missing = [];
|
|
15
|
+
if (!pr.repo)
|
|
16
|
+
missing.push("repo");
|
|
17
|
+
if (pr.number === undefined || pr.number === null)
|
|
18
|
+
missing.push("number");
|
|
19
|
+
if (!pr.title)
|
|
20
|
+
missing.push("title");
|
|
21
|
+
if (!pr.author)
|
|
22
|
+
missing.push("author");
|
|
23
|
+
if (!pr.link)
|
|
24
|
+
missing.push("link");
|
|
25
|
+
if (!pr.tag)
|
|
26
|
+
missing.push("tag");
|
|
27
|
+
if (missing.length > 0) {
|
|
28
|
+
throw new TfaRcaTurnError(`prDetails[${i}] missing required field(s): ${missing.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
if (!PR_TAGS.includes(pr.tag)) {
|
|
31
|
+
throw new TfaRcaTurnError(`prDetails[${i}].tag must be one of: ${PR_TAGS.join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
// link must be the canonical PR URL for repo+number.
|
|
34
|
+
if (!pr.link.includes(`/${pr.repo}/pull/${pr.number}`)) {
|
|
35
|
+
throw new TfaRcaTurnError(`prDetails[${i}].link must be the canonical URL for ${pr.repo}#${pr.number} ` +
|
|
36
|
+
`(https://github.com/${pr.repo}/pull/${pr.number})`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
// Structured PR payload for the request's `clientContext` digest channel
|
|
41
|
+
// (size-capped at 100_000 bytes) — kept out of `message`, which is capped at 5000.
|
|
42
|
+
function buildPrClientContext(prDetails) {
|
|
43
|
+
if (!prDetails || prDetails.length === 0)
|
|
44
|
+
return undefined;
|
|
45
|
+
return { pr_details: prDetails };
|
|
46
|
+
}
|
|
47
|
+
// Append a compact PR marker (`repo#number [tag]` refs) to the message; the
|
|
48
|
+
// full objects travel in `clientContext`. Downgraded/dropped if it would push
|
|
49
|
+
// the message past MESSAGE_MAX_LENGTH — nothing is lost since clientContext has it.
|
|
50
|
+
function composeMessageWithPrMarker(message, prDetails) {
|
|
51
|
+
const fits = (suffix) => message.length + suffix.length <= MESSAGE_MAX_LENGTH;
|
|
52
|
+
if (!prDetails || prDetails.length === 0) {
|
|
53
|
+
const noneFull = "\n\nPR_DETAILS: none provided — no client-supplied PR this turn. Do not" +
|
|
54
|
+
" infer a repo, URL or author from prose; leave related_prs empty and state" +
|
|
55
|
+
" what was searched.";
|
|
56
|
+
if (fits(noneFull))
|
|
57
|
+
return `${message}${noneFull}`;
|
|
58
|
+
const none = "\n\nPR_DETAILS: none provided — do not infer PRs from prose.";
|
|
59
|
+
return fits(none) ? `${message}${none}` : message;
|
|
60
|
+
}
|
|
61
|
+
const refs = prDetails
|
|
62
|
+
.map((pr) => `${pr.repo}#${pr.number} [${pr.tag}]`)
|
|
63
|
+
.join(", ");
|
|
64
|
+
const legend = " — regression = the PR introduced the fault; latent = the fault predates the" +
|
|
65
|
+
" PR and the PR exposed it. Carry the distinction into the RCA.";
|
|
66
|
+
const full = `\n\nPR_DETAILS: ${prDetails.length} in clientContext (${refs})${legend}`;
|
|
67
|
+
if (fits(full))
|
|
68
|
+
return `${message}${full}`;
|
|
69
|
+
const noLegend = `\n\nPR_DETAILS: ${prDetails.length} in clientContext (${refs})`;
|
|
70
|
+
if (fits(noLegend))
|
|
71
|
+
return `${message}${noLegend}`;
|
|
72
|
+
const terse = `\n\nPR_DETAILS: ${prDetails.length} in clientContext`;
|
|
73
|
+
return fits(terse) ? `${message}${terse}` : message;
|
|
74
|
+
}
|
|
75
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
async function notify(context, message, progress) {
|
|
77
|
+
if (!context?.sendNotification)
|
|
78
|
+
return;
|
|
79
|
+
await context.sendNotification({
|
|
80
|
+
method: "notifications/progress",
|
|
81
|
+
params: {
|
|
82
|
+
progressToken: context._meta?.progressToken?.toString() ?? "tfa-rca-turn",
|
|
83
|
+
message,
|
|
84
|
+
progress,
|
|
85
|
+
total: 100,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Map a submit (POST) non-2xx into a clean, group-scope-safe domain error. */
|
|
90
|
+
function mapSubmitError(status) {
|
|
91
|
+
if (status === 403) {
|
|
92
|
+
return new TfaRcaTurnError("AI consent not enabled for this group");
|
|
93
|
+
}
|
|
94
|
+
if (status === 404) {
|
|
95
|
+
return new TfaRcaTurnError("test run not found for your group");
|
|
96
|
+
}
|
|
97
|
+
return new TfaRcaTurnError(`failed to submit RCA turn (status ${status})`);
|
|
98
|
+
}
|
|
99
|
+
// Submit one collaborative RCA turn to the o11y `rcaChat` proxy and poll to
|
|
100
|
+
// completion, returning a trimmed structured result.
|
|
101
|
+
export async function submitTfaRcaTurn(args, config, context) {
|
|
102
|
+
const authHeader = buildAuthHeader(config);
|
|
103
|
+
const headers = {
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
Authorization: authHeader,
|
|
106
|
+
};
|
|
107
|
+
const baseUrl = getO11yBaseUrl();
|
|
108
|
+
let turnId = args.turnId;
|
|
109
|
+
let threadId = args.threadId;
|
|
110
|
+
// Submit only when we are not resuming an existing turn.
|
|
111
|
+
if (!turnId) {
|
|
112
|
+
const submitUrl = baseUrl + RCA_CHAT_SUBMIT_PATH.replace("{testRunId}", args.testRunId);
|
|
113
|
+
await notify(context, "Submitting RCA turn to TFA agent...", 5);
|
|
114
|
+
// Validate the PR contract before sending; a partial PR object is rejected
|
|
115
|
+
// rather than forwarded as misleading context.
|
|
116
|
+
if (args.prDetails) {
|
|
117
|
+
validatePrDetails(args.prDetails);
|
|
118
|
+
}
|
|
119
|
+
// PR context travels as structured data in the `clientContext` digest lane;
|
|
120
|
+
// the message carries only a bounded marker naming the PRs.
|
|
121
|
+
const composedMessage = composeMessageWithPrMarker(args.message, args.prDetails);
|
|
122
|
+
const prClientContext = buildPrClientContext(args.prDetails);
|
|
123
|
+
const body = {
|
|
124
|
+
message: composedMessage,
|
|
125
|
+
};
|
|
126
|
+
if (prClientContext) {
|
|
127
|
+
body.clientContext = prClientContext;
|
|
128
|
+
}
|
|
129
|
+
if (args.threadId) {
|
|
130
|
+
body.thread_id = args.threadId;
|
|
131
|
+
}
|
|
132
|
+
const submitResponse = await apiClient.post({
|
|
133
|
+
url: submitUrl,
|
|
134
|
+
headers,
|
|
135
|
+
body,
|
|
136
|
+
raise_error: false,
|
|
137
|
+
});
|
|
138
|
+
if (!submitResponse.ok) {
|
|
139
|
+
throw mapSubmitError(submitResponse.status);
|
|
140
|
+
}
|
|
141
|
+
const data = submitResponse.data ?? {};
|
|
142
|
+
turnId = data.turnId;
|
|
143
|
+
threadId = data.threadId ?? threadId;
|
|
144
|
+
if (!turnId) {
|
|
145
|
+
throw new TfaRcaTurnError("turn expired or not found");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Poll to completion, soft-PENDING on wall-clock cap.
|
|
149
|
+
const pollUrl = buildPollUrl(args.testRunId, turnId);
|
|
150
|
+
await delay(POLL_INITIAL_DELAY_MS);
|
|
151
|
+
const startTime = Date.now();
|
|
152
|
+
while (true) {
|
|
153
|
+
const pollResponse = await apiClient.get({
|
|
154
|
+
url: pollUrl,
|
|
155
|
+
headers,
|
|
156
|
+
raise_error: false,
|
|
157
|
+
});
|
|
158
|
+
if (pollResponse.status === 404) {
|
|
159
|
+
throw new TfaRcaTurnError("turn expired or not found");
|
|
160
|
+
}
|
|
161
|
+
if (pollResponse.ok) {
|
|
162
|
+
const data = pollResponse.data ?? {};
|
|
163
|
+
const status = data.status;
|
|
164
|
+
threadId = data.threadId ?? threadId;
|
|
165
|
+
if (status === "failed") {
|
|
166
|
+
throw new TfaRcaTurnError(data.error || "TFA agent run failed");
|
|
167
|
+
}
|
|
168
|
+
if (status === "completed") {
|
|
169
|
+
const turn = readStructuredTurn(data);
|
|
170
|
+
await notify(context, "TFA agent turn complete.", 100);
|
|
171
|
+
return toTrimmedResult(turn, threadId);
|
|
172
|
+
}
|
|
173
|
+
// status === "working" (or any other in-progress value) → keep polling.
|
|
174
|
+
}
|
|
175
|
+
// Transient non-2xx (other than 404) during polling: classify and continue.
|
|
176
|
+
if (Date.now() - startTime >= POLL_MAX_WAIT_MS) {
|
|
177
|
+
await notify(context, "TFA agent still working; will resume later.", 90);
|
|
178
|
+
// PENDING keeps only what the skill needs to resume polling.
|
|
179
|
+
return {
|
|
180
|
+
status: PENDING_STATUS,
|
|
181
|
+
threadId,
|
|
182
|
+
turnId,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
await notify(context, "Waiting for TFA agent reply...", 50);
|
|
186
|
+
await delay(POLL_INTERVAL_MS);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { BrowserStackConfig } from "../../lib/types.js";
|
|
2
|
+
export interface TriggerRcaReportArgs {
|
|
3
|
+
buildUuid: string;
|
|
4
|
+
/** Re-run even if a completed report already exists. */
|
|
5
|
+
force?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare class TriggerRcaReportError extends Error {
|
|
8
|
+
}
|
|
9
|
+
export interface RcaReportGlimpse {
|
|
10
|
+
state?: string;
|
|
11
|
+
verdict?: string;
|
|
12
|
+
verdictProvisional?: boolean;
|
|
13
|
+
partial?: boolean;
|
|
14
|
+
analyzedCount?: number;
|
|
15
|
+
totalFailedCount?: number;
|
|
16
|
+
totalPrs?: number;
|
|
17
|
+
faultyPrNumbers?: unknown[];
|
|
18
|
+
failureReason?: string;
|
|
19
|
+
/** TRA UI link where the full report lives. */
|
|
20
|
+
viewReport: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function triggerRcaReport(args: TriggerRcaReportArgs, config: BrowserStackConfig): Promise<RcaReportGlimpse>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { apiClient } from "../../lib/apiClient.js";
|
|
2
|
+
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
3
|
+
import { getO11yBaseUrl, getO11yUiBuildUrl, RELEASE_READINESS_TRIGGER_PATH, } from "./constants.js";
|
|
4
|
+
export class TriggerRcaReportError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** Pull a machine error code out of a non-2xx body, wherever it rides. */
|
|
7
|
+
function extractErrorCode(data) {
|
|
8
|
+
const candidate = data?.code ?? data?.error ?? data?.errorCode ?? data?.message;
|
|
9
|
+
return typeof candidate === "string" ? candidate : "";
|
|
10
|
+
}
|
|
11
|
+
/** Map a trigger (POST) non-2xx into a clean, group-scope-safe domain error. */
|
|
12
|
+
function mapTriggerError(status, data) {
|
|
13
|
+
const code = extractErrorCode(data);
|
|
14
|
+
if (code.includes("REPO_NOT_CONFIGURED")) {
|
|
15
|
+
return new TriggerRcaReportError("repository not configured for Release Readiness; connect the repo in Test Observability settings");
|
|
16
|
+
}
|
|
17
|
+
if (code.includes("RELEASE_READINESS_NOT_FOUND")) {
|
|
18
|
+
return new TriggerRcaReportError("no Release Readiness report found for this build");
|
|
19
|
+
}
|
|
20
|
+
if (status === 403) {
|
|
21
|
+
return new TriggerRcaReportError("Release Readiness AI is not enabled for this group (plan or feature flag)");
|
|
22
|
+
}
|
|
23
|
+
if (status === 404) {
|
|
24
|
+
return new TriggerRcaReportError("build not found for your group");
|
|
25
|
+
}
|
|
26
|
+
return new TriggerRcaReportError(`failed to trigger Release Readiness report (status ${status})`);
|
|
27
|
+
}
|
|
28
|
+
export async function triggerRcaReport(args, config) {
|
|
29
|
+
const authString = getBrowserStackAuth(config);
|
|
30
|
+
const headers = {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
Authorization: `Basic ${Buffer.from(authString).toString("base64")}`,
|
|
33
|
+
};
|
|
34
|
+
const url = getO11yBaseUrl() +
|
|
35
|
+
RELEASE_READINESS_TRIGGER_PATH.replace("{buildUuid}", encodeURIComponent(args.buildUuid)) +
|
|
36
|
+
`?force=${args.force === true}`;
|
|
37
|
+
const response = await apiClient.post({
|
|
38
|
+
url,
|
|
39
|
+
headers,
|
|
40
|
+
body: {},
|
|
41
|
+
raise_error: false,
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw mapTriggerError(response.status, response.data);
|
|
45
|
+
}
|
|
46
|
+
const data = response.data ?? {};
|
|
47
|
+
const summary = data.summary ?? {};
|
|
48
|
+
// Trimmed glimpse only — never the raw response, never prs[]/workflows[].
|
|
49
|
+
return {
|
|
50
|
+
state: summary.state ?? data.state,
|
|
51
|
+
verdict: summary.verdict,
|
|
52
|
+
verdictProvisional: summary.verdictProvisional,
|
|
53
|
+
partial: summary.partial,
|
|
54
|
+
analyzedCount: summary.analyzedCount,
|
|
55
|
+
totalFailedCount: summary.totalFailedCount,
|
|
56
|
+
totalPrs: summary.totalPrs,
|
|
57
|
+
faultyPrNumbers: summary.faultyPrNumbers,
|
|
58
|
+
failureReason: summary.failureReason,
|
|
59
|
+
viewReport: getO11yUiBuildUrl(args.buildUuid),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BrowserStackConfig } from "../../lib/types.js";
|
|
2
|
+
import { TfaRcaTurnResult, TurnResponse } from "./types.js";
|
|
3
|
+
export declare class TfaRcaTurnError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export interface GetTfaTurnResultArgs {
|
|
6
|
+
testRunId: string;
|
|
7
|
+
turnId: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function buildAuthHeader(config: BrowserStackConfig): string;
|
|
10
|
+
export declare function readStructuredTurn(data: any): TurnResponse;
|
|
11
|
+
export declare function toTrimmedResult(turn: TurnResponse, threadId: string | undefined): TfaRcaTurnResult;
|
|
12
|
+
/** Build the poll (GET) URL for one already-submitted turn. */
|
|
13
|
+
export declare function buildPollUrl(testRunId: string, turnId: string): string;
|
|
14
|
+
export declare function getTfaTurnResult(args: GetTfaTurnResultArgs, config: BrowserStackConfig): Promise<TfaRcaTurnResult>;
|