From cd14e814870011104e2793584479056946606149 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Fri, 29 May 2026 14:37:56 +0200 Subject: [PATCH] feat(jira): kanban backfill-jira CLI con batches + ejecutado backfill 127 cards Subcomando 'kanban backfill-jira': - --batch-size N --pause-sec S: procesa N cards entre pausas para no saturar Jira REST. - --limit N: cap total. - --column NAME: filtro case-insensitive por columna kanban. - --dry-run: lista candidatos + counts por columna sin tocar Jira. Walk: cards con jira_key vacio, no borradas, no archivadas, ORDER BY created_at ASC. Por cada card: jiraHandler.Handle(card.created event) que crea issue + transition al status del status_map + labels. Tras success/failure updateCardJiraSync persiste jira_last_status, jira_last_sync_at, jira_last_error. Ejecutado contra Jira DATA project: 127 issues creadas (DATA-276..DATA-402), 0 fail. Distribucion final: Done: 85 (HECHO) In Progress: 18 (HACIENDO 7 + Bloqueadas 11, las ultimas con label 'blocked') IMPLEMENTADO: 14 (PNDNT FEEDBACK) To Do: 6 (DEUDA TECNICA) CREADO: 4 (IDEAS) --- backend/backfill_jira.go | 190 +++++++++++++++++++++++++++++++++++++++ backend/main.go | 10 +++ 2 files changed, 200 insertions(+) create mode 100644 backend/backfill_jira.go diff --git a/backend/backfill_jira.go b/backend/backfill_jira.go new file mode 100644 index 0000000..d45e5a6 --- /dev/null +++ b/backend/backfill_jira.go @@ -0,0 +1,190 @@ +package main + +import ( + "context" + "database/sql" + "flag" + "fmt" + "strings" + "time" +) + +// backfillCandidate is the minimal projection we read from the cards table +// for the backfill loop. Avoids loading the full Card row + history we do not +// need. +type backfillCandidate struct { + ID string + Title string + ColumnID string + ColumnName string +} + +// runBackfillJira walks every active kanban card that is not yet linked to a +// Jira issue and creates one via the configured jira module's handler. It is +// the only sanctioned way to backport existing kanban cards into Jira because +// the event dispatcher only ever fires on NEW kanban mutations. +// +// Batching: cards are processed in groups of --batch-size with a +// --pause-sec sleep between groups so we stay well below Jira's REST quota. +// --limit caps the total number of cards processed (0 = no cap). +// --column restricts to a single kanban column by name (case-insensitive). +// --dry-run lists the candidates without calling Jira. +func runBackfillJira(args []string) error { + fs := flag.NewFlagSet("kanban backfill-jira", flag.ContinueOnError) + dbPath := fs.String("db", "operations.db", "SQLite database path") + batchSize := fs.Int("batch-size", 10, "Cards processed per batch before pausing") + pauseSec := fs.Int("pause-sec", 5, "Seconds to sleep between batches") + limit := fs.Int("limit", 0, "Maximum cards to process total (0 = no limit)") + columnFilter := fs.String("column", "", "Only backfill cards whose column name matches (case-insensitive)") + dryRun := fs.Bool("dry-run", false, "List candidates and exit without calling Jira") + if err := fs.Parse(args); err != nil { + return err + } + if *batchSize <= 0 { + return fmt.Errorf("--batch-size must be > 0") + } + + db, err := openDB(*dbPath) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + + mod, cfg, err := activeJiraModule(db) + if err != nil { + return fmt.Errorf("module config: %w", err) + } + + candidates, err := listBackfillCandidates(db, *columnFilter, *limit) + if err != nil { + return err + } + if len(candidates) == 0 { + fmt.Println("no cards to backfill (all active cards have jira_key set or filter is empty)") + return nil + } + + fmt.Printf("backfill plan: %d cards across columns; batch=%d pause=%ds dry_run=%v\n", + len(candidates), *batchSize, *pauseSec, *dryRun) + fmt.Printf("module: %q (project=%s, board=%d, issue_type=%q)\n", + mod.Name, cfg.ProjectKey, cfg.BoardID, cfg.IssueType) + fmt.Println() + + if *dryRun { + printCandidates(candidates) + return nil + } + + h := &jiraHandler{} + var ok, failed int + for i := 0; i < len(candidates); i++ { + c := candidates[i] + if i > 0 && i%*batchSize == 0 { + fmt.Printf("--- batch boundary (%d/%d) — sleeping %ds ---\n", i, len(candidates), *pauseSec) + time.Sleep(time.Duration(*pauseSec) * time.Second) + } + status, err := h.Handle(context.Background(), db, mod, Event{ + Type: "card.created", + CardID: c.ID, + }) + now := time.Now().UTC().Format(time.RFC3339) + if err != nil { + failed++ + _ = db.updateCardJiraSync(c.ID, "", now, err.Error()) + fmt.Printf("[%4d/%4d] FAIL %-40s http=%d err=%s\n", + i+1, len(candidates), truncateInline(c.Title, 40), status, truncateInline(err.Error(), 80)) + continue + } + ok++ + statusName := cfg.StatusMap[c.ColumnName] + _ = db.updateCardJiraSync(c.ID, statusName, now, "") + // After Handle() the card row now carries the assigned jira_key. + linked, _ := db.lookupCardJiraKey(c.ID) + fmt.Printf("[%4d/%4d] OK %-40s -> %-12s status=%s\n", + i+1, len(candidates), truncateInline(c.Title, 40), linked, statusName) + } + fmt.Println() + fmt.Printf("done: %d ok · %d failed · %d total\n", ok, failed, len(candidates)) + if failed > 0 { + return fmt.Errorf("%d cards failed to backfill", failed) + } + return nil +} + +// listBackfillCandidates returns active (not deleted, not archived) cards +// without a Jira link, optionally filtered by column name. Newest-first so +// recent cards are mirrored first. +func listBackfillCandidates(db *DB, columnFilter string, limit int) ([]backfillCandidate, error) { + q := ` +SELECT c.id, c.title, c.column_id, col.name +FROM cards c +JOIN columns col ON col.id = c.column_id +WHERE (c.jira_key IS NULL OR c.jira_key = '') + AND c.deleted_at IS NULL + AND c.archived_at IS NULL +` + args := []interface{}{} + if strings.TrimSpace(columnFilter) != "" { + q += " AND LOWER(col.name) = LOWER(?) " + args = append(args, columnFilter) + } + q += " ORDER BY c.created_at ASC " + if limit > 0 { + q += " LIMIT ? " + args = append(args, limit) + } + rows, err := db.conn.Query(q, args...) + if err != nil { + return nil, fmt.Errorf("list candidates: %w", err) + } + defer rows.Close() + out := []backfillCandidate{} + for rows.Next() { + var c backfillCandidate + if err := rows.Scan(&c.ID, &c.Title, &c.ColumnID, &c.ColumnName); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// lookupCardJiraKey returns the jira_key column for a card, or empty string. +// Used to print the freshly-assigned key in the progress log. +func (db *DB) lookupCardJiraKey(cardID string) (string, error) { + var k sql.NullString + err := db.conn.QueryRow(`SELECT jira_key FROM cards WHERE id = ?`, cardID).Scan(&k) + if err != nil { + return "", err + } + if !k.Valid { + return "", nil + } + return k.String, nil +} + +func printCandidates(cs []backfillCandidate) { + byCol := map[string]int{} + for _, c := range cs { + byCol[c.ColumnName]++ + } + fmt.Println("candidates by column:") + for col, n := range byCol { + fmt.Printf(" %-25s %d\n", col, n) + } + fmt.Println() + fmt.Printf("first 20 of %d:\n", len(cs)) + for i, c := range cs { + if i >= 20 { + break + } + fmt.Printf(" %-12s [%s] %s\n", c.ID, c.ColumnName, truncateInline(c.Title, 60)) + } +} + +func truncateInline(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} diff --git a/backend/main.go b/backend/main.go index b927973..4675f81 100644 --- a/backend/main.go +++ b/backend/main.go @@ -55,6 +55,16 @@ func main() { return } + // Subcommand `kanban backfill-jira` mirrors every active kanban card that + // is not yet linked to a Jira issue into Jira, in batches. + if len(os.Args) > 1 && os.Args[1] == "backfill-jira" { + if err := runBackfillJira(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "kanban backfill-jira: %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")