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,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()},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user