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
+77 -30
View File
@@ -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) {
+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)
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()
+17 -1
View File
@@ -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