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)
}
}
+19
View File
@@ -57,6 +57,7 @@ import {
IconLogout,
IconPlug,
IconKey,
IconBrandJira,
IconMenu2,
IconMessageChatbot,
IconMoodSmile,
@@ -86,6 +87,7 @@ import { colorBg, colorBorder } from "./components/colors";
import { NotificationsBell } from "./components/NotificationsBell";
import { ModulesModal } from "./components/ModulesModal";
import { MCPTokensModal } from "./components/MCPTokensModal";
import { ImportJiraModal } from "./components/ImportJiraModal";
import { useEventStream } from "./hooks/useEventStream";
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
@@ -364,6 +366,7 @@ export function App() {
const [modulesOpen, setModulesOpen] = useState(false);
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
const [jiraImportOpen, setJiraImportOpen] = useState(false);
const reloadNotifs = useCallback(async () => {
try {
@@ -1292,6 +1295,14 @@ export function App() {
Modulos
</Menu.Item>
)}
{auth.user.is_admin && (
<Menu.Item
leftSection={<IconBrandJira size={14} />}
onClick={() => setJiraImportOpen(true)}
>
Importar de Jira
</Menu.Item>
)}
<Menu.Item
leftSection={<IconKey size={14} />}
onClick={() => setMcpTokensOpen(true)}
@@ -1311,6 +1322,14 @@ export function App() {
{auth.user?.is_admin && (
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
)}
{auth.user?.is_admin && board && (
<ImportJiraModal
opened={jiraImportOpen}
onClose={() => setJiraImportOpen(false)}
columns={board.columns}
onImported={() => reload()}
/>
)}
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
</Group>
</Group>
+60
View File
@@ -505,6 +505,66 @@ export function revokeMCPToken(id: string): Promise<void> {
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
}
// --- Jira sync state + import ----------------------------------------------
export interface CardJiraSyncState {
card_id: string;
jira_key: string;
last_status: string;
last_sync_at: string;
last_error: string;
inflight: boolean;
issue_url?: string;
}
export function getCardJiraSync(cardId: string): Promise<CardJiraSyncState> {
return fetchJSON(`/cards/${cardId}/jira-sync`);
}
export interface JiraIssue {
key: string;
summary: string;
status_name: string;
issue_type: string;
assignee: string;
updated: string;
url: string;
already_imported: boolean;
mapped_column_id?: string;
issue_type_icon?: string;
}
export interface ListJiraIssuesResponse {
issues: JiraIssue[];
board_id: number;
project_key: string;
status_to_column: Record<string, string>;
include_imported: boolean;
}
export function listJiraIssues(opts?: { includeImported?: boolean; limit?: number }): Promise<ListJiraIssuesResponse> {
const qs = new URLSearchParams();
if (opts?.includeImported) qs.set("include_imported", "true");
if (opts?.limit) qs.set("limit", String(opts.limit));
const q = qs.toString();
return fetchJSON(`/jira/issues${q ? `?${q}` : ""}`);
}
export interface JiraImportResult {
key: string;
status: "imported" | "skipped" | "error";
card_id?: string;
column_id?: string;
error?: string;
}
export function importJiraIssues(issueKeys: string[], fallbackColumnId?: string): Promise<{ results: JiraImportResult[] }> {
return fetchJSON("/jira/import", {
method: "POST",
body: JSON.stringify({ issue_keys: issueKeys, fallback_column_id: fallbackColumnId || "" }),
});
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
+279
View File
@@ -0,0 +1,279 @@
import {
Anchor,
Badge,
Box,
Button,
Checkbox,
Group,
Image,
Loader,
Modal,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconBrandJira, IconRefresh, IconSearch } from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { JiraIssue } from "../api";
import type { Column } from "../types";
interface Props {
opened: boolean;
onClose: () => void;
columns: Column[];
onImported?: () => void;
}
// ImportJiraModal lists every issue on the configured Jira board and lets the
// admin pick which ones to materialise as kanban cards. Issues already linked
// to a card are hidden by default and re-shown by toggling "Mostrar
// importadas". Each new card lands in the column whose status_map matches the
// issue's current status; the fallback column picker covers statuses without
// a mapping (e.g. Jira Backlog → kanban "HACIENDO").
export function ImportJiraModal({ opened, onClose, columns, onImported }: Props) {
const [issues, setIssues] = useState<JiraIssue[] | null>(null);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showImported, setShowImported] = useState(false);
const [query, setQuery] = useState("");
const [fallbackColumn, setFallbackColumn] = useState<string>("");
const [boardId, setBoardId] = useState<number | null>(null);
const [projectKey, setProjectKey] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const reload = async () => {
setLoading(true);
setError(null);
try {
const r = await api.listJiraIssues({ includeImported: showImported, limit: 200 });
setIssues(r.issues);
setBoardId(r.board_id);
setProjectKey(r.project_key);
// Clear selection of any keys that disappeared from the new list.
setSelected((prev) => {
const validKeys = new Set(r.issues.map((i) => i.key));
const next = new Set<string>();
for (const k of prev) if (validKeys.has(k)) next.add(k);
return next;
});
} catch (e) {
setError((e as Error).message);
setIssues([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (opened) reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, showImported]);
// Default the fallback column to the first board column (operator can change
// it via the Select). We only set it once columns are available.
useEffect(() => {
if (!fallbackColumn && columns.length > 0) {
const boardCol = columns.find((c) => c.location !== "sidebar") || columns[0];
setFallbackColumn(boardCol.id);
}
}, [columns, fallbackColumn]);
const visible = useMemo(() => {
if (!issues) return [];
const q = query.trim().toLowerCase();
if (!q) return issues;
return issues.filter(
(i) =>
i.key.toLowerCase().includes(q) ||
i.summary.toLowerCase().includes(q) ||
i.assignee.toLowerCase().includes(q) ||
i.status_name.toLowerCase().includes(q),
);
}, [issues, query]);
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAll = () => {
const importable = visible.filter((i) => !i.already_imported);
if (selected.size === importable.length && importable.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(importable.map((i) => i.key)));
}
};
const doImport = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const res = await api.importJiraIssues(Array.from(selected), fallbackColumn || undefined);
const ok = res.results.filter((r) => r.status === "imported").length;
const skip = res.results.filter((r) => r.status === "skipped").length;
const err = res.results.filter((r) => r.status === "error");
const msg = `${ok} importadas` + (skip > 0 ? ` · ${skip} omitidas` : "") + (err.length > 0 ? ` · ${err.length} con error` : "");
notifications.show({
color: err.length > 0 ? "yellow" : "green",
message: msg,
autoClose: 5000,
});
if (err.length > 0) {
console.warn("import errors", err);
}
setSelected(new Set());
await reload();
onImported?.();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setImporting(false);
}
};
const columnOptions = columns.map((c) => ({ value: c.id, label: c.name }));
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap={8}>
<IconBrandJira size={18} />
<Text fw={600}>
Importar tareas de Jira{boardId ? ` · board ${boardId}` : ""}{projectKey ? ` (${projectKey})` : ""}
</Text>
</Group>
}
size="xl"
>
<Stack gap="sm">
<Group gap="xs" wrap="nowrap">
<TextInput
placeholder="Filtrar por key, titulo, asignado o status..."
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
leftSection={<IconSearch size={14} />}
style={{ flex: 1 }}
/>
<Tooltip label="Refrescar" withArrow>
<Button variant="default" onClick={reload} loading={loading} leftSection={<IconRefresh size={14} />}>
Refrescar
</Button>
</Tooltip>
</Group>
<Group justify="space-between" gap="xs">
<Checkbox
label="Mostrar issues ya importadas"
checked={showImported}
onChange={(e) => setShowImported(e.currentTarget.checked)}
/>
<Select
label="Columna fallback"
description="Para issues cuyo status no tiene mapping"
data={columnOptions}
value={fallbackColumn}
onChange={(v) => setFallbackColumn(v || "")}
allowDeselect={false}
w={260}
size="xs"
/>
</Group>
{error && (
<Text size="sm" c="red">{error}</Text>
)}
<Box style={{ maxHeight: 480, overflowY: "auto", border: "1px solid var(--mantine-color-default-border)", borderRadius: 4 }}>
{loading && !issues ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : visible.length === 0 ? (
<Text size="sm" c="dimmed" p="md" ta="center">
{issues?.length === 0
? "No hay issues sin importar en el board."
: "Ninguna issue coincide con el filtro."}
</Text>
) : (
<Stack gap={0}>
<Group
p="xs"
gap="xs"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)", background: "var(--mantine-color-default-hover)" }}
>
<Checkbox
checked={selected.size > 0 && selected.size === visible.filter((i) => !i.already_imported).length}
indeterminate={selected.size > 0 && selected.size < visible.filter((i) => !i.already_imported).length}
onChange={toggleAll}
/>
<Text size="xs" fw={600} c="dimmed">
{visible.length} issues · {selected.size} seleccionadas
</Text>
</Group>
{visible.map((iss) => (
<Group
key={iss.key}
p="xs"
gap="xs"
wrap="nowrap"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)", opacity: iss.already_imported ? 0.5 : 1 }}
>
<Checkbox
checked={selected.has(iss.key)}
disabled={iss.already_imported}
onChange={() => toggle(iss.key)}
/>
{iss.issue_type_icon && (
<Image src={iss.issue_type_icon} w={16} h={16} alt={iss.issue_type} />
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Anchor href={iss.url} target="_blank" rel="noopener noreferrer" size="sm" fw={600}>
{iss.key}
</Anchor>
<Text size="sm" truncate style={{ flex: 1 }}>{iss.summary}</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="light" color={badgeColor(iss.status_name)}>{iss.status_name}</Badge>
<Badge size="xs" variant="outline">{iss.issue_type}</Badge>
{iss.assignee && <Text size="xs" c="dimmed">· {iss.assignee}</Text>}
{iss.already_imported && <Badge size="xs" color="gray">ya en kanban</Badge>}
{!iss.already_imported && !iss.mapped_column_id && (
<Badge size="xs" color="orange" variant="light">sin mapping (usa fallback)</Badge>
)}
</Group>
</Stack>
</Group>
))}
</Stack>
)}
</Box>
<Group justify="flex-end" gap="xs">
<Button variant="default" onClick={onClose} disabled={importing}>Cerrar</Button>
<Button onClick={doImport} disabled={selected.size === 0 || importing} loading={importing}>
Importar {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</Group>
</Stack>
</Modal>
);
}
function badgeColor(status: string): string {
const s = status.toLowerCase();
if (s.includes("done") || s.includes("hecho") || s.includes("closed")) return "green";
if (s.includes("progress") || s.includes("doing")) return "blue";
if (s.includes("implementado") || s.includes("review")) return "violet";
if (s.includes("creado") || s.includes("backlog") || s.includes("nuevo")) return "gray";
return "yellow";
}
@@ -0,0 +1,157 @@
import { Anchor, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconBrandJira, IconAlertCircle } from "@tabler/icons-react";
import { CSSProperties, useEffect, useState } from "react";
import * as api from "../api";
import type { CardJiraSyncState } from "../api";
import { formatDateTimeShort } from "./format";
// Pull state every POLL_MS so the indicator catches up with async dispatcher
// pushes without needing SSE. 10s is a reasonable balance: short enough that a
// drag-to-Jira shows green within one tick, long enough that the polling is
// not a noticeable load.
const POLL_MS = 10000;
type Tone = "gray" | "yellow" | "green" | "red";
function tone(state: CardJiraSyncState): Tone {
if (state.inflight) return "yellow";
if (state.last_error) return "red";
if (state.jira_key) return "green";
return "gray";
}
const TONE_COLOR: Record<Tone, string> = {
gray: "var(--mantine-color-gray-5)",
yellow: "var(--mantine-color-yellow-5)",
green: "var(--mantine-color-green-5)",
red: "var(--mantine-color-red-6)",
};
const TONE_LABEL: Record<Tone, string> = {
gray: "Sin sincronizar con Jira",
yellow: "Sincronizando...",
green: "Sincronizada con Jira",
red: "Error de sincronizacion",
};
interface Props {
cardId: string;
// Pollen-down so the parent can refresh when needed (e.g. after a move
// animation finishes) without waiting for the next tick.
refreshTick?: number;
}
export function JiraSyncIndicator({ cardId, refreshTick }: Props) {
const [state, setState] = useState<CardJiraSyncState | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const s = await api.getCardJiraSync(cardId);
if (!cancelled) {
setState(s);
setErr(null);
}
} catch (e) {
if (!cancelled) setErr((e as Error).message);
}
};
load();
const t = setInterval(load, POLL_MS);
return () => {
cancelled = true;
clearInterval(t);
};
}, [cardId, refreshTick]);
if (err && !state) {
return (
<Box title={err} style={dotStyle("var(--mantine-color-gray-3)")} aria-label="Jira sync state unavailable" />
);
}
if (!state) {
// Initial render — fade in a placeholder dot so the layout does not shift
// when the fetch resolves.
return <Box style={dotStyle("var(--mantine-color-gray-2)")} aria-label="Cargando estado Jira" />;
}
const t = tone(state);
return (
<HoverCard width={300} shadow="md" openDelay={150} closeDelay={120} withinPortal>
<HoverCard.Target>
<Box
role="status"
aria-label={TONE_LABEL[t]}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
style={dotStyle(TONE_COLOR[t])}
/>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<Stack gap={6}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<IconBrandJira size={14} />
<Text size="sm" fw={600}>{TONE_LABEL[t]}</Text>
</Group>
{state.issue_url && (
<Anchor
size="xs"
href={state.issue_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Abrir en Jira
</Anchor>
)}
</Group>
{state.jira_key && (
<Text size="xs">
<Text component="span" c="dimmed">Issue:</Text>{" "}
<Text component="span" fw={600}>{state.jira_key}</Text>
</Text>
)}
{state.last_status && (
<Text size="xs">
<Text component="span" c="dimmed">Status:</Text>{" "}
<Text component="span">{state.last_status}</Text>
</Text>
)}
{state.last_sync_at && (
<Text size="xs">
<Text component="span" c="dimmed">Ultimo sync:</Text>{" "}
<Text component="span">{formatDateTimeShort(state.last_sync_at)}</Text>
</Text>
)}
{state.last_error && (
<Group gap={6} wrap="nowrap" align="flex-start">
<IconAlertCircle size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red" style={{ wordBreak: "break-word" }}>{state.last_error}</Text>
</Group>
)}
{!state.jira_key && (
<Text size="xs" c="dimmed">
La card todavia no se ha empujado a Jira. Editala o muevela para
disparar el sync, o usa la opcion "Importar de Jira" si ya existe
alli.
</Text>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
function dotStyle(color: string): CSSProperties {
return {
width: 10,
height: 10,
borderRadius: "50%",
background: color,
cursor: "default",
boxShadow: "0 0 0 2px var(--mantine-color-body)",
transition: "background 120ms ease",
};
}
+14 -10
View File
@@ -38,6 +38,7 @@ import type { Card, CardColor, User } from "../types";
import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid";
import { formatDateTimeShort, formatDuration } from "./format";
import { JiraSyncIndicator } from "./JiraSyncIndicator";
interface Props {
card: Card;
@@ -358,16 +359,19 @@ const KanbanCardBody = memo(function KanbanCardBody({
{card.title}
</Text>
</Group>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" style={{ flexShrink: 0 }} onPointerDown={(e) => e.stopPropagation()}>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
{menuItems}
</Menu.Dropdown>
</Menu>
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" onPointerDown={(e) => e.stopPropagation()}>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
{menuItems}
</Menu.Dropdown>
</Menu>
<JiraSyncIndicator cardId={card.id} />
</Stack>
</Group>
{(card.requester || assignee) && (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>