feat(jira): menu 'Jira' (rename) + modal con tabs Importar/Comprobar columnas

UI:
- Menu avatar dropdown: 'Importar de Jira' -> 'Jira' (renombrado).
- ImportJiraModal.tsx eliminado. Sustituido por JiraModal.tsx con Mantine Tabs:
  * 'Importar de Jira': UI heredada del modal anterior intacta.
  * 'Comprobar columnas': nueva. Lista cards linked y muestra desincronizadas
    (kanban col vs Jira status actual). Por cada row: kanban col + expected jira
    status + jira status real. Checkbox multi-select + boton 'Sincronizar' que
    empuja Jira al status correcto (kanban gana).

Backend:
- GET /api/jira/check-columns: walk cards.jira_key != ''. Por cada uno GET
  /rest/api/3/issue/{key}?fields=status. Compara status real vs status_map.
  Devuelve {rows[], total, mismatches, in_sync, status_map, reverse_map}.
- POST /api/jira/reconcile-columns {card_ids[], direction:'kanban-wins'}:
  reusa jiraHandler.transitionToStatus para empujar cada issue al status del
  status_map de su columna kanban actual + actualiza cards.jira_last_status.
- Helper listLinkedCardsForCheck en jira_import.go.

Direction='kanban-wins' default. Reverse direction (jira-wins) no soportado
por ahora: mover cards desde el server tiene efectos colaterales (eventos,
notificaciones, timers) que no quiero disparar masivos sin pensar.
This commit is contained in:
egutierrez
2026-05-29 15:18:59 +02:00
parent 9b0b6e516c
commit d4558667f6
8 changed files with 871 additions and 436 deletions
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-Zozqj0rw.js"></script>
<script type="module" crossorigin src="/assets/index-Be_Ib5cu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
+3
View File
@@ -707,6 +707,9 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
// 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)},
// Jira column-sync check: detect drift between kanban col ↔ Jira status.
{Method: "GET", Path: "/api/jira/check-columns", Handler: handleCheckJiraColumns(db)},
{Method: "POST", Path: "/api/jira/reconcile-columns", Handler: handleReconcileJiraColumns(db)},
}
}
+209
View File
@@ -38,6 +38,40 @@ type jiraIssueOut struct {
IssueTypeIcon string `json:"issue_type_icon,omitempty"`
}
// linkedCardForCheck is the projection used by the check-columns endpoint.
// We only need fields visible in the report table.
type linkedCardForCheck struct {
ID string
Title string
JiraKey string
ColumnID string
ColumnName string
}
func listLinkedCardsForCheck(db *DB) ([]linkedCardForCheck, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.title, c.jira_key, c.column_id, col.name
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE c.jira_key != ''
AND c.deleted_at IS NULL
ORDER BY c.jira_key ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []linkedCardForCheck{}
for rows.Next() {
var c linkedCardForCheck
if err := rows.Scan(&c.ID, &c.Title, &c.JiraKey, &c.ColumnID, &c.ColumnName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// 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.
@@ -245,6 +279,181 @@ func handleListJiraIssues(db *DB) http.HandlerFunc {
})
}
// jiraCheckRow is one row of the check-columns report.
type jiraCheckRow struct {
CardID string `json:"card_id"`
JiraKey string `json:"jira_key"`
Title string `json:"title"`
KanbanColumnID string `json:"kanban_column_id"`
KanbanColumnName string `json:"kanban_column_name"`
JiraStatusName string `json:"jira_status_name"`
ExpectedKanbanCol string `json:"expected_kanban_col"` // kanban col that matches the current Jira status (reverse status_map)
ExpectedJiraStat string `json:"expected_jira_status"` // jira status that matches the current kanban col (status_map)
Mismatch bool `json:"mismatch"`
IssueURL string `json:"issue_url"`
}
// handleCheckJiraColumns walks every linked card, fetches its current Jira
// status, and reports whether the kanban column ↔ Jira status mapping is in
// sync. Used by the "Comprobar columnas" tab in the Jira modal.
//
// Performance note: one Jira REST call per linked card. With 127 cards that
// is ~127 round-trips — slow (≈30s end-to-end) but tolerable as an admin op.
// A future optimisation could batch via /search/jql with key IN (...) and a
// fields=status projection.
func handleCheckJiraColumns(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
}
cards, err := listLinkedCardsForCheck(db)
if err != nil {
serverError(w, err)
return
}
statusToCol := reverseStatusMap(cfg)
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*5)
defer cancel()
rows := make([]jiraCheckRow, 0, len(cards))
var mismatches int
for _, c := range cards {
var iss struct {
Fields struct {
Status struct {
Name string `json:"name"`
} `json:"status"`
} `json:"fields"`
}
if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(c.JiraKey)+"?fields=status", &iss); err != nil {
rows = append(rows, jiraCheckRow{
CardID: c.ID,
JiraKey: c.JiraKey,
Title: c.Title,
KanbanColumnID: c.ColumnID,
KanbanColumnName: c.ColumnName,
JiraStatusName: "(fetch failed: " + err.Error() + ")",
Mismatch: true,
IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey,
})
mismatches++
continue
}
expectedCol := statusToCol[strings.ToLower(iss.Fields.Status.Name)]
expectedStat := cfg.StatusMap[c.ColumnName]
mm := !strings.EqualFold(iss.Fields.Status.Name, expectedStat)
if mm {
mismatches++
}
rows = append(rows, jiraCheckRow{
CardID: c.ID,
JiraKey: c.JiraKey,
Title: c.Title,
KanbanColumnID: c.ColumnID,
KanbanColumnName: c.ColumnName,
JiraStatusName: iss.Fields.Status.Name,
ExpectedKanbanCol: expectedCol,
ExpectedJiraStat: expectedStat,
Mismatch: mm,
IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey,
})
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"rows": rows,
"total": len(rows),
"mismatches": mismatches,
"in_sync": len(rows) - mismatches,
"status_map": cfg.StatusMap,
"reverse_map": statusToCol,
})
})
}
// reconcileRequest is the body shape for POST /api/jira/reconcile-columns.
// direction=kanban-wins → push Jira to match kanban (the only mode for now;
// reverse is risky because moving cards in kanban can trigger downstream
// notifications/timers).
type reconcileRequest struct {
CardIDs []string `json:"card_ids"`
Direction string `json:"direction"` // currently only "kanban-wins"
}
// handleReconcileJiraColumns transitions each requested issue so its status
// matches the current kanban column (kanban as source of truth). Reuses
// the dispatcher's transitionToStatus helper for consistency with the
// regular card.moved path. Per-card result.
func handleReconcileJiraColumns(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
var body reconcileRequest
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if len(body.CardIDs) == 0 {
badRequest(w, "card_ids required")
return
}
if body.Direction == "" {
body.Direction = "kanban-wins"
}
if body.Direction != "kanban-wins" {
badRequest(w, "only direction=kanban-wins is supported")
return
}
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
h := &jiraHandler{}
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.CardIDs)+1))
defer cancel()
results := make([]map[string]interface{}, 0, len(body.CardIDs))
for _, cid := range body.CardIDs {
res := map[string]interface{}{"card_id": cid}
card, cerr := db.getCardForJira(cid)
if cerr != nil {
res["status"] = "error"
res["error"] = cerr.Error()
results = append(results, res)
continue
}
if card.JiraKey == "" {
res["status"] = "skipped"
res["error"] = "card has no jira_key"
results = append(results, res)
continue
}
if _, ok := cfg.StatusMap[card.ColumnName]; !ok {
res["status"] = "skipped"
res["error"] = "no status_map entry for column " + card.ColumnName
results = append(results, res)
continue
}
status, terr := h.transitionToStatus(ctx, cfg, card.JiraKey, card.ColumnName)
if terr != nil {
res["status"] = "error"
res["error"] = terr.Error()
res["http"] = status
results = append(results, res)
continue
}
now := time.Now().UTC().Format(time.RFC3339)
_ = db.updateCardJiraSync(cid, cfg.StatusMap[card.ColumnName], now, "")
res["status"] = "fixed"
res["jira_key"] = card.JiraKey
res["jira_status"] = cfg.StatusMap[card.ColumnName]
results = append(results, res)
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{"results": results})
})
}
// 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