diff --git a/Cargo.lock b/Cargo.lock index 1c0b593e9..19852f701 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1726,6 +1726,7 @@ dependencies = [ "chrono", "fabro-automation", "fabro-config", + "fabro-environment", "fabro-model", "fabro-types", "openapiv3", @@ -2366,6 +2367,7 @@ dependencies = [ "chrono", "clap", "cookie", + "croner", "dirs", "fabro-agent", "fabro-api", diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx index 5f79f4a78..4788557ec 100644 --- a/apps/fabro-web/app/components/automation-form.tsx +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -1,15 +1,21 @@ import { useRef, type ReactNode } from "react"; import { Switch } from "@headlessui/react"; -import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client"; +import type { + Automation, + AutomationTrigger, + Run, + WorkflowSettings, +} from "@qltysh/fabro-api-client"; +import { findApiTrigger, findScheduleTrigger } from "../lib/automation"; import { Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; +import { sandboxRuntime } from "../lib/run-sandbox-lifecycle"; export interface AutomationFormValues { id: string; name: string; description: string; - enabled: boolean; repository: string; ref: string; workflow: string; @@ -22,7 +28,6 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = { id: "", name: "", description: "", - enabled: true, repository: "", ref: "main", workflow: "", @@ -39,13 +44,12 @@ const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ ]; export function automationToFormValues(automation: Automation): AutomationFormValues { - const apiTrigger = automation.triggers.find((t) => t.type === "api"); - const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule"); + const apiTrigger = findApiTrigger(automation); + const scheduleTrigger = findScheduleTrigger(automation); return { id: automation.id, name: automation.name, description: automation.description ?? "", - enabled: automation.enabled, repository: automation.target.repository, ref: automation.target.ref, workflow: automation.target.workflow, @@ -55,6 +59,37 @@ export function automationToFormValues(automation: Automation): AutomationFormVa }; } +export function automationFormValuesFromRun( + run: Run, + settings?: WorkflowSettings | null, +): AutomationFormValues { + const name = firstPresentString( + run.title, + run.workflow.name, + run.workflow.graph_name, + run.workflow.slug, + "New automation", + ); + const workflowName = firstPresentString( + run.workflow.name, + run.workflow.graph_name, + name, + ); + const repository = githubRepositoryFromSettings(settings) + ?? githubRepositoryName(run.repository?.name) + ?? githubRepositoryFromOriginUrl(run.repository?.origin_url) + ?? ""; + const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch; + return { + ...EMPTY_AUTOMATION_FORM, + id: kebabify(name), + name, + repository, + ref: cloneBranch ?? EMPTY_AUTOMATION_FORM.ref, + workflow: run.workflow.slug?.trim() || snakeify(workflowName), + }; +} + export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] { const triggers: AutomationTrigger[] = []; if (values.manualEnabled) { @@ -77,8 +112,7 @@ export function isFormValid(values: AutomationFormValues): boolean { values.name.trim() !== "" && values.repository.trim() !== "" && values.ref.trim() !== "" && - values.workflow.trim() !== "" && - (values.manualEnabled || values.scheduleEnabled) + values.workflow.trim() !== "" ); } @@ -98,6 +132,54 @@ export function snakeify(value: string): string { .replace(/^_|_$/g, ""); } +function firstPresentString(...values: Array): string { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return ""; +} + +function githubRepositoryFromSettings( + settings?: WorkflowSettings | null, +): string | null { + const owner = settings?.run?.scm?.owner; + const repository = settings?.run?.scm?.repository; + if (!owner || !repository) return null; + return githubRepositoryName(`${owner}/${repository}`); +} + +function githubRepositoryName(value: string | null | undefined): string | null { + const trimmed = value?.trim().replace(/\.git$/i, ""); + if (!trimmed) return null; + + const match = trimmed.match(/^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+)$/); + if (!match) return null; + return `${match[1]}/${match[2]}`; +} + +function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + + const scpLikeMatch = trimmed.match( + /^git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i, + ); + if (scpLikeMatch) { + return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`); + } + + try { + const url = new URL(trimmed); + if (url.hostname.toLowerCase() !== "github.com") return null; + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length !== 2) return null; + return githubRepositoryName(`${parts[0]}/${parts[1]}`); + } catch { + return null; + } +} + function describeCron(expression: string): string { const trimmed = expression.trim(); const preset = CRON_PRESETS.find((p) => p.value === trimmed); @@ -187,13 +269,6 @@ export function AutomationFormFields({ className={`${INPUT_CLASS} resize-y`} /> - - patch({ enabled })} - label="Enable automation" - /> - diff --git a/apps/fabro-web/app/lib/automation.ts b/apps/fabro-web/app/lib/automation.ts new file mode 100644 index 000000000..118ab61c2 --- /dev/null +++ b/apps/fabro-web/app/lib/automation.ts @@ -0,0 +1,20 @@ +import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client"; + +type TriggerOfType = Extract< + AutomationTrigger, + { type: K } +>; + +export function findApiTrigger(automation: Automation): TriggerOfType<"api"> | undefined { + return automation.triggers.find((t): t is TriggerOfType<"api"> => t.type === "api"); +} + +export function findScheduleTrigger( + automation: Automation, +): TriggerOfType<"schedule"> | undefined { + return automation.triggers.find((t): t is TriggerOfType<"schedule"> => t.type === "schedule"); +} + +export function hasEnabledApiTrigger(automation: Automation): boolean { + return findApiTrigger(automation)?.enabled === true; +} diff --git a/apps/fabro-web/app/routes/automation-detail.tsx b/apps/fabro-web/app/routes/automation-detail.tsx index d8c3021dc..a7cd29860 100644 --- a/apps/fabro-web/app/routes/automation-detail.tsx +++ b/apps/fabro-web/app/routes/automation-detail.tsx @@ -18,6 +18,7 @@ import type { import { toRunWithStatus } from "../data/runs"; import { ApiError, apiData, automationsApi } from "../lib/api-client"; +import { findApiTrigger, findScheduleTrigger } from "../lib/automation"; import { useAutomation, useAutomationRuns } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; import { useDataUpdatedAt } from "../hooks/use-data-updated-at"; @@ -90,9 +91,9 @@ function AutomationHeader({ automation }: { automation: Automation }) { const toast = useToast(); const [running, setRunning] = useState(false); - const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule"); - const apiTrigger = automation.triggers.find((t) => t.type === "api"); - const canRun = apiTrigger?.enabled === true && automation.enabled; + const scheduleTrigger = findScheduleTrigger(automation); + const apiTrigger = findApiTrigger(automation); + const canRun = apiTrigger?.enabled === true; async function onRun() { if (!canRun || running) return; @@ -137,7 +138,6 @@ function AutomationHeader({ automation }: { automation: Automation }) { {automation.id}
- {automation.target.repository} · {automation.target.ref} @@ -165,13 +165,7 @@ function AutomationHeader({ automation }: { automation: Automation }) { type="button" onClick={onRun} disabled={!canRun || running} - title={ - !automation.enabled - ? "Enable the automation to run it" - : !apiTrigger?.enabled - ? "Enable the API trigger to run it" - : undefined - } + title={canRun ? undefined : "Enable the API trigger to run it"} className={PRIMARY_BUTTON_CLASS} >