From bd89917b1237a748ecf332457087bd421be14d9c Mon Sep 17 00:00:00 2001 From: fkukuck Date: Mon, 8 Jun 2026 11:49:45 +0200 Subject: [PATCH 1/4] feat(automations): add GitHub issue triggers Adds automation-based GitHub issue intake: trigger model, run source context, trigger-cycle persistence, webhook dispatch, issue comments, OpenAPI/client updates, and web UI support. Verification: - cargo +nightly-2026-04-14 fmt --all - bun test app/routes/automations-new.test.tsx app/routes/runs.test.tsx - bun run typecheck - cargo nextest run -p fabro-automation -p fabro-types -p fabro-store -p fabro-github -p fabro-server -p fabro-api: fails on pre-existing fabro-server server::tests::get_graph_returns_svg - bun run test: fails on app/routes/run-detail.test.ts when run in full suite; test passes standalone --- Cargo.lock | 1 + .../app/components/automation-form.tsx | 71 +- .../components/runs-list/run-table-row.tsx | 11 + apps/fabro-web/app/data/runs.ts | 12 + apps/fabro-web/app/lib/automation.ts | 6 + .../app/routes/automations-new.test.tsx | 84 ++ .../app/routes/run-detail/header.tsx | 15 + apps/fabro-web/app/routes/runs.test.tsx | 26 +- docs/public/api-reference/fabro-api.yaml | 81 ++ ...-08-github-issue-automation-intake-port.md | 973 ++++++++++++++++++ lib/crates/fabro-api/build.rs | 6 + lib/crates/fabro-api/src/lib.rs | 27 +- .../fabro-api/tests/automation_round_trip.rs | 19 +- .../tests/run_projection_round_trip.rs | 15 +- .../fabro-api/tests/run_summary_round_trip.rs | 22 +- lib/crates/fabro-automation/src/lib.rs | 2 +- lib/crates/fabro-automation/src/model.rs | 72 +- lib/crates/fabro-github/src/lib.rs | 55 + .../src/automation_materializer.rs | 45 +- .../fabro-server/src/automation_runner.rs | 249 +++++ lib/crates/fabro-server/src/demo/mod.rs | 1 + .../src/github_issue_automation_trigger.rs | 444 ++++++++ .../fabro-server/src/github_issue_events.rs | 152 +++ lib/crates/fabro-server/src/lib.rs | 3 + lib/crates/fabro-server/src/run_files.rs | 1 + lib/crates/fabro-server/src/run_manifest.rs | 1 + lib/crates/fabro-server/src/serve.rs | 2 + lib/crates/fabro-server/src/server.rs | 43 +- .../src/server/automation_scheduler.rs | 81 +- .../src/server/handler/automations.rs | 77 +- .../fabro-server/src/server/handler/events.rs | 1 + .../src/server/handler/lifecycle.rs | 2 +- .../fabro-server/src/server/handler/mod.rs | 4 +- .../fabro-server/src/server/handler/pair.rs | 1 + .../fabro-server/src/server/handler/runs.rs | 10 +- .../src/server/handler/sessions.rs | 1 + lib/crates/fabro-server/src/server/tests.rs | 337 +++++- lib/crates/fabro-server/src/test_support.rs | 24 +- lib/crates/fabro-store/Cargo.toml | 1 + lib/crates/fabro-store/src/lib.rs | 7 +- lib/crates/fabro-store/src/run_state.rs | 5 + .../src/slate/automation_trigger_runs.rs | 682 ++++++++++++ lib/crates/fabro-store/src/slate/mod.rs | 24 + .../tests/serializable_projection.rs | 1 + lib/crates/fabro-types/src/lib.rs | 4 +- lib/crates/fabro-types/src/run.rs | 16 + lib/crates/fabro-types/src/run_event/run.rs | 5 +- lib/crates/fabro-types/src/run_projection.rs | 2 + lib/crates/fabro-types/src/run_summary.rs | 4 +- .../fabro-types/tests/run_event_serde.rs | 21 +- .../fabro-types/tests/run_spec_methods.rs | 1 + .../fabro-types/tests/run_spec_serde.rs | 20 +- .../fabro-workflow/src/event/convert.rs | 2 + lib/crates/fabro-workflow/src/event/events.rs | 6 +- .../fabro-workflow/src/operations/create.rs | 19 +- .../fabro-workflow/src/operations/fork.rs | 1 + .../fabro-workflow/src/operations/retry.rs | 2 + lib/crates/fabro-workflow/src/test_support.rs | 1 + .../src/.openapi-generator/FILES | 2 + .../models/automation-github-issue-trigger.ts | 33 + .../src/models/automation-trigger.ts | 5 +- .../src/models/github-issue-run-source.ts | 29 + .../fabro-api-client/src/models/index.ts | 2 + .../fabro-api-client/src/models/run-spec.ts | 4 + .../fabro-api-client/src/models/run.ts | 4 + 65 files changed, 3701 insertions(+), 179 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-08-github-issue-automation-intake-port.md create mode 100644 lib/crates/fabro-server/src/automation_runner.rs create mode 100644 lib/crates/fabro-server/src/github_issue_automation_trigger.rs create mode 100644 lib/crates/fabro-server/src/github_issue_events.rs create mode 100644 lib/crates/fabro-store/src/slate/automation_trigger_runs.rs create mode 100644 lib/packages/fabro-api-client/src/models/automation-github-issue-trigger.ts create mode 100644 lib/packages/fabro-api-client/src/models/github-issue-run-source.ts 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 && (