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,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user