From 69a26cdd6cacfd83facf4e51ba2add0e2138626c Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 9 Jan 2026 11:58:09 -0500 Subject: [PATCH] Auto-close tasks when PR branch is merged Add automatic detection of merged branches to close associated tasks: - Add GetTasksWithBranches() to find tasks with branches that aren't done - Add checkMergedBranches() that runs every 30s in executor worker loop - Add isBranchMerged() to detect if a task's branch has been merged - Detection methods: git branch --merged, remote branch deletion check, and local branch ancestor check Co-Authored-By: Claude Opus 4.5 --- internal/db/tasks.go | 33 +++++++++- internal/db/tasks_test.go | 90 ++++++++++++++++++++++++++ internal/executor/executor.go | 118 ++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 1 deletion(-) diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 149d3e55..d0bf1658 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -267,7 +267,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { created_at, updated_at, started_at, completed_at FROM tasks WHERE status = ? - ORDER BY + ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at ASC `, StatusQueued) @@ -291,6 +291,37 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { return tasks, nil } +// GetTasksWithBranches returns tasks that have a branch name and aren't done. +// These are candidates for automatic closure when their PR is merged. +func (db *DB) GetTasksWithBranches() ([]*Task, error) { + rows, err := db.Query(` + SELECT id, title, body, status, type, project, priority, + worktree_path, branch_name, + created_at, updated_at, started_at, completed_at + FROM tasks + WHERE branch_name != '' AND status != ? + ORDER BY created_at DESC + `, StatusDone) + if err != nil { + return nil, fmt.Errorf("query tasks with branches: %w", err) + } + defer rows.Close() + + var tasks []*Task + for rows.Next() { + t := &Task{} + if err := rows.Scan( + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Priority, + &t.WorktreePath, &t.BranchName, + &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, + ); err != nil { + return nil, fmt.Errorf("scan task: %w", err) + } + tasks = append(tasks, t) + } + return tasks, nil +} + // TaskLog represents a log entry for a task. type TaskLog struct { ID int64 diff --git a/internal/db/tasks_test.go b/internal/db/tasks_test.go index 238f2e2f..5f66518b 100644 --- a/internal/db/tasks_test.go +++ b/internal/db/tasks_test.go @@ -122,6 +122,96 @@ func TestTaskLogsWithQuestion(t *testing.T) { } } +func TestGetTasksWithBranches(t *testing.T) { + // Create temporary database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + defer os.Remove(dbPath) + + // Create tasks with various states + taskNoBranch := &Task{ + Title: "Task without branch", + Status: StatusBacklog, + Type: TypeCode, + Priority: "normal", + } + taskWithBranch := &Task{ + Title: "Task with branch", + Status: StatusBlocked, + Type: TypeCode, + Priority: "normal", + BranchName: "task/1-task-with-branch", + } + taskDoneWithBranch := &Task{ + Title: "Done task with branch", + Status: StatusDone, + Type: TypeCode, + Priority: "normal", + BranchName: "task/2-done-task", + } + taskProcessingWithBranch := &Task{ + Title: "Processing task with branch", + Status: StatusProcessing, + Type: TypeCode, + Priority: "normal", + BranchName: "task/3-processing-task", + } + + for _, task := range []*Task{taskNoBranch, taskWithBranch, taskDoneWithBranch, taskProcessingWithBranch} { + if err := db.CreateTask(task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + // Update branch name (CreateTask doesn't set it) + if task.BranchName != "" { + db.UpdateTask(task) + } + } + + // Get tasks with branches + tasks, err := db.GetTasksWithBranches() + if err != nil { + t.Fatalf("failed to get tasks with branches: %v", err) + } + + // Should return 2 tasks (with branch but not done) + if len(tasks) != 2 { + t.Errorf("expected 2 tasks, got %d", len(tasks)) + } + + // Verify we got the right tasks + foundBlocked := false + foundProcessing := false + for _, task := range tasks { + if task.BranchName == "task/1-task-with-branch" { + foundBlocked = true + } + if task.BranchName == "task/3-processing-task" { + foundProcessing = true + } + // Should never include done task + if task.Status == StatusDone { + t.Error("should not include done tasks") + } + // Should never include tasks without branches + if task.BranchName == "" { + t.Error("should not include tasks without branches") + } + } + + if !foundBlocked { + t.Error("should include blocked task with branch") + } + if !foundProcessing { + t.Error("should include processing task with branch") + } +} + func TestGetProjectByPath(t *testing.T) { // Create temporary database tmpDir := t.TempDir() diff --git a/internal/executor/executor.go b/internal/executor/executor.go index a6b863a3..d17009d8 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -253,6 +253,10 @@ func (e *Executor) worker(ctx context.Context) { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() + // Check merged branches every 30 seconds (15 ticks) + tickCount := 0 + const mergeCheckInterval = 15 + for { select { case <-ctx.Done(): @@ -261,6 +265,13 @@ func (e *Executor) worker(ctx context.Context) { return case <-ticker.C: e.processNextTask(ctx) + + // Periodically check for merged branches + tickCount++ + if tickCount >= mergeCheckInterval { + tickCount = 0 + e.checkMergedBranches() + } } } } @@ -1430,6 +1441,113 @@ func (e *Executor) getConversationHistory(taskID int64) string { return sb.String() } +// checkMergedBranches checks for tasks whose branches have been merged into the default branch. +// If a task's branch is merged, it automatically closes the task. +func (e *Executor) checkMergedBranches() { + // Get all tasks that have branches and aren't done + tasks, err := e.db.GetTasksWithBranches() + if err != nil { + e.logger.Debug("Failed to get tasks with branches", "error", err) + return + } + + for _, task := range tasks { + // Skip tasks currently being processed + e.mu.RLock() + isRunning := e.runningTasks[task.ID] + e.mu.RUnlock() + if isRunning { + continue + } + + // Check if the branch has been merged + if e.isBranchMerged(task) { + e.logger.Info("Branch merged, closing task", "id", task.ID, "branch", task.BranchName) + e.logLine(task.ID, "system", fmt.Sprintf("Branch %s has been merged - automatically closing task", task.BranchName)) + e.updateStatus(task.ID, db.StatusDone) + e.hooks.OnStatusChange(task, db.StatusDone, "PR merged") + } + } +} + +// isBranchMerged checks if a task's branch has been merged into the default branch. +func (e *Executor) isBranchMerged(task *db.Task) bool { + projectDir := e.getProjectDir(task.Project) + if projectDir == "" { + return false + } + + // Check if it's a git repo + gitDir := filepath.Join(projectDir, ".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + return false + } + + // Get the default branch + defaultBranch := e.getDefaultBranch(projectDir) + + // Fetch from remote to get latest state (ignore errors - might be offline) + fetchCmd := exec.Command("git", "fetch", "--quiet") + fetchCmd.Dir = projectDir + fetchCmd.Run() + + // Check if the branch has been merged into the default branch + // Use git branch --merged to see which branches have been merged + cmd := exec.Command("git", "branch", "-r", "--merged", defaultBranch) + cmd.Dir = projectDir + output, err := cmd.Output() + if err != nil { + return false + } + + // Look for our branch in the merged list + // Branches appear as " origin/branch-name" or "* origin/branch-name" + mergedBranches := strings.Split(string(output), "\n") + for _, branch := range mergedBranches { + branch = strings.TrimSpace(branch) + branch = strings.TrimPrefix(branch, "* ") + + // Check both with and without origin/ prefix + branchName := task.BranchName + if strings.Contains(branch, branchName) || + strings.HasSuffix(branch, "/"+branchName) || + branch == "origin/"+branchName { + return true + } + } + + // Also check if the branch no longer exists on remote (was deleted after merge) + // This is common when PRs are merged and branches are auto-deleted + lsRemoteCmd := exec.Command("git", "ls-remote", "--heads", "origin", task.BranchName) + lsRemoteCmd.Dir = projectDir + lsOutput, err := lsRemoteCmd.Output() + if err == nil && len(strings.TrimSpace(string(lsOutput))) == 0 { + // Branch doesn't exist on remote - check if it ever had commits + // that are now part of the default branch + logCmd := exec.Command("git", "log", "--oneline", "-1", "origin/"+defaultBranch, "--grep="+task.BranchName) + logCmd.Dir = projectDir + logOutput, err := logCmd.Output() + if err == nil && len(strings.TrimSpace(string(logOutput))) > 0 { + return true + } + + // Alternative: check if local branch exists and its tip is reachable from default + localLogCmd := exec.Command("git", "branch", "--list", task.BranchName) + localLogCmd.Dir = projectDir + localOutput, _ := localLogCmd.Output() + if len(strings.TrimSpace(string(localOutput))) > 0 { + // Local branch exists - check if its commits are in default branch + mergeBaseCmd := exec.Command("git", "merge-base", "--is-ancestor", task.BranchName, defaultBranch) + mergeBaseCmd.Dir = projectDir + if mergeBaseCmd.Run() == nil { + return true + } + } + } + + return false +} + // setupWorktree creates a git worktree for the task if the project is a git repo. // Returns the working directory to use (worktree path or project path). func (e *Executor) setupWorktree(task *db.Task) (string, error) {