diff --git a/crates/db/.sqlx/query-24af70a49202c620de1837961c37327e156160562b71197f8a2747f41187e198.json b/crates/db/.sqlx/query-24af70a49202c620de1837961c37327e156160562b71197f8a2747f41187e198.json new file mode 100644 index 0000000000..25f286e15f --- /dev/null +++ b/crates/db/.sqlx/query-24af70a49202c620de1837961c37327e156160562b71197f8a2747f41187e198.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n t.id AS \"id!: Uuid\",\n t.project_id AS \"project_id!: Uuid\",\n t.title,\n t.description,\n t.status AS \"status!: TaskStatus\",\n t.parent_workspace_id AS \"parent_workspace_id: Uuid\",\n t.shared_task_id AS \"shared_task_id: Uuid\",\n t.github_issue_number AS \"github_issue_number: i64\",\n t.github_issue_url AS \"github_issue_url: String\",\n t.created_at AS \"created_at!: DateTime\",\n t.updated_at AS \"updated_at!: DateTime\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM workspaces w\n JOIN sessions s ON s.workspace_id = w.id\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE w.task_id = t.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"has_in_progress_attempt!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM workspaces w\n JOIN sessions s ON s.workspace_id = w.id\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE w.task_id = t.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END\n AS \"last_attempt_failed!: i64\",\n\n ( SELECT s.executor\n FROM workspaces w\n JOIN sessions s ON s.workspace_id = w.id\n WHERE w.task_id = t.id\n ORDER BY s.created_at DESC\n LIMIT 1\n ) AS \"executor!: String\",\n\n ( SELECT m.pr_number\n FROM workspaces w\n JOIN merges m ON m.workspace_id = w.id\n WHERE w.task_id = t.id\n AND m.merge_type = 'pr'\n AND m.pr_status = 'open'\n ORDER BY m.created_at DESC\n LIMIT 1\n ) AS \"open_pr_number: i64\",\n\n ( SELECT m.pr_url\n FROM workspaces w\n JOIN merges m ON m.workspace_id = w.id\n WHERE w.task_id = t.id\n AND m.merge_type = 'pr'\n AND m.pr_status = 'open'\n ORDER BY m.created_at DESC\n LIMIT 1\n ) AS \"open_pr_url: String\"\n\nFROM tasks t\nWHERE t.project_id = $1\nORDER BY t.created_at DESC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "parent_workspace_id: Uuid", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "shared_task_id: Uuid", + "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "github_issue_number: i64", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "github_issue_url: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "has_in_progress_attempt!: i64", + "ordinal": 11, + "type_info": "Null" + }, + { + "name": "last_attempt_failed!: i64", + "ordinal": 12, + "type_info": "Null" + }, + { + "name": "executor!: String", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "open_pr_number: i64", + "ordinal": 14, + "type_info": "Integer" + }, + { + "name": "open_pr_url: String", + "ordinal": 15, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + false, + true, + true, + true, + true, + false, + false, + null, + null, + true, + true, + true + ] + }, + "hash": "24af70a49202c620de1837961c37327e156160562b71197f8a2747f41187e198" +} diff --git a/crates/db/.sqlx/query-8a6a07bff27cfe305fd95aefcfa2b4fe5dafe0464148ad07363733114547fda8.json b/crates/db/.sqlx/query-26e57ec856497cc92e5559ffcc045156c5753d219e841109c8001dddba8233d7.json similarity index 57% rename from crates/db/.sqlx/query-8a6a07bff27cfe305fd95aefcfa2b4fe5dafe0464148ad07363733114547fda8.json rename to crates/db/.sqlx/query-26e57ec856497cc92e5559ffcc045156c5753d219e841109c8001dddba8233d7.json index 0cb93d660f..06c9cfb964 100644 --- a/crates/db/.sqlx/query-8a6a07bff27cfe305fd95aefcfa2b4fe5dafe0464148ad07363733114547fda8.json +++ b/crates/db/.sqlx/query-26e57ec856497cc92e5559ffcc045156c5753d219e841109c8001dddba8233d7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO tasks (id, project_id, title, description, status, parent_workspace_id, shared_task_id)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "INSERT INTO tasks (id, project_id, title, description, status, parent_workspace_id, shared_task_id, github_issue_number, github_issue_url)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", github_issue_number as \"github_issue_number: i64\", github_issue_url as \"github_issue_url: String\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -39,18 +39,28 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "github_issue_number: i64", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "github_issue_url: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 10, "type_info": "Text" } ], "parameters": { - "Right": 7 + "Right": 9 }, "nullable": [ true, @@ -60,9 +70,11 @@ false, true, true, + true, + true, false, false ] }, - "hash": "8a6a07bff27cfe305fd95aefcfa2b4fe5dafe0464148ad07363733114547fda8" + "hash": "26e57ec856497cc92e5559ffcc045156c5753d219e841109c8001dddba8233d7" } diff --git a/crates/db/.sqlx/query-bb208fb7ca5d8b91ed214e591474eecddd97eedf88ac80f9c6bbdb759064efe3.json b/crates/db/.sqlx/query-501c6489447f644da1f331d34622c74e81361dcfb7513d4fa02ab0a8d54a809e.json similarity index 69% rename from crates/db/.sqlx/query-bb208fb7ca5d8b91ed214e591474eecddd97eedf88ac80f9c6bbdb759064efe3.json rename to crates/db/.sqlx/query-501c6489447f644da1f331d34622c74e81361dcfb7513d4fa02ab0a8d54a809e.json index 2643a1dab9..d3e6e68f15 100644 --- a/crates/db/.sqlx/query-bb208fb7ca5d8b91ed214e591474eecddd97eedf88ac80f9c6bbdb759064efe3.json +++ b/crates/db/.sqlx/query-501c6489447f644da1f331d34622c74e81361dcfb7513d4fa02ab0a8d54a809e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE rowid = $1", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", github_issue_number as \"github_issue_number: i64\", github_issue_url as \"github_issue_url: String\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE rowid = $1", "describe": { "columns": [ { @@ -39,13 +39,23 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "github_issue_number: i64", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "github_issue_url: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 10, "type_info": "Text" } ], @@ -60,9 +70,11 @@ false, true, true, + true, + true, false, false ] }, - "hash": "bb208fb7ca5d8b91ed214e591474eecddd97eedf88ac80f9c6bbdb759064efe3" + "hash": "501c6489447f644da1f331d34622c74e81361dcfb7513d4fa02ab0a8d54a809e" } diff --git a/crates/db/.sqlx/query-8d9617c146fbf7a59f406c0531472c646c4e78dd9a698ad38e18dc5b91d51bf4.json b/crates/db/.sqlx/query-8d9617c146fbf7a59f406c0531472c646c4e78dd9a698ad38e18dc5b91d51bf4.json deleted file mode 100644 index 5d4fa1cfec..0000000000 --- a/crates/db/.sqlx/query-8d9617c146fbf7a59f406c0531472c646c4e78dd9a698ad38e18dc5b91d51bf4.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT\n t.id AS \"id!: Uuid\",\n t.project_id AS \"project_id!: Uuid\",\n t.title,\n t.description,\n t.status AS \"status!: TaskStatus\",\n t.parent_workspace_id AS \"parent_workspace_id: Uuid\",\n t.shared_task_id AS \"shared_task_id: Uuid\",\n t.created_at AS \"created_at!: DateTime\",\n t.updated_at AS \"updated_at!: DateTime\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM workspaces w\n JOIN sessions s ON s.workspace_id = w.id\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE w.task_id = t.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"has_in_progress_attempt!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM workspaces w\n JOIN sessions s ON s.workspace_id = w.id\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE w.task_id = t.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END\n AS \"last_attempt_failed!: i64\",\n\n ( SELECT s.executor\n FROM workspaces w\n JOIN sessions s ON s.workspace_id = w.id\n WHERE w.task_id = t.id\n ORDER BY s.created_at DESC\n LIMIT 1\n ) AS \"executor!: String\"\n\nFROM tasks t\nWHERE t.project_id = $1\nORDER BY t.created_at DESC", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - }, - { - "name": "project_id!: Uuid", - "ordinal": 1, - "type_info": "Blob" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "description", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "status!: TaskStatus", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "parent_workspace_id: Uuid", - "ordinal": 5, - "type_info": "Blob" - }, - { - "name": "shared_task_id: Uuid", - "ordinal": 6, - "type_info": "Blob" - }, - { - "name": "created_at!: DateTime", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "has_in_progress_attempt!: i64", - "ordinal": 9, - "type_info": "Null" - }, - { - "name": "last_attempt_failed!: i64", - "ordinal": 10, - "type_info": "Null" - }, - { - "name": "executor!: String", - "ordinal": 11, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false, - true, - false, - true, - true, - false, - false, - null, - null, - true - ] - }, - "hash": "8d9617c146fbf7a59f406c0531472c646c4e78dd9a698ad38e18dc5b91d51bf4" -} diff --git a/crates/db/.sqlx/query-e3dccf263fc925e7ee11f9d44ba5fac8a3db49abdc3f85ced80034dbf86d36b6.json b/crates/db/.sqlx/query-a5230655a00d87c51fdfba03a85e3ae8fbafeae5b498640e0f638a0bb8078700.json similarity index 68% rename from crates/db/.sqlx/query-e3dccf263fc925e7ee11f9d44ba5fac8a3db49abdc3f85ced80034dbf86d36b6.json rename to crates/db/.sqlx/query-a5230655a00d87c51fdfba03a85e3ae8fbafeae5b498640e0f638a0bb8078700.json index 487c600205..0acb53d1f4 100644 --- a/crates/db/.sqlx/query-e3dccf263fc925e7ee11f9d44ba5fac8a3db49abdc3f85ced80034dbf86d36b6.json +++ b/crates/db/.sqlx/query-a5230655a00d87c51fdfba03a85e3ae8fbafeae5b498640e0f638a0bb8078700.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE shared_task_id IS NOT NULL", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", github_issue_number as \"github_issue_number: i64\", github_issue_url as \"github_issue_url: String\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE shared_task_id IS NOT NULL", "describe": { "columns": [ { @@ -39,13 +39,23 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "github_issue_number: i64", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "github_issue_url: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 10, "type_info": "Text" } ], @@ -60,9 +70,11 @@ false, true, true, + true, + true, false, false ] }, - "hash": "e3dccf263fc925e7ee11f9d44ba5fac8a3db49abdc3f85ced80034dbf86d36b6" + "hash": "a5230655a00d87c51fdfba03a85e3ae8fbafeae5b498640e0f638a0bb8078700" } diff --git a/crates/db/.sqlx/query-a19c2df7514df39aeb8bbf7b846e486f439bc3464f68ae0b7ce6e011542fa6c5.json b/crates/db/.sqlx/query-c6d1b12ae4e701a6ea3862e609f6c23e2ede88fbac0bd6e3c21f01f737627511.json similarity index 74% rename from crates/db/.sqlx/query-a19c2df7514df39aeb8bbf7b846e486f439bc3464f68ae0b7ce6e011542fa6c5.json rename to crates/db/.sqlx/query-c6d1b12ae4e701a6ea3862e609f6c23e2ede88fbac0bd6e3c21f01f737627511.json index d37aa7bd70..690285fbd1 100644 --- a/crates/db/.sqlx/query-a19c2df7514df39aeb8bbf7b846e486f439bc3464f68ae0b7ce6e011542fa6c5.json +++ b/crates/db/.sqlx/query-c6d1b12ae4e701a6ea3862e609f6c23e2ede88fbac0bd6e3c21f01f737627511.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE tasks\n SET title = $3, description = $4, status = $5, parent_workspace_id = $6\n WHERE id = $1 AND project_id = $2\n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "UPDATE tasks\n SET title = $3, description = $4, status = $5, parent_workspace_id = $6\n WHERE id = $1 AND project_id = $2\n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", github_issue_number as \"github_issue_number: i64\", github_issue_url as \"github_issue_url: String\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -39,13 +39,23 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "github_issue_number: i64", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "github_issue_url: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 10, "type_info": "Text" } ], @@ -60,9 +70,11 @@ false, true, true, + true, + true, false, false ] }, - "hash": "a19c2df7514df39aeb8bbf7b846e486f439bc3464f68ae0b7ce6e011542fa6c5" + "hash": "c6d1b12ae4e701a6ea3862e609f6c23e2ede88fbac0bd6e3c21f01f737627511" } diff --git a/crates/db/.sqlx/query-d53d6e7e8346e60b3a95779dbcb28f319866559725b5c803c52c422759749324.json b/crates/db/.sqlx/query-cba1b4236172b1233e195f0e0b82a75e3b09afb94b066694a44a4cddee4e9046.json similarity index 67% rename from crates/db/.sqlx/query-d53d6e7e8346e60b3a95779dbcb28f319866559725b5c803c52c422759749324.json rename to crates/db/.sqlx/query-cba1b4236172b1233e195f0e0b82a75e3b09afb94b066694a44a4cddee4e9046.json index 67f0071959..cf9c4ee85e 100644 --- a/crates/db/.sqlx/query-d53d6e7e8346e60b3a95779dbcb28f319866559725b5c803c52c422759749324.json +++ b/crates/db/.sqlx/query-cba1b4236172b1233e195f0e0b82a75e3b09afb94b066694a44a4cddee4e9046.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE id = $1", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", github_issue_number as \"github_issue_number: i64\", github_issue_url as \"github_issue_url: String\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE parent_workspace_id = $1\n ORDER BY created_at DESC", "describe": { "columns": [ { @@ -39,13 +39,23 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "github_issue_number: i64", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "github_issue_url: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 10, "type_info": "Text" } ], @@ -60,9 +70,11 @@ false, true, true, + true, + true, false, false ] }, - "hash": "d53d6e7e8346e60b3a95779dbcb28f319866559725b5c803c52c422759749324" + "hash": "cba1b4236172b1233e195f0e0b82a75e3b09afb94b066694a44a4cddee4e9046" } diff --git a/crates/db/.sqlx/query-8540534cf071da7c5027111bc8464ff2044e3e1a2a50ff9eb8cfccdec2292ad2.json b/crates/db/.sqlx/query-d073e6dec1f89d264726f98d19612c85235154c71aecc996025e8020dcaa0529.json similarity index 69% rename from crates/db/.sqlx/query-8540534cf071da7c5027111bc8464ff2044e3e1a2a50ff9eb8cfccdec2292ad2.json rename to crates/db/.sqlx/query-d073e6dec1f89d264726f98d19612c85235154c71aecc996025e8020dcaa0529.json index 784639c160..7c8d7de58e 100644 --- a/crates/db/.sqlx/query-8540534cf071da7c5027111bc8464ff2044e3e1a2a50ff9eb8cfccdec2292ad2.json +++ b/crates/db/.sqlx/query-d073e6dec1f89d264726f98d19612c85235154c71aecc996025e8020dcaa0529.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE parent_workspace_id = $1\n ORDER BY created_at DESC", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", github_issue_number as \"github_issue_number: i64\", github_issue_url as \"github_issue_url: String\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE id = $1", "describe": { "columns": [ { @@ -39,13 +39,23 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "github_issue_number: i64", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "github_issue_url: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 10, "type_info": "Text" } ], @@ -60,9 +70,11 @@ false, true, true, + true, + true, false, false ] }, - "hash": "8540534cf071da7c5027111bc8464ff2044e3e1a2a50ff9eb8cfccdec2292ad2" + "hash": "d073e6dec1f89d264726f98d19612c85235154c71aecc996025e8020dcaa0529" } diff --git a/crates/db/.sqlx/query-a5b88fa071f981b5607d13e982bfb43c390ba6fb2fc6a856d7b1d849b74b3745.json b/crates/db/.sqlx/query-fd6ad8e122cf5937c4f38193d14390eea52a4f7f7832c2f37113e617ccfc50b8.json similarity index 67% rename from crates/db/.sqlx/query-a5b88fa071f981b5607d13e982bfb43c390ba6fb2fc6a856d7b1d849b74b3745.json rename to crates/db/.sqlx/query-fd6ad8e122cf5937c4f38193d14390eea52a4f7f7832c2f37113e617ccfc50b8.json index 58ca467c49..f222817b07 100644 --- a/crates/db/.sqlx/query-a5b88fa071f981b5607d13e982bfb43c390ba6fb2fc6a856d7b1d849b74b3745.json +++ b/crates/db/.sqlx/query-fd6ad8e122cf5937c4f38193d14390eea52a4f7f7832c2f37113e617ccfc50b8.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE shared_task_id = $1\n LIMIT 1", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", shared_task_id as \"shared_task_id: Uuid\", github_issue_number as \"github_issue_number: i64\", github_issue_url as \"github_issue_url: String\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE shared_task_id = $1\n LIMIT 1", "describe": { "columns": [ { @@ -39,13 +39,23 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "github_issue_number: i64", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "github_issue_url: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 10, "type_info": "Text" } ], @@ -60,9 +70,11 @@ false, true, true, + true, + true, false, false ] }, - "hash": "a5b88fa071f981b5607d13e982bfb43c390ba6fb2fc6a856d7b1d849b74b3745" + "hash": "fd6ad8e122cf5937c4f38193d14390eea52a4f7f7832c2f37113e617ccfc50b8" } diff --git a/crates/db/migrations/20260107000000_add_github_issue_to_tasks.sql b/crates/db/migrations/20260107000000_add_github_issue_to_tasks.sql new file mode 100644 index 0000000000..6479902584 --- /dev/null +++ b/crates/db/migrations/20260107000000_add_github_issue_to_tasks.sql @@ -0,0 +1,5 @@ +-- Add GitHub issue tracking fields to tasks table +-- These fields link tasks to their source GitHub issues for PR integration + +ALTER TABLE tasks ADD COLUMN github_issue_number INTEGER; +ALTER TABLE tasks ADD COLUMN github_issue_url TEXT; diff --git a/crates/db/src/models/task.rs b/crates/db/src/models/task.rs index e04d1dc790..ffc299cb50 100644 --- a/crates/db/src/models/task.rs +++ b/crates/db/src/models/task.rs @@ -31,10 +31,19 @@ pub struct Task { pub status: TaskStatus, pub parent_workspace_id: Option, // Foreign key to parent Workspace pub shared_task_id: Option, + pub github_issue_number: Option, + pub github_issue_url: Option, pub created_at: DateTime, pub updated_at: DateTime, } +/// Minimal PR info for display in task cards +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct TaskOpenPr { + pub number: i64, + pub url: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct TaskWithAttemptStatus { #[serde(flatten)] @@ -43,6 +52,7 @@ pub struct TaskWithAttemptStatus { pub has_in_progress_attempt: bool, pub last_attempt_failed: bool, pub executor: String, + pub open_pr: Option, } impl std::ops::Deref for TaskWithAttemptStatus { @@ -74,6 +84,8 @@ pub struct CreateTask { pub parent_workspace_id: Option, pub image_ids: Option>, pub shared_task_id: Option, + pub github_issue_number: Option, + pub github_issue_url: Option, } impl CreateTask { @@ -90,6 +102,8 @@ impl CreateTask { parent_workspace_id: None, image_ids: None, shared_task_id: None, + github_issue_number: None, + github_issue_url: None, } } @@ -108,6 +122,8 @@ impl CreateTask { parent_workspace_id: None, image_ids: None, shared_task_id: Some(shared_task_id), + github_issue_number: None, + github_issue_url: None, } } } @@ -147,6 +163,8 @@ impl Task { t.status AS "status!: TaskStatus", t.parent_workspace_id AS "parent_workspace_id: Uuid", t.shared_task_id AS "shared_task_id: Uuid", + t.github_issue_number AS "github_issue_number: i64", + t.github_issue_url AS "github_issue_url: String", t.created_at AS "created_at!: DateTime", t.updated_at AS "updated_at!: DateTime", @@ -179,7 +197,27 @@ impl Task { WHERE w.task_id = t.id ORDER BY s.created_at DESC LIMIT 1 - ) AS "executor!: String" + ) AS "executor!: String", + + ( SELECT m.pr_number + FROM workspaces w + JOIN merges m ON m.workspace_id = w.id + WHERE w.task_id = t.id + AND m.merge_type = 'pr' + AND m.pr_status = 'open' + ORDER BY m.created_at DESC + LIMIT 1 + ) AS "open_pr_number: i64", + + ( SELECT m.pr_url + FROM workspaces w + JOIN merges m ON m.workspace_id = w.id + WHERE w.task_id = t.id + AND m.merge_type = 'pr' + AND m.pr_status = 'open' + ORDER BY m.created_at DESC + LIMIT 1 + ) AS "open_pr_url: String" FROM tasks t WHERE t.project_id = $1 @@ -200,12 +238,18 @@ ORDER BY t.created_at DESC"#, status: rec.status, parent_workspace_id: rec.parent_workspace_id, shared_task_id: rec.shared_task_id, + github_issue_number: rec.github_issue_number, + github_issue_url: rec.github_issue_url, created_at: rec.created_at, updated_at: rec.updated_at, }, has_in_progress_attempt: rec.has_in_progress_attempt != 0, last_attempt_failed: rec.last_attempt_failed != 0, executor: rec.executor, + open_pr: match (rec.open_pr_number, rec.open_pr_url) { + (Some(number), Some(url)) => Some(TaskOpenPr { number, url }), + _ => None, + }, }) .collect(); @@ -215,7 +259,7 @@ ORDER BY t.created_at DESC"#, pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", github_issue_number as "github_issue_number: i64", github_issue_url as "github_issue_url: String", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE id = $1"#, id @@ -227,7 +271,7 @@ ORDER BY t.created_at DESC"#, pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result, sqlx::Error> { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", github_issue_number as "github_issue_number: i64", github_issue_url as "github_issue_url: String", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE rowid = $1"#, rowid @@ -245,7 +289,7 @@ ORDER BY t.created_at DESC"#, { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", github_issue_number as "github_issue_number: i64", github_issue_url as "github_issue_url: String", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE shared_task_id = $1 LIMIT 1"#, @@ -258,7 +302,7 @@ ORDER BY t.created_at DESC"#, pub async fn find_all_shared(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", github_issue_number as "github_issue_number: i64", github_issue_url as "github_issue_url: String", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE shared_task_id IS NOT NULL"# ) @@ -274,16 +318,18 @@ ORDER BY t.created_at DESC"#, let status = data.status.clone().unwrap_or_default(); sqlx::query_as!( Task, - r#"INSERT INTO tasks (id, project_id, title, description, status, parent_workspace_id, shared_task_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + r#"INSERT INTO tasks (id, project_id, title, description, status, parent_workspace_id, shared_task_id, github_issue_number, github_issue_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", github_issue_number as "github_issue_number: i64", github_issue_url as "github_issue_url: String", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, task_id, data.project_id, data.title, data.description, status, data.parent_workspace_id, - data.shared_task_id + data.shared_task_id, + data.github_issue_number, + data.github_issue_url ) .fetch_one(pool) .await @@ -303,7 +349,7 @@ ORDER BY t.created_at DESC"#, r#"UPDATE tasks SET title = $3, description = $4, status = $5, parent_workspace_id = $6 WHERE id = $1 AND project_id = $2 - RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", github_issue_number as "github_issue_number: i64", github_issue_url as "github_issue_url: String", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, project_id, title, @@ -446,7 +492,7 @@ ORDER BY t.created_at DESC"#, // Find only child tasks that have this workspace as their parent sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", shared_task_id as "shared_task_id: Uuid", github_issue_number as "github_issue_number: i64", github_issue_url as "github_issue_url: String", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE parent_workspace_id = $1 ORDER BY created_at DESC"#, diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 1e977dddcd..bf24afa7b4 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -32,6 +32,7 @@ fn generate_types_content() -> String { db::models::tag::UpdateTag::decl(), db::models::task::TaskStatus::decl(), db::models::task::Task::decl(), + db::models::task::TaskOpenPr::decl(), db::models::task::TaskWithAttemptStatus::decl(), db::models::task::TaskRelationships::decl(), db::models::task::CreateTask::decl(), @@ -142,6 +143,10 @@ fn generate_types_content() -> String { server::routes::task_attempts::pr::GetPrCommentsQuery::decl(), services::services::git_host::UnifiedPrComment::decl(), services::services::git_host::ProviderKind::decl(), + services::services::git_host::github::cli::GitHubIssue::decl(), + server::routes::github_issues::GitHubIssueToImport::decl(), + server::routes::github_issues::ImportGitHubIssuesRequest::decl(), + server::routes::github_issues::ImportGitHubIssuesResponse::decl(), server::routes::task_attempts::RepoBranchStatus::decl(), server::routes::task_attempts::UpdateWorkspace::decl(), server::routes::task_attempts::workspace_summary::WorkspaceSummaryRequest::decl(), diff --git a/crates/server/src/routes/github_issues.rs b/crates/server/src/routes/github_issues.rs new file mode 100644 index 0000000000..4b00fc3c4a --- /dev/null +++ b/crates/server/src/routes/github_issues.rs @@ -0,0 +1,147 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + response::Json as ResponseJson, + routing::{get, post}, +}; +use db::models::{ + repo::Repo, + task::{CreateTask, Task, TaskStatus}, +}; +use deployment::Deployment; +use serde::{Deserialize, Serialize}; +use services::services::git_host::github::{GhCli, GitHubIssue}; +use ts_rs::TS; +use utils::response::ApiResponse; +use uuid::Uuid; + +use crate::{DeploymentImpl, error::ApiError}; + +#[derive(Debug, Deserialize)] +pub struct ListIssuesQuery { + #[serde(default = "default_state")] + pub state: String, +} + +fn default_state() -> String { + "open".to_string() +} + +/// GET /api/projects/{project_id}/repos/{repo_id}/github-issues +pub async fn list_github_issues( + State(deployment): State, + Path((_project_id, repo_id)): Path<(Uuid, Uuid)>, + Query(query): Query, +) -> Result>>, ApiError> { + // Find the repo + let repo = Repo::find_by_id(&deployment.db().pool, repo_id) + .await? + .ok_or_else(|| ApiError::BadRequest(format!("Repository {repo_id} not found")))?; + + // Create GitHub CLI and get repo info + let gh_cli = GhCli::new(); + let repo_info = gh_cli.get_repo_info(&repo.path).map_err(|e| { + ApiError::BadRequest(format!( + "Repository is not a GitHub repository or remote not configured: {e}" + )) + })?; + + // Fetch issues using GitHub CLI + let issues: Vec = gh_cli + .list_issues(&repo_info.owner, &repo_info.repo_name, &query.state) + .map_err(|e| ApiError::BadRequest(format!("Failed to fetch issues: {e}")))?; + + tracing::info!( + "Fetched {} GitHub issues for repo {}", + issues.len(), + repo_id + ); + + Ok(ResponseJson(ApiResponse::success(issues))) +} + +#[derive(Debug, Deserialize, Serialize, TS)] +pub struct GitHubIssueToImport { + #[ts(type = "number")] + pub number: i64, + pub title: String, + pub body: Option, + pub url: String, +} + +#[derive(Debug, Deserialize, Serialize, TS)] +pub struct ImportGitHubIssuesRequest { + pub repo_id: Uuid, + pub issues: Vec, +} + +#[derive(Debug, Serialize, TS)] +pub struct ImportGitHubIssuesResponse { + pub created_count: u64, + pub task_ids: Vec, +} + +/// POST /api/projects/{project_id}/import-github-issues +pub async fn import_github_issues( + State(deployment): State, + Path(project_id): Path, + Json(payload): Json, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + + let mut task_ids = Vec::with_capacity(payload.issues.len()); + + for issue in &payload.issues { + let task_id = Uuid::new_v4(); + + let create_task = CreateTask { + project_id, + title: issue.title.clone(), + description: issue.body.clone(), + status: Some(TaskStatus::Todo), + parent_workspace_id: None, + image_ids: None, + shared_task_id: None, + github_issue_number: Some(issue.number), + github_issue_url: Some(issue.url.clone()), + }; + + let task = Task::create(pool, &create_task, task_id).await?; + task_ids.push(task.id); + } + + let created_count = task_ids.len() as u64; + + tracing::info!( + "Imported {} GitHub issues as tasks for project {}", + created_count, + project_id + ); + + deployment + .track_if_analytics_allowed( + "github_issues_imported", + serde_json::json!({ + "project_id": project_id.to_string(), + "count": created_count, + }), + ) + .await; + + Ok(ResponseJson(ApiResponse::success(ImportGitHubIssuesResponse { + created_count, + task_ids, + }))) +} + +pub fn router(_deployment: &DeploymentImpl) -> Router { + Router::new() + .route( + "/projects/{project_id}/repos/{repo_id}/github-issues", + get(list_github_issues), + ) + .route( + "/projects/{project_id}/import-github-issues", + post(import_github_issues), + ) +} diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 2c3c6ebb8e..92a2086310 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -11,6 +11,7 @@ pub mod containers; pub mod filesystem; // pub mod github; pub mod events; +pub mod github_issues; pub mod execution_processes; pub mod frontend; pub mod health; @@ -45,6 +46,7 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService { .merge(events::router(&deployment)) .merge(approvals::router()) .merge(scratch::router(&deployment)) + .merge(github_issues::router(&deployment)) .merge(sessions::router(&deployment)) .nest("/images", images::routes()) .with_state(deployment); diff --git a/crates/server/src/routes/task_attempts/pr.rs b/crates/server/src/routes/task_attempts/pr.rs index d9fb112931..a80141376b 100644 --- a/crates/server/src/routes/task_attempts/pr.rs +++ b/crates/server/src/routes/task_attempts/pr.rs @@ -98,6 +98,7 @@ Analyze the changes in this branch and write: - What changes were made - Why they were made (based on the task context) - Any important implementation details + - IMPORTANT: Preserve any existing "Closes #X" or "Fixes #X" issue references from the current PR body - At the end, include a note: "This PR was written using [Vibe Kanban](https://vibekanban.com)" Use the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps)."#; @@ -310,10 +311,23 @@ pub async fn create_pr( let provider = git_host.provider_kind(); + // Get the task to check for linked GitHub issue + let task = Task::find_by_id(pool, workspace.task_id) + .await? + .ok_or(ApiError::Workspace(WorkspaceError::TaskNotFound))?; + + // Build PR body, appending issue reference if available + // Use short format "Closes #123" for better GitHub auto-linking + let pr_body = match (&request.body, &task.github_issue_number) { + (Some(body), Some(issue_num)) => Some(format!("{}\n\nCloses #{}", body, issue_num)), + (None, Some(issue_num)) => Some(format!("Closes #{}", issue_num)), + (body, None) => body.clone(), + }; + // Create the PR let pr_request = CreatePrRequest { title: request.title.clone(), - body: request.body.clone(), + body: pr_body, head_branch: workspace.branch.clone(), base_branch: norm_target_branch_name.clone(), draft: request.draft, diff --git a/crates/server/src/routes/tasks.rs b/crates/server/src/routes/tasks.rs index c194033c56..3149e8d1d5 100644 --- a/crates/server/src/routes/tasks.rs +++ b/crates/server/src/routes/tasks.rs @@ -243,6 +243,7 @@ pub async fn create_task_and_start( has_in_progress_attempt: is_attempt_running, last_attempt_failed: false, executor: payload.executor_profile_id.executor.to_string(), + open_pr: None, }))) } diff --git a/crates/services/src/services/git_host/github/cli.rs b/crates/services/src/services/git_host/github/cli.rs index 78705b87e5..bff8c9d7e3 100644 --- a/crates/services/src/services/git_host/github/cli.rs +++ b/crates/services/src/services/git_host/github/cli.rs @@ -12,7 +12,8 @@ use std::{ use chrono::{DateTime, Utc}; use db::models::merge::{MergeStatus, PullRequestInfo}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; use tempfile::NamedTempFile; use thiserror::Error; use utils::shell::resolve_executable_path_blocking; @@ -27,6 +28,17 @@ pub struct GitHubRepoInfo { pub repo_name: String, } +/// A GitHub issue +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct GitHubIssue { + #[ts(type = "number")] + pub number: i64, + pub title: String, + pub body: Option, + pub state: String, // "open" or "closed" + pub url: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GhCommentResponse { @@ -303,6 +315,32 @@ impl GhCli { )?; Self::parse_pr_review_comments(&raw) } + + /// List issues for a repository. + /// `state` can be "open", "closed", or "all". + pub fn list_issues( + &self, + owner: &str, + repo: &str, + state: &str, + ) -> Result, GhCliError> { + let raw = self.run( + [ + "issue", + "list", + "--repo", + &format!("{owner}/{repo}"), + "--state", + state, + "--json", + "number,title,body,state,url", + "--limit", + "100", + ], + None, + )?; + Self::parse_issues(&raw) + } } impl GhCli { @@ -439,4 +477,12 @@ impl GhCli { }) .collect()) } + + fn parse_issues(raw: &str) -> Result, GhCliError> { + serde_json::from_str(raw.trim()).map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse gh issue list response: {err}; raw: {raw}" + )) + }) + } } diff --git a/crates/services/src/services/git_host/github/mod.rs b/crates/services/src/services/git_host/github/mod.rs index ec75f2270d..e57456b0e4 100644 --- a/crates/services/src/services/git_host/github/mod.rs +++ b/crates/services/src/services/git_host/github/mod.rs @@ -6,7 +6,7 @@ use std::{path::Path, time::Duration}; use async_trait::async_trait; use backon::{ExponentialBuilder, Retryable}; -pub use cli::GhCli; +pub use cli::{GhCli, GitHubIssue}; use cli::{GhCliError, GitHubRepoInfo}; use db::models::merge::PullRequestInfo; use tokio::task; diff --git a/frontend/src/components/dialogs/index.ts b/frontend/src/components/dialogs/index.ts index 8932454dd1..b67fd57ae0 100644 --- a/frontend/src/components/dialogs/index.ts +++ b/frontend/src/components/dialogs/index.ts @@ -93,6 +93,11 @@ export { type EditBranchNameDialogResult, } from './tasks/EditBranchNameDialog'; export { CreateAttemptDialog } from './tasks/CreateAttemptDialog'; +export { + ImportGitHubIssuesDialog, + type ImportGitHubIssuesDialogProps, + type ImportGitHubIssuesDialogResult, +} from './tasks/ImportGitHubIssuesDialog'; // Auth dialogs export { GhCliSetupDialog } from './auth/GhCliSetupDialog'; diff --git a/frontend/src/components/dialogs/tasks/ImportGitHubIssuesDialog.tsx b/frontend/src/components/dialogs/tasks/ImportGitHubIssuesDialog.tsx new file mode 100644 index 0000000000..fc88e1b922 --- /dev/null +++ b/frontend/src/components/dialogs/tasks/ImportGitHubIssuesDialog.tsx @@ -0,0 +1,387 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { AlertCircle, Loader2, Github, Check } from 'lucide-react'; +import { useProjectRepos } from '@/hooks/useProjectRepos'; +import { useGitHubIssues, useImportGitHubIssues } from '@/hooks/useGitHubIssues'; +import { useProjectTasks } from '@/hooks/useProjectTasks'; + +export interface ImportGitHubIssuesDialogProps { + projectId: string; +} + +export interface ImportGitHubIssuesDialogResult { + imported: boolean; + count: number; +} + +type IssueState = 'open' | 'closed' | 'all'; +type ImportFilter = 'new' | 'all'; + +const ImportGitHubIssuesDialogImpl = NiceModal.create( + ({ projectId }) => { + const { t } = useTranslation(['tasks', 'common']); + const modal = useModal(); + + // State + const [selectedRepoId, setSelectedRepoId] = useState(); + const [issueState, setIssueState] = useState('open'); + const [importFilter, setImportFilter] = useState('new'); + const [selectedIssues, setSelectedIssues] = useState>(new Set()); + const [isImporting, setIsImporting] = useState(false); + + // Fetch repos for the project + const { data: repos, isLoading: isLoadingRepos } = useProjectRepos(projectId); + + // Fetch GitHub issues for the selected repo + const { + data: issues, + isLoading: isLoadingIssues, + isError, + error, + } = useGitHubIssues(projectId, selectedRepoId, issueState, !!selectedRepoId); + + // Import mutation + const importMutation = useImportGitHubIssues(projectId); + + // Get existing tasks to check which issues are already imported + const { tasks } = useProjectTasks(projectId); + + // Build a set of already-imported GitHub issue numbers + const importedIssueNumbers = useMemo(() => { + const set = new Set(); + for (const task of tasks) { + if (task.github_issue_number != null) { + set.add(Number(task.github_issue_number)); + } + } + return set; + }, [tasks]); + + // Auto-select first repo if only one + useEffect(() => { + if (repos?.length === 1 && !selectedRepoId) { + setSelectedRepoId(repos[0].id); + } + }, [repos, selectedRepoId]); + + // Reset selection when repo, state, or import filter changes + useEffect(() => { + setSelectedIssues(new Set()); + }, [selectedRepoId, issueState, importFilter]); + + const toggleSelection = (issueNumber: number) => { + setSelectedIssues((prev) => { + const newSet = new Set(prev); + if (newSet.has(issueNumber)) { + newSet.delete(issueNumber); + } else { + newSet.add(issueNumber); + } + return newSet; + }); + }; + + // Filter issues based on import filter + const filteredIssues = useMemo(() => { + if (!issues) return []; + if (importFilter === 'new') { + return issues.filter((i) => !importedIssueNumbers.has(Number(i.number))); + } + return issues; + }, [issues, importedIssueNumbers, importFilter]); + + // Get selectable issues (not already imported) from the filtered list + const selectableIssues = useMemo(() => { + return filteredIssues.filter((i) => !importedIssueNumbers.has(Number(i.number))); + }, [filteredIssues, importedIssueNumbers]); + + const selectAll = () => { + // Select all selectable (non-imported) issues in the current view + setSelectedIssues(new Set(selectableIssues.map((i) => Number(i.number)))); + }; + + const deselectAll = () => { + setSelectedIssues(new Set()); + }; + + const isAllSelected = + selectableIssues.length > 0 && selectedIssues.size === selectableIssues.length; + + const handleImport = async () => { + if (!selectedRepoId || !issues || selectedIssues.size === 0) return; + + setIsImporting(true); + try { + const issuesToImport = issues + .filter((i) => selectedIssues.has(Number(i.number))) + .map((i) => ({ + number: Number(i.number), + title: i.title, + body: i.body, + url: i.url, + })); + + const result = await importMutation.mutateAsync({ + repo_id: selectedRepoId, + issues: issuesToImport, + }); + + modal.resolve({ + imported: true, + count: Number(result.created_count), + }); + modal.hide(); + } catch (err) { + console.error('Failed to import issues:', err); + } finally { + setIsImporting(false); + } + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + modal.resolve({ imported: false, count: 0 }); + modal.hide(); + } + }; + + const errorMessage = isError ? getErrorMessage(error) : null; + + return ( + + { + if (e.key === 'Escape') { + e.stopPropagation(); + modal.resolve({ imported: false, count: 0 }); + modal.hide(); + } + }} + > + + + + Import GitHub Issues + + + Select issues from a GitHub repository to import as tasks + + + +
+ {/* Repository Selector */} +
+ + +
+ + {/* State Filter */} +
+ +
+ {(['open', 'closed', 'all'] as IssueState[]).map((state) => ( + + ))} +
+
+ + {/* Import Filter */} +
+ +
+ + +
+
+
+ +
+
+ {!selectedRepoId ? ( +

+ Select a repository to view issues +

+ ) : errorMessage ? ( + + + {errorMessage} + + ) : isLoadingIssues ? ( +
+ +
+ ) : !issues || issues.length === 0 ? ( +

+ No {issueState === 'all' ? '' : issueState} issues found +

+ ) : filteredIssues.length === 0 ? ( +

+ All issues have been imported +

+ ) : ( + <> +
+ + {selectedIssues.size} of {selectableIssues.length} selected + + +
+
+ {filteredIssues.map((issue) => { + const issueNum = Number(issue.number); + const isImported = importedIssueNumbers.has(issueNum); + return ( +
!isImported && toggleSelection(issueNum)} + > + e.stopPropagation()}> + {isImported ? ( + + ) : ( + toggleSelection(issueNum)} + className="mt-0.5" + /> + )} + +
+
+ + #{String(issue.number)} + + + {issue.state} + + {isImported && ( + + Imported + + )} +
+

{issue.title}

+
+
+ ); + })} +
+ + )} +
+
+ + + + + +
+
+ ); + } +); + +function getErrorMessage(error: unknown): string { + if (error && typeof error === 'object' && 'message' in error) { + return String((error as { message: string }).message); + } + return 'Failed to load GitHub issues. Make sure the repository is on GitHub and gh CLI is authenticated.'; +} + +export const ImportGitHubIssuesDialog = defineModal< + ImportGitHubIssuesDialogProps, + ImportGitHubIssuesDialogResult +>(ImportGitHubIssuesDialogImpl); diff --git a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx index daad181a25..eada546063 100644 --- a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx +++ b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx @@ -190,6 +190,8 @@ const TaskFormDialogImpl = NiceModal.create((props) => { mode === 'subtask' ? props.parentTaskAttemptId : null, image_ids: imageIds, shared_task_id: null, + github_issue_number: null, + github_issue_url: null, }; const shouldAutoStart = value.autoStart && !forceCreateOnlyRef.current; if (shouldAutoStart) { diff --git a/frontend/src/components/panels/TaskPanel.tsx b/frontend/src/components/panels/TaskPanel.tsx index a43bc660d9..383402817a 100644 --- a/frontend/src/components/panels/TaskPanel.tsx +++ b/frontend/src/components/panels/TaskPanel.tsx @@ -8,7 +8,7 @@ import type { TaskWithAttemptStatus } from 'shared/types'; import type { WorkspaceWithSession } from '@/types/attempt'; import { NewCardContent } from '../ui/new-card'; import { Button } from '../ui/button'; -import { PlusIcon } from 'lucide-react'; +import { ExternalLink, Github, GitPullRequest, PlusIcon } from 'lucide-react'; import { CreateAttemptDialog } from '@/components/dialogs/tasks/CreateAttemptDialog'; import WYSIWYGEditor from '@/components/ui/wysiwyg'; import { DataTable, type ColumnDef } from '@/components/ui/table'; @@ -106,6 +106,32 @@ const TaskPanel = ({ task }: TaskPanelProps) => { {descriptionContent && ( )} + {(task.github_issue_url || task.open_pr) && ( +
+ {task.github_issue_url && task.github_issue_number != null && ( + + )} + {task.open_pr && ( + + )} +
+ )}
diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx index f35b810982..add8d9120b 100644 --- a/frontend/src/components/tasks/TaskCard.tsx +++ b/frontend/src/components/tasks/TaskCard.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { KanbanCard } from '@/components/ui/shadcn-io/kanban'; -import { Link, Loader2, XCircle } from 'lucide-react'; +import { Github, Link, Loader2, XCircle } from 'lucide-react'; import type { TaskWithAttemptStatus } from 'shared/types'; import { ActionsDropdown } from '@/components/ui/actions-dropdown'; import { Button } from '@/components/ui/button'; @@ -108,6 +108,23 @@ export function TaskCard({ } : undefined } + left={ + task.github_issue_url && task.github_issue_number != null ? ( + + ) : null + } right={ <> {task.has_in_progress_attempt && ( diff --git a/frontend/src/components/tasks/TaskCardHeader.tsx b/frontend/src/components/tasks/TaskCardHeader.tsx index d7b7043568..0a05cb01d7 100644 --- a/frontend/src/components/tasks/TaskCardHeader.tsx +++ b/frontend/src/components/tasks/TaskCardHeader.tsx @@ -11,6 +11,7 @@ interface HeaderAvatar { interface TaskCardHeaderProps { title: ReactNode; avatar?: HeaderAvatar; + left?: ReactNode; right?: ReactNode; className?: string; titleClassName?: string; @@ -19,12 +20,16 @@ interface TaskCardHeaderProps { export function TaskCardHeader({ title, avatar, + left, right, className, titleClassName, }: TaskCardHeaderProps) { return (
+ {left ? ( +
{left}
+ ) : null}

diff --git a/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx b/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx index 5faa7ebc20..a15f16433c 100644 --- a/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx +++ b/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx @@ -134,6 +134,8 @@ export function NoServerContent({ parent_workspace_id: null, image_ids: null, shared_task_id: null, + github_issue_number: null, + github_issue_url: null, }, executor_profile_id: config.executor_profile, repos, diff --git a/frontend/src/components/tasks/TaskKanbanBoard.tsx b/frontend/src/components/tasks/TaskKanbanBoard.tsx index 3abe18c6d0..9a841b70c8 100644 --- a/frontend/src/components/tasks/TaskKanbanBoard.tsx +++ b/frontend/src/components/tasks/TaskKanbanBoard.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useCallback } from 'react'; import { useAuth } from '@/hooks'; import { type DragEndEvent, @@ -12,6 +12,7 @@ import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types'; import { statusBoardColors, statusLabels } from '@/utils/statusLabels'; import type { SharedTaskRecord } from '@/hooks/useProjectTasks'; import { SharedTaskCard } from './SharedTaskCard'; +import { ImportGitHubIssuesDialog } from '@/components/dialogs/tasks/ImportGitHubIssuesDialog'; export type KanbanColumnItem = | { @@ -49,6 +50,10 @@ function TaskKanbanBoard({ }: TaskKanbanBoardProps) { const { userId } = useAuth(); + const handleImportIssues = useCallback(() => { + ImportGitHubIssuesDialog.show({ projectId }); + }, [projectId]); + return ( {Object.entries(columns).map(([status, items]) => { @@ -59,6 +64,7 @@ function TaskKanbanBoard({ name={statusLabels[statusKey]} color={statusBoardColors[statusKey]} onAddTask={onCreateTask} + onImportIssues={statusKey === 'todo' ? handleImportIssues : undefined} /> {items.map((item, index) => { diff --git a/frontend/src/components/ui-new/actions/index.ts b/frontend/src/components/ui-new/actions/index.ts index 5d76efefd9..f160eccca1 100644 --- a/frontend/src/components/ui-new/actions/index.ts +++ b/frontend/src/components/ui-new/actions/index.ts @@ -612,6 +612,7 @@ export const Actions = { has_in_progress_attempt: false, last_attempt_failed: false, executor: '', + open_pr: null, }, repoId, }); diff --git a/frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx b/frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx index c95443c177..c90b392708 100644 --- a/frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx +++ b/frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx @@ -115,6 +115,8 @@ export function CreateChatBoxContainer() { parent_workspace_id: null, image_ids: getImageIds(), shared_task_id: null, + github_issue_number: null, + github_issue_url: null, }, executor_profile_id: effectiveProfile, repos: repos.map((r) => ({ diff --git a/frontend/src/components/ui/shadcn-io/kanban/index.tsx b/frontend/src/components/ui/shadcn-io/kanban/index.tsx index 7cfa319741..e7ea5333e0 100644 --- a/frontend/src/components/ui/shadcn-io/kanban/index.tsx +++ b/frontend/src/components/ui/shadcn-io/kanban/index.tsx @@ -21,7 +21,7 @@ import { import { type ReactNode, type Ref, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus } from 'lucide-react'; +import { Github, Plus } from 'lucide-react'; import type { ClientRect } from '@dnd-kit/core'; import type { Transform } from '@dnd-kit/utilities'; import { Button } from '../../button'; @@ -153,6 +153,7 @@ export type KanbanHeaderProps = color: Status['color']; className?: string; onAddTask?: () => void; + onImportIssues?: () => void; }; export const KanbanHeader = (props: KanbanHeaderProps) => { @@ -182,6 +183,21 @@ export const KanbanHeader = (props: KanbanHeaderProps) => {

{props.name}

+ {props.onImportIssues && ( + + + + + {t('actions.importIssues')} + + )} +
+ +

@@ -856,15 +858,15 @@ export function ProjectTasks() { ) : (
+ columns={kanbanColumns} + onDragEnd={handleDragEnd} + onViewTaskDetails={handleViewTaskDetails} + onViewSharedTask={handleViewSharedTask} + selectedTaskId={selectedTask?.id} + selectedSharedTaskId={selectedSharedTaskId} + onCreateTask={handleCreateNewTask} + projectId={projectId!} + />
); diff --git a/shared/types.ts b/shared/types.ts index 7c3004ebb8..a152767a55 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -48,13 +48,15 @@ export type UpdateTag = { tag_name: string | null, content: string | null, }; export type TaskStatus = "todo" | "inprogress" | "inreview" | "done" | "cancelled"; -export type Task = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, parent_workspace_id: string | null, shared_task_id: string | null, created_at: string, updated_at: string, }; +export type Task = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, parent_workspace_id: string | null, shared_task_id: string | null, github_issue_number: bigint | null, github_issue_url: string | null, created_at: string, updated_at: string, }; -export type TaskWithAttemptStatus = { has_in_progress_attempt: boolean, last_attempt_failed: boolean, executor: string, id: string, project_id: string, title: string, description: string | null, status: TaskStatus, parent_workspace_id: string | null, shared_task_id: string | null, created_at: string, updated_at: string, }; +export type TaskOpenPr = { number: bigint, url: string, }; + +export type TaskWithAttemptStatus = { has_in_progress_attempt: boolean, last_attempt_failed: boolean, executor: string, open_pr: TaskOpenPr | null, id: string, project_id: string, title: string, description: string | null, status: TaskStatus, parent_workspace_id: string | null, shared_task_id: string | null, github_issue_number: bigint | null, github_issue_url: string | null, created_at: string, updated_at: string, }; export type TaskRelationships = { parent_task: Task | null, current_workspace: Workspace, children: Array, }; -export type CreateTask = { project_id: string, title: string, description: string | null, status: TaskStatus | null, parent_workspace_id: string | null, image_ids: Array | null, shared_task_id: string | null, }; +export type CreateTask = { project_id: string, title: string, description: string | null, status: TaskStatus | null, parent_workspace_id: string | null, image_ids: Array | null, shared_task_id: string | null, github_issue_number: bigint | null, github_issue_url: string | null, }; export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, parent_workspace_id: string | null, image_ids: Array | null, }; @@ -300,6 +302,14 @@ export type UnifiedPrComment = { "comment_type": "general", id: string, author: export type ProviderKind = "git_hub" | "azure_dev_ops" | "unknown"; +export type GitHubIssue = { number: number, title: string, body: string | null, state: string, url: string, }; + +export type GitHubIssueToImport = { number: number, title: string, body: string | null, url: string, }; + +export type ImportGitHubIssuesRequest = { repo_id: string, issues: Array, }; + +export type ImportGitHubIssuesResponse = { created_count: bigint, task_ids: Array, }; + export type RepoBranchStatus = { repo_id: string, repo_name: string, commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, target_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array, /** * True if a `git rebase` is currently in progress in this worktree