Files
kanban_cpp/backend/handlers_boards.go
T
agent 0b93a985d6 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>
2026-05-18 18:56:22 +02:00

255 lines
6.8 KiB
Go

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()},
}
}