@browserstack/mcp-server 1.2.36 → 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/README.md +2 -0
- 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/lib/tm-base-url.js +7 -1
- package/dist/tools/accessibility.js +30 -0
- package/dist/tools/appautomate.js +20 -2
- package/dist/tools/applive.js +6 -0
- package/dist/tools/automate.js +6 -0
- package/dist/tools/bstack-sdk.js +7 -1
- package/dist/tools/build-insights.js +16 -3
- package/dist/tools/get-failure-logs.js +6 -0
- package/dist/tools/live.js +7 -1
- package/dist/tools/observability.js +6 -0
- package/dist/tools/percy-sdk.js +49 -7
- 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 +30 -6
- package/dist/tools/selfheal.js +12 -0
- package/dist/tools/testmanagement.js +119 -17
- 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,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>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { apiClient } from "../../lib/apiClient.js";
|
|
2
|
+
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
3
|
+
import { getO11yBaseUrl, getRcaViewGuidance, RCA_CHAT_POLL_PATH, } from "./constants.js";
|
|
4
|
+
import { PENDING_STATUS, TfaStatus, } from "./types.js";
|
|
5
|
+
export class TfaRcaTurnError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export function buildAuthHeader(config) {
|
|
8
|
+
const authString = getBrowserStackAuth(config);
|
|
9
|
+
return `Basic ${Buffer.from(authString).toString("base64")}`;
|
|
10
|
+
}
|
|
11
|
+
/** Map a raw status string from the wire onto the `TfaStatus` enum. */
|
|
12
|
+
function toTfaStatus(raw) {
|
|
13
|
+
switch (raw) {
|
|
14
|
+
case "RESOLVED":
|
|
15
|
+
return TfaStatus.RESOLVED;
|
|
16
|
+
case "BLOCKED":
|
|
17
|
+
return TfaStatus.BLOCKED;
|
|
18
|
+
default:
|
|
19
|
+
return TfaStatus.NEEDS_INFO;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Map one wire ask (snake_case) to the client `TfaAsk` (camelCase). */
|
|
23
|
+
function toAsk(raw) {
|
|
24
|
+
return {
|
|
25
|
+
what: raw?.what ?? "",
|
|
26
|
+
why: raw?.why ?? "",
|
|
27
|
+
evidenceType: raw?.evidence_type ?? "other",
|
|
28
|
+
priority: raw?.priority ?? "medium",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// The poll envelope's `status` is the lifecycle state (working/completed/failed);
|
|
32
|
+
// the agent's TurnResponse under `turn` has its own status (NEEDS_INFO/RESOLVED/
|
|
33
|
+
// BLOCKED). Default-fills lists so the skill never sees `undefined`.
|
|
34
|
+
export function readStructuredTurn(data) {
|
|
35
|
+
const turn = data.turn ?? {};
|
|
36
|
+
const status = toTfaStatus(turn.status);
|
|
37
|
+
const needsInfo = turn.needs_info ?? {};
|
|
38
|
+
const blocked = turn.blocked ?? {};
|
|
39
|
+
return {
|
|
40
|
+
status,
|
|
41
|
+
confidence: turn.confidence ?? "unknown",
|
|
42
|
+
questions: Array.isArray(needsInfo.questions) ? needsInfo.questions : [],
|
|
43
|
+
asks: Array.isArray(needsInfo.asks) ? needsInfo.asks.map(toAsk) : [],
|
|
44
|
+
suggestions: Array.isArray(needsInfo.suggestions)
|
|
45
|
+
? needsInfo.suggestions
|
|
46
|
+
: [],
|
|
47
|
+
hypotheses: Array.isArray(needsInfo.hypotheses) ? needsInfo.hypotheses : [],
|
|
48
|
+
rca: status === TfaStatus.RESOLVED ? (turn.rca ?? undefined) : undefined,
|
|
49
|
+
reason: status === TfaStatus.BLOCKED ? blocked.reason : undefined,
|
|
50
|
+
unmetAsks: status === TfaStatus.BLOCKED && Array.isArray(blocked.unmet_asks)
|
|
51
|
+
? blocked.unmet_asks
|
|
52
|
+
: undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Trim a completed turn to the status-discriminated contract: NEEDS_INFO
|
|
56
|
+
// carries questions/asks/suggestions/hypotheses verbatim; RESOLVED carries a
|
|
57
|
+
// glimpse + viewRca pointer; BLOCKED carries reason + unmetAsks.
|
|
58
|
+
export function toTrimmedResult(turn, threadId) {
|
|
59
|
+
switch (turn.status) {
|
|
60
|
+
case TfaStatus.RESOLVED: {
|
|
61
|
+
const rca = turn.rca ?? {};
|
|
62
|
+
return {
|
|
63
|
+
status: turn.status,
|
|
64
|
+
confidence: turn.confidence,
|
|
65
|
+
threadId,
|
|
66
|
+
glimpse: {
|
|
67
|
+
root_cause: rca.root_cause,
|
|
68
|
+
failure_type: rca.failure_type,
|
|
69
|
+
related_prs: rca.related_prs,
|
|
70
|
+
},
|
|
71
|
+
viewRca: getRcaViewGuidance(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
case TfaStatus.BLOCKED:
|
|
75
|
+
return {
|
|
76
|
+
status: turn.status,
|
|
77
|
+
confidence: turn.confidence,
|
|
78
|
+
threadId,
|
|
79
|
+
reason: turn.reason,
|
|
80
|
+
unmetAsks: turn.unmetAsks,
|
|
81
|
+
};
|
|
82
|
+
default:
|
|
83
|
+
return {
|
|
84
|
+
status: turn.status,
|
|
85
|
+
confidence: turn.confidence,
|
|
86
|
+
threadId,
|
|
87
|
+
questions: turn.questions,
|
|
88
|
+
asks: turn.asks,
|
|
89
|
+
suggestions: turn.suggestions,
|
|
90
|
+
hypotheses: turn.hypotheses,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Build the poll (GET) URL for one already-submitted turn. */
|
|
95
|
+
export function buildPollUrl(testRunId, turnId) {
|
|
96
|
+
return (getO11yBaseUrl() +
|
|
97
|
+
RCA_CHAT_POLL_PATH.replace("{testRunId}", testRunId).replace("{turnId}", turnId));
|
|
98
|
+
}
|
|
99
|
+
// Read an already-submitted turn once (no polling loop, no submit); a turn
|
|
100
|
+
// still in flight yields the soft PENDING status carrying turnId/threadId.
|
|
101
|
+
export async function getTfaTurnResult(args, config) {
|
|
102
|
+
const headers = {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
Authorization: buildAuthHeader(config),
|
|
105
|
+
};
|
|
106
|
+
const response = await apiClient.get({
|
|
107
|
+
url: buildPollUrl(args.testRunId, args.turnId),
|
|
108
|
+
headers,
|
|
109
|
+
raise_error: false,
|
|
110
|
+
});
|
|
111
|
+
if (response.status === 404) {
|
|
112
|
+
throw new TfaRcaTurnError("turn expired or not found");
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new TfaRcaTurnError(`failed to read RCA turn (status ${response.status})`);
|
|
116
|
+
}
|
|
117
|
+
const data = response.data ?? {};
|
|
118
|
+
const threadId = data.threadId;
|
|
119
|
+
if (data.status === "failed") {
|
|
120
|
+
throw new TfaRcaTurnError(data.error || "TFA agent run failed");
|
|
121
|
+
}
|
|
122
|
+
if (data.status === "completed") {
|
|
123
|
+
return toTrimmedResult(readStructuredTurn(data), threadId);
|
|
124
|
+
}
|
|
125
|
+
// "working" (or any other in-progress value) → soft PENDING, read again later.
|
|
126
|
+
return {
|
|
127
|
+
status: PENDING_STATUS,
|
|
128
|
+
threadId,
|
|
129
|
+
turnId: args.turnId,
|
|
130
|
+
};
|
|
131
|
+
}
|