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:
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user