diff --git a/backend/plugins/jira/tasks/issue_extractor.go b/backend/plugins/jira/tasks/issue_extractor.go index 94620711399..2fef63841fa 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 00000000000..f198e5b2256 --- /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 +}