feat(modules): jira scoped a project=DATA + board=33 con seed CLI desde pass
Cambios:
- jiraConfig: nuevo campo BoardID. TestConnection valida que board.location.projectKey
coincide con ProjectKey declarado. Refuse mismatched scopes so a typo in
project_key cannot create issues in the wrong project.
- backend/seed_jira.go: subcomando 'kanban seed-jira-data' lee credenciales
desde pass (jira/anjana/{email,api-token,domain}) e inserta module row con
kind=jira, project_key=DATA, board_id=33, event_filter sensible. Idempotente
(upsert por name). status_map vacio por defecto (operator lo edita por UI).
- main.go: wire del nuevo subcomando.
Requiere KANBAN_MODULE_KEY env var para encriptar/desencriptar config. El
servidor que ejecuta el dispatcher debe usar el mismo valor.
This commit is contained in:
@@ -45,6 +45,16 @@ func main() {
|
|||||||
return
|
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)
|
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
|
||||||
port := flags.Int("port", 8095, "HTTP port")
|
port := flags.Int("port", 8095, "HTTP port")
|
||||||
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
||||||
|
|||||||
+29
-2
@@ -484,6 +484,7 @@ type jiraConfig struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
APIToken string `json:"api_token"`
|
APIToken string `json:"api_token"`
|
||||||
ProjectKey string `json:"project_key"`
|
ProjectKey string `json:"project_key"`
|
||||||
|
BoardID int `json:"board_id"`
|
||||||
StatusMap map[string]string `json:"status_map"`
|
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
|
return 0, err
|
||||||
}
|
}
|
||||||
status, _, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/myself", nil)
|
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) {
|
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)
|
return h.update(ctx, db, c, ev)
|
||||||
}
|
}
|
||||||
if c.ProjectKey == "" {
|
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{}{
|
body := map[string]interface{}{
|
||||||
"fields": map[string]interface{}{
|
"fields": map[string]interface{}{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user