diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..1ef1d0f2b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "code", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "code", "dev"], + "port": 5173 + } + ] +} diff --git a/.claude/worktrees/gifted-haibt b/.claude/worktrees/gifted-haibt new file mode 160000 index 000000000..53ed7f4e2 --- /dev/null +++ b/.claude/worktrees/gifted-haibt @@ -0,0 +1 @@ +Subproject commit 53ed7f4e23ebe2afb197fb2e9adfdb26afb25afb diff --git a/apps/code/src/main/db/migrations/0003_bumpy_morgan_stark.sql b/apps/code/src/main/db/migrations/0003_bumpy_morgan_stark.sql new file mode 100644 index 000000000..ccde634f2 --- /dev/null +++ b/apps/code/src/main/db/migrations/0003_bumpy_morgan_stark.sql @@ -0,0 +1,32 @@ +CREATE TABLE `automation_runs` ( + `id` text PRIMARY KEY NOT NULL, + `automation_id` text NOT NULL, + `status` text NOT NULL, + `output` text, + `error` text, + `started_at` text NOT NULL, + `completed_at` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`automation_id`) REFERENCES `automations`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `automation_runs_automation_id_idx` ON `automation_runs` (`automation_id`);--> statement-breakpoint +CREATE TABLE `automations` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `prompt` text NOT NULL, + `repo_path` text NOT NULL, + `repository` text, + `github_integration_id` integer, + `schedule_time` text NOT NULL, + `timezone` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `template_id` text, + `last_run_at` text, + `last_run_status` text, + `last_task_id` text, + `last_error` text, + `next_run_at` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); diff --git a/apps/code/src/main/db/migrations/meta/0003_snapshot.json b/apps/code/src/main/db/migrations/meta/0003_snapshot.json new file mode 100644 index 000000000..c5d0919f6 --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,654 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b2235e08-596a-4baa-b107-d676f1882b17", + "prevId": "f5d77788-5c4e-4bfa-a114-096b8d377332", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": [ + "workspace_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "automation_runs_automation_id_idx": { + "name": "automation_runs_automation_id_idx", + "columns": [ + "automation_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_integration_id": { + "name": "github_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_time": { + "name": "schedule_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_task_id": { + "name": "last_task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": [ + "workspace_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": [ + "task_id" + ], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": [ + "repository_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": [ + "workspace_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 3583bdfb3..2fe891dc4 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1773335630838, "tag": "0002_massive_bishop", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1774459077084, + "tag": "0003_bumpy_morgan_stark", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 677932b39..387038594 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { index, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; const id = () => text() @@ -76,3 +76,42 @@ export const suspensions = sqliteTable("suspensions", { createdAt: createdAt(), updatedAt: updatedAt(), }); + +export const automations = sqliteTable("automations", { + id: id(), + name: text().notNull(), + prompt: text().notNull(), + repoPath: text().notNull(), + repository: text(), + githubIntegrationId: integer(), + scheduleTime: text().notNull(), + timezone: text().notNull(), + enabled: integer({ mode: "boolean" }).notNull().default(true), + templateId: text(), + lastRunAt: text(), + lastRunStatus: text({ enum: ["success", "failed", "skipped", "running"] }), + lastTaskId: text(), + lastError: text(), + nextRunAt: text(), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export const automationRuns = sqliteTable( + "automation_runs", + { + id: id(), + automationId: text() + .notNull() + .references(() => automations.id, { onDelete: "cascade" }), + status: text({ + enum: ["running", "success", "failed", "skipped"], + }).notNull(), + output: text(), + error: text(), + startedAt: text().notNull(), + completedAt: text(), + createdAt: createdAt(), + }, + (t) => [index("automation_runs_automation_id_idx").on(t.automationId)], +); diff --git a/apps/code/src/renderer/api/fetcher.ts b/apps/code/src/renderer/api/fetcher.ts index 14a69051b..9fb1242c4 100644 --- a/apps/code/src/renderer/api/fetcher.ts +++ b/apps/code/src/renderer/api/fetcher.ts @@ -8,6 +8,22 @@ export const buildApiFetcher: (config: { }) => Parameters[0] = (config) => { let currentToken = config.apiToken; + const formatErrorResponse = async (response: Response): Promise => { + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("application/json")) { + const errorResponse = await response.json().catch(() => ({})); + return `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`; + } + + const bodyText = await response.text().catch(() => ""); + const preview = bodyText.replace(/\s+/g, " ").trim().slice(0, 200); + + return `Failed request: [${response.status}] ${response.statusText}${ + preview ? ` ${preview}` : "" + }`; + }; + const makeRequest = async ( input: Parameters[0]["fetch"]>[0], token: string, @@ -66,18 +82,12 @@ export const buildApiFetcher: (config: { response = await makeRequest(input, currentToken); } catch { // Token refresh failed - throw the original 401 error - const errorResponse = await response.json(); - throw new Error( - `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`, - ); + throw new Error(await formatErrorResponse(response)); } } if (!response.ok) { - const errorResponse = await response.json(); - throw new Error( - `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`, - ); + throw new Error(await formatErrorResponse(response)); } return response; diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index c7eef4a64..fb8ea5caa 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -50,6 +50,25 @@ export interface ExternalDataSource { schemas?: ExternalDataSourceSchema[] | string; } +export interface TaskAutomationApi { + id: string; + name: string; + prompt: string; + repository: string; + github_integration?: number | null; + schedule_time: string; + timezone: string; + template_id?: string | null; + enabled: boolean; + last_run_at?: string | null; + last_run_status?: "success" | "failed" | "running" | null; + last_task_id?: string | null; + last_task_run_id?: string | null; + last_error?: string | null; + created_at: string; + updated_at: string; +} + function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -382,6 +401,139 @@ export class PostHogAPIClient { return data.results ?? []; } + async listTaskAutomations(): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_automations/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch task automations: ${response.statusText}`, + ); + } + const data = (await response.json()) as + | { results?: TaskAutomationApi[] } + | TaskAutomationApi[]; + + return Array.isArray(data) ? data : (data.results ?? []); + } + + async createTaskAutomation(input: { + name: string; + prompt: string; + repository: string; + github_integration?: number | null; + schedule_time: string; + timezone: string; + template_id?: string | null; + enabled?: boolean; + }): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_automations/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify(input), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to create task automation: ${response.statusText}`, + ); + } + + return (await response.json()) as TaskAutomationApi; + } + + async updateTaskAutomation( + automationId: string, + input: Partial<{ + name: string; + prompt: string; + repository: string; + github_integration: number | null; + schedule_time: string; + timezone: string; + template_id: string | null; + enabled: boolean; + }>, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_automations/${automationId}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: urlPath, + overrides: { + body: JSON.stringify(input), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to update task automation: ${response.statusText}`, + ); + } + + return (await response.json()) as TaskAutomationApi; + } + + async deleteTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_automations/${automationId}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to delete task automation: ${response.statusText}`, + ); + } + } + + async runTaskAutomationNow(automationId: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_automations/${automationId}/run_now/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({}), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to run task automation: ${response.statusText}`, + ); + } + + return (await response.json()) as TaskAutomationApi; + } + async getTask(taskId: string) { const teamId = await this.getTeamId(); const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { @@ -700,12 +852,12 @@ export class PostHogAPIClient { async getIntegrationsForProject(projectId: number) { const url = new URL( - `${this.api.baseUrl}/api/environments/${projectId}/integrations/`, + `${this.api.baseUrl}/api/projects/${projectId}/integrations/`, ); const response = await this.api.fetcher.fetch({ method: "get", url, - path: `/api/environments/${projectId}/integrations/`, + path: `/api/projects/${projectId}/integrations/`, }); if (!response.ok) { @@ -722,13 +874,13 @@ export class PostHogAPIClient { ): Promise<{ branches: string[]; defaultBranch: string | null }> { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + `${this.api.baseUrl}/api/projects/${teamId}/integrations/${integrationId}/github_branches/`, ); url.searchParams.set("repo", repo); const response = await this.api.fetcher.fetch({ method: "get", url, - path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + path: `/api/projects/${teamId}/integrations/${integrationId}/github_branches/`, }); if (!response.ok) { @@ -749,12 +901,12 @@ export class PostHogAPIClient { ): Promise { const teamId = await this.getTeamId(); const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_repos/`, + `${this.api.baseUrl}/api/projects/${teamId}/integrations/${integrationId}/github_repos/`, ); const response = await this.api.fetcher.fetch({ method: "get", url, - path: `/api/environments/${teamId}/integrations/${integrationId}/github_repos/`, + path: `/api/projects/${teamId}/integrations/${integrationId}/github_repos/`, }); if (!response.ok) { diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 698574438..9bb563ea6 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -4,6 +4,7 @@ import { HedgehogMode } from "@components/HedgehogMode"; import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet"; import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView"; +import { AutomationsView } from "@features/automations/components/AutomationsView"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; import { InboxView } from "@features/inbox/components/InboxView"; @@ -80,6 +81,8 @@ export function MainLayout() { {view.type === "command-center" && } + {view.type === "automations" && } + {view.type === "skills" && } diff --git a/apps/code/src/renderer/features/automations/components/AutomationsView.tsx b/apps/code/src/renderer/features/automations/components/AutomationsView.tsx new file mode 100644 index 000000000..53865586d --- /dev/null +++ b/apps/code/src/renderer/features/automations/components/AutomationsView.tsx @@ -0,0 +1,733 @@ +import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; +import { useRepositoryIntegration } from "@hooks/useIntegrations"; +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { + ClockCounterClockwise, + FloppyDisk, + Play, + Plus, + Robot, + Trash, +} from "@phosphor-icons/react"; +import { + Badge, + Box, + Button, + Flex, + ScrollArea, + Separator, + Switch, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; +import type { Automation } from "@shared/types/automations"; +import { useEffect, useMemo, useState } from "react"; +import { + useAutomations, + useCreateAutomation, + useDeleteAutomation, + useRunAutomationNow, + useUpdateAutomation, +} from "../hooks/useAutomations"; +import { AUTOMATION_TEMPLATES } from "../templates"; +import { formatAutomationDateTime, getLocalTimezone } from "../utils/schedule"; + +interface AutomationDraft { + name: string; + prompt: string; + repository: string | null; + githubIntegrationId: number | null; + scheduleTime: string; + templateId: string | null; +} + +function toDraft( + automation?: Automation | null, + githubIntegrationId?: number | null, +): AutomationDraft { + if (!automation) { + return { + name: "", + prompt: "", + repository: null, + githubIntegrationId: githubIntegrationId ?? null, + scheduleTime: "09:00", + templateId: null, + }; + } + + return { + name: automation.name, + prompt: automation.prompt, + repository: automation.repository ?? automation.repoPath ?? null, + githubIntegrationId: + automation.githubIntegrationId ?? githubIntegrationId ?? null, + scheduleTime: automation.scheduleTime, + templateId: automation.templateId ?? null, + }; +} + +function AutomationStatusBadge({ automation }: { automation: Automation }) { + if (!automation.enabled) { + return ( + + Paused + + ); + } + + if (automation.lastRunStatus === "failed") { + return ( + + Failed + + ); + } + + if (automation.lastRunStatus === "success") { + return ( + + Healthy + + ); + } + + if (automation.lastRunStatus === "running") { + return ( + + Running + + ); + } + + return ( + + Active + + ); +} + +export function AutomationsView() { + const { automations, isLoading } = useAutomations(); + const createAutomation = useCreateAutomation(); + const updateAutomation = useUpdateAutomation(); + const deleteAutomation = useDeleteAutomation(); + const runAutomationNow = useRunAutomationNow(); + const { githubIntegration, repositories, isLoadingRepos } = + useRepositoryIntegration(); + + const [selectedAutomationId, setSelectedAutomationId] = useState< + string | null + >(null); + const [isCreating, setIsCreating] = useState(true); + const [draft, setDraft] = useState(() => + toDraft(null, githubIntegration?.id), + ); + const [pendingRunAutomationId, setPendingRunAutomationId] = useState< + string | null + >(null); + const [pendingToggleAutomationId, setPendingToggleAutomationId] = useState< + string | null + >(null); + const [formError, setFormError] = useState(null); + + const selectedAutomation = useMemo( + () => + automations.find( + (automation) => automation.id === selectedAutomationId, + ) ?? null, + [automations, selectedAutomationId], + ); + + useEffect(() => { + if (isLoading) { + return; + } + + if (automations.length === 0) { + setIsCreating(true); + setSelectedAutomationId(null); + return; + } + + if (isCreating) { + return; + } + + if (!selectedAutomationId) { + setSelectedAutomationId(automations[0]?.id ?? null); + return; + } + + const stillExists = automations.some( + (automation) => automation.id === selectedAutomationId, + ); + if (!stillExists) { + setSelectedAutomationId(automations[0]?.id ?? null); + } + }, [automations, isCreating, isLoading, selectedAutomationId]); + + useEffect(() => { + if (isCreating) { + setDraft(toDraft(null, githubIntegration?.id)); + return; + } + + if (selectedAutomation) { + setDraft(toDraft(selectedAutomation, githubIntegration?.id)); + } + }, [isCreating, selectedAutomation, githubIntegration?.id]); + + const headerContent = useMemo( + () => ( + + + + Automations + + + ), + [], + ); + + useSetHeaderContent(headerContent); + + const timezone = getLocalTimezone(); + const enabledCount = automations.filter( + (automation) => automation.enabled, + ).length; + const hasGitHubIntegration = + Boolean(githubIntegration) && repositories.length > 0; + + const openCreate = () => { + setFormError(null); + setIsCreating(true); + setSelectedAutomationId(null); + }; + + const openExisting = (automation: Automation) => { + setFormError(null); + setIsCreating(false); + setSelectedAutomationId(automation.id); + }; + + const applyTemplate = (templateId: string) => { + const template = AUTOMATION_TEMPLATES.find( + (item) => item.id === templateId, + ); + if (!template) { + return; + } + + setDraft((current) => ({ + ...current, + name: current.name || template.name, + prompt: template.prompt, + templateId: template.id, + })); + }; + + const handleSave = async () => { + if (!draft.name.trim() || !draft.prompt.trim() || !draft.repository) { + return; + } + + setFormError(null); + + try { + if (isCreating || !selectedAutomation) { + const created = await createAutomation.mutateAsync({ + name: draft.name.trim(), + prompt: draft.prompt.trim(), + repository: draft.repository, + github_integration: draft.githubIntegrationId, + schedule_time: draft.scheduleTime, + timezone, + template_id: draft.templateId, + enabled: true, + }); + setIsCreating(false); + setSelectedAutomationId(created.id); + return; + } + + await updateAutomation.mutateAsync({ + automationId: selectedAutomation.id, + updates: { + name: draft.name.trim(), + prompt: draft.prompt.trim(), + repository: draft.repository, + github_integration: draft.githubIntegrationId, + schedule_time: draft.scheduleTime, + timezone, + template_id: draft.templateId, + }, + }); + } catch (error) { + setFormError( + error instanceof Error ? error.message : "Failed to save automation.", + ); + } + }; + + const handleDelete = async () => { + if (!selectedAutomation) { + return; + } + + await deleteAutomation.mutateAsync(selectedAutomation.id); + openCreate(); + }; + + const handleToggleEnabled = async (enabled: boolean) => { + if (!selectedAutomation) { + return; + } + + setPendingToggleAutomationId(selectedAutomation.id); + setFormError(null); + try { + await updateAutomation.mutateAsync({ + automationId: selectedAutomation.id, + updates: { enabled }, + }); + } catch (error) { + setFormError( + error instanceof Error + ? error.message + : "Failed to update automation state.", + ); + } finally { + setPendingToggleAutomationId(null); + } + }; + + const handleRunNow = async () => { + if (!selectedAutomation) { + return; + } + + setPendingRunAutomationId(selectedAutomation.id); + setFormError(null); + try { + await runAutomationNow.mutateAsync(selectedAutomation.id); + } catch (error) { + setFormError( + error instanceof Error ? error.message : "Failed to run automation.", + ); + } finally { + setPendingRunAutomationId(null); + } + }; + + const isSaving = createAutomation.isPending || updateAutomation.isPending; + const isDeleting = deleteAutomation.isPending; + + return ( + + + + {automations.length} automation{automations.length === 1 ? "" : "s"} + + + {enabledCount} enabled + +
+ + + + + + + + {isLoading ? ( + + + Loading automations... + + + ) : automations.length === 0 ? ( + + + + No automations yet + + + ) : ( + automations.map((automation) => { + const isSelected = + !isCreating && selectedAutomation?.id === automation.id; + + return ( + + ); + }) + )} + + + + + + + + + + {isCreating + ? "New automation" + : (selectedAutomation?.name ?? "Automation")} + + + Runs in the cloud sandbox on schedule, even while Twig is + closed. + + + + + + Template library + + + {AUTOMATION_TEMPLATES.map((template) => ( + + + + + {template.name} + + + {template.category} + + + + {template.description} + + + + + ))} + + + + + + + + + Name + + + setDraft((current) => ({ + ...current, + name: event.target.value, + })) + } + placeholder="Morning repo check" + /> + + + + + Prompt + +