From 3fb557a7f51dbbd3fac445734196f1b9a1d2e287 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 9 Apr 2026 12:40:34 -0400 Subject: [PATCH 01/28] fix: flow step testing UX improvements (#8781) * fix: flow dev page layout and compact toolbar improvements - Fix JSON.parse error on /flows/dev page when editor not yet initialized - Increase compact topbar threshold from 700px to 800px - Reposition "Test flow" button below settings bar when pane is narrow on dev pages Co-Authored-By: Claude Opus 4.6 (1M context) * fix: flow step testing UX improvements - Store and display logs for step test results (previewLogs in flowState) - Add logs toggle button in output picker popover - Fix AI proxy 401 in VS Code extension by passing OpenAPI.TOKEN - Prevent output picker from closing when clicking Run on same node - Make toggleOpen idempotent to avoid flicker - Show loading placeholder in badge area during test execution - Keep pin button visible during test runs - Auto-refresh step history when new test completes Co-Authored-By: Claude Opus 4.6 (1M context) * fix: track previous previewJobId to avoid redundant history refreshes Co-Authored-By: Claude Opus 4.6 (1M context) * fix: dev page insert popover z-index, summary editing, output picker UX - Add #flow-editor portal div to /flows/dev page for correct popover stacking - Add summary text field at bottom of dev pages when a step node is selected - Keep pin button visible during test runs - Show loading placeholder badge to prevent content shift - Exclude same-node run button from output picker outside-click detection - Make toggleOpen idempotent when popover already open Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: reuse findModuleInFlow instead of duplicated findModule Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- frontend/src/lib/components/Dev.svelte | 20 ++++++++- .../components/FlowStatusViewerInner.svelte | 3 +- frontend/src/lib/components/ModuleTest.svelte | 3 +- frontend/src/lib/components/copilot/lib.ts | 5 ++- .../src/lib/components/flows/flowState.ts | 1 + .../flows/map/FlowModuleSchemaItem.svelte | 2 +- .../flows/map/FlowModuleSchemaMap.svelte | 3 +- .../flows/propPicker/OutputPicker.svelte | 3 +- .../flows/propPicker/OutputPickerInner.svelte | 45 +++++++++++++++++-- .../flows/propPicker/StepHistory.svelte | 11 ++++- frontend/src/routes/flows/dev/+page.svelte | 22 ++++++++- 11 files changed, 104 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 7113fadcb3..a94e1bc952 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -28,6 +28,7 @@ import FlowModuleSchemaMap from './flows/map/FlowModuleSchemaMap.svelte' import FlowEditorPanel from './flows/content/FlowEditorPanel.svelte' import { deepEqual } from 'fast-equals' + import { findModuleInFlow } from './flows/flowDiff' import { writable } from 'svelte/store' import type { FlowState } from './flows/flowState' import { initHistory } from '$lib/history.svelte' @@ -698,6 +699,12 @@ const flowHasChanged = $derived(flowPreviewContent?.flowHasChanged()) const selectedId = $derived(selectionManager.getSelectedId()) + + const selectedModule = $derived( + selectedId && flowStore.val?.value + ? findModuleInFlow(flowStore.val.value, selectedId) ?? undefined + : undefined + ) @@ -870,7 +877,7 @@ }} /> - + {#if flowStore.val?.value?.modules}
@@ -925,6 +932,17 @@ {/key}
+ {#if selectedModule} +
+ {selectedModule.id} summary + +
+ {/if} {/if} diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index c33ab7cd0a..57c4714849 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -884,7 +884,8 @@ previewResult: job['result'], previewArgs: job.args, previewJobId: job.id, - previewSuccess: job['success'] + previewSuccess: job['success'], + previewLogs: job['logs'] } } diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 37cb96bea7..9e408e234d 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -142,7 +142,8 @@ ...flowStateStore.val[mod.id], previewResult: testJob.result, previewSuccess: testJob.success, - previewJobId: testJob.id + previewJobId: testJob.id, + previewLogs: testJob['logs'] } } stepHistoryLoader?.resetInitial(mod.id) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index d8149086d4..4b0c8c0e9e 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -387,7 +387,7 @@ export function createOpenAIProxyClient(baseURL: string): OpenAI { baseURL, apiKey: 'fake-key', defaultHeaders: { - Authorization: '' // a non empty string will be unable to access Windmill backend proxy + Authorization: OpenAPI.TOKEN ? `Bearer ${OpenAPI.TOKEN}` : '' }, dangerouslyAllowBrowser: true }) @@ -397,7 +397,8 @@ export function createAnthropicProxyClient(baseURL: string): Anthropic { return new Anthropic({ baseURL, apiKey: 'fake-key', - dangerouslyAllowBrowser: true + dangerouslyAllowBrowser: true, + defaultHeaders: OpenAPI.TOKEN ? { Authorization: `Bearer ${OpenAPI.TOKEN}` } : undefined }) } diff --git a/frontend/src/lib/components/flows/flowState.ts b/frontend/src/lib/components/flows/flowState.ts index db9e07701d..0dafbe67b1 100644 --- a/frontend/src/lib/components/flows/flowState.ts +++ b/frontend/src/lib/components/flows/flowState.ts @@ -11,6 +11,7 @@ export type FlowModuleState = { previewArgs?: any previewJobId?: string previewSuccess?: boolean + previewLogs?: string } export type FlowState = Record diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index e6a548b39a..de949a6dd9 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -574,7 +574,7 @@ onmouseleave={() => (hover = false)} > {#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible} -
+
{#if !testIsLoading} + + {/snippet} + + {#await import('../SimpleEditor.svelte')} + + {:then Module} +
+ +
+ {/await} + + From c57c769deaa207e7ba7995f75649d3630774e898 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 9 Apr 2026 13:21:36 -0400 Subject: [PATCH 06/28] feat: add CI test scripts with auto-trigger on deploy (#8736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add CI test scripts with auto-trigger on deploy Co-Authored-By: Claude Opus 4.6 (1M context) * fix: fix annotation parser early return and handle renames correctly Co-Authored-By: Claude Opus 4.6 (1M context) * fix: move CI test results to top of script/flow detail pages Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve CI test results spacing, icon, and remove pass label Co-Authored-By: Claude Opus 4.6 (1M context) * feat: support one-line annotation and use script/path format Co-Authored-By: Claude Opus 4.6 (1M context) * feat: move CI test trigger logic to EE Co-Authored-By: Claude Opus 4.6 (1M context) * feat: move CI badge next to New badge and add deduplicated CI summary Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add CI test e2e tests and fix nullable column annotations Add integration tests for CI test annotation parsing (creates/removes ci_test_reference rows) and the CI test results API (single + batch endpoints). Add backend test for auto-trigger on deploy (private+python). Fix sqlx LEFT JOIN LATERAL nullable column annotations in get_ci_test_results and get_ci_test_results_batch queries — sqlx cannot infer nullability from LATERAL subqueries, causing runtime decode errors when no matching job exists. Co-Authored-By: Claude Opus 4.6 (1M context) * fix build/sqlx * fix * feat: CI test improvements and templates - Fix windmill-dep-map/private feature propagation in worker, api-scripts, and api-flows Cargo.toml so CI test triggers actually fire in EE mode - Clone ci_test_reference rows during workspace fork - Add polling to CiTestResults component (refetch every 3s while running) - Add running state and auto-refresh to ForkWorkspaceBanner CI summary - Add yellow "CI test" badge on script list rows and detail page - Fix Library badge border color (remove indigo border override) - Add CI Test TypeScript and CI Test Python templates in ScriptBuilder - Update sqlx offline cache - Add debug tracing for CI test trigger in worker_lockfiles Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add missing children prop to WorkspaceDeployLayout Fixes svelte-fast-check type error when passing named snippets as children content inside the component tag. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review feedback - Remove empty wrapper divs around CiTestResults, move mb-4 into component - Add batch endpoint size cap (max 200 items) - Add ON DELETE CASCADE to ci_test_reference workspace FK (new migration) - Downgrade CI test trigger logs from info to debug - Fix false-positive polling: only treat status='running' as running, not null status (CiTestResults, CompareWorkspaces, ForkWorkspaceBanner) - Fix test numbering in integration tests Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to latest EE commit Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to d9d68c2406df0b59f413ea0b2cb24780a9817d04 This commit updates the EE repository reference after PR #516 was merged in windmill-ee-private. Previous ee-repo-ref: d7ccd9b86da99ec056a0e8708e3637d64290387a New ee-repo-ref: d9d68c2406df0b59f413ea0b2cb24780a9817d04 Automated by sync-ee-ref workflow. * fix: treat queued jobs (job_id set, null status) as running Jobs that have been pushed but not yet picked up by a worker have a job_id but null status. Treat these as 'running' to avoid showing misleading 'pass' badges or '0 passing'. Tests that were never triggered (no job_id, null status) remain neutral/hidden. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: hugocasa Co-authored-by: windmill-internal-app[bot] --- ...094c6c8d7434a31dcdeeb2875bd89cd6bd328.json | 16 ++ ...bc7e9ee85289ff5c52af43166928337c257e7.json | 3 +- ...a16519bdd3d47d538f58fbf2b64962214df8c.json | 20 ++ ...8e1c0cbb4241afa8804a906795e320be240e6.json | 18 ++ ...243ad2e0aef485a780daea5d76b0be2bb2ea2.json | 3 +- ...adbb2860ce52d37a56b2861de1215260ecff8.json | 6 +- ...307ca90d8dd76da2ca560b310b12820be2576.json | 3 +- ...c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json | 3 +- ...d8777744e116861f6fe9226bd9c95ed75d61b.json | 15 ++ ...e045f3e53a811b0d43738ca0ce96739785fc4.json | 32 +++ ...ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json | 3 +- ...6b9905f9c1846c8081a3b8fd265f4c484e809.json | 32 +++ ...100fd80f6b113a45a66c03125b93ce2ca2a83.json | 16 ++ ...fbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json | 3 +- ...2ede46b4471e21b11f607573a844013911abe.json | 3 +- ...e33244fe8ab9a803248fc23f334034d24aad4.json | 3 +- ...0cd9b0b0240f8cf101e7963a83a72ca8f39d5.json | 42 ++++ ...bb6a8f961c119516e4c33dc928cd3b4f4edbc.json | 3 +- ...9746147aa9b8d0a81642a7b7cb4738a0cad66.json | 3 +- ...1e5bbe5050a373dd3ebe6d383b09789c61810.json | 16 ++ ...e1ea2fe96a1b94f5a92953fb1c1642d15c016.json | 3 +- ...b373b9855c9c437b27c4452761eda5cce78f4.json | 16 ++ ...9352d4d1e3cb3a8a08f1c9c03055a1cab1235.json | 3 +- ...3aa68c775357920d84d53737acab3d3dee00d.json | 24 +++ ...b5aac4b443673066a15079c149e1e6207f153.json | 42 ++++ backend/Cargo.lock | 2 + backend/Cargo.toml | 2 +- backend/ee-repo-ref.txt | 2 +- ...20260406104248_ci_test_references.down.sql | 1 + .../20260406104248_ci_test_references.up.sql | 22 ++ ...9160116_ci_test_reference_cascade.down.sql | 4 + ...409160116_ci_test_reference_cascade.up.sql | 4 + backend/summarized_schema.txt | 4 +- backend/tests/ci_tests.rs | 116 ++++++++++ backend/windmill-api-flows/Cargo.toml | 3 +- backend/windmill-api-flows/src/flows.rs | 33 +++ .../tests/ci_tests.rs | 200 ++++++++++++++++++ backend/windmill-api-scripts/Cargo.toml | 2 +- backend/windmill-api-scripts/src/scripts.rs | 175 ++++++++++++++- .../windmill-api-workspaces/src/workspaces.rs | 20 ++ backend/windmill-api/openapi.yaml | 90 ++++++++ backend/windmill-common/src/schema.rs | 75 +++++++ backend/windmill-dep-map/Cargo.toml | 1 + backend/windmill-dep-map/src/ci_tests.rs | 21 ++ backend/windmill-dep-map/src/lib.rs | 11 +- backend/windmill-store/Cargo.toml | 1 + backend/windmill-store/src/resources.rs | 48 ++++- backend/windmill-types/src/jobs.rs | 4 + backend/windmill-worker/Cargo.toml | 2 +- .../windmill-worker/src/worker_lockfiles.rs | 27 +++ ee-repo-ref.txt | 1 - frontend/package-lock.json | 50 +---- .../src/lib/components/CiTestResults.svelte | 75 +++++++ .../lib/components/CompareWorkspaces.svelte | 97 +++++++++ frontend/src/lib/components/EditorBar.svelte | 2 + .../lib/components/ForkWorkspaceBanner.svelte | 74 ++++++- .../src/lib/components/NoMainFuncBadge.svelte | 2 +- .../src/lib/components/ScriptBuilder.svelte | 34 +++ .../src/lib/components/ScriptEditor.svelte | 6 +- .../components/WorkspaceDeployLayout.svelte | 1 + .../components/common/table/ScriptRow.svelte | 8 + frontend/src/lib/components/script_builder.ts | 10 +- frontend/src/lib/script_helpers.ts | 16 ++ .../src/lib/templates/ci_test_bun.ts.template | 19 ++ .../lib/templates/ci_test_python.py.template | 18 ++ .../(logged)/flows/get/[...path]/+page.svelte | 5 + .../scripts/get/[...hash]/+page.svelte | 13 ++ 67 files changed, 1552 insertions(+), 80 deletions(-) create mode 100644 backend/.sqlx/query-04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328.json create mode 100644 backend/.sqlx/query-173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c.json create mode 100644 backend/.sqlx/query-17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6.json create mode 100644 backend/.sqlx/query-6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b.json create mode 100644 backend/.sqlx/query-6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4.json create mode 100644 backend/.sqlx/query-811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809.json create mode 100644 backend/.sqlx/query-871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83.json create mode 100644 backend/.sqlx/query-b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5.json create mode 100644 backend/.sqlx/query-cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810.json create mode 100644 backend/.sqlx/query-d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4.json create mode 100644 backend/.sqlx/query-e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d.json create mode 100644 backend/.sqlx/query-f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153.json create mode 100644 backend/migrations/20260406104248_ci_test_references.down.sql create mode 100644 backend/migrations/20260406104248_ci_test_references.up.sql create mode 100644 backend/migrations/20260409160116_ci_test_reference_cascade.down.sql create mode 100644 backend/migrations/20260409160116_ci_test_reference_cascade.up.sql create mode 100644 backend/tests/ci_tests.rs create mode 100644 backend/windmill-api-integration-tests/tests/ci_tests.rs create mode 100644 backend/windmill-dep-map/src/ci_tests.rs delete mode 100644 ee-repo-ref.txt create mode 100644 frontend/src/lib/components/CiTestResults.svelte create mode 100644 frontend/src/lib/templates/ci_test_bun.ts.template create mode 100644 frontend/src/lib/templates/ci_test_python.py.template diff --git a/backend/.sqlx/query-04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328.json b/backend/.sqlx/query-04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328.json new file mode 100644 index 0000000000..c85c20905c --- /dev/null +++ b/backend/.sqlx/query-04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'resource'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328" +} diff --git a/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json b/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json index b925141065..4c69461711 100644 --- a/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json +++ b/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json @@ -31,7 +31,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c.json b/backend/.sqlx/query-173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c.json new file mode 100644 index 0000000000..a76b504182 --- /dev/null +++ b/backend/.sqlx/query-173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT test_script_path FROM ci_test_reference WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c" +} diff --git a/backend/.sqlx/query-17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6.json b/backend/.sqlx/query-17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6.json new file mode 100644 index 0000000000..a492b18487 --- /dev/null +++ b/backend/.sqlx/query-17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind) VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6" +} diff --git a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json index 30e071f8ce..ceed3795f0 100644 --- a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json +++ b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json @@ -41,7 +41,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json b/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json index b0354db034..9d0ace0831 100644 --- a/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json +++ b/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json @@ -35,7 +35,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } @@ -69,7 +70,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json b/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json index 7c13ded9a3..7cb6932851 100644 --- a/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json +++ b/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json @@ -124,7 +124,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json index 0df68ba631..2f86bc4178 100644 --- a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json +++ b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json @@ -247,7 +247,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b.json b/backend/.sqlx/query-6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b.json new file mode 100644 index 0000000000..27343b7dd5 --- /dev/null +++ b/backend/.sqlx/query-6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind)\n SELECT $2, test_script_path, test_script_hash, tested_item_path, tested_item_kind\n FROM ci_test_reference WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b" +} diff --git a/backend/.sqlx/query-6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4.json b/backend/.sqlx/query-6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4.json new file mode 100644 index 0000000000..e4bc3acc19 --- /dev/null +++ b/backend/.sqlx/query-6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT j.id, j.runnable_path, j.trigger_kind::text as trigger_kind FROM v2_job j WHERE j.workspace_id = 'test-workspace' AND j.trigger_kind = 'ci_test'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "trigger_kind", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4" +} diff --git a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json index 59b56ceda7..9bf61b09a1 100644 --- a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json +++ b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json @@ -41,7 +41,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809.json b/backend/.sqlx/query-811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809.json new file mode 100644 index 0000000000..9cafe23200 --- /dev/null +++ b/backend/.sqlx/query-811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT test_script_path, tested_item_path, tested_item_kind FROM ci_test_reference WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "tested_item_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "tested_item_kind", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809" +} diff --git a/backend/.sqlx/query-871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83.json b/backend/.sqlx/query-871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83.json new file mode 100644 index 0000000000..8d79a3603c --- /dev/null +++ b/backend/.sqlx/query-871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ci_test_reference WHERE workspace_id = $1 AND (test_script_path = $2 OR test_script_path = $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83" +} diff --git a/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json b/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json index b6fe1b7fa0..09cc00f737 100644 --- a/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json +++ b/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json @@ -31,7 +31,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json index 8a0b536f44..490e821025 100644 --- a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json +++ b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json @@ -157,7 +157,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json index 0a8e61a3f7..f60512fa07 100644 --- a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json +++ b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json @@ -187,7 +187,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5.json b/backend/.sqlx/query-b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5.json new file mode 100644 index 0000000000..86f5da7969 --- /dev/null +++ b/backend/.sqlx/query-b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n ctr.test_script_path,\n j.id as \"job_id?\",\n COALESCE(jc.status::text, CASE WHEN j.id IS NOT NULL THEN 'running' ELSE NULL END) as \"status?\",\n j.created_at as \"started_at?\"\n FROM ci_test_reference ctr\n LEFT JOIN LATERAL (\n SELECT id, created_at FROM v2_job\n WHERE workspace_id = $1\n AND runnable_path = ctr.test_script_path\n AND trigger_kind = 'ci_test'\n ORDER BY created_at DESC\n LIMIT 1\n ) j ON true\n LEFT JOIN v2_job_completed jc ON jc.id = j.id\n WHERE ctr.workspace_id = $1\n AND ctr.tested_item_path = $2\n AND ctr.tested_item_kind = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "job_id?", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "status?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "started_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + null, + false + ] + }, + "hash": "b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5" +} diff --git a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json index c6fd65605f..5bccf10f4f 100644 --- a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json +++ b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json @@ -162,7 +162,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json b/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json index e7ee8296d5..d3494214dc 100644 --- a/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json +++ b/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json @@ -107,7 +107,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810.json b/backend/.sqlx/query-cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810.json new file mode 100644 index 0000000000..663f6c21b9 --- /dev/null +++ b/backend/.sqlx/query-cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'flow'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810" +} diff --git a/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json b/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json index e27d91ceb3..49ec375dab 100644 --- a/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json +++ b/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json @@ -107,7 +107,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4.json b/backend/.sqlx/query-d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4.json new file mode 100644 index 0000000000..3510b5fd76 --- /dev/null +++ b/backend/.sqlx/query-d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'script'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4" +} diff --git a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json index 9dffee0d1d..6fb03a4523 100644 --- a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json +++ b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json @@ -187,7 +187,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d.json b/backend/.sqlx/query-e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d.json new file mode 100644 index 0000000000..1c88f11310 --- /dev/null +++ b/backend/.sqlx/query-e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT test_script_path FROM ci_test_reference WHERE workspace_id = $1 AND tested_item_path = $2 AND tested_item_kind = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d" +} diff --git a/backend/.sqlx/query-f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153.json b/backend/.sqlx/query-f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153.json new file mode 100644 index 0000000000..dff9760d92 --- /dev/null +++ b/backend/.sqlx/query-f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n ctr.test_script_path,\n j.id as \"job_id?\",\n COALESCE(jc.status::text, CASE WHEN j.id IS NOT NULL THEN 'running' ELSE NULL END) as \"status?\",\n j.created_at as \"started_at?\"\n FROM ci_test_reference ctr\n LEFT JOIN LATERAL (\n SELECT id, created_at FROM v2_job\n WHERE workspace_id = $1\n AND runnable_path = ctr.test_script_path\n AND trigger_kind = 'ci_test'\n ORDER BY created_at DESC\n LIMIT 1\n ) j ON true\n LEFT JOIN v2_job_completed jc ON jc.id = j.id\n WHERE ctr.workspace_id = $1\n AND ctr.tested_item_path = $2\n AND ctr.tested_item_kind = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "job_id?", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "status?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "started_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + null, + false + ] + }, + "hash": "f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 58adf9273b..87c86d139a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16400,6 +16400,7 @@ dependencies = [ "serde_json", "sql-builder", "sqlx", + "tokio", "tracing", "windmill-api-auth", "windmill-audit", @@ -17430,6 +17431,7 @@ dependencies = [ "windmill-api-auth", "windmill-audit", "windmill-common", + "windmill-dep-map", "windmill-git-sync", "windmill-mcp", "windmill-oauth", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4d1d2f8df4..3d76078d4f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -108,7 +108,7 @@ strip = "none" [features] default = [] -private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-object-store/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"] +private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-dep-map/private", "windmill-object-store/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"] agent_worker_server = ["windmill-api/agent_worker_server", "dep:windmill-api-agent-workers", "windmill-test-utils/agent_worker_server"] enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-api-agent-workers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise", "windmill-object-store/enterprise"] local_reports = ["windmill-common/local_reports"] diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fd2e07ccdd..b8cf7f144e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9ea1259fa6a95e6b24d1523b2f6ec6ae0042ecfe +d9d68c2406df0b59f413ea0b2cb24780a9817d04 diff --git a/backend/migrations/20260406104248_ci_test_references.down.sql b/backend/migrations/20260406104248_ci_test_references.down.sql new file mode 100644 index 0000000000..d3bea6d2a3 --- /dev/null +++ b/backend/migrations/20260406104248_ci_test_references.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ci_test_reference; diff --git a/backend/migrations/20260406104248_ci_test_references.up.sql b/backend/migrations/20260406104248_ci_test_references.up.sql new file mode 100644 index 0000000000..4f4a4b79fd --- /dev/null +++ b/backend/migrations/20260406104248_ci_test_references.up.sql @@ -0,0 +1,22 @@ +-- CI test references: maps test scripts to the items they test +CREATE TABLE ci_test_reference ( + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id), + test_script_path VARCHAR(510) NOT NULL, + test_script_hash BIGINT NOT NULL, + tested_item_path VARCHAR(510) NOT NULL, + tested_item_kind VARCHAR(10) NOT NULL, + PRIMARY KEY (workspace_id, test_script_path, tested_item_path, tested_item_kind) +); + +-- Fast lookup: "which tests cover this item?" (used on deploy) +CREATE INDEX idx_ci_test_ref_tested_item + ON ci_test_reference (workspace_id, tested_item_path, tested_item_kind); + +-- Fast lookup: "which items does this test cover?" (used on test script update) +CREATE INDEX idx_ci_test_ref_test_script + ON ci_test_reference (workspace_id, test_script_path); + +GRANT ALL ON ci_test_reference TO windmill_user; +GRANT ALL ON ci_test_reference TO windmill_admin; + +ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'ci_test'; diff --git a/backend/migrations/20260409160116_ci_test_reference_cascade.down.sql b/backend/migrations/20260409160116_ci_test_reference_cascade.down.sql new file mode 100644 index 0000000000..fcc88c45bf --- /dev/null +++ b/backend/migrations/20260409160116_ci_test_reference_cascade.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE ci_test_reference + DROP CONSTRAINT ci_test_reference_workspace_id_fkey, + ADD CONSTRAINT ci_test_reference_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace(id); diff --git a/backend/migrations/20260409160116_ci_test_reference_cascade.up.sql b/backend/migrations/20260409160116_ci_test_reference_cascade.up.sql new file mode 100644 index 0000000000..57f6261eaf --- /dev/null +++ b/backend/migrations/20260409160116_ci_test_reference_cascade.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE ci_test_reference + DROP CONSTRAINT ci_test_reference_workspace_id_fkey, + ADD CONSTRAINT ci_test_reference_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 8446017263..b090030be3 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -16,7 +16,7 @@ http_method: get, post, put, delete, patch importer_kind: script, flow, app job_kind: script, preview, flow, dependencies, flowpreview, script_hub, identity, flowdependencies, http, graphql, postgresql, noop, appdependencies, deploymentcallback, singlestepflow, flowscript, flownode, appscript, aiagent, unassigned_script, unassigned_flow, unassigned_singlestepflow job_status: success, failure, canceled, skipped -job_trigger_kind: webhook, http, websocket, kafka, email, nats, schedule, app, ui, postgres, sqs, gcp, mqtt, nextcloud +job_trigger_kind: webhook, http, websocket, kafka, email, nats, schedule, app, ui, postgres, sqs, gcp, mqtt, nextcloud, ci_test log_mode: standalone, server, worker, agent, indexer, mcp login_type: password, github message_type: user, assistant, tool @@ -60,6 +60,8 @@ cloud_workspace_settings: workspace_id(char), threshold_alert_amount(int), last_ concurrency_counter: concurrency_id(char), job_uuids(jsonb) concurrency_key: key(char), ended_at(ts), job_id(uuid) concurrency_locks: id(char), last_locked_at(ts), owner(char) +ci_test_reference: workspace_id(char), test_script_path(char), test_script_hash(bigint), tested_item_path(char), tested_item_kind(char) + FK: (workspace_id) -> workspace(id) concurrency_settings: hash(bigint), concurrency_key(char), concurrent_limit(int), concurrency_time_window_s(int) config: name(char), config(jsonb) custom_concurrency_key_ended: key(char), ended_at(ts) diff --git a/backend/tests/ci_tests.rs b/backend/tests/ci_tests.rs new file mode 100644 index 0000000000..c8551a30a5 --- /dev/null +++ b/backend/tests/ci_tests.rs @@ -0,0 +1,116 @@ +#[cfg(all(feature = "python", feature = "private"))] +mod ci_tests { + use std::collections::HashMap; + + use sqlx::{Pool, Postgres}; + use tokio_stream::StreamExt; + + use windmill_api_client::types::{NewScript, ScriptLang}; + use windmill_test_utils::{in_test_worker, init_client, listen_for_completed_jobs}; + + fn quick_ns(content: &str, path: &str, parent_hash: Option) -> NewScript { + NewScript { + content: content.into(), + language: ScriptLang::Python3, + lock: None, + parent_hash, + path: path.into(), + concurrent_limit: None, + concurrency_time_window_s: None, + cache_ttl: None, + dedicated_worker: None, + description: "".to_string(), + draft_only: None, + envs: vec![], + is_template: None, + kind: None, + summary: "".to_string(), + tag: None, + schema: HashMap::new(), + ws_error_handler_muted: Some(false), + priority: None, + delete_after_secs: None, + timeout: None, + restart_unless_cancelled: None, + deployment_message: None, + concurrency_key: None, + visible_to_runner_only: None, + auto_kind: None, + codebase: None, + has_preprocessor: None, + on_behalf_of_email: None, + assets: vec![], + modules: None, + } + } + + /// Test 2: Deploying a script automatically triggers CI test jobs for test scripts + /// that reference it via the `# test:` annotation. + #[sqlx::test(fixtures("base"))] + async fn test_ci_test_trigger_on_deploy(db: Pool) -> anyhow::Result<()> { + let (client, port, _s) = init_client(db.clone()).await; + + // Step 1: Create the test script with a CI annotation targeting deploy_target. + // This inserts a ci_test_reference row. + client + .create_script( + "test-workspace", + &quick_ns( + "# test: script/u/test-user/deploy_target\ndef main():\n return True", + "u/test-user/ci_test_for_deploy", + None, + ), + ) + .await + .unwrap(); + + // Process the test script's dependency (lock generation) job + let mut completed = listen_for_completed_jobs(&db).await; + in_test_worker(&db, completed.next(), port).await; + + // Step 2: Create the target script. Its dependency job, once processed, + // will call trigger_ci_tests_for_item which finds our test script. + client + .create_script( + "test-workspace", + &quick_ns( + "def main():\n return 42", + "u/test-user/deploy_target", + None, + ), + ) + .await + .unwrap(); + + // Process the target script's dependency job → CI trigger fires in tokio::spawn + let mut completed = listen_for_completed_jobs(&db).await; + in_test_worker(&db, completed.next(), port).await; + + // Give the spawned trigger task time to push the CI test job + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Verify a CI test job was queued + let ci_jobs = sqlx::query!( + "SELECT j.id, j.runnable_path, j.trigger_kind::text as trigger_kind \ + FROM v2_job j \ + WHERE j.workspace_id = 'test-workspace' \ + AND j.trigger_kind = 'ci_test'" + ) + .fetch_all(&db) + .await?; + + assert_eq!(ci_jobs.len(), 1, "expected exactly 1 CI test job"); + assert_eq!( + ci_jobs[0].runnable_path.as_deref(), + Some("u/test-user/ci_test_for_deploy"), + "CI test job should run the test script" + ); + assert_eq!( + ci_jobs[0].trigger_kind.as_deref(), + Some("ci_test"), + "trigger_kind should be ci_test" + ); + + Ok(()) + } +} diff --git a/backend/windmill-api-flows/Cargo.toml b/backend/windmill-api-flows/Cargo.toml index 5407e38068..5796c8fad8 100644 --- a/backend/windmill-api-flows/Cargo.toml +++ b/backend/windmill-api-flows/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] enterprise = ["windmill-common/enterprise"] -private = ["windmill-common/private"] +private = ["windmill-common/private", "windmill-dep-map/private"] [dependencies] windmill-common = { workspace = true, default-features = false } @@ -27,5 +27,6 @@ serde.workspace = true serde_json.workspace = true sql-builder.workspace = true sqlx.workspace = true +tokio.workspace = true tracing.workspace = true chrono.workspace = true diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index e78331a57d..51b17a1c52 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -1059,6 +1059,16 @@ async fn update_flow( ) .execute(&mut *tx) .await?; + + // Update ci_test_reference when a tested flow is renamed + sqlx::query!( + "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'flow'", + nf.path, + flow_path, + w_id + ) + .execute(&mut *tx) + .await?; } // tracing::error!("Updating flow: {:?}", nf.value.get()); @@ -1277,6 +1287,29 @@ async fn update_flow( new_tx.commit().await?; + // Trigger CI tests for items that reference this flow + { + let db2 = db.clone(); + let w_id2 = w_id.clone(); + let flow_path2 = nf.path.clone(); + let email2 = authed.email.clone(); + let username2 = authed.username.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, + &w_id2, + &flow_path2, + "flow", + &email2, + &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after flow deploy"); + } + }); + } + Ok(nf.path.to_string()) } diff --git a/backend/windmill-api-integration-tests/tests/ci_tests.rs b/backend/windmill-api-integration-tests/tests/ci_tests.rs new file mode 100644 index 0000000000..64e6aaa181 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/ci_tests.rs @@ -0,0 +1,200 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +fn script_url(port: u16, endpoint: &str, path: &str) -> String { + format!("http://localhost:{port}/api/w/test-workspace/scripts/{endpoint}/{path}") +} + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn new_script(path: &str, content: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "", + "description": "", + "content": content, + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +/// Test 1: Creating a script with a CI test annotation inserts rows into ci_test_reference, +/// and deploying a new version without the annotation removes them. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ci_test_annotation_creates_and_removes_references( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/scripts"); + + // Create target script (no annotation) + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/target_script", + "export async function main() { return 42; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "create target: {}", resp.text().await?); + + // Create test script with CI annotation + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/ci_test_script", + "// test: script/u/test-user/target_script\nexport async function main() { return 'test passed'; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "create test: {}", resp.text().await?); + + // Verify ci_test_reference row exists + let refs = sqlx::query!( + "SELECT test_script_path, tested_item_path, tested_item_kind \ + FROM ci_test_reference WHERE workspace_id = 'test-workspace'" + ) + .fetch_all(&db) + .await?; + + assert_eq!(refs.len(), 1, "expected 1 ci_test_reference row"); + assert_eq!(refs[0].test_script_path, "u/test-user/ci_test_script"); + assert_eq!(refs[0].tested_item_path, "u/test-user/target_script"); + assert_eq!(refs[0].tested_item_kind, "script"); + + // Get the hash of the test script (needed as parent_hash for the update) + let resp = authed(client().get(script_url(port, "get/p", "u/test-user/ci_test_script"))) + .send() + .await?; + let body = resp.json::().await?; + let hash = body["hash"].as_str().unwrap().to_string(); + + // Create new version of test script WITHOUT annotation + let mut updated = new_script( + "u/test-user/ci_test_script", + "export async function main() { return 'no longer a test'; }", + ); + updated["parent_hash"] = json!(hash); + let resp = authed(client().post(format!("{base}/create"))) + .json(&updated) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "remove annotation: {}", + resp.text().await? + ); + + // Verify ci_test_reference row was deleted + let refs = sqlx::query!( + "SELECT test_script_path FROM ci_test_reference \ + WHERE workspace_id = 'test-workspace'" + ) + .fetch_all(&db) + .await?; + assert_eq!( + refs.len(), + 0, + "ci_test_reference should be empty after removing annotation" + ); + + Ok(()) +} + +/// Test 2: The CI test results API returns test references (with null job info when +/// no CI test job has run yet), and the batch endpoint aggregates correctly. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ci_test_results_api(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/scripts"); + + // Create target script + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/target_for_results", + "export async function main() { return 1; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "create target: {}", resp.text().await?); + + // Create test script with annotation + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/test_for_results", + "// test: script/u/test-user/target_for_results\nexport async function main() { return true; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "create test: {}", resp.text().await?); + + // --- Single item endpoint --- + let resp = authed(client().get(script_url( + port, + "ci_test_results/script", + "u/test-user/target_for_results", + ))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let results = resp.json::>().await?; + + assert_eq!(results.len(), 1, "expected 1 CI test result"); + assert_eq!( + results[0]["test_script_path"], + "u/test-user/test_for_results" + ); + // No CI test job has been triggered yet + assert!(results[0]["job_id"].is_null()); + assert!(results[0]["status"].is_null()); + + // --- Batch endpoint --- + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/ci_test_results_batch" + ))) + .json(&json!({ + "items": [ + {"path": "u/test-user/target_for_results", "kind": "script"}, + {"path": "u/test-user/nonexistent", "kind": "script"} + ] + })) + .send() + .await?; + assert_eq!(resp.status(), 200); + let batch = resp + .json::>() + .await?; + + // Target should have one test + let key = "script:u/test-user/target_for_results"; + assert!(batch.contains_key(key), "missing key {key}"); + let target_results = batch[key].as_array().unwrap(); + assert_eq!(target_results.len(), 1); + assert_eq!( + target_results[0]["test_script_path"], + "u/test-user/test_for_results" + ); + + // Nonexistent should have empty array + let key = "script:u/test-user/nonexistent"; + assert!(batch.contains_key(key), "missing key {key}"); + assert_eq!(batch[key].as_array().unwrap().len(), 0); + + Ok(()) +} diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index d905882b09..dcae7b779f 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] enterprise = ["windmill-common/enterprise"] -private = ["windmill-common/private"] +private = ["windmill-common/private", "windmill-dep-map/private"] python = ["dep:windmill-parser-py"] [dependencies] diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 8c54378ea5..dc6026a4ca 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -249,6 +249,9 @@ pub fn workspaced_service() -> Router { // Temporary raw script storage for CLI lock generation .route("/raw_temp/store", post(store_raw_script_temp)) .route("/raw_temp/diff", post(diff_raw_scripts_with_deployed)) + // CI test results + .route("/ci_test_results/{kind}/{*path}", get(get_ci_test_results)) + .route("/ci_test_results_batch", post(get_ci_test_results_batch)) } #[derive(Serialize, FromRow)] @@ -582,11 +585,31 @@ async fn create_script( { return Err(Error::PermissionDenied(msg)); } + let script_path = ns.path.clone(); + let email = authed.email.clone(); + let username = authed.username.clone(); let (hash, tx, hdm) = - create_script_internal(ns, w_id, authed, db.clone(), user_db, webhook).await?; + create_script_internal(ns, w_id.clone(), authed, db.clone(), user_db, webhook).await?; tx.commit().await?; if let Some(hdm) = hdm { + // hdm is Some when no lock generation is needed (script is ready immediately). + // Trigger CI tests for any items that reference this script. hdm.handle(&db).await?; + let db2 = db.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, + &w_id, + &script_path, + "script", + &email, + &username, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after script deploy"); + } + }); } Ok((StatusCode::CREATED, format!("{}", hash))) } @@ -914,6 +937,14 @@ async fn create_script_internal<'c>( } }; + let ci_test_refs = + windmill_common::schema::parse_ci_test_annotation(&ns.content, &lang.as_comment_lit()); + let auto_kind = if ci_test_refs.is_some() { + Some("test".to_string()) + } else { + auto_kind + }; + let runnable_settings_handle = windmill_common::runnable_settings::insert_rs( RunnableSettings { debouncing_settings: ns.debouncing_settings.insert_cached(&db).await?, @@ -997,6 +1028,34 @@ async fn create_script_internal<'c>( .execute(&mut *tx) .await?; + // Update ci_test_reference table for test scripts + // Delete by both new and old path to handle renames + let old_path = parent_hashes_and_perms.as_ref().map(|x| x.p_path.as_str()); + sqlx::query!( + "DELETE FROM ci_test_reference WHERE workspace_id = $1 AND (test_script_path = $2 OR test_script_path = $3)", + &w_id, + &ns.path, + old_path.unwrap_or(&ns.path) + ) + .execute(&mut *tx) + .await?; + + if let Some(ref refs) = ci_test_refs { + for item in refs { + sqlx::query!( + "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind) \ + VALUES ($1, $2, $3, $4, $5)", + &w_id, + &ns.path, + &hash.0, + &item.path, + &item.kind + ) + .execute(&mut *tx) + .await?; + } + } + let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { sqlx::query!( @@ -1055,6 +1114,16 @@ async fn create_script_internal<'c>( .execute(&mut *tx) .await?; + // Update ci_test_reference when a tested item is renamed + sqlx::query!( + "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'script'", + &ns.path, + &p_path, + &w_id + ) + .execute(&mut *tx) + .await?; + for schedule in schedulables { clear_schedule(&mut tx, &schedule.path, &w_id).await?; @@ -2750,3 +2819,107 @@ async fn diff_raw_scripts_with_deployed( Ok(Json(mismatched)) } + +#[derive(Serialize, FromRow)] +struct CiTestResult { + test_script_path: String, + job_id: Option, + status: Option, + started_at: Option>, +} + +async fn get_ci_test_results( + _authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, kind, path)): Path<(String, String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + let results = sqlx::query_as!( + CiTestResult, + r#"SELECT + ctr.test_script_path, + j.id as "job_id?", + COALESCE(jc.status::text, CASE WHEN j.id IS NOT NULL THEN 'running' ELSE NULL END) as "status?", + j.created_at as "started_at?" + FROM ci_test_reference ctr + LEFT JOIN LATERAL ( + SELECT id, created_at FROM v2_job + WHERE workspace_id = $1 + AND runnable_path = ctr.test_script_path + AND trigger_kind = 'ci_test' + ORDER BY created_at DESC + LIMIT 1 + ) j ON true + LEFT JOIN v2_job_completed jc ON jc.id = j.id + WHERE ctr.workspace_id = $1 + AND ctr.tested_item_path = $2 + AND ctr.tested_item_kind = $3"#, + &w_id, + path, + &kind + ) + .fetch_all(&db) + .await?; + + Ok(Json(results)) +} + +#[derive(Deserialize)] +struct CiTestBatchItem { + path: String, + kind: String, +} + +#[derive(Deserialize)] +struct CiTestBatchRequest { + items: Vec, +} + +async fn get_ci_test_results_batch( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> JsonResult>> { + if req.items.len() > 200 { + return Err(Error::BadRequest( + "too many items in batch request (max 200)".to_string(), + )); + } + + let mut result_map: HashMap> = HashMap::new(); + + for item in &req.items { + let results = sqlx::query_as!( + CiTestResult, + r#"SELECT + ctr.test_script_path, + j.id as "job_id?", + COALESCE(jc.status::text, CASE WHEN j.id IS NOT NULL THEN 'running' ELSE NULL END) as "status?", + j.created_at as "started_at?" + FROM ci_test_reference ctr + LEFT JOIN LATERAL ( + SELECT id, created_at FROM v2_job + WHERE workspace_id = $1 + AND runnable_path = ctr.test_script_path + AND trigger_kind = 'ci_test' + ORDER BY created_at DESC + LIMIT 1 + ) j ON true + LEFT JOIN v2_job_completed jc ON jc.id = j.id + WHERE ctr.workspace_id = $1 + AND ctr.tested_item_path = $2 + AND ctr.tested_item_kind = $3"#, + &w_id, + &item.path, + &item.kind + ) + .fetch_all(&db) + .await?; + + let key = format!("{}:{}", item.kind, item.path); + result_map.insert(key, results); + } + + Ok(Json(result_map)) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 15399fbe74..dccd260449 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3490,6 +3490,9 @@ async fn clone_workspace_data( // Clone scripts with new hashes clone_scripts(tx, source_workspace_id, target_workspace_id).await?; + // Clone CI test references + clone_ci_test_references(tx, source_workspace_id, target_workspace_id).await?; + // Clone flows with new versions clone_flows(tx, source_workspace_id, target_workspace_id).await?; @@ -3747,6 +3750,23 @@ async fn clone_scripts( Ok(()) } +async fn clone_ci_test_references( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind) + SELECT $2, test_script_path, test_script_hash, tested_item_path, tested_item_kind + FROM ci_test_reference WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + async fn clone_flows( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 60ee3b8470..3e3c9a9acb 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7771,6 +7771,77 @@ paths: type: string format: uuid + /w/{workspace}/scripts/ci_test_results/{kind}/{path}: + get: + summary: get CI test results for a script, flow, or resource + operationId: getCiTestResults + tags: + - script + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + type: string + enum: [script, flow, resource] + - name: path + in: path + required: true + schema: + type: string + responses: + "200": + description: CI test results + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/CiTestResult" + + /w/{workspace}/scripts/ci_test_results_batch: + post: + summary: get CI test results for multiple items + operationId: getCiTestResultsBatch + tags: + - script + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + items: + type: object + required: + - path + - kind + properties: + path: + type: string + kind: + type: string + enum: [script, flow, resource] + responses: + "200": + description: CI test results by item key + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: + $ref: "#/components/schemas/CiTestResult" + /w/{workspace}/scripts/raw_temp/store: post: summary: store raw script content temporarily for CLI lock generation @@ -19346,6 +19417,25 @@ components: # This is why it is better to inline each of schemas for better compat # Do not change next line. It is used by python-client for pre-processing # -- INLINE START -- + CiTestResult: + type: object + properties: + test_script_path: + type: string + job_id: + type: string + format: uuid + nullable: true + status: + type: string + nullable: true + started_at: + type: string + format: date-time + nullable: true + required: + - test_script_path + OpenFlow: $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" FlowValue: diff --git a/backend/windmill-common/src/schema.rs b/backend/windmill-common/src/schema.rs index 6c5d3cedd2..2116435de3 100644 --- a/backend/windmill-common/src/schema.rs +++ b/backend/windmill-common/src/schema.rs @@ -343,6 +343,81 @@ fn find_annotation(comm_lit: &str, annotation: &str, code: &str) -> bool { false } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CiTestedItem { + pub path: String, + pub kind: String, +} + +/// Parse a CI test annotation from the top of a script. +/// +/// Multi-line format: +/// ``` +/// // test: +/// // script/u/admin/my_script +/// // flow/u/admin/my_flow +/// // resource/u/admin/my_resource +/// ``` +/// +/// One-line format: +/// ``` +/// // test: script/u/admin/my_script +/// ``` +pub fn parse_ci_test_annotation(code: &str, comment_prefix: &str) -> Option> { + let test_marker = format!("{comment_prefix} test:"); + let mut lines = code.lines(); + + let first = lines.next()?; + let first_trimmed = first.trim_end(); + if !first_trimmed.starts_with(&test_marker) { + return None; + } + + let mut items = Vec::new(); + + // Check for one-line format: "// test: script/path" + let inline = first_trimmed[test_marker.len()..].trim(); + if !inline.is_empty() { + if let Some(item) = parse_ci_test_item(inline) { + items.push(item); + } + } + + for line in lines { + let Some(after_prefix) = line.strip_prefix(comment_prefix) else { + break; + }; + let stripped = after_prefix.trim(); + if stripped.is_empty() { + continue; + } + + if let Some(item) = parse_ci_test_item(stripped) { + items.push(item); + } else { + break; + } + } + + if items.is_empty() { + None + } else { + Some(items) + } +} + +fn parse_ci_test_item(s: &str) -> Option { + if let Some(path) = s.strip_prefix("script/") { + Some(CiTestedItem { path: path.trim().to_string(), kind: "script".to_string() }) + } else if let Some(path) = s.strip_prefix("flow/") { + Some(CiTestedItem { path: path.trim().to_string(), kind: "flow".to_string() }) + } else if let Some(path) = s.strip_prefix("resource/") { + Some(CiTestedItem { path: path.trim().to_string(), kind: "resource".to_string() }) + } else { + None + } +} + pub fn should_validate_schema(code: &str, lang: &ScriptLang) -> bool { let annotation = "schema_validation"; find_annotation(&lang.as_comment_lit(), annotation, code) diff --git a/backend/windmill-dep-map/Cargo.toml b/backend/windmill-dep-map/Cargo.toml index ae81f5d1a7..28b8552e8e 100644 --- a/backend/windmill-dep-map/Cargo.toml +++ b/backend/windmill-dep-map/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [features] default = [] python = ["dep:windmill-parser-py-imports"] +private = [] [dependencies] windmill-common = { workspace = true, default-features = false } diff --git a/backend/windmill-dep-map/src/ci_tests.rs b/backend/windmill-dep-map/src/ci_tests.rs new file mode 100644 index 0000000000..79f1b7e30b --- /dev/null +++ b/backend/windmill-dep-map/src/ci_tests.rs @@ -0,0 +1,21 @@ +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::ci_tests_ee::*; + +#[cfg(not(feature = "private"))] +use uuid::Uuid; + +#[cfg(not(feature = "private"))] +use windmill_common::error; + +#[cfg(not(feature = "private"))] +pub async fn trigger_ci_tests_for_item( + _db: &sqlx::Pool, + _w_id: &str, + _item_path: &str, + _item_kind: &str, + _email: &str, + _username: &str, +) -> error::Result> { + Ok(vec![]) +} diff --git a/backend/windmill-dep-map/src/lib.rs b/backend/windmill-dep-map/src/lib.rs index 39f2413bf7..ee6a3c3fd4 100644 --- a/backend/windmill-dep-map/src/lib.rs +++ b/backend/windmill-dep-map/src/lib.rs @@ -1,3 +1,6 @@ +pub mod ci_tests; +#[cfg(feature = "private")] +pub mod ci_tests_ee; pub mod scoped_dependency_map; pub mod trigger_dependents; pub mod workspace_dependencies; @@ -88,7 +91,13 @@ pub fn extract_referenced_paths( ) -> Option> { let mut referenced_paths = vec![]; if let Some(wk_deps_refs) = language - .and_then(|l| windmill_common::scripts::extract_workspace_dependencies_annotated_refs(&l, raw_code, script_path)) + .and_then(|l| { + windmill_common::scripts::extract_workspace_dependencies_annotated_refs( + &l, + raw_code, + script_path, + ) + }) .map(|r| r.external) { let l = language.expect("should be some"); diff --git a/backend/windmill-store/Cargo.toml b/backend/windmill-store/Cargo.toml index 3eddd95a39..9fee538ef6 100644 --- a/backend/windmill-store/Cargo.toml +++ b/backend/windmill-store/Cargo.toml @@ -29,6 +29,7 @@ openidconnect = ["windmill-common/openidconnect"] windmill-common = { workspace = true, default-features = false } windmill-api-auth.workspace = true windmill-audit.workspace = true +windmill-dep-map.workspace = true windmill-git-sync.workspace = true windmill-mcp = { workspace = true, optional = true } windmill-oauth = { workspace = true, optional = true } diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 94617f85ec..1a716fff7f 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -1286,6 +1286,16 @@ async fn update_resource( .execute(&mut *tx) .await?; + // Update ci_test_reference when a tested resource is renamed + sqlx::query!( + "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'resource'", + npath, + path, + w_id + ) + .execute(&mut *tx) + .await?; + sqlx::query!( "UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'resource' AND path = $3", npath, @@ -1343,12 +1353,29 @@ async fn update_resource( webhook.send_message( w_id.clone(), WebhookMessage::UpdateResource { - workspace: w_id, + workspace: w_id.clone(), old_path: path.to_owned(), new_path: npath.clone(), }, ); + // Trigger CI tests for items that reference this resource + { + let db2 = db.clone(); + let npath2 = npath.clone(); + let email2 = authed.email.clone(); + let username2 = authed.username.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, &w_id, &npath2, "resource", &email2, &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after resource update"); + } + }); + } + Ok(format!("resource {} updated (npath: {:?})", path, npath)) } @@ -1416,12 +1443,29 @@ async fn update_resource_value( webhook.send_message( w_id.clone(), WebhookMessage::UpdateResource { - workspace: w_id, + workspace: w_id.clone(), old_path: path.to_owned(), new_path: path.to_owned(), }, ); + // Trigger CI tests for items that reference this resource + { + let db2 = db.clone(); + let path2 = path.to_string(); + let email2 = authed.email.clone(); + let username2 = authed.username.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, &w_id, &path2, "resource", &email2, &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after resource value update"); + } + }); + } + Ok(format!("value of resource {} updated", path)) } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 1eed946ad8..9261302272 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -38,6 +38,9 @@ pub enum JobTriggerKind { Gcp, Nextcloud, Google, + #[serde(rename = "ci_test")] + #[sqlx(rename = "ci_test")] + CiTest, } impl std::fmt::Display for JobTriggerKind { @@ -56,6 +59,7 @@ impl std::fmt::Display for JobTriggerKind { JobTriggerKind::Gcp => "gcp", JobTriggerKind::Nextcloud => "nextcloud", JobTriggerKind::Google => "google", + JobTriggerKind::CiTest => "ci_test", }; write!(f, "{}", kind) } diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 62cb9de4bb..c436277ee1 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -10,7 +10,7 @@ path = "src/lib.rs" [features] default = [] -private = ["windmill-worker-volumes/private", "windmill-queue/private", "windmill-common/private"] +private = ["windmill-worker-volumes/private", "windmill-queue/private", "windmill-common/private", "windmill-dep-map/private"] mcp = ["dep:windmill-mcp"] prometheus = ["dep:prometheus", "windmill-common/prometheus"] enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker-volumes/enterprise", "dep:pem", "dep:tokio-util", "dep:opentelemetry-proto", "dep:prost", "dep:hudsucker", "dep:rcgen", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util"] diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 670c593dcf..4275bb7014 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -303,6 +303,33 @@ pub async fn handle_dependency_job( ) .await?; + // Trigger CI tests for items that reference this script + tracing::debug!( + "CI test trigger: checking for tests referencing script {}", + script_path + ); + { + let db2 = db.clone(); + let w_id2 = w_id.to_string(); + let script_path2 = script_path.to_string(); + let email2 = job.permissioned_as_email.clone(); + let username2 = job.created_by.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, + &w_id2, + &script_path2, + "script", + &email2, + &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after script lock generation"); + } + }); + } + Ok(to_raw_value_owned( json!({ "status": "Successful lock file generation", "lock": content }), )) diff --git a/ee-repo-ref.txt b/ee-repo-ref.txt deleted file mode 100644 index 6dcdb4be96..0000000000 --- a/ee-repo-ref.txt +++ /dev/null @@ -1 +0,0 @@ -bb2c05c399d27b0fadedbf6e1fa5c4f19e71f37f diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c38109cad6..291e5defa2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -844,7 +844,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -856,7 +855,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -867,7 +865,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1357,7 +1354,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1514,7 +1510,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1531,7 +1526,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1548,7 +1542,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1565,7 +1558,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1582,7 +1574,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1599,7 +1590,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1616,7 +1606,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1633,7 +1622,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1650,7 +1638,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1667,7 +1654,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1684,7 +1670,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1701,7 +1686,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1718,7 +1702,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1735,7 +1718,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1752,7 +1734,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2058,7 +2039,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6867,7 +6847,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7366,7 +7346,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7387,7 +7366,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7408,7 +7386,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7429,7 +7406,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7450,7 +7426,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7471,7 +7446,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7492,7 +7466,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7513,7 +7486,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7534,7 +7506,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7555,7 +7526,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7576,7 +7546,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12152,21 +12121,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12897,7 +12851,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/src/lib/components/CiTestResults.svelte b/frontend/src/lib/components/CiTestResults.svelte new file mode 100644 index 0000000000..b3256a5b21 --- /dev/null +++ b/frontend/src/lib/components/CiTestResults.svelte @@ -0,0 +1,75 @@ + + +{#if hasResults} +
+
+ + CI tests +
+
+ {#each results as test (test.test_script_path)} +
+ {#if test.status === 'success'} + + {:else if test.status === 'failure' || test.status === 'canceled'} + + {:else if test.status === 'running'} + + {:else} +
+ {/if} + + {test.test_script_path} + + {#if test.job_id} + + view run + + {/if} +
+ {/each} +
+
+{/if} diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 68f1625e17..0b62cea9d7 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -7,13 +7,17 @@ ArrowUp, ArrowUpRight, Building, + CircleCheck, + CircleX, DiffIcon, FileJson, + FlaskConical, GitFork, Loader2, Trash2, Upload } from 'lucide-svelte' + import type { CiTestResult } from '$lib/gen' import { Alert, Badge } from './common' import { AppService, @@ -503,6 +507,57 @@ extraLabel?: string } + let ciTestResults = $state>({}) + + async function fetchCiTests() { + if (!comparison?.diffs || !currentWorkspaceId) return + const items = comparison.diffs + .filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource') + .map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' })) + if (items.length === 0) return + try { + ciTestResults = await ScriptService.getCiTestResultsBatch({ + workspace: currentWorkspaceId, + requestBody: { items } + }) + } catch (e) { + console.error('Failed to fetch CI test results:', e) + } + } + + $effect(() => { + if (comparison && comparison.diffs.length > 0) { + fetchCiTests() + } + }) + + function getCiTestStatus(diff: WorkspaceItemDiff): 'pass' | 'fail' | 'running' | null { + const key = `${diff.kind}:${diff.path}` + const results = ciTestResults[key] + if (!results || results.length === 0) return null + if (results.some((r) => r.status === 'failure' || r.status === 'canceled')) return 'fail' + if (results.some((r) => r.status === 'running' || (r.job_id && !r.status))) return 'running' + if (results.every((r) => r.status === 'success')) return 'pass' + return null + } + + // Deduplicated list of all CI tests across all items + let allCiTests = $derived.by(() => { + const seen = new Map() + for (const results of Object.values(ciTestResults)) { + for (const r of results) { + const existing = seen.get(r.test_script_path) + if ( + !existing || + (r.started_at && (!existing.started_at || r.started_at > existing.started_at)) + ) { + seen.set(r.test_script_path, r) + } + } + } + return [...seen.values()] + }) + let forkTriggers = $state([]) let loadingTriggers = $state(true) let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined) @@ -827,6 +882,38 @@
{/snippet} + {#if allCiTests.length > 0} +
+
+ + CI tests +
+
+ {#each allCiTests as test (test.test_script_path)} +
+ {#if test.status === 'success'} + + {:else if test.status === 'failure' || test.status === 'canceled'} + + {:else if test.status === 'running'} + + {:else} +
+ {/if} + {test.test_script_path} + {#if test.job_id} + + view run + + {/if} +
+ {/each} +
+
+ {/if} {#snippet alerts()} {#if mergeIntoParent} @@ -965,6 +1052,16 @@ size="xs">New {/if} + {@const ciStatus = getCiTestStatus(diff)} + {#if ciStatus === 'pass'} + CI pass + {:else if ciStatus === 'fail'} + CI fail + {:else if ciStatus === 'running'} + CI + {/if} {#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
{#if isConflict || existsInBothWorkspaces} diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index 0b2e5ae6f8..b6addb6176 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -89,6 +89,8 @@ | 'claudesandbox' | 'wac_python' | 'wac_typescript' + | 'ci_test_bun' + | 'ci_test_python' collabMode?: boolean collabLive?: boolean collabUsers?: { name: string }[] diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index df3469f57d..92cea4bdf8 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -1,9 +1,9 @@ + +{#if open} + +{/if} diff --git a/frontend/src/lib/components/OffboardItemsBox.svelte b/frontend/src/lib/components/OffboardItemsBox.svelte new file mode 100644 index 0000000000..8e97c5bf21 --- /dev/null +++ b/frontend/src/lib/components/OffboardItemsBox.svelte @@ -0,0 +1,70 @@ + + +
+
+ + {#if headerExtra} + {@render headerExtra()} + {/if} +
+ {#if description} +

+ {description} +

+ {/if} + {#if expanded} +
    + {#each flattenPaths(paths) as { kind, path }} +
  • {kindLabel(kind)}{#if itemHref(kind, path)}{path}{:else}{path}{/if}
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/OffboardReassignControls.svelte b/frontend/src/lib/components/OffboardReassignControls.svelte new file mode 100644 index 0000000000..b15f1322de --- /dev/null +++ b/frontend/src/lib/components/OffboardReassignControls.svelte @@ -0,0 +1,72 @@ + + +{#if showTargetSelector} +
+ Move u/{username}/* items to + { + targetKind = value + }} + class={size === 'sm' ? 'mb-1.5' : 'mb-2'} + > + {#snippet children({ item })} + + + {/snippet} + + {#if targetKind === 'user'} + + {/if} +
+{/if} + +
+ Update triggers/runnables permissions to +