Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/changelog/watermark
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ab68fc27d10412e9d827d07a62374ccc3cd12f12
e38fbdcbeffbd04cc43f65608ac040cb17a89f68
2 changes: 1 addition & 1 deletion .claude/skills/docs/watermark
Original file line number Diff line number Diff line change
@@ -1 +1 @@
c54025a21c66136ec515ac392d5c072d0e245385
e38fbdcbeffbd04cc43f65608ac040cb17a89f68
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 69 additions & 2 deletions apps/fabro-web/app/components/automation-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
WorkflowSettings,
} from "@qltysh/fabro-api-client";

import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
import { findApiTrigger, findGithubIssueTrigger, findScheduleTrigger } from "../lib/automation";
import { Panel, Row } from "./settings-panel";
import { INPUT_CLASS } from "./ui";
import { sandboxRuntime } from "../lib/run-sandbox-lifecycle";
Expand All @@ -22,6 +22,10 @@ export interface AutomationFormValues {
manualEnabled: boolean;
scheduleEnabled: boolean;
cron: string;
githubIssueEnabled: boolean;
githubIssueTriggerLabel: string;
githubIssueIssueLabel: string;
githubIssueComment: boolean;
}

export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
Expand All @@ -34,6 +38,10 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
manualEnabled: true,
scheduleEnabled: false,
cron: "0 9 * * 1-5",
githubIssueEnabled: false,
githubIssueTriggerLabel: "fabro",
githubIssueIssueLabel: "",
githubIssueComment: true,
};

const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
Expand All @@ -46,6 +54,7 @@ const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
export function automationToFormValues(automation: Automation): AutomationFormValues {
const apiTrigger = findApiTrigger(automation);
const scheduleTrigger = findScheduleTrigger(automation);
const githubIssueTrigger = findGithubIssueTrigger(automation);
return {
id: automation.id,
name: automation.name,
Expand All @@ -56,6 +65,10 @@ export function automationToFormValues(automation: Automation): AutomationFormVa
manualEnabled: apiTrigger?.enabled ?? false,
scheduleEnabled: scheduleTrigger?.enabled ?? false,
cron: scheduleTrigger?.expression ?? "0 9 * * 1-5",
githubIssueEnabled: githubIssueTrigger?.enabled ?? false,
githubIssueTriggerLabel: githubIssueTrigger?.trigger_label ?? "fabro",
githubIssueIssueLabel: githubIssueTrigger?.issue_label ?? "",
githubIssueComment: githubIssueTrigger?.comment ?? true,
};
}

Expand Down Expand Up @@ -103,6 +116,17 @@ export function triggersFromFormValues(values: AutomationFormValues): Automation
expression: values.cron.trim(),
});
}
if (values.githubIssueEnabled) {
const issueLabel = values.githubIssueIssueLabel.trim();
triggers.push({
id: "github-issue",
type: "github_issue",
enabled: true,
trigger_label: values.githubIssueTriggerLabel.trim(),
issue_label: issueLabel === "" ? null : issueLabel,
comment: values.githubIssueComment,
});
}
return triggers;
}

Expand All @@ -112,7 +136,8 @@ export function isFormValid(values: AutomationFormValues): boolean {
values.name.trim() !== "" &&
values.repository.trim() !== "" &&
values.ref.trim() !== "" &&
values.workflow.trim() !== ""
values.workflow.trim() !== "" &&
(!values.githubIssueEnabled || values.githubIssueTriggerLabel.trim() !== "")
);
}

Expand Down Expand Up @@ -376,6 +401,48 @@ export function AutomationFormFields({
</div>
</Row>
) : null}
<Row title="GitHub issue label" help="Start runs when a matching label is added to a GitHub issue.">
<ToggleSwitch
checked={values.githubIssueEnabled}
onChange={(githubIssueEnabled) => patch({ githubIssueEnabled })}
label="Enable GitHub issue label trigger"
/>
</Row>
{values.githubIssueEnabled ? (
<>
<Row title={<Label required>Trigger label</Label>} help="The label that starts this automation when applied.">
<input
type="text"
name="github_issue_trigger_label"
aria-label="GitHub trigger label"
value={values.githubIssueTriggerLabel}
onChange={(e) => patch({ githubIssueTriggerLabel: e.target.value })}
placeholder="fabro"
autoComplete="off"
className={INPUT_CLASS}
/>
</Row>
<Row title={<Label optional>Issue label</Label>} help="Optional extra label to pass into the workflow inputs.">
<input
type="text"
name="github_issue_issue_label"
aria-label="GitHub issue label"
value={values.githubIssueIssueLabel}
onChange={(e) => patch({ githubIssueIssueLabel: e.target.value })}
placeholder="Bug"
autoComplete="off"
className={INPUT_CLASS}
/>
</Row>
<Row title="Comment on issue" help="Post a comment when Fabro starts or cannot start the run.">
<ToggleSwitch
checked={values.githubIssueComment}
onChange={(githubIssueComment) => patch({ githubIssueComment })}
label="Post GitHub issue comments"
/>
</Row>
</>
) : null}
</Panel>
</>
);
Expand Down
11 changes: 11 additions & 0 deletions apps/fabro-web/app/components/runs-list/run-table-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ export function RunTableRow({
</PullRequestChip>
</span>
)}
{!run.pullRequestUrl && run.sourceIssueUrl && run.sourceIssueNumber != null && (
<a
href={run.sourceIssueUrl}
target="_blank"
rel="noopener noreferrer"
title={run.sourceIssueTitle}
className="relative z-10 font-mono text-xs text-fg-muted hover:text-fg"
>
#{run.sourceIssueNumber}
</a>
)}
</td>
)}
<td className="relative z-10 w-10 whitespace-nowrap px-3 py-2.5 text-right">
Expand Down
12 changes: 12 additions & 0 deletions apps/fabro-web/app/data/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export interface RunItem {
resources?: string;
actionDisabled?: boolean;
comments?: number;
sourceIssueNumber?: number;
sourceIssueTitle?: string;
sourceIssueUrl?: string;
question?: string;
pendingApproval?: boolean;
sandboxId?: string;
Expand Down Expand Up @@ -115,6 +118,15 @@ export function mapRunListItem(item: Run): RunItem {
lastEventAt: item.timestamps.last_event_at ?? undefined,
additions: item.diff?.additions,
deletions: item.diff?.deletions,
sourceIssueNumber: item.source_context?.type === "github_issue"
? item.source_context.issue_number
: undefined,
sourceIssueTitle: item.source_context?.type === "github_issue"
? item.source_context.issue_title
: undefined,
sourceIssueUrl: item.source_context?.type === "github_issue"
? item.source_context.issue_url
: undefined,
size: item.size,
};
}
Expand Down
6 changes: 6 additions & 0 deletions apps/fabro-web/app/lib/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export function findScheduleTrigger(
return automation.triggers.find((t): t is TriggerOfType<"schedule"> => t.type === "schedule");
}

export function findGithubIssueTrigger(
automation: Automation,
): TriggerOfType<"github_issue"> | undefined {
return automation.triggers.find((t): t is TriggerOfType<"github_issue"> => t.type === "github_issue");
}

export function hasEnabledApiTrigger(automation: Automation): boolean {
return findApiTrigger(automation)?.enabled === true;
}
84 changes: 84 additions & 0 deletions apps/fabro-web/app/routes/automations-new.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ mock.module("@headlessui/react", () => ({
createElement("div", props, children),
DialogTitle: ({ children, ...props }: any) =>
createElement("h2", props, children),
Menu: ({ children, ...props }: any) =>
createElement("div", props, children),
MenuButton: ({ children, ...props }: any) =>
createElement("button", { ...props, type: "button" }, children),
MenuItem: ({ children, ...props }: any) =>
createElement("div", props, typeof children === "function" ? children({ focus: false }) : children),
MenuItems: ({ children, ...props }: any) =>
createElement("div", props, children),
Listbox: ({ children, ...props }: any) =>
createElement("div", props, children),
ListboxButton: ({ children, ...props }: any) =>
createElement("button", { ...props, type: "button" }, children),
ListboxOption: ({ children, ...props }: any) =>
createElement("div", props, typeof children === "function" ? children({ selected: false }) : children),
ListboxOptions: ({ children, ...props }: any) =>
createElement("div", props, children),
Switch: ({ checked, onChange, children, ...props }: any) =>
createElement(
"button",
Expand Down Expand Up @@ -57,6 +73,10 @@ mock.module("../lib/queries", () => ({
isLoading: false,
};
},
useAllRuns: () => ({ data: null, error: null, isLoading: false }),
useAuthConfig: () => ({ data: null, error: null, isLoading: false }),
useRunsPage: () => ({ data: null, error: null, isLoading: false }),
useSystemInfo: () => ({ data: null, error: null, isLoading: false }),
}));

mock.module("../lib/api-client", () => ({
Expand Down Expand Up @@ -89,9 +109,16 @@ mock.module("../lib/api-client", () => ({
const response = await call();
return response.data;
},
apiResponse: async function apiResponse<T>(
call: () => Promise<T>,
): Promise<T> {
return await call();
},
requestSignalOptions: () => undefined,
automationsApi: {
createAutomation: createAutomationMock,
},
runsApi: {},
}));

mock.module("swr", () => ({
Expand Down Expand Up @@ -223,6 +250,25 @@ function switchChecked(renderer: TestRenderer.ReactTestRenderer, label: string)
return props["aria-checked"] ?? props.checked;
}

function changeField(
renderer: TestRenderer.ReactTestRenderer,
label: string,
value: string,
) {
renderer.root.findByProps({ "aria-label": label }).props.onChange({
target: { value },
});
}

function clickSwitch(renderer: TestRenderer.ReactTestRenderer, label: string) {
const props = renderer.root.findByProps({ "aria-label": label }).props;
if (typeof props.onClick === "function") {
props.onClick();
} else {
props.onChange(!switchChecked(renderer, label));
}
}

function textFromNode(
node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>,
): string {
Expand Down Expand Up @@ -297,4 +343,42 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Default branch")).toBe("main");
expect(fieldValue(renderer, "Workflow slug")).toBe("");
});

test("creates automation with GitHub issue trigger", async () => {
const { renderer } = await renderAutomationsNew("/automations/new");

await act(async () => changeField(renderer, "Automation name", "Issue Intake"));
await act(async () => changeField(renderer, "Repository", "qltysh/fabro"));
await act(async () => changeField(renderer, "Workflow slug", "issue_intake"));
await act(async () => clickSwitch(renderer, "Enable GitHub issue label trigger"));
await act(async () => changeField(renderer, "GitHub trigger label", "fabro"));
await act(async () => changeField(renderer, "GitHub issue label", "Bug"));

await act(async () => {
await renderer.root.findByType("form").props.onSubmit({
preventDefault() {},
});
});

expect(createAutomationMock).toHaveBeenCalledTimes(1);
expect(createAutomationMock.mock.calls[0]?.[0]).toMatchObject({
id: "issue-intake",
target: {
repository: "qltysh/fabro",
ref: "main",
workflow: "issue_intake",
},
triggers: [
{ id: "manual", type: "api", enabled: true },
{
id: "github-issue",
type: "github_issue",
enabled: true,
trigger_label: "fabro",
issue_label: "Bug",
comment: true,
},
],
});
});
});
15 changes: 15 additions & 0 deletions apps/fabro-web/app/routes/run-detail/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ export function RunDetailHeader({
const sizeChip = (
<SizeChip size={summary.size} totalUsdMicros={summary.billing?.total_usd_micros} />
);
const sourceIssue = summary.source_context?.type === "github_issue"
? summary.source_context
: null;

return (
<>
Expand Down Expand Up @@ -199,6 +202,18 @@ export function RunDetailHeader({
</HoverCard>
)}

{sourceIssue && (
<a
href={sourceIssue.issue_url}
target="_blank"
rel="noopener noreferrer"
title={sourceIssue.issue_title}
className={SECONDARY_BUTTON_CLASS}
>
<span className="font-mono">Issue #{sourceIssue.issue_number}</span>
</a>
)}

{actions.approval.visible && (
<button
type="button"
Expand Down
Loading
Loading