blob: f3d1209c7024a6cbfbffc6c8305ac4a89a6543de [file]
/*
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"
"strconv"
"strings"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/plugin"
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/plugins/clickup/models"
)
var ExtractTaskMeta = plugin.SubTaskMeta{
Name: "Extract Tasks",
EntryPoint: ExtractTasks,
EnabledByDefault: true,
Description: "Extract raw task data into the tool layer table _tool_clickup_tasks",
DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET},
}
var _ plugin.SubTaskEntryPoint = ExtractTasks
// ClickUpApiTask is the subset of the ClickUp task JSON that the extractor reads.
type ClickUpApiTask struct {
Id string `json:"id"`
CustomId string `json:"custom_id"`
Name string `json:"name"`
TextContent string `json:"text_content"`
Description string `json:"markdown_description"`
Status *struct {
Status string `json:"status"`
Type string `json:"type"`
} `json:"status"`
DateCreated string `json:"date_created"`
DateUpdated string `json:"date_updated"`
DateClosed string `json:"date_closed"`
Creator *struct {
Id json.Number `json:"id"`
Username string `json:"username"`
} `json:"creator"`
Assignees []struct {
Id json.Number `json:"id"`
Username string `json:"username"`
} `json:"assignees"`
Priority *struct {
Priority string `json:"priority"`
} `json:"priority"`
Parent string `json:"parent"`
Url string `json:"url"`
// Points is ClickUp's native sprint points field (Fibonacci LOE for these
// teams). It is the default story-point source.
Points *float64 `json:"points"`
CustomFields []clickUpCustomField `json:"custom_fields"`
TaskType string `json:"task_type"`
List *struct {
Id string `json:"id"`
} `json:"list"`
Space *struct {
Id string `json:"id"`
} `json:"space"`
}
// clickUpCustomField is one entry of a task's custom_fields array. Value is left
// raw because ClickUp encodes it as a number or a string depending on the field.
type clickUpCustomField struct {
Name string `json:"name"`
Value json.RawMessage `json:"value"`
}
func ExtractTasks(taskCtx plugin.SubTaskContext) errors.Error {
data := taskCtx.GetData().(*ClickUpTaskData)
extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
Ctx: taskCtx,
Params: ClickUpApiParams{
ConnectionId: data.Options.ConnectionId,
FolderId: data.Options.FolderId,
},
Table: RAW_TASK_TABLE,
},
Extract: func(row *helper.RawData) ([]interface{}, errors.Error) {
apiTask := &ClickUpApiTask{}
if err := errors.Convert(json.Unmarshal(row.Data, apiTask)); err != nil {
return nil, err
}
if apiTask.Id == "" {
return nil, nil
}
description := apiTask.Description
if description == "" {
description = apiTask.TextContent
}
listId := ""
if apiTask.List != nil {
listId = apiTask.List.Id
}
task := &models.ClickUpTask{
ConnectionId: data.Options.ConnectionId,
Id: apiTask.Id,
ListId: listId,
FolderId: data.Options.FolderId,
CustomId: apiTask.CustomId,
Name: apiTask.Name,
Description: description,
Type: apiTask.TaskType,
ParentId: parentOf(apiTask.Parent),
StoryPoint: storyPointOf(apiTask, data.ScopeConfig),
Url: apiTask.Url,
CreatedDate: parseClickUpTime(apiTask.DateCreated),
UpdatedDate: parseClickUpTime(apiTask.DateUpdated),
ClosedDate: parseClickUpTime(apiTask.DateClosed),
}
if apiTask.Space != nil {
task.SpaceId = apiTask.Space.Id
}
if apiTask.Status != nil {
task.Status = apiTask.Status.Status
task.StatusType = apiTask.Status.Type
}
if apiTask.Priority != nil {
task.Priority = apiTask.Priority.Priority
}
if apiTask.Creator != nil {
task.CreatorId = apiTask.Creator.Id.String()
}
// TODO(clickup): ClickUp tasks can have multiple assignees. The MVP
// keeps only the first for Issue.AssigneeId; emitting one
// ticket.IssueAssignee row per assignee is a follow-up.
if len(apiTask.Assignees) > 0 {
task.AssigneeId = apiTask.Assignees[0].Id.String()
task.AssigneeName = apiTask.Assignees[0].Username
}
return []interface{}{task}, nil
},
})
if err != nil {
return err
}
return extractor.Execute()
}
// parentOf normalizes ClickUp's parent field, which is the JSON literal null
// (decoded to an empty string) for top-level tasks.
func parentOf(parent string) string {
if parent == "null" {
return ""
}
return parent
}
// storyPointOf resolves a task's story points. Default source is ClickUp's
// native sprint points field. When the scope config names a custom field, that
// field's numeric value wins (teams that track Fibonacci LOE in a custom field).
func storyPointOf(apiTask *ClickUpApiTask, sc *models.ClickUpScopeConfig) *float64 {
if sc != nil && sc.StoryPointField != "" {
for _, cf := range apiTask.CustomFields {
if strings.EqualFold(cf.Name, sc.StoryPointField) {
return numericValue(cf.Value)
}
}
return nil
}
return apiTask.Points
}
// numericValue coerces a raw custom-field value (JSON number or quoted string)
// into a float pointer; returns nil for empty / non-numeric values.
func numericValue(raw json.RawMessage) *float64 {
s := strings.TrimSpace(strings.Trim(string(raw), `"`))
if s == "" || s == "null" {
return nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
return &f
}