0687b65ea2
Project DATA's Epic and Mejora issue types mark customfield_10158
('Área Solicitante', a single-select) as required on the create screen.
The create payload omitted it, so enabling card.created sync produced
HTTP 400 'Solicitante is required'.
Add RequesterField/RequesterMap/RequesterDefault to jiraConfig. create()
and update() now inject the field as a {value:<option>} single-select,
resolved from the card requester via the map (case-insensitive) or the
default. Kanban requesters are person names, not departments, so cards
fall through to requester_default ('Transformación' for our setup).
seed-jira-data gains --requester-field (default customfield_10158) and
--requester-default (default Transformación); the existing-module branch
now merges config so operator UI edits (e.g. a requester_map) survive a
re-seed. Validated against Jira: Epic create with the field succeeds 201
(reporter auto-defaults to the token owner).
167 lines
6.0 KiB
Go
167 lines
6.0 KiB
Go
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
|
|
}
|