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