DevOps plugin (5 skills): - install-core-agent, repair-core-agent, merge-workspace, update-deps, clean-workspaces CLI commands: version, check, extract for diagnostics. Codex dispatch: --skip-git-repo-check, removed broken --model-reasoning-effort, --sandbox workspace-write via --full-auto. Workspace template extracts to wsDir not srcDir. AX sweep (Codex-generated): sanitise.go extracted from prep/plan, mirror.go JSON parsing via encoding/json, setup/config.go URL parsing via net/url, strings/fmt imports eliminated from setup. CODEX.md template updated with Env/Path patterns. Review workspace template with audit-only PROMPT.md. Marketplace updated with devops plugin. Co-Authored-By: Virgil <virgil@lethean.io>
288 lines
7.8 KiB
Go
288 lines
7.8 KiB
Go
// 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 <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 {
|
|
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
|
|
}
|