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:
+64
-17
@@ -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,12 +737,26 @@ 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) {
|
||||||
if ev.CardID == "" {
|
if ev.CardID == "" {
|
||||||
|
|||||||
+11
-3
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user