From fd7be4daca3c4cdb4282a7d975ebb03b4b0b4f1a Mon Sep 17 00:00:00 2001 From: Nitay Rabinovich Date: Thu, 30 Apr 2026 13:21:22 +0200 Subject: [PATCH] Publish agent results as draft PRs --- README.md | 3 + internal/gh/gh.go | 155 +++++++++++++++++++++++++++++++++++++++- internal/sao/sao.go | 55 ++++++++++++-- internal/state/state.go | 4 ++ 4 files changed, 208 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 85855ba..8881d46 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ sao plan sao once ``` +When a dispatched agent produces file changes, `sao` creates an isolated git worktree under `~/.local/state/sao/worktrees/`, commits the diff on a task branch, pushes it, opens a draft pull request, and prints the PR URL. The PR URL, branch, worktree path, commit SHA, and agent summary are also stored in the local state file under `~/.local/state/sao/`. + 6. Or run the foreground loop: ```bash @@ -193,4 +195,5 @@ State: - This is currently an MVP. - Agent execution is routed through `acpx`. +- Completed tasks run in isolated git worktrees and are delivered as draft pull requests when they produce a git diff. - Supported agent runtimes today are `codex` and `claude`. diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 61193ab..72106a6 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "os/exec" "path/filepath" "sort" @@ -38,6 +39,20 @@ type Issue struct { Assignees []string } +type DeliveryResult struct { + Branch string + WorktreePath string + CommitSHA string + PullRequestURL string + HasChanges bool +} + +type TaskWorktree struct { + Name string + BaseBranch string + Path string +} + type issueJSON struct { Number int `json:"number"` Title string `json:"title"` @@ -130,6 +145,93 @@ func ListIssues(ctx context.Context, repo Repository, repoCfg config.RepoConfig) return all, nil } +func PrepareTaskWorktree(ctx context.Context, repo Repository, issue Issue) (TaskWorktree, error) { + repoPath := repo.LocalPath + baseBranch, err := gitOutput(ctx, repoPath, "branch", "--show-current") + if err != nil { + return TaskWorktree{}, fmt.Errorf("read current branch: %w", err) + } + baseBranch = strings.TrimSpace(baseBranch) + if baseBranch == "" { + return TaskWorktree{}, errors.New("cannot dispatch from detached HEAD") + } + + stateDir, err := config.MachineStateDir() + if err != nil { + return TaskWorktree{}, err + } + + suffix := time.Now().UTC().Format("20060102-150405") + branch := fmt.Sprintf("sao/issue-%d-%s", issue.Number, suffix) + worktreePath := filepath.Join(stateDir, "worktrees", sanitizePathComponent(repo.Slug), fmt.Sprintf("issue-%d-%s", issue.Number, suffix)) + if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { + return TaskWorktree{}, fmt.Errorf("create worktree parent: %w", err) + } + + if _, err := gitOutput(ctx, repoPath, "worktree", "add", "-b", branch, worktreePath, "HEAD"); err != nil { + return TaskWorktree{}, fmt.Errorf("create task worktree %s: %w", worktreePath, err) + } + return TaskWorktree{Name: branch, BaseBranch: baseBranch, Path: worktreePath}, nil +} + +func PublishTaskChanges(ctx context.Context, repo Repository, issue Issue, worktree TaskWorktree, agentSummary string) (DeliveryResult, error) { + clean, err := WorkingTreeClean(ctx, worktree.Path) + if err != nil { + return DeliveryResult{}, err + } + + result := DeliveryResult{ + Branch: worktree.Name, + WorktreePath: worktree.Path, + HasChanges: !clean, + } + if clean { + return result, nil + } + + if _, err := gitOutput(ctx, worktree.Path, "add", "-A"); err != nil { + return DeliveryResult{}, fmt.Errorf("stage changes: %w", err) + } + + commitTitle := fmt.Sprintf("Fix #%d: %s", issue.Number, issue.Title) + commitBody := strings.TrimSpace(fmt.Sprintf("Implemented by sao.\n\nIssue: %s\n\nAgent summary:\n%s", issue.URL, strings.TrimSpace(agentSummary))) + if _, err := gitOutput(ctx, worktree.Path, "commit", "-m", commitTitle, "-m", commitBody); err != nil { + return DeliveryResult{}, fmt.Errorf("commit task changes: %w", err) + } + + commitSHA, err := gitOutput(ctx, worktree.Path, "rev-parse", "--short", "HEAD") + if err != nil { + return DeliveryResult{}, fmt.Errorf("read commit sha: %w", err) + } + result.CommitSHA = strings.TrimSpace(commitSHA) + + if _, err := gitOutput(ctx, worktree.Path, "push", "-u", "origin", worktree.Name); err != nil { + return DeliveryResult{}, fmt.Errorf("push task branch: %w", err) + } + + prURL, err := createDraftPR(ctx, repo, issue, worktree.Name, agentSummary) + if err != nil { + return DeliveryResult{}, err + } + result.PullRequestURL = prURL + return result, nil +} + +func DeliveryForWorktree(worktree TaskWorktree) DeliveryResult { + return DeliveryResult{ + Branch: worktree.Name, + WorktreePath: worktree.Path, + } +} + +func WorkingTreeClean(ctx context.Context, repoPath string) (bool, error) { + out, err := gitOutput(ctx, repoPath, "status", "--porcelain") + if err != nil { + return false, fmt.Errorf("check working tree: %w", err) + } + return strings.TrimSpace(out) == "", nil +} + func gitOutput(ctx context.Context, repoPath string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repoPath}, args...)...) var stderr bytes.Buffer @@ -145,7 +247,56 @@ func gitOutput(ctx context.Context, repoPath string, args ...string) (string, er return string(out), nil } +func createDraftPR(ctx context.Context, repo Repository, issue Issue, branch, agentSummary string) (string, error) { + title := fmt.Sprintf("Fix #%d: %s", issue.Number, issue.Title) + body := strings.TrimSpace(fmt.Sprintf("Closes %s\n\nAgent summary:\n%s", issue.URL, strings.TrimSpace(agentSummary))) + if body == "" { + body = fmt.Sprintf("Closes %s", issue.URL) + } + out, err := ghOutput( + ctx, + repo.LocalPath, + "pr", "create", + "--repo", repo.Slug, + "--draft", + "--head", branch, + "--title", title, + "--body", body, + ) + if err != nil { + return "", fmt.Errorf("create draft pull request: %w", err) + } + return strings.TrimSpace(out), nil +} + +func sanitizePathComponent(value string) string { + var b strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} + func ghJSON(ctx context.Context, repoPath string, args ...string) ([]byte, error) { + out, err := ghOutput(ctx, repoPath, args...) + if err != nil { + return nil, err + } + return []byte(out), nil +} + +func ghOutput(ctx context.Context, repoPath string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "gh", args...) if repoPath != "" { cmd.Dir = repoPath @@ -158,9 +309,9 @@ func ghJSON(ctx context.Context, repoPath string, args ...string) ([]byte, error if msg == "" { msg = err.Error() } - return nil, errors.New(msg) + return "", errors.New(msg) } - return out, nil + return string(out), nil } func mapIssue(item issueJSON) (Issue, error) { diff --git a/internal/sao/sao.go b/internal/sao/sao.go index bf283a1..d83cfbc 100644 --- a/internal/sao/sao.go +++ b/internal/sao/sao.go @@ -507,12 +507,35 @@ func runCycle(ctx context.Context, cfg config.MachineConfig, stdout, stderr io.W wg.Add(1) go func(plan dispatchPlan) { defer wg.Done() - prompt := buildPrompt(plan.Candidate) + worktree, err := gh.PrepareTaskWorktree(ctx, plan.Candidate.Repo, plan.Candidate.Issue) + if err != nil { + outcomes <- dispatchOutcome{ + Plan: plan, + Err: fmt.Errorf("prepare task worktree: %w", err), + CompletedAt: time.Now().UTC(), + } + return + } + + prompt := buildPrompt(plan.Candidate, worktree.Path) runner := acpx.NewRunner(plan.Agent.Command) - response, err := runner.Exec(ctx, plan.Candidate.ProjectPath, plan.RuntimeName, prompt) + response, err := runner.Exec(ctx, worktree.Path, plan.RuntimeName, prompt) + if err != nil { + outcomes <- dispatchOutcome{ + Plan: plan, + Response: response, + Delivery: gh.DeliveryForWorktree(worktree), + Err: err, + CompletedAt: time.Now().UTC(), + } + return + } + + delivery, err := gh.PublishTaskChanges(ctx, plan.Candidate.Repo, plan.Candidate.Issue, worktree, response.AssistantText) outcomes <- dispatchOutcome{ Plan: plan, Response: response, + Delivery: delivery, Err: err, CompletedAt: time.Now().UTC(), } @@ -526,7 +549,7 @@ func runCycle(ctx context.Context, cfg config.MachineConfig, stdout, stderr io.W var errs []error for outcome := range outcomes { if outcome.Err != nil { - markTaskFailure(store, outcome.Plan.Candidate.Issue.URL, outcome.Plan.Candidate.Issue.UpdatedAt, outcome.CompletedAt, outcome.Err) + markTaskFailure(store, outcome.Plan.Candidate.Issue.URL, outcome.Plan.Candidate.Issue.UpdatedAt, outcome.CompletedAt, outcome.Err, outcome.Delivery) fmt.Fprintf( stderr, "failed %s #%d with %s: %v\n", @@ -547,6 +570,10 @@ func runCycle(ctx context.Context, cfg config.MachineConfig, stdout, stderr io.W UpdatedAt: outcome.CompletedAt, CompletedAt: outcome.CompletedAt, LastResponse: outcome.Response.AssistantText, + Branch: outcome.Delivery.Branch, + WorktreePath: outcome.Delivery.WorktreePath, + CommitSHA: outcome.Delivery.CommitSHA, + PullRequestURL: outcome.Delivery.PullRequestURL, } if outcome.Response.AssistantText != "" { fmt.Fprintf(stdout, "completed %s #%d\n", outcome.Plan.Candidate.Repo.Slug, outcome.Plan.Candidate.Issue.Number) @@ -554,6 +581,11 @@ func runCycle(ctx context.Context, cfg config.MachineConfig, stdout, stderr io.W } else { fmt.Fprintf(stdout, "completed %s #%d with no summary returned\n", outcome.Plan.Candidate.Repo.Slug, outcome.Plan.Candidate.Issue.Number) } + if outcome.Delivery.PullRequestURL != "" { + fmt.Fprintf(stdout, "pull request: %s\n", outcome.Delivery.PullRequestURL) + } else if outcome.Delivery.Branch != "" && !outcome.Delivery.HasChanges { + fmt.Fprintf(stdout, "no changes to publish for %s #%d\n", outcome.Plan.Candidate.Repo.Slug, outcome.Plan.Candidate.Issue.Number) + } } if err := state.Save(store); err != nil { @@ -576,6 +608,7 @@ type dispatchPlan struct { type dispatchOutcome struct { Plan dispatchPlan Response acpx.Result + Delivery gh.DeliveryResult Err error CompletedAt time.Time } @@ -702,14 +735,15 @@ func runtimeAgentName(agent config.InstalledAgent) (string, bool) { return acpx.ResolveAgentName(agent.Name) } -func buildPrompt(candidate planner.Candidate) string { +func buildPrompt(candidate planner.Candidate, worktreePath string) string { body := strings.TrimSpace(candidate.Issue.Body) if len(body) > 4000 { body = body[:4000] + "\n...[truncated]" } return strings.TrimSpace(fmt.Sprintf(` -You are working inside the repository at: %s +You are working inside a git worktree at: %s +Original watched repository path: %s GitHub issue: - Repository: %s @@ -726,17 +760,24 @@ Please: 3. run any relevant local validation if available 4. summarize the changes you made +Do not create a branch, commit, push, or open a pull request. The orchestrator will publish your file changes after you finish. Do not ask for orchestration help. Work directly in the repository. -`, candidate.ProjectPath, candidate.Repo.Slug, candidate.Issue.Number, candidate.Issue.URL, candidate.Issue.Title, body)) +`, worktreePath, candidate.ProjectPath, candidate.Repo.Slug, candidate.Issue.Number, candidate.Issue.URL, candidate.Issue.Title, body)) } -func markTaskFailure(store state.Store, issueURL string, issueUpdatedAt, completedAt time.Time, err error) { +func markTaskFailure(store state.Store, issueURL string, issueUpdatedAt, completedAt time.Time, err error, delivery gh.DeliveryResult) { record := store.Tasks[issueURL] record.Status = "failed" record.IssueUpdatedAt = issueUpdatedAt record.UpdatedAt = completedAt record.CompletedAt = completedAt record.LastError = err.Error() + if delivery.Branch != "" { + record.Branch = delivery.Branch + } + if delivery.WorktreePath != "" { + record.WorktreePath = delivery.WorktreePath + } store.Tasks[issueURL] = record } diff --git a/internal/state/state.go b/internal/state/state.go index e56264a..5664bff 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -28,6 +28,10 @@ type TaskRecord struct { CompletedAt time.Time `json:"completed_at,omitempty"` LastError string `json:"last_error,omitempty"` LastResponse string `json:"last_response,omitempty"` + Branch string `json:"branch,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` + PullRequestURL string `json:"pull_request_url,omitempty"` } func Load() (Store, error) {