chore: sync from fn-registry agent
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user