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 && (