c36aa18c67
Nuevas skills para crear TUIs, inicializar frontends React y módulos Go. Incluye binario parallel-executor y utilidades de soporte. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
353 lines
10 KiB
Go
353 lines
10 KiB
Go
// parallel-executor — Orquestador de ejecución paralela de issues.
|
|
//
|
|
// Crea git worktrees, ejecuta claude en cada uno, y mergea los resultados.
|
|
// Usa DevFactory para Result[T], MapSlice, y operaciones I/O.
|
|
//
|
|
// Uso:
|
|
//
|
|
// parallel-executor [flags]
|
|
// --plan <file> Plan markdown (default: PARALLEL_EXECUTION_ORDER.md)
|
|
// --group <N> Ejecutar solo grupo N
|
|
// --sequential Sin paralelismo
|
|
// --dry-run Solo mostrar plan sin ejecutar
|
|
// --timeout <min> Timeout por issue en minutos (default: 30)
|
|
// --cleanup Solo limpiar worktrees existentes
|
|
// --sort Analizar issues y generar plan (no ejecutar)
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
dfshell "github.com/lucasdataproyects/devfactory/shell"
|
|
|
|
pcore "github.com/lucasdataproyects/parallel-executor/core"
|
|
"github.com/lucasdataproyects/parallel-executor/shell"
|
|
)
|
|
|
|
// CLI colors
|
|
const (
|
|
red = "\033[0;31m"
|
|
green = "\033[0;32m"
|
|
yellow = "\033[1;33m"
|
|
blue = "\033[0;34m"
|
|
cyan = "\033[0;36m"
|
|
nc = "\033[0m"
|
|
)
|
|
|
|
func main() {
|
|
planFile := flag.String("plan", "PARALLEL_EXECUTION_ORDER.md", "plan markdown file")
|
|
groupNum := flag.Int("group", 0, "execute only this group number")
|
|
sequential := flag.Bool("sequential", false, "disable parallel execution")
|
|
dryRun := flag.Bool("dry-run", false, "show plan without executing")
|
|
timeoutMin := flag.Int("timeout", 30, "timeout per issue in minutes")
|
|
cleanup := flag.Bool("cleanup", false, "only cleanup existing worktrees")
|
|
sortMode := flag.Bool("sort", false, "analyze issues and generate plan")
|
|
flag.Parse()
|
|
|
|
repoRoot, err := os.Getwd()
|
|
if err != nil {
|
|
fatal("cannot get working directory: %v", err)
|
|
}
|
|
|
|
// --- Modo cleanup ---
|
|
if *cleanup {
|
|
info("Cleaning up worktrees...")
|
|
result := shell.CleanupAllWorktrees(repoRoot)
|
|
if result.IsErr() {
|
|
fatal("cleanup failed: %v", result.Error())
|
|
}
|
|
ok("Cleaned %d worktrees", result.Unwrap())
|
|
return
|
|
}
|
|
|
|
// --- Modo sort: analizar issues y generar plan ---
|
|
if *sortMode {
|
|
generatePlan(repoRoot)
|
|
return
|
|
}
|
|
|
|
// --- Ejecutar plan ---
|
|
executePlan(repoRoot, *planFile, *groupNum, *sequential, *dryRun, *timeoutMin)
|
|
}
|
|
|
|
func generatePlan(repoRoot string) {
|
|
issuesDir := repoRoot + "/dev/issues"
|
|
if !dfshell.DirExists(issuesDir) {
|
|
fatal("issues directory not found: %s", issuesDir)
|
|
}
|
|
|
|
info("Reading issues from %s...", issuesDir)
|
|
filesResult := shell.ReadIssueFiles(issuesDir)
|
|
if filesResult.IsErr() {
|
|
fatal("cannot read issues: %v", filesResult.Error())
|
|
}
|
|
|
|
issues := pcore.ParseIssueFiles(filesResult.Unwrap())
|
|
if len(issues) == 0 {
|
|
warn("No pending issues found")
|
|
return
|
|
}
|
|
info("Found %d pending issues", len(issues))
|
|
|
|
// Topological sort
|
|
groupsResult := pcore.TopologicalSort(issues)
|
|
if groupsResult.IsErr() {
|
|
fatal("dependency analysis failed: %v", groupsResult.Error())
|
|
}
|
|
|
|
groups := groupsResult.Unwrap()
|
|
|
|
// Generate markdown
|
|
markdown := pcore.GeneratePlanMarkdown(groups, nil)
|
|
|
|
outFile := repoRoot + "/PARALLEL_EXECUTION_ORDER.md"
|
|
writeResult := dfshell.WriteString(outFile, markdown)
|
|
if writeResult.IsErr() {
|
|
fatal("cannot write plan: %v", writeResult.Error())
|
|
}
|
|
|
|
ok("Plan generated: %s", outFile)
|
|
fmt.Printf("\n%sIssues:%s %d\n", cyan, nc, len(issues))
|
|
fmt.Printf("%sGroups:%s %d\n", cyan, nc, len(groups))
|
|
for _, g := range groups {
|
|
fmt.Printf(" %sGrupo %d:%s %d issues\n", blue, g.Number, nc, len(g.Issues))
|
|
for _, issue := range g.Issues {
|
|
fmt.Printf(" - #%04d %s\n", issue.Number, issue.Title)
|
|
}
|
|
}
|
|
}
|
|
|
|
func executePlan(repoRoot string, planFile string, groupNum int, sequential bool, dryRun bool, timeoutMin int) {
|
|
// Leer plan
|
|
planContent := dfshell.ReadString(planFile)
|
|
if planContent.IsErr() {
|
|
// Intentar generar plan automáticamente
|
|
warn("Plan not found, generating from issues...")
|
|
generatePlan(repoRoot)
|
|
planContent = dfshell.ReadString(planFile)
|
|
if planContent.IsErr() {
|
|
fatal("cannot read plan: %v", planContent.Error())
|
|
}
|
|
}
|
|
|
|
planResult := pcore.ParsePlan(planContent.Unwrap())
|
|
if planResult.IsErr() {
|
|
fatal("cannot parse plan: %v", planResult.Error())
|
|
}
|
|
|
|
plan := planResult.Unwrap()
|
|
|
|
// Filtrar por grupo si se especificó
|
|
if groupNum > 0 {
|
|
filtered := pcore.FilterGroup(plan, groupNum)
|
|
if filtered.IsErr() {
|
|
fatal("group filter: %v", filtered.Error())
|
|
}
|
|
plan = filtered.Unwrap()
|
|
}
|
|
|
|
info("Plan: %d issues in %d groups", plan.Total, len(plan.Groups))
|
|
|
|
// --- Dry run: solo mostrar ---
|
|
if dryRun {
|
|
for _, group := range plan.Groups {
|
|
fmt.Printf("\n%s%s%s (%d issues)\n", cyan, group.Name, nc, len(group.Issues))
|
|
specs := pcore.BuildWorktreeSpecs(group.Issues, repoRoot)
|
|
for _, spec := range specs {
|
|
fmt.Printf(" #%04d → %s\n", spec.Issue.Number, spec.BranchName)
|
|
fmt.Printf(" %s\n", spec.WorkDir)
|
|
}
|
|
}
|
|
fmt.Printf("\n%sDry run complete. No worktrees created.%s\n", yellow, nc)
|
|
return
|
|
}
|
|
|
|
// --- Logger ---
|
|
loggerResult := shell.NewLogger(repoRoot)
|
|
if loggerResult.IsErr() {
|
|
fatal("cannot create logger: %v", loggerResult.Error())
|
|
}
|
|
logger := loggerResult.Unwrap()
|
|
info("Logs: %s", logger.SessionLogFile())
|
|
|
|
// --- Ejecutar grupos secuencialmente, issues dentro de cada grupo en paralelo ---
|
|
timeout := time.Duration(timeoutMin) * time.Minute
|
|
var allResults []pcore.ExecutionResult
|
|
|
|
for _, group := range plan.Groups {
|
|
fmt.Printf("\n%s══════════════════════════════════════%s\n", cyan, nc)
|
|
fmt.Printf("%s %s — %d issues%s\n", cyan, group.Name, len(group.Issues), nc)
|
|
fmt.Printf("%s══════════════════════════════════════%s\n", cyan, nc)
|
|
|
|
specs := pcore.BuildWorktreeSpecs(group.Issues, repoRoot)
|
|
|
|
// Crear worktrees
|
|
info("Creating %d worktrees...", len(specs))
|
|
var validSpecs []pcore.WorktreeSpec
|
|
for _, spec := range specs {
|
|
result := shell.CreateWorktree(spec, repoRoot)
|
|
if result.IsErr() {
|
|
warn("Failed to create worktree for #%04d: %v", spec.Issue.Number, result.Error())
|
|
allResults = append(allResults, pcore.ExecutionResult{
|
|
Issue: spec.Issue,
|
|
Success: false,
|
|
Error: result.Error().Error(),
|
|
})
|
|
continue
|
|
}
|
|
ok("Worktree: %s → %s", spec.BranchName, result.Unwrap())
|
|
validSpecs = append(validSpecs, spec)
|
|
}
|
|
|
|
// Ejecutar
|
|
var groupResults []pcore.ExecutionResult
|
|
if sequential || len(validSpecs) == 1 {
|
|
groupResults = executeSequential(validSpecs, timeout, logger)
|
|
} else {
|
|
groupResults = executeParallel(validSpecs, timeout, logger)
|
|
}
|
|
|
|
allResults = append(allResults, groupResults...)
|
|
|
|
// Mergear las exitosas
|
|
for _, r := range groupResults {
|
|
if !r.Success {
|
|
continue
|
|
}
|
|
spec := findSpec(validSpecs, r.Issue.Number)
|
|
if spec == nil {
|
|
continue
|
|
}
|
|
info("Merging %s...", spec.BranchName)
|
|
mergeResult := shell.MergeBranchToMaster(spec.BranchName, repoRoot)
|
|
if mergeResult.IsErr() {
|
|
warn("Merge failed for %s: %v", spec.BranchName, mergeResult.Error())
|
|
} else {
|
|
ok("Merged %s", spec.BranchName)
|
|
}
|
|
}
|
|
|
|
// Limpiar worktrees del grupo
|
|
for _, spec := range validSpecs {
|
|
shell.RemoveWorktree(spec, repoRoot)
|
|
}
|
|
}
|
|
|
|
// --- Resumen ---
|
|
summaryResult := logger.WriteSummary(allResults)
|
|
summaryFile := ""
|
|
if summaryResult.IsOk() {
|
|
summaryFile = summaryResult.Unwrap()
|
|
}
|
|
|
|
fmt.Printf("\n%s══════════════════════════════════════%s\n", green, nc)
|
|
fmt.Printf("%s Execution Complete%s\n", green, nc)
|
|
fmt.Printf("%s══════════════════════════════════════%s\n\n", green, nc)
|
|
|
|
succeeded := 0
|
|
failed := 0
|
|
for _, r := range allResults {
|
|
if r.Success {
|
|
succeeded++
|
|
fmt.Printf(" %s✓%s #%04d %s (%s)\n", green, nc, r.Issue.Number, r.Issue.Title, r.Duration)
|
|
} else {
|
|
failed++
|
|
fmt.Printf(" %s✗%s #%04d %s — %s\n", red, nc, r.Issue.Number, r.Issue.Title, r.Error)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\n Total: %d | Succeeded: %s%d%s | Failed: %s%d%s\n",
|
|
len(allResults), green, succeeded, nc, red, failed, nc)
|
|
if summaryFile != "" {
|
|
fmt.Printf(" Summary: %s\n", summaryFile)
|
|
}
|
|
fmt.Printf(" Log: %s\n", logger.SessionLogFile())
|
|
|
|
// Cleanup final
|
|
shell.CleanupAllWorktrees(repoRoot)
|
|
|
|
if failed > 0 {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func executeSequential(specs []pcore.WorktreeSpec, timeout time.Duration, logger *shell.Logger) []pcore.ExecutionResult {
|
|
results := make([]pcore.ExecutionResult, 0, len(specs))
|
|
|
|
for _, spec := range specs {
|
|
info("Executing #%04d %s...", spec.Issue.Number, spec.Issue.Title)
|
|
logger.LogIssueStart(spec.Issue)
|
|
|
|
result := shell.ExecuteIssue(spec, timeout)
|
|
logger.LogIssueResult(result)
|
|
|
|
if result.Success {
|
|
ok("#%04d completed in %s", spec.Issue.Number, result.Duration)
|
|
} else {
|
|
warn("#%04d failed: %s", spec.Issue.Number, result.Error)
|
|
}
|
|
|
|
results = append(results, result)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
func executeParallel(specs []pcore.WorktreeSpec, timeout time.Duration, logger *shell.Logger) []pcore.ExecutionResult {
|
|
results := make([]pcore.ExecutionResult, len(specs))
|
|
var wg sync.WaitGroup
|
|
|
|
for i, spec := range specs {
|
|
wg.Add(1)
|
|
go func(idx int, s pcore.WorktreeSpec) {
|
|
defer wg.Done()
|
|
|
|
info("[goroutine] Executing #%04d %s...", s.Issue.Number, s.Issue.Title)
|
|
logger.LogIssueStart(s.Issue)
|
|
|
|
result := shell.ExecuteIssue(s, timeout)
|
|
logger.LogIssueResult(result)
|
|
results[idx] = result
|
|
|
|
if result.Success {
|
|
ok("[goroutine] #%04d completed in %s", s.Issue.Number, result.Duration)
|
|
} else {
|
|
warn("[goroutine] #%04d failed: %s", s.Issue.Number, result.Error)
|
|
}
|
|
}(i, spec)
|
|
}
|
|
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
func findSpec(specs []pcore.WorktreeSpec, issueNum int) *pcore.WorktreeSpec {
|
|
for _, s := range specs {
|
|
if s.Issue.Number == issueNum {
|
|
return &s
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func info(format string, args ...any) {
|
|
fmt.Printf("%s[INFO]%s %s\n", blue, nc, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func ok(format string, args ...any) {
|
|
fmt.Printf("%s[OK]%s %s\n", green, nc, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func warn(format string, args ...any) {
|
|
fmt.Printf("%s[WARN]%s %s\n", yellow, nc, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func fatal(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", red, nc, fmt.Sprintf(format, args...))
|
|
os.Exit(1)
|
|
}
|