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:
+173
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user