From 5744b82f58334e4d65c33955819a705853238405 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Fri, 29 May 2026 11:44:04 +0200 Subject: [PATCH] feat(jira): issue_type config + labels_map + status_map default DATA + transition tras create - jiraConfig: campos IssueType + LabelsMap (kanban col -> labels Jira). Default IssueType='Tarea Tecnica' (DATA project no tiene Task). - create(): usa c.IssueType y aplica labels iniciales. Despues del POST /issue ejecuta transitionToStatus para mover la card recien creada al status del status_map, asi no aterriza en el initial workflow status (CREADO o To Do) sino donde toca segun la columna kanban. - update() y transition(): aplican labels en cada sync (PUT replaces array). Card que sale de Bloqueadas pierde el label 'blocked' automaticamente. - transitionToStatus: helper compartido entre create() y transition(). - seed-jira-data: inyecta status_map por defecto para nuestras 6 columnas (HACIENDO -> In Progress, PNDNT FEEDBACK -> IMPLEMENTADO, HECHO -> Done, IDEAS -> CREADO, DEUDA TECNICA -> To Do, Bloqueadas -> In Progress) y labels_map (Bloqueadas -> ['blocked']). - modules_test: mock Jira tambien responde /transitions endpoints. --- backend/modules.go | 107 +++++++++++++++++++++++++++++----------- backend/modules_test.go | 14 ++++-- backend/seed_jira.go | 18 ++++++- 3 files changed, 105 insertions(+), 34 deletions(-) diff --git a/backend/modules.go b/backend/modules.go index 979413d..98f63b9 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -480,12 +480,14 @@ func cutoffOK(db *DB, m Module, ev Event) bool { type jiraHandler struct{} type jiraConfig struct { - BaseURL string `json:"base_url"` - Email string `json:"email"` - APIToken string `json:"api_token"` - ProjectKey string `json:"project_key"` - BoardID int `json:"board_id"` - StatusMap map[string]string `json:"status_map"` + BaseURL string `json:"base_url"` + Email string `json:"email"` + APIToken string `json:"api_token"` + ProjectKey string `json:"project_key"` + BoardID int `json:"board_id"` + IssueType string `json:"issue_type"` // Jira issuetype name applied on create + StatusMap map[string]string `json:"status_map"` // kanban_column_name -> Jira status name + LabelsMap map[string][]string `json:"labels_map,omitempty"` // kanban_column_name -> Jira labels (replaces every sync) } func parseJiraConfig(m Module) (jiraConfig, error) { @@ -501,6 +503,9 @@ func parseJiraConfig(m Module) (jiraConfig, error) { if c.BaseURL == "" { return c, fmt.Errorf("base_url required") } + if c.IssueType == "" { + c.IssueType = "Tarea Técnica" + } return c, nil } @@ -615,14 +620,16 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event if c.ProjectKey == "" { return 0, fmt.Errorf("project_key required for create (configure module before pushing)") } - body := map[string]interface{}{ - "fields": map[string]interface{}{ - "project": map[string]string{"key": c.ProjectKey}, - "summary": card.Title, - "description": adfText(card.Description), - "issuetype": map[string]string{"name": "Task"}, - }, + fields := map[string]interface{}{ + "project": map[string]string{"key": c.ProjectKey}, + "summary": card.Title, + "description": adfText(card.Description), + "issuetype": map[string]string{"name": c.IssueType}, } + if labels := c.LabelsMap[card.ColumnName]; len(labels) > 0 { + fields["labels"] = labels + } + body := map[string]interface{}{"fields": fields} status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body) if err != nil { return status, err @@ -631,9 +638,18 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event Key string `json:"key"` } _ = json.Unmarshal(resp, &parsed) - if parsed.Key != "" { - if err := db.setCardJiraKey(card.ID, parsed.Key); err != nil { - return status, fmt.Errorf("link jira key: %w", err) + if parsed.Key == "" { + return status, fmt.Errorf("jira create returned empty key") + } + if err := db.setCardJiraKey(card.ID, parsed.Key); err != nil { + return status, fmt.Errorf("link jira key: %w", err) + } + // Jira places new issues in the workflow's initial status (typically + // CREADO / To Do for DATA). Drive a transition immediately so the issue + // lands in the column that mirrors where the card is in kanban. + if _, ok := c.StatusMap[card.ColumnName]; ok { + if _, err := h.transitionToStatus(ctx, c, parsed.Key, card.ColumnName); err != nil { + return status, fmt.Errorf("created %s but initial transition failed: %w", parsed.Key, err) } } return status, nil @@ -651,20 +667,26 @@ func (h *jiraHandler) update(ctx context.Context, db *DB, c jiraConfig, ev Event // Card not yet linked — bootstrap by creating it. return h.create(ctx, db, c, ev) } - body := map[string]interface{}{ - "fields": map[string]interface{}{ - "summary": card.Title, - "description": adfText(card.Description), - }, + fields := map[string]interface{}{ + "summary": card.Title, + "description": adfText(card.Description), } + // Labels are derived from the current kanban column. We always send them + // (even an empty array) so a card that leaves a labelled column gets its + // label removed from Jira — PUT fields.labels REPLACES the whole array. + labels := c.LabelsMap[card.ColumnName] + if labels == nil { + labels = []string{} + } + fields["labels"] = labels + body := map[string]interface{}{"fields": fields} status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body) return status, err } // transition uses the configured status_map to translate the kanban column -// to a Jira transition name. We list available transitions, find the one -// whose target status name matches, and POST it. Kanban remains the source -// of truth even if Jira's current state differs. +// to a Jira transition name. Kanban remains the source of truth even if +// Jira's current state differs. func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) { if ev.CardID == "" { return 0, nil @@ -676,11 +698,22 @@ func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev E if card.JiraKey == "" { return h.create(ctx, db, c, ev) } - target, ok := c.StatusMap[card.ColumnName] - if !ok || target == "" { + if _, ok := c.StatusMap[card.ColumnName]; !ok { return 0, fmt.Errorf("no status_map entry for column %q", card.ColumnName) } - status, body, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/issue/"+card.JiraKey+"/transitions", nil) + return h.transitionToStatus(ctx, c, card.JiraKey, card.ColumnName) +} + +// transitionToStatus drives a Jira issue to the status mapped from the given +// kanban column and refreshes labels accordingly. Used by transition() on +// card.moved events and by create() right after issue creation so new issues +// do not stall at the workflow's default initial status. +func (h *jiraHandler) transitionToStatus(ctx context.Context, c jiraConfig, jiraKey, columnName string) (int, error) { + target := c.StatusMap[columnName] + if target == "" { + return 0, fmt.Errorf("no status_map entry for column %q", columnName) + } + status, body, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/issue/"+jiraKey+"/transitions", nil) if err != nil { return status, err } @@ -704,11 +737,25 @@ func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev E } } if tID == "" { - return 0, fmt.Errorf("transition %q not available for %s", target, card.JiraKey) + return 0, fmt.Errorf("transition %q not available for %s", target, jiraKey) } req := map[string]interface{}{"transition": map[string]string{"id": tID}} - status, _, err = h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue/"+card.JiraKey+"/transitions", req) - return status, err + status, _, err = h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue/"+jiraKey+"/transitions", req) + if err != nil { + return status, err + } + // Refresh labels to match the new column. Replaces the labels array; an + // empty list strips any stale labels from the previous column. + labels := c.LabelsMap[columnName] + if labels == nil { + labels = []string{} + } + lbody := map[string]interface{}{"fields": map[string]interface{}{"labels": labels}} + lStatus, _, lErr := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+jiraKey, lbody) + if lErr != nil { + return lStatus, fmt.Errorf("transition ok but labels sync failed: %w", lErr) + } + return status, nil } func (h *jiraHandler) comment(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) { diff --git a/backend/modules_test.go b/backend/modules_test.go index 3ac8d24..fd62e8f 100644 --- a/backend/modules_test.go +++ b/backend/modules_test.go @@ -141,7 +141,8 @@ func TestJiraHandler_CreateLinksCardKey(t *testing.T) { card, _ := db.CreateCard(col.ID, "req", "Buy bread", "desc", user.ID) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue" { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue": b, _ := io.ReadAll(r.Body) var p struct { Fields struct { @@ -154,9 +155,16 @@ func TestJiraHandler_CreateLinksCardKey(t *testing.T) { } w.WriteHeader(http.StatusCreated) _, _ = io.WriteString(w, `{"id":"10000","key":"KAN-1"}`) - return + case r.Method == http.MethodGet && r.URL.Path == "/rest/api/3/issue/KAN-1/transitions": + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"transitions":[{"id":"11","name":"Start","to":{"name":"To Do"}}]}`) + case r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue/KAN-1/transitions": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPut && r.URL.Path == "/rest/api/3/issue/KAN-1": + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) } - w.WriteHeader(http.StatusNotFound) })) defer srv.Close() diff --git a/backend/seed_jira.go b/backend/seed_jira.go index de7a13a..25f7231 100644 --- a/backend/seed_jira.go +++ b/backend/seed_jira.go @@ -57,13 +57,29 @@ func runSeedJiraData(args []string) error { } defer db.Close() + // Default mapping for our setup: Kanban columns → Jira `Epicas en Data` board (33) + // statuses. Operator can edit via the Modulos UI once the row exists. + statusMap := map[string]string{ + "HACIENDO 🚧": "In Progress", + "PNDNT FEEDBACK ▶️": "IMPLEMENTADO", + "HECHO ✅": "Done", + "IDEAS 💡": "CREADO", + "DEUDA TÉCNICA 🔄": "To Do", + "Bloqueadas": "In Progress", + } + labelsMap := map[string][]string{ + "Bloqueadas": {"blocked"}, + } + cfg := JSONValue{ "base_url": baseURL, "email": email, "api_token": token, "project_key": *project, "board_id": *board, - "status_map": map[string]string{}, // operator fills via UI (column name → Jira status) + "issue_type": "Tarea Técnica", + "status_map": statusMap, + "labels_map": labelsMap, } // Upsert by name. Module name is the human-friendly identifier; we treat