mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-13 23:14:17 +00:00
Merge pull request #825 from swerner/codex/automation-workflow-sources
Add independent workflow sources to automations
This commit is contained in:
commit
0fd4714da7
54 changed files with 3087 additions and 500 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2380,6 +2380,7 @@ dependencies = [
|
|||
"fabro-types",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
|
|
|
|||
98
apps/fabro-web/app/components/automation-form.test.tsx
Normal file
98
apps/fabro-web/app/components/automation-form.test.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
EMPTY_AUTOMATION_FORM,
|
||||
automationFormValuesFromRun,
|
||||
automationToFormValues,
|
||||
isFormValid,
|
||||
workflowSourceFromFormValues,
|
||||
} from "./automation-form";
|
||||
|
||||
describe("automation workflow source form values", () => {
|
||||
test("the default and create-from-run forms inherit the target checkout", () => {
|
||||
expect(workflowSourceFromFormValues(EMPTY_AUTOMATION_FORM)).toBeUndefined();
|
||||
|
||||
const values = automationFormValuesFromRun({
|
||||
title: "Release",
|
||||
workflow: { name: "Release", graph_name: "release", slug: "release" },
|
||||
repository: {
|
||||
name: "fabro-sh/fabro",
|
||||
origin_url: "https://github.com/fabro-sh/fabro.git",
|
||||
},
|
||||
sandbox: null,
|
||||
} as any);
|
||||
expect(values.usesRemoteWorkflow).toBe(false);
|
||||
expect(workflowSourceFromFormValues(values)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("branch, tag, and SHA selectors serialize with target precedence", () => {
|
||||
const base = {
|
||||
...EMPTY_AUTOMATION_FORM,
|
||||
usesRemoteWorkflow: true,
|
||||
workflowSourceRepository: " fabro-sh/workflows ",
|
||||
workflowSourceBranch: " main ",
|
||||
};
|
||||
|
||||
expect(workflowSourceFromFormValues(base)).toEqual({
|
||||
repo: "fabro-sh/workflows", branch: "main",
|
||||
});
|
||||
expect(workflowSourceFromFormValues({
|
||||
...base,
|
||||
workflowSourceTag: " v1.2.3 ",
|
||||
})).toEqual({ repo: "fabro-sh/workflows", branch: "main", tag: "v1.2.3" });
|
||||
expect(workflowSourceFromFormValues({
|
||||
...base,
|
||||
workflowSourceTag: " v1.2.3 ",
|
||||
workflowSourceSha: "ABCDEF0123456789ABCDEF0123456789ABCDEF01",
|
||||
})).toEqual({
|
||||
repo: "fabro-sh/workflows",
|
||||
branch: "main",
|
||||
tag: "v1.2.3",
|
||||
sha: "abcdef0123456789abcdef0123456789abcdef01",
|
||||
});
|
||||
});
|
||||
|
||||
test("remote workflow fields are required and SHAs need 40 hex characters", () => {
|
||||
const validBase = {
|
||||
...EMPTY_AUTOMATION_FORM,
|
||||
id: "nightly",
|
||||
name: "Nightly",
|
||||
environmentId: "daytona-smoke",
|
||||
targetRepository: "fabro-sh/app",
|
||||
targetBranch: "main",
|
||||
workflow: "release",
|
||||
usesRemoteWorkflow: true,
|
||||
workflowSourceRepository: "fabro-sh/workflows",
|
||||
workflowSourceBranch: "main",
|
||||
workflowSourceSha: "0123456789abcdef0123456789abcdef01234567",
|
||||
};
|
||||
|
||||
expect(isFormValid(validBase)).toBe(true);
|
||||
expect(isFormValid({ ...validBase, workflowSourceRepository: "" })).toBe(false);
|
||||
expect(isFormValid({ ...validBase, workflowSourceBranch: "" })).toBe(false);
|
||||
expect(isFormValid({ ...validBase, workflowSourceSha: "short" })).toBe(false);
|
||||
});
|
||||
|
||||
test("editing preserves an explicit source even when it equals the target", () => {
|
||||
const values = automationToFormValues({
|
||||
id: "nightly",
|
||||
revision: "revision",
|
||||
name: "Nightly",
|
||||
description: null,
|
||||
target: { kind: "git", repo: "fabro-sh/fabro", branch: "main" },
|
||||
workflow: "release",
|
||||
workflow_source: { repo: "fabro-sh/fabro", branch: "main" },
|
||||
triggers: [],
|
||||
});
|
||||
|
||||
expect(values.usesRemoteWorkflow).toBe(true);
|
||||
expect(values.workflowSourceRepository).toBe("fabro-sh/fabro");
|
||||
expect(values.workflowSourceBranch).toBe("main");
|
||||
expect(values.workflowSourceTag).toBe("");
|
||||
expect(values.workflowSourceSha).toBe("");
|
||||
expect(workflowSourceFromFormValues({
|
||||
...values,
|
||||
usesRemoteWorkflow: false,
|
||||
})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { Link } from "react-router";
|
|||
import { Switch } from "@headlessui/react";
|
||||
import type {
|
||||
Automation,
|
||||
AutomationGitWorkflowSource,
|
||||
AutomationTrigger,
|
||||
Environment,
|
||||
Run,
|
||||
|
|
@ -26,29 +27,39 @@ export interface AutomationFormValues {
|
|||
name: string;
|
||||
description: string;
|
||||
environmentId: string;
|
||||
repository: string;
|
||||
branch: string;
|
||||
tag: string;
|
||||
sha: string;
|
||||
targetRepository: string;
|
||||
targetBranch: string;
|
||||
targetTag: string;
|
||||
targetSha: string;
|
||||
workflow: string;
|
||||
usesRemoteWorkflow: boolean;
|
||||
workflowSourceRepository: string;
|
||||
workflowSourceBranch: string;
|
||||
workflowSourceTag: string;
|
||||
workflowSourceSha: string;
|
||||
manualEnabled: boolean;
|
||||
scheduleEnabled: boolean;
|
||||
cron: string;
|
||||
}
|
||||
|
||||
export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
environmentId: "",
|
||||
repository: "",
|
||||
branch: "main",
|
||||
tag: "",
|
||||
sha: "",
|
||||
workflow: "",
|
||||
manualEnabled: true,
|
||||
scheduleEnabled: false,
|
||||
cron: "0 9 * * 1-5",
|
||||
targetRepository: "",
|
||||
targetBranch: "main",
|
||||
targetTag: "",
|
||||
targetSha: "",
|
||||
workflow: "",
|
||||
usesRemoteWorkflow: false,
|
||||
workflowSourceRepository: "",
|
||||
workflowSourceBranch: "main",
|
||||
workflowSourceTag: "",
|
||||
workflowSourceSha: "",
|
||||
manualEnabled: true,
|
||||
scheduleEnabled: false,
|
||||
cron: "0 9 * * 1-5",
|
||||
};
|
||||
|
||||
const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
|
||||
|
|
@ -62,19 +73,25 @@ export function automationToFormValues(automation: Automation): AutomationFormVa
|
|||
const apiTrigger = findApiTrigger(automation);
|
||||
const scheduleTrigger = findScheduleTrigger(automation);
|
||||
const target = gitTarget(automation.target);
|
||||
const workflowSource = automation.workflow_source;
|
||||
return {
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? "",
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? "",
|
||||
environmentId: automation.environment_id ?? "",
|
||||
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",
|
||||
targetRepository: target?.repo ?? "",
|
||||
targetBranch: target?.branch ?? EMPTY_AUTOMATION_FORM.targetBranch,
|
||||
targetTag: target?.tag ?? "",
|
||||
targetSha: target?.sha ?? "",
|
||||
workflow: automation.workflow,
|
||||
usesRemoteWorkflow: workflowSource != null,
|
||||
workflowSourceRepository: workflowSource?.repo ?? "",
|
||||
workflowSourceBranch: workflowSource?.branch ?? "main",
|
||||
workflowSourceTag: workflowSource?.tag ?? "",
|
||||
workflowSourceSha: workflowSource?.sha ?? "",
|
||||
manualEnabled: apiTrigger?.enabled ?? false,
|
||||
scheduleEnabled: scheduleTrigger?.enabled ?? false,
|
||||
cron: scheduleTrigger?.expression ?? "0 9 * * 1-5",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +114,7 @@ export function automationFormValuesFromRun(
|
|||
name,
|
||||
);
|
||||
const canonicalTarget = gitTarget(runState?.spec.target);
|
||||
const repository = canonicalTarget?.repo
|
||||
const targetRepository = canonicalTarget?.repo
|
||||
?? githubRepositoryFromSettings(settings)
|
||||
?? githubRepositoryName(run.repository?.name)
|
||||
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
|
||||
|
|
@ -112,16 +129,16 @@ export function automationFormValuesFromRun(
|
|||
: "";
|
||||
return {
|
||||
...EMPTY_AUTOMATION_FORM,
|
||||
id: kebabify(name),
|
||||
id: kebabify(name),
|
||||
name,
|
||||
environmentId,
|
||||
repository,
|
||||
branch: canonicalTarget?.branch
|
||||
targetRepository,
|
||||
targetBranch: canonicalTarget?.branch
|
||||
?? cloneBranch
|
||||
?? EMPTY_AUTOMATION_FORM.branch,
|
||||
tag: canonicalTarget?.tag ?? "",
|
||||
sha: canonicalTarget?.sha ?? "",
|
||||
workflow: run.workflow.slug?.trim() || kebabify(workflowName),
|
||||
?? EMPTY_AUTOMATION_FORM.targetBranch,
|
||||
targetTag: canonicalTarget?.tag ?? "",
|
||||
targetSha: canonicalTarget?.sha ?? "",
|
||||
workflow: run.workflow.slug?.trim() || kebabify(workflowName),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -146,10 +163,11 @@ export function isFormValid(values: AutomationFormValues): boolean {
|
|||
values.id.trim() !== "" &&
|
||||
values.name.trim() !== "" &&
|
||||
values.environmentId.trim() !== "" &&
|
||||
values.repository.trim() !== "" &&
|
||||
values.branch.trim() !== "" &&
|
||||
isOptionalShaValid(values.sha) &&
|
||||
values.workflow.trim() !== ""
|
||||
values.targetRepository.trim() !== "" &&
|
||||
values.targetBranch.trim() !== "" &&
|
||||
isOptionalShaValid(values.targetSha) &&
|
||||
values.workflow.trim() !== "" &&
|
||||
isWorkflowSourceValid(values)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -165,10 +183,31 @@ function isOptionalShaValid(sha: string): boolean {
|
|||
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,
|
||||
repo: values.targetRepository.trim(),
|
||||
branch: values.targetBranch.trim(),
|
||||
tag: values.targetTag.trim() || undefined,
|
||||
sha: values.targetSha.trim().toLowerCase() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isWorkflowSourceValid(values: AutomationFormValues): boolean {
|
||||
if (!values.usesRemoteWorkflow) return true;
|
||||
return (
|
||||
values.workflowSourceRepository.trim() !== "" &&
|
||||
values.workflowSourceBranch.trim() !== "" &&
|
||||
isOptionalShaValid(values.workflowSourceSha)
|
||||
);
|
||||
}
|
||||
|
||||
export function workflowSourceFromFormValues(
|
||||
values: AutomationFormValues,
|
||||
): AutomationGitWorkflowSource | undefined {
|
||||
if (!values.usesRemoteWorkflow) return undefined;
|
||||
return {
|
||||
repo: values.workflowSourceRepository.trim(),
|
||||
branch: values.workflowSourceBranch.trim(),
|
||||
tag: values.workflowSourceTag.trim() || undefined,
|
||||
sha: values.workflowSourceSha.trim().toLowerCase() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +295,8 @@ export function AutomationFormFields({
|
|||
environmentsError = false,
|
||||
}: AutomationFormFieldsProps) {
|
||||
const slugTouchedRef = useRef(values.id.length > 0);
|
||||
const shaValid = isOptionalShaValid(values.sha);
|
||||
const shaValid = isOptionalShaValid(values.targetSha);
|
||||
const workflowSourceShaValid = isOptionalShaValid(values.workflowSourceSha);
|
||||
const compatibleEnvironments = environments
|
||||
.filter(isCloneBasedEnvironment)
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
|
|
@ -380,14 +420,17 @@ export function AutomationFormFields({
|
|||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Source">
|
||||
<Row title={<Label required>Repository</Label>} help="GitHub repository in owner/repo form.">
|
||||
<Panel title="Run target">
|
||||
<Row
|
||||
title={<Label required>Repository</Label>}
|
||||
help="GitHub repository whose workspace the run changes, in owner/repo form."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="repository"
|
||||
aria-label="Repository"
|
||||
value={values.repository}
|
||||
onChange={(e) => patch({ repository: e.target.value })}
|
||||
name="target_repository"
|
||||
aria-label="Run target repository"
|
||||
value={values.targetRepository}
|
||||
onChange={(e) => patch({ targetRepository: e.target.value })}
|
||||
placeholder="acme/orders-api"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
|
|
@ -400,10 +443,10 @@ export function AutomationFormFields({
|
|||
>
|
||||
<input
|
||||
type="text"
|
||||
name="branch"
|
||||
name="target_branch"
|
||||
aria-label="Working branch"
|
||||
value={values.branch}
|
||||
onChange={(e) => patch({ branch: e.target.value })}
|
||||
value={values.targetBranch}
|
||||
onChange={(e) => patch({ targetBranch: e.target.value })}
|
||||
placeholder="main"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
|
|
@ -416,10 +459,10 @@ export function AutomationFormFields({
|
|||
>
|
||||
<input
|
||||
type="text"
|
||||
name="tag"
|
||||
name="target_tag"
|
||||
aria-label="Tag"
|
||||
value={values.tag}
|
||||
onChange={(e) => patch({ tag: e.target.value })}
|
||||
value={values.targetTag}
|
||||
onChange={(e) => patch({ targetTag: e.target.value })}
|
||||
placeholder="v1.2.3"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
|
|
@ -436,20 +479,27 @@ export function AutomationFormFields({
|
|||
>
|
||||
<input
|
||||
type="text"
|
||||
name="sha"
|
||||
name="target_sha"
|
||||
aria-label="Exact commit SHA"
|
||||
aria-invalid={!shaValid}
|
||||
value={values.sha}
|
||||
onChange={(e) => patch({ sha: e.target.value })}
|
||||
value={values.targetSha}
|
||||
onChange={(e) => patch({ targetSha: e.target.value })}
|
||||
placeholder="0123456789abcdef0123456789abcdef01234567"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Workflow">
|
||||
<Row
|
||||
title={<Label required>Workflow slug</Label>}
|
||||
help="Dash-separated identifier matching the workflow directory name (e.g. patch-cves)."
|
||||
help={
|
||||
values.usesRemoteWorkflow
|
||||
? "Dash-separated identifier resolved in the remote workflow checkout."
|
||||
: "Dash-separated identifier resolved in the run target checkout."
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -463,6 +513,89 @@ export function AutomationFormFields({
|
|||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title="Remote workflow"
|
||||
help="Load workflow files from a GitHub repository and revision instead of the run target checkout. The repository may match the run target."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={values.usesRemoteWorkflow}
|
||||
onChange={(usesRemoteWorkflow) => patch({ usesRemoteWorkflow })}
|
||||
label="Use a remote workflow"
|
||||
/>
|
||||
</Row>
|
||||
{values.usesRemoteWorkflow ? (
|
||||
<>
|
||||
<Row
|
||||
title={<Label required>Workflow repository</Label>}
|
||||
help="GitHub owner/repo containing the workflow files."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="workflow_source_repository"
|
||||
aria-label="Remote workflow repository"
|
||||
value={values.workflowSourceRepository}
|
||||
onChange={(e) => patch({ workflowSourceRepository: e.target.value })}
|
||||
placeholder="acme/automation-workflows"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Branch</Label>}
|
||||
help="Fallback revision and audit context. An exact SHA does not need to be reachable from this branch."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="workflow_source_branch"
|
||||
aria-label="Remote workflow branch"
|
||||
value={values.workflowSourceBranch}
|
||||
onChange={(e) => patch({ workflowSourceBranch: 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="workflow_source_tag"
|
||||
aria-label="Remote workflow tag"
|
||||
value={values.workflowSourceTag}
|
||||
onChange={(e) => patch({ workflowSourceTag: 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={
|
||||
workflowSourceShaValid
|
||||
? "A 40-character commit SHA takes precedence over tag and branch. It is fetched directly and need not be reachable from the named branch."
|
||||
: <span className="text-coral">Enter exactly 40 hexadecimal characters.</span>
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="workflow_source_sha"
|
||||
aria-label="Remote workflow exact commit SHA"
|
||||
aria-invalid={!workflowSourceShaValid}
|
||||
value={values.workflowSourceSha}
|
||||
onChange={(e) => patch({ workflowSourceSha: e.target.value })}
|
||||
placeholder="0123456789abcdef0123456789abcdef01234567"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Triggers">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { Automation, AutomationTrigger, RunTarget } from "@qltysh/fabro-api-client";
|
||||
import type {
|
||||
Automation,
|
||||
AutomationGitWorkflowSource,
|
||||
AutomationTrigger,
|
||||
RunTarget,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
export type GitRunTarget = Extract<RunTarget, { kind: "git" }>;
|
||||
|
||||
|
|
@ -27,3 +32,16 @@ export function findScheduleTrigger(
|
|||
export function hasEnabledApiTrigger(automation: Automation): boolean {
|
||||
return findApiTrigger(automation)?.enabled === true;
|
||||
}
|
||||
|
||||
export function workflowSourceSummary(source: AutomationGitWorkflowSource): string {
|
||||
if (source.sha) return `${source.repo} · commit ${source.sha}`;
|
||||
if (source.tag) return `${source.repo} · tag ${source.tag}`;
|
||||
return `${source.repo} · branch ${source.branch}`;
|
||||
}
|
||||
|
||||
export const RUN_TARGET_CHECKOUT_LABEL = "run target checkout";
|
||||
|
||||
/** Where an automation's workflow files come from, for display. */
|
||||
export function workflowSourceLabel(source: AutomationGitWorkflowSource | undefined): string {
|
||||
return source ? workflowSourceSummary(source) : RUN_TARGET_CHECKOUT_LABEL;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
findApiTrigger,
|
||||
findScheduleTrigger,
|
||||
gitTarget,
|
||||
workflowSourceLabel,
|
||||
} from "../lib/automation";
|
||||
import { useAutomation, useAutomationRuns } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
|
|
@ -146,7 +147,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
|
|||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<Chip icon={FolderIcon}>
|
||||
{target?.repo ?? UNSUPPORTED_TARGET_LABEL}
|
||||
Run target · {target?.repo ?? UNSUPPORTED_TARGET_LABEL}
|
||||
{target ? (
|
||||
<span className="text-fg-muted/70">
|
||||
{" · "}{target.branch}
|
||||
|
|
@ -155,7 +156,9 @@ function AutomationHeader({ automation }: { automation: Automation }) {
|
|||
</span>
|
||||
) : null}
|
||||
</Chip>
|
||||
<Chip icon={RectangleStackIcon}>{automation.workflow}</Chip>
|
||||
<Chip icon={RectangleStackIcon}>
|
||||
Workflow · {automation.workflow} · {workflowSourceLabel(automation.workflow_source)}
|
||||
</Chip>
|
||||
<Chip icon={CubeTransparentIcon}>
|
||||
{automation.environment_id ?? (
|
||||
<span className="text-coral">Environment required</span>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
isFormValid,
|
||||
targetFromFormValues,
|
||||
triggersFromFormValues,
|
||||
workflowSourceFromFormValues,
|
||||
type AutomationFormValues,
|
||||
} from "../components/automation-form";
|
||||
import { Panel, PanelSkeleton } from "../components/settings-panel";
|
||||
|
|
@ -109,6 +110,7 @@ function EditAutomationForm({
|
|||
environment_id: values.environmentId.trim(),
|
||||
target: targetFromFormValues(values),
|
||||
workflow: values.workflow.trim(),
|
||||
workflow_source: workflowSourceFromFormValues(values),
|
||||
triggers: triggersFromFormValues(values),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ describe("AutomationsNew", () => {
|
|||
|
||||
expect(fieldValue(renderer, "Automation name")).toBe("");
|
||||
expect(fieldValue(renderer, "Automation slug")).toBe("");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("");
|
||||
expect(fieldValue(renderer, "Run target repository")).toBe("");
|
||||
expect(fieldValue(renderer, "Working branch")).toBe("main");
|
||||
expect(fieldValue(renderer, "Tag")).toBe("");
|
||||
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
|
||||
|
|
@ -310,6 +310,8 @@ describe("AutomationsNew", () => {
|
|||
expect(fieldValue(renderer, "Automation environment")).toBe("");
|
||||
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
|
||||
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
|
||||
expect(switchChecked(renderer, "Use a remote workflow")).toBe(false);
|
||||
expect(renderer.root.findAllByProps({ "aria-label": "Remote workflow repository" })).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("environment selector offers Docker and Daytona but not local", async () => {
|
||||
|
|
@ -340,7 +342,7 @@ describe("AutomationsNew", () => {
|
|||
test("creation sends the selected environment id", async () => {
|
||||
const { renderer } = await renderAutomationsNew("/automations/new");
|
||||
changeField(renderer, "Automation name", "Nightly");
|
||||
changeField(renderer, "Repository", "fabro-sh/fabro");
|
||||
changeField(renderer, "Run target repository", "fabro-sh/fabro");
|
||||
changeField(renderer, "Workflow slug", "hello");
|
||||
changeField(renderer, "Automation environment", "daytona-smoke");
|
||||
|
||||
|
|
@ -372,7 +374,7 @@ describe("AutomationsNew", () => {
|
|||
|
||||
expect(fieldValue(renderer, "Automation name")).toBe("Fix failing tests");
|
||||
expect(fieldValue(renderer, "Automation slug")).toBe("fix-failing-tests");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("qltysh/fabro");
|
||||
expect(fieldValue(renderer, "Run target repository")).toBe("qltysh/fabro");
|
||||
expect(fieldValue(renderer, "Working branch")).toBe("feature/from-run");
|
||||
expect(fieldValue(renderer, "Tag")).toBe("");
|
||||
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
|
||||
|
|
@ -380,6 +382,7 @@ describe("AutomationsNew", () => {
|
|||
expect(fieldValue(renderer, "Automation environment")).toBe("default");
|
||||
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
|
||||
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
|
||||
expect(switchChecked(renderer, "Use a remote workflow")).toBe(false);
|
||||
expect(
|
||||
renderer.root.findAllByProps({ "aria-label": "Cron expression" }),
|
||||
).toHaveLength(0);
|
||||
|
|
@ -405,7 +408,7 @@ describe("AutomationsNew", () => {
|
|||
|
||||
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
|
||||
|
||||
expect(fieldValue(renderer, "Repository")).toBe("canonical/repo");
|
||||
expect(fieldValue(renderer, "Run target repository")).toBe("canonical/repo");
|
||||
expect(fieldValue(renderer, "Working branch")).toBe("release");
|
||||
expect(fieldValue(renderer, "Tag")).toBe("v2.0.0");
|
||||
expect(fieldValue(renderer, "Exact commit SHA")).toBe(
|
||||
|
|
@ -436,8 +439,41 @@ describe("AutomationsNew", () => {
|
|||
expect(textFromNode(renderer.toJSON())).toContain("could not be loaded");
|
||||
expect(textFromNode(renderer.toJSON())).toContain("fill it out manually");
|
||||
expect(fieldValue(renderer, "Automation name")).toBe("");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("");
|
||||
expect(fieldValue(renderer, "Run target repository")).toBe("");
|
||||
expect(fieldValue(renderer, "Working branch")).toBe("main");
|
||||
expect(fieldValue(renderer, "Workflow slug")).toBe("");
|
||||
});
|
||||
|
||||
test("submits a canonical explicit workflow source", async () => {
|
||||
const { renderer } = await renderAutomationsNew("/automations/new");
|
||||
changeField(renderer, "Automation name", "Nightly");
|
||||
changeField(renderer, "Run target repository", "fabro-sh/app");
|
||||
changeField(renderer, "Automation environment", "daytona-smoke");
|
||||
changeField(renderer, "Workflow slug", "release");
|
||||
act(() => {
|
||||
byLabel(renderer, "Use a remote workflow").props.onChange(true);
|
||||
});
|
||||
changeField(renderer, "Remote workflow repository", " fabro-sh/workflows ");
|
||||
changeField(renderer, "Remote workflow branch", " release ");
|
||||
changeField(renderer, "Remote workflow tag", " v2.0.0 ");
|
||||
changeField(
|
||||
renderer,
|
||||
"Remote workflow exact commit SHA",
|
||||
"ABCDEF0123456789ABCDEF0123456789ABCDEF01",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await renderer.root.findByType("form").props.onSubmit({ preventDefault() {} });
|
||||
});
|
||||
|
||||
expect(createAutomationMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAutomationMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
workflow_source: {
|
||||
repo: "fabro-sh/workflows",
|
||||
branch: "release",
|
||||
tag: "v2.0.0",
|
||||
sha: "abcdef0123456789abcdef0123456789abcdef01",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
isFormValid,
|
||||
targetFromFormValues,
|
||||
triggersFromFormValues,
|
||||
workflowSourceFromFormValues,
|
||||
type AutomationFormValues,
|
||||
} from "../components/automation-form";
|
||||
import {
|
||||
|
|
@ -137,6 +138,7 @@ function AutomationCreateForm({
|
|||
environment_id: values.environmentId.trim(),
|
||||
target: targetFromFormValues(values),
|
||||
workflow: values.workflow.trim(),
|
||||
workflow_source: workflowSourceFromFormValues(values),
|
||||
triggers: triggersFromFormValues(values),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ import type { Automation, AutomationListResponse } from "@qltysh/fabro-api-clien
|
|||
import { Link, useNavigate } from "react-router";
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import {
|
||||
RUN_TARGET_CHECKOUT_LABEL,
|
||||
UNSUPPORTED_TARGET_LABEL,
|
||||
findScheduleTrigger,
|
||||
gitTarget,
|
||||
hasEnabledApiTrigger,
|
||||
workflowSourceSummary,
|
||||
} from "../lib/automation";
|
||||
import { useAutomations } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
|
|
@ -55,6 +57,7 @@ interface AutomationRow {
|
|||
workflow: string;
|
||||
repository: string;
|
||||
environmentId: string | null;
|
||||
workflowSource?: string;
|
||||
schedule?: string;
|
||||
apiEnabled: boolean;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
|
|
@ -96,6 +99,9 @@ function mapAutomations(result: AutomationListResponse | undefined): AutomationR
|
|||
workflow: a.workflow,
|
||||
repository: target?.repo ?? UNSUPPORTED_TARGET_LABEL,
|
||||
environmentId: a.environment_id,
|
||||
workflowSource: a.workflow_source
|
||||
? workflowSourceSummary(a.workflow_source)
|
||||
: undefined,
|
||||
schedule: findScheduleTrigger(a)?.expression,
|
||||
apiEnabled: hasEnabledApiTrigger(a),
|
||||
icon: slugIconMap[a.workflow] ?? CodeBracketIcon,
|
||||
|
|
@ -149,11 +155,14 @@ function AutomationCard({
|
|||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{automation.repository}
|
||||
Run target · {automation.repository}
|
||||
<span className={automation.environmentId ? "" : " text-coral"}>
|
||||
{" · "}{automation.environmentId ?? "environment required"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-fg-muted">
|
||||
Workflow source · {automation.workflowSource ?? RUN_TARGET_CHECKOUT_LABEL}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
|
|
@ -291,7 +300,8 @@ export default function Automations() {
|
|||
(triggerFilter === "manual" && a.schedule == null)) &&
|
||||
(a.name.toLowerCase().includes(lowerQuery) ||
|
||||
a.workflow.toLowerCase().includes(lowerQuery) ||
|
||||
a.repository.toLowerCase().includes(lowerQuery)),
|
||||
a.repository.toLowerCase().includes(lowerQuery) ||
|
||||
a.workflowSource?.toLowerCase().includes(lowerQuery)),
|
||||
);
|
||||
|
||||
async function confirmDelete() {
|
||||
|
|
|
|||
|
|
@ -6728,6 +6728,84 @@ components:
|
|||
|
||||
# ── Automations ──────────────────────────────────────────────────────
|
||||
|
||||
AutomationGitWorkflowSource:
|
||||
description: >-
|
||||
Explicit GitHub coordinate from which an automation acquires workflow
|
||||
bytes. The branch is the fallback selector and audit context. An
|
||||
optional tag overrides the branch, and an optional exact SHA overrides
|
||||
both without requiring branch ancestry. This source is independent of
|
||||
the run target and does not provide its working branch.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- repo
|
||||
- branch
|
||||
properties:
|
||||
repo:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 140
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+$"
|
||||
description: GitHub repository slug in `owner/name` form.
|
||||
example: acme/workflows
|
||||
branch:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
pattern: "^[A-Za-z0-9/._-]+$"
|
||||
description: >-
|
||||
Required bare branch name used when neither tag nor SHA is present.
|
||||
It is retained as context when an override is present and is not
|
||||
an ancestry constraint.
|
||||
example: main
|
||||
tag:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
pattern: "^[A-Za-z0-9/._-]+$"
|
||||
description: >-
|
||||
Optional bare tag name. Without `sha`, this tag is resolved whenever
|
||||
the automation fires. Prefixes such as `refs/tags/` are rejected.
|
||||
example: v1.2.3
|
||||
sha:
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{40}$"
|
||||
description: >-
|
||||
Optional exact commit, authoritative over tag and branch. The
|
||||
server lowercase-normalizes it and fetches it directly; it need
|
||||
not be reachable from the named branch.
|
||||
|
||||
ResolvedAutomationGitWorkflowSource:
|
||||
description: >-
|
||||
Workflow source coordinate and exact commit captured when an
|
||||
automation run was created. The requested selectors remain available
|
||||
for audit context while `resolved_sha` identifies the immutable source
|
||||
revision that supplied the workflow bytes.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- repo
|
||||
- branch
|
||||
- resolved_sha
|
||||
properties:
|
||||
repo:
|
||||
type: string
|
||||
description: GitHub repository slug in `owner/name` form.
|
||||
branch:
|
||||
type: string
|
||||
description: Required branch fallback and audit context.
|
||||
tag:
|
||||
type: string
|
||||
description: Optional tag requested by the automation.
|
||||
sha:
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{40}$"
|
||||
description: Optional exact commit requested by the automation.
|
||||
resolved_sha:
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{40}$"
|
||||
description: Exact lowercase Git commit that supplied the workflow bytes.
|
||||
|
||||
Automation:
|
||||
description: Public automation definition.
|
||||
type: object
|
||||
|
|
@ -6772,8 +6850,13 @@ components:
|
|||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
description: >-
|
||||
Workflow slug or path resolved in the run-target checkout when
|
||||
`workflow_source` is omitted, or in the explicit workflow-source
|
||||
checkout when present.
|
||||
example: dependency-update
|
||||
workflow_source:
|
||||
$ref: "#/components/schemas/AutomationGitWorkflowSource"
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -6867,8 +6950,13 @@ components:
|
|||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
description: >-
|
||||
Workflow slug or path resolved in the run-target checkout when
|
||||
`workflow_source` is omitted, or in the explicit workflow-source
|
||||
checkout when present.
|
||||
example: dependency-update
|
||||
workflow_source:
|
||||
$ref: "#/components/schemas/AutomationGitWorkflowSource"
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -6899,8 +6987,13 @@ components:
|
|||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
description: >-
|
||||
Workflow slug or path resolved in the run-target checkout when
|
||||
`workflow_source` is omitted, or in the explicit workflow-source
|
||||
checkout when present.
|
||||
example: dependency-update
|
||||
workflow_source:
|
||||
$ref: "#/components/schemas/AutomationGitWorkflowSource"
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -12090,6 +12183,11 @@ components:
|
|||
type: ["string", "null"]
|
||||
trigger_id:
|
||||
type: ["string", "null"]
|
||||
workflow_source:
|
||||
description: Resolved workflow source for automation runs that declare one.
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ResolvedAutomationGitWorkflowSource"
|
||||
- type: "null"
|
||||
|
||||
RunOrigin:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: "Automations"
|
|||
description: "Named, repeatable run configurations with API and schedule triggers"
|
||||
---
|
||||
|
||||
An **automation** is a saved run configuration — a Git repository, working branch, optional tag or exact commit, workflow, and server-managed environment — plus the triggers that may start it. When a trigger fires, Fabro packages the selected workflow as an immutable workflow version and admits it through the same `RunIntent` pipeline as `POST /api/v1/runs`. Automation runs therefore get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
|
||||
An **automation** is a saved run configuration — a Git run target, a workflow, a server-managed environment, and the triggers that may start it. By default, Fabro loads the workflow from the run-target checkout. An automation can instead name an independent GitHub repository and branch, tag, or exact commit for its workflow files. When a trigger fires, Fabro packages the selected workflow as an immutable workflow version and admits it through the same `RunIntent` pipeline as `POST /api/v1/runs`. Automation runs therefore get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
|
||||
|
||||
## Defining automations
|
||||
|
||||
|
|
@ -45,6 +45,39 @@ An extensionless workflow such as `"release"` resolves directly to `.fabro/workf
|
|||
|
||||
Automation admission does not read `.fabro/project.toml`. Put settings needed by the run in the workflow configuration or the selected server environment. Fabro packages the workflow and its runnable dependencies into immutable workflow versions before creating the run.
|
||||
|
||||
### Using a remote workflow
|
||||
|
||||
Omit `workflow_source` to resolve the `workflow` selector in the run-target checkout, as in the request above. This is the compatibility default for existing definitions.
|
||||
|
||||
To keep reusable workflow files in another repository, enable a remote workflow and provide the same branch-plus-overrides coordinate used by Git run targets:
|
||||
|
||||
```json title="Create automation with a remote workflow"
|
||||
{
|
||||
"id": "nightly-release",
|
||||
"name": "Nightly release",
|
||||
"environment_id": "default",
|
||||
"target": {
|
||||
"kind": "git",
|
||||
"repo": "acme/orders-api",
|
||||
"branch": "main"
|
||||
},
|
||||
"workflow": "release",
|
||||
"workflow_source": {
|
||||
"repo": "acme/automation-workflows",
|
||||
"branch": "main"
|
||||
},
|
||||
"triggers": [
|
||||
{ "type": "api", "id": "manual", "enabled": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`branch` is required and is used when neither override is present. An optional bare `tag` takes precedence over the branch, and an optional 40-character `sha` takes precedence over both. The branch is retained as fallback and audit context; Fabro fetches an exact SHA directly and does not require it to be reachable from the named branch. Branches and tags are resolved again on every firing. The server uses its configured GitHub credentials independently for the target and workflow-source repositories, with read-only repository access for workflow materialization; automation requests never carry credentials.
|
||||
|
||||
Fabro resolves the run target to an exact commit and checks out the selected workflow source before it creates a run. It packages the workflow and its dependencies into immutable, content-addressed workflow versions, then admits the run with the root workflow-version ID and the independently exact run target. The run's automation metadata records both the requested workflow-source coordinate and its resolved commit, so the mutable source can be audited after its branch or tag moves. If an explicit source names the same repository and effective selector as the target, Fabro reuses the checkout without removing the explicit saved source.
|
||||
|
||||
If target or source authentication, checkout, workflow discovery, packaging, or workflow-version storage fails, Fabro creates no run and sends no start request.
|
||||
|
||||
### Upgrading legacy targets
|
||||
|
||||
When upgrading from file-backed automation storage, startup imports every valid `automations/*.toml` file next to the active `settings.toml`. Existing SQLite definitions win on ID conflicts. After a successful import, Fabro renames the directory to a timestamped backup such as `automations.imported-20260711T180000000000Z.bak`. Invalid TOML or an invalid target leaves the original directory untouched for operator repair.
|
||||
|
|
@ -86,7 +119,7 @@ The same conversion runs transactionally for automations already in SQLite. An u
|
|||
|
||||
Automations created before environment selection was introduced are backfilled conservatively. Fabro selects a compatible environment named `default` when one exists, or the sole Docker or Daytona environment when there is exactly one. With no compatible environment or multiple ambiguous choices, the automation remains incomplete until an operator selects one in the web UI. An incomplete automation cannot run.
|
||||
|
||||
When a trigger fires, Fabro prepares the repository at the selected branch, tag, or exact commit, packages the workflow, and creates and starts the run. The created run records the exact checked-out commit in its canonical target, so later inspection and automation creation preserve the revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
|
||||
When a trigger fires, Fabro resolves the run target and selected workflow checkout, packages the workflow, and creates and starts the run. The created run records the exact checked-out target commit in its canonical target, so later inspection and automation creation preserve the target revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
|
||||
|
||||
## Triggers
|
||||
|
||||
|
|
@ -114,7 +147,7 @@ The server fires each enabled schedule trigger at its next occurrence and create
|
|||
|
||||
The `/automations` area lists automations with create, edit, delete, and Run actions. The create and edit forms require a Docker or Daytona environment. Migrated automations without an environment are shown as incomplete and cannot run until edited. Saves are revision-checked, so concurrent edits fail loudly instead of silently overwriting each other. The detail page shows the automation's configuration, its most recent schedule error, and its run history with status, time, and repo filters.
|
||||
|
||||
To bootstrap an automation from work you have already run, open a run's actions menu and choose **Create automation from run** — the new-automation form is pre-filled from that run's repository and workflow. Runs that were created by an automation show **View automation** instead.
|
||||
To bootstrap an automation from work you have already run, open a run's actions menu and choose **Create automation from run** — the new-automation form is pre-filled from that run's target repository and workflow. Its workflow source defaults to the target checkout because normal run summaries do not retain the automation's mutable source coordinate. You can enable a remote workflow before saving. Runs that were created by an automation show **View automation** instead.
|
||||
|
||||
## API
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -104,7 +104,8 @@ impl GitRepoCache {
|
|||
.join(format!("{}.git", repo.repo()))
|
||||
}
|
||||
|
||||
/// Prepare a worktree containing the requested ref of `repo` at
|
||||
/// Prepare a worktree containing the requested ref of `repo`, fetched from
|
||||
/// `clone_url`, at
|
||||
/// `worktree_dir`. Returns the resolved commit SHA.
|
||||
///
|
||||
/// First call for a repo: a `--bare --depth 1` clone is created at
|
||||
|
|
@ -117,14 +118,6 @@ impl GitRepoCache {
|
|||
pub(crate) async fn prepare_worktree(
|
||||
&self,
|
||||
args: WorktreePrepareInput<'_>,
|
||||
) -> Result<String, GitCheckoutError> {
|
||||
let clone_url = github_clone_url(args.repo);
|
||||
self.prepare_worktree_with_clone_url(args, &clone_url).await
|
||||
}
|
||||
|
||||
async fn prepare_worktree_with_clone_url(
|
||||
&self,
|
||||
args: WorktreePrepareInput<'_>,
|
||||
clone_url: &str,
|
||||
) -> Result<String, GitCheckoutError> {
|
||||
let _guard = self
|
||||
|
|
@ -179,7 +172,7 @@ impl GitRepoCache {
|
|||
.map_err(|source| GitCheckoutError::Clone { source })?;
|
||||
}
|
||||
|
||||
let fetch_target = GitFetchTarget::from(args.target);
|
||||
let fetch_target = args.selector;
|
||||
run_git_plan(build_bare_fetch_plan(
|
||||
bare_dir,
|
||||
clone_url,
|
||||
|
|
@ -202,18 +195,19 @@ impl GitRepoCache {
|
|||
|
||||
pub(crate) struct WorktreePrepareInput<'a> {
|
||||
pub repo: &'a GitHubRepositorySlug,
|
||||
pub target: &'a GitRunTarget,
|
||||
pub selector: GitCheckoutSelector<'a>,
|
||||
pub auth: Option<&'a GitAuthConfig>,
|
||||
pub worktree_dir: &'a Path,
|
||||
}
|
||||
|
||||
enum GitFetchTarget<'a> {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GitCheckoutSelector<'a> {
|
||||
Branch(&'a str),
|
||||
Tag(&'a str),
|
||||
Commit(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> From<&'a GitRunTarget> for GitFetchTarget<'a> {
|
||||
impl<'a> From<&'a GitRunTarget> for GitCheckoutSelector<'a> {
|
||||
fn from(target: &'a GitRunTarget) -> Self {
|
||||
if let Some(sha) = target.sha.as_deref() {
|
||||
Self::Commit(sha)
|
||||
|
|
@ -225,7 +219,7 @@ impl<'a> From<&'a GitRunTarget> for GitFetchTarget<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
impl GitFetchTarget<'_> {
|
||||
impl GitCheckoutSelector<'_> {
|
||||
fn selector(&self) -> Cow<'_, str> {
|
||||
match self {
|
||||
Self::Branch(selector) | Self::Commit(selector) => Cow::Borrowed(selector),
|
||||
|
|
@ -268,7 +262,7 @@ async fn bare_clone_may_be_corrupt(bare_dir: &Path) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
fn github_clone_url(repo: &GitHubRepositorySlug) -> String {
|
||||
pub(crate) fn github_clone_url(repo: &GitHubRepositorySlug) -> String {
|
||||
let mut url = repo.https_url();
|
||||
url.push_str(".git");
|
||||
url
|
||||
|
|
@ -276,40 +270,36 @@ fn github_clone_url(repo: &GitHubRepositorySlug) -> String {
|
|||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GitAuthConfig {
|
||||
extraheader: Option<String>,
|
||||
extraheader: String,
|
||||
sensitive_values: Vec<String>,
|
||||
}
|
||||
|
||||
impl GitAuthConfig {
|
||||
fn new(username: Option<String>, password: Option<String>) -> Self {
|
||||
let Some(password) = password.filter(|value| !value.is_empty()) else {
|
||||
return Self {
|
||||
extraheader: None,
|
||||
sensitive_values: Vec::new(),
|
||||
};
|
||||
};
|
||||
let username = username
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "x-access-token".to_string());
|
||||
pub(crate) fn new(credentials: &fabro_github::GitCloneCredentials) -> Self {
|
||||
Self::from_parts(credentials.username(), credentials.password())
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(username: &str, password: &str) -> Self {
|
||||
let encoded_credentials = BASE64_STANDARD.encode(format!("{username}:{password}"));
|
||||
let extraheader = basic_auth_header_from_encoded(&encoded_credentials);
|
||||
Self {
|
||||
sensitive_values: vec![password, encoded_credentials, extraheader.clone()],
|
||||
extraheader: Some(extraheader),
|
||||
sensitive_values: vec![
|
||||
password.to_string(),
|
||||
encoded_credentials,
|
||||
extraheader.clone(),
|
||||
],
|
||||
extraheader,
|
||||
}
|
||||
}
|
||||
|
||||
fn git_env(&self, clone_url: &str) -> Vec<(String, String)> {
|
||||
let Some(extraheader) = self.extraheader.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
vec![
|
||||
("GIT_CONFIG_COUNT".to_string(), "1".to_string()),
|
||||
(
|
||||
"GIT_CONFIG_KEY_0".to_string(),
|
||||
format!("http.{clone_url}.extraheader"),
|
||||
),
|
||||
("GIT_CONFIG_VALUE_0".to_string(), extraheader.clone()),
|
||||
("GIT_CONFIG_VALUE_0".to_string(), self.extraheader.clone()),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -318,7 +308,7 @@ impl GitAuthConfig {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_git_auth_config(
|
||||
pub(crate) async fn resolve_git_read_auth_config(
|
||||
credentials: Option<&fabro_github::GitHubCredentials>,
|
||||
repo: &GitHubRepositorySlug,
|
||||
github_api_base_url: &str,
|
||||
|
|
@ -333,9 +323,10 @@ pub(crate) async fn resolve_git_auth_config(
|
|||
}
|
||||
None => fabro_github::GitHubContext::new(credentials, github_api_base_url),
|
||||
};
|
||||
let (username, password) =
|
||||
fabro_github::resolve_clone_credentials(&context, repo.owner(), repo.repo()).await?;
|
||||
Ok(Some(GitAuthConfig::new(username, password)))
|
||||
let credentials =
|
||||
fabro_github::resolve_read_only_clone_credentials(&context, repo.owner(), repo.repo())
|
||||
.await?;
|
||||
Ok(Some(GitAuthConfig::new(&credentials)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -575,6 +566,69 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn workflow_source(branch: &str, tag: Option<&str>, sha: Option<&str>) -> GitRunTarget {
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: branch.to_string(),
|
||||
tag: tag.map(str::to_string),
|
||||
sha: sha.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_selectors_use_sha_then_tag_then_branch_precedence() {
|
||||
let target = git_target(
|
||||
"main",
|
||||
Some("v1"),
|
||||
Some("abcdef0123456789abcdef0123456789abcdef01"),
|
||||
);
|
||||
assert_eq!(
|
||||
GitCheckoutSelector::from(&target),
|
||||
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01")
|
||||
);
|
||||
|
||||
for (source, expected) in [
|
||||
(
|
||||
workflow_source("main", None, None),
|
||||
GitCheckoutSelector::Branch("main"),
|
||||
),
|
||||
(
|
||||
workflow_source("main", Some("v1"), None),
|
||||
GitCheckoutSelector::Tag("v1"),
|
||||
),
|
||||
(
|
||||
workflow_source(
|
||||
"unrelated-context",
|
||||
Some("v1"),
|
||||
Some("abcdef0123456789abcdef0123456789abcdef01"),
|
||||
),
|
||||
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01"),
|
||||
),
|
||||
] {
|
||||
assert_eq!(GitCheckoutSelector::from(&source), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_reuse_identity_folds_only_repository_case() {
|
||||
assert_eq!(
|
||||
repository_slug("Fabro-Sh/Workflows"),
|
||||
repository_slug("fabro-sh/workflows")
|
||||
);
|
||||
assert_ne!(
|
||||
GitCheckoutSelector::Branch("Main"),
|
||||
GitCheckoutSelector::Branch("main")
|
||||
);
|
||||
assert_ne!(
|
||||
GitCheckoutSelector::Branch("v1"),
|
||||
GitCheckoutSelector::Tag("v1")
|
||||
);
|
||||
assert_eq!(
|
||||
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01"),
|
||||
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_repository_urls_are_github_metadata_urls_without_credentials() {
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
|
|
@ -690,10 +744,7 @@ mod tests {
|
|||
fn credential_config_env_keeps_clone_url_uncredentialed() {
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
let clone_url = github_clone_url(&repo);
|
||||
let auth = GitAuthConfig::new(
|
||||
Some("x-access-token".to_string()),
|
||||
Some("ghu_secret".to_string()),
|
||||
);
|
||||
let auth = GitAuthConfig::from_parts("x-access-token", "ghu_secret");
|
||||
let plan = build_bare_clone_plan(&clone_url, Path::new("/tmp/fabro-checkout"), Some(&auth));
|
||||
|
||||
assert!(
|
||||
|
|
@ -807,10 +858,10 @@ mod tests {
|
|||
|
||||
let worktree_a = temp.path().join("wt-a");
|
||||
let sha_a = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
.prepare_worktree(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
selector: GitCheckoutSelector::from(&target),
|
||||
auth: None,
|
||||
worktree_dir: &worktree_a,
|
||||
},
|
||||
|
|
@ -829,10 +880,10 @@ mod tests {
|
|||
|
||||
let worktree_b = temp.path().join("wt-b");
|
||||
let sha_b = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
.prepare_worktree(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
selector: GitCheckoutSelector::from(&target),
|
||||
auth: None,
|
||||
worktree_dir: &worktree_b,
|
||||
},
|
||||
|
|
@ -860,10 +911,10 @@ mod tests {
|
|||
|
||||
let worktree_a = temp.path().join("wt-a");
|
||||
cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
.prepare_worktree(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
selector: GitCheckoutSelector::from(&target),
|
||||
auth: None,
|
||||
worktree_dir: &worktree_a,
|
||||
},
|
||||
|
|
@ -878,10 +929,10 @@ mod tests {
|
|||
|
||||
let worktree_b = temp.path().join("wt-b");
|
||||
let sha = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
.prepare_worktree(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
selector: GitCheckoutSelector::from(&target),
|
||||
auth: None,
|
||||
worktree_dir: &worktree_b,
|
||||
},
|
||||
|
|
@ -916,10 +967,10 @@ mod tests {
|
|||
("commit", git_target("main", None, Some(&expected_sha))),
|
||||
] {
|
||||
let sha = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
.prepare_worktree(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
selector: GitCheckoutSelector::from(&target),
|
||||
auth: None,
|
||||
worktree_dir: &temp.path().join(name),
|
||||
},
|
||||
|
|
@ -944,10 +995,10 @@ mod tests {
|
|||
let unavailable_commit = git_target("main", None, Some(unavailable_sha));
|
||||
|
||||
let tag_error = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
.prepare_worktree(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &missing_tag,
|
||||
selector: GitCheckoutSelector::from(&missing_tag),
|
||||
auth: None,
|
||||
worktree_dir: &temp.path().join("missing-tag"),
|
||||
},
|
||||
|
|
@ -961,10 +1012,10 @@ mod tests {
|
|||
));
|
||||
|
||||
let commit_error = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
.prepare_worktree(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &unavailable_commit,
|
||||
selector: GitCheckoutSelector::from(&unavailable_commit),
|
||||
auth: None,
|
||||
worktree_dir: &temp.path().join("missing-commit"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1025,9 +1025,10 @@ include = ["reports/{{ vars.path }}/*.json"]
|
|||
let run_id = RunId::new();
|
||||
let parent_id = RunId::new();
|
||||
let automation = AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule".to_string()),
|
||||
workflow_source: None,
|
||||
};
|
||||
let submitted = b"submitted manifest".to_vec();
|
||||
let workflow_version_id = fabro_types::test_support::test_workflow_version_id();
|
||||
|
|
|
|||
|
|
@ -1205,7 +1205,7 @@ impl AppState {
|
|||
let credentials = self
|
||||
.github_credentials(&settings.server.integrations.github)
|
||||
.await
|
||||
.map_err(|source| RunMaterializeError::Credentials { source })?;
|
||||
.map_err(|source| RunMaterializeError::LoadCredentials { source })?;
|
||||
ProductionAutomationRunMaterializer::new(
|
||||
credentials,
|
||||
self.github_api_base_url.clone(),
|
||||
|
|
|
|||
|
|
@ -263,7 +263,8 @@ async fn fire_scheduled_automation_run(
|
|||
.materialize_automation_run(AutomationRunMaterializeInput {
|
||||
automation_id: automation_id.clone(),
|
||||
target,
|
||||
workflow: automation.workflow.clone(),
|
||||
workflow_source: automation.workflow_source,
|
||||
workflow: automation.workflow,
|
||||
run_id,
|
||||
temp_root: state.automation_temp_root(),
|
||||
})
|
||||
|
|
@ -286,9 +287,10 @@ async fn fire_scheduled_automation_run(
|
|||
system_kind: SystemActorKind::Engine,
|
||||
};
|
||||
let automation_ref = AutomationRef {
|
||||
id: automation_id.to_string(),
|
||||
name: Some(automation.name.clone()),
|
||||
trigger_id: Some(trigger_id.to_string()),
|
||||
id: automation_id.to_string(),
|
||||
name: Some(automation.name.clone()),
|
||||
trigger_id: Some(trigger_id.to_string()),
|
||||
workflow_source: materialized.workflow_source.clone(),
|
||||
};
|
||||
// RunIntent admission produces a large future; box it to keep our
|
||||
// stack frame small (matches handler/automations.rs).
|
||||
|
|
@ -388,10 +390,12 @@ fn run_due_schedules_once<'a>(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_automation::{AutomationDraft, AutomationTrigger, ScheduleTrigger};
|
||||
use fabro_automation::{
|
||||
AutomationDraft, AutomationGitWorkflowSource, AutomationTrigger, ScheduleTrigger,
|
||||
};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::ListRunsQuery;
|
||||
use fabro_types::{GitRunTarget, RunStatus, RunTarget};
|
||||
use fabro_types::{GitRunTarget, ResolvedAutomationGitWorkflowSource, RunStatus, RunTarget};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::{TestAppStateBuilder, TestAutomationRunMaterializer};
|
||||
|
|
@ -432,6 +436,7 @@ mod tests {
|
|||
environment_id: Some("default".to_string()),
|
||||
last_error: None,
|
||||
target: target(),
|
||||
workflow_source: None,
|
||||
workflow: "workflow.fabro".to_string(),
|
||||
triggers,
|
||||
}
|
||||
|
|
@ -442,6 +447,16 @@ mod tests {
|
|||
id: &str,
|
||||
name: &str,
|
||||
triggers: Vec<AutomationTrigger>,
|
||||
) -> Automation {
|
||||
create_automation_with_source(state, id, name, None, triggers).await
|
||||
}
|
||||
|
||||
async fn create_automation_with_source(
|
||||
state: &AppState,
|
||||
id: &str,
|
||||
name: &str,
|
||||
workflow_source: Option<AutomationGitWorkflowSource>,
|
||||
triggers: Vec<AutomationTrigger>,
|
||||
) -> Automation {
|
||||
state
|
||||
.automation_store()
|
||||
|
|
@ -451,6 +466,7 @@ mod tests {
|
|||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow_source,
|
||||
workflow: "workflow.fabro".to_string(),
|
||||
triggers,
|
||||
})
|
||||
|
|
@ -701,6 +717,48 @@ mod tests {
|
|||
assert_eq!(cached_runs(state.as_ref()).await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_run_passes_saved_workflow_source_to_materialization() {
|
||||
let materializer = succeeding_materializer();
|
||||
let state = test_state_with_materializer(materializer.clone());
|
||||
let workflow_source = AutomationGitWorkflowSource {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: "context-only".to_string(),
|
||||
tag: Some("v1".to_string()),
|
||||
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
};
|
||||
create_automation_with_source(
|
||||
state.as_ref(),
|
||||
"scheduled-source",
|
||||
"scheduled-source",
|
||||
Some(workflow_source.clone()),
|
||||
vec![schedule_trigger("schedule", "* * * * *", true)],
|
||||
)
|
||||
.await;
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
|
||||
let captured = materializer.captured_inputs();
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(captured[0].workflow_source, Some(workflow_source.clone()));
|
||||
let runs = cached_runs(state.as_ref()).await;
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(
|
||||
runs[0]
|
||||
.automation
|
||||
.as_ref()
|
||||
.and_then(|automation| automation.workflow_source.clone()),
|
||||
Some(Box::new(
|
||||
ResolvedAutomationGitWorkflowSource::from_requested(
|
||||
workflow_source,
|
||||
"ffffffffffffffffffffffffffffffffffffffff".to_string(),
|
||||
)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_schedule_trigger_does_not_create_run() {
|
||||
let materializer = succeeding_materializer();
|
||||
|
|
@ -802,4 +860,30 @@ mod tests {
|
|||
assert!(cached_runs(state.as_ref()).await.is_empty());
|
||||
assert_eq!(materializer.captured_inputs().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_source_failure_creates_and_starts_no_scheduled_run() {
|
||||
let materializer = TestAutomationRunMaterializer::fail_invalid_workflow_source();
|
||||
let state = test_state_with_materializer(materializer.clone());
|
||||
create_automation_with_source(
|
||||
state.as_ref(),
|
||||
"failing-source",
|
||||
"failing-source",
|
||||
Some(AutomationGitWorkflowSource {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
}),
|
||||
vec![schedule_trigger("schedule", "* * * * *", true)],
|
||||
)
|
||||
.await;
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
|
||||
assert!(cached_runs(state.as_ref()).await.is_empty());
|
||||
assert_eq!(materializer.captured_inputs().len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ async fn create_automation_run(
|
|||
.materialize_automation_run(AutomationRunMaterializeInput {
|
||||
automation_id: automation.id.clone(),
|
||||
target,
|
||||
workflow_source: automation.workflow_source.clone(),
|
||||
workflow: automation.workflow.clone(),
|
||||
run_id,
|
||||
temp_root: state.automation_temp_root(),
|
||||
|
|
@ -153,9 +154,10 @@ async fn create_automation_run(
|
|||
}
|
||||
};
|
||||
let automation_ref = AutomationRef {
|
||||
id: automation.id.to_string(),
|
||||
name: Some(automation.name.clone()),
|
||||
trigger_id: Some(api_trigger_id),
|
||||
id: automation.id.to_string(),
|
||||
name: Some(automation.name.clone()),
|
||||
trigger_id: Some(api_trigger_id),
|
||||
workflow_source: materialized.workflow_source.clone(),
|
||||
};
|
||||
|
||||
let response = Box::pin(runs::create_run_from_intent(
|
||||
|
|
|
|||
|
|
@ -4586,9 +4586,10 @@ async fn create_run_from_manifest_helper_persists_automation_metadata_and_exact_
|
|||
let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
let run_id = RunId::new();
|
||||
let automation = fabro_types::AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule".to_string()),
|
||||
workflow_source: None,
|
||||
};
|
||||
let target = RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
|
|
@ -4645,9 +4646,10 @@ async fn create_run_from_intent_helper_persists_automation_version_and_exact_tar
|
|||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let run_id = RunId::new();
|
||||
let automation = fabro_types::AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule".to_string()),
|
||||
workflow_source: None,
|
||||
};
|
||||
let target = RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
|
|
@ -5083,6 +5085,7 @@ async fn fake_automation_materializer_injection_captures_input_and_returns_versi
|
|||
.materialize_automation_run(AutomationRunMaterializeInput {
|
||||
automation_id: AutomationId::new("nightly").unwrap(),
|
||||
target: target.clone(),
|
||||
workflow_source: None,
|
||||
workflow: "demo".to_string(),
|
||||
run_id,
|
||||
temp_root: temp_root.clone(),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use fabro_automation::AutomationGitWorkflowSource;
|
||||
use fabro_config::Storage;
|
||||
use fabro_server::server::build_router;
|
||||
use fabro_server::test_support::{
|
||||
|
|
@ -1034,6 +1035,47 @@ async fn incomplete_legacy_automation_fails_before_materialization() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_triggered_run_passes_saved_workflow_source_to_materialization() {
|
||||
let materializer = TestAutomationRunMaterializer::succeed(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
});
|
||||
let (app, _temp_dir, _automation_dir) = automation_app_with_materializer(materializer.clone());
|
||||
let mut body = automation_body("nightly", "Nightly");
|
||||
body["workflow_source"] = json!({
|
||||
"repo": "fabro-sh/workflows",
|
||||
"branch": "main",
|
||||
"tag": "release-v1"
|
||||
});
|
||||
create_automation_with_body(&app, &body).await;
|
||||
|
||||
let created = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
|
||||
|
||||
let captured = materializer.captured_workflow_sources();
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(
|
||||
captured[0],
|
||||
Some(AutomationGitWorkflowSource {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: Some("release-v1".to_string()),
|
||||
sha: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
created["automation"]["workflow_source"],
|
||||
json!({
|
||||
"repo": "fabro-sh/workflows",
|
||||
"branch": "main",
|
||||
"tag": "release-v1",
|
||||
"resolved_sha": "ffffffffffffffffffffffffffffffffffffffff"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_triggered_automation_with_missing_version_does_not_create_or_start_a_run() {
|
||||
let materializer = TestAutomationRunMaterializer::return_unstored_version(GitRunTarget {
|
||||
|
|
@ -1053,6 +1095,29 @@ async fn api_triggered_automation_with_missing_version_does_not_create_or_start_
|
|||
assert_eq!(runs["data"], json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_workflow_source_failure_does_not_create_or_start_a_run() {
|
||||
let materializer = TestAutomationRunMaterializer::fail_invalid_workflow_source();
|
||||
let (app, _temp_dir, _automation_dir) = automation_app_with_materializer(materializer);
|
||||
let mut body = automation_body("nightly", "Nightly");
|
||||
body["workflow_source"] = json!({
|
||||
"repo": "fabro-sh/workflows",
|
||||
"branch": "main"
|
||||
});
|
||||
create_automation_with_body(&app, &body).await;
|
||||
|
||||
let error = create_automation_run(&app, "nightly", StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
|
||||
assert!(
|
||||
error["errors"][0]["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|detail| detail.contains("workflow source"))
|
||||
);
|
||||
let runs = list_automation_runs(&app, "/automations/nightly/runs").await;
|
||||
assert_eq!(runs["meta"]["total"], 0);
|
||||
assert_eq!(runs["data"], json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_run_listing_includes_only_runs_for_that_automation() {
|
||||
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
||||
|
|
|
|||
|
|
@ -28,5 +28,6 @@ tracing.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
anyhow.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "test-util"] }
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ fn parse_legacy_automation(
|
|||
environment_id: None,
|
||||
target,
|
||||
workflow,
|
||||
workflow_source: None,
|
||||
triggers: legacy.triggers,
|
||||
})
|
||||
.map_err(|source| AutomationStoreError::StoredValidation { id, source })
|
||||
|
|
|
|||
|
|
@ -62,12 +62,13 @@ pub async fn backfill_environment_selectors(
|
|||
for automation in &incomplete {
|
||||
store
|
||||
.replace(&automation.id, &automation.revision, AutomationReplace {
|
||||
name: automation.name.clone(),
|
||||
description: automation.description.clone(),
|
||||
environment_id: Some(environment_id.clone()),
|
||||
target: automation.target.clone(),
|
||||
workflow: automation.workflow.clone(),
|
||||
triggers: automation.triggers.clone(),
|
||||
name: automation.name.clone(),
|
||||
description: automation.description.clone(),
|
||||
environment_id: Some(environment_id.clone()),
|
||||
target: automation.target.clone(),
|
||||
workflow: automation.workflow.clone(),
|
||||
workflow_source: automation.workflow_source.clone(),
|
||||
triggers: automation.triggers.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use croner::errors::CronError;
|
||||
use fabro_types::TargetValidationError;
|
||||
use fabro_types::{GitCoordinateValidationError, TargetValidationError};
|
||||
use toml::de::Error as TomlDeError;
|
||||
use toml::ser::Error as TomlSerError;
|
||||
|
||||
|
|
@ -24,6 +24,11 @@ pub enum AutomationValidationError {
|
|||
#[source]
|
||||
source: TargetValidationError,
|
||||
},
|
||||
#[error("automation workflow source is invalid")]
|
||||
InvalidWorkflowSource {
|
||||
#[source]
|
||||
source: GitCoordinateValidationError,
|
||||
},
|
||||
#[error("workflow selector {value:?} is not safe")]
|
||||
InvalidWorkflowSelector { value: String },
|
||||
#[error("duplicate automation trigger id {id:?}")]
|
||||
|
|
@ -77,6 +82,8 @@ pub enum AutomationStoreError {
|
|||
},
|
||||
#[error("stored automation {id} has an invalid trigger row")]
|
||||
StoredTriggerShape { id: AutomationId },
|
||||
#[error("stored automation {id} has a partial workflow source coordinate")]
|
||||
StoredWorkflowSourceShape { id: AutomationId },
|
||||
#[error("stored automation {id} has an invalid revision")]
|
||||
InvalidRevision {
|
||||
id: AutomationId,
|
||||
|
|
@ -163,6 +170,7 @@ impl AutomationStoreError {
|
|||
Self::StoredValidation { .. } => "stored_validation",
|
||||
Self::StoredId { .. } => "stored_id",
|
||||
Self::StoredTriggerShape { .. } => "stored_trigger_shape",
|
||||
Self::StoredWorkflowSourceShape { .. } => "stored_workflow_source_shape",
|
||||
Self::InvalidRevision { .. } => "invalid_revision",
|
||||
Self::Db { .. } => "db",
|
||||
Self::InvalidFilename { .. } => "invalid_filename",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub use migrations::{
|
|||
import_legacy_directory_once,
|
||||
};
|
||||
pub use model::{
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTrigger, ScheduleTrigger,
|
||||
parse_schedule_expression,
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationGitWorkflowSource, AutomationReplace,
|
||||
AutomationTrigger, ScheduleTrigger, parse_schedule_expression, validate_workflow_source,
|
||||
};
|
||||
pub use store::AutomationStore;
|
||||
|
|
|
|||
|
|
@ -34,19 +34,21 @@ pub fn parse_schedule_expression(expression: &str) -> Result<Cron, CronError> {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Automation {
|
||||
pub id: AutomationId,
|
||||
pub revision: AutomationRevision,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub id: AutomationId,
|
||||
pub revision: AutomationRevision,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Server-managed environment selected when the automation fires. Legacy
|
||||
/// rows may be incomplete until an operator selects one.
|
||||
pub environment_id: Option<String>,
|
||||
pub environment_id: Option<String>,
|
||||
/// Most recent scheduler failure. Runtime status is not part of the
|
||||
/// optimistic-concurrency revision.
|
||||
pub last_error: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
pub last_error: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_source: Option<AutomationGitWorkflowSource>,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
|
|
@ -140,11 +142,35 @@ impl Automation {
|
|||
last_error: None,
|
||||
target: replace.target,
|
||||
workflow: replace.workflow,
|
||||
workflow_source: replace.workflow_source,
|
||||
triggers: replace.triggers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Git coordinate from which an automation loads workflow files.
|
||||
///
|
||||
/// This deliberately reuses the run target's branch/tag/SHA model. The branch
|
||||
/// is the fallback selector and audit context; it does not constrain an exact
|
||||
/// SHA to be reachable from that branch.
|
||||
pub type AutomationGitWorkflowSource = GitRunTarget;
|
||||
|
||||
/// Validate and canonicalize a saved workflow source without resolving remote
|
||||
/// repository state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the repository slug, branch, tag, or exact commit
|
||||
/// does not use the canonical Git-coordinate grammar.
|
||||
pub fn validate_workflow_source(
|
||||
source: AutomationGitWorkflowSource,
|
||||
) -> Result<AutomationGitWorkflowSource, AutomationValidationError> {
|
||||
source
|
||||
.validate()
|
||||
.map(fabro_types::ValidatedGitRunTarget::into_target)
|
||||
.map_err(|source| AutomationValidationError::InvalidWorkflowSource { source })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum AutomationTrigger {
|
||||
|
|
@ -200,26 +226,29 @@ pub struct ScheduleTrigger {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AutomationDraft {
|
||||
pub id: AutomationId,
|
||||
pub name: String,
|
||||
pub id: AutomationId,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub environment_id: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
pub environment_id: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_source: Option<AutomationGitWorkflowSource>,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
||||
impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
|
||||
fn from(value: AutomationDraft) -> Self {
|
||||
(value.id, AutomationReplace {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
environment_id: value.environment_id,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
triggers: value.triggers,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
environment_id: value.environment_id,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
workflow_source: value.workflow_source,
|
||||
triggers: value.triggers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -227,39 +256,44 @@ impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AutomationReplace {
|
||||
pub name: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub environment_id: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
pub environment_id: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_source: Option<AutomationGitWorkflowSource>,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PersistedAutomation {
|
||||
name: String,
|
||||
name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
environment_id: Option<String>,
|
||||
target: RunTarget,
|
||||
workflow: String,
|
||||
environment_id: Option<String>,
|
||||
target: RunTarget,
|
||||
workflow: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
workflow_source: Option<AutomationGitWorkflowSource>,
|
||||
#[serde(default)]
|
||||
triggers: Vec<AutomationTrigger>,
|
||||
triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
||||
impl From<AutomationReplace> for PersistedAutomation {
|
||||
fn from(value: AutomationReplace) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
environment_id: value.environment_id,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
triggers: value.triggers,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
environment_id: value.environment_id,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
workflow_source: value.workflow_source,
|
||||
triggers: value.triggers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -267,12 +301,13 @@ impl From<AutomationReplace> for PersistedAutomation {
|
|||
impl From<PersistedAutomation> for AutomationReplace {
|
||||
fn from(value: PersistedAutomation) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
environment_id: value.environment_id,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
triggers: value.triggers,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
environment_id: value.environment_id,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
workflow_source: value.workflow_source,
|
||||
triggers: value.triggers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -321,6 +356,10 @@ fn normalize_replace(
|
|||
.environment_id
|
||||
.map(|environment_id| environment_id.trim().to_string())
|
||||
.filter(|environment_id| !environment_id.is_empty());
|
||||
value.workflow_source = value
|
||||
.workflow_source
|
||||
.map(validate_workflow_source)
|
||||
.transpose()?;
|
||||
validate_fields(&value, require_environment)?;
|
||||
|
||||
let api_enabled = value
|
||||
|
|
@ -431,11 +470,14 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::{GitRunTarget, RunTarget, TargetValidationError};
|
||||
use fabro_types::{
|
||||
GitCoordinateValidationError, GitRunTarget, RunTarget, TargetValidationError,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ApiTrigger, Automation, AutomationId, AutomationReplace, AutomationTrigger,
|
||||
AutomationTriggerId, AutomationValidationError, ScheduleTrigger,
|
||||
ApiTrigger, Automation, AutomationGitWorkflowSource, AutomationId, AutomationReplace,
|
||||
AutomationStoreError, AutomationTrigger, AutomationTriggerId, AutomationValidationError,
|
||||
ScheduleTrigger,
|
||||
};
|
||||
|
||||
fn target() -> RunTarget {
|
||||
|
|
@ -466,6 +508,170 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
fn workflow_source(
|
||||
branch: &str,
|
||||
tag: Option<&str>,
|
||||
sha: Option<&str>,
|
||||
) -> AutomationGitWorkflowSource {
|
||||
AutomationGitWorkflowSource {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: branch.to_string(),
|
||||
tag: tag.map(str::to_string),
|
||||
sha: sha.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_with_source(
|
||||
workflow_source: Option<AutomationGitWorkflowSource>,
|
||||
) -> AutomationReplace {
|
||||
AutomationReplace {
|
||||
name: "Nightly".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source,
|
||||
triggers: vec![api_trigger("manual")],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_workflow_source_preserves_canonical_bytes_and_revision() {
|
||||
let expected = concat!(
|
||||
"name = \"Nightly\"\n",
|
||||
"environment_id = \"default\"\n",
|
||||
"workflow = \"release\"\n",
|
||||
"\n",
|
||||
"[target]\n",
|
||||
"kind = \"git\"\n",
|
||||
"repo = \"fabro-sh/fabro\"\n",
|
||||
"branch = \"main\"\n",
|
||||
"\n",
|
||||
"[[triggers]]\n",
|
||||
"type = \"api\"\n",
|
||||
"id = \"manual\"\n",
|
||||
"enabled = true\n",
|
||||
);
|
||||
|
||||
let (automation, bytes) = Automation::from_replace(
|
||||
AutomationId::new("nightly").unwrap(),
|
||||
replace_with_source(None),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(automation.workflow_source, None);
|
||||
assert_eq!(bytes, expected.as_bytes());
|
||||
assert_eq!(
|
||||
automation.revision.as_str(),
|
||||
"bc26bacc9ed091f4f171c8fdaf45cd319549a50b4291a5c93205028094abf385"
|
||||
);
|
||||
|
||||
let decoded =
|
||||
Automation::from_toml_bytes(AutomationId::new("nightly").unwrap(), expected.as_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(decoded.workflow_source, None);
|
||||
assert_eq!(
|
||||
serde_json::to_value(decoded)
|
||||
.unwrap()
|
||||
.get("workflow_source"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_sources_round_trip_and_shas_are_canonicalized() {
|
||||
for (source, expected_sha) in [
|
||||
(workflow_source("main", None, None), None),
|
||||
(workflow_source("main", Some("release/v1"), None), None),
|
||||
(
|
||||
workflow_source(
|
||||
"context-only",
|
||||
Some("release/v1"),
|
||||
Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01"),
|
||||
),
|
||||
Some("abcdef0123456789abcdef0123456789abcdef01"),
|
||||
),
|
||||
] {
|
||||
let (automation, bytes) = Automation::from_replace(
|
||||
AutomationId::new("nightly").unwrap(),
|
||||
replace_with_source(Some(source)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let source = automation.workflow_source.as_ref().unwrap();
|
||||
assert_eq!(source.sha.as_deref(), expected_sha);
|
||||
assert!(
|
||||
String::from_utf8(bytes.clone())
|
||||
.unwrap()
|
||||
.contains("[workflow_source]")
|
||||
);
|
||||
|
||||
let decoded =
|
||||
Automation::from_toml_bytes(AutomationId::new("nightly").unwrap(), &bytes).unwrap();
|
||||
assert_eq!(decoded.workflow_source, automation.workflow_source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_workflow_source_changes_revision_and_is_never_collapsed() {
|
||||
let (omitted, _) = Automation::from_replace(
|
||||
AutomationId::new("nightly").unwrap(),
|
||||
replace_with_source(None),
|
||||
)
|
||||
.unwrap();
|
||||
let mut explicit_source = workflow_source("main", None, None);
|
||||
explicit_source.repo = "FABRO-SH/FABRO".to_string();
|
||||
let (explicit, _) = Automation::from_replace(
|
||||
AutomationId::new("nightly").unwrap(),
|
||||
replace_with_source(Some(explicit_source.clone())),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(explicit.revision, omitted.revision);
|
||||
assert_eq!(explicit.workflow_source, Some(explicit_source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_source_validation_reports_the_invalid_coordinate_part() {
|
||||
let cases = [
|
||||
(
|
||||
workflow_source("main", None, None),
|
||||
GitCoordinateValidationError::Repository,
|
||||
),
|
||||
(
|
||||
workflow_source("refs/heads/main", None, None),
|
||||
GitCoordinateValidationError::Branch,
|
||||
),
|
||||
(
|
||||
workflow_source("main", Some("tags/v1"), None),
|
||||
GitCoordinateValidationError::Tag,
|
||||
),
|
||||
(
|
||||
workflow_source("main", None, Some("short")),
|
||||
GitCoordinateValidationError::Sha,
|
||||
),
|
||||
];
|
||||
|
||||
for (mut source, expected) in cases {
|
||||
if expected == GitCoordinateValidationError::Repository {
|
||||
source.repo = "not/a/github/slug".to_string();
|
||||
}
|
||||
let error = Automation::from_replace(
|
||||
AutomationId::new("nightly").unwrap(),
|
||||
replace_with_source(Some(source)),
|
||||
)
|
||||
.unwrap_err();
|
||||
let AutomationStoreError::Validation { source } = error else {
|
||||
panic!("expected validation error");
|
||||
};
|
||||
assert!(matches!(
|
||||
source,
|
||||
AutomationValidationError::InvalidWorkflowSource { source }
|
||||
if source == expected
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_toml_applies_defaults_and_canonicalizes_without_id_or_revision() {
|
||||
let bytes = br#"
|
||||
|
|
@ -530,12 +736,13 @@ enabled = true
|
|||
fn enabled_schedule_triggers_returns_only_enabled_schedule_triggers() {
|
||||
let (automation, _) =
|
||||
Automation::from_replace(AutomationId::new("nightly").unwrap(), AutomationReplace {
|
||||
name: "Nightly".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
|
||||
triggers: vec![
|
||||
name: "Nightly".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![
|
||||
api_trigger("manual"),
|
||||
schedule_trigger_with_enabled("nightly", "0 0 * * *", true),
|
||||
schedule_trigger_with_enabled("disabled", "0 1 * * *", false),
|
||||
|
|
@ -581,81 +788,89 @@ enabled = true
|
|||
fn validation_rejects_invalid_inputs() {
|
||||
let cases = [
|
||||
AutomationReplace {
|
||||
name: " ".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![api_trigger("manual")],
|
||||
name: " ".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad repo".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
name: "Bad repo".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
repo: "not/github/slug".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
}),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![api_trigger("manual")],
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad ref".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
name: "Bad ref".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main;rm".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
}),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![api_trigger("manual")],
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad workflow".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "../release".to_string(),
|
||||
triggers: vec![api_trigger("manual")],
|
||||
name: "Bad workflow".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "../release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Duplicate trigger".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![
|
||||
name: "Duplicate trigger".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![
|
||||
api_trigger("manual"),
|
||||
schedule_trigger("manual", "0 0 * * *"),
|
||||
],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Two API triggers".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![api_trigger("one"), api_trigger("two")],
|
||||
name: "Two API triggers".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![api_trigger("one"), api_trigger("two")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Six field cron".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
|
||||
name: "Six field cron".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad cron".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
|
||||
name: "Bad cron".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ use sqlx::sqlite::SqliteRow;
|
|||
use sqlx::{Row as _, Sqlite, Transaction};
|
||||
|
||||
use crate::{
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
|
||||
AutomationStoreError, AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationGitWorkflowSource, AutomationId,
|
||||
AutomationReplace, AutomationRevision, AutomationStoreError, AutomationTrigger,
|
||||
AutomationTriggerId, ScheduleTrigger,
|
||||
};
|
||||
|
||||
/// Shared projection for loading automations with their schedule triggers.
|
||||
|
|
@ -28,6 +29,10 @@ macro_rules! select_automations_sql {
|
|||
a.target_tag,
|
||||
a.target_sha,
|
||||
a.target_workflow,
|
||||
a.workflow_source_repository,
|
||||
a.workflow_source_branch,
|
||||
a.workflow_source_tag,
|
||||
a.workflow_source_sha,
|
||||
t.id AS trigger_id,
|
||||
t.enabled AS trigger_enabled,
|
||||
t.expression AS trigger_expression
|
||||
|
|
@ -125,6 +130,7 @@ impl AutomationStore {
|
|||
) -> Result<Automation, AutomationStoreError> {
|
||||
let (automation, _) = Automation::from_replace(id.clone(), draft)?;
|
||||
let target = stored_git_target(&automation);
|
||||
let workflow_source = automation.workflow_source.as_ref();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
r"
|
||||
|
|
@ -139,7 +145,11 @@ impl AutomationStore {
|
|||
target_branch = ?,
|
||||
target_tag = ?,
|
||||
target_sha = ?,
|
||||
target_workflow = ?
|
||||
target_workflow = ?,
|
||||
workflow_source_repository = ?,
|
||||
workflow_source_branch = ?,
|
||||
workflow_source_tag = ?,
|
||||
workflow_source_sha = ?
|
||||
WHERE id = ? AND revision = ?
|
||||
",
|
||||
)
|
||||
|
|
@ -153,6 +163,10 @@ impl AutomationStore {
|
|||
.bind(target.tag.as_deref())
|
||||
.bind(target.sha.as_deref())
|
||||
.bind(&automation.workflow)
|
||||
.bind(workflow_source.map(|source| source.repo.as_str()))
|
||||
.bind(workflow_source.map(|source| source.branch.as_str()))
|
||||
.bind(workflow_source.and_then(|source| source.tag.as_deref()))
|
||||
.bind(workflow_source.and_then(|source| source.sha.as_deref()))
|
||||
.bind(id.as_str())
|
||||
.bind(expected.as_str())
|
||||
.execute(&mut *transaction)
|
||||
|
|
@ -199,6 +213,7 @@ struct StoredAutomation {
|
|||
api_enabled: bool,
|
||||
target: RunTarget,
|
||||
workflow: String,
|
||||
workflow_source: Option<AutomationGitWorkflowSource>,
|
||||
schedule_triggers: Vec<ScheduleTrigger>,
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +231,7 @@ impl StoredAutomation {
|
|||
id: id.clone(),
|
||||
source,
|
||||
})?;
|
||||
let workflow_source = stored_workflow_source(row, &id)?;
|
||||
Ok(Self {
|
||||
id,
|
||||
revision,
|
||||
|
|
@ -231,6 +247,7 @@ impl StoredAutomation {
|
|||
sha: row.try_get("target_sha")?,
|
||||
}),
|
||||
workflow: row.try_get("target_workflow")?,
|
||||
workflow_source,
|
||||
schedule_triggers: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
|
@ -279,6 +296,7 @@ impl StoredAutomation {
|
|||
environment_id: self.environment_id,
|
||||
target: self.target,
|
||||
workflow: self.workflow,
|
||||
workflow_source: self.workflow_source,
|
||||
triggers,
|
||||
})
|
||||
.map_err(|source| AutomationStoreError::StoredValidation { id, source })?;
|
||||
|
|
@ -324,6 +342,7 @@ pub(crate) async fn insert_automation_ignoring_conflict(
|
|||
automation: &Automation,
|
||||
) -> Result<bool, AutomationStoreError> {
|
||||
let target = stored_git_target(automation);
|
||||
let workflow_source = automation.workflow_source.as_ref();
|
||||
let result = sqlx::query(
|
||||
r"
|
||||
INSERT INTO automations (
|
||||
|
|
@ -337,8 +356,12 @@ pub(crate) async fn insert_automation_ignoring_conflict(
|
|||
target_branch,
|
||||
target_tag,
|
||||
target_sha,
|
||||
target_workflow
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
target_workflow,
|
||||
workflow_source_repository,
|
||||
workflow_source_branch,
|
||||
workflow_source_tag,
|
||||
workflow_source_sha
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
",
|
||||
)
|
||||
|
|
@ -353,6 +376,10 @@ pub(crate) async fn insert_automation_ignoring_conflict(
|
|||
.bind(target.tag.as_deref())
|
||||
.bind(target.sha.as_deref())
|
||||
.bind(&automation.workflow)
|
||||
.bind(workflow_source.map(|source| source.repo.as_str()))
|
||||
.bind(workflow_source.map(|source| source.branch.as_str()))
|
||||
.bind(workflow_source.and_then(|source| source.tag.as_deref()))
|
||||
.bind(workflow_source.and_then(|source| source.sha.as_deref()))
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
|
|
@ -362,6 +389,26 @@ pub(crate) async fn insert_automation_ignoring_conflict(
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
fn stored_workflow_source(
|
||||
row: &SqliteRow,
|
||||
id: &AutomationId,
|
||||
) -> Result<Option<AutomationGitWorkflowSource>, AutomationStoreError> {
|
||||
let repository = row.try_get::<Option<String>, _>("workflow_source_repository")?;
|
||||
let branch = row.try_get::<Option<String>, _>("workflow_source_branch")?;
|
||||
let tag = row.try_get::<Option<String>, _>("workflow_source_tag")?;
|
||||
let sha = row.try_get::<Option<String>, _>("workflow_source_sha")?;
|
||||
match (repository, branch) {
|
||||
(None, None) if tag.is_none() && sha.is_none() => Ok(None),
|
||||
(Some(repo), Some(branch)) => Ok(Some(AutomationGitWorkflowSource {
|
||||
repo,
|
||||
branch,
|
||||
tag,
|
||||
sha,
|
||||
})),
|
||||
_ => Err(AutomationStoreError::StoredWorkflowSourceShape { id: id.clone() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_git_target(automation: &Automation) -> &GitRunTarget {
|
||||
automation
|
||||
.git_target()
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@
|
|||
use std::path::Path;
|
||||
|
||||
use fabro_automation::{
|
||||
ApiTrigger, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
|
||||
AutomationStore, AutomationStoreError, AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
|
||||
ApiTrigger, AutomationDraft, AutomationGitWorkflowSource, AutomationId, AutomationReplace,
|
||||
AutomationRevision, AutomationStore, AutomationStoreError, AutomationTrigger,
|
||||
AutomationTriggerId, ScheduleTrigger,
|
||||
};
|
||||
use fabro_db::Database;
|
||||
use fabro_types::{GitRunTarget, RunTarget};
|
||||
use sqlx::Row as _;
|
||||
use tokio::fs;
|
||||
|
||||
async fn test_database() -> (tempfile::TempDir, Database) {
|
||||
|
|
@ -40,15 +42,29 @@ fn schedule(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
|
|||
})
|
||||
}
|
||||
|
||||
fn workflow_source(
|
||||
branch: &str,
|
||||
tag: Option<&str>,
|
||||
sha: Option<&str>,
|
||||
) -> AutomationGitWorkflowSource {
|
||||
AutomationGitWorkflowSource {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: branch.to_string(),
|
||||
tag: tag.map(str::to_string),
|
||||
sha: sha.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
|
||||
AutomationDraft {
|
||||
id: AutomationId::new(id).unwrap(),
|
||||
name: "Nightly".to_string(),
|
||||
description: Some("Runs every night".to_string()),
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![
|
||||
id: AutomationId::new(id).unwrap(),
|
||||
name: "Nightly".to_string(),
|
||||
description: Some("Runs every night".to_string()),
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![
|
||||
schedule("z-last", "0 2 * * *", false),
|
||||
AutomationTrigger::Api(ApiTrigger {
|
||||
id: AutomationTriggerId::new("custom-api-id").unwrap(),
|
||||
|
|
@ -61,12 +77,13 @@ fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
|
|||
|
||||
fn replacement(name: &str, expression: &str) -> AutomationReplace {
|
||||
AutomationReplace {
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![
|
||||
schedule("nightly", expression, true),
|
||||
AutomationTrigger::Api(ApiTrigger {
|
||||
id: AutomationTriggerId::new("api").unwrap(),
|
||||
|
|
@ -268,6 +285,113 @@ async fn insert_environment(pool: &fabro_db::DbPool, id: &str, provider: &str) {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crud_round_trips_workflow_source_selectors_and_clears_to_omission() {
|
||||
let (_dir, database) = test_database().await;
|
||||
let store = AutomationStore::new(database.clone_pool());
|
||||
|
||||
for (index, source) in [
|
||||
workflow_source("main", None, None),
|
||||
workflow_source("main", Some("release/v1"), None),
|
||||
workflow_source(
|
||||
"context-only",
|
||||
Some("release/v1"),
|
||||
Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("source-{index}");
|
||||
let mut value = draft(&id, true);
|
||||
value.workflow_source = Some(source);
|
||||
let created = store.create(value).await.unwrap();
|
||||
assert_eq!(
|
||||
created
|
||||
.workflow_source
|
||||
.as_ref()
|
||||
.and_then(|source| source.sha.as_deref()),
|
||||
(index == 2).then_some("abcdef0123456789abcdef0123456789abcdef01")
|
||||
);
|
||||
assert_eq!(store.get(&created.id).await.unwrap(), Some(created.clone()));
|
||||
|
||||
let mut cleared = replacement("Cleared", "30 4 * * *");
|
||||
cleared.workflow_source = None;
|
||||
let replaced = store
|
||||
.replace(&created.id, &created.revision, cleared)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replaced.workflow_source, None);
|
||||
let columns = sqlx::query(
|
||||
"SELECT workflow_source_repository, workflow_source_branch, workflow_source_tag, \
|
||||
workflow_source_sha \
|
||||
FROM automations WHERE id = ?",
|
||||
)
|
||||
.bind(created.id.as_str())
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
columns.get::<Option<String>, _>("workflow_source_repository"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
columns.get::<Option<String>, _>("workflow_source_branch"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
columns.get::<Option<String>, _>("workflow_source_tag"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
columns.get::<Option<String>, _>("workflow_source_sha"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(store.list().await.unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_workflow_source_rows_are_rejected_as_stored_shape_errors() {
|
||||
let (_dir, database) = test_database().await;
|
||||
let store = AutomationStore::new(database.clone_pool());
|
||||
let partial = store.create(draft("partial", true)).await.unwrap();
|
||||
let orphan = store.create(draft("orphan", true)).await.unwrap();
|
||||
|
||||
let mut connection = database.pool().acquire().await.unwrap();
|
||||
sqlx::query("DROP TRIGGER automation_workflow_source_all_or_none_update")
|
||||
.execute(&mut *connection)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("PRAGMA ignore_check_constraints = ON")
|
||||
.execute(&mut *connection)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"UPDATE automations SET workflow_source_repository = 'fabro-sh/workflows' WHERE id = ?",
|
||||
)
|
||||
.bind(partial.id.as_str())
|
||||
.execute(&mut *connection)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE automations SET workflow_source_tag = 'v1' WHERE id = ?")
|
||||
.bind(orphan.id.as_str())
|
||||
.execute(&mut *connection)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
|
||||
assert!(matches!(
|
||||
store.get(&partial.id).await.unwrap_err(),
|
||||
AutomationStoreError::StoredWorkflowSourceShape { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
store.get(&orphan.id).await.unwrap_err(),
|
||||
AutomationStoreError::StoredWorkflowSourceShape { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_api_trigger_normalizes_to_absent() {
|
||||
let (_dir, database) = test_database().await;
|
||||
|
|
@ -375,12 +499,13 @@ async fn failed_schedule_insert_rolls_back_parent_replace() {
|
|||
.await
|
||||
.unwrap();
|
||||
let replacement = AutomationReplace {
|
||||
name: "Should roll back".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![schedule("blocked", "0 7 * * *", true)],
|
||||
name: "Should roll back".to_string(),
|
||||
description: None,
|
||||
environment_id: Some("default".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
workflow_source: None,
|
||||
triggers: vec![schedule("blocked", "0 7 * * *", true)],
|
||||
};
|
||||
|
||||
let err = store
|
||||
|
|
@ -449,6 +574,7 @@ async fn legacy_import_is_transactional_and_sql_wins() {
|
|||
assert_eq!(imported.name, "Imported");
|
||||
assert_eq!(imported.revision, expected_revision);
|
||||
assert_eq!(imported.workflow, "release");
|
||||
assert_eq!(imported.workflow_source, None);
|
||||
assert!(matches!(
|
||||
imported.target,
|
||||
RunTarget::Git(GitRunTarget {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ use fabro_types::settings::run::MergeStrategy;
|
|||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::token_source::SecretString;
|
||||
|
||||
pub mod access;
|
||||
pub mod token_source;
|
||||
|
||||
|
|
@ -1266,42 +1268,119 @@ pub async fn update_app_webhook_config(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve git clone credentials for a GitHub repository.
|
||||
/// Required credentials for an authenticated GitHub HTTPS clone.
|
||||
///
|
||||
/// Returns `(username, password)` for authenticated cloning and pushing.
|
||||
/// Always generates a token regardless of repo visibility, since the token
|
||||
/// is needed for pushing from the sandbox.
|
||||
/// The password is redacted from `Debug` output and should only be exposed at
|
||||
/// the point where it is passed to Git.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GitCloneCredentials {
|
||||
username: String,
|
||||
password: SecretString,
|
||||
}
|
||||
|
||||
impl GitCloneCredentials {
|
||||
fn from_token(token: String) -> anyhow::Result<Self> {
|
||||
if token.is_empty() {
|
||||
bail!("GitHub clone credential token is empty");
|
||||
}
|
||||
Ok(Self {
|
||||
username: "x-access-token".to_string(),
|
||||
password: SecretString::new(token),
|
||||
})
|
||||
}
|
||||
|
||||
/// The username passed to Git's HTTPS basic authentication.
|
||||
#[must_use]
|
||||
pub fn username(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
|
||||
/// The secret passed to Git's HTTPS basic authentication.
|
||||
///
|
||||
/// Callers must not log or persist the returned value.
|
||||
#[must_use]
|
||||
pub fn password(&self) -> &str {
|
||||
self.password.expose()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve Git clone credentials for a GitHub repository.
|
||||
///
|
||||
/// Always generates credentials regardless of repository visibility because
|
||||
/// the token is needed for pushing from the sandbox.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when an installation token is expired, a GitHub App token
|
||||
/// cannot be minted, the HTTP client cannot be created, or the resolved token
|
||||
/// is empty.
|
||||
pub async fn resolve_clone_credentials(
|
||||
ctx: &GitHubContext<'_>,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||
) -> anyhow::Result<GitCloneCredentials> {
|
||||
let token = match ctx.creds {
|
||||
GitHubCredentials::Pat(token) => token.clone(),
|
||||
GitHubCredentials::Installation(token) => token.valid_token()?.to_string(),
|
||||
GitHubCredentials::App(_) => {
|
||||
let client = ctx.http_client()?;
|
||||
mint_git_contents_write_token(&client, ctx, owner, repo).await?
|
||||
mint_git_token(
|
||||
&client,
|
||||
ctx,
|
||||
owner,
|
||||
repo,
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok((Some("x-access-token".to_string()), Some(token)))
|
||||
GitCloneCredentials::from_token(token)
|
||||
}
|
||||
|
||||
/// Mint an installation token scoped to repository contents writes.
|
||||
async fn mint_git_contents_write_token(
|
||||
/// Resolve credentials for fetching repository contents without granting a
|
||||
/// GitHub App token permission to push.
|
||||
///
|
||||
/// Static PATs and pre-minted installation tokens retain their configured
|
||||
/// permissions. App credentials mint a repository-scoped token with
|
||||
/// `contents: read`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when an installation token is expired, a GitHub App token
|
||||
/// cannot be minted, the HTTP client cannot be created, or the resolved token
|
||||
/// is empty.
|
||||
pub async fn resolve_read_only_clone_credentials(
|
||||
ctx: &GitHubContext<'_>,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> anyhow::Result<GitCloneCredentials> {
|
||||
let token = match ctx.creds {
|
||||
GitHubCredentials::Pat(token) => token.clone(),
|
||||
GitHubCredentials::Installation(token) => token.valid_token()?.to_string(),
|
||||
GitHubCredentials::App(_) => {
|
||||
let client = ctx.http_client()?;
|
||||
mint_git_token(
|
||||
&client,
|
||||
ctx,
|
||||
owner,
|
||||
repo,
|
||||
serde_json::json!({ "contents": "read" }),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
GitCloneCredentials::from_token(token)
|
||||
}
|
||||
|
||||
async fn mint_git_token(
|
||||
client: &impl HttpClient,
|
||||
ctx: &GitHubContext<'_>,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
permissions: serde_json::Value,
|
||||
) -> anyhow::Result<String> {
|
||||
ctx.creds
|
||||
.resolve_bearer_token(
|
||||
client,
|
||||
owner,
|
||||
repo,
|
||||
ctx.base_url,
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.resolve_bearer_token(client, owner, repo, ctx.base_url, permissions)
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
@ -1330,11 +1409,8 @@ pub async fn resolve_authenticated_url(
|
|||
url: &str,
|
||||
) -> anyhow::Result<DisplaySafeUrl> {
|
||||
let (owner, repo) = parse_github_owner_repo(url)?;
|
||||
let (_username, password) = resolve_clone_credentials(ctx, &owner, &repo).await?;
|
||||
match password {
|
||||
Some(token) => embed_token_in_url(url, &token),
|
||||
None => DisplaySafeUrl::parse(url).context("Failed to parse GitHub HTTPS URL"),
|
||||
}
|
||||
let credentials = resolve_clone_credentials(ctx, &owner, &repo).await?;
|
||||
embed_token_in_url(url, credentials.password())
|
||||
}
|
||||
|
||||
/// Fetch detailed information about a pull request.
|
||||
|
|
@ -2538,13 +2614,22 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
credentials,
|
||||
(
|
||||
Some("x-access-token".to_string()),
|
||||
Some("ghu_test".to_string())
|
||||
)
|
||||
);
|
||||
assert_eq!(credentials.username(), "x-access-token");
|
||||
assert_eq!(credentials.password(), "ghu_test");
|
||||
assert!(!format!("{credentials:?}").contains("ghu_test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_read_only_clone_credentials_returns_static_token_unchanged() {
|
||||
let creds = GitHubCredentials::Pat("ghu_test".to_string());
|
||||
|
||||
let credentials =
|
||||
resolve_read_only_clone_credentials(&GitHubContext::new(&creds, ""), "owner", "repo")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(credentials.username(), "x-access-token");
|
||||
assert_eq!(credentials.password(), "ghu_test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -2569,9 +2654,50 @@ mod tests {
|
|||
slug: None,
|
||||
});
|
||||
let context = GitHubContext::new(&credentials, "");
|
||||
let token = mint_git_contents_write_token(&mock, &context, "owner", "repo")
|
||||
.await
|
||||
.unwrap();
|
||||
let token = mint_git_token(
|
||||
&mock,
|
||||
&context,
|
||||
"owner",
|
||||
"repo",
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(token, "ghs_xxx");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_only_clone_token_requests_only_contents_read() {
|
||||
let mock = MockHttpClient::new()
|
||||
.on(
|
||||
HttpMethod::Get,
|
||||
"/repos/owner/repo/installation",
|
||||
200,
|
||||
r#"{"id": 123}"#,
|
||||
)
|
||||
.on(
|
||||
HttpMethod::Post,
|
||||
"/app/installations/123/access_tokens",
|
||||
201,
|
||||
r#"{"token": "ghs_xxx", "expires_at": "2099-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.with_req_body(r#"{"permissions":{"contents":"read"},"repositories":["repo"]}"#);
|
||||
let credentials = GitHubCredentials::App(GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: test_rsa_key().to_string(),
|
||||
slug: None,
|
||||
});
|
||||
let context = GitHubContext::new(&credentials, "");
|
||||
let token = mint_git_token(
|
||||
&mock,
|
||||
&context,
|
||||
"owner",
|
||||
"repo",
|
||||
serde_json::json!({ "contents": "read" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(token, "ghs_xxx");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2473,9 +2473,10 @@ mod tests {
|
|||
#[test]
|
||||
fn run_created_projects_automation_into_spec_and_summary() {
|
||||
let automation = AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
workflow_source: None,
|
||||
};
|
||||
let event = test_raw_event(
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -2162,15 +2162,17 @@ mod tests {
|
|||
|
||||
let mut first = projection(first_id, "bravo", created_at);
|
||||
first.spec.automation = Some(AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: None,
|
||||
trigger_id: None,
|
||||
id: "nightly".to_string(),
|
||||
name: None,
|
||||
trigger_id: None,
|
||||
workflow_source: None,
|
||||
});
|
||||
let mut second = projection(second_id, "alpha", created_at);
|
||||
second.spec.automation = Some(AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: None,
|
||||
trigger_id: None,
|
||||
id: "nightly".to_string(),
|
||||
name: None,
|
||||
trigger_id: None,
|
||||
workflow_source: None,
|
||||
});
|
||||
let mut archived = projection(archived_id, "charlie", created_at);
|
||||
archived.archived_at = Some(created_at);
|
||||
|
|
@ -2219,9 +2221,10 @@ mod tests {
|
|||
let run_id = run_id(created_at.timestamp_millis().cast_unsigned(), 1);
|
||||
let mut projection = projection(run_id, "billed", created_at);
|
||||
projection.spec.automation = Some(AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: None,
|
||||
trigger_id: None,
|
||||
id: "nightly".to_string(),
|
||||
name: None,
|
||||
trigger_id: None,
|
||||
workflow_source: None,
|
||||
});
|
||||
projection.status = RunStatus::Succeeded {
|
||||
reason: SuccessReason::Completed,
|
||||
|
|
|
|||
|
|
@ -2850,9 +2850,10 @@ mod tests {
|
|||
subject: user_principal("alice"),
|
||||
};
|
||||
let automation = AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
workflow_source: None,
|
||||
};
|
||||
let workflow_version_id = test_support::test_workflow_version_id();
|
||||
|
||||
|
|
|
|||
|
|
@ -1644,9 +1644,10 @@ reasoning = false
|
|||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage_root = dir.path().join("storage");
|
||||
let automation = AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
workflow_source: None,
|
||||
};
|
||||
let request = CreateRunInput {
|
||||
workflow: WorkflowInput::DotSource {
|
||||
|
|
@ -1790,9 +1791,10 @@ reasoning = false
|
|||
let compiled_source = MINIMAL_DOT.replace("Build feature", "Compiled goal");
|
||||
std::fs::write(&dot_path, &compiled_source).unwrap();
|
||||
let automation = AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
workflow_source: None,
|
||||
};
|
||||
let request = CreateRunInput {
|
||||
workflow: WorkflowInput::Path(dot_path.clone()),
|
||||
|
|
@ -2493,9 +2495,10 @@ reasoning = false
|
|||
None,
|
||||
));
|
||||
let automation = fabro_types::AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
workflow_source: None,
|
||||
};
|
||||
let created = create(
|
||||
store.as_ref(),
|
||||
|
|
|
|||
|
|
@ -1198,7 +1198,7 @@ async fn daytona_clone_public_repo_gets_credentials() {
|
|||
|
||||
// Directly test resolve_clone_credentials against a repo in an org where the
|
||||
// app is installed
|
||||
let (username, password) = fabro_github::resolve_clone_credentials(
|
||||
let credentials = fabro_github::resolve_clone_credentials(
|
||||
&fabro_github::GitHubContext::new(&creds, &fabro_github::github_api_base_url()),
|
||||
"fabro-sh",
|
||||
"fabro",
|
||||
|
|
@ -1207,12 +1207,12 @@ async fn daytona_clone_public_repo_gets_credentials() {
|
|||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
username.as_deref(),
|
||||
Some("x-access-token"),
|
||||
credentials.username(),
|
||||
"x-access-token",
|
||||
"installed org repo should get credentials for pushing"
|
||||
);
|
||||
assert!(
|
||||
password.is_some(),
|
||||
!credentials.password().is_empty(),
|
||||
"installed org repo should get a token for pushing"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -688,6 +688,11 @@ fn main() {
|
|||
("SandboxTimestamps", "fabro_types::SandboxTimestamps", &[]),
|
||||
("AskFabro", "fabro_types::AskFabro", &[]),
|
||||
("Automation", "fabro_automation::Automation", &[]),
|
||||
(
|
||||
"AutomationGitWorkflowSource",
|
||||
"fabro_types::GitRunTarget",
|
||||
&[],
|
||||
),
|
||||
("AutomationRef", "fabro_types::AutomationRef", &[]),
|
||||
(
|
||||
"AutomationTrigger",
|
||||
|
|
|
|||
|
|
@ -48,27 +48,28 @@ pub mod types {
|
|||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
|
||||
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
|
||||
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
||||
FailureSignature, GitContext, GitRunTarget, IdpIdentity, IntegrationConnectionKind,
|
||||
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
|
||||
IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind,
|
||||
McpServerDraft as CreateMcpServerRequest, McpServerProjection,
|
||||
McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer,
|
||||
McpTransportView, Message, PairId, PairMessageId, PairMessageRecord, PairMessageRequest,
|
||||
PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry,
|
||||
PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord,
|
||||
PermissionLevel, Principal, PullRequest, PullRequestCreation, PullRequestCreationId,
|
||||
PullRequestCreationStatus, PullRequestDetails, PullRequestDetailsStatus,
|
||||
PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse,
|
||||
QuestionType, ReasoningOutput, RepositoryRef, ReviewTarget, ReviewTargetKind, Role, Run,
|
||||
RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind,
|
||||
RunEventDetailResponse, RunFailure, RunIntent, RunIntentArgs, RunPairStatusResponse,
|
||||
RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure,
|
||||
RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, RunServerProvenance,
|
||||
RunSize, RunTarget, SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse,
|
||||
SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind,
|
||||
SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse,
|
||||
SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail,
|
||||
SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
|
||||
FailureSignature, GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource,
|
||||
IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState,
|
||||
IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption,
|
||||
InterviewQuestionRecord, LlmOutputKind, McpServerDraft as CreateMcpServerRequest,
|
||||
McpServerProjection, McpServerReplace as ReplaceMcpServerRequest, McpServerStatus,
|
||||
McpServerView as McpServer, McpTransportView, Message, PairId, PairMessageId,
|
||||
PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, PairStatus,
|
||||
PairTarget, PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId,
|
||||
ParallelBranchResult, PendingInterviewRecord, PermissionLevel, Principal, PullRequest,
|
||||
PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails,
|
||||
PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink,
|
||||
PullRequestMeta, PullRequestResponse, QuestionType, ReasoningOutput, RepositoryRef,
|
||||
ReviewTarget, ReviewTargetKind, Role, Run, RunApproval, RunApprovalState,
|
||||
RunClientProvenance, RunEvent, RunEventDetailContentKind, RunEventDetailResponse,
|
||||
RunFailure, RunIntent, RunIntentArgs, RunPairStatusResponse, RunProjection, RunProvenance,
|
||||
RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind,
|
||||
RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSize, RunTarget, SandboxDetails,
|
||||
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
|
||||
SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError,
|
||||
SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState,
|
||||
SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId,
|
||||
SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
|
||||
SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,
|
||||
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
|
||||
StageContextWindowStaleness, StageContextWindowUnavailableReason,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
use fabro_api::types::{
|
||||
Automation as ApiAutomation, AutomationTrigger as ApiAutomationTrigger,
|
||||
Automation as ApiAutomation, AutomationGitWorkflowSource as ApiAutomationGitWorkflowSource,
|
||||
AutomationTrigger as ApiAutomationTrigger,
|
||||
CreateAutomationRequest as ApiCreateAutomationRequest,
|
||||
ReplaceAutomationRequest as ApiReplaceAutomationRequest,
|
||||
};
|
||||
use fabro_automation::{Automation, AutomationDraft, AutomationReplace, AutomationTrigger};
|
||||
use fabro_automation::{
|
||||
Automation, AutomationDraft, AutomationGitWorkflowSource, AutomationReplace, AutomationTrigger,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// Compile-time witnesses that the generated API types resolve to the same
|
||||
|
|
@ -12,6 +15,7 @@ use serde_json::json;
|
|||
// checking and the build fails.
|
||||
const _: fn(ApiAutomation) -> Automation = |value| value;
|
||||
const _: fn(ApiAutomationTrigger) -> AutomationTrigger = |value| value;
|
||||
const _: fn(ApiAutomationGitWorkflowSource) -> AutomationGitWorkflowSource = |value| value;
|
||||
const _: fn(ApiCreateAutomationRequest) -> AutomationDraft = |value| value;
|
||||
const _: fn(ApiReplaceAutomationRequest) -> AutomationReplace = |value| value;
|
||||
|
||||
|
|
@ -103,3 +107,57 @@ fn replace_automation_request_round_trips_public_json_shape() {
|
|||
let api: ApiReplaceAutomationRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_workflow_sources_round_trip_each_public_json_shape() {
|
||||
for source in [
|
||||
json!({"repo": "fabro-sh/workflows", "branch": "main"}),
|
||||
json!({
|
||||
"repo": "fabro-sh/workflows",
|
||||
"branch": "main",
|
||||
"tag": "release/v1"
|
||||
}),
|
||||
json!({
|
||||
"repo": "fabro-sh/workflows",
|
||||
"branch": "context-only",
|
||||
"tag": "release/v1",
|
||||
"sha": "abcdef0123456789abcdef0123456789abcdef01"
|
||||
}),
|
||||
] {
|
||||
let value = json!({
|
||||
"id": "nightly-deps",
|
||||
"name": "Nightly dependency update",
|
||||
"environment_id": "daytona-smoke",
|
||||
"target": {
|
||||
"kind": "git",
|
||||
"repo": "fabro-sh/app",
|
||||
"branch": "main"
|
||||
},
|
||||
"workflow": "dependency-update",
|
||||
"workflow_source": source,
|
||||
"triggers": []
|
||||
});
|
||||
|
||||
let api: ApiCreateAutomationRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_workflow_source_rejects_unknown_or_incomplete_coordinates() {
|
||||
for source in [
|
||||
json!({"repo": "fabro-sh/workflows"}),
|
||||
json!({"branch": "main"}),
|
||||
json!({"repo": "fabro-sh/workflows", "branch": "main", "extra": true}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<ApiAutomationGitWorkflowSource>(source).is_err());
|
||||
}
|
||||
|
||||
let invalid_commit: ApiAutomationGitWorkflowSource = serde_json::from_value(json!({
|
||||
"repo": "fabro-sh/workflows",
|
||||
"branch": "main",
|
||||
"sha": "short"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(fabro_automation::validate_workflow_source(invalid_commit).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ use fabro_api::types::{
|
|||
use fabro_types::status::{RunStatus, SuccessReason};
|
||||
use fabro_types::{
|
||||
AskFabro, AskFabroUnavailableReason, AutomationRef, DiffSummary, PullRequestLink,
|
||||
RepositoryProvider, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary,
|
||||
RunId, RunLifecycle, RunLinks, RunOrigin, RunRunnableSource, RunSize, RunTimestamps, RunTiming,
|
||||
WorkflowRef, fixtures, test_support,
|
||||
RepositoryProvider, RepositoryRef, ResolvedAutomationGitWorkflowSource, Run, RunApproval,
|
||||
RunApprovalState, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin,
|
||||
RunRunnableSource, RunSize, RunTimestamps, RunTiming, WorkflowRef, fixtures, test_support,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -79,9 +79,16 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
edge_count: 9,
|
||||
},
|
||||
automation: Some(AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
workflow_source: Some(Box::new(ResolvedAutomationGitWorkflowSource {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: "context-only".to_string(),
|
||||
tag: Some("v1".to_string()),
|
||||
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
resolved_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
})),
|
||||
}),
|
||||
repository: Some(RepositoryRef {
|
||||
name: "fabro".to_string(),
|
||||
|
|
@ -154,7 +161,14 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
"automation": {
|
||||
"id": "nightly",
|
||||
"name": "Nightly",
|
||||
"trigger_id": "schedule_1"
|
||||
"trigger_id": "schedule_1",
|
||||
"workflow_source": {
|
||||
"repo": "fabro-sh/workflows",
|
||||
"branch": "context-only",
|
||||
"tag": "v1",
|
||||
"sha": "0123456789abcdef0123456789abcdef01234567",
|
||||
"resolved_sha": "0123456789abcdef0123456789abcdef01234567"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"name": "fabro",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
ALTER TABLE automations ADD COLUMN workflow_source_repository TEXT
|
||||
CHECK (
|
||||
workflow_source_repository IS NULL
|
||||
OR length(workflow_source_repository) BETWEEN 3 AND 140
|
||||
);
|
||||
|
||||
ALTER TABLE automations ADD COLUMN workflow_source_branch TEXT
|
||||
CHECK (
|
||||
workflow_source_branch IS NULL
|
||||
OR length(workflow_source_branch) BETWEEN 1 AND 255
|
||||
);
|
||||
|
||||
ALTER TABLE automations ADD COLUMN workflow_source_tag TEXT
|
||||
CHECK (
|
||||
workflow_source_tag IS NULL
|
||||
OR length(workflow_source_tag) BETWEEN 1 AND 255
|
||||
);
|
||||
|
||||
ALTER TABLE automations ADD COLUMN workflow_source_sha TEXT
|
||||
CHECK (
|
||||
workflow_source_sha IS NULL
|
||||
OR (
|
||||
length(workflow_source_sha) = 40
|
||||
AND workflow_source_sha NOT GLOB '*[^0-9a-f]*'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TRIGGER automation_workflow_source_all_or_none_insert
|
||||
BEFORE INSERT ON automations
|
||||
WHEN
|
||||
(NEW.workflow_source_repository IS NULL)
|
||||
+ (NEW.workflow_source_branch IS NULL) NOT IN (0, 2)
|
||||
OR (
|
||||
NEW.workflow_source_repository IS NULL
|
||||
AND (NEW.workflow_source_tag IS NOT NULL OR NEW.workflow_source_sha IS NOT NULL)
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'automation workflow source requires repository and branch together');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER automation_workflow_source_all_or_none_update
|
||||
BEFORE UPDATE OF
|
||||
workflow_source_repository,
|
||||
workflow_source_branch,
|
||||
workflow_source_tag,
|
||||
workflow_source_sha
|
||||
ON automations
|
||||
WHEN
|
||||
(NEW.workflow_source_repository IS NULL)
|
||||
+ (NEW.workflow_source_branch IS NULL) NOT IN (0, 2)
|
||||
OR (
|
||||
NEW.workflow_source_repository IS NULL
|
||||
AND (NEW.workflow_source_tag IS NOT NULL OR NEW.workflow_source_sha IS NOT NULL)
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'automation workflow source requires repository and branch together');
|
||||
END;
|
||||
|
|
@ -388,6 +388,41 @@ async fn automations_schema_enforces_aggregate_constraints() -> anyhow::Result<(
|
|||
.await
|
||||
.is_err()
|
||||
);
|
||||
for (repository, branch, tag, sha) in [
|
||||
(Some("fabro-sh/workflows"), None, None, None),
|
||||
(None, Some("main"), None, None),
|
||||
(None, None, Some("v1"), None),
|
||||
(
|
||||
Some("fabro-sh/workflows"),
|
||||
Some("main"),
|
||||
None,
|
||||
Some("short"),
|
||||
),
|
||||
] {
|
||||
let result = sqlx::query(
|
||||
"UPDATE automations SET workflow_source_repository = ?, \
|
||||
workflow_source_branch = ?, workflow_source_tag = ?, workflow_source_sha = ? \
|
||||
WHERE id = 'valid'",
|
||||
)
|
||||
.bind(repository)
|
||||
.bind(branch)
|
||||
.bind(tag)
|
||||
.bind(sha)
|
||||
.execute(database.pool())
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"invalid workflow source row should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE automations SET workflow_source_repository = 'fabro-sh/workflows', \
|
||||
workflow_source_branch = 'main', workflow_source_tag = 'v1', \
|
||||
workflow_source_sha = '0123456789abcdef0123456789abcdef01234567' WHERE id = 'valid'",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
assert!(
|
||||
sqlx::query(
|
||||
"INSERT INTO automation_triggers (automation_id, id, enabled, expression) \
|
||||
|
|
@ -431,6 +466,92 @@ async fn automations_schema_enforces_aggregate_constraints() -> anyhow::Result<(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_workflow_sources_migrate_without_rewriting_existing_rows() -> anyhow::Result<()>
|
||||
{
|
||||
let dir = tempfile::tempdir()?;
|
||||
let db_path = dir.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&db_path).await?;
|
||||
database.migrate().await?;
|
||||
rewind_automation_workflow_source_migration(&database).await?;
|
||||
|
||||
insert_minimal_automation(database.pool(), "preserved", 1).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO automation_triggers (automation_id, id, enabled, expression) \
|
||||
VALUES ('preserved', 'nightly', 1, '0 3 * * *')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
|
||||
database.migrate().await?;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, revision, target_repository, target_branch, target_tag, target_sha, \
|
||||
target_workflow, workflow_source_repository, workflow_source_branch, \
|
||||
workflow_source_tag, workflow_source_sha \
|
||||
FROM automations WHERE id = 'preserved'",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
assert_eq!(row.get::<String, _>("id"), "preserved");
|
||||
assert_eq!(row.get::<String, _>("revision"), "a".repeat(64));
|
||||
assert_eq!(row.get::<String, _>("target_repository"), "fabro-sh/fabro");
|
||||
assert_eq!(row.get::<String, _>("target_branch"), "main");
|
||||
assert_eq!(row.get::<Option<String>, _>("target_tag"), None);
|
||||
assert_eq!(row.get::<Option<String>, _>("target_sha"), None);
|
||||
assert_eq!(row.get::<String, _>("target_workflow"), "release");
|
||||
assert_eq!(
|
||||
row.get::<Option<String>, _>("workflow_source_repository"),
|
||||
None
|
||||
);
|
||||
assert_eq!(row.get::<Option<String>, _>("workflow_source_branch"), None);
|
||||
assert_eq!(row.get::<Option<String>, _>("workflow_source_tag"), None);
|
||||
assert_eq!(row.get::<Option<String>, _>("workflow_source_sha"), None);
|
||||
let trigger_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM automation_triggers WHERE automation_id = 'preserved'",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
assert_eq!(trigger_count, 1);
|
||||
assert!(fabro_db::pre_migration_snapshot_path(&db_path).exists());
|
||||
|
||||
database.migrate().await?;
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM automations WHERE id = 'preserved'")
|
||||
.fetch_one(database.pool())
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rewind_automation_workflow_source_migration(
|
||||
database: &fabro_db::Database,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query("DROP TRIGGER automation_workflow_source_all_or_none_update")
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
sqlx::query("DROP TRIGGER automation_workflow_source_all_or_none_insert")
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
sqlx::query("ALTER TABLE automations DROP COLUMN workflow_source_sha")
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
sqlx::query("ALTER TABLE automations DROP COLUMN workflow_source_tag")
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
sqlx::query("ALTER TABLE automations DROP COLUMN workflow_source_branch")
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
sqlx::query("ALTER TABLE automations DROP COLUMN workflow_source_repository")
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = 2026082803")
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_minimal_automation(
|
||||
pool: &fabro_db::DbPool,
|
||||
id: &str,
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ pub use pull_request::{
|
|||
};
|
||||
pub use reasoning::ReasoningOutput;
|
||||
pub use repository::{
|
||||
GitHubRepositorySlug, RepositoryProvider, RepositoryRef, normalize_git_commit_sha,
|
||||
GitHubRepositorySlug, GitHubRepositorySlugError, RepositoryProvider, RepositoryRef,
|
||||
is_valid_git_branch_name, is_valid_git_tag_name, normalize_git_commit_sha,
|
||||
};
|
||||
pub use run::{
|
||||
DirtyStatus, ForkSourceRef, GitContext, RunClientProvenance, RunProvenance,
|
||||
|
|
@ -131,7 +132,8 @@ pub use run_event::{
|
|||
pub use run_failure::RunFailure;
|
||||
pub use run_id::{RunId, fixtures};
|
||||
pub use run_intent::{
|
||||
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedRunTarget,
|
||||
GitCoordinateValidationError, GitRunTarget, RunIntent, RunIntentArgs, RunTarget,
|
||||
TargetValidationError, ValidatedGitRunTarget, ValidatedRunTarget,
|
||||
};
|
||||
pub use run_projection::{
|
||||
ActivatedSkill, AgentControlState, CheckpointRecord, McpServerProjection, McpServerStatus,
|
||||
|
|
@ -146,9 +148,9 @@ pub use run_sandbox::{
|
|||
RunSandboxRuntime,
|
||||
};
|
||||
pub use run_summary::{
|
||||
AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunApproval, RunApprovalState,
|
||||
RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind,
|
||||
RunSize, RunTimestamps, WorkflowRef,
|
||||
AskFabro, AskFabroUnavailableReason, AutomationRef, ResolvedAutomationGitWorkflowSource, Run,
|
||||
RunApproval, RunApprovalState, RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel,
|
||||
RunOrigin, RunOriginKind, RunSize, RunTimestamps, WorkflowRef,
|
||||
};
|
||||
pub use run_title::{
|
||||
MAX_RUN_TITLE_CHARS, RunTitleError, infer_run_title, normalize_explicit_run_title,
|
||||
|
|
|
|||
|
|
@ -212,6 +212,33 @@ pub fn is_valid_github_ref_selector(value: &str) -> bool {
|
|||
.all(|part| !part.is_empty() && !part.starts_with('.') && !has_lock_suffix(part))
|
||||
}
|
||||
|
||||
/// Reports whether `value` is a canonical bare Git branch name.
|
||||
///
|
||||
/// Bare names exclude symbolic selectors, fully qualified refs, tag-prefixed
|
||||
/// selectors, commit SHAs, and the `heads/` prefix accepted only after Git has
|
||||
/// already entered the refs namespace.
|
||||
#[must_use]
|
||||
pub fn is_valid_git_branch_name(value: &str) -> bool {
|
||||
is_valid_bare_git_ref_name(value) && !value.starts_with("heads/")
|
||||
}
|
||||
|
||||
/// Reports whether `value` is a canonical bare Git tag name.
|
||||
///
|
||||
/// The input is a tag name rather than a selector, so `tags/`, `refs/`,
|
||||
/// symbolic `HEAD`, and exact commit SHAs are rejected.
|
||||
#[must_use]
|
||||
pub fn is_valid_git_tag_name(value: &str) -> bool {
|
||||
is_valid_bare_git_ref_name(value)
|
||||
}
|
||||
|
||||
fn is_valid_bare_git_ref_name(value: &str) -> bool {
|
||||
value != "HEAD"
|
||||
&& !value.starts_with("tags/")
|
||||
&& !value.starts_with("refs/")
|
||||
&& normalize_git_commit_sha(value).is_none()
|
||||
&& is_valid_github_ref_selector(value)
|
||||
}
|
||||
|
||||
/// Validates and canonicalizes an exact Git commit SHA.
|
||||
///
|
||||
/// The grammar accepts exactly 40 untrimmed ASCII hexadecimal bytes. It does
|
||||
|
|
|
|||
|
|
@ -72,18 +72,54 @@ pub struct GitRunTarget {
|
|||
pub sha: Option<String>,
|
||||
}
|
||||
|
||||
/// A bare branch or tag name: not `HEAD`, not a `refs/` or `tags/` selector,
|
||||
/// not a commit SHA, and otherwise a valid GitHub ref selector.
|
||||
///
|
||||
/// The selector grammar is checked on the bare name so its leading-character
|
||||
/// rules apply to the name itself, not to a prefixed selector that would mask
|
||||
/// them.
|
||||
fn is_bare_ref_name(name: &str) -> bool {
|
||||
name != "HEAD"
|
||||
&& !name.starts_with("tags/")
|
||||
&& !name.starts_with("refs/")
|
||||
&& repository::normalize_git_commit_sha(name).is_none()
|
||||
&& repository::is_valid_github_ref_selector(name)
|
||||
impl GitRunTarget {
|
||||
/// Validates and canonicalizes this Git coordinate without resolving remote
|
||||
/// repository state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the repository slug, branch, tag, or exact commit
|
||||
/// does not use the canonical grammar accepted for Git-backed runs.
|
||||
pub fn validate(self) -> Result<ValidatedGitRunTarget, GitCoordinateValidationError> {
|
||||
let Self {
|
||||
repo,
|
||||
branch,
|
||||
tag,
|
||||
sha,
|
||||
} = self;
|
||||
let repository =
|
||||
GitHubRepositorySlug::try_new(&repo).ok_or(GitCoordinateValidationError::Repository)?;
|
||||
if !repository::is_valid_git_branch_name(&branch) {
|
||||
return Err(GitCoordinateValidationError::Branch);
|
||||
}
|
||||
if tag
|
||||
.as_deref()
|
||||
.is_some_and(|tag| !repository::is_valid_git_tag_name(tag))
|
||||
{
|
||||
return Err(GitCoordinateValidationError::Tag);
|
||||
}
|
||||
let sha = sha
|
||||
.map(|sha| {
|
||||
repository::normalize_git_commit_sha(&sha).ok_or(GitCoordinateValidationError::Sha)
|
||||
})
|
||||
.transpose()?;
|
||||
let git = GitContext {
|
||||
origin_url: repository.https_url(),
|
||||
branch: branch.clone(),
|
||||
sha: sha.clone(),
|
||||
dirty: DirtyStatus::Clean,
|
||||
};
|
||||
Ok(ValidatedGitRunTarget {
|
||||
target: Self {
|
||||
repo,
|
||||
branch,
|
||||
tag,
|
||||
sha,
|
||||
},
|
||||
repository,
|
||||
git,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RunTarget {
|
||||
|
|
@ -98,41 +134,18 @@ impl RunTarget {
|
|||
/// Git targets include their derived operational Git projection. Targets
|
||||
/// without a repository return no projection. Folder paths require
|
||||
/// filesystem validation and canonicalization during provider admission.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when a Git target's repository slug, branch, tag, or
|
||||
/// exact commit does not use the canonical grammar accepted for runs.
|
||||
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
|
||||
match self {
|
||||
Self::Git(GitRunTarget {
|
||||
repo,
|
||||
branch,
|
||||
tag,
|
||||
sha,
|
||||
}) => {
|
||||
let slug = GitHubRepositorySlug::try_new(&repo)
|
||||
.ok_or(TargetValidationError::Repository)?;
|
||||
if !is_bare_ref_name(&branch) || branch.starts_with("heads/") {
|
||||
return Err(TargetValidationError::Branch);
|
||||
}
|
||||
if tag.as_deref().is_some_and(|tag| !is_bare_ref_name(tag)) {
|
||||
return Err(TargetValidationError::Tag);
|
||||
}
|
||||
let sha = sha
|
||||
.map(|sha| {
|
||||
repository::normalize_git_commit_sha(&sha).ok_or(TargetValidationError::Sha)
|
||||
})
|
||||
.transpose()?;
|
||||
let git = GitContext {
|
||||
origin_url: slug.https_url(),
|
||||
branch: branch.clone(),
|
||||
sha: sha.clone(),
|
||||
dirty: DirtyStatus::Clean,
|
||||
};
|
||||
Self::Git(target) => {
|
||||
let validated = target.validate().map_err(TargetValidationError::from)?;
|
||||
Ok(ValidatedRunTarget {
|
||||
target: Self::Git(GitRunTarget {
|
||||
repo,
|
||||
branch,
|
||||
tag,
|
||||
sha,
|
||||
}),
|
||||
git: Some(git),
|
||||
target: Self::Git(validated.target),
|
||||
git: Some(validated.git),
|
||||
})
|
||||
}
|
||||
Self::None {} => Ok(ValidatedRunTarget {
|
||||
|
|
@ -147,6 +160,34 @@ impl RunTarget {
|
|||
}
|
||||
}
|
||||
|
||||
/// A [`GitRunTarget`] whose local grammar has been validated and canonicalized.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidatedGitRunTarget {
|
||||
target: GitRunTarget,
|
||||
repository: GitHubRepositorySlug,
|
||||
git: GitContext,
|
||||
}
|
||||
|
||||
impl ValidatedGitRunTarget {
|
||||
/// The canonical Git target coordinate.
|
||||
#[must_use]
|
||||
pub fn target(&self) -> &GitRunTarget {
|
||||
&self.target
|
||||
}
|
||||
|
||||
/// The parsed GitHub repository named by the target.
|
||||
#[must_use]
|
||||
pub fn repository(&self) -> &GitHubRepositorySlug {
|
||||
&self.repository
|
||||
}
|
||||
|
||||
/// Consume the validation proof and return the canonical Git target.
|
||||
#[must_use]
|
||||
pub fn into_target(self) -> GitRunTarget {
|
||||
self.target
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`RunTarget`] whose grammar has been validated, together with its
|
||||
/// optional operational Git projection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -155,6 +196,19 @@ pub struct ValidatedRunTarget {
|
|||
pub git: Option<GitContext>,
|
||||
}
|
||||
|
||||
/// A Git coordinate that failed local grammar validation.
|
||||
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GitCoordinateValidationError {
|
||||
#[error("repository must be a valid GitHub owner/name slug")]
|
||||
Repository,
|
||||
#[error("branch must be a non-empty branch name, not a ref or commit selector")]
|
||||
Branch,
|
||||
#[error("tag must be a non-empty bare tag name, not a ref or commit selector")]
|
||||
Tag,
|
||||
#[error("SHA must be exactly 40 ASCII hexadecimal characters")]
|
||||
Sha,
|
||||
}
|
||||
|
||||
/// A [`RunTarget`] that failed grammar validation.
|
||||
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TargetValidationError {
|
||||
|
|
@ -167,3 +221,14 @@ pub enum TargetValidationError {
|
|||
#[error("target SHA must be exactly 40 ASCII hexadecimal characters")]
|
||||
Sha,
|
||||
}
|
||||
|
||||
impl From<GitCoordinateValidationError> for TargetValidationError {
|
||||
fn from(error: GitCoordinateValidationError) -> Self {
|
||||
match error {
|
||||
GitCoordinateValidationError::Repository => Self::Repository,
|
||||
GitCoordinateValidationError::Branch => Self::Branch,
|
||||
GitCoordinateValidationError::Tag => Self::Tag,
|
||||
GitCoordinateValidationError::Sha => Self::Sha,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
DiffSummary, InterviewQuestionRecord, Principal, PullRequestLink, RepositoryRef,
|
||||
DiffSummary, GitRunTarget, InterviewQuestionRecord, Principal, PullRequestLink, RepositoryRef,
|
||||
RunControlAction, RunId, RunSandbox, RunStatus, RunTiming,
|
||||
};
|
||||
|
||||
|
|
@ -113,13 +113,44 @@ impl WorkflowRef {
|
|||
}
|
||||
}
|
||||
|
||||
/// Requested workflow-source coordinate and the immutable commit selected for
|
||||
/// one automation run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResolvedAutomationGitWorkflowSource {
|
||||
pub repo: String,
|
||||
pub branch: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha: Option<String>,
|
||||
pub resolved_sha: String,
|
||||
}
|
||||
|
||||
impl ResolvedAutomationGitWorkflowSource {
|
||||
#[must_use]
|
||||
pub fn from_requested(source: GitRunTarget, resolved_sha: String) -> Self {
|
||||
Self {
|
||||
repo: source.repo,
|
||||
branch: source.branch,
|
||||
tag: source.tag,
|
||||
sha: source.sha,
|
||||
resolved_sha,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AutomationRef {
|
||||
pub id: String,
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trigger_id: Option<String>,
|
||||
pub trigger_id: Option<String>,
|
||||
/// Boxed because this metadata is uncommon and run specs cross many async
|
||||
/// server boundaries where their inline size matters.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_source: Option<Box<ResolvedAutomationGitWorkflowSource>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ use fabro_types::settings::InterpString;
|
|||
use fabro_types::settings::run::RunGoal;
|
||||
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
||||
use fabro_types::{
|
||||
AutomationRef, EventBody, GitRunTarget, RunTarget, TurnId, WorkflowSettings, fixtures,
|
||||
AutomationRef, EventBody, GitRunTarget, ResolvedAutomationGitWorkflowSource, RunTarget, TurnId,
|
||||
WorkflowSettings, fixtures,
|
||||
};
|
||||
|
||||
fn templated_settings() -> WorkflowSettings {
|
||||
|
|
@ -35,9 +36,16 @@ fn run_created_props_round_trip_templated_settings() {
|
|||
sha: None,
|
||||
})),
|
||||
automation: Some(AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
workflow_source: Some(Box::new(ResolvedAutomationGitWorkflowSource {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: Some("v1".to_string()),
|
||||
sha: None,
|
||||
resolved_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
})),
|
||||
}),
|
||||
provenance: test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
|
|
@ -78,6 +86,12 @@ fn run_created_props_round_trip_templated_settings() {
|
|||
assert_eq!(json["parent_id"], fixtures::RUN_2.to_string());
|
||||
assert_eq!(json["automation"]["id"], "nightly");
|
||||
assert_eq!(json["automation"]["trigger_id"], "schedule_1");
|
||||
assert_eq!(json["automation"]["workflow_source"]["branch"], "main");
|
||||
assert_eq!(json["automation"]["workflow_source"]["tag"], "v1");
|
||||
assert_eq!(
|
||||
json["automation"]["workflow_source"]["resolved_sha"],
|
||||
"0123456789abcdef0123456789abcdef01234567"
|
||||
);
|
||||
assert_eq!(
|
||||
json["workflow_version_id"],
|
||||
test_workflow_version_id().to_string()
|
||||
|
|
|
|||
|
|
@ -217,6 +217,25 @@ fn target_validation_normalizes_sha_without_network_resolution() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_target_validation_carries_the_parsed_repository_proof() {
|
||||
let validated = GitRunTarget {
|
||||
repo: "Fabro-Sh/Fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
tag: None,
|
||||
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(validated.repository().owner(), "Fabro-Sh");
|
||||
assert_eq!(validated.repository().repo(), "Fabro");
|
||||
assert_eq!(
|
||||
validated.target().sha.as_deref(),
|
||||
Some("abcdef0123456789abcdef0123456789abcdef01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_none_target_validates_without_a_git_projection() {
|
||||
let validated = RunTarget::None {}.validate().unwrap();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, RunSpec};
|
|||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::RunGoal;
|
||||
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
||||
use fabro_types::{AutomationRef, GitRunTarget, RunTarget, WorkflowSettings, fixtures};
|
||||
use fabro_types::{
|
||||
AutomationRef, GitRunTarget, ResolvedAutomationGitWorkflowSource, RunTarget, WorkflowSettings,
|
||||
fixtures,
|
||||
};
|
||||
|
||||
fn templated_settings() -> WorkflowSettings {
|
||||
let mut settings = WorkflowSettings::default();
|
||||
|
|
@ -29,9 +32,16 @@ fn run_spec_round_trips_templated_settings() {
|
|||
sha: Some("abc123".to_string()),
|
||||
})),
|
||||
automation: Some(AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule_1".to_string()),
|
||||
workflow_source: Some(Box::new(ResolvedAutomationGitWorkflowSource {
|
||||
repo: "fabro-sh/workflows".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
resolved_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
})),
|
||||
}),
|
||||
source_directory: Some("/Users/client/project".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
|
|
@ -66,6 +76,11 @@ fn run_spec_round_trips_templated_settings() {
|
|||
assert_eq!(json["fork_source_ref"]["checkpoint_sha"], "def456");
|
||||
assert_eq!(json["automation"]["id"], "nightly");
|
||||
assert_eq!(json["automation"]["trigger_id"], "schedule_1");
|
||||
assert_eq!(json["automation"]["workflow_source"]["branch"], "main");
|
||||
assert_eq!(
|
||||
json["automation"]["workflow_source"]["resolved_sha"],
|
||||
"0123456789abcdef0123456789abcdef01234567"
|
||||
);
|
||||
assert_eq!(
|
||||
json["workflow_version_id"],
|
||||
test_workflow_version_id().to_string()
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ models/auth-session-user.ts
|
|||
models/auth-session.ts
|
||||
models/auth-sessions-response.ts
|
||||
models/automation-api-trigger.ts
|
||||
models/automation-git-workflow-source.ts
|
||||
models/automation-list-meta.ts
|
||||
models/automation-list-response.ts
|
||||
models/automation-ref.ts
|
||||
|
|
@ -335,6 +336,7 @@ models/replace-mcp-server-request.ts
|
|||
models/repo-check-response-permissions.ts
|
||||
models/repo-check-response.ts
|
||||
models/repository-ref.ts
|
||||
models/resolved-automation-git-workflow-source.ts
|
||||
models/review-target-kind.ts
|
||||
models/review-target.ts
|
||||
models/rewind-request.ts
|
||||
|
|
|
|||
37
lib/packages/fabro-api-client/src/models/automation-git-workflow-source.ts
generated
Normal file
37
lib/packages/fabro-api-client/src/models/automation-git-workflow-source.ts
generated
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Explicit GitHub coordinate from which an automation acquires workflow bytes. The branch is the fallback selector and audit context. An optional tag overrides the branch, and an optional exact SHA overrides both without requiring branch ancestry. This source is independent of the run target and does not provide its working branch.
|
||||
*/
|
||||
export interface AutomationGitWorkflowSource {
|
||||
/**
|
||||
* GitHub repository slug in `owner/name` form.
|
||||
*/
|
||||
'repo': string;
|
||||
/**
|
||||
* Required bare branch name used when neither tag nor SHA is present. It is retained as context when an override is present and is not an ancestry constraint.
|
||||
*/
|
||||
'branch': string;
|
||||
/**
|
||||
* Optional bare tag name. Without `sha`, this tag is resolved whenever the automation fires. Prefixes such as `refs/tags/` are rejected.
|
||||
*/
|
||||
'tag'?: string;
|
||||
/**
|
||||
* Optional exact commit, authoritative over tag and branch. The server lowercase-normalizes it and fetches it directly; it need not be reachable from the named branch.
|
||||
*/
|
||||
'sha'?: string;
|
||||
}
|
||||
|
|
@ -13,9 +13,13 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { ResolvedAutomationGitWorkflowSource } from './resolved-automation-git-workflow-source';
|
||||
|
||||
export interface AutomationRef {
|
||||
'id': string;
|
||||
'name': string | null;
|
||||
'trigger_id'?: string | null;
|
||||
'workflow_source'?: ResolvedAutomationGitWorkflowSource | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { AutomationGitWorkflowSource } from './automation-git-workflow-source';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { AutomationTrigger } from './automation-trigger';
|
||||
|
|
@ -41,8 +44,9 @@ export interface Automation {
|
|||
'last_error': string | null;
|
||||
'target': RunTarget;
|
||||
/**
|
||||
* Workflow slug or path resolved in the selected repository checkout.
|
||||
* Workflow slug or path resolved in the run-target checkout when `workflow_source` is omitted, or in the explicit workflow-source checkout when present.
|
||||
*/
|
||||
'workflow': string;
|
||||
'workflow_source'?: AutomationGitWorkflowSource;
|
||||
'triggers': Array<AutomationTrigger>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { AutomationGitWorkflowSource } from './automation-git-workflow-source';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { AutomationTrigger } from './automation-trigger';
|
||||
|
|
@ -33,8 +36,9 @@ export interface CreateAutomationRequest {
|
|||
'environment_id': string;
|
||||
'target': RunTarget;
|
||||
/**
|
||||
* Workflow slug or path resolved in the selected repository checkout.
|
||||
* Workflow slug or path resolved in the run-target checkout when `workflow_source` is omitted, or in the explicit workflow-source checkout when present.
|
||||
*/
|
||||
'workflow': string;
|
||||
'workflow_source'?: AutomationGitWorkflowSource;
|
||||
'triggers': Array<AutomationTrigger>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export * from './auth-session-user';
|
|||
export * from './auth-sessions-response';
|
||||
export * from './automation';
|
||||
export * from './automation-api-trigger';
|
||||
export * from './automation-git-workflow-source';
|
||||
export * from './automation-list-meta';
|
||||
export * from './automation-list-response';
|
||||
export * from './automation-ref';
|
||||
|
|
@ -304,6 +305,7 @@ export * from './replace-mcp-server-request';
|
|||
export * from './repo-check-response';
|
||||
export * from './repo-check-response-permissions';
|
||||
export * from './repository-ref';
|
||||
export * from './resolved-automation-git-workflow-source';
|
||||
export * from './review-target';
|
||||
export * from './review-target-kind';
|
||||
export * from './rewind-request';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { AutomationGitWorkflowSource } from './automation-git-workflow-source';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { AutomationTrigger } from './automation-trigger';
|
||||
|
|
@ -32,8 +35,9 @@ export interface ReplaceAutomationRequest {
|
|||
'environment_id': string;
|
||||
'target': RunTarget;
|
||||
/**
|
||||
* Workflow slug or path resolved in the selected repository checkout.
|
||||
* Workflow slug or path resolved in the run-target checkout when `workflow_source` is omitted, or in the explicit workflow-source checkout when present.
|
||||
*/
|
||||
'workflow': string;
|
||||
'workflow_source'?: AutomationGitWorkflowSource;
|
||||
'triggers': Array<AutomationTrigger>;
|
||||
}
|
||||
|
|
|
|||
41
lib/packages/fabro-api-client/src/models/resolved-automation-git-workflow-source.ts
generated
Normal file
41
lib/packages/fabro-api-client/src/models/resolved-automation-git-workflow-source.ts
generated
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Workflow source coordinate and exact commit captured when an automation run was created. The requested selectors remain available for audit context while `resolved_sha` identifies the immutable source revision that supplied the workflow bytes.
|
||||
*/
|
||||
export interface ResolvedAutomationGitWorkflowSource {
|
||||
/**
|
||||
* GitHub repository slug in `owner/name` form.
|
||||
*/
|
||||
'repo': string;
|
||||
/**
|
||||
* Required branch fallback and audit context.
|
||||
*/
|
||||
'branch': string;
|
||||
/**
|
||||
* Optional tag requested by the automation.
|
||||
*/
|
||||
'tag'?: string;
|
||||
/**
|
||||
* Optional exact commit requested by the automation.
|
||||
*/
|
||||
'sha'?: string;
|
||||
/**
|
||||
* Exact lowercase Git commit that supplied the workflow bytes.
|
||||
*/
|
||||
'resolved_sha': string;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue