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)
This commit is contained in:
@@ -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] + "…"
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user