diff --git a/.claude/skills/changelog/watermark b/.claude/skills/changelog/watermark
index 58fa6535bb..a6d048b56a 100644
--- a/.claude/skills/changelog/watermark
+++ b/.claude/skills/changelog/watermark
@@ -1 +1 @@
-ab68fc27d10412e9d827d07a62374ccc3cd12f12
+e38fbdcbeffbd04cc43f65608ac040cb17a89f68
diff --git a/.claude/skills/docs/watermark b/.claude/skills/docs/watermark
index d9eb0aee6c..a6d048b56a 100644
--- a/.claude/skills/docs/watermark
+++ b/.claude/skills/docs/watermark
@@ -1 +1 @@
-c54025a21c66136ec515ac392d5c072d0e245385
+e38fbdcbeffbd04cc43f65608ac040cb17a89f68
diff --git a/Cargo.lock b/Cargo.lock
index 0856c86427..8d9658290b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2484,6 +2484,7 @@ dependencies = [
"bytes",
"chrono",
"dashmap",
+ "fabro-automation",
"fabro-types",
"fabro-util",
"futures",
diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx
index 4788557ec8..395897f718 100644
--- a/apps/fabro-web/app/components/automation-form.tsx
+++ b/apps/fabro-web/app/components/automation-form.tsx
@@ -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";
@@ -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 = {
@@ -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 }> = [
@@ -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,
@@ -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,
};
}
@@ -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;
}
@@ -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() !== "")
);
}
@@ -376,6 +401,48 @@ export function AutomationFormFields({
) : null}
+
+ patch({ githubIssueEnabled })}
+ label="Enable GitHub issue label trigger"
+ />
+
+ {values.githubIssueEnabled ? (
+ <>
+ Trigger label} help="The label that starts this automation when applied.">
+ patch({ githubIssueTriggerLabel: e.target.value })}
+ placeholder="fabro"
+ autoComplete="off"
+ className={INPUT_CLASS}
+ />
+
+ Issue label} help="Optional extra label to pass into the workflow inputs.">
+ patch({ githubIssueIssueLabel: e.target.value })}
+ placeholder="Bug"
+ autoComplete="off"
+ className={INPUT_CLASS}
+ />
+
+
+ patch({ githubIssueComment })}
+ label="Post GitHub issue comments"
+ />
+
+ >
+ ) : null}
>
);
diff --git a/apps/fabro-web/app/components/runs-list/run-table-row.tsx b/apps/fabro-web/app/components/runs-list/run-table-row.tsx
index 4fdb82777f..f1d9de6aa0 100644
--- a/apps/fabro-web/app/components/runs-list/run-table-row.tsx
+++ b/apps/fabro-web/app/components/runs-list/run-table-row.tsx
@@ -137,6 +137,17 @@ export function RunTableRow({
)}
+ {!run.pullRequestUrl && run.sourceIssueUrl && run.sourceIssueNumber != null && (
+
+ #{run.sourceIssueNumber}
+
+ )}
)}
diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts
index 2b277b6d0f..f2301d4a88 100644
--- a/apps/fabro-web/app/data/runs.ts
+++ b/apps/fabro-web/app/data/runs.ts
@@ -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;
@@ -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,
};
}
diff --git a/apps/fabro-web/app/lib/automation.ts b/apps/fabro-web/app/lib/automation.ts
index 118ab61c2f..84bfdc1b77 100644
--- a/apps/fabro-web/app/lib/automation.ts
+++ b/apps/fabro-web/app/lib/automation.ts
@@ -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;
}
diff --git a/apps/fabro-web/app/routes/automations-new.test.tsx b/apps/fabro-web/app/routes/automations-new.test.tsx
index 47f471abf9..91658e48cd 100644
--- a/apps/fabro-web/app/routes/automations-new.test.tsx
+++ b/apps/fabro-web/app/routes/automations-new.test.tsx
@@ -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",
@@ -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", () => ({
@@ -89,9 +109,16 @@ mock.module("../lib/api-client", () => ({
const response = await call();
return response.data;
},
+ apiResponse: async function apiResponse(
+ call: () => Promise,
+ ): Promise {
+ return await call();
+ },
+ requestSignalOptions: () => undefined,
automationsApi: {
createAutomation: createAutomationMock,
},
+ runsApi: {},
}));
mock.module("swr", () => ({
@@ -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,
): string {
@@ -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,
+ },
+ ],
+ });
+ });
});
diff --git a/apps/fabro-web/app/routes/run-detail/header.tsx b/apps/fabro-web/app/routes/run-detail/header.tsx
index efc68f09b5..104aad3130 100644
--- a/apps/fabro-web/app/routes/run-detail/header.tsx
+++ b/apps/fabro-web/app/routes/run-detail/header.tsx
@@ -94,6 +94,9 @@ export function RunDetailHeader({
const sizeChip = (
);
+ const sourceIssue = summary.source_context?.type === "github_issue"
+ ? summary.source_context
+ : null;
return (
<>
@@ -199,6 +202,18 @@ export function RunDetailHeader({
)}
+ {sourceIssue && (
+
+ Issue #{sourceIssue.issue_number}
+
+ )}
+
{actions.approval.visible && (
|