From c5113f75a5b9cb391b616c2b400fe853d2e32a9c Mon Sep 17 00:00:00 2001 From: egutierrez Date: Fri, 29 May 2026 14:52:48 +0200 Subject: [PATCH] feat(jira): issue_type=Epic + AssigneeMap + CLI resync-jira-fields Cambios: - jiraConfig: nuevo campo AssigneeMap (kanban_user_id -> jira_accountId). - jiraHandler.create() y update(): aplican fields.assignee={accountId} cuando card.AssigneeID esta en el map. NO se borra el assignee de Jira cuando no hay mapeo (evita pisar asignaciones manuales). - resolveJiraAssignee: helper compartido. - seed-jira-data: cambio issue_type default Tarea Tecnica -> Epic (board 33 filtra issuetype=Epic). assignee_map inyectada con 3 mapeos confirmados: egutierrez (Enmaa) -> 712020:2cf3b82f-... (Enmanuel Gutierrez Perez) amassaguer (alfon) -> 712020:3f3ca9e1-... (Alfonso Massaguer Gomez) ntajuelo (Nat) -> 712020:feb5f7c5-... (Natalia Tajuelo Gomez) - Nueva CLI 'kanban resync-jira-fields' con flags --set-issuetype/--set-assignee/--set-labels/--dry-run/--limit/--batch-size/--pause-sec Idempotente. PUT /rest/api/3/issue/{key} con los fields del config actual. Usado para patchear las 127 issues ya creadas con Tarea Tecnica -> Epic + assignee (donde mapea). - Ejecutado: 127/127 OK, 0 fail. Board 33 ahora muestra 219 issues totales (92 Epics previas + 127 nuevas). Sample verificado contra Jira REST API. --- backend/main.go | 10 +++ backend/modules.go | 34 ++++++-- backend/resync_jira.go | 192 +++++++++++++++++++++++++++++++++++++++++ backend/seed_jira.go | 26 ++++-- 4 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 backend/resync_jira.go diff --git a/backend/main.go b/backend/main.go index 4675f81..83f8403 100644 --- a/backend/main.go +++ b/backend/main.go @@ -65,6 +65,16 @@ func main() { return } + // Subcommand `kanban resync-jira-fields` patches existing linked issues + // so their issuetype/assignee/labels reflect the current module config. + if len(os.Args) > 1 && os.Args[1] == "resync-jira-fields" { + if err := runResyncJiraFields(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "kanban resync-jira-fields: %v\n", err) + os.Exit(1) + } + return + } + flags := flag.NewFlagSet("kanban", flag.ExitOnError) port := flags.Int("port", 8095, "HTTP port") dbPath := flags.String("db", "operations.db", "SQLite database path") diff --git a/backend/modules.go b/backend/modules.go index 65f2466..f00c72d 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -652,14 +652,15 @@ 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"` - 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) + 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) + AssigneeMap map[string]string `json:"assignee_map,omitempty"` // kanban_user_id -> Jira accountId } func parseJiraConfig(m Module) (jiraConfig, error) { @@ -801,6 +802,9 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event if labels := c.LabelsMap[card.ColumnName]; len(labels) > 0 { fields["labels"] = labels } + if acct := resolveJiraAssignee(c, card); acct != "" { + fields["assignee"] = map[string]string{"accountId": acct} + } body := map[string]interface{}{"fields": fields} status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body) if err != nil { @@ -851,11 +855,25 @@ func (h *jiraHandler) update(ctx context.Context, db *DB, c jiraConfig, ev Event labels = []string{} } fields["labels"] = labels + if acct := resolveJiraAssignee(c, card); acct != "" { + fields["assignee"] = map[string]string{"accountId": acct} + } body := map[string]interface{}{"fields": fields} status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body) return status, err } +// resolveJiraAssignee maps the kanban card's assignee_id to a Jira accountId +// via the module's assignee_map. Returns "" when the card has no assignee or +// the assignee is not mapped, signalling to the caller to omit the field +// (avoids accidentally CLEARING an existing Jira assignee on every sync). +func resolveJiraAssignee(c jiraConfig, card *cardForJira) string { + if card == nil || card.AssigneeID == "" { + return "" + } + return c.AssigneeMap[card.AssigneeID] +} + // transition uses the configured status_map to translate the kanban column // to a Jira transition name. Kanban remains the source of truth even if // Jira's current state differs. diff --git a/backend/resync_jira.go b/backend/resync_jira.go new file mode 100644 index 0000000..1c55b64 --- /dev/null +++ b/backend/resync_jira.go @@ -0,0 +1,192 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// runResyncJiraFields patches every Jira issue currently linked to a kanban +// card so its issuetype / assignee / labels reflect the *latest* module +// configuration. Use cases: +// +// - We changed issue_type in the module (e.g. "Tarea Técnica" → "Epic") and +// need the backfilled issues to match. +// - We added/changed the assignee_map and want existing issues to pick up +// the mapping retroactively. +// - We renamed kanban columns and need labels re-applied. +// +// The CLI is idempotent: running it twice on the same set is a no-op for +// fields that already match. Batching mirrors `backfill-jira` so we stay +// under Jira's REST quota. +func runResyncJiraFields(args []string) error { + fs := flag.NewFlagSet("kanban resync-jira-fields", flag.ContinueOnError) + dbPath := fs.String("db", "operations.db", "SQLite database path") + batchSize := fs.Int("batch-size", 10, "Issues per batch before pausing") + pauseSec := fs.Int("pause-sec", 5, "Seconds to sleep between batches") + limit := fs.Int("limit", 0, "Maximum issues to patch (0 = no limit)") + doIssueType := fs.Bool("set-issuetype", true, "Set issuetype to module.issue_type") + doAssignee := fs.Bool("set-assignee", true, "Set assignee from module.assignee_map (or clear when no mapping)") + doLabels := fs.Bool("set-labels", false, "Re-apply labels from module.labels_map (off by default; labels were already correct after backfill)") + dryRun := fs.Bool("dry-run", false, "Print the planned PATCH for each issue and exit") + if err := fs.Parse(args); err != nil { + return err + } + + db, err := openDB(*dbPath) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + + _, cfg, err := activeJiraModule(db) + if err != nil { + return fmt.Errorf("module config: %w", err) + } + + cards, err := listLinkedJiraCards(db, *limit) + if err != nil { + return err + } + if len(cards) == 0 { + fmt.Println("no linked cards to resync") + return nil + } + + fmt.Printf("resync plan: %d issues; batch=%d pause=%ds dry_run=%v\n", + len(cards), *batchSize, *pauseSec, *dryRun) + fmt.Printf("ops: issuetype=%v(%q) assignee=%v(%d mappings) labels=%v\n", + *doIssueType, cfg.IssueType, *doAssignee, len(cfg.AssigneeMap), *doLabels) + fmt.Println() + + var ok, failed, noop int + for i, c := range cards { + if i > 0 && i%*batchSize == 0 { + fmt.Printf("--- batch boundary (%d/%d) — sleeping %ds ---\n", i, len(cards), *pauseSec) + time.Sleep(time.Duration(*pauseSec) * time.Second) + } + fields := map[string]interface{}{} + if *doIssueType && cfg.IssueType != "" { + fields["issuetype"] = map[string]string{"name": cfg.IssueType} + } + if *doAssignee { + acct := cfg.AssigneeMap[c.AssigneeID] + if acct != "" { + fields["assignee"] = map[string]string{"accountId": acct} + } + // We intentionally do NOT clear the assignee when the card has no + // mapping — that would overwrite a manual Jira assignment with + // nothing. To explicitly clear, the operator can remove the card's + // kanban assignee and trigger a card.updated event. + } + if *doLabels { + labels := cfg.LabelsMap[c.ColumnName] + if labels == nil { + labels = []string{} + } + fields["labels"] = labels + } + if len(fields) == 0 { + noop++ + fmt.Printf("[%4d/%4d] NOOP %s (no fields to patch)\n", i+1, len(cards), c.JiraKey) + continue + } + if *dryRun { + b, _ := json.Marshal(fields) + fmt.Printf("[%4d/%4d] PLAN %s fields=%s\n", i+1, len(cards), c.JiraKey, b) + continue + } + status, err := jiraPUTFields(context.Background(), cfg, c.JiraKey, fields) + if err != nil { + failed++ + fmt.Printf("[%4d/%4d] FAIL %s http=%d err=%s\n", + i+1, len(cards), c.JiraKey, status, truncateInline(err.Error(), 100)) + continue + } + ok++ + fmt.Printf("[%4d/%4d] OK %s\n", i+1, len(cards), c.JiraKey) + } + fmt.Println() + fmt.Printf("done: %d ok · %d noop · %d failed · %d total\n", ok, noop, failed, len(cards)) + if failed > 0 { + return fmt.Errorf("%d issues failed to patch", failed) + } + return nil +} + +// linkedJiraCard is the projection used by the resync CLI. We also pull +// assignee_id so the assignee_map lookup works without re-fetching cards. +type linkedJiraCard struct { + ID string + JiraKey string + ColumnName string + AssigneeID string +} + +func listLinkedJiraCards(db *DB, limit int) ([]linkedJiraCard, error) { + q := ` +SELECT c.id, c.jira_key, col.name, COALESCE(c.assignee_id, '') +FROM cards c +JOIN columns col ON col.id = c.column_id +WHERE c.jira_key != '' + AND c.deleted_at IS NULL +ORDER BY c.jira_key ASC +` + args := []interface{}{} + if limit > 0 { + q += " LIMIT ? " + args = append(args, limit) + } + rows, err := db.conn.Query(q, args...) + if err != nil { + return nil, fmt.Errorf("list linked cards: %w", err) + } + defer rows.Close() + out := []linkedJiraCard{} + for rows.Next() { + var c linkedJiraCard + if err := rows.Scan(&c.ID, &c.JiraKey, &c.ColumnName, &c.AssigneeID); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// jiraPUTFields is a thin wrapper around PUT /rest/api/3/issue/{key} that +// returns the HTTP status code + error. We do not need the response body — +// Jira returns 204 No Content on success. +func jiraPUTFields(ctx context.Context, c jiraConfig, key string, fields map[string]interface{}) (int, error) { + body := map[string]interface{}{"fields": fields} + raw, err := json.Marshal(body) + if err != nil { + return 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, + c.BaseURL+"/rest/api/3/issue/"+key, bytes.NewReader(raw)) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + basic := base64.StdEncoding.EncodeToString([]byte(c.Email + ":" + c.APIToken)) + req.Header.Set("Authorization", "Basic "+basic) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 400 { + return resp.StatusCode, fmt.Errorf("jira PUT %s: %d %s", + key, resp.StatusCode, truncateInline(strings.TrimSpace(string(respBody)), 240)) + } + return resp.StatusCode, nil +} diff --git a/backend/seed_jira.go b/backend/seed_jira.go index 25f7231..9f6d271 100644 --- a/backend/seed_jira.go +++ b/backend/seed_jira.go @@ -70,16 +70,26 @@ func runSeedJiraData(args []string) error { labelsMap := map[string][]string{ "Bloqueadas": {"blocked"}, } + // kanban user_id -> Jira accountId. Resolved via Jira /user/search; the + // three current data-team users keep stable IDs across sessions. New + // users added to the kanban must be added here (or the seed re-run with + // --pass-prefix overrides) so the dispatcher can route the assignee. + assigneeMap := map[string]string{ + "6a75edc6e99d8405": "712020:2cf3b82f-47d6-4597-b0e9-ffaaf3a07cc3", // Enmaa -> Enmanuel Gutierrez Perez + "039c97acf1869393": "712020:3f3ca9e1-c86e-445e-979a-bc7b82a4f45d", // alfon -> Alfonso Massaguer Gómez + "9e91db261084d529": "712020:feb5f7c5-7643-4381-977c-d83c95ba4955", // Nat -> Natalia Tajuelo Gomez + } cfg := JSONValue{ - "base_url": baseURL, - "email": email, - "api_token": token, - "project_key": *project, - "board_id": *board, - "issue_type": "Tarea Técnica", - "status_map": statusMap, - "labels_map": labelsMap, + "base_url": baseURL, + "email": email, + "api_token": token, + "project_key": *project, + "board_id": *board, + "issue_type": "Epic", + "status_map": statusMap, + "labels_map": labelsMap, + "assignee_map": assigneeMap, } // Upsert by name. Module name is the human-friendly identifier; we treat