0b93a985d6
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>
255 lines
6.8 KiB
Go
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()},
|
|
}
|
|
}
|