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.
This commit is contained in:
egutierrez
2026-05-29 11:44:04 +02:00
parent ef197236db
commit 5744b82f58
3 changed files with 105 additions and 34 deletions
+64 -17
View File
@@ -485,7 +485,9 @@ type jiraConfig struct {
APIToken string `json:"api_token"` APIToken string `json:"api_token"`
ProjectKey string `json:"project_key"` ProjectKey string `json:"project_key"`
BoardID int `json:"board_id"` BoardID int `json:"board_id"`
StatusMap map[string]string `json:"status_map"` 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) { func parseJiraConfig(m Module) (jiraConfig, error) {
@@ -501,6 +503,9 @@ func parseJiraConfig(m Module) (jiraConfig, error) {
if c.BaseURL == "" { if c.BaseURL == "" {
return c, fmt.Errorf("base_url required") return c, fmt.Errorf("base_url required")
} }
if c.IssueType == "" {
c.IssueType = "Tarea Técnica"
}
return c, nil return c, nil
} }
@@ -615,14 +620,16 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event
if c.ProjectKey == "" { if c.ProjectKey == "" {
return 0, fmt.Errorf("project_key required for create (configure module before pushing)") return 0, fmt.Errorf("project_key required for create (configure module before pushing)")
} }
body := map[string]interface{}{ fields := map[string]interface{}{
"fields": map[string]interface{}{
"project": map[string]string{"key": c.ProjectKey}, "project": map[string]string{"key": c.ProjectKey},
"summary": card.Title, "summary": card.Title,
"description": adfText(card.Description), "description": adfText(card.Description),
"issuetype": map[string]string{"name": "Task"}, "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) status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body)
if err != nil { if err != nil {
return status, err return status, err
@@ -631,10 +638,19 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event
Key string `json:"key"` Key string `json:"key"`
} }
_ = json.Unmarshal(resp, &parsed) _ = json.Unmarshal(resp, &parsed)
if parsed.Key != "" { if parsed.Key == "" {
return status, fmt.Errorf("jira create returned empty key")
}
if err := db.setCardJiraKey(card.ID, parsed.Key); err != nil { if err := db.setCardJiraKey(card.ID, parsed.Key); err != nil {
return status, fmt.Errorf("link jira key: %w", err) 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 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. // Card not yet linked — bootstrap by creating it.
return h.create(ctx, db, c, ev) return h.create(ctx, db, c, ev)
} }
body := map[string]interface{}{ fields := map[string]interface{}{
"fields": map[string]interface{}{
"summary": card.Title, "summary": card.Title,
"description": adfText(card.Description), "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) status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body)
return status, err return status, err
} }
// transition uses the configured status_map to translate the kanban column // transition uses the configured status_map to translate the kanban column
// to a Jira transition name. We list available transitions, find the one // to a Jira transition name. Kanban remains the source of truth even if
// whose target status name matches, and POST it. Kanban remains the source // Jira's current state differs.
// of truth even if Jira's current state differs.
func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) { func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) {
if ev.CardID == "" { if ev.CardID == "" {
return 0, nil return 0, nil
@@ -676,11 +698,22 @@ func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev E
if card.JiraKey == "" { if card.JiraKey == "" {
return h.create(ctx, db, c, ev) return h.create(ctx, db, c, ev)
} }
target, ok := c.StatusMap[card.ColumnName] if _, ok := c.StatusMap[card.ColumnName]; !ok {
if !ok || target == "" {
return 0, fmt.Errorf("no status_map entry for column %q", card.ColumnName) 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 { if err != nil {
return status, err return status, err
} }
@@ -704,11 +737,25 @@ func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev E
} }
} }
if tID == "" { 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}} 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) status, _, err = h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue/"+jiraKey+"/transitions", req)
if err != nil {
return status, err 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) { func (h *jiraHandler) comment(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) {
+11 -3
View File
@@ -141,7 +141,8 @@ func TestJiraHandler_CreateLinksCardKey(t *testing.T) {
card, _ := db.CreateCard(col.ID, "req", "Buy bread", "desc", user.ID) card, _ := db.CreateCard(col.ID, "req", "Buy bread", "desc", user.ID)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) b, _ := io.ReadAll(r.Body)
var p struct { var p struct {
Fields struct { Fields struct {
@@ -154,9 +155,16 @@ func TestJiraHandler_CreateLinksCardKey(t *testing.T) {
} }
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"id":"10000","key":"KAN-1"}`) _, _ = 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() defer srv.Close()
+17 -1
View File
@@ -57,13 +57,29 @@ func runSeedJiraData(args []string) error {
} }
defer db.Close() 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{ cfg := JSONValue{
"base_url": baseURL, "base_url": baseURL,
"email": email, "email": email,
"api_token": token, "api_token": token,
"project_key": *project, "project_key": *project,
"board_id": *board, "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 // Upsert by name. Module name is the human-friendly identifier; we treat