347 lines
9.2 KiB
Go
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()},
|
|
}
|
|
}
|