Compare commits

13 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
23 changed files with 3880 additions and 1380 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
name: kanban name: kanban
lang: go lang: go
domain: tools domain: tools
version: 0.5.0 version: 0.5.2
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." 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:
@@ -195,4 +195,6 @@ Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
- 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.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 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.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. - 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-DFLRkdHe.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>
+30
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)
} }
@@ -687,6 +709,14 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{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
}
@@ -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 {
+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>
+102
View File
@@ -505,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);
+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 }}>