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,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