Files
kanban/backend/backfill_jira.go
T
egutierrez cd14e81487 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)
2026-05-29 14:37:56 +02:00

191 lines
5.7 KiB
Go

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] + "…"
}