feat: añadir skills create-tui, init-frontend, init-go-module y utilidades
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>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
.PHONY: build test clean install
|
||||
|
||||
BIN := parallel-executor
|
||||
BUILD_DIR := ../../bin
|
||||
|
||||
## build: Compila el binario
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build -trimpath -ldflags="-s -w" -o $(BUILD_DIR)/$(BIN) .
|
||||
@echo "✓ Built $(BUILD_DIR)/$(BIN)"
|
||||
|
||||
## test: Ejecuta tests
|
||||
test:
|
||||
go test ./core/... -v
|
||||
|
||||
## clean: Elimina artefactos
|
||||
clean:
|
||||
rm -f $(BUILD_DIR)/$(BIN)
|
||||
|
||||
## install: Copia binario a la skill
|
||||
install: build
|
||||
@echo "✓ Binary at $(BUILD_DIR)/$(BIN)"
|
||||
@echo " Use: $(BUILD_DIR)/$(BIN) --help"
|
||||
|
||||
## tidy: go mod tidy
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
## help: Muestra ayuda
|
||||
help:
|
||||
@grep -E '^## ' Makefile | sed 's/## //' | column -t -s ':'
|
||||
@@ -0,0 +1,251 @@
|
||||
// Package core contiene funciones puras para el parallel executor.
|
||||
// Sin side effects: parseo de planes, análisis de dependencias, agrupamiento.
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
df "github.com/lucasdataproyects/devfactory/core"
|
||||
)
|
||||
|
||||
// Issue representa una issue parseada del plan de ejecución.
|
||||
type Issue struct {
|
||||
Number int
|
||||
Slug string
|
||||
Title string
|
||||
Group int
|
||||
Deps []int
|
||||
}
|
||||
|
||||
// ExecutionPlan es el plan completo parseado del markdown.
|
||||
type ExecutionPlan struct {
|
||||
Groups []IssueGroup
|
||||
Total int
|
||||
}
|
||||
|
||||
// IssueGroup es un conjunto de issues ejecutables en paralelo.
|
||||
type IssueGroup struct {
|
||||
Number int
|
||||
Name string
|
||||
Issues []Issue
|
||||
}
|
||||
|
||||
// WorktreeSpec define la especificación para crear un worktree.
|
||||
type WorktreeSpec struct {
|
||||
Issue Issue
|
||||
BranchName string
|
||||
WorkDir string
|
||||
}
|
||||
|
||||
// ExecutionResult es el resultado de ejecutar una issue.
|
||||
type ExecutionResult struct {
|
||||
Issue Issue
|
||||
Success bool
|
||||
Duration string
|
||||
Error string
|
||||
LogFile string
|
||||
}
|
||||
|
||||
var (
|
||||
groupHeaderRe = regexp.MustCompile(`^##\s+Grupo\s+(\d+)`)
|
||||
issueLineRe = regexp.MustCompile(`-\s+Issue\s+#(\d{4})\s*-?\s*(.*)`)
|
||||
depRe = regexp.MustCompile(`#(\d{4})`)
|
||||
)
|
||||
|
||||
// ParsePlan parsea el contenido markdown de PARALLEL_EXECUTION_ORDER.md.
|
||||
// Función pura: recibe string, retorna Result[ExecutionPlan].
|
||||
func ParsePlan(content string) df.Result[ExecutionPlan] {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) == 0 {
|
||||
return df.Err[ExecutionPlan](errors.New("empty plan"))
|
||||
}
|
||||
|
||||
groups := parsePlanGroups(lines)
|
||||
if len(groups) == 0 {
|
||||
return df.Err[ExecutionPlan](errors.New("no groups found in plan"))
|
||||
}
|
||||
|
||||
total := df.Reduce(groups, 0, func(acc int, g IssueGroup) int {
|
||||
return acc + len(g.Issues)
|
||||
})
|
||||
|
||||
return df.Ok(ExecutionPlan{
|
||||
Groups: groups,
|
||||
Total: total,
|
||||
})
|
||||
}
|
||||
|
||||
// parsePlanGroups extrae los grupos del markdown.
|
||||
func parsePlanGroups(lines []string) []IssueGroup {
|
||||
var groups []IssueGroup
|
||||
var currentGroup *IssueGroup
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if matches := groupHeaderRe.FindStringSubmatch(trimmed); len(matches) > 1 {
|
||||
if currentGroup != nil {
|
||||
groups = append(groups, *currentGroup)
|
||||
}
|
||||
num, _ := strconv.Atoi(matches[1])
|
||||
currentGroup = &IssueGroup{
|
||||
Number: num,
|
||||
Name: trimmed,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentGroup != nil {
|
||||
if matches := issueLineRe.FindStringSubmatch(trimmed); len(matches) > 1 {
|
||||
num, _ := strconv.Atoi(matches[1])
|
||||
title := strings.TrimSpace(matches[2])
|
||||
deps := extractDeps(title, num)
|
||||
issue := Issue{
|
||||
Number: num,
|
||||
Slug: issueSlug(num, title),
|
||||
Title: title,
|
||||
Group: currentGroup.Number,
|
||||
Deps: deps,
|
||||
}
|
||||
currentGroup.Issues = append(currentGroup.Issues, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if currentGroup != nil && len(currentGroup.Issues) > 0 {
|
||||
groups = append(groups, *currentGroup)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// extractDeps extrae números de issues referenciadas como dependencias.
|
||||
func extractDeps(text string, selfNum int) []int {
|
||||
matches := depRe.FindAllStringSubmatch(text, -1)
|
||||
return df.FilterSlice(
|
||||
df.MapSlice(matches, func(m []string) int {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}),
|
||||
func(n int) bool { return n != selfNum },
|
||||
)
|
||||
}
|
||||
|
||||
// issueSlug genera el slug de branch para una issue.
|
||||
func issueSlug(num int, title string) string {
|
||||
slug := strings.ToLower(title)
|
||||
slug = regexp.MustCompile(`[^a-z0-9\s-]`).ReplaceAllString(slug, "")
|
||||
slug = regexp.MustCompile(`\s+`).ReplaceAllString(slug, "-")
|
||||
slug = regexp.MustCompile(`-{2,}`).ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
|
||||
words := strings.SplitN(slug, "-", 5)
|
||||
if len(words) > 4 {
|
||||
words = words[:4]
|
||||
}
|
||||
slug = strings.Join(words, "-")
|
||||
|
||||
if slug == "" {
|
||||
slug = "issue"
|
||||
}
|
||||
|
||||
return formatIssueNum(num) + "-" + slug
|
||||
}
|
||||
|
||||
func formatIssueNum(n int) string {
|
||||
return fmt.Sprintf("%04d", n)
|
||||
}
|
||||
|
||||
// FilterGroup filtra el plan para ejecutar solo un grupo específico.
|
||||
func FilterGroup(plan ExecutionPlan, groupNum int) df.Result[ExecutionPlan] {
|
||||
filtered := df.FilterSlice(plan.Groups, func(g IssueGroup) bool {
|
||||
return g.Number == groupNum
|
||||
})
|
||||
if len(filtered) == 0 {
|
||||
return df.Err[ExecutionPlan](fmt.Errorf("group not found: %d", groupNum))
|
||||
}
|
||||
total := len(filtered[0].Issues)
|
||||
return df.Ok(ExecutionPlan{Groups: filtered, Total: total})
|
||||
}
|
||||
|
||||
// BuildWorktreeSpecs genera las specs de worktrees para un grupo de issues.
|
||||
// Función pura: recibe issues y base path, retorna specs.
|
||||
func BuildWorktreeSpecs(issues []Issue, basePath string) []WorktreeSpec {
|
||||
return df.MapSlice(issues, func(issue Issue) WorktreeSpec {
|
||||
branch := "issue/" + issue.Slug
|
||||
workDir := basePath + "/worktrees/issue-" + formatIssueNum(issue.Number)
|
||||
return WorktreeSpec{
|
||||
Issue: issue,
|
||||
BranchName: branch,
|
||||
WorkDir: workDir,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ParseIssueFiles parsea los archivos de issues del directorio dev/issues/.
|
||||
// Retorna issues con dependencias extraídas del contenido de cada archivo.
|
||||
func ParseIssueFiles(files map[string]string) []Issue {
|
||||
var issues []Issue
|
||||
|
||||
numRe := regexp.MustCompile(`^(\d{3,4})[a-z]?-(.+)\.md$`)
|
||||
|
||||
for filename, content := range files {
|
||||
matches := numRe.FindStringSubmatch(filename)
|
||||
if len(matches) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
num, _ := strconv.Atoi(matches[1])
|
||||
slug := matches[2]
|
||||
|
||||
// Extraer título de la primera línea
|
||||
title := slug
|
||||
for _, line := range strings.SplitN(content, "\n", 5) {
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
title = strings.TrimPrefix(line, "# ")
|
||||
title = strings.TrimSpace(title)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extraer dependencias de "Bloqueada por" o tabla de dependencias
|
||||
deps := extractAllDeps(content, num)
|
||||
|
||||
// Solo incluir si está pendiente
|
||||
if isIssuePending(content) {
|
||||
issues = append(issues, Issue{
|
||||
Number: num,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
Deps: deps,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func extractAllDeps(content string, selfNum int) []int {
|
||||
allMatches := depRe.FindAllStringSubmatch(content, -1)
|
||||
seen := make(map[int]bool)
|
||||
var deps []int
|
||||
for _, m := range allMatches {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
if n != selfNum && !seen[n] {
|
||||
seen[n] = true
|
||||
deps = append(deps, n)
|
||||
}
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
func isIssuePending(content string) bool {
|
||||
lower := strings.ToLower(content)
|
||||
return strings.Contains(lower, "pendiente") ||
|
||||
strings.Contains(lower, "🟡") ||
|
||||
(!strings.Contains(lower, "completado") && !strings.Contains(lower, "✅"))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePlan(t *testing.T) {
|
||||
plan := `# Plan de Ejecución Paralela
|
||||
|
||||
## Grupo 1
|
||||
|
||||
- Issue #0003 - testing agent
|
||||
- Issue #0006 - improve db-reader
|
||||
|
||||
## Grupo 2
|
||||
|
||||
- Issue #0004 - api client — depende de #0003
|
||||
`
|
||||
|
||||
result := ParsePlan(plan)
|
||||
if result.IsErr() {
|
||||
t.Fatalf("ParsePlan failed: %v", result.Error())
|
||||
}
|
||||
|
||||
ep := result.Unwrap()
|
||||
if len(ep.Groups) != 2 {
|
||||
t.Errorf("expected 2 groups, got %d", len(ep.Groups))
|
||||
}
|
||||
if ep.Total != 3 {
|
||||
t.Errorf("expected 3 total issues, got %d", ep.Total)
|
||||
}
|
||||
if ep.Groups[0].Issues[0].Number != 3 {
|
||||
t.Errorf("expected issue #0003, got #%04d", ep.Groups[0].Issues[0].Number)
|
||||
}
|
||||
if len(ep.Groups[1].Issues[0].Deps) != 1 || ep.Groups[1].Issues[0].Deps[0] != 3 {
|
||||
t.Errorf("expected issue #0004 to depend on #0003")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanEmpty(t *testing.T) {
|
||||
result := ParsePlan("")
|
||||
if !result.IsErr() {
|
||||
t.Error("expected error for empty plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterGroup(t *testing.T) {
|
||||
plan := ExecutionPlan{
|
||||
Groups: []IssueGroup{
|
||||
{Number: 1, Issues: []Issue{{Number: 1}}},
|
||||
{Number: 2, Issues: []Issue{{Number: 2}, {Number: 3}}},
|
||||
},
|
||||
Total: 3,
|
||||
}
|
||||
|
||||
result := FilterGroup(plan, 2)
|
||||
if result.IsErr() {
|
||||
t.Fatalf("FilterGroup failed: %v", result.Error())
|
||||
}
|
||||
filtered := result.Unwrap()
|
||||
if filtered.Total != 2 {
|
||||
t.Errorf("expected 2 issues, got %d", filtered.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterGroupNotFound(t *testing.T) {
|
||||
plan := ExecutionPlan{Groups: []IssueGroup{{Number: 1}}}
|
||||
result := FilterGroup(plan, 99)
|
||||
if !result.IsErr() {
|
||||
t.Error("expected error for non-existent group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorktreeSpecs(t *testing.T) {
|
||||
issues := []Issue{
|
||||
{Number: 3, Slug: "0003-testing"},
|
||||
{Number: 6, Slug: "0006-db-reader"},
|
||||
}
|
||||
specs := BuildWorktreeSpecs(issues, "/tmp/project")
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("expected 2 specs, got %d", len(specs))
|
||||
}
|
||||
if specs[0].BranchName != "issue/0003-testing" {
|
||||
t.Errorf("expected branch issue/0003-testing, got %s", specs[0].BranchName)
|
||||
}
|
||||
if specs[1].WorkDir != "/tmp/project/worktrees/issue-0006" {
|
||||
t.Errorf("unexpected workdir: %s", specs[1].WorkDir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
df "github.com/lucasdataproyects/devfactory/core"
|
||||
)
|
||||
|
||||
// TopologicalSort ordena issues por dependencias usando Kahn's algorithm.
|
||||
// Función pura: retorna Result con los grupos ordenados.
|
||||
func TopologicalSort(issues []Issue) df.Result[[]IssueGroup] {
|
||||
if len(issues) == 0 {
|
||||
return df.Err[[]IssueGroup](errors.New("no issues to sort"))
|
||||
}
|
||||
|
||||
// Construir mapa de issues por número
|
||||
issueMap := make(map[int]Issue)
|
||||
for _, issue := range issues {
|
||||
issueMap[issue.Number] = issue
|
||||
}
|
||||
|
||||
// Calcular in-degree (solo deps que existen en el set)
|
||||
inDegree := make(map[int]int)
|
||||
for _, issue := range issues {
|
||||
if _, exists := inDegree[issue.Number]; !exists {
|
||||
inDegree[issue.Number] = 0
|
||||
}
|
||||
for _, dep := range issue.Deps {
|
||||
if _, exists := issueMap[dep]; exists {
|
||||
inDegree[issue.Number]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm por niveles (cada nivel = un grupo paralelo)
|
||||
var groups []IssueGroup
|
||||
resolved := make(map[int]bool)
|
||||
remaining := len(issues)
|
||||
groupNum := 1
|
||||
|
||||
for remaining > 0 {
|
||||
// Encontrar issues con in-degree 0
|
||||
var ready []Issue
|
||||
for _, issue := range issues {
|
||||
if !resolved[issue.Number] && inDegree[issue.Number] == 0 {
|
||||
issue.Group = groupNum
|
||||
ready = append(ready, issue)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ready) == 0 {
|
||||
// Ciclo detectado
|
||||
var cycleNums []int
|
||||
for _, issue := range issues {
|
||||
if !resolved[issue.Number] {
|
||||
cycleNums = append(cycleNums, issue.Number)
|
||||
}
|
||||
}
|
||||
return df.Err[[]IssueGroup](
|
||||
fmt.Errorf("circular dependency detected among issues: %v", cycleNums),
|
||||
)
|
||||
}
|
||||
|
||||
// Ordenar por número dentro del grupo (determinista)
|
||||
sort.Slice(ready, func(i, j int) bool {
|
||||
return ready[i].Number < ready[j].Number
|
||||
})
|
||||
|
||||
groups = append(groups, IssueGroup{
|
||||
Number: groupNum,
|
||||
Name: fmt.Sprintf("Grupo %d", groupNum),
|
||||
Issues: ready,
|
||||
})
|
||||
|
||||
// Marcar como resueltas y decrementar in-degree
|
||||
for _, issue := range ready {
|
||||
resolved[issue.Number] = true
|
||||
remaining--
|
||||
// Decrementar in-degree de dependientes
|
||||
for _, other := range issues {
|
||||
if !resolved[other.Number] {
|
||||
for _, dep := range other.Deps {
|
||||
if dep == issue.Number {
|
||||
inDegree[other.Number]--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupNum++
|
||||
}
|
||||
|
||||
return df.Ok(groups)
|
||||
}
|
||||
|
||||
// AnalyzeFileConflicts detecta issues que tocan los mismos archivos.
|
||||
// Recibe un mapa issue_number -> lista de archivos mencionados.
|
||||
// Retorna pares de issues en conflicto.
|
||||
func AnalyzeFileConflicts(filesByIssue map[int][]string) []ConflictPair {
|
||||
var conflicts []ConflictPair
|
||||
nums := sortedKeys(filesByIssue)
|
||||
|
||||
for i := 0; i < len(nums); i++ {
|
||||
for j := i + 1; j < len(nums); j++ {
|
||||
shared := intersect(filesByIssue[nums[i]], filesByIssue[nums[j]])
|
||||
if len(shared) > 0 {
|
||||
conflicts = append(conflicts, ConflictPair{
|
||||
IssueA: nums[i],
|
||||
IssueB: nums[j],
|
||||
SharedFiles: shared,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts
|
||||
}
|
||||
|
||||
// ConflictPair representa dos issues que comparten archivos.
|
||||
type ConflictPair struct {
|
||||
IssueA int
|
||||
IssueB int
|
||||
SharedFiles []string
|
||||
}
|
||||
|
||||
// GeneratePlanMarkdown genera el markdown del plan de ejecución.
|
||||
// Función pura: recibe grupos, retorna string.
|
||||
func GeneratePlanMarkdown(groups []IssueGroup, conflicts []ConflictPair) string {
|
||||
var b []byte
|
||||
|
||||
b = append(b, "# Plan de Ejecución Paralela\n\n"...)
|
||||
b = append(b, fmt.Sprintf("Generado automáticamente. Total: %d issues en %d grupos.\n\n",
|
||||
countIssues(groups), len(groups))...)
|
||||
|
||||
if len(conflicts) > 0 {
|
||||
b = append(b, "## Conflictos Detectados\n\n"...)
|
||||
for _, c := range conflicts {
|
||||
b = append(b, fmt.Sprintf("- Issue #%04d y #%04d comparten: %v\n",
|
||||
c.IssueA, c.IssueB, c.SharedFiles)...)
|
||||
}
|
||||
b = append(b, "\n"...)
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
b = append(b, fmt.Sprintf("## Grupo %d\n\n", group.Number)...)
|
||||
for _, issue := range group.Issues {
|
||||
depStr := ""
|
||||
if len(issue.Deps) > 0 {
|
||||
depStr = fmt.Sprintf(" — depende de %s", formatDeps(issue.Deps))
|
||||
}
|
||||
b = append(b, fmt.Sprintf("- Issue #%04d - %s%s\n", issue.Number, issue.Title, depStr)...)
|
||||
}
|
||||
b = append(b, "\n"...)
|
||||
}
|
||||
|
||||
b = append(b, "## Resumen\n\n"...)
|
||||
b = append(b, "| Métrica | Valor |\n"...)
|
||||
b = append(b, "|---------|-------|\n"...)
|
||||
b = append(b, fmt.Sprintf("| Issues totales | %d |\n", countIssues(groups))...)
|
||||
b = append(b, fmt.Sprintf("| Grupos paralelos | %d |\n", len(groups))...)
|
||||
if len(groups) > 0 {
|
||||
sequential := countIssues(groups)
|
||||
parallel := len(groups)
|
||||
if sequential > 0 {
|
||||
saving := ((sequential - parallel) * 100) / sequential
|
||||
b = append(b, fmt.Sprintf("| Ahorro estimado | %d%% |\n", saving)...)
|
||||
}
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func countIssues(groups []IssueGroup) int {
|
||||
return df.Reduce(groups, 0, func(acc int, g IssueGroup) int {
|
||||
return acc + len(g.Issues)
|
||||
})
|
||||
}
|
||||
|
||||
func formatDeps(deps []int) string {
|
||||
strs := df.MapSlice(deps, func(n int) string {
|
||||
return fmt.Sprintf("#%04d", n)
|
||||
})
|
||||
s := ""
|
||||
for i, str := range strs {
|
||||
if i > 0 {
|
||||
s += ", "
|
||||
}
|
||||
s += str
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sortedKeys(m map[int][]string) []int {
|
||||
keys := make([]int, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func intersect(a, b []string) []string {
|
||||
set := make(map[string]bool)
|
||||
for _, s := range a {
|
||||
set[s] = true
|
||||
}
|
||||
var result []string
|
||||
for _, s := range b {
|
||||
if set[s] {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTopologicalSort(t *testing.T) {
|
||||
issues := []Issue{
|
||||
{Number: 1, Title: "base", Deps: nil},
|
||||
{Number: 2, Title: "depends on 1", Deps: []int{1}},
|
||||
{Number: 3, Title: "independent", Deps: nil},
|
||||
{Number: 4, Title: "depends on 1 and 3", Deps: []int{1, 3}},
|
||||
}
|
||||
|
||||
result := TopologicalSort(issues)
|
||||
if result.IsErr() {
|
||||
t.Fatalf("TopologicalSort failed: %v", result.Error())
|
||||
}
|
||||
|
||||
groups := result.Unwrap()
|
||||
if len(groups) < 2 {
|
||||
t.Fatalf("expected at least 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
// Group 1 should have issues 1 and 3 (no deps)
|
||||
g1Nums := make(map[int]bool)
|
||||
for _, issue := range groups[0].Issues {
|
||||
g1Nums[issue.Number] = true
|
||||
}
|
||||
if !g1Nums[1] || !g1Nums[3] {
|
||||
t.Errorf("group 1 should contain issues 1 and 3, got %v", groups[0].Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopologicalSortCycle(t *testing.T) {
|
||||
issues := []Issue{
|
||||
{Number: 1, Title: "a", Deps: []int{2}},
|
||||
{Number: 2, Title: "b", Deps: []int{1}},
|
||||
}
|
||||
|
||||
result := TopologicalSort(issues)
|
||||
if !result.IsErr() {
|
||||
t.Error("expected cycle detection error")
|
||||
}
|
||||
if !strings.Contains(result.Error().Error(), "circular") {
|
||||
t.Errorf("expected circular dependency error, got: %v", result.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopologicalSortEmpty(t *testing.T) {
|
||||
result := TopologicalSort(nil)
|
||||
if !result.IsErr() {
|
||||
t.Error("expected error for empty issues")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeFileConflicts(t *testing.T) {
|
||||
files := map[int][]string{
|
||||
1: {"core/types.go", "shell/http.go"},
|
||||
2: {"core/types.go", "app/main.go"},
|
||||
3: {"app/other.go"},
|
||||
}
|
||||
|
||||
conflicts := AnalyzeFileConflicts(files)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
|
||||
}
|
||||
if conflicts[0].IssueA != 1 || conflicts[0].IssueB != 2 {
|
||||
t.Errorf("expected conflict between 1 and 2, got %d and %d",
|
||||
conflicts[0].IssueA, conflicts[0].IssueB)
|
||||
}
|
||||
if conflicts[0].SharedFiles[0] != "core/types.go" {
|
||||
t.Errorf("expected shared file core/types.go, got %s", conflicts[0].SharedFiles[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePlanMarkdown(t *testing.T) {
|
||||
groups := []IssueGroup{
|
||||
{Number: 1, Issues: []Issue{
|
||||
{Number: 1, Title: "base"},
|
||||
{Number: 3, Title: "other"},
|
||||
}},
|
||||
{Number: 2, Issues: []Issue{
|
||||
{Number: 2, Title: "depends", Deps: []int{1}},
|
||||
}},
|
||||
}
|
||||
|
||||
md := GeneratePlanMarkdown(groups, nil)
|
||||
if !strings.Contains(md, "## Grupo 1") {
|
||||
t.Error("markdown should contain group headers")
|
||||
}
|
||||
if !strings.Contains(md, "Issue #0001") {
|
||||
t.Error("markdown should contain issue references")
|
||||
}
|
||||
if !strings.Contains(md, "3 issues") || !strings.Contains(md, "2 grupos") {
|
||||
// Check summary table
|
||||
if !strings.Contains(md, "| 3 |") {
|
||||
t.Error("markdown should contain correct totals")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
module github.com/lucasdataproyects/parallel-executor
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require github.com/lucasdataproyects/devfactory v0.0.0
|
||||
|
||||
require (
|
||||
github.com/apache/arrow/go/v14 v14.0.2 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/flatbuffers v23.5.26+incompatible // indirect
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/marcboeker/go-duckdb v1.6.5 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.18 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/mod v0.13.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/tools v0.14.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
)
|
||||
|
||||
replace github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
|
||||
@@ -0,0 +1,45 @@
|
||||
github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw=
|
||||
github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
|
||||
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/marcboeker/go-duckdb v1.6.5 h1:XCfR1JVZxsemcSPxRQKK0R0ESfgRMHTEqh3Y+dv40SI=
|
||||
github.com/marcboeker/go-duckdb v1.6.5/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o=
|
||||
gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,6 @@
|
||||
go 1.22.2
|
||||
|
||||
use (
|
||||
.
|
||||
/home/lucas/.local_agentes/backend
|
||||
)
|
||||
@@ -0,0 +1,352 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lucasdataproyects/devfactory/core"
|
||||
dfshell "github.com/lucasdataproyects/devfactory/shell"
|
||||
|
||||
pcore "github.com/lucasdataproyects/parallel-executor/core"
|
||||
)
|
||||
|
||||
const defaultTimeout = 30 * time.Minute
|
||||
|
||||
// ExecuteIssue ejecuta claude para resolver una issue en un worktree.
|
||||
// Invoca `claude -p` con el prompt de fix-issue dentro del worktree.
|
||||
func ExecuteIssue(spec pcore.WorktreeSpec, timeout time.Duration) pcore.ExecutionResult {
|
||||
start := time.Now()
|
||||
|
||||
if timeout == 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
"Read the issue file dev/issues/%04d-*.md and implement all tasks. "+
|
||||
"Run tests after each change. Follow pure core / impure shell pattern. "+
|
||||
"When done, commit all changes with a descriptive message.",
|
||||
spec.Issue.Number,
|
||||
)
|
||||
|
||||
result := dfshell.RunWithTimeout("claude",
|
||||
timeout,
|
||||
"-p", prompt,
|
||||
"--cwd", spec.WorkDir,
|
||||
)
|
||||
|
||||
duration := time.Since(start).Round(time.Second).String()
|
||||
|
||||
if result.IsErr() {
|
||||
return pcore.ExecutionResult{
|
||||
Issue: spec.Issue,
|
||||
Success: false,
|
||||
Duration: duration,
|
||||
Error: result.Error().Error(),
|
||||
}
|
||||
}
|
||||
|
||||
cmdResult := result.Unwrap()
|
||||
if !cmdResult.Success() {
|
||||
return pcore.ExecutionResult{
|
||||
Issue: spec.Issue,
|
||||
Success: false,
|
||||
Duration: duration,
|
||||
Error: cmdResult.Stderr,
|
||||
}
|
||||
}
|
||||
|
||||
return pcore.ExecutionResult{
|
||||
Issue: spec.Issue,
|
||||
Success: true,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// PushWorktreeBranch hace push de la branch del worktree al remote.
|
||||
func PushWorktreeBranch(spec pcore.WorktreeSpec) core.Result[struct{}] {
|
||||
result := dfshell.Run("git", "-C", spec.WorkDir,
|
||||
"push", "-u", "origin", spec.BranchName,
|
||||
)
|
||||
if result.IsErr() {
|
||||
return core.Err[struct{}](fmt.Errorf("push failed for %s: %w",
|
||||
spec.BranchName, result.Error()))
|
||||
}
|
||||
return core.Ok(struct{}{})
|
||||
}
|
||||
|
||||
// MergeBranchToMaster mergea una branch a master con --no-ff.
|
||||
func MergeBranchToMaster(branchName string, repoRoot string) core.Result[struct{}] {
|
||||
// Checkout master
|
||||
result := dfshell.Run("git", "-C", repoRoot, "checkout", "master")
|
||||
if result.IsErr() {
|
||||
return core.Err[struct{}](result.Error())
|
||||
}
|
||||
|
||||
// Merge --no-ff
|
||||
message := fmt.Sprintf("merge: %s — parallel execution", branchName)
|
||||
result = dfshell.Run("git", "-C", repoRoot,
|
||||
"merge", "--no-ff", "-m", message, branchName,
|
||||
)
|
||||
if result.IsErr() {
|
||||
return core.Err[struct{}](fmt.Errorf("merge failed for %s: %w",
|
||||
branchName, result.Error()))
|
||||
}
|
||||
|
||||
return core.Ok(struct{}{})
|
||||
}
|
||||
|
||||
// DeleteBranch elimina una branch local.
|
||||
func DeleteBranch(branchName string, repoRoot string) core.Result[struct{}] {
|
||||
result := dfshell.Run("git", "-C", repoRoot, "branch", "-d", branchName)
|
||||
if result.IsErr() {
|
||||
return core.Err[struct{}](result.Error())
|
||||
}
|
||||
return core.Ok(struct{}{})
|
||||
}
|
||||
|
||||
// ReadIssueFiles lee todos los archivos de issues de un directorio.
|
||||
func ReadIssueFiles(issuesDir string) core.Result[map[string]string] {
|
||||
entries := dfshell.ListDir(issuesDir)
|
||||
if entries.IsErr() {
|
||||
return core.Err[map[string]string](entries.Error())
|
||||
}
|
||||
|
||||
files := make(map[string]string)
|
||||
for _, entry := range entries.Unwrap() {
|
||||
if !strings.HasSuffix(entry, ".md") || entry == "README.md" {
|
||||
continue
|
||||
}
|
||||
content := dfshell.ReadString(issuesDir + "/" + entry)
|
||||
if content.IsOk() {
|
||||
files[entry] = content.Unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
return core.Ok(files)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lucasdataproyects/devfactory/core"
|
||||
dfshell "github.com/lucasdataproyects/devfactory/shell"
|
||||
|
||||
pcore "github.com/lucasdataproyects/parallel-executor/core"
|
||||
)
|
||||
|
||||
// Logger escribe logs de la ejecución paralela a disco.
|
||||
type Logger struct {
|
||||
logsDir string
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// NewLogger crea un logger para la sesión actual.
|
||||
func NewLogger(repoRoot string) core.Result[*Logger] {
|
||||
sessionID := time.Now().Format("20060102-150405")
|
||||
logsDir := repoRoot + "/logs"
|
||||
|
||||
result := dfshell.MkdirAll(logsDir)
|
||||
if result.IsErr() {
|
||||
return core.Err[*Logger](result.Error())
|
||||
}
|
||||
|
||||
return core.Ok(&Logger{
|
||||
logsDir: logsDir,
|
||||
sessionID: sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
// LogIssueStart registra el inicio de ejecución de una issue.
|
||||
func (l *Logger) LogIssueStart(issue pcore.Issue) {
|
||||
msg := fmt.Sprintf("[%s] START Issue #%04d - %s\n",
|
||||
time.Now().Format("15:04:05"), issue.Number, issue.Title)
|
||||
l.appendToSession(msg)
|
||||
}
|
||||
|
||||
// LogIssueResult registra el resultado de una issue.
|
||||
func (l *Logger) LogIssueResult(result pcore.ExecutionResult) {
|
||||
status := "SUCCESS"
|
||||
if !result.Success {
|
||||
status = "FAILED"
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("[%s] %s Issue #%04d - %s (duration: %s)",
|
||||
time.Now().Format("15:04:05"), status,
|
||||
result.Issue.Number, result.Issue.Title, result.Duration)
|
||||
|
||||
if result.Error != "" {
|
||||
msg += "\n Error: " + result.Error
|
||||
}
|
||||
msg += "\n"
|
||||
|
||||
l.appendToSession(msg)
|
||||
|
||||
// Log individual por issue
|
||||
issueLogFile := fmt.Sprintf("%s/issue-%04d-%s.log",
|
||||
l.logsDir, result.Issue.Number, l.sessionID)
|
||||
content := fmt.Sprintf("Issue #%04d - %s\nStatus: %s\nDuration: %s\n",
|
||||
result.Issue.Number, result.Issue.Title, status, result.Duration)
|
||||
if result.Error != "" {
|
||||
content += "Error:\n" + result.Error + "\n"
|
||||
}
|
||||
dfshell.WriteString(issueLogFile, content)
|
||||
}
|
||||
|
||||
// WriteSummary escribe el resumen consolidado.
|
||||
func (l *Logger) WriteSummary(results []pcore.ExecutionResult) core.Result[string] {
|
||||
summaryFile := fmt.Sprintf("%s/summary-%s.txt", l.logsDir, l.sessionID)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("=" + strings.Repeat("=", 59) + "\n")
|
||||
b.WriteString(fmt.Sprintf(" Parallel Execution Summary — %s\n", l.sessionID))
|
||||
b.WriteString("=" + strings.Repeat("=", 59) + "\n\n")
|
||||
|
||||
succeeded := 0
|
||||
failed := 0
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
succeeded++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Total: %d\n", len(results)))
|
||||
b.WriteString(fmt.Sprintf("Succeeded: %d\n", succeeded))
|
||||
b.WriteString(fmt.Sprintf("Failed: %d\n\n", failed))
|
||||
|
||||
b.WriteString("Results:\n")
|
||||
b.WriteString(strings.Repeat("-", 60) + "\n")
|
||||
|
||||
for _, r := range results {
|
||||
status := "✓"
|
||||
if !r.Success {
|
||||
status = "✗"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s #%04d %-30s %s\n",
|
||||
status, r.Issue.Number, r.Issue.Title, r.Duration))
|
||||
if r.Error != "" {
|
||||
b.WriteString(fmt.Sprintf(" Error: %s\n", truncate(r.Error, 80)))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString(strings.Repeat("-", 60) + "\n")
|
||||
|
||||
writeResult := dfshell.WriteString(summaryFile, b.String())
|
||||
if writeResult.IsErr() {
|
||||
return core.Err[string](writeResult.Error())
|
||||
}
|
||||
|
||||
return core.Ok(summaryFile)
|
||||
}
|
||||
|
||||
// SessionLogFile retorna la ruta del log de sesión.
|
||||
func (l *Logger) SessionLogFile() string {
|
||||
return fmt.Sprintf("%s/parallel-execution-%s.log", l.logsDir, l.sessionID)
|
||||
}
|
||||
|
||||
func (l *Logger) appendToSession(msg string) {
|
||||
logFile := l.SessionLogFile()
|
||||
dfshell.AppendFile(logFile, []byte(msg))
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package shell contiene operaciones I/O del parallel executor.
|
||||
// Todas las funciones retornan Result[T] y tienen side effects (git, filesystem).
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lucasdataproyects/devfactory/core"
|
||||
dfshell "github.com/lucasdataproyects/devfactory/shell"
|
||||
|
||||
pcore "github.com/lucasdataproyects/parallel-executor/core"
|
||||
)
|
||||
|
||||
// CreateWorktree crea un git worktree para una issue.
|
||||
// Crea branch desde master y configura el directorio de trabajo.
|
||||
func CreateWorktree(spec pcore.WorktreeSpec, repoRoot string) core.Result[string] {
|
||||
// Verificar que no existe ya
|
||||
if dfshell.DirExists(spec.WorkDir) {
|
||||
return core.Ok(spec.WorkDir)
|
||||
}
|
||||
|
||||
// Crear directorio padre si no existe
|
||||
parentDir := spec.WorkDir[:strings.LastIndex(spec.WorkDir, "/")]
|
||||
mkResult := dfshell.MkdirAll(parentDir)
|
||||
if mkResult.IsErr() {
|
||||
return core.Err[string](mkResult.Error())
|
||||
}
|
||||
|
||||
// Actualizar master primero
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
updateResult := dfshell.RunWithContext(ctx, "git", "-C", repoRoot, "fetch", "origin", "master")
|
||||
if updateResult.IsErr() {
|
||||
// No fatal — puede no tener remote
|
||||
}
|
||||
|
||||
// Crear worktree con nueva branch desde master
|
||||
result := dfshell.Run("git", "-C", repoRoot,
|
||||
"worktree", "add",
|
||||
"-b", spec.BranchName,
|
||||
spec.WorkDir,
|
||||
"master",
|
||||
)
|
||||
if result.IsErr() {
|
||||
// Branch puede existir — intentar sin -b
|
||||
result = dfshell.Run("git", "-C", repoRoot,
|
||||
"worktree", "add",
|
||||
spec.WorkDir,
|
||||
spec.BranchName,
|
||||
)
|
||||
if result.IsErr() {
|
||||
return core.Err[string](fmt.Errorf("failed to create worktree for issue #%04d: %w",
|
||||
spec.Issue.Number, result.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
return core.Ok(spec.WorkDir)
|
||||
}
|
||||
|
||||
// RemoveWorktree elimina un worktree y su branch.
|
||||
func RemoveWorktree(spec pcore.WorktreeSpec, repoRoot string) core.Result[struct{}] {
|
||||
if !dfshell.DirExists(spec.WorkDir) {
|
||||
return core.Ok(struct{}{})
|
||||
}
|
||||
|
||||
// Remover worktree
|
||||
result := dfshell.Run("git", "-C", repoRoot,
|
||||
"worktree", "remove", "--force", spec.WorkDir,
|
||||
)
|
||||
if result.IsErr() {
|
||||
// Fallback: eliminar directorio manualmente
|
||||
os.RemoveAll(spec.WorkDir)
|
||||
// Prune worktrees huérfanos
|
||||
dfshell.Run("git", "-C", repoRoot, "worktree", "prune")
|
||||
}
|
||||
|
||||
return core.Ok(struct{}{})
|
||||
}
|
||||
|
||||
// CleanupAllWorktrees limpia todos los worktrees del directorio worktrees/.
|
||||
func CleanupAllWorktrees(repoRoot string) core.Result[int] {
|
||||
worktreesDir := repoRoot + "/worktrees"
|
||||
if !dfshell.DirExists(worktreesDir) {
|
||||
return core.Ok(0)
|
||||
}
|
||||
|
||||
entries := dfshell.ListDir(worktreesDir)
|
||||
if entries.IsErr() {
|
||||
return core.Ok(0)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, entry := range entries.Unwrap() {
|
||||
fullPath := worktreesDir + "/" + entry
|
||||
dfshell.Run("git", "-C", repoRoot, "worktree", "remove", "--force", fullPath)
|
||||
count++
|
||||
}
|
||||
|
||||
// Prune
|
||||
dfshell.Run("git", "-C", repoRoot, "worktree", "prune")
|
||||
|
||||
// Eliminar directorio vacío
|
||||
os.Remove(worktreesDir)
|
||||
|
||||
return core.Ok(count)
|
||||
}
|
||||
|
||||
// ListWorktrees devuelve los worktrees activos.
|
||||
func ListWorktrees(repoRoot string) core.Result[[]string] {
|
||||
result := dfshell.Run("git", "-C", repoRoot, "worktree", "list", "--porcelain")
|
||||
if result.IsErr() {
|
||||
return core.Err[[]string](result.Error())
|
||||
}
|
||||
|
||||
output := result.Unwrap().Output()
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
var paths []string
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "worktree ") {
|
||||
path := strings.TrimPrefix(line, "worktree ")
|
||||
if path != repoRoot {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return core.Ok(paths)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user