Files
kanban_cpp/backend/handlers_boards.go
T

347 lines
9.2 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()
if globalHub != nil {
globalHub.Broadcast(ServerEvent{
Board: board,
CardID: id,
Action: "updated",
EventType: "card_changed",
})
}
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)
}
}
// GET /api/boards/{board}/stream (text/event-stream)
//
// Long-lived SSE connection that emits one event per card change on the
// given board. Events:
// - card_added {"board","card_id","action":"created"}
// - card_changed {"board","card_id","action":"updated"}
// - card_removed {"board","card_id","action":"deleted"}
// - keepalive ts=<unix>
//
// Events for OTHER boards are filtered out (one subscription per board).
// A keepalive is emitted every 15s to prevent proxy timeouts.
func handleBoardStream() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
board := r.PathValue("board")
dir, _, _ := dirAndCacheForBoard(board)
if dir == "" {
notFound(w, "unknown board: "+board)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
if globalHub == nil {
http.Error(w, "hub not initialised", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := globalHub.Subscribe()
defer globalHub.Unsubscribe(ch)
// Honor Last-Event-ID is not supported yet (TODO: replay buffer).
_ = r.Header.Get("Last-Event-ID")
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := fmt.Fprintf(w, "event: keepalive\ndata: ts=%d\n\n", time.Now().Unix()); err != nil {
return
}
flusher.Flush()
case ev, ok := <-ch:
if !ok {
return
}
if ev.Board != board {
continue
}
evType := ev.EventType
if evType == "" {
evType = "card_changed"
}
payload, err := json.Marshal(map[string]string{
"board": ev.Board,
"card_id": ev.CardID,
"action": ev.Action,
})
if err != nil {
continue
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evType, payload); err != nil {
return
}
flusher.Flush()
}
}
}
}
// 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: "GET", Path: "/api/boards/{board}/stream", Handler: handleBoardStream()},
{Method: "PATCH", Path: "/api/boards/{board}/cards/{id}", Handler: handlePatchBoardCard()},
{Method: "POST", Path: "/api/boards/{board}/cards/{id}/launch", Handler: handleLaunchBoardCard()},
}
}