mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
- Materializer derives the manifest GitContext from RunTarget::validate() instead of hand-building it and re-parsing the repository slug - Drop parse_github_repository_slug and InvalidRepositorySlug, now unused - Store reuses Automation::git_target() instead of a private duplicate - Legacy TOML import returns the target directly rather than a tuple - Automation target migration updates columns with a single UPDATE ... FROM - Web: share gitTarget(), targetFromFormValues(), and one SHA validator across the automation form, list, detail, new, and edit views Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
498 lines
16 KiB
TypeScript
498 lines
16 KiB
TypeScript
import { useRef, type ReactNode } from "react";
|
|
import { Switch } from "@headlessui/react";
|
|
import type {
|
|
Automation,
|
|
AutomationTrigger,
|
|
Run,
|
|
RunProjection,
|
|
WorkflowSettings,
|
|
} from "@qltysh/fabro-api-client";
|
|
|
|
import {
|
|
findApiTrigger,
|
|
findScheduleTrigger,
|
|
gitTarget,
|
|
type GitRunTarget,
|
|
} 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;
|
|
branch: string;
|
|
tag: string;
|
|
sha: string;
|
|
workflow: string;
|
|
manualEnabled: boolean;
|
|
scheduleEnabled: boolean;
|
|
cron: string;
|
|
}
|
|
|
|
export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
|
|
id: "",
|
|
name: "",
|
|
description: "",
|
|
repository: "",
|
|
branch: "main",
|
|
tag: "",
|
|
sha: "",
|
|
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);
|
|
const target = gitTarget(automation.target);
|
|
return {
|
|
id: automation.id,
|
|
name: automation.name,
|
|
description: automation.description ?? "",
|
|
repository: target?.repo ?? "",
|
|
branch: target?.branch ?? EMPTY_AUTOMATION_FORM.branch,
|
|
tag: target?.tag ?? "",
|
|
sha: target?.sha ?? "",
|
|
workflow: automation.workflow,
|
|
manualEnabled: apiTrigger?.enabled ?? false,
|
|
scheduleEnabled: scheduleTrigger?.enabled ?? false,
|
|
cron: scheduleTrigger?.expression ?? "0 9 * * 1-5",
|
|
};
|
|
}
|
|
|
|
export function automationFormValuesFromRun(
|
|
run: Run,
|
|
runState?: RunProjection | null,
|
|
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 canonicalTarget = gitTarget(runState?.spec.target);
|
|
const repository = canonicalTarget?.repo
|
|
?? 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,
|
|
branch: canonicalTarget?.branch
|
|
?? cloneBranch
|
|
?? EMPTY_AUTOMATION_FORM.branch,
|
|
tag: canonicalTarget?.tag ?? "",
|
|
sha: canonicalTarget?.sha ?? "",
|
|
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.branch.trim() !== "" &&
|
|
isOptionalShaValid(values.sha) &&
|
|
values.workflow.trim() !== ""
|
|
);
|
|
}
|
|
|
|
const GIT_SHA_RE = /^[0-9a-fA-F]{40}$/;
|
|
|
|
/** An empty SHA means "no pin"; anything else must be a full 40-hex commit id. */
|
|
function isOptionalShaValid(sha: string): boolean {
|
|
const trimmed = sha.trim();
|
|
return trimmed === "" || GIT_SHA_RE.test(trimmed);
|
|
}
|
|
|
|
/** Canonical Git target sent in create/replace requests. */
|
|
export function targetFromFormValues(values: AutomationFormValues): GitRunTarget {
|
|
return {
|
|
kind: "git",
|
|
repo: values.repository.trim(),
|
|
branch: values.branch.trim(),
|
|
tag: values.tag.trim() || undefined,
|
|
sha: values.sha.trim().toLowerCase() || undefined,
|
|
};
|
|
}
|
|
|
|
function kebabify(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
}
|
|
|
|
function firstPresentString(...values: Array<string | null | undefined>): 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);
|
|
const shaValid = isOptionalShaValid(values.sha);
|
|
|
|
function patch(partial: Partial<AutomationFormValues>) {
|
|
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 (
|
|
<>
|
|
<Panel title="Basics">
|
|
<Row title={<Label required>Name</Label>} help="Shown wherever this automation is listed.">
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
aria-label="Automation name"
|
|
value={values.name}
|
|
onChange={(e) => onNameChange(e.target.value)}
|
|
placeholder="Fix Build"
|
|
autoComplete="off"
|
|
className={INPUT_CLASS}
|
|
/>
|
|
</Row>
|
|
{lockIdAndTarget ? null : (
|
|
<Row
|
|
title={<Label required>Slug</Label>}
|
|
help={
|
|
<>
|
|
Identifier used in the URL:{" "}
|
|
<span className="font-mono text-fg-2">/automations/{values.id || "<slug>"}</span>
|
|
</>
|
|
}
|
|
>
|
|
<input
|
|
type="text"
|
|
name="slug"
|
|
aria-label="Automation slug"
|
|
value={values.id}
|
|
onChange={(e) => onSlugChange(e.target.value)}
|
|
placeholder="fix-build"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={`${INPUT_CLASS} font-mono`}
|
|
/>
|
|
</Row>
|
|
)}
|
|
<Row title={<Label optional>Description</Label>} help="A short summary teammates will see when browsing automations.">
|
|
<textarea
|
|
name="description"
|
|
aria-label="Automation description"
|
|
value={values.description}
|
|
onChange={(e) => patch({ description: e.target.value })}
|
|
rows={2}
|
|
placeholder="Diagnose and fix CI build failures by analyzing logs and applying targeted patches."
|
|
className={`${INPUT_CLASS} resize-y`}
|
|
/>
|
|
</Row>
|
|
</Panel>
|
|
|
|
<Panel title="Source">
|
|
<Row title={<Label required>Repository</Label>} help="GitHub repository in owner/repo form.">
|
|
<input
|
|
type="text"
|
|
name="repository"
|
|
aria-label="Repository"
|
|
value={values.repository}
|
|
onChange={(e) => patch({ repository: e.target.value })}
|
|
placeholder="acme/orders-api"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={`${INPUT_CLASS} font-mono`}
|
|
/>
|
|
</Row>
|
|
<Row
|
|
title={<Label required>Working branch</Label>}
|
|
help="Attached branch retained with the run, including when a tag or exact commit is selected."
|
|
>
|
|
<input
|
|
type="text"
|
|
name="branch"
|
|
aria-label="Working branch"
|
|
value={values.branch}
|
|
onChange={(e) => patch({ branch: e.target.value })}
|
|
placeholder="main"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={`${INPUT_CLASS} font-mono`}
|
|
/>
|
|
</Row>
|
|
<Row
|
|
title={<Label optional>Tag</Label>}
|
|
help="Bare tag name resolved when the automation fires. Used only when exact SHA is empty."
|
|
>
|
|
<input
|
|
type="text"
|
|
name="tag"
|
|
aria-label="Tag"
|
|
value={values.tag}
|
|
onChange={(e) => patch({ tag: e.target.value })}
|
|
placeholder="v1.2.3"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={`${INPUT_CLASS} font-mono`}
|
|
/>
|
|
</Row>
|
|
<Row
|
|
title={<Label optional>Exact SHA</Label>}
|
|
help={
|
|
shaValid
|
|
? "A 40-character commit SHA pins exact content and takes precedence over branch and tag."
|
|
: <span className="text-coral">Enter exactly 40 hexadecimal characters.</span>
|
|
}
|
|
>
|
|
<input
|
|
type="text"
|
|
name="sha"
|
|
aria-label="Exact commit SHA"
|
|
aria-invalid={!shaValid}
|
|
value={values.sha}
|
|
onChange={(e) => patch({ sha: e.target.value })}
|
|
placeholder="0123456789abcdef0123456789abcdef01234567"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={`${INPUT_CLASS} font-mono`}
|
|
/>
|
|
</Row>
|
|
<Row
|
|
title={<Label required>Workflow slug</Label>}
|
|
help="Dash-separated identifier matching the workflow directory name (e.g. patch-cves)."
|
|
>
|
|
<input
|
|
type="text"
|
|
name="workflow_slug"
|
|
aria-label="Workflow slug"
|
|
value={values.workflow}
|
|
onChange={(e) => patch({ workflow: kebabify(e.target.value) })}
|
|
placeholder="patch-cves"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={`${INPUT_CLASS} font-mono`}
|
|
/>
|
|
</Row>
|
|
</Panel>
|
|
|
|
<Panel title="Triggers">
|
|
<Row title="Manual / API" help="Start a run by clicking Run in the UI or calling the API.">
|
|
<ToggleSwitch
|
|
checked={values.manualEnabled}
|
|
onChange={(manualEnabled) => patch({ manualEnabled })}
|
|
label="Enable manual and API triggers"
|
|
/>
|
|
</Row>
|
|
<Row title="Schedule" help="Start runs automatically on a recurring cron schedule.">
|
|
<ToggleSwitch
|
|
checked={values.scheduleEnabled}
|
|
onChange={(scheduleEnabled) => patch({ scheduleEnabled })}
|
|
label="Enable scheduled triggers"
|
|
/>
|
|
</Row>
|
|
{values.scheduleEnabled ? (
|
|
<Row
|
|
title="Cron expression"
|
|
help={
|
|
<>
|
|
Five-field POSIX cron in UTC. Next run:{" "}
|
|
<span className="text-fg-2">{describeCron(values.cron)}</span>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-2">
|
|
<input
|
|
type="text"
|
|
name="cron"
|
|
aria-label="Cron expression"
|
|
value={values.cron}
|
|
onChange={(e) => patch({ cron: e.target.value })}
|
|
placeholder="0 9 * * 1-5"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={`${INPUT_CLASS} font-mono`}
|
|
/>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{CRON_PRESETS.map((preset) => {
|
|
const active = preset.value === values.cron;
|
|
return (
|
|
<button
|
|
key={preset.value}
|
|
type="button"
|
|
onClick={() => patch({ cron: preset.value })}
|
|
aria-pressed={active}
|
|
className={`rounded-full px-2.5 py-1 text-xs transition-colors ${
|
|
active
|
|
? "bg-teal-500/15 text-teal-300 outline-1 -outline-offset-1 outline-teal-500/40"
|
|
: "bg-overlay text-fg-3 hover:bg-overlay-strong hover:text-fg-2"
|
|
}`}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</Row>
|
|
) : null}
|
|
</Panel>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Label({
|
|
children,
|
|
required,
|
|
optional,
|
|
}: {
|
|
children: ReactNode;
|
|
required?: boolean;
|
|
optional?: boolean;
|
|
}) {
|
|
return (
|
|
<span className="inline-flex items-baseline gap-1.5">
|
|
<span>{children}</span>
|
|
{required ? (
|
|
<span aria-label="required" className="text-coral">
|
|
*
|
|
</span>
|
|
) : null}
|
|
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ToggleSwitch({
|
|
checked,
|
|
onChange,
|
|
label,
|
|
}: {
|
|
checked: boolean;
|
|
onChange: (next: boolean) => void;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<Switch
|
|
checked={checked}
|
|
onChange={onChange}
|
|
aria-label={label}
|
|
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
|
|
>
|
|
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
|
|
</Switch>
|
|
);
|
|
}
|