mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
Multi-word workflow slugs typed into the New/Edit Automation form were
being silently converted to snake_case (e.g. `patch-cves` →
`patch_cves`), causing scheduled automations to resolve against a
non-existent directory and **silently never fire**.
## What changed
- `automation-form.tsx`: `onChange` for the Workflow slug field now
calls `kebabify()` instead of the removed `snakeify()`. The "create from
run" fallback prefill is updated the same way. Help text and placeholder
are updated to reflect dash-separated slugs.
- `snakeify()` is removed entirely (was only used in these two spots).
- `kebabify()` is unexported (it was `export function`; it's now only
used within the same file).
- `automations-new.test.tsx`: updates the pre-populate assertion from
`"fix_ci"` → `"fix-ci"`, adds a regression test that dashes are
preserved and `"Patch CVEs"` → `"patch-cves"`, and adds a unit test for
the `automationFormValuesFromRun` kebab fallback.
## Why kebab-case is correct
Workflow slugs are derived from on-disk directory names
(`.fabro/workflows/patch-cves/`), which are dash-separated by
convention. The backend validator already accepts dashes; `AutomationId`
actually forbids underscores. The snake_case behavior was a UI-only
outlier present since the form's first draft with no documented
rationale.
No backend changes are needed. Existing automations with a stored
snake_cased `workflow` selector will need a manual `PUT` to correct the
value — that is an operational fix, out of scope here.
### Fabro Details
<details>
<summary>Ran 8 stages in 31m 57s for $6.85</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 20s | – | 0 |
| preflight_lint | 2m 33s | – | 0 |
| implement | 4m 28s | $2.46 | 0 |
| simplify_fable | 8m 39s | $3.31 | 0 |
| simplify_gpt | 2m 25s | $1.07 | 0 |
| verify | 11m 2s | – | 0 |
| **Total** | **31m 57s** | **$6.85** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Fable)", prompt="@prompts/simplify.md", model="claude-fable-5", reasoning_effort="xhigh"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_fable -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
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 | 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);
|
|
|
|
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>Branch</Label>} help="Default branch to run against.">
|
|
<input
|
|
type="text"
|
|
name="branch"
|
|
aria-label="Default branch"
|
|
value={values.ref}
|
|
onChange={(e) => patch({ ref: e.target.value })}
|
|
placeholder="main"
|
|
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>
|
|
);
|
|
}
|