diff --git a/backend/plugins/zentao/e2e/raw_tables/_raw_zentao_api_task_worklogs.csv b/backend/plugins/zentao/e2e/raw_tables/_raw_zentao_api_task_worklogs.csv new file mode 100644 index 00000000000..41131be4bba --- /dev/null +++ b/backend/plugins/zentao/e2e/raw_tables/_raw_zentao_api_task_worklogs.csv @@ -0,0 +1,3 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ProjectId"":48}","{""id"":106,""objectType"":""task"",""objectID"":135,""product"":"""",""project"":48,""execution"":49,""account"":""devlake"",""work"":""sample worklog"",""vision"":""rnd"",""date"":""2025-02-20"",""left"":5,""consumed"":11,""begin"":0,""end"":0,""extra"":null,""order"":0,""deleted"":""0""}",http://iwater.red:8000/api.php/v1/tasks/135/estimate,"{""id"":135}",2025-02-21 06:28:36.902 +2,"{""ConnectionId"":1,""ProjectId"":48}","{""id"":107,""objectType"":""task"",""objectID"":135,""product"":"""",""project"":48,""execution"":49,""account"":""devlake"",""work"":"""",""vision"":""rnd"",""date"":""2025-02-20"",""left"":1,""consumed"":4,""begin"":0,""end"":0,""extra"":null,""order"":0,""deleted"":""0""}",http://iwater.red:8000/api.php/v1/tasks/135/estimate,"{""id"":135}",2025-02-21 06:28:37.001 diff --git a/backend/plugins/zentao/e2e/snapshot_tables/_tool_zentao_worklogs.csv b/backend/plugins/zentao/e2e/snapshot_tables/_tool_zentao_worklogs.csv new file mode 100644 index 00000000000..4cfce3fc1f5 --- /dev/null +++ b/backend/plugins/zentao/e2e/snapshot_tables/_tool_zentao_worklogs.csv @@ -0,0 +1,3 @@ +connection_id,id,object_id,object_type,project,execution,product,account,work,vision,date,left,consumed,begin,end,extra,order,deleted +1,106,135,task,48,49,,devlake,sample worklog,rnd,2025-02-20,5,11,0,0,,0,0 +1,107,135,task,48,49,,devlake,,rnd,2025-02-20,1,4,0,0,,0,0 diff --git a/backend/plugins/zentao/e2e/snapshot_tables/issue_worklogs.csv b/backend/plugins/zentao/e2e/snapshot_tables/issue_worklogs.csv new file mode 100644 index 00000000000..2afb729c0e0 --- /dev/null +++ b/backend/plugins/zentao/e2e/snapshot_tables/issue_worklogs.csv @@ -0,0 +1,3 @@ +id,author_id,comment,time_spent_minutes,logged_date,started_date,issue_id,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark +zentao:ZentaoWorklog:1:106,zentao:ZentaoAccount:1:1,sample worklog,660,2025-02-20T00:00:00.000+00:00,2025-02-20T00:00:00.000+00:00,zentao:ZentaoTask:1:135,"{""ConnectionId"":1,""ProjectId"":48}",_raw_zentao_api_task_worklogs,1, +zentao:ZentaoWorklog:1:107,zentao:ZentaoAccount:1:1,,240,2025-02-20T00:00:00.000+00:00,2025-02-20T00:00:00.000+00:00,zentao:ZentaoTask:1:135,"{""ConnectionId"":1,""ProjectId"":48}",_raw_zentao_api_task_worklogs,2, diff --git a/backend/plugins/zentao/e2e/task_worklogs_test.go b/backend/plugins/zentao/e2e/task_worklogs_test.go new file mode 100644 index 00000000000..444a70477c7 --- /dev/null +++ b/backend/plugins/zentao/e2e/task_worklogs_test.go @@ -0,0 +1,64 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "testing" + + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" + "github.com/apache/incubator-devlake/helpers/e2ehelper" + "github.com/apache/incubator-devlake/plugins/zentao/impl" + "github.com/apache/incubator-devlake/plugins/zentao/models" + "github.com/apache/incubator-devlake/plugins/zentao/tasks" +) + +func TestZentaoTaskWorklogDataFlow(t *testing.T) { + + var zentao impl.Zentao + dataflowTester := e2ehelper.NewDataFlowTester(t, "zentao", zentao) + + taskData := &tasks.ZentaoTaskData{ + Options: &tasks.ZentaoOptions{ + ConnectionId: 1, + ProjectId: 48, + }, + ApiClient: getFakeAPIClient(), + } + + // import _raw_zentao_api_task_worklogs raw data table + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_zentao_api_task_worklogs.csv", + "_raw_zentao_api_task_worklogs") + dataflowTester.ImportCsvIntoTabler("./snapshot_tables/_tool_zentao_accounts.csv", &models.ZentaoAccount{}) + + // verify worklogs extraction + dataflowTester.FlushTabler(&models.ZentaoWorklog{}) + dataflowTester.Subtask(tasks.ExtractTaskWorklogsMeta, taskData) + dataflowTester.VerifyTableWithOptions(&models.ZentaoWorklog{}, e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_zentao_worklogs.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) + + // verify task repo commit conversion + dataflowTester.FlushTabler(&ticket.IssueWorklog{}) + dataflowTester.Subtask(tasks.ConvertTaskWorklogsMeta, taskData) + dataflowTester.VerifyTableWithOptions(&ticket.IssueWorklog{}, e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/issue_worklogs.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) +} diff --git a/backend/plugins/zentao/impl/impl.go b/backend/plugins/zentao/impl/impl.go index 2c24a235dc3..2111426b0d5 100644 --- a/backend/plugins/zentao/impl/impl.go +++ b/backend/plugins/zentao/impl/impl.go @@ -91,6 +91,7 @@ func (p Zentao) GetTablesInfo() []dal.Tabler { &models.ZentaoExecutionSummary{}, &models.ZentaoProductSummary{}, &models.ZentaoProjectStory{}, + &models.ZentaoWorklog{}, } } @@ -160,6 +161,10 @@ func (p Zentao) SubTaskMetas() []plugin.SubTaskMeta { tasks.DBGetChangelogMeta, tasks.ConvertChangelogMeta, + + tasks.CollectTaskWorklogsMeta, + tasks.ExtractTaskWorklogsMeta, + tasks.ConvertTaskWorklogsMeta, } } diff --git a/backend/plugins/zentao/models/migrationscripts/20250219_add_worklogs.go b/backend/plugins/zentao/models/migrationscripts/20250219_add_worklogs.go new file mode 100644 index 00000000000..0b11778a447 --- /dev/null +++ b/backend/plugins/zentao/models/migrationscripts/20250219_add_worklogs.go @@ -0,0 +1,42 @@ +/* +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 ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/helpers/migrationhelper" + "github.com/apache/incubator-devlake/plugins/zentao/models/migrationscripts/archived" +) + +type addWorklogs struct{} + +func (*addWorklogs) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &archived.ZentaoWorklog{}, + ) +} + +func (*addWorklogs) Version() uint64 { + return 20250219153329 +} + +func (*addWorklogs) Name() string { + return "add table _tool_zentao_worklogs" +} diff --git a/backend/plugins/zentao/models/migrationscripts/archived/worklog.go b/backend/plugins/zentao/models/migrationscripts/archived/worklog.go new file mode 100644 index 00000000000..86fdca66f7a --- /dev/null +++ b/backend/plugins/zentao/models/migrationscripts/archived/worklog.go @@ -0,0 +1,48 @@ +/* +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 archived + +import ( + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" +) + +type ZentaoWorklog struct { + archived.NoPKModel + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + Id uint64 `gorm:"primaryKey;type:BIGINT NOT NULL;autoIncrement:false" json:"id"` + ObjectId uint64 `json:"objectID"` + ObjectType string `json:"objectType"` + Project uint64 `json:"project"` + Execution uint64 `json:"execution"` + Product string `json:"product"` + Account string `json:"account"` + Work string `json:"work"` + Vision string `json:"vision"` + Date string `json:"date"` + Left float32 `json:"left"` + Consumed float32 `json:"consumed"` + Begin uint64 `json:"begin"` + End uint64 `json:"end"` + Extra *string `json:"extra"` + Order uint64 `json:"order"` + Deleted string `json:"deleted"` +} + +func (ZentaoWorklog) TableName() string { + return "_tool_zentao_worklogs" +} diff --git a/backend/plugins/zentao/models/migrationscripts/register.go b/backend/plugins/zentao/models/migrationscripts/register.go index 914eb1f1132..582d5432cfc 100644 --- a/backend/plugins/zentao/models/migrationscripts/register.go +++ b/backend/plugins/zentao/models/migrationscripts/register.go @@ -32,5 +32,6 @@ func All() []plugin.MigrationScript { new(addExecutionStoryAndExecutionSummary), new(addRawParamTableForScope), new(dropTotalReal), + new(addWorklogs), } } diff --git a/backend/plugins/zentao/models/worklog.go b/backend/plugins/zentao/models/worklog.go new file mode 100644 index 00000000000..24ec38249ef --- /dev/null +++ b/backend/plugins/zentao/models/worklog.go @@ -0,0 +1,48 @@ +/* +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 models + +import ( + "github.com/apache/incubator-devlake/core/models/common" +) + +type ZentaoWorklog struct { + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + Id int64 `gorm:"primaryKey;type:BIGINT NOT NULL;autoIncrement:false" json:"id"` + ObjectId int64 `json:"objectID"` + ObjectType string `json:"objectType"` + Project int64 `json:"project"` + Execution int64 `json:"execution"` + Product string `json:"product"` + Account string `json:"account"` + Work string `json:"work"` + Vision string `json:"vision"` + Date string `json:"date"` + Left float32 `json:"left"` + Consumed float32 `json:"consumed"` + Begin int64 `json:"begin"` + End int64 `json:"end"` + Extra *string `json:"extra"` + Order int64 `json:"order"` + Deleted string `json:"deleted"` + common.NoPKModel +} + +func (ZentaoWorklog) TableName() string { + return "_tool_zentao_worklogs" +} diff --git a/backend/plugins/zentao/tasks/task_worklog_collector.go b/backend/plugins/zentao/tasks/task_worklog_collector.go new file mode 100644 index 00000000000..4bf6102885e --- /dev/null +++ b/backend/plugins/zentao/tasks/task_worklog_collector.go @@ -0,0 +1,121 @@ +/* +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" + "net/http" + "net/url" + "reflect" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/zentao/models" +) + +const RAW_TASK_WORKLOGS_TABLE = "zentao_api_task_worklogs" + +var CollectTaskWorklogsMeta = plugin.SubTaskMeta{ + Name: "collectTaskWorklogs", + EntryPoint: CollectTaskWorklogs, + EnabledByDefault: true, + Description: "collect Zentao task work logs, supports both timeFilter and diffSync.", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +type Input struct { + Id uint64 `json:"id"` +} + +func CollectTaskWorklogs(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + data := taskCtx.GetData().(*ZentaoTaskData) + + logger := taskCtx.GetLogger() + + apiCollector, err := api.NewStatefulApiCollector(api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_TASK_WORKLOGS_TABLE, + }) + if err != nil { + return err + } + + // load task IDs from db + clauses := []dal.Clause{ + dal.Select("id"), + dal.From(&models.ZentaoTask{}), + dal.Where( + "project = ? AND connection_id = ?", + data.Options.ProjectId, data.Options.ConnectionId, + ), + } + if apiCollector.IsIncremental() && apiCollector.GetSince() != nil { + clauses = append(clauses, dal.Where("last_edited_date IS NOT NULL AND last_edited_date > ?", apiCollector.GetSince())) + } + + // construct the input iterator + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + iterator, err := api.NewDalCursorIterator(db, cursor, reflect.TypeOf(Input{})) + if err != nil { + return err + } + + // collect task worklogs + err = apiCollector.InitCollector(api.ApiCollectorArgs{ + Input: iterator, + ApiClient: data.ApiClient, + UrlTemplate: "tasks/{{ .Input.Id }}/estimate", + Query: func(reqData *api.RequestData) (url.Values, errors.Error) { + return nil, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + var data struct { + Effort json.RawMessage `json:"effort"` + } + err := api.UnmarshalResponse(res, &data) + if err != nil { + return nil, err + } + + if string(data.Effort) == "{}" || string(data.Effort) == "null" { + return nil, nil + } + + var efforts []json.RawMessage + jsonErr := json.Unmarshal(data.Effort, &efforts) + if jsonErr != nil { + return nil, errors.Default.Wrap(jsonErr, "failed to unmarshal efforts") + } + return efforts, nil + }, + AfterResponse: ignoreHTTPStatus404, + }) + if err != nil { + logger.Error(err, "collect Zentao task worklogs error") + return err + } + + return apiCollector.Execute() +} diff --git a/backend/plugins/zentao/tasks/task_worklog_convertor.go b/backend/plugins/zentao/tasks/task_worklog_convertor.go new file mode 100644 index 00000000000..9fea703b55e --- /dev/null +++ b/backend/plugins/zentao/tasks/task_worklog_convertor.go @@ -0,0 +1,124 @@ +/* +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 ( + "reflect" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/models/domainlayer" + "github.com/apache/incubator-devlake/core/models/domainlayer/didgen" + "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/zentao/models" +) + +var ConvertTaskWorklogsMeta = plugin.SubTaskMeta{ + Name: "convertTaskWorklogs", + EntryPoint: ConvertTaskWorklogs, + EnabledByDefault: true, + Description: "convert Zentao task worklogs", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertTaskWorklogs(taskCtx plugin.SubTaskContext) errors.Error { + logger := taskCtx.GetLogger() + db := taskCtx.GetDal() + data := taskCtx.GetData().(*ZentaoTaskData) + logger.Info( + "convert Zentao task worklogs of %d in %d", + data.Options.ProjectId, + data.Options.ConnectionId, + ) + worklogIdGen := didgen.NewDomainIdGenerator(&models.ZentaoWorklog{}) + clauses := []dal.Clause{ + dal.From(&models.ZentaoWorklog{}), + dal.Where( + "connection_id = ? AND project = ? AND object_type = ?", + data.Options.ConnectionId, + data.Options.ProjectId, + "task", + ), + } + + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + defer cursor.Close() + + taskIdGen := didgen.NewDomainIdGenerator(&models.ZentaoTask{}) + + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_TASK_WORKLOGS_TABLE, + }, + InputRowType: reflect.TypeOf(models.ZentaoWorklog{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + toolL := inputRow.(*models.ZentaoWorklog) + domainL := &ticket.IssueWorklog{ + DomainEntity: domainlayer.DomainEntity{ + Id: worklogIdGen.Generate(data.Options.ConnectionId, toolL.Id), + }, + Comment: toolL.Work, + TimeSpentMinutes: int(toolL.Consumed * 60), + } + timeData, err := common.ConvertStringToTime(toolL.Date) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to convert zentao task worklog date") + } + // zentao task only has one field as date type for worklog creation + domainL.StartedDate = &timeData + domainL.LoggedDate = &timeData + + domainL.IssueId = taskIdGen.Generate(data.Options.ConnectionId, toolL.ObjectId) + + // get ID of account by username + var account models.ZentaoAccount + err = db.First(&account, dal.Where("connection_id = ? AND account = ?", + data.Options.ConnectionId, toolL.Account)) + if err != nil { + // if account isn't available, giving empty string as ID + if db.IsErrorNotFound(err) { + logger.Warn(nil, "cannot find zentao account by account: %s", toolL.Account) + domainL.AuthorId = "" + } else { + return nil, errors.Default.Wrap(err, "failed to get zentao account by account") + } + } else { + accountIdGen := didgen.NewDomainIdGenerator(&models.ZentaoAccount{}) + domainL.AuthorId = accountIdGen.Generate(account.ConnectionId, account.ID) + } + + return []interface{}{ + domainL, + }, nil + }, + }) + if err != nil { + return err + } + + return converter.Execute() +} diff --git a/backend/plugins/zentao/tasks/task_worklog_extractor.go b/backend/plugins/zentao/tasks/task_worklog_extractor.go new file mode 100644 index 00000000000..928890675c1 --- /dev/null +++ b/backend/plugins/zentao/tasks/task_worklog_extractor.go @@ -0,0 +1,100 @@ +/* +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/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/zentao/models" +) + +var _ plugin.SubTaskEntryPoint = ExtractTaskWorklogs + +var ExtractTaskWorklogsMeta = plugin.SubTaskMeta{ + Name: "extractTaskWorklogs", + EntryPoint: ExtractTaskWorklogs, + EnabledByDefault: true, + Description: "Extract raw zentao task worklog data into tool layer table _tool_zentao_worklogs", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ExtractTaskWorklogs(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*ZentaoTaskData) + extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{ + RawDataSubTaskArgs: api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Options: data.Options, + Table: RAW_TASK_WORKLOGS_TABLE, + }, + Extract: func(row *api.RawData) ([]interface{}, errors.Error) { + var input struct { + Id int64 `json:"id"` + ObjectType string `json:"objectType"` + ObjectId int64 `json:"objectID"` + Product string `json:"product"` + Project int64 `json:"project"` + Execution int64 `json:"Execution"` + Account string `json:"account"` + Work string `json:"work"` + Vision string `json:"vision"` + Date string `json:"date"` + Left float32 `json:"left"` + Consumed float32 `json:"consumed"` + Begin int64 `json:"begin"` + End int64 `json:"end"` + Extra *string `json:"extra"` + Order int64 `json:"order"` + Deleted string `json:"deleted"` + } + + err := errors.Convert(json.Unmarshal(row.Data, &input)) + if err != nil { + return nil, err + } + worklog := &models.ZentaoWorklog{ + ConnectionId: data.Options.ConnectionId, + Id: input.Id, + ObjectId: input.ObjectId, + ObjectType: input.ObjectType, + Project: input.Project, + Execution: input.Execution, + Product: input.Product, + Account: input.Account, + Work: input.Work, + Vision: input.Vision, + Date: input.Date, + Left: input.Left, + Consumed: input.Consumed, + Begin: input.Begin, + End: input.End, + Extra: input.Extra, + Order: input.Order, + Deleted: input.Deleted, + } + return []interface{}{worklog}, nil + }, + }) + + if err != nil { + return err + } + + return extractor.Execute() +}