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:
+157
-152
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user