Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath, errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim, core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(), core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives. Framework boundary exceptions preserved where stdlib types are required by external interfaces (Gin, net/http, CGo, Wails, bubbletea). Co-Authored-By: Virgil <virgil@lethean.io>
231 lines
6.1 KiB
Go
231 lines
6.1 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
|
|
core "dappco.re/go/core"
|
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
type MirrorInput struct {
|
|
Repo string `json:"repo,omitempty"`
|
|
DryRun bool `json:"dry_run,omitempty"`
|
|
MaxFiles int `json:"max_files,omitempty"`
|
|
}
|
|
|
|
type MirrorOutput struct {
|
|
Success bool `json:"success"`
|
|
Synced []MirrorSync `json:"synced"`
|
|
Skipped []string `json:"skipped,omitempty"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
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(svc *coremcp.Service) {
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &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
|
|
}
|
|
process := s.Core().Process()
|
|
|
|
basePath := s.codePath
|
|
if basePath == "" {
|
|
basePath = core.JoinPath(HomeDir(), "Code", "core")
|
|
} else {
|
|
basePath = core.JoinPath(basePath, "core")
|
|
}
|
|
|
|
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)
|
|
|
|
if !s.hasRemote(repoDir, "github") {
|
|
skipped = append(skipped, core.Concat(repo, ": no github remote"))
|
|
continue
|
|
}
|
|
|
|
process.RunIn(ctx, repoDir, "git", "fetch", "github")
|
|
|
|
localBase := s.DefaultBranch(repoDir)
|
|
ahead := s.commitsAhead(repoDir, "github/main", localBase)
|
|
if ahead == 0 {
|
|
continue
|
|
}
|
|
|
|
files := s.filesChanged(repoDir, "github/main", localBase)
|
|
|
|
sync := MirrorSync{
|
|
Repo: repo,
|
|
CommitsAhead: ahead,
|
|
FilesChanged: files,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
s.ensureDevBranch(repoDir)
|
|
|
|
base := s.DefaultBranch(repoDir)
|
|
if r := process.RunIn(ctx, repoDir, "git", "push", "github", core.Concat(base, ":refs/heads/dev"), "--force"); !r.OK {
|
|
sync.Skipped = core.Sprintf("push failed: %s", r.Value)
|
|
synced = append(synced, sync)
|
|
continue
|
|
}
|
|
sync.Pushed = true
|
|
|
|
pullRequestURL, err := s.createGitHubPR(ctx, repoDir, repo, ahead, files)
|
|
if err != nil {
|
|
sync.Skipped = core.Sprintf("PR creation failed: %v", err)
|
|
} else {
|
|
sync.PRURL = pullRequestURL
|
|
}
|
|
|
|
synced = append(synced, sync)
|
|
}
|
|
|
|
return nil, MirrorOutput{
|
|
Success: true,
|
|
Synced: synced,
|
|
Skipped: skipped,
|
|
Count: len(synced),
|
|
}, nil
|
|
}
|
|
|
|
// url, err := s.createGitHubPR(ctx, repoDir, "go-io", 3, 12)
|
|
func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string, commits, files int) (string, error) {
|
|
ghRepo := core.Sprintf("%s/%s", GitHubOrg(), repo)
|
|
process := s.Core().Process()
|
|
|
|
r := process.RunIn(ctx, repoDir, "gh", "pr", "list", "--repo", ghRepo, "--head", "dev", "--state", "open", "--json", "url", "--limit", "1")
|
|
if r.OK {
|
|
out := r.Value.(string)
|
|
if core.Contains(out, "url") {
|
|
if url := extractJSONField(out, "url"); url != "" {
|
|
return url, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
body := core.Sprintf(
|
|
"## Forge → GitHub Sync\n\n**Commits:** %d\n**Files changed:** %d\n\nAutomated sync from Forge (forge.lthn.ai) to GitHub mirror.\nReview with CodeRabbit before merging.\n\n---\nCo-Authored-By: Virgil <virgil@lethean.io>",
|
|
commits, files)
|
|
|
|
title := core.Sprintf("[sync] %s: %d commits, %d files", repo, commits, files)
|
|
|
|
r = process.RunIn(ctx, repoDir, "gh", "pr", "create",
|
|
"--repo", ghRepo, "--head", "dev", "--base", "main",
|
|
"--title", title, "--body", body)
|
|
if !r.OK {
|
|
return "", core.E("createGitHubPR", r.Value.(string), nil)
|
|
}
|
|
|
|
prOut := r.Value.(string)
|
|
lines := core.Split(core.Trim(prOut), "\n")
|
|
if len(lines) > 0 {
|
|
return lines[len(lines)-1], nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (s *PrepSubsystem) ensureDevBranch(repoDir string) {
|
|
s.Core().Process().RunIn(context.Background(), repoDir, "git", "push", "github", "HEAD:refs/heads/dev")
|
|
}
|
|
|
|
func (s *PrepSubsystem) hasRemote(repoDir, name string) bool {
|
|
return s.Core().Process().RunIn(context.Background(), repoDir, "git", "remote", "get-url", name).OK
|
|
}
|
|
|
|
func (s *PrepSubsystem) commitsAhead(repoDir, base, head string) int {
|
|
r := s.Core().Process().RunIn(context.Background(), repoDir, "git", "rev-list", core.Concat(base, "..", head), "--count")
|
|
if !r.OK {
|
|
return 0
|
|
}
|
|
out := core.Trim(r.Value.(string))
|
|
return parseInt(out)
|
|
}
|
|
|
|
func (s *PrepSubsystem) filesChanged(repoDir, base, head string) int {
|
|
r := s.Core().Process().RunIn(context.Background(), repoDir, "git", "diff", "--name-only", core.Concat(base, "..", head))
|
|
if !r.OK {
|
|
return 0
|
|
}
|
|
out := core.Trim(r.Value.(string))
|
|
if out == "" {
|
|
return 0
|
|
}
|
|
return len(core.Split(out, "\n"))
|
|
}
|
|
|
|
func (s *PrepSubsystem) listLocalRepos(basePath string) []string {
|
|
paths := core.PathGlob(core.JoinPath(basePath, "*"))
|
|
var repos []string
|
|
for _, p := range paths {
|
|
name := core.PathBase(p)
|
|
if !fs.IsDir(p) {
|
|
continue
|
|
}
|
|
if fs.IsDir(core.JoinPath(basePath, name, ".git")) {
|
|
repos = append(repos, name)
|
|
}
|
|
}
|
|
return repos
|
|
}
|
|
|
|
func extractJSONField(jsonStr, field string) string {
|
|
if jsonStr == "" || field == "" {
|
|
return ""
|
|
}
|
|
|
|
var list []map[string]any
|
|
if r := core.JSONUnmarshalString(jsonStr, &list); r.OK {
|
|
for _, item := range list {
|
|
if value, ok := item[field].(string); ok {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
|
|
var item map[string]any
|
|
if r := core.JSONUnmarshalString(jsonStr, &item); !r.OK {
|
|
return ""
|
|
}
|
|
|
|
value, _ := item[field].(string)
|
|
return value
|
|
}
|