feat(jira): indicator per-card + import view desde Jira board 33

Backend:
- migration 018: cards.jira_last_status / sync_at / error (estado persistido del ultimo
  sync para render UI sin polling Jira).
- Dispatcher: sync.Map inflight para 'yellow' realtime + persistencia de exito/fallo
  en cards tras cada dispatch attempt.
- GET /api/cards/{id}/jira-sync: devuelve {jira_key, last_status, last_sync_at,
  last_error, inflight, issue_url} para el tooltip del indicador.
- GET /api/jira/issues: lista issues del board 33 con flag already_imported +
  mapped_column_id (reverse status_map). Filtros include_imported, limit.
- POST /api/jira/import: multi-key. Cada issue -> CreateCard + setCardJiraKey +
  seed jira_last_status. Cae en columna mapeada por status, o en fallback_column_id.
  ADF de description extraido a texto plano.

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

Backend tests siguen verdes (test mock cubre transitions endpoints).
Frontend pnpm build OK.
This commit is contained in:
egutierrez
2026-05-29 12:00:26 +02:00
parent 5744b82f58
commit c3cc42b350
13 changed files with 2450 additions and 1330 deletions
+173 -1
View File
@@ -11,6 +11,7 @@ import (
"log"
"net/http"
"strings"
"sync"
"time"
)
@@ -194,6 +195,112 @@ func (db *DB) setCardJiraKey(cardID, jiraKey string) error {
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
}
// 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) {
var c cardForJira
var assignee, deadline, jiraKey sql.NullString
@@ -298,6 +405,19 @@ type Dispatcher struct {
ctx context.Context
cancel context.CancelFunc
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 {
@@ -412,7 +532,13 @@ func (d *Dispatcher) dispatch(t dispatchTask) {
})
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}
var lastErr error
var lastStatus int
for attempt := 0; attempt < moduleRetries; attempt++ {
if delays[attempt] > 0 {
select {
@@ -433,14 +559,60 @@ func (d *Dispatcher) dispatch(t dispatchTask) {
ml.Error = err.Error()
}
_ = d.db.appendModuleLog(ml)
lastErr = err
lastStatus = status
if err == nil {
d.recordCardSyncSuccess(t.module, t.event)
return
}
// 4xx client errors are not worth retrying.
if status >= 400 && status < 500 {
return
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
}
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)
}
}
// =============================================================================