import { useRef, type ReactNode } from "react"; import { Switch } from "@headlessui/react"; 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; repository: string; ref: string; workflow: string; manualEnabled: boolean; scheduleEnabled: boolean; cron: string; } export const EMPTY_AUTOMATION_FORM: AutomationFormValues = { id: "", name: "", description: "", repository: "", ref: "main", workflow: "", manualEnabled: true, scheduleEnabled: false, cron: "0 9 * * 1-5", }; const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ { label: "Every hour", value: "0 * * * *" }, { label: "Daily 9:00 UTC", value: "0 9 * * *" }, { label: "Weekdays 9:00", value: "0 9 * * 1-5" }, { label: "Mondays 8:00", value: "0 8 * * 1" }, ]; export function automationToFormValues(automation: Automation): AutomationFormValues { const apiTrigger = findApiTrigger(automation); const scheduleTrigger = findScheduleTrigger(automation); return { id: automation.id, name: automation.name, description: automation.description ?? "", repository: automation.target.repository, ref: automation.target.ref, workflow: automation.target.workflow, manualEnabled: apiTrigger?.enabled ?? false, scheduleEnabled: scheduleTrigger?.enabled ?? false, cron: scheduleTrigger?.expression ?? "0 9 * * 1-5", }; } 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() || kebabify(workflowName), }; } export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] { const triggers: AutomationTrigger[] = []; if (values.manualEnabled) { triggers.push({ id: "manual", type: "api", enabled: true }); } if (values.scheduleEnabled) { triggers.push({ id: "schedule", type: "schedule", enabled: true, expression: values.cron.trim(), }); } return triggers; } export function isFormValid(values: AutomationFormValues): boolean { return ( values.id.trim() !== "" && values.name.trim() !== "" && values.repository.trim() !== "" && values.ref.trim() !== "" && values.workflow.trim() !== "" ); } function kebabify(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/-+/g, "-") .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); if (preset) return preset.label; if (!/^[\d*/,\-\s]+$/.test(trimmed) || trimmed.split(/\s+/).length !== 5) { return "Waiting for a valid expression…"; } return "Computed when saved"; } interface AutomationFormFieldsProps { values: AutomationFormValues; onChange: (values: AutomationFormValues) => void; lockIdAndTarget?: boolean; } export function AutomationFormFields({ values, onChange, lockIdAndTarget = false, }: AutomationFormFieldsProps) { const slugTouchedRef = useRef(values.id.length > 0); function patch(partial: Partial) { onChange({ ...values, ...partial }); } function onNameChange(next: string) { if (slugTouchedRef.current || lockIdAndTarget) { patch({ name: next }); } else { patch({ name: next, id: kebabify(next) }); } } function onSlugChange(next: string) { slugTouchedRef.current = true; patch({ id: kebabify(next) }); } return ( <> Name} help="Shown wherever this automation is listed."> onNameChange(e.target.value)} placeholder="Fix Build" autoComplete="off" className={INPUT_CLASS} /> {lockIdAndTarget ? null : ( Slug} help={ <> Identifier used in the URL:{" "} /automations/{values.id || ""} } > onSlugChange(e.target.value)} placeholder="fix-build" autoComplete="off" spellCheck={false} className={`${INPUT_CLASS} font-mono`} /> )} Description} help="A short summary teammates will see when browsing automations.">