diff --git a/.github/workflows/release-workflow-schema.yml b/.github/workflows/release-workflow-schema.yml new file mode 100644 index 0000000000..1767a41d89 --- /dev/null +++ b/.github/workflows/release-workflow-schema.yml @@ -0,0 +1,153 @@ +name: Release JSON Schema + +on: + push: + branches: + - main + paths: + - "pyproject.toml" + - "keep/providers/**" + - "keep-ui/entities/workflows/model/yaml.schema.ts" + pull_request: + paths: + - "pyproject.toml" + - "keep/providers/**" + - "keep-ui/entities/workflows/model/yaml.schema.ts" + workflow_dispatch: + +env: + PYTHON_VERSION: 3.11 + STORAGE_MANAGER_DIRECTORY: /tmp/storage-manager + SCHEMA_REPO_NAME: keephq/keep-workflow-schema + +jobs: + release-schema: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from pyproject.toml + id: get_version + run: | + VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Cache dependencies + id: cache-deps + uses: actions/cache@v4.2.0 + with: + path: .venv + key: pydeps-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies using poetry + run: poetry install --no-interaction --no-root --with dev + + - name: Save providers list + run: | + PYTHONPATH="${{ github.workspace }}" poetry run python ./scripts/save_providers_list.py + + - name: Set up Node.js 20 + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: keep-ui/package-lock.json + + - name: Install Node dependencies + working-directory: keep-ui + run: npm ci + + - name: Generate JSON Schema + working-directory: keep-ui + run: npm run build:workflow-yaml-json-schema + + - name: Checkout schema repository + uses: actions/checkout@v4 + with: + repository: ${{ env.SCHEMA_REPO_NAME }} + token: ${{ secrets.SCHEMA_REPO_PAT }} + path: schema-repo + + - name: Set target branch variable + id: set_branch + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "branch=${{ github.head_ref }}" >> $GITHUB_OUTPUT + else + echo "branch=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + + - name: Create or switch to target branch in schema repo + working-directory: schema-repo + run: | + git fetch origin + if git show-ref --verify --quiet refs/heads/${{ steps.set_branch.outputs.branch }}; then + git checkout ${{ steps.set_branch.outputs.branch }} + else + git checkout -b ${{ steps.set_branch.outputs.branch }} + fi + + - name: Copy schema to target repository + run: | + cp workflow-yaml-json-schema.json schema-repo/schema.json + + # Update schema with version info + jq --arg version "${{ steps.get_version.outputs.version }}" \ + --arg id "https://raw.githubusercontent.com/${{ env.SCHEMA_REPO_NAME }}/v${{ steps.get_version.outputs.version }}/schema.json" \ + '. + {version: $version, "$id": $id}' \ + schema-repo/schema.json > schema-repo/schema.tmp.json + + mv schema-repo/schema.tmp.json schema-repo/schema.json + + - name: Check if schema changed + id: check_changes + working-directory: schema-repo + run: | + if git diff --quiet schema.json; then + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Commit and push schema + if: steps.check_changes.outputs.changed == 'true' + working-directory: schema-repo + run: | + git config user.name "Keep Schema Bot" + git config user.email "no-reply@keephq.dev" + git add schema.json + git commit -m "Release schema v${{ steps.get_version.outputs.version }}" + git push origin ${{ steps.set_branch.outputs.branch }} + if [ "${{ steps.set_branch.outputs.branch }}" = "main" ]; then + git tag "v${{ steps.get_version.outputs.version }}" + git push origin "v${{ steps.get_version.outputs.version }}" + fi + + - name: Create GitHub Release + if: steps.check_changes.outputs.changed == 'true' && steps.set_branch.outputs.branch == 'main' + uses: softprops/action-gh-release@v1 + with: + repository: ${{ env.SCHEMA_REPO_NAME }} + tag_name: v${{ steps.get_version.outputs.version }} + name: Release v${{ steps.get_version.outputs.version }} + body: | + Automated release of schema version v${{ steps.get_version.outputs.version }}. + env: + GITHUB_TOKEN: ${{ secrets.SCHEMA_REPO_PAT }} diff --git a/.gitignore b/.gitignore index 65cfb4294a..c59aa1db94 100644 --- a/.gitignore +++ b/.gitignore @@ -217,6 +217,7 @@ scripts/keep_slack_bot.py *.db providers_cache.json providers_list.json +workflow-yaml-json-schema.json tests/provision/* !tests/provision/workflows* diff --git a/keep-ui/entities/workflows/lib/generateWorkflowYamlJsonSchema.ts b/keep-ui/entities/workflows/lib/generateWorkflowYamlJsonSchema.ts new file mode 100644 index 0000000000..be84a40a00 --- /dev/null +++ b/keep-ui/entities/workflows/lib/generateWorkflowYamlJsonSchema.ts @@ -0,0 +1,46 @@ +import { ZodSchema } from "zod"; +import zodToJsonSchema, { PostProcessCallback } from "zod-to-json-schema"; + +const schemaName = "KeepWorkflowSchema"; +const rootPath = `#/definitions/${schemaName}/properties/workflow`; + +const makeRequiredEitherStepsOrActions: PostProcessCallback = ( + // The original output produced by the package itself: + jsonSchema, + // The ZodSchema def used to produce the original schema: + def, + // The refs object containing the current path, passed options, etc. + refs +) => { + const path = refs.currentPath.join("/"); + if (jsonSchema && path === rootPath) { + // @ts-ignore + jsonSchema.required = jsonSchema.required.filter( + (r: string) => r !== "steps" + ); + // @ts-ignore + jsonSchema.anyOf = [ + { + required: ["steps"], + properties: { + steps: { minItems: 1 }, + }, + }, + { + required: ["actions"], + properties: { + actions: { minItems: 1 }, + }, + }, + ]; + } + return jsonSchema; +}; + +export function generateWorkflowYamlJsonSchema(zodSchema: ZodSchema) { + return zodToJsonSchema(zodSchema, { + name: schemaName, + // Make workflow valid if it has either actions or steps + postProcess: makeRequiredEitherStepsOrActions, + }); +} diff --git a/keep-ui/entities/workflows/lib/useWorkflowJsonSchema.ts b/keep-ui/entities/workflows/lib/useWorkflowJsonSchema.ts index 200c23f39b..a707083de1 100644 --- a/keep-ui/entities/workflows/lib/useWorkflowJsonSchema.ts +++ b/keep-ui/entities/workflows/lib/useWorkflowJsonSchema.ts @@ -1,58 +1,17 @@ import { useProviders } from "@/utils/hooks/useProviders"; import { getYamlWorkflowDefinitionSchema } from "../model/yaml.schema"; import { useMemo } from "react"; -import zodToJsonSchema, { PostProcessCallback } from "zod-to-json-schema"; import { YamlWorkflowDefinitionSchema } from "../model/yaml.schema"; - -const makeRequiredEitherStepsOrActions: PostProcessCallback = ( - // The original output produced by the package itself: - jsonSchema, - // The ZodSchema def used to produce the original schema: - def, - // The refs object containing the current path, passed options, etc. - refs -) => { - const path = refs.currentPath.join("/"); - if ( - jsonSchema && - path === "#/definitions/WorkflowSchema/properties/workflow" - ) { - // @ts-ignore - jsonSchema.required = jsonSchema.required.filter( - (r: string) => r !== "steps" - ); - // @ts-ignore - jsonSchema.anyOf = [ - { - required: ["steps"], - properties: { - steps: { minItems: 1 }, - }, - }, - { - required: ["actions"], - properties: { - actions: { minItems: 1 }, - }, - }, - ]; - } - return jsonSchema; -}; +import { generateWorkflowYamlJsonSchema } from "./generateWorkflowYamlJsonSchema"; export function useWorkflowJsonSchema() { const { data: { providers } = {} } = useProviders(); return useMemo(() => { if (!providers) { - return zodToJsonSchema(YamlWorkflowDefinitionSchema, { - name: "WorkflowSchema", - postProcess: makeRequiredEitherStepsOrActions, - }); + return generateWorkflowYamlJsonSchema(YamlWorkflowDefinitionSchema); } - return zodToJsonSchema(getYamlWorkflowDefinitionSchema(providers), { - name: "WorkflowSchema", - // Make workflow valid if it has either actions or steps - postProcess: makeRequiredEitherStepsOrActions, - }); + return generateWorkflowYamlJsonSchema( + getYamlWorkflowDefinitionSchema(providers) + ); }, [providers]); } diff --git a/keep-ui/package.json b/keep-ui/package.json index da424b2cee..7cb68f8b55 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -6,6 +6,7 @@ "scripts": { "build-monaco-workers": "node scripts/build-monaco-workers-turbopack.js", "build": "./next_build.sh", + "build:workflow-yaml-json-schema": "ts-node -P tsconfig.scripts.json scripts/generate-workflow-yaml-json-schema.ts", "dev": "npm run build-monaco-workers && next dev --turbopack -p 3000", "dev:webpack": "next dev -p 3000", "lint": "next lint", diff --git a/keep-ui/scripts/generate-workflow-yaml-json-schema.ts b/keep-ui/scripts/generate-workflow-yaml-json-schema.ts new file mode 100644 index 0000000000..63a53e468b --- /dev/null +++ b/keep-ui/scripts/generate-workflow-yaml-json-schema.ts @@ -0,0 +1,23 @@ +import { getYamlWorkflowDefinitionSchema } from "../entities/workflows/model/yaml.schema"; +import fs from "fs"; +import path from "path"; +import { generateWorkflowYamlJsonSchema } from "../entities/workflows/lib/generateWorkflowYamlJsonSchema"; + +function saveWorkflowYamlJsonSchema() { + console.log("Loading providers list"); + // providers_list.json should be generated with "python3 scripts/save_providers_list.py" from the root of the repo + const providers = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../providers_list.json"), "utf8") + ) as any[]; + console.log(`Providers list loaded, ${providers.length} providers found`); + const zodSchema = getYamlWorkflowDefinitionSchema(providers); + console.log(`Zod schema loaded`); + const jsonSchema = generateWorkflowYamlJsonSchema(zodSchema); + fs.writeFileSync( + path.join(__dirname, "../../workflow-yaml-json-schema.json"), + JSON.stringify(jsonSchema, null, 2) + ); + console.log("JSON schema generated"); +} + +saveWorkflowYamlJsonSchema(); diff --git a/scripts/workflow_yaml_generate_json_schema.sh b/scripts/workflow_yaml_generate_json_schema.sh new file mode 100644 index 0000000000..f50419e37c --- /dev/null +++ b/scripts/workflow_yaml_generate_json_schema.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Save providers list to providers_list.json +python3 ./scripts/save_providers_list.py + +# Generate JSON schema from providers list +cd keep-ui && npm run build:workflow-yaml-json-schema \ No newline at end of file diff --git a/tests/e2e_tests/test_end_to_end.py b/tests/e2e_tests/test_end_to_end.py index 15e6832a13..736908f85b 100644 --- a/tests/e2e_tests/test_end_to_end.py +++ b/tests/e2e_tests/test_end_to_end.py @@ -35,6 +35,7 @@ from tests.e2e_tests.utils import ( assert_connected_provider_count, assert_scope_text_count, + choose_combobox_option_with_retry, delete_provider, init_e2e_test, install_webhook_provider, @@ -823,10 +824,8 @@ def test_run_workflow_from_alert_and_incident( page.wait_for_timeout(200) expect(modal).to_be_visible() page.wait_for_timeout(200) - modal.get_by_test_id("manual-run-workflow-select-control").click() - modal.get_by_role( - "option", name=re.compile(r"Log every incident") - ).first.click() + select = modal.get_by_test_id("manual-run-workflow-select-control") + choose_combobox_option_with_retry(page, select, "Log every incident") modal.get_by_role("button", name="Run").click() expect(page.get_by_text("Workflow started successfully")).to_be_visible() # Run workflow from alert @@ -841,8 +840,8 @@ def test_run_workflow_from_alert_and_incident( "button", name="Run workflow" ).click() modal = page.get_by_test_id("manual-run-workflow-modal") - modal.get_by_test_id("manual-run-workflow-select-control").click() - modal.get_by_role("option", name=re.compile(r"Log every alert")).click() + select = modal.get_by_test_id("manual-run-workflow-select-control") + choose_combobox_option_with_retry(page, select, "Log every alert") modal.get_by_role("button", name="Run").click() expect(page.get_by_text("Workflow started successfully")).to_be_visible() except Exception: diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index fae8912aec..910d499f31 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -1,16 +1,36 @@ import json import os +import re import sys from datetime import datetime import requests -from playwright.sync_api import Page, expect +from playwright.sync_api import Locator, Page, expect from keep.providers.providers_factory import ProvidersFactory KEEP_UI_URL = "http://localhost:3000" +def choose_combobox_option_with_retry( + page: Page, + combobox_container_locator: Locator, + option_text: str, + max_retries: int = 3, +): + for i in range(max_retries): + combobox_container_locator.click() + combobox = combobox_container_locator.get_by_role("combobox") + combobox.fill(option_text) + combobox.press("Enter") + if combobox_container_locator.get_by_text(re.compile(option_text)).is_visible(): + return + page.wait_for_timeout(100) + raise Exception( + f"Failed to choose combobox option {option_text}, current value: {combobox.input_value()}" + ) + + def trigger_alert(provider_name, tenant_id=None): provider = ProvidersFactory.get_provider_class(provider_name) token = get_token(tenant_id) @@ -237,6 +257,12 @@ def save_failure_artifacts(page, log_entries=[], prefix=""): if prefix: current_test_name = prefix + "_" + current_test_name + # print current active element + print( + "current active element: ", + page.locator("body").evaluate("() => document.activeElement.outerHTML")[:200], + ) + # Save screenshot page.screenshot(path=current_test_name + ".png") diff --git a/tests/test_workflow_execution.py b/tests/test_workflow_execution.py index 24dc8b5939..e831ff6871 100644 --- a/tests/test_workflow_execution.py +++ b/tests/test_workflow_execution.py @@ -1780,7 +1780,7 @@ def test_workflow_with_on_failure_action(db_session, workflow_manager, mocker): ) -def test_get_all_workflows_with_last_execution(db_session, workflow_manager): +def test_get_all_workflows_with_last_execution(db_session, workflow_manager, mocker): workflow = Workflow( id="log-every-alert", name="log-every-alert", @@ -1789,6 +1789,7 @@ def test_get_all_workflows_with_last_execution(db_session, workflow_manager): created_by="borat@keephq.dev", interval=0, workflow_raw=LOG_EVERY_ALERT_WORKFLOW, + last_updated=datetime.now(tz=pytz.utc), ) db_session.add(workflow) db_session.commit() @@ -1819,6 +1820,16 @@ def test_get_all_workflows_with_last_execution(db_session, workflow_manager): should_fail="true", ) + def mock_notify(*args, **kwargs): + if kwargs.get("invalid-argument-to-fail-workflow"): + raise Exception("Workflow failed") + return True + + mocker.patch( + "keep.providers.console_provider.console_provider.ConsoleProvider._notify", + mock_notify, + ) + workflow_manager.insert_events(SINGLE_TENANT_UUID, [alert1]) # Wait for the workflow to execute