Compare commits
27 Commits
12729b5166
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 466a055f72 | |||
| a934897099 | |||
| 0687b65ea2 | |||
| 87e8f62544 | |||
| 0d8ec1e8e7 | |||
| d4558667f6 | |||
| 9b0b6e516c | |||
| c5113f75a5 | |||
| cd14e81487 | |||
| c3cc42b350 | |||
| 5744b82f58 | |||
| ef197236db | |||
| 65771ebb12 | |||
| 084defe014 | |||
| abb787facd | |||
| 6a35bdec42 | |||
| 065070cec7 | |||
| 172850178d | |||
| d13993c0d7 | |||
| 5b30fb1ded | |||
| 87fd95314e | |||
| 472fa25bae | |||
| aab4f12fc4 | |||
| e86c93cb73 | |||
| 489d2bbef6 | |||
| ac5f016e7e | |||
| 2401eb5abc |
@@ -16,6 +16,9 @@ frontend/tsconfig.tsbuildinfo
|
|||||||
# Local files
|
# Local files
|
||||||
local_files/
|
local_files/
|
||||||
|
|
||||||
|
# Card file attachments (issue 0128) — binarios en disco; metadata en card_files
|
||||||
|
uploads/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
frontend/test-results/
|
frontend/test-results/
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
name: kanban
|
name: kanban
|
||||||
lang: go
|
lang: go
|
||||||
domain: tools
|
domain: tools
|
||||||
version: 0.4.0
|
version: 0.5.2
|
||||||
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit) y tracking del tiempo que cada tarjeta pasa en cada columna. Frontend Vite + React + Mantine v9 embebido en el binario Go."
|
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit), tracking del tiempo por columna, adjuntos de archivos por card, notificaciones realtime (SSE) y modulos externos (Jira). Frontend Vite + React + Mantine v9 embebido en el binario Go. Endpoint MCP Streamable HTTP en /mcp."
|
||||||
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
|
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- random_hex_id_go_core
|
- random_hex_id_go_core
|
||||||
@@ -82,6 +82,10 @@ e2e_checks:
|
|||||||
cmd: "go test -tags fts5 -count=1 ./..."
|
cmd: "go test -tags fts5 -count=1 ./..."
|
||||||
timeout_s: 120
|
timeout_s: 120
|
||||||
expect_exit: 0
|
expect_exit: 0
|
||||||
|
- id: smoke_files
|
||||||
|
cmd: "bash e2e/files_smoke.sh"
|
||||||
|
timeout_s: 30
|
||||||
|
expect_exit: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
## Arquitectura
|
## Arquitectura
|
||||||
@@ -144,7 +148,12 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
|
|||||||
- **Enlaces** (`CardLinksPanel`): extrae URLs (`https?://...`) de titulo, descripcion y cuerpo de cada mensaje del chat. Deduplica, muestra hostname + URL completa + badge de origen. Click abre en pestaña nueva (`target="_blank"`).
|
- **Enlaces** (`CardLinksPanel`): extrae URLs (`https?://...`) de titulo, descripcion y cuerpo de cada mensaje del chat. Deduplica, muestra hostname + URL completa + badge de origen. Click abre en pestaña nueva (`target="_blank"`).
|
||||||
- **Duplicar card:** click derecho sobre la card abre el menu contextual (mismo que el boton `⋮`), donde aparece el item "Duplicar". Al pulsarlo invoca `POST /api/cards/{id}/duplicate`. La copia se inserta al final de la misma columna con titulo + " (copia)".
|
- **Duplicar card:** click derecho sobre la card abre el menu contextual (mismo que el boton `⋮`), donde aparece el item "Duplicar". Al pulsarlo invoca `POST /api/cards/{id}/duplicate`. La copia se inserta al final de la misma columna con titulo + " (copia)".
|
||||||
- **Sesion obligatoria para chat:** `POST/DELETE /api/cards/{id}/messages` exige sesion activa (401 si falta). `author_id` siempre poblado; no hay comentarios anonimos.
|
- **Sesion obligatoria para chat:** `POST/DELETE /api/cards/{id}/messages` exige sesion activa (401 si falta). `author_id` siempre poblado; no hay comentarios anonimos.
|
||||||
- **Archivos (proximamente):** blobs persistidos en SQLite (`card_attachments` con `BLOB`), no en filesystem.
|
- **Archivos** (`CardFilesPanel`): adjuntos por card almacenados en `apps/kanban/uploads/<card_id>/<random>__<safe_filename>` (filesystem, gitignored). Tabla `card_files` con soft-delete. Limite 10 MB por archivo. Tres vias de upload:
|
||||||
|
1. Drag&drop en el editor de descripcion (`CardForm`) → inserta `` (imagen) o `[name](url)` (resto) en la posicion del cursor.
|
||||||
|
2. Drag&drop o boton paperclip en el chat (`CardChatPanel`) → crea un mensaje cuyo cuerpo es la ref markdown.
|
||||||
|
3. Boton "Subir" en el tab Archivos → sube sin embed.
|
||||||
|
- El renderer de mensajes (`MessageBody`) reconoce `` -> `<Image>` thumb 220px y `[name](url)` -> `<Anchor>`. Texto plano se renderiza con `whiteSpace: pre-wrap`.
|
||||||
|
- Endpoints: `POST /api/cards/{id}/files` (multipart, 10 MB max), `GET /api/cards/{id}/files`, `GET /api/files/{id}` (sirve binario con `inline` o `attachment` segun MIME), `DELETE /api/files/{id}` (soft delete).
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
@@ -183,5 +192,9 @@ Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
|||||||
- `patch`: bugfix sin cambio observable.
|
- `patch`: bugfix sin cambio observable.
|
||||||
|
|
||||||
- v0.1.0 (2026-05-18) — baseline.
|
- v0.1.0 (2026-05-18) — baseline.
|
||||||
|
- v0.2.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): tabla `card_files` con soft-delete, endpoints REST (`POST/GET/DELETE /api/cards/{id}/files`, `GET/DELETE /api/files/{id}`), tres vias de upload (drag&drop en descripcion y chat, boton en tab Archivos), render inline de imagenes via `MessageBody`. Limite 10 MB.
|
||||||
- v0.3.1 (2026-05-21) — patch: debounce board.invalidated (300ms trailing) + autoClose 4s en toasts de notification.created. Fix de blow-up de memoria en navegador por ráfagas de SSE.
|
- v0.3.1 (2026-05-21) — patch: debounce board.invalidated (300ms trailing) + autoClose 4s en toasts de notification.created. Fix de blow-up de memoria en navegador por ráfagas de SSE.
|
||||||
- v0.4.0 (2026-05-22) — minor: endpoint MCP Streamable HTTP `/mcp` con per-user bearer tokens (tabla `mcp_tokens`, migration 016). Modal "MCP tokens" en avatar menu para generar/listar/revocar. Vite proxy enruta `/mcp` a WSL. Usa nueva funcion `mcp_server_http_go_infra`. Doc en `docs/MCP.md`.
|
- v0.4.0 (2026-05-22) — minor: endpoint MCP Streamable HTTP `/mcp` con per-user bearer tokens (tabla `mcp_tokens`, migration 017). Modal "MCP tokens" en avatar menu para generar/listar/revocar. Vite proxy enruta `/mcp` a WSL. Usa nueva funcion `mcp_server_http_go_infra`. Doc en `docs/MCP.md`.
|
||||||
|
- v0.5.2 (2026-06-01) — patch: el alta a Jira rellena el campo obligatorio "Área Solicitante" (`customfield_10158`) que el issue type Epic (y Mejora) del proyecto DATA exige en la pantalla de creacion. Sin esto, el `card.created` del 0.5.1 daba HTTP 400 "Solicitante is required". Nuevos campos en `jiraConfig`: `requester_field`, `requester_map`, `requester_default`. `create()`/`update()` inyectan el campo como single-select `{value:<opcion>}` resuelto desde el requester de la card (mapa case-insensitive) o el default. Como los requesters del kanban son nombres de persona (no departamentos), las cards caen al default (`Transformación`). `seed-jira-data` gana flags `--requester-field`/`--requester-default` y la rama de update ahora mergea config para no pisar ediciones de UI.
|
||||||
|
- v0.5.1 (2026-06-01) — patch: `handleCreateCard` ahora emite el evento `card.created` (antes solo `board.invalidated`, que no estaba en el filtro del modulo). Con esto la creacion de una card dispara `jiraHandler.create` y sincroniza el alta a Jira, igual que ya ocurria con move (`card.moved`) y chat (`message.created`). El evento se emite tras aplicar assignee/tags para que el issue de Jira los lleve.
|
||||||
|
- v0.5.0 (2026-05-27) — minor: merge ramas notifications-realtime + modules con master post-files. Trae notificaciones SSE (tabla `notifications`, migration 015), modulos externos para sincronizacion bidireccional (Jira, etc., tabla `modules`, migration 016), tokens MCP per-user (migration 017). Conserva files attachments del 0128. Renumeradas migrations notif 014/015/016 -> 015/016/017.
|
||||||
|
|||||||
@@ -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] + "…"
|
||||||
|
}
|
||||||
+1333
File diff suppressed because one or more lines are too long
-1280
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<title>Kanban</title>
|
||||||
<script type="module" crossorigin src="/assets/index-UVzY_37O.js"></script>
|
<script type="module" crossorigin src="/assets/index-Be_Ib5cu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 0128: adjuntos de archivos por card.
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxUploadBytes = 10 << 20 // 10 MiB
|
||||||
|
uploadsSubdir = "uploads"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CardFile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CardID string `json:"card_id"`
|
||||||
|
UploaderID string `json:"uploader_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
MIME string `json:"mime"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateCardFile(cardID, uploaderID, filename, mimeType, storedPath, source string, size int64) (*CardFile, error) {
|
||||||
|
id := newID()
|
||||||
|
now := nowRFC3339()
|
||||||
|
if source == "" {
|
||||||
|
source = "upload"
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`INSERT INTO card_files
|
||||||
|
(id, card_id, uploader_id, filename, mime, size, stored_path, source, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, cardID, uploaderID, filename, mimeType, size, storedPath, source, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CardFile{
|
||||||
|
ID: id,
|
||||||
|
CardID: cardID,
|
||||||
|
UploaderID: uploaderID,
|
||||||
|
Filename: filename,
|
||||||
|
MIME: mimeType,
|
||||||
|
Size: size,
|
||||||
|
Source: source,
|
||||||
|
URL: "/api/files/" + id,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListCardFiles(cardID string) ([]CardFile, error) {
|
||||||
|
rows, err := db.conn.Query(
|
||||||
|
`SELECT id, card_id, uploader_id, filename, mime, size, source, created_at
|
||||||
|
FROM card_files
|
||||||
|
WHERE card_id = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
cardID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []CardFile{}
|
||||||
|
for rows.Next() {
|
||||||
|
var f CardFile
|
||||||
|
if err := rows.Scan(&f.ID, &f.CardID, &f.UploaderID, &f.Filename, &f.MIME, &f.Size, &f.Source, &f.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.URL = "/api/files/" + f.ID
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedCardFile struct {
|
||||||
|
ID string
|
||||||
|
CardID string
|
||||||
|
Filename string
|
||||||
|
MIME string
|
||||||
|
Size int64
|
||||||
|
StoredPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetCardFile(id string) (*storedCardFile, error) {
|
||||||
|
var f storedCardFile
|
||||||
|
err := db.conn.QueryRow(
|
||||||
|
`SELECT id, card_id, filename, mime, size, stored_path
|
||||||
|
FROM card_files
|
||||||
|
WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
id,
|
||||||
|
).Scan(&f.ID, &f.CardID, &f.Filename, &f.MIME, &f.Size, &f.StoredPath)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SoftDeleteCardFile(id string) (int64, error) {
|
||||||
|
res, err := db.conn.Exec(
|
||||||
|
`UPDATE card_files SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
nowRFC3339(), id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadsDir(workdir string) string {
|
||||||
|
return filepath.Join(workdir, uploadsSubdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeFilename(name string) string {
|
||||||
|
name = filepath.Base(name)
|
||||||
|
name = strings.ReplaceAll(name, string(os.PathSeparator), "_")
|
||||||
|
name = strings.ReplaceAll(name, "/", "_")
|
||||||
|
name = strings.ReplaceAll(name, "\\", "_")
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || name == "." || name == ".." {
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomFilePrefix() string {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/cards/{id}/files (multipart, field "file", optional "source")
|
||||||
|
func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cardID := r.PathValue("id")
|
||||||
|
if cardID == "" {
|
||||||
|
badRequest(w, "card id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20)
|
||||||
|
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
|
||||||
|
badRequest(w, "multipart parse: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
badRequest(w, "missing 'file' field: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if header.Size > maxUploadBytes {
|
||||||
|
infra.HTTPErrorResponse(w, infra.HTTPError{
|
||||||
|
Status: http.StatusRequestEntityTooLarge,
|
||||||
|
Code: "file_too_large",
|
||||||
|
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source := r.FormValue("source")
|
||||||
|
switch source {
|
||||||
|
case "", "upload":
|
||||||
|
source = "upload"
|
||||||
|
case "description", "chat":
|
||||||
|
// keep
|
||||||
|
default:
|
||||||
|
source = "upload"
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(uploadsDir(workdir), cardID)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
serverError(w, fmt.Errorf("mkdir uploads: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fname := safeFilename(header.Filename)
|
||||||
|
storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname)
|
||||||
|
|
||||||
|
out, err := os.Create(storedPath)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, fmt.Errorf("create file: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
written, copyErr := io.Copy(out, file)
|
||||||
|
closeErr := out.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
os.Remove(storedPath)
|
||||||
|
serverError(w, fmt.Errorf("write file: %w", copyErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(storedPath)
|
||||||
|
serverError(w, fmt.Errorf("close file: %w", closeErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if written > maxUploadBytes {
|
||||||
|
os.Remove(storedPath)
|
||||||
|
infra.HTTPErrorResponse(w, infra.HTTPError{
|
||||||
|
Status: http.StatusRequestEntityTooLarge,
|
||||||
|
Code: "file_too_large",
|
||||||
|
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := header.Header.Get("Content-Type")
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = mime.TypeByExtension(filepath.Ext(fname))
|
||||||
|
}
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||||
|
|
||||||
|
cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(storedPath)
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
infra.HTTPJSONResponse(w, http.StatusCreated, cf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/cards/{id}/files
|
||||||
|
func handleListCardFiles(db *DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cardID := r.PathValue("id")
|
||||||
|
if cardID == "" {
|
||||||
|
badRequest(w, "card id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files, err := db.ListCardFiles(cardID)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
infra.HTTPJSONResponse(w, http.StatusOK, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/files/{id}
|
||||||
|
func handleServeFile(db *DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
badRequest(w, "file id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := db.GetCardFile(id)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
notFound(w, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fh, err := os.Open(f.StoredPath)
|
||||||
|
if err != nil {
|
||||||
|
notFound(w, "file missing on disk")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
if f.MIME != "" {
|
||||||
|
w.Header().Set("Content-Type", f.MIME)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||||
|
disposition := "inline"
|
||||||
|
if !isInlineMIME(f.MIME) {
|
||||||
|
disposition = "attachment"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizeHeaderFilename(f.Filename)))
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||||
|
_, _ = io.Copy(w, fh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/files/{id}
|
||||||
|
func handleDeleteCardFile(db *DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
badRequest(w, "file id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := db.SoftDeleteCardFile(id)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
notFound(w, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInlineMIME(m string) bool {
|
||||||
|
if m == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m = strings.ToLower(m)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(m, "image/"):
|
||||||
|
return true
|
||||||
|
case m == "application/pdf":
|
||||||
|
return true
|
||||||
|
case strings.HasPrefix(m, "text/"):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeHeaderFilename(name string) string {
|
||||||
|
name = strings.ReplaceAll(name, `"`, "")
|
||||||
|
name = strings.ReplaceAll(name, "\n", "")
|
||||||
|
name = strings.ReplaceAll(name, "\r", "")
|
||||||
|
return name
|
||||||
|
}
|
||||||
+33
-11
@@ -2,20 +2,31 @@ module kanban
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require fn-registry v0.0.0-00010101000000-000000000000
|
require (
|
||||||
|
fn-registry v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
|
golang.org/x/crypto v0.51.0
|
||||||
|
nhooyr.io/websocket v1.8.17
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/go-faster/city v1.0.1 // indirect
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
github.com/go-faster/errors v0.7.1 // indirect
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||||
@@ -23,27 +34,38 @@ require (
|
|||||||
github.com/klauspost/compress v1.18.3 // indirect
|
github.com/klauspost/compress v1.18.3 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/paulmach/orb v0.12.0 // indirect
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||||
|
github.com/rs/zerolog v1.35.1 // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
|
github.com/tidwall/gjson v1.19.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
|
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.mau.fi/util v0.9.9 // indirect
|
||||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.50.0 // indirect
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
nhooyr.io/websocket v1.8.17 // indirect
|
maunium.net/go/mautrix v0.28.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace fn-registry => ../../..
|
replace fn-registry => ../../..
|
||||||
|
|||||||
+65
-18
@@ -1,18 +1,28 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
||||||
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
||||||
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
||||||
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||||
|
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
@@ -21,6 +31,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
|
|||||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
@@ -34,6 +46,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -60,8 +74,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||||
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||||
@@ -70,6 +90,8 @@ github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJ
|
|||||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
@@ -77,17 +99,33 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||||
|
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
@@ -96,10 +134,16 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||||
|
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
|
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
|
||||||
|
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
|
||||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
@@ -111,21 +155,21 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -138,23 +182,24 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -172,5 +217,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||||
|
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
|
||||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ func handleCreateCard(db *DB, hub *EventHub) http.HandlerFunc {
|
|||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// card.created drives outbound modules (Jira) to create the issue.
|
||||||
|
// Emitted after assignee/tags are applied so the synced issue carries
|
||||||
|
// them. board.invalidated stays for the SPA's refetch path.
|
||||||
|
hub.PublishJSON("card.created", c.ID, "", map[string]string{
|
||||||
|
"card_id": c.ID,
|
||||||
|
"column_id": body.ColumnID,
|
||||||
|
})
|
||||||
publishInvalidated(hub, c.ID, body.ColumnID)
|
publishInvalidated(hub, c.ID, body.ColumnID)
|
||||||
infra.HTTPJSONResponse(w, http.StatusCreated, c)
|
infra.HTTPJSONResponse(w, http.StatusCreated, c)
|
||||||
}
|
}
|
||||||
@@ -322,6 +329,10 @@ func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
|
|||||||
badRequest(w, "column_id required")
|
badRequest(w, "column_id required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Read the previous column BEFORE mutating so we can decide whether
|
||||||
|
// this is an actual column move (vs a same-column reorder). Outbound
|
||||||
|
// modules (Jira) only care about the former.
|
||||||
|
prevColumnID, _ := db.lookupCardColumnID(id)
|
||||||
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||||
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
|
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
@@ -331,6 +342,17 @@ func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
|
|||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Distinct event when the card crossed columns so the Jira module
|
||||||
|
// runs transition() instead of plain update(). Reorder-only goes
|
||||||
|
// straight to board.invalidated (frontend refetch) without a Jira
|
||||||
|
// roundtrip.
|
||||||
|
if prevColumnID != "" && prevColumnID != body.ColumnID {
|
||||||
|
hub.PublishJSON("card.moved", id, "", map[string]string{
|
||||||
|
"card_id": id,
|
||||||
|
"from_column_id": prevColumnID,
|
||||||
|
"to_column_id": body.ColumnID,
|
||||||
|
})
|
||||||
|
}
|
||||||
publishInvalidated(hub, id, body.ColumnID)
|
publishInvalidated(hub, id, body.ColumnID)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -664,21 +686,37 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
|||||||
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
|
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
|
||||||
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
||||||
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
|
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
|
||||||
|
// Issue 0128: adjuntos de archivos.
|
||||||
|
{Method: "POST", Path: "/api/cards/{id}/files", Handler: handleUploadCardFile(db, chatWorkdir)},
|
||||||
|
{Method: "GET", Path: "/api/cards/{id}/files", Handler: handleListCardFiles(db)},
|
||||||
|
{Method: "GET", Path: "/api/files/{id}", Handler: handleServeFile(db)},
|
||||||
|
{Method: "DELETE", Path: "/api/files/{id}", Handler: handleDeleteCardFile(db)},
|
||||||
|
// Notifications + realtime (issue notifications-realtime).
|
||||||
{Method: "GET", Path: "/api/events", Handler: handleEventStream(hub)},
|
{Method: "GET", Path: "/api/events", Handler: handleEventStream(hub)},
|
||||||
{Method: "GET", Path: "/api/cards/{id}/chat/ws", Handler: handleCardChatWS(db, hub)},
|
{Method: "GET", Path: "/api/cards/{id}/chat/ws", Handler: handleCardChatWS(db, hub)},
|
||||||
{Method: "GET", Path: "/api/notifications", Handler: handleListNotifications(db)},
|
{Method: "GET", Path: "/api/notifications", Handler: handleListNotifications(db)},
|
||||||
{Method: "GET", Path: "/api/notifications/unread-count", Handler: handleUnreadCount(db)},
|
{Method: "GET", Path: "/api/notifications/unread-count", Handler: handleUnreadCount(db)},
|
||||||
{Method: "POST", Path: "/api/notifications/{id}/read", Handler: handleMarkNotificationRead(db, hub)},
|
{Method: "POST", Path: "/api/notifications/{id}/read", Handler: handleMarkNotificationRead(db, hub)},
|
||||||
{Method: "POST", Path: "/api/notifications/read-all", Handler: handleMarkAllNotificationsRead(db, hub)},
|
{Method: "POST", Path: "/api/notifications/read-all", Handler: handleMarkAllNotificationsRead(db, hub)},
|
||||||
|
// MCP per-user tokens.
|
||||||
{Method: "POST", Path: "/api/mcp-tokens", Handler: handleCreateMCPToken(db)},
|
{Method: "POST", Path: "/api/mcp-tokens", Handler: handleCreateMCPToken(db)},
|
||||||
{Method: "GET", Path: "/api/mcp-tokens", Handler: handleListMCPTokens(db)},
|
{Method: "GET", Path: "/api/mcp-tokens", Handler: handleListMCPTokens(db)},
|
||||||
{Method: "DELETE", Path: "/api/mcp-tokens/{id}", Handler: handleRevokeMCPToken(db)},
|
{Method: "DELETE", Path: "/api/mcp-tokens/{id}", Handler: handleRevokeMCPToken(db)},
|
||||||
|
// Modules: external integrations (Jira, ...).
|
||||||
{Method: "GET", Path: "/api/modules", Handler: handleListModules(db)},
|
{Method: "GET", Path: "/api/modules", Handler: handleListModules(db)},
|
||||||
{Method: "POST", Path: "/api/modules", Handler: handleCreateModule(db)},
|
{Method: "POST", Path: "/api/modules", Handler: handleCreateModule(db)},
|
||||||
{Method: "PATCH", Path: "/api/modules/{id}", Handler: handleUpdateModule(db)},
|
{Method: "PATCH", Path: "/api/modules/{id}", Handler: handleUpdateModule(db)},
|
||||||
{Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)},
|
{Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)},
|
||||||
{Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)},
|
{Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)},
|
||||||
{Method: "POST", Path: "/api/modules/{id}/test", Handler: handleTestModule(db, dispatcher)},
|
{Method: "POST", Path: "/api/modules/{id}/test", Handler: handleTestModule(db, dispatcher)},
|
||||||
|
// Per-card Jira sync state (indicator + tooltip).
|
||||||
|
{Method: "GET", Path: "/api/cards/{id}/jira-sync", Handler: handleCardJiraSync(db, dispatcher)},
|
||||||
|
// Jira import: list issues not yet in kanban + bulk import.
|
||||||
|
{Method: "GET", Path: "/api/jira/issues", Handler: handleListJiraIssues(db)},
|
||||||
|
{Method: "POST", Path: "/api/jira/import", Handler: handleImportJiraIssues(db)},
|
||||||
|
// Jira column-sync check: detect drift between kanban col ↔ Jira status.
|
||||||
|
{Method: "GET", Path: "/api/jira/check-columns", Handler: handleCheckJiraColumns(db)},
|
||||||
|
{Method: "POST", Path: "/api/jira/reconcile-columns", Handler: handleReconcileJiraColumns(db)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 @@
|
|||||||
|
-- Issue 0128: adjuntos de archivos por card.
|
||||||
|
CREATE TABLE IF NOT EXISTS card_files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
card_id TEXT NOT NULL,
|
||||||
|
uploader_id TEXT NOT NULL DEFAULT '',
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL DEFAULT '',
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stored_path TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'upload',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_files_card_active
|
||||||
|
ON card_files(card_id, deleted_at);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Per-card Jira sync state. Populated by the dispatcher after every push to
|
||||||
|
-- Jira so the frontend can render an indicator (gray/yellow/green) and a
|
||||||
|
-- tooltip with the last known status without polling Jira itself.
|
||||||
|
--
|
||||||
|
-- jira_last_status: the Jira status name the card was transitioned to in the
|
||||||
|
-- most recent successful sync (e.g. "In Progress", "Done").
|
||||||
|
-- jira_last_sync_at: RFC3339 timestamp of the last sync attempt (success or
|
||||||
|
-- failure).
|
||||||
|
-- jira_last_error: the error message from the last failed sync, or empty when
|
||||||
|
-- the last sync succeeded.
|
||||||
|
ALTER TABLE cards ADD COLUMN jira_last_status TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE cards ADD COLUMN jira_last_sync_at TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE cards ADD COLUMN jira_last_error TEXT NOT NULL DEFAULT '';
|
||||||
+353
-18
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ func TestExecuteTool_MoveCard_BetweenColumns_OpensHistory(t *testing.T) {
|
|||||||
|
|
||||||
histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
|
histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
|
||||||
mustOK(t, histRes)
|
mustOK(t, histRes)
|
||||||
hist := histRes.Result.([]HistoryEntry)
|
hist := histRes.Result.(*CardHistoryResponse).ColumnHistory
|
||||||
if len(hist) != 2 {
|
if len(hist) != 2 {
|
||||||
t.Fatalf("expected 2 history entries, got %d", len(hist))
|
t.Fatalf("expected 2 history entries, got %d", len(hist))
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ func TestExecuteTool_CardHistory_Single(t *testing.T) {
|
|||||||
|
|
||||||
res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
|
res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
|
||||||
mustOK(t, res)
|
mustOK(t, res)
|
||||||
hist := res.Result.([]HistoryEntry)
|
hist := res.Result.(*CardHistoryResponse).ColumnHistory
|
||||||
if len(hist) != 1 || hist[0].ExitedAt != nil {
|
if len(hist) != 1 || hist[0].ExitedAt != nil {
|
||||||
t.Fatalf("expected 1 open history entry, got %+v", hist)
|
t.Fatalf("expected 1 open history entry, got %+v", hist)
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Issue 0128 — smoke test of card file attachments.
|
||||||
|
# Builds kanban (assumes ./kanban present or builds it), boots on ephemeral port,
|
||||||
|
# exercises POST upload, GET list, GET serve, DELETE, GET list-after-delete.
|
||||||
|
# Exits 0 on success, non-zero on any failure.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT=${PORT:-18095}
|
||||||
|
BASE="http://127.0.0.1:${PORT}"
|
||||||
|
DB=$(mktemp /tmp/kanban_files_smoke.XXXXXX.db)
|
||||||
|
COOKIE=$(mktemp /tmp/kanban_files_smoke.cookie.XXXXXX)
|
||||||
|
UPLOAD_DIR=$(dirname "$DB")/uploads
|
||||||
|
PNG=$(mktemp /tmp/kanban_files_smoke.XXXXXX.png)
|
||||||
|
PID_FILE=$(mktemp /tmp/kanban_files_smoke.pid.XXXXXX)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -s "$PID_FILE" ]; then
|
||||||
|
kill "$(cat "$PID_FILE")" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$DB" "$DB-shm" "$DB-wal" "$COOKIE" "$PNG" "$PID_FILE"
|
||||||
|
rm -rf "$UPLOAD_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Build if missing.
|
||||||
|
if [ ! -x ./kanban ]; then
|
||||||
|
echo "[smoke] building kanban binary..."
|
||||||
|
(cd backend && CGO_ENABLED=1 go build -tags fts5 -o ../kanban .)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Boot.
|
||||||
|
./kanban --port "$PORT" --db "$DB" --initial-admin admin:adminpw \
|
||||||
|
> /tmp/kanban_files_smoke.log 2>&1 &
|
||||||
|
echo $! > "$PID_FILE"
|
||||||
|
|
||||||
|
# Wait for /api/board.
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -sf -o /dev/null "$BASE/api/board"; then break; fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Login.
|
||||||
|
curl -sf -c "$COOKIE" -X POST "$BASE/api/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"adminpw"}' > /dev/null
|
||||||
|
|
||||||
|
# Column + card.
|
||||||
|
COL=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/columns" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"To Do"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||||
|
CARD=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"column_id\":\"$COL\",\"title\":\"smoke\",\"requester\":\"r\"}" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||||
|
|
||||||
|
# Minimal PNG.
|
||||||
|
printf '\x89PNG\r\n\x1a\n' > "$PNG"
|
||||||
|
|
||||||
|
# Upload.
|
||||||
|
UP=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards/$CARD/files" \
|
||||||
|
-F "file=@$PNG;type=image/png")
|
||||||
|
FID=$(echo "$UP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||||
|
[ -n "$FID" ] || { echo "[smoke] upload missing id"; exit 1; }
|
||||||
|
|
||||||
|
# List active.
|
||||||
|
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
|
||||||
|
[ "$N" = "1" ] || { echo "[smoke] expected 1 file, got $N"; exit 1; }
|
||||||
|
|
||||||
|
# Serve.
|
||||||
|
CT=$(curl -sf -b "$COOKIE" -I "$BASE/api/files/$FID" | awk '/^[Cc]ontent-[Tt]ype/ {print $2}' | tr -d '\r\n')
|
||||||
|
echo "$CT" | grep -q image/png || { echo "[smoke] wrong content-type: $CT"; exit 1; }
|
||||||
|
|
||||||
|
# Delete.
|
||||||
|
HTTP=$(curl -sb "$COOKIE" -X DELETE -o /dev/null -w "%{http_code}" "$BASE/api/files/$FID")
|
||||||
|
[ "$HTTP" = "204" ] || { echo "[smoke] expected 204 on delete, got $HTTP"; exit 1; }
|
||||||
|
|
||||||
|
# List after delete.
|
||||||
|
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
|
||||||
|
[ "$N" = "0" ] || { echo "[smoke] expected 0 after delete, got $N"; exit 1; }
|
||||||
|
|
||||||
|
echo "[smoke] OK"
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Board,
|
Board,
|
||||||
Card,
|
Card,
|
||||||
|
CardFile,
|
||||||
CardHistoryResponse,
|
CardHistoryResponse,
|
||||||
CardMessage,
|
CardMessage,
|
||||||
Column,
|
Column,
|
||||||
@@ -443,6 +444,44 @@ export function listRequesters(): Promise<string[]> {
|
|||||||
return fetchJSON("/requesters");
|
return fetchJSON("/requesters");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Files (issue 0128) -----------------------------------------------------
|
||||||
|
|
||||||
|
export function listCardFiles(cardId: string): Promise<CardFile[]> {
|
||||||
|
return fetchJSON(`/cards/${cardId}/files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCardFile(
|
||||||
|
cardId: string,
|
||||||
|
file: File,
|
||||||
|
source: "upload" | "description" | "chat" = "upload"
|
||||||
|
): Promise<CardFile> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("source", source);
|
||||||
|
const res = await fetch(`${BASE}/cards/${cardId}/files`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `upload failed: ${res.status}`;
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { Message?: string; message?: string };
|
||||||
|
if (body.Message || body.message) msg = body.Message || body.message || msg;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new HTTPError(res.status, msg);
|
||||||
|
}
|
||||||
|
return (await res.json()) as CardFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCardFile(fileId: string): Promise<void> {
|
||||||
|
return fetchJSON(`/files/${fileId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MCP per-user tokens ----------------------------------------------------
|
||||||
|
|
||||||
export interface MCPToken {
|
export interface MCPToken {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -466,6 +505,108 @@ export function revokeMCPToken(id: string): Promise<void> {
|
|||||||
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
|
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Jira sync state + import ----------------------------------------------
|
||||||
|
|
||||||
|
export interface CardJiraSyncState {
|
||||||
|
card_id: string;
|
||||||
|
jira_key: string;
|
||||||
|
last_status: string;
|
||||||
|
last_sync_at: string;
|
||||||
|
last_error: string;
|
||||||
|
inflight: boolean;
|
||||||
|
issue_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCardJiraSync(cardId: string): Promise<CardJiraSyncState> {
|
||||||
|
return fetchJSON(`/cards/${cardId}/jira-sync`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraIssue {
|
||||||
|
key: string;
|
||||||
|
summary: string;
|
||||||
|
status_name: string;
|
||||||
|
issue_type: string;
|
||||||
|
assignee: string;
|
||||||
|
updated: string;
|
||||||
|
url: string;
|
||||||
|
already_imported: boolean;
|
||||||
|
mapped_column_id?: string;
|
||||||
|
issue_type_icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListJiraIssuesResponse {
|
||||||
|
issues: JiraIssue[];
|
||||||
|
board_id: number;
|
||||||
|
project_key: string;
|
||||||
|
status_to_column: Record<string, string>;
|
||||||
|
include_imported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listJiraIssues(opts?: { includeImported?: boolean; limit?: number }): Promise<ListJiraIssuesResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (opts?.includeImported) qs.set("include_imported", "true");
|
||||||
|
if (opts?.limit) qs.set("limit", String(opts.limit));
|
||||||
|
const q = qs.toString();
|
||||||
|
return fetchJSON(`/jira/issues${q ? `?${q}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraImportResult {
|
||||||
|
key: string;
|
||||||
|
status: "imported" | "skipped" | "error";
|
||||||
|
card_id?: string;
|
||||||
|
column_id?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importJiraIssues(issueKeys: string[], fallbackColumnId?: string): Promise<{ results: JiraImportResult[] }> {
|
||||||
|
return fetchJSON("/jira/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ issue_keys: issueKeys, fallback_column_id: fallbackColumnId || "" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraCheckRow {
|
||||||
|
card_id: string;
|
||||||
|
jira_key: string;
|
||||||
|
title: string;
|
||||||
|
kanban_column_id: string;
|
||||||
|
kanban_column_name: string;
|
||||||
|
jira_status_name: string;
|
||||||
|
expected_kanban_col: string;
|
||||||
|
expected_jira_status: string;
|
||||||
|
mismatch: boolean;
|
||||||
|
issue_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraCheckResponse {
|
||||||
|
rows: JiraCheckRow[];
|
||||||
|
total: number;
|
||||||
|
mismatches: number;
|
||||||
|
in_sync: number;
|
||||||
|
status_map: Record<string, string>;
|
||||||
|
reverse_map: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkJiraColumns(): Promise<JiraCheckResponse> {
|
||||||
|
return fetchJSON("/jira/check-columns");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraReconcileResult {
|
||||||
|
card_id: string;
|
||||||
|
status: "fixed" | "skipped" | "error";
|
||||||
|
jira_key?: string;
|
||||||
|
jira_status?: string;
|
||||||
|
error?: string;
|
||||||
|
http?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileJiraColumns(cardIds: string[]): Promise<{ results: JiraReconcileResult[] }> {
|
||||||
|
return fetchJSON("/jira/reconcile-columns", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ card_ids: cardIds, direction: "kanban-wins" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (f.from) qs.set("from", f.from);
|
if (f.from) qs.set("from", f.from);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Combobox,
|
Combobox,
|
||||||
|
FileButton,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -14,11 +14,11 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
useCombobox,
|
useCombobox,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconSend, IconTrash } from "@tabler/icons-react";
|
import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
|
DragEvent,
|
||||||
KeyboardEvent,
|
KeyboardEvent,
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -29,17 +29,24 @@ import * as api from "../api";
|
|||||||
import type { CardMessage, User } from "../types";
|
import type { CardMessage, User } from "../types";
|
||||||
import { tagColor } from "./colors";
|
import { tagColor } from "./colors";
|
||||||
import { formatDateTimeShort } from "./format";
|
import { formatDateTimeShort } from "./format";
|
||||||
|
import { MessageBody } from "./MessageBody";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
users: User[];
|
users: User[];
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
onMessagesChange?: (messages: CardMessage[]) => void;
|
onMessagesChange?: (messages: CardMessage[]) => void;
|
||||||
|
onFileUploaded?: () => void;
|
||||||
// When set, the panel scrolls the matching message into view and flashes a
|
// When set, the panel scrolls the matching message into view and flashes a
|
||||||
// brief highlight (~2s). Used by notification click → open card.
|
// brief highlight (~2s). Used by notification click → open card.
|
||||||
highlightMessageId?: string;
|
highlightMessageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refForFile(filename: string, url: string, mime: string): string {
|
||||||
|
const safe = filename.replace(/]/g, "");
|
||||||
|
return mime.startsWith("image/") ? `` : `[${safe}](${url})`;
|
||||||
|
}
|
||||||
|
|
||||||
// Window for considering a peer "actively typing" after its last event.
|
// Window for considering a peer "actively typing" after its last event.
|
||||||
const TYPING_LIFETIME_MS = 4000;
|
const TYPING_LIFETIME_MS = 4000;
|
||||||
// Minimum gap between successive typing pings emitted while the user types.
|
// Minimum gap between successive typing pings emitted while the user types.
|
||||||
@@ -69,43 +76,20 @@ function detectMention(value: string, cursor: number): MentionMatch | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionRegex = /(^|\s)(@[a-z0-9][a-z0-9_.-]{0,63})/gi;
|
export function CardChatPanel({
|
||||||
|
cardId,
|
||||||
function renderBody(body: string, knownUsers: Map<string, User>): ReactNode {
|
users,
|
||||||
const out: ReactNode[] = [];
|
currentUserId,
|
||||||
let last = 0;
|
onMessagesChange,
|
||||||
let key = 0;
|
onFileUploaded,
|
||||||
for (const m of body.matchAll(mentionRegex)) {
|
highlightMessageId,
|
||||||
const handle = m[2].slice(1).toLowerCase();
|
}: Props) {
|
||||||
const idx = (m.index ?? 0) + m[1].length;
|
|
||||||
if (idx > last) out.push(body.slice(last, idx));
|
|
||||||
const user = knownUsers.get(handle);
|
|
||||||
if (user) {
|
|
||||||
out.push(
|
|
||||||
<Badge
|
|
||||||
key={`m${key++}`}
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color={user.color || tagColor(user.username)}
|
|
||||||
style={{ verticalAlign: "middle" }}
|
|
||||||
>
|
|
||||||
@{user.username}
|
|
||||||
</Badge>,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
out.push(`@${handle}`);
|
|
||||||
}
|
|
||||||
last = idx + m[2].length;
|
|
||||||
}
|
|
||||||
if (last < body.length) out.push(body.slice(last));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange, highlightMessageId }: Props) {
|
|
||||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [typingUsers, setTypingUsers] = useState<Record<string, number>>({});
|
const [typingUsers, setTypingUsers] = useState<Record<string, number>>({});
|
||||||
const [mention, setMention] = useState<MentionMatch | null>(null);
|
const [mention, setMention] = useState<MentionMatch | null>(null);
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -114,7 +98,6 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
|
|||||||
const lastTypingEmitRef = useRef(0);
|
const lastTypingEmitRef = useRef(0);
|
||||||
|
|
||||||
const usersById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
const usersById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||||
const usersByUsername = useMemo(() => new Map(users.map((u) => [u.username.toLowerCase(), u])), [users]);
|
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -311,6 +294,47 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFiles = async (files: FileList | File[]) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
const cf = await api.uploadCardFile(cardId, file, "chat");
|
||||||
|
const ref = refForFile(cf.filename, cf.url, cf.mime);
|
||||||
|
const m = await api.createCardMessage(cardId, ref);
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev, m];
|
||||||
|
onMessagesChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
onFileUploaded?.();
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
const typingNames = Object.keys(typingUsers)
|
const typingNames = Object.keys(typingUsers)
|
||||||
.filter((uid) => uid !== currentUserId)
|
.filter((uid) => uid !== currentUserId)
|
||||||
.map((uid) => {
|
.map((uid) => {
|
||||||
@@ -319,7 +343,20 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" style={{ height: "100%", minHeight: 0 }}>
|
<Stack
|
||||||
|
gap="xs"
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
position: "relative",
|
||||||
|
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
|
||||||
|
outlineOffset: dragOver ? -2 : undefined,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
viewportRef={viewportRef}
|
viewportRef={viewportRef}
|
||||||
style={{ flex: 1, minHeight: 200 }}
|
style={{ flex: 1, minHeight: 200 }}
|
||||||
@@ -330,7 +367,7 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
|
|||||||
<Group justify="center" p="md"><Loader size="sm" /></Group>
|
<Group justify="center" p="md"><Loader size="sm" /></Group>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||||
Sin mensajes aun. Escribe el primero.
|
Sin mensajes aun. Escribe el primero o arrastra un archivo.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={6} p={4}>
|
<Stack gap={6} p={4}>
|
||||||
@@ -376,9 +413,9 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
<Stack gap={4}>
|
||||||
{renderBody(m.body, usersByUsername)}
|
<MessageBody text={m.body} />
|
||||||
</Text>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -407,13 +444,29 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
|
|||||||
value={body}
|
value={body}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Escribe un mensaje (Enter = enviar, @ para mencionar)"
|
placeholder="Escribe un mensaje (Enter = enviar, @ para mencionar). Arrastra archivos o usa el clip."
|
||||||
autosize
|
autosize
|
||||||
minRows={1}
|
minRows={1}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
<FileButton onChange={(file) => file && handleFiles([file])} disabled={uploading}>
|
||||||
|
{(props) => (
|
||||||
|
<Tooltip label="Adjuntar archivo" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Adjuntar"
|
||||||
|
loading={uploading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<IconPaperclip size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
<Tooltip label="Enviar" withArrow>
|
<Tooltip label="Enviar" withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -446,6 +499,24 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange,
|
|||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Combobox.Dropdown>
|
</Combobox.Dropdown>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
{(dragOver || uploading) && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(34,139,230,0.08)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
pointerEvents: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={500} c="blue">
|
||||||
|
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Box, Divider, Group, Tabs, Text } from "@mantine/core";
|
import { Box, Divider, Group, Tabs } from "@mantine/core";
|
||||||
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
|
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Card, CardMessage, User } from "../types";
|
import type { Card, CardMessage, User } from "../types";
|
||||||
import { CardChatPanel } from "./CardChatPanel";
|
import { CardChatPanel } from "./CardChatPanel";
|
||||||
|
import { CardFilesPanel } from "./CardFilesPanel";
|
||||||
import { CardLinksPanel } from "./CardLinksPanel";
|
import { CardLinksPanel } from "./CardLinksPanel";
|
||||||
import { CardForm, CardFormValues } from "./CardForm";
|
import { CardForm, CardFormValues } from "./CardForm";
|
||||||
|
|
||||||
@@ -31,12 +32,15 @@ export function CardEditPanel({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||||
const [liveCard, setLiveCard] = useState(card);
|
const [liveCard, setLiveCard] = useState(card);
|
||||||
|
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
|
||||||
|
|
||||||
const wrappedSubmit = async (v: CardFormValues) => {
|
const wrappedSubmit = async (v: CardFormValues) => {
|
||||||
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
|
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
|
||||||
await onSubmit(v);
|
await onSubmit(v);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
||||||
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
||||||
@@ -52,6 +56,8 @@ export function CardEditPanel({
|
|||||||
tags: liveCard.tags || [],
|
tags: liveCard.tags || [],
|
||||||
}}
|
}}
|
||||||
submitLabel="Guardar"
|
submitLabel="Guardar"
|
||||||
|
cardId={liveCard.id}
|
||||||
|
onFileUploaded={bumpFiles}
|
||||||
onSubmit={wrappedSubmit}
|
onSubmit={wrappedSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
@@ -62,7 +68,7 @@ export function CardEditPanel({
|
|||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
|
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
|
||||||
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
|
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
|
||||||
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />} disabled>Archivos</Tabs.Tab>
|
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />}>Archivos</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||||
@@ -72,6 +78,7 @@ export function CardEditPanel({
|
|||||||
users={users}
|
users={users}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onMessagesChange={setMessages}
|
onMessagesChange={setMessages}
|
||||||
|
onFileUploaded={bumpFiles}
|
||||||
highlightMessageId={highlightMessageId}
|
highlightMessageId={highlightMessageId}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -80,9 +87,7 @@ export function CardEditPanel({
|
|||||||
<CardLinksPanel card={liveCard} messages={messages} />
|
<CardLinksPanel card={liveCard} messages={messages} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="files">
|
<Tabs.Panel value="files">
|
||||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
<CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
|
||||||
Proximamente: adjuntos de archivos.
|
|
||||||
</Text>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Box>
|
</Box>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
IconDownload,
|
||||||
|
IconFile,
|
||||||
|
IconFileSpreadsheet,
|
||||||
|
IconFileText,
|
||||||
|
IconFileTypePdf,
|
||||||
|
IconPhoto,
|
||||||
|
IconTrash,
|
||||||
|
IconUpload,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import * as api from "../api";
|
||||||
|
import type { CardFile } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cardId: string;
|
||||||
|
refreshKey?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(mime: string): boolean {
|
||||||
|
return mime.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(mime: string, size = 18) {
|
||||||
|
const m = mime.toLowerCase();
|
||||||
|
if (m.startsWith("image/")) return <IconPhoto size={size} />;
|
||||||
|
if (m === "application/pdf") return <IconFileTypePdf size={size} />;
|
||||||
|
if (
|
||||||
|
m.includes("spreadsheet") ||
|
||||||
|
m.includes("excel") ||
|
||||||
|
m === "text/csv" ||
|
||||||
|
m === "application/vnd.ms-excel"
|
||||||
|
) {
|
||||||
|
return <IconFileSpreadsheet size={size} />;
|
||||||
|
}
|
||||||
|
if (m.startsWith("text/")) return <IconFileText size={size} />;
|
||||||
|
return <IconFile size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceBadge(s: CardFile["source"]) {
|
||||||
|
if (s === "description") return { color: "blue", label: "descripcion" };
|
||||||
|
if (s === "chat") return { color: "teal", label: "chat" };
|
||||||
|
return { color: "gray", label: "subido" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFilesPanel({ cardId, refreshKey }: Props) {
|
||||||
|
const [files, setFiles] = useState<CardFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.listCardFiles(cardId);
|
||||||
|
setFiles(list);
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [cardId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload, refreshKey]);
|
||||||
|
|
||||||
|
const onUpload = async (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const cf = await api.uploadCardFile(cardId, file, "upload");
|
||||||
|
setFiles((prev) => [...prev, cf]);
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (id: string) => {
|
||||||
|
if (!window.confirm("¿Borrar este archivo?")) return;
|
||||||
|
try {
|
||||||
|
await api.deleteCardFile(id);
|
||||||
|
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" p={4}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{files.length} archivo{files.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
<FileButton onChange={onUpload} disabled={uploading}>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconUpload size={14} />}
|
||||||
|
loading={uploading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
Subir
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" p="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 160 }}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Sin archivos
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
|
Sube archivos con el boton, arrastra al chat o a la descripcion.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap={6}>
|
||||||
|
{files.map((f) => {
|
||||||
|
const badge = sourceBadge(f.source);
|
||||||
|
return (
|
||||||
|
<Paper key={f.id} withBorder p="xs" radius="sm">
|
||||||
|
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||||
|
{isImage(f.mime) ? (
|
||||||
|
<Anchor href={f.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Image
|
||||||
|
src={f.url}
|
||||||
|
alt={f.filename}
|
||||||
|
w={64}
|
||||||
|
h={64}
|
||||||
|
fit="cover"
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileIcon(f.mime, 28)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Anchor href={f.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
|
||||||
|
{f.filename}
|
||||||
|
</Anchor>
|
||||||
|
<Group gap={6} mt={4}>
|
||||||
|
<Badge size="xs" variant="light" color={badge.color}>
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">{formatSize(f.size)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{f.mime || "?"}</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Tooltip label="Descargar" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={f.url}
|
||||||
|
download={f.filename}
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Descargar"
|
||||||
|
>
|
||||||
|
<IconDownload size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Borrar" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(f.id)}
|
||||||
|
aria-label="Borrar"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
|
import { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
|
||||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
|
||||||
|
import * as api from "../api";
|
||||||
import type { User } from "../types";
|
import type { User } from "../types";
|
||||||
|
|
||||||
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
|
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
|
||||||
@@ -21,16 +23,25 @@ interface Props {
|
|||||||
users?: User[];
|
users?: User[];
|
||||||
requesterOptions?: string[];
|
requesterOptions?: string[];
|
||||||
tagOptions?: string[];
|
tagOptions?: string[];
|
||||||
|
cardId?: string;
|
||||||
|
onFileUploaded?: () => void;
|
||||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdownRef(filename: string, url: string, isImage: boolean): string {
|
||||||
|
const safeName = filename.replace(/]/g, "");
|
||||||
|
return isImage ? `` : `[${safeName}](${url})`;
|
||||||
|
}
|
||||||
|
|
||||||
export function CardForm({
|
export function CardForm({
|
||||||
initial,
|
initial,
|
||||||
submitLabel = "Guardar",
|
submitLabel = "Guardar",
|
||||||
users = [],
|
users = [],
|
||||||
requesterOptions = [],
|
requesterOptions = [],
|
||||||
tagOptions = [],
|
tagOptions = [],
|
||||||
|
cardId,
|
||||||
|
onFileUploaded,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -39,6 +50,9 @@ export function CardForm({
|
|||||||
const [description, setDescription] = useState(initial?.description ?? "");
|
const [description, setDescription] = useState(initial?.description ?? "");
|
||||||
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
||||||
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const submit = async (e?: FormEvent) => {
|
const submit = async (e?: FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
@@ -60,6 +74,66 @@ export function CardForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const insertAtCursor = (snippet: string) => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) {
|
||||||
|
setDescription((d) => (d ? d + "\n" + snippet : snippet));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = ta.selectionStart ?? description.length;
|
||||||
|
const end = ta.selectionEnd ?? description.length;
|
||||||
|
const before = description.slice(0, start);
|
||||||
|
const after = description.slice(end);
|
||||||
|
const sep = before && !before.endsWith("\n") ? "\n" : "";
|
||||||
|
const next = before + sep + snippet + after;
|
||||||
|
setDescription(next);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ta.focus();
|
||||||
|
const pos = (before + sep + snippet).length;
|
||||||
|
ta.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFiles = async (files: FileList | File[]) => {
|
||||||
|
if (!cardId) {
|
||||||
|
notifications.show({ color: "yellow", message: "Guarda la tarjeta antes de subir archivos." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
const cf = await api.uploadCardFile(cardId, file, "description");
|
||||||
|
insertAtCursor(markdownRef(cf.filename, cf.url, cf.mime.startsWith("image/")));
|
||||||
|
onFileUploaded?.();
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!cardId) return;
|
||||||
|
if (!e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -95,7 +169,19 @@ export function CardForm({
|
|||||||
if (e.key === "Enter") e.preventDefault();
|
if (e.key === "Enter") e.preventDefault();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Box
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
|
||||||
|
outlineOffset: dragOver ? 2 : undefined,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
label="Descripcion"
|
label="Descripcion"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||||
@@ -104,8 +190,27 @@ export function CardForm({
|
|||||||
minRows={3}
|
minRows={3}
|
||||||
maxRows={8}
|
maxRows={8}
|
||||||
onKeyDown={textareaEnter}
|
onKeyDown={textareaEnter}
|
||||||
description="Ctrl+Enter para guardar"
|
description={cardId ? "Ctrl+Enter para guardar. Arrastra archivos para adjuntar." : "Ctrl+Enter para guardar"}
|
||||||
/>
|
/>
|
||||||
|
{(dragOver || uploading) && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(34,139,230,0.08)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
pointerEvents: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={500} c="blue">
|
||||||
|
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
<Select
|
<Select
|
||||||
label="Asignar a"
|
label="Asignar a"
|
||||||
placeholder="Sin asignar"
|
placeholder="Sin asignar"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Anchor, Image, Text } from "@mantine/core";
|
||||||
|
import { Fragment, ReactNode } from "react";
|
||||||
|
|
||||||
|
// Minimal markdown renderer for chat/desc embeds (issue 0128).
|
||||||
|
// Recognises:
|
||||||
|
//  -> <Image>
|
||||||
|
// [name](url) -> <Anchor>name</Anchor>
|
||||||
|
// Everything else is plain text with preserved whitespace.
|
||||||
|
|
||||||
|
interface ImgToken { kind: "img"; alt: string; url: string }
|
||||||
|
interface LinkToken { kind: "link"; label: string; url: string }
|
||||||
|
interface TextToken { kind: "text"; value: string }
|
||||||
|
type Token = ImgToken | LinkToken | TextToken;
|
||||||
|
|
||||||
|
const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g;
|
||||||
|
|
||||||
|
// Allow only safe URL schemes. Reject javascript:, data:text/html, vbscript:, etc.
|
||||||
|
// Accepts: absolute http(s), protocol-relative //, and same-origin paths (/...).
|
||||||
|
function safeURL(url: string): string | null {
|
||||||
|
const u = url.trim();
|
||||||
|
if (u.startsWith("/")) return u;
|
||||||
|
if (/^https?:\/\//i.test(u)) return u;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// data: scheme is allowed only when the MIME prefix is image/.
|
||||||
|
function safeImageURL(url: string): string | null {
|
||||||
|
const safe = safeURL(url);
|
||||||
|
if (safe) return safe;
|
||||||
|
const u = url.trim();
|
||||||
|
if (/^data:image\/[a-z0-9.+-]+(;[a-z0-9-]+=[^,]+)*;base64,/i.test(u)) return u;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(input: string): Token[] {
|
||||||
|
const out: Token[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
TOKEN_RE.lastIndex = 0;
|
||||||
|
while ((m = TOKEN_RE.exec(input)) !== null) {
|
||||||
|
if (m.index > last) {
|
||||||
|
out.push({ kind: "text", value: input.slice(last, m.index) });
|
||||||
|
}
|
||||||
|
if (m[1]) {
|
||||||
|
const url = safeImageURL(m[3]);
|
||||||
|
if (url) {
|
||||||
|
out.push({ kind: "img", alt: m[2] || "", url });
|
||||||
|
} else {
|
||||||
|
out.push({ kind: "text", value: m[0] });
|
||||||
|
}
|
||||||
|
} else if (m[4]) {
|
||||||
|
const url = safeURL(m[6]);
|
||||||
|
if (url) {
|
||||||
|
out.push({ kind: "link", label: m[5], url });
|
||||||
|
} else {
|
||||||
|
out.push({ kind: "text", value: m[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last = TOKEN_RE.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < input.length) {
|
||||||
|
out.push({ kind: "text", value: input.slice(last) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBody({ text, size = "sm" }: Props): ReactNode {
|
||||||
|
const tokens = tokenize(text);
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineNodes: ReactNode[] = [];
|
||||||
|
const blocks: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
const flushInline = () => {
|
||||||
|
if (inlineNodes.length === 0) return;
|
||||||
|
blocks.push(
|
||||||
|
<Text
|
||||||
|
key={`t-${key++}`}
|
||||||
|
size={size}
|
||||||
|
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{inlineNodes.map((n, i) => (
|
||||||
|
<Fragment key={i}>{n}</Fragment>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
inlineNodes.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (t.kind === "img") {
|
||||||
|
flushInline();
|
||||||
|
blocks.push(
|
||||||
|
<Anchor key={`i-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Image
|
||||||
|
src={t.url}
|
||||||
|
alt={t.alt}
|
||||||
|
maw={220}
|
||||||
|
mah={220}
|
||||||
|
fit="contain"
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
} else if (t.kind === "link") {
|
||||||
|
inlineNodes.push(
|
||||||
|
<Anchor key={`l-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{t.label}
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
inlineNodes.push(t.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushInline();
|
||||||
|
|
||||||
|
return <>{blocks}</>;
|
||||||
|
}
|
||||||
@@ -46,6 +46,18 @@ export interface Card {
|
|||||||
total_locked_ms: number;
|
total_locked_ms: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CardFile {
|
||||||
|
id: string;
|
||||||
|
card_id: string;
|
||||||
|
uploader_id: string;
|
||||||
|
filename: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
source: "upload" | "description" | "chat";
|
||||||
|
url: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user