diff --git a/backend/main.go b/backend/main.go index dc6c9d7..b927973 100644 --- a/backend/main.go +++ b/backend/main.go @@ -45,6 +45,16 @@ func main() { return } + // Subcommand `kanban seed-jira-data` provisions the Jira push module + // scoped to project DATA + board 33 using pass-stored credentials. + if len(os.Args) > 1 && os.Args[1] == "seed-jira-data" { + if err := runSeedJiraData(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "kanban seed-jira-data: %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") diff --git a/backend/modules.go b/backend/modules.go index 3c5d124..979413d 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -484,6 +484,7 @@ type jiraConfig struct { Email string `json:"email"` APIToken string `json:"api_token"` ProjectKey string `json:"project_key"` + BoardID int `json:"board_id"` StatusMap map[string]string `json:"status_map"` } @@ -549,7 +550,33 @@ func (h *jiraHandler) TestConnection(ctx context.Context, m Module) (int, error) return 0, err } status, _, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/myself", nil) - return status, err + if err != nil { + return status, err + } + // If a board scope is configured, verify the board exists AND lives in + // the declared project. Refuse silently-mismatched configurations so a + // typo in project_key cannot create issues outside the intended board. + if c.BoardID > 0 { + bStatus, body, err := h.jiraRequest(ctx, c, http.MethodGet, + fmt.Sprintf("/rest/agile/1.0/board/%d", c.BoardID), nil) + if err != nil { + return bStatus, fmt.Errorf("board %d lookup: %w", c.BoardID, err) + } + var board struct { + Type string `json:"type"` + Location struct { + ProjectKey string `json:"projectKey"` + } `json:"location"` + } + if err := json.Unmarshal(body, &board); err != nil { + return bStatus, fmt.Errorf("decode board %d: %w", c.BoardID, err) + } + if c.ProjectKey != "" && !strings.EqualFold(board.Location.ProjectKey, c.ProjectKey) { + return 0, fmt.Errorf("board %d belongs to project %q, config declares %q", + c.BoardID, board.Location.ProjectKey, c.ProjectKey) + } + } + return status, nil } func (h *jiraHandler) Handle(ctx context.Context, db *DB, m Module, ev Event) (int, error) { @@ -586,7 +613,7 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event return h.update(ctx, db, c, ev) } if c.ProjectKey == "" { - return 0, fmt.Errorf("project_key required for create") + return 0, fmt.Errorf("project_key required for create (configure module before pushing)") } body := map[string]interface{}{ "fields": map[string]interface{}{ diff --git a/backend/seed_jira.go b/backend/seed_jira.go new file mode 100644 index 0000000..de7a13a --- /dev/null +++ b/backend/seed_jira.go @@ -0,0 +1,125 @@ +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}") + 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() + + cfg := JSONValue{ + "base_url": baseURL, + "email": email, + "api_token": token, + "project_key": *project, + "board_id": *board, + "status_map": map[string]string{}, // operator fills via UI (column name → Jira status) + } + + // 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) + existing.Config = cfg + 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 +}