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/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 1c8dfe08 [feat-1681][jira]: Collect the epic issue in an issue's epic 
link (#2401)
1c8dfe08 is described below

commit 1c8dfe085d6d1440b97f45beb4934c231b2c45fd
Author: Keon Amini <[email protected]>
AuthorDate: Thu Jul 28 08:49:03 2022 -0500

    [feat-1681][jira]: Collect the epic issue in an issue's epic link (#2401)
    
    * feat(jira): collect the epic issue in an issue's epic link
    
    * test: added e2e test for external epic
    
    * fix: addressing changes per PR feedback
    
    * fix: add new subtasks
    
    * fix: added batching via iterators to the epic collector + improvements to 
iterator framework
    
    * fix: troubleshooting
    
    * fix: cyclic imports eliminated
---
 api/pipelines/pipelines.go                         |   4 +-
 helpers/e2ehelper/data_flow_tester.go              |  16 +-
 plugins/helper/iterator.go                         |  66 ++++---
 plugins/helper/list.go                             |  11 +-
 plugins/helper/queue.go                            |  35 +++-
 plugins/jenkins/models/job.go                      |   2 +-
 plugins/jira/e2e/epic_test.go                      | 130 ++++++++++++++
 .../jira/e2e/raw_tables/_raw_jira_api_boards.csv   |   1 +
 .../e2e/raw_tables/_raw_jira_api_issue_types.csv   |  44 +++++
 .../jira/e2e/raw_tables/_raw_jira_api_issues.csv   |   3 +
 .../e2e/raw_tables/_raw_jira_external_epics.csv    |   4 +
 .../_tool_jira_board_issues_for_external_epics.csv |   4 +
 .../_tool_jira_boards_for_external_epics.csv       |   2 +
 .../_tool_jira_issues_for_external_epics.csv       |   5 +
 plugins/jira/impl/impl.go                          |  11 +-
 plugins/jira/tasks/epic_collector.go               | 135 ++++++++++++++
 plugins/jira/tasks/epic_extractor.go               |  63 +++++++
 plugins/jira/tasks/issue_extractor.go              | 199 +++++++++++----------
 18 files changed, 604 insertions(+), 131 deletions(-)

diff --git a/api/pipelines/pipelines.go b/api/pipelines/pipelines.go
index 990101ba..931fa853 100644
--- a/api/pipelines/pipelines.go
+++ b/api/pipelines/pipelines.go
@@ -51,10 +51,10 @@ POST /pipelines
 // @Description }
 // @Tags pipelines
 // @Accept application/json
-// @Param pipeline body string true "json"
+// @Param pipeline body models.NewPipeline true "json"
 // @Success 200  {object} models.Pipeline
 // @Failure 400  {string} errcode.Error "Bad Request"
-// @Failure 500  {string} errcode.Error "Internel Error"
+// @Failure 500  {string} errcode.Error "Internal Error"
 // @Router /pipelines [post]
 func Post(c *gin.Context) {
        newPipeline := &models.NewPipeline{}
diff --git a/helpers/e2ehelper/data_flow_tester.go 
b/helpers/e2ehelper/data_flow_tester.go
index 378ccba0..60a30f81 100644
--- a/helpers/e2ehelper/data_flow_tester.go
+++ b/helpers/e2ehelper/data_flow_tester.go
@@ -180,13 +180,18 @@ func (t *DataFlowTester) FlushTabler(dst schema.Tabler) {
 
 // Subtask executes specified subtasks
 func (t *DataFlowTester) Subtask(subtaskMeta core.SubTaskMeta, taskData 
interface{}) {
-       subtaskCtx := helper.NewStandaloneSubTaskContext(context.Background(), 
t.Cfg, t.Log, t.Db, t.Name, taskData)
+       subtaskCtx := t.SubtaskContext(taskData)
        err := subtaskMeta.EntryPoint(subtaskCtx)
        if err != nil {
                panic(err)
        }
 }
 
+// SubtaskContext creates a subtask context
+func (t *DataFlowTester) SubtaskContext(taskData interface{}) 
core.SubTaskContext {
+       return helper.NewStandaloneSubTaskContext(context.Background(), t.Cfg, 
t.Log, t.Db, t.Name, taskData)
+}
+
 func filterColumn(column dal.ColumnMeta, opts TableOptions) bool {
        for _, ignore := range opts.IgnoreFields {
                if column.Name() == ignore {
@@ -241,6 +246,8 @@ func (t *DataFlowTester) CreateSnapshot(dst schema.Tabler, 
opts TableOptions) {
                        forScanValues[i] = new(sql.NullTime)
                } else if columnType.ScanType().Name() == `bool` {
                        forScanValues[i] = new(bool)
+               } else if columnType.ScanType().Name() == `RawBytes` {
+                       forScanValues[i] = new(sql.NullString)
                } else {
                        forScanValues[i] = new(string)
                }
@@ -267,6 +274,13 @@ func (t *DataFlowTester) CreateSnapshot(dst schema.Tabler, 
opts TableOptions) {
                                } else {
                                        values[i] = `0`
                                }
+                       case *sql.NullString:
+                               value := *forScanValues[i].(*sql.NullString)
+                               if value.Valid {
+                                       values[i] = value.String
+                               } else {
+                                       values[i] = ``
+                               }
                        case *string:
                                values[i] = 
fmt.Sprint(*forScanValues[i].(*string))
                        }
diff --git a/plugins/helper/iterator.go b/plugins/helper/iterator.go
index 7c8953b2..873b9adf 100644
--- a/plugins/helper/iterator.go
+++ b/plugins/helper/iterator.go
@@ -34,27 +34,41 @@ type Iterator interface {
 
 // DalCursorIterator FIXME ...
 type DalCursorIterator struct {
-       db       dal.Dal
-       cursor   *sql.Rows
-       elemType reflect.Type
+       db        dal.Dal
+       cursor    *sql.Rows
+       elemType  reflect.Type
+       batchSize int
 }
 
 // NewDalCursorIterator FIXME ...
 func NewDalCursorIterator(db dal.Dal, cursor *sql.Rows, elemType reflect.Type) 
(*DalCursorIterator, error) {
+       return NewBatchedDalCursorIterator(db, cursor, elemType, -1)
+}
+
+// NewBatchedDalCursorIterator FIXME ...
+func NewBatchedDalCursorIterator(db dal.Dal, cursor *sql.Rows, elemType 
reflect.Type, batchSize int) (*DalCursorIterator, error) {
        return &DalCursorIterator{
-               db:       db,
-               cursor:   cursor,
-               elemType: elemType,
+               db:        db,
+               cursor:    cursor,
+               elemType:  elemType,
+               batchSize: batchSize,
        }, nil
 }
 
-// HasNext FIXME ...
+// HasNext increments the row curser. If we're at the end, it'll return false.
 func (c *DalCursorIterator) HasNext() bool {
        return c.cursor.Next()
 }
 
-// Fetch FIXME ...
+// Fetch if batching is disabled, it'll read a single row, otherwise it'll 
read as many rows up to the batch size, and the
+// runtime return type will be []interface{}. Note, HasNext needs to have been 
called before invoking this.
 func (c *DalCursorIterator) Fetch() (interface{}, error) {
+       if c.batchSize > 0 {
+               return c.batchedFetch()
+       }
+       if c.batchSize != -1 {
+               panic("invalid batch size")
+       }
        elem := reflect.New(c.elemType).Interface()
        err := c.db.Fetch(c.cursor, elem)
        if err != nil {
@@ -63,7 +77,23 @@ func (c *DalCursorIterator) Fetch() (interface{}, error) {
        return elem, nil
 }
 
-// Close interator
+func (c *DalCursorIterator) batchedFetch() (interface{}, error) {
+       var elems []interface{}
+       for i := 1; ; i++ {
+               elem := reflect.New(c.elemType).Interface()
+               err := c.cursor.Scan(elem)
+               if err != nil {
+                       return nil, err
+               }
+               elems = append(elems, elem)
+               if i == c.batchSize || !c.HasNext() {
+                       break
+               }
+       }
+       return elems, nil
+}
+
+// Close iterator
 func (c *DalCursorIterator) Close() error {
        return c.cursor.Close()
 }
@@ -114,22 +144,6 @@ func NewDateIterator(days int) (*DateIterator, error) {
        }, nil
 }
 
-type QueueIteratorNode struct {
-       data interface{}
-       next *QueueIteratorNode
-}
-
-func (q *QueueIteratorNode) Next() interface{} {
-       if q.next == nil {
-               return nil
-       }
-       return q.next
-}
-
-func (q *QueueIteratorNode) SetNext(next interface{}) {
-       q.next, _ = next.(*QueueIteratorNode)
-}
-
 type QueueIterator struct {
        queue *Queue
 }
@@ -143,7 +157,7 @@ func (q *QueueIterator) Fetch() (interface{}, error) {
 }
 
 func (q *QueueIterator) Push(data QueueNode) {
-       q.queue.PushWitouLock(data)
+       q.queue.PushWithoutLock(data)
 }
 
 func (q *QueueIterator) Close() error {
diff --git a/plugins/helper/list.go b/plugins/helper/list.go
index b3a1381b..a130bfa2 100644
--- a/plugins/helper/list.go
+++ b/plugins/helper/list.go
@@ -17,8 +17,13 @@ limitations under the License.
 
 package helper
 
+// ListBaseNode 'abstract' base struct for Nodes that are chained in a linked 
list manner
 type ListBaseNode struct {
-       next interface{}
+       next *ListBaseNode
+}
+
+func (l *ListBaseNode) Data() interface{} {
+       panic("list node Data() needs to be implemented by subclasses")
 }
 
 func (l *ListBaseNode) Next() interface{} {
@@ -29,10 +34,10 @@ func (l *ListBaseNode) Next() interface{} {
 }
 
 func (l *ListBaseNode) SetNext(next interface{}) {
-       l.next = next
+       l.next = next.(*ListBaseNode)
 }
 
-// NewListBaseNode create and init a new node
+// NewListBaseNode create and init a new node (only to be called by subclasses)
 func NewListBaseNode() *ListBaseNode {
        return &ListBaseNode{
                next: nil,
diff --git a/plugins/helper/queue.go b/plugins/helper/queue.go
index b1e74783..edf58d62 100644
--- a/plugins/helper/queue.go
+++ b/plugins/helper/queue.go
@@ -25,6 +25,7 @@ import (
 type QueueNode interface {
        Next() interface{}
        SetNext(next interface{})
+       Data() interface{}
 }
 
 type Queue struct {
@@ -38,7 +39,7 @@ type Queue struct {
 func (q *Queue) Push(node QueueNode) {
        q.mux.Lock()
        defer q.mux.Unlock()
-       q.PushWitouLock(node)
+       q.PushWithoutLock(node)
 }
 
 // Pull get a node from queue
@@ -57,8 +58,8 @@ func (q *Queue) Pull(add *int64) QueueNode {
        return node
 }
 
-// PushWitouLock is no lock mode of Push
-func (q *Queue) PushWitouLock(node QueueNode) {
+// PushWithoutLock is no lock mode of Push
+func (q *Queue) PushWithoutLock(node QueueNode) {
        if q.tail == nil {
                q.head = node
                q.tail = node
@@ -70,7 +71,7 @@ func (q *Queue) PushWitouLock(node QueueNode) {
        }
 }
 
-// PullWitouLock is no lock mode of Pull
+// PullWithOutLock is no lock mode of Pull
 func (q *Queue) PullWithOutLock() QueueNode {
        var node QueueNode = nil
 
@@ -125,3 +126,29 @@ func NewQueue() *Queue {
                mux:   sync.Mutex{},
        }
 }
+
+type QueueIteratorNode struct {
+       next *QueueIteratorNode
+       data interface{}
+}
+
+func (q *QueueIteratorNode) Next() interface{} {
+       if q.next == nil {
+               return nil
+       }
+       return q.next
+}
+
+func (q *QueueIteratorNode) SetNext(next interface{}) {
+       q.next, _ = next.(*QueueIteratorNode)
+}
+
+func (q *QueueIteratorNode) Data() interface{} {
+       return q.data
+}
+
+func NewQueueIteratorNode(data interface{}) *QueueIteratorNode {
+       return &QueueIteratorNode{
+               data: data,
+       }
+}
diff --git a/plugins/jenkins/models/job.go b/plugins/jenkins/models/job.go
index 13b0fd9d..0a406999 100644
--- a/plugins/jenkins/models/job.go
+++ b/plugins/jenkins/models/job.go
@@ -44,8 +44,8 @@ func (JenkinsJob) TableName() string {
 }
 
 type FolderInput struct {
-       Path string
        *helper.ListBaseNode
+       Path string
 }
 
 func NewFolderInput(path string) *FolderInput {
diff --git a/plugins/jira/e2e/epic_test.go b/plugins/jira/e2e/epic_test.go
new file mode 100644
index 00000000..e0f3ef3f
--- /dev/null
+++ b/plugins/jira/e2e/epic_test.go
@@ -0,0 +1,130 @@
+/*
+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 e2e
+
+import (
+       "github.com/apache/incubator-devlake/helpers/e2ehelper"
+       "github.com/apache/incubator-devlake/models/common"
+       "github.com/apache/incubator-devlake/plugins/jira/impl"
+       "github.com/apache/incubator-devlake/plugins/jira/models"
+       "github.com/apache/incubator-devlake/plugins/jira/tasks"
+       "github.com/stretchr/testify/require"
+       "testing"
+)
+
+func TestEpicDataflow(t *testing.T) {
+       var plugin impl.Jira
+       dataflowTester := e2ehelper.NewDataFlowTester(t, "jira", plugin)
+       taskData := &tasks.JiraTaskData{
+               Options: &tasks.JiraOptions{
+                       ConnectionId:        1,
+                       BoardId:             93,
+                       TransformationRules: 
tasks.TransformationRules{StoryPointField: "customfield_10024"},
+               },
+       }
+
+       
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_jira_api_issue_types.csv",
 "_raw_jira_api_issue_types")
+       
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_jira_api_issues.csv", 
"_raw_jira_api_issues")
+       
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_jira_external_epics.csv",
 "_raw_jira_api_epics")
+
+       dataflowTester.FlushTabler(&models.JiraIssue{})
+       dataflowTester.FlushTabler(&models.JiraBoardIssue{})
+       dataflowTester.FlushTabler(&models.JiraSprintIssue{})
+       dataflowTester.FlushTabler(&models.JiraIssueChangelogs{})
+       dataflowTester.FlushTabler(&models.JiraIssueChangelogItems{})
+       dataflowTester.FlushTabler(&models.JiraWorklog{})
+       dataflowTester.FlushTabler(&models.JiraAccount{})
+       dataflowTester.FlushTabler(&models.JiraIssueType{})
+
+       ctx := dataflowTester.SubtaskContext(taskData)
+
+       // run pre-req subtasks
+       require.NoError(t, tasks.ExtractIssueTypesMeta.EntryPoint(ctx))
+       require.NoError(t, tasks.ExtractIssuesMeta.EntryPoint(ctx))
+       dataflowTester.VerifyTableWithOptions(
+               models.JiraIssue{}, e2ehelper.TableOptions{
+                       CSVRelPath:   
"./snapshot_tables/_tool_jira_issues_for_external_epics.csv",
+                       TargetFields: nil,
+                       IgnoreFields: nil,
+                       IgnoreTypes:  []interface{}{common.NoPKModel{}},
+               },
+       )
+       dataflowTester.VerifyTableWithOptions(
+               models.JiraBoardIssue{}, e2ehelper.TableOptions{
+                       CSVRelPath:   
"./snapshot_tables/_tool_jira_board_issues_for_external_epics.csv",
+                       TargetFields: []string{"connection_id", "board_id", 
"issue_id"},
+                       IgnoreFields: nil,
+                       IgnoreTypes:  []interface{}{common.NoPKModel{}},
+               },
+       )
+       t.Run("batch_single", func(t *testing.T) {
+               // run the part of the collector that queries tools data
+               iter, err := tasks.GetEpicKeysIterator(ctx.GetDal(), taskData, 
1)
+               require.NoError(t, err)
+               require.True(t, iter.HasNext())
+               e1, err := iter.Fetch()
+               require.NoError(t, err)
+               require.True(t, iter.HasNext())
+               e2, err := iter.Fetch()
+               require.NoError(t, err)
+               require.False(t, iter.HasNext())
+               require.Equal(t, 1, len(e1.([]interface{})))
+               require.Equal(t, 1, len(e2.([]interface{})))
+               epicKeys := []string{
+                       *(e1.([]interface{})[0].(*string)),
+                       *(e2.([]interface{})[0].(*string)),
+               }
+               require.Contains(t, epicKeys, "K5-1")
+               require.Contains(t, epicKeys, "K5-4")
+       })
+       t.Run("batch_multiple", func(t *testing.T) {
+               // run the part of the collector that queries tools data
+               iter, err := tasks.GetEpicKeysIterator(ctx.GetDal(), taskData, 
2)
+               require.NoError(t, err)
+               require.True(t, iter.HasNext())
+               e, err := iter.Fetch()
+               require.NoError(t, err)
+               require.False(t, iter.HasNext())
+               require.Equal(t, 2, len(e.([]interface{})))
+               epicKeys := []string{
+                       *(e.([]interface{})[0].(*string)),
+                       *(e.([]interface{})[1].(*string)),
+               }
+               require.Contains(t, epicKeys, "K5-1")
+               require.Contains(t, epicKeys, "K5-4")
+       })
+
+       require.NoError(t, tasks.ExtractEpicsMeta.EntryPoint(ctx))
+
+       dataflowTester.VerifyTableWithOptions(
+               models.JiraBoardIssue{}, e2ehelper.TableOptions{
+                       CSVRelPath:   
"./snapshot_tables/_tool_jira_board_issues_for_external_epics.csv",
+                       TargetFields: nil,
+                       IgnoreFields: nil,
+                       IgnoreTypes:  []interface{}{common.NoPKModel{}},
+               },
+       )
+       dataflowTester.VerifyTableWithOptions(
+               models.JiraIssue{}, e2ehelper.TableOptions{
+                       CSVRelPath:   
"./snapshot_tables/_tool_jira_issues_for_external_epics.csv",
+                       TargetFields: nil,
+                       IgnoreFields: nil,
+                       IgnoreTypes:  []interface{}{common.NoPKModel{}},
+               },
+       )
+}
diff --git a/plugins/jira/e2e/raw_tables/_raw_jira_api_boards.csv 
b/plugins/jira/e2e/raw_tables/_raw_jira_api_boards.csv
index 99f53629..7705a32d 100644
--- a/plugins/jira/e2e/raw_tables/_raw_jira_api_boards.csv
+++ b/plugins/jira/e2e/raw_tables/_raw_jira_api_boards.csv
@@ -1,2 +1,3 @@
 "id","params","data","url","input","created_at"
 7,"{""ConnectionId"":2,""BoardId"":8}","{""id"": 8, ""name"": ""迭代开发看板(停用)"", 
""self"": ""https://merico.atlassian.net/rest/agile/1.0/board/8"";, ""type"": 
""scrum"", ""location"": {""name"": ""Enterprise Edition (EE)"", ""avatarURI"": 
""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10552?size=small"";,
 ""projectId"": 10003, ""projectKey"": ""EE"", ""displayName"": ""Enterprise 
Edition (EE)"", ""projectName"": ""Enterprise Edition"", ""projectTypeKey"":  
[...]
+1,"{""ConnectionId"":1,""BoardId"":93}","{""id"":93,""self"":""https://merico.atlassian.net/rest/agile/1.0/board/93"",""name"":""test_board"",""type"":""kanban"",""location"":{""projectId"":10050,""displayName"":""Keon_5
 
(K5)"",""projectName"":""Keon_5"",""projectKey"":""K5"",""projectTypeKey"":""software"",""avatarURI"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10410?size=small"",""name"":""Keon_5
 (K5)""}}",https://merico.atlassian.net/rest/agil [...]
diff --git a/plugins/jira/e2e/raw_tables/_raw_jira_api_issue_types.csv 
b/plugins/jira/e2e/raw_tables/_raw_jira_api_issue_types.csv
index 37062b59..11934417 100644
--- a/plugins/jira/e2e/raw_tables/_raw_jira_api_issue_types.csv
+++ b/plugins/jira/e2e/raw_tables/_raw_jira_api_issue_types.csv
@@ -43,3 +43,47 @@ id,params,data,url,input,created_at
 
2,"{""ConnectionId"":2,""BoardId"":8}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10128"",""id"":""10128"",""description"":""Subtasks
 track small pieces of work that are part of a larger 
task."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10316?size=medium"",""name"":""Subtask"",""untranslatedName"":""Subtask"",""subtask"":true,""avatarId"":10316,""hierarchyLevel"":-1,""scope"":{""type"":""PROJECT"",""project"":{"";
 [...]
 
37,"{""ConnectionId"":2,""BoardId"":8}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10132"",""id"":""10132"",""description"":""问题或错误。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium"",""name"":""缺陷"",""untranslatedName"":""Bug"",""subtask"":false,""avatarId"":10303,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10046""}}}",https://merico.atlassian.net/rest/api/3/issuetype,
 [...]
 
10,"{""ConnectionId"":2,""BoardId"":8}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10133"",""id"":""10133"",""description"":""表述为用户目标的功能。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium"",""name"":""故事"",""untranslatedName"":""Story"",""subtask"":false,""avatarId"":10315,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10046""}}}",https://merico.atlassian.net/rest/api/3/iss
 [...]
+45,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10077"",""id"":""10077"",""description"":""Subtasks
 track small pieces of work that are part of a larger 
task."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10316?size=medium"",""name"":""Subtask"",""untranslatedName"":""Subtask"",""subtask"":true,""avatarId"":10316,""hierarchyLevel"":-1,""scope"":{""type"":""PROJECT"",""project"":{
 [...]
+46,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10128"",""id"":""10128"",""description"":""Subtasks
 track small pieces of work that are part of a larger 
task."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10316?size=medium"",""name"":""Subtask"",""untranslatedName"":""Subtask"",""subtask"":true,""avatarId"":10316,""hierarchyLevel"":-1,""scope"":{""type"":""PROJECT"",""project"":{
 [...]
+47,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10091"",""id"":""10091"",""description"":""非业务的测试开发工作,例如开发测试平台"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium"",""name"":""测试开发任务"",""untranslatedName"":""测试开发任务"",""subtask"":false,""avatarId"":10318,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+48,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10069"",""id"":""10069"",""description"":"""",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10300?size=medium"",""name"":""疑问"",""untranslatedName"":""疑问"",""subtask"":false,""avatarId"":10300,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+49,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10074"",""id"":""10074"",""description"":""Tasks
 track small, distinct pieces of 
work."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium"",""name"":""Task"",""untranslatedName"":""Task"",""subtask"":false,""avatarId"":10318,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10033""}}}",https://m
 [...]
+50,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10051"",""id"":""10051"",""description"":""Tasks
 track small, distinct pieces of 
work."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium"",""name"":""任务"",""untranslatedName"":""任务"",""subtask"":false,""avatarId"":10318,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10022""}}}",https://meric
 [...]
+51,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10040"",""id"":""10040"",""description"":""For
 system outages or incidents. Created by Jira Service 
Desk."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10553?size=medium"",""name"":""Incident"",""untranslatedName"":""Incident"",""subtask"":false,""avatarId"":10553,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuet
 [...]
+52,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10032"",""id"":""10032"",""description"":""对产品的任何疑问或建议,问题由产品经理定期进行处理。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10300?size=medium"",""name"":""Question"",""untranslatedName"":""Question"",""subtask"":false,""avatarId"":10300,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+53,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10073"",""id"":""10073"",""description"":""Stories
 track functionality or features expressed as user 
goals."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium"",""name"":""Story"",""untranslatedName"":""Story"",""subtask"":false,""avatarId"":10315,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":
 [...]
+54,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10133"",""id"":""10133"",""description"":""Stories
 track functionality or features expressed as user 
goals."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium"",""name"":""Story"",""untranslatedName"":""Story"",""subtask"":false,""avatarId"":10315,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":
 [...]
+55,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10072"",""id"":""10072"",""description"":""技术债需求"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10321?size=medium"",""name"":""Tech
 Story"",""untranslatedName"":""Tech 
Story"",""subtask"":false,""avatarId"":10321,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+56,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10000"",""id"":""10000"",""description"":""讲述一个业务,包含多个Story,可能垮Sprint的工作任务,例如效率报表。"",""iconUrl"":""https://merico.atlassian.net/images/icons/issuetypes/epic.svg"",""name"":""Epic"",""untranslatedName"":""Epic"",""subtask"":false,""hierarchyLevel"":1}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+57,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10107"",""id"":""10107"",""description"":""This
 is the Xray Sub Test Execution Issue Type. Used to execute test cases already 
defined. A Sub Test Execution can be created for a parent issue like a 
requirement in order to execute the test cases associated with 
it."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10566?size=medium"",""na
 [...]
+58,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10078"",""id"":""10078"",""description"":"""",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium"",""name"":""测试任务"",""untranslatedName"":""测试任务"",""subtask"":false,""avatarId"":10318,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+59,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10090"",""id"":""10090"",""description"":""Subtasks
 track small pieces of work that are part of a larger 
task."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10316?size=medium"",""name"":""Subtask"",""untranslatedName"":""Subtask"",""subtask"":true,""avatarId"":10316,""hierarchyLevel"":-1,""scope"":{""type"":""PROJECT"",""project"":{
 [...]
+60,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10004"",""id"":""10004"",""description"":""测试发现的系统缺陷。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium"",""name"":""Bug"",""untranslatedName"":""Bug"",""subtask"":false,""avatarId"":10303,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+61,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10075"",""id"":""10075"",""description"":""Bugs
 track problems or 
errors."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium"",""name"":""Bug"",""untranslatedName"":""Bug"",""subtask"":false,""avatarId"":10303,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10033""}}}",https://merico.atlassian
 [...]
+62,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10047"",""id"":""10047"",""description"":""客户提出的一种改进方案。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10322?size=medium"",""name"":""Suggestion"",""untranslatedName"":""Suggestion"",""subtask"":false,""avatarId"":10322,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+63,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10052"",""id"":""10052"",""description"":""长篇故事追踪相关缺陷、故事和任务集。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10307?size=medium"",""name"":""项目"",""untranslatedName"":""项目"",""subtask"":false,""avatarId"":10307,""hierarchyLevel"":1,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10022""}}}",https://merico.atlassian.net/rest/api/
 [...]
+64,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10001"",""id"":""10001"",""description"":""具体一个功能点,以用户角度,大小适中,可以满足某一个需要为原则。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium"",""name"":""Story"",""untranslatedName"":""Story"",""subtask"":false,""avatarId"":10315,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+65,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10102"",""id"":""10102"",""description"":""This
 is the Xray Test Issue Type. Used to define test cases of different types that 
can be executed multiple times using Test Execution 
issues."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10561?size=medium"",""name"":""Test"",""untranslatedName"":""Test"",""subtask"":false,""avatarId"":10
 [...]
+66,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10031"",""id"":""10031"",""description"":""技术性任务或需求。包括技术债和用户故事的拆解。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium"",""name"":""Task"",""untranslatedName"":""Task"",""subtask"":false,""avatarId"":10318,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+67,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10106"",""id"":""10106"",""description"":""This
 is the Xray Precondition Issue Type. Used to abstract common actions that must 
be ensured before the test case execution. A Precondition can be associated 
with multiple test 
cases."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10565?size=medium"",""name"":""Precondition"",""untranslate
 [...]
+68,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10053"",""id"":""10053"",""description"":""子任务跟踪大任务中的小任务。"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10316?size=medium"",""name"":""子任务"",""untranslatedName"":""子任务"",""subtask"":true,""avatarId"":10316,""hierarchyLevel"":-1,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10022""}}}",https://merico.atlassian.net/rest/api/3/
 [...]
+69,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10101"",""id"":""10101"",""description"":""Subtasks
 track small pieces of work that are part of a larger 
task."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10316?size=medium"",""name"":""Subtask"",""untranslatedName"":""Subtask"",""subtask"":true,""avatarId"":10316,""hierarchyLevel"":-1,""scope"":{""type"":""PROJECT"",""project"":{
 [...]
+70,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10082"",""id"":""10082"",""description"":""讲述一个完整的模块、场景、任务,包含多了Epic,可能跨Project、跨Team。例如:Dashboard、人才发展场景、设置重构"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10306?size=medium"",""name"":""Initiative"",""untranslatedName"":""Initiative"",""subtask"":false,""avatarId"":10306,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/
 [...]
+71,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10097"",""id"":""10097"",""description"":""Stories
 track functionality or features expressed as user 
goals."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium"",""name"":""Story"",""untranslatedName"":""Story"",""subtask"":false,""avatarId"":10315,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":
 [...]
+72,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10068"",""id"":""10068"",""description"":"""",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10300?size=medium"",""name"":""风险"",""untranslatedName"":""风险"",""subtask"":false,""avatarId"":10300,""hierarchyLevel"":0}",https://merico.atlassian.net/rest/api/3/issuetype,null,2022-07-22T00:20:26.871+00:00
+73,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10103"",""id"":""10103"",""description"":""This
 is the Xray Test Set Issue Type. Creates a group of test cases. Used to 
associate all included Tests with other Xray issue types like Test Execution 
and Test Plan. A Test Set can also be associated with a requirement issue to 
provide coverage and test 
status."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/ty
 [...]
+74,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10100"",""id"":""10100"",""description"":""Epics
 track collections of related bugs, stories, and 
tasks."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10307?size=medium"",""name"":""Epic"",""untranslatedName"":""Epic"",""subtask"":false,""avatarId"":10307,""hierarchyLevel"":1,""scope"":{""type"":""PROJECT"",""project"":{""id"":""1004
 [...]
+75,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10054"",""id"":""10054"",""description"":""在新一代项目中创建的一个用来测试的类型。看看是不是只在新一代项目中可以看见"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10304?size=medium"",""name"":""新一代测试类型"",""untranslatedName"":""新一代测试类型"",""subtask"":false,""avatarId"":10304,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10022""}}}",https://m
 [...]
+76,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10086"",""id"":""10086"",""description"":""Stories
 track functionality or features expressed as user 
goals."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium"",""name"":""Story"",""untranslatedName"":""Story"",""subtask"":false,""avatarId"":10315,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":
 [...]
+77,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10104"",""id"":""10104"",""description"":""This
 is the Xray Test Plan Issue Type. Used to define the scope of test cases for a 
given test campaign and to aggregate all executions for those tests displaying 
the latest result for each test 
case."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10563?size=medium"",""name"":""Test
 Plan""," [...]
+78,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10089"",""id"":""10089"",""description"":""Epics
 track collections of related bugs, stories, and 
tasks."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10307?size=medium"",""name"":""Epic"",""untranslatedName"":""Epic"",""subtask"":false,""avatarId"":10307,""hierarchyLevel"":1,""scope"":{""type"":""PROJECT"",""project"":{""id"":""1003
 [...]
+79,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10099"",""id"":""10099"",""description"":""Bugs
 track problems or 
errors."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium"",""name"":""Bug"",""untranslatedName"":""Bug"",""subtask"":false,""avatarId"":10303,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10041""}}}",https://merico.atlassian
 [...]
+80,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10127"",""id"":""10127"",""description"":""Epics
 track collections of related bugs, stories, and 
tasks."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10307?size=medium"",""name"":""Epic"",""untranslatedName"":""Epic"",""subtask"":false,""avatarId"":10307,""hierarchyLevel"":1,""scope"":{""type"":""PROJECT"",""project"":{""id"":""1004
 [...]
+81,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10132"",""id"":""10132"",""description"":""Bugs
 track problems or 
errors."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium"",""name"":""Bug"",""untranslatedName"":""Bug"",""subtask"":false,""avatarId"":10303,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10046""}}}",https://merico.atlassian
 [...]
+82,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10087"",""id"":""10087"",""description"":""Tasks
 track small, distinct pieces of 
work."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium"",""name"":""Task"",""untranslatedName"":""Task"",""subtask"":false,""avatarId"":10318,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10038""}}}",https://m
 [...]
+83,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10098"",""id"":""10098"",""description"":""Tasks
 track small, distinct pieces of 
work."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium"",""name"":""Task"",""untranslatedName"":""Task"",""subtask"":false,""avatarId"":10318,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10041""}}}",https://m
 [...]
+84,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10126"",""id"":""10126"",""description"":""Tasks
 track small, distinct pieces of 
work."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium"",""name"":""Task"",""untranslatedName"":""Task"",""subtask"":false,""avatarId"":10318,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10046""}}}",https://m
 [...]
+85,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10088"",""id"":""10088"",""description"":""Bugs
 track problems or 
errors."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium"",""name"":""Bug"",""untranslatedName"":""Bug"",""subtask"":false,""avatarId"":10303,""hierarchyLevel"":0,""scope"":{""type"":""PROJECT"",""project"":{""id"":""10038""}}}",https://merico.atlassian
 [...]
+86,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10076"",""id"":""10076"",""description"":""Epics
 track collections of related bugs, stories, and 
tasks."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10307?size=medium"",""name"":""Epic"",""untranslatedName"":""Epic"",""subtask"":false,""avatarId"":10307,""hierarchyLevel"":1,""scope"":{""type"":""PROJECT"",""project"":{""id"":""1003
 [...]
+87,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10003"",""id"":""10003"",""description"":""拆分Task、Story或Bug,最小执行单位,通常是具体的测试任务或编写测试用例。一般最大为8h,极特殊情况允许16h"",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10316?size=medium"",""name"":""Sub-task"",""untranslatedName"":""Sub-task"",""subtask"":true,""avatarId"":10316,""hierarchyLevel"":-1}",https://merico.atlassian.net/rest/api/3/issuetyp
 [...]
+88,"{""ConnectionId"":1,""BoardId"":93}","{""self"":""https://merico.atlassian.net/rest/api/3/issuetype/10105"",""id"":""10105"",""description"":""This
 is the Xray Test Execution Issue Type. Used to execute test cases already 
defined."",""iconUrl"":""https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10564?size=medium"",""name"":""Test
 Execution"",""untranslatedName"":""Test 
Execution"",""subtask"":false,""avatarId"":10564,""hierarchyLevel"":0}",https://m
 [...]
diff --git a/plugins/jira/e2e/raw_tables/_raw_jira_api_issues.csv 
b/plugins/jira/e2e/raw_tables/_raw_jira_api_issues.csv
index 7387436f..feeca3dc 100644
--- a/plugins/jira/e2e/raw_tables/_raw_jira_api_issues.csv
+++ b/plugins/jira/e2e/raw_tables/_raw_jira_api_issues.csv
@@ -29,3 +29,6 @@
 12468,"{""ConnectionId"":2,""BoardId"":8}","{""id"": ""10097"", ""key"": 
""EE-35"", ""self"": 
""https://merico.atlassian.net/rest/agile/1.0/issue/10097"";, ""expand"": 
""operations,versionedRepresentations,editmeta,changelog,renderedFields"", 
""fields"": {""epic"": null, ""votes"": {""self"": 
""https://merico.atlassian.net/rest/api/2/issue/EE-35/votes"";, ""votes"": 0, 
""hasVoted"": false}, ""labels"": [], ""parent"": {""id"": ""10065"", ""key"": 
""EE-3"", ""self"": ""https://merico.atlass [...]
 12469,"{""ConnectionId"":2,""BoardId"":8}","{""id"": ""10098"", ""key"": 
""EE-36"", ""self"": 
""https://merico.atlassian.net/rest/agile/1.0/issue/10098"";, ""expand"": 
""operations,versionedRepresentations,editmeta,changelog,renderedFields"", 
""fields"": {""epic"": null, ""votes"": {""self"": 
""https://merico.atlassian.net/rest/api/2/issue/EE-36/votes"";, ""votes"": 0, 
""hasVoted"": false}, ""labels"": [], ""parent"": {""id"": ""10065"", ""key"": 
""EE-3"", ""self"": ""https://merico.atlass [...]
 12470,"{""ConnectionId"":2,""BoardId"":8}","{""id"": ""10099"", ""key"": 
""EE-37"", ""self"": 
""https://merico.atlassian.net/rest/agile/1.0/issue/10099"";, ""expand"": 
""operations,versionedRepresentations,editmeta,changelog,renderedFields"", 
""fields"": {""epic"": null, ""votes"": {""self"": 
""https://merico.atlassian.net/rest/api/2/issue/EE-37/votes"";, ""votes"": 0, 
""hasVoted"": false}, ""labels"": [], ""parent"": {""id"": ""10065"", ""key"": 
""EE-3"", ""self"": ""https://merico.atlass [...]
+13000,"{""ConnectionId"":1,""BoardId"":93}","{""expand"":""operations,versionedRepresentations,editmeta,changelog,renderedFields"",""id"":""20708"",""self"":""https://merico.atlassian.net/rest/agile/1.0/issue/20708"",""key"":""K5-2"",""changelog"":{""startAt"":0,""maxResults"":2,""total"":2,""histories"":[{""id"":""422892"",""author"":{""self"":""https://merico.atlassian.net/rest/api/2/user?accountId=62a2d08d1be00a0068af1945"",""accountId"":""62a2d08d1be00a0068af1945"",""emailAddress"":";
 [...]
+13001,"{""ConnectionId"":1,""BoardId"":93}","{""expand"":""operations,versionedRepresentations,editmeta,changelog,renderedFields"",""id"":""20709"",""self"":""https://merico.atlassian.net/rest/agile/1.0/issue/20709"",""key"":""K5-3"",""changelog"":{""startAt"":0,""maxResults"":2,""total"":2,""histories"":[{""id"":""422897"",""author"":{""self"":""https://merico.atlassian.net/rest/api/2/user?accountId=62a2d08d1be00a0068af1945"",""accountId"":""62a2d08d1be00a0068af1945"",""emailAddress"":";
 [...]
+13002,"{""ConnectionId"":1,""BoardId"":93}","{""expand"":""operations,versionedRepresentations,editmeta,changelog,renderedFields"",""id"":""20710"",""self"":""https://merico.atlassian.net/rest/agile/1.0/issue/20710"",""key"":""K5-4"",""changelog"":{""startAt"":0,""maxResults"":1,""total"":1,""histories"":[{""id"":""422896"",""author"":{""self"":""https://merico.atlassian.net/rest/api/2/user?accountId=62a2d08d1be00a0068af1945"",""accountId"":""62a2d08d1be00a0068af1945"",""emailAddress"":";
 [...]
\ No newline at end of file
diff --git a/plugins/jira/e2e/raw_tables/_raw_jira_external_epics.csv 
b/plugins/jira/e2e/raw_tables/_raw_jira_external_epics.csv
new file mode 100644
index 00000000..f30df960
--- /dev/null
+++ b/plugins/jira/e2e/raw_tables/_raw_jira_external_epics.csv
@@ -0,0 +1,4 @@
+"id","params","data","url","input","created_at"
+13000,"{""ConnectionId"":1,""BoardId"":93}","{""expand"":""operations,versionedRepresentations,editmeta,changelog,renderedFields"",""id"":""20708"",""self"":""https://merico.atlassian.net/rest/agile/1.0/issue/20708"",""key"":""K5-2"",""changelog"":{""startAt"":0,""maxResults"":2,""total"":2,""histories"":[{""id"":""422892"",""author"":{""self"":""https://merico.atlassian.net/rest/api/2/user?accountId=62a2d08d1be00a0068af1945"",""accountId"":""62a2d08d1be00a0068af1945"",""emailAddress"":";
 [...]
+13001,"{""ConnectionId"":1,""BoardId"":93}","{""expand"":""operations,versionedRepresentations,editmeta,changelog,renderedFields"",""id"":""20709"",""self"":""https://merico.atlassian.net/rest/agile/1.0/issue/20709"",""key"":""K5-3"",""changelog"":{""startAt"":0,""maxResults"":2,""total"":2,""histories"":[{""id"":""422897"",""author"":{""self"":""https://merico.atlassian.net/rest/api/2/user?accountId=62a2d08d1be00a0068af1945"",""accountId"":""62a2d08d1be00a0068af1945"",""emailAddress"":";
 [...]
+13002,"{""ConnectionId"":1,""BoardId"":93}","{""expand"":""operations,versionedRepresentations,editmeta,changelog,renderedFields"",""id"":""20710"",""self"":""https://merico.atlassian.net/rest/agile/1.0/issue/20710"",""key"":""K5-4"",""changelog"":{""startAt"":0,""maxResults"":1,""total"":1,""histories"":[{""id"":""422896"",""author"":{""self"":""https://merico.atlassian.net/rest/api/2/user?accountId=62a2d08d1be00a0068af1945"",""accountId"":""62a2d08d1be00a0068af1945"",""emailAddress"":";
 [...]
diff --git 
a/plugins/jira/e2e/snapshot_tables/_tool_jira_board_issues_for_external_epics.csv
 
b/plugins/jira/e2e/snapshot_tables/_tool_jira_board_issues_for_external_epics.csv
new file mode 100644
index 00000000..cd050491
--- /dev/null
+++ 
b/plugins/jira/e2e/snapshot_tables/_tool_jira_board_issues_for_external_epics.csv
@@ -0,0 +1,4 @@
+connection_id,board_id,issue_id
+1,93,20708
+1,93,20709
+1,93,20710
diff --git 
a/plugins/jira/e2e/snapshot_tables/_tool_jira_boards_for_external_epics.csv 
b/plugins/jira/e2e/snapshot_tables/_tool_jira_boards_for_external_epics.csv
new file mode 100644
index 00000000..5dbb4dfa
--- /dev/null
+++ b/plugins/jira/e2e/snapshot_tables/_tool_jira_boards_for_external_epics.csv
@@ -0,0 +1,2 @@
+connection_id,board_id,created_at,updated_at,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,project_id,name,self,type
+1,93,2022-07-19T19:36:30.254+00:00,2022-07-19T19:36:30.254+00:00,"{""ConnectionId"":1,""BoardId"":93}",_raw_jira_api_boards,1,,10050,test_board,https://merico.atlassian.net/rest/agile/1.0/board/93,kanban
diff --git 
a/plugins/jira/e2e/snapshot_tables/_tool_jira_issues_for_external_epics.csv 
b/plugins/jira/e2e/snapshot_tables/_tool_jira_issues_for_external_epics.csv
new file mode 100644
index 00000000..d1f5c8cb
--- /dev/null
+++ b/plugins/jira/e2e/snapshot_tables/_tool_jira_issues_for_external_epics.csv
@@ -0,0 +1,5 @@
+connection_id,issue_id,project_id,self,icon_url,issue_key,summary,type,epic_key,status_name,status_key,story_point,original_estimate_minutes,aggregate_estimate_minutes,remaining_estimate_minutes,creator_account_id,creator_account_type,creator_display_name,assignee_account_id,assignee_account_type,assignee_display_name,priority_id,priority_name,parent_id,parent_key,sprint_id,sprint_name,resolution_date,created,updated,spent_minutes,lead_time_minutes,std_story_point,std_type,std_status,all
 [...]
+1,20707,10050,https://merico.atlassian.net/rest/api/2/issue/20707,https://merico.atlassian.net/images/icons/issuetypes/epic.svg,K5-1,test
 epic,Epic,,To Do,new,0,0,0,0,62a2d08d1be00a0068af1945,,Keon 
Amini,,,,3,Medium,0,,0,,,2022-07-15T22:15:43.810+00:00,2022-07-15T22:54:52.078+00:00,0,0,0,EPIC,TODO,,2022-07-20T00:16:37.030+00:00,2022-07-20T00:16:37.030+00:00,"{""ConnectionId"":1,""BoardId"":93}",_raw_jira_external_epics,9,
+1,20708,10050,https://merico.atlassian.net/rest/agile/1.0/issue/20708,https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium,K5-2,first
 story,Story,K5-1,To Do,new,0,0,0,0,62a2d08d1be00a0068af1945,,Keon 
Amini,,,,3,Medium,20707,K5-1,175,K5 Sprint 
1,,2022-07-15T22:29:49.026+00:00,2022-07-15T22:30:23.341+00:00,0,0,0,STORY,TODO,,2022-07-20T00:15:41.988+00:00,2022-07-20T00:15:41.988+00:00,"{""ConnectionId"":1,""BoardId"":93}",_raw_jira_api_issues,1,
+1,20709,10050,https://merico.atlassian.net/rest/agile/1.0/issue/20709,https://merico.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10315?size=medium,K5-3,second
 story,Story,K5-4,To Do,new,0,0,0,0,62a2d08d1be00a0068af1945,,Keon 
Amini,,,,3,Medium,20710,K5-4,175,K5 Sprint 
1,,2022-07-15T22:30:43.178+00:00,2022-07-15T22:31:38.612+00:00,0,0,0,STORY,TODO,,2022-07-20T00:15:41.988+00:00,2022-07-20T00:15:41.988+00:00,"{""ConnectionId"":1,""BoardId"":93}",_raw_jira_api_issues,2,
+1,20710,10050,https://merico.atlassian.net/rest/agile/1.0/issue/20710,https://merico.atlassian.net/images/icons/issuetypes/epic.svg,K5-4,K5
 epic,Epic,,To Do,new,0,0,0,0,62a2d08d1be00a0068af1945,,Keon 
Amini,,,,3,Medium,0,,0,,,2022-07-15T22:31:15.981+00:00,2022-07-15T22:31:38.598+00:00,0,0,0,EPIC,TODO,,2022-07-20T00:15:41.988+00:00,2022-07-20T00:15:41.988+00:00,"{""ConnectionId"":1,""BoardId"":93}",_raw_jira_api_issues,3,
diff --git a/plugins/jira/impl/impl.go b/plugins/jira/impl/impl.go
index 9a368abd..cedd9f29 100644
--- a/plugins/jira/impl/impl.go
+++ b/plugins/jira/impl/impl.go
@@ -100,6 +100,9 @@ func (plugin Jira) SubTaskMetas() []core.SubTaskMeta {
 
                tasks.ExtractAccountsMeta,
                tasks.ConvertAccountsMeta,
+
+               tasks.CollectEpicsMeta,
+               tasks.ExtractEpicsMeta,
        }
 }
 
@@ -110,10 +113,10 @@ func (plugin Jira) PrepareTaskData(taskCtx 
core.TaskContext, options map[string]
        logger.Debug("%v", options)
        err = mapstructure.Decode(options, &op)
        if err != nil {
-               return nil, err
+               return nil, fmt.Errorf("could not decode Jira options: %v", err)
        }
        if op.ConnectionId == 0 {
-               return nil, fmt.Errorf("connectionId is invalid")
+               return nil, fmt.Errorf("jira connectionId is invalid")
        }
        connection := &models.JiraConnection{}
        connectionHelper := helper.NewConnectionHelper(
@@ -121,11 +124,11 @@ func (plugin Jira) PrepareTaskData(taskCtx 
core.TaskContext, options map[string]
                nil,
        )
        if err != nil {
-               return nil, err
+               return nil, fmt.Errorf("could not get connection API instance 
for Jira: %v", err)
        }
        err = connectionHelper.FirstById(connection, op.ConnectionId)
        if err != nil {
-               return nil, err
+               return nil, fmt.Errorf("unable to get Jira connection: %v", err)
        }
 
        var since time.Time
diff --git a/plugins/jira/tasks/epic_collector.go 
b/plugins/jira/tasks/epic_collector.go
new file mode 100644
index 00000000..082b0398
--- /dev/null
+++ b/plugins/jira/tasks/epic_collector.go
@@ -0,0 +1,135 @@
+/*
+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 (
+       "fmt"
+       "github.com/apache/incubator-devlake/plugins/core"
+       "github.com/apache/incubator-devlake/plugins/core/dal"
+       "reflect"
+       "strings"
+
+       "encoding/json"
+       "github.com/apache/incubator-devlake/plugins/helper"
+       "io/ioutil"
+       "net/http"
+       "net/url"
+)
+
+const RAW_EPIC_TABLE = "jira_api_epics"
+
+var _ core.SubTaskEntryPoint = CollectEpics
+
+var CollectEpicsMeta = core.SubTaskMeta{
+       Name:             "collectEpics",
+       EntryPoint:       CollectEpics,
+       EnabledByDefault: true,
+       Description:      "collect Jira epics from all boards",
+       DomainTypes:      []string{core.DOMAIN_TYPE_TICKET, 
core.DOMAIN_TYPE_CROSS},
+}
+
+func CollectEpics(taskCtx core.SubTaskContext) error {
+       db := taskCtx.GetDal()
+       data := taskCtx.GetData().(*JiraTaskData)
+       epicIterator, err := GetEpicKeysIterator(db, data, 100)
+       if err != nil {
+               return err
+       }
+       since := data.Since
+       jql := "ORDER BY created ASC"
+       if since != nil {
+               // prepend a time range criteria if `since` was specified, 
either by user or from database
+               jql = fmt.Sprintf("updated >= '%s' %s", 
since.Format("2006/01/02 15:04"), jql)
+       }
+       collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{
+               RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+                       Ctx: taskCtx,
+                       Params: JiraApiParams{
+                               ConnectionId: data.Options.ConnectionId,
+                               BoardId:      data.Options.BoardId,
+                       },
+                       Table: RAW_EPIC_TABLE,
+               },
+               ApiClient:   data.ApiClient,
+               PageSize:    100,
+               Incremental: false,
+               UrlTemplate: "api/2/search",
+               Query: func(reqData *helper.RequestData) (url.Values, error) {
+                       query := url.Values{}
+                       epicKeys := []string{}
+                       for _, e := range reqData.Input.([]interface{}) {
+                               epicKeys = append(epicKeys, *e.(*string))
+                       }
+                       localJQL := fmt.Sprintf("issue in (%s) %s", 
strings.Join(epicKeys, ","), jql)
+                       query.Set("jql", localJQL)
+                       query.Set("startAt", fmt.Sprintf("%v", 
reqData.Pager.Skip))
+                       query.Set("maxResults", fmt.Sprintf("%v", 
reqData.Pager.Size))
+                       query.Set("expand", "changelog")
+                       return query, nil
+               },
+               Input:         epicIterator,
+               GetTotalPages: GetTotalPagesFromResponse,
+               Concurrency:   10,
+               ResponseParser: func(res *http.Response) ([]json.RawMessage, 
error) {
+                       var data struct {
+                               Issues []json.RawMessage `json:"issues"`
+                       }
+                       blob, err := ioutil.ReadAll(res.Body)
+                       if err != nil {
+                               return nil, err
+                       }
+                       err = json.Unmarshal(blob, &data)
+                       if err != nil {
+                               return nil, err
+                       }
+                       return data.Issues, nil
+               },
+       })
+       if err != nil {
+               return err
+       }
+       return collector.Execute()
+}
+
+func GetEpicKeysIterator(db dal.Dal, data *JiraTaskData, batchSize int) 
(helper.Iterator, error) {
+       cursor, err := db.RawCursor(`
+                       SELECT
+                               DISTINCT epic_key
+                       FROM
+                               _tool_jira_issues i
+                       LEFT JOIN _tool_jira_board_issues bi ON (
+                               i.connection_id = bi.connection_id
+                               AND 
+                               i.issue_id = bi.issue_id
+                       )
+                       WHERE
+                               i.connection_id = ?
+                               AND 
+                               bi.board_id = ?
+                               AND
+                               i.epic_key != ''
+               `, data.Options.ConnectionId, data.Options.BoardId)
+       if err != nil {
+               return nil, fmt.Errorf("unable to query for external epics: 
%v", err)
+       }
+       iter, err := helper.NewBatchedDalCursorIterator(db, cursor, 
reflect.TypeOf(""), batchSize)
+       if err != nil {
+               return nil, err
+       }
+       return iter, nil
+}
diff --git a/plugins/jira/tasks/epic_extractor.go 
b/plugins/jira/tasks/epic_extractor.go
new file mode 100644
index 00000000..d6379c5e
--- /dev/null
+++ b/plugins/jira/tasks/epic_extractor.go
@@ -0,0 +1,63 @@
+/*
+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 (
+       "github.com/apache/incubator-devlake/plugins/core"
+       "github.com/apache/incubator-devlake/plugins/helper"
+)
+
+var _ core.SubTaskEntryPoint = ExtractEpics
+
+var ExtractEpicsMeta = core.SubTaskMeta{
+       Name:             "extractEpics",
+       EntryPoint:       ExtractEpics,
+       EnabledByDefault: true,
+       Description:      "extract Jira epics from all boards",
+       DomainTypes:      []string{core.DOMAIN_TYPE_TICKET, 
core.DOMAIN_TYPE_CROSS},
+}
+
+func ExtractEpics(taskCtx core.SubTaskContext) error {
+       data := taskCtx.GetData().(*JiraTaskData)
+       db := taskCtx.GetDal()
+       connectionId := data.Options.ConnectionId
+       boardId := data.Options.BoardId
+       logger := taskCtx.GetLogger()
+       logger.Info("extract external epic Issues, connection_id=%d, 
board_id=%d", connectionId, boardId)
+       mappings, err := getTypeMappings(data, db)
+       if err != nil {
+               return err
+       }
+       extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
+               RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+                       Ctx: taskCtx,
+                       Params: JiraApiParams{
+                               ConnectionId: data.Options.ConnectionId,
+                               BoardId:      data.Options.BoardId,
+                       },
+                       Table: RAW_EPIC_TABLE,
+               },
+               Extract: func(row *helper.RawData) ([]interface{}, error) {
+                       return extractIssues(data, mappings, true, row)
+               },
+       })
+       if err != nil {
+               return err
+       }
+       return extractor.Execute()
+}
diff --git a/plugins/jira/tasks/issue_extractor.go 
b/plugins/jira/tasks/issue_extractor.go
index 6fc28bbe..207bb33b 100644
--- a/plugins/jira/tasks/issue_extractor.go
+++ b/plugins/jira/tasks/issue_extractor.go
@@ -40,6 +40,11 @@ var ExtractIssuesMeta = core.SubTaskMeta{
        DomainTypes:      []string{core.DOMAIN_TYPE_TICKET, 
core.DOMAIN_TYPE_CROSS},
 }
 
+type typeMappings struct {
+       typeIdMappings  map[string]string
+       stdTypeMappings map[string]string
+}
+
 func ExtractIssues(taskCtx core.SubTaskContext) error {
        data := taskCtx.GetData().(*JiraTaskData)
        db := taskCtx.GetDal()
@@ -47,24 +52,10 @@ func ExtractIssues(taskCtx core.SubTaskContext) error {
        boardId := data.Options.BoardId
        logger := taskCtx.GetLogger()
        logger.Info("extract Issues, connection_id=%d, board_id=%d", 
connectionId, boardId)
-       typeIdMapping := make(map[string]string)
-       issueTypes := make([]models.JiraIssueType, 0)
-       clauses := []dal.Clause{
-               dal.From(&models.JiraIssueType{}),
-               dal.Where("connection_id = ?", connectionId),
-       }
-       err := db.All(&issueTypes, clauses...)
+       mappings, err := getTypeMappings(data, db)
        if err != nil {
                return err
        }
-       for _, issueType := range issueTypes {
-               typeIdMapping[issueType.Id] = issueType.UntranslatedName
-       }
-
-       stdTypeMappings := make(map[string]string)
-       for userType, stdType := range 
data.Options.TransformationRules.TypeMappings {
-               stdTypeMappings[userType] = 
strings.ToUpper(stdType.StandardType)
-       }
        extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
                RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
                        Ctx: taskCtx,
@@ -82,85 +73,113 @@ func ExtractIssues(taskCtx core.SubTaskContext) error {
                        Table: RAW_ISSUE_TABLE,
                },
                Extract: func(row *helper.RawData) ([]interface{}, error) {
-                       var apiIssue apiv2models.Issue
-                       err := json.Unmarshal(row.Data, &apiIssue)
-                       if err != nil {
-                               return nil, err
-                       }
-                       err = apiIssue.SetAllFields(row.Data)
-                       if err != nil {
-                               return nil, err
-                       }
-                       var results []interface{}
-                       sprints, issue, worklogs, changelogs, changelogItems, 
users := apiIssue.ExtractEntities(data.Options.ConnectionId)
-                       for _, sprintId := range sprints {
-                               sprintIssue := &models.JiraSprintIssue{
-                                       ConnectionId:     
data.Options.ConnectionId,
-                                       SprintId:         sprintId,
-                                       IssueId:          issue.IssueId,
-                                       IssueCreatedDate: &issue.Created,
-                                       ResolutionDate:   issue.ResolutionDate,
-                               }
-                               results = append(results, sprintIssue)
-                       }
-                       if issue.ResolutionDate != nil {
-                               issue.LeadTimeMinutes = 
uint(issue.ResolutionDate.Unix()-issue.Created.Unix()) / 60
-                       }
-                       if data.Options.TransformationRules.StoryPointField != 
"" {
-                               strStoryPoint, _ := 
apiIssue.Fields.AllFields[data.Options.TransformationRules.StoryPointField].(string)
-                               if strStoryPoint != "" {
-                                       issue.StoryPoint, _ = 
strconv.ParseFloat(strStoryPoint, 32)
-                               }
-                       }
-                       issue.Type = typeIdMapping[issue.Type]
-                       issue.StdStoryPoint = int64(issue.StoryPoint)
-                       issue.StdType = stdTypeMappings[issue.Type]
-                       if issue.StdType == "" {
-                               issue.StdType = strings.ToUpper(issue.Type)
-                       }
-                       issue.StdStatus = getStdStatus(issue.StatusKey)
-                       results = append(results, issue)
-                       for _, worklog := range worklogs {
-                               results = append(results, worklog)
-                       }
-                       var issueUpdated *time.Time
-                       // likely this issue has more changelogs to be collected
-                       if len(changelogs) == 100 {
-                               issueUpdated = nil
-                       } else {
-                               issueUpdated = &issue.Updated
-                       }
-                       for _, changelog := range changelogs {
-                               changelog.IssueUpdated = issueUpdated
-                               results = append(results, changelog)
-                       }
-                       for _, changelogItem := range changelogItems {
-                               results = append(results, changelogItem)
-                       }
-                       for _, user := range users {
-                               results = append(results, user)
-                       }
-                       results = append(results, &models.JiraBoardIssue{
-                               ConnectionId: connectionId,
-                               BoardId:      boardId,
-                               IssueId:      issue.IssueId,
-                       })
-                       labels := apiIssue.Fields.Labels
-                       for _, v := range labels {
-                               issueLabel := &models.JiraIssueLabel{
-                                       IssueId:      issue.IssueId,
-                                       LabelName:    v,
-                                       ConnectionId: data.Options.ConnectionId,
-                               }
-                               results = append(results, issueLabel)
-                       }
-                       return results, nil
+                       return extractIssues(data, mappings, false, row)
                },
        })
-
        if err != nil {
                return err
        }
-
        return extractor.Execute()
 }
+
+func extractIssues(data *JiraTaskData, mappings *typeMappings, ignoreBoard 
bool, row *helper.RawData) ([]interface{}, error) {
+       var apiIssue apiv2models.Issue
+       err := json.Unmarshal(row.Data, &apiIssue)
+       if err != nil {
+               return nil, err
+       }
+       err = apiIssue.SetAllFields(row.Data)
+       if err != nil {
+               return nil, err
+       }
+       var results []interface{}
+       sprints, issue, worklogs, changelogs, changelogItems, users := 
apiIssue.ExtractEntities(data.Options.ConnectionId)
+       for _, sprintId := range sprints {
+               sprintIssue := &models.JiraSprintIssue{
+                       ConnectionId:     data.Options.ConnectionId,
+                       SprintId:         sprintId,
+                       IssueId:          issue.IssueId,
+                       IssueCreatedDate: &issue.Created,
+                       ResolutionDate:   issue.ResolutionDate,
+               }
+               results = append(results, sprintIssue)
+       }
+       if issue.ResolutionDate != nil {
+               issue.LeadTimeMinutes = 
uint(issue.ResolutionDate.Unix()-issue.Created.Unix()) / 60
+       }
+       if data.Options.TransformationRules.StoryPointField != "" {
+               strStoryPoint, _ := 
apiIssue.Fields.AllFields[data.Options.TransformationRules.StoryPointField].(string)
+               if strStoryPoint != "" {
+                       issue.StoryPoint, _ = strconv.ParseFloat(strStoryPoint, 
32)
+               }
+       }
+       issue.Type = mappings.typeIdMappings[issue.Type]
+       issue.StdStoryPoint = int64(issue.StoryPoint)
+       issue.StdType = mappings.stdTypeMappings[issue.Type]
+       if issue.StdType == "" {
+               issue.StdType = strings.ToUpper(issue.Type)
+       }
+       issue.StdStatus = getStdStatus(issue.StatusKey)
+       results = append(results, issue)
+       for _, worklog := range worklogs {
+               results = append(results, worklog)
+       }
+       var issueUpdated *time.Time
+       // likely this issue has more changelogs to be collected
+       if len(changelogs) == 100 {
+               issueUpdated = nil
+       } else {
+               issueUpdated = &issue.Updated
+       }
+       for _, changelog := range changelogs {
+               changelog.IssueUpdated = issueUpdated
+               results = append(results, changelog)
+       }
+       for _, changelogItem := range changelogItems {
+               results = append(results, changelogItem)
+       }
+       for _, user := range users {
+               results = append(results, user)
+       }
+       if !ignoreBoard {
+               results = append(results, &models.JiraBoardIssue{
+                       ConnectionId: data.Options.ConnectionId,
+                       BoardId:      data.Options.BoardId,
+                       IssueId:      issue.IssueId,
+               })
+       }
+       labels := apiIssue.Fields.Labels
+       for _, v := range labels {
+               issueLabel := &models.JiraIssueLabel{
+                       IssueId:      issue.IssueId,
+                       LabelName:    v,
+                       ConnectionId: data.Options.ConnectionId,
+               }
+               results = append(results, issueLabel)
+       }
+       return results, nil
+}
+
+func getTypeMappings(data *JiraTaskData, db dal.Dal) (*typeMappings, error) {
+       typeIdMapping := make(map[string]string)
+       issueTypes := make([]models.JiraIssueType, 0)
+       clauses := []dal.Clause{
+               dal.From(&models.JiraIssueType{}),
+               dal.Where("connection_id = ?", data.Options.ConnectionId),
+       }
+       err := db.All(&issueTypes, clauses...)
+       if err != nil {
+               return nil, err
+       }
+       for _, issueType := range issueTypes {
+               typeIdMapping[issueType.Id] = issueType.UntranslatedName
+       }
+       stdTypeMappings := make(map[string]string)
+       for userType, stdType := range 
data.Options.TransformationRules.TypeMappings {
+               stdTypeMappings[userType] = 
strings.ToUpper(stdType.StandardType)
+       }
+       return &typeMappings{
+               typeIdMappings:  typeIdMapping,
+               stdTypeMappings: stdTypeMappings,
+       }, nil
+}

Reply via email to