This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new c9721ee11 fix(jira): honor EpicKeyField when extracting issues (#9067)
c9721ee11 is described below
commit c9721ee11f00ce311e4fc122492baa177d16618a
Author: Francisc Munteanu <[email protected]>
AuthorDate: Fri Aug 21 05:42:29 2026 +0200
fix(jira): honor EpicKeyField when extracting issues (#9067)
EpicKeyField is already on Jira scope config and in the Config UI, but
extractIssues never applied it, so Cloud/Parent Link epics stayed empty.
Co-authored-by: Cursor Grok 4.6 <[email protected]>
Co-authored-by: Cursor <[email protected]>
---
backend/plugins/jira/tasks/issue_extractor.go | 18 +++
backend/plugins/jira/tasks/issue_extractor_test.go | 126 +++++++++++++++++++++
2 files changed, 144 insertions(+)
diff --git a/backend/plugins/jira/tasks/issue_extractor.go
b/backend/plugins/jira/tasks/issue_extractor.go
index 946207113..2fef63841 100644
--- a/backend/plugins/jira/tasks/issue_extractor.go
+++ b/backend/plugins/jira/tasks/issue_extractor.go
@@ -74,6 +74,7 @@ func ExtractIssues(subtaskCtx plugin.SubTaskContext)
errors.Error {
"typeMappings": mappings,
"storyPointField":
data.Options.ScopeConfig.StoryPointField,
"dueDateField":
data.Options.ScopeConfig.DueDateField,
+ "epicKeyField":
data.Options.ScopeConfig.EpicKeyField,
},
},
BeforeExtract: func(apiIssue *apiv2models.Issue, stateManager
*api.SubtaskStateManager) errors.Error {
@@ -145,6 +146,23 @@ func extractIssues(data *JiraTaskData, mappings
*typeMappings, apiIssue *apiv2mo
}
}
+ // Honor the configured epic/parent-link custom field. Jira Cloud and
+ // company-managed projects often store the epic key there instead of
the
+ // legacy fields.epic object. Leave EpicKey unchanged when the field is
unset
+ // so the standard epic mapping from toToolLayer still applies.
+ if data.Options.ScopeConfig != nil &&
data.Options.ScopeConfig.EpicKeyField != "" {
+ unknownEpicKey :=
apiIssue.Fields.AllFields[data.Options.ScopeConfig.EpicKeyField]
+ switch ek := unknownEpicKey.(type) {
+ case string:
+ issue.EpicKey = ek
+ case map[string]interface{}:
+ if key, ok := ek["key"].(string); ok {
+ issue.EpicKey = key
+ }
+ case nil:
+ // Field is not set; keep EpicKey from the standard
epic field.
+ }
+ }
// default due date field is "duedate"
dueDateField := "duedate"
if data.Options.ScopeConfig != nil &&
data.Options.ScopeConfig.DueDateField != "" {
diff --git a/backend/plugins/jira/tasks/issue_extractor_test.go
b/backend/plugins/jira/tasks/issue_extractor_test.go
new file mode 100644
index 000000000..f198e5b22
--- /dev/null
+++ b/backend/plugins/jira/tasks/issue_extractor_test.go
@@ -0,0 +1,126 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/jira/models"
+ "github.com/apache/incubator-devlake/plugins/jira/tasks/apiv2models"
+)
+
+func TestExtractIssuesEpicKeyField(t *testing.T) {
+ const customField = "customfield_10014"
+ mappings := &typeMappings{
+ TypeIdMappings: map[string]string{},
+ StdTypeMappings: map[string]string{},
+ StandardStatusMappings: map[string]models.StatusMappings{},
+ }
+
+ tests := []struct {
+ name string
+ epicKeyField string
+ fieldsJSON string
+ wantEpicKey string
+ }{
+ {
+ name: "string custom field",
+ epicKeyField: customField,
+ fieldsJSON: fmt.Sprintf(`"%s": "EPIC-1"`,
customField),
+ wantEpicKey: "EPIC-1",
+ },
+ {
+ name: "object custom field",
+ epicKeyField: customField,
+ fieldsJSON: fmt.Sprintf(`"%s":
{"key":"EPIC-2","id":"123"}`, customField),
+ wantEpicKey: "EPIC-2",
+ },
+ {
+ name: "unset custom field keeps legacy epic",
+ epicKeyField: customField,
+ fieldsJSON: `"epic": {"key": "LEGACY-1"}`,
+ wantEpicKey: "LEGACY-1",
+ },
+ {
+ name: "empty EpicKeyField does not apply custom
field",
+ epicKeyField: "",
+ fieldsJSON: fmt.Sprintf(`"%s": "EPIC-1"`,
customField),
+ wantEpicKey: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ raw := minimalIssueJSON(tt.fieldsJSON)
+ var apiIssue apiv2models.Issue
+ if err := json.Unmarshal(raw, &apiIssue); err != nil {
+ t.Fatalf("unmarshal issue: %v", err)
+ }
+
+ data := &JiraTaskData{
+ Options: &JiraOptions{
+ ConnectionId: 1,
+ BoardId: 1,
+ ScopeConfig:
&models.JiraScopeConfig{EpicKeyField: tt.epicKeyField},
+ },
+ }
+ results, err := extractIssues(data, mappings,
&apiIssue, &api.RawData{Data: raw}, nil)
+ if err != nil {
+ t.Fatalf("extractIssues() error = %v", err)
+ }
+ issue := jiraIssueFromResults(results)
+ if issue == nil {
+ t.Fatal("extractIssues() did not return a
JiraIssue")
+ }
+ if issue.EpicKey != tt.wantEpicKey {
+ t.Errorf("EpicKey = %q, want %q",
issue.EpicKey, tt.wantEpicKey)
+ }
+ })
+ }
+}
+
+func minimalIssueJSON(fieldsExtra string) []byte {
+ if fieldsExtra != "" {
+ fieldsExtra = "," + fieldsExtra
+ }
+ return []byte(fmt.Sprintf(`{
+ "id": "10001",
+ "key": "TEST-1",
+ "self":
"https://example.atlassian.net/rest/agile/1.0/issue/10001",
+ "fields": {
+ "created": "2024-01-01T00:00:00.000+0000",
+ "updated": "2024-01-01T00:00:00.000+0000",
+ "summary": "test",
+ "issuetype": {"id": "1", "name": "Story", "subtask":
false},
+ "status": {"name": "To Do", "statusCategory": {"key":
"new"}}
+ %s
+ }
+ }`, fieldsExtra))
+}
+
+func jiraIssueFromResults(results []interface{}) *models.JiraIssue {
+ for _, r := range results {
+ if issue, ok := r.(*models.JiraIssue); ok {
+ return issue
+ }
+ }
+ return nil
+}