feat(backend): issues/flows sync layer (issue 0119)
Read dev/issues/*.md and dev/flows/*.md as kanban cards via new
/api/boards/{issues|flows}/cards endpoints. PATCH writes status back
to the frontmatter atomically (tmp + rename), POST .../launch proxies
to agent_runner_api.
- issues_source.go: scan + parse frontmatter (yaml.v3) into IssueCard.
Skips README/INDEX/AGENT_GUIDE. Malformed YAML yields parse-error
cards (no crash). Description = first 5 body lines (no full body).
- flows_source.go: same shape, distinct status->column mapping
(pending/running/done/deferred -> Pending/Running/Done/Deferred).
- frontmatter_edit.go: PatchFrontmatterField — atomic, preserves the
rest of the file byte-for-byte, inserts key if missing.
- handlers_boards.go: list + patch + launch endpoints, taxonomy 0103
enforced. Cache 30s in memory, thread-safe (mutex), invalidated on
PATCH. Launch returns 502 with suggestion when runner is down.
- main.go: SkipPaths += "/api/boards/" so the C++ frontend hits the
read endpoints without a kanban_web session.
Smoke (FN_REGISTRY_ROOT pointed at the worktree, 87 issues + 9 flows
on disk):
GET /api/boards/issues/cards -> 200, 87 cards
GET /api/boards/flows/cards -> 200, 9 cards
PATCH /api/boards/issues/cards/0119 {status:en-curso} -> 200,
file mtime changes, frontmatter rewritten, rest preserved
POST /api/boards/issues/cards/0119/launch -> 502
agent_runner_unreachable (expected, runner not yet implemented)
Tests: issues_source_test (3 cases incl. malformed + missing status),
flows_source_test (3 cases), frontmatter_edit_test (4 cases incl.
atomic rename + no tmp leftovers). Pre-existing tools_test failure
on TestExecuteTool_MoveCard_BetweenColumns_OpensHistory is unrelated
(CardHistoryResponse type assert, not touched here).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// flowsCache mirrors issuesCache but for dev/flows/*.md.
|
||||
var flowsCache = &cardsCache{ttl: 30 * time.Second}
|
||||
|
||||
// mapFlowStatusToColumn maps flow frontmatter status -> kanban column id.
|
||||
// Flows use a different vocabulary than issues.
|
||||
func mapFlowStatusToColumn(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "pending", "":
|
||||
return "Pending"
|
||||
case "running":
|
||||
return "Running"
|
||||
case "done":
|
||||
return "Done"
|
||||
case "deferred":
|
||||
return "Deferred"
|
||||
default:
|
||||
return "Pending"
|
||||
}
|
||||
}
|
||||
|
||||
func loadFlowCards(dir string) ([]IssueCard, error) {
|
||||
return loadCardsFromDir(dir, mapFlowStatusToColumn, "flow")
|
||||
}
|
||||
|
||||
func flowsDir() string {
|
||||
return filepath.Join(registryRoot(), "dev", "flows")
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadFlowCards_MapsStatuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, "INDEX.md", "skip")
|
||||
writeFixture(t, dir, "0001-foo.md", "---\nid: 0001\nname: foo\nstatus: pending\n---\nbody\n")
|
||||
writeFixture(t, dir, "0002-bar.md", "---\nid: 0002\nname: bar\nstatus: running\n---\nbody\n")
|
||||
writeFixture(t, dir, "0003-baz.md", "---\nid: 0003\nname: baz\nstatus: done\n---\nbody\n")
|
||||
writeFixture(t, dir, "0004-bop.md", "---\nid: 0004\nname: bop\nstatus: deferred\n---\nbody\n")
|
||||
|
||||
cards, err := loadFlowCards(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(cards) != 4 {
|
||||
t.Fatalf("expected 4 cards, got %d", len(cards))
|
||||
}
|
||||
want := map[string]string{
|
||||
"0001": "Pending",
|
||||
"0002": "Running",
|
||||
"0003": "Done",
|
||||
"0004": "Deferred",
|
||||
}
|
||||
for _, c := range cards {
|
||||
if want[c.ID] != c.ColumnID {
|
||||
t.Fatalf("%s: expected column %s, got %s", c.ID, want[c.ID], c.ColumnID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFlowCards_MissingStatusDefaultsPending(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, "0010-nostatus.md", "---\nid: 0010\nname: empty\n---\nbody\n")
|
||||
cards, err := loadFlowCards(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("expected 1 card")
|
||||
}
|
||||
if cards[0].ColumnID != "Pending" {
|
||||
t.Fatalf("expected Pending column, got %q", cards[0].ColumnID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFlowCards_MalformedDoesNotCrash(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, "0011-bad.md", "---\nid: 0011\nstatus: pending\n : malformed yaml\n---\nbody\n")
|
||||
cards, err := loadFlowCards(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(cards) != 1 || cards[0].ParseError == "" {
|
||||
t.Fatalf("expected 1 card with ParseError, got %#v", cards)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PatchFrontmatterField rewrites the value of a single YAML frontmatter key
|
||||
// in the file at filePath, preserving everything else byte-for-byte.
|
||||
//
|
||||
// The file MUST begin with a "---" frontmatter delimiter (issue 0100 canonical).
|
||||
// The function:
|
||||
// - locates the frontmatter block delimited by leading "---" and closing "---"
|
||||
// - finds a line matching "<key>:" at the root level (no indent)
|
||||
// - replaces the value portion (keeping the key) with newValue
|
||||
// - writes back atomically via temp file + rename
|
||||
//
|
||||
// If the key does not exist, it is inserted just before the closing "---" line.
|
||||
// The function does NOT validate that newValue is YAML-safe; callers should
|
||||
// pass plain scalars (no embedded newlines).
|
||||
func PatchFrontmatterField(filePath, key, newValue string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("PatchFrontmatterField: empty key")
|
||||
}
|
||||
if strings.ContainsAny(newValue, "\n\r") {
|
||||
return fmt.Errorf("PatchFrontmatterField: newValue must not contain newlines")
|
||||
}
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
out, err := patchFrontmatterBytes(raw, key, newValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
tmp, err := os.CreateTemp(dir, ".fm.*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tmp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(out); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("write tmp: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("close tmp: %w", err)
|
||||
}
|
||||
// Preserve original file mode if possible.
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
_ = os.Chmod(tmpName, info.Mode())
|
||||
}
|
||||
if err := os.Rename(tmpName, filePath); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func patchFrontmatterBytes(raw []byte, key, newValue string) ([]byte, error) {
|
||||
// Detect line ending convention (default to LF).
|
||||
eol := []byte("\n")
|
||||
if bytes.Contains(raw, []byte("\r\n")) {
|
||||
eol = []byte("\r\n")
|
||||
}
|
||||
|
||||
// Find frontmatter boundaries. First line must be "---".
|
||||
lines := splitKeepEOL(raw)
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("empty file")
|
||||
}
|
||||
if !isDashDashDash(lines[0]) {
|
||||
return nil, fmt.Errorf("no frontmatter (file does not start with '---')")
|
||||
}
|
||||
closeIdx := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if isDashDashDash(lines[i]) {
|
||||
closeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if closeIdx < 0 {
|
||||
return nil, fmt.Errorf("no frontmatter close delimiter")
|
||||
}
|
||||
|
||||
keyPrefix := key + ":"
|
||||
found := -1
|
||||
for i := 1; i < closeIdx; i++ {
|
||||
trimmed := strings.TrimRight(strings.TrimRight(string(lines[i]), "\n"), "\r")
|
||||
// Only match top-level keys (no leading whitespace).
|
||||
if !strings.HasPrefix(trimmed, keyPrefix) {
|
||||
continue
|
||||
}
|
||||
// Ensure next char after key is ':' followed by space or EOL (avoid prefix collisions).
|
||||
afterKey := trimmed[len(keyPrefix):]
|
||||
if afterKey != "" && afterKey[0] != ' ' && afterKey[0] != '\t' {
|
||||
continue
|
||||
}
|
||||
found = i
|
||||
break
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if found >= 0 {
|
||||
// Replace just the value on that line, preserving EOL.
|
||||
original := lines[found]
|
||||
// Compute EOL preserved at end.
|
||||
lineEOL := []byte{}
|
||||
if bytes.HasSuffix(original, []byte("\r\n")) {
|
||||
lineEOL = []byte("\r\n")
|
||||
} else if bytes.HasSuffix(original, []byte("\n")) {
|
||||
lineEOL = []byte("\n")
|
||||
}
|
||||
newLine := []byte(key + ": " + newValue)
|
||||
newLine = append(newLine, lineEOL...)
|
||||
for i, l := range lines {
|
||||
if i == found {
|
||||
buf.Write(newLine)
|
||||
} else {
|
||||
buf.Write(l)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Insert just before closeIdx.
|
||||
insertion := []byte(key + ": " + newValue)
|
||||
insertion = append(insertion, eol...)
|
||||
for i, l := range lines {
|
||||
if i == closeIdx {
|
||||
buf.Write(insertion)
|
||||
}
|
||||
buf.Write(l)
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// splitKeepEOL splits raw into lines, preserving the trailing EOL on each line.
|
||||
func splitKeepEOL(raw []byte) [][]byte {
|
||||
var lines [][]byte
|
||||
start := 0
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] == '\n' {
|
||||
lines = append(lines, raw[start:i+1])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(raw) {
|
||||
lines = append(lines, raw[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func isDashDashDash(line []byte) bool {
|
||||
s := strings.TrimRight(strings.TrimRight(string(line), "\n"), "\r")
|
||||
return s == "---"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPatchFrontmatterField_UpdateExistingKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "0119-x.md")
|
||||
original := "---\n" +
|
||||
"id: \"0119\"\n" +
|
||||
"title: \"Test\"\n" +
|
||||
"status: pendiente\n" +
|
||||
"priority: alta\n" +
|
||||
"tags: [a, b]\n" +
|
||||
"---\n" +
|
||||
"\n" +
|
||||
"# Body heading\n" +
|
||||
"\n" +
|
||||
"Some body.\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := PatchFrontmatterField(path, "status", "en-curso"); err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
gotStr := string(got)
|
||||
if !strings.Contains(gotStr, "status: en-curso") {
|
||||
t.Fatalf("expected status: en-curso, got:\n%s", gotStr)
|
||||
}
|
||||
// Preserve everything else.
|
||||
for _, line := range []string{
|
||||
`id: "0119"`,
|
||||
`title: "Test"`,
|
||||
`priority: alta`,
|
||||
`tags: [a, b]`,
|
||||
`# Body heading`,
|
||||
`Some body.`,
|
||||
} {
|
||||
if !strings.Contains(gotStr, line) {
|
||||
t.Fatalf("line %q lost after patch, got:\n%s", line, gotStr)
|
||||
}
|
||||
}
|
||||
// Ensure original status line is gone (no duplicate).
|
||||
if strings.Count(gotStr, "status:") != 1 {
|
||||
t.Fatalf("expected exactly one status: line, got %d:\n%s", strings.Count(gotStr, "status:"), gotStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchFrontmatterField_InsertMissingKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "0119-x.md")
|
||||
original := "---\n" +
|
||||
"id: \"0119\"\n" +
|
||||
"title: \"Test\"\n" +
|
||||
"---\n" +
|
||||
"body\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := PatchFrontmatterField(path, "status", "done"); err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
gotStr := string(got)
|
||||
if !strings.Contains(gotStr, "status: done") {
|
||||
t.Fatalf("missing inserted status line:\n%s", gotStr)
|
||||
}
|
||||
if !strings.Contains(gotStr, "body") {
|
||||
t.Fatalf("body lost:\n%s", gotStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchFrontmatterField_NoFrontmatter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "plain.md")
|
||||
_ = os.WriteFile(path, []byte("just a body\n"), 0o644)
|
||||
err := PatchFrontmatterField(path, "status", "done")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing frontmatter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchFrontmatterField_AtomicNoLeftovers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "0119.md")
|
||||
_ = os.WriteFile(path, []byte("---\nid: \"0119\"\nstatus: pendiente\n---\n"), 0o644)
|
||||
if err := PatchFrontmatterField(path, "status", "en-curso"); err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".fm.") || strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Fatalf("leftover tmp file: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -416,7 +416,7 @@ func handlePurgeCard(db *DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route {
|
||||
return []infra.Route{
|
||||
routes := []infra.Route{
|
||||
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
|
||||
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)},
|
||||
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
|
||||
@@ -449,6 +449,8 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
||||
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
||||
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
|
||||
}
|
||||
routes = append(routes, boardRoutes()...)
|
||||
return routes
|
||||
}
|
||||
|
||||
func handleListTags(db *DB) http.HandlerFunc {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
// agentRunnerEndpoint returns the agent_runner_api base URL.
|
||||
// Override with KANBAN_AGENT_RUNNER_API env var.
|
||||
func agentRunnerEndpoint() string {
|
||||
if v := strings.TrimSpace(os.Getenv("KANBAN_AGENT_RUNNER_API")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "http://127.0.0.1:8486"
|
||||
}
|
||||
|
||||
// allowedStatusForBoard returns the canonical statuses a PATCH can set on a
|
||||
// given board. Anything else returns 400 (taxonomy issue 0103).
|
||||
func allowedStatusForBoard(board string) []string {
|
||||
switch board {
|
||||
case "issues":
|
||||
return []string{"pendiente", "en-curso", "en-revisión", "en-revision", "done", "deferred"}
|
||||
case "flows":
|
||||
return []string{"pending", "running", "done", "deferred"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedStatus(board, status string) bool {
|
||||
allowed := allowedStatusForBoard(board)
|
||||
for _, a := range allowed {
|
||||
if a == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dirAndCacheForBoard returns the filesystem directory + cache for a board
|
||||
// name. Unknown boards yield ("", nil).
|
||||
func dirAndCacheForBoard(board string) (string, *cardsCache, func(string) string) {
|
||||
switch board {
|
||||
case "issues":
|
||||
return issuesDir(), issuesCache, mapIssueStatusToColumn
|
||||
case "flows":
|
||||
return flowsDir(), flowsCache, mapFlowStatusToColumn
|
||||
default:
|
||||
return "", nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// findCardFile locates the .md file in dir whose leading numeric id matches
|
||||
// the given card id. Returns "" if not found.
|
||||
func findCardFile(dir, id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".md") {
|
||||
continue
|
||||
}
|
||||
if isSkippedMarkdown(name) {
|
||||
continue
|
||||
}
|
||||
if deriveIDFromFilename(name) == id {
|
||||
return filepath.Join(dir, name), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// GET /api/boards/{board}/cards
|
||||
func handleListBoardCards() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
board := r.PathValue("board")
|
||||
dir, cache, _ := dirAndCacheForBoard(board)
|
||||
if dir == "" {
|
||||
notFound(w, "unknown board: "+board)
|
||||
return
|
||||
}
|
||||
if cached, ok := cache.get(); ok {
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
var (
|
||||
cards []IssueCard
|
||||
err error
|
||||
)
|
||||
switch board {
|
||||
case "issues":
|
||||
cards, err = loadIssueCards(dir)
|
||||
case "flows":
|
||||
cards, err = loadFlowCards(dir)
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
cache.set(cards)
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, cards)
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/boards/{board}/cards/{id} body: { status: "..." }
|
||||
func handlePatchBoardCard() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
board := r.PathValue("board")
|
||||
id := r.PathValue("id")
|
||||
dir, cache, _ := dirAndCacheForBoard(board)
|
||||
if dir == "" {
|
||||
notFound(w, "unknown board: "+board)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(body.Status)
|
||||
if status == "" {
|
||||
badRequest(w, "status required")
|
||||
return
|
||||
}
|
||||
if !isAllowedStatus(board, status) {
|
||||
badRequest(w, fmt.Sprintf("invalid status for board %q: %q (allowed: %s)",
|
||||
board, status, strings.Join(allowedStatusForBoard(board), ", ")))
|
||||
return
|
||||
}
|
||||
file, err := findCardFile(dir, id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if file == "" {
|
||||
notFound(w, fmt.Sprintf("card %q not found on board %q", id, board))
|
||||
return
|
||||
}
|
||||
// Patch status; also bump updated to today (YYYY-MM-DD).
|
||||
if err := PatchFrontmatterField(file, "status", status); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = PatchFrontmatterField(file, "updated", time.Now().UTC().Format("2006-01-02"))
|
||||
cache.invalidate()
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"id": id,
|
||||
"board": board,
|
||||
"status": status,
|
||||
"file": file,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/boards/{board}/cards/{id}/launch
|
||||
// Proxies to agent_runner_api at /api/runs with payload including the issue id
|
||||
// and the DoD items pulled from the .md frontmatter. If the runner is
|
||||
// unreachable, returns 502 with a suggestion.
|
||||
func handleLaunchBoardCard() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
board := r.PathValue("board")
|
||||
id := r.PathValue("id")
|
||||
dir, _, statusMapper := dirAndCacheForBoard(board)
|
||||
if dir == "" {
|
||||
notFound(w, "unknown board: "+board)
|
||||
return
|
||||
}
|
||||
file, err := findCardFile(dir, id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if file == "" {
|
||||
notFound(w, fmt.Sprintf("card %q not found on board %q", id, board))
|
||||
return
|
||||
}
|
||||
card, err := parseCardFile(file, statusMapper)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
// Drain incoming body (optional overrides from client). We do not
|
||||
// forward it as-is to avoid trust issues; we build a clean payload.
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
||||
payload := map[string]any{
|
||||
"board": board,
|
||||
"issue_id": card.ExternalID,
|
||||
"title": card.Title,
|
||||
"priority": card.Priority,
|
||||
"type": card.Type,
|
||||
"flow_id": card.FlowID,
|
||||
"dod_items": card.DoDItems,
|
||||
"file_path": card.FilePath,
|
||||
"launched_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
buf, _ := json.Marshal(payload)
|
||||
|
||||
url := strings.TrimRight(agentRunnerEndpoint(), "/") + "/api/runs"
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, url, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{
|
||||
Status: http.StatusBadGateway,
|
||||
Code: "agent_runner_unreachable",
|
||||
Message: fmt.Sprintf("could not reach agent_runner_api at %s: %v (suggestion: start agent_runner_api service)", url, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// Forward status + body verbatim so the UI can show backend errors.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
// boardRoutes returns the additional routes for issues/flows boards. Called
|
||||
// from apiRoutes() in handlers.go.
|
||||
func boardRoutes() []infra.Route {
|
||||
return []infra.Route{
|
||||
{Method: "GET", Path: "/api/boards/{board}/cards", Handler: handleListBoardCards()},
|
||||
{Method: "PATCH", Path: "/api/boards/{board}/cards/{id}", Handler: handlePatchBoardCard()},
|
||||
{Method: "POST", Path: "/api/boards/{board}/cards/{id}/launch", Handler: handleLaunchBoardCard()},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// IssueCard is the on-the-wire card representation for issues/flows boards.
|
||||
// It mirrors enough of the regular Card shape so the kanban UI can render it,
|
||||
// but it is built from a .md frontmatter file (NOT from operations.db).
|
||||
type IssueCard struct {
|
||||
ID string `json:"id"` // canonical "external_id" (e.g. "0119")
|
||||
ExternalID string `json:"external_id"` // same as ID, for clarity
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"` // first ~5 lines of body
|
||||
Status string `json:"status"` // raw frontmatter status (pendiente/en-curso/done/deferred)
|
||||
ColumnID string `json:"column_id"` // mapped column ("Backlog"/"Doing"/"Review"/"Done"/"Deferred")
|
||||
Priority string `json:"priority"` // alta/media/baja
|
||||
Type string `json:"type"` // feature/bug/chore/...
|
||||
Tag string `json:"tag"` // same as type, for card.tag convenience
|
||||
Tags []string `json:"tags"`
|
||||
FlowID string `json:"flow_id"` // frontmatter flow
|
||||
DoDItems []string `json:"dod_items"` // from dod_evidence_schema if present
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
FilePath string `json:"file_path"` // relative path under registry root
|
||||
ParseError string `json:"parse_error,omitempty"`
|
||||
}
|
||||
|
||||
type issueFrontmatter struct {
|
||||
ID string `yaml:"id"`
|
||||
Title string `yaml:"title"`
|
||||
Status string `yaml:"status"`
|
||||
Type string `yaml:"type"`
|
||||
Priority string `yaml:"priority"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Flow string `yaml:"flow"`
|
||||
Created string `yaml:"created"`
|
||||
Updated string `yaml:"updated"`
|
||||
DoDEvidenceSchema []any `yaml:"dod_evidence_schema"`
|
||||
Extra map[string]interface{} `yaml:",inline"`
|
||||
}
|
||||
|
||||
// --- in-memory cache ---------------------------------------------------------
|
||||
|
||||
type cardsCache struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
cards []IssueCard
|
||||
dir string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func (c *cardsCache) get() ([]IssueCard, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if time.Since(c.at) < c.ttl && c.cards != nil {
|
||||
out := make([]IssueCard, len(c.cards))
|
||||
copy(out, c.cards)
|
||||
return out, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (c *cardsCache) set(cards []IssueCard) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cards = make([]IssueCard, len(cards))
|
||||
copy(c.cards, cards)
|
||||
c.at = time.Now()
|
||||
}
|
||||
|
||||
func (c *cardsCache) invalidate() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.at = time.Time{}
|
||||
c.cards = nil
|
||||
}
|
||||
|
||||
var (
|
||||
issuesCache = &cardsCache{ttl: 30 * time.Second}
|
||||
)
|
||||
|
||||
// mapIssueStatusToColumn maps canonical issue frontmatter statuses to kanban
|
||||
// column ids. Falls back to "Backlog" for unknown / empty values.
|
||||
func mapIssueStatusToColumn(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "pendiente", "":
|
||||
return "Backlog"
|
||||
case "en-curso":
|
||||
return "Doing"
|
||||
case "en-revisión", "en-revision":
|
||||
return "Review"
|
||||
case "done":
|
||||
return "Done"
|
||||
case "deferred":
|
||||
return "Deferred"
|
||||
default:
|
||||
return "Backlog"
|
||||
}
|
||||
}
|
||||
|
||||
// loadIssueCards scans issuesDir for *.md issue files, parses each frontmatter
|
||||
// and returns the resulting cards. README/INDEX/AGENT_GUIDE and files inside
|
||||
// completed/ are skipped. Parse errors do NOT abort the scan — they yield a
|
||||
// card with ParseError set so the UI can surface them.
|
||||
//
|
||||
// Results are sorted by updated_at desc, then id asc.
|
||||
func loadIssueCards(issuesDir string) ([]IssueCard, error) {
|
||||
return loadCardsFromDir(issuesDir, mapIssueStatusToColumn, "issue")
|
||||
}
|
||||
|
||||
// loadCardsFromDir is the shared implementation for issues and flows.
|
||||
// statusMapper translates frontmatter status -> column id for the given board.
|
||||
func loadCardsFromDir(dir string, statusMapper func(string) string, kind string) ([]IssueCard, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", dir, err)
|
||||
}
|
||||
out := make([]IssueCard, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".md") {
|
||||
continue
|
||||
}
|
||||
if isSkippedMarkdown(name) {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, name)
|
||||
c, err := parseCardFile(full, statusMapper)
|
||||
if err != nil {
|
||||
// Surface as a parse-error card so the UI still shows it.
|
||||
out = append(out, IssueCard{
|
||||
ID: deriveIDFromFilename(name),
|
||||
ExternalID: deriveIDFromFilename(name),
|
||||
Title: name,
|
||||
Status: "pendiente",
|
||||
ColumnID: statusMapper(""),
|
||||
FilePath: full,
|
||||
ParseError: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].UpdatedAt != out[j].UpdatedAt {
|
||||
return out[i].UpdatedAt > out[j].UpdatedAt
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
_ = kind // reserved for telemetry/log labels
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isSkippedMarkdown(name string) bool {
|
||||
upper := strings.ToUpper(name)
|
||||
switch upper {
|
||||
case "README.MD", "INDEX.MD", "AGENT_GUIDE.MD":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deriveIDFromFilename pulls the leading numeric id segment from a filename
|
||||
// like "0119-foo-bar.md" -> "0119". If no leading digits, returns the
|
||||
// stem without extension.
|
||||
func deriveIDFromFilename(name string) string {
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
for i := 0; i < len(stem); i++ {
|
||||
if stem[i] < '0' || stem[i] > '9' {
|
||||
if i == 0 {
|
||||
return stem
|
||||
}
|
||||
return stem[:i]
|
||||
}
|
||||
}
|
||||
return stem
|
||||
}
|
||||
|
||||
// parseCardFile reads filePath, splits frontmatter from body and returns a
|
||||
// populated IssueCard. The body's first ~5 non-empty lines (after the first
|
||||
// markdown heading) become the description.
|
||||
func parseCardFile(filePath string, statusMapper func(string) string) (IssueCard, error) {
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return IssueCard{}, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
fmText, body, err := splitFrontmatter(raw)
|
||||
if err != nil {
|
||||
return IssueCard{}, err
|
||||
}
|
||||
|
||||
var fm issueFrontmatter
|
||||
if err := yaml.Unmarshal(fmText, &fm); err != nil {
|
||||
return IssueCard{}, fmt.Errorf("yaml: %w", err)
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(fm.ID)
|
||||
if id == "" {
|
||||
id = deriveIDFromFilename(filepath.Base(filePath))
|
||||
}
|
||||
status := strings.TrimSpace(fm.Status)
|
||||
if status == "" {
|
||||
status = "pendiente"
|
||||
}
|
||||
|
||||
card := IssueCard{
|
||||
ID: id,
|
||||
ExternalID: id,
|
||||
Title: strings.TrimSpace(fm.Title),
|
||||
Status: status,
|
||||
ColumnID: statusMapper(status),
|
||||
Priority: strings.TrimSpace(fm.Priority),
|
||||
Type: strings.TrimSpace(fm.Type),
|
||||
Tag: strings.TrimSpace(fm.Type),
|
||||
Tags: fm.Tags,
|
||||
FlowID: strings.TrimSpace(fm.Flow),
|
||||
UpdatedAt: strings.TrimSpace(fm.Updated),
|
||||
CreatedAt: strings.TrimSpace(fm.Created),
|
||||
FilePath: filePath,
|
||||
DoDItems: dodItemsFromSchema(fm.DoDEvidenceSchema),
|
||||
}
|
||||
if card.Title == "" {
|
||||
card.Title = filepath.Base(filePath)
|
||||
}
|
||||
card.Description = firstBodyLines(body, 5)
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// splitFrontmatter expects raw to start with "---\n". Returns frontmatter
|
||||
// bytes (without the delimiters) and body bytes.
|
||||
func splitFrontmatter(raw []byte) ([]byte, []byte, error) {
|
||||
// Tolerate optional BOM.
|
||||
if bytes.HasPrefix(raw, []byte{0xEF, 0xBB, 0xBF}) {
|
||||
raw = raw[3:]
|
||||
}
|
||||
if !bytes.HasPrefix(raw, []byte("---")) {
|
||||
return nil, nil, fmt.Errorf("no frontmatter")
|
||||
}
|
||||
// Find end of first delimiter line.
|
||||
firstNL := bytes.IndexByte(raw, '\n')
|
||||
if firstNL < 0 {
|
||||
return nil, nil, fmt.Errorf("malformed frontmatter (no newline after opening ---)")
|
||||
}
|
||||
rest := raw[firstNL+1:]
|
||||
// Find closing delimiter at start of a line.
|
||||
closeIdx := -1
|
||||
searchFrom := 0
|
||||
for {
|
||||
idx := bytes.Index(rest[searchFrom:], []byte("\n---"))
|
||||
if idx < 0 {
|
||||
// also accept frontmatter that starts immediately with "---" then directly "---" on next line
|
||||
if bytes.HasPrefix(rest, []byte("---")) {
|
||||
closeIdx = 0
|
||||
}
|
||||
break
|
||||
}
|
||||
absolute := searchFrom + idx + 1 // skip the \n
|
||||
// confirm it's on its own line (followed by \n or EOF or \r\n)
|
||||
after := absolute + 3
|
||||
if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {
|
||||
closeIdx = absolute
|
||||
break
|
||||
}
|
||||
searchFrom = absolute + 3
|
||||
}
|
||||
if closeIdx < 0 {
|
||||
return nil, nil, fmt.Errorf("malformed frontmatter (no closing ---)")
|
||||
}
|
||||
fm := rest[:closeIdx]
|
||||
// Trim trailing newline from fm if present.
|
||||
fm = bytes.TrimRight(fm, "\r\n")
|
||||
body := []byte{}
|
||||
bodyStart := closeIdx + 3
|
||||
if bodyStart < len(rest) {
|
||||
// skip leading EOL after closing ---
|
||||
if rest[bodyStart] == '\r' && bodyStart+1 < len(rest) && rest[bodyStart+1] == '\n' {
|
||||
bodyStart += 2
|
||||
} else if rest[bodyStart] == '\n' {
|
||||
bodyStart++
|
||||
}
|
||||
body = rest[bodyStart:]
|
||||
}
|
||||
return fm, body, nil
|
||||
}
|
||||
|
||||
// firstBodyLines returns up to n meaningful lines from body (skipping the
|
||||
// initial H1/H2 heading and blank lines) joined with spaces.
|
||||
func firstBodyLines(body []byte, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(string(body), "\n")
|
||||
out := make([]string, 0, n)
|
||||
skippedHeading := false
|
||||
for _, l := range lines {
|
||||
t := strings.TrimRight(strings.TrimRight(l, "\n"), "\r")
|
||||
ts := strings.TrimSpace(t)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
if !skippedHeading && strings.HasPrefix(ts, "#") {
|
||||
skippedHeading = true
|
||||
continue
|
||||
}
|
||||
// Skip pure markdown decorations like horizontal rules.
|
||||
if ts == "---" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ts)
|
||||
if len(out) >= n {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// dodItemsFromSchema extracts a flat list of titles/keys from the
|
||||
// `dod_evidence_schema` frontmatter field, which can be either a list of
|
||||
// strings or a list of objects with a "title" or "key" field.
|
||||
func dodItemsFromSchema(items []any) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
switch v := it.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(v)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for _, k := range []string{"title", "name", "key", "id"} {
|
||||
if val, ok := v[k]; ok {
|
||||
if s, ok2 := val.(string); ok2 && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- registry root resolution ------------------------------------------------
|
||||
|
||||
// registryRoot returns FN_REGISTRY_ROOT if set, otherwise walks upward from
|
||||
// cwd looking for a "dev/issues" directory. Falls back to cwd.
|
||||
func registryRoot() string {
|
||||
if v := strings.TrimSpace(os.Getenv("FN_REGISTRY_ROOT")); v != "" {
|
||||
return v
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
dir := cwd
|
||||
for i := 0; i < 8; i++ {
|
||||
if st, err := os.Stat(filepath.Join(dir, "dev", "issues")); err == nil && st.IsDir() {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func issuesDir() string {
|
||||
return filepath.Join(registryRoot(), "dev", "issues")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFixture(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIssueCards_MapsStatusesAndSkipsNonIssues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, "README.md", "skip me")
|
||||
writeFixture(t, dir, "AGENT_GUIDE.md", "skip me too")
|
||||
writeFixture(t, dir, "0001-foo.md", "---\n"+
|
||||
"id: \"0001\"\n"+
|
||||
"title: \"Foo\"\n"+
|
||||
"status: pendiente\n"+
|
||||
"priority: alta\n"+
|
||||
"type: feature\n"+
|
||||
"tags: [x, y]\n"+
|
||||
"flow: \"0008\"\n"+
|
||||
"created: 2026-05-18\n"+
|
||||
"updated: 2026-05-18\n"+
|
||||
"---\n# Foo\n\nDescription body line 1.\nLine 2.\n")
|
||||
writeFixture(t, dir, "0002-bar.md", "---\n"+
|
||||
"id: \"0002\"\n"+
|
||||
"title: \"Bar\"\n"+
|
||||
"status: en-curso\n"+
|
||||
"---\nBody\n")
|
||||
writeFixture(t, dir, "0003-baz.md", "---\n"+
|
||||
"id: \"0003\"\n"+
|
||||
"title: \"Baz\"\n"+
|
||||
"status: done\n"+
|
||||
"---\nBody\n")
|
||||
|
||||
cards, err := loadIssueCards(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(cards) != 3 {
|
||||
t.Fatalf("expected 3 cards, got %d: %#v", len(cards), cards)
|
||||
}
|
||||
byID := map[string]IssueCard{}
|
||||
for _, c := range cards {
|
||||
byID[c.ID] = c
|
||||
}
|
||||
if c := byID["0001"]; c.ColumnID != "Backlog" || c.Priority != "alta" || c.FlowID != "0008" || c.Type != "feature" {
|
||||
t.Fatalf("0001 mismapped: %#v", c)
|
||||
}
|
||||
if c := byID["0002"]; c.ColumnID != "Doing" {
|
||||
t.Fatalf("0002 expected Doing, got %s", c.ColumnID)
|
||||
}
|
||||
if c := byID["0003"]; c.ColumnID != "Done" {
|
||||
t.Fatalf("0003 expected Done, got %s", c.ColumnID)
|
||||
}
|
||||
// Description must contain body content but NOT the title heading.
|
||||
if c := byID["0001"]; c.Description == "" {
|
||||
t.Fatalf("0001 missing description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIssueCards_MalformedYAMLDoesNotCrash(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, "0010-bad.md", "---\nid: \"0010\"\ntitle: \"Bad\"\nstatus: pendiente\n : malformed\n---\nbody\n")
|
||||
|
||||
cards, err := loadIssueCards(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no top-level error, got: %v", err)
|
||||
}
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("expected 1 card (with parse error), got %d", len(cards))
|
||||
}
|
||||
if cards[0].ParseError == "" {
|
||||
t.Fatalf("expected ParseError to be set on malformed card")
|
||||
}
|
||||
if cards[0].ID != "0010" {
|
||||
t.Fatalf("expected id 0010 derived from filename, got %q", cards[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIssueCards_MissingStatusDefaultsPendiente(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, "0011-nostatus.md", "---\nid: \"0011\"\ntitle: \"NoStatus\"\n---\nbody\n")
|
||||
cards, err := loadIssueCards(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("expected 1 card, got %d", len(cards))
|
||||
}
|
||||
if cards[0].Status != "pendiente" {
|
||||
t.Fatalf("expected default status pendiente, got %q", cards[0].Status)
|
||||
}
|
||||
if cards[0].ColumnID != "Backlog" {
|
||||
t.Fatalf("expected column Backlog, got %q", cards[0].ColumnID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesCacheTTL(t *testing.T) {
|
||||
// Sanity: a fresh cache misses.
|
||||
cache := &cardsCache{ttl: 30 * 1_000_000_000} // 30s in ns
|
||||
if _, ok := cache.get(); ok {
|
||||
t.Fatalf("expected miss on empty cache")
|
||||
}
|
||||
cache.set([]IssueCard{{ID: "0001"}})
|
||||
if c, ok := cache.get(); !ok || len(c) != 1 {
|
||||
t.Fatalf("expected cache hit")
|
||||
}
|
||||
cache.invalidate()
|
||||
if _, ok := cache.get(); ok {
|
||||
t.Fatalf("expected miss after invalidate")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -76,7 +76,7 @@ func main() {
|
||||
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
|
||||
DB: db.conn,
|
||||
CookieName: cookieName,
|
||||
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/health", "/assets/", "/index.html"},
|
||||
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/api/boards/", "/health", "/assets/", "/index.html"},
|
||||
UserCtxKey: userCtxKey,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user