From 2cb1ba7b66e06f92da523b4e52c59c8be64be72e Mon Sep 17 00:00:00 2001 From: Jonathan Schmitt Date: Thu, 27 Jun 2024 15:25:44 -0300 Subject: [PATCH 1/4] chore: Update comment ID type to int in ClickUp API --- clickup/comments.go | 4 ++-- clickup/comments_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/clickup/comments.go b/clickup/comments.go index e5ad643..260656b 100644 --- a/clickup/comments.go +++ b/clickup/comments.go @@ -20,7 +20,7 @@ type UpdateCommentRequest struct { } type CreateCommentResponse struct { - ID string `json:"id"` + ID int `json:"id"` HistId string `json:"hist_id"` Date *Date `json:"date"` } @@ -35,7 +35,7 @@ type TaskCommentOptions struct { } type Comment struct { - ID string `json:"id"` + ID int `json:"id"` Comment []CommentInComment `json:"comment"` CommentText string `json:"comment_text"` User User `json:"user"` diff --git a/clickup/comments_test.go b/clickup/comments_test.go index a136861..e336001 100644 --- a/clickup/comments_test.go +++ b/clickup/comments_test.go @@ -44,7 +44,7 @@ func TestCommentsService_CreateTaskComment(t *testing.T) { t.Errorf("Actions.ListArtifacts returned error: %v", err) } - want := &CreateCommentResponse{ID: "458", HistId: "26508", Date: NewDateWithUnixTime(1568036964079)} + want := &CreateCommentResponse{ID: 458, HistId: "26508", Date: NewDateWithUnixTime(1568036964079)} if !cmp.Equal(artifacts, want) { t.Errorf("Actions.ListArtifacts returned %+v, want %+v", artifacts, want) } @@ -115,7 +115,7 @@ func TestCommentsService_GetTaskComments(t *testing.T) { ProfilePicture: "https://attachments-public.clickup.com/profilePictures/183_abc.jpg", } comment := Comment{ - ID: "458", + ID: 458, Comment: []CommentInComment{{Text: "Task comment content"}}, CommentText: "Task comment content", User: user, From 80e48ae01c9f43d285f165f803727c7986b3f194 Mon Sep 17 00:00:00 2001 From: Jonathan Schmitt Date: Thu, 27 Jun 2024 17:14:54 -0300 Subject: [PATCH 2/4] feat: Add TimeTracking service to ClickUp client --- clickup/client.go | 2 + clickup/spaces.go | 22 +++---- clickup/time_tracking.go | 104 ++++++++++++++++++++++++++++++++++ clickup/time_tracking_test.go | 43 ++++++++++++++ 4 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 clickup/time_tracking.go create mode 100644 clickup/time_tracking_test.go diff --git a/clickup/client.go b/clickup/client.go index 82c05b2..6e3dc0c 100644 --- a/clickup/client.go +++ b/clickup/client.go @@ -55,6 +55,7 @@ type Client struct { Tasks *TasksService TaskTemplates *TaskTemplatesService Teams *TeamsService + TimeTrackings *TimeTrackingsService SharedHierarchy *SharedHierarchyService Spaces *SpacesService Folders *FoldersService @@ -138,6 +139,7 @@ func NewClient(httpClient *http.Client, APIKey string) *Client { c.Tasks = (*TasksService)(&c.common) c.TaskTemplates = (*TaskTemplatesService)(&c.common) c.Teams = (*TeamsService)(&c.common) + c.TimeTrackings = (*TimeTrackingsService)(&c.common) c.SharedHierarchy = (*SharedHierarchyService)(&c.common) c.Spaces = (*SpacesService)(&c.common) c.Folders = (*FoldersService)(&c.common) diff --git a/clickup/spaces.go b/clickup/spaces.go index 983c823..fc8df44 100644 --- a/clickup/spaces.go +++ b/clickup/spaces.go @@ -25,7 +25,7 @@ type DueDates struct { RemapClosedDueDate bool `json:"remap_closed_due_date"` } -type TimeTracking struct { +type TimeTrackingFeature struct { Enabled bool `json:"enabled"` } @@ -60,15 +60,15 @@ type Portfolios struct { } type Features struct { - DueDates DueDates `json:"due_dates"` - TimeTracking TimeTracking `json:"time_tracking"` - Tags Tags `json:"tags"` - TimeEstimates TimeEstimates `json:"time_estimates"` - Checklists Checklists `json:"checklists"` - CustomFields CustomFields `json:"custom_fields"` - RemapDependencies RemapDependencies `json:"remap_dependencies"` - DependencyWarning DependencyWarning `json:"dependency_warning"` - Portfolios Portfolios `json:"portfolios"` + DueDates DueDates `json:"due_dates"` + TimeTracking TimeTrackingFeature `json:"time_tracking"` + Tags Tags `json:"tags"` + TimeEstimates TimeEstimates `json:"time_estimates"` + Checklists Checklists `json:"checklists"` + CustomFields CustomFields `json:"custom_fields"` + RemapDependencies RemapDependencies `json:"remap_dependencies"` + DependencyWarning DependencyWarning `json:"dependency_warning"` + Portfolios Portfolios `json:"portfolios"` } type Space struct { @@ -88,7 +88,7 @@ type Space struct { Sprints struct { Enabled bool `json:"enabled"` } `json:"sprints"` - TimeTracking TimeTracking `json:"time_tracking"` + TimeTracking TimeTrackingFeature `json:"time_tracking"` Points struct { Enabled bool `json:"enabled"` } `json:"points"` diff --git a/clickup/time_tracking.go b/clickup/time_tracking.go new file mode 100644 index 0000000..9a1a794 --- /dev/null +++ b/clickup/time_tracking.go @@ -0,0 +1,104 @@ +package clickup + +import ( + "context" + "fmt" +) + +type TimeTrackingsService service + +type GetTimeTrackingResponse struct { + Data []TimeTracking `json:"data"` +} + +// See https://clickup.com/api/clickupreference/operation/Createatimeentry/ +type TimeTrackingRequest struct { + Description string `json:"description,omitempty"` + Tags []TimeTrackingTag `json:"tags,omitempty"` + Start int64 `json:"start"` + End int64 `json:"end,omitempty"` + Stop int64 `json:"stop,omitempty"` + Billable bool `json:"billable,omitempty"` + Duration int32 `json:"duration"` + Assignee int `json:"assignee,omitempty"` + Tid string `json:"tid,omitempty"` +} + +type TimeTrackingTag struct { + Name string `json:"name"` + TagBg string `json:"tag_bg"` + TagFg string `json:"tag_fg"` + Creator int `json:"creator"` +} + +type TimeTracking struct { + ID string `json:"id"` + Wid string `json:"wid"` + User User `json:"user"` + Billable bool `json:"billable"` + Start string `json:"start"` + End string `json:"end"` + Duration string `json:"duration"` + Description string `json:"description"` + Source string `json:"source"` + At string `json:"at"` + TaskLocation TaskLocation `json:"task_location"` + Task []TimeTrackingTag `json:"task_"` + TaskURL string `json:"task_url"` +} + +type TaskLocation struct { + ListID int `json:"list_id"` + FolderID int `json:"folder_id"` + SpaceID int `json:"space_id"` + ListName string `json:"list_name"` + FolderName string `json:"folder_name"` + SpaceName string `json:"space_name"` +} + +type CreateTimeTrackingOptions struct { + CustomTaskIDs bool `url:"custom_task_ids,omitempty"` + TeamID int `url:"team_id,omitempty"` +} + +func (s *TimeTrackingsService) CreateTimeTracking(ctx context.Context, teamID string, opts *CreateTimeTrackingOptions, ttr *TimeTrackingRequest) (*TimeTracking, *Response, error) { + u := fmt.Sprintf("team/%s/time_entries", teamID) + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("POST", u, ttr) + if err != nil { + return nil, nil, err + } + + timeTracking := new(TimeTracking) + resp, err := s.client.Do(ctx, req, timeTracking) + if err != nil { + return nil, resp, err + } + + return timeTracking, resp, nil +} + +func (s *TimeTrackingsService) GetTimeTracking(ctx context.Context, teamID string, timerID string, opts *CreateTimeTrackingOptions, ttr *TimeTrackingRequest) (*GetTimeTrackingResponse, *Response, error) { + u := fmt.Sprintf("team/%s/time_entries/%s", teamID, timerID) + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, ttr) + if err != nil { + return nil, nil, err + } + + getTimeTrackingResponse := new(GetTimeTrackingResponse) + resp, err := s.client.Do(ctx, req, getTimeTrackingResponse) + if err != nil { + return nil, resp, err + } + + return getTimeTrackingResponse, resp, nil +} diff --git a/clickup/time_tracking_test.go b/clickup/time_tracking_test.go new file mode 100644 index 0000000..eb82e93 --- /dev/null +++ b/clickup/time_tracking_test.go @@ -0,0 +1,43 @@ +package clickup + +import ( + "context" + "fmt" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestTimeTrackingService_CreateTimeTracking(t *testing.T) { + client, _, _, teardown := setup() + defer teardown() + + teamID := "teamID" + taskID := "taskID" + assigneeID := 1234 + var dur int32 = 120000 + + start := time.Now().Add(time.Millisecond * time.Duration(-dur)) + + _, _, err := client.TimeTrackings.CreateTimeTracking(context.Background(), teamID, + &CreateTimeTrackingOptions{}, + &TimeTrackingRequest{ + Description: "description", + Start: start.Unix() * 1000, + Duration: dur, + Assignee: assigneeID, + Tid: taskID, + Billable: true, + }) + + OK(t, err) +} + +func OK(tb testing.TB, err error) { + if err != nil { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) + tb.FailNow() + } +} From 349bbbfd92315ec226d93d8ef21f943819df75e8 Mon Sep 17 00:00:00 2001 From: Jonathan Schmitt Date: Fri, 28 Jun 2024 12:28:54 -0300 Subject: [PATCH 3/4] chore: Fix comments tests and improve time tracking --- clickup/comments_test.go | 4 +- clickup/spaces.go | 22 +++---- clickup/tasks.go | 1 + clickup/time_tracking.go | 22 ++++--- clickup/time_tracking_test.go | 111 +++++++++++++++++++++++++--------- go.mod | 2 +- 6 files changed, 112 insertions(+), 50 deletions(-) diff --git a/clickup/comments_test.go b/clickup/comments_test.go index e336001..e6f22a7 100644 --- a/clickup/comments_test.go +++ b/clickup/comments_test.go @@ -31,7 +31,7 @@ func TestCommentsService_CreateTaskComment(t *testing.T) { fmt.Fprint(w, `{ - "id": "458", + "id": 458, "hist_id": "26508", "date": 1568036964079 }`, @@ -60,7 +60,7 @@ func TestCommentsService_GetTaskComments(t *testing.T) { `{ "comments": [ { - "id": "458", + "id": 458, "comment": [ { "text": "Task comment content" diff --git a/clickup/spaces.go b/clickup/spaces.go index fc8df44..983c823 100644 --- a/clickup/spaces.go +++ b/clickup/spaces.go @@ -25,7 +25,7 @@ type DueDates struct { RemapClosedDueDate bool `json:"remap_closed_due_date"` } -type TimeTrackingFeature struct { +type TimeTracking struct { Enabled bool `json:"enabled"` } @@ -60,15 +60,15 @@ type Portfolios struct { } type Features struct { - DueDates DueDates `json:"due_dates"` - TimeTracking TimeTrackingFeature `json:"time_tracking"` - Tags Tags `json:"tags"` - TimeEstimates TimeEstimates `json:"time_estimates"` - Checklists Checklists `json:"checklists"` - CustomFields CustomFields `json:"custom_fields"` - RemapDependencies RemapDependencies `json:"remap_dependencies"` - DependencyWarning DependencyWarning `json:"dependency_warning"` - Portfolios Portfolios `json:"portfolios"` + DueDates DueDates `json:"due_dates"` + TimeTracking TimeTracking `json:"time_tracking"` + Tags Tags `json:"tags"` + TimeEstimates TimeEstimates `json:"time_estimates"` + Checklists Checklists `json:"checklists"` + CustomFields CustomFields `json:"custom_fields"` + RemapDependencies RemapDependencies `json:"remap_dependencies"` + DependencyWarning DependencyWarning `json:"dependency_warning"` + Portfolios Portfolios `json:"portfolios"` } type Space struct { @@ -88,7 +88,7 @@ type Space struct { Sprints struct { Enabled bool `json:"enabled"` } `json:"sprints"` - TimeTracking TimeTrackingFeature `json:"time_tracking"` + TimeTracking TimeTracking `json:"time_tracking"` Points struct { Enabled bool `json:"enabled"` } `json:"points"` diff --git a/clickup/tasks.go b/clickup/tasks.go index 91b7960..a6d2ed0 100644 --- a/clickup/tasks.go +++ b/clickup/tasks.go @@ -129,6 +129,7 @@ type TasksInStatus struct { } type TaskStatus struct { + ID string `json:"id"` Status string `json:"status"` Color string `json:"color"` Type string `json:"type"` diff --git a/clickup/time_tracking.go b/clickup/time_tracking.go index 9a1a794..bdf243a 100644 --- a/clickup/time_tracking.go +++ b/clickup/time_tracking.go @@ -8,7 +8,11 @@ import ( type TimeTrackingsService service type GetTimeTrackingResponse struct { - Data []TimeTracking `json:"data"` + Data TimeTrackingData `json:"data"` +} + +type CreateTimeTrackingResponse struct { + Data TimeTrackingData `json:"data"` } // See https://clickup.com/api/clickupreference/operation/Createatimeentry/ @@ -31,19 +35,21 @@ type TimeTrackingTag struct { Creator int `json:"creator"` } -type TimeTracking struct { +type TimeTrackingData struct { ID string `json:"id"` Wid string `json:"wid"` User User `json:"user"` Billable bool `json:"billable"` - Start string `json:"start"` + Start int `json:"start"` End string `json:"end"` - Duration string `json:"duration"` + Duration int `json:"duration"` Description string `json:"description"` Source string `json:"source"` - At string `json:"at"` + At int `json:"at"` + IsLocked bool `json:"is_locked"` TaskLocation TaskLocation `json:"task_location"` - Task []TimeTrackingTag `json:"task_"` + Task Task `json:"task"` + Tags []TimeTrackingTag `json:"tags"` TaskURL string `json:"task_url"` } @@ -61,7 +67,7 @@ type CreateTimeTrackingOptions struct { TeamID int `url:"team_id,omitempty"` } -func (s *TimeTrackingsService) CreateTimeTracking(ctx context.Context, teamID string, opts *CreateTimeTrackingOptions, ttr *TimeTrackingRequest) (*TimeTracking, *Response, error) { +func (s *TimeTrackingsService) CreateTimeTracking(ctx context.Context, teamID string, opts *CreateTimeTrackingOptions, ttr *TimeTrackingRequest) (*CreateTimeTrackingResponse, *Response, error) { u := fmt.Sprintf("team/%s/time_entries", teamID) u, err := addOptions(u, opts) if err != nil { @@ -73,7 +79,7 @@ func (s *TimeTrackingsService) CreateTimeTracking(ctx context.Context, teamID st return nil, nil, err } - timeTracking := new(TimeTracking) + timeTracking := new(CreateTimeTrackingResponse) resp, err := s.client.Do(ctx, req, timeTracking) if err != nil { return nil, resp, err diff --git a/clickup/time_tracking_test.go b/clickup/time_tracking_test.go index eb82e93..75179d1 100644 --- a/clickup/time_tracking_test.go +++ b/clickup/time_tracking_test.go @@ -2,42 +2,97 @@ package clickup import ( "context" + "encoding/json" "fmt" - "path/filepath" - "runtime" + "net/http" "testing" - "time" + + "github.com/google/go-cmp/cmp" ) func TestTimeTrackingService_CreateTimeTracking(t *testing.T) { - client, _, _, teardown := setup() + client, mux, _, teardown := setup() defer teardown() - teamID := "teamID" - taskID := "taskID" - assigneeID := 1234 - var dur int32 = 120000 - - start := time.Now().Add(time.Millisecond * time.Duration(-dur)) - - _, _, err := client.TimeTrackings.CreateTimeTracking(context.Background(), teamID, - &CreateTimeTrackingOptions{}, - &TimeTrackingRequest{ - Description: "description", - Start: start.Unix() * 1000, - Duration: dur, - Assignee: assigneeID, - Tid: taskID, - Billable: true, - }) - - OK(t, err) -} + input := &TimeTrackingRequest{ + Description: "description", + Start: 1719595398, + Duration: 120000, + Assignee: 99999999, + Tid: "9hz", + Billable: true, + } + + mux.HandleFunc("/team/123/time_entries", func(w http.ResponseWriter, r *http.Request) { + v := new(TimeTrackingRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !cmp.Equal(v, input) { + t.Errorf("Request body = %+v, want %+v", v, input) + } -func OK(tb testing.TB, err error) { + fmt.Fprint(w, + `{ + "data": { + "id": "4090130922962924695", + "task": { + "id": "9hz", + "name": "Test", + "status": { + "status": "in progress", + "id": "p99999999999_99asdhAS", + "color": "#5f55ee", + "type": "custom", + "orderindex": 1 + } + }, + "wid": "9999999999", + "user": { + "id": 99999999, + "username": "John", + "email": "john@mail.com", + "color": "#afb42b", + "initials": "J", + "profilePicture": "https://attachments.clickup.com/profilePictures/99999999_tX9.jpg" + }, + "billable": true, + "start": 1719595398, + "end": "1719715398", + "duration": 120000, + "description": "description", + "tags": [], + "at": 1719586940375, + "is_locked": false, + "task_location": {} + } + }`, + ) + }) + + ctx := context.Background() + artifacts, _, err := client.TimeTrackings.CreateTimeTracking(ctx, "123", nil, input) if err != nil { - _, file, line, _ := runtime.Caller(1) - fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) - tb.FailNow() + t.Errorf("Actions.ListArtifacts returned error: %v", err) + } + + want := &CreateTimeTrackingResponse{Data: TimeTrackingData{ + ID: "4090130922962924695", + Wid: "9999999999", + User: User{ID: 99999999, Username: "John", Email: "john@mail.com", Color: "#afb42b", Initials: "J", ProfilePicture: "https://attachments.clickup.com/profilePictures/99999999_tX9.jpg"}, + Billable: true, + Start: 1719595398, + End: "1719715398", + Duration: 120000, + Description: "description", + Tags: []TimeTrackingTag{}, + At: 1719586940375, + IsLocked: false, + TaskLocation: TaskLocation{}, + Task: Task{ID: "9hz", Name: "Test", Status: TaskStatus{ID: "p99999999999_99asdhAS", Status: "in progress", Color: "#5f55ee", Type: "custom", Orderindex: json.Number("1")}}, + }} + if !cmp.Equal(artifacts, want) { + t.Errorf("Actions.ListArtifacts returned %+v, want %+v", artifacts, want) } + } diff --git a/go.mod b/go.mod index 96772d5..da3b72f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/raksul/go-clickup -go 1.21.1 +go 1.22.3 require ( github.com/google/go-cmp v0.5.8 From ac90cf78ec0514587c3360d7302793df4c8777fe Mon Sep 17 00:00:00 2001 From: Jonathan Schmitt Date: Fri, 28 Jun 2024 16:34:36 -0300 Subject: [PATCH 4/4] fix: Update ClickUp API's UpdateTask method to use TaskUpdateRequest instead of TaskRequest. Fixed assignees update struct --- clickup/tasks.go | 27 ++++++++++++++++++++++++++- example/update-duedate/main.go | 8 ++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/clickup/tasks.go b/clickup/tasks.go index a6d2ed0..3440336 100644 --- a/clickup/tasks.go +++ b/clickup/tasks.go @@ -37,6 +37,31 @@ type TaskRequest struct { CustomItemId int `json:"custom_item_id,omitempty"` // To create a task that doesn't use a custom task type, either don't include this field in the request body, or send 'null'. To create this task as a Milestone, send a value of 1. To use a custom task type, send the custom task type ID as defined in your Workspace, such as 2. } +type TaskUpdateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Assignees TaskAssigneeUpdateRequest `json:"assignees,omitempty"` + Tags []string `json:"tags,omitempty"` + Status string `json:"status,omitempty"` + Priority int `json:"priority,omitempty"` + DueDate *Date `json:"due_date,omitempty"` + DueDateTime bool `json:"due_date_time,omitempty"` + TimeEstimate int `json:"time_estimate,omitempty"` + StartDate *Date `json:"start_date,omitempty"` + StartDateTime bool `json:"start_date_time,omitempty"` + NotifyAll bool `json:"notify_all,omitempty"` + Parent string `json:"parent,omitempty"` + LinksTo string `json:"links_to,omitempty"` + CheckRequiredCustomFields bool `json:"check_required_custom_fields,omitempty"` + CustomFields []CustomFieldInTaskRequest `json:"custom_fields,omitempty"` + CustomItemId int `json:"custom_item_id,omitempty"` // To create a task that doesn't use a custom task type, either don't include this field in the request body, or send 'null'. To create this task as a Milestone, send a value of 1. To use a custom task type, send the custom task type ID as defined in your Workspace, such as 2. +} + +type TaskAssigneeUpdateRequest struct { + Add []int `json:"add,omitempty"` + Rem []int `json:"rem,omitempty"` +} + type CustomFieldInTaskRequest struct { ID string `json:"id"` Value interface{} `json:"value"` @@ -283,7 +308,7 @@ func (s *TasksService) CreateTask(ctx context.Context, listID string, tr *TaskRe } // FIXME: assignees add/rem -func (s *TasksService) UpdateTask(ctx context.Context, taskID string, opts *GetTaskOptions, tr *TaskRequest) (*Task, *Response, error) { +func (s *TasksService) UpdateTask(ctx context.Context, taskID string, opts *GetTaskOptions, tr *TaskUpdateRequest) (*Task, *Response, error) { u := fmt.Sprintf("task/%v/", taskID) u, err := addOptions(u, opts) if err != nil { diff --git a/example/update-duedate/main.go b/example/update-duedate/main.go index 0a71f28..c854cbc 100644 --- a/example/update-duedate/main.go +++ b/example/update-duedate/main.go @@ -27,17 +27,17 @@ func main() { getTask(ctx, client, taskId) fmt.Println("\nUpdate due date of the task to 2122/01/02 03:04:05:06") - updateTask(ctx, client, taskId, &clickup.TaskRequest{ + updateTask(ctx, client, taskId, &clickup.TaskUpdateRequest{ DueDate: clickup.NewDate( time.Date(2122, 1, 2, 3, 4, 5, 6, time.Now().Location()), ), }) fmt.Println("\nUpdate the task with empty TaskRequest") - updateTask(ctx, client, taskId, &clickup.TaskRequest{}) + updateTask(ctx, client, taskId, &clickup.TaskUpdateRequest{}) fmt.Println("\nRemove task due date with NullDate()") - updateTask(ctx, client, taskId, &clickup.TaskRequest{ + updateTask(ctx, client, taskId, &clickup.TaskUpdateRequest{ DueDate: clickup.NullDate(), }) } @@ -50,7 +50,7 @@ func getTask(ctx context.Context, client *clickup.Client, taskID string) { fmt.Println(task.Name, task.DueDate) } -func updateTask(ctx context.Context, client *clickup.Client, taskID string, tr *clickup.TaskRequest) { +func updateTask(ctx context.Context, client *clickup.Client, taskID string, tr *clickup.TaskUpdateRequest) { task, _, err := client.Tasks.UpdateTask(ctx, taskID, &clickup.GetTaskOptions{}, tr) if err != nil { log.Fatalln(err)