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:
+1328
File diff suppressed because one or more lines are too long
-1318
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -687,6 +687,11 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
|||||||
{Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)},
|
{Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)},
|
||||||
{Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)},
|
{Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)},
|
||||||
{Method: "POST", Path: "/api/modules/{id}/test", Handler: handleTestModule(db, dispatcher)},
|
{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)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -11,6 +11,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -194,6 +195,112 @@ func (db *DB) setCardJiraKey(cardID, jiraKey string) error {
|
|||||||
return err
|
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) {
|
func (db *DB) getCardForJira(cardID string) (*cardForJira, error) {
|
||||||
var c cardForJira
|
var c cardForJira
|
||||||
var assignee, deadline, jiraKey sql.NullString
|
var assignee, deadline, jiraKey sql.NullString
|
||||||
@@ -298,6 +405,19 @@ type Dispatcher struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
enabled bool
|
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 {
|
type dispatchTask struct {
|
||||||
@@ -412,7 +532,13 @@ func (d *Dispatcher) dispatch(t dispatchTask) {
|
|||||||
})
|
})
|
||||||
return
|
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}
|
delays := []time.Duration{0, moduleRetryDelay1, moduleRetryDelay2, moduleRetryDelay3}
|
||||||
|
var lastErr error
|
||||||
|
var lastStatus int
|
||||||
for attempt := 0; attempt < moduleRetries; attempt++ {
|
for attempt := 0; attempt < moduleRetries; attempt++ {
|
||||||
if delays[attempt] > 0 {
|
if delays[attempt] > 0 {
|
||||||
select {
|
select {
|
||||||
@@ -433,14 +559,60 @@ func (d *Dispatcher) dispatch(t dispatchTask) {
|
|||||||
ml.Error = err.Error()
|
ml.Error = err.Error()
|
||||||
}
|
}
|
||||||
_ = d.db.appendModuleLog(ml)
|
_ = d.db.appendModuleLog(ml)
|
||||||
|
lastErr = err
|
||||||
|
lastStatus = status
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
d.recordCardSyncSuccess(t.module, t.event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 4xx client errors are not worth retrying.
|
// 4xx client errors are not worth retrying.
|
||||||
if status >= 400 && status < 500 {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -224,3 +224,44 @@ func handleTestModule(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
|
|||||||
infra.HTTPJSONResponse(w, http.StatusOK, resp)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
IconLogout,
|
IconLogout,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconKey,
|
IconKey,
|
||||||
|
IconBrandJira,
|
||||||
IconMenu2,
|
IconMenu2,
|
||||||
IconMessageChatbot,
|
IconMessageChatbot,
|
||||||
IconMoodSmile,
|
IconMoodSmile,
|
||||||
@@ -86,6 +87,7 @@ import { colorBg, colorBorder } from "./components/colors";
|
|||||||
import { NotificationsBell } from "./components/NotificationsBell";
|
import { NotificationsBell } from "./components/NotificationsBell";
|
||||||
import { ModulesModal } from "./components/ModulesModal";
|
import { ModulesModal } from "./components/ModulesModal";
|
||||||
import { MCPTokensModal } from "./components/MCPTokensModal";
|
import { MCPTokensModal } from "./components/MCPTokensModal";
|
||||||
|
import { ImportJiraModal } from "./components/ImportJiraModal";
|
||||||
import { useEventStream } from "./hooks/useEventStream";
|
import { useEventStream } from "./hooks/useEventStream";
|
||||||
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
|
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
|
||||||
|
|
||||||
@@ -364,6 +366,7 @@ export function App() {
|
|||||||
|
|
||||||
const [modulesOpen, setModulesOpen] = useState(false);
|
const [modulesOpen, setModulesOpen] = useState(false);
|
||||||
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
|
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
|
||||||
|
const [jiraImportOpen, setJiraImportOpen] = useState(false);
|
||||||
|
|
||||||
const reloadNotifs = useCallback(async () => {
|
const reloadNotifs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1292,6 +1295,14 @@ export function App() {
|
|||||||
Modulos
|
Modulos
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
{auth.user.is_admin && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconBrandJira size={14} />}
|
||||||
|
onClick={() => setJiraImportOpen(true)}
|
||||||
|
>
|
||||||
|
Importar de Jira
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconKey size={14} />}
|
leftSection={<IconKey size={14} />}
|
||||||
onClick={() => setMcpTokensOpen(true)}
|
onClick={() => setMcpTokensOpen(true)}
|
||||||
@@ -1311,6 +1322,14 @@ export function App() {
|
|||||||
{auth.user?.is_admin && (
|
{auth.user?.is_admin && (
|
||||||
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
|
<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)} />
|
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -505,6 +505,66 @@ export function revokeMCPToken(id: string): Promise<void> {
|
|||||||
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
|
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> {
|
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (f.from) qs.set("from", f.from);
|
if (f.from) qs.set("from", f.from);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import type { Card, CardColor, User } from "../types";
|
|||||||
import { colorBg, colorBorder, tagColor } from "./colors";
|
import { colorBg, colorBorder, tagColor } from "./colors";
|
||||||
import { ColorPickerGrid } from "./ColorPickerGrid";
|
import { ColorPickerGrid } from "./ColorPickerGrid";
|
||||||
import { formatDateTimeShort, formatDuration } from "./format";
|
import { formatDateTimeShort, formatDuration } from "./format";
|
||||||
|
import { JiraSyncIndicator } from "./JiraSyncIndicator";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: Card;
|
card: Card;
|
||||||
@@ -358,16 +359,19 @@ const KanbanCardBody = memo(function KanbanCardBody({
|
|||||||
{card.title}
|
{card.title}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
|
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||||
<Menu.Target>
|
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" style={{ flexShrink: 0 }} onPointerDown={(e) => e.stopPropagation()}>
|
<Menu.Target>
|
||||||
<IconDotsVertical size={14} />
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
</ActionIcon>
|
<IconDotsVertical size={14} />
|
||||||
</Menu.Target>
|
</ActionIcon>
|
||||||
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
|
</Menu.Target>
|
||||||
{menuItems}
|
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
|
||||||
</Menu.Dropdown>
|
{menuItems}
|
||||||
</Menu>
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
<JiraSyncIndicator cardId={card.id} />
|
||||||
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
{(card.requester || assignee) && (
|
{(card.requester || assignee) && (
|
||||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user