feat: auto-create PR on Forge after agent completion

When a dispatched agent completes with commits:
1. Branch name threaded through PrepOutput → status.json
2. Completion goroutine pushes branch to forge
3. Auto-creates PR via Forge API with task description
4. PR URL stored in status.json for review

Agents now create PRs instead of committing to main. Combined
with sandbox restrictions, this closes the loop on controlled
agent contributions.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-17 04:19:48 +00:00
parent da1c45b4df
commit 71decc26b2
4 changed files with 103 additions and 0 deletions

Binary file not shown.

96
pkg/agentic/auto_pr.go Normal file
View file

@ -0,0 +1,96 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
)
// autoCreatePR pushes the agent's branch and creates a PR on Forge
// if the agent made any commits beyond the initial clone.
func (s *PrepSubsystem) autoCreatePR(wsDir string) {
st, err := readStatus(wsDir)
if err != nil || st.Branch == "" || st.Repo == "" {
return
}
srcDir := filepath.Join(wsDir, "src")
// Check if there are commits on the branch beyond origin/main
diffCmd := exec.Command("git", "log", "--oneline", "origin/main..HEAD")
diffCmd.Dir = srcDir
out, err := diffCmd.Output()
if err != nil || len(strings.TrimSpace(string(out))) == 0 {
// No commits — nothing to PR
return
}
commitCount := len(strings.Split(strings.TrimSpace(string(out)), "\n"))
// Get the repo's forge remote URL to extract org/repo
org := st.Org
if org == "" {
org = "core"
}
// Push the branch to forge
forgeRemote := fmt.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo)
pushCmd := exec.Command("git", "push", forgeRemote, st.Branch)
pushCmd.Dir = srcDir
if pushErr := pushCmd.Run(); pushErr != nil {
// Push failed — update status with error but don't block
if st2, err := readStatus(wsDir); err == nil {
st2.Question = fmt.Sprintf("PR push failed: %v", pushErr)
writeStatus(wsDir, st2)
}
return
}
// Create PR via Forge API
title := fmt.Sprintf("[agent/%s] %s", st.Agent, truncate(st.Task, 60))
body := s.buildAutoPRBody(st, commitCount)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prURL, _, err := s.forgeCreatePR(ctx, org, st.Repo, st.Branch, "main", title, body)
if err != nil {
if st2, err := readStatus(wsDir); err == nil {
st2.Question = fmt.Sprintf("PR creation failed: %v", err)
writeStatus(wsDir, st2)
}
return
}
// Update status with PR URL
if st2, err := readStatus(wsDir); err == nil {
st2.PRURL = prURL
writeStatus(wsDir, st2)
}
}
func (s *PrepSubsystem) buildAutoPRBody(st *WorkspaceStatus, commits int) string {
var b strings.Builder
b.WriteString("## Task\n\n")
b.WriteString(st.Task)
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("**Agent:** %s\n", st.Agent))
b.WriteString(fmt.Sprintf("**Commits:** %d\n", commits))
b.WriteString(fmt.Sprintf("**Branch:** `%s`\n", st.Branch))
b.WriteString("\n---\n")
b.WriteString("Auto-created by core-agent dispatch system.\n")
b.WriteString("Co-Authored-By: Virgil <virgil@lethean.io>\n")
return b.String()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}

View file

@ -132,6 +132,9 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st
// Emit completion event
emitCompletionEvent(agent, filepath.Base(wsDir))
// Auto-create PR if agent made commits
s.autoCreatePR(wsDir)
// Ingest scan findings as issues
s.ingestFindings(wsDir)
@ -202,6 +205,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
Repo: input.Repo,
Org: input.Org,
Task: input.Task,
Branch: prepOut.Branch,
StartedAt: time.Now(),
Runs: 0,
})
@ -226,6 +230,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
Repo: input.Repo,
Org: input.Org,
Task: input.Task,
Branch: prepOut.Branch,
PID: pid,
StartedAt: time.Now(),
Runs: 1,

View file

@ -114,6 +114,7 @@ type PrepInput struct {
type PrepOutput struct {
Success bool `json:"success"`
WorkspaceDir string `json:"workspace_dir"`
Branch string `json:"branch"`
WikiPages int `json:"wiki_pages"`
SpecFiles int `json:"spec_files"`
Memories int `json:"memories"`
@ -171,6 +172,7 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName)
branchCmd.Dir = srcDir
branchCmd.Run()
out.Branch = branchName
// Create context dirs inside src/
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb"))