picoclaw/pkg/tools/createpr.go
dj-oyu bc26810def refactor: remove unused workspace field from GitPushTool and CreatePRTool
Both tools get their working directory from worktree context, making the
workspace field redundant. Also replace custom contains helper with
strings.Contains in tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 01:46:22 +09:00

301 lines
9.2 KiB
Go

package tools
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
const (
ciPollInterval = 30 * time.Second
ciPollTimeout = 15 * time.Minute
)
// CreatePRTool creates a GitHub pull request from the current worktree branch.
//
// Safety invariants:
// - Only works inside a worktree (WorktreeInfo must be in context)
// - Base branch is auto-detected from WorktreeInfo.BaseBranch
// - Requires the branch to be already pushed (use git_push first)
// - Checks for merge conflicts with base before creating
// - Uses `gh pr create` under the hood
//
// Async behavior:
// - PR creation itself is synchronous and returns immediately with the PR URL
// - If CI runs are triggered, a background goroutine polls `gh pr checks`
// and calls the AsyncCallback when CI completes (pass or fail)
type CreatePRTool struct {
callback AsyncCallback
}
// NewCreatePRTool creates a CreatePRTool.
func NewCreatePRTool() *CreatePRTool {
return &CreatePRTool{}
}
func (t *CreatePRTool) Name() string { return "create_pr" }
// SetCallback implements AsyncTool for CI completion notification.
func (t *CreatePRTool) SetCallback(cb AsyncCallback) {
t.callback = cb
}
func (t *CreatePRTool) Description() string {
return "Create a GitHub pull request from the current worktree branch. " +
"The base branch is auto-detected from the worktree's parent branch. " +
"The branch must be pushed to origin first (use git_push). " +
"Checks for merge conflicts with the base branch before creating. " +
"After PR creation, polls CI status in the background and notifies when complete. " +
"Requires the `gh` CLI to be installed and authenticated."
}
func (t *CreatePRTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{
"type": "string",
"description": "Pull request title",
},
"body": map[string]any{
"type": "string",
"description": "Pull request body/description (supports markdown)",
},
"draft": map[string]any{
"type": "boolean",
"description": "Create as draft PR (default: false)",
},
},
"required": []string{"title"},
}
}
func (t *CreatePRTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
wt := WorktreeInfoFromCtx(ctx)
if wt == nil {
return ErrorResult(
"create_pr requires an active worktree.\n" +
"This tool can only be used during worktree-based sessions " +
"(e.g., heartbeat tasks or plan executing phase).\n" +
"The worktree provides the branch name and base branch for the PR.")
}
branch := wt.Branch
if branch == "" {
return ErrorResult(
"worktree has no branch name.\n" +
"The WorktreeInfo was set but Branch is empty. " +
"This is an internal error — the worktree may not have been created correctly.")
}
baseBranch := wt.BaseBranch
if baseBranch == "" {
baseBranch = "main"
}
title, ok := args["title"].(string)
if !ok || strings.TrimSpace(title) == "" {
return ErrorResult(
"title is required.\n" +
"Provide a concise PR title describing the change (e.g., \"Add rate limiter to API endpoints\").")
}
// Verify the branch has been pushed by checking if the remote ref exists
checkCtx, checkCancel := context.WithTimeout(ctx, 15*time.Second)
defer checkCancel()
checkCmd := exec.CommandContext(checkCtx, "git", "ls-remote", "--exit-code", "origin", branch)
checkCmd.Dir = wt.Path
if err := checkCmd.Run(); err != nil {
return ErrorResult(fmt.Sprintf(
"branch %q not found on origin.\n"+
"The branch must be pushed before creating a PR. Use the git_push tool first.\n"+
"git_push will auto-commit uncommitted changes and push the worktree branch to origin.",
branch))
}
// Fetch latest base branch and check for merge conflicts
fetchCtx, fetchCancel := context.WithTimeout(ctx, 30*time.Second)
defer fetchCancel()
fetchCmd := exec.CommandContext(fetchCtx, "git", "fetch", "origin", baseBranch)
fetchCmd.Dir = wt.Path
if out, err := fetchCmd.CombinedOutput(); err != nil {
return ErrorResult(fmt.Sprintf(
"failed to fetch origin/%s: %s\n%s\n"+
"Cannot verify merge compatibility without the latest base branch. "+
"Check network connectivity and that the base branch %q exists on origin.",
baseBranch, err, strings.TrimSpace(string(out)), baseBranch))
}
// Try a merge dry-run to detect conflicts.
// merge-tree --write-tree is a plumbing command (Git 2.38+) that performs a
// three-way merge entirely in-memory without touching the working tree.
// Exit code 0 = clean merge, non-zero = conflicts detected.
mergeCtx, mergeCancel := context.WithTimeout(ctx, 30*time.Second)
defer mergeCancel()
mergeCmd := exec.CommandContext(mergeCtx, "git", "merge-tree",
"--write-tree", "--no-messages",
branch, "origin/"+baseBranch)
mergeCmd.Dir = wt.RepoRoot
mergeOut, mergeErr := mergeCmd.CombinedOutput()
if mergeErr != nil {
conflictInfo := strings.TrimSpace(string(mergeOut))
return ErrorResult(fmt.Sprintf(
"merge conflict detected between %q and %s.\n"+
"The PR cannot be created cleanly. Resolve the conflicts in the worktree first, "+
"then use git_push to push the resolution before retrying create_pr.\n"+
"Conflict details:\n%s",
branch, baseBranch, conflictInfo))
}
// Build gh pr create command
ghArgs := []string{
"pr", "create",
"--base", baseBranch,
"--head", branch,
"--title", title,
}
if body, ok := args["body"].(string); ok && body != "" {
ghArgs = append(ghArgs, "--body", body)
} else {
ghArgs = append(ghArgs, "--body", "")
}
if draft, ok := args["draft"].(bool); ok && draft {
ghArgs = append(ghArgs, "--draft")
}
prCtx, prCancel := context.WithTimeout(ctx, 30*time.Second)
defer prCancel()
cmd := exec.CommandContext(prCtx, "gh", ghArgs...)
cmd.Dir = wt.RepoRoot
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
return ErrorResult(fmt.Sprintf(
"gh pr create failed: %s\n%s\n"+
"Possible causes:\n"+
"- gh CLI not installed or not authenticated (run `gh auth login`)\n"+
"- A PR already exists for branch %q (check with `gh pr list`)\n"+
"- Repository not configured as a GitHub remote",
err, output, branch))
}
prURL := output // gh pr create outputs the PR URL
// Start background CI polling if callback is set
if t.callback != nil && prURL != "" {
cb := t.callback
repoRoot := wt.RepoRoot
go pollCIStatus(repoRoot, prURL, cb)
}
return AsyncResult(fmt.Sprintf(
"Pull request created: %s\n"+
"Branch: %s -> %s\n"+
"CI status will be reported asynchronously when checks complete.",
prURL, branch, baseBranch))
}
// pollCIStatus polls `gh pr checks` in the background until all checks
// pass, fail, or the timeout is reached. Reports back via AsyncCallback.
func pollCIStatus(repoRoot, prURL string, callback AsyncCallback) {
// Detached context with hard timeout — this goroutine outlives the tool call.
ctx, cancel := context.WithTimeout(context.Background(), ciPollTimeout)
defer cancel()
// Initial wait: CI runs take a few seconds to register after PR creation
select {
case <-time.After(10 * time.Second):
case <-ctx.Done():
return
}
ticker := time.NewTicker(ciPollInterval)
defer ticker.Stop()
for {
status, detail := checkPRChecks(ctx, repoRoot, prURL)
switch status {
case ciStatusPass:
callback(ctx, NewToolResult(fmt.Sprintf(
"CI passed for %s\n%s",
prURL, detail)))
return
case ciStatusFail:
callback(ctx, &ToolResult{
ForLLM: fmt.Sprintf(
"CI failed for %s\n%s\n"+
"Run `gh run view` for detailed logs.",
prURL, detail),
IsError: true,
})
return
case ciStatusNone:
callback(ctx, NewToolResult(fmt.Sprintf(
"No CI checks configured for %s. PR is ready for review.",
prURL)))
return
case ciStatusPending:
// Still running, continue polling
}
select {
case <-ticker.C:
case <-ctx.Done():
callback(ctx, &ToolResult{
ForLLM: fmt.Sprintf(
"CI polling timed out after %s for %s.\n"+
"Checks may still be running. Run `gh pr checks %s` to check.",
ciPollTimeout, prURL, prURL),
IsError: true,
})
return
}
}
}
type ciStatus int
const (
ciStatusPending ciStatus = iota
ciStatusPass
ciStatusFail
ciStatusNone
)
// checkPRChecks runs `gh pr checks` and parses the result.
// Returns the aggregate status and raw output for the caller to include.
func checkPRChecks(ctx context.Context, repoRoot, prURL string) (ciStatus, string) {
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
cmd := exec.CommandContext(checkCtx, "gh", "pr", "checks", prURL)
cmd.Dir = repoRoot
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
// gh pr checks exits 1 when any check has failed
if strings.Contains(output, "fail") || strings.Contains(output, "X ") {
return ciStatusFail, output
}
// "no checks" case
if strings.Contains(output, "no checks") || output == "" {
return ciStatusNone, ""
}
// Transient error or still pending — keep polling
return ciStatusPending, output
}
// Exit 0: all checks completed. Check for pending.
if strings.Contains(output, "pending") || strings.Contains(output, "- ") {
return ciStatusPending, output
}
return ciStatusPass, output
}