agent/pkg/agentic/mirror.go
Snider 3022f05fb8 refactor(agentic): route file I/O through core.Fs
Replace raw os.* file operations with Core Fs equivalents:
- os.Stat → fs.Exists/fs.IsFile/fs.IsDir (resume, pr, plan, mirror, prep)
- os.ReadDir → fs.List (queue, status, plan, mirror, review_queue)
- os.Remove → fs.Delete (dispatch)
- os.OpenFile(append) → fs.Append (events, review_queue)
- strings.Replace → core.Replace (scan)

Eliminates os import from resume.go, pr.go. Eliminates strings
import from scan.go. Trades os for io in events.go.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 09:08:45 +00:00

281 lines
7.7 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"os"
"os/exec"
"strings"
core "dappco.re/go/core"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// --- agentic_mirror tool ---
// MirrorInput is the input for agentic_mirror.
//
// input := agentic.MirrorInput{Repo: "go-io", DryRun: true, MaxFiles: 50}
type MirrorInput struct {
Repo string `json:"repo,omitempty"` // Specific repo, or empty for all
DryRun bool `json:"dry_run,omitempty"` // Preview without pushing
MaxFiles int `json:"max_files,omitempty"` // Max files per PR (default 50, CodeRabbit limit)
}
// MirrorOutput is the output for agentic_mirror.
//
// out := agentic.MirrorOutput{Success: true, Count: 1, Synced: []agentic.MirrorSync{{Repo: "go-io"}}}
type MirrorOutput struct {
Success bool `json:"success"`
Synced []MirrorSync `json:"synced"`
Skipped []string `json:"skipped,omitempty"`
Count int `json:"count"`
}
// MirrorSync records one repo sync.
//
// sync := agentic.MirrorSync{Repo: "go-io", CommitsAhead: 3, FilesChanged: 12}
type MirrorSync struct {
Repo string `json:"repo"`
CommitsAhead int `json:"commits_ahead"`
FilesChanged int `json:"files_changed"`
PRURL string `json:"pr_url,omitempty"`
Pushed bool `json:"pushed"`
Skipped string `json:"skipped,omitempty"`
}
func (s *PrepSubsystem) registerMirrorTool(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_mirror",
Description: "Sync Forge repos to GitHub mirrors. Pushes Forge main to GitHub dev branch and creates a PR. Respects file count limits for CodeRabbit review.",
}, s.mirror)
}
func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, input MirrorInput) (*mcp.CallToolResult, MirrorOutput, error) {
maxFiles := input.MaxFiles
if maxFiles <= 0 {
maxFiles = 50
}
basePath := s.codePath
if basePath == "" {
home, _ := os.UserHomeDir()
basePath = core.JoinPath(home, "Code", "core")
} else {
basePath = core.JoinPath(basePath, "core")
}
// Build list of repos to sync
var repos []string
if input.Repo != "" {
repos = []string{input.Repo}
} else {
repos = s.listLocalRepos(basePath)
}
var synced []MirrorSync
var skipped []string
for _, repo := range repos {
repoDir := core.JoinPath(basePath, repo)
// Check if github remote exists
if !hasRemote(repoDir, "github") {
skipped = append(skipped, repo+": no github remote")
continue
}
// Fetch github to get current state
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "github")
fetchCmd.Dir = repoDir
fetchCmd.Run()
// Check how far ahead local default branch is vs github
localBase := DefaultBranch(repoDir)
ahead := commitsAhead(repoDir, "github/main", localBase)
if ahead == 0 {
continue // Already in sync
}
// Count files changed
files := filesChanged(repoDir, "github/main", localBase)
sync := MirrorSync{
Repo: repo,
CommitsAhead: ahead,
FilesChanged: files,
}
// Skip if too many files for one PR
if files > maxFiles {
sync.Skipped = core.Sprintf("%d files exceeds limit of %d", files, maxFiles)
synced = append(synced, sync)
continue
}
if input.DryRun {
sync.Skipped = "dry run"
synced = append(synced, sync)
continue
}
// Ensure dev branch exists on GitHub
ensureDevBranch(repoDir)
// Push local main to github dev (explicit main, not HEAD)
base := DefaultBranch(repoDir)
pushCmd := exec.CommandContext(ctx, "git", "push", "github", base+":refs/heads/dev", "--force")
pushCmd.Dir = repoDir
if err := pushCmd.Run(); err != nil {
sync.Skipped = core.Sprintf("push failed: %v", err)
synced = append(synced, sync)
continue
}
sync.Pushed = true
// Create PR: dev → main on GitHub
prURL, err := s.createGitHubPR(ctx, repoDir, repo, ahead, files)
if err != nil {
sync.Skipped = core.Sprintf("PR creation failed: %v", err)
} else {
sync.PRURL = prURL
}
synced = append(synced, sync)
}
return nil, MirrorOutput{
Success: true,
Synced: synced,
Skipped: skipped,
Count: len(synced),
}, nil
}
// createGitHubPR creates a PR from dev → main using the gh CLI.
func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string, commits, files int) (string, error) {
// Check if there's already an open PR from dev
ghRepo := core.Sprintf("%s/%s", GitHubOrg(), repo)
checkCmd := exec.CommandContext(ctx, "gh", "pr", "list", "--repo", ghRepo, "--head", "dev", "--state", "open", "--json", "url", "--limit", "1")
checkCmd.Dir = repoDir
out, err := checkCmd.Output()
if err == nil && core.Contains(string(out), "url") {
// PR already exists — extract URL
// Format: [{"url":"https://..."}]
url := extractJSONField(string(out), "url")
if url != "" {
return url, nil
}
}
// Build PR body
body := core.Sprintf("## Forge → GitHub Sync\n\n"+
"**Commits:** %d\n"+
"**Files changed:** %d\n\n"+
"Automated sync from Forge (forge.lthn.ai) to GitHub mirror.\n"+
"Review with CodeRabbit before merging.\n\n"+
"---\n"+
"Co-Authored-By: Virgil <virgil@lethean.io>",
commits, files)
title := core.Sprintf("[sync] %s: %d commits, %d files", repo, commits, files)
prCmd := exec.CommandContext(ctx, "gh", "pr", "create",
"--repo", ghRepo,
"--head", "dev",
"--base", "main",
"--title", title,
"--body", body,
)
prCmd.Dir = repoDir
prOut, err := prCmd.CombinedOutput()
if err != nil {
return "", core.E("createGitHubPR", string(prOut), err)
}
// gh pr create outputs the PR URL on the last line
lines := core.Split(core.Trim(string(prOut)), "\n")
if len(lines) > 0 {
return lines[len(lines)-1], nil
}
return "", nil
}
// ensureDevBranch creates the dev branch on GitHub if it doesn't exist.
func ensureDevBranch(repoDir string) {
// Try to push current main as dev — if dev exists this is a no-op (we force-push later)
cmd := exec.Command("git", "push", "github", "HEAD:refs/heads/dev")
cmd.Dir = repoDir
cmd.Run() // Ignore error — branch may already exist
}
// hasRemote checks if a git remote exists.
func hasRemote(repoDir, name string) bool {
cmd := exec.Command("git", "remote", "get-url", name)
cmd.Dir = repoDir
return cmd.Run() == nil
}
// commitsAhead returns how many commits HEAD is ahead of the ref.
func commitsAhead(repoDir, base, head string) int {
cmd := exec.Command("git", "rev-list", base+".."+head, "--count")
cmd.Dir = repoDir
out, err := cmd.Output()
if err != nil {
return 0
}
return parseInt(string(out))
}
// filesChanged returns the number of files changed between two refs.
func filesChanged(repoDir, base, head string) int {
cmd := exec.Command("git", "diff", "--name-only", base+".."+head)
cmd.Dir = repoDir
out, err := cmd.Output()
if err != nil {
return 0
}
lines := core.Split(core.Trim(string(out)), "\n")
if len(lines) == 1 && lines[0] == "" {
return 0
}
return len(lines)
}
// listLocalRepos returns repo names that exist as directories in basePath.
func (s *PrepSubsystem) listLocalRepos(basePath string) []string {
r := fs.List(basePath)
if !r.OK {
return nil
}
entries := r.Value.([]os.DirEntry)
var repos []string
for _, e := range entries {
if !e.IsDir() {
continue
}
// Must have a .git directory
if fs.IsDir(core.JoinPath(basePath, e.Name(), ".git")) {
repos = append(repos, e.Name())
}
}
return repos
}
// extractJSONField extracts a simple string field from JSON array output.
func extractJSONField(jsonStr, field string) string {
// Quick and dirty — works for gh CLI output like [{"url":"https://..."}]
key := core.Sprintf(`"%s":"`, field)
idx := strings.Index(jsonStr, key)
if idx < 0 {
return ""
}
start := idx + len(key)
end := strings.Index(jsonStr[start:], `"`)
if end < 0 {
return ""
}
return jsonStr[start : start+end]
}