// SPDX-License-Identifier: EUPL-1.2 package agentic import ( "context" "encoding/json" "os" "os/exec" 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 == "" { basePath = core.JoinPath(core.Env("DIR_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 ", 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 { if jsonStr == "" || field == "" { return "" } var list []map[string]any if err := json.Unmarshal([]byte(jsonStr), &list); err == nil { for _, item := range list { if value, ok := item[field].(string); ok { return value } } } var item map[string]any if err := json.Unmarshal([]byte(jsonStr), &item); err != nil { return "" } value, _ := item[field].(string) return value }