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).
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
backend/kanban_cpp_backend
|
||||
backend/operations.db*
|
||||
backend/registry.db
|
||||
build/
|
||||
*.log
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 => ../../..
|
||||
+176
@@ -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=
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
+107
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
#include "data.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
|
||||
#include "core/http_request.h"
|
||||
#include "core/sse_client.h"
|
||||
#include "core/logger.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace kanban {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
State& state() {
|
||||
static State s;
|
||||
return s;
|
||||
}
|
||||
|
||||
static std::unique_ptr<fn_sse::Client> g_sse;
|
||||
|
||||
static std::vector<std::string> j_arr(const json& j, const char* key) {
|
||||
std::vector<std::string> 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<std::string>());
|
||||
}
|
||||
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<std::string>();
|
||||
}
|
||||
|
||||
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<bool>();
|
||||
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<std::mutex> 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<std::mutex> g(state().mu);
|
||||
state().last_error = "issues: invalid JSON";
|
||||
return false;
|
||||
}
|
||||
std::vector<Issue> out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& it : j) out.push_back(parse_issue(it));
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().issues = std::move(out);
|
||||
state().last_refresh_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
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<std::mutex> 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<Flow> out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& it : j) out.push_back(parse_flow(it));
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<fn_sse::Client>();
|
||||
g_sse->start(
|
||||
cfg,
|
||||
[](const fn_sse::Event& /*ev*/) {
|
||||
std::lock_guard<std::mutex> 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<std::string>& 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<std::string> collect_domains() {
|
||||
std::set<std::string> uniq;
|
||||
for (const auto& i : state().issues) {
|
||||
for (const auto& d : i.domain) uniq.insert(d);
|
||||
}
|
||||
return {uniq.begin(), uniq.end()};
|
||||
}
|
||||
|
||||
std::vector<std::string> collect_tags() {
|
||||
std::set<std::string> uniq;
|
||||
for (const auto& i : state().issues) {
|
||||
for (const auto& t : i.tags) uniq.insert(t);
|
||||
}
|
||||
return {uniq.begin(), uniq.end()};
|
||||
}
|
||||
|
||||
} // namespace kanban
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace kanban {
|
||||
|
||||
struct Issue {
|
||||
std::string id;
|
||||
std::string title;
|
||||
std::string status;
|
||||
std::string type;
|
||||
std::string scope;
|
||||
std::string priority;
|
||||
std::vector<std::string> domain;
|
||||
std::vector<std::string> tags;
|
||||
std::vector<std::string> depends;
|
||||
std::vector<std::string> blocks;
|
||||
std::vector<std::string> 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<std::string> tags;
|
||||
std::string file_path;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
struct Meta {
|
||||
std::vector<std::string> statuses;
|
||||
std::vector<std::string> priorities;
|
||||
std::vector<std::string> scopes;
|
||||
std::vector<std::string> types;
|
||||
};
|
||||
|
||||
struct Filters {
|
||||
std::set<std::string> domains;
|
||||
std::set<std::string> scopes;
|
||||
std::set<std::string> priorities;
|
||||
std::set<std::string> tags;
|
||||
bool include_completed = false;
|
||||
};
|
||||
|
||||
struct State {
|
||||
std::mutex mu;
|
||||
std::string backend_url = "http://127.0.0.1:8487";
|
||||
std::vector<Issue> issues;
|
||||
std::vector<Flow> 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<std::string> collect_domains();
|
||||
std::vector<std::string> collect_tags();
|
||||
|
||||
} // namespace kanban
|
||||
@@ -0,0 +1,107 @@
|
||||
#include <imgui.h>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#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<bool> 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;
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
#include "panels.h"
|
||||
#include "data.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <imgui_internal.h>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<const Issue*>& 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<Issue> snapshot;
|
||||
{
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
snapshot = state().issues;
|
||||
}
|
||||
|
||||
// Bucket issues by status
|
||||
std::vector<std::vector<const Issue*>> 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
|
||||
@@ -0,0 +1,229 @@
|
||||
#include "panels.h"
|
||||
#include "data.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/icons_tabler.h"
|
||||
|
||||
namespace kanban {
|
||||
|
||||
static std::string join(const std::vector<std::string>& 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<std::string>& 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<std::string> split_csv(const std::string& s) {
|
||||
std::vector<std::string> 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<std::string>& 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<std::mutex> 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<const char*> 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<const char*> 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<const char*> 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<std::string>& 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
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "panels.h"
|
||||
#include "data.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include "core/icons_tabler.h"
|
||||
|
||||
namespace kanban {
|
||||
|
||||
static void multi_select(const char* label, const std::vector<std::string>& options, std::set<std::string>& 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<std::string> domains, tags;
|
||||
Meta meta;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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
|
||||
@@ -0,0 +1,69 @@
|
||||
#include "panels.h"
|
||||
#include "data.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#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<Flow> snapshot;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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
|
||||
Reference in New Issue
Block a user