Compare commits
5 Commits
d4558667f6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 466a055f72 | |||
| a934897099 | |||
| 0687b65ea2 | |||
| 87e8f62544 | |||
| 0d8ec1e8e7 |
@@ -2,7 +2,7 @@
|
|||||||
name: kanban
|
name: kanban
|
||||||
lang: go
|
lang: go
|
||||||
domain: tools
|
domain: tools
|
||||||
version: 0.5.0
|
version: 0.5.2
|
||||||
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit), tracking del tiempo por columna, adjuntos de archivos por card, notificaciones realtime (SSE) y modulos externos (Jira). Frontend Vite + React + Mantine v9 embebido en el binario Go. Endpoint MCP Streamable HTTP en /mcp."
|
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit), tracking del tiempo por columna, adjuntos de archivos por card, notificaciones realtime (SSE) y modulos externos (Jira). Frontend Vite + React + Mantine v9 embebido en el binario Go. Endpoint MCP Streamable HTTP en /mcp."
|
||||||
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
|
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
@@ -195,4 +195,6 @@ Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
|||||||
- v0.2.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): tabla `card_files` con soft-delete, endpoints REST (`POST/GET/DELETE /api/cards/{id}/files`, `GET/DELETE /api/files/{id}`), tres vias de upload (drag&drop en descripcion y chat, boton en tab Archivos), render inline de imagenes via `MessageBody`. Limite 10 MB.
|
- v0.2.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): tabla `card_files` con soft-delete, endpoints REST (`POST/GET/DELETE /api/cards/{id}/files`, `GET/DELETE /api/files/{id}`), tres vias de upload (drag&drop en descripcion y chat, boton en tab Archivos), render inline de imagenes via `MessageBody`. Limite 10 MB.
|
||||||
- v0.3.1 (2026-05-21) — patch: debounce board.invalidated (300ms trailing) + autoClose 4s en toasts de notification.created. Fix de blow-up de memoria en navegador por ráfagas de SSE.
|
- v0.3.1 (2026-05-21) — patch: debounce board.invalidated (300ms trailing) + autoClose 4s en toasts de notification.created. Fix de blow-up de memoria en navegador por ráfagas de SSE.
|
||||||
- v0.4.0 (2026-05-22) — minor: endpoint MCP Streamable HTTP `/mcp` con per-user bearer tokens (tabla `mcp_tokens`, migration 017). Modal "MCP tokens" en avatar menu para generar/listar/revocar. Vite proxy enruta `/mcp` a WSL. Usa nueva funcion `mcp_server_http_go_infra`. Doc en `docs/MCP.md`.
|
- v0.4.0 (2026-05-22) — minor: endpoint MCP Streamable HTTP `/mcp` con per-user bearer tokens (tabla `mcp_tokens`, migration 017). Modal "MCP tokens" en avatar menu para generar/listar/revocar. Vite proxy enruta `/mcp` a WSL. Usa nueva funcion `mcp_server_http_go_infra`. Doc en `docs/MCP.md`.
|
||||||
|
- v0.5.2 (2026-06-01) — patch: el alta a Jira rellena el campo obligatorio "Área Solicitante" (`customfield_10158`) que el issue type Epic (y Mejora) del proyecto DATA exige en la pantalla de creacion. Sin esto, el `card.created` del 0.5.1 daba HTTP 400 "Solicitante is required". Nuevos campos en `jiraConfig`: `requester_field`, `requester_map`, `requester_default`. `create()`/`update()` inyectan el campo como single-select `{value:<opcion>}` resuelto desde el requester de la card (mapa case-insensitive) o el default. Como los requesters del kanban son nombres de persona (no departamentos), las cards caen al default (`Transformación`). `seed-jira-data` gana flags `--requester-field`/`--requester-default` y la rama de update ahora mergea config para no pisar ediciones de UI.
|
||||||
|
- v0.5.1 (2026-06-01) — patch: `handleCreateCard` ahora emite el evento `card.created` (antes solo `board.invalidated`, que no estaba en el filtro del modulo). Con esto la creacion de una card dispara `jiraHandler.create` y sincroniza el alta a Jira, igual que ya ocurria con move (`card.moved`) y chat (`message.created`). El evento se emite tras aplicar assignee/tags para que el issue de Jira los lleve.
|
||||||
- v0.5.0 (2026-05-27) — minor: merge ramas notifications-realtime + modules con master post-files. Trae notificaciones SSE (tabla `notifications`, migration 015), modulos externos para sincronizacion bidireccional (Jira, etc., tabla `modules`, migration 016), tokens MCP per-user (migration 017). Conserva files attachments del 0128. Renumeradas migrations notif 014/015/016 -> 015/016/017.
|
- v0.5.0 (2026-05-27) — minor: merge ramas notifications-realtime + modules con master post-files. Trae notificaciones SSE (tabla `notifications`, migration 015), modulos externos para sincronizacion bidireccional (Jira, etc., tabla `modules`, migration 016), tokens MCP per-user (migration 017). Conserva files attachments del 0128. Renumeradas migrations notif 014/015/016 -> 015/016/017.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -679,6 +679,20 @@ type jiraConfig struct {
|
|||||||
StatusMap map[string]string `json:"status_map"` // kanban_column_name -> Jira status name
|
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)
|
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
|
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) {
|
||||||
@@ -823,6 +837,10 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event
|
|||||||
if acct := resolveJiraAssignee(c, card); acct != "" {
|
if acct := resolveJiraAssignee(c, card); acct != "" {
|
||||||
fields["assignee"] = map[string]string{"accountId": 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}
|
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 {
|
||||||
@@ -876,6 +894,9 @@ func (h *jiraHandler) update(ctx context.Context, db *DB, c jiraConfig, ev Event
|
|||||||
if acct := resolveJiraAssignee(c, card); acct != "" {
|
if acct := resolveJiraAssignee(c, card); acct != "" {
|
||||||
fields["assignee"] = map[string]string{"accountId": 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}
|
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
|
||||||
@@ -892,6 +913,38 @@ func resolveJiraAssignee(c jiraConfig, card *cardForJira) string {
|
|||||||
return c.AssigneeMap[card.AssigneeID]
|
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. Kanban remains the source of truth even if
|
// to a Jira transition name. Kanban remains the source of truth even if
|
||||||
// Jira's current state differs.
|
// Jira's current state differs.
|
||||||
|
|||||||
+16
-1
@@ -33,6 +33,10 @@ func runSeedJiraData(args []string) error {
|
|||||||
"Comma-separated event types this module subscribes to")
|
"Comma-separated event types this module subscribes to")
|
||||||
enabled := fs.Bool("enabled", true, "Start with module enabled (true) or disabled (false)")
|
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}")
|
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 {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -91,6 +95,10 @@ func runSeedJiraData(args []string) error {
|
|||||||
"labels_map": labelsMap,
|
"labels_map": labelsMap,
|
||||||
"assignee_map": assigneeMap,
|
"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
|
// 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
|
// it as unique for the purposes of seeding so re-running this command does
|
||||||
@@ -111,7 +119,14 @@ func runSeedJiraData(args []string) error {
|
|||||||
existing.Kind = "jira"
|
existing.Kind = "jira"
|
||||||
existing.Enabled = *enabled
|
existing.Enabled = *enabled
|
||||||
existing.EventFilter = splitCSV(*filter)
|
existing.EventFilter = splitCSV(*filter)
|
||||||
existing.Config = cfg
|
// 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 {
|
if err := db.saveModule(existing); err != nil {
|
||||||
return fmt.Errorf("update module: %w", err)
|
return fmt.Errorf("update module: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user