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
+}

Reply via email to