Files
2026-05-17 02:44:02 +02:00

419 lines
11 KiB
Go

package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// Issue represents a parsed dev/issues/*.md file.
type Issue struct {
ID string `yaml:"id" json:"id"`
Title string `yaml:"title" json:"title"`
Status string `yaml:"status" json:"status"`
Type string `yaml:"type" json:"type"`
Domain []string `yaml:"domain" json:"domain"`
Scope string `yaml:"scope" json:"scope"`
Priority string `yaml:"priority" json:"priority"`
Depends []string `yaml:"depends" json:"depends"`
Blocks []string `yaml:"blocks" json:"blocks"`
Related []string `yaml:"related" json:"related"`
Created string `yaml:"created" json:"created"`
Updated string `yaml:"updated" json:"updated"`
Tags []string `yaml:"tags" json:"tags"`
// Computed fields (not in YAML)
Path string `json:"path"`
Body string `json:"body,omitempty"`
AcceptancePct int `json:"acceptance_pct"`
DoDPct int `json:"dod_pct"`
DepsResolved bool `json:"deps_resolved"`
}
// Flow represents a parsed dev/flows/*.md file.
type Flow struct {
Name string `yaml:"name" json:"name"`
ID string `yaml:"id" json:"id"`
Status string `yaml:"status" json:"status"`
Created string `yaml:"created" json:"created"`
Updated string `yaml:"updated" json:"updated"`
Priority string `yaml:"priority" json:"priority"`
Risk string `yaml:"risk" json:"risk"`
RelatedIssues []string `yaml:"related_issues" json:"related_issues"`
Apps []string `yaml:"apps" json:"apps"`
Trigger string `yaml:"trigger" json:"trigger"`
Schedule string `yaml:"schedule" json:"schedule"`
ExpectedRuntimeS int `yaml:"expected_runtime_s" json:"expected_runtime_s"`
Tags []string `yaml:"tags" json:"tags"`
Pattern string `yaml:"pattern" json:"pattern"`
// Computed fields
Path string `json:"path"`
Body string `json:"body,omitempty"`
AcceptancePct int `json:"acceptance_pct"`
DoDPct int `json:"dod_pct"`
UserFacingPct int `json:"user_facing_pct"`
}
// splitFrontmatter splits a markdown file into YAML frontmatter and body.
// Returns (yamlStr, body, err).
func splitFrontmatter(path string) (string, string, error) {
f, err := os.Open(path)
if err != nil {
return "", "", fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return "", "", fmt.Errorf("scan %s: %w", path, err)
}
if len(lines) == 0 {
return "", "", nil
}
// Find frontmatter delimiters
if lines[0] != "---" {
// No frontmatter — entire file is body
return "", strings.Join(lines, "\n"), nil
}
end := -1
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
end = i
break
}
}
if end == -1 {
return "", strings.Join(lines, "\n"), nil
}
yamlStr := strings.Join(lines[1:end], "\n")
body := ""
if end+1 < len(lines) {
body = strings.Join(lines[end+1:], "\n")
}
return yamlStr, body, nil
}
// countCheckboxes counts checked and total checkboxes in a section of markdown.
// section is the content of the section (after the heading line).
func countCheckboxes(body string, sectionHeading string) (checked, total int) {
lines := strings.Split(body, "\n")
inSection := false
// Match headings of same or greater depth to detect section end
headingRe := regexp.MustCompile(`^#{1,6}\s`)
var sectionLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Detect section start - flexible: "## Acceptance", "## Definition of Done"
if !inSection {
// case-insensitive heading match
lower := strings.ToLower(trimmed)
headingLower := strings.ToLower(sectionHeading)
if strings.HasPrefix(lower, "##") && strings.Contains(lower, headingLower) {
inSection = true
continue
}
} else {
// Stop at any heading of ## or higher
if headingRe.MatchString(trimmed) && (strings.HasPrefix(trimmed, "## ") || strings.HasPrefix(trimmed, "# ")) {
break
}
// Also stop at "### " sub-headings only if they are a different section
// Actually: stop at any heading that's the same level or higher
if strings.HasPrefix(trimmed, "## ") {
break
}
sectionLines = append(sectionLines, line)
}
}
for _, l := range sectionLines {
t := strings.TrimSpace(l)
if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") {
checked++
total++
} else if strings.HasPrefix(t, "- [ ]") {
total++
}
}
return checked, total
}
// pct computes integer percentage (0-100).
func pct(checked, total int) int {
if total == 0 {
return 0
}
return (checked * 100) / total
}
// ParseIssue parses a single issue markdown file.
func ParseIssue(path string) (Issue, error) {
yamlStr, body, err := splitFrontmatter(path)
if err != nil {
return Issue{}, err
}
var issue Issue
if yamlStr != "" {
if err := yaml.Unmarshal([]byte(yamlStr), &issue); err != nil {
return Issue{}, fmt.Errorf("yaml parse %s: %w", path, err)
}
}
issue.Path = path
issue.Body = body
// Compute AcceptancePct
acceptChecked, acceptTotal := countCheckboxes(body, "Acceptance")
issue.AcceptancePct = pct(acceptChecked, acceptTotal)
// Compute DoDPct
dodChecked, dodTotal := countCheckboxes(body, "Definition of Done")
issue.DoDPct = pct(dodChecked, dodTotal)
return issue, nil
}
// ParseFlow parses a single flow markdown file.
func ParseFlow(path string) (Flow, error) {
yamlStr, body, err := splitFrontmatter(path)
if err != nil {
return Flow{}, err
}
var flow Flow
if yamlStr != "" {
if err := yaml.Unmarshal([]byte(yamlStr), &flow); err != nil {
return Flow{}, fmt.Errorf("yaml parse %s: %w", path, err)
}
}
flow.Path = path
flow.Body = body
// Compute AcceptancePct
acceptChecked, acceptTotal := countCheckboxes(body, "Acceptance")
flow.AcceptancePct = pct(acceptChecked, acceptTotal)
// Compute DoDPct from full DoD section (all checkboxes in that section)
dodChecked, dodTotal := countCheckboxesDeep(body, "Definition of Done")
flow.DoDPct = pct(dodChecked, dodTotal)
// UserFacingPct from ### User-facing sub-block
ufChecked, ufTotal := countCheckboxesSubsection(body, "User-facing")
flow.UserFacingPct = pct(ufChecked, ufTotal)
return flow, nil
}
// countCheckboxesDeep counts all checkboxes under a section heading (## or ###), including sub-sections.
func countCheckboxesDeep(body, sectionHeading string) (checked, total int) {
lines := strings.Split(body, "\n")
inSection := false
sectionDepth := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
headingLower := strings.ToLower(sectionHeading)
if !inSection {
if isHeading(trimmed) && strings.Contains(lower, headingLower) {
inSection = true
sectionDepth = headingDepth(trimmed)
continue
}
} else {
// Stop if we hit a heading at same or lesser depth
if isHeading(trimmed) && headingDepth(trimmed) <= sectionDepth {
break
}
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") {
checked++
total++
} else if strings.HasPrefix(t, "- [ ]") {
total++
}
}
}
return checked, total
}
// countCheckboxesSubsection counts checkboxes under a ### sub-heading.
func countCheckboxesSubsection(body, subHeading string) (checked, total int) {
lines := strings.Split(body, "\n")
inSection := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
headingLower := strings.ToLower(subHeading)
if !inSection {
if isHeading(trimmed) && strings.Contains(lower, headingLower) {
inSection = true
continue
}
} else {
if isHeading(trimmed) {
break
}
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") {
checked++
total++
} else if strings.HasPrefix(t, "- [ ]") {
total++
}
}
}
return checked, total
}
func isHeading(line string) bool {
return regexp.MustCompile(`^#{1,6}\s`).MatchString(line)
}
func headingDepth(line string) int {
for i, c := range line {
if c != '#' {
return i
}
}
return 0
}
// isSkippable returns true for files that should be skipped when loading issues.
func isSkippable(name string) bool {
lower := strings.ToLower(name)
skip := []string{"readme", "template", "index", "agent_guide"}
for _, s := range skip {
if strings.Contains(lower, s) {
return true
}
}
return false
}
// LoadAllIssues loads all issues from dev/issues/ (open) and dev/issues/completed/ (completed).
// If onlyOpen is true, skips completed/.
func LoadAllIssues(root string) ([]Issue, error) {
return loadIssuesFromDirs(root, false)
}
func loadIssuesFromDirs(root string, onlyOpen bool) ([]Issue, error) {
dirs := []string{
filepath.Join(root, "dev", "issues"),
}
if !onlyOpen {
dirs = append(dirs, filepath.Join(root, "dev", "issues", "completed"))
}
// Deduplicate by ID: first occurrence wins (dev/issues/ takes precedence over completed/).
seen := make(map[string]bool)
var issues []Issue
for _, dir := range dirs {
entries, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return nil, err
}
for _, path := range entries {
name := filepath.Base(path)
if isSkippable(name) {
continue
}
issue, err := ParseIssue(path)
if err != nil {
// Skip malformed files with a warning
fmt.Fprintf(os.Stderr, "warn: skip %s: %v\n", path, err)
continue
}
// Deduplicate by ID; if same file name appears in both dirs, keep first seen.
key := issue.ID
if key == "" {
key = name // fallback to filename
}
if seen[key] {
continue
}
seen[key] = true
issues = append(issues, issue)
}
}
return issues, nil
}
// LoadOpenIssues loads only non-completed issues (from dev/issues/, not completed/).
func LoadOpenIssues(root string) ([]Issue, error) {
return loadIssuesFromDirs(root, true)
}
// LoadAllFlows loads all flows from dev/flows/*.md.
func LoadAllFlows(root string) ([]Flow, error) {
dir := filepath.Join(root, "dev", "flows")
entries, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return nil, err
}
var flows []Flow
for _, path := range entries {
name := filepath.Base(path)
if isSkippable(name) {
continue
}
flow, err := ParseFlow(path)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: skip %s: %v\n", path, err)
continue
}
flows = append(flows, flow)
}
return flows, nil
}
// ComputeDepsResolved sets DepsResolved on each issue based on the full list.
func ComputeDepsResolved(issues []Issue) {
// Build a map of id -> status
statusMap := make(map[string]string)
for _, iss := range issues {
if iss.ID != "" {
statusMap[iss.ID] = iss.Status
}
}
for i := range issues {
if len(issues[i].Depends) == 0 {
issues[i].DepsResolved = true
continue
}
allResolved := true
for _, dep := range issues[i].Depends {
dep = strings.TrimSpace(dep)
if dep == "" {
continue
}
st, found := statusMap[dep]
if !found || st != "completado" {
allResolved = false
break
}
}
issues[i].DepsResolved = allResolved
}
}