Compare commits

27 Commits

Author SHA1 Message Date
egutierrez 466a055f72 chore: auto-commit (1 archivos)
- app.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 15:49:55 +02:00
egutierrez a934897099 merge: fill required Área Solicitante on Jira create (v0.5.2) 2026-06-01 15:41:19 +02:00
egutierrez 0687b65ea2 fix(jira): fill required 'Área Solicitante' on Epic create
Project DATA's Epic and Mejora issue types mark customfield_10158
('Área Solicitante', a single-select) as required on the create screen.
The create payload omitted it, so enabling card.created sync produced
HTTP 400 'Solicitante is required'.

Add RequesterField/RequesterMap/RequesterDefault to jiraConfig. create()
and update() now inject the field as a {value:<option>} single-select,
resolved from the card requester via the map (case-insensitive) or the
default. Kanban requesters are person names, not departments, so cards
fall through to requester_default ('Transformación' for our setup).

seed-jira-data gains --requester-field (default customfield_10158) and
--requester-default (default Transformación); the existing-module branch
now merges config so operator UI edits (e.g. a requester_map) survive a
re-seed. Validated against Jira: Epic create with the field succeeds 201
(reporter auto-defaults to the token owner).
2026-06-01 15:41:19 +02:00
egutierrez 87e8f62544 merge: jira sync on card creation (v0.5.1) 2026-06-01 15:25:26 +02:00
egutierrez 0d8ec1e8e7 fix(jira): emit card.created so card creation syncs to Jira
handleCreateCard only published board.invalidated, which is not in the
module event filter, so the dispatcher dropped it and jiraHandler.create
never ran. Newly created cards therefore never produced a Jira issue,
unlike moves (card.moved) and chat (message.created) which already synced.

Emit card.created after assignee/tags are applied so the synced issue
carries them. board.invalidated is kept for the SPA refetch path. No loop
risk (card.created fires only from the HTTP handler) and no double-create
(board.invalidated stays out of the filter).
2026-06-01 15:25:25 +02:00
egutierrez d4558667f6 feat(jira): menu 'Jira' (rename) + modal con tabs Importar/Comprobar columnas
UI:
- Menu avatar dropdown: 'Importar de Jira' -> 'Jira' (renombrado).
- ImportJiraModal.tsx eliminado. Sustituido por JiraModal.tsx con Mantine Tabs:
  * 'Importar de Jira': UI heredada del modal anterior intacta.
  * 'Comprobar columnas': nueva. Lista cards linked y muestra desincronizadas
    (kanban col vs Jira status actual). Por cada row: kanban col + expected jira
    status + jira status real. Checkbox multi-select + boton 'Sincronizar' que
    empuja Jira al status correcto (kanban gana).

Backend:
- GET /api/jira/check-columns: walk cards.jira_key != ''. Por cada uno GET
  /rest/api/3/issue/{key}?fields=status. Compara status real vs status_map.
  Devuelve {rows[], total, mismatches, in_sync, status_map, reverse_map}.
- POST /api/jira/reconcile-columns {card_ids[], direction:'kanban-wins'}:
  reusa jiraHandler.transitionToStatus para empujar cada issue al status del
  status_map de su columna kanban actual + actualiza cards.jira_last_status.
- Helper listLinkedCardsForCheck en jira_import.go.

Direction='kanban-wins' default. Reverse direction (jira-wins) no soportado
por ahora: mover cards desde el server tiene efectos colaterales (eventos,
notificaciones, timers) que no quiero disparar masivos sin pensar.
2026-05-29 15:18:59 +02:00
egutierrez 9b0b6e516c fix(jira): emitir card.moved al cambiar columna + adaptive indicator polling
Bug: handleMoveCard solo emitia board.invalidated. Dispatcher mapeaba a
update() (PUT summary/description/labels) NUNCA a transition(), asi que
mover una card en kanban no transicionaba su Jira issue de columna. Solo
los labels reflejaban el cambio.

Fix backend (handlers.go):
- handleMoveCard ahora lee column_id antes del MoveCard. Si la card crusa
  columnas (prev != new) publica 'card.moved' antes de 'board.invalidated'.
  El dispatcher reconoce 'card.moved' y ejecuta transition() -> Jira status
  cambia + labels sincronizan.
- Reorder dentro de la misma columna sigue como antes: solo board.invalidated
  para refetch del cliente sin tocar Jira.
- nuevo helper db.lookupCardColumnID(cardID).

UX frontend (JiraSyncIndicator):
- Polling adaptativo: 5s steady, 1s mientras inflight=true. El usuario VE
  el yellow durante el sync.
- Listener de window CustomEvent 'kanban-card-moved' (cardId match) que
  fuerza un refetch inmediato (~150ms) tras drop. App.tsx dispara el evento
  tras api.moveCard resolve. Yellow visible casi instantaneo en lugar de
  esperar al proximo tick steady.
2026-05-29 15:01:51 +02:00
egutierrez c5113f75a5 feat(jira): issue_type=Epic + AssigneeMap + CLI resync-jira-fields
Cambios:
- jiraConfig: nuevo campo AssigneeMap (kanban_user_id -> jira_accountId).
- jiraHandler.create() y update(): aplican fields.assignee={accountId} cuando
  card.AssigneeID esta en el map. NO se borra el assignee de Jira cuando no
  hay mapeo (evita pisar asignaciones manuales).
- resolveJiraAssignee: helper compartido.
- seed-jira-data: cambio issue_type default Tarea Tecnica -> Epic (board 33
  filtra issuetype=Epic). assignee_map inyectada con 3 mapeos confirmados:
    egutierrez (Enmaa)  -> 712020:2cf3b82f-... (Enmanuel Gutierrez Perez)
    amassaguer (alfon)  -> 712020:3f3ca9e1-... (Alfonso Massaguer Gomez)
    ntajuelo   (Nat)    -> 712020:feb5f7c5-... (Natalia Tajuelo Gomez)
- Nueva CLI 'kanban resync-jira-fields' con flags
    --set-issuetype/--set-assignee/--set-labels/--dry-run/--limit/--batch-size/--pause-sec
  Idempotente. PUT /rest/api/3/issue/{key} con los fields del config actual.
  Usado para patchear las 127 issues ya creadas con Tarea Tecnica -> Epic +
  assignee (donde mapea).
- Ejecutado: 127/127 OK, 0 fail. Board 33 ahora muestra 219 issues totales
  (92 Epics previas + 127 nuevas). Sample verificado contra Jira REST API.
2026-05-29 14:52:48 +02:00
egutierrez cd14e81487 feat(jira): kanban backfill-jira CLI con batches + ejecutado backfill 127 cards
Subcomando 'kanban backfill-jira':
- --batch-size N --pause-sec S: procesa N cards entre pausas para no saturar Jira REST.
- --limit N: cap total.
- --column NAME: filtro case-insensitive por columna kanban.
- --dry-run: lista candidatos + counts por columna sin tocar Jira.

Walk: cards con jira_key vacio, no borradas, no archivadas, ORDER BY created_at ASC.
Por cada card: jiraHandler.Handle(card.created event) que crea issue + transition al
status del status_map + labels. Tras success/failure updateCardJiraSync persiste
jira_last_status, jira_last_sync_at, jira_last_error.

Ejecutado contra Jira DATA project: 127 issues creadas (DATA-276..DATA-402), 0 fail.
Distribucion final:
  Done: 85 (HECHO)
  In Progress: 18 (HACIENDO 7 + Bloqueadas 11, las ultimas con label 'blocked')
  IMPLEMENTADO: 14 (PNDNT FEEDBACK)
  To Do: 6 (DEUDA TECNICA)
  CREADO: 4 (IDEAS)
2026-05-29 14:37:56 +02:00
egutierrez c3cc42b350 feat(jira): indicator per-card + import view desde Jira board 33
Backend:
- migration 018: cards.jira_last_status / sync_at / error (estado persistido del ultimo
  sync para render UI sin polling Jira).
- Dispatcher: sync.Map inflight para 'yellow' realtime + persistencia de exito/fallo
  en cards tras cada dispatch attempt.
- GET /api/cards/{id}/jira-sync: devuelve {jira_key, last_status, last_sync_at,
  last_error, inflight, issue_url} para el tooltip del indicador.
- GET /api/jira/issues: lista issues del board 33 con flag already_imported +
  mapped_column_id (reverse status_map). Filtros include_imported, limit.
- POST /api/jira/import: multi-key. Cada issue -> CreateCard + setCardJiraKey +
  seed jira_last_status. Cae en columna mapeada por status, o en fallback_column_id.
  ADF de description extraido a texto plano.

Frontend:
- JiraSyncIndicator: dot gris/amarillo/verde/rojo bajo IconDotsVertical de cada card.
  Mantine HoverCard con jira_key, status, last_sync, last_error, link 'Abrir en Jira'.
  Poll cada 10s, refresh-tick opcional.
- KanbanCard: agrupa menu + indicator en Stack vertical (indicator debajo de los 3 dots).
- ImportJiraModal: modal admin con tabla de issues. Checkbox por fila, filtro por texto,
  toggle 'mostrar ya importadas', Select de columna fallback. Tras import recarga board.
- App.tsx: nueva entrada de menu 'Importar de Jira' (admin) y ImportJiraModal mounted.

Backend tests siguen verdes (test mock cubre transitions endpoints).
Frontend pnpm build OK.
2026-05-29 12:00:26 +02:00
egutierrez 5744b82f58 feat(jira): issue_type config + labels_map + status_map default DATA + transition tras create
- jiraConfig: campos IssueType + LabelsMap (kanban col -> labels Jira). Default
  IssueType='Tarea Tecnica' (DATA project no tiene Task).
- create(): usa c.IssueType y aplica labels iniciales. Despues del POST /issue
  ejecuta transitionToStatus para mover la card recien creada al status del
  status_map, asi no aterriza en el initial workflow status (CREADO o To Do)
  sino donde toca segun la columna kanban.
- update() y transition(): aplican labels en cada sync (PUT replaces array).
  Card que sale de Bloqueadas pierde el label 'blocked' automaticamente.
- transitionToStatus: helper compartido entre create() y transition().
- seed-jira-data: inyecta status_map por defecto para nuestras 6 columnas
  (HACIENDO -> In Progress, PNDNT FEEDBACK -> IMPLEMENTADO, HECHO -> Done,
  IDEAS -> CREADO, DEUDA TECNICA -> To Do, Bloqueadas -> In Progress) y
  labels_map (Bloqueadas -> ['blocked']).
- modules_test: mock Jira tambien responde /transitions endpoints.
2026-05-29 11:44:04 +02:00
egutierrez ef197236db feat(modules): jira scoped a project=DATA + board=33 con seed CLI desde pass
Cambios:
- jiraConfig: nuevo campo BoardID. TestConnection valida que board.location.projectKey
  coincide con ProjectKey declarado. Refuse mismatched scopes so a typo in
  project_key cannot create issues in the wrong project.
- backend/seed_jira.go: subcomando 'kanban seed-jira-data' lee credenciales
  desde pass (jira/anjana/{email,api-token,domain}) e inserta module row con
  kind=jira, project_key=DATA, board_id=33, event_filter sensible. Idempotente
  (upsert por name). status_map vacio por defecto (operator lo edita por UI).
- main.go: wire del nuevo subcomando.

Requiere KANBAN_MODULE_KEY env var para encriptar/desencriptar config. El
servidor que ejecuta el dispatcher debe usar el mismo valor.
2026-05-28 12:56:33 +02:00
egutierrez 65771ebb12 feat(mcp): mint-token CLI + get_card / delete_comment tools + executeToolAs(actor)
Net-new capacidades recuperadas del WIP stash que el merge notif no traia:

- mint-token CLI subcommand: 'kanban mint-token --user <id> --name <pc>' genera token bearer
  para configurar Claude Code u otros clientes MCP HTTP sin tocar la UI.
- executeToolAs(db, name, input, actor): variante actor-aware de executeTool. El dispatcher
  HTTP /mcp pasa el user_id resuelto del bearer token; tools per-user (add_comment,
  delete_comment) lo usan como autor sin que el llamante pueda forjarlo.
- get_card tool: lookup por id o seq_num. Devuelve Card completa.
- delete_comment tool: borra card_message; solo el autor original (validado en DB).

executeTool() sigue siendo el wrapper legacy sin actor para chat WS.
2026-05-28 09:36:48 +02:00
egutierrez 084defe014 Merge branch 'merge/notifications-modules' into master
Trae notifications-realtime + modules + MCP tokens al master preservando
files attachments (issue 0128). Migrations renumeradas: 014_card_files (master),
015_notifications, 016_modules, 017_mcp_tokens (notif renumerada).

Bump version 0.2.0 -> 0.5.0.
2026-05-27 18:50:44 +02:00
egutierrez abb787facd chore: go mod tidy tras merge notif (nuevas deps de modules/notifications) 2026-05-27 18:50:30 +02:00
egutierrez 6a35bdec42 chore: auto-commit (5 archivos)
- backend/dist/assets/index-DFLRkdHe.js
- backend/dist/assets/index-DT3pghXY.js
- backend/dist/assets/index-UVzY_37O.js
- backend/dist/index.html
- frontend/src/components/CardChatPanel.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:48:14 +02:00
egutierrez 065070cec7 fix(tests): card_history tool ahora retorna *CardHistoryResponse — desempaquetar .ColumnHistory 2026-05-27 18:46:18 +02:00
egutierrez 172850178d merge: bring notifications-realtime + modules into master (preserves files attachments) 2026-05-27 18:43:54 +02:00
egutierrez d13993c0d7 chore(migrations): renumber 014/015/016 -> 015/016/017 to avoid collision with master 014_card_files 2026-05-27 18:38:46 +02:00
egutierrez 5b30fb1ded chore: restore control.sh TUI launcher from issue/notifications-realtime
Script vivia solo en rama issue/notifications-realtime y se perdio al
hacer checkout master para el branch 0128. Es self-contained (no toca
otros archivos de esa rama). Permite ./control.sh para gestionar
backend (WSL) + frontend Vite (Windows).
2026-05-27 11:13:03 +02:00
dataforge 87fd95314e Merge pull request 'feat(kanban): adjuntos de archivos por card (issue 0128)' (#1) from issue/0128-files-attachments into master 2026-05-27 09:04:38 +00:00
egutierrez 472fa25bae build(frontend): rebuild dist with XSS scheme guard 2026-05-27 11:04:20 +02:00
egutierrez aab4f12fc4 fix(0128): XSS scheme allowlist + drop dead fileID
review findings:
- MessageBody: only http(s) and relative paths allowed for links;
  data:image/* allowed for inline images. Rejects javascript:,
  data:text/html, vbscript: which would execute via <a href>.
  Unsafe matches fall back to plain text.
- files.go: remove unused fileID var generated then discarded.
2026-05-27 11:04:20 +02:00
egutierrez e86c93cb73 build(frontend): rebuild embedded dist with files UI 2026-05-27 10:52:15 +02:00
egutierrez 489d2bbef6 chore: bump kanban 0.1.0 -> 0.2.0 + e2e smoke (issue 0128)
- app.md: descripcion, e2e_checks smoke_files, doc Archivos, capability growth log
- .gitignore: uploads/
- e2e/files_smoke.sh: build, login, upload PNG, list, serve, delete
2026-05-27 10:52:06 +02:00
egutierrez ac5f016e7e feat(frontend): UI archivos en cards (issue 0128)
- CardFilesPanel: tab Archivos con grid thumbs + boton subir/borrar
- CardForm: drag&drop en descripcion, inserta ref markdown en cursor
- CardChatPanel: drag&drop + boton paperclip, sube y envia ref como mensaje
- MessageBody: renderer markdown minimo (img inline + link chip)
- api.ts: listCardFiles, uploadCardFile (multipart), deleteCardFile
- types.ts: CardFile
2026-05-27 10:52:01 +02:00
egutierrez 2401eb5abc feat(backend): card file attachments (issue 0128)
- migration 014_card_files: tabla con soft-delete + index activo
- handlers POST/GET/DELETE en backend/files.go
- routes /api/cards/{id}/files, /api/files/{id}
- limite 10MB, storage en uploads/<card_id>/<random>__<safe>
2026-05-27 10:51:52 +02:00
39 changed files with 5091 additions and 1437 deletions
+3
View File
@@ -16,6 +16,9 @@ frontend/tsconfig.tsbuildinfo
# Local files # Local files
local_files/ local_files/
# Card file attachments (issue 0128) — binarios en disco; metadata en card_files
uploads/
# Logs # Logs
*.log *.log
frontend/test-results/ frontend/test-results/
+17 -4
View File
@@ -2,8 +2,8 @@
name: kanban name: kanban
lang: go lang: go
domain: tools domain: tools
version: 0.4.0 version: 0.5.2
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit) y tracking del tiempo que cada tarjeta pasa en cada columna. Frontend Vite + React + Mantine v9 embebido en el binario Go." description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit), tracking del tiempo por columna, adjuntos de archivos por card, notificaciones realtime (SSE) y modulos externos (Jira). Frontend Vite + React + Mantine v9 embebido en el binario Go. Endpoint MCP Streamable HTTP en /mcp."
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking] tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
uses_functions: uses_functions:
- random_hex_id_go_core - random_hex_id_go_core
@@ -82,6 +82,10 @@ e2e_checks:
cmd: "go test -tags fts5 -count=1 ./..." cmd: "go test -tags fts5 -count=1 ./..."
timeout_s: 120 timeout_s: 120
expect_exit: 0 expect_exit: 0
- id: smoke_files
cmd: "bash e2e/files_smoke.sh"
timeout_s: 30
expect_exit: 0
--- ---
## Arquitectura ## Arquitectura
@@ -144,7 +148,12 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
- **Enlaces** (`CardLinksPanel`): extrae URLs (`https?://...`) de titulo, descripcion y cuerpo de cada mensaje del chat. Deduplica, muestra hostname + URL completa + badge de origen. Click abre en pestaña nueva (`target="_blank"`). - **Enlaces** (`CardLinksPanel`): extrae URLs (`https?://...`) de titulo, descripcion y cuerpo de cada mensaje del chat. Deduplica, muestra hostname + URL completa + badge de origen. Click abre en pestaña nueva (`target="_blank"`).
- **Duplicar card:** click derecho sobre la card abre el menu contextual (mismo que el boton `⋮`), donde aparece el item "Duplicar". Al pulsarlo invoca `POST /api/cards/{id}/duplicate`. La copia se inserta al final de la misma columna con titulo + " (copia)". - **Duplicar card:** click derecho sobre la card abre el menu contextual (mismo que el boton `⋮`), donde aparece el item "Duplicar". Al pulsarlo invoca `POST /api/cards/{id}/duplicate`. La copia se inserta al final de la misma columna con titulo + " (copia)".
- **Sesion obligatoria para chat:** `POST/DELETE /api/cards/{id}/messages` exige sesion activa (401 si falta). `author_id` siempre poblado; no hay comentarios anonimos. - **Sesion obligatoria para chat:** `POST/DELETE /api/cards/{id}/messages` exige sesion activa (401 si falta). `author_id` siempre poblado; no hay comentarios anonimos.
- **Archivos (proximamente):** blobs persistidos en SQLite (`card_attachments` con `BLOB`), no en filesystem. - **Archivos** (`CardFilesPanel`): adjuntos por card almacenados en `apps/kanban/uploads/<card_id>/<random>__<safe_filename>` (filesystem, gitignored). Tabla `card_files` con soft-delete. Limite 10 MB por archivo. Tres vias de upload:
1. Drag&drop en el editor de descripcion (`CardForm`) → inserta `![name](url)` (imagen) o `[name](url)` (resto) en la posicion del cursor.
2. Drag&drop o boton paperclip en el chat (`CardChatPanel`) → crea un mensaje cuyo cuerpo es la ref markdown.
3. Boton "Subir" en el tab Archivos → sube sin embed.
- El renderer de mensajes (`MessageBody`) reconoce `![alt](url)` -> `<Image>` thumb 220px y `[name](url)` -> `<Anchor>`. Texto plano se renderiza con `whiteSpace: pre-wrap`.
- Endpoints: `POST /api/cards/{id}/files` (multipart, 10 MB max), `GET /api/cards/{id}/files`, `GET /api/files/{id}` (sirve binario con `inline` o `attachment` segun MIME), `DELETE /api/files/{id}` (soft delete).
### Build ### Build
@@ -183,5 +192,9 @@ Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
- `patch`: bugfix sin cambio observable. - `patch`: bugfix sin cambio observable.
- v0.1.0 (2026-05-18) — baseline. - v0.1.0 (2026-05-18) — baseline.
- v0.2.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): tabla `card_files` con soft-delete, endpoints REST (`POST/GET/DELETE /api/cards/{id}/files`, `GET/DELETE /api/files/{id}`), tres vias de upload (drag&drop en descripcion y chat, boton en tab Archivos), render inline de imagenes via `MessageBody`. Limite 10 MB.
- v0.3.1 (2026-05-21) — patch: debounce board.invalidated (300ms trailing) + autoClose 4s en toasts de notification.created. Fix de blow-up de memoria en navegador por ráfagas de SSE. - v0.3.1 (2026-05-21) — patch: debounce board.invalidated (300ms trailing) + autoClose 4s en toasts de notification.created. Fix de blow-up de memoria en navegador por ráfagas de SSE.
- v0.4.0 (2026-05-22) — minor: endpoint MCP Streamable HTTP `/mcp` con per-user bearer tokens (tabla `mcp_tokens`, migration 016). Modal "MCP tokens" en avatar menu para generar/listar/revocar. Vite proxy enruta `/mcp` a WSL. Usa nueva funcion `mcp_server_http_go_infra`. Doc en `docs/MCP.md`. - v0.4.0 (2026-05-22) — minor: endpoint MCP Streamable HTTP `/mcp` con per-user bearer tokens (tabla `mcp_tokens`, migration 017). Modal "MCP tokens" en avatar menu para generar/listar/revocar. Vite proxy enruta `/mcp` a WSL. Usa nueva funcion `mcp_server_http_go_infra`. Doc en `docs/MCP.md`.
- v0.5.2 (2026-06-01) — patch: el alta a Jira rellena el campo obligatorio "Área Solicitante" (`customfield_10158`) que el issue type Epic (y Mejora) del proyecto DATA exige en la pantalla de creacion. Sin esto, el `card.created` del 0.5.1 daba HTTP 400 "Solicitante is required". Nuevos campos en `jiraConfig`: `requester_field`, `requester_map`, `requester_default`. `create()`/`update()` inyectan el campo como single-select `{value:<opcion>}` resuelto desde el requester de la card (mapa case-insensitive) o el default. Como los requesters del kanban son nombres de persona (no departamentos), las cards caen al default (`Transformación`). `seed-jira-data` gana flags `--requester-field`/`--requester-default` y la rama de update ahora mergea config para no pisar ediciones de UI.
- v0.5.1 (2026-06-01) — patch: `handleCreateCard` ahora emite el evento `card.created` (antes solo `board.invalidated`, que no estaba en el filtro del modulo). Con esto la creacion de una card dispara `jiraHandler.create` y sincroniza el alta a Jira, igual que ya ocurria con move (`card.moved`) y chat (`message.created`). El evento se emite tras aplicar assignee/tags para que el issue de Jira los lleve.
- v0.5.0 (2026-05-27) — minor: merge ramas notifications-realtime + modules con master post-files. Trae notificaciones SSE (tabla `notifications`, migration 015), modulos externos para sincronizacion bidireccional (Jira, etc., tabla `modules`, migration 016), tokens MCP per-user (migration 017). Conserva files attachments del 0128. Renumeradas migrations notif 014/015/016 -> 015/016/017.
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"strings"
"time"
)
// backfillCandidate is the minimal projection we read from the cards table
// for the backfill loop. Avoids loading the full Card row + history we do not
// need.
type backfillCandidate struct {
ID string
Title string
ColumnID string
ColumnName string
}
// runBackfillJira walks every active kanban card that is not yet linked to a
// Jira issue and creates one via the configured jira module's handler. It is
// the only sanctioned way to backport existing kanban cards into Jira because
// the event dispatcher only ever fires on NEW kanban mutations.
//
// Batching: cards are processed in groups of --batch-size with a
// --pause-sec sleep between groups so we stay well below Jira's REST quota.
// --limit caps the total number of cards processed (0 = no cap).
// --column restricts to a single kanban column by name (case-insensitive).
// --dry-run lists the candidates without calling Jira.
func runBackfillJira(args []string) error {
fs := flag.NewFlagSet("kanban backfill-jira", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
batchSize := fs.Int("batch-size", 10, "Cards processed per batch before pausing")
pauseSec := fs.Int("pause-sec", 5, "Seconds to sleep between batches")
limit := fs.Int("limit", 0, "Maximum cards to process total (0 = no limit)")
columnFilter := fs.String("column", "", "Only backfill cards whose column name matches (case-insensitive)")
dryRun := fs.Bool("dry-run", false, "List candidates and exit without calling Jira")
if err := fs.Parse(args); err != nil {
return err
}
if *batchSize <= 0 {
return fmt.Errorf("--batch-size must be > 0")
}
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
mod, cfg, err := activeJiraModule(db)
if err != nil {
return fmt.Errorf("module config: %w", err)
}
candidates, err := listBackfillCandidates(db, *columnFilter, *limit)
if err != nil {
return err
}
if len(candidates) == 0 {
fmt.Println("no cards to backfill (all active cards have jira_key set or filter is empty)")
return nil
}
fmt.Printf("backfill plan: %d cards across columns; batch=%d pause=%ds dry_run=%v\n",
len(candidates), *batchSize, *pauseSec, *dryRun)
fmt.Printf("module: %q (project=%s, board=%d, issue_type=%q)\n",
mod.Name, cfg.ProjectKey, cfg.BoardID, cfg.IssueType)
fmt.Println()
if *dryRun {
printCandidates(candidates)
return nil
}
h := &jiraHandler{}
var ok, failed int
for i := 0; i < len(candidates); i++ {
c := candidates[i]
if i > 0 && i%*batchSize == 0 {
fmt.Printf("--- batch boundary (%d/%d) — sleeping %ds ---\n", i, len(candidates), *pauseSec)
time.Sleep(time.Duration(*pauseSec) * time.Second)
}
status, err := h.Handle(context.Background(), db, mod, Event{
Type: "card.created",
CardID: c.ID,
})
now := time.Now().UTC().Format(time.RFC3339)
if err != nil {
failed++
_ = db.updateCardJiraSync(c.ID, "", now, err.Error())
fmt.Printf("[%4d/%4d] FAIL %-40s http=%d err=%s\n",
i+1, len(candidates), truncateInline(c.Title, 40), status, truncateInline(err.Error(), 80))
continue
}
ok++
statusName := cfg.StatusMap[c.ColumnName]
_ = db.updateCardJiraSync(c.ID, statusName, now, "")
// After Handle() the card row now carries the assigned jira_key.
linked, _ := db.lookupCardJiraKey(c.ID)
fmt.Printf("[%4d/%4d] OK %-40s -> %-12s status=%s\n",
i+1, len(candidates), truncateInline(c.Title, 40), linked, statusName)
}
fmt.Println()
fmt.Printf("done: %d ok · %d failed · %d total\n", ok, failed, len(candidates))
if failed > 0 {
return fmt.Errorf("%d cards failed to backfill", failed)
}
return nil
}
// listBackfillCandidates returns active (not deleted, not archived) cards
// without a Jira link, optionally filtered by column name. Newest-first so
// recent cards are mirrored first.
func listBackfillCandidates(db *DB, columnFilter string, limit int) ([]backfillCandidate, error) {
q := `
SELECT c.id, c.title, c.column_id, col.name
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE (c.jira_key IS NULL OR c.jira_key = '')
AND c.deleted_at IS NULL
AND c.archived_at IS NULL
`
args := []interface{}{}
if strings.TrimSpace(columnFilter) != "" {
q += " AND LOWER(col.name) = LOWER(?) "
args = append(args, columnFilter)
}
q += " ORDER BY c.created_at ASC "
if limit > 0 {
q += " LIMIT ? "
args = append(args, limit)
}
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("list candidates: %w", err)
}
defer rows.Close()
out := []backfillCandidate{}
for rows.Next() {
var c backfillCandidate
if err := rows.Scan(&c.ID, &c.Title, &c.ColumnID, &c.ColumnName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// lookupCardJiraKey returns the jira_key column for a card, or empty string.
// Used to print the freshly-assigned key in the progress log.
func (db *DB) lookupCardJiraKey(cardID string) (string, error) {
var k sql.NullString
err := db.conn.QueryRow(`SELECT jira_key FROM cards WHERE id = ?`, cardID).Scan(&k)
if err != nil {
return "", err
}
if !k.Valid {
return "", nil
}
return k.String, nil
}
func printCandidates(cs []backfillCandidate) {
byCol := map[string]int{}
for _, c := range cs {
byCol[c.ColumnName]++
}
fmt.Println("candidates by column:")
for col, n := range byCol {
fmt.Printf(" %-25s %d\n", col, n)
}
fmt.Println()
fmt.Printf("first 20 of %d:\n", len(cs))
for i, c := range cs {
if i >= 20 {
break
}
fmt.Printf(" %-12s [%s] %s\n", c.ID, c.ColumnName, truncateInline(c.Title, 60))
}
}
func truncateInline(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title> <title>Kanban</title>
<script type="module" crossorigin src="/assets/index-UVzY_37O.js"></script> <script type="module" crossorigin src="/assets/index-Be_Ib5cu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css"> <link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head> </head>
<body> <body>
+346
View File
@@ -0,0 +1,346 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fn-registry/functions/infra"
)
// Issue 0128: adjuntos de archivos por card.
const (
maxUploadBytes = 10 << 20 // 10 MiB
uploadsSubdir = "uploads"
)
type CardFile struct {
ID string `json:"id"`
CardID string `json:"card_id"`
UploaderID string `json:"uploader_id"`
Filename string `json:"filename"`
MIME string `json:"mime"`
Size int64 `json:"size"`
Source string `json:"source"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
}
func (db *DB) CreateCardFile(cardID, uploaderID, filename, mimeType, storedPath, source string, size int64) (*CardFile, error) {
id := newID()
now := nowRFC3339()
if source == "" {
source = "upload"
}
_, err := db.conn.Exec(
`INSERT INTO card_files
(id, card_id, uploader_id, filename, mime, size, stored_path, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, cardID, uploaderID, filename, mimeType, size, storedPath, source, now,
)
if err != nil {
return nil, err
}
return &CardFile{
ID: id,
CardID: cardID,
UploaderID: uploaderID,
Filename: filename,
MIME: mimeType,
Size: size,
Source: source,
URL: "/api/files/" + id,
CreatedAt: now,
}, nil
}
func (db *DB) ListCardFiles(cardID string) ([]CardFile, error) {
rows, err := db.conn.Query(
`SELECT id, card_id, uploader_id, filename, mime, size, source, created_at
FROM card_files
WHERE card_id = ? AND deleted_at IS NULL
ORDER BY created_at ASC`,
cardID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CardFile{}
for rows.Next() {
var f CardFile
if err := rows.Scan(&f.ID, &f.CardID, &f.UploaderID, &f.Filename, &f.MIME, &f.Size, &f.Source, &f.CreatedAt); err != nil {
return nil, err
}
f.URL = "/api/files/" + f.ID
out = append(out, f)
}
return out, rows.Err()
}
type storedCardFile struct {
ID string
CardID string
Filename string
MIME string
Size int64
StoredPath string
}
func (db *DB) GetCardFile(id string) (*storedCardFile, error) {
var f storedCardFile
err := db.conn.QueryRow(
`SELECT id, card_id, filename, mime, size, stored_path
FROM card_files
WHERE id = ? AND deleted_at IS NULL`,
id,
).Scan(&f.ID, &f.CardID, &f.Filename, &f.MIME, &f.Size, &f.StoredPath)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &f, nil
}
func (db *DB) SoftDeleteCardFile(id string) (int64, error) {
res, err := db.conn.Exec(
`UPDATE card_files SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
nowRFC3339(), id,
)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func uploadsDir(workdir string) string {
return filepath.Join(workdir, uploadsSubdir)
}
func safeFilename(name string) string {
name = filepath.Base(name)
name = strings.ReplaceAll(name, string(os.PathSeparator), "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "file"
}
return name
}
func randomFilePrefix() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// POST /api/cards/{id}/files (multipart, field "file", optional "source")
func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cardID := r.PathValue("id")
if cardID == "" {
badRequest(w, "card id required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20)
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
badRequest(w, "multipart parse: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
badRequest(w, "missing 'file' field: "+err.Error())
return
}
defer file.Close()
if header.Size > maxUploadBytes {
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusRequestEntityTooLarge,
Code: "file_too_large",
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
})
return
}
source := r.FormValue("source")
switch source {
case "", "upload":
source = "upload"
case "description", "chat":
// keep
default:
source = "upload"
}
dir := filepath.Join(uploadsDir(workdir), cardID)
if err := os.MkdirAll(dir, 0o755); err != nil {
serverError(w, fmt.Errorf("mkdir uploads: %w", err))
return
}
fname := safeFilename(header.Filename)
storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname)
out, err := os.Create(storedPath)
if err != nil {
serverError(w, fmt.Errorf("create file: %w", err))
return
}
written, copyErr := io.Copy(out, file)
closeErr := out.Close()
if copyErr != nil {
os.Remove(storedPath)
serverError(w, fmt.Errorf("write file: %w", copyErr))
return
}
if closeErr != nil {
os.Remove(storedPath)
serverError(w, fmt.Errorf("close file: %w", closeErr))
return
}
if written > maxUploadBytes {
os.Remove(storedPath)
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusRequestEntityTooLarge,
Code: "file_too_large",
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
})
return
}
mimeType := header.Header.Get("Content-Type")
if mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(fname))
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written)
if err != nil {
os.Remove(storedPath)
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, cf)
}
}
// GET /api/cards/{id}/files
func handleListCardFiles(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cardID := r.PathValue("id")
if cardID == "" {
badRequest(w, "card id required")
return
}
files, err := db.ListCardFiles(cardID)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, files)
}
}
// GET /api/files/{id}
func handleServeFile(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
badRequest(w, "file id required")
return
}
f, err := db.GetCardFile(id)
if err != nil {
serverError(w, err)
return
}
if f == nil {
notFound(w, "file not found")
return
}
fh, err := os.Open(f.StoredPath)
if err != nil {
notFound(w, "file missing on disk")
return
}
defer fh.Close()
if f.MIME != "" {
w.Header().Set("Content-Type", f.MIME)
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
disposition := "inline"
if !isInlineMIME(f.MIME) {
disposition = "attachment"
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizeHeaderFilename(f.Filename)))
w.Header().Set("Cache-Control", "private, max-age=3600")
_, _ = io.Copy(w, fh)
}
}
// DELETE /api/files/{id}
func handleDeleteCardFile(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
badRequest(w, "file id required")
return
}
n, err := db.SoftDeleteCardFile(id)
if err != nil {
serverError(w, err)
return
}
if n == 0 {
notFound(w, "file not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func isInlineMIME(m string) bool {
if m == "" {
return false
}
m = strings.ToLower(m)
switch {
case strings.HasPrefix(m, "image/"):
return true
case m == "application/pdf":
return true
case strings.HasPrefix(m, "text/"):
return true
}
return false
}
func sanitizeHeaderFilename(name string) string {
name = strings.ReplaceAll(name, `"`, "")
name = strings.ReplaceAll(name, "\n", "")
name = strings.ReplaceAll(name, "\r", "")
return name
}
+33 -11
View File
@@ -2,20 +2,31 @@ module kanban
go 1.25.0 go 1.25.0
require fn-registry v0.0.0-00010101000000-000000000000 require (
fn-registry v0.0.0-00010101000000-000000000000
github.com/mattn/go-sqlite3 v1.14.44
golang.org/x/crypto v0.51.0
nhooyr.io/websocket v1.8.17
)
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/errors v0.7.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/pgx/v5 v5.9.1 // indirect
@@ -23,27 +34,38 @@ require (
github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/paulmach/orb v0.12.0 // indirect github.com/paulmach/orb v0.12.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/rs/zerolog v1.35.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.mau.fi/util v0.9.9 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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-20260508232706-74f9aab9d74a // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect golang.org/x/mod v0.36.0 // indirect
golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.43.0 // indirect golang.org/x/tools v0.45.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
nhooyr.io/websocket v1.8.17 // indirect maunium.net/go/mautrix v0.28.0 // indirect
) )
replace fn-registry => ../../.. replace fn-registry => ../../..
+65 -18
View File
@@ -1,18 +1,28 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= 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/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 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0= 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 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= 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 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
@@ -21,6 +31,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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/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.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -34,6 +46,8 @@ 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -60,8 +74,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8= 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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= 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/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 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
@@ -70,6 +90,8 @@ github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJ
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= 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/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= 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/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -77,17 +99,33 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 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/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
@@ -96,10 +134,16 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= 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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= 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/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 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= 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 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
@@ -111,21 +155,21 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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-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.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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-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-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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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-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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -138,23 +182,24 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-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.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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-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-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.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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -172,5 +217,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
+38
View File
@@ -203,6 +203,13 @@ func handleCreateCard(db *DB, hub *EventHub) http.HandlerFunc {
serverError(w, err) serverError(w, err)
return return
} }
// card.created drives outbound modules (Jira) to create the issue.
// Emitted after assignee/tags are applied so the synced issue carries
// them. board.invalidated stays for the SPA's refetch path.
hub.PublishJSON("card.created", c.ID, "", map[string]string{
"card_id": c.ID,
"column_id": body.ColumnID,
})
publishInvalidated(hub, c.ID, body.ColumnID) publishInvalidated(hub, c.ID, body.ColumnID)
infra.HTTPJSONResponse(w, http.StatusCreated, c) infra.HTTPJSONResponse(w, http.StatusCreated, c)
} }
@@ -322,6 +329,10 @@ func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
badRequest(w, "column_id required") badRequest(w, "column_id required")
return return
} }
// Read the previous column BEFORE mutating so we can decide whether
// this is an actual column move (vs a same-column reorder). Outbound
// modules (Jira) only care about the former.
prevColumnID, _ := db.lookupCardColumnID(id)
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil { if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
if strings.Contains(err.Error(), "not found") { if strings.Contains(err.Error(), "not found") {
@@ -331,6 +342,17 @@ func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
serverError(w, err) serverError(w, err)
return return
} }
// Distinct event when the card crossed columns so the Jira module
// runs transition() instead of plain update(). Reorder-only goes
// straight to board.invalidated (frontend refetch) without a Jira
// roundtrip.
if prevColumnID != "" && prevColumnID != body.ColumnID {
hub.PublishJSON("card.moved", id, "", map[string]string{
"card_id": id,
"from_column_id": prevColumnID,
"to_column_id": body.ColumnID,
})
}
publishInvalidated(hub, id, body.ColumnID) publishInvalidated(hub, id, body.ColumnID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -664,21 +686,37 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)}, {Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)}, {Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)}, {Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
// Issue 0128: adjuntos de archivos.
{Method: "POST", Path: "/api/cards/{id}/files", Handler: handleUploadCardFile(db, chatWorkdir)},
{Method: "GET", Path: "/api/cards/{id}/files", Handler: handleListCardFiles(db)},
{Method: "GET", Path: "/api/files/{id}", Handler: handleServeFile(db)},
{Method: "DELETE", Path: "/api/files/{id}", Handler: handleDeleteCardFile(db)},
// Notifications + realtime (issue notifications-realtime).
{Method: "GET", Path: "/api/events", Handler: handleEventStream(hub)}, {Method: "GET", Path: "/api/events", Handler: handleEventStream(hub)},
{Method: "GET", Path: "/api/cards/{id}/chat/ws", Handler: handleCardChatWS(db, hub)}, {Method: "GET", Path: "/api/cards/{id}/chat/ws", Handler: handleCardChatWS(db, hub)},
{Method: "GET", Path: "/api/notifications", Handler: handleListNotifications(db)}, {Method: "GET", Path: "/api/notifications", Handler: handleListNotifications(db)},
{Method: "GET", Path: "/api/notifications/unread-count", Handler: handleUnreadCount(db)}, {Method: "GET", Path: "/api/notifications/unread-count", Handler: handleUnreadCount(db)},
{Method: "POST", Path: "/api/notifications/{id}/read", Handler: handleMarkNotificationRead(db, hub)}, {Method: "POST", Path: "/api/notifications/{id}/read", Handler: handleMarkNotificationRead(db, hub)},
{Method: "POST", Path: "/api/notifications/read-all", Handler: handleMarkAllNotificationsRead(db, hub)}, {Method: "POST", Path: "/api/notifications/read-all", Handler: handleMarkAllNotificationsRead(db, hub)},
// MCP per-user tokens.
{Method: "POST", Path: "/api/mcp-tokens", Handler: handleCreateMCPToken(db)}, {Method: "POST", Path: "/api/mcp-tokens", Handler: handleCreateMCPToken(db)},
{Method: "GET", Path: "/api/mcp-tokens", Handler: handleListMCPTokens(db)}, {Method: "GET", Path: "/api/mcp-tokens", Handler: handleListMCPTokens(db)},
{Method: "DELETE", Path: "/api/mcp-tokens/{id}", Handler: handleRevokeMCPToken(db)}, {Method: "DELETE", Path: "/api/mcp-tokens/{id}", Handler: handleRevokeMCPToken(db)},
// Modules: external integrations (Jira, ...).
{Method: "GET", Path: "/api/modules", Handler: handleListModules(db)}, {Method: "GET", Path: "/api/modules", Handler: handleListModules(db)},
{Method: "POST", Path: "/api/modules", Handler: handleCreateModule(db)}, {Method: "POST", Path: "/api/modules", Handler: handleCreateModule(db)},
{Method: "PATCH", Path: "/api/modules/{id}", Handler: handleUpdateModule(db)}, {Method: "PATCH", Path: "/api/modules/{id}", Handler: handleUpdateModule(db)},
{Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)}, {Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)},
{Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)}, {Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)},
{Method: "POST", Path: "/api/modules/{id}/test", Handler: handleTestModule(db, dispatcher)}, {Method: "POST", Path: "/api/modules/{id}/test", Handler: handleTestModule(db, dispatcher)},
// Per-card Jira sync state (indicator + tooltip).
{Method: "GET", Path: "/api/cards/{id}/jira-sync", Handler: handleCardJiraSync(db, dispatcher)},
// Jira import: list issues not yet in kanban + bulk import.
{Method: "GET", Path: "/api/jira/issues", Handler: handleListJiraIssues(db)},
{Method: "POST", Path: "/api/jira/import", Handler: handleImportJiraIssues(db)},
// Jira column-sync check: detect drift between kanban col ↔ Jira status.
{Method: "GET", Path: "/api/jira/check-columns", Handler: handleCheckJiraColumns(db)},
{Method: "POST", Path: "/api/jira/reconcile-columns", Handler: handleReconcileJiraColumns(db)},
} }
} }
+569
View File
@@ -0,0 +1,569 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"fn-registry/functions/infra"
)
// jiraImportRequest is the body shape for POST /api/jira/import.
type jiraImportRequest struct {
IssueKeys []string `json:"issue_keys"`
FallbackColumnID string `json:"fallback_column_id"` // optional: where to land issues whose status has no kanban mapping
}
// jiraIssueOut is what we return in GET /api/jira/issues for the frontend
// import picker. We deliberately keep this small — clicking a row redirects
// to Jira for full detail.
type jiraIssueOut struct {
Key string `json:"key"`
Summary string `json:"summary"`
StatusName string `json:"status_name"`
IssueType string `json:"issue_type"`
Assignee string `json:"assignee"`
Updated string `json:"updated"`
URL string `json:"url"`
AlreadyImported bool `json:"already_imported"`
MappedColumnID string `json:"mapped_column_id,omitempty"`
IssueTypeIcon string `json:"issue_type_icon,omitempty"`
}
// linkedCardForCheck is the projection used by the check-columns endpoint.
// We only need fields visible in the report table.
type linkedCardForCheck struct {
ID string
Title string
JiraKey string
ColumnID string
ColumnName string
}
func listLinkedCardsForCheck(db *DB) ([]linkedCardForCheck, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.title, c.jira_key, c.column_id, col.name
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE c.jira_key != ''
AND c.deleted_at IS NULL
ORDER BY c.jira_key ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []linkedCardForCheck{}
for rows.Next() {
var c linkedCardForCheck
if err := rows.Scan(&c.ID, &c.Title, &c.JiraKey, &c.ColumnID, &c.ColumnName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// activeJiraModule returns the first enabled Jira module + its decoded config,
// or an error if no module is configured. The handlers below need both the
// credentials and the status_map to operate.
func activeJiraModule(db *DB) (Module, jiraConfig, error) {
mods, err := db.listModulesEnabled()
if err != nil {
return Module{}, jiraConfig{}, err
}
for _, m := range mods {
if m.Kind != "jira" {
continue
}
cfg, perr := parseJiraConfig(m)
if perr != nil {
return Module{}, jiraConfig{}, perr
}
return m, cfg, nil
}
return Module{}, jiraConfig{}, fmt.Errorf("no enabled jira module configured")
}
// jiraGET performs an authenticated GET against the configured Jira API and
// decodes the JSON response into out.
func jiraGET(ctx context.Context, cfg jiraConfig, path string, out interface{}) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.BaseURL+path, nil)
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/json")
if cfg.Email != "" && cfg.APIToken != "" {
basic := base64.StdEncoding.EncodeToString([]byte(cfg.Email + ":" + cfg.APIToken))
req.Header.Set("Authorization", "Basic "+basic)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode >= 400 {
return resp.StatusCode, fmt.Errorf("jira GET %s: %d %s", path, resp.StatusCode, truncate(body, 240))
}
if out != nil {
if err := json.Unmarshal(body, out); err != nil {
return resp.StatusCode, fmt.Errorf("decode %s: %w", path, err)
}
}
return resp.StatusCode, nil
}
// extractADFText walks an Atlassian Document Format JSON node and collects the
// text content. Returns "" when the input is not a valid ADF doc. Used to
// pre-populate the kanban card description on import — operators can edit it
// later, the Jira link is the source of truth for rich content.
func extractADFText(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var node struct {
Type string `json:"type"`
Text string `json:"text"`
Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(raw, &node); err != nil {
return ""
}
var buf bytes.Buffer
collectADFText(&buf, node.Type, node.Text, node.Content)
return strings.TrimSpace(buf.String())
}
func collectADFText(buf *bytes.Buffer, nodeType, text string, content []json.RawMessage) {
if text != "" {
buf.WriteString(text)
}
for _, c := range content {
var inner struct {
Type string `json:"type"`
Text string `json:"text"`
Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(c, &inner); err != nil {
continue
}
collectADFText(buf, inner.Type, inner.Text, inner.Content)
}
// Paragraph / list-item / heading boundaries get a newline so the result
// is roughly readable in the kanban card body.
switch nodeType {
case "paragraph", "heading", "listItem", "bulletList", "orderedList":
buf.WriteString("\n")
}
}
// reverseStatusMap inverts the kanban-col-name -> jira-status-name mapping so
// importers can land an issue in the column whose status matches. Lower-cased
// keys for case-insensitive lookup.
func reverseStatusMap(cfg jiraConfig) map[string]string {
out := make(map[string]string, len(cfg.StatusMap))
for col, status := range cfg.StatusMap {
out[strings.ToLower(status)] = col
}
return out
}
// handleListJiraIssues fetches up to `limit` issues from the configured Jira
// board and annotates each with whether it is already imported into kanban
// and (if mappable) the kanban column it would land in.
func handleListJiraIssues(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
if cfg.BoardID <= 0 {
badRequest(w, "module is missing board_id")
return
}
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if n, perr := strconv.Atoi(v); perr == nil && n > 0 && n <= 200 {
limit = n
}
}
showImported := r.URL.Query().Get("include_imported") == "true"
q := url.Values{}
q.Set("maxResults", strconv.Itoa(limit))
q.Set("fields", "summary,status,assignee,updated,issuetype,description")
path := fmt.Sprintf("/rest/agile/1.0/board/%d/issue?%s", cfg.BoardID, q.Encode())
var page struct {
Issues []struct {
Key string `json:"key"`
Fields struct {
Summary string `json:"summary"`
Status struct {
Name string `json:"name"`
} `json:"status"`
Assignee *struct {
DisplayName string `json:"displayName"`
} `json:"assignee"`
Updated string `json:"updated"`
IssueType struct {
Name string `json:"name"`
IconURL string `json:"iconUrl"`
} `json:"issuetype"`
} `json:"fields"`
} `json:"issues"`
}
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout)
defer cancel()
if _, err := jiraGET(ctx, cfg, path, &page); err != nil {
serverError(w, err)
return
}
// Build lookup: jira_key -> bool (already imported)
importedKeys, err := db.listImportedJiraKeys()
if err != nil {
serverError(w, err)
return
}
colByName, err := db.listColumnsByName()
if err != nil {
serverError(w, err)
return
}
statusToCol := reverseStatusMap(cfg)
out := make([]jiraIssueOut, 0, len(page.Issues))
for _, iss := range page.Issues {
already := importedKeys[iss.Key]
if already && !showImported {
continue
}
row := jiraIssueOut{
Key: iss.Key,
Summary: iss.Fields.Summary,
StatusName: iss.Fields.Status.Name,
IssueType: iss.Fields.IssueType.Name,
Updated: iss.Fields.Updated,
URL: cfg.BaseURL + "/browse/" + iss.Key,
AlreadyImported: already,
IssueTypeIcon: iss.Fields.IssueType.IconURL,
}
if iss.Fields.Assignee != nil {
row.Assignee = iss.Fields.Assignee.DisplayName
}
if colName, ok := statusToCol[strings.ToLower(iss.Fields.Status.Name)]; ok {
if col, ok := colByName[colName]; ok {
row.MappedColumnID = col.ID
}
}
out = append(out, row)
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"issues": out,
"board_id": cfg.BoardID,
"project_key": cfg.ProjectKey,
"status_to_column": statusToCol,
"include_imported": showImported,
})
})
}
// jiraCheckRow is one row of the check-columns report.
type jiraCheckRow struct {
CardID string `json:"card_id"`
JiraKey string `json:"jira_key"`
Title string `json:"title"`
KanbanColumnID string `json:"kanban_column_id"`
KanbanColumnName string `json:"kanban_column_name"`
JiraStatusName string `json:"jira_status_name"`
ExpectedKanbanCol string `json:"expected_kanban_col"` // kanban col that matches the current Jira status (reverse status_map)
ExpectedJiraStat string `json:"expected_jira_status"` // jira status that matches the current kanban col (status_map)
Mismatch bool `json:"mismatch"`
IssueURL string `json:"issue_url"`
}
// handleCheckJiraColumns walks every linked card, fetches its current Jira
// status, and reports whether the kanban column ↔ Jira status mapping is in
// sync. Used by the "Comprobar columnas" tab in the Jira modal.
//
// Performance note: one Jira REST call per linked card. With 127 cards that
// is ~127 round-trips — slow (≈30s end-to-end) but tolerable as an admin op.
// A future optimisation could batch via /search/jql with key IN (...) and a
// fields=status projection.
func handleCheckJiraColumns(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
cards, err := listLinkedCardsForCheck(db)
if err != nil {
serverError(w, err)
return
}
statusToCol := reverseStatusMap(cfg)
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*5)
defer cancel()
rows := make([]jiraCheckRow, 0, len(cards))
var mismatches int
for _, c := range cards {
var iss struct {
Fields struct {
Status struct {
Name string `json:"name"`
} `json:"status"`
} `json:"fields"`
}
if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(c.JiraKey)+"?fields=status", &iss); err != nil {
rows = append(rows, jiraCheckRow{
CardID: c.ID,
JiraKey: c.JiraKey,
Title: c.Title,
KanbanColumnID: c.ColumnID,
KanbanColumnName: c.ColumnName,
JiraStatusName: "(fetch failed: " + err.Error() + ")",
Mismatch: true,
IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey,
})
mismatches++
continue
}
expectedCol := statusToCol[strings.ToLower(iss.Fields.Status.Name)]
expectedStat := cfg.StatusMap[c.ColumnName]
mm := !strings.EqualFold(iss.Fields.Status.Name, expectedStat)
if mm {
mismatches++
}
rows = append(rows, jiraCheckRow{
CardID: c.ID,
JiraKey: c.JiraKey,
Title: c.Title,
KanbanColumnID: c.ColumnID,
KanbanColumnName: c.ColumnName,
JiraStatusName: iss.Fields.Status.Name,
ExpectedKanbanCol: expectedCol,
ExpectedJiraStat: expectedStat,
Mismatch: mm,
IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey,
})
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"rows": rows,
"total": len(rows),
"mismatches": mismatches,
"in_sync": len(rows) - mismatches,
"status_map": cfg.StatusMap,
"reverse_map": statusToCol,
})
})
}
// reconcileRequest is the body shape for POST /api/jira/reconcile-columns.
// direction=kanban-wins → push Jira to match kanban (the only mode for now;
// reverse is risky because moving cards in kanban can trigger downstream
// notifications/timers).
type reconcileRequest struct {
CardIDs []string `json:"card_ids"`
Direction string `json:"direction"` // currently only "kanban-wins"
}
// handleReconcileJiraColumns transitions each requested issue so its status
// matches the current kanban column (kanban as source of truth). Reuses
// the dispatcher's transitionToStatus helper for consistency with the
// regular card.moved path. Per-card result.
func handleReconcileJiraColumns(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
var body reconcileRequest
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if len(body.CardIDs) == 0 {
badRequest(w, "card_ids required")
return
}
if body.Direction == "" {
body.Direction = "kanban-wins"
}
if body.Direction != "kanban-wins" {
badRequest(w, "only direction=kanban-wins is supported")
return
}
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
h := &jiraHandler{}
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.CardIDs)+1))
defer cancel()
results := make([]map[string]interface{}, 0, len(body.CardIDs))
for _, cid := range body.CardIDs {
res := map[string]interface{}{"card_id": cid}
card, cerr := db.getCardForJira(cid)
if cerr != nil {
res["status"] = "error"
res["error"] = cerr.Error()
results = append(results, res)
continue
}
if card.JiraKey == "" {
res["status"] = "skipped"
res["error"] = "card has no jira_key"
results = append(results, res)
continue
}
if _, ok := cfg.StatusMap[card.ColumnName]; !ok {
res["status"] = "skipped"
res["error"] = "no status_map entry for column " + card.ColumnName
results = append(results, res)
continue
}
status, terr := h.transitionToStatus(ctx, cfg, card.JiraKey, card.ColumnName)
if terr != nil {
res["status"] = "error"
res["error"] = terr.Error()
res["http"] = status
results = append(results, res)
continue
}
now := time.Now().UTC().Format(time.RFC3339)
_ = db.updateCardJiraSync(cid, cfg.StatusMap[card.ColumnName], now, "")
res["status"] = "fixed"
res["jira_key"] = card.JiraKey
res["jira_status"] = cfg.StatusMap[card.ColumnName]
results = append(results, res)
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{"results": results})
})
}
// handleImportJiraIssues creates a kanban card for each requested issue_key
// and links it to the existing Jira issue (sets jira_key directly, so the
// dispatcher will treat any future kanban edits as updates instead of trying
// to create a duplicate). The card lands in the column whose status_map entry
// matches the issue's current status; falls back to FallbackColumnID when
// unmappable.
func handleImportJiraIssues(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
var body jiraImportRequest
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if len(body.IssueKeys) == 0 {
badRequest(w, "issue_keys required")
return
}
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
colByName, err := db.listColumnsByName()
if err != nil {
serverError(w, err)
return
}
statusToCol := reverseStatusMap(cfg)
results := make([]map[string]interface{}, 0, len(body.IssueKeys))
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.IssueKeys)+1))
defer cancel()
for _, key := range body.IssueKeys {
res := map[string]interface{}{"key": key}
// Skip if already imported.
if existing, _ := db.findCardByJiraKey(key); existing != "" {
res["status"] = "skipped"
res["error"] = "already imported (card " + existing + ")"
results = append(results, res)
continue
}
// Fetch issue detail to get summary + description + status.
var iss struct {
Fields struct {
Summary string `json:"summary"`
Status struct {
Name string `json:"name"`
} `json:"status"`
Description json.RawMessage `json:"description"`
Assignee *struct {
DisplayName string `json:"displayName"`
} `json:"assignee"`
} `json:"fields"`
}
if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(key), &iss); err != nil {
res["status"] = "error"
res["error"] = err.Error()
results = append(results, res)
continue
}
// Determine target column.
columnID := body.FallbackColumnID
if colName, ok := statusToCol[strings.ToLower(iss.Fields.Status.Name)]; ok {
if col, ok := colByName[colName]; ok {
columnID = col.ID
}
}
if columnID == "" {
res["status"] = "error"
res["error"] = fmt.Sprintf("no column mapping for status %q and no fallback_column_id", iss.Fields.Status.Name)
results = append(results, res)
continue
}
requester := ""
if iss.Fields.Assignee != nil {
requester = iss.Fields.Assignee.DisplayName
}
description := extractADFText(iss.Fields.Description)
if description == "" {
description = "Imported from Jira " + key
} else {
description = description + "\n\n— Imported from Jira " + key
}
card, cerr := db.CreateCard(columnID, requester, iss.Fields.Summary, description, uid)
if cerr != nil {
res["status"] = "error"
res["error"] = cerr.Error()
results = append(results, res)
continue
}
// Link to existing Jira issue + seed sync state so the indicator
// renders green immediately.
if err := db.setCardJiraKey(card.ID, key); err != nil {
res["status"] = "error"
res["error"] = "card created but link failed: " + err.Error()
results = append(results, res)
continue
}
now := time.Now().UTC().Format(time.RFC3339)
_ = db.updateCardJiraSync(card.ID, iss.Fields.Status.Name, now, "")
res["status"] = "imported"
res["card_id"] = card.ID
res["column_id"] = columnID
results = append(results, res)
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"results": results,
})
})
}
+39
View File
@@ -36,6 +36,45 @@ func main() {
return return
} }
// Subcommand `kanban mint-token` issues an HTTP MCP bearer token for a user.
if len(os.Args) > 1 && os.Args[1] == "mint-token" {
if err := runMintToken(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban mint-token: %v\n", err)
os.Exit(1)
}
return
}
// Subcommand `kanban seed-jira-data` provisions the Jira push module
// scoped to project DATA + board 33 using pass-stored credentials.
if len(os.Args) > 1 && os.Args[1] == "seed-jira-data" {
if err := runSeedJiraData(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban seed-jira-data: %v\n", err)
os.Exit(1)
}
return
}
// Subcommand `kanban backfill-jira` mirrors every active kanban card that
// is not yet linked to a Jira issue into Jira, in batches.
if len(os.Args) > 1 && os.Args[1] == "backfill-jira" {
if err := runBackfillJira(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban backfill-jira: %v\n", err)
os.Exit(1)
}
return
}
// Subcommand `kanban resync-jira-fields` patches existing linked issues
// so their issuetype/assignee/labels reflect the current module config.
if len(os.Args) > 1 && os.Args[1] == "resync-jira-fields" {
if err := runResyncJiraFields(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban resync-jira-fields: %v\n", err)
os.Exit(1)
}
return
}
flags := flag.NewFlagSet("kanban", flag.ExitOnError) flags := flag.NewFlagSet("kanban", flag.ExitOnError)
port := flags.Int("port", 8095, "HTTP port") port := flags.Int("port", 8095, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path") dbPath := flags.String("db", "operations.db", "SQLite database path")
+26
View File
@@ -279,6 +279,32 @@ func mcpToolDefs() []infra.MCPToolDef {
"required": []string{"card_id"}, "required": []string{"card_id"},
}), }),
}, },
{
Name: "delete_comment",
Description: "Borra un comentario propio. Solo el autor original puede borrar (validado en server). " +
"Requiere autenticacion via MCP HTTP — el actor se infiere del bearer token. " +
"Output: {ok:true}.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "ID del card_message a borrar (no de la card)."},
},
"required": []string{"id"},
}),
},
{
Name: "get_card",
Description: "Devuelve una tarjeta activa (no archivada) por id o por seq_num. Read-only. " +
"Pasa exactamente UNO de los dos: id (hash interno) o seq_num (entero visible, ej. la '115' de 'card 00115'). " +
"Output: Card completa con time_in_column_ms, total_locked_ms, tags, stickers, deadline.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "ID hash de la tarjeta (16 hex)."},
"seq_num": map[string]any{"type": "integer", "description": "Numero secuencial visible al usuario."},
},
}),
},
} }
} }
+4 -3
View File
@@ -13,8 +13,8 @@ import (
// mcpHTTPHandler builds the http.Handler that serves the MCP Streamable HTTP // mcpHTTPHandler builds the http.Handler that serves the MCP Streamable HTTP
// transport for remote Claude clients. Bearer-auth backed by the mcp_tokens // transport for remote Claude clients. Bearer-auth backed by the mcp_tokens
// table; tool dispatch reuses executeTool() — the same set of operations the // table; tool dispatch reuses executeToolAs() so per-user tools (add_comment,
// chat assistant uses internally. // delete_comment) can infer the actor from the authenticated token.
func mcpHTTPHandler(db *DB) http.Handler { func mcpHTTPHandler(db *DB) http.Handler {
auth := func(r *http.Request) (context.Context, error) { auth := func(r *http.Request) (context.Context, error) {
header := r.Header.Get("Authorization") header := r.Header.Get("Authorization")
@@ -37,7 +37,8 @@ func mcpHTTPHandler(db *DB) http.Handler {
if len(body) == 0 { if len(body) == 0 {
body = json.RawMessage(`{}`) body = json.RawMessage(`{}`)
} }
res := executeTool(db, name, body) actor, _ := infra.UserIDFromContext(ctx, userCtxKey)
res := executeToolAs(db, name, body, actor)
if !res.OK { if !res.OK {
return res.Error, true, nil return res.Error, true, nil
} }
+42
View File
@@ -6,6 +6,7 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"errors" "errors"
"flag"
"fmt" "fmt"
) )
@@ -130,3 +131,44 @@ func generateMCPTokenPlaintext() (string, error) {
} }
return mcpTokenPrefix + hex.EncodeToString(b), nil return mcpTokenPrefix + hex.EncodeToString(b), nil
} }
// runMintToken implements `kanban mint-token --user <id> --name <pc>`.
// Generates a fresh token, persists its sha256 in mcp_tokens, and prints the
// plaintext ONCE to stdout. The caller must save it — the server keeps only
// the hash.
func runMintToken(args []string) error {
fs := flag.NewFlagSet("kanban mint-token", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
userID := fs.String("user", "", "owner user_id (must exist in users table)")
name := fs.String("name", "", "label for this token (e.g. PC name)")
if err := fs.Parse(args); err != nil {
return err
}
if *userID == "" || *name == "" {
return fmt.Errorf("--user and --name required")
}
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
var exists int
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users WHERE id=?`, *userID).Scan(&exists); err != nil {
return fmt.Errorf("user lookup: %w", err)
}
if exists == 0 {
return fmt.Errorf("user %q not found", *userID)
}
plaintext, tok, err := db.MintMCPToken(*userID, *name)
if err != nil {
return fmt.Errorf("mint: %w", err)
}
fmt.Printf("token id: %s\n", tok.ID)
fmt.Printf("name: %s\n", tok.Name)
fmt.Printf("created_at: %s\n", tok.CreatedAt)
fmt.Printf("\ntoken (save now, will not be shown again):\n%s\n", plaintext)
return nil
}
+16
View File
@@ -0,0 +1,16 @@
-- Issue 0128: adjuntos de archivos por card.
CREATE TABLE IF NOT EXISTS card_files (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
uploader_id TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
stored_path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'upload',
created_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_card_files_card_active
ON card_files(card_id, deleted_at);
@@ -0,0 +1,13 @@
-- Per-card Jira sync state. Populated by the dispatcher after every push to
-- Jira so the frontend can render an indicator (gray/yellow/green) and a
-- tooltip with the last known status without polling Jira itself.
--
-- jira_last_status: the Jira status name the card was transitioned to in the
-- most recent successful sync (e.g. "In Progress", "Done").
-- jira_last_sync_at: RFC3339 timestamp of the last sync attempt (success or
-- failure).
-- jira_last_error: the error message from the last failed sync, or empty when
-- the last sync succeeded.
ALTER TABLE cards ADD COLUMN jira_last_status TEXT NOT NULL DEFAULT '';
ALTER TABLE cards ADD COLUMN jira_last_sync_at TEXT NOT NULL DEFAULT '';
ALTER TABLE cards ADD COLUMN jira_last_error TEXT NOT NULL DEFAULT '';
+353 -18
View File
@@ -11,6 +11,7 @@ import (
"log" "log"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -194,6 +195,130 @@ func (db *DB) setCardJiraKey(cardID, jiraKey string) error {
return err return err
} }
// listImportedJiraKeys returns a set of jira keys currently linked to any
// active kanban card. Used by the Jira import picker to filter out issues
// already present in the kanban.
func (db *DB) listImportedJiraKeys() (map[string]bool, error) {
rows, err := db.conn.Query(`SELECT jira_key FROM cards WHERE jira_key != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
out[k] = true
}
return out, rows.Err()
}
// listColumnsByName returns columns keyed by name for status-map reverse
// lookup during Jira import.
func (db *DB) listColumnsByName() (map[string]Column, error) {
cols, err := db.ListColumns()
if err != nil {
return nil, err
}
out := make(map[string]Column, len(cols))
for _, c := range cols {
out[c.Name] = c
}
return out, nil
}
// lookupCardColumnID returns the current column_id for a card, or "" if the
// card does not exist. Used by handleMoveCard to detect column changes vs
// same-column reorders before publishing card.moved events.
func (db *DB) lookupCardColumnID(cardID string) (string, error) {
var col sql.NullString
err := db.conn.QueryRow(`SELECT column_id FROM cards WHERE id = ?`, cardID).Scan(&col)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
if !col.Valid {
return "", nil
}
return col.String, nil
}
// findCardByJiraKey returns the id of the card linked to jiraKey, or "" if
// no card carries that link. The lookup ignores soft-deleted cards.
func (db *DB) findCardByJiraKey(jiraKey string) (string, error) {
var id string
err := db.conn.QueryRow(
`SELECT id FROM cards WHERE jira_key = ? AND deleted_at IS NULL LIMIT 1`,
jiraKey,
).Scan(&id)
if err == sql.ErrNoRows {
return "", nil
}
return id, err
}
// updateCardJiraSync updates the per-card sync-state columns. statusName is
// preserved when empty (so we do not blank it on events that do not change
// the Jira status, like comments).
func (db *DB) updateCardJiraSync(cardID, statusName, syncAt, errMsg string) error {
if statusName != "" {
_, err := db.conn.Exec(
`UPDATE cards SET jira_last_status=?, jira_last_sync_at=?, jira_last_error=? WHERE id=?`,
statusName, syncAt, errMsg, cardID,
)
return err
}
_, err := db.conn.Exec(
`UPDATE cards SET jira_last_sync_at=?, jira_last_error=? WHERE id=?`,
syncAt, errMsg, cardID,
)
return err
}
// CardJiraSyncState is the row returned by /api/cards/{id}/jira-sync.
type CardJiraSyncState struct {
CardID string `json:"card_id"`
JiraKey string `json:"jira_key"`
LastStatus string `json:"last_status"`
LastSyncAt string `json:"last_sync_at"`
LastError string `json:"last_error"`
Inflight bool `json:"inflight"`
IssueURL string `json:"issue_url,omitempty"`
}
// readCardJiraSync loads the persisted sync state for a card. Callers add the
// inflight flag + issue url separately because those depend on runtime state
// (dispatcher map) and module config (base url).
func (db *DB) readCardJiraSync(cardID string) (CardJiraSyncState, error) {
var s CardJiraSyncState
s.CardID = cardID
var jiraKey, lastStatus, lastSyncAt, lastError sql.NullString
err := db.conn.QueryRow(
`SELECT jira_key, jira_last_status, jira_last_sync_at, jira_last_error
FROM cards WHERE id = ?`, cardID,
).Scan(&jiraKey, &lastStatus, &lastSyncAt, &lastError)
if err != nil {
return s, err
}
if jiraKey.Valid {
s.JiraKey = jiraKey.String
}
if lastStatus.Valid {
s.LastStatus = lastStatus.String
}
if lastSyncAt.Valid {
s.LastSyncAt = lastSyncAt.String
}
if lastError.Valid {
s.LastError = lastError.String
}
return s, nil
}
func (db *DB) getCardForJira(cardID string) (*cardForJira, error) { func (db *DB) getCardForJira(cardID string) (*cardForJira, error) {
var c cardForJira var c cardForJira
var assignee, deadline, jiraKey sql.NullString var assignee, deadline, jiraKey sql.NullString
@@ -298,6 +423,19 @@ type Dispatcher struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
enabled bool enabled bool
// inflight tracks cards whose sync is currently being attempted. Used by
// /api/cards/{id}/jira-sync to render the "yellow" state in the UI.
inflight sync.Map // map[cardID]struct{}
}
// IsInflight reports whether a sync attempt is currently being executed for
// the given card. Callers can use it to render a "syncing" indicator.
func (d *Dispatcher) IsInflight(cardID string) bool {
if d == nil {
return false
}
_, ok := d.inflight.Load(cardID)
return ok
} }
type dispatchTask struct { type dispatchTask struct {
@@ -412,7 +550,13 @@ func (d *Dispatcher) dispatch(t dispatchTask) {
}) })
return return
} }
if t.event.CardID != "" {
d.inflight.Store(t.event.CardID, struct{}{})
defer d.inflight.Delete(t.event.CardID)
}
delays := []time.Duration{0, moduleRetryDelay1, moduleRetryDelay2, moduleRetryDelay3} delays := []time.Duration{0, moduleRetryDelay1, moduleRetryDelay2, moduleRetryDelay3}
var lastErr error
var lastStatus int
for attempt := 0; attempt < moduleRetries; attempt++ { for attempt := 0; attempt < moduleRetries; attempt++ {
if delays[attempt] > 0 { if delays[attempt] > 0 {
select { select {
@@ -433,13 +577,59 @@ func (d *Dispatcher) dispatch(t dispatchTask) {
ml.Error = err.Error() ml.Error = err.Error()
} }
_ = d.db.appendModuleLog(ml) _ = d.db.appendModuleLog(ml)
lastErr = err
lastStatus = status
if err == nil { if err == nil {
d.recordCardSyncSuccess(t.module, t.event)
return return
} }
// 4xx client errors are not worth retrying. // 4xx client errors are not worth retrying.
if status >= 400 && status < 500 { if status >= 400 && status < 500 {
break
}
}
// All retries exhausted (or stopped early on 4xx). Persist the failure
// so the UI can render the card as out-of-sync without polling Jira.
d.recordCardSyncFailure(t.event, lastErr, lastStatus)
}
// recordCardSyncSuccess persists the post-sync state to cards.jira_last_*
// columns. The "status" stored mirrors what we asked Jira to land at via the
// status_map; comment events leave the status field unchanged.
func (d *Dispatcher) recordCardSyncSuccess(m Module, ev Event) {
if ev.CardID == "" {
return return
} }
now := time.Now().UTC().Format(time.RFC3339)
var statusName string
if m.Kind == "jira" && ev.Type != "message.created" {
cfg, err := parseJiraConfig(m)
if err == nil {
card, cerr := d.db.getCardForJira(ev.CardID)
if cerr == nil {
statusName = cfg.StatusMap[card.ColumnName]
}
}
}
if err := d.db.updateCardJiraSync(ev.CardID, statusName, now, ""); err != nil {
log.Printf("dispatcher: updateCardJiraSync(success) %s: %v", ev.CardID, err)
}
}
func (d *Dispatcher) recordCardSyncFailure(ev Event, err error, status int) {
if ev.CardID == "" {
return
}
now := time.Now().UTC().Format(time.RFC3339)
msg := "sync failed"
if err != nil {
msg = err.Error()
}
if status > 0 {
msg = fmt.Sprintf("(http %d) %s", status, msg)
}
if uerr := d.db.updateCardJiraSync(ev.CardID, "", now, msg); uerr != nil {
log.Printf("dispatcher: updateCardJiraSync(failure) %s: %v", ev.CardID, uerr)
} }
} }
@@ -484,7 +674,25 @@ type jiraConfig struct {
Email string `json:"email"` Email string `json:"email"`
APIToken string `json:"api_token"` APIToken string `json:"api_token"`
ProjectKey string `json:"project_key"` ProjectKey string `json:"project_key"`
StatusMap map[string]string `json:"status_map"` BoardID int `json:"board_id"`
IssueType string `json:"issue_type"` // Jira issuetype name applied on create
StatusMap map[string]string `json:"status_map"` // kanban_column_name -> Jira status name
LabelsMap map[string][]string `json:"labels_map,omitempty"` // kanban_column_name -> Jira labels (replaces every sync)
AssigneeMap map[string]string `json:"assignee_map,omitempty"` // kanban_user_id -> Jira accountId
// RequesterField is the Jira custom field id (e.g. "customfield_10158",
// "Área Solicitante") that some issue types (Epic, Mejora in project DATA)
// mark as required on the create screen. When set, create()/update() send a
// single-select option value resolved from the kanban card's requester.
RequesterField string `json:"requester_field,omitempty"`
// RequesterMap translates the free-text kanban requester to a Jira option
// value. Matched case-insensitively. Kanban requesters are usually person
// names, so most cards fall through to RequesterDefault.
RequesterMap map[string]string `json:"requester_map,omitempty"`
// RequesterDefault is the option value used when the card requester is
// empty or not present in RequesterMap. Required field never goes unfilled
// as long as this is set.
RequesterDefault string `json:"requester_default,omitempty"`
} }
func parseJiraConfig(m Module) (jiraConfig, error) { func parseJiraConfig(m Module) (jiraConfig, error) {
@@ -500,6 +708,9 @@ func parseJiraConfig(m Module) (jiraConfig, error) {
if c.BaseURL == "" { if c.BaseURL == "" {
return c, fmt.Errorf("base_url required") return c, fmt.Errorf("base_url required")
} }
if c.IssueType == "" {
c.IssueType = "Tarea Técnica"
}
return c, nil return c, nil
} }
@@ -549,8 +760,34 @@ func (h *jiraHandler) TestConnection(ctx context.Context, m Module) (int, error)
return 0, err return 0, err
} }
status, _, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/myself", nil) status, _, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/myself", nil)
if err != nil {
return status, err return status, err
} }
// If a board scope is configured, verify the board exists AND lives in
// the declared project. Refuse silently-mismatched configurations so a
// typo in project_key cannot create issues outside the intended board.
if c.BoardID > 0 {
bStatus, body, err := h.jiraRequest(ctx, c, http.MethodGet,
fmt.Sprintf("/rest/agile/1.0/board/%d", c.BoardID), nil)
if err != nil {
return bStatus, fmt.Errorf("board %d lookup: %w", c.BoardID, err)
}
var board struct {
Type string `json:"type"`
Location struct {
ProjectKey string `json:"projectKey"`
} `json:"location"`
}
if err := json.Unmarshal(body, &board); err != nil {
return bStatus, fmt.Errorf("decode board %d: %w", c.BoardID, err)
}
if c.ProjectKey != "" && !strings.EqualFold(board.Location.ProjectKey, c.ProjectKey) {
return 0, fmt.Errorf("board %d belongs to project %q, config declares %q",
c.BoardID, board.Location.ProjectKey, c.ProjectKey)
}
}
return status, nil
}
func (h *jiraHandler) Handle(ctx context.Context, db *DB, m Module, ev Event) (int, error) { func (h *jiraHandler) Handle(ctx context.Context, db *DB, m Module, ev Event) (int, error) {
c, err := parseJiraConfig(m) c, err := parseJiraConfig(m)
@@ -586,16 +823,25 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event
return h.update(ctx, db, c, ev) return h.update(ctx, db, c, ev)
} }
if c.ProjectKey == "" { if c.ProjectKey == "" {
return 0, fmt.Errorf("project_key required for create") return 0, fmt.Errorf("project_key required for create (configure module before pushing)")
} }
body := map[string]interface{}{ fields := map[string]interface{}{
"fields": map[string]interface{}{
"project": map[string]string{"key": c.ProjectKey}, "project": map[string]string{"key": c.ProjectKey},
"summary": card.Title, "summary": card.Title,
"description": adfText(card.Description), "description": adfText(card.Description),
"issuetype": map[string]string{"name": "Task"}, "issuetype": map[string]string{"name": c.IssueType},
},
} }
if labels := c.LabelsMap[card.ColumnName]; len(labels) > 0 {
fields["labels"] = labels
}
if acct := resolveJiraAssignee(c, card); acct != "" {
fields["assignee"] = map[string]string{"accountId": acct}
}
// Epic / Mejora issue types require "Área Solicitante" on the create
// screen. Fill it from the card requester (mapped) or the default so the
// create does not 400 on a missing required field.
applyRequesterField(c, card, fields)
body := map[string]interface{}{"fields": fields}
status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body) status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body)
if err != nil { if err != nil {
return status, err return status, err
@@ -604,10 +850,19 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event
Key string `json:"key"` Key string `json:"key"`
} }
_ = json.Unmarshal(resp, &parsed) _ = json.Unmarshal(resp, &parsed)
if parsed.Key != "" { if parsed.Key == "" {
return status, fmt.Errorf("jira create returned empty key")
}
if err := db.setCardJiraKey(card.ID, parsed.Key); err != nil { if err := db.setCardJiraKey(card.ID, parsed.Key); err != nil {
return status, fmt.Errorf("link jira key: %w", err) return status, fmt.Errorf("link jira key: %w", err)
} }
// Jira places new issues in the workflow's initial status (typically
// CREADO / To Do for DATA). Drive a transition immediately so the issue
// lands in the column that mirrors where the card is in kanban.
if _, ok := c.StatusMap[card.ColumnName]; ok {
if _, err := h.transitionToStatus(ctx, c, parsed.Key, card.ColumnName); err != nil {
return status, fmt.Errorf("created %s but initial transition failed: %w", parsed.Key, err)
}
} }
return status, nil return status, nil
} }
@@ -624,20 +879,75 @@ func (h *jiraHandler) update(ctx context.Context, db *DB, c jiraConfig, ev Event
// Card not yet linked — bootstrap by creating it. // Card not yet linked — bootstrap by creating it.
return h.create(ctx, db, c, ev) return h.create(ctx, db, c, ev)
} }
body := map[string]interface{}{ fields := map[string]interface{}{
"fields": map[string]interface{}{
"summary": card.Title, "summary": card.Title,
"description": adfText(card.Description), "description": adfText(card.Description),
},
} }
// Labels are derived from the current kanban column. We always send them
// (even an empty array) so a card that leaves a labelled column gets its
// label removed from Jira — PUT fields.labels REPLACES the whole array.
labels := c.LabelsMap[card.ColumnName]
if labels == nil {
labels = []string{}
}
fields["labels"] = labels
if acct := resolveJiraAssignee(c, card); acct != "" {
fields["assignee"] = map[string]string{"accountId": acct}
}
// Keep "Área Solicitante" populated on edits too — the field is required
// and a PUT that omits it can be rejected on the edit screen.
applyRequesterField(c, card, fields)
body := map[string]interface{}{"fields": fields}
status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body) status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body)
return status, err return status, err
} }
// resolveJiraAssignee maps the kanban card's assignee_id to a Jira accountId
// via the module's assignee_map. Returns "" when the card has no assignee or
// the assignee is not mapped, signalling to the caller to omit the field
// (avoids accidentally CLEARING an existing Jira assignee on every sync).
func resolveJiraAssignee(c jiraConfig, card *cardForJira) string {
if card == nil || card.AssigneeID == "" {
return ""
}
return c.AssigneeMap[card.AssigneeID]
}
// resolveRequesterOption maps the card's requester to a Jira single-select
// option value for RequesterField. Lookup order: exact map hit, case-insensitive
// map hit, then RequesterDefault. Returns "" only when the field is unconfigured
// or no default exists, signalling the caller to omit it.
func resolveRequesterOption(c jiraConfig, card *cardForJira) string {
if c.RequesterField == "" {
return ""
}
if card != nil {
r := strings.TrimSpace(card.Requester)
if r != "" && len(c.RequesterMap) > 0 {
if v, ok := c.RequesterMap[r]; ok {
return v
}
for k, v := range c.RequesterMap {
if strings.EqualFold(k, r) {
return v
}
}
}
}
return c.RequesterDefault
}
// applyRequesterField injects RequesterField as a single-select option into a
// Jira fields map when configured and resolvable. No-op otherwise.
func applyRequesterField(c jiraConfig, card *cardForJira, fields map[string]interface{}) {
if opt := resolveRequesterOption(c, card); opt != "" {
fields[c.RequesterField] = map[string]string{"value": opt}
}
}
// transition uses the configured status_map to translate the kanban column // transition uses the configured status_map to translate the kanban column
// to a Jira transition name. We list available transitions, find the one // to a Jira transition name. Kanban remains the source of truth even if
// whose target status name matches, and POST it. Kanban remains the source // Jira's current state differs.
// of truth even if Jira's current state differs.
func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) { func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) {
if ev.CardID == "" { if ev.CardID == "" {
return 0, nil return 0, nil
@@ -649,11 +959,22 @@ func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev E
if card.JiraKey == "" { if card.JiraKey == "" {
return h.create(ctx, db, c, ev) return h.create(ctx, db, c, ev)
} }
target, ok := c.StatusMap[card.ColumnName] if _, ok := c.StatusMap[card.ColumnName]; !ok {
if !ok || target == "" {
return 0, fmt.Errorf("no status_map entry for column %q", card.ColumnName) return 0, fmt.Errorf("no status_map entry for column %q", card.ColumnName)
} }
status, body, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/issue/"+card.JiraKey+"/transitions", nil) return h.transitionToStatus(ctx, c, card.JiraKey, card.ColumnName)
}
// transitionToStatus drives a Jira issue to the status mapped from the given
// kanban column and refreshes labels accordingly. Used by transition() on
// card.moved events and by create() right after issue creation so new issues
// do not stall at the workflow's default initial status.
func (h *jiraHandler) transitionToStatus(ctx context.Context, c jiraConfig, jiraKey, columnName string) (int, error) {
target := c.StatusMap[columnName]
if target == "" {
return 0, fmt.Errorf("no status_map entry for column %q", columnName)
}
status, body, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/issue/"+jiraKey+"/transitions", nil)
if err != nil { if err != nil {
return status, err return status, err
} }
@@ -677,12 +998,26 @@ func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev E
} }
} }
if tID == "" { if tID == "" {
return 0, fmt.Errorf("transition %q not available for %s", target, card.JiraKey) return 0, fmt.Errorf("transition %q not available for %s", target, jiraKey)
} }
req := map[string]interface{}{"transition": map[string]string{"id": tID}} req := map[string]interface{}{"transition": map[string]string{"id": tID}}
status, _, err = h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue/"+card.JiraKey+"/transitions", req) status, _, err = h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue/"+jiraKey+"/transitions", req)
if err != nil {
return status, err return status, err
} }
// Refresh labels to match the new column. Replaces the labels array; an
// empty list strips any stale labels from the previous column.
labels := c.LabelsMap[columnName]
if labels == nil {
labels = []string{}
}
lbody := map[string]interface{}{"fields": map[string]interface{}{"labels": labels}}
lStatus, _, lErr := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+jiraKey, lbody)
if lErr != nil {
return lStatus, fmt.Errorf("transition ok but labels sync failed: %w", lErr)
}
return status, nil
}
func (h *jiraHandler) comment(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) { func (h *jiraHandler) comment(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) {
if ev.CardID == "" { if ev.CardID == "" {
+41
View File
@@ -224,3 +224,44 @@ func handleTestModule(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
infra.HTTPJSONResponse(w, http.StatusOK, resp) infra.HTTPJSONResponse(w, http.StatusOK, resp)
}) })
} }
// handleCardJiraSync returns the per-card Jira sync state for the indicator
// tooltip. Reads cards.jira_last_* columns + dispatcher inflight map. The
// caller does not need admin: any authenticated user can see the state of
// their cards. Returns 200 + zero-valued state when the card has no link
// yet (so the UI can show the gray indicator without a special case).
func handleCardJiraSync(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if uid == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
id := r.PathValue("id")
state, err := db.readCardJiraSync(id)
if err != nil {
notFound(w, "card not found")
return
}
state.Inflight = dispatcher.IsInflight(id)
// Resolve issue URL by reading any enabled jira module's base_url. We
// pick the first match because the kanban-jira link is conceptually
// 1:1 — multiple jira modules pointing at different projects would be
// a misconfiguration.
if state.JiraKey != "" {
if mods, err := db.listModulesEnabled(); err == nil {
for _, m := range mods {
if m.Kind != "jira" {
continue
}
cfg, perr := parseJiraConfig(m)
if perr == nil && cfg.BaseURL != "" {
state.IssueURL = cfg.BaseURL + "/browse/" + state.JiraKey
break
}
}
}
}
infra.HTTPJSONResponse(w, http.StatusOK, state)
}
}
+11 -3
View File
@@ -141,7 +141,8 @@ func TestJiraHandler_CreateLinksCardKey(t *testing.T) {
card, _ := db.CreateCard(col.ID, "req", "Buy bread", "desc", user.ID) card, _ := db.CreateCard(col.ID, "req", "Buy bread", "desc", user.ID)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue" { switch {
case r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue":
b, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
var p struct { var p struct {
Fields struct { Fields struct {
@@ -154,9 +155,16 @@ func TestJiraHandler_CreateLinksCardKey(t *testing.T) {
} }
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"id":"10000","key":"KAN-1"}`) _, _ = io.WriteString(w, `{"id":"10000","key":"KAN-1"}`)
return case r.Method == http.MethodGet && r.URL.Path == "/rest/api/3/issue/KAN-1/transitions":
} w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"transitions":[{"id":"11","name":"Start","to":{"name":"To Do"}}]}`)
case r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue/KAN-1/transitions":
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodPut && r.URL.Path == "/rest/api/3/issue/KAN-1":
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
}
})) }))
defer srv.Close() defer srv.Close()
+192
View File
@@ -0,0 +1,192 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// runResyncJiraFields patches every Jira issue currently linked to a kanban
// card so its issuetype / assignee / labels reflect the *latest* module
// configuration. Use cases:
//
// - We changed issue_type in the module (e.g. "Tarea Técnica" → "Epic") and
// need the backfilled issues to match.
// - We added/changed the assignee_map and want existing issues to pick up
// the mapping retroactively.
// - We renamed kanban columns and need labels re-applied.
//
// The CLI is idempotent: running it twice on the same set is a no-op for
// fields that already match. Batching mirrors `backfill-jira` so we stay
// under Jira's REST quota.
func runResyncJiraFields(args []string) error {
fs := flag.NewFlagSet("kanban resync-jira-fields", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
batchSize := fs.Int("batch-size", 10, "Issues per batch before pausing")
pauseSec := fs.Int("pause-sec", 5, "Seconds to sleep between batches")
limit := fs.Int("limit", 0, "Maximum issues to patch (0 = no limit)")
doIssueType := fs.Bool("set-issuetype", true, "Set issuetype to module.issue_type")
doAssignee := fs.Bool("set-assignee", true, "Set assignee from module.assignee_map (or clear when no mapping)")
doLabels := fs.Bool("set-labels", false, "Re-apply labels from module.labels_map (off by default; labels were already correct after backfill)")
dryRun := fs.Bool("dry-run", false, "Print the planned PATCH for each issue and exit")
if err := fs.Parse(args); err != nil {
return err
}
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
_, cfg, err := activeJiraModule(db)
if err != nil {
return fmt.Errorf("module config: %w", err)
}
cards, err := listLinkedJiraCards(db, *limit)
if err != nil {
return err
}
if len(cards) == 0 {
fmt.Println("no linked cards to resync")
return nil
}
fmt.Printf("resync plan: %d issues; batch=%d pause=%ds dry_run=%v\n",
len(cards), *batchSize, *pauseSec, *dryRun)
fmt.Printf("ops: issuetype=%v(%q) assignee=%v(%d mappings) labels=%v\n",
*doIssueType, cfg.IssueType, *doAssignee, len(cfg.AssigneeMap), *doLabels)
fmt.Println()
var ok, failed, noop int
for i, c := range cards {
if i > 0 && i%*batchSize == 0 {
fmt.Printf("--- batch boundary (%d/%d) — sleeping %ds ---\n", i, len(cards), *pauseSec)
time.Sleep(time.Duration(*pauseSec) * time.Second)
}
fields := map[string]interface{}{}
if *doIssueType && cfg.IssueType != "" {
fields["issuetype"] = map[string]string{"name": cfg.IssueType}
}
if *doAssignee {
acct := cfg.AssigneeMap[c.AssigneeID]
if acct != "" {
fields["assignee"] = map[string]string{"accountId": acct}
}
// We intentionally do NOT clear the assignee when the card has no
// mapping — that would overwrite a manual Jira assignment with
// nothing. To explicitly clear, the operator can remove the card's
// kanban assignee and trigger a card.updated event.
}
if *doLabels {
labels := cfg.LabelsMap[c.ColumnName]
if labels == nil {
labels = []string{}
}
fields["labels"] = labels
}
if len(fields) == 0 {
noop++
fmt.Printf("[%4d/%4d] NOOP %s (no fields to patch)\n", i+1, len(cards), c.JiraKey)
continue
}
if *dryRun {
b, _ := json.Marshal(fields)
fmt.Printf("[%4d/%4d] PLAN %s fields=%s\n", i+1, len(cards), c.JiraKey, b)
continue
}
status, err := jiraPUTFields(context.Background(), cfg, c.JiraKey, fields)
if err != nil {
failed++
fmt.Printf("[%4d/%4d] FAIL %s http=%d err=%s\n",
i+1, len(cards), c.JiraKey, status, truncateInline(err.Error(), 100))
continue
}
ok++
fmt.Printf("[%4d/%4d] OK %s\n", i+1, len(cards), c.JiraKey)
}
fmt.Println()
fmt.Printf("done: %d ok · %d noop · %d failed · %d total\n", ok, noop, failed, len(cards))
if failed > 0 {
return fmt.Errorf("%d issues failed to patch", failed)
}
return nil
}
// linkedJiraCard is the projection used by the resync CLI. We also pull
// assignee_id so the assignee_map lookup works without re-fetching cards.
type linkedJiraCard struct {
ID string
JiraKey string
ColumnName string
AssigneeID string
}
func listLinkedJiraCards(db *DB, limit int) ([]linkedJiraCard, error) {
q := `
SELECT c.id, c.jira_key, col.name, COALESCE(c.assignee_id, '')
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE c.jira_key != ''
AND c.deleted_at IS NULL
ORDER BY c.jira_key ASC
`
args := []interface{}{}
if limit > 0 {
q += " LIMIT ? "
args = append(args, limit)
}
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("list linked cards: %w", err)
}
defer rows.Close()
out := []linkedJiraCard{}
for rows.Next() {
var c linkedJiraCard
if err := rows.Scan(&c.ID, &c.JiraKey, &c.ColumnName, &c.AssigneeID); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// jiraPUTFields is a thin wrapper around PUT /rest/api/3/issue/{key} that
// returns the HTTP status code + error. We do not need the response body —
// Jira returns 204 No Content on success.
func jiraPUTFields(ctx context.Context, c jiraConfig, key string, fields map[string]interface{}) (int, error) {
body := map[string]interface{}{"fields": fields}
raw, err := json.Marshal(body)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
c.BaseURL+"/rest/api/3/issue/"+key, bytes.NewReader(raw))
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
basic := base64.StdEncoding.EncodeToString([]byte(c.Email + ":" + c.APIToken))
req.Header.Set("Authorization", "Basic "+basic)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return resp.StatusCode, fmt.Errorf("jira PUT %s: %d %s",
key, resp.StatusCode, truncateInline(strings.TrimSpace(string(respBody)), 240))
}
return resp.StatusCode, nil
}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"flag"
"fmt"
"os/exec"
"strings"
)
// runSeedJiraData provisions (or updates) the Jira module that pushes kanban
// changes to soporte-anjana.atlassian.net, project DATA, board 33.
//
// Credentials are read from `pass` so they never appear in argv or env. The
// API token, email, and domain are loaded from the canonical entries:
//
// pass jira/anjana/api-token
// pass jira/anjana/email
// pass jira/anjana/domain
//
// Defaults can be overridden with flags (project, board, name, filter).
//
// Idempotent: if a module with the same name already exists, its config is
// rewritten (encrypted at rest by saveModule). The kanban module key
// (KANBAN_MODULE_KEY env var) must be set — the same value the running server
// uses, otherwise the server cannot decrypt the secrets we wrote.
func runSeedJiraData(args []string) error {
fs := flag.NewFlagSet("kanban seed-jira-data", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
name := fs.String("name", "Jira DATA", "Module display name (also used as upsert key)")
project := fs.String("project", "DATA", "Jira project key (e.g. DATA)")
board := fs.Int("board", 33, "Jira board id (Agile board; informational + validated at /test)")
filter := fs.String("event-filter", "card.created,card.updated,card.moved,message.created",
"Comma-separated event types this module subscribes to")
enabled := fs.Bool("enabled", true, "Start with module enabled (true) or disabled (false)")
passEntry := fs.String("pass-prefix", "jira/anjana", "pass entry prefix; reads ${prefix}/{email,api-token,domain}")
requesterField := fs.String("requester-field", "customfield_10158",
"Jira custom field id for the required 'Área Solicitante' select (empty to disable)")
requesterDefault := fs.String("requester-default", "Transformación",
"Default 'Área Solicitante' option value for auto-created cards whose requester is not mapped")
if err := fs.Parse(args); err != nil {
return err
}
email, err := passShow(*passEntry + "/email")
if err != nil {
return fmt.Errorf("read email from pass: %w", err)
}
token, err := passShow(*passEntry + "/api-token")
if err != nil {
return fmt.Errorf("read api-token from pass: %w", err)
}
domain, err := passShow(*passEntry + "/domain")
if err != nil {
return fmt.Errorf("read domain from pass: %w", err)
}
baseURL := "https://" + strings.TrimSpace(domain)
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
// Default mapping for our setup: Kanban columns → Jira `Epicas en Data` board (33)
// statuses. Operator can edit via the Modulos UI once the row exists.
statusMap := map[string]string{
"HACIENDO 🚧": "In Progress",
"PNDNT FEEDBACK ▶️": "IMPLEMENTADO",
"HECHO ✅": "Done",
"IDEAS 💡": "CREADO",
"DEUDA TÉCNICA 🔄": "To Do",
"Bloqueadas": "In Progress",
}
labelsMap := map[string][]string{
"Bloqueadas": {"blocked"},
}
// kanban user_id -> Jira accountId. Resolved via Jira /user/search; the
// three current data-team users keep stable IDs across sessions. New
// users added to the kanban must be added here (or the seed re-run with
// --pass-prefix overrides) so the dispatcher can route the assignee.
assigneeMap := map[string]string{
"6a75edc6e99d8405": "712020:2cf3b82f-47d6-4597-b0e9-ffaaf3a07cc3", // Enmaa -> Enmanuel Gutierrez Perez
"039c97acf1869393": "712020:3f3ca9e1-c86e-445e-979a-bc7b82a4f45d", // alfon -> Alfonso Massaguer Gómez
"9e91db261084d529": "712020:feb5f7c5-7643-4381-977c-d83c95ba4955", // Nat -> Natalia Tajuelo Gomez
}
cfg := JSONValue{
"base_url": baseURL,
"email": email,
"api_token": token,
"project_key": *project,
"board_id": *board,
"issue_type": "Epic",
"status_map": statusMap,
"labels_map": labelsMap,
"assignee_map": assigneeMap,
}
if *requesterField != "" {
cfg["requester_field"] = *requesterField
cfg["requester_default"] = *requesterDefault
}
// Upsert by name. Module name is the human-friendly identifier; we treat
// it as unique for the purposes of seeding so re-running this command does
// not duplicate the row.
mods, err := db.listModulesAll()
if err != nil {
return fmt.Errorf("list modules: %w", err)
}
var existing *Module
for i := range mods {
if mods[i].Name == *name {
existing = &mods[i]
break
}
}
if existing != nil {
existing.Kind = "jira"
existing.Enabled = *enabled
existing.EventFilter = splitCSV(*filter)
// Merge so keys the operator added via the UI (e.g. a custom
// requester_map) survive a re-seed. Seed-managed keys are refreshed.
if existing.Config == nil {
existing.Config = JSONValue{}
}
for k, v := range cfg {
existing.Config[k] = v
}
if err := db.saveModule(existing); err != nil {
return fmt.Errorf("update module: %w", err)
}
fmt.Printf("updated module %q (id=%s)\n", existing.Name, existing.ID)
return nil
}
m := &Module{
Name: *name,
Kind: "jira",
Enabled: *enabled,
EventFilter: splitCSV(*filter),
Config: cfg,
}
if err := db.saveModule(m); err != nil {
return fmt.Errorf("create module: %w", err)
}
fmt.Printf("created module %q (id=%s)\n", m.Name, m.ID)
fmt.Printf("project: %s board: %d base_url: %s email: %s\n",
*project, *board, baseURL, email)
fmt.Println("\nnext steps:")
fmt.Println(" 1. Edit status_map in the Modulos UI: map kanban column names to Jira statuses")
fmt.Println(" (e.g. \"In Progress\" → \"In Progress\", \"Done\" → \"Done\")")
fmt.Println(" 2. Click \"Test\" in the UI to verify board 33 belongs to project DATA")
fmt.Println(" 3. Move a card in kanban — push should hit Jira REST API")
return nil
}
// passShow shells out to pass(1) to read a secret. We do not cache or print
// the value; just trim trailing whitespace before returning.
func passShow(entry string) (string, error) {
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return "", fmt.Errorf("pass show %s: %w", entry, err)
}
return strings.TrimSpace(string(out)), nil
}
+81 -12
View File
@@ -19,8 +19,15 @@ func errResult(err error) ToolResult { return ToolResult{OK: false, Error: err.
func errMsg(msg string) ToolResult { return ToolResult{OK: false, Error: msg} } func errMsg(msg string) ToolResult { return ToolResult{OK: false, Error: msg} }
// executeTool dispatches a tool by name with raw JSON input and returns a ToolResult. // executeTool dispatches a tool by name with raw JSON input and returns a ToolResult.
// Tools that mutate the board return ok=true on success; read-only tools include their data in result. // Used by the legacy chat path (no authenticated user available).
func executeTool(db *DB, name string, input json.RawMessage) ToolResult { func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
return executeToolAs(db, name, input, "")
}
// executeToolAs is the actor-aware dispatch used by the HTTP MCP path.
// actor is the authenticated user id (resolved from the bearer token) for tools
// that need it (add_comment / delete_comment infer the author from it).
func executeToolAs(db *DB, name string, input json.RawMessage, actor string) ToolResult {
switch name { switch name {
case "list_board": case "list_board":
return toolListBoard(db) return toolListBoard(db)
@@ -50,10 +57,14 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
return toolListUsers(db) return toolListUsers(db)
case "assign_card": case "assign_card":
return toolAssignCard(db, input) return toolAssignCard(db, input)
case "get_card":
return toolGetCard(db, input)
case "add_comment": case "add_comment":
return toolAddComment(db, input) return toolAddCommentAs(db, input, actor)
case "list_comments": case "list_comments":
return toolListComments(db, input) return toolListComments(db, input)
case "delete_comment":
return toolDeleteComment(db, input, actor)
default: default:
return errMsg("unknown tool: " + name) return errMsg("unknown tool: " + name)
} }
@@ -64,7 +75,7 @@ func toolMutates(name string) bool {
switch name { switch name {
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns", case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
"create_card", "update_card", "delete_card", "move_card", "assign_card", "create_card", "update_card", "delete_card", "move_card", "assign_card",
"add_comment": "add_comment", "delete_comment":
return true return true
} }
return false return false
@@ -352,7 +363,8 @@ func validateToolName(name string) error {
"update_card": true, "delete_card": true, "move_card": true, "update_card": true, "delete_card": true, "move_card": true,
"card_history": true, "find_cards": true, "card_history": true, "find_cards": true,
"list_users": true, "assign_card": true, "list_users": true, "assign_card": true,
"add_comment": true, "list_comments": true, "add_comment": true, "list_comments": true, "delete_comment": true,
"get_card": true,
} }
if !known[name] { if !known[name] {
return fmt.Errorf("unknown tool: %s", name) return fmt.Errorf("unknown tool: %s", name)
@@ -360,10 +372,15 @@ func validateToolName(name string) error {
return nil return nil
} }
// toolAddComment appends a comment (card_message) to a card. Accepts either // toolAddCommentAs appends a comment (card_message) to a card.
// {card_id, body, author_id} or {card_id, body, author_username}. Resolves //
// the username to an id when needed. // Author resolution order:
func toolAddComment(db *DB, input json.RawMessage) ToolResult { // 1. explicit "author_id" in input (legacy chat path)
// 2. explicit "author_username" in input -> resolve to id
// 3. fallback to `actor` (authenticated user from MCP HTTP token)
//
// At least one must yield a non-empty id.
func toolAddCommentAs(db *DB, input json.RawMessage, actor string) ToolResult {
var in struct { var in struct {
CardID string `json:"card_id"` CardID string `json:"card_id"`
Body string `json:"body"` Body string `json:"body"`
@@ -380,16 +397,19 @@ func toolAddComment(db *DB, input json.RawMessage) ToolResult {
return errMsg("body required") return errMsg("body required")
} }
authorID := strings.TrimSpace(in.AuthorID) authorID := strings.TrimSpace(in.AuthorID)
if authorID == "" { if authorID == "" && in.AuthorUsername != "" {
if in.AuthorUsername == "" {
return errMsg("author_id or author_username required")
}
u, _, err := db.GetUserByUsername(in.AuthorUsername) u, _, err := db.GetUserByUsername(in.AuthorUsername)
if err != nil { if err != nil {
return errResult(fmt.Errorf("author_username: %w", err)) return errResult(fmt.Errorf("author_username: %w", err))
} }
authorID = u.ID authorID = u.ID
} }
if authorID == "" {
authorID = actor
}
if authorID == "" {
return errMsg("author_id, author_username, or authenticated MCP token required")
}
m, err := db.CreateCardMessage(in.CardID, authorID, in.Body) m, err := db.CreateCardMessage(in.CardID, authorID, in.Body)
if err != nil { if err != nil {
return errResult(err) return errResult(err)
@@ -397,6 +417,55 @@ func toolAddComment(db *DB, input json.RawMessage) ToolResult {
return okResult(m) return okResult(m)
} }
// toolGetCard returns a single active (non-archived) card by id or seq_num.
// Pass exactly ONE of {id, seq_num}.
func toolGetCard(db *DB, input json.RawMessage) ToolResult {
var in struct {
ID string `json:"id"`
SeqNum int `json:"seq_num"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
}
if in.ID == "" && in.SeqNum == 0 {
return errMsg("provide id or seq_num")
}
cards, err := db.ListCardsWithTime()
if err != nil {
return errResult(err)
}
for _, c := range cards {
if in.ID != "" && c.ID == in.ID {
return okResult(c)
}
if in.SeqNum != 0 && c.SeqNum == in.SeqNum {
return okResult(c)
}
}
return errMsg("card not found")
}
// toolDeleteComment deletes a comment. Only the original author can delete it
// (enforced via actor == message.author_id).
func toolDeleteComment(db *DB, input json.RawMessage, actor string) ToolResult {
if actor == "" {
return errMsg("authenticated user required (call via MCP HTTP with a valid token)")
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
}
if in.ID == "" {
return errMsg("id required")
}
if err := db.DeleteCardMessage(in.ID, actor); err != nil {
return errResult(err)
}
return okResult(map[string]bool{"ok": true})
}
// toolListComments returns every comment (card_message) attached to a card // toolListComments returns every comment (card_message) attached to a card
// sorted by created_at ascending. // sorted by created_at ascending.
func toolListComments(db *DB, input json.RawMessage) ToolResult { func toolListComments(db *DB, input json.RawMessage) ToolResult {
+2 -2
View File
@@ -256,7 +256,7 @@ func TestExecuteTool_MoveCard_BetweenColumns_OpensHistory(t *testing.T) {
histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID})) histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
mustOK(t, histRes) mustOK(t, histRes)
hist := histRes.Result.([]HistoryEntry) hist := histRes.Result.(*CardHistoryResponse).ColumnHistory
if len(hist) != 2 { if len(hist) != 2 {
t.Fatalf("expected 2 history entries, got %d", len(hist)) t.Fatalf("expected 2 history entries, got %d", len(hist))
} }
@@ -286,7 +286,7 @@ func TestExecuteTool_CardHistory_Single(t *testing.T) {
res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID})) res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
mustOK(t, res) mustOK(t, res)
hist := res.Result.([]HistoryEntry) hist := res.Result.(*CardHistoryResponse).ColumnHistory
if len(hist) != 1 || hist[0].ExitedAt != nil { if len(hist) != 1 || hist[0].ExitedAt != nil {
t.Fatalf("expected 1 open history entry, got %+v", hist) t.Fatalf("expected 1 open history entry, got %+v", hist)
} }
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Issue 0128 — smoke test of card file attachments.
# Builds kanban (assumes ./kanban present or builds it), boots on ephemeral port,
# exercises POST upload, GET list, GET serve, DELETE, GET list-after-delete.
# Exits 0 on success, non-zero on any failure.
set -euo pipefail
PORT=${PORT:-18095}
BASE="http://127.0.0.1:${PORT}"
DB=$(mktemp /tmp/kanban_files_smoke.XXXXXX.db)
COOKIE=$(mktemp /tmp/kanban_files_smoke.cookie.XXXXXX)
UPLOAD_DIR=$(dirname "$DB")/uploads
PNG=$(mktemp /tmp/kanban_files_smoke.XXXXXX.png)
PID_FILE=$(mktemp /tmp/kanban_files_smoke.pid.XXXXXX)
cleanup() {
if [ -s "$PID_FILE" ]; then
kill "$(cat "$PID_FILE")" 2>/dev/null || true
fi
rm -f "$DB" "$DB-shm" "$DB-wal" "$COOKIE" "$PNG" "$PID_FILE"
rm -rf "$UPLOAD_DIR"
}
trap cleanup EXIT
# Build if missing.
if [ ! -x ./kanban ]; then
echo "[smoke] building kanban binary..."
(cd backend && CGO_ENABLED=1 go build -tags fts5 -o ../kanban .)
fi
# Boot.
./kanban --port "$PORT" --db "$DB" --initial-admin admin:adminpw \
> /tmp/kanban_files_smoke.log 2>&1 &
echo $! > "$PID_FILE"
# Wait for /api/board.
for _ in $(seq 1 30); do
if curl -sf -o /dev/null "$BASE/api/board"; then break; fi
sleep 0.2
done
# Login.
curl -sf -c "$COOKIE" -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"adminpw"}' > /dev/null
# Column + card.
COL=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/columns" \
-H 'Content-Type: application/json' \
-d '{"name":"To Do"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
CARD=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards" \
-H 'Content-Type: application/json' \
-d "{\"column_id\":\"$COL\",\"title\":\"smoke\",\"requester\":\"r\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# Minimal PNG.
printf '\x89PNG\r\n\x1a\n' > "$PNG"
# Upload.
UP=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards/$CARD/files" \
-F "file=@$PNG;type=image/png")
FID=$(echo "$UP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
[ -n "$FID" ] || { echo "[smoke] upload missing id"; exit 1; }
# List active.
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
[ "$N" = "1" ] || { echo "[smoke] expected 1 file, got $N"; exit 1; }
# Serve.
CT=$(curl -sf -b "$COOKIE" -I "$BASE/api/files/$FID" | awk '/^[Cc]ontent-[Tt]ype/ {print $2}' | tr -d '\r\n')
echo "$CT" | grep -q image/png || { echo "[smoke] wrong content-type: $CT"; exit 1; }
# Delete.
HTTP=$(curl -sb "$COOKIE" -X DELETE -o /dev/null -w "%{http_code}" "$BASE/api/files/$FID")
[ "$HTTP" = "204" ] || { echo "[smoke] expected 204 on delete, got $HTTP"; exit 1; }
# List after delete.
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
[ "$N" = "0" ] || { echo "[smoke] expected 0 after delete, got $N"; exit 1; }
echo "[smoke] OK"
+23
View File
@@ -57,6 +57,7 @@ import {
IconLogout, IconLogout,
IconPlug, IconPlug,
IconKey, IconKey,
IconBrandJira,
IconMenu2, IconMenu2,
IconMessageChatbot, IconMessageChatbot,
IconMoodSmile, IconMoodSmile,
@@ -86,6 +87,7 @@ import { colorBg, colorBorder } from "./components/colors";
import { NotificationsBell } from "./components/NotificationsBell"; import { NotificationsBell } from "./components/NotificationsBell";
import { ModulesModal } from "./components/ModulesModal"; import { ModulesModal } from "./components/ModulesModal";
import { MCPTokensModal } from "./components/MCPTokensModal"; import { MCPTokensModal } from "./components/MCPTokensModal";
import { JiraModal } from "./components/JiraModal";
import { useEventStream } from "./hooks/useEventStream"; import { useEventStream } from "./hooks/useEventStream";
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types"; import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
@@ -364,6 +366,7 @@ export function App() {
const [modulesOpen, setModulesOpen] = useState(false); const [modulesOpen, setModulesOpen] = useState(false);
const [mcpTokensOpen, setMcpTokensOpen] = useState(false); const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
const [jiraImportOpen, setJiraImportOpen] = useState(false);
const reloadNotifs = useCallback(async () => { const reloadNotifs = useCallback(async () => {
try { try {
@@ -649,6 +652,10 @@ export function App() {
try { try {
await api.moveCard(activeId, destCol, orderedIds); await api.moveCard(activeId, destCol, orderedIds);
// Nudge the moved card's Jira sync indicator to refetch immediately
// so the operator sees the yellow "syncing" state without waiting for
// the steady-state poll tick (5s).
window.dispatchEvent(new CustomEvent("kanban-card-moved", { detail: { cardId: activeId } }));
} catch (err) { } catch (err) {
notifications.show({ color: "red", message: (err as Error).message }); notifications.show({ color: "red", message: (err as Error).message });
} }
@@ -1292,6 +1299,14 @@ export function App() {
Modulos Modulos
</Menu.Item> </Menu.Item>
)} )}
{auth.user.is_admin && (
<Menu.Item
leftSection={<IconBrandJira size={14} />}
onClick={() => setJiraImportOpen(true)}
>
Jira
</Menu.Item>
)}
<Menu.Item <Menu.Item
leftSection={<IconKey size={14} />} leftSection={<IconKey size={14} />}
onClick={() => setMcpTokensOpen(true)} onClick={() => setMcpTokensOpen(true)}
@@ -1311,6 +1326,14 @@ export function App() {
{auth.user?.is_admin && ( {auth.user?.is_admin && (
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} /> <ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
)} )}
{auth.user?.is_admin && board && (
<JiraModal
opened={jiraImportOpen}
onClose={() => setJiraImportOpen(false)}
columns={board.columns}
onMutated={() => reload()}
/>
)}
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} /> <MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
</Group> </Group>
</Group> </Group>
+141
View File
@@ -1,6 +1,7 @@
import type { import type {
Board, Board,
Card, Card,
CardFile,
CardHistoryResponse, CardHistoryResponse,
CardMessage, CardMessage,
Column, Column,
@@ -443,6 +444,44 @@ export function listRequesters(): Promise<string[]> {
return fetchJSON("/requesters"); return fetchJSON("/requesters");
} }
// --- Files (issue 0128) -----------------------------------------------------
export function listCardFiles(cardId: string): Promise<CardFile[]> {
return fetchJSON(`/cards/${cardId}/files`);
}
export async function uploadCardFile(
cardId: string,
file: File,
source: "upload" | "description" | "chat" = "upload"
): Promise<CardFile> {
const fd = new FormData();
fd.append("file", file);
fd.append("source", source);
const res = await fetch(`${BASE}/cards/${cardId}/files`, {
method: "POST",
credentials: "same-origin",
body: fd,
});
if (!res.ok) {
let msg = `upload failed: ${res.status}`;
try {
const body = (await res.json()) as { Message?: string; message?: string };
if (body.Message || body.message) msg = body.Message || body.message || msg;
} catch {
/* ignore */
}
throw new HTTPError(res.status, msg);
}
return (await res.json()) as CardFile;
}
export function deleteCardFile(fileId: string): Promise<void> {
return fetchJSON(`/files/${fileId}`, { method: "DELETE" });
}
// --- MCP per-user tokens ----------------------------------------------------
export interface MCPToken { export interface MCPToken {
id: string; id: string;
name: string; name: string;
@@ -466,6 +505,108 @@ export function revokeMCPToken(id: string): Promise<void> {
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" }); return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
} }
// --- Jira sync state + import ----------------------------------------------
export interface CardJiraSyncState {
card_id: string;
jira_key: string;
last_status: string;
last_sync_at: string;
last_error: string;
inflight: boolean;
issue_url?: string;
}
export function getCardJiraSync(cardId: string): Promise<CardJiraSyncState> {
return fetchJSON(`/cards/${cardId}/jira-sync`);
}
export interface JiraIssue {
key: string;
summary: string;
status_name: string;
issue_type: string;
assignee: string;
updated: string;
url: string;
already_imported: boolean;
mapped_column_id?: string;
issue_type_icon?: string;
}
export interface ListJiraIssuesResponse {
issues: JiraIssue[];
board_id: number;
project_key: string;
status_to_column: Record<string, string>;
include_imported: boolean;
}
export function listJiraIssues(opts?: { includeImported?: boolean; limit?: number }): Promise<ListJiraIssuesResponse> {
const qs = new URLSearchParams();
if (opts?.includeImported) qs.set("include_imported", "true");
if (opts?.limit) qs.set("limit", String(opts.limit));
const q = qs.toString();
return fetchJSON(`/jira/issues${q ? `?${q}` : ""}`);
}
export interface JiraImportResult {
key: string;
status: "imported" | "skipped" | "error";
card_id?: string;
column_id?: string;
error?: string;
}
export function importJiraIssues(issueKeys: string[], fallbackColumnId?: string): Promise<{ results: JiraImportResult[] }> {
return fetchJSON("/jira/import", {
method: "POST",
body: JSON.stringify({ issue_keys: issueKeys, fallback_column_id: fallbackColumnId || "" }),
});
}
export interface JiraCheckRow {
card_id: string;
jira_key: string;
title: string;
kanban_column_id: string;
kanban_column_name: string;
jira_status_name: string;
expected_kanban_col: string;
expected_jira_status: string;
mismatch: boolean;
issue_url: string;
}
export interface JiraCheckResponse {
rows: JiraCheckRow[];
total: number;
mismatches: number;
in_sync: number;
status_map: Record<string, string>;
reverse_map: Record<string, string>;
}
export function checkJiraColumns(): Promise<JiraCheckResponse> {
return fetchJSON("/jira/check-columns");
}
export interface JiraReconcileResult {
card_id: string;
status: "fixed" | "skipped" | "error";
jira_key?: string;
jira_status?: string;
error?: string;
http?: number;
}
export function reconcileJiraColumns(cardIds: string[]): Promise<{ results: JiraReconcileResult[] }> {
return fetchJSON("/jira/reconcile-columns", {
method: "POST",
body: JSON.stringify({ card_ids: cardIds, direction: "kanban-wins" }),
});
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> { export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from); if (f.from) qs.set("from", f.from);
+114 -43
View File
@@ -1,9 +1,9 @@
import { import {
ActionIcon, ActionIcon,
Avatar, Avatar,
Badge,
Box, Box,
Combobox, Combobox,
FileButton,
Group, Group,
Loader, Loader,
Paper, Paper,
@@ -14,11 +14,11 @@ import {
Tooltip, Tooltip,
useCombobox, useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { IconSend, IconTrash } from "@tabler/icons-react"; import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { import {
DragEvent,
KeyboardEvent, KeyboardEvent,
ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -29,17 +29,24 @@ import * as api from "../api";
import type { CardMessage, User } from "../types"; import type { CardMessage, User } from "../types";
import { tagColor } from "./colors"; import { tagColor } from "./colors";
import { formatDateTimeShort } from "./format"; import { formatDateTimeShort } from "./format";
import { MessageBody } from "./MessageBody";
interface Props { interface Props {
cardId: string; cardId: string;
users: User[]; users: User[];
currentUserId?: string; currentUserId?: string;
onMessagesChange?: (messages: CardMessage[]) => void; onMessagesChange?: (messages: CardMessage[]) => void;
onFileUploaded?: () => void;
// When set, the panel scrolls the matching message into view and flashes a // When set, the panel scrolls the matching message into view and flashes a
// brief highlight (~2s). Used by notification click → open card. // brief highlight (~2s). Used by notification click → open card.
highlightMessageId?: string; highlightMessageId?: string;
} }
function refForFile(filename: string, url: string, mime: string): string {
const safe = filename.replace(/]/g, "");
return mime.startsWith("image/") ? `![${safe}](${url})` : `[${safe}](${url})`;
}
// Window for considering a peer "actively typing" after its last event. // Window for considering a peer "actively typing" after its last event.
const TYPING_LIFETIME_MS = 4000; const TYPING_LIFETIME_MS = 4000;
// Minimum gap between successive typing pings emitted while the user types. // Minimum gap between successive typing pings emitted while the user types.
@@ -69,43 +76,20 @@ function detectMention(value: string, cursor: number): MentionMatch | null {
return null; return null;
} }
const mentionRegex = /(^|\s)(@[a-z0-9][a-z0-9_.-]{0,63})/gi; export function CardChatPanel({
cardId,
function renderBody(body: string, knownUsers: Map<string, User>): ReactNode { users,
const out: ReactNode[] = []; currentUserId,
let last = 0; onMessagesChange,
let key = 0; onFileUploaded,
for (const m of body.matchAll(mentionRegex)) { highlightMessageId,
const handle = m[2].slice(1).toLowerCase(); }: Props) {
const idx = (m.index ?? 0) + m[1].length;
if (idx > last) out.push(body.slice(last, idx));
const user = knownUsers.get(handle);
if (user) {
out.push(
<Badge
key={`m${key++}`}
size="xs"
variant="light"
color={user.color || tagColor(user.username)}
style={{ verticalAlign: "middle" }}
>
@{user.username}
</Badge>,
);
} else {
out.push(`@${handle}`);
}
last = idx + m[2].length;
}
if (last < body.length) out.push(body.slice(last));
return out;
}
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange, highlightMessageId }: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]); const [messages, setMessages] = useState<CardMessage[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [typingUsers, setTypingUsers] = useState<Record<string, number>>({}); const [typingUsers, setTypingUsers] = useState<Record<string, number>>({});
const [mention, setMention] = useState<MentionMatch | null>(null); const [mention, setMention] = useState<MentionMatch | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null); const viewportRef = useRef<HTMLDivElement | null>(null);
@@ -114,7 +98,6 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
const lastTypingEmitRef = useRef(0); const lastTypingEmitRef = useRef(0);
const usersById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]); const usersById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
const usersByUsername = useMemo(() => new Map(users.map((u) => [u.username.toLowerCase(), u])), [users]);
const reload = useCallback(async () => { const reload = useCallback(async () => {
try { try {
@@ -311,6 +294,47 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
} }
}; };
const handleFiles = async (files: FileList | File[]) => {
setUploading(true);
try {
for (const file of Array.from(files)) {
try {
const cf = await api.uploadCardFile(cardId, file, "chat");
const ref = refForFile(cf.filename, cf.url, cf.mime);
const m = await api.createCardMessage(cardId, ref);
setMessages((prev) => {
const next = [...prev, m];
onMessagesChange?.(next);
return next;
});
onFileUploaded?.();
} catch (e) {
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
}
}
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
};
const typingNames = Object.keys(typingUsers) const typingNames = Object.keys(typingUsers)
.filter((uid) => uid !== currentUserId) .filter((uid) => uid !== currentUserId)
.map((uid) => { .map((uid) => {
@@ -319,7 +343,20 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
}); });
return ( return (
<Stack gap="xs" style={{ height: "100%", minHeight: 0 }}> <Stack
gap="xs"
style={{
height: "100%",
minHeight: 0,
position: "relative",
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
outlineOffset: dragOver ? -2 : undefined,
borderRadius: 4,
}}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<ScrollArea <ScrollArea
viewportRef={viewportRef} viewportRef={viewportRef}
style={{ flex: 1, minHeight: 200 }} style={{ flex: 1, minHeight: 200 }}
@@ -330,7 +367,7 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
<Group justify="center" p="md"><Loader size="sm" /></Group> <Group justify="center" p="md"><Loader size="sm" /></Group>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md"> <Text size="sm" c="dimmed" ta="center" p="md">
Sin mensajes aun. Escribe el primero. Sin mensajes aun. Escribe el primero o arrastra un archivo.
</Text> </Text>
) : ( ) : (
<Stack gap={6} p={4}> <Stack gap={6} p={4}>
@@ -376,9 +413,9 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
</Tooltip> </Tooltip>
)} )}
</Group> </Group>
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> <Stack gap={4}>
{renderBody(m.body, usersByUsername)} <MessageBody text={m.body} />
</Text> </Stack>
</Box> </Box>
</Group> </Group>
</Paper> </Paper>
@@ -407,13 +444,29 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
value={body} value={body}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Escribe un mensaje (Enter = enviar, @ para mencionar)" placeholder="Escribe un mensaje (Enter = enviar, @ para mencionar). Arrastra archivos o usa el clip."
autosize autosize
minRows={1} minRows={1}
maxRows={6} maxRows={6}
style={{ flex: 1 }} style={{ flex: 1 }}
disabled={sending} disabled={sending}
/> />
<FileButton onChange={(file) => file && handleFiles([file])} disabled={uploading}>
{(props) => (
<Tooltip label="Adjuntar archivo" withArrow>
<ActionIcon
size="lg"
variant="subtle"
color="gray"
aria-label="Adjuntar"
loading={uploading}
{...props}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
)}
</FileButton>
<Tooltip label="Enviar" withArrow> <Tooltip label="Enviar" withArrow>
<ActionIcon <ActionIcon
size="lg" size="lg"
@@ -446,6 +499,24 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
</Combobox.Options> </Combobox.Options>
</Combobox.Dropdown> </Combobox.Dropdown>
</Combobox> </Combobox>
{(dragOver || uploading) && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(34,139,230,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
borderRadius: 4,
}}
>
<Text size="sm" fw={500} c="blue">
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
</Text>
</Box>
)}
</Stack> </Stack>
); );
} }
+10 -5
View File
@@ -1,8 +1,9 @@
import { Box, Divider, Group, Tabs, Text } from "@mantine/core"; import { Box, Divider, Group, Tabs } from "@mantine/core";
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react"; import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import type { Card, CardMessage, User } from "../types"; import type { Card, CardMessage, User } from "../types";
import { CardChatPanel } from "./CardChatPanel"; import { CardChatPanel } from "./CardChatPanel";
import { CardFilesPanel } from "./CardFilesPanel";
import { CardLinksPanel } from "./CardLinksPanel"; import { CardLinksPanel } from "./CardLinksPanel";
import { CardForm, CardFormValues } from "./CardForm"; import { CardForm, CardFormValues } from "./CardForm";
@@ -31,12 +32,15 @@ export function CardEditPanel({
}: Props) { }: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]); const [messages, setMessages] = useState<CardMessage[]>([]);
const [liveCard, setLiveCard] = useState(card); const [liveCard, setLiveCard] = useState(card);
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
const wrappedSubmit = async (v: CardFormValues) => { const wrappedSubmit = async (v: CardFormValues) => {
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id })); setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
await onSubmit(v); await onSubmit(v);
}; };
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
return ( return (
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}> <Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
<Box style={{ flex: "1 1 0", minWidth: 320 }}> <Box style={{ flex: "1 1 0", minWidth: 320 }}>
@@ -52,6 +56,8 @@ export function CardEditPanel({
tags: liveCard.tags || [], tags: liveCard.tags || [],
}} }}
submitLabel="Guardar" submitLabel="Guardar"
cardId={liveCard.id}
onFileUploaded={bumpFiles}
onSubmit={wrappedSubmit} onSubmit={wrappedSubmit}
onCancel={onCancel} onCancel={onCancel}
/> />
@@ -62,7 +68,7 @@ export function CardEditPanel({
<Tabs.List> <Tabs.List>
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab> <Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab> <Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />} disabled>Archivos</Tabs.Tab> <Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />}>Archivos</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}> <Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}> <Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
@@ -72,6 +78,7 @@ export function CardEditPanel({
users={users} users={users}
currentUserId={currentUserId} currentUserId={currentUserId}
onMessagesChange={setMessages} onMessagesChange={setMessages}
onFileUploaded={bumpFiles}
highlightMessageId={highlightMessageId} highlightMessageId={highlightMessageId}
/> />
</Box> </Box>
@@ -80,9 +87,7 @@ export function CardEditPanel({
<CardLinksPanel card={liveCard} messages={messages} /> <CardLinksPanel card={liveCard} messages={messages} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="files"> <Tabs.Panel value="files">
<Text size="sm" c="dimmed" ta="center" p="md"> <CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
Proximamente: adjuntos de archivos.
</Text>
</Tabs.Panel> </Tabs.Panel>
</Box> </Box>
</Tabs> </Tabs>
+223
View File
@@ -0,0 +1,223 @@
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
FileButton,
Group,
Image,
Loader,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconDownload,
IconFile,
IconFileSpreadsheet,
IconFileText,
IconFileTypePdf,
IconPhoto,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import * as api from "../api";
import type { CardFile } from "../types";
interface Props {
cardId: string;
refreshKey?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function isImage(mime: string): boolean {
return mime.startsWith("image/");
}
function fileIcon(mime: string, size = 18) {
const m = mime.toLowerCase();
if (m.startsWith("image/")) return <IconPhoto size={size} />;
if (m === "application/pdf") return <IconFileTypePdf size={size} />;
if (
m.includes("spreadsheet") ||
m.includes("excel") ||
m === "text/csv" ||
m === "application/vnd.ms-excel"
) {
return <IconFileSpreadsheet size={size} />;
}
if (m.startsWith("text/")) return <IconFileText size={size} />;
return <IconFile size={size} />;
}
function sourceBadge(s: CardFile["source"]) {
if (s === "description") return { color: "blue", label: "descripcion" };
if (s === "chat") return { color: "teal", label: "chat" };
return { color: "gray", label: "subido" };
}
export function CardFilesPanel({ cardId, refreshKey }: Props) {
const [files, setFiles] = useState<CardFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const reload = useCallback(async () => {
try {
const list = await api.listCardFiles(cardId);
setFiles(list);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, [cardId]);
useEffect(() => {
reload();
}, [reload, refreshKey]);
const onUpload = async (file: File | null) => {
if (!file) return;
setUploading(true);
try {
const cf = await api.uploadCardFile(cardId, file, "upload");
setFiles((prev) => [...prev, cf]);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setUploading(false);
}
};
const onDelete = async (id: string) => {
if (!window.confirm("¿Borrar este archivo?")) return;
try {
await api.deleteCardFile(id);
setFiles((prev) => prev.filter((f) => f.id !== id));
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
return (
<Stack gap="xs" p={4}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{files.length} archivo{files.length === 1 ? "" : "s"}
</Text>
<FileButton onChange={onUpload} disabled={uploading}>
{(props) => (
<Button
size="xs"
variant="light"
leftSection={<IconUpload size={14} />}
loading={uploading}
{...props}
>
Subir
</Button>
)}
</FileButton>
</Group>
{loading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : files.length === 0 ? (
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 160 }}>
<Text size="sm" c="dimmed">
Sin archivos
</Text>
<Text size="xs" c="dimmed" ta="center">
Sube archivos con el boton, arrastra al chat o a la descripcion.
</Text>
</Stack>
) : (
<Stack gap={6}>
{files.map((f) => {
const badge = sourceBadge(f.source);
return (
<Paper key={f.id} withBorder p="xs" radius="sm">
<Group gap="xs" wrap="nowrap" align="flex-start">
{isImage(f.mime) ? (
<Anchor href={f.url} target="_blank" rel="noopener noreferrer">
<Image
src={f.url}
alt={f.filename}
w={64}
h={64}
fit="cover"
radius="sm"
/>
</Anchor>
) : (
<Box
style={{
width: 64,
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
{fileIcon(f.mime, 28)}
</Box>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Anchor href={f.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
{f.filename}
</Anchor>
<Group gap={6} mt={4}>
<Badge size="xs" variant="light" color={badge.color}>
{badge.label}
</Badge>
<Text size="xs" c="dimmed">{formatSize(f.size)}</Text>
<Text size="xs" c="dimmed">{f.mime || "?"}</Text>
</Group>
</Box>
<Group gap={4} wrap="nowrap">
<Tooltip label="Descargar" withArrow>
<ActionIcon
component="a"
href={f.url}
download={f.filename}
variant="subtle"
size="sm"
aria-label="Descargar"
>
<IconDownload size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Borrar" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onDelete(f.id)}
aria-label="Borrar"
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
})}
</Stack>
)}
</Stack>
);
}
+108 -3
View File
@@ -1,5 +1,7 @@
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core"; import { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
import { FormEvent, KeyboardEvent, useState } from "react"; import { notifications } from "@mantine/notifications";
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
import * as api from "../api";
import type { User } from "../types"; import type { User } from "../types";
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter. // CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
@@ -21,16 +23,25 @@ interface Props {
users?: User[]; users?: User[];
requesterOptions?: string[]; requesterOptions?: string[];
tagOptions?: string[]; tagOptions?: string[];
cardId?: string;
onFileUploaded?: () => void;
onSubmit: (v: CardFormValues) => Promise<void> | void; onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void; onCancel: () => void;
} }
function markdownRef(filename: string, url: string, isImage: boolean): string {
const safeName = filename.replace(/]/g, "");
return isImage ? `![${safeName}](${url})` : `[${safeName}](${url})`;
}
export function CardForm({ export function CardForm({
initial, initial,
submitLabel = "Guardar", submitLabel = "Guardar",
users = [], users = [],
requesterOptions = [], requesterOptions = [],
tagOptions = [], tagOptions = [],
cardId,
onFileUploaded,
onSubmit, onSubmit,
onCancel, onCancel,
}: Props) { }: Props) {
@@ -39,6 +50,9 @@ export function CardForm({
const [description, setDescription] = useState(initial?.description ?? ""); const [description, setDescription] = useState(initial?.description ?? "");
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null); const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
const [tags, setTags] = useState<string[]>(initial?.tags ?? []); const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const submit = async (e?: FormEvent) => { const submit = async (e?: FormEvent) => {
e?.preventDefault(); e?.preventDefault();
@@ -60,6 +74,66 @@ export function CardForm({
} }
}; };
const insertAtCursor = (snippet: string) => {
const ta = textareaRef.current;
if (!ta) {
setDescription((d) => (d ? d + "\n" + snippet : snippet));
return;
}
const start = ta.selectionStart ?? description.length;
const end = ta.selectionEnd ?? description.length;
const before = description.slice(0, start);
const after = description.slice(end);
const sep = before && !before.endsWith("\n") ? "\n" : "";
const next = before + sep + snippet + after;
setDescription(next);
queueMicrotask(() => {
ta.focus();
const pos = (before + sep + snippet).length;
ta.setSelectionRange(pos, pos);
});
};
const handleFiles = async (files: FileList | File[]) => {
if (!cardId) {
notifications.show({ color: "yellow", message: "Guarda la tarjeta antes de subir archivos." });
return;
}
setUploading(true);
try {
for (const file of Array.from(files)) {
try {
const cf = await api.uploadCardFile(cardId, file, "description");
insertAtCursor(markdownRef(cf.filename, cf.url, cf.mime.startsWith("image/")));
onFileUploaded?.();
} catch (e) {
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
}
}
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!cardId) return;
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
};
return ( return (
<form onSubmit={submit}> <form onSubmit={submit}>
<Stack gap="sm"> <Stack gap="sm">
@@ -95,7 +169,19 @@ export function CardForm({
if (e.key === "Enter") e.preventDefault(); if (e.key === "Enter") e.preventDefault();
}} }}
/> />
<Box
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
style={{
position: "relative",
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
outlineOffset: dragOver ? 2 : undefined,
borderRadius: 4,
}}
>
<Textarea <Textarea
ref={textareaRef}
label="Descripcion" label="Descripcion"
value={description} value={description}
onChange={(e) => setDescription(e.currentTarget.value)} onChange={(e) => setDescription(e.currentTarget.value)}
@@ -104,8 +190,27 @@ export function CardForm({
minRows={3} minRows={3}
maxRows={8} maxRows={8}
onKeyDown={textareaEnter} onKeyDown={textareaEnter}
description="Ctrl+Enter para guardar" description={cardId ? "Ctrl+Enter para guardar. Arrastra archivos para adjuntar." : "Ctrl+Enter para guardar"}
/> />
{(dragOver || uploading) && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(34,139,230,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
borderRadius: 4,
}}
>
<Text size="sm" fw={500} c="blue">
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
</Text>
</Box>
)}
</Box>
<Select <Select
label="Asignar a" label="Asignar a"
placeholder="Sin asignar" placeholder="Sin asignar"
+455
View File
@@ -0,0 +1,455 @@
import {
Anchor,
Badge,
Box,
Button,
Checkbox,
Group,
Image,
Loader,
Modal,
Select,
Stack,
Tabs,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconAlertTriangle,
IconBrandJira,
IconChecks,
IconDownload,
IconRefresh,
IconSearch,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { JiraIssue } from "../api";
import type { Column } from "../types";
interface Props {
opened: boolean;
onClose: () => void;
columns: Column[];
// Called when imports or reconciles modified the board so the parent can
// refetch /api/board.
onMutated?: () => void;
}
// JiraModal is the admin hub for everything Jira-related: importing issues
// into kanban and verifying the kanban-column ↔ Jira-status mapping is in
// sync. The previous standalone "Importar de Jira" modal moved here as the
// "Importar" tab; the new "Comprobar columnas" tab surfaces drift detected
// by /api/jira/check-columns and offers a one-click fix per row.
export function JiraModal({ opened, onClose, columns, onMutated }: Props) {
const [tab, setTab] = useState<string | null>("import");
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
title={
<Group gap={8}>
<IconBrandJira size={18} />
<Text fw={600}>Jira</Text>
</Group>
}
>
<Tabs value={tab} onChange={setTab} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="import" leftSection={<IconDownload size={14} />}>
Importar de Jira
</Tabs.Tab>
<Tabs.Tab value="check" leftSection={<IconChecks size={14} />}>
Comprobar columnas
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="import" pt="sm">
<ImportTab columns={columns} onImported={onMutated} />
</Tabs.Panel>
<Tabs.Panel value="check" pt="sm">
<CheckTab onReconciled={onMutated} />
</Tabs.Panel>
</Tabs>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Importar de Jira (kept verbatim from the old standalone modal, minus the
// Modal frame which lives in the parent now).
// ---------------------------------------------------------------------------
function ImportTab({ columns, onImported }: { columns: Column[]; onImported?: () => void }) {
const [issues, setIssues] = useState<JiraIssue[] | null>(null);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showImported, setShowImported] = useState(false);
const [query, setQuery] = useState("");
const [fallbackColumn, setFallbackColumn] = useState<string>("");
const [boardId, setBoardId] = useState<number | null>(null);
const [projectKey, setProjectKey] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const reload = async () => {
setLoading(true);
setError(null);
try {
const r = await api.listJiraIssues({ includeImported: showImported, limit: 200 });
setIssues(r.issues);
setBoardId(r.board_id);
setProjectKey(r.project_key);
setSelected((prev) => {
const validKeys = new Set(r.issues.map((i) => i.key));
const next = new Set<string>();
for (const k of prev) if (validKeys.has(k)) next.add(k);
return next;
});
} catch (e) {
setError((e as Error).message);
setIssues([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showImported]);
useEffect(() => {
if (!fallbackColumn && columns.length > 0) {
const boardCol = columns.find((c) => c.location !== "sidebar") || columns[0];
setFallbackColumn(boardCol.id);
}
}, [columns, fallbackColumn]);
const visible = useMemo(() => {
if (!issues) return [];
const q = query.trim().toLowerCase();
if (!q) return issues;
return issues.filter(
(i) =>
i.key.toLowerCase().includes(q) ||
i.summary.toLowerCase().includes(q) ||
i.assignee.toLowerCase().includes(q) ||
i.status_name.toLowerCase().includes(q),
);
}, [issues, query]);
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAll = () => {
const importable = visible.filter((i) => !i.already_imported);
if (selected.size === importable.length && importable.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(importable.map((i) => i.key)));
}
};
const doImport = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const res = await api.importJiraIssues(Array.from(selected), fallbackColumn || undefined);
const ok = res.results.filter((r) => r.status === "imported").length;
const skip = res.results.filter((r) => r.status === "skipped").length;
const err = res.results.filter((r) => r.status === "error");
const msg = `${ok} importadas` + (skip > 0 ? ` · ${skip} omitidas` : "") + (err.length > 0 ? ` · ${err.length} con error` : "");
notifications.show({
color: err.length > 0 ? "yellow" : "green",
message: msg,
autoClose: 5000,
});
if (err.length > 0) console.warn("import errors", err);
setSelected(new Set());
await reload();
onImported?.();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setImporting(false);
}
};
const columnOptions = columns.map((c) => ({ value: c.id, label: c.name }));
return (
<Stack gap="sm">
<Text size="xs" c="dimmed">
{boardId ? `Board ${boardId} (${projectKey})` : "Cargando board..."}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
placeholder="Filtrar por key, titulo, asignado o status..."
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
leftSection={<IconSearch size={14} />}
style={{ flex: 1 }}
/>
<Tooltip label="Refrescar" withArrow>
<Button variant="default" onClick={reload} loading={loading} leftSection={<IconRefresh size={14} />}>
Refrescar
</Button>
</Tooltip>
</Group>
<Group justify="space-between" gap="xs">
<Checkbox
label="Mostrar issues ya importadas"
checked={showImported}
onChange={(e) => setShowImported(e.currentTarget.checked)}
/>
<Select
label="Columna fallback"
description="Para issues cuyo status no tiene mapping"
data={columnOptions}
value={fallbackColumn}
onChange={(v) => setFallbackColumn(v || "")}
allowDeselect={false}
w={260}
size="xs"
/>
</Group>
{error && <Text size="sm" c="red">{error}</Text>}
<Box style={{ maxHeight: 440, overflowY: "auto", border: "1px solid var(--mantine-color-default-border)", borderRadius: 4 }}>
{loading && !issues ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : visible.length === 0 ? (
<Text size="sm" c="dimmed" p="md" ta="center">
{issues?.length === 0 ? "No hay issues sin importar en el board." : "Ninguna issue coincide con el filtro."}
</Text>
) : (
<Stack gap={0}>
<Group p="xs" gap="xs" style={{ borderBottom: "1px solid var(--mantine-color-default-border)", background: "var(--mantine-color-default-hover)" }}>
<Checkbox
checked={selected.size > 0 && selected.size === visible.filter((i) => !i.already_imported).length}
indeterminate={selected.size > 0 && selected.size < visible.filter((i) => !i.already_imported).length}
onChange={toggleAll}
/>
<Text size="xs" fw={600} c="dimmed">{visible.length} issues · {selected.size} seleccionadas</Text>
</Group>
{visible.map((iss) => (
<Group key={iss.key} p="xs" gap="xs" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)", opacity: iss.already_imported ? 0.5 : 1 }}>
<Checkbox checked={selected.has(iss.key)} disabled={iss.already_imported} onChange={() => toggle(iss.key)} />
{iss.issue_type_icon && <Image src={iss.issue_type_icon} w={16} h={16} alt={iss.issue_type} />}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Anchor href={iss.url} target="_blank" rel="noopener noreferrer" size="sm" fw={600}>{iss.key}</Anchor>
<Text size="sm" truncate style={{ flex: 1 }}>{iss.summary}</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="light" color={badgeColor(iss.status_name)}>{iss.status_name}</Badge>
<Badge size="xs" variant="outline">{iss.issue_type}</Badge>
{iss.assignee && <Text size="xs" c="dimmed">· {iss.assignee}</Text>}
{iss.already_imported && <Badge size="xs" color="gray">ya en kanban</Badge>}
{!iss.already_imported && !iss.mapped_column_id && <Badge size="xs" color="orange" variant="light">sin mapping (usa fallback)</Badge>}
</Group>
</Stack>
</Group>
))}
</Stack>
)}
</Box>
<Group justify="flex-end" gap="xs">
<Button onClick={doImport} disabled={selected.size === 0 || importing} loading={importing}>
Importar {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</Group>
</Stack>
);
}
// ---------------------------------------------------------------------------
// Comprobar columnas: detects drift between kanban column ↔ Jira status.
// ---------------------------------------------------------------------------
function CheckTab({ onReconciled }: { onReconciled?: () => void }) {
const [data, setData] = useState<api.JiraCheckResponse | null>(null);
const [loading, setLoading] = useState(false);
const [fixing, setFixing] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [onlyMismatch, setOnlyMismatch] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = async () => {
setLoading(true);
setError(null);
try {
const r = await api.checkJiraColumns();
setData(r);
// After a refresh, drop selections that no longer apply.
setSelected((prev) => {
const validIds = new Set(r.rows.filter((x) => x.mismatch).map((x) => x.card_id));
const next = new Set<string>();
for (const id of prev) if (validIds.has(id)) next.add(id);
return next;
});
} catch (e) {
setError((e as Error).message);
setData(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, []);
const visible = useMemo(() => {
if (!data) return [];
return onlyMismatch ? data.rows.filter((r) => r.mismatch) : data.rows;
}, [data, onlyMismatch]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
const fixable = visible.filter((r) => r.mismatch).map((r) => r.card_id);
if (selected.size === fixable.length && fixable.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(fixable));
}
};
const doFix = async () => {
if (selected.size === 0) return;
setFixing(true);
try {
const res = await api.reconcileJiraColumns(Array.from(selected));
const ok = res.results.filter((r) => r.status === "fixed").length;
const skip = res.results.filter((r) => r.status === "skipped").length;
const err = res.results.filter((r) => r.status === "error");
const msg = `${ok} sincronizadas` + (skip > 0 ? ` · ${skip} omitidas` : "") + (err.length > 0 ? ` · ${err.length} con error` : "");
notifications.show({
color: err.length > 0 ? "yellow" : "green",
message: msg,
autoClose: 5000,
});
if (err.length > 0) console.warn("reconcile errors", err);
setSelected(new Set());
await reload();
onReconciled?.();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setFixing(false);
}
};
return (
<Stack gap="sm">
<Text size="xs" c="dimmed">
Compara la columna kanban de cada card con el status actual de su Jira issue. Lo hace
consultando Jira en vivo (1 request por card) puede tardar varios segundos con
muchas cards.
</Text>
{data && (
<Group gap="xs">
<Badge color="green" variant="light">{data.in_sync} en sync</Badge>
{data.mismatches > 0 && (
<Badge color="red" variant="filled" leftSection={<IconAlertTriangle size={12} />}>
{data.mismatches} desincronizadas
</Badge>
)}
<Badge color="gray" variant="outline">{data.total} totales</Badge>
</Group>
)}
<Group gap="xs" justify="space-between">
<Checkbox
label="Solo mostrar desincronizadas"
checked={onlyMismatch}
onChange={(e) => setOnlyMismatch(e.currentTarget.checked)}
/>
<Button variant="default" onClick={reload} loading={loading} leftSection={<IconRefresh size={14} />}>
Re-comprobar
</Button>
</Group>
{error && <Text size="sm" c="red">{error}</Text>}
<Box style={{ maxHeight: 440, overflowY: "auto", border: "1px solid var(--mantine-color-default-border)", borderRadius: 4 }}>
{loading && !data ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : visible.length === 0 ? (
<Text size="sm" c="dimmed" p="md" ta="center">
{data && data.mismatches === 0
? "Todas las cards estan en su columna correcta. ✅"
: "No hay rows que mostrar con el filtro actual."}
</Text>
) : (
<Stack gap={0}>
<Group p="xs" gap="xs" style={{ borderBottom: "1px solid var(--mantine-color-default-border)", background: "var(--mantine-color-default-hover)" }}>
<Checkbox
checked={selected.size > 0 && selected.size === visible.filter((r) => r.mismatch).length}
indeterminate={selected.size > 0 && selected.size < visible.filter((r) => r.mismatch).length}
onChange={toggleAll}
/>
<Text size="xs" fw={600} c="dimmed">
{visible.length} rows · {selected.size} seleccionadas para sync
</Text>
</Group>
{visible.map((row) => (
<Group key={row.card_id} p="xs" gap="xs" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
<Checkbox checked={selected.has(row.card_id)} disabled={!row.mismatch} onChange={() => toggle(row.card_id)} />
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Anchor href={row.issue_url} target="_blank" rel="noopener noreferrer" size="sm" fw={600}>{row.jira_key}</Anchor>
<Text size="sm" truncate style={{ flex: 1 }}>{row.title}</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="light" color="blue">kanban: {row.kanban_column_name}</Badge>
<Text size="xs" c="dimmed"> esperado Jira: <b>{row.expected_jira_status || "(sin mapeo)"}</b></Text>
<Badge size="xs" variant="light" color={row.mismatch ? "red" : "green"}>
jira: {row.jira_status_name}
</Badge>
</Group>
</Stack>
</Group>
))}
</Stack>
)}
</Box>
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">Fix = transicionar Jira al status que matchea la columna kanban (kanban gana)</Text>
<Button color="orange" onClick={doFix} disabled={selected.size === 0 || fixing} loading={fixing}>
Sincronizar {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</Group>
</Stack>
);
}
// ---------------------------------------------------------------------------
function badgeColor(status: string): string {
const s = status.toLowerCase();
if (s.includes("done") || s.includes("hecho") || s.includes("closed")) return "green";
if (s.includes("progress") || s.includes("doing")) return "blue";
if (s.includes("implementado") || s.includes("review")) return "violet";
if (s.includes("creado") || s.includes("backlog") || s.includes("nuevo")) return "gray";
return "yellow";
}
@@ -0,0 +1,178 @@
import { Anchor, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconBrandJira, IconAlertCircle } from "@tabler/icons-react";
import { CSSProperties, useEffect, useState } from "react";
import * as api from "../api";
import type { CardJiraSyncState } from "../api";
import { formatDateTimeShort } from "./format";
// Adaptive polling: 5s steady-state, 1s while the card is mid-sync. The fast
// cadence catches the yellow → green transition right after a column drag;
// the slow cadence keeps the per-card load manageable when the board is idle.
const POLL_MS_STEADY = 5000;
const POLL_MS_INFLIGHT = 1000;
type Tone = "gray" | "yellow" | "green" | "red";
function tone(state: CardJiraSyncState): Tone {
if (state.inflight) return "yellow";
if (state.last_error) return "red";
if (state.jira_key) return "green";
return "gray";
}
const TONE_COLOR: Record<Tone, string> = {
gray: "var(--mantine-color-gray-5)",
yellow: "var(--mantine-color-yellow-5)",
green: "var(--mantine-color-green-5)",
red: "var(--mantine-color-red-6)",
};
const TONE_LABEL: Record<Tone, string> = {
gray: "Sin sincronizar con Jira",
yellow: "Sincronizando...",
green: "Sincronizada con Jira",
red: "Error de sincronizacion",
};
interface Props {
cardId: string;
// Pollen-down so the parent can refresh when needed (e.g. after a move
// animation finishes) without waiting for the next tick.
refreshTick?: number;
}
export function JiraSyncIndicator({ cardId, refreshTick }: Props) {
const [state, setState] = useState<CardJiraSyncState | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const schedule = (ms: number) => {
if (cancelled) return;
if (timer) clearTimeout(timer);
timer = setTimeout(load, ms);
};
const load = async () => {
try {
const s = await api.getCardJiraSync(cardId);
if (cancelled) return;
setState(s);
setErr(null);
// Adaptive cadence: fast while the dispatcher is actively processing
// this card, slow otherwise. Re-arms on every fetch so the moment
// inflight flips off we drop back to the steady cadence.
schedule(s.inflight ? POLL_MS_INFLIGHT : POLL_MS_STEADY);
} catch (e) {
if (cancelled) return;
setErr((e as Error).message);
schedule(POLL_MS_STEADY);
}
};
// Window event fired by the App's drag-end handler so the indicator
// refetches immediately after a move (and so the user sees yellow within
// ~200ms instead of waiting up to POLL_MS_STEADY).
const onMoved = (e: Event) => {
const detail = (e as CustomEvent).detail as { cardId?: string } | undefined;
if (detail?.cardId === cardId) {
schedule(150);
}
};
window.addEventListener("kanban-card-moved", onMoved);
load();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
window.removeEventListener("kanban-card-moved", onMoved);
};
}, [cardId, refreshTick]);
if (err && !state) {
return (
<Box title={err} style={dotStyle("var(--mantine-color-gray-3)")} aria-label="Jira sync state unavailable" />
);
}
if (!state) {
// Initial render — fade in a placeholder dot so the layout does not shift
// when the fetch resolves.
return <Box style={dotStyle("var(--mantine-color-gray-2)")} aria-label="Cargando estado Jira" />;
}
const t = tone(state);
return (
<HoverCard width={300} shadow="md" openDelay={150} closeDelay={120} withinPortal>
<HoverCard.Target>
<Box
role="status"
aria-label={TONE_LABEL[t]}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
style={dotStyle(TONE_COLOR[t])}
/>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<Stack gap={6}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<IconBrandJira size={14} />
<Text size="sm" fw={600}>{TONE_LABEL[t]}</Text>
</Group>
{state.issue_url && (
<Anchor
size="xs"
href={state.issue_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Abrir en Jira
</Anchor>
)}
</Group>
{state.jira_key && (
<Text size="xs">
<Text component="span" c="dimmed">Issue:</Text>{" "}
<Text component="span" fw={600}>{state.jira_key}</Text>
</Text>
)}
{state.last_status && (
<Text size="xs">
<Text component="span" c="dimmed">Status:</Text>{" "}
<Text component="span">{state.last_status}</Text>
</Text>
)}
{state.last_sync_at && (
<Text size="xs">
<Text component="span" c="dimmed">Ultimo sync:</Text>{" "}
<Text component="span">{formatDateTimeShort(state.last_sync_at)}</Text>
</Text>
)}
{state.last_error && (
<Group gap={6} wrap="nowrap" align="flex-start">
<IconAlertCircle size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red" style={{ wordBreak: "break-word" }}>{state.last_error}</Text>
</Group>
)}
{!state.jira_key && (
<Text size="xs" c="dimmed">
La card todavia no se ha empujado a Jira. Editala o muevela para
disparar el sync, o usa la opcion "Importar de Jira" si ya existe
alli.
</Text>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
function dotStyle(color: string): CSSProperties {
return {
width: 10,
height: 10,
borderRadius: "50%",
background: color,
cursor: "default",
boxShadow: "0 0 0 2px var(--mantine-color-body)",
transition: "background 120ms ease",
};
}
+5 -1
View File
@@ -38,6 +38,7 @@ import type { Card, CardColor, User } from "../types";
import { colorBg, colorBorder, tagColor } from "./colors"; import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid"; import { ColorPickerGrid } from "./ColorPickerGrid";
import { formatDateTimeShort, formatDuration } from "./format"; import { formatDateTimeShort, formatDuration } from "./format";
import { JiraSyncIndicator } from "./JiraSyncIndicator";
interface Props { interface Props {
card: Card; card: Card;
@@ -358,9 +359,10 @@ const KanbanCardBody = memo(function KanbanCardBody({
{card.title} {card.title}
</Text> </Text>
</Group> </Group>
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow> <Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" style={{ flexShrink: 0 }} onPointerDown={(e) => e.stopPropagation()}> <ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" onPointerDown={(e) => e.stopPropagation()}>
<IconDotsVertical size={14} /> <IconDotsVertical size={14} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -368,6 +370,8 @@ const KanbanCardBody = memo(function KanbanCardBody({
{menuItems} {menuItems}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
<JiraSyncIndicator cardId={card.id} />
</Stack>
</Group> </Group>
{(card.requester || assignee) && ( {(card.requester || assignee) && (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
+126
View File
@@ -0,0 +1,126 @@
import { Anchor, Image, Text } from "@mantine/core";
import { Fragment, ReactNode } from "react";
// Minimal markdown renderer for chat/desc embeds (issue 0128).
// Recognises:
// ![alt](url) -> <Image>
// [name](url) -> <Anchor>name</Anchor>
// Everything else is plain text with preserved whitespace.
interface ImgToken { kind: "img"; alt: string; url: string }
interface LinkToken { kind: "link"; label: string; url: string }
interface TextToken { kind: "text"; value: string }
type Token = ImgToken | LinkToken | TextToken;
const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g;
// Allow only safe URL schemes. Reject javascript:, data:text/html, vbscript:, etc.
// Accepts: absolute http(s), protocol-relative //, and same-origin paths (/...).
function safeURL(url: string): string | null {
const u = url.trim();
if (u.startsWith("/")) return u;
if (/^https?:\/\//i.test(u)) return u;
return null;
}
// data: scheme is allowed only when the MIME prefix is image/.
function safeImageURL(url: string): string | null {
const safe = safeURL(url);
if (safe) return safe;
const u = url.trim();
if (/^data:image\/[a-z0-9.+-]+(;[a-z0-9-]+=[^,]+)*;base64,/i.test(u)) return u;
return null;
}
function tokenize(input: string): Token[] {
const out: Token[] = [];
let last = 0;
let m: RegExpExecArray | null;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(input)) !== null) {
if (m.index > last) {
out.push({ kind: "text", value: input.slice(last, m.index) });
}
if (m[1]) {
const url = safeImageURL(m[3]);
if (url) {
out.push({ kind: "img", alt: m[2] || "", url });
} else {
out.push({ kind: "text", value: m[0] });
}
} else if (m[4]) {
const url = safeURL(m[6]);
if (url) {
out.push({ kind: "link", label: m[5], url });
} else {
out.push({ kind: "text", value: m[0] });
}
}
last = TOKEN_RE.lastIndex;
}
if (last < input.length) {
out.push({ kind: "text", value: input.slice(last) });
}
return out;
}
interface Props {
text: string;
size?: string;
}
export function MessageBody({ text, size = "sm" }: Props): ReactNode {
const tokens = tokenize(text);
if (tokens.length === 0) {
return null;
}
const inlineNodes: ReactNode[] = [];
const blocks: ReactNode[] = [];
let key = 0;
const flushInline = () => {
if (inlineNodes.length === 0) return;
blocks.push(
<Text
key={`t-${key++}`}
size={size}
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{inlineNodes.map((n, i) => (
<Fragment key={i}>{n}</Fragment>
))}
</Text>
);
inlineNodes.length = 0;
};
for (const t of tokens) {
if (t.kind === "img") {
flushInline();
blocks.push(
<Anchor key={`i-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
<Image
src={t.url}
alt={t.alt}
maw={220}
mah={220}
fit="contain"
radius="sm"
/>
</Anchor>
);
} else if (t.kind === "link") {
inlineNodes.push(
<Anchor key={`l-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
{t.label}
</Anchor>
);
} else {
inlineNodes.push(t.value);
}
}
flushInline();
return <>{blocks}</>;
}
+12
View File
@@ -46,6 +46,18 @@ export interface Card {
total_locked_ms: number; total_locked_ms: number;
} }
export interface CardFile {
id: string;
card_id: string;
uploader_id: string;
filename: string;
mime: string;
size: number;
source: "upload" | "description" | "chat";
url: string;
created_at: string;
}
export interface User { export interface User {
id: string; id: string;
username: string; username: string;