Compare commits

...

5 Commits

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

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

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

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

Emit card.created after assignee/tags are applied so the synced issue
carries them. board.invalidated is kept for the SPA refetch path. No loop
risk (card.created fires only from the HTTP handler) and no double-create
(board.invalidated stays out of the filter).
2026-06-01 15:25:25 +02:00
4 changed files with 79 additions and 2 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
name: kanban
lang: go
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."
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
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.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.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.
+7
View File
@@ -203,6 +203,13 @@ func handleCreateCard(db *DB, hub *EventHub) http.HandlerFunc {
serverError(w, err)
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)
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
+53
View File
@@ -679,6 +679,20 @@ type jiraConfig struct {
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) {
@@ -823,6 +837,10 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event
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)
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 != "" {
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)
return status, err
@@ -892,6 +913,38 @@ func resolveJiraAssignee(c jiraConfig, card *cardForJira) string {
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
// to a Jira transition name. Kanban remains the source of truth even if
// Jira's current state differs.
+16 -1
View File
@@ -33,6 +33,10 @@ func runSeedJiraData(args []string) error {
"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
}
@@ -91,6 +95,10 @@ func runSeedJiraData(args []string) error {
"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
@@ -111,7 +119,14 @@ func runSeedJiraData(args []string) error {
existing.Kind = "jira"
existing.Enabled = *enabled
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 {
return fmt.Errorf("update module: %w", err)
}