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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-DFLRkdHe.js"></script>
<script type="module" crossorigin src="/assets/index-BbedqQPY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
+5
View File
@@ -687,6 +687,11 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)},
{Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)},
{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)},
}
}
+360
View File
@@ -0,0 +1,360 @@
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"`
}
// 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,
})
})
}
// 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,
})
})
}
@@ -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 '';
+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)
}
}
// =============================================================================
+41
View File
@@ -224,3 +224,44 @@ func handleTestModule(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
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)
}
}