Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
90 changes: 90 additions & 0 deletions internal/db/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
118 changes: 118 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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()
}
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down