package main import ( "flag" "fmt" "os/exec" "strings" ) // runSeedJiraData provisions (or updates) the Jira module that pushes kanban // changes to soporte-anjana.atlassian.net, project DATA, board 33. // // Credentials are read from `pass` so they never appear in argv or env. The // API token, email, and domain are loaded from the canonical entries: // // pass jira/anjana/api-token // pass jira/anjana/email // pass jira/anjana/domain // // Defaults can be overridden with flags (project, board, name, filter). // // Idempotent: if a module with the same name already exists, its config is // rewritten (encrypted at rest by saveModule). The kanban module key // (KANBAN_MODULE_KEY env var) must be set — the same value the running server // uses, otherwise the server cannot decrypt the secrets we wrote. func runSeedJiraData(args []string) error { fs := flag.NewFlagSet("kanban seed-jira-data", flag.ContinueOnError) dbPath := fs.String("db", "operations.db", "SQLite database path") name := fs.String("name", "Jira DATA", "Module display name (also used as upsert key)") project := fs.String("project", "DATA", "Jira project key (e.g. DATA)") board := fs.Int("board", 33, "Jira board id (Agile board; informational + validated at /test)") filter := fs.String("event-filter", "card.created,card.updated,card.moved,message.created", "Comma-separated event types this module subscribes to") enabled := fs.Bool("enabled", true, "Start with module enabled (true) or disabled (false)") passEntry := fs.String("pass-prefix", "jira/anjana", "pass entry prefix; reads ${prefix}/{email,api-token,domain}") requesterField := fs.String("requester-field", "customfield_10158", "Jira custom field id for the required 'Área Solicitante' select (empty to disable)") requesterDefault := fs.String("requester-default", "Transformación", "Default 'Área Solicitante' option value for auto-created cards whose requester is not mapped") if err := fs.Parse(args); err != nil { return err } email, err := passShow(*passEntry + "/email") if err != nil { return fmt.Errorf("read email from pass: %w", err) } token, err := passShow(*passEntry + "/api-token") if err != nil { return fmt.Errorf("read api-token from pass: %w", err) } domain, err := passShow(*passEntry + "/domain") if err != nil { return fmt.Errorf("read domain from pass: %w", err) } baseURL := "https://" + strings.TrimSpace(domain) db, err := openDB(*dbPath) if err != nil { return fmt.Errorf("open db: %w", err) } defer db.Close() // Default mapping for our setup: Kanban columns → Jira `Epicas en Data` board (33) // statuses. Operator can edit via the Modulos UI once the row exists. statusMap := map[string]string{ "HACIENDO 🚧": "In Progress", "PNDNT FEEDBACK ▶️": "IMPLEMENTADO", "HECHO ✅": "Done", "IDEAS 💡": "CREADO", "DEUDA TÉCNICA 🔄": "To Do", "Bloqueadas": "In Progress", } 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": "Epic", "status_map": statusMap, "labels_map": labelsMap, "assignee_map": assigneeMap, } if *requesterField != "" { cfg["requester_field"] = *requesterField cfg["requester_default"] = *requesterDefault } // Upsert by name. Module name is the human-friendly identifier; we treat // it as unique for the purposes of seeding so re-running this command does // not duplicate the row. mods, err := db.listModulesAll() if err != nil { return fmt.Errorf("list modules: %w", err) } var existing *Module for i := range mods { if mods[i].Name == *name { existing = &mods[i] break } } if existing != nil { existing.Kind = "jira" existing.Enabled = *enabled existing.EventFilter = splitCSV(*filter) // Merge so keys the operator added via the UI (e.g. a custom // requester_map) survive a re-seed. Seed-managed keys are refreshed. if existing.Config == nil { existing.Config = JSONValue{} } for k, v := range cfg { existing.Config[k] = v } if err := db.saveModule(existing); err != nil { return fmt.Errorf("update module: %w", err) } fmt.Printf("updated module %q (id=%s)\n", existing.Name, existing.ID) return nil } m := &Module{ Name: *name, Kind: "jira", Enabled: *enabled, EventFilter: splitCSV(*filter), Config: cfg, } if err := db.saveModule(m); err != nil { return fmt.Errorf("create module: %w", err) } fmt.Printf("created module %q (id=%s)\n", m.Name, m.ID) fmt.Printf("project: %s board: %d base_url: %s email: %s\n", *project, *board, baseURL, email) fmt.Println("\nnext steps:") fmt.Println(" 1. Edit status_map in the Modulos UI: map kanban column names to Jira statuses") fmt.Println(" (e.g. \"In Progress\" → \"In Progress\", \"Done\" → \"Done\")") fmt.Println(" 2. Click \"Test\" in the UI to verify board 33 belongs to project DATA") fmt.Println(" 3. Move a card in kanban — push should hit Jira REST API") return nil } // passShow shells out to pass(1) to read a secret. We do not cache or print // the value; just trim trailing whitespace before returning. func passShow(entry string) (string, error) { out, err := exec.Command("pass", "show", entry).Output() if err != nil { return "", fmt.Errorf("pass show %s: %w", entry, err) } return strings.TrimSpace(string(out)), nil }