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