Skip to content

Commit 4e8cedc

Browse files
authored
Add Jira custom field support for push (#3678)
Allow Jira push to include configured project-specific fields such as Team. Custom field values can be plain strings or JSON objects/arrays for Jira field types that require structured payloads, and per-type config overrides global values after the Beads issue type is mapped to a Jira issue type.
1 parent 7d7ff50 commit 4e8cedc

4 files changed

Lines changed: 384 additions & 13 deletions

File tree

docs/CONFIG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,8 +663,18 @@ bd config set jira.status_map.closed "Done"
663663
bd config set jira.type_map.bug "Bug"
664664
bd config set jira.type_map.feature "Story"
665665
bd config set jira.type_map.task "Task"
666+
667+
# Set Jira custom fields on pushed issues
668+
bd config set jira.custom_fields.customfield_10042 '{"value":"AI Platform"}'
669+
bd config set jira.custom_fields.Story.customfield_10042 '{"value":"AI Platform"}'
666670
```
667671

672+
`jira.custom_fields.<field>` applies to every issue pushed to Jira.
673+
`jira.custom_fields.<JiraType>.<field>` applies only when the mapped Jira issue
674+
type matches `<JiraType>`; per-type fields override global fields with the same
675+
field key. Values beginning with `{` or `[` are sent as JSON, which is useful
676+
for select-like fields. Other values are sent as strings.
677+
668678
### Example: Linear Integration
669679

670680
Linear integration provides bidirectional sync between bd and Linear via GraphQL API.

internal/jira/fieldmapper.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import (
1010

1111
// jiraFieldMapper implements tracker.FieldMapper for Jira.
1212
type jiraFieldMapper struct {
13-
apiVersion string // "2" or "3" (default: "3")
14-
statusMap map[string]string // beads status → Jira status name (from jira.status_map.* config)
15-
typeMap map[string]string // beads type → Jira type (from jira.type_map.* config)
16-
priorityMap map[string]string // beads priority (as string "0"-"4") → Jira priority name (from jira.priority_map.* config)
13+
apiVersion string // "2" or "3" (default: "3")
14+
statusMap map[string]string // beads status → Jira status name (from jira.status_map.* config)
15+
typeMap map[string]string // beads type → Jira type (from jira.type_map.* config)
16+
priorityMap map[string]string // beads priority (as string "0"-"4") → Jira priority name (from jira.priority_map.* config)
17+
customFields map[string]interface{} // Jira field name/id → value (from jira.custom_fields.* config)
18+
typeCustomFields map[string]map[string]interface{} // Jira issue type → field name/id → value
1719
}
1820

1921
func (m *jiraFieldMapper) PriorityToBeads(trackerPriority interface{}) int {
@@ -216,6 +218,21 @@ func (m *jiraFieldMapper) IssueToTracker(issue *types.Issue) map[string]interfac
216218
fields["labels"] = issue.Labels
217219
}
218220

221+
for fieldName, value := range m.customFields {
222+
fields[fieldName] = value
223+
}
224+
225+
if name, ok := typeName.(string); ok {
226+
for jiraType, customFields := range m.typeCustomFields {
227+
if !strings.EqualFold(jiraType, name) {
228+
continue
229+
}
230+
for fieldName, value := range customFields {
231+
fields[fieldName] = value
232+
}
233+
}
234+
}
235+
219236
return fields
220237
}
221238

internal/jira/tracker.go

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package jira
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"os"
78
"strconv"
@@ -22,14 +23,16 @@ func init() {
2223

2324
// Tracker implements tracker.IssueTracker for Jira.
2425
type Tracker struct {
25-
client *Client
26-
store storage.Storage
27-
jiraURL string
28-
projectKeys []string // one or more project keys (first is primary)
29-
apiVersion string // "2" or "3" (default: "3")
30-
statusMap map[string]string // beads status → Jira status name (from jira.status_map.* config)
31-
typeMap map[string]string // beads type → Jira type (from jira.type_map.* config)
32-
priorityMap map[string]string // beads priority → Jira priority name (from jira.priority_map.* config)
26+
client *Client
27+
store storage.Storage
28+
jiraURL string
29+
projectKeys []string // one or more project keys (first is primary)
30+
apiVersion string // "2" or "3" (default: "3")
31+
statusMap map[string]string // beads status → Jira status name (from jira.status_map.* config)
32+
typeMap map[string]string // beads type → Jira type (from jira.type_map.* config)
33+
priorityMap map[string]string // beads priority → Jira priority name (from jira.priority_map.* config)
34+
customFields map[string]interface{} // Jira field name/id → value (from jira.custom_fields.* config)
35+
typeCustomFields map[string]map[string]interface{} // Jira issue type → Jira field name/id → value
3336
}
3437

3538
// SetProjectKeys sets project keys before Init(). When set, Init() uses these
@@ -124,6 +127,44 @@ func (t *Tracker) Init(ctx context.Context, store storage.Storage) error {
124127
if len(priorityMap) > 0 {
125128
t.priorityMap = priorityMap
126129
}
130+
131+
const customFieldPrefix = "jira.custom_fields."
132+
customFields := make(map[string]interface{})
133+
typeCustomFields := make(map[string]map[string]interface{})
134+
for key, val := range allConfig {
135+
if !strings.HasPrefix(key, customFieldPrefix) || strings.TrimSpace(val) == "" {
136+
continue
137+
}
138+
139+
suffix := strings.TrimPrefix(key, customFieldPrefix)
140+
if suffix == "" {
141+
continue
142+
}
143+
144+
parsed, err := parseJiraCustomFieldValue(val)
145+
if err != nil {
146+
return fmt.Errorf("parse %s: %w", key, err)
147+
}
148+
149+
parts := strings.SplitN(suffix, ".", 2)
150+
if len(parts) == 2 {
151+
if parts[0] == "" || parts[1] == "" {
152+
continue
153+
}
154+
if typeCustomFields[parts[0]] == nil {
155+
typeCustomFields[parts[0]] = make(map[string]interface{})
156+
}
157+
typeCustomFields[parts[0]][parts[1]] = parsed
158+
continue
159+
}
160+
customFields[suffix] = parsed
161+
}
162+
if len(customFields) > 0 {
163+
t.customFields = customFields
164+
}
165+
if len(typeCustomFields) > 0 {
166+
t.typeCustomFields = typeCustomFields
167+
}
127168
}
128169

129170
return nil
@@ -273,7 +314,14 @@ func (t *Tracker) applyTransition(ctx context.Context, key string, status types.
273314
}
274315

275316
func (t *Tracker) FieldMapper() tracker.FieldMapper {
276-
return &jiraFieldMapper{apiVersion: t.apiVersion, statusMap: t.statusMap, typeMap: t.typeMap, priorityMap: t.priorityMap}
317+
return &jiraFieldMapper{
318+
apiVersion: t.apiVersion,
319+
statusMap: t.statusMap,
320+
typeMap: t.typeMap,
321+
priorityMap: t.priorityMap,
322+
customFields: t.customFields,
323+
typeCustomFields: t.typeCustomFields,
324+
}
277325
}
278326

279327
func (t *Tracker) IsExternalRef(ref string) bool {
@@ -318,6 +366,21 @@ func (t *Tracker) getConfig(ctx context.Context, key, envVar string) (string, er
318366
return "", nil
319367
}
320368

369+
func parseJiraCustomFieldValue(value string) (interface{}, error) {
370+
trimmed := strings.TrimSpace(value)
371+
if trimmed == "" {
372+
return "", nil
373+
}
374+
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
375+
var parsed interface{}
376+
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
377+
return nil, err
378+
}
379+
return parsed, nil
380+
}
381+
return trimmed, nil
382+
}
383+
321384
// jiraToTrackerIssue converts a Jira API Issue to the generic TrackerIssue format.
322385
// priorityMap is optional (nil uses hardcoded defaults).
323386
func jiraToTrackerIssue(ji *Issue, priorityMap map[string]string) tracker.TrackerIssue {

0 commit comments

Comments
 (0)