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:
+77
-30
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user