Allow multiple merges (#510)

* Allow multiple merge for a single task attempt

Merge more than once (vibe-kanban 618829fc)

When creating a PR, new changes can be pushed after creation.
We need merge to work the same way, when changes have been made after the first merge, a second one should work.

Commit changes from coding agent for task attempt 548ff450-df77-47b2-a5ba-c88d0aa4a334

Merge more than once (vibe-kanban 618829fc)

When creating a PR, new changes can be pushed after creation.
We need merge to work the same way, when changes have been made after the first merge, a second one should work.

Remove pinned todo list (vibe-kanban cc66cda2)

Make a minimal change to remove the pinned todo list from the frontend

Remove pinned todo list (vibe-kanban cc66cda2)

Make a minimal change to remove the pinned todo list from the frontend

* Create merges table; remove task_attempt.merge_commit

Add merge model, replace ta.merge_commit with m.merge_commit

Fix frontend

* Move PR to merges table

* Refactor GitHub repository info retrieval to return structured data

* Fix frontend

* Reset task branch after PR merge

Add branch status handling to TaskDetailsProvider and related components

fmt

Add branch status handling to TaskDetailsProvider and related components

fmt

Test (vibe-kanban 1bf1a80f)

add test.txt

Show merged diff when no worktree present

Refresh branch status after PR creation

Test (vibe-kanban 1bf1a80f)

add test.txt

Test (vibe-kanban 1bf1a80f)

add test.txt

Show rebase when behind

Refactor container service to check if the container is clean before showing merged diff; remove unused BranchStatus import

Test (vibe-kanban a3c1b297)

add test.txt

Refactor branch status handling: rename BranchStatusResponse to BranchStatus and update related types and usages

Test (vibe-kanban) (#540)

* Remove test.txt

* Test (vibe-kanban aade357e)

add test.txt

* test.txt removed.

* Fix diff when merged and new commits have been made

* Remvoe logging (vibe-kanban) (#541)

* Test (vibe-kanban aade357e)

add test.txt

* Test (vibe-kanban aade357e)

add test.txt

* Perfect! I've successfully removed the "Fetching branch status" logging statement from the code. The logging has been removed from `crates/server/src/routes/task_attempts.rs:568-571`.

* Clear previous errors on successful PR creation, push, merge, and rebase actions

* Show branch in worktree dirty error message

* Add success indicators for push and merge actions in CurrentAttempt

* Refactor status display logic in CurrentAttempt for improved readability and maintainability

* Add target_branch_name to merge models and queries for direct and PR merges

* Enhance merge status display logic in CurrentAttempt for better clarity on direct merges

* Remove unnecessary condition check in attempt data fetching interval

* Clippy

* Add index for task_attempt_id in merges table to improve query performance

* Pass PR creation error

* Disable buttons (vibe-kanban 240346bf)

Instead of not showing the merge/pr buttons when theyre not available we should disable them. frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx
This commit is contained in:
Alex Netsch
2025-08-21 16:00:35 +01:00
committed by GitHub
parent 061b461397
commit ed594a3d80
34 changed files with 1348 additions and 810 deletions

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE task_attempts SET merge_commit = $1, updated_at = datetime('now') WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "03f2b02ba6dc5ea2b3cf6b1004caea0ad6bcc10ebd63f441d321a389f026e263"
}

View File

@@ -0,0 +1,80 @@
{
"db_name": "SQLite",
"query": "INSERT INTO merges (\n id, task_attempt_id, merge_type, pr_number, pr_url, pr_status, created_at, target_branch_name\n ) VALUES ($1, $2, 'pr', $3, $4, 'open', $5, $6)\n RETURNING \n id as \"id!: Uuid\",\n task_attempt_id as \"task_attempt_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n pr_merge_commit_sha,\n created_at as \"created_at!: DateTime<Utc>\",\n target_branch_name as \"target_branch_name!: String\"\n ",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "task_attempt_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "merge_type!: MergeType",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "merge_commit",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "pr_url",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_status?: MergeStatus",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_merged_at?: DateTime<Utc>",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_merge_commit_sha",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "target_branch_name!: String",
"ordinal": 10,
"type_info": "Text"
}
],
"parameters": {
"Right": 6
},
"nullable": [
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "09510a7e5927bd5000f6e9e027d4bf1edf6246f1feb575917ed0aff0e6e0f5a1"
}

View File

@@ -0,0 +1,80 @@
{
"db_name": "SQLite",
"query": "SELECT \n id as \"id!: Uuid\",\n task_attempt_id as \"task_attempt_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n pr_merge_commit_sha,\n target_branch_name as \"target_branch_name!: String\",\n created_at as \"created_at!: DateTime<Utc>\"\n FROM merges \n WHERE task_attempt_id = $1\n ORDER BY created_at DESC",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "task_attempt_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "merge_type!: MergeType",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "merge_commit",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "pr_url",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_status?: MergeStatus",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_merged_at?: DateTime<Utc>",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_merge_commit_sha",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "target_branch_name!: String",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 10,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "1395fe4c3041a4d05e5c3caa068471c8790a67890d6a0566f44bd4e134679095"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE merges \n SET pr_status = $1, \n pr_merge_commit_sha = $2,\n pr_merged_at = $3\n WHERE id = $4",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "19fcd51ab5368347045ccb0eb39f0bf5dc321c057d01b55151b6ca67f163fc9b"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "INSERT INTO task_attempts (id, task_id, container_ref, branch, base_branch, merge_commit, profile, pr_url, pr_number, pr_status, pr_merged_at, worktree_deleted, setup_completed_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", container_ref, branch, base_branch, merge_commit, profile as \"profile!\", pr_url, pr_number, pr_status, pr_merged_at as \"pr_merged_at: DateTime<Utc>\", worktree_deleted as \"worktree_deleted!: bool\", setup_completed_at as \"setup_completed_at: DateTime<Utc>\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"query": "INSERT INTO task_attempts (id, task_id, container_ref, branch, base_branch, profile, worktree_deleted, setup_completed_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", container_ref, branch, base_branch, profile as \"profile!\", worktree_deleted as \"worktree_deleted!: bool\", setup_completed_at as \"setup_completed_at: DateTime<Utc>\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": {
"columns": [
{
@@ -29,58 +29,33 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "profile!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "profile!",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "worktree_deleted!: bool",
"ordinal": 11,
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "setup_completed_at: DateTime<Utc>",
"ordinal": 12,
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 13,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 14,
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
"Right": 13
"Right": 8
},
"nullable": [
true,
@@ -89,16 +64,11 @@
true,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "741e831f4509958c508da36e955e381905672b39fc121170418015e2512184a2"
"hash": "25d8df97101afa1a3a6e7c609d741237f24871bd270d37f50c37807cdece1104"
}

View File

@@ -0,0 +1,80 @@
{
"db_name": "SQLite",
"query": "INSERT INTO merges (\n id, task_attempt_id, merge_type, merge_commit, created_at, target_branch_name\n ) VALUES ($1, $2, 'direct', $3, $4, $5)\n RETURNING \n id as \"id!: Uuid\",\n task_attempt_id as \"task_attempt_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n pr_merge_commit_sha,\n created_at as \"created_at!: DateTime<Utc>\",\n target_branch_name as \"target_branch_name!: String\"\n ",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "task_attempt_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "merge_type!: MergeType",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "merge_commit",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "pr_url",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_status?: MergeStatus",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_merged_at?: DateTime<Utc>",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_merge_commit_sha",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "target_branch_name!: String",
"ordinal": 10,
"type_info": "Text"
}
],
"parameters": {
"Right": 5
},
"nullable": [
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "32c9dae46df6480ce1ca07f72b8659e60d9159afcc03a4bb5213f7a2bae537d8"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n merge_commit,\n profile AS \"profile!\",\n pr_url,\n pr_number,\n pr_status,\n pr_merged_at AS \"pr_merged_at: DateTime<Utc>\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n created_at AS \"created_at!: DateTime<Utc>\",\n updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts\n ORDER BY created_at DESC",
"query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n profile AS \"profile!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n created_at AS \"created_at!: DateTime<Utc>\",\n updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts\n ORDER BY created_at DESC",
"describe": {
"columns": [
{
@@ -29,53 +29,28 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "profile!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "profile!",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "worktree_deleted!: bool",
"ordinal": 11,
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "setup_completed_at: DateTime<Utc>",
"ordinal": 12,
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 13,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 14,
"ordinal": 9,
"type_info": "Text"
}
],
@@ -89,16 +64,11 @@
true,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "8a1b8a47f4405a3e4a5bc41db8ec40af31f748587b61b7821f7e326ce9e23a75"
"hash": "7e7f701c7e56081684128df131135cad9e4d5633f5f1d95ed9186379fcb099b4"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE task_attempts SET pr_url = $1, pr_number = $2, pr_status = $3, updated_at = datetime('now') WHERE id = $4",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "86d03eb70eef39c59296416867f2ee66c9f7cd8b7f961fbda2f89fc0a1c442c2"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT ta.id AS \"id!: Uuid\",\n ta.task_id AS \"task_id!: Uuid\",\n ta.container_ref,\n ta.branch,\n ta.base_branch,\n ta.merge_commit,\n ta.profile AS \"profile!\",\n ta.pr_url,\n ta.pr_number,\n ta.pr_status,\n ta.pr_merged_at AS \"pr_merged_at: DateTime<Utc>\",\n ta.worktree_deleted AS \"worktree_deleted!: bool\",\n ta.setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n ta.created_at AS \"created_at!: DateTime<Utc>\",\n ta.updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts ta\n JOIN tasks t ON ta.task_id = t.id\n JOIN projects p ON t.project_id = p.id\n WHERE ta.id = $1 AND t.id = $2 AND p.id = $3",
"query": "SELECT ta.id AS \"id!: Uuid\",\n ta.task_id AS \"task_id!: Uuid\",\n ta.container_ref,\n ta.branch,\n ta.base_branch,\n ta.profile AS \"profile!\",\n ta.worktree_deleted AS \"worktree_deleted!: bool\",\n ta.setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n ta.created_at AS \"created_at!: DateTime<Utc>\",\n ta.updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts ta\n JOIN tasks t ON ta.task_id = t.id\n JOIN projects p ON t.project_id = p.id\n WHERE ta.id = $1 AND t.id = $2 AND p.id = $3",
"describe": {
"columns": [
{
@@ -29,53 +29,28 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "profile!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "profile!",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "worktree_deleted!: bool",
"ordinal": 11,
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "setup_completed_at: DateTime<Utc>",
"ordinal": 12,
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 13,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 14,
"ordinal": 9,
"type_info": "Text"
}
],
@@ -89,16 +64,11 @@
true,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "703270b5172b81852470a886a72d9f749cbef0078e786582d23eb18e8cf11119"
"hash": "8def2c6c696ac747df23dea77c23e135110c85a8d10cf0df096ffe7e7cd201c4"
}

View File

@@ -1,6 +1,6 @@
{
"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_task_attempt AS \"parent_task_attempt: Uuid\",\n t.created_at AS \"created_at!: DateTime<Utc>\",\n t.updated_at AS \"updated_at!: DateTime<Utc>\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM task_attempts ta\n JOIN execution_processes ep\n ON ep.task_attempt_id = ta.id\n WHERE ta.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 EXISTS (\n SELECT 1\n FROM task_attempts ta\n WHERE ta.task_id = t.id\n AND ta.merge_commit IS NOT NULL\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"has_merged_attempt!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM task_attempts ta\n JOIN execution_processes ep\n ON ep.task_attempt_id = ta.id\n WHERE ta.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 ta.profile\n FROM task_attempts ta\n WHERE ta.task_id = t.id\n ORDER BY ta.created_at DESC\n LIMIT 1\n ) AS \"profile!: String\"\n\nFROM tasks t\nWHERE t.project_id = $1\nORDER BY t.created_at DESC",
"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_task_attempt AS \"parent_task_attempt: Uuid\",\n t.created_at AS \"created_at!: DateTime<Utc>\",\n t.updated_at AS \"updated_at!: DateTime<Utc>\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM task_attempts ta\n JOIN execution_processes ep\n ON ep.task_attempt_id = ta.id\n WHERE ta.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 task_attempts ta\n JOIN execution_processes ep\n ON ep.task_attempt_id = ta.id\n WHERE ta.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 ta.profile\n FROM task_attempts ta\n WHERE ta.task_id = t.id\n ORDER BY ta.created_at DESC\n LIMIT 1\n ) AS \"profile!: String\"\n\nFROM tasks t\nWHERE t.project_id = $1\nORDER BY t.created_at DESC",
"describe": {
"columns": [
{
@@ -49,18 +49,13 @@
"type_info": "Integer"
},
{
"name": "has_merged_attempt!: i64",
"name": "last_attempt_failed!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_attempt_failed!: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "profile!: String",
"ordinal": 11,
"ordinal": 10,
"type_info": "Text"
}
],
@@ -78,9 +73,8 @@
false,
false,
false,
false,
true
]
},
"hash": "f338f880ec72989bcaabe3ae3e843fe1faabc1f990f2c91ceb30b76b0fe43153"
"hash": "8f848d77f2464b4010475de13aacf8157663b139b363da000ca0c94fbcac378e"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n merge_commit,\n profile AS \"profile!\",\n pr_url,\n pr_number,\n pr_status,\n pr_merged_at AS \"pr_merged_at: DateTime<Utc>\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n created_at AS \"created_at!: DateTime<Utc>\",\n updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts\n WHERE task_id = $1\n ORDER BY created_at DESC",
"query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n profile AS \"profile!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n created_at AS \"created_at!: DateTime<Utc>\",\n updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts\n WHERE task_id = $1\n ORDER BY created_at DESC",
"describe": {
"columns": [
{
@@ -29,53 +29,28 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "profile!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "profile!",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "worktree_deleted!: bool",
"ordinal": 11,
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "setup_completed_at: DateTime<Utc>",
"ordinal": 12,
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 13,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 14,
"ordinal": 9,
"type_info": "Text"
}
],
@@ -89,16 +64,11 @@
true,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "70474b20d1a3affa13c80926f954ca2007faa6977508bb6372a867bdc56c4830"
"hash": "9298f2ee7230893e28a8defecabb17c7b9e08d355d654b846f0e6a56189c10b6"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n merge_commit,\n base_branch,\n profile AS \"profile!\",\n pr_url,\n pr_number,\n pr_status,\n pr_merged_at AS \"pr_merged_at: DateTime<Utc>\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n created_at AS \"created_at!: DateTime<Utc>\",\n updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts\n WHERE id = $1",
"query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n profile AS \"profile!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n created_at AS \"created_at!: DateTime<Utc>\",\n updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts\n WHERE rowid = $1",
"describe": {
"columns": [
{
@@ -24,58 +24,33 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "base_branch",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "base_branch",
"name": "profile!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "profile!",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "worktree_deleted!: bool",
"ordinal": 11,
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "setup_completed_at: DateTime<Utc>",
"ordinal": 12,
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 13,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 14,
"ordinal": 9,
"type_info": "Text"
}
],
@@ -87,18 +62,13 @@
false,
true,
true,
true,
false,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "2494dbc96dfeed84122a142ca4b4ce5166875560295794e09ffff754861fd765"
"hash": "94535d0c0e4eac82202f5420b62781ab774616c6d6c5ffd58b5b344c75104a0a"
}

View File

@@ -1,38 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT \n ta.id as \"attempt_id!: Uuid\",\n ta.task_id as \"task_id!: Uuid\",\n ta.pr_number as \"pr_number!: i64\",\n ta.pr_url as \"pr_url!: String\"\n FROM task_attempts ta\n WHERE ta.pr_status = 'open' AND ta.pr_number IS NOT NULL",
"describe": {
"columns": [
{
"name": "attempt_id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "task_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "pr_number!: i64",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "pr_url!: String",
"ordinal": 3,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
true,
true
]
},
"hash": "c1e5b46545fcef759610463d9bf902b25f18cd83d2ca8616bf3ec1c84728bf6f"
}

View File

@@ -0,0 +1,80 @@
{
"db_name": "SQLite",
"query": "SELECT \n id as \"id!: Uuid\",\n task_attempt_id as \"task_attempt_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime<Utc>\",\n pr_merge_commit_sha,\n created_at as \"created_at!: DateTime<Utc>\",\n target_branch_name as \"target_branch_name!: String\"\n FROM merges \n WHERE merge_type = 'pr' AND pr_status = 'open'\n ORDER BY created_at DESC",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "task_attempt_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "merge_type!: MergeType",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "merge_commit",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "pr_url",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_status?: MergeStatus",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_merged_at?: DateTime<Utc>",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_merge_commit_sha",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "target_branch_name!: String",
"ordinal": 10,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
true,
true,
true,
false,
true,
true,
false,
false
]
},
"hash": "e45aa1e2282cc62522f66049de7d1d1c47e926000fac7a5c5f28237fdb65a0bb"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n merge_commit,\n base_branch,\n profile AS \"profile!\",\n pr_url,\n pr_number,\n pr_status,\n pr_merged_at AS \"pr_merged_at: DateTime<Utc>\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n created_at AS \"created_at!: DateTime<Utc>\",\n updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts\n WHERE rowid = $1",
"query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n profile AS \"profile!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime<Utc>\",\n created_at AS \"created_at!: DateTime<Utc>\",\n updated_at AS \"updated_at!: DateTime<Utc>\"\n FROM task_attempts\n WHERE id = $1",
"describe": {
"columns": [
{
@@ -24,58 +24,33 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "base_branch",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "base_branch",
"name": "profile!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "profile!",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "worktree_deleted!: bool",
"ordinal": 11,
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "setup_completed_at: DateTime<Utc>",
"ordinal": 12,
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 13,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 14,
"ordinal": 9,
"type_info": "Text"
}
],
@@ -87,18 +62,13 @@
false,
true,
true,
true,
false,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "5f44ebd79693cfe8f0eab52c1a41533bb78d340771a3ac178f7745852785c843"
"hash": "fed05aaa5ad03cc0e9c5f261b48ec194c4a7a2dd05975f60d2c58107b958b8a7"
}

View File

@@ -0,0 +1,78 @@
-- Create enhanced merges table with type-specific columns
CREATE TABLE merges (
id BLOB PRIMARY KEY,
task_attempt_id BLOB NOT NULL,
merge_type TEXT NOT NULL CHECK (merge_type IN ('direct', 'pr')),
-- Direct merge fields (NULL for PR merges)
merge_commit TEXT,
-- PR merge fields (NULL for direct merges)
pr_number INTEGER,
pr_url TEXT,
pr_status TEXT CHECK (pr_status IN ('open', 'merged', 'closed')),
pr_merged_at TEXT,
pr_merge_commit_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')),
target_branch_name TEXT NOT NULL,
-- Data integrity constraints
CHECK (
(merge_type = 'direct' AND merge_commit IS NOT NULL
AND pr_number IS NULL AND pr_url IS NULL)
OR
(merge_type = 'pr' AND pr_number IS NOT NULL AND pr_url IS NOT NULL
AND pr_status IS NOT NULL AND merge_commit IS NULL)
),
FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE
);
-- Create general index for all task_attempt_id queries
CREATE INDEX idx_merges_task_attempt_id ON merges(task_attempt_id);
-- Create index for finding open PRs quickly
CREATE INDEX idx_merges_open_pr ON merges(task_attempt_id, pr_status)
WHERE merge_type = 'pr' AND pr_status = 'open';
-- Migrate existing merge_commit data to new table as direct merges
INSERT INTO merges (id, task_attempt_id, merge_type, merge_commit, created_at, target_branch_name)
SELECT
randomblob(16),
id,
'direct',
merge_commit,
updated_at,
base_branch
FROM task_attempts
WHERE merge_commit IS NOT NULL;
-- Migrate existing PR data from task_attempts to merges
INSERT INTO merges (id, task_attempt_id, merge_type, pr_number, pr_url, pr_status, pr_merged_at, pr_merge_commit_sha, created_at, target_branch_name)
SELECT
randomblob(16),
id,
'pr',
pr_number,
pr_url,
CASE
WHEN pr_status = 'merged' THEN 'merged'
WHEN pr_status = 'closed' THEN 'closed'
ELSE 'open'
END,
pr_merged_at,
NULL, -- We don't have merge_commit for PRs in task_attempts
COALESCE(pr_merged_at, updated_at),
base_branch
FROM task_attempts
WHERE pr_number IS NOT NULL;
-- Drop merge_commit column from task_attempts
ALTER TABLE task_attempts DROP COLUMN merge_commit;
-- Drop PR columns from task_attempts
ALTER TABLE task_attempts DROP COLUMN pr_url;
ALTER TABLE task_attempts DROP COLUMN pr_number;
ALTER TABLE task_attempts DROP COLUMN pr_status;
ALTER TABLE task_attempts DROP COLUMN pr_merged_at;

View File

@@ -0,0 +1,299 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool, Type};
use ts_rs::TS;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, TS, Type)]
#[sqlx(type_name = "merge_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum MergeStatus {
Open,
Merged,
Closed,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Merge {
Direct(DirectMerge),
Pr(PrMerge),
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct DirectMerge {
pub id: Uuid,
pub task_attempt_id: Uuid,
pub merge_commit: String,
pub target_branch_name: String,
pub created_at: DateTime<Utc>,
}
/// PR merge - represents a pull request merge
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct PrMerge {
pub id: Uuid,
pub task_attempt_id: Uuid,
pub created_at: DateTime<Utc>,
pub target_branch_name: String,
pub pr_info: PullRequestInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct PullRequestInfo {
pub number: i64,
pub url: String,
pub status: MergeStatus,
pub merged_at: Option<chrono::DateTime<chrono::Utc>>,
pub merge_commit_sha: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[sqlx(type_name = "TEXT", rename_all = "snake_case")]
pub enum MergeType {
Direct,
Pr,
}
#[derive(FromRow)]
struct MergeRow {
id: Uuid,
task_attempt_id: Uuid,
merge_type: MergeType,
merge_commit: Option<String>,
target_branch_name: String,
pr_number: Option<i64>,
pr_url: Option<String>,
pr_status: Option<MergeStatus>,
pr_merged_at: Option<DateTime<Utc>>,
pr_merge_commit_sha: Option<String>,
created_at: DateTime<Utc>,
}
impl Merge {
pub fn merge_commit(&self) -> Option<String> {
match self {
Merge::Direct(direct) => Some(direct.merge_commit.clone()),
Merge::Pr(pr) => pr.pr_info.merge_commit_sha.clone(),
}
}
/// Create a direct merge record
pub async fn create_direct(
pool: &SqlitePool,
task_attempt_id: Uuid,
target_branch_name: &str,
merge_commit: &str,
) -> Result<DirectMerge, sqlx::Error> {
let id = Uuid::new_v4();
let now = Utc::now();
sqlx::query_as!(
MergeRow,
r#"INSERT INTO merges (
id, task_attempt_id, merge_type, merge_commit, created_at, target_branch_name
) VALUES ($1, $2, 'direct', $3, $4, $5)
RETURNING
id as "id!: Uuid",
task_attempt_id as "task_attempt_id!: Uuid",
merge_type as "merge_type!: MergeType",
merge_commit,
pr_number,
pr_url,
pr_status as "pr_status?: MergeStatus",
pr_merged_at as "pr_merged_at?: DateTime<Utc>",
pr_merge_commit_sha,
created_at as "created_at!: DateTime<Utc>",
target_branch_name as "target_branch_name!: String"
"#,
id,
task_attempt_id,
merge_commit,
now,
target_branch_name
)
.fetch_one(pool)
.await
.map(Into::into)
}
/// Create a new PR record (when PR is opened)
pub async fn create_pr(
pool: &SqlitePool,
task_attempt_id: Uuid,
target_branch_name: &str,
pr_number: i64,
pr_url: &str,
) -> Result<PrMerge, sqlx::Error> {
let id = Uuid::new_v4();
let now = Utc::now();
sqlx::query_as!(
MergeRow,
r#"INSERT INTO merges (
id, task_attempt_id, merge_type, pr_number, pr_url, pr_status, created_at, target_branch_name
) VALUES ($1, $2, 'pr', $3, $4, 'open', $5, $6)
RETURNING
id as "id!: Uuid",
task_attempt_id as "task_attempt_id!: Uuid",
merge_type as "merge_type!: MergeType",
merge_commit,
pr_number,
pr_url,
pr_status as "pr_status?: MergeStatus",
pr_merged_at as "pr_merged_at?: DateTime<Utc>",
pr_merge_commit_sha,
created_at as "created_at!: DateTime<Utc>",
target_branch_name as "target_branch_name!: String"
"#,
id,
task_attempt_id,
pr_number,
pr_url,
now,
target_branch_name
)
.fetch_one(pool)
.await
.map(Into::into)
}
/// Get all open PRs for monitoring
pub async fn get_open_prs(pool: &SqlitePool) -> Result<Vec<PrMerge>, sqlx::Error> {
let rows = sqlx::query_as!(
MergeRow,
r#"SELECT
id as "id!: Uuid",
task_attempt_id as "task_attempt_id!: Uuid",
merge_type as "merge_type!: MergeType",
merge_commit,
pr_number,
pr_url,
pr_status as "pr_status?: MergeStatus",
pr_merged_at as "pr_merged_at?: DateTime<Utc>",
pr_merge_commit_sha,
created_at as "created_at!: DateTime<Utc>",
target_branch_name as "target_branch_name!: String"
FROM merges
WHERE merge_type = 'pr' AND pr_status = 'open'
ORDER BY created_at DESC"#,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Update PR status for a task attempt
pub async fn update_status(
pool: &SqlitePool,
merge_id: Uuid,
pr_status: MergeStatus,
merge_commit_sha: Option<String>,
) -> Result<(), sqlx::Error> {
let merged_at = if matches!(pr_status, MergeStatus::Merged) {
Some(Utc::now())
} else {
None
};
sqlx::query!(
r#"UPDATE merges
SET pr_status = $1,
pr_merge_commit_sha = $2,
pr_merged_at = $3
WHERE id = $4"#,
pr_status,
merge_commit_sha,
merged_at,
merge_id
)
.execute(pool)
.await?;
Ok(())
}
/// Find all merges for a task attempt (returns both direct and PR merges)
pub async fn find_by_task_attempt_id(
pool: &SqlitePool,
task_attempt_id: Uuid,
) -> Result<Vec<Self>, sqlx::Error> {
// Get raw data from database
let rows = sqlx::query_as!(
MergeRow,
r#"SELECT
id as "id!: Uuid",
task_attempt_id as "task_attempt_id!: Uuid",
merge_type as "merge_type!: MergeType",
merge_commit,
pr_number,
pr_url,
pr_status as "pr_status?: MergeStatus",
pr_merged_at as "pr_merged_at?: DateTime<Utc>",
pr_merge_commit_sha,
target_branch_name as "target_branch_name!: String",
created_at as "created_at!: DateTime<Utc>"
FROM merges
WHERE task_attempt_id = $1
ORDER BY created_at DESC"#,
task_attempt_id
)
.fetch_all(pool)
.await?;
// Convert to appropriate types based on merge_type
Ok(rows.into_iter().map(Into::into).collect())
}
/// Find the most recent merge for a task attempt
pub async fn find_latest_by_task_attempt_id(
pool: &SqlitePool,
task_attempt_id: Uuid,
) -> Result<Option<Self>, sqlx::Error> {
Self::find_by_task_attempt_id(pool, task_attempt_id)
.await
.map(|mut merges| merges.pop())
}
}
// Conversion implementations
impl From<MergeRow> for DirectMerge {
fn from(row: MergeRow) -> Self {
DirectMerge {
id: row.id,
task_attempt_id: row.task_attempt_id,
merge_commit: row
.merge_commit
.expect("direct merge must have merge_commit"),
target_branch_name: row.target_branch_name,
created_at: row.created_at,
}
}
}
impl From<MergeRow> for PrMerge {
fn from(row: MergeRow) -> Self {
PrMerge {
id: row.id,
task_attempt_id: row.task_attempt_id,
target_branch_name: row.target_branch_name,
pr_info: PullRequestInfo {
number: row.pr_number.expect("pr merge must have pr_number"),
url: row.pr_url.expect("pr merge must have pr_url"),
status: row.pr_status.expect("pr merge must have status"),
merged_at: row.pr_merged_at,
merge_commit_sha: row.pr_merge_commit_sha,
},
created_at: row.created_at,
}
}
}
impl From<MergeRow> for Merge {
fn from(row: MergeRow) -> Self {
match row.merge_type {
MergeType::Direct => Merge::Direct(DirectMerge::from(row)),
MergeType::Pr => Merge::Pr(PrMerge::from(row)),
}
}
}

View File

@@ -2,6 +2,7 @@ pub mod execution_process;
pub mod execution_process_logs;
pub mod executor_session;
pub mod image;
pub mod merge;
pub mod project;
pub mod task;
pub mod task_attempt;

View File

@@ -102,14 +102,6 @@ impl Task {
LIMIT 1
) THEN 1 ELSE 0 END AS "has_in_progress_attempt!: i64",
CASE WHEN EXISTS (
SELECT 1
FROM task_attempts ta
WHERE ta.task_id = t.id
AND ta.merge_commit IS NOT NULL
LIMIT 1
) THEN 1 ELSE 0 END AS "has_merged_attempt!: i64",
CASE WHEN (
SELECT ep.status
FROM task_attempts ta
@@ -149,7 +141,7 @@ ORDER BY t.created_at DESC"#,
created_at: rec.created_at,
updated_at: rec.updated_at,
has_in_progress_attempt: rec.has_in_progress_attempt != 0,
has_merged_attempt: rec.has_merged_attempt != 0,
has_merged_attempt: false, // TODO use merges table
last_attempt_failed: rec.last_attempt_failed != 0,
profile: rec.profile,
})

View File

@@ -7,40 +7,6 @@ use uuid::Uuid;
use super::{project::Project, task::Task};
#[derive(Debug)]
pub struct PrInfo {
pub attempt_id: Uuid,
pub task_id: Uuid,
pub pr_number: i64,
pub repo_owner: String,
pub repo_name: String,
}
impl PrInfo {
pub fn from_task_attempt_data(
attempt_id: Uuid,
task_id: Uuid,
pr_number: i64,
pr_url: &str,
) -> Result<Self, sqlx::Error> {
let re = regex::Regex::new(r"github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)").unwrap();
let caps = re
.captures(pr_url)
.ok_or_else(|| sqlx::Error::ColumnNotFound("Invalid URL format".into()))?;
let owner = caps.name("owner").unwrap().as_str().to_string();
let repo_name = caps.name("repo").unwrap().as_str().to_string();
Ok(Self {
attempt_id,
task_id,
pr_number,
repo_owner: owner,
repo_name,
})
}
}
#[derive(Debug, Error)]
pub enum TaskAttemptError {
#[error(transparent)]
@@ -74,13 +40,8 @@ pub struct TaskAttempt {
pub container_ref: Option<String>, // Path to a worktree (local), or cloud container id
pub branch: Option<String>, // Git branch name for this task attempt
pub base_branch: String, // Base branch this attempt is based on
pub merge_commit: Option<String>,
pub profile: String, // Name of the base coding agent to use ("AMP", "CLAUDE_CODE",
// "GEMINI", etc.)
pub pr_url: Option<String>, // GitHub PR URL
pub pr_number: Option<i64>, // GitHub PR number
pub pr_status: Option<String>, // open, closed, merged
pub pr_merged_at: Option<DateTime<Utc>>, // When PR was merged
pub worktree_deleted: bool, // Flag indicating if worktree has been cleaned up
pub setup_completed_at: Option<DateTime<Utc>>, // When setup script was last completed
pub created_at: DateTime<Utc>,
@@ -141,12 +102,7 @@ impl TaskAttempt {
container_ref,
branch,
base_branch,
merge_commit,
profile AS "profile!",
pr_url,
pr_number,
pr_status,
pr_merged_at AS "pr_merged_at: DateTime<Utc>",
worktree_deleted AS "worktree_deleted!: bool",
setup_completed_at AS "setup_completed_at: DateTime<Utc>",
created_at AS "created_at!: DateTime<Utc>",
@@ -166,12 +122,7 @@ impl TaskAttempt {
container_ref,
branch,
base_branch,
merge_commit,
profile AS "profile!",
pr_url,
pr_number,
pr_status,
pr_merged_at AS "pr_merged_at: DateTime<Utc>",
worktree_deleted AS "worktree_deleted!: bool",
setup_completed_at AS "setup_completed_at: DateTime<Utc>",
created_at AS "created_at!: DateTime<Utc>",
@@ -202,12 +153,7 @@ impl TaskAttempt {
ta.container_ref,
ta.branch,
ta.base_branch,
ta.merge_commit,
ta.profile AS "profile!",
ta.pr_url,
ta.pr_number,
ta.pr_status,
ta.pr_merged_at AS "pr_merged_at: DateTime<Utc>",
ta.worktree_deleted AS "worktree_deleted!: bool",
ta.setup_completed_at AS "setup_completed_at: DateTime<Utc>",
ta.created_at AS "created_at!: DateTime<Utc>",
@@ -296,13 +242,8 @@ impl TaskAttempt {
task_id AS "task_id!: Uuid",
container_ref,
branch,
merge_commit,
base_branch,
profile AS "profile!",
pr_url,
pr_number,
pr_status,
pr_merged_at AS "pr_merged_at: DateTime<Utc>",
worktree_deleted AS "worktree_deleted!: bool",
setup_completed_at AS "setup_completed_at: DateTime<Utc>",
created_at AS "created_at!: DateTime<Utc>",
@@ -322,13 +263,8 @@ impl TaskAttempt {
task_id AS "task_id!: Uuid",
container_ref,
branch,
merge_commit,
base_branch,
profile AS "profile!",
pr_url,
pr_number,
pr_status,
pr_merged_at AS "pr_merged_at: DateTime<Utc>",
worktree_deleted AS "worktree_deleted!: bool",
setup_completed_at AS "setup_completed_at: DateTime<Utc>",
created_at AS "created_at!: DateTime<Utc>",
@@ -341,36 +277,6 @@ impl TaskAttempt {
.await
}
// pub async fn find_by_task_id(
// pool: &SqlitePool,
// task_id: Uuid,
// ) -> Result<Vec<Self>, sqlx::Error> {
// sqlx::query_as!(
// TaskAttempt,
// r#"SELECT id AS "id!: Uuid",
// task_id AS "task_id!: Uuid",
// worktree_path,
// branch,
// base_branch,
// merge_commit,
// executor,
// pr_url,
// pr_number,
// pr_status,
// pr_merged_at AS "pr_merged_at: DateTime<Utc>",
// worktree_deleted AS "worktree_deleted!: bool",
// setup_completed_at AS "setup_completed_at: DateTime<Utc>",
// created_at AS "created_at!: DateTime<Utc>",
// updated_at AS "updated_at!: DateTime<Utc>"
// FROM task_attempts
// WHERE task_id = $1
// ORDER BY created_at DESC"#,
// task_id
// )
// .fetch_all(pool)
// .await
// }
/// Find task attempts by task_id with project git repo path for cleanup operations
pub async fn find_by_task_id_with_project(
pool: &SqlitePool,
@@ -481,20 +387,15 @@ impl TaskAttempt {
// Insert the record into the database
Ok(sqlx::query_as!(
TaskAttempt,
r#"INSERT INTO task_attempts (id, task_id, container_ref, branch, base_branch, merge_commit, profile, pr_url, pr_number, pr_status, pr_merged_at, worktree_deleted, setup_completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id as "id!: Uuid", task_id as "task_id!: Uuid", container_ref, branch, base_branch, merge_commit, profile as "profile!", pr_url, pr_number, pr_status, pr_merged_at as "pr_merged_at: DateTime<Utc>", worktree_deleted as "worktree_deleted!: bool", setup_completed_at as "setup_completed_at: DateTime<Utc>", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
r#"INSERT INTO task_attempts (id, task_id, container_ref, branch, base_branch, profile, worktree_deleted, setup_completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id as "id!: Uuid", task_id as "task_id!: Uuid", container_ref, branch, base_branch, profile as "profile!", worktree_deleted as "worktree_deleted!: bool", setup_completed_at as "setup_completed_at: DateTime<Utc>", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
attempt_id,
task_id,
Option::<String>::None, // Container isn't known yet
Option::<String>::None, // branch name isn't known yet
data.base_branch,
Option::<String>::None, // merge_commit is always None during creation
data.profile,
Option::<String>::None, // pr_url is None during creation
Option::<i64>::None, // pr_number is None during creation
Option::<String>::None, // pr_status is None during creation
Option::<DateTime<Utc>>::None, // pr_merged_at is None during creation
false, // worktree_deleted is false during creation
Option::<DateTime<Utc>>::None // setup_completed_at is None during creation
)
@@ -502,23 +403,6 @@ impl TaskAttempt {
.await?)
}
/// Update the task attempt with the merge commit ID
pub async fn update_merge_commit(
pool: &SqlitePool,
attempt_id: Uuid,
merge_commit_id: &str,
) -> Result<(), TaskAttemptError> {
sqlx::query!(
"UPDATE task_attempts SET merge_commit = $1, updated_at = datetime('now') WHERE id = $2",
merge_commit_id,
attempt_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_base_branch(
pool: &SqlitePool,
attempt_id: Uuid,
@@ -535,27 +419,6 @@ impl TaskAttempt {
Ok(())
}
/// Update PR status for a task attempt
pub async fn update_pr_status(
pool: &SqlitePool,
attempt_id: Uuid,
pr_url: String,
pr_number: i64,
pr_status: String,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"UPDATE task_attempts SET pr_url = $1, pr_number = $2, pr_status = $3, updated_at = datetime('now') WHERE id = $4",
pr_url,
pr_number,
pr_status,
attempt_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn resolve_container_ref(
pool: &SqlitePool,
container_ref: &str,
@@ -575,24 +438,4 @@ impl TaskAttempt {
Ok((result.attempt_id, result.task_id, result.project_id))
}
pub async fn get_open_prs(pool: &SqlitePool) -> Result<Vec<PrInfo>, sqlx::Error> {
let rows = sqlx::query!(
r#"SELECT
ta.id as "attempt_id!: Uuid",
ta.task_id as "task_id!: Uuid",
ta.pr_number as "pr_number!: i64",
ta.pr_url as "pr_url!: String"
FROM task_attempts ta
WHERE ta.pr_status = 'open' AND ta.pr_number IS NOT NULL"#
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.filter_map(|r| {
PrInfo::from_task_attempt_data(r.attempt_id, r.task_id, r.pr_number, &r.pr_url).ok()
})
.collect())
}
}

View File

@@ -18,6 +18,7 @@ use db::{
ExecutionContext, ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus,
},
executor_session::ExecutorSession,
merge::Merge,
project::Project,
task::{Task, TaskStatus},
task_attempt::TaskAttempt,
@@ -813,6 +814,20 @@ impl ContainerService for LocalContainerService {
Ok(container_ref.to_string())
}
async fn is_container_clean(&self, task_attempt: &TaskAttempt) -> Result<bool, ContainerError> {
if let Some(container_ref) = &task_attempt.container_ref {
// If container_ref is set, check if the worktree exists
let path = PathBuf::from(container_ref);
if path.exists() {
self.git().is_worktree_clean(&path).map_err(|e| e.into())
} else {
return Ok(true); // No worktree means it's clean
}
} else {
return Ok(true); // No container_ref means no worktree, so it's clean
}
}
async fn start_execution_inner(
&self,
task_attempt: &TaskAttempt,
@@ -904,16 +919,9 @@ impl ContainerService for LocalContainerService {
task_attempt: &TaskAttempt,
) -> Result<futures::stream::BoxStream<'static, Result<Event, std::io::Error>>, ContainerError>
{
let container_ref = self.ensure_container_exists(task_attempt).await?;
let worktree_path = PathBuf::from(container_ref);
let project_repo_path = self.get_project_repo_path(task_attempt).await?;
// Handle merged attempts (static diff)
if let Some(merge_commit_id) = &task_attempt.merge_commit {
return self.create_merged_diff_stream(&project_repo_path, merge_commit_id);
}
let latest_merge =
Merge::find_latest_by_task_attempt_id(&self.db.pool, task_attempt.id).await?;
let task_branch = task_attempt
.branch
.clone()
@@ -922,6 +930,29 @@ impl ContainerService for LocalContainerService {
task_attempt.id
)))?;
let is_ahead = if let Ok((ahead, _)) = self.git().get_local_branch_status(
&project_repo_path,
&task_branch,
&task_attempt.base_branch,
) {
ahead > 0
} else {
false
};
// Show merged diff when no new work is on the branch or container
if let Some(merge) = &latest_merge
&& let Some(commit) = merge.merge_commit()
&& self.is_container_clean(task_attempt).await?
&& !is_ahead
{
return self.create_merged_diff_stream(&project_repo_path, &commit);
}
// worktree is needed for non-merged diffs
let container_ref = self.ensure_container_exists(task_attempt).await?;
let worktree_path = PathBuf::from(container_ref);
// Handle ongoing attempts (live streaming diff)
self.create_live_diff_stream(
&project_repo_path,

View File

@@ -54,7 +54,6 @@ fn generate_types_content() -> String {
server::routes::auth::DevicePollStatus::decl(),
server::routes::auth::CheckTokenResponse::decl(),
services::services::git::GitBranch::decl(),
services::services::git::BranchStatus::decl(),
utils::diff::Diff::decl(),
utils::diff::FileDiffDetails::decl(),
services::services::github_service::RepositoryInfo::decl(),
@@ -73,10 +72,16 @@ fn generate_types_content() -> String {
executors::actions::coding_agent_follow_up::CodingAgentFollowUpRequest::decl(),
server::routes::task_attempts::CreateTaskAttemptBody::decl(),
server::routes::task_attempts::RebaseTaskAttemptRequest::decl(),
server::routes::task_attempts::BranchStatus::decl(),
db::models::task_attempt::TaskAttempt::decl(),
db::models::execution_process::ExecutionProcess::decl(),
db::models::execution_process::ExecutionProcessStatus::decl(),
db::models::execution_process::ExecutionProcessRunReason::decl(),
db::models::merge::Merge::decl(),
db::models::merge::DirectMerge::decl(),
db::models::merge::PrMerge::decl(),
db::models::merge::MergeStatus::decl(),
db::models::merge::PullRequestInfo::decl(),
services::services::events::EventPatch::decl(),
services::services::events::EventPatchInner::decl(),
services::services::events::RecordTypes::decl(),

View File

@@ -12,6 +12,8 @@ use axum::{
use db::models::{
execution_process::{ExecutionProcess, ExecutionProcessRunReason},
image::TaskImage,
merge::{Merge, MergeStatus, PrMerge, PullRequestInfo},
project::{Project, ProjectError},
task::{Task, TaskStatus},
task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptError},
};
@@ -25,11 +27,11 @@ use executors::{
profile::{ProfileConfigs, ProfileVariantLabel},
};
use futures_util::TryStreamExt;
use local_deployment::container;
use serde::{Deserialize, Serialize};
use services::services::{
container::ContainerService,
git::BranchStatus,
github_service::{CreatePrRequest, GitHubRepoInfo, GitHubService, GitHubServiceError},
github_service::{CreatePrRequest, GitHubService, GitHubServiceError},
image::ImageService,
};
use sqlx::Error as SqlxError;
@@ -324,7 +326,13 @@ pub async fn merge_task_attempt(
&commit_message,
)?;
TaskAttempt::update_merge_commit(pool, task_attempt.id, &merge_commit_id).await?;
Merge::create_direct(
pool,
task_attempt.id,
&ctx.task_attempt.base_branch,
&merge_commit_id,
)
.await?;
Task::update_status(pool, ctx.task.id, TaskStatus::Done).await?;
deployment
@@ -358,15 +366,11 @@ pub async fn push_task_attempt_branch(
.parent_task(pool)
.await?
.ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?;
let ctx = TaskAttempt::load_context(pool, task_attempt.id, task.id, task.project_id).await?;
let project = Project::find_by_id(pool, task.project_id)
.await?
.ok_or(ApiError::Project(ProjectError::ProjectNotFound))?;
let container_ref = deployment
.container()
.ensure_container_exists(&task_attempt)
.await?;
let worktree_path = std::path::Path::new(&container_ref);
let branch_name = ctx.task_attempt.branch.as_ref().ok_or_else(|| {
let branch_name = task_attempt.branch.as_ref().ok_or_else(|| {
ApiError::TaskAttempt(TaskAttemptError::ValidationError(
"No branch found for task attempt".to_string(),
))
@@ -374,7 +378,7 @@ pub async fn push_task_attempt_branch(
deployment
.git()
.push_to_github(worktree_path, branch_name, &github_token)?;
.push_to_github(&project.git_repo_path, branch_name, &github_token)?;
Ok(ResponseJson(ApiResponse::success(())))
}
@@ -417,32 +421,27 @@ pub async fn create_github_pr(
.parent_task(pool)
.await?
.ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?;
let ctx = TaskAttempt::load_context(pool, task_attempt.id, task.id, task.project_id).await?;
// Ensure worktree exists (recreate if needed for cold task support)
let container_ref = deployment
.container()
.ensure_container_exists(&task_attempt)
.await?;
let worktree_path = std::path::Path::new(&container_ref);
let project = Project::find_by_id(pool, task.project_id)
.await?
.ok_or(ApiError::Project(ProjectError::ProjectNotFound))?;
// Use GitService to get the remote URL, then create GitHubRepoInfo
let (owner, repo_name) = deployment
let repo_info = deployment
.git()
.get_github_repo_info(&ctx.project.git_repo_path)?;
let repo_info = GitHubRepoInfo { owner, repo_name };
.get_github_repo_info(&project.git_repo_path)?;
// Get branch name from task attempt
let branch_name = ctx.task_attempt.branch.as_ref().ok_or_else(|| {
let branch_name = task_attempt.branch.as_ref().ok_or_else(|| {
ApiError::TaskAttempt(TaskAttemptError::ValidationError(
"No branch found for task attempt".to_string(),
))
})?;
// Push the branch to GitHub first
if let Err(e) = deployment
if let Err(e) =
deployment
.git()
.push_to_github(worktree_path, branch_name, &github_token)
.push_to_github(&project.git_repo_path, branch_name, &github_token)
{
tracing::error!("Failed to push branch to GitHub: {}", e);
let gh_e = GitHubServiceError::from(e);
@@ -450,7 +449,7 @@ pub async fn create_github_pr(
return Ok(ResponseJson(ApiResponse::error_with_data(gh_e)));
} else {
return Ok(ResponseJson(ApiResponse::error(
"Failed to push branch to GitHub",
format!("Failed to push branch to GitHub: {}", gh_e).as_str(),
)));
}
}
@@ -465,12 +464,12 @@ pub async fn create_github_pr(
match github_service.create_pr(&repo_info, &pr_request).await {
Ok(pr_info) => {
// Update the task attempt with PR information
if let Err(e) = TaskAttempt::update_pr_status(
if let Err(e) = Merge::create_pr(
pool,
task_attempt.id,
pr_info.url.clone(),
&base_branch,
pr_info.number,
pr_info.status.clone(),
&pr_info.url,
)
.await
{
@@ -481,8 +480,8 @@ pub async fn create_github_pr(
.track_if_analytics_allowed(
"github_pr_created",
serde_json::json!({
"task_id": ctx.task.id.to_string(),
"project_id": ctx.project.id.to_string(),
"task_id": task.id.to_string(),
"project_id": project.id.to_string(),
"attempt_id": task_attempt.id.to_string(),
}),
)
@@ -499,7 +498,9 @@ pub async fn create_github_pr(
if e.is_api_data() {
Ok(ResponseJson(ApiResponse::error_with_data(e)))
} else {
Ok(ResponseJson(ApiResponse::error("Failed to create PR")))
Ok(ResponseJson(ApiResponse::error(
format!("Failed to create PR: {}", e).as_str(),
)))
}
}
}
@@ -563,6 +564,17 @@ pub async fn open_task_attempt_in_editor(
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct BranchStatus {
pub commits_behind: Option<usize>,
pub commits_ahead: Option<usize>,
pub has_uncommitted_changes: Option<bool>,
pub base_branch_name: String,
pub remote_commits_behind: Option<usize>,
pub remote_commits_ahead: Option<usize>,
pub merges: Vec<Merge>,
}
pub async fn get_task_attempt_branch_status(
Extension(task_attempt): Extension<TaskAttempt>,
State(deployment): State<DeploymentImpl>,
@@ -574,30 +586,60 @@ pub async fn get_task_attempt_branch_status(
.await?
.ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?;
let ctx = TaskAttempt::load_context(pool, task_attempt.id, task.id, task.project_id).await?;
let github_config = deployment.config().read().await.github.clone();
let has_uncommitted_changes = deployment
.container()
.is_container_clean(&task_attempt)
.await
.ok()
.map(|is_clean| !is_clean);
let branch_status = deployment
.git()
.get_branch_status(
&ctx.project.git_repo_path,
ctx.task_attempt.branch.as_ref().ok_or_else(|| {
ApiError::TaskAttempt(TaskAttemptError::ValidationError(
let task_branch =
task_attempt
.branch
.ok_or(ApiError::TaskAttempt(TaskAttemptError::ValidationError(
"No branch found for task attempt".to_string(),
))
})?,
&ctx.task_attempt.base_branch,
ctx.task_attempt.merge_commit.is_some(),
github_config.token(),
)
.map_err(|e| {
tracing::error!(
"Failed to get branch status for task attempt {}: {}",
task_attempt.id,
e
);
ApiError::GitService(e)
})?;
)))?;
let (commits_ahead, commits_behind) = deployment.git().get_local_branch_status(
&ctx.project.git_repo_path,
&task_branch,
&task_attempt.base_branch,
)?;
// Fetch merges for this task attempt and add to branch status
let merges = Merge::find_by_task_attempt_id(pool, task_attempt.id).await?;
let mut branch_status = BranchStatus {
commits_ahead: Some(commits_ahead),
commits_behind: Some(commits_behind),
has_uncommitted_changes,
remote_commits_ahead: None,
remote_commits_behind: None,
merges,
base_branch_name: task_attempt.base_branch.clone(),
};
// check remote status if the attempt has an open PR
if branch_status.merges.first().is_some_and(|m| {
matches!(
m,
Merge::Pr(PrMerge {
pr_info: PullRequestInfo {
status: MergeStatus::Open,
..
},
..
})
)
}) {
let github_config = deployment.config().read().await.github.clone();
let token = github_config
.token()
.ok_or(ApiError::GitHubService(GitHubServiceError::TokenInvalid))?;
let (remote_commits_ahead, remote_commits_behind) = deployment
.git()
.get_remote_branch_status(&ctx.project.git_repo_path, &task_branch, token)?;
branch_status.remote_commits_ahead = Some(remote_commits_ahead);
branch_status.remote_commits_behind = Some(remote_commits_behind);
}
Ok(ResponseJson(ApiResponse::success(branch_status)))
}

View File

@@ -110,6 +110,7 @@ pub trait ContainerService {
&self,
task_attempt: &TaskAttempt,
) -> Result<ContainerRef, ContainerError>;
async fn is_container_clean(&self, task_attempt: &TaskAttempt) -> Result<bool, ContainerError>;
async fn start_execution_inner(
&self,

View File

@@ -3,16 +3,17 @@ use std::{collections::HashMap, path::Path};
use chrono::{DateTime, Utc};
use git2::{
BranchType, CherrypickOptions, Delta, DiffFindOptions, DiffOptions, Error as GitError,
FetchOptions, Repository, Sort, Status, StatusOptions, build::CheckoutBuilder,
FetchOptions, Repository, Sort, build::CheckoutBuilder,
};
use regex;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use thiserror::Error;
use ts_rs::TS;
use utils::diff::{Diff, FileDiffDetails};
// Import for file ranking functionality
use super::file_ranker::FileStat;
use crate::services::github_service::GitHubRepoInfo;
#[derive(Debug, Error)]
pub enum GitServiceError {
@@ -28,8 +29,8 @@ pub enum GitServiceError {
MergeConflicts(String),
#[error("Invalid path: {0}")]
InvalidPath(String),
#[error("Worktree has uncommitted changes: {0}")]
WorktreeDirty(String),
#[error("{0} has uncommitted changes: {1}")]
WorktreeDirty(String, String),
#[error("Invalid file paths: {0}")]
InvalidFilePaths(String),
#[error("No GitHub token available.")]
@@ -55,19 +56,6 @@ pub struct HeadInfo {
pub oid: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct BranchStatus {
pub commits_behind: Option<usize>,
pub commits_ahead: Option<usize>,
pub up_to_date: Option<bool>,
pub merged: bool,
pub has_uncommitted_changes: bool,
pub base_branch_name: String,
pub remote_commits_behind: Option<usize>,
pub remote_commits_ahead: Option<usize>,
pub remote_up_to_date: Option<bool>,
}
/// Target for diff generation
pub enum DiffTarget<'p> {
/// Work-in-progress branch checked out in this worktree
@@ -501,10 +489,12 @@ impl GitService {
commit_message: &str,
) -> Result<String, GitServiceError> {
// Open the worktree repository
let worktree_repo = Repository::open(worktree_path)?;
let worktree_repo = self.open_repo(worktree_path)?;
let main_repo = self.open_repo(repo_path)?;
// Check if worktree is dirty before proceeding
self.check_worktree_clean(&worktree_repo)?;
self.check_worktree_clean(&main_repo)?;
// Verify the task branch exists in the worktree
let task_branch = worktree_repo
@@ -533,8 +523,17 @@ impl GitService {
base_branch_name,
)?;
// Reset the task branch to point to the squash commit
// This allows follow-up work to continue from the merged state without conflicts
let task_refname = format!("refs/heads/{branch_name}");
main_repo.reference(
&task_refname,
squash_commit_id,
true,
"Reset task branch after merge in main repo",
)?;
// Fix: Update main repo's HEAD if it's pointing to the base branch
let main_repo = self.open_repo(repo_path)?;
let refname = format!("refs/heads/{base_branch_name}");
if let Ok(main_head) = main_repo.head()
@@ -551,14 +550,36 @@ impl GitService {
Ok(squash_commit_id.to_string())
}
pub fn get_branch_status(
pub fn get_local_branch_status(
&self,
repo_path: &Path,
branch_name: &str,
base_branch_name: &str,
is_merged: bool,
github_token: Option<String>,
) -> Result<BranchStatus, GitServiceError> {
) -> Result<(usize, usize), GitServiceError> {
let repo = Repository::open(repo_path)?;
let branch_ref = repo
// try "refs/heads/<name>" first, then raw name
.find_reference(&format!("refs/heads/{branch_name}"))
.or_else(|_| repo.find_reference(branch_name))?;
let branch_oid = branch_ref.target().unwrap();
// Calculate ahead/behind counts using the stored base branch
let base_oid = repo
.find_branch(base_branch_name, BranchType::Local)?
.get()
.target()
.ok_or(GitServiceError::BranchNotFound(format!(
"refs/heads/{base_branch_name}"
)))?;
let (a, b) = repo.graph_ahead_behind(branch_oid, base_oid)?;
Ok((a, b))
}
pub fn get_remote_branch_status(
&self,
repo_path: &Path,
branch_name: &str,
github_token: String,
) -> Result<(usize, usize), GitServiceError> {
let repo = Repository::open(repo_path)?;
let branch_ref = repo
@@ -566,55 +587,25 @@ impl GitService {
.find_reference(&format!("refs/heads/{branch_name}"))
.or_else(|_| repo.find_reference(branch_name))?;
let branch_oid = branch_ref.target().unwrap();
// Check for unpushed commits by comparing with origin/branch_name
let (remote_commits_ahead, remote_commits_behind, remote_up_to_date) = if let Some(token) =
github_token
&& self.fetch_from_remote(&repo, &token).is_ok()
&& let Ok(remote_ref) =
repo.find_reference(&format!("refs/remotes/origin/{branch_name}"))
&& let Some(remote_oid) = remote_ref.target()
{
self.fetch_from_remote(&repo, &github_token)?;
let remote_oid = repo
.find_reference(&format!("refs/remotes/origin/{branch_name}"))?
.target()
.ok_or(GitServiceError::BranchNotFound(format!(
"origin/{branch_name}"
)))?;
let (a, b) = repo.graph_ahead_behind(branch_oid, remote_oid)?;
(Some(a), Some(b), Some(a == 0 && b == 0))
} else {
(None, None, None)
};
Ok((a, b))
}
// Calculate ahead/behind counts using the stored base branch
let (commits_ahead, commits_behind, up_to_date) = if let Ok(base_branch) =
repo.find_branch(base_branch_name, BranchType::Local)
&& let Some(base_oid) = base_branch.get().target()
{
let (a, b) = repo.graph_ahead_behind(branch_oid, base_oid)?;
(Some(a), Some(b), Some(a == 0 && b == 0))
} else {
// Base branch doesn't exist, assume no relationship
(None, None, None)
};
let mut status_opts = StatusOptions::new();
status_opts
.include_untracked(true)
.recurse_untracked_dirs(true)
.include_ignored(false);
let has_uncommitted_changes = repo
.statuses(Some(&mut status_opts))?
.iter()
.any(|e| e.status() != Status::CURRENT);
Ok(BranchStatus {
commits_behind,
commits_ahead,
up_to_date,
merged: is_merged,
has_uncommitted_changes,
base_branch_name: base_branch_name.to_string(),
remote_commits_behind,
remote_commits_ahead,
remote_up_to_date,
})
pub fn is_worktree_clean(&self, worktree_path: &Path) -> Result<bool, GitServiceError> {
let repo = self.open_repo(worktree_path)?;
match self.check_worktree_clean(&repo) {
Ok(()) => Ok(true),
Err(GitServiceError::WorktreeDirty(_, _)) => Ok(false),
Err(e) => Err(e),
}
}
/// Check if the worktree is clean (no uncommitted changes to tracked files)
@@ -648,7 +639,15 @@ impl GitService {
}
if !dirty_files.is_empty() {
return Err(GitServiceError::WorktreeDirty(dirty_files.join(", ")));
let branch_name = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown branch".to_string());
return Err(GitServiceError::WorktreeDirty(
branch_name,
dirty_files.join(", "),
));
}
}
@@ -1008,7 +1007,7 @@ impl GitService {
pub fn get_github_repo_info(
&self,
repo_path: &Path,
) -> Result<(String, String), GitServiceError> {
) -> Result<GitHubRepoInfo, GitServiceError> {
let repo = self.open_repo(repo_path)?;
let remote = repo.find_remote("origin").map_err(|_| {
GitServiceError::InvalidRepository("No 'origin' remote found".to_string())
@@ -1025,7 +1024,7 @@ impl GitService {
if let Some(captures) = github_regex.captures(url) {
let owner = captures.get(1).unwrap().as_str().to_string();
let repo_name = captures.get(2).unwrap().as_str().to_string();
Ok((owner, repo_name))
Ok(GitHubRepoInfo { owner, repo_name })
} else {
Err(GitServiceError::InvalidRepository(format!(
"Not a GitHub repository: {url}"
@@ -1041,6 +1040,7 @@ impl GitService {
github_token: &str,
) -> Result<(), GitServiceError> {
let repo = Repository::open(worktree_path)?;
self.check_worktree_clean(&repo)?;
// Get the remote
let remote = repo.find_remote("origin")?;

View File

@@ -1,6 +1,7 @@
use std::time::Duration;
use backon::{ExponentialBuilder, Retryable};
use db::models::merge::{MergeStatus, PullRequestInfo};
use octocrab::{Octocrab, OctocrabBuilder};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -97,6 +98,19 @@ pub struct GitHubRepoInfo {
pub owner: String,
pub repo_name: String,
}
impl GitHubRepoInfo {
pub fn from_pr_url(pr_url: &str) -> Result<Self, sqlx::Error> {
let re = regex::Regex::new(r"github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)").unwrap();
let caps = re
.captures(pr_url)
.ok_or_else(|| sqlx::Error::ColumnNotFound("Invalid URL format".into()))?;
let owner = caps.name("owner").unwrap().as_str().to_string();
let repo_name = caps.name("repo").unwrap().as_str().to_string();
Ok(Self { owner, repo_name })
}
}
#[derive(Debug, Clone)]
pub struct CreatePrRequest {
@@ -106,16 +120,6 @@ pub struct CreatePrRequest {
pub base_branch: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequestInfo {
pub number: i64,
pub url: String,
pub status: String,
pub merged: bool,
pub merged_at: Option<chrono::DateTime<chrono::Utc>>,
pub merge_commit_sha: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct RepositoryInfo {
pub id: i64,
@@ -163,7 +167,10 @@ impl GitHubService {
.with_max_times(3)
.with_jitter(),
)
.when(|e| !matches!(e, GitHubServiceError::TokenInvalid))
.when(|e| {
!matches!(e, GitHubServiceError::TokenInvalid)
&& !matches!(e, GitHubServiceError::Branch(_))
})
.notify(|err: &GitHubServiceError, dur: Duration| {
tracing::warn!(
"GitHub API call failed, retrying after {:.2}s: {}",
@@ -255,8 +262,7 @@ impl GitHubService {
let pr_info = PullRequestInfo {
number: pr.number as i64,
url: pr.html_url.map(|url| url.to_string()).unwrap_or_default(),
status: "open".to_string(),
merged: false,
status: MergeStatus::Open,
merged_at: None,
merge_commit_sha: None,
};
@@ -309,23 +315,22 @@ impl GitHubService {
})?;
let status = match pr.state {
Some(octocrab::models::IssueState::Open) => "open",
Some(octocrab::models::IssueState::Open) => MergeStatus::Open,
Some(octocrab::models::IssueState::Closed) => {
if pr.merged_at.is_some() {
"merged"
MergeStatus::Merged
} else {
"closed"
MergeStatus::Closed
}
}
None => "unknown",
Some(_) => "unknown", // Handle any other states
None => MergeStatus::Unknown,
Some(_) => MergeStatus::Unknown,
};
let pr_info = PullRequestInfo {
number: pr.number as i64,
url: pr.html_url.map(|url| url.to_string()).unwrap_or_default(),
status: status.to_string(),
merged: pr.merged_at.is_some(),
status,
merged_at: pr.merged_at.map(|dt| dt.naive_utc().and_utc()),
merge_commit_sha: pr.merge_commit_sha.clone(),
};

View File

@@ -3,8 +3,9 @@ use std::{sync::Arc, time::Duration};
use db::{
DBService,
models::{
merge::{Merge, MergeStatus, PrMerge},
task::{Task, TaskStatus},
task_attempt::{PrInfo, TaskAttempt, TaskAttemptError},
task_attempt::{TaskAttempt, TaskAttemptError},
},
};
use sqlx::error::Error as SqlxError;
@@ -66,7 +67,7 @@ impl PrMonitorService {
/// Check all open PRs for updates with the provided GitHub token
async fn check_all_open_prs(&self) -> Result<(), PrMonitorError> {
let open_prs = TaskAttempt::get_open_prs(&self.db.pool).await?;
let open_prs = Merge::get_open_prs(&self.db.pool).await?;
if open_prs.is_empty() {
debug!("No open PRs to check");
@@ -75,65 +76,56 @@ impl PrMonitorService {
info!("Checking {} open PRs", open_prs.len());
for pr_info in open_prs {
if let Err(e) = self.check_pr_status(&pr_info).await {
for pr_merge in open_prs {
if let Err(e) = self.check_pr_status(&pr_merge).await {
error!(
"Error checking PR #{} for attempt {}: {}",
pr_info.pr_number, pr_info.attempt_id, e
pr_merge.pr_info.number, pr_merge.task_attempt_id, e
);
}
}
Ok(())
}
/// Check the status of a specific PR
async fn check_pr_status(&self, pr_info: &PrInfo) -> Result<(), PrMonitorError> {
async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> {
let github_config = self.config.read().await.github.clone();
let github_token = github_config.token().ok_or(PrMonitorError::NoGitHubToken)?;
let github_service = GitHubService::new(&github_token)?;
let repo_info = GitHubRepoInfo {
owner: pr_info.repo_owner.clone(),
repo_name: pr_info.repo_name.clone(),
};
let repo_info = GitHubRepoInfo::from_pr_url(&pr_merge.pr_info.url)?;
let pr_status = github_service
.update_pr_status(&repo_info, pr_info.pr_number)
.update_pr_status(&repo_info, pr_merge.pr_info.number)
.await?;
debug!(
"PR #{} status: {} (was open)",
pr_info.pr_number, pr_status.status
"PR #{} status: {:?} (was open)",
pr_merge.pr_info.number, pr_status.status
);
// Update the PR status in the database
if pr_status.status != "open" {
// Extract merge commit SHA if the PR was merged
TaskAttempt::update_pr_status(
if !matches!(&pr_status.status, MergeStatus::Open) {
// Update merge status with the latest information from GitHub
Merge::update_status(
&self.db.pool,
pr_info.attempt_id,
pr_status.url,
pr_status.number,
pr_status.status,
pr_merge.id,
pr_status.status.clone(),
pr_status.merge_commit_sha,
)
.await?;
// If the PR was merged, update the task status to done
if pr_status.merged {
if matches!(&pr_status.status, MergeStatus::Merged)
&& let Some(task_attempt) =
TaskAttempt::find_by_id(&self.db.pool, pr_merge.task_attempt_id).await?
{
info!(
"PR #{} was merged, updating task {} to done",
pr_info.pr_number, pr_info.task_id
pr_merge.pr_info.number, task_attempt.task_id
);
let merge_commit_sha = pr_status.merge_commit_sha.as_deref().unwrap_or("unknown");
Task::update_status(&self.db.pool, pr_info.task_id, TaskStatus::Done).await?;
TaskAttempt::update_merge_commit(
&self.db.pool,
pr_info.attempt_id,
merge_commit_sha,
)
.await?;
Task::update_status(&self.db.pool, task_attempt.task_id, TaskStatus::Done).await?;
}
}

View File

@@ -46,7 +46,8 @@
"react-window": "^1.8.11",
"rfc6902": "^5.1.2",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.2.43",
@@ -8035,6 +8036,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -13,6 +13,7 @@ import type {
EditorType,
TaskAttempt,
TaskWithAttemptStatus,
BranchStatus,
} from 'shared/types';
import { attemptsApi, executionProcessesApi } from '@/lib/api.ts';
import {
@@ -52,6 +53,7 @@ const TaskDetailsProvider: FC<{
processes: [],
runningProcessDetails: {},
});
const [branchStatus, setBranchStatus] = useState<BranchStatus | null>(null);
const handleOpenInEditor = useCallback(
async (editorType?: EditorType) => {
@@ -111,6 +113,15 @@ const TaskDetailsProvider: FC<{
return newData;
});
}
// Also fetch branch status as part of attempt data
try {
const branchResult = await attemptsApi.getBranchStatus(attemptId);
setBranchStatus(branchResult);
} catch (err) {
console.error('Failed to fetch branch status:', err);
setBranchStatus(null);
}
} catch (err) {
console.error('Failed to fetch attempt data:', err);
}
@@ -165,8 +176,6 @@ const TaskDetailsProvider: FC<{
}, [attemptData.processes, selectedAttempt?.profile, profiles]);
useEffect(() => {
if (!isAttemptRunning || !task) return;
const interval = setInterval(() => {
if (selectedAttempt) {
fetchAttemptData(selectedAttempt.id);
@@ -176,6 +185,26 @@ const TaskDetailsProvider: FC<{
return () => clearInterval(interval);
}, [isAttemptRunning, task, selectedAttempt, fetchAttemptData]);
// Fetch branch status when selected attempt changes
useEffect(() => {
if (!selectedAttempt) {
setBranchStatus(null);
return;
}
const fetchBranchStatus = async () => {
try {
const result = await attemptsApi.getBranchStatus(selectedAttempt.id);
setBranchStatus(result);
} catch (err) {
console.error('Failed to fetch branch status:', err);
setBranchStatus(null);
}
};
fetchBranchStatus();
}, [selectedAttempt]);
const value = useMemo(
() => ({
task,
@@ -218,8 +247,16 @@ const TaskDetailsProvider: FC<{
fetchAttemptData,
isAttemptRunning,
defaultFollowUpVariant,
branchStatus,
setBranchStatus,
}),
[attemptData, fetchAttemptData, isAttemptRunning, defaultFollowUpVariant]
[
attemptData,
fetchAttemptData,
isAttemptRunning,
defaultFollowUpVariant,
branchStatus,
]
);
return (

View File

@@ -3,6 +3,7 @@ import type {
EditorType,
TaskAttempt,
TaskWithAttemptStatus,
BranchStatus,
} from 'shared/types';
import { AttemptData } from '@/lib/types.ts';
@@ -33,6 +34,8 @@ interface TaskAttemptDataContextValue {
fetchAttemptData: (attemptId: string) => Promise<void> | void;
isAttemptRunning: boolean;
defaultFollowUpVariant: string | null;
branchStatus: BranchStatus | null;
setBranchStatus: Dispatch<SetStateAction<BranchStatus | null>>;
}
export const TaskAttemptDataContext =

View File

@@ -37,6 +37,7 @@ export function TaskFollowUpSection() {
fetchAttemptData,
isAttemptRunning,
defaultFollowUpVariant,
branchStatus,
} = useContext(TaskAttemptDataContext);
const { profiles } = useUserSystem();
@@ -66,12 +67,24 @@ export function TaskFollowUpSection() {
) {
return false;
}
// Check if PR is merged - if so, block follow-ups
if (branchStatus?.merges) {
const mergedPR = branchStatus.merges.find(
(m) => m.type === 'pr' && m.pr_info.status === 'merged'
);
if (mergedPR) {
return false;
}
}
return true;
}, [
selectedAttempt,
attemptData.processes,
isAttemptRunning,
isSendingFollowUp,
branchStatus?.merges,
]);
const currentProfile = useMemo(() => {
if (!selectedProfile || !profiles) return null;

View File

@@ -19,6 +19,7 @@ import {
} from '@/components/ui/select';
import { useCallback, useContext, useEffect, useState } from 'react';
import {
TaskAttemptDataContext,
TaskDetailsContext,
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
@@ -46,6 +47,7 @@ function CreatePrDialog({
}: Props) {
const { projectId, task } = useContext(TaskDetailsContext);
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
const { fetchAttemptData } = useContext(TaskAttemptDataContext);
const [prTitle, setPrTitle] = useState('');
const [prBody, setPrBody] = useState('');
const [prBaseBranch, setPrBaseBranch] = useState(
@@ -82,12 +84,14 @@ function CreatePrDialog({
});
if (result.success) {
setError(null); // Clear any previous errors on success
window.open(result.data, '_blank');
setShowCreatePRDialog(false);
// Reset form
setPrTitle('');
setPrBody('');
setPrBaseBranch(selectedAttempt?.base_branch || 'main');
// Refresh branch status to show the new PR
fetchAttemptData(selectedAttempt.id);
} else {
if (result.error) {
setShowCreatePRDialog(false);
@@ -112,7 +116,7 @@ function CreatePrDialog({
setError('Failed to create GitHub PR');
}
}
setShowCreatePRDialog(false);
setCreatingPR(false);
}, [
projectId,

View File

@@ -3,7 +3,6 @@ import {
GitBranch as GitBranchIcon,
GitPullRequest,
History,
Upload,
Play,
Plus,
RefreshCw,
@@ -44,7 +43,7 @@ import {
useState,
} from 'react';
import type { ExecutionProcess } from 'shared/types';
import type { BranchStatus, GitBranch, TaskAttempt } from 'shared/types';
import type { GitBranch, TaskAttempt } from 'shared/types';
import {
TaskAttemptDataContext,
TaskAttemptStoppingContext,
@@ -103,9 +102,8 @@ function CurrentAttempt({
useContext(TaskDetailsContext);
const { config } = useConfig();
const { isStopping, setIsStopping } = useContext(TaskAttemptStoppingContext);
const { attemptData, fetchAttemptData, isAttemptRunning } = useContext(
TaskAttemptDataContext
);
const { attemptData, fetchAttemptData, isAttemptRunning, branchStatus } =
useContext(TaskAttemptDataContext);
const { jumpToProcess } = useProcessSelection();
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
@@ -115,12 +113,12 @@ function CurrentAttempt({
const [devServerDetails, setDevServerDetails] =
useState<ExecutionProcess | null>(null);
const [isHoveringDevServer, setIsHoveringDevServer] = useState(false);
const [branchStatus, setBranchStatus] = useState<BranchStatus | null>(null);
const [branchStatusLoading, setBranchStatusLoading] = useState(false);
const [showRebaseDialog, setShowRebaseDialog] = useState(false);
const [selectedRebaseBranch, setSelectedRebaseBranch] = useState<string>('');
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const [copied, setCopied] = useState(false);
const [mergeSuccess, setMergeSuccess] = useState(false);
const [pushSuccess, setPushSuccess] = useState(false);
const processedDevServerLogs = useMemo(() => {
if (!devServerDetails) return 'No output yet...';
@@ -263,7 +261,10 @@ function CurrentAttempt({
try {
setPushing(true);
await attemptsApi.push(selectedAttempt.id);
fetchBranchStatus();
setError(null); // Clear any previous errors on success
setPushSuccess(true);
setTimeout(() => setPushSuccess(false), 2000);
fetchAttemptData(selectedAttempt.id);
} catch (error: any) {
console.error('Failed to push changes:', error);
setError(error.message || 'Failed to push changes');
@@ -272,38 +273,16 @@ function CurrentAttempt({
}
};
const fetchBranchStatus = useCallback(async () => {
if (!selectedAttempt?.id) return;
try {
setBranchStatusLoading(true);
const result = await attemptsApi.getBranchStatus(selectedAttempt.id);
setBranchStatus((prev) => {
if (JSON.stringify(prev) === JSON.stringify(result)) return prev;
return result;
});
} catch (err) {
setError('Failed to load branch status');
} finally {
setBranchStatusLoading(false);
}
}, [projectId, selectedAttempt?.id, selectedAttempt?.task_id, setError]);
// Fetch branch status when selected attempt changes
useEffect(() => {
if (selectedAttempt) {
fetchBranchStatus();
}
}, [selectedAttempt, fetchBranchStatus]);
const performMerge = async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
try {
setMerging(true);
await attemptsApi.merge(selectedAttempt.id);
// Refetch branch status to show updated state
fetchBranchStatus();
setError(null); // Clear any previous errors on success
setMergeSuccess(true);
setTimeout(() => setMergeSuccess(false), 2000);
fetchAttemptData(selectedAttempt.id);
} catch (error) {
console.error('Failed to merge changes:', error);
// @ts-expect-error it is type ApiError
@@ -319,8 +298,8 @@ function CurrentAttempt({
try {
setRebasing(true);
await attemptsApi.rebase(selectedAttempt.id, { new_base_branch: null });
// Refresh branch status after rebase
fetchBranchStatus();
setError(null); // Clear any previous errors on success
fetchAttemptData(selectedAttempt.id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rebase branch');
} finally {
@@ -336,8 +315,8 @@ function CurrentAttempt({
await attemptsApi.rebase(selectedAttempt.id, {
new_base_branch: newBaseBranch,
});
// Refresh branch status after rebase
fetchBranchStatus();
setError(null); // Clear any previous errors on success
fetchAttemptData(selectedAttempt.id);
setShowRebaseDialog(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rebase branch');
@@ -360,9 +339,9 @@ function CurrentAttempt({
const handlePRButtonClick = async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
// If PR already exists, view it in a new tab
if (selectedAttempt.pr_url) {
window.open(selectedAttempt.pr_url, '_blank');
// If PR already exists, push to it
if (mergeInfo.hasOpenPR) {
await handlePushClick();
return;
}
@@ -387,6 +366,42 @@ function CurrentAttempt({
return getEditorDisplayName(config.editor.editor_type);
}, [config?.editor?.editor_type]);
// Memoize merge status information to avoid repeated calculations
const mergeInfo = useMemo(() => {
if (!branchStatus?.merges)
return {
hasOpenPR: false,
openPR: null,
hasMergedPR: false,
mergedPR: null,
hasMerged: false,
latestMerge: null,
};
const openPR = branchStatus.merges.find(
(m) => m.type === 'pr' && m.pr_info.status === 'open'
);
const mergedPR = branchStatus.merges.find(
(m) => m.type === 'pr' && m.pr_info.status === 'merged'
);
const merges = branchStatus.merges.filter(
(m) =>
m.type === 'direct' ||
(m.type === 'pr' && m.pr_info.status === 'merged')
);
return {
hasOpenPR: !!openPR,
openPR,
hasMergedPR: !!mergedPR,
mergedPR,
hasMerged: merges.length > 0,
latestMerge: branchStatus.merges[0] || null, // Most recent merge
};
}, [branchStatus?.merges]);
const handleCopyWorktreePath = useCallback(async () => {
try {
await navigator.clipboard.writeText(selectedAttempt.container_ref || '');
@@ -397,6 +412,71 @@ function CurrentAttempt({
}
}, [selectedAttempt.container_ref]);
// Get status information for display
const getStatusInfo = useCallback(() => {
if (mergeInfo.hasMergedPR && mergeInfo.mergedPR?.type === 'pr') {
const prMerge = mergeInfo.mergedPR;
return {
dotColor: 'bg-green-500',
textColor: 'text-green-700',
text: `PR #${prMerge.pr_info.number} merged`,
isClickable: true,
onClick: () => window.open(prMerge.pr_info.url, '_blank'),
};
}
if (
mergeInfo.hasMerged &&
mergeInfo.latestMerge?.type === 'direct' &&
(branchStatus?.commits_ahead ?? 0) === 0
) {
return {
dotColor: 'bg-green-500',
textColor: 'text-green-700',
text: `Merged`,
isClickable: false,
};
}
if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') {
const prMerge = mergeInfo.openPR;
return {
dotColor: 'bg-blue-500',
textColor: 'text-blue-700',
text: `PR #${prMerge.pr_info.number}`,
isClickable: true,
onClick: () => window.open(prMerge.pr_info.url, '_blank'),
};
}
if ((branchStatus?.commits_behind ?? 0) > 0) {
return {
dotColor: 'bg-orange-500',
textColor: 'text-orange-700',
text: `Rebase needed${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`,
isClickable: false,
};
}
if ((branchStatus?.commits_ahead ?? 0) > 0) {
return {
dotColor: 'bg-yellow-500',
textColor: 'text-yellow-700',
text:
branchStatus?.commits_ahead === 1
? `1 commit ahead${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`
: `${branchStatus?.commits_ahead} commits ahead${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`,
isClickable: false,
};
}
return {
dotColor: 'bg-gray-500',
textColor: 'text-gray-700',
text: `Up to date${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`,
isClickable: false,
};
}, [mergeInfo, branchStatus]);
return (
<div className="space-y-2">
<div className="flex gap-6 items-start">
@@ -429,9 +509,7 @@ function CurrentAttempt({
variant="ghost"
size="xs"
onClick={handleRebaseDialogOpen}
disabled={
rebasing || branchStatusLoading || isAttemptRunning
}
disabled={rebasing || isAttemptRunning}
className="h-4 w-4 p-0 hover:bg-muted"
>
<Settings className="h-3 w-3" />
@@ -456,25 +534,31 @@ function CurrentAttempt({
Status
</div>
<div className="flex items-center gap-1.5">
{selectedAttempt.merge_commit ? (
<div className="flex items-center gap-1.5 overflow-hidden">
<div className="h-2 w-2 bg-green-500 rounded-full" />
<span className="text-sm font-medium text-green-700 truncate">
Merged
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
({selectedAttempt.merge_commit.slice(0, 8)})
</span>
</div>
{(() => {
const statusInfo = getStatusInfo();
return (
<div className="flex items-center gap-1.5">
<div
className={`h-2 w-2 ${statusInfo.dotColor} rounded-full`}
/>
{statusInfo.isClickable ? (
<button
onClick={statusInfo.onClick}
className={`text-sm font-medium ${statusInfo.textColor} hover:underline cursor-pointer`}
>
{statusInfo.text}
</button>
) : (
<div className="flex items-center gap-1.5 overflow-hidden">
<div className="h-2 w-2 bg-yellow-500 rounded-full" />
<span className="text-sm font-medium text-yellow-700">
Not merged
<span
className={`text-sm font-medium ${statusInfo.textColor}`}
>
{statusInfo.text}
</span>
</div>
)}
</div>
);
})()}
</div>
</div>
</div>
@@ -494,7 +578,7 @@ function CurrentAttempt({
</Button>
</div>
<div
className={`text-xs font-mono px-2 py-1 rounded cursor-pointer transition-all duration-300 flex items-center gap-2 ${
className={`text-xs font-mono px-2 py-1 rounded break-all cursor-pointer transition-all duration-300 flex items-center gap-2 ${
copied
? 'bg-green-100 text-green-800 border border-green-300'
: 'text-muted-foreground bg-muted hover:bg-muted/80'
@@ -600,15 +684,12 @@ function CurrentAttempt({
<div className="flex items-center gap-2 flex-wrap">
{/* Git Operations */}
{selectedAttempt && branchStatus && (
{selectedAttempt && branchStatus && !mergeInfo.hasMergedPR && (
<>
{(branchStatus.commits_behind ?? 0) > 0 &&
!branchStatus.merged && (
{(branchStatus.commits_behind ?? 0) > 0 && (
<Button
onClick={handleRebaseClick}
disabled={
rebasing || branchStatusLoading || isAttemptRunning
}
disabled={rebasing || isAttemptRunning}
variant="outline"
size="xs"
className="border-orange-300 text-orange-700 hover:bg-orange-50 gap-1"
@@ -619,69 +700,57 @@ function CurrentAttempt({
{rebasing ? 'Rebasing...' : `Rebase`}
</Button>
)}
{
// Normal merge and PR buttons for regular tasks
!branchStatus.merged && (
<>
<Button
onClick={handlePRButtonClick}
disabled={
creatingPR ||
pushing ||
Boolean((branchStatus.commits_behind ?? 0) > 0) ||
isAttemptRunning
isAttemptRunning ||
(mergeInfo.hasOpenPR &&
branchStatus.remote_commits_ahead === 0) ||
((branchStatus.commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
variant="outline"
size="xs"
className="border-blue-300 text-blue-700 hover:bg-blue-50 gap-1"
className="border-blue-300 text-blue-700 hover:bg-blue-50 gap-1 min-w-[120px]"
>
<GitPullRequest className="h-3 w-3" />
{selectedAttempt.pr_url
? 'View PR'
{mergeInfo.hasOpenPR
? pushSuccess
? 'Pushed!'
: pushing
? 'Pushing...'
: branchStatus.remote_commits_ahead === 0
? 'Push to PR'
: branchStatus.remote_commits_ahead === 1
? 'Push 1 commit'
: `Push ${branchStatus.remote_commits_ahead || 0} commits`
: creatingPR
? 'Creating...'
: 'Create PR'}
</Button>
<Button
onClick={
selectedAttempt.pr_status === 'open'
? handlePushClick
: handleMergeClick
}
onClick={handleMergeClick}
disabled={
selectedAttempt.pr_status === 'open'
? pushing ||
isAttemptRunning ||
(branchStatus.remote_up_to_date ?? true)
: merging ||
mergeInfo.hasOpenPR ||
merging ||
Boolean((branchStatus.commits_behind ?? 0) > 0) ||
isAttemptRunning
isAttemptRunning ||
((branchStatus.commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
size="xs"
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 gap-1"
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 gap-1 min-w-[120px]"
>
{selectedAttempt.pr_status === 'open' ? (
<>
<Upload className="h-3 w-3" />
{pushing
? 'Pushing...'
: branchStatus.remote_commits_behind === null
? 'Disconnected'
: branchStatus.remote_commits_behind === 0
? 'Push to remote'
: branchStatus.remote_commits_behind === 1
? 'Push 1 commit'
: `Push ${branchStatus.remote_commits_behind} commits`}
</>
) : (
<>
<GitBranchIcon className="h-3 w-3" />
{merging ? 'Merging...' : 'Merge'}
</>
)}
{mergeSuccess ? 'Merged!' : merging ? 'Merging...' : 'Merge'}
</Button>
</>
)
}
</>
)}

View File

@@ -94,8 +94,6 @@ export enum CheckTokenResponse { VALID = "VALID", INVALID = "INVALID" }
export type GitBranch = { name: string, is_current: boolean, is_remote: boolean, last_commit_date: Date, };
export type BranchStatus = { commits_behind: number | null, commits_ahead: number | null, up_to_date: boolean | null, merged: boolean, has_uncommitted_changes: boolean, base_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, remote_up_to_date: boolean | null, };
export type Diff = { oldFile: FileDiffDetails | null, newFile: FileDiffDetails | null, hunks: Array<string>, };
export type FileDiffDetails = { fileName: string | null, content: string | null, };
@@ -160,7 +158,9 @@ export type CreateTaskAttemptBody = { task_id: string, profile_variant_label: Pr
export type RebaseTaskAttemptRequest = { new_base_branch: string | null, };
export type TaskAttempt = { id: string, task_id: string, container_ref: string | null, branch: string | null, base_branch: string, merge_commit: string | null, profile: string, pr_url: string | null, pr_number: bigint | null, pr_status: string | null, pr_merged_at: string | null, worktree_deleted: boolean, setup_completed_at: string | null, created_at: string, updated_at: string, };
export type BranchStatus = { commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, base_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array<Merge>, };
export type TaskAttempt = { id: string, task_id: string, container_ref: string | null, branch: string | null, base_branch: string, profile: string, worktree_deleted: boolean, setup_completed_at: string | null, created_at: string, updated_at: string, };
export type ExecutionProcess = { id: string, task_attempt_id: string, run_reason: ExecutionProcessRunReason, executor_action: ExecutorAction, status: ExecutionProcessStatus, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, };
@@ -168,6 +168,16 @@ export type ExecutionProcessStatus = "running" | "completed" | "failed" | "kille
export type ExecutionProcessRunReason = "setupscript" | "cleanupscript" | "codingagent" | "devserver";
export type Merge = { "type": "direct" } & DirectMerge | { "type": "pr" } & PrMerge;
export type DirectMerge = { id: string, task_attempt_id: string, merge_commit: string, target_branch_name: string, created_at: string, };
export type PrMerge = { id: string, task_attempt_id: string, created_at: string, target_branch_name: string, pr_info: PullRequestInfo, };
export type MergeStatus = "open" | "merged" | "closed" | "unknown";
export type PullRequestInfo = { number: bigint, url: string, status: MergeStatus, merged_at: string | null, merge_commit_sha: string | null, };
export type EventPatch = { op: string, path: string, value: EventPatchInner, };
export type EventPatchInner = { db_op: string, record: RecordTypes, };