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 74226840 [feat-1022][github]: Support GitHub Milestones (#2215)
74226840 is described below

commit 74226840d5dd64200c1b8a3953589dd37efa2c65
Author: Keon Amini <[email protected]>
AuthorDate: Tue Jul 5 19:01:03 2022 -0600

    [feat-1022][github]: Support GitHub Milestones (#2215)
    
    * feat(github): added support for github milestones (#1022)
    
    * test: added unit tests to data_flow_tester
    
    * test: improved the added tests
    
    Co-authored-by: Keon Amini <[email protected]>
---
 config-ui/src/hooks/useConnectionManager.jsx       |   2 +-
 config/config.go                                   |   8 +-
 go.mod                                             |   6 +-
 go.sum                                             |   5 +-
 helpers/e2ehelper/data_flow_tester.go              | 117 ++++++++--
 helpers/e2ehelper/data_flow_tester_test.go         | 153 +++++++++++++
 plugins/github/e2e/issue_test.go                   |   1 +
 plugins/github/e2e/milestone_test.go               |  76 +++++++
 .../e2e/raw_tables/_raw_github_api_issues.csv      |  84 +++----
 .../e2e/raw_tables/_raw_github_api_milestones.csv  |   2 +
 .../e2e/snapshot_tables/_tool_github_issues.csv    |  54 ++---
 .../snapshot_tables/_tool_github_milestones.csv    |   2 +
 .../github/e2e/snapshot_tables/board_sprint.csv    |   2 +
 plugins/github/e2e/snapshot_tables/boards.csv      |   2 +-
 .../github/e2e/snapshot_tables/sprint_issue.csv    |  27 +++
 plugins/github/e2e/snapshot_tables/sprints.csv     |   2 +
 plugins/github/impl/impl.go                        |   8 +-
 plugins/github/models/issue.go                     |   1 +
 plugins/github/models/migrationscripts/register.go |   5 +-
 .../migrationscripts/updateSchemas20220620.go      |  73 +++++++
 .../{migrationscripts/register.go => milestone.go} |  30 ++-
 plugins/github/tasks/issue_collector.go            |   2 +-
 plugins/github/tasks/issue_extractor.go            | 241 ++++++++++++---------
 plugins/github/tasks/milestone_collector.go        |  78 +++++++
 plugins/github/tasks/milestone_converter.go        | 109 ++++++++++
 plugins/github/tasks/milestone_extractor.go        | 118 ++++++++++
 runner/directrun.go                                |  56 +++--
 27 files changed, 1034 insertions(+), 230 deletions(-)

diff --git a/config-ui/src/hooks/useConnectionManager.jsx 
b/config-ui/src/hooks/useConnectionManager.jsx
index c4d8eeda..56917613 100644
--- a/config-ui/src/hooks/useConnectionManager.jsx
+++ b/config-ui/src/hooks/useConnectionManager.jsx
@@ -86,7 +86,7 @@ function useConnectionManager ({
           connectionPayload = { endpoint: endpointUrl, username: username, 
password: password, proxy: proxy }
           break
         case Providers.GITHUB:
-          connectionPayload = { endpoint: endpointUrl, auth: token, proxy: 
proxy }
+          connectionPayload = { endpoint: endpointUrl, token: token, proxy: 
proxy }
           break
         case Providers.JENKINS:
           connectionPayload = { endpoint: endpointUrl, username: username, 
password: password }
diff --git a/config/config.go b/config/config.go
index ec2edc07..734db1a7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -46,10 +46,10 @@ func initConfig(v *viper.Viper) {
        v.SetConfigType("env")
        envPath := getEnvPath()
        // AddConfigPath adds a path for Viper to search for the config file in.
-       v.AddConfigPath("$PWD/../..")
-       v.AddConfigPath("$PWD/../../..")
-       v.AddConfigPath("..")
-       v.AddConfigPath(".")
+       v.AddConfigPath("./../../..")
+       v.AddConfigPath("./../..")
+       v.AddConfigPath("./../")
+       v.AddConfigPath("./")
        v.AddConfigPath(envPath)
 
 }
diff --git a/go.mod b/go.mod
index 38ce2129..710d9139 100644
--- a/go.mod
+++ b/go.mod
@@ -3,13 +3,13 @@ module github.com/apache/incubator-devlake
 go 1.17
 
 require (
-       github.com/agiledragon/gomonkey/v2 v2.7.0
        github.com/gin-contrib/cors v1.3.1
        github.com/gin-gonic/gin v1.7.7
        github.com/go-git/go-git/v5 v5.4.2
        github.com/go-playground/validator/v10 v10.9.0
        github.com/libgit2/git2go/v33 v33.0.6
        github.com/magiconair/properties v1.8.5
+       github.com/manifoldco/promptui v0.9.0
        github.com/mitchellh/mapstructure v1.4.1
        github.com/panjf2000/ants/v2 v2.4.6
        github.com/robfig/cron/v3 v3.0.0
@@ -17,6 +17,7 @@ require (
        github.com/spf13/afero v1.6.0
        github.com/spf13/cobra v1.5.0
        github.com/spf13/viper v1.8.1
+       github.com/stoewer/go-strcase v1.2.0
        github.com/stretchr/testify v1.7.0
        github.com/swaggo/gin-swagger v1.4.3
        github.com/swaggo/swag v1.8.1
@@ -37,6 +38,7 @@ require (
        github.com/PuerkitoBio/purell v1.1.1 // indirect
        github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // 
indirect
        github.com/acomagu/bufpipe v1.0.3 // indirect
+       github.com/agiledragon/gomonkey/v2 v2.7.0 // indirect
        github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // 
indirect
        github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
        github.com/davecgh/go-spew v1.1.1 // indirect
@@ -81,7 +83,6 @@ require (
        github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // 
indirect
        github.com/leodido/go-urn v1.2.1 // indirect
        github.com/mailru/easyjson v0.7.6 // indirect
-       github.com/manifoldco/promptui v0.9.0 // indirect
        github.com/mattn/go-colorable v0.1.6 // indirect
        github.com/mattn/go-isatty v0.0.13 // indirect
        github.com/mattn/go-sqlite3 v1.14.6 // indirect
@@ -100,7 +101,6 @@ require (
        github.com/spf13/cast v1.4.1 // indirect
        github.com/spf13/jwalterweatherman v1.1.0 // indirect
        github.com/spf13/pflag v1.0.6-0.20200504143853-81378bbcd8a1 // indirect
-       github.com/stoewer/go-strcase v1.2.0 // indirect
        github.com/stretchr/objx v0.3.0 // indirect
        github.com/subosito/gotenv v1.2.0 // indirect
        github.com/ugorji/go/codec v1.2.6 // indirect
diff --git a/go.sum b/go.sum
index 217f9ee8..7fc8d5cb 100644
--- a/go.sum
+++ b/go.sum
@@ -70,9 +70,11 @@ github.com/bgentry/speakeasy v0.1.0/go.mod 
h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
 github.com/bketelsen/crypt v0.0.4/go.mod 
h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod 
h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod 
h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
 github.com/chzyer/logex v1.1.10/go.mod 
h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e 
h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod 
h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 
h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod 
h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/client9/misspell v0.3.4/go.mod 
h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod 
h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
@@ -89,7 +91,6 @@ github.com/coreos/go-systemd 
v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod 
h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod 
h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod 
h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod 
h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/cpuguy83/go-md2man/v2 v2.0.2 
h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
 github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod 
h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.7/go.mod 
h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
@@ -525,8 +526,6 @@ github.com/spf13/afero v1.6.0/go.mod 
h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z
 github.com/spf13/cast v1.3.1/go.mod 
h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
 github.com/spf13/cast v1.4.1/go.mod 
h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
-github.com/spf13/cobra v1.2.1/go.mod 
h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
 github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
 github.com/spf13/cobra v1.5.0/go.mod 
h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
 github.com/spf13/jwalterweatherman v1.1.0 
h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
diff --git a/helpers/e2ehelper/data_flow_tester.go 
b/helpers/e2ehelper/data_flow_tester.go
index 2474b046..28c33875 100644
--- a/helpers/e2ehelper/data_flow_tester.go
+++ b/helpers/e2ehelper/data_flow_tester.go
@@ -38,6 +38,7 @@ import (
        "os"
        "strconv"
        "strings"
+       "sync"
        "testing"
        "time"
 )
@@ -73,6 +74,18 @@ type DataFlowTester struct {
        Log    core.Logger
 }
 
+type TableOptions struct {
+       // CSVRelPath relative path to the CSV file that contains the seeded 
data
+       CSVRelPath string
+       // TargetFields the fields (columns) to consider for verification. 
Leave empty to default to all.
+       TargetFields []string
+       // IgnoreFields the fields (columns) to ignore/skip.
+       IgnoreFields []string
+       // IgnoreTypes similar to IgnoreFields, this will ignore the fields 
contained in the type. Useful for ignoring embedded
+       // types and their fields in the target model
+       IgnoreTypes []interface{}
+}
+
 // NewDataFlowTester create a *DataFlowTester to help developer test their 
subtasks data flow
 func NewDataFlowTester(t *testing.T, pluginName string, pluginMeta 
core.PluginMeta) *DataFlowTester {
        err := core.RegisterPlugin(pluginName, pluginMeta)
@@ -174,24 +187,51 @@ func (t *DataFlowTester) Subtask(subtaskMeta 
core.SubTaskMeta, taskData interfac
 }
 
 func (t *DataFlowTester) getPkFields(dst schema.Tabler) []string {
+       return t.getFields(dst, func(column gorm.ColumnType) bool {
+               isPk, _ := column.PrimaryKey()
+               return isPk
+       })
+}
+
+func filterColumn(column gorm.ColumnType, opts TableOptions) bool {
+       for _, ignore := range opts.IgnoreFields {
+               if column.Name() == ignore {
+                       return false
+               }
+       }
+       if len(opts.TargetFields) == 0 {
+               return true
+       }
+       targetFound := false
+       for _, target := range opts.TargetFields {
+               if column.Name() == target {
+                       targetFound = true
+                       break
+               }
+       }
+       return targetFound
+}
+
+func (t *DataFlowTester) getFields(dst schema.Tabler, filter func(column 
gorm.ColumnType) bool) []string {
        columnTypes, err := t.Db.Migrator().ColumnTypes(dst)
-       var pkFields []string
+       var fields []string
        if err != nil {
                panic(err)
        }
        for _, columnType := range columnTypes {
-               if isPrimaryKey, _ := columnType.PrimaryKey(); isPrimaryKey {
-                       pkFields = append(pkFields, columnType.Name())
+               if filter == nil || filter(columnType) {
+                       fields = append(fields, columnType.Name())
                }
        }
-       return pkFields
+       return fields
 }
 
 // CreateSnapshot reads rows from database and write them into .csv file.
-func (t *DataFlowTester) CreateSnapshot(dst schema.Tabler, csvRelPath string, 
targetfields []string) {
+func (t *DataFlowTester) CreateSnapshot(dst schema.Tabler, opts TableOptions) {
        location, _ := time.LoadLocation(`UTC`)
        pkFields := t.getPkFields(dst)
-       allFields := append(pkFields, targetfields...)
+       targetFields := t.resolveTargetFields(dst, opts)
+       allFields := append(pkFields, targetFields...)
        allFields = utils.StringsUniq(allFields)
        dbCursor, err := t.Dal.Cursor(
                dal.Select(strings.Join(allFields, `,`)),
@@ -199,14 +239,14 @@ func (t *DataFlowTester) CreateSnapshot(dst 
schema.Tabler, csvRelPath string, ta
                dal.Orderby(strings.Join(pkFields, `,`)),
        )
        if err != nil {
-               panic(err)
+               panic(fmt.Errorf("unable to run select query on table %s: %v", 
dst.TableName(), err))
        }
 
        columns, err := dbCursor.Columns()
        if err != nil {
-               panic(err)
+               panic(fmt.Errorf("unable to get columns from table %s: %v", 
dst.TableName(), err))
        }
-       csvWriter := pluginhelper.NewCsvFileWriter(csvRelPath, columns)
+       csvWriter := pluginhelper.NewCsvFileWriter(opts.CSVRelPath, columns)
        defer csvWriter.Close()
 
        // define how to scan value
@@ -225,7 +265,7 @@ func (t *DataFlowTester) CreateSnapshot(dst schema.Tabler, 
csvRelPath string, ta
        for dbCursor.Next() {
                err = dbCursor.Scan(forScanValues...)
                if err != nil {
-                       panic(err)
+                       panic(fmt.Errorf("unable to scan row on table %s: %v", 
dst.TableName(), err))
                }
                values := make([]string, len(allFields))
                for i := range forScanValues {
@@ -249,6 +289,7 @@ func (t *DataFlowTester) CreateSnapshot(dst schema.Tabler, 
csvRelPath string, ta
                }
                csvWriter.Write(values)
        }
+       fmt.Printf("created CSV file: %s\n", opts.CSVRelPath)
 }
 
 // ExportRawTable reads rows from raw table and write them into .csv file.
@@ -300,20 +341,57 @@ func formatDbValue(value interface{}) string {
        return ``
 }
 
-// VerifyTable reads rows from csv file and compare with records from database 
one by one. You must specified the
+// VerifyTable reads rows from csv file and compare with records from database 
one by one. You must specify the
 // Primary Key Fields with `pkFields` so DataFlowTester could select the exact 
record from database, as well as which
-// fields to compare with by specifying `targetFields` parameter.
+// fields to compare with by specifying `targetFields` parameter. Leaving 
`targetFields` empty/nil will compare all fields.
 func (t *DataFlowTester) VerifyTable(dst schema.Tabler, csvRelPath string, 
targetFields []string) {
-       _, err := os.Stat(csvRelPath)
+       t.VerifyTableWithOptions(dst, TableOptions{
+               CSVRelPath:   csvRelPath,
+               TargetFields: targetFields,
+       })
+}
+
+func (t *DataFlowTester) extractColumns(ifc interface{}) []string {
+       sch, err := schema.Parse(ifc, &sync.Map{}, schema.NamingStrategy{})
+       if err != nil {
+               panic(fmt.Sprintf("error getting object schema: %v", err))
+       }
+       var columns []string
+       for _, f := range sch.Fields {
+               columns = append(columns, f.DBName)
+       }
+       return columns
+}
+
+func (t *DataFlowTester) resolveTargetFields(dst schema.Tabler, opts 
TableOptions) []string {
+       for _, ignore := range opts.IgnoreTypes {
+               opts.IgnoreFields = append(opts.IgnoreFields, 
t.extractColumns(ignore)...)
+       }
+       var targetFields []string
+       if len(opts.TargetFields) == 0 || len(opts.IgnoreFields) > 0 {
+               targetFields = append(targetFields, t.getFields(dst, 
func(column gorm.ColumnType) bool {
+                       return filterColumn(column, opts)
+               })...)
+       } else {
+               targetFields = opts.TargetFields
+       }
+       return targetFields
+}
+
+// VerifyTableWithOptions extends VerifyTable and allows for more advanced 
usages using TableOptions
+func (t *DataFlowTester) VerifyTableWithOptions(dst schema.Tabler, opts 
TableOptions) {
+       if opts.CSVRelPath == "" {
+               panic("CSV relative path missing")
+       }
+       _, err := os.Stat(opts.CSVRelPath)
        if os.IsNotExist(err) {
-               t.CreateSnapshot(dst, csvRelPath, targetFields)
+               t.CreateSnapshot(dst, opts)
                return
        }
+       targetFields := t.resolveTargetFields(dst, opts)
        pkFields := t.getPkFields(dst)
-
-       csvIter := pluginhelper.NewCsvFileIterator(csvRelPath)
+       csvIter := pluginhelper.NewCsvFileIterator(opts.CSVRelPath)
        defer csvIter.Close()
-
        expectedTotal := int64(0)
        csvMap := map[string]map[string]interface{}{}
        for csvIter.HasNext() {
@@ -325,9 +403,11 @@ func (t *DataFlowTester) VerifyTable(dst schema.Tabler, 
csvRelPath string, targe
                pkValueStr := strings.Join(pkValues, `-`)
                _, ok := csvMap[pkValueStr]
                assert.False(t.T, ok, fmt.Sprintf(`%s duplicated in csv (with 
params from csv %s)`, dst.TableName(), pkValues))
+               for _, ignore := range opts.IgnoreFields {
+                       delete(expected, ignore)
+               }
                csvMap[pkValueStr] = expected
        }
-
        dbRows := &[]map[string]interface{}{}
        err = t.Db.Table(dst.TableName()).Find(dbRows).Error
        if err != nil {
@@ -348,7 +428,6 @@ func (t *DataFlowTester) VerifyTable(dst schema.Tabler, 
csvRelPath string, targe
                }
                expectedTotal++
        }
-
        var actualTotal int64
        err = t.Db.Table(dst.TableName()).Count(&actualTotal).Error
        if err != nil {
diff --git a/helpers/e2ehelper/data_flow_tester_test.go 
b/helpers/e2ehelper/data_flow_tester_test.go
index 8c0b5c8d..e98d792f 100644
--- a/helpers/e2ehelper/data_flow_tester_test.go
+++ b/helpers/e2ehelper/data_flow_tester_test.go
@@ -18,13 +18,27 @@ limitations under the License.
 package e2ehelper
 
 import (
+       "github.com/apache/incubator-devlake/models/common"
        gitlabModels "github.com/apache/incubator-devlake/plugins/gitlab/models"
+       "github.com/stretchr/testify/assert"
+       "gorm.io/gorm"
        "testing"
 
        "github.com/apache/incubator-devlake/plugins/core"
        "github.com/apache/incubator-devlake/plugins/gitlab/tasks"
 )
 
+type TestModel struct {
+       ConnectionId uint64 `gorm:"primaryKey"`
+       IssueId      int    `gorm:"primaryKey;autoIncrement:false"`
+       LabelName    string `gorm:"primaryKey;type:varchar(255)"`
+       common.NoPKModel
+}
+
+func (t TestModel) TableName() string {
+       return "_tool_test_model"
+}
+
 func ExampleDataFlowTester() {
        var t *testing.T // stub
 
@@ -68,3 +82,142 @@ func ExampleDataFlowTester() {
                },
        )
 }
+
+func TestGetTableMetaData(t *testing.T) {
+       var meta core.PluginMeta
+       dataflowTester := NewDataFlowTester(t, "test_dataflow", meta)
+       dataflowTester.FlushTabler(&TestModel{})
+       t.Run("get_fields", func(t *testing.T) {
+               fields := dataflowTester.getFields(&TestModel{}, func(column 
gorm.ColumnType) bool {
+                       return true
+               })
+               assert.Equal(t, 9, len(fields))
+               for _, e := range []string{
+                       "connection_id",
+                       "issue_id",
+                       "label_name",
+                       "created_at",
+                       "updated_at",
+                       "_raw_data_params",
+                       "_raw_data_table",
+                       "_raw_data_id",
+                       "_raw_data_remark",
+               } {
+                       assert.Contains(t, fields, e)
+               }
+       })
+       t.Run("extract_columns", func(t *testing.T) {
+               columns := 
dataflowTester.extractColumns(&common.RawDataOrigin{})
+               assert.Equal(t, 4, len(columns))
+               for _, e := range []string{
+                       "_raw_data_params",
+                       "_raw_data_table",
+                       "_raw_data_id",
+                       "_raw_data_remark",
+               } {
+                       assert.Contains(t, columns, e)
+               }
+       })
+       t.Run("get_pk_fields", func(t *testing.T) {
+               fields := dataflowTester.getPkFields(&TestModel{})
+               assert.Equal(t, 3, len(fields))
+               for _, e := range []string{
+                       "connection_id",
+                       "issue_id",
+                       "label_name",
+               } {
+                       assert.Contains(t, fields, e)
+               }
+       })
+       t.Run("resolve_fields_targetFieldsOnly", func(t *testing.T) {
+               fields := dataflowTester.resolveTargetFields(&TestModel{}, 
TableOptions{
+                       TargetFields: []string{"connection_id"},
+                       IgnoreFields: nil,
+                       IgnoreTypes:  nil,
+               })
+               assert.Equal(t, 1, len(fields))
+               for _, e := range []string{"connection_id"} {
+                       assert.Contains(t, fields, e)
+               }
+       })
+       t.Run("resolve_fields_ignoreFieldsOnly", func(t *testing.T) {
+               fields := dataflowTester.resolveTargetFields(&TestModel{}, 
TableOptions{
+                       TargetFields: nil,
+                       IgnoreFields: []string{
+                               "label_name",
+                               "created_at",
+                               "updated_at",
+                               "_raw_data_params",
+                               "_raw_data_table",
+                               "_raw_data_id",
+                               "_raw_data_remark",
+                       },
+                       IgnoreTypes: nil,
+               })
+               assert.Equal(t, 2, len(fields))
+               for _, e := range []string{"connection_id", "issue_id"} {
+                       assert.Contains(t, fields, e)
+               }
+       })
+       t.Run("resolve_fields_ignoreFieldsOnly", func(t *testing.T) {
+               fields := dataflowTester.resolveTargetFields(&TestModel{}, 
TableOptions{
+                       TargetFields: nil,
+                       IgnoreFields: []string{
+                               "label_name",
+                               "created_at",
+                               "updated_at",
+                               "_raw_data_params",
+                               "_raw_data_table",
+                               "_raw_data_id",
+                               "_raw_data_remark",
+                       },
+                       IgnoreTypes: nil,
+               })
+               assert.Equal(t, 2, len(fields))
+               for _, e := range []string{"connection_id", "issue_id"} {
+                       assert.Contains(t, fields, e)
+               }
+       })
+       t.Run("resolve_fields_ignoreType", func(t *testing.T) {
+               fields := dataflowTester.resolveTargetFields(&TestModel{}, 
TableOptions{
+                       TargetFields: nil,
+                       IgnoreFields: nil,
+                       IgnoreTypes:  []interface{}{&common.NoPKModel{}},
+               })
+               assert.Equal(t, 3, len(fields))
+               for _, e := range []string{
+                       "connection_id",
+                       "issue_id",
+                       "label_name",
+               } {
+                       assert.Contains(t, fields, e)
+               }
+       })
+       t.Run("resolve_fields_ignoreType_ignoreFields", func(t *testing.T) {
+               fields := dataflowTester.resolveTargetFields(&TestModel{}, 
TableOptions{
+                       TargetFields: nil,
+                       IgnoreFields: []string{"label_name"},
+                       IgnoreTypes:  []interface{}{&common.NoPKModel{}},
+               })
+               assert.Equal(t, 2, len(fields))
+               for _, e := range []string{
+                       "connection_id",
+                       "issue_id",
+               } {
+                       assert.Contains(t, fields, e)
+               }
+       })
+       t.Run("resolve_fields_targetFields_ignoreType_ignoreFields", func(t 
*testing.T) {
+               fields := dataflowTester.resolveTargetFields(&TestModel{}, 
TableOptions{
+                       TargetFields: []string{"label_name", "createdAt", 
"connection_id"},
+                       IgnoreFields: []string{"label_name"},
+                       IgnoreTypes:  []interface{}{&common.NoPKModel{}},
+               })
+               assert.Equal(t, 1, len(fields))
+               for _, e := range []string{
+                       "connection_id",
+               } {
+                       assert.Contains(t, fields, e)
+               }
+       })
+}
diff --git a/plugins/github/e2e/issue_test.go b/plugins/github/e2e/issue_test.go
index 1b33d02c..3cbec5cb 100644
--- a/plugins/github/e2e/issue_test.go
+++ b/plugins/github/e2e/issue_test.go
@@ -81,6 +81,7 @@ func TestIssueDataFlow(t *testing.T) {
                        "author_name",
                        "assignee_id",
                        "assignee_name",
+                       "milestone_id",
                        "lead_time_minutes",
                        "url",
                        "closed_at",
diff --git a/plugins/github/e2e/milestone_test.go 
b/plugins/github/e2e/milestone_test.go
new file mode 100644
index 00000000..465f3c8f
--- /dev/null
+++ b/plugins/github/e2e/milestone_test.go
@@ -0,0 +1,76 @@
+package e2e
+
+import (
+       "github.com/apache/incubator-devlake/helpers/e2ehelper"
+       "github.com/apache/incubator-devlake/models/common"
+       "github.com/apache/incubator-devlake/models/domainlayer/ticket"
+       "github.com/apache/incubator-devlake/plugins/github/impl"
+       "github.com/apache/incubator-devlake/plugins/github/models"
+       "github.com/apache/incubator-devlake/plugins/github/tasks"
+       "testing"
+       "time"
+)
+
+func TestMilestoneDataFlow(t *testing.T) {
+       var plugin impl.Github
+       dataflowTester := e2ehelper.NewDataFlowTester(t, "github", plugin)
+       githubRepository := &models.GithubRepo{
+               ConnectionId: 1,
+               GithubId:     134018330,
+               CreatedDate: func() time.Time {
+                       createdTime, _ := time.Parse(time.RFC3339, 
"2006-01-02T15:04:05Z")
+                       return createdTime
+               }(),
+       }
+       taskData := &tasks.GithubTaskData{
+               Options: &tasks.GithubOptions{
+                       ConnectionId: 1,
+                       Owner:        "panjf2000",
+                       Repo:         "ants",
+                       TransformationRules: models.TransformationRules{
+                               PrType:               "type/(.*)$",
+                               PrComponent:          "component/(.*)$",
+                               PrBodyClosePattern:   
"(?mi)(fix|close|resolve|fixes|closes|resolves|fixed|closed|resolved)[\\s]*.*(((and
 )?(#|https:\\/\\/github.com\\/%s\\/%s\\/issues\\/)\\d+[ ]*)+)",
+                               IssueSeverity:        "severity/(.*)$",
+                               IssuePriority:        
"^(highest|high|medium|low)$",
+                               IssueComponent:       "component/(.*)$",
+                               IssueTypeBug:         "^(bug|failure|error)$",
+                               IssueTypeIncident:    "",
+                               IssueTypeRequirement: 
"^(feat|feature|proposal|requirement)$",
+                       },
+               },
+               Repo: githubRepository,
+       }
+
+       dataflowTester.FlushTabler(&models.GithubMilestone{})
+       dataflowTester.FlushTabler(&models.GithubIssue{})
+
+       // import raw data table
+       
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_github_api_milestones.csv",
 "_raw_"+tasks.RAW_MILESTONE_TABLE)
+       
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_github_api_issues.csv", 
"_raw_"+tasks.RAW_ISSUE_TABLE)
+
+       dataflowTester.Subtask(tasks.ExtractApiIssuesMeta, taskData)
+       dataflowTester.Subtask(tasks.ExtractMilestonesMeta, taskData)
+       dataflowTester.VerifyTableWithOptions(&models.GithubMilestone{}, 
e2ehelper.TableOptions{
+               CSVRelPath:  "./snapshot_tables/_tool_github_milestones.csv",
+               IgnoreTypes: []interface{}{common.NoPKModel{}},
+       })
+
+       dataflowTester.FlushTabler(&ticket.Sprint{})
+       dataflowTester.FlushTabler(&ticket.BoardSprint{})
+       dataflowTester.FlushTabler(&ticket.SprintIssue{})
+
+       dataflowTester.Subtask(tasks.ConvertMilestonesMeta, taskData)
+       dataflowTester.VerifyTableWithOptions(&ticket.Sprint{}, 
e2ehelper.TableOptions{
+               CSVRelPath:  "./snapshot_tables/sprints.csv",
+               IgnoreTypes: []interface{}{common.NoPKModel{}},
+       })
+       dataflowTester.VerifyTableWithOptions(&ticket.BoardSprint{}, 
e2ehelper.TableOptions{
+               CSVRelPath:  "./snapshot_tables/board_sprint.csv",
+               IgnoreTypes: []interface{}{common.NoPKModel{}},
+       })
+       dataflowTester.VerifyTableWithOptions(&ticket.SprintIssue{}, 
e2ehelper.TableOptions{
+               CSVRelPath:  "./snapshot_tables/sprint_issue.csv",
+               IgnoreTypes: []interface{}{common.NoPKModel{}},
+       })
+}
diff --git a/plugins/github/e2e/raw_tables/_raw_github_api_issues.csv 
b/plugins/github/e2e/raw_tables/_raw_github_api_issues.csv
index 1c8760f3..00b79cd6 100644
--- a/plugins/github/e2e/raw_tables/_raw_github_api_issues.csv
+++ b/plugins/github/e2e/raw_tables/_raw_github_api_issues.csv
@@ -1,43 +1,43 @@
 id,params,data,url,input,created_at
-9,"{""ConnectionId"":2,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/4"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/events"",""html_url"":""https://github.com/panjf2000
 [...]
-10,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/4"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/events"",""html_url"":""https://github.com/panjf200
 [...]
-11,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/5"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/5/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/5/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/5/events"",""html_url"":""https://github.com/panjf200
 [...]
-12,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/6"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/6/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/6/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/6/events"",""html_url"":""https://github.com/panjf200
 [...]
-13,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/7"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/7/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/7/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/7/events"",""html_url"":""https://github.com/panjf200
 [...]
-14,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/8"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/8/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/8/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/8/events"",""html_url"":""https://github.com/panjf200
 [...]
-15,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/9"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/9/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/9/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/9/events"",""html_url"":""https://github.com/panjf200
 [...]
-16,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/10"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/10/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/10/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/10/events"",""html_url"":""https://github.com/panj
 [...]
-17,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/11"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/11/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/11/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/11/events"",""html_url"":""https://github.com/panj
 [...]
-18,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/12"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/12/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/12/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/12/events"",""html_url"":""https://github.com/panj
 [...]
-19,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/13"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/13/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/13/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/13/events"",""html_url"":""https://github.com/panj
 [...]
-20,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/14"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/14/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/14/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/14/events"",""html_url"":""https://github.com/panj
 [...]
-21,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/15"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/15/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/15/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/15/events"",""html_url"":""https://github.com/panj
 [...]
-22,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/16"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/16/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/16/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/16/events"",""html_url"":""https://github.com/panj
 [...]
-23,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/17"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/17/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/17/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/17/events"",""html_url"":""https://github.com/panj
 [...]
-24,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/18"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/18/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/18/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/18/events"",""html_url"":""https://github.com/panj
 [...]
-25,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/19"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/19/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/19/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/19/events"",""html_url"":""https://github.com/panj
 [...]
-26,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/20"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/20/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/20/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/20/events"",""html_url"":""https://github.com/panj
 [...]
-27,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/21"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/21/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/21/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/21/events"",""html_url"":""https://github.com/panj
 [...]
-28,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/22"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/22/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/22/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/22/events"",""html_url"":""https://github.com/panj
 [...]
-29,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/23"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/23/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/23/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/23/events"",""html_url"":""https://github.com/panj
 [...]
-30,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/24"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/24/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/24/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/24/events"",""html_url"":""https://github.com/panj
 [...]
-31,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/25"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/25/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/25/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/25/events"",""html_url"":""https://github.com/panj
 [...]
-32,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/26"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/26/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/26/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/26/events"",""html_url"":""https://github.com/panj
 [...]
-33,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/27"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/27/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/27/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/27/events"",""html_url"":""https://github.com/panj
 [...]
-34,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/28"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/28/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/28/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/28/events"",""html_url"":""https://github.com/panj
 [...]
-35,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/29"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/29/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/29/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/29/events"",""html_url"":""https://github.com/panj
 [...]
-36,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/30"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/30/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/30/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/30/events"",""html_url"":""https://github.com/panj
 [...]
-37,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/31"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/31/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/31/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/31/events"",""html_url"":""https://github.com/panj
 [...]
-38,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/32"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/32/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/32/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/32/events"",""html_url"":""https://github.com/panj
 [...]
-39,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/33"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/33/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/33/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/33/events"",""html_url"":""https://github.com/panj
 [...]
-40,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/34"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/34/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/34/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/34/events"",""html_url"":""https://github.com/panj
 [...]
-41,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/35"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/35/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/35/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/35/events"",""html_url"":""https://github.com/panj
 [...]
-42,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/36"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/36/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/36/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/36/events"",""html_url"":""https://github.com/panj
 [...]
-43,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/37"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/37/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/37/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/37/events"",""html_url"":""https://github.com/panj
 [...]
-44,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/38"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/38/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/38/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/38/events"",""html_url"":""https://github.com/panj
 [...]
-45,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/39"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/39/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/39/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/39/events"",""html_url"":""https://github.com/panj
 [...]
-46,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/40"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/40/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/40/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/40/events"",""html_url"":""https://github.com/panj
 [...]
-47,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/41"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/41/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/41/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/41/events"",""html_url"":""https://github.com/panj
 [...]
-48,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/42"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/42/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/42/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/42/events"",""html_url"":""https://github.com/panj
 [...]
-49,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/43"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/43/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/43/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/43/events"",""html_url"":""https://github.com/panj
 [...]
-50,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/44"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/44/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/44/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/44/events"",""html_url"":""https://github.com/panj
 [...]
+9,"{""ConnectionId"":2,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/4"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/events"",""html_url"":""https://github.com/panjf2000
 [...]
+10,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/4"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/4/events"",""html_url"":""https://github.com/panjf200
 [...]
+11,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/5"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/5/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/5/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/5/events"",""html_url"":""https://github.com/panjf200
 [...]
+12,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/6"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/6/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/6/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/6/events"",""html_url"":""https://github.com/panjf200
 [...]
+13,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/7"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/7/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/7/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/7/events"",""html_url"":""https://github.com/panjf200
 [...]
+14,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/8"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/8/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/8/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/8/events"",""html_url"":""https://github.com/panjf200
 [...]
+15,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/9"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/9/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/9/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/9/events"",""html_url"":""https://github.com/panjf200
 [...]
+16,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/10"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/10/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/10/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/10/events"",""html_url"":""https://github.com/panj
 [...]
+17,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/11"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/11/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/11/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/11/events"",""html_url"":""https://github.com/panj
 [...]
+18,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/12"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/12/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/12/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/12/events"",""html_url"":""https://github.com/panj
 [...]
+19,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/13"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/13/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/13/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/13/events"",""html_url"":""https://github.com/panj
 [...]
+20,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/14"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/14/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/14/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/14/events"",""html_url"":""https://github.com/panj
 [...]
+21,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/15"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/15/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/15/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/15/events"",""html_url"":""https://github.com/panj
 [...]
+22,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/16"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/16/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/16/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/16/events"",""html_url"":""https://github.com/panj
 [...]
+23,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/17"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/17/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/17/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/17/events"",""html_url"":""https://github.com/panj
 [...]
+24,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/18"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/18/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/18/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/18/events"",""html_url"":""https://github.com/panj
 [...]
+25,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/19"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/19/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/19/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/19/events"",""html_url"":""https://github.com/panj
 [...]
+26,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/20"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/20/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/20/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/20/events"",""html_url"":""https://github.com/panj
 [...]
+27,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/21"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/21/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/21/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/21/events"",""html_url"":""https://github.com/panj
 [...]
+28,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/22"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/22/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/22/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/22/events"",""html_url"":""https://github.com/panj
 [...]
+29,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/23"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/23/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/23/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/23/events"",""html_url"":""https://github.com/panj
 [...]
+30,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/24"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/24/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/24/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/24/events"",""html_url"":""https://github.com/panj
 [...]
+31,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/25"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/25/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/25/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/25/events"",""html_url"":""https://github.com/panj
 [...]
+32,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/26"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/26/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/26/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/26/events"",""html_url"":""https://github.com/panj
 [...]
+33,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/27"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/27/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/27/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/27/events"",""html_url"":""https://github.com/panj
 [...]
+34,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/28"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/28/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/28/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/28/events"",""html_url"":""https://github.com/panj
 [...]
+35,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/29"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/29/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/29/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/29/events"",""html_url"":""https://github.com/panj
 [...]
+36,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/30"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/30/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/30/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/30/events"",""html_url"":""https://github.com/panj
 [...]
+37,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/31"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/31/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/31/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/31/events"",""html_url"":""https://github.com/panj
 [...]
+38,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/32"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/32/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/32/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/32/events"",""html_url"":""https://github.com/panj
 [...]
+39,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/33"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/33/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/33/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/33/events"",""html_url"":""https://github.com/panj
 [...]
+40,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/34"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/34/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/34/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/34/events"",""html_url"":""https://github.com/panj
 [...]
+41,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/35"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/35/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/35/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/35/events"",""html_url"":""https://github.com/panj
 [...]
+42,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/36"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/36/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/36/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/36/events"",""html_url"":""https://github.com/panj
 [...]
+43,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/37"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/37/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/37/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/37/events"",""html_url"":""https://github.com/panj
 [...]
+44,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/38"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/38/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/38/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/38/events"",""html_url"":""https://github.com/panj
 [...]
+45,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/39"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/39/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/39/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/39/events"",""html_url"":""https://github.com/panj
 [...]
+46,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/40"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/40/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/40/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/40/events"",""html_url"":""https://github.com/panj
 [...]
+47,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/41"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/41/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/41/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/41/events"",""html_url"":""https://github.com/panj
 [...]
+48,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/42"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/42/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/42/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/42/events"",""html_url"":""https://github.com/panj
 [...]
+49,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/43"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/43/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/43/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/43/events"",""html_url"":""https://github.com/panj
 [...]
+50,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/panjf2000/ants/issues/44"",""repository_url"":""https://api.github.com/repos/panjf2000/ants"",""labels_url"":""https://api.github.com/repos/panjf2000/ants/issues/44/labels{/name}"",""comments_url"":""https://api.github.com/repos/panjf2000/ants/issues/44/comments"",""events_url"":""https://api.github.com/repos/panjf2000/ants/issues/44/events"",""html_url"":""https://github.com/panj
 [...]
diff --git a/plugins/github/e2e/raw_tables/_raw_github_api_milestones.csv 
b/plugins/github/e2e/raw_tables/_raw_github_api_milestones.csv
new file mode 100644
index 00000000..32cacaad
--- /dev/null
+++ b/plugins/github/e2e/raw_tables/_raw_github_api_milestones.csv
@@ -0,0 +1,2 @@
+id,params,data,url,input,created_at
+109,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}","{""url"":""https://api.github.com/repos/apache/incubator-devlake/milestones/7"",""html_url"":""https://github.com/apache/incubator-devlake/milestone/7"",""labels_url"":""https://api.github.com/repos/apache/incubator-devlake/milestones/7/labels"",""id"":7856149,""node_id"":""MI_kwDOFuUSzs4Ad-AV"",""number"":7,""title"":""v0.11.0"",""description"":null,""creator"":{""login"":""Startrekzky"",""id"":14050754,""node_id"":"";
 [...]
diff --git a/plugins/github/e2e/snapshot_tables/_tool_github_issues.csv 
b/plugins/github/e2e/snapshot_tables/_tool_github_issues.csv
index 801fc2dd..071ad042 100644
--- a/plugins/github/e2e/snapshot_tables/_tool_github_issues.csv
+++ b/plugins/github/e2e/snapshot_tables/_tool_github_issues.csv
@@ -1,27 +1,27 @@
-connection_id,github_id,repo_id,number,state,title,body,priority,type,status,author_id,author_name,assignee_id,assignee_name,lead_time_minutes,url,closed_at,github_created_at,github_updated_at,severity,component,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark
-1,346842831,134018330,5,closed,关于 <-p.freeSignal 的疑惑,"""Hi,\r\n    我阅读了源码,对 
`<-p.freeSignal` 这句代码有疑惑。 这句代码出现在了多个地方,freeSignal 的作用英文注释我是理解的,并且知道在 
`putWorker` 中才进行 `p.freeSignal <- sig{}`\r\n\r\n对于下面的代码\r\n```\r\nfunc (p 
*Pool) getWorker() *Worker {\r\n\tvar w *Worker\r\n\twaiting := 
false\r\n\r\n\tp.lock.Lock()\r\n\tidleWorkers := p.workers\r\n\tn := 
len(idleWorkers) - 1\r\n\tif n < 0 { // 说明 pool中没有worker了\r\n\t\twaiting = 
p.Running() >= p.Cap()\r\n\t} else { // 说明pool中有worker\r\n\t\t<-p [...]
-1,347255859,134018330,6,closed,死锁bug,"""func (p *Pool) getWorker() *Worker  
这个函数的 199行 \r\n必须先解锁在加锁, 
要不然会产生死锁\r\n\r\n\tp.lock.Unlock()\r\n\t\t<-p.freeSignal\r\n\t\tp.lock.Lock()""",,BUG,,13118848,lovelly,0,,1786,https://github.com/panjf2000/ants/issues/6,2018-08-04T10:18:41.000+00:00,2018-08-03T04:32:28.000+00:00,2018-08-04T10:18:41.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,12,
-1,348630179,134018330,7,closed,清理过期协程报错,"""你好,非常感谢提供这么好用的工具包。我在使用ants时,发现报异常。结果见下图\r\n![image](https://user-images.githubusercontent.com/4555057/43823431-98384444-9b21-11e8-880c-7458b931734a.png)\r\n日志是我在periodicallyPurge里加的调试信息\r\n![image](https://user-images.githubusercontent.com/4555057/43823534-e3c624a8-9b21-11e8-96c6-512e3e08db22.png)\r\n\r\n###
 原因分析\r\n\r\n我认为可能原因是没有处理n==0的情况\r\n```\r\nif n > 0 {\r\n\tn++\r\n\tp.workers = 
idleWorkers[n:]\r\n}\r\n```\r\n\r\n\r\n### 测试代码\r\n```\r\npa [...]
-1,356703393,134018330,10,closed,高并发下设定较小的worker数量问题,"""会存在cpu飚升的问题吧?""",,,,11763614,Moonlight-Zhao,0,,36198,https://github.com/panjf2000/ants/issues/10,2018-09-29T11:45:00.000+00:00,2018-09-04T08:26:55.000+00:00,2018-09-29T11:45:00.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,16,
-1,364361014,134018330,12,closed,潘少,更新下tag吧,"""鄙人现在在弄dep依赖管理,有用到你写的ants项目,可是你好像忘记打最新的tag了。最新的tag
 
3.6是指向ed55924这个提交,git上的最新代码是af376f1b这次提交,两次提交都隔了快5个月了,看到的话,麻烦打一个最新的tag吧。(手动可怜)""",,,,29452204,edcismybrother,0,,1293,https://github.com/panjf2000/ants/issues/12,2018-09-28T06:05:58.000+00:00,2018-09-27T08:32:25.000+00:00,2019-04-21T08:19:58.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,18,
-1,381941219,134018330,17,closed,关于优雅退出的问题,"""关于这个package优雅退出的问题,我看了一下Release的代码:\r\n\r\n`\r\n\t//
 Release Closed this pool.\r\n\tfunc (p *PoolWithFunc) Release() error 
{\r\n\t\tp.once.Do(func() {\r\n\t\t\tp.release <- 
sig{}\r\n\t\t\tp.lock.Lock()\r\n\t\t\tidleWorkers := p.workers\r\n\t\t\tfor i, 
w := range idleWorkers {\r\n\t\t\t\tw.args <- nil\r\n\t\t\t\tidleWorkers[i] = 
nil\r\n\t\t\t}\r\n\t\t\tp.workers = 
nil\r\n\t\t\tp.lock.Unlock()\r\n\t\t})\r\n\t\treturn 
nil\r\n\t}\r\n`\r\n\r\nrelea [...]
-1,382039050,134018330,18,closed,go协程的理解,"""你好楼主,向您请教一个协程和线程的问题,协程基于go进程调度,线程基于系统内核调度,调度协程的过程是先调度线程后获得资源再去调度协程。\""官方解释:
 GOMAXPROCS sets the maximum number of CPUs that can be executing 
simultaneously。限制cpu数,本质上是什么,限制并行数?,并行数即同时执行数量?,执行单元即线程?,即限制最大并行线程数量?\""""",,,,13944100,LinuxForYQH,0,,20213,https://github.com/panjf2000/ants/issues/18,2018-12-03T03:53:50.000+00:00,2018-11-19T02:59:53.000+00:00,2018-12-03T03:53:50.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}
 [...]
-1,382574800,134018330,20,closed,是否考虑任务支持回调函数处理失败的逻辑和任务依赖,"""#""",,,,5668717,kklinan,0,,95398,https://github.com/panjf2000/ants/issues/20,2019-01-25T15:34:03.000+00:00,2018-11-20T09:36:02.000+00:00,2019-01-25T15:34:03.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,26,
-1,388907811,134018330,21,closed,Benchmark 下直接使用 Semaphore 似乎更快呢?,"""简单跑了一下 
benchmark,Semaphore 更快且很简单\r\n\r\n```bash\r\n$ go test -bench .\r\ngoos: 
darwin\r\ngoarch: amd64\r\npkg: 
github.com/panjf2000/ants\r\nBenchmarkGoroutineWithFunc-4   \t       
1\t3445631705 ns/op\r\nBenchmarkSemaphoreWithFunc-4   \t       1\t1037219073 
ns/op\r\nBenchmarkAntsPoolWithFunc-4    \t       1\t1138053222 
ns/op\r\nBenchmarkGoroutine-4           \t       2\t 731850771 
ns/op\r\nBenchmarkSemaphore-4            [...]
-1,401277739,134018330,22,closed,是否考虑 worker 中添加  PanicHandler ?,"""比方说在创建 Pool 
的时候传入一个 PanicHandler,然后在每个 worker 创建的时候 recover 之后传给 PanicHandler  处理。否则池子里如果发生 
panic 
会直接挂掉整个进程。""",,,,8923413,choleraehyq,0,,1174,https://github.com/panjf2000/ants/issues/22,2019-01-22T05:41:34.000+00:00,2019-01-21T10:06:56.000+00:00,2019-01-22T05:41:34.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,28,
-1,402513849,134018330,24,closed,提交任务不阻塞,"""`Pool.Submit`和`PoolWithFunc.Server`提交任务,如果没有空的worker,会一直阻塞。建议增加不阻塞的接口,当前失败时直接返回错误。""",,,,5044825,tenfyzhong,0,,300032,https://github.com/panjf2000/ants/issues/24,2019-08-20T10:56:30.000+00:00,2019-01-24T02:24:13.000+00:00,2019-08-20T10:56:30.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,30,
-1,405951301,134018330,25,closed,use example errors,"""./antstest.go:37:14: 
cannot use syncCalculateSum (type func()) as type ants.f in argument to 
ants.Submit\r\n./antstest.go:45:35: cannot use func literal (type 
func(interface {})) as type ants.pf in argument to 
ants.NewPoolWithFunc\r\n""",,,,5244267,jiashiwen,0,,3088,https://github.com/panjf2000/ants/issues/25,2019-02-04T09:11:52.000+00:00,2019-02-02T05:43:38.000+00:00,2019-02-04T09:11:52.000+00:00,,,"{""ConnectionId"":1,""Owner"":""pa
 [...]
-1,413968505,134018330,26,closed,running可能大于cap的问题,"""running与cap的比较判断与incRuning分开执行的,
 可能会出现running大于cap的问题?\r\n`func (p *Pool) retrieveWorker() *Worker {\r\n\tvar w 
*Worker\r\n\r\n\tp.lock.Lock()\r\n\tidleWorkers := p.workers\r\n\tn := 
len(idleWorkers) - 1\r\n\tif n >= 0 {\r\n\t\tw = 
idleWorkers[n]\r\n\t\tidleWorkers[n] = nil\r\n\t\tp.workers = 
idleWorkers[:n]\r\n\t\tp.lock.Unlock()\r\n\t} else if p.Running() < p.Cap() 
{\r\n\t\tp.lock.Unlock()\r\n\t\tif cacheWorker := p.workerCache.Get() [...]
-1,419183961,134018330,27,closed,为何goroutine一直上不去,用户量也打不上去,"""为何goroutine一直上不去,用户量也打不上去\r\n是我用的有问题吗?\r\n\r\nwebsocket
 
server\r\nhttps://github.com/im-ai/pushm/blob/master/learn/goroutine/goroutinepoolwebsocket.go\r\n\r\nwebsocket
 
cient\r\nhttps://github.com/im-ai/pushm/blob/master/learn/goroutine/goroutinepoolwebsocketclient.go\r\n""",,,,38367404,liliang8858,0,,37496,https://github.com/panjf2000/ants/issues/27,2019-04-05T14:05:20.000+00:00,2019-03-10T13:08:52.000+00:00,2019-04-05T14:05:20
 [...]
-1,419268851,134018330,28,closed,cap 和 running 比较的问题,"""这是我在 Playground 上面的代码 
https://play.golang.org/p/D94YUU3FnX6\r\natomic 
只能保证自增自减时的原子操作,在比较过程中,其他线程对变量进行了操作 比较过程并无感知,所以这个比较结果 不是完全正确的,想要实现 
比较的数量完全正确,只能在修改和比较两个值的地方加锁\r\n像 #26 
说的是对的""",,,,29243953,naiba,0,,237002,https://github.com/panjf2000/ants/issues/28,2019-08-22T16:27:37.000+00:00,2019-03-11T02:24:41.000+00:00,2019-08-22T16:27:37.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,34,
-1,424634533,134018330,29,closed,任务传参,"""你好,你的项目太酷了👍\r\n\r\nhttps://github.com/panjf2000/ants/blob/master/pool.go#L124
 貌似不支持带参数的任务, 
请问传参是用闭包的方式吗?\r\n""",,,,8509898,prprprus,0,,999,https://github.com/panjf2000/ants/issues/29,2019-03-25T09:32:11.000+00:00,2019-03-24T16:52:21.000+00:00,2019-03-25T09:45:05.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,35,
-1,429972115,134018330,31,closed,Add 
go.mod,"""""",,,,48135919,tsatke,0,,3474,https://github.com/panjf2000/ants/issues/31,2019-04-08T09:45:31.000+00:00,2019-04-05T23:50:36.000+00:00,2019-10-17T03:12:19.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,37,
-1,433564955,134018330,32,closed,关于版本问题,我发现小版本(0.0.x)这种更新就会不向下兼容?,"""如题,我感觉这样不好。\r\n\r\n功能版本号不向下兼容能理解\r\n\r\n修复问题的版本号也不向下兼容,难以理解。""",,,,7931755,zplzpl,0,,7440,https://github.com/panjf2000/ants/issues/32,2019-04-21T07:16:26.000+00:00,2019-04-16T03:16:02.000+00:00,2019-04-21T07:16:26.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,38,
-1,434069015,134018330,33,closed,support semantic 
versioning.,"""建议将发布的tag兼容为semantic versioning,vX.Y.Z。go 
modules对此支持比较良好。\r\nhttps://semver.org/\r\nhttps://research.swtch.com/vgo-import""",,,,1284892,jjeffcaii,0,,6090,https://github.com/panjf2000/ants/issues/33,2019-04-21T08:25:20.000+00:00,2019-04-17T02:55:11.000+00:00,2019-04-21T08:25:20.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,39,
-1,435486645,134018330,34,closed,Important announcement about <ants> from 
author !!!,"""**Dear users of `ants`:**\r\nI am apologetically telling you that 
I have to dump all tags which already presents in `ants` repository.\r\n\r\nThe 
reason why I'm doing so is to standardize the version management with `Semantic 
Versioning`, which will make a formal and clear dependency management in go, 
for go modules, godep, or glide, etc. So I decide to start over the tag 
sequence, you could find more  [...]
-1,461280653,134018330,35,closed,worker exit on 
panic,"""个人认为PanicHandler设计不妥。\r\n1.无PanicHandler时,抛出给外面的不是panic,外层感受不到。\r\n2.无论有没有PanicHandler,都会导致worker退出,最终pool阻塞住全部任务。""",,,,38849208,king526,0,,74481,https://github.com/panjf2000/ants/issues/35,2019-08-17T20:33:10.000+00:00,2019-06-27T03:11:49.000+00:00,2019-08-17T20:33:10.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,41,
-1,462631417,134018330,37,closed,请不要再随意变更版本号了。。。,"""之前用的是 
3.9.9,结果今天构建出了问题,一看发现这个版本没了,变成 
1.0.0。这种变更完全不考虑现有用户的情况。希望以后不要随意变更了""",,,,8923413,choleraehyq,0,,140,https://github.com/panjf2000/ants/issues/37,2019-07-01T12:37:55.000+00:00,2019-07-01T10:17:15.000+00:00,2019-07-02T10:17:31.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,43,
-1,472125082,134018330,38,closed,retrieveWorker与revertWorker之间会导致死锁,"""func (p 
*Pool) retrieveWorker() *Worker {\r\n\tvar w 
*Worker\r\n\r\n\t**p.lock.Lock()**\r\n\tidleWorkers := p.workers\r\n\tn := 
len(idleWorkers) - 1\r\n\tif n >= 0 {\r\n\t\tw = 
idleWorkers[n]\r\n\t\tidleWorkers[n] = nil\r\n\t\tp.workers = 
idleWorkers[:n]\r\n\t\tp.lock.Unlock()\r\n\t} else if p.Running() < p.Cap() 
{\r\n\t\tp.lock.Unlock()\r\n\t\tif cacheWorker := p.workerCache.Get(); 
cacheWorker != nil {\r\n\t\t\tw = ca [...]
-1,483164833,134018330,42,closed,带选项的初始化函数,我觉得用 functional options 
更好一点,"""以下是示意代码\r\n如果用 functional 
options,原来的写法是\r\n```\r\nants.NewPool(10)\r\n```\r\n新的写法,如果不加 option,写法是不变的,因为 
options 是作为可变参数传进去的。如果要加 option,只需要改成\r\n```\r\nants.NewPool(10, 
ants.WithNonblocking(true))\r\n```\r\n这样。\r\n\r\n现在是直接传一个 Option 
结构体进去,所有的地方都要改,感觉很不优雅。\r\n具体 functional options 的设计可以看 rob pike 的一篇博客 
https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html""",,,,8923413,choleraehyq,0
 [...]
-1,483736247,134018330,43,closed,1.3.0 是不兼容更新,"""Pool 里那些暴露出来的字段(PanicHandler 
之类的)都没了,这是一个不兼容更新,根据语义化版本的要求要发大版本。""",,,,8923413,choleraehyq,0,,652,https://github.com/panjf2000/ants/issues/43,2019-08-22T13:22:10.000+00:00,2019-08-22T02:29:34.000+00:00,2019-08-22T13:22:10.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,49,
-1,484311063,134018330,44,closed,1.1.1 -> 1.2.0 也是不兼容更新,"""Pool.Release 
的返回值没了""",,,,8923413,choleraehyq,0,,3068,https://github.com/panjf2000/ants/issues/44,2019-08-25T06:36:14.000+00:00,2019-08-23T03:27:38.000+00:00,2019-08-25T06:36:14.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,50,
+connection_id,github_id,repo_id,milestone_id,number,state,title,body,priority,type,status,author_id,author_name,assignee_id,assignee_name,lead_time_minutes,url,closed_at,github_created_at,github_updated_at,severity,component,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark
+1,346842831,134018330,7856149,5,closed,关于 <-p.freeSignal 的疑惑,"""Hi,\r\n    
我阅读了源码,对 `<-p.freeSignal` 这句代码有疑惑。 这句代码出现在了多个地方,freeSignal 的作用英文注释我是理解的,并且知道在 
`putWorker` 中才进行 `p.freeSignal <- sig{}`\r\n\r\n对于下面的代码\r\n```\r\nfunc (p 
*Pool) getWorker() *Worker {\r\n\tvar w *Worker\r\n\twaiting := 
false\r\n\r\n\tp.lock.Lock()\r\n\tidleWorkers := p.workers\r\n\tn := 
len(idleWorkers) - 1\r\n\tif n < 0 { // 说明 pool中没有worker了\r\n\t\twaiting = 
p.Running() >= p.Cap()\r\n\t} else { // 说明pool中有worker\r\ [...]
+1,347255859,134018330,7856149,6,closed,死锁bug,"""func (p *Pool) getWorker() 
*Worker  这个函数的 199行 \r\n必须先解锁在加锁, 
要不然会产生死锁\r\n\r\n\tp.lock.Unlock()\r\n\t\t<-p.freeSignal\r\n\t\tp.lock.Lock()""",,BUG,,13118848,lovelly,0,,1786,https://github.com/panjf2000/ants/issues/6,2018-08-04T10:18:41.000+00:00,2018-08-03T04:32:28.000+00:00,2018-08-04T10:18:41.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,12,
+1,348630179,134018330,7856149,7,closed,清理过期协程报错,"""你好,非常感谢提供这么好用的工具包。我在使用ants时,发现报异常。结果见下图\r\n![image](https://user-images.githubusercontent.com/4555057/43823431-98384444-9b21-11e8-880c-7458b931734a.png)\r\n日志是我在periodicallyPurge里加的调试信息\r\n![image](https://user-images.githubusercontent.com/4555057/43823534-e3c624a8-9b21-11e8-96c6-512e3e08db22.png)\r\n\r\n###
 原因分析\r\n\r\n我认为可能原因是没有处理n==0的情况\r\n```\r\nif n > 0 {\r\n\tn++\r\n\tp.workers = 
idleWorkers[n:]\r\n}\r\n```\r\n\r\n\r\n### 测试代码\r\n` [...]
+1,356703393,134018330,7856149,10,closed,高并发下设定较小的worker数量问题,"""会存在cpu飚升的问题吧?""",,,,11763614,Moonlight-Zhao,0,,36198,https://github.com/panjf2000/ants/issues/10,2018-09-29T11:45:00.000+00:00,2018-09-04T08:26:55.000+00:00,2018-09-29T11:45:00.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,16,
+1,364361014,134018330,7856149,12,closed,潘少,更新下tag吧,"""鄙人现在在弄dep依赖管理,有用到你写的ants项目,可是你好像忘记打最新的tag了。最新的tag
 
3.6是指向ed55924这个提交,git上的最新代码是af376f1b这次提交,两次提交都隔了快5个月了,看到的话,麻烦打一个最新的tag吧。(手动可怜)""",,,,29452204,edcismybrother,0,,1293,https://github.com/panjf2000/ants/issues/12,2018-09-28T06:05:58.000+00:00,2018-09-27T08:32:25.000+00:00,2019-04-21T08:19:58.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,18,
+1,381941219,134018330,7856149,17,closed,关于优雅退出的问题,"""关于这个package优雅退出的问题,我看了一下Release的代码:\r\n\r\n`\r\n\t//
 Release Closed this pool.\r\n\tfunc (p *PoolWithFunc) Release() error 
{\r\n\t\tp.once.Do(func() {\r\n\t\t\tp.release <- 
sig{}\r\n\t\t\tp.lock.Lock()\r\n\t\t\tidleWorkers := p.workers\r\n\t\t\tfor i, 
w := range idleWorkers {\r\n\t\t\t\tw.args <- nil\r\n\t\t\t\tidleWorkers[i] = 
nil\r\n\t\t\t}\r\n\t\t\tp.workers = 
nil\r\n\t\t\tp.lock.Unlock()\r\n\t\t})\r\n\t\treturn nil\r\n\t}\r\n`\r\n\ [...]
+1,382039050,134018330,7856149,18,closed,go协程的理解,"""你好楼主,向您请教一个协程和线程的问题,协程基于go进程调度,线程基于系统内核调度,调度协程的过程是先调度线程后获得资源再去调度协程。\""官方解释:
 GOMAXPROCS sets the maximum number of CPUs that can be executing 
simultaneously。限制cpu数,本质上是什么,限制并行数?,并行数即同时执行数量?,执行单元即线程?,即限制最大并行线程数量?\""""",,,,13944100,LinuxForYQH,0,,20213,https://github.com/panjf2000/ants/issues/18,2018-12-03T03:53:50.000+00:00,2018-11-19T02:59:53.000+00:00,2018-12-03T03:53:50.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":";
 [...]
+1,382574800,134018330,7856149,20,closed,是否考虑任务支持回调函数处理失败的逻辑和任务依赖,"""#""",,,,5668717,kklinan,0,,95398,https://github.com/panjf2000/ants/issues/20,2019-01-25T15:34:03.000+00:00,2018-11-20T09:36:02.000+00:00,2019-01-25T15:34:03.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,26,
+1,388907811,134018330,7856149,21,closed,Benchmark 下直接使用 Semaphore 
似乎更快呢?,"""简单跑了一下 benchmark,Semaphore 更快且很简单\r\n\r\n```bash\r\n$ go test -bench 
.\r\ngoos: darwin\r\ngoarch: amd64\r\npkg: 
github.com/panjf2000/ants\r\nBenchmarkGoroutineWithFunc-4   \t       
1\t3445631705 ns/op\r\nBenchmarkSemaphoreWithFunc-4   \t       1\t1037219073 
ns/op\r\nBenchmarkAntsPoolWithFunc-4    \t       1\t1138053222 
ns/op\r\nBenchmarkGoroutine-4           \t       2\t 731850771 
ns/op\r\nBenchmarkSemaphore-4    [...]
+1,401277739,134018330,7856149,22,closed,是否考虑 worker 中添加  PanicHandler 
?,"""比方说在创建 Pool 的时候传入一个 PanicHandler,然后在每个 worker 创建的时候 recover 之后传给 
PanicHandler  处理。否则池子里如果发生 panic 
会直接挂掉整个进程。""",,,,8923413,choleraehyq,0,,1174,https://github.com/panjf2000/ants/issues/22,2019-01-22T05:41:34.000+00:00,2019-01-21T10:06:56.000+00:00,2019-01-22T05:41:34.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,28,
+1,402513849,134018330,7856149,24,closed,提交任务不阻塞,"""`Pool.Submit`和`PoolWithFunc.Server`提交任务,如果没有空的worker,会一直阻塞。建议增加不阻塞的接口,当前失败时直接返回错误。""",,,,5044825,tenfyzhong,0,,300032,https://github.com/panjf2000/ants/issues/24,2019-08-20T10:56:30.000+00:00,2019-01-24T02:24:13.000+00:00,2019-08-20T10:56:30.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,30,
+1,405951301,134018330,7856149,25,closed,use example 
errors,"""./antstest.go:37:14: cannot use syncCalculateSum (type func()) as 
type ants.f in argument to ants.Submit\r\n./antstest.go:45:35: cannot use func 
literal (type func(interface {})) as type ants.pf in argument to 
ants.NewPoolWithFunc\r\n""",,,,5244267,jiashiwen,0,,3088,https://github.com/panjf2000/ants/issues/25,2019-02-04T09:11:52.000+00:00,2019-02-02T05:43:38.000+00:00,2019-02-04T09:11:52.000+00:00,,,"{""ConnectionId"":1,""Owne
 [...]
+1,413968505,134018330,7856149,26,closed,running可能大于cap的问题,"""running与cap的比较判断与incRuning分开执行的,
 可能会出现running大于cap的问题?\r\n`func (p *Pool) retrieveWorker() *Worker {\r\n\tvar w 
*Worker\r\n\r\n\tp.lock.Lock()\r\n\tidleWorkers := p.workers\r\n\tn := 
len(idleWorkers) - 1\r\n\tif n >= 0 {\r\n\t\tw = 
idleWorkers[n]\r\n\t\tidleWorkers[n] = nil\r\n\t\tp.workers = 
idleWorkers[:n]\r\n\t\tp.lock.Unlock()\r\n\t} else if p.Running() < p.Cap() 
{\r\n\t\tp.lock.Unlock()\r\n\t\tif cacheWorker := p.workerCac [...]
+1,419183961,134018330,7856149,27,closed,为何goroutine一直上不去,用户量也打不上去,"""为何goroutine一直上不去,用户量也打不上去\r\n是我用的有问题吗?\r\n\r\nwebsocket
 
server\r\nhttps://github.com/im-ai/pushm/blob/master/learn/goroutine/goroutinepoolwebsocket.go\r\n\r\nwebsocket
 
cient\r\nhttps://github.com/im-ai/pushm/blob/master/learn/goroutine/goroutinepoolwebsocketclient.go\r\n""",,,,38367404,liliang8858,0,,37496,https://github.com/panjf2000/ants/issues/27,2019-04-05T14:05:20.000+00:00,2019-03-10T13:08:52.000+00:00,2019-04-05T
 [...]
+1,419268851,134018330,7856149,28,closed,cap 和 running 比较的问题,"""这是我在 Playground 
上面的代码 https://play.golang.org/p/D94YUU3FnX6\r\natomic 
只能保证自增自减时的原子操作,在比较过程中,其他线程对变量进行了操作 比较过程并无感知,所以这个比较结果 不是完全正确的,想要实现 
比较的数量完全正确,只能在修改和比较两个值的地方加锁\r\n像 #26 
说的是对的""",,,,29243953,naiba,0,,237002,https://github.com/panjf2000/ants/issues/28,2019-08-22T16:27:37.000+00:00,2019-03-11T02:24:41.000+00:00,2019-08-22T16:27:37.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,34,
+1,424634533,134018330,7856149,29,closed,任务传参,"""你好,你的项目太酷了👍\r\n\r\nhttps://github.com/panjf2000/ants/blob/master/pool.go#L124
 貌似不支持带参数的任务, 
请问传参是用闭包的方式吗?\r\n""",,,,8509898,prprprus,0,,999,https://github.com/panjf2000/ants/issues/29,2019-03-25T09:32:11.000+00:00,2019-03-24T16:52:21.000+00:00,2019-03-25T09:45:05.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,35,
+1,429972115,134018330,7856149,31,closed,Add 
go.mod,"""""",,,,48135919,tsatke,0,,3474,https://github.com/panjf2000/ants/issues/31,2019-04-08T09:45:31.000+00:00,2019-04-05T23:50:36.000+00:00,2019-10-17T03:12:19.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,37,
+1,433564955,134018330,7856149,32,closed,关于版本问题,我发现小版本(0.0.x)这种更新就会不向下兼容?,"""如题,我感觉这样不好。\r\n\r\n功能版本号不向下兼容能理解\r\n\r\n修复问题的版本号也不向下兼容,难以理解。""",,,,7931755,zplzpl,0,,7440,https://github.com/panjf2000/ants/issues/32,2019-04-21T07:16:26.000+00:00,2019-04-16T03:16:02.000+00:00,2019-04-21T07:16:26.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,38,
+1,434069015,134018330,7856149,33,closed,support semantic 
versioning.,"""建议将发布的tag兼容为semantic versioning,vX.Y.Z。go 
modules对此支持比较良好。\r\nhttps://semver.org/\r\nhttps://research.swtch.com/vgo-import""",,,,1284892,jjeffcaii,0,,6090,https://github.com/panjf2000/ants/issues/33,2019-04-21T08:25:20.000+00:00,2019-04-17T02:55:11.000+00:00,2019-04-21T08:25:20.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,39,
+1,435486645,134018330,7856149,34,closed,Important announcement about <ants> 
from author !!!,"""**Dear users of `ants`:**\r\nI am apologetically telling you 
that I have to dump all tags which already presents in `ants` 
repository.\r\n\r\nThe reason why I'm doing so is to standardize the version 
management with `Semantic Versioning`, which will make a formal and clear 
dependency management in go, for go modules, godep, or glide, etc. So I decide 
to start over the tag sequence, you could fi [...]
+1,461280653,134018330,7856149,35,closed,worker exit on 
panic,"""个人认为PanicHandler设计不妥。\r\n1.无PanicHandler时,抛出给外面的不是panic,外层感受不到。\r\n2.无论有没有PanicHandler,都会导致worker退出,最终pool阻塞住全部任务。""",,,,38849208,king526,0,,74481,https://github.com/panjf2000/ants/issues/35,2019-08-17T20:33:10.000+00:00,2019-06-27T03:11:49.000+00:00,2019-08-17T20:33:10.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,41,
+1,462631417,134018330,7856149,37,closed,请不要再随意变更版本号了。。。,"""之前用的是 
3.9.9,结果今天构建出了问题,一看发现这个版本没了,变成 
1.0.0。这种变更完全不考虑现有用户的情况。希望以后不要随意变更了""",,,,8923413,choleraehyq,0,,140,https://github.com/panjf2000/ants/issues/37,2019-07-01T12:37:55.000+00:00,2019-07-01T10:17:15.000+00:00,2019-07-02T10:17:31.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,43,
+1,472125082,134018330,7856149,38,closed,retrieveWorker与revertWorker之间会导致死锁,"""func
 (p *Pool) retrieveWorker() *Worker {\r\n\tvar w 
*Worker\r\n\r\n\t**p.lock.Lock()**\r\n\tidleWorkers := p.workers\r\n\tn := 
len(idleWorkers) - 1\r\n\tif n >= 0 {\r\n\t\tw = 
idleWorkers[n]\r\n\t\tidleWorkers[n] = nil\r\n\t\tp.workers = 
idleWorkers[:n]\r\n\t\tp.lock.Unlock()\r\n\t} else if p.Running() < p.Cap() 
{\r\n\t\tp.lock.Unlock()\r\n\t\tif cacheWorker := p.workerCache.Get(); 
cacheWorker != nil {\r\n\t\t [...]
+1,483164833,134018330,7856149,42,closed,带选项的初始化函数,我觉得用 functional options 
更好一点,"""以下是示意代码\r\n如果用 functional 
options,原来的写法是\r\n```\r\nants.NewPool(10)\r\n```\r\n新的写法,如果不加 option,写法是不变的,因为 
options 是作为可变参数传进去的。如果要加 option,只需要改成\r\n```\r\nants.NewPool(10, 
ants.WithNonblocking(true))\r\n```\r\n这样。\r\n\r\n现在是直接传一个 Option 
结构体进去,所有的地方都要改,感觉很不优雅。\r\n具体 functional options 的设计可以看 rob pike 的一篇博客 
https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html""",,,,8923413,chole
 [...]
+1,483736247,134018330,7856149,43,closed,1.3.0 是不兼容更新,"""Pool 
里那些暴露出来的字段(PanicHandler 
之类的)都没了,这是一个不兼容更新,根据语义化版本的要求要发大版本。""",,,,8923413,choleraehyq,0,,652,https://github.com/panjf2000/ants/issues/43,2019-08-22T13:22:10.000+00:00,2019-08-22T02:29:34.000+00:00,2019-08-22T13:22:10.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,49,
+1,484311063,134018330,7856149,44,closed,1.1.1 -> 1.2.0 也是不兼容更新,"""Pool.Release 
的返回值没了""",,,,8923413,choleraehyq,0,,3068,https://github.com/panjf2000/ants/issues/44,2019-08-25T06:36:14.000+00:00,2019-08-23T03:27:38.000+00:00,2019-08-25T06:36:14.000+00:00,,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_api_issues,50,
diff --git a/plugins/github/e2e/snapshot_tables/_tool_github_milestones.csv 
b/plugins/github/e2e/snapshot_tables/_tool_github_milestones.csv
new file mode 100644
index 00000000..75dfe942
--- /dev/null
+++ b/plugins/github/e2e/snapshot_tables/_tool_github_milestones.csv
@@ -0,0 +1,2 @@
+connection_id,milestone_id,repo_id,number,url,title,open_issues,closed_issues,state,created_at,updated_at,closed_at,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark
+1,7856149,134018330,7,https://api.github.com/repos/apache/incubator-devlake/milestones/7,v0.11.0,2,118,open,2022-04-08T02:05:35.000+00:00,2022-06-24T01:34:37.000+00:00,,"{""ConnectionId"":1,""Owner"":""panjf2000"",""Repo"":""ants""}",_raw_github_milestones,109,
diff --git a/plugins/github/e2e/snapshot_tables/board_sprint.csv 
b/plugins/github/e2e/snapshot_tables/board_sprint.csv
new file mode 100644
index 00000000..0f45a0ae
--- /dev/null
+++ b/plugins/github/e2e/snapshot_tables/board_sprint.csv
@@ -0,0 +1,2 @@
+board_id,sprint_id
+github:GithubRepo:1:134018330,github:GithubMilestone:1:7856149
diff --git a/plugins/github/e2e/snapshot_tables/boards.csv 
b/plugins/github/e2e/snapshot_tables/boards.csv
index 7201be87..e98f6bfa 100644
--- a/plugins/github/e2e/snapshot_tables/boards.csv
+++ b/plugins/github/e2e/snapshot_tables/boards.csv
@@ -1,2 +1,2 @@
 id,name,description,url,created_date
-github:GithubRepo:1:134018330,panjf2000/ants,"🐜🐜🐜 ants is a high-performance 
and low-cost goroutine pool in Go, inspired by fasthttp./ ants 是一个高性能且低损耗的 
goroutine 
池。",https://github.com/panjf2000/ants/issues,2018-05-19T01:13:38.000+00:00
+github:GithubRepo:1:134018330,panjf2000/ants,"🐜🐜🐜 ants is a high-performance 
and low-cost goroutine pool in Go, inspired by fasthttp./ ants 是一个高性能且低损耗的 
goroutine 
池。",https://github.com/panjf2000/ants/issues,2018-05-19T01:13:38.000+00:00
\ No newline at end of file
diff --git a/plugins/github/e2e/snapshot_tables/sprint_issue.csv 
b/plugins/github/e2e/snapshot_tables/sprint_issue.csv
new file mode 100644
index 00000000..153ed83d
--- /dev/null
+++ b/plugins/github/e2e/snapshot_tables/sprint_issue.csv
@@ -0,0 +1,27 @@
+sprint_id,issue_id
+github:GithubMilestone:1:7856149,github:GithubIssue:1:346842831
+github:GithubMilestone:1:7856149,github:GithubIssue:1:347255859
+github:GithubMilestone:1:7856149,github:GithubIssue:1:348630179
+github:GithubMilestone:1:7856149,github:GithubIssue:1:356703393
+github:GithubMilestone:1:7856149,github:GithubIssue:1:364361014
+github:GithubMilestone:1:7856149,github:GithubIssue:1:381941219
+github:GithubMilestone:1:7856149,github:GithubIssue:1:382039050
+github:GithubMilestone:1:7856149,github:GithubIssue:1:382574800
+github:GithubMilestone:1:7856149,github:GithubIssue:1:388907811
+github:GithubMilestone:1:7856149,github:GithubIssue:1:401277739
+github:GithubMilestone:1:7856149,github:GithubIssue:1:402513849
+github:GithubMilestone:1:7856149,github:GithubIssue:1:405951301
+github:GithubMilestone:1:7856149,github:GithubIssue:1:413968505
+github:GithubMilestone:1:7856149,github:GithubIssue:1:419183961
+github:GithubMilestone:1:7856149,github:GithubIssue:1:419268851
+github:GithubMilestone:1:7856149,github:GithubIssue:1:424634533
+github:GithubMilestone:1:7856149,github:GithubIssue:1:429972115
+github:GithubMilestone:1:7856149,github:GithubIssue:1:433564955
+github:GithubMilestone:1:7856149,github:GithubIssue:1:434069015
+github:GithubMilestone:1:7856149,github:GithubIssue:1:435486645
+github:GithubMilestone:1:7856149,github:GithubIssue:1:461280653
+github:GithubMilestone:1:7856149,github:GithubIssue:1:462631417
+github:GithubMilestone:1:7856149,github:GithubIssue:1:472125082
+github:GithubMilestone:1:7856149,github:GithubIssue:1:483164833
+github:GithubMilestone:1:7856149,github:GithubIssue:1:483736247
+github:GithubMilestone:1:7856149,github:GithubIssue:1:484311063
diff --git a/plugins/github/e2e/snapshot_tables/sprints.csv 
b/plugins/github/e2e/snapshot_tables/sprints.csv
new file mode 100644
index 00000000..92f3df97
--- /dev/null
+++ b/plugins/github/e2e/snapshot_tables/sprints.csv
@@ -0,0 +1,2 @@
+id,name,url,status,started_date,ended_date,completed_date,original_board_id
+github:GithubMilestone:1:7856149,v0.11.0,https://api.github.com/repos/apache/incubator-devlake/milestones/7,open,2022-04-08T02:05:35.000+00:00,,,github:GithubRepo:1:134018330
diff --git a/plugins/github/impl/impl.go b/plugins/github/impl/impl.go
index 7f5a5ccd..8ade2967 100644
--- a/plugins/github/impl/impl.go
+++ b/plugins/github/impl/impl.go
@@ -18,6 +18,7 @@ limitations under the License.
 package impl
 
 import (
+       "fmt"
        "github.com/apache/incubator-devlake/migration"
        "github.com/apache/incubator-devlake/plugins/core"
        "github.com/apache/incubator-devlake/plugins/github/api"
@@ -67,6 +68,8 @@ func (plugin Github) SubTaskMetas() []core.SubTaskMeta {
                tasks.ExtractApiCommitsMeta,
                tasks.CollectApiCommitStatsMeta,
                tasks.ExtractApiCommitStatsMeta,
+               tasks.CollectMilestonesMeta,
+               tasks.ExtractMilestonesMeta,
                tasks.EnrichPullRequestIssuesMeta,
                tasks.ConvertRepoMeta,
                tasks.ConvertIssuesMeta,
@@ -79,6 +82,7 @@ func (plugin Github) SubTaskMetas() []core.SubTaskMeta {
                tasks.ConvertUsersMeta,
                tasks.ConvertIssueCommentsMeta,
                tasks.ConvertPullRequestCommentsMeta,
+               tasks.ConvertMilestonesMeta,
        }
 }
 
@@ -94,12 +98,12 @@ func (plugin Github) PrepareTaskData(taskCtx 
core.TaskContext, options map[strin
        connection := &models.GithubConnection{}
        err = connectionHelper.FirstById(connection, op.ConnectionId)
        if err != nil {
-               return err, nil
+               return nil, fmt.Errorf("unable to get github connection by the 
given connection ID: %v", err)
        }
 
        apiClient, err := tasks.CreateApiClient(taskCtx, connection)
        if err != nil {
-               return nil, err
+               return nil, fmt.Errorf("unable to get github API client 
instance: %v", err)
        }
 
        return &tasks.GithubTaskData{
diff --git a/plugins/github/models/issue.go b/plugins/github/models/issue.go
index b508e55d..c658d416 100644
--- a/plugins/github/models/issue.go
+++ b/plugins/github/models/issue.go
@@ -37,6 +37,7 @@ type GithubIssue struct {
        AuthorName      string `gorm:"type:varchar(255)"`
        AssigneeId      int
        AssigneeName    string `gorm:"type:varchar(255)"`
+       MilestoneId     int    `gorm:"index"`
        LeadTimeMinutes uint
        Url             string `gorm:"type:varchar(255)"`
        ClosedAt        *time.Time
diff --git a/plugins/github/models/migrationscripts/register.go 
b/plugins/github/models/migrationscripts/register.go
index 51152508..e0b31a88 100644
--- a/plugins/github/models/migrationscripts/register.go
+++ b/plugins/github/models/migrationscripts/register.go
@@ -17,11 +17,14 @@ limitations under the License.
 
 package migrationscripts
 
-import "github.com/apache/incubator-devlake/migration"
+import (
+       "github.com/apache/incubator-devlake/migration"
+)
 
 // All return all the migration scripts
 func All() []migration.Script {
        return []migration.Script{
                new(initSchemas),
+               new(UpdateSchemas20220620),
        }
 }
diff --git a/plugins/github/models/migrationscripts/updateSchemas20220620.go 
b/plugins/github/models/migrationscripts/updateSchemas20220620.go
new file mode 100644
index 00000000..c5178cfa
--- /dev/null
+++ b/plugins/github/models/migrationscripts/updateSchemas20220620.go
@@ -0,0 +1,73 @@
+/*
+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 migrationscripts
+
+import (
+       "context"
+       "github.com/apache/incubator-devlake/models/migrationscripts/archived"
+       "gorm.io/gorm"
+       "time"
+)
+
+// GithubMilestone20220620 new struct for milestones
+type GithubMilestone20220620 struct {
+       archived.NoPKModel
+       ConnectionId uint64 `gorm:"primaryKey"`
+       MilestoneId  int    `gorm:"primaryKey;autoIncrement:false"`
+       RepoId       int
+       Number       int
+       URL          string
+       OpenIssues   int
+       ClosedIssues int
+       State        string
+       Title        string
+       CreatedAt    time.Time
+       UpdatedAt    time.Time
+       ClosedAt     time.Time
+}
+
+// GithubIssue20220620 new field for models.GithubIssue
+type GithubIssue20220620 struct {
+       MilestoneId int
+}
+
+type UpdateSchemas20220620 struct{}
+
+func (GithubMilestone20220620) TableName() string {
+       return "_tool_github_milestones"
+}
+
+func (GithubIssue20220620) TableName() string {
+       return "_tool_github_issues"
+}
+
+func (*UpdateSchemas20220620) Up(_ context.Context, db *gorm.DB) error {
+       err := db.Migrator().AddColumn(GithubIssue20220620{}, "milestone_id")
+       if err != nil {
+               return err
+       }
+       return db.Migrator().CreateTable(GithubMilestone20220620{})
+}
+
+func (*UpdateSchemas20220620) Version() uint64 {
+       return 20220620000001
+}
+
+func (*UpdateSchemas20220620) Name() string {
+       return "Add milestone for github"
+}
diff --git a/plugins/github/models/migrationscripts/register.go 
b/plugins/github/models/milestone.go
similarity index 59%
copy from plugins/github/models/migrationscripts/register.go
copy to plugins/github/models/milestone.go
index 51152508..561cbfa5 100644
--- a/plugins/github/models/migrationscripts/register.go
+++ b/plugins/github/models/milestone.go
@@ -15,13 +15,29 @@ See the License for the specific language governing 
permissions and
 limitations under the License.
 */
 
-package migrationscripts
+package models
 
-import "github.com/apache/incubator-devlake/migration"
+import (
+       "github.com/apache/incubator-devlake/models/common"
+       "time"
+)
 
-// All return all the migration scripts
-func All() []migration.Script {
-       return []migration.Script{
-               new(initSchemas),
-       }
+type GithubMilestone struct {
+       ConnectionId uint64 `gorm:"primaryKey"`
+       MilestoneId  int    `gorm:"primaryKey;autoIncrement:false"`
+       RepoId       int
+       Number       int
+       URL          string
+       Title        string
+       OpenIssues   int
+       ClosedIssues int
+       State        string
+       CreatedAt    time.Time
+       UpdatedAt    time.Time
+       ClosedAt     *time.Time
+       common.NoPKModel
+}
+
+func (GithubMilestone) TableName() string {
+       return "_tool_github_milestones"
 }
diff --git a/plugins/github/tasks/issue_collector.go 
b/plugins/github/tasks/issue_collector.go
index 35f618d1..ae735f12 100644
--- a/plugins/github/tasks/issue_collector.go
+++ b/plugins/github/tasks/issue_collector.go
@@ -114,7 +114,7 @@ func CollectApiIssues(taskCtx core.SubTaskContext) error {
                        query.Set("direction", "asc")
                        query.Set("page", fmt.Sprintf("%v", reqData.Pager.Page))
                        query.Set("per_page", fmt.Sprintf("%v", 
reqData.Pager.Size))
-
+                       query.Set("milestone", "*")
                        return query, nil
                },
                /*
diff --git a/plugins/github/tasks/issue_extractor.go 
b/plugins/github/tasks/issue_extractor.go
index efbbf54a..f5799c8b 100644
--- a/plugins/github/tasks/issue_extractor.go
+++ b/plugins/github/tasks/issue_extractor.go
@@ -51,67 +51,33 @@ type IssuesResponse struct {
        Labels []struct {
                Name string `json:"name"`
        } `json:"labels"`
-
-       Assignee        *GithubUserResponse
-       User            *GithubUserResponse
+       Assignee  *GithubUserResponse
+       User      *GithubUserResponse
+       Milestone *struct {
+               Id int
+       }
        ClosedAt        *helper.Iso8601Time `json:"closed_at"`
        GithubCreatedAt helper.Iso8601Time  `json:"created_at"`
        GithubUpdatedAt helper.Iso8601Time  `json:"updated_at"`
 }
 
+type IssueRegexes struct {
+       SeverityRegex        *regexp.Regexp
+       ComponentRegex       *regexp.Regexp
+       PriorityRegex        *regexp.Regexp
+       TypeBugRegex         *regexp.Regexp
+       TypeRequirementRegex *regexp.Regexp
+       TypeIncidentRegex    *regexp.Regexp
+}
+
 func ExtractApiIssues(taskCtx core.SubTaskContext) error {
        data := taskCtx.GetData().(*GithubTaskData)
+
        config := data.Options.TransformationRules
-       var issueSeverityRegex *regexp.Regexp
-       var issueComponentRegex *regexp.Regexp
-       var issuePriorityRegex *regexp.Regexp
-       var issueTypeBugRegex *regexp.Regexp
-       var issueTypeRequirementRegex *regexp.Regexp
-       var issueTypeIncidentRegex *regexp.Regexp
-       var issueSeverity = config.IssueSeverity
-       var err error
-       if len(issueSeverity) > 0 {
-               issueSeverityRegex, err = regexp.Compile(issueSeverity)
-               if err != nil {
-                       return fmt.Errorf("regexp Compile issueSeverity 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
-               }
-       }
-       var issueComponent = config.IssueComponent
-       if len(issueComponent) > 0 {
-               issueComponentRegex, err = regexp.Compile(issueComponent)
-               if err != nil {
-                       return fmt.Errorf("regexp Compile issueComponent 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
-               }
-       }
-       var issuePriority = config.IssuePriority
-       if len(issuePriority) > 0 {
-               issuePriorityRegex, err = regexp.Compile(issuePriority)
-               if err != nil {
-                       return fmt.Errorf("regexp Compile issuePriority 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
-               }
-       }
-       var issueTypeBug = config.IssueTypeBug
-       if len(issueTypeBug) > 0 {
-               issueTypeBugRegex, err = regexp.Compile(issueTypeBug)
-               if err != nil {
-                       return fmt.Errorf("regexp Compile issueTypeBug 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
-               }
-       }
-       var issueTypeRequirement = config.IssueTypeRequirement
-       if len(issueTypeRequirement) > 0 {
-               issueTypeRequirementRegex, err = 
regexp.Compile(issueTypeRequirement)
-               if err != nil {
-                       return fmt.Errorf("regexp Compile issueTypeRequirement 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
-               }
-       }
-       var issueTypeIncident = config.IssueTypeIncident
-       if len(issueTypeIncident) > 0 {
-               issueTypeIncidentRegex, err = regexp.Compile(issueTypeIncident)
-               if err != nil {
-                       return fmt.Errorf("regexp Compile issueTypeIncident 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
-               }
+       issueRegexes, err := NewIssueRegexes(config)
+       if err != nil {
+               return nil
        }
-
        extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
                RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
                        Ctx: taskCtx,
@@ -144,13 +110,18 @@ func ExtractApiIssues(taskCtx core.SubTaskContext) error {
                                return nil, nil
                        }
                        results := make([]interface{}, 0, 2)
+
                        githubIssue, err := convertGithubIssue(body, 
data.Options.ConnectionId, data.Repo.GithubId)
                        if err != nil {
                                return nil, err
                        }
+                       githubLabels, err := convertGithubLabels(issueRegexes, 
body, githubIssue)
+                       if err != nil {
+                               return nil, err
+                       }
+                       results = append(results, githubLabels...)
+                       results = append(results, githubIssue)
                        if body.Assignee != nil {
-                               githubIssue.AssigneeId = body.Assignee.Id
-                               githubIssue.AssigneeName = body.Assignee.Login
                                relatedUser, err := convertUser(body.Assignee, 
data.Options.ConnectionId)
                                if err != nil {
                                        return nil, err
@@ -158,71 +129,22 @@ func ExtractApiIssues(taskCtx core.SubTaskContext) error {
                                results = append(results, relatedUser)
                        }
                        if body.User != nil {
-                               githubIssue.AuthorId = body.User.Id
-                               githubIssue.AuthorName = body.User.Login
                                relatedUser, err := convertUser(body.User, 
data.Options.ConnectionId)
                                if err != nil {
                                        return nil, err
                                }
                                results = append(results, relatedUser)
                        }
-                       for _, label := range body.Labels {
-                               results = append(results, 
&models.GithubIssueLabel{
-                                       ConnectionId: data.Options.ConnectionId,
-                                       IssueId:      githubIssue.GithubId,
-                                       LabelName:    label.Name,
-                               })
-                               if issueSeverityRegex != nil {
-                                       groups := 
issueSeverityRegex.FindStringSubmatch(label.Name)
-                                       if len(groups) > 0 {
-                                               githubIssue.Severity = groups[1]
-                                       }
-                               }
-
-                               if issueComponentRegex != nil {
-                                       groups := 
issueComponentRegex.FindStringSubmatch(label.Name)
-                                       if len(groups) > 0 {
-                                               githubIssue.Component = 
groups[1]
-                                       }
-                               }
-
-                               if issuePriorityRegex != nil {
-                                       groups := 
issuePriorityRegex.FindStringSubmatch(label.Name)
-                                       if len(groups) > 0 {
-                                               githubIssue.Priority = groups[1]
-                                       }
-                               }
-
-                               if issueTypeBugRegex != nil {
-                                       if ok := 
issueTypeBugRegex.MatchString(label.Name); ok {
-                                               githubIssue.Type = ticket.BUG
-                                       }
-                               }
-
-                               if issueTypeRequirementRegex != nil {
-                                       if ok := 
issueTypeRequirementRegex.MatchString(label.Name); ok {
-                                               githubIssue.Type = 
ticket.REQUIREMENT
-                                       }
-                               }
-
-                               if issueTypeIncidentRegex != nil {
-                                       if ok := 
issueTypeIncidentRegex.MatchString(label.Name); ok {
-                                               githubIssue.Type = 
ticket.INCIDENT
-                                       }
-                               }
-                       }
-                       results = append(results, githubIssue)
-
                        return results, nil
                },
        })
-
        if err != nil {
                return err
        }
 
        return extractor.Execute()
 }
+
 func convertGithubIssue(issue *IssuesResponse, connectionId uint64, 
repositoryId int) (*models.GithubIssue, error) {
        githubIssue := &models.GithubIssue{
                ConnectionId:    connectionId,
@@ -233,13 +155,122 @@ func convertGithubIssue(issue *IssuesResponse, 
connectionId uint64, repositoryId
                Title:           issue.Title,
                Body:            string(issue.Body),
                Url:             issue.HtmlUrl,
+               MilestoneId:     issue.Milestone.Id,
                ClosedAt:        helper.Iso8601TimeToTime(issue.ClosedAt),
                GithubCreatedAt: issue.GithubCreatedAt.ToTime(),
                GithubUpdatedAt: issue.GithubUpdatedAt.ToTime(),
        }
+       if issue.Assignee != nil {
+               githubIssue.AssigneeId = issue.Assignee.Id
+               githubIssue.AssigneeName = issue.Assignee.Login
+       }
+       if issue.User != nil {
+               githubIssue.AuthorId = issue.User.Id
+               githubIssue.AuthorName = issue.User.Login
+       }
        if issue.ClosedAt != nil {
                githubIssue.LeadTimeMinutes = 
uint(issue.ClosedAt.ToTime().Sub(issue.GithubCreatedAt.ToTime()).Minutes())
        }
-
+       if issue.Assignee != nil {
+               githubIssue.AssigneeId = issue.Assignee.Id
+               githubIssue.AssigneeName = issue.Assignee.Login
+       }
+       if issue.User != nil {
+               githubIssue.AuthorId = issue.User.Id
+               githubIssue.AuthorName = issue.User.Login
+       }
        return githubIssue, nil
 }
+
+func convertGithubLabels(issueRegexes *IssueRegexes, issue *IssuesResponse, 
githubIssue *models.GithubIssue) ([]interface{}, error) {
+       var results []interface{}
+       for _, label := range issue.Labels {
+               results = append(results, &models.GithubIssueLabel{
+                       ConnectionId: githubIssue.ConnectionId,
+                       IssueId:      githubIssue.GithubId,
+                       LabelName:    label.Name,
+               })
+               if issueRegexes.SeverityRegex != nil {
+                       groups := 
issueRegexes.SeverityRegex.FindStringSubmatch(label.Name)
+                       if len(groups) > 0 {
+                               githubIssue.Severity = groups[1]
+                       }
+               }
+               if issueRegexes.ComponentRegex != nil {
+                       groups := 
issueRegexes.ComponentRegex.FindStringSubmatch(label.Name)
+                       if len(groups) > 0 {
+                               githubIssue.Component = groups[1]
+                       }
+               }
+               if issueRegexes.PriorityRegex != nil {
+                       groups := 
issueRegexes.PriorityRegex.FindStringSubmatch(label.Name)
+                       if len(groups) > 0 {
+                               githubIssue.Priority = groups[1]
+                       }
+               }
+               if issueRegexes.TypeBugRegex != nil {
+                       if ok := 
issueRegexes.TypeBugRegex.MatchString(label.Name); ok {
+                               githubIssue.Type = ticket.BUG
+                       }
+               }
+               if issueRegexes.TypeRequirementRegex != nil {
+                       if ok := 
issueRegexes.TypeRequirementRegex.MatchString(label.Name); ok {
+                               githubIssue.Type = ticket.REQUIREMENT
+                       }
+               }
+               if issueRegexes.TypeIncidentRegex != nil {
+                       if ok := 
issueRegexes.TypeIncidentRegex.MatchString(label.Name); ok {
+                               githubIssue.Type = ticket.INCIDENT
+                       }
+               }
+       }
+       return results, nil
+}
+
+func NewIssueRegexes(config models.TransformationRules) (*IssueRegexes, error) 
{
+       var issueRegexes IssueRegexes
+       var issueSeverity = config.IssueSeverity
+       var err error
+       if len(issueSeverity) > 0 {
+               issueRegexes.SeverityRegex, err = regexp.Compile(issueSeverity)
+               if err != nil {
+                       return nil, fmt.Errorf("regexp Compile issueSeverity 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
+               }
+       }
+       var issueComponent = config.IssueComponent
+       if len(issueComponent) > 0 {
+               issueRegexes.ComponentRegex, err = 
regexp.Compile(issueComponent)
+               if err != nil {
+                       return nil, fmt.Errorf("regexp Compile issueComponent 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
+               }
+       }
+       var issuePriority = config.IssuePriority
+       if len(issuePriority) > 0 {
+               issueRegexes.PriorityRegex, err = regexp.Compile(issuePriority)
+               if err != nil {
+                       return nil, fmt.Errorf("regexp Compile issuePriority 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
+               }
+       }
+       var issueTypeBug = config.IssueTypeBug
+       if len(issueTypeBug) > 0 {
+               issueRegexes.TypeBugRegex, err = regexp.Compile(issueTypeBug)
+               if err != nil {
+                       return nil, fmt.Errorf("regexp Compile issueTypeBug 
failed:[%s] stack:[%s]", err.Error(), debug.Stack())
+               }
+       }
+       var issueTypeRequirement = config.IssueTypeRequirement
+       if len(issueTypeRequirement) > 0 {
+               issueRegexes.TypeRequirementRegex, err = 
regexp.Compile(issueTypeRequirement)
+               if err != nil {
+                       return nil, fmt.Errorf("regexp Compile 
issueTypeRequirement failed:[%s] stack:[%s]", err.Error(), debug.Stack())
+               }
+       }
+       var issueTypeIncident = config.IssueTypeIncident
+       if len(issueTypeIncident) > 0 {
+               issueRegexes.TypeIncidentRegex, err = 
regexp.Compile(issueTypeIncident)
+               if err != nil {
+                       return nil, fmt.Errorf("regexp Compile 
issueTypeIncident failed:[%s] stack:[%s]", err.Error(), debug.Stack())
+               }
+       }
+       return &issueRegexes, nil
+}
diff --git a/plugins/github/tasks/milestone_collector.go 
b/plugins/github/tasks/milestone_collector.go
new file mode 100644
index 00000000..ff43bd10
--- /dev/null
+++ b/plugins/github/tasks/milestone_collector.go
@@ -0,0 +1,78 @@
+/*
+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"
+       "net/http"
+       "net/url"
+
+       "github.com/apache/incubator-devlake/plugins/helper"
+
+       "github.com/apache/incubator-devlake/plugins/core"
+)
+
+const RAW_MILESTONE_TABLE = "github_milestones"
+
+var CollectMilestonesMeta = core.SubTaskMeta{
+       Name:             "collectApiMilestones",
+       EntryPoint:       CollectApiMilestones,
+       EnabledByDefault: true,
+       Description:      "Collect milestone data from Github api",
+}
+
+func CollectApiMilestones(taskCtx core.SubTaskContext) error {
+       data := taskCtx.GetData().(*GithubTaskData)
+       collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{
+               RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+                       Ctx: taskCtx,
+                       Params: GithubApiParams{
+                               Owner: data.Options.Owner,
+                               Repo:  data.Options.Repo,
+                       },
+                       Table: RAW_MILESTONE_TABLE,
+               },
+               ApiClient:   data.ApiClient,
+               PageSize:    100,
+               Incremental: false,
+               UrlTemplate: "repos/{{ .Params.Owner }}/{{ .Params.Repo 
}}/milestones",
+               Query: func(reqData *helper.RequestData) (url.Values, error) {
+                       query := url.Values{}
+                       query.Set("state", "all")
+                       query.Set("direction", "asc")
+                       query.Set("page", fmt.Sprintf("%v", reqData.Pager.Page))
+                       query.Set("per_page", fmt.Sprintf("%v", 
reqData.Pager.Size))
+                       return query, nil
+               },
+               GetTotalPages: GetTotalPagesFromResponse,
+               ResponseParser: func(res *http.Response) ([]json.RawMessage, 
error) {
+                       var items []json.RawMessage
+                       err := helper.UnmarshalResponse(res, &items)
+                       if err != nil {
+                               return nil, err
+                       }
+                       return items, nil
+               },
+       })
+
+       if err != nil {
+               return err
+       }
+       return collector.Execute()
+}
diff --git a/plugins/github/tasks/milestone_converter.go 
b/plugins/github/tasks/milestone_converter.go
new file mode 100644
index 00000000..069f9623
--- /dev/null
+++ b/plugins/github/tasks/milestone_converter.go
@@ -0,0 +1,109 @@
+/*
+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/models/common"
+       "github.com/apache/incubator-devlake/models/domainlayer"
+       "github.com/apache/incubator-devlake/models/domainlayer/didgen"
+       "github.com/apache/incubator-devlake/models/domainlayer/ticket"
+       "github.com/apache/incubator-devlake/plugins/core"
+       "github.com/apache/incubator-devlake/plugins/core/dal"
+       githubModels "github.com/apache/incubator-devlake/plugins/github/models"
+       "github.com/apache/incubator-devlake/plugins/helper"
+       "reflect"
+)
+
+var ConvertMilestonesMeta = core.SubTaskMeta{
+       Name:             "convertMilestones",
+       EntryPoint:       ConvertMilestones,
+       EnabledByDefault: true,
+       Description:      "Convert tool layer table github_milestones into  
domain layer table milestones",
+}
+
+type MilestoneConverterModel struct {
+       common.RawDataOrigin
+       githubModels.GithubMilestone
+       GithubId int
+}
+
+func ConvertMilestones(taskCtx core.SubTaskContext) error {
+       data := taskCtx.GetData().(*GithubTaskData)
+       repoId := data.Repo.GithubId
+       connectionId := data.Options.ConnectionId
+       db := taskCtx.GetDal()
+       clauses := []dal.Clause{
+               dal.Select("gi.github_id, gm.*"),
+               dal.From("_tool_github_issues gi"),
+               dal.Join("JOIN _tool_github_milestones gm ON gm.milestone_id = 
gi.milestone_id"),
+               dal.Where("gm.repo_id = ?", repoId),
+       }
+       cursor, err := db.Cursor(clauses...)
+       if err != nil {
+               return err
+       }
+       defer cursor.Close()
+
+       boardIdGen := didgen.NewDomainIdGenerator(&githubModels.GithubRepo{})
+       domainBoardId := boardIdGen.Generate(connectionId, repoId)
+       sprintIdGen := 
didgen.NewDomainIdGenerator(&githubModels.GithubMilestone{})
+       issueIdGen := didgen.NewDomainIdGenerator(&githubModels.GithubIssue{})
+
+       converter, err := helper.NewDataConverter(helper.DataConverterArgs{
+               RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+                       Ctx: taskCtx,
+                       Params: GithubApiParams{
+                               ConnectionId: connectionId,
+                               Owner:        data.Options.Owner,
+                               Repo:         data.Options.Repo,
+                       },
+                       Table: RAW_MILESTONE_TABLE,
+               },
+               InputRowType: reflect.TypeOf(MilestoneConverterModel{}),
+               Input:        cursor,
+               Convert: func(inputRow interface{}) ([]interface{}, error) {
+                       response := inputRow.(*MilestoneConverterModel)
+                       domainSprintId := sprintIdGen.Generate(connectionId, 
response.GithubMilestone.MilestoneId)
+                       domainIssueId := issueIdGen.Generate(connectionId, 
response.GithubId)
+                       sprint := &ticket.Sprint{
+                               DomainEntity:    domainlayer.DomainEntity{Id: 
domainSprintId},
+                               Name:            response.GithubMilestone.Title,
+                               Url:             response.GithubMilestone.URL,
+                               Status:          response.GithubMilestone.State,
+                               StartedDate:     
&response.GithubMilestone.CreatedAt, //GitHub doesn't give us a "start date"
+                               EndedDate:       
response.GithubMilestone.ClosedAt,
+                               CompletedDate:   
response.GithubMilestone.ClosedAt,
+                               OriginalBoardID: domainBoardId,
+                       }
+                       boardSprint := &ticket.BoardSprint{
+                               BoardId:  domainBoardId,
+                               SprintId: domainSprintId,
+                       }
+                       sprintIssue := &ticket.SprintIssue{
+                               SprintId: domainSprintId,
+                               IssueId:  domainIssueId,
+                       }
+                       return []interface{}{sprint, sprintIssue, boardSprint}, 
nil
+               },
+       })
+       if err != nil {
+               return err
+       }
+
+       return converter.Execute()
+}
diff --git a/plugins/github/tasks/milestone_extractor.go 
b/plugins/github/tasks/milestone_extractor.go
new file mode 100644
index 00000000..fc61aa96
--- /dev/null
+++ b/plugins/github/tasks/milestone_extractor.go
@@ -0,0 +1,118 @@
+/*
+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"
+       "github.com/apache/incubator-devlake/plugins/core"
+       "github.com/apache/incubator-devlake/plugins/github/models"
+       "github.com/apache/incubator-devlake/plugins/helper"
+)
+
+var ExtractMilestonesMeta = core.SubTaskMeta{
+       Name:             "extractMilestones",
+       EntryPoint:       ExtractMilestones,
+       EnabledByDefault: true,
+       Description:      "Extract raw milestone data into tool layer table 
github_milestones",
+}
+
+type MilestonesResponse struct {
+       Url         string `json:"url"`
+       HtmlUrl     string `json:"html_url"`
+       LabelsUrl   string `json:"labels_url"`
+       Id          int    `json:"id"`
+       NodeId      string `json:"node_id"`
+       Number      int    `json:"number"`
+       Title       string `json:"title"`
+       Description string `json:"description"`
+       Creator     struct {
+               Login             string `json:"login"`
+               Id                int    `json:"id"`
+               NodeId            string `json:"node_id"`
+               AvatarUrl         string `json:"avatar_url"`
+               GravatarId        string `json:"gravatar_id"`
+               Url               string `json:"url"`
+               HtmlUrl           string `json:"html_url"`
+               FollowersUrl      string `json:"followers_url"`
+               FollowingUrl      string `json:"following_url"`
+               GistsUrl          string `json:"gists_url"`
+               StarredUrl        string `json:"starred_url"`
+               SubscriptionsUrl  string `json:"subscriptions_url"`
+               OrganizationsUrl  string `json:"organizations_url"`
+               ReposUrl          string `json:"repos_url"`
+               EventsUrl         string `json:"events_url"`
+               ReceivedEventsUrl string `json:"received_events_url"`
+               Type              string `json:"type"`
+               SiteAdmin         bool   `json:"site_admin"`
+       } `json:"creator"`
+       OpenIssues   int                 `json:"open_issues"`
+       ClosedIssues int                 `json:"closed_issues"`
+       State        string              `json:"state"`
+       CreatedAt    helper.Iso8601Time  `json:"created_at"`
+       UpdatedAt    helper.Iso8601Time  `json:"updated_at"`
+       DueOn        *helper.Iso8601Time `json:"due_on"`
+       ClosedAt     *helper.Iso8601Time `json:"closed_at"`
+}
+
+func ExtractMilestones(taskCtx core.SubTaskContext) error {
+       data := taskCtx.GetData().(*GithubTaskData)
+       extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
+               RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+                       Ctx: taskCtx,
+                       Params: GithubApiParams{
+                               ConnectionId: data.Options.ConnectionId,
+                               Owner:        data.Options.Owner,
+                               Repo:         data.Options.Repo,
+                       },
+                       Table: RAW_MILESTONE_TABLE,
+               },
+               Extract: func(row *helper.RawData) ([]interface{}, error) {
+                       response := &MilestonesResponse{}
+                       err := json.Unmarshal(row.Data, response)
+                       if err != nil {
+                               return nil, err
+                       }
+                       results := make([]interface{}, 0, 1)
+                       results = append(results, 
convertGithubMilestone(response, data.Options.ConnectionId, data.Repo.GithubId))
+                       return results, nil
+               },
+       })
+       if err != nil {
+               return err
+       }
+
+       return extractor.Execute()
+}
+
+func convertGithubMilestone(response *MilestonesResponse, connectionId uint64, 
repositoryId int) *models.GithubMilestone {
+       milestone := &models.GithubMilestone{
+               ConnectionId: connectionId,
+               MilestoneId:  response.Id,
+               RepoId:       repositoryId,
+               Number:       response.Number,
+               URL:          response.Url,
+               Title:        response.Title,
+               OpenIssues:   response.OpenIssues,
+               ClosedIssues: response.ClosedIssues,
+               State:        response.State,
+               ClosedAt:     helper.Iso8601TimeToTime(response.ClosedAt),
+               CreatedAt:    response.CreatedAt.ToTime(),
+               UpdatedAt:    response.UpdatedAt.ToTime(),
+       }
+       return milestone
+}
diff --git a/runner/directrun.go b/runner/directrun.go
index 4e0fa19a..05199e07 100644
--- a/runner/directrun.go
+++ b/runner/directrun.go
@@ -19,13 +19,18 @@ package runner
 
 import (
        "context"
+       "errors"
        "fmt"
-
        "github.com/apache/incubator-devlake/config"
        "github.com/apache/incubator-devlake/logger"
        "github.com/apache/incubator-devlake/migration"
        "github.com/apache/incubator-devlake/plugins/core"
        "github.com/spf13/cobra"
+       "io"
+       "os"
+       "os/signal"
+       "runtime"
+       "syscall"
 )
 
 func RunCmd(cmd *cobra.Command) {
@@ -72,13 +77,39 @@ func DirectRun(cmd *cobra.Command, args []string, 
pluginTask core.PluginTask, op
        if err != nil {
                panic(err)
        }
+       ctx := createContext()
+       err = RunPluginSubTasks(
+               cfg,
+               log,
+               db,
+               ctx,
+               cmd.Use,
+               tasks,
+               options,
+               pluginTask,
+               nil,
+       )
+       if err != nil {
+               panic(err)
+       }
+}
 
+func createContext() context.Context {
        ctx, cancel := context.WithCancel(context.Background())
+       sigc := make(chan os.Signal, 1)
+       signal.Notify(sigc, getStopSignals()...)
+       go func() {
+               <-sigc
+               cancel()
+       }()
        go func() {
                var buf string
 
                n, err := fmt.Scan(&buf)
                if err != nil {
+                       if errors.Is(err, io.EOF) {
+                               return
+                       }
                        panic(err)
                } else if n == 1 && buf == "c" {
                        cancel()
@@ -88,19 +119,16 @@ func DirectRun(cmd *cobra.Command, args []string, 
pluginTask core.PluginTask, op
                }
        }()
        println("press `c` and enter to send cancel signal")
+       return ctx
+}
 
-       err = RunPluginSubTasks(
-               cfg,
-               log,
-               db,
-               ctx,
-               cmd.Use,
-               tasks,
-               options,
-               pluginTask,
-               nil,
-       )
-       if err != nil {
-               panic(err)
+func getStopSignals() []os.Signal {
+       if runtime.GOOS == "windows" {
+               return []os.Signal{
+                       syscall.Signal(0x6), //syscall.SIGABRT for windows
+               }
+       }
+       return []os.Signal{
+               syscall.Signal(0x14), //syscall.SIGTSTP for posix
        }
 }

Reply via email to