commit 255e8dcf71eac3f7b6a4bbea8a5af8ade1214b9a Author: agent Date: Fri May 22 22:19:47 2026 +0200 feat: initial scaffold of kanban_cpp v2 (issue 0130) Frontend C++ ImGui (main.cpp + 4 paneles) + backend Go (HTTP + SQLite + fsnotify + SSE). Reusa parse/scan/watch funcs del registry (issue 0130a). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e9692d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +backend/kanban_cpp_backend +backend/operations.db* +backend/registry.db +build/ +*.log diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..71cce1c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,18 @@ +add_imgui_app(kanban_cpp + main.cpp + data.cpp + panel_board.cpp + panel_flows.cpp + panel_filters.cpp + panel_detail.cpp + ${CMAKE_SOURCE_DIR}/functions/core/http_request.cpp + ${CMAKE_SOURCE_DIR}/functions/core/sse_client.cpp +) +target_include_directories(kanban_cpp PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/vendor +) + +if(WIN32) + set_target_properties(kanban_cpp PROPERTIES WIN32_EXECUTABLE TRUE) +endif() diff --git a/app.md b/app.md new file mode 100644 index 0000000..a222630 --- /dev/null +++ b/app.md @@ -0,0 +1,76 @@ +--- +name: kanban_cpp +lang: cpp +domain: tools +version: 0.2.0 +description: "Kanban C++ v2 — gestor de dev/issues y dev/flows del registry. Board drag-drop, edicion bidireccional de frontmatter MD" +tags: [imgui, kanban, dev_ux, issues, flows] +icon: + phosphor: "kanban" + accent: "#a855f7" +uses_functions: + - http_request_cpp_core + - sse_client_cpp_core +uses_types: [] +framework: "imgui" +entry_point: "main.cpp" +dir_path: "apps/kanban_cpp" +repo_url: "https://gitea.organic-machine.com/dataforge/kanban_cpp" +e2e_checks: + - id: backend_build + cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -o kanban_cpp_backend ." + timeout_s: 180 + - id: backend_tests + cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go test -count=1 ./..." + timeout_s: 60 + - id: build + cmd: "cmake --build cpp/build/linux --target kanban_cpp -j" + timeout_s: 300 + - id: self_test + cmd: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test" + timeout_s: 30 +--- + +# kanban_cpp + +Kanban C++ v2 para gestionar `dev/issues/` y `dev/flows/` del registry. Frontend ImGui sobre `fn::run_app`, backend Go local en `backend/` que parsea los `.md` a SQLite y expone REST + SSE. + +## Build + +```bash +# Backend +cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -o kanban_cpp_backend . + +# Frontend +cmake --build cpp/build/linux --target kanban_cpp -j +``` + +## Run + +```bash +# Terminal 1: backend +apps/kanban_cpp/backend/kanban_cpp_backend --port 8487 --registry $PWD + +# Terminal 2: frontend +./cpp/build/linux/apps/kanban_cpp/kanban_cpp +``` + +Por defecto el frontend apunta a `http://127.0.0.1:8487`. Cambiar con `--backend http://host:port`. + +## Self-test + +`./kanban_cpp --self-test` — smoke headless (state singleton, filtros, HTTP wrapper sin backend). + +## Paneles + +1. **Board** — 4 columnas (pendiente / in-progress / bloqueado / completado). Drag-drop entre columnas reescribe el `.md` correspondiente via PATCH al backend. +2. **Flows** — tabla de flows. Click para detalle. +3. **Filters** — sidebar multi-select de priority / scope / domain / tag + include_completed. +4. **Detail** — combos para status/priority/scope + CSV editors para tags/domain/depends/blocks. Body MD read-only. + +## Anti-scope (issue 0130) + +- Sin grafo de dependencias. +- Sin edicion del body MD (solo frontmatter). +- Sin crear issues nuevos. +- Sin DoD panel / agent runs / worktrees. diff --git a/backend/app.md b/backend/app.md new file mode 100644 index 0000000..b2cba42 --- /dev/null +++ b/backend/app.md @@ -0,0 +1,72 @@ +--- +name: kanban_cpp_backend +lang: go +domain: tools +version: 0.1.0 +description: "Backend HTTP del kanban_cpp v2: sirve dev/issues y dev/flows con parser MD bidireccional + SQLite cache + fsnotify watcher + SSE" +tags: [service, kanban, go, sqlite, sse] +uses_functions: + - parse_issue_md_go_infra + - write_issue_md_go_infra + - scan_issues_dir_go_infra + - scan_flows_dir_go_infra + - watch_dir_fsnotify_go_infra +uses_types: + - issue_go_infra + - flow_go_infra + - fs_event_go_infra +framework: "" +entry_point: "main.go" +dir_path: "apps/kanban_cpp/backend" +repo_url: "https://gitea.organic-machine.com/dataforge/kanban_cpp" +service: + port: 8487 + health_endpoint: /api/health + health_timeout_s: 3 + systemd_unit: null + systemd_scope: null + restart_policy: none + runtime: manual + pc_targets: + - home-wsl + is_local_only: true +e2e_checks: + - id: build + cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -o kanban_cpp_backend ." + timeout_s: 180 + - id: tests + cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go test -count=1 ./..." + timeout_s: 60 + - id: smoke_health + cmd: "cd apps/kanban_cpp/backend && ./kanban_cpp_backend --port 18487 --db /tmp/kanban_cpp_e2e.db --registry /home/lucas/fn_registry &\nsleep 2\ncurl -fsS http://127.0.0.1:18487/api/health\npkill -f kanban_cpp_backend || true" + timeout_s: 30 +--- + +# kanban_cpp_backend + +Servicio HTTP local que sirve `dev/issues/` y `dev/flows/` al frontend C++ ImGui (`apps/kanban_cpp/`). + +## Endpoints + +- `GET /api/health` — counts + version. +- `GET /api/issues?status=&priority=&scope=&domain=&tag=&completed=` — lista. +- `GET /api/issues/{id}` — detalle + body. +- `PATCH /api/issues/{id}` — reescribe frontmatter en disco. Body intacto. +- `GET /api/flows` — lista. +- `GET /api/flows/{id}` — detalle. +- `GET /api/meta` — enums (statuses, priorities, scopes, types). +- `GET /api/sse` — stream cambios (incl. mods externas en disco). + +## Run + +```bash +cd apps/kanban_cpp/backend +CGO_ENABLED=1 go build -o kanban_cpp_backend . +./kanban_cpp_backend --port 8487 --registry /home/lucas/fn_registry +``` + +El binario auto-detecta el root del registry buscando `registry.db` hacia arriba si no se pasa `--registry`. + +## Esquema + +Ver `migrations/001_init.sql`. SQLite WAL. Las filas se upsertean en cada ingest/watch event. diff --git a/backend/db.go b/backend/db.go new file mode 100644 index 0000000..6297986 --- /dev/null +++ b/backend/db.go @@ -0,0 +1,51 @@ +package main + +import ( + "database/sql" + "fmt" + "io/fs" + "sort" + "strings" + + _ "github.com/mattn/go-sqlite3" +) + +func openDB(path string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on", path) + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + return nil, err + } + return db, nil +} + +func applyMigrations(db *sql.DB) error { + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return err + } + names := []string{} + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + for _, n := range names { + b, err := fs.ReadFile(migrationsFS, "migrations/"+n) + if err != nil { + return err + } + if _, err := db.Exec(string(b)); err != nil { + low := strings.ToLower(err.Error()) + if strings.Contains(low, "duplicate column") || strings.Contains(low, "already exists") { + continue + } + return fmt.Errorf("%s: %w", n, err) + } + } + return nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..c6bd3b1 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,51 @@ +module kanban_cpp_backend + +go 1.25.0 + +require ( + fn-registry v0.0.0-00010101000000-000000000000 + github.com/mattn/go-sqlite3 v1.14.37 +) + +require ( + github.com/ClickHouse/ch-go v0.71.0 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/apache/arrow-go/v18 v18.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/flatbuffers v25.1.24+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/marcboeker/go-duckdb v1.8.5 // indirect + github.com/paulmach/orb v0.12.0 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + nhooyr.io/websocket v1.8.17 // indirect +) + +replace fn-registry => ../../.. diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..9a0f3cd --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,176 @@ +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ= +github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y= +github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o= +github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0= +github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8= +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0= +gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/backend/handlers.go b/backend/handlers.go new file mode 100644 index 0000000..26f5062 --- /dev/null +++ b/backend/handlers.go @@ -0,0 +1,343 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "fn-registry/functions/infra" +) + +func (s *Server) registerRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/health", s.handleHealth) + mux.HandleFunc("/api/issues", s.handleIssues) + mux.HandleFunc("/api/issues/", s.handleIssueByID) + mux.HandleFunc("/api/flows", s.handleFlows) + mux.HandleFunc("/api/flows/", s.handleFlowByID) + mux.HandleFunc("/api/meta", s.handleMeta) + mux.HandleFunc("/api/sse", s.handleSSE) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + var ni, nf int + s.db.QueryRow(`SELECT COUNT(*) FROM issues`).Scan(&ni) + s.db.QueryRow(`SELECT COUNT(*) FROM flows`).Scan(&nf) + writeJSON(w, 200, map[string]any{ + "ok": true, + "version": "0.1.0", + "count_issues": ni, + "count_flows": nf, + "issues_dir": s.issuesDir, + "flows_dir": s.flowsDir, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeErr(w, 405, "method not allowed") + return + } + q := r.URL.Query() + where := []string{"1=1"} + args := []any{} + if v := q.Get("status"); v != "" { + where = append(where, "status=?") + args = append(args, v) + } + if v := q.Get("priority"); v != "" { + where = append(where, "priority=?") + args = append(args, v) + } + if v := q.Get("scope"); v != "" { + where = append(where, "scope=?") + args = append(args, v) + } + if v := q.Get("domain"); v != "" { + where = append(where, "domain_json LIKE ?") + args = append(args, "%\""+v+"\"%") + } + if v := q.Get("tag"); v != "" { + where = append(where, "tags_json LIKE ?") + args = append(args, "%\""+v+"\"%") + } + if v := q.Get("completed"); v != "" { + if v == "true" || v == "1" { + where = append(where, "completed=1") + } else { + where = append(where, "completed=0") + } + } + sql := "SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,file_path,completed,created_at,updated_at FROM issues WHERE " + strings.Join(where, " AND ") + " ORDER BY id ASC" + rows, err := s.db.Query(sql, args...) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var ( + id, title, status, typ, scope, priority string + domJ, tagJ, depJ, blkJ, relJ, flow, path string + completedInt int + createdAt, updatedAt string + ) + if err := rows.Scan(&id, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &path, &completedInt, &createdAt, &updatedAt); err != nil { + writeErr(w, 500, err.Error()) + return + } + out = append(out, map[string]any{ + "id": id, + "title": title, + "status": status, + "type": typ, + "scope": scope, + "priority": priority, + "domain": parseJSONArr(domJ), + "tags": parseJSONArr(tagJ), + "depends": parseJSONArr(depJ), + "blocks": parseJSONArr(blkJ), + "related": parseJSONArr(relJ), + "flow": flow, + "file_path": path, + "completed": completedInt == 1, + "created": createdAt, + "updated": updatedAt, + }) + } + writeJSON(w, 200, out) +} + +func (s *Server) handleIssueByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/issues/") + if id == "" { + writeErr(w, 400, "missing id") + return + } + switch r.Method { + case "GET": + s.getIssue(w, id) + case "PATCH": + s.patchIssue(w, r, id) + default: + writeErr(w, 405, "method not allowed") + } +} + +func (s *Server) getIssue(w http.ResponseWriter, id string) { + row := s.db.QueryRow(`SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,body,file_path,completed,created_at,updated_at FROM issues WHERE id=?`, id) + var ( + iid, title, status, typ, scope, priority string + domJ, tagJ, depJ, blkJ, relJ, flow, body, path string + completedInt int + createdAt, updatedAt string + ) + if err := row.Scan(&iid, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &body, &path, &completedInt, &createdAt, &updatedAt); err != nil { + writeErr(w, 404, "not found") + return + } + writeJSON(w, 200, map[string]any{ + "id": iid, "title": title, "status": status, "type": typ, "scope": scope, "priority": priority, + "domain": parseJSONArr(domJ), "tags": parseJSONArr(tagJ), + "depends": parseJSONArr(depJ), "blocks": parseJSONArr(blkJ), "related": parseJSONArr(relJ), + "flow": flow, "body": body, "file_path": path, "completed": completedInt == 1, + "created": createdAt, "updated": updatedAt, + }) +} + +func (s *Server) patchIssue(w http.ResponseWriter, r *http.Request, id string) { + var patch map[string]any + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + writeErr(w, 400, "bad json") + return + } + var filePath string + if err := s.db.QueryRow(`SELECT file_path FROM issues WHERE id=?`, id).Scan(&filePath); err != nil { + writeErr(w, 404, "not found") + return + } + iss, body, err := infra.ParseIssueMd(filePath) + if err != nil { + writeErr(w, 500, fmt.Sprintf("parse: %v", err)) + return + } + applyPatch(&iss, patch) + iss.Updated = time.Now().UTC().Format("2006-01-02") + if err := infra.WriteIssueMd(filePath, iss, body); err != nil { + writeErr(w, 500, fmt.Sprintf("write: %v", err)) + return + } + info, _ := os.Stat(filePath) + if info != nil { + iss.MtimeNs = info.ModTime().UnixNano() + } + iss.FilePath = filePath + iss.Completed = strings.Contains(filePath, "/completed/") + if err := s.upsertIssueRow(iss); err != nil { + writeErr(w, 500, err.Error()) + return + } + s.hub.broadcast(SSEEvent{Type: "updated", ID: id, Path: filePath}) + s.getIssue(w, id) +} + +func applyPatch(iss *infra.Issue, patch map[string]any) { + if v, ok := patch["status"].(string); ok { + iss.Status = v + } + if v, ok := patch["priority"].(string); ok { + iss.Priority = v + } + if v, ok := patch["scope"].(string); ok { + iss.Scope = v + } + if v, ok := patch["title"].(string); ok { + iss.Title = v + } + if v, ok := patch["type"].(string); ok { + iss.Type = v + } + if v, ok := patch["flow"].(string); ok { + iss.Flow = v + } + for _, k := range []string{"domain", "tags", "depends", "blocks", "related"} { + if raw, ok := patch[k]; ok { + arr := []string{} + if xs, ok := raw.([]any); ok { + for _, x := range xs { + if s, ok := x.(string); ok { + arr = append(arr, s) + } + } + } + switch k { + case "domain": + iss.Domain = arr + case "tags": + iss.Tags = arr + case "depends": + iss.Depends = arr + case "blocks": + iss.Blocks = arr + case "related": + iss.Related = arr + } + } + } +} + +func (s *Server) handleFlows(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.Query(`SELECT id,title,status,kind,tags_json,file_path FROM flows ORDER BY id ASC`) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var id, title, status, kind, tagJ, path string + rows.Scan(&id, &title, &status, &kind, &tagJ, &path) + out = append(out, map[string]any{ + "id": id, "title": title, "status": status, "kind": kind, + "tags": parseJSONArr(tagJ), "file_path": path, + }) + } + writeJSON(w, 200, out) +} + +func (s *Server) handleFlowByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/flows/") + if id == "" { + writeErr(w, 400, "missing id") + return + } + row := s.db.QueryRow(`SELECT id,title,status,kind,tags_json,body,file_path FROM flows WHERE id=?`, id) + var iid, title, status, kind, tagJ, body, path string + if err := row.Scan(&iid, &title, &status, &kind, &tagJ, &body, &path); err != nil { + writeErr(w, 404, "not found") + return + } + writeJSON(w, 200, map[string]any{ + "id": iid, "title": title, "status": status, "kind": kind, + "tags": parseJSONArr(tagJ), "body": body, "file_path": path, + }) +} + +func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{ + "statuses": []string{"pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"}, + "priorities": []string{"critica", "alta", "media", "baja"}, + "scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"}, + "types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"}, + }) +} + +func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher, ok := w.(http.Flusher) + if !ok { + writeErr(w, 500, "streaming unsupported") + return + } + ch := s.hub.subscribe() + defer s.hub.unsubscribe(ch) + + pingTick := time.NewTicker(15 * time.Second) + defer pingTick.Stop() + + for { + select { + case <-r.Context().Done(): + return + case ev := <-ch: + b, _ := json.Marshal(ev) + fmt.Fprintf(w, "data: %s\n\n", b) + flusher.Flush() + case <-pingTick.C: + fmt.Fprintf(w, ": ping\n\n") + flusher.Flush() + } + } +} + +func parseJSONArr(s string) []string { + if s == "" { + return []string{} + } + var arr []string + if err := json.Unmarshal([]byte(s), &arr); err != nil { + return []string{} + } + return arr +} + +func withMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + if r.Method == "OPTIONS" { + w.WriteHeader(204) + return + } + start := time.Now() + h.ServeHTTP(w, r) + fmt.Printf("[%s] %s %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path, time.Since(start)) + }) +} diff --git a/backend/ingest.go b/backend/ingest.go new file mode 100644 index 0000000..001adee --- /dev/null +++ b/backend/ingest.go @@ -0,0 +1,137 @@ +package main + +import ( + "database/sql" + "encoding/json" + "log" + "path/filepath" + "strings" + + "fn-registry/functions/infra" +) + +func (s *Server) ingestAll() error { + issues, err := infra.ScanIssuesDir(s.issuesDir) + if err != nil { + return err + } + for _, iss := range issues { + if err := s.upsertIssueRow(iss); err != nil { + log.Printf("upsert issue %s: %v", iss.ID, err) + } + } + + flows, err := infra.ScanFlowsDir(s.flowsDir) + if err != nil { + return err + } + for _, fl := range flows { + if err := s.upsertFlowRow(fl); err != nil { + log.Printf("upsert flow %s: %v", fl.ID, err) + } + } + + log.Printf("ingested %d issues, %d flows", len(issues), len(flows)) + return nil +} + +func (s *Server) upsertIssueRow(iss infra.Issue) error { + body, err := readBody(iss.FilePath) + if err != nil { + return err + } + _, err = s.db.Exec(` + INSERT INTO issues (id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,body,file_path,mtime_ns,created_at,updated_at,completed) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + title=excluded.title, status=excluded.status, type=excluded.type, scope=excluded.scope, + priority=excluded.priority, domain_json=excluded.domain_json, tags_json=excluded.tags_json, + depends_json=excluded.depends_json, blocks_json=excluded.blocks_json, related_json=excluded.related_json, + flow_id=excluded.flow_id, body=excluded.body, file_path=excluded.file_path, mtime_ns=excluded.mtime_ns, + created_at=excluded.created_at, updated_at=excluded.updated_at, completed=excluded.completed + `, + iss.ID, iss.Title, iss.Status, iss.Type, iss.Scope, iss.Priority, + jsonOrEmpty(iss.Domain), jsonOrEmpty(iss.Tags), + jsonOrEmpty(iss.Depends), jsonOrEmpty(iss.Blocks), jsonOrEmpty(iss.Related), + iss.Flow, string(body), iss.FilePath, iss.MtimeNs, + iss.Created, iss.Updated, boolInt(iss.Completed), + ) + return err +} + +func (s *Server) upsertFlowRow(fl infra.Flow) error { + body, err := readBody(fl.FilePath) + if err != nil { + return err + } + _, err = s.db.Exec(` + INSERT INTO flows (id,title,status,kind,tags_json,body,file_path,mtime_ns) + VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + title=excluded.title, status=excluded.status, kind=excluded.kind, + tags_json=excluded.tags_json, body=excluded.body, file_path=excluded.file_path, mtime_ns=excluded.mtime_ns + `, + fl.ID, fl.Title, fl.Status, fl.Kind, jsonOrEmpty(fl.Tags), + string(body), fl.FilePath, fl.MtimeNs, + ) + return err +} + +func readBody(path string) ([]byte, error) { + _, body, err := infra.ParseIssueMd(path) + if err != nil { + return nil, err + } + return body, nil +} + +func jsonOrEmpty(xs []string) string { + if len(xs) == 0 { + return "[]" + } + b, _ := json.Marshal(xs) + return string(b) +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func (s *Server) deleteIssue(id string) error { + _, err := s.db.Exec(`DELETE FROM issues WHERE id=?`, id) + return err +} + +func (s *Server) deleteFlow(id string) error { + _, err := s.db.Exec(`DELETE FROM flows WHERE id=?`, id) + return err +} + +func issueIDFromPath(path, issuesDir string) string { + rel, err := filepath.Rel(issuesDir, path) + if err != nil { + return "" + } + base := filepath.Base(rel) + if !strings.HasSuffix(base, ".md") { + return "" + } + name := strings.TrimSuffix(base, ".md") + for i, r := range name { + if r == '-' { + return name[:i] + } + if r < '0' || r > '9' { + if i == 0 { + return "" + } + return name[:i] + } + } + return name +} + +var _ = sql.ErrNoRows diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..1c5e579 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "database/sql" + "embed" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type Server struct { + db *sql.DB + issuesDir string + flowsDir string + hub *SSEHub +} + +func main() { + port := flag.Int("port", 8487, "HTTP port") + dbPath := flag.String("db", "operations.db", "SQLite path") + registryRoot := flag.String("registry", "", "fn_registry root (default: auto-detect from cwd)") + flag.Parse() + + root := *registryRoot + if root == "" { + root = detectRegistryRoot() + } + issuesDir := filepath.Join(root, "dev", "issues") + flowsDir := filepath.Join(root, "dev", "flows") + + for _, d := range []string{issuesDir, flowsDir} { + if _, err := os.Stat(d); err != nil { + log.Fatalf("missing dir %s: %v", d, err) + } + } + + db, err := openDB(*dbPath) + if err != nil { + log.Fatalf("openDB: %v", err) + } + defer db.Close() + if err := applyMigrations(db); err != nil { + log.Fatalf("applyMigrations: %v", err) + } + + srv := &Server{ + db: db, + issuesDir: issuesDir, + flowsDir: flowsDir, + hub: newSSEHub(), + } + + if err := srv.ingestAll(); err != nil { + log.Fatalf("initial ingest: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go srv.watchLoop(ctx, srv.issuesDir, "issue") + go srv.watchLoop(ctx, srv.flowsDir, "flow") + + mux := http.NewServeMux() + srv.registerRoutes(mux) + httpSrv := &http.Server{ + Addr: fmt.Sprintf(":%d", *port), + Handler: withMiddleware(mux), + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } + + go func() { + log.Printf("kanban_cpp_backend listening on :%d (registry=%s)", *port, root) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + log.Println("shutdown") + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShutdown() + httpSrv.Shutdown(shutdownCtx) +} + +func detectRegistryRoot() string { + wd, _ := os.Getwd() + for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { + if _, err := os.Stat(filepath.Join(d, "registry.db")); err == nil { + return d + } + } + log.Fatalf("could not auto-detect fn_registry root from %s", wd) + return "" +} diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..0b33d49 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL, + type TEXT, + scope TEXT, + priority TEXT, + domain_json TEXT NOT NULL DEFAULT '[]', + tags_json TEXT NOT NULL DEFAULT '[]', + depends_json TEXT NOT NULL DEFAULT '[]', + blocks_json TEXT NOT NULL DEFAULT '[]', + related_json TEXT NOT NULL DEFAULT '[]', + flow_id TEXT, + body TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + mtime_ns INTEGER NOT NULL, + created_at TEXT, + updated_at TEXT, + completed INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status); +CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority); +CREATE INDEX IF NOT EXISTS idx_issues_scope ON issues(scope); + +CREATE TABLE IF NOT EXISTS flows ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT, + kind TEXT, + tags_json TEXT NOT NULL DEFAULT '[]', + body TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + mtime_ns INTEGER NOT NULL +); diff --git a/backend/sse_hub.go b/backend/sse_hub.go new file mode 100644 index 0000000..30babea --- /dev/null +++ b/backend/sse_hub.go @@ -0,0 +1,46 @@ +package main + +import ( + "sync" +) + +type SSEEvent struct { + Type string `json:"type"` + ID string `json:"id"` + Path string `json:"path,omitempty"` +} + +type SSEHub struct { + mu sync.RWMutex + clients map[chan SSEEvent]struct{} +} + +func newSSEHub() *SSEHub { + return &SSEHub{clients: map[chan SSEEvent]struct{}{}} +} + +func (h *SSEHub) subscribe() chan SSEEvent { + ch := make(chan SSEEvent, 16) + h.mu.Lock() + h.clients[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *SSEHub) unsubscribe(ch chan SSEEvent) { + h.mu.Lock() + delete(h.clients, ch) + h.mu.Unlock() + close(ch) +} + +func (h *SSEHub) broadcast(ev SSEEvent) { + h.mu.RLock() + defer h.mu.RUnlock() + for ch := range h.clients { + select { + case ch <- ev: + default: + } + } +} diff --git a/backend/watcher.go b/backend/watcher.go new file mode 100644 index 0000000..b870ce8 --- /dev/null +++ b/backend/watcher.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "log" + "strings" + + "fn-registry/functions/infra" +) + +func (s *Server) watchLoop(ctx context.Context, root, kind string) { + ch, err := infra.WatchDirFsnotify(ctx, root) + if err != nil { + log.Printf("watcher %s: %v", root, err) + return + } + for ev := range ch { + if !strings.HasSuffix(ev.Path, ".md") { + continue + } + s.handleFsEvent(kind, ev) + } +} + +func (s *Server) handleFsEvent(kind string, ev infra.FsEvent) { + switch kind { + case "issue": + s.handleIssueEvent(ev) + case "flow": + s.handleFlowEvent(ev) + } +} + +func (s *Server) handleIssueEvent(ev infra.FsEvent) { + id := issueIDFromPath(ev.Path, s.issuesDir) + if id == "" { + return + } + if ev.Op == "remove" || ev.Op == "rename" { + s.deleteIssue(id) + s.hub.broadcast(SSEEvent{Type: "removed", ID: id, Path: ev.Path}) + return + } + iss, _, err := infra.ParseIssueMd(ev.Path) + if err != nil { + log.Printf("parse %s: %v", ev.Path, err) + return + } + if err := s.upsertIssueRow(iss); err != nil { + log.Printf("upsert %s: %v", iss.ID, err) + return + } + s.hub.broadcast(SSEEvent{Type: "updated", ID: iss.ID, Path: ev.Path}) +} + +func (s *Server) handleFlowEvent(ev infra.FsEvent) { + id := issueIDFromPath(ev.Path, s.flowsDir) + if id == "" { + return + } + if ev.Op == "remove" || ev.Op == "rename" { + s.deleteFlow(id) + s.hub.broadcast(SSEEvent{Type: "flow_removed", ID: id, Path: ev.Path}) + return + } + flows, err := infra.ScanFlowsDir(s.flowsDir) + if err != nil { + log.Printf("scan flows: %v", err) + return + } + for _, fl := range flows { + if fl.ID == id { + if err := s.upsertFlowRow(fl); err != nil { + log.Printf("upsert flow %s: %v", id, err) + } + s.hub.broadcast(SSEEvent{Type: "flow_updated", ID: id, Path: ev.Path}) + return + } + } +} diff --git a/data.cpp b/data.cpp new file mode 100644 index 0000000..350309f --- /dev/null +++ b/data.cpp @@ -0,0 +1,261 @@ +#include "data.h" + +#include +#include +#include +#include + +#include "core/http_request.h" +#include "core/sse_client.h" +#include "core/logger.h" +#include + +namespace kanban { + +using json = nlohmann::json; + +State& state() { + static State s; + return s; +} + +static std::unique_ptr g_sse; + +static std::vector j_arr(const json& j, const char* key) { + std::vector out; + if (!j.contains(key) || !j[key].is_array()) return out; + for (const auto& v : j[key]) { + if (v.is_string()) out.push_back(v.get()); + } + return out; +} + +static std::string j_str(const json& j, const char* key) { + if (!j.contains(key) || !j[key].is_string()) return ""; + return j[key].get(); +} + +static Issue parse_issue(const json& j) { + Issue i; + i.id = j_str(j, "id"); + i.title = j_str(j, "title"); + i.status = j_str(j, "status"); + i.type = j_str(j, "type"); + i.scope = j_str(j, "scope"); + i.priority = j_str(j, "priority"); + i.domain = j_arr(j, "domain"); + i.tags = j_arr(j, "tags"); + i.depends = j_arr(j, "depends"); + i.blocks = j_arr(j, "blocks"); + i.related = j_arr(j, "related"); + i.flow = j_str(j, "flow"); + i.file_path = j_str(j, "file_path"); + if (j.contains("completed") && j["completed"].is_boolean()) + i.completed = j["completed"].get(); + i.body = j_str(j, "body"); + return i; +} + +static Flow parse_flow(const json& j) { + Flow f; + f.id = j_str(j, "id"); + f.title = j_str(j, "title"); + f.status = j_str(j, "status"); + f.kind = j_str(j, "kind"); + f.tags = j_arr(j, "tags"); + f.file_path = j_str(j, "file_path"); + f.body = j_str(j, "body"); + return f; +} + +static fn_http::Response http_get(const std::string& path) { + fn_http::Request req; + req.method = "GET"; + req.url = state().backend_url + path; + req.timeout_ms = 5000; + return fn_http::request(req); +} + +static fn_http::Response http_patch_json(const std::string& path, const std::string& body) { + fn_http::Request req; + req.method = "PATCH"; + req.url = state().backend_url + path; + req.timeout_ms = 5000; + req.headers.push_back({"Content-Type", "application/json"}); + req.body = body; + return fn_http::request(req); +} + +bool refresh_issues() { + auto resp = http_get("/api/issues"); + if (resp.status != 200) { + std::lock_guard g(state().mu); + state().last_error = "issues fetch: " + std::to_string(resp.status) + " " + resp.error; + return false; + } + auto j = json::parse(resp.body, nullptr, false); + if (j.is_discarded() || !j.is_array()) { + std::lock_guard g(state().mu); + state().last_error = "issues: invalid JSON"; + return false; + } + std::vector out; + out.reserve(j.size()); + for (const auto& it : j) out.push_back(parse_issue(it)); + std::lock_guard g(state().mu); + state().issues = std::move(out); + state().last_refresh_ns = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + state().last_error.clear(); + return true; +} + +bool refresh_flows() { + auto resp = http_get("/api/flows"); + if (resp.status != 200) { + std::lock_guard g(state().mu); + state().last_error = "flows fetch: " + std::to_string(resp.status); + return false; + } + auto j = json::parse(resp.body, nullptr, false); + if (j.is_discarded() || !j.is_array()) return false; + std::vector out; + out.reserve(j.size()); + for (const auto& it : j) out.push_back(parse_flow(it)); + std::lock_guard g(state().mu); + state().flows = std::move(out); + return true; +} + +bool refresh_meta() { + auto resp = http_get("/api/meta"); + if (resp.status != 200) return false; + auto j = json::parse(resp.body, nullptr, false); + if (j.is_discarded()) return false; + std::lock_guard g(state().mu); + state().meta.statuses = j_arr(j, "statuses"); + state().meta.priorities = j_arr(j, "priorities"); + state().meta.scopes = j_arr(j, "scopes"); + state().meta.types = j_arr(j, "types"); + return true; +} + +bool refresh_issue_detail(const std::string& id) { + auto resp = http_get("/api/issues/" + id); + if (resp.status != 200) return false; + auto j = json::parse(resp.body, nullptr, false); + if (j.is_discarded()) return false; + std::lock_guard g(state().mu); + state().selected_issue_id = id; + state().selected_issue_detail = parse_issue(j); + return true; +} + +static std::string json_escape(const std::string& s) { + json j = s; + return j.dump(); +} + +bool patch_issue_status(const std::string& id, const std::string& new_status) { + std::string body = "{\"status\":" + json_escape(new_status) + "}"; + auto resp = http_patch_json("/api/issues/" + id, body); + if (resp.status != 200) { + std::lock_guard g(state().mu); + state().last_error = "PATCH status " + id + ": " + std::to_string(resp.status); + return false; + } + // Update local cache without full refresh. + auto j = json::parse(resp.body, nullptr, false); + if (!j.is_discarded()) { + Issue updated = parse_issue(j); + std::lock_guard g(state().mu); + for (auto& i : state().issues) { + if (i.id == id) { + i = updated; + break; + } + } + } + return true; +} + +bool patch_issue_fields(const std::string& id, const std::string& json_partial) { + auto resp = http_patch_json("/api/issues/" + id, json_partial); + if (resp.status != 200) { + std::lock_guard g(state().mu); + state().last_error = "PATCH " + id + ": " + std::to_string(resp.status); + return false; + } + return true; +} + +void start_sse() { + if (g_sse) return; + fn_sse::Config cfg; + cfg.url = state().backend_url + "/api/sse"; + g_sse = std::make_unique(); + g_sse->start( + cfg, + [](const fn_sse::Event& /*ev*/) { + std::lock_guard g(state().mu); + state().last_refresh_ns = 0; + }, + nullptr); +} + +void stop_sse() { + if (g_sse) { + g_sse->stop(); + g_sse.reset(); + } +} + +static bool contains(const std::set& s, const std::string& v) { + return s.find(v) != s.end(); +} + +bool passes_filters(const Issue& iss) { + const auto& f = state().filters; + if (!f.include_completed && iss.completed) return false; + if (!f.priorities.empty() && !contains(f.priorities, iss.priority)) return false; + if (!f.scopes.empty() && !contains(f.scopes, iss.scope)) return false; + if (!f.domains.empty()) { + bool match = false; + for (const auto& d : iss.domain) { + if (contains(f.domains, d)) { + match = true; + break; + } + } + if (!match) return false; + } + if (!f.tags.empty()) { + bool match = false; + for (const auto& t : iss.tags) { + if (contains(f.tags, t)) { + match = true; + break; + } + } + if (!match) return false; + } + return true; +} + +std::vector collect_domains() { + std::set uniq; + for (const auto& i : state().issues) { + for (const auto& d : i.domain) uniq.insert(d); + } + return {uniq.begin(), uniq.end()}; +} + +std::vector collect_tags() { + std::set uniq; + for (const auto& i : state().issues) { + for (const auto& t : i.tags) uniq.insert(t); + } + return {uniq.begin(), uniq.end()}; +} + +} // namespace kanban diff --git a/data.h b/data.h new file mode 100644 index 0000000..878256e --- /dev/null +++ b/data.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include + +namespace kanban { + +struct Issue { + std::string id; + std::string title; + std::string status; + std::string type; + std::string scope; + std::string priority; + std::vector domain; + std::vector tags; + std::vector depends; + std::vector blocks; + std::vector related; + std::string flow; + std::string file_path; + bool completed = false; + std::string body; // only filled in detail GET +}; + +struct Flow { + std::string id; + std::string title; + std::string status; + std::string kind; + std::vector tags; + std::string file_path; + std::string body; +}; + +struct Meta { + std::vector statuses; + std::vector priorities; + std::vector scopes; + std::vector types; +}; + +struct Filters { + std::set domains; + std::set scopes; + std::set priorities; + std::set tags; + bool include_completed = false; +}; + +struct State { + std::mutex mu; + std::string backend_url = "http://127.0.0.1:8487"; + std::vector issues; + std::vector flows; + Meta meta; + Filters filters; + std::string selected_issue_id; + Issue selected_issue_detail; + bool loading = false; + std::string last_error; + long long last_refresh_ns = 0; +}; + +State& state(); + +// HTTP operations (block briefly, called from background or coalesced). +bool refresh_issues(); +bool refresh_flows(); +bool refresh_meta(); +bool refresh_issue_detail(const std::string& id); +bool patch_issue_status(const std::string& id, const std::string& new_status); +bool patch_issue_fields(const std::string& id, const std::string& json_partial); + +// Background SSE subscription (no-op if already started). +void start_sse(); +void stop_sse(); + +// Filters helpers. +bool passes_filters(const Issue& iss); +std::vector collect_domains(); +std::vector collect_tags(); + +} // namespace kanban diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..3bf0140 --- /dev/null +++ b/main.cpp @@ -0,0 +1,107 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "app_base.h" +#include "core/panel_menu.h" +#include "core/icons_tabler.h" +#include "core/logger.h" + +#include "panels.h" +#include "data.h" + +static bool g_show_board = true; +static bool g_show_flows = true; +static bool g_show_filters = true; +static bool g_show_detail = true; + +static std::atomic g_refresh_thread_alive{false}; +static std::thread g_refresh_thread; + +static void start_refresh_thread() { + g_refresh_thread_alive = true; + g_refresh_thread = std::thread([]() { + kanban::refresh_meta(); + kanban::refresh_issues(); + kanban::refresh_flows(); + kanban::start_sse(); + while (g_refresh_thread_alive) { + std::this_thread::sleep_for(std::chrono::seconds(30)); + if (!g_refresh_thread_alive) break; + kanban::refresh_issues(); + kanban::refresh_flows(); + } + kanban::stop_sse(); + }); +} + +static void stop_refresh_thread() { + g_refresh_thread_alive = false; + if (g_refresh_thread.joinable()) g_refresh_thread.join(); +} + +static void render() { + if (g_show_board) kanban::draw_board(); + if (g_show_flows) kanban::draw_flows(); + if (g_show_filters) kanban::draw_filters(); + if (g_show_detail) kanban::draw_detail(); +} + +static int run_self_test() { + kanban::State& s = kanban::state(); + s.backend_url = "http://127.0.0.1:1"; + bool ok_issues = kanban::refresh_issues(); + bool ok_flows = kanban::refresh_flows(); + if (ok_issues || ok_flows) { + std::fprintf(stderr, "[self-test] expected refresh to fail on unreachable backend\n"); + return 1; + } + kanban::Issue dummy; + dummy.status = "pendiente"; + dummy.priority = "media"; + if (!kanban::passes_filters(dummy)) { + std::fprintf(stderr, "[self-test] empty filters should let issue pass\n"); + return 2; + } + s.filters.priorities.insert("alta"); + if (kanban::passes_filters(dummy)) { + std::fprintf(stderr, "[self-test] priority filter should reject media when alta required\n"); + return 3; + } + std::fprintf(stdout, "[self-test] ok\n"); + return 0; +} + +int main(int argc, char** argv) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--self-test") == 0) { + return run_self_test(); + } + if (std::strcmp(argv[i], "--backend") == 0 && i + 1 < argc) { + kanban::state().backend_url = argv[++i]; + } + } + + static fn_ui::PanelToggle panels[] = { + { "Board", nullptr, &g_show_board }, + { "Flows", nullptr, &g_show_flows }, + { "Filters", nullptr, &g_show_filters }, + { "Detail", nullptr, &g_show_detail }, + }; + + fn::AppConfig cfg; + cfg.title = "kanban_cpp v2 — dev/issues + dev/flows"; + cfg.about = { "kanban_cpp", "0.2.0", "Kanban C++ v2 — gestor de dev/issues y dev/flows del registry" }; + cfg.log = { "kanban_cpp.log", 1 }; + cfg.panels = panels; + cfg.panel_count = sizeof(panels) / sizeof(panels[0]); + + start_refresh_thread(); + int rc = fn::run_app(cfg, render); + stop_refresh_thread(); + return rc; +} diff --git a/panel_board.cpp b/panel_board.cpp new file mode 100644 index 0000000..aa7b1ae --- /dev/null +++ b/panel_board.cpp @@ -0,0 +1,150 @@ +#include "panels.h" +#include "data.h" + +#include +#include +#include +#include +#include + +#include "core/icons_tabler.h" + +namespace kanban { + +static const char* k_columns[] = {"pendiente", "in-progress", "bloqueado", "completado"}; +static const int k_n_columns = sizeof(k_columns) / sizeof(k_columns[0]); + +static ImU32 priority_color(const std::string& p) { + if (p == "critica") return IM_COL32(255, 80, 80, 255); + if (p == "alta") return IM_COL32(255, 165, 0, 255); + if (p == "media") return IM_COL32(120, 170, 255, 255); + if (p == "baja") return IM_COL32(140, 140, 140, 255); + return IM_COL32(180, 180, 180, 255); +} + +static void draw_card(const Issue& iss) { + ImGui::PushID(iss.id.c_str()); + + ImVec2 size(0.0f, 64.0f); + ImGui::BeginChild("card", size, true, ImGuiWindowFlags_NoScrollbar); + + // Header: id + priority badge + ImGui::TextColored(ImColor(180, 180, 180, 255), "%s", iss.id.c_str()); + ImGui::SameLine(); + { + ImVec2 cur = ImGui::GetCursorScreenPos(); + ImU32 c = priority_color(iss.priority); + ImDrawList* dl = ImGui::GetWindowDrawList(); + float r = 5.0f; + dl->AddCircleFilled(ImVec2(cur.x + r, cur.y + r + 2), r, c); + ImGui::Dummy(ImVec2(2 * r + 4, 2 * r)); + ImGui::SameLine(); + ImGui::TextUnformatted(iss.priority.c_str()); + } + + // Title (truncate to ~60 chars) + std::string t = iss.title; + if (t.size() > 70) t = t.substr(0, 67) + "..."; + ImGui::TextWrapped("%s", t.c_str()); + + // First domain chip + if (!iss.domain.empty()) { + ImGui::TextColored(ImColor(140, 200, 255, 255), TI_TAG " %s", iss.domain[0].c_str()); + } + + ImGui::EndChild(); + + if (ImGui::IsItemClicked()) { + refresh_issue_detail(iss.id); + } + + // Drag source + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_None)) { + ImGui::SetDragDropPayload("KANBAN_ISSUE", iss.id.c_str(), iss.id.size() + 1); + ImGui::Text("%s %s", iss.id.c_str(), t.c_str()); + ImGui::EndDragDropSource(); + } + + ImGui::PopID(); +} + +static void draw_column(const char* status_key, std::vector& items) { + ImGui::TableNextColumn(); + // Column header with count + ImGui::PushFont(nullptr); + ImGui::TextColored(ImColor(220, 220, 220, 255), "%s (%zu)", status_key, items.size()); + ImGui::PopFont(); + ImGui::Separator(); + + // Drop target on the whole column + ImVec2 region = ImGui::GetContentRegionAvail(); + ImGui::BeginChild((std::string("col_") + status_key).c_str(), region, false); + + for (const auto* iss : items) { + draw_card(*iss); + } + // Bottom drop zone + ImGui::InvisibleButton("col_drop", ImVec2(-1, 16)); + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("KANBAN_ISSUE")) { + std::string id((const char*)p->Data, p->DataSize - 1); + patch_issue_status(id, status_key); + } + ImGui::EndDragDropTarget(); + } + ImGui::EndChild(); + + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("KANBAN_ISSUE")) { + std::string id((const char*)p->Data, p->DataSize - 1); + patch_issue_status(id, status_key); + } + ImGui::EndDragDropTarget(); + } +} + +void draw_board() { + if (!ImGui::Begin(TI_LAYOUT_KANBAN " Board")) { + ImGui::End(); + return; + } + + // Snapshot under lock + std::vector snapshot; + { + std::lock_guard g(state().mu); + snapshot = state().issues; + } + + // Bucket issues by status + std::vector> buckets(k_n_columns); + int filtered_out = 0; + for (const auto& iss : snapshot) { + if (!passes_filters(iss)) { + filtered_out++; + continue; + } + for (int c = 0; c < k_n_columns; ++c) { + if (iss.status == k_columns[c]) { + buckets[c].push_back(&iss); + break; + } + } + } + + ImGui::Text("%zu total — %d filtered out", snapshot.size(), filtered_out); + ImGui::Separator(); + + if (ImGui::BeginTable("board_table", k_n_columns, + ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchSame)) { + ImGui::TableNextRow(); + for (int c = 0; c < k_n_columns; ++c) { + draw_column(k_columns[c], buckets[c]); + } + ImGui::EndTable(); + } + + ImGui::End(); +} + +} // namespace kanban diff --git a/panel_detail.cpp b/panel_detail.cpp new file mode 100644 index 0000000..463c1e0 --- /dev/null +++ b/panel_detail.cpp @@ -0,0 +1,229 @@ +#include "panels.h" +#include "data.h" + +#include +#include +#include +#include + +#include "core/icons_tabler.h" + +namespace kanban { + +static std::string join(const std::vector& xs, const char* sep) { + std::string out; + for (size_t i = 0; i < xs.size(); ++i) { + if (i) out += sep; + out += xs[i]; + } + return out; +} + +static int find_idx(const std::vector& xs, const std::string& v) { + for (size_t i = 0; i < xs.size(); ++i) if (xs[i] == v) return (int)i; + return -1; +} + +static std::vector split_csv(const std::string& s) { + std::vector out; + std::string cur; + for (char c : s) { + if (c == ',') { + // trim + while (!cur.empty() && (cur.front() == ' ' || cur.front() == '\t')) cur.erase(cur.begin()); + while (!cur.empty() && (cur.back() == ' ' || cur.back() == '\t')) cur.pop_back(); + if (!cur.empty()) out.push_back(cur); + cur.clear(); + } else { + cur.push_back(c); + } + } + while (!cur.empty() && (cur.front() == ' ' || cur.front() == '\t')) cur.erase(cur.begin()); + while (!cur.empty() && (cur.back() == ' ' || cur.back() == '\t')) cur.pop_back(); + if (!cur.empty()) out.push_back(cur); + return out; +} + +static std::string js_arr(const std::vector& xs) { + std::string out = "["; + for (size_t i = 0; i < xs.size(); ++i) { + if (i) out += ","; + out += "\""; + for (char c : xs[i]) { + if (c == '"' || c == '\\') out += '\\'; + out += c; + } + out += "\""; + } + out += "]"; + return out; +} + +static std::string js_str(const std::string& s) { + std::string out = "\""; + for (char c : s) { + if (c == '"' || c == '\\') out += '\\'; + out += c; + } + out += "\""; + return out; +} + +void draw_detail() { + if (!ImGui::Begin(TI_INFO_CIRCLE " Detail")) { + ImGui::End(); + return; + } + + Issue iss; + Meta meta; + std::string sel_id; + { + std::lock_guard g(state().mu); + sel_id = state().selected_issue_id; + iss = state().selected_issue_detail; + meta = state().meta; + } + + if (sel_id.empty()) { + ImGui::TextDisabled("Click a card on the Board to load detail."); + ImGui::End(); + return; + } + + ImGui::Text("%s — %s", iss.id.c_str(), iss.title.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton(TI_REFRESH "##reload")) { + refresh_issue_detail(sel_id); + } + ImGui::Separator(); + + // Status combo + int s_idx = find_idx(meta.statuses, iss.status); + { + std::vector items; + for (const auto& s : meta.statuses) items.push_back(s.c_str()); + if (!items.empty() && ImGui::Combo("Status", &s_idx, items.data(), (int)items.size())) { + if (s_idx >= 0 && s_idx < (int)meta.statuses.size()) { + patch_issue_status(sel_id, meta.statuses[s_idx]); + refresh_issue_detail(sel_id); + } + } + } + + // Priority combo + int p_idx = find_idx(meta.priorities, iss.priority); + { + std::vector items; + for (const auto& s : meta.priorities) items.push_back(s.c_str()); + if (!items.empty() && ImGui::Combo("Priority", &p_idx, items.data(), (int)items.size())) { + if (p_idx >= 0 && p_idx < (int)meta.priorities.size()) { + std::string body = "{\"priority\":" + js_str(meta.priorities[p_idx]) + "}"; + patch_issue_fields(sel_id, body); + refresh_issue_detail(sel_id); + } + } + } + + // Scope combo + int sc_idx = find_idx(meta.scopes, iss.scope); + { + std::vector items; + for (const auto& s : meta.scopes) items.push_back(s.c_str()); + if (!items.empty() && ImGui::Combo("Scope", &sc_idx, items.data(), (int)items.size())) { + if (sc_idx >= 0 && sc_idx < (int)meta.scopes.size()) { + std::string body = "{\"scope\":" + js_str(meta.scopes[sc_idx]) + "}"; + patch_issue_fields(sel_id, body); + refresh_issue_detail(sel_id); + } + } + } + + ImGui::Separator(); + + // Tags / domain / depends / blocks via CSV editors + auto edit_csv = [&](const char* label, std::vector& field, const char* json_key) { + static char buf[1024]; + std::string cur = join(field, ", "); + std::snprintf(buf, sizeof(buf), "%s", cur.c_str()); + if (ImGui::InputText(label, buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) { + auto parts = split_csv(buf); + std::string body = "{\""; + body += json_key; + body += "\":"; + body += js_arr(parts); + body += "}"; + patch_issue_fields(sel_id, body); + refresh_issue_detail(sel_id); + } + }; + // For each list, we render in its own scope to keep buf separate. + // ImGui uses widget id to disambiguate; we add ## suffix. + { + static char buf[1024]; + std::string cur = join(iss.tags, ", "); + std::snprintf(buf, sizeof(buf), "%s", cur.c_str()); + ImGui::PushID("tags"); + if (ImGui::InputText("Tags (CSV, Enter to save)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) { + auto parts = split_csv(buf); + std::string body = "{\"tags\":" + js_arr(parts) + "}"; + patch_issue_fields(sel_id, body); + refresh_issue_detail(sel_id); + } + ImGui::PopID(); + } + { + static char buf[1024]; + std::string cur = join(iss.domain, ", "); + std::snprintf(buf, sizeof(buf), "%s", cur.c_str()); + ImGui::PushID("domain"); + if (ImGui::InputText("Domain (CSV, Enter to save)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) { + auto parts = split_csv(buf); + std::string body = "{\"domain\":" + js_arr(parts) + "}"; + patch_issue_fields(sel_id, body); + refresh_issue_detail(sel_id); + } + ImGui::PopID(); + } + { + static char buf[1024]; + std::string cur = join(iss.depends, ", "); + std::snprintf(buf, sizeof(buf), "%s", cur.c_str()); + ImGui::PushID("depends"); + if (ImGui::InputText("Depends (CSV, Enter)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) { + auto parts = split_csv(buf); + std::string body = "{\"depends\":" + js_arr(parts) + "}"; + patch_issue_fields(sel_id, body); + refresh_issue_detail(sel_id); + } + ImGui::PopID(); + } + { + static char buf[1024]; + std::string cur = join(iss.blocks, ", "); + std::snprintf(buf, sizeof(buf), "%s", cur.c_str()); + ImGui::PushID("blocks"); + if (ImGui::InputText("Blocks (CSV, Enter)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) { + auto parts = split_csv(buf); + std::string body = "{\"blocks\":" + js_arr(parts) + "}"; + patch_issue_fields(sel_id, body); + refresh_issue_detail(sel_id); + } + ImGui::PopID(); + } + + ImGui::Separator(); + ImGui::TextDisabled("%s", iss.file_path.c_str()); + + if (ImGui::CollapsingHeader("Body (read-only)")) { + ImGui::BeginChild("body_ro", ImVec2(0, 320), true); + ImGui::TextWrapped("%s", iss.body.c_str()); + ImGui::EndChild(); + } + + (void)edit_csv; // silence unused (we inline the variants instead) + + ImGui::End(); +} + +} // namespace kanban diff --git a/panel_filters.cpp b/panel_filters.cpp new file mode 100644 index 0000000..215e1a1 --- /dev/null +++ b/panel_filters.cpp @@ -0,0 +1,70 @@ +#include "panels.h" +#include "data.h" + +#include +#include +#include +#include + +#include "core/icons_tabler.h" + +namespace kanban { + +static void multi_select(const char* label, const std::vector& options, std::set& selected) { + if (ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen)) { + for (const auto& opt : options) { + bool in = selected.find(opt) != selected.end(); + std::string id = std::string(label) + "##" + opt; + if (ImGui::Checkbox(id.c_str(), &in)) { + if (in) selected.insert(opt); + else selected.erase(opt); + } + ImGui::SameLine(); + ImGui::TextUnformatted(opt.c_str()); + } + if (!selected.empty()) { + std::string btn_id = std::string("Clear##") + label; + if (ImGui::SmallButton(btn_id.c_str())) selected.clear(); + } + } +} + +void draw_filters() { + if (!ImGui::Begin(TI_FILTER " Filters")) { + ImGui::End(); + return; + } + + std::vector domains, tags; + Meta meta; + { + std::lock_guard g(state().mu); + meta = state().meta; + } + domains = collect_domains(); + tags = collect_tags(); + + multi_select("Priorities", meta.priorities, state().filters.priorities); + multi_select("Scopes", meta.scopes, state().filters.scopes); + multi_select("Domains", domains, state().filters.domains); + multi_select("Tags", tags, state().filters.tags); + + ImGui::Separator(); + ImGui::Checkbox("Include completed", &state().filters.include_completed); + + ImGui::Separator(); + if (ImGui::Button(TI_REFRESH " Refresh all")) { + refresh_issues(); + refresh_flows(); + } + { + std::lock_guard g(state().mu); + if (!state().last_error.empty()) { + ImGui::TextColored(ImColor(255, 100, 100, 255), "%s", state().last_error.c_str()); + } + } + + ImGui::End(); +} + +} // namespace kanban diff --git a/panel_flows.cpp b/panel_flows.cpp new file mode 100644 index 0000000..8ddac6a --- /dev/null +++ b/panel_flows.cpp @@ -0,0 +1,69 @@ +#include "panels.h" +#include "data.h" + +#include +#include +#include + +#include "core/icons_tabler.h" + +namespace kanban { + +static int g_selected_flow = -1; + +void draw_flows() { + if (!ImGui::Begin(TI_LIST " Flows")) { + ImGui::End(); + return; + } + + std::vector snapshot; + { + std::lock_guard g(state().mu); + snapshot = state().flows; + } + + ImGui::Text("%zu flows", snapshot.size()); + ImGui::Separator(); + + if (ImGui::BeginTable("flows_table", 4, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("Title", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 100.0f); + ImGui::TableSetupColumn("Kind", ImGuiTableColumnFlags_WidthFixed, 100.0f); + ImGui::TableHeadersRow(); + + for (int i = 0; i < (int)snapshot.size(); ++i) { + const auto& fl = snapshot[i]; + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + bool selected = (i == g_selected_flow); + if (ImGui::Selectable(fl.id.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns)) { + g_selected_flow = i; + } + ImGui::TableNextColumn(); + ImGui::TextUnformatted(fl.title.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(fl.status.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(fl.kind.c_str()); + } + ImGui::EndTable(); + } + + if (g_selected_flow >= 0 && g_selected_flow < (int)snapshot.size()) { + const auto& fl = snapshot[g_selected_flow]; + ImGui::Separator(); + ImGui::Text("%s — %s", fl.id.c_str(), fl.title.c_str()); + if (!fl.tags.empty()) { + ImGui::SameLine(); + ImGui::TextColored(ImColor(140, 200, 255, 255), "%s %s", TI_TAG, fl.tags[0].c_str()); + } + ImGui::TextWrapped("%s", fl.file_path.c_str()); + } + + ImGui::End(); +} + +} // namespace kanban diff --git a/panels.h b/panels.h new file mode 100644 index 0000000..6710425 --- /dev/null +++ b/panels.h @@ -0,0 +1,10 @@ +#pragma once + +namespace kanban { + +void draw_board(); +void draw_flows(); +void draw_filters(); +void draw_detail(); + +} // namespace kanban