diff --git a/.github/workflows/e2e-advisor.yaml b/.github/workflows/e2e-advisor.yaml index b52dc89442..a8fcbd4900 100644 --- a/.github/workflows/e2e-advisor.yaml +++ b/.github/workflows/e2e-advisor.yaml @@ -173,6 +173,19 @@ jobs: --schema "$ADVISOR_DIR/tools/e2e-advisor/schema.json" \ --out-dir "$GITHUB_WORKSPACE/artifacts/e2e-advisor" + - name: Run deterministic scenario E2E advisor + id: scenario-analysis + continue-on-error: true + env: + BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || (github.event_name == 'workflow_dispatch' && inputs.target_repo != '' && inputs.target_pr != '' && format('target/{0}', inputs.target_base) || inputs.base_ref) }} + HEAD_REF: ${{ github.event_name == 'pull_request' && 'HEAD' || (github.event_name == 'workflow_dispatch' && inputs.target_repo != '' && inputs.target_pr != '' && 'HEAD' || inputs.head_ref) }} + run: | + cd "$ADVISOR_WORKDIR" + node --experimental-strip-types "$ADVISOR_DIR/tools/e2e-advisor/scenarios.mts" \ + --base "$BASE_REF" \ + --head "$HEAD_REF" \ + --out-dir "$GITHUB_WORKSPACE/artifacts/e2e-advisor" + - name: Publish job summary if: always() run: | @@ -181,6 +194,9 @@ jobs: else printf '# E2E Recommendation Advisor\n\nAdvisor analysis did not produce a summary. See raw artifacts/logs.\n' >> "$GITHUB_STEP_SUMMARY" fi + if [ -f "$GITHUB_WORKSPACE/artifacts/e2e-advisor/e2e-scenario-advisor-summary.md" ]; then + cat "$GITHUB_WORKSPACE/artifacts/e2e-advisor/e2e-scenario-advisor-summary.md" >> "$GITHUB_STEP_SUMMARY" + fi - name: Auto-dispatch required E2E jobs if: ${{ always() && github.event_name == 'pull_request' }} @@ -231,6 +247,23 @@ jobs: echo "Skipping E2E advisor comment: trusted main checkout does not contain comment.mts or comment.mjs" fi + - name: Post E2E scenario advisor PR comment + if: ${{ always() && github.event_name == 'pull_request' }} + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.E2E_ADVISOR_GITHUB_TOKEN || github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ -f "$GITHUB_WORKSPACE/artifacts/e2e-advisor/e2e-scenario-advisor-summary.md" ] && [ -f "$ADVISOR_DIR/tools/e2e-advisor/scenario-comment.mts" ]; then + node --experimental-strip-types "$ADVISOR_DIR/tools/e2e-advisor/scenario-comment.mts" \ + --repo "$GITHUB_REPOSITORY" \ + --pr "$PR_NUMBER" \ + --summary "$GITHUB_WORKSPACE/artifacts/e2e-advisor/e2e-scenario-advisor-summary.md" \ + --result "$GITHUB_WORKSPACE/artifacts/e2e-advisor/e2e-scenario-advisor-result.json" + else + echo "Skipping E2E scenario advisor comment: summary or scenario-comment.mts missing" + fi + - name: Upload advisor artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/test/e2e-scenario-advisor.test.ts b/test/e2e-scenario-advisor.test.ts new file mode 100644 index 0000000000..a70285d4b0 --- /dev/null +++ b/test/e2e-scenario-advisor.test.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { buildScenarioComment } from "../tools/e2e-advisor/scenario-comment.mts"; +import { + analyzeScenarioRecommendations, + renderScenarioSummary, +} from "../tools/e2e-advisor/scenarios.mts"; + +const ROOT = new URL("../", import.meta.url).pathname; + +function analyze(changedFiles: string[]) { + return analyzeScenarioRecommendations({ + baseRef: "origin/main", + headRef: "HEAD", + changedFiles, + root: ROOT, + }); +} + +describe("E2E scenario advisor", () => { + it("requires all scenario E2E when the all-scenarios workflow changes", () => { + const result = analyze([".github/workflows/e2e-scenarios-all.yaml"]); + + expect(result.required).toEqual([ + expect.objectContaining({ + id: "e2e-scenarios-all", + workflow: "e2e-scenarios-all.yaml", + dispatchCommand: + "gh workflow run e2e-scenarios-all.yaml --ref ", + }), + ]); + expect(result.noScenarioE2eReason).toBeNull(); + }); + + it("requires targeted scenario E2E when a validation suite changes", () => { + const result = analyze([ + "test/e2e/validation_suites/messaging/telegram/00-telegram-injection-safety.sh", + ]); + + expect(result.required).toContainEqual( + expect.objectContaining({ + id: "ubuntu-repo-docker__cloud-nvidia-openclaw-telegram:messaging-telegram", + workflow: "e2e-scenarios.yaml", + scenario: "ubuntu-repo-docker__cloud-nvidia-openclaw-telegram", + suiteFilter: "messaging-telegram", + }), + ); + }); + + it("requires all scenario E2E and targeted follow-up when suite metadata changes", () => { + const result = analyze([ + "test/e2e/validation_suites/suites.yaml", + "test/e2e/validation_suites/messaging/telegram/00-telegram-injection-safety.sh", + ]); + + expect(result.required).toContainEqual( + expect.objectContaining({ id: "e2e-scenarios-all" }), + ); + expect(result.required).toContainEqual( + expect.objectContaining({ + id: "ubuntu-repo-docker__cloud-nvidia-openclaw-telegram:messaging-telegram", + scenario: "ubuntu-repo-docker__cloud-nvidia-openclaw-telegram", + suiteFilter: "messaging-telegram", + }), + ); + }); + + it("does not recommend scenario E2E for unrelated files", () => { + const result = analyze(["docs/reference/commands.mdx"]); + + expect(result.required).toEqual([]); + expect(result.optional).toEqual([]); + expect(result.noScenarioE2eReason).toMatch(/No scenario workflow/); + }); + + it("renders a summary and second sticky scenario comment", () => { + const result = analyze([".github/workflows/e2e-scenarios.yaml"]); + const summary = renderScenarioSummary(result); + const comment = buildScenarioComment({ + summary, + result, + runUrl: "https://example.invalid/run", + }); + + expect(summary).toContain("# E2E Scenario Advisor"); + expect(comment).toContain(""); + expect(comment).toContain("## E2E Scenario Advisor Recommendation"); + expect(comment).toContain("`e2e-scenarios-all`"); + expect(comment).toContain("https://example.invalid/run"); + }); +}); diff --git a/tools/e2e-advisor/scenario-comment.mts b/tools/e2e-advisor/scenario-comment.mts new file mode 100644 index 0000000000..0b6fa054fa --- /dev/null +++ b/tools/e2e-advisor/scenario-comment.mts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { pathToFileURL } from "node:url"; + +import { upsertStickyComment } from "../advisors/github.mts"; +import { parseArgs, readIfExists, readJsonIfExists } from "../advisors/io.mts"; + +import type { + ScenarioAdvisorResult, + ScenarioRecommendation, +} from "./scenarios.mts"; + +export const SCENARIO_ADVISOR_MARKER = ""; + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const repo = args.repo || process.env.GITHUB_REPOSITORY; + const pr = args.pr || process.env.PR_NUMBER; + const summaryPath = + args.summary || "artifacts/e2e-advisor/e2e-scenario-advisor-summary.md"; + const resultPath = + args.result || "artifacts/e2e-advisor/e2e-scenario-advisor-result.json"; + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + const runUrl = + process.env.GITHUB_SERVER_URL && + process.env.GITHUB_REPOSITORY && + process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` + : undefined; + + if (!repo || !pr) { + console.log( + "Skipping E2E scenario advisor comment: repo or PR number not provided", + ); + return; + } + if (!token) { + console.log( + "Skipping E2E scenario advisor comment: GITHUB_TOKEN/GH_TOKEN not provided", + ); + return; + } + + const summary = readIfExists(summaryPath); + if (!summary) { + throw new Error(`No scenario advisor summary found at ${summaryPath}`); + } + + const result = readJsonIfExists(resultPath); + const body = buildScenarioComment({ summary, result, runUrl }); + + await upsertStickyComment({ + repo, + pr, + token, + marker: SCENARIO_ADVISOR_MARKER, + body, + label: "E2E scenario advisor", + userAgent: "nemoclaw-e2e-scenario-advisor", + }); +} + +export function buildScenarioComment({ + summary, + result, + runUrl, + marker = SCENARIO_ADVISOR_MARKER, +}: { + summary: string; + result?: ScenarioAdvisorResult; + runUrl?: string; + marker?: string; +}): string { + const required = Array.isArray(result?.required) ? result.required : []; + const optional = Array.isArray(result?.optional) ? result.optional : []; + const requiredLine = recommendationLine(required); + const optionalLine = recommendationLine(optional); + const dispatch = + required.length > 0 + ? `\n\n**Dispatch required scenario E2E:**\n${required.map((item) => `- \`${item.dispatchCommand}\``).join("\n")}` + : ""; + const run = runUrl ? `\n\n[Workflow run](${runUrl})` : ""; + + return `${marker} +## E2E Scenario Advisor Recommendation + +**Required scenario E2E:** ${requiredLine} +**Optional scenario E2E:** ${optionalLine}${dispatch}${run} + +
+Full scenario advisor summary + +${summary.trim()} + +
+`; +} + +function recommendationLine(recommendations: ScenarioRecommendation[]): string { + return recommendations.length > 0 + ? recommendations.map((item) => `\`${item.id}\``).join(", ") + : "_None_"; +} diff --git a/tools/e2e-advisor/scenarios.mts b/tools/e2e-advisor/scenarios.mts new file mode 100644 index 0000000000..1f413b58bf --- /dev/null +++ b/tools/e2e-advisor/scenarios.mts @@ -0,0 +1,503 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const require = createRequire(import.meta.url); +const yaml: { load(input: string): unknown } = require("js-yaml"); + +import { getChangedFiles } from "../advisors/git.mts"; +import { parseArgs, writeJson } from "../advisors/io.mts"; + +const SCENARIO_WORKFLOW = "e2e-scenarios.yaml"; +const SCENARIO_ALL_WORKFLOW = "e2e-scenarios-all.yaml"; +const DEFAULT_BASELINE_SCENARIO = "ubuntu-repo-cloud-openclaw"; +const CORE_SCENARIO_IDS = [ + "ubuntu-repo-cloud-openclaw", + "ubuntu-repo-cloud-hermes", + "gpu-repo-local-ollama-openclaw", + "macos-repo-cloud-openclaw", + "wsl-repo-cloud-openclaw", + "brev-launchable-cloud-openclaw", + "ubuntu-no-docker-preflight-negative", +]; + +export type ScenarioRecommendation = { + id: string; + workflow: string; + scenario?: string; + suiteFilter?: string; + required: boolean; + reason: string; + dispatchCommand: string; +}; + +export type ScenarioAdvisorResult = { + version: 1; + baseRef: string; + headRef: string; + changedFiles: string[]; + relevantChangedFiles: string[]; + required: ScenarioRecommendation[]; + optional: ScenarioRecommendation[]; + noScenarioE2eReason: string | null; + confidence: "high"; +}; + +type ScenarioEntry = { + suites?: unknown; + runner_requirements?: unknown; + dimensions?: { + platform?: unknown; + onboarding?: unknown; + }; +}; + +type ScenariosFile = { + setup_scenarios?: Record; + test_plans?: Record; +}; + +type SuiteEntry = { + steps?: Array<{ script?: unknown }>; +}; + +type SuitesFile = { + suites?: Record; +}; + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + main(); + } catch (error: unknown) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const outDir = args.outDir || "artifacts/e2e-advisor"; + const baseRef = args.base || process.env.BASE_REF || "origin/main"; + const headRef = args.head || process.env.HEAD_REF || "HEAD"; + const resultPath = path.join(outDir, "e2e-scenario-advisor-result.json"); + const summaryPath = path.join(outDir, "e2e-scenario-advisor-summary.md"); + + fs.mkdirSync(outDir, { recursive: true }); + + const changedFiles = getChangedFiles(baseRef, headRef); + const result = analyzeScenarioRecommendations({ + baseRef, + headRef, + changedFiles, + root: process.cwd(), + }); + writeJson(resultPath, result); + fs.writeFileSync(summaryPath, renderScenarioSummary(result)); + console.log(renderScenarioSummary(result)); +} + +export function analyzeScenarioRecommendations({ + baseRef, + headRef, + changedFiles, + root = process.cwd(), +}: { + baseRef: string; + headRef: string; + changedFiles: string[]; + root?: string; +}): ScenarioAdvisorResult { + const scenarios = loadScenarios(root); + const suiteToScenarios = buildSuiteToScenarios(scenarios); + const scenariosWithGpuOrSpecialRunners = + detectSpecialRunnerScenarios(scenarios); + const suiteScriptMap = loadSuiteScriptMap(root); + const suiteIds = new Set(Object.keys(suiteScriptMap)); + const directScenarioIds = new Set(); + const changedSuiteIds = new Set(); + const reasons = new Set(); + const relevantChangedFiles = changedFiles.filter(isScenarioRelevantFile); + let allScenariosRequired = false; + + for (const file of changedFiles) { + if (file === ".github/workflows/e2e-scenarios-all.yaml") { + allScenariosRequired = true; + reasons.add("the all-scenarios fan-out workflow changed"); + } else if (file === ".github/workflows/e2e-scenarios.yaml") { + allScenariosRequired = true; + reasons.add("the reusable single-scenario workflow changed"); + } else if (file === "test/e2e/nemoclaw_scenarios/scenarios.yaml") { + allScenariosRequired = true; + reasons.add("scenario catalog metadata changed"); + } else if (file === "test/e2e/nemoclaw_scenarios/expected-states.yaml") { + allScenariosRequired = true; + reasons.add("expected-state metadata changed"); + } else if (file === "test/e2e/validation_suites/suites.yaml") { + allScenariosRequired = true; + reasons.add("suite catalog metadata changed"); + } else if ( + file.startsWith("test/e2e/runtime/") || + file.startsWith("test/e2e/nemoclaw_scenarios/helpers/") + ) { + allScenariosRequired = true; + reasons.add("shared scenario runner/runtime code changed"); + } else if ( + file.startsWith("test/e2e/nemoclaw_scenarios/onboard/") || + file.startsWith("test/e2e/nemoclaw_scenarios/install/") + ) { + directScenarioIds.add(DEFAULT_BASELINE_SCENARIO); + reasons.add("scenario install/onboard helper code changed"); + } + + for (const suiteId of inferSuiteIdsFromPath( + file, + suiteIds, + suiteScriptMap, + )) { + changedSuiteIds.add(suiteId); + reasons.add(`validation suite \`${suiteId}\` changed`); + } + } + + for (const suiteId of changedSuiteIds) { + const matchingScenarios = suiteToScenarios.get(suiteId) || []; + for (const scenario of matchingScenarios) directScenarioIds.add(scenario); + } + + const required: ScenarioRecommendation[] = []; + const optional: ScenarioRecommendation[] = []; + if (allScenariosRequired) { + required.push({ + id: "e2e-scenarios-all", + workflow: SCENARIO_ALL_WORKFLOW, + required: true, + reason: + [...reasons].join("; ") || "scenario E2E workflow or metadata changed", + dispatchCommand: + "gh workflow run e2e-scenarios-all.yaml --ref ", + }); + } + + for (const scenario of [...directScenarioIds].sort()) { + if (allScenariosRequired && CORE_SCENARIO_IDS.includes(scenario)) continue; + const suiteFilter = suiteFilterForScenario( + scenario, + changedSuiteIds, + scenarios, + ); + required.push( + buildSingleScenarioRecommendation( + scenario, + suiteFilter, + reasonForScenario(scenario, changedSuiteIds, reasons), + ), + ); + } + + if (allScenariosRequired && changedSuiteIds.size > 0) { + for (const scenario of scenariosForSuites( + changedSuiteIds, + suiteToScenarios, + )) { + if (CORE_SCENARIO_IDS.includes(scenario)) continue; + const suiteFilter = suiteFilterForScenario( + scenario, + changedSuiteIds, + scenarios, + ); + optional.push( + buildSingleScenarioRecommendation( + scenario, + suiteFilter, + `Targeted follow-up for changed suite(s): ${suiteFilter || [...changedSuiteIds].sort().join(",")}`, + false, + ), + ); + } + } + + for (const specialScenario of scenariosWithGpuOrSpecialRunners) { + if ( + [...required, ...optional].some( + (item) => item.scenario === specialScenario, + ) + ) + continue; + const suites = suitesForScenario(specialScenario, scenarios); + if ([...changedSuiteIds].some((suite) => suites.includes(suite))) { + optional.push( + buildSingleScenarioRecommendation( + specialScenario, + suiteFilterForScenario(specialScenario, changedSuiteIds, scenarios), + "Special-runner scenario covers a changed suite but may require scarce hardware/secrets.", + false, + ), + ); + } + } + + return { + version: 1, + baseRef, + headRef, + changedFiles, + relevantChangedFiles, + required: uniqueRecommendations(required), + optional: uniqueRecommendations(optional).filter( + (candidate) => !required.some((item) => item.id === candidate.id), + ), + noScenarioE2eReason: + required.length === 0 && optional.length === 0 + ? "No scenario workflow, scenario metadata, scenario runtime, or validation-suite files changed." + : null, + confidence: "high", + }; +} + +export function renderScenarioSummary(result: ScenarioAdvisorResult): string { + const lines: string[] = []; + lines.push("# E2E Scenario Advisor"); + lines.push(""); + lines.push(`Base: \`${result.baseRef}\` `); + lines.push(`Head: \`${result.headRef}\` `); + lines.push(`Confidence: **${result.confidence}**`); + lines.push(""); + lines.push("## Required scenario E2E"); + if (result.required.length === 0) { + lines.push(`- _None._ ${result.noScenarioE2eReason || ""}`.trim()); + } else { + for (const recommendation of result.required) { + lines.push(`- **${recommendation.id}**: ${recommendation.reason}`); + lines.push(` - Dispatch: \`${recommendation.dispatchCommand}\``); + } + } + lines.push(""); + lines.push("## Optional scenario E2E"); + if (result.optional.length === 0) { + lines.push("- _None._"); + } else { + for (const recommendation of result.optional) { + lines.push(`- **${recommendation.id}**: ${recommendation.reason}`); + lines.push(` - Dispatch: \`${recommendation.dispatchCommand}\``); + } + } + lines.push(""); + lines.push("## Relevant changed files"); + if (result.relevantChangedFiles.length === 0) { + lines.push("- _None._"); + } else { + for (const file of result.relevantChangedFiles) lines.push(`- \`${file}\``); + } + lines.push(""); + return `${lines.join("\n")}\n`; +} + +function loadScenarios(root: string): Record { + const filePath = path.join( + root, + "test/e2e/nemoclaw_scenarios/scenarios.yaml", + ); + if (!fs.existsSync(filePath)) return {}; + const doc = yaml.load(fs.readFileSync(filePath, "utf8")) as + | ScenariosFile + | undefined; + return { ...(doc?.test_plans ?? {}), ...(doc?.setup_scenarios ?? {}) }; +} + +function loadSuiteScriptMap(root: string): Record { + const filePath = path.join(root, "test/e2e/validation_suites/suites.yaml"); + if (!fs.existsSync(filePath)) return {}; + const doc = yaml.load(fs.readFileSync(filePath, "utf8")) as + | SuitesFile + | undefined; + const output: Record = {}; + for (const [suiteId, suite] of Object.entries(doc?.suites ?? {})) { + output[suiteId] = Array.isArray(suite.steps) + ? suite.steps + .map((step) => step.script) + .filter((script): script is string => typeof script === "string") + : []; + } + return output; +} + +function buildSuiteToScenarios( + scenarios: Record, +): Map { + const suiteToScenarios = new Map(); + for (const [scenario, entry] of Object.entries(scenarios)) { + for (const suite of normalizeStringArray(entry.suites)) { + const current = suiteToScenarios.get(suite) || []; + current.push(scenario); + suiteToScenarios.set(suite, current); + } + } + for (const [suite, scenarioIds] of suiteToScenarios) + suiteToScenarios.set(suite, scenarioIds.sort()); + return suiteToScenarios; +} + +function detectSpecialRunnerScenarios( + scenarios: Record, +): string[] { + return Object.entries(scenarios) + .filter( + ([id, entry]) => + id.startsWith("gpu-") || + id.startsWith("macos-") || + id.startsWith("wsl-") || + id.startsWith("brev-") || + normalizeStringArray(entry.runner_requirements).length > 0, + ) + .map(([id]) => id) + .sort(); +} + +function isScenarioRelevantFile(file: string): boolean { + return ( + file === ".github/workflows/e2e-scenarios.yaml" || + file === ".github/workflows/e2e-scenarios-all.yaml" || + file.startsWith("test/e2e/runtime/") || + file.startsWith("test/e2e/nemoclaw_scenarios/") || + file.startsWith("test/e2e/validation_suites/") + ); +} + +function inferSuiteIdsFromPath( + file: string, + suiteIds: Set, + suiteScriptMap: Record, +): string[] { + if ( + !file.startsWith("test/e2e/validation_suites/") || + file.endsWith("/suites.yaml") + ) + return []; + const relative = file.slice("test/e2e/validation_suites/".length); + const segments = relative.split("/"); + const candidates = new Set(); + for (let size = Math.min(segments.length, 3); size >= 1; size -= 1) { + candidates.add(segments.slice(0, size).join("-")); + candidates.add(segments.slice(0, size).join("/")); + } + candidates.add(segments[0]); + for (const suiteId of suiteIds) { + const normalizedSuiteId = suiteId.replaceAll("-", "/"); + if ( + relative === `${normalizedSuiteId}.sh` || + relative.startsWith(`${normalizedSuiteId}/`) + ) { + candidates.add(suiteId); + } + } + + const matches = [...candidates].filter((candidate) => + suiteIds.has(candidate), + ); + if (matches.length > 0) + return matches.sort((a, b) => b.length - a.length).slice(0, 1); + + const scriptMatches = Object.entries(suiteScriptMap) + .filter(([, scripts]) => scripts.includes(relative)) + .map(([suiteId]) => suiteId); + if (scriptMatches.length > 0) return scriptMatches.sort(); + + return [segments[0]]; +} + +function scenariosForSuites( + changedSuiteIds: Set, + suiteToScenarios: Map, +): string[] { + const scenarioIds = new Set(); + for (const suiteId of changedSuiteIds) { + for (const scenarioId of suiteToScenarios.get(suiteId) || []) + scenarioIds.add(scenarioId); + } + return [...scenarioIds].sort(); +} + +function suiteFilterForScenario( + scenario: string, + changedSuiteIds: Set, + scenarios: Record, +): string | undefined { + const scenarioSuites = suitesForScenario(scenario, scenarios); + const relevantSuites = [...changedSuiteIds] + .filter((suite) => scenarioSuites.includes(suite)) + .sort(); + return relevantSuites.length > 0 ? relevantSuites.join(",") : undefined; +} + +function suitesForScenario( + scenario: string, + scenarios: Record, +): string[] { + return normalizeStringArray(scenarios[scenario]?.suites); +} + +function reasonForScenario( + scenario: string, + changedSuiteIds: Set, + reasons: Set, +): string { + const suiteText = + changedSuiteIds.size > 0 + ? ` Changed suite(s): ${[...changedSuiteIds] + .sort() + .map((suite) => `\`${suite}\``) + .join(", ")}.` + : ""; + return `Scenario \`${scenario}\` exercises the changed scenario E2E surface.${suiteText} ${[...reasons].join("; ")}`.trim(); +} + +function buildSingleScenarioRecommendation( + scenario: string, + suiteFilter: string | undefined, + reason: string, + required = true, +): ScenarioRecommendation { + const suitePart = suiteFilter + ? ` --field suite_filter=${shellQuote(suiteFilter)}` + : ""; + return { + id: suiteFilter ? `${scenario}:${suiteFilter}` : scenario, + workflow: SCENARIO_WORKFLOW, + scenario, + suiteFilter, + required, + reason, + dispatchCommand: `gh workflow run e2e-scenarios.yaml --ref --field scenario=${shellQuote(scenario)}${suitePart}`, + }; +} + +function uniqueRecommendations( + recommendations: ScenarioRecommendation[], +): ScenarioRecommendation[] { + const seen = new Set(); + const output: ScenarioRecommendation[] = []; + for (const recommendation of recommendations) { + if (seen.has(recommendation.id)) continue; + seen.add(recommendation.id); + output.push(recommendation); + } + return output; +} + +function normalizeStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_.:/=-]+$/.test(value)) return value; + return `'${value.replace(/'/g, "'\\''")}'`; +}