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).
This commit is contained in:
2026-06-01 15:41:19 +02:00
parent 87e8f62544
commit 0687b65ea2
2 changed files with 69 additions and 1 deletions
+53
View File
@@ -679,6 +679,20 @@ type jiraConfig struct {
StatusMap map[string]string `json:"status_map"` // kanban_column_name -> Jira status name 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) 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 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) { func parseJiraConfig(m Module) (jiraConfig, error) {
@@ -823,6 +837,10 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event
if acct := resolveJiraAssignee(c, card); acct != "" { if acct := resolveJiraAssignee(c, card); acct != "" {
fields["assignee"] = map[string]string{"accountId": 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} body := map[string]interface{}{"fields": fields}
status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body) status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body)
if err != nil { if err != nil {
@@ -876,6 +894,9 @@ func (h *jiraHandler) update(ctx context.Context, db *DB, c jiraConfig, ev Event
if acct := resolveJiraAssignee(c, card); acct != "" { if acct := resolveJiraAssignee(c, card); acct != "" {
fields["assignee"] = map[string]string{"accountId": 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} body := map[string]interface{}{"fields": fields}
status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body) status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body)
return status, err return status, err
@@ -892,6 +913,38 @@ func resolveJiraAssignee(c jiraConfig, card *cardForJira) string {
return c.AssigneeMap[card.AssigneeID] 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 // transition uses the configured status_map to translate the kanban column
// to a Jira transition name. Kanban remains the source of truth even if // to a Jira transition name. Kanban remains the source of truth even if
// Jira's current state differs. // Jira's current state differs.
+16 -1
View File
@@ -33,6 +33,10 @@ func runSeedJiraData(args []string) error {
"Comma-separated event types this module subscribes to") "Comma-separated event types this module subscribes to")
enabled := fs.Bool("enabled", true, "Start with module enabled (true) or disabled (false)") 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}") 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
@@ -91,6 +95,10 @@ func runSeedJiraData(args []string) error {
"labels_map": labelsMap, "labels_map": labelsMap,
"assignee_map": assigneeMap, "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 // 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 // it as unique for the purposes of seeding so re-running this command does
@@ -111,7 +119,14 @@ func runSeedJiraData(args []string) error {
existing.Kind = "jira" existing.Kind = "jira"
existing.Enabled = *enabled existing.Enabled = *enabled
existing.EventFilter = splitCSV(*filter) existing.EventFilter = splitCSV(*filter)
existing.Config = cfg // 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 { if err := db.saveModule(existing); err != nil {
return fmt.Errorf("update module: %w", err) return fmt.Errorf("update module: %w", err)
} }