Files
kanban/backend/modules.go
T
egutierrez 0687b65ea2 fix(jira): fill required 'Área Solicitante' on Epic create
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).
2026-06-01 15:41:19 +02:00

1061 lines
32 KiB
Go

package main
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
// =============================================================================
// Module model
// =============================================================================
type Module struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
EventFilter []string `json:"event_filter"`
Config JSONValue `json:"config"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// JSONValue is an arbitrary JSON object decoded into a generic map. We do not
// model per-kind config in Go types because the set of kinds grows over time
// and the dispatcher only inspects fields it knows.
type JSONValue map[string]interface{}
type ModuleLog struct {
ID string `json:"id"`
ModuleID string `json:"module_id"`
EventType string `json:"event_type"`
CardID string `json:"card_id"`
Status int `json:"status"`
DurationMs int `json:"duration_ms"`
Error string `json:"error"`
CreatedAt string `json:"created_at"`
}
// =============================================================================
// DB helpers (modules + logs)
// =============================================================================
// listModulesEnabled returns all enabled modules with their config decrypted.
// Disabled modules are silently skipped — callers iterate the result without
// further filtering.
func (db *DB) listModulesEnabled() ([]Module, error) {
return db.listModulesWhere("WHERE enabled = 1")
}
func (db *DB) listModulesAll() ([]Module, error) {
return db.listModulesWhere("")
}
func (db *DB) listModulesWhere(filter string) ([]Module, error) {
q := `SELECT id, name, kind, enabled, event_filter, config_cipher, config_nonce, created_at, updated_at FROM modules ` + filter + ` ORDER BY created_at`
rows, err := db.conn.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Module{}
for rows.Next() {
var m Module
var enabled int
var filter, createdAt, updatedAt string
var cipherBlob, nonce []byte
if err := rows.Scan(&m.ID, &m.Name, &m.Kind, &enabled, &filter, &cipherBlob, &nonce, &createdAt, &updatedAt); err != nil {
return nil, err
}
m.Enabled = enabled == 1
m.EventFilter = splitCSV(filter)
m.CreatedAt = createdAt
m.UpdatedAt = updatedAt
cfg, err := decryptConfig(cipherBlob, nonce)
if err != nil {
// Surface the decrypt failure so the operator notices but
// avoid dropping the module from the list entirely.
log.Printf("module %s: decrypt config: %v", m.ID, err)
m.Config = JSONValue{"_decrypt_error": err.Error()}
} else {
_ = json.Unmarshal(cfg, &m.Config)
}
out = append(out, m)
}
return out, rows.Err()
}
func (db *DB) getModule(id string) (*Module, error) {
mods, err := db.listModulesWhere(`WHERE id = '` + escapeSQL(id) + `'`)
if err != nil || len(mods) == 0 {
if err == nil {
err = sql.ErrNoRows
}
return nil, err
}
return &mods[0], nil
}
func escapeSQL(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func (db *DB) saveModule(m *Module) error {
cfgJSON, err := json.Marshal(m.Config)
if err != nil {
return err
}
cipherBlob, nonce, err := encryptConfig(cfgJSON)
if err != nil {
return err
}
now := nowRFC3339()
if m.ID == "" {
m.ID = newID()
m.CreatedAt = now
m.UpdatedAt = now
_, err = db.conn.Exec(
`INSERT INTO modules (id, name, kind, enabled, event_filter, config_cipher, config_nonce, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.ID, m.Name, m.Kind, boolInt(m.Enabled), strings.Join(m.EventFilter, ","),
cipherBlob, nonce, m.CreatedAt, m.UpdatedAt,
)
return err
}
m.UpdatedAt = now
_, err = db.conn.Exec(
`UPDATE modules SET name=?, kind=?, enabled=?, event_filter=?, config_cipher=?, config_nonce=?, updated_at=? WHERE id=?`,
m.Name, m.Kind, boolInt(m.Enabled), strings.Join(m.EventFilter, ","),
cipherBlob, nonce, m.UpdatedAt, m.ID,
)
return err
}
func (db *DB) deleteModule(id string) error {
_, err := db.conn.Exec(`DELETE FROM modules WHERE id=?`, id)
return err
}
func (db *DB) appendModuleLog(l ModuleLog) error {
if l.ID == "" {
l.ID = newID()
}
if l.CreatedAt == "" {
l.CreatedAt = nowRFC3339()
}
_, err := db.conn.Exec(
`INSERT INTO module_logs (id, module_id, event_type, card_id, status, duration_ms, error, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
l.ID, l.ModuleID, l.EventType, l.CardID, l.Status, l.DurationMs, l.Error, l.CreatedAt,
)
return err
}
func (db *DB) listModuleLogs(moduleID string, limit int) ([]ModuleLog, error) {
if limit <= 0 {
limit = 100
}
rows, err := db.conn.Query(
`SELECT id, module_id, event_type, card_id, status, duration_ms, error, created_at
FROM module_logs WHERE module_id = ? ORDER BY created_at DESC LIMIT ?`,
moduleID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ModuleLog{}
for rows.Next() {
var l ModuleLog
var cardID sql.NullString
if err := rows.Scan(&l.ID, &l.ModuleID, &l.EventType, &cardID, &l.Status, &l.DurationMs, &l.Error, &l.CreatedAt); err != nil {
return nil, err
}
if cardID.Valid {
l.CardID = cardID.String
}
out = append(out, l)
}
return out, rows.Err()
}
// setCardJiraKey stores the Jira issue key for a card after a successful
// create call. We skip the regular UpdateCard path to avoid emitting a
// `card.updated` event (which would loop us back through the dispatcher).
func (db *DB) setCardJiraKey(cardID, jiraKey string) error {
_, err := db.conn.Exec(`UPDATE cards SET jira_key=? WHERE id=?`, jiraKey, cardID)
return err
}
// listImportedJiraKeys returns a set of jira keys currently linked to any
// active kanban card. Used by the Jira import picker to filter out issues
// already present in the kanban.
func (db *DB) listImportedJiraKeys() (map[string]bool, error) {
rows, err := db.conn.Query(`SELECT jira_key FROM cards WHERE jira_key != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
out[k] = true
}
return out, rows.Err()
}
// listColumnsByName returns columns keyed by name for status-map reverse
// lookup during Jira import.
func (db *DB) listColumnsByName() (map[string]Column, error) {
cols, err := db.ListColumns()
if err != nil {
return nil, err
}
out := make(map[string]Column, len(cols))
for _, c := range cols {
out[c.Name] = c
}
return out, nil
}
// lookupCardColumnID returns the current column_id for a card, or "" if the
// card does not exist. Used by handleMoveCard to detect column changes vs
// same-column reorders before publishing card.moved events.
func (db *DB) lookupCardColumnID(cardID string) (string, error) {
var col sql.NullString
err := db.conn.QueryRow(`SELECT column_id FROM cards WHERE id = ?`, cardID).Scan(&col)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
if !col.Valid {
return "", nil
}
return col.String, nil
}
// findCardByJiraKey returns the id of the card linked to jiraKey, or "" if
// no card carries that link. The lookup ignores soft-deleted cards.
func (db *DB) findCardByJiraKey(jiraKey string) (string, error) {
var id string
err := db.conn.QueryRow(
`SELECT id FROM cards WHERE jira_key = ? AND deleted_at IS NULL LIMIT 1`,
jiraKey,
).Scan(&id)
if err == sql.ErrNoRows {
return "", nil
}
return id, err
}
// updateCardJiraSync updates the per-card sync-state columns. statusName is
// preserved when empty (so we do not blank it on events that do not change
// the Jira status, like comments).
func (db *DB) updateCardJiraSync(cardID, statusName, syncAt, errMsg string) error {
if statusName != "" {
_, err := db.conn.Exec(
`UPDATE cards SET jira_last_status=?, jira_last_sync_at=?, jira_last_error=? WHERE id=?`,
statusName, syncAt, errMsg, cardID,
)
return err
}
_, err := db.conn.Exec(
`UPDATE cards SET jira_last_sync_at=?, jira_last_error=? WHERE id=?`,
syncAt, errMsg, cardID,
)
return err
}
// CardJiraSyncState is the row returned by /api/cards/{id}/jira-sync.
type CardJiraSyncState struct {
CardID string `json:"card_id"`
JiraKey string `json:"jira_key"`
LastStatus string `json:"last_status"`
LastSyncAt string `json:"last_sync_at"`
LastError string `json:"last_error"`
Inflight bool `json:"inflight"`
IssueURL string `json:"issue_url,omitempty"`
}
// readCardJiraSync loads the persisted sync state for a card. Callers add the
// inflight flag + issue url separately because those depend on runtime state
// (dispatcher map) and module config (base url).
func (db *DB) readCardJiraSync(cardID string) (CardJiraSyncState, error) {
var s CardJiraSyncState
s.CardID = cardID
var jiraKey, lastStatus, lastSyncAt, lastError sql.NullString
err := db.conn.QueryRow(
`SELECT jira_key, jira_last_status, jira_last_sync_at, jira_last_error
FROM cards WHERE id = ?`, cardID,
).Scan(&jiraKey, &lastStatus, &lastSyncAt, &lastError)
if err != nil {
return s, err
}
if jiraKey.Valid {
s.JiraKey = jiraKey.String
}
if lastStatus.Valid {
s.LastStatus = lastStatus.String
}
if lastSyncAt.Valid {
s.LastSyncAt = lastSyncAt.String
}
if lastError.Valid {
s.LastError = lastError.String
}
return s, nil
}
func (db *DB) getCardForJira(cardID string) (*cardForJira, error) {
var c cardForJira
var assignee, deadline, jiraKey sql.NullString
var tagsJSON string
err := db.conn.QueryRow(
`SELECT c.id, c.title, c.description, c.requester, c.column_id, c.assignee_id,
c.deadline, c.tags, c.jira_key, c.created_at, col.name
FROM cards c JOIN columns col ON col.id = c.column_id WHERE c.id = ?`,
cardID,
).Scan(&c.ID, &c.Title, &c.Description, &c.Requester, &c.ColumnID, &assignee,
&deadline, &tagsJSON, &jiraKey, &c.CreatedAt, &c.ColumnName)
if err != nil {
return nil, err
}
if assignee.Valid {
c.AssigneeID = assignee.String
}
if deadline.Valid {
c.Deadline = deadline.String
}
if jiraKey.Valid {
c.JiraKey = jiraKey.String
}
_ = json.Unmarshal([]byte(tagsJSON), &c.Tags)
return &c, nil
}
type cardForJira struct {
ID string
Title string
Description string
Requester string
ColumnID string
ColumnName string
AssigneeID string
Deadline string
Tags []string
JiraKey string
CreatedAt string
}
func (c *cardForJira) hasTag(name string) bool {
name = strings.ToLower(name)
for _, t := range c.Tags {
if strings.ToLower(t) == name {
return true
}
}
return false
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// =============================================================================
// Dispatcher
// =============================================================================
const (
moduleRetries = 3
moduleRetryDelay1 = 1 * time.Second
moduleRetryDelay2 = 5 * time.Second
moduleRetryDelay3 = 30 * time.Second
moduleHTTPTimeout = 15 * time.Second
moduleOptOutTag = "nojira"
moduleDispatchQueue = 256
)
// Dispatcher fans events from the EventHub into per-module handlers.
//
// Lifecycle:
// - Start() spawns a single subscriber goroutine on the hub plus a
// bounded worker pool.
// - Stop() cancels the context and waits for in-flight requests to drain.
//
// Handlers receive a decrypted Module copy + the Event; they own the HTTP
// call to the target system. The dispatcher logs every attempt.
type Dispatcher struct {
db *DB
hub *EventHub
handlers map[string]Handler
queue chan dispatchTask
ctx context.Context
cancel context.CancelFunc
enabled bool
// inflight tracks cards whose sync is currently being attempted. Used by
// /api/cards/{id}/jira-sync to render the "yellow" state in the UI.
inflight sync.Map // map[cardID]struct{}
}
// IsInflight reports whether a sync attempt is currently being executed for
// the given card. Callers can use it to render a "syncing" indicator.
func (d *Dispatcher) IsInflight(cardID string) bool {
if d == nil {
return false
}
_, ok := d.inflight.Load(cardID)
return ok
}
type dispatchTask struct {
module Module
event Event
}
type Handler interface {
Handle(ctx context.Context, db *DB, m Module, ev Event) (status int, err error)
TestConnection(ctx context.Context, m Module) (status int, err error)
}
func NewDispatcher(db *DB, hub *EventHub) *Dispatcher {
_, hasKey := moduleKey()
return &Dispatcher{
db: db,
hub: hub,
handlers: map[string]Handler{"jira": &jiraHandler{}},
queue: make(chan dispatchTask, moduleDispatchQueue),
enabled: hasKey,
}
}
func (d *Dispatcher) Start() {
if !d.enabled {
log.Printf("module dispatcher disabled (%s not set)", moduleKeyEnv)
return
}
d.ctx, d.cancel = context.WithCancel(context.Background())
// Subscribe under a synthetic user so the hub treats us as a normal
// recipient of broadcast events. Private user-targeted events are
// uninteresting for outbound sync.
go d.run()
for i := 0; i < 4; i++ {
go d.worker(i)
}
log.Printf("module dispatcher started")
}
func (d *Dispatcher) Stop() {
if d.cancel != nil {
d.cancel()
}
}
func (d *Dispatcher) run() {
ch := d.hub.SubscribeUser("__module_dispatcher__")
defer d.hub.UnsubscribeUser("__module_dispatcher__", ch)
for {
select {
case <-d.ctx.Done():
return
case ev, ok := <-ch:
if !ok {
return
}
d.fanout(ev)
}
}
}
func (d *Dispatcher) fanout(ev Event) {
mods, err := d.db.listModulesEnabled()
if err != nil {
log.Printf("dispatcher: listModulesEnabled: %v", err)
return
}
for _, m := range mods {
if !filterMatches(m.EventFilter, ev.Type) {
continue
}
if !cutoffOK(d.db, m, ev) {
continue
}
if ev.CardID != "" {
c, err := d.db.getCardForJira(ev.CardID)
if err == nil && c.hasTag(moduleOptOutTag) {
continue
}
}
select {
case d.queue <- dispatchTask{module: m, event: ev}:
default:
log.Printf("dispatcher: queue full, dropping event %s for module %s", ev.Type, m.ID)
}
}
}
func (d *Dispatcher) worker(id int) {
for {
select {
case <-d.ctx.Done():
return
case task, ok := <-d.queue:
if !ok {
return
}
d.dispatch(task)
}
}
}
// dispatch runs the handler with up to moduleRetries attempts using a
// fixed back-off schedule (1s, 5s, 30s). Each attempt creates a log row;
// the final outcome is the one returned to the caller.
func (d *Dispatcher) dispatch(t dispatchTask) {
h, ok := d.handlers[t.module.Kind]
if !ok {
_ = d.db.appendModuleLog(ModuleLog{
ModuleID: t.module.ID, EventType: t.event.Type, CardID: t.event.CardID,
Error: "unknown module kind: " + t.module.Kind,
})
return
}
if t.event.CardID != "" {
d.inflight.Store(t.event.CardID, struct{}{})
defer d.inflight.Delete(t.event.CardID)
}
delays := []time.Duration{0, moduleRetryDelay1, moduleRetryDelay2, moduleRetryDelay3}
var lastErr error
var lastStatus int
for attempt := 0; attempt < moduleRetries; attempt++ {
if delays[attempt] > 0 {
select {
case <-d.ctx.Done():
return
case <-time.After(delays[attempt]):
}
}
ctx, cancel := context.WithTimeout(d.ctx, moduleHTTPTimeout)
start := time.Now()
status, err := h.Handle(ctx, d.db, t.module, t.event)
cancel()
ml := ModuleLog{
ModuleID: t.module.ID, EventType: t.event.Type, CardID: t.event.CardID,
Status: status, DurationMs: int(time.Since(start).Milliseconds()),
}
if err != nil {
ml.Error = err.Error()
}
_ = d.db.appendModuleLog(ml)
lastErr = err
lastStatus = status
if err == nil {
d.recordCardSyncSuccess(t.module, t.event)
return
}
// 4xx client errors are not worth retrying.
if status >= 400 && status < 500 {
break
}
}
// All retries exhausted (or stopped early on 4xx). Persist the failure
// so the UI can render the card as out-of-sync without polling Jira.
d.recordCardSyncFailure(t.event, lastErr, lastStatus)
}
// recordCardSyncSuccess persists the post-sync state to cards.jira_last_*
// columns. The "status" stored mirrors what we asked Jira to land at via the
// status_map; comment events leave the status field unchanged.
func (d *Dispatcher) recordCardSyncSuccess(m Module, ev Event) {
if ev.CardID == "" {
return
}
now := time.Now().UTC().Format(time.RFC3339)
var statusName string
if m.Kind == "jira" && ev.Type != "message.created" {
cfg, err := parseJiraConfig(m)
if err == nil {
card, cerr := d.db.getCardForJira(ev.CardID)
if cerr == nil {
statusName = cfg.StatusMap[card.ColumnName]
}
}
}
if err := d.db.updateCardJiraSync(ev.CardID, statusName, now, ""); err != nil {
log.Printf("dispatcher: updateCardJiraSync(success) %s: %v", ev.CardID, err)
}
}
func (d *Dispatcher) recordCardSyncFailure(ev Event, err error, status int) {
if ev.CardID == "" {
return
}
now := time.Now().UTC().Format(time.RFC3339)
msg := "sync failed"
if err != nil {
msg = err.Error()
}
if status > 0 {
msg = fmt.Sprintf("(http %d) %s", status, msg)
}
if uerr := d.db.updateCardJiraSync(ev.CardID, "", now, msg); uerr != nil {
log.Printf("dispatcher: updateCardJiraSync(failure) %s: %v", ev.CardID, uerr)
}
}
// =============================================================================
// Helpers
// =============================================================================
func filterMatches(filter []string, eventType string) bool {
for _, f := range filter {
if f == eventType || f == "*" {
return true
}
}
return false
}
// cutoffOK applies the "module only sees events posterior to its creation"
// rule. Cards that were already linked to Jira (jira_key != "") are always
// eligible regardless of timestamps.
func cutoffOK(db *DB, m Module, ev Event) bool {
if ev.CardID == "" {
return true
}
c, err := db.getCardForJira(ev.CardID)
if err != nil {
return false
}
if c.JiraKey != "" {
return true
}
return c.CreatedAt >= m.CreatedAt
}
// =============================================================================
// Jira handler
// =============================================================================
type jiraHandler struct{}
type jiraConfig struct {
BaseURL string `json:"base_url"`
Email string `json:"email"`
APIToken string `json:"api_token"`
ProjectKey string `json:"project_key"`
BoardID int `json:"board_id"`
IssueType string `json:"issue_type"` // Jira issuetype name applied on create
StatusMap map[string]string `json:"status_map"` // kanban_column_name -> Jira status name
LabelsMap map[string][]string `json:"labels_map,omitempty"` // kanban_column_name -> Jira labels (replaces every sync)
AssigneeMap map[string]string `json:"assignee_map,omitempty"` // kanban_user_id -> Jira accountId
// RequesterField is the Jira custom field id (e.g. "customfield_10158",
// "Área Solicitante") that some issue types (Epic, Mejora in project DATA)
// mark as required on the create screen. When set, create()/update() send a
// single-select option value resolved from the kanban card's requester.
RequesterField string `json:"requester_field,omitempty"`
// RequesterMap translates the free-text kanban requester to a Jira option
// value. Matched case-insensitively. Kanban requesters are usually person
// names, so most cards fall through to RequesterDefault.
RequesterMap map[string]string `json:"requester_map,omitempty"`
// RequesterDefault is the option value used when the card requester is
// empty or not present in RequesterMap. Required field never goes unfilled
// as long as this is set.
RequesterDefault string `json:"requester_default,omitempty"`
}
func parseJiraConfig(m Module) (jiraConfig, error) {
b, err := json.Marshal(m.Config)
if err != nil {
return jiraConfig{}, err
}
var c jiraConfig
if err := json.Unmarshal(b, &c); err != nil {
return jiraConfig{}, err
}
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
if c.BaseURL == "" {
return c, fmt.Errorf("base_url required")
}
if c.IssueType == "" {
c.IssueType = "Tarea Técnica"
}
return c, nil
}
func (h *jiraHandler) jiraRequest(ctx context.Context, c jiraConfig, method, path string, body interface{}) (int, []byte, error) {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
if err != nil {
return 0, nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Email != "" && c.APIToken != "" {
basic := base64.StdEncoding.EncodeToString([]byte(c.Email + ":" + c.APIToken))
req.Header.Set("Authorization", "Basic "+basic)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return resp.StatusCode, respBody, fmt.Errorf("jira %s %s: %d %s", method, path, resp.StatusCode, truncate(respBody, 240))
}
return resp.StatusCode, respBody, nil
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
func (h *jiraHandler) TestConnection(ctx context.Context, m Module) (int, error) {
c, err := parseJiraConfig(m)
if err != nil {
return 0, err
}
status, _, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/myself", nil)
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) {
c, err := parseJiraConfig(m)
if err != nil {
return 0, err
}
switch ev.Type {
case "card.created":
return h.create(ctx, db, c, ev)
case "card.updated", "board.invalidated":
return h.update(ctx, db, c, ev)
case "card.moved":
return h.transition(ctx, db, c, ev)
case "message.created":
return h.comment(ctx, db, c, ev)
default:
// Silently ignore unhandled event types so the dispatcher does not
// retry on irrelevant traffic.
return 200, nil
}
}
func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) {
if ev.CardID == "" {
return 0, nil
}
card, err := db.getCardForJira(ev.CardID)
if err != nil {
return 0, err
}
if card.JiraKey != "" {
// Idempotent: card already linked to Jira; treat as update.
return h.update(ctx, db, c, ev)
}
if c.ProjectKey == "" {
return 0, fmt.Errorf("project_key required for create (configure module before pushing)")
}
fields := map[string]interface{}{
"project": map[string]string{"key": c.ProjectKey},
"summary": card.Title,
"description": adfText(card.Description),
"issuetype": map[string]string{"name": c.IssueType},
}
if labels := c.LabelsMap[card.ColumnName]; len(labels) > 0 {
fields["labels"] = labels
}
if acct := resolveJiraAssignee(c, card); acct != "" {
fields["assignee"] = map[string]string{"accountId": acct}
}
// Epic / Mejora issue types require "Área Solicitante" on the create
// screen. Fill it from the card requester (mapped) or the default so the
// create does not 400 on a missing required field.
applyRequesterField(c, card, fields)
body := map[string]interface{}{"fields": fields}
status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body)
if err != nil {
return status, err
}
var parsed struct {
Key string `json:"key"`
}
_ = json.Unmarshal(resp, &parsed)
if parsed.Key == "" {
return status, fmt.Errorf("jira create returned empty key")
}
if err := db.setCardJiraKey(card.ID, parsed.Key); err != nil {
return status, fmt.Errorf("link jira key: %w", err)
}
// Jira places new issues in the workflow's initial status (typically
// CREADO / To Do for DATA). Drive a transition immediately so the issue
// lands in the column that mirrors where the card is in kanban.
if _, ok := c.StatusMap[card.ColumnName]; ok {
if _, err := h.transitionToStatus(ctx, c, parsed.Key, card.ColumnName); err != nil {
return status, fmt.Errorf("created %s but initial transition failed: %w", parsed.Key, err)
}
}
return status, nil
}
func (h *jiraHandler) update(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) {
if ev.CardID == "" {
return 0, nil
}
card, err := db.getCardForJira(ev.CardID)
if err != nil {
return 0, err
}
if card.JiraKey == "" {
// Card not yet linked — bootstrap by creating it.
return h.create(ctx, db, c, ev)
}
fields := map[string]interface{}{
"summary": card.Title,
"description": adfText(card.Description),
}
// Labels are derived from the current kanban column. We always send them
// (even an empty array) so a card that leaves a labelled column gets its
// label removed from Jira — PUT fields.labels REPLACES the whole array.
labels := c.LabelsMap[card.ColumnName]
if labels == nil {
labels = []string{}
}
fields["labels"] = labels
if acct := resolveJiraAssignee(c, card); acct != "" {
fields["assignee"] = map[string]string{"accountId": acct}
}
// Keep "Área Solicitante" populated on edits too — the field is required
// and a PUT that omits it can be rejected on the edit screen.
applyRequesterField(c, card, fields)
body := map[string]interface{}{"fields": fields}
status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body)
return status, err
}
// resolveJiraAssignee maps the kanban card's assignee_id to a Jira accountId
// via the module's assignee_map. Returns "" when the card has no assignee or
// the assignee is not mapped, signalling to the caller to omit the field
// (avoids accidentally CLEARING an existing Jira assignee on every sync).
func resolveJiraAssignee(c jiraConfig, card *cardForJira) string {
if card == nil || card.AssigneeID == "" {
return ""
}
return c.AssigneeMap[card.AssigneeID]
}
// resolveRequesterOption maps the card's requester to a Jira single-select
// option value for RequesterField. Lookup order: exact map hit, case-insensitive
// map hit, then RequesterDefault. Returns "" only when the field is unconfigured
// or no default exists, signalling the caller to omit it.
func resolveRequesterOption(c jiraConfig, card *cardForJira) string {
if c.RequesterField == "" {
return ""
}
if card != nil {
r := strings.TrimSpace(card.Requester)
if r != "" && len(c.RequesterMap) > 0 {
if v, ok := c.RequesterMap[r]; ok {
return v
}
for k, v := range c.RequesterMap {
if strings.EqualFold(k, r) {
return v
}
}
}
}
return c.RequesterDefault
}
// applyRequesterField injects RequesterField as a single-select option into a
// Jira fields map when configured and resolvable. No-op otherwise.
func applyRequesterField(c jiraConfig, card *cardForJira, fields map[string]interface{}) {
if opt := resolveRequesterOption(c, card); opt != "" {
fields[c.RequesterField] = map[string]string{"value": opt}
}
}
// transition uses the configured status_map to translate the kanban column
// to a Jira transition name. Kanban remains the source of truth even if
// Jira's current state differs.
func (h *jiraHandler) transition(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) {
if ev.CardID == "" {
return 0, nil
}
card, err := db.getCardForJira(ev.CardID)
if err != nil {
return 0, err
}
if card.JiraKey == "" {
return h.create(ctx, db, c, ev)
}
if _, ok := c.StatusMap[card.ColumnName]; !ok {
return 0, fmt.Errorf("no status_map entry for column %q", card.ColumnName)
}
return h.transitionToStatus(ctx, c, card.JiraKey, card.ColumnName)
}
// transitionToStatus drives a Jira issue to the status mapped from the given
// kanban column and refreshes labels accordingly. Used by transition() on
// card.moved events and by create() right after issue creation so new issues
// do not stall at the workflow's default initial status.
func (h *jiraHandler) transitionToStatus(ctx context.Context, c jiraConfig, jiraKey, columnName string) (int, error) {
target := c.StatusMap[columnName]
if target == "" {
return 0, fmt.Errorf("no status_map entry for column %q", columnName)
}
status, body, err := h.jiraRequest(ctx, c, http.MethodGet, "/rest/api/3/issue/"+jiraKey+"/transitions", nil)
if err != nil {
return status, err
}
var available struct {
Transitions []struct {
ID string `json:"id"`
Name string `json:"name"`
To struct {
Name string `json:"name"`
} `json:"to"`
} `json:"transitions"`
}
if err := json.Unmarshal(body, &available); err != nil {
return status, fmt.Errorf("decode transitions: %w", err)
}
var tID string
for _, t := range available.Transitions {
if strings.EqualFold(t.To.Name, target) || strings.EqualFold(t.Name, target) {
tID = t.ID
break
}
}
if tID == "" {
return 0, fmt.Errorf("transition %q not available for %s", target, jiraKey)
}
req := map[string]interface{}{"transition": map[string]string{"id": tID}}
status, _, err = h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue/"+jiraKey+"/transitions", req)
if err != nil {
return status, err
}
// Refresh labels to match the new column. Replaces the labels array; an
// empty list strips any stale labels from the previous column.
labels := c.LabelsMap[columnName]
if labels == nil {
labels = []string{}
}
lbody := map[string]interface{}{"fields": map[string]interface{}{"labels": labels}}
lStatus, _, lErr := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+jiraKey, lbody)
if lErr != nil {
return lStatus, fmt.Errorf("transition ok but labels sync failed: %w", lErr)
}
return status, nil
}
func (h *jiraHandler) comment(ctx context.Context, db *DB, c jiraConfig, ev Event) (int, error) {
if ev.CardID == "" {
return 0, nil
}
card, err := db.getCardForJira(ev.CardID)
if err != nil {
return 0, err
}
if card.JiraKey == "" {
// Cannot comment on a card not yet synced; skip.
return 0, nil
}
var payload struct {
Body string `json:"body"`
}
_ = json.Unmarshal(ev.Payload, &payload)
if payload.Body == "" {
return 0, nil
}
body := map[string]interface{}{"body": adfText(payload.Body)}
status, _, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue/"+card.JiraKey+"/comment", body)
return status, err
}
// adfText wraps a plain string into the minimal Atlassian Document Format
// fragment Jira Cloud requires for description / comment bodies.
func adfText(s string) map[string]interface{} {
return map[string]interface{}{
"type": "doc",
"version": 1,
"content": []map[string]interface{}{{
"type": "paragraph",
"content": []map[string]interface{}{{
"type": "text",
"text": s,
}},
}},
}
}