From 3201a59479391ac321cdd853c8622767c45f8c05 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Mon, 10 Aug 2026 16:14:33 -0300 Subject: [PATCH] Feature: Task move and bulk workflow stage move Add `TaskMoveRequest` for `PUT /tasks/{id}/move.json`, the only route that moves a task to another project, and `WorkflowStageTasksMoveRequest`, which moves a set of tasks in one request instead of one per task. `LegacyNumericList` can now be decoded. --- AGENTS.md | 18 ++ projects/main_test.go | 21 ++ projects/task.go | 115 ++++++++- projects/task_example_test.go | 39 +++ projects/task_test.go | 241 +++++++++++++++++++ projects/types.go | 41 ++++ projects/types_test.go | 82 +++++++ projects/workflow_stage_task.go | 90 +++++++ projects/workflow_stage_task_example_test.go | 35 +++ projects/workflow_stage_task_test.go | 69 ++++++ 10 files changed, 749 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a8fb3e..61387ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,6 +169,24 @@ Why: surfaces typos at compile time, makes the legal value set self-documenting Always hard-code `skipCounts=true` in the filter's `apply()` rather than exposing it as an option. Total counts are derivable from `(page * pageSize) + 1` and `HasMore`, so leaving the option open just lets callers opt into a slower API call by mistake. The list response's `Meta` should mirror the pattern in [Pagination (list responses)](#pagination-list-responses) and expose only `HasMore` — never `Offset`, `Size`, or `Count`. +### v1 and v3 are not interchangeable for the same operation + +Several resources are reachable through both API versions, and the newer route is not always the one that does more. `comment.go`, `link.go` and `message.go` already write through v1 while reading through v3, because that is where the working endpoint is. + +Moving a task between tasklists is reachable three ways, and **all three carry the task's subtasks**, so "does it cascade?" is not what separates them: + +- `PUT /projects/api/v3/tasks/{id}.json` (`TaskUpdateRequest.TasklistID`). Clears the moved task's own parent link, since a subtask may not live outside its parent's tasklist. A `parentTaskId` sent alongside must already be in the destination, or the call fails. Until an API fix in 2026-08 it failed for *any* subtask, sent parent or not. +- `PUT /tasks/{id}.json`, the v1 generic edit, envelope `{"task":…}` or `{"todo-item":…}`. Clears the parent link too, but silently ignores a `parentTaskId` repeating the current parent instead of failing. +- `PUT /tasks/{id}/move.json` (`TaskMoveRequest`), purpose-built, parameters at the top level of the body. Preserves the parent link when asked, and is the only one that moves a task to a tasklist in another project — v3 answers 422 for that. It also takes a board-column parameter, deliberately unmodelled: columns are superseded by workflows. + +Rules that follow: + +1. v1 and v3 routes for the "same" operation are separate implementations, not one behind two doors. A finding on one says nothing about the other; verify both. +2. When a write appears in more than one place, look for the purpose-built route before extending the generic one. A `move` or `complete` endpoint usually exists and does more. +3. Do not trust an `affected*Ids` field to be a manifest of what changed. On the v1 move, `affectedTaskIds` is dependency bookkeeping: it holds predecessors and dependents, never the moved task, and is empty when there are none. On the v3 update it really is the subtasks that moved, but only two levels deep — a move cascades to the whole subtree, so deeper descendants change tasklist without appearing. `affectedTaskListIds` and `affectedProjectIds` are the source and destination, and are reliable. +4. Legacy payloads spell things differently and the difference is load-bearing: `taskListId` versus v3's `tasklistId`, IDs returned as a comma-separated string (`LegacyNumericList`) rather than a JSON array, and `0` rather than `null` as the value that clears a relationship (`TaskDetachFromParent`). Kebab and camel keys are interchangeable (`todo-item`, `todoItem`), but snake case is not. +5. A live call confirms what happened once. It does not show which parameter was ignored, or which of several plausible causes produced an error — so treat one successful response as weak evidence for how an endpoint behaves in general. + ### HTTPRequest implementation (POST/PUT/PATCH) ```go diff --git a/projects/main_test.go b/projects/main_test.go index 5dfbb2c..57d7556 100644 --- a/projects/main_test.go +++ b/projects/main_test.go @@ -299,6 +299,27 @@ func createTask(t testEngine, tasklistID int64) (int64, func(), error) { }, nil } +func createSubtask(t testEngine, tasklistID, parentTaskID int64) (int64, func(), error) { + taskResponse, err := projects.TaskCreate(t.Context(), engine, projects.TaskCreateRequest{ + Path: projects.TaskCreateRequestPath{ + TasklistID: tasklistID, + }, + Name: fmt.Sprintf("test%d%d", time.Now().UnixNano(), rand.Intn(100)), + ParentTaskID: &parentTaskID, + }) + if err != nil { + return 0, nil, fmt.Errorf("failed to create subtask for test: %w", err) + } + id := taskResponse.Task.ID + return id, func() { + ctx := context.Background() // t.Context is always canceled in cleanup + _, err := projects.TaskDelete(ctx, engine, projects.NewTaskDeleteRequest(id)) + if err != nil { + t.Errorf("failed to delete subtask after test: %s", err) + } + }, nil +} + func createUser(t testEngine) (int64, func(), error) { user, err := projects.UserCreate(t.Context(), engine, projects.NewUserCreateRequest( fmt.Sprintf("test%d%d", time.Now().UnixNano(), rand.Intn(100)), diff --git a/projects/task.go b/projects/task.go index 8041b00..3e2a6d9 100644 --- a/projects/task.go +++ b/projects/task.go @@ -22,6 +22,8 @@ var ( _ twapi.HTTPResponser = (*TaskDeleteResponse)(nil) _ twapi.HTTPRequester = (*TaskCompleteRequest)(nil) _ twapi.HTTPResponser = (*TaskCompleteResponse)(nil) + _ twapi.HTTPRequester = (*TaskMoveRequest)(nil) + _ twapi.HTTPResponser = (*TaskMoveResponse)(nil) _ twapi.HTTPRequester = (*TaskGetRequest)(nil) _ twapi.HTTPResponser = (*TaskGetResponse)(nil) _ twapi.HTTPRequester = (*TaskListRequest)(nil) @@ -372,11 +374,16 @@ type TaskUpdateRequest struct { EstimatedMinutes *int64 `json:"estimatedMinutes,omitempty"` // TasklistID is the identifier of the tasklist that will contain the task. If - // provided, the task will be moved to this tasklist. + // provided, the task and its subtasks are moved to this tasklist. The task + // keeps its own parent only if that parent is already in the destination, + // otherwise it becomes top-level. Use TaskMoveRequest to move a task to + // another project. TasklistID *int64 `json:"tasklistId,omitempty"` // ParentTaskID is the identifier of the parent task, if this task is a - // subtask. If provided, the task will be moved under this parent task. + // subtask. If provided, the task will be moved under this parent task, which + // must share the task's tasklist. Nil leaves the link alone; + // TaskDetachFromParent promotes the task to top level. ParentTaskID *int64 `json:"parentTaskId,omitempty"` // Assignees is the list of users, teams or clients/companies assigned to this @@ -596,6 +603,110 @@ func TaskComplete( return twapi.Execute[TaskCompleteRequest, *TaskCompleteResponse](ctx, engine, req) } +// TaskDetachFromParent is the ParentTaskID value that detaches a subtask, +// promoting it to top level. Null does nothing. +const TaskDetachFromParent int64 = 0 + +// TaskMoveRequestPath contains the path parameters for moving a task. +type TaskMoveRequestPath struct { + // ID is the unique identifier of the task to be moved. + ID int64 +} + +// TaskMoveRequest represents the request body for moving a task to another +// tasklist. +// +// TaskUpdateRequest.TasklistID also moves a task, and carries the subtree just as +// this does. Use this one to move a task to a tasklist in another project, which +// the v3 endpoint rejects. +// +// https://apidocs.teamwork.com/docs/teamwork/v1/tasks/put-tasks-id-move-json +type TaskMoveRequest struct { + // Path contains the path parameters for the request. + Path TaskMoveRequestPath `json:"-"` + + // TasklistID is the tasklist that will receive the task, along with its + // subtasks. It may belong to another project. Zero means the Inbox list. + TasklistID int64 `json:"taskListId"` + + // ParentTaskID sets the moved task's parent. Nil detaches, unlike elsewhere in + // the SDK — pass the current parent to keep the task a subtask. + ParentTaskID *int64 `json:"parentTaskId,omitempty"` + + // RemoveDependencies drops the task's dependencies instead of carrying them. + RemoveDependencies bool `json:"removeDependencies,omitempty"` +} + +// NewTaskMoveRequest creates a new TaskMoveRequest with the provided task and +// tasklist IDs. Both are required to move a task. +func NewTaskMoveRequest(taskID, tasklistID int64) TaskMoveRequest { + return TaskMoveRequest{ + Path: TaskMoveRequestPath{ + ID: taskID, + }, + TasklistID: tasklistID, + } +} + +// HTTPRequest creates an HTTP request for the TaskMoveRequest. +func (t TaskMoveRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + uri := server + "/tasks/" + strconv.FormatInt(t.Path.ID, 10) + "/move.json" + + // no envelope on this endpoint, unlike task create and update + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(t); err != nil { + return nil, fmt.Errorf("failed to encode move task request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uri, &body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +// TaskMoveResponse represents the response body for moving a task to another +// tasklist. +// +// https://apidocs.teamwork.com/docs/teamwork/v1/tasks/put-tasks-id-move-json +type TaskMoveResponse struct { + // AffectedTaskIDs lists predecessors and dependents of the moved task, not + // what moved: it excludes the task itself and is empty when it has no + // dependencies. Read the subtree to learn what changed tasklist. + AffectedTaskIDs LegacyNumericList `json:"affectedTaskIds"` + + // AffectedTasklistIDs is the tasklist the task left and the one it joined. + AffectedTasklistIDs LegacyNumericList `json:"affectedTaskListIds"` + + // AffectedProjectIDs is set when the move crossed projects. + AffectedProjectIDs LegacyNumericList `json:"affectedProjectIds"` +} + +// HandleHTTPResponse handles the HTTP response for the TaskMoveResponse. If some +// unexpected HTTP status code is returned by the API, a twapi.HTTPError is +// returned. +func (t *TaskMoveResponse) HandleHTTPResponse(resp *http.Response) error { + if resp.StatusCode != http.StatusOK { + return twapi.NewHTTPError(resp, "failed to move task") + } + if err := json.NewDecoder(resp.Body).Decode(t); err != nil { + return fmt.Errorf("failed to decode move task response: %w", err) + } + return nil +} + +// TaskMove moves a task, together with every subtask beneath it, to another +// tasklist using the provided request and returns the response. +func TaskMove( + ctx context.Context, + engine *twapi.Engine, + req TaskMoveRequest, +) (*TaskMoveResponse, error) { + return twapi.Execute[TaskMoveRequest, *TaskMoveResponse](ctx, engine, req) +} + // TaskRequestSideload contains the possible sideload options when loading // tasks. type TaskRequestSideload string diff --git a/projects/task_example_test.go b/projects/task_example_test.go index 45a0215..bd47daf 100644 --- a/projects/task_example_test.go +++ b/projects/task_example_test.go @@ -102,6 +102,31 @@ func ExampleTaskComplete() { // Output: task completed! } +func ExampleTaskMove() { + address, stop, err := startTaskServer() // mock server for demonstration purposes + if err != nil { + fmt.Printf("failed to start server: %s", err) + return + } + defer stop() + + ctx := context.Background() + engine := twapi.NewEngine(session.NewBearerToken("your_token", fmt.Sprintf("http://%s", address))) + + // the task keeps its subtasks either way; this keeps its own parent too + taskRequest := projects.NewTaskMoveRequest(12345, 888) + taskRequest.ParentTaskID = new(int64(999)) + + taskResponse, err := projects.TaskMove(ctx, engine, taskRequest) + if err != nil { + fmt.Printf("failed to move task: %s", err) + } else { + fmt.Printf("moved task between tasklists %v\n", taskResponse.AffectedTasklistIDs) + } + + // Output: moved task between tasklists [777 888] +} + func ExampleTaskGet() { address, stop, err := startTaskServer() // mock server for demonstration purposes if err != nil { @@ -201,6 +226,20 @@ func startTaskServer() (string, func(), error) { w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintln(w, `{"STATUS":"OK"}`) }) + mux.HandleFunc("PUT /tasks/{id}/move", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "Unsupported Media Type", http.StatusUnsupportedMediaType) + return + } + if r.PathValue("id") != "12345" { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + // affectedTaskIds is dependency bookkeeping, empty here + _, _ = fmt.Fprintln(w, `{"affectedTaskIds":"","affectedTaskListIds":"777,888","STATUS":"OK"}`) + }) mux.HandleFunc("GET /projects/api/v3/tasks/{id}", func(w http.ResponseWriter, r *http.Request) { if r.PathValue("id") != "12345" { http.Error(w, "Not Found", http.StatusNotFound) diff --git a/projects/task_test.go b/projects/task_test.go index c107cb0..fd5312d 100644 --- a/projects/task_test.go +++ b/projects/task_test.go @@ -2,13 +2,19 @@ package projects_test import ( "context" + "encoding/json" "fmt" + "io" "math/rand" + "net/http" + "slices" + "strings" "testing" "time" "github.com/teamwork/twapi-go-sdk" "github.com/teamwork/twapi-go-sdk/projects" + "github.com/teamwork/twapi-go-sdk/session" ) func TestTaskCreate(t *testing.T) { @@ -210,6 +216,241 @@ func TestTaskComplete(t *testing.T) { } } +func TestTaskMoveRequestGeneration(t *testing.T) { + tests := []struct { + name string + input projects.TaskMoveRequest + wantParentTaskID *int64 + }{{ + // omitting the field means detach here, so this is deliberate + name: "detaching by default", + input: projects.NewTaskMoveRequest(12345, 888), + }, { + name: "detaching explicitly", + input: func() projects.TaskMoveRequest { + req := projects.NewTaskMoveRequest(12345, 888) + req.ParentTaskID = new(projects.TaskDetachFromParent) + return req + }(), + wantParentTaskID: new(int64(0)), + }, { + // The only way to move a subtask and have it stay one. + name: "keeping the parent link", + input: func() projects.TaskMoveRequest { + req := projects.NewTaskMoveRequest(12345, 888) + req.ParentTaskID = new(int64(999)) + return req + }(), + wantParentTaskID: new(int64(999)), + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpReq, err := tt.input.HTTPRequest(context.Background(), "https://test.com") + if err != nil { + t.Fatalf("unexpected error creating HTTP request: %s", err) + } + + // the dedicated move route, at the installation root + if httpReq.URL.Path != "/tasks/12345/move.json" { + t.Errorf("unexpected request path: %s", httpReq.URL.Path) + } + if httpReq.Method != http.MethodPut { + t.Errorf("expected PUT but got %s", httpReq.Method) + } + + body, err := io.ReadAll(httpReq.Body) + if err != nil { + t.Fatalf("failed to read request body: %s", err) + } + + var payload struct { + TasklistID int64 `json:"taskListId"` + ParentTaskID *int64 `json:"parentTaskId"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to decode request body %q: %s", body, err) + } + + // top level of the body: an envelope would leave the defaults in place + if payload.TasklistID != 888 { + t.Errorf("expected taskListId 888 but got %d (body %q)", payload.TasklistID, body) + } + + switch { + case tt.wantParentTaskID == nil && payload.ParentTaskID != nil: + t.Errorf("expected parentTaskId to be omitted but got %d (body %q)", *payload.ParentTaskID, body) + case tt.wantParentTaskID != nil && payload.ParentTaskID == nil: + t.Errorf("expected parentTaskId %d to reach the wire (body %q)", *tt.wantParentTaskID, body) + case tt.wantParentTaskID != nil && *payload.ParentTaskID != *tt.wantParentTaskID: + t.Errorf("expected parentTaskId %d but got %d (body %q)", + *tt.wantParentTaskID, *payload.ParentTaskID, body) + } + }) + } +} + +// The affected lists are dependency bookkeeping, not a record of what moved, and +// are routinely absent. Decoding must not turn that into an error. +func TestTaskMoveResponseDecoding(t *testing.T) { + tests := []struct { + name string + body string + wantTasks projects.LegacyNumericList + wantTasklists projects.LegacyNumericList + wantProjects projects.LegacyNumericList + }{{ + name: "dependencies and tasklists reported", + body: `{"affectedTaskIds":"12346,12347","affectedTaskListIds":"777,888","STATUS":"OK"}`, + wantTasks: projects.LegacyNumericList{12346, 12347}, + wantTasklists: projects.LegacyNumericList{777, 888}, + }, { + name: "cross project move", + body: `{"affectedTaskListIds":"777,888","affectedProjectIds":"1,2","STATUS":"OK"}`, + wantTasklists: projects.LegacyNumericList{777, 888}, + wantProjects: projects.LegacyNumericList{1, 2}, + }, { + // a task with no dependencies: the move happened, the field is empty + name: "no affected tasks", + body: `{"affectedTaskIds":"","affectedTaskListIds":"777,888","STATUS":"OK"}`, + wantTasklists: projects.LegacyNumericList{777, 888}, + }, { + name: "fields absent", + body: `{"STATUS":"OK"}`, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stub := twapi.NewEngine( + session.NewBearerToken("token", "https://test.com"), + twapi.WithHTTPClient(twapi.HTTPClientFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(tt.body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + })), + ) + + response, err := projects.TaskMove(context.Background(), stub, projects.NewTaskMoveRequest(12345, 888)) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if !slices.Equal(response.AffectedTaskIDs, tt.wantTasks) { + t.Errorf("affected tasks: got %v, want %v", response.AffectedTaskIDs, tt.wantTasks) + } + if !slices.Equal(response.AffectedTasklistIDs, tt.wantTasklists) { + t.Errorf("affected tasklists: got %v, want %v", response.AffectedTasklistIDs, tt.wantTasklists) + } + if !slices.Equal(response.AffectedProjectIDs, tt.wantProjects) { + t.Errorf("affected projects: got %v, want %v", response.AffectedProjectIDs, tt.wantProjects) + } + }) + } +} + +func TestTaskMove(t *testing.T) { + if engine == nil { + t.Skip("Skipping test because the engine is not initialized") + } + + destinationTasklistID, destinationCleanup, err := createTasklist(t, testResources.ProjectID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(destinationCleanup) + + parentTaskID, parentTaskCleanup, err := createTask(t, testResources.TasklistID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(parentTaskCleanup) + + subtaskID, subtaskCleanup, err := createSubtask(t, testResources.TasklistID, parentTaskID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(subtaskCleanup) + + ctx := t.Context() + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + t.Cleanup(cancel) + + // moving the parent carries the subtask with it, in one call + if _, err = projects.TaskMove(ctx, engine, + projects.NewTaskMoveRequest(parentTaskID, destinationTasklistID)); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + subtaskAfter, err := projects.TaskGet(ctx, engine, projects.NewTaskGetRequest(subtaskID)) + if err != nil { + t.Fatalf("failed to reload subtask: %s", err) + } + if subtaskAfter.Task.Tasklist.ID != destinationTasklistID { + t.Errorf("expected the subtask to be carried to tasklist %d but got %d", + destinationTasklistID, subtaskAfter.Task.Tasklist.ID) + } + if subtaskAfter.Task.ParentTask == nil || subtaskAfter.Task.ParentTask.ID != parentTaskID { + t.Errorf("expected the subtask to still hang from task %d, got %+v", + parentTaskID, subtaskAfter.Task.ParentTask) + } +} + +// Moving a subtask without flattening it. The parent moves first, stranding the +// subtask, which is then repaired. +func TestTaskMoveKeepsParentWhenAsked(t *testing.T) { + if engine == nil { + t.Skip("Skipping test because the engine is not initialized") + } + + destinationTasklistID, destinationCleanup, err := createTasklist(t, testResources.ProjectID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(destinationCleanup) + + parentTaskID, parentTaskCleanup, err := createTask(t, testResources.TasklistID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(parentTaskCleanup) + + subtaskID, subtaskCleanup, err := createSubtask(t, testResources.TasklistID, parentTaskID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(subtaskCleanup) + + ctx := t.Context() + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + t.Cleanup(cancel) + + updateRequest := projects.NewTaskUpdateRequest(parentTaskID) + updateRequest.TasklistID = &destinationTasklistID + if _, err := projects.TaskUpdate(ctx, engine, updateRequest); err != nil { + t.Fatalf("failed to move the parent through v3: %s", err) + } + + moveRequest := projects.NewTaskMoveRequest(subtaskID, destinationTasklistID) + moveRequest.ParentTaskID = &parentTaskID + if _, err := projects.TaskMove(ctx, engine, moveRequest); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + subtaskAfter, err := projects.TaskGet(ctx, engine, projects.NewTaskGetRequest(subtaskID)) + if err != nil { + t.Fatalf("failed to reload subtask: %s", err) + } + if subtaskAfter.Task.Tasklist.ID != destinationTasklistID { + t.Errorf("expected the subtask in tasklist %d but got %d", + destinationTasklistID, subtaskAfter.Task.Tasklist.ID) + } + if subtaskAfter.Task.ParentTask == nil || subtaskAfter.Task.ParentTask.ID != parentTaskID { + t.Errorf("expected the subtask to still hang from task %d, got %+v", + parentTaskID, subtaskAfter.Task.ParentTask) + } +} + func TestTaskGet(t *testing.T) { if engine == nil { t.Skip("Skipping test because the engine is not initialized") diff --git a/projects/types.go b/projects/types.go index 1814ee2..ab3f6f3 100644 --- a/projects/types.go +++ b/projects/types.go @@ -1,6 +1,7 @@ package projects import ( + "bytes" "encoding/json" "fmt" "strconv" @@ -77,6 +78,46 @@ func (l LegacyNumericList) MarshalJSON() ([]byte, error) { return fmt.Appendf(nil, `"%s"`, strings.Join(result, ",")), nil } +// UnmarshalJSON decodes the comma-separated string the legacy API produces, e.g. +// "12345,12346". A JSON array is accepted too; null and "" decode to nil. +func (l *LegacyNumericList) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + *l = nil + return nil + } + + if data[0] == '[' { + var ids []LegacyNumber + if err := json.Unmarshal(data, &ids); err != nil { + return err + } + *l = make(LegacyNumericList, 0, len(ids)) + for _, id := range ids { + *l = append(*l, int64(id)) + } + return nil + } + + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + *l = nil + for part := range strings.SplitSeq(str, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return fmt.Errorf("invalid numeric list item %q: %w", part, err) + } + *l = append(*l, id) + } + return nil +} + // Add adds a numeric value to the LegacyNumericList. func (l *LegacyNumericList) Add(n float64) { *l = append(*l, int64(n)) diff --git a/projects/types_test.go b/projects/types_test.go index 6820d52..775f73f 100644 --- a/projects/types_test.go +++ b/projects/types_test.go @@ -158,3 +158,85 @@ func TestLegacyUserGroups_IsEmpty(t *testing.T) { }) } } + +func TestLegacyNumericList_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want projects.LegacyNumericList + wantErr bool + }{{ + name: "comma separated string", + input: `"12345,12346,12347"`, + want: projects.LegacyNumericList{12345, 12346, 12347}, + }, { + name: "single value", + input: `"12345"`, + want: projects.LegacyNumericList{12345}, + }, { + name: "padded values", + input: `"12345, 12346"`, + want: projects.LegacyNumericList{12345, 12346}, + }, { + name: "empty string", + input: `""`, + want: nil, + }, { + name: "null", + input: `null`, + want: nil, + }, { + name: "json array of numbers", + input: `[12345,12346]`, + want: projects.LegacyNumericList{12345, 12346}, + }, { + name: "json array of quoted numbers", + input: `["12345","12346"]`, + want: projects.LegacyNumericList{12345, 12346}, + }, { + name: "not a number", + input: `"12345,abc"`, + wantErr: true, + }, { + name: "wrong type", + input: `{"id":1}`, + wantErr: true, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got projects.LegacyNumericList + err := json.Unmarshal([]byte(tt.input), &got) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error but got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if !slices.Equal(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestLegacyNumericList_RoundTrip(t *testing.T) { + want := projects.LegacyNumericList{12345, 12346, 12347} + + encoded, err := json.Marshal(want) + if err != nil { + t.Fatalf("unexpected marshal error: %s", err) + } + + var got projects.LegacyNumericList + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("unexpected unmarshal error: %s", err) + } + + if !slices.Equal(got, want) { + t.Errorf("round trip mismatch: got %v, want %v", got, want) + } +} diff --git a/projects/workflow_stage_task.go b/projects/workflow_stage_task.go index 4304743..c0b16bc 100644 --- a/projects/workflow_stage_task.go +++ b/projects/workflow_stage_task.go @@ -13,6 +13,8 @@ import ( var ( _ twapi.HTTPRequester = (*WorkflowStageTaskMoveRequest)(nil) _ twapi.HTTPResponser = (*WorkflowStageTaskMoveResponse)(nil) + _ twapi.HTTPRequester = (*WorkflowStageTasksMoveRequest)(nil) + _ twapi.HTTPResponser = (*WorkflowStageTasksMoveResponse)(nil) ) // WorkflowStageTaskMoveRequestPath contains the path parameters for moving @@ -106,3 +108,91 @@ func WorkflowStageTaskMove( ) (*WorkflowStageTaskMoveResponse, error) { return twapi.Execute[WorkflowStageTaskMoveRequest, *WorkflowStageTaskMoveResponse](ctx, engine, req) } + +// WorkflowStageTasksMoveRequestPath contains the path parameters for moving +// several tasks to a workflow stage. +type WorkflowStageTasksMoveRequestPath struct { + // WorkflowID is the identifier of the workflow that contains the stage to + // which the tasks will be moved. + WorkflowID int64 + + // StageID is the identifier of the stage to which the tasks will be moved. + StageID int64 +} + +// WorkflowStageTasksMoveRequest represents the request body for moving several +// tasks to a workflow stage in a single call. +// +// WorkflowStageTaskMoveRequest carries its task in the path, costing one request +// per task. This one takes them in the body. Tasks are appended to the end of the +// stage in the order given, so use the singular request to position a task. +// +// The endpoint rejects unknown body fields, so do not add any the server does not +// declare. +// +// https://apidocs.teamwork.com/guides/teamwork/workflows-api-getting-started-guide +type WorkflowStageTasksMoveRequest struct { + // Path contains the path parameters for the request. + Path WorkflowStageTasksMoveRequestPath `json:"-"` + + // TaskIDs are the tasks to move into the stage. An empty slice does nothing. + TaskIDs []int64 `json:"taskIds"` +} + +// NewWorkflowStageTasksMoveRequest creates a new WorkflowStageTasksMoveRequest +// with the provided workflow, stage and task IDs. +func NewWorkflowStageTasksMoveRequest(workflowID, stageID int64, taskIDs ...int64) WorkflowStageTasksMoveRequest { + return WorkflowStageTasksMoveRequest{ + Path: WorkflowStageTasksMoveRequestPath{ + WorkflowID: workflowID, + StageID: stageID, + }, + TaskIDs: taskIDs, + } +} + +// HTTPRequest creates an HTTP request for the WorkflowStageTasksMoveRequest. +func (w WorkflowStageTasksMoveRequest) HTTPRequest(ctx context.Context, server string) (*http.Request, error) { + uri := fmt.Sprintf("%s/projects/api/v3/workflows/%d/stages/%d/tasks.json", + server, w.Path.WorkflowID, w.Path.StageID) + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(w); err != nil { + return nil, fmt.Errorf("failed to encode workflow stage tasks move request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, &body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +// WorkflowStageTasksMoveResponse represents the response body for moving several +// tasks to a workflow stage. The endpoint answers with the outcome in its status +// code alone. +// +// https://apidocs.teamwork.com/guides/teamwork/workflows-api-getting-started-guide +type WorkflowStageTasksMoveResponse struct{} + +// HandleHTTPResponse handles the HTTP response for the +// WorkflowStageTasksMoveResponse. If some unexpected HTTP status code is +// returned by the API, a twapi.HTTPError is returned. +func (*WorkflowStageTasksMoveResponse) HandleHTTPResponse(resp *http.Response) error { + if resp.StatusCode != http.StatusNoContent { + return twapi.NewHTTPError(resp, "failed to move tasks to workflow stage") + } + return nil +} + +// WorkflowStageTasksMove moves several tasks to a workflow stage in a single +// call. +func WorkflowStageTasksMove( + ctx context.Context, + engine *twapi.Engine, + req WorkflowStageTasksMoveRequest, +) (*WorkflowStageTasksMoveResponse, error) { + return twapi.Execute[WorkflowStageTasksMoveRequest, *WorkflowStageTasksMoveResponse](ctx, engine, req) +} diff --git a/projects/workflow_stage_task_example_test.go b/projects/workflow_stage_task_example_test.go index e13687e..e860e34 100644 --- a/projects/workflow_stage_task_example_test.go +++ b/projects/workflow_stage_task_example_test.go @@ -33,6 +33,28 @@ func ExampleWorkflowStageTaskMove() { // Output: moved workflow stage task } +func ExampleWorkflowStageTasksMove() { + address, stop, err := startWorkflowStageTaskServer() // mock server for demonstration purposes + if err != nil { + fmt.Printf("failed to start server: %s", err) + return + } + defer stop() + + ctx := context.Background() + engine := twapi.NewEngine(session.NewBearerToken("your_token", fmt.Sprintf("http://%s", address))) + + _, err = projects.WorkflowStageTasksMove(ctx, engine, + projects.NewWorkflowStageTasksMoveRequest(123, 456, 789, 790, 791)) + if err != nil { + fmt.Printf("failed to move workflow stage tasks: %s", err) + } else { + fmt.Println("moved workflow stage tasks") + } + + // Output: moved workflow stage tasks +} + func startWorkflowStageTaskServer() (string, func(), error) { ln, err := net.Listen("tcp", "localhost:0") if err != nil { @@ -57,6 +79,19 @@ func startWorkflowStageTaskServer() (string, func(), error) { w.WriteHeader(http.StatusNoContent) }, ) + mux.HandleFunc("POST /projects/api/v3/workflows/{workflowId}/stages/{stageId}/tasks", + func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "Unsupported Media Type", http.StatusUnsupportedMediaType) + return + } + if r.PathValue("workflowId") != "123" || r.PathValue("stageId") != "456" { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + }, + ) server := &http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/projects/workflow_stage_task_test.go b/projects/workflow_stage_task_test.go index 88897b3..87303e4 100644 --- a/projects/workflow_stage_task_test.go +++ b/projects/workflow_stage_task_test.go @@ -2,6 +2,10 @@ package projects_test import ( "context" + "encoding/json" + "io" + "net/http" + "slices" "testing" "time" @@ -54,3 +58,68 @@ func TestWorkflowStageTaskMove(t *testing.T) { }) } } + +func TestWorkflowStageTasksMoveRequestGeneration(t *testing.T) { + req := projects.NewWorkflowStageTasksMoveRequest(123, 456, 789, 790, 791) + + httpReq, err := req.HTTPRequest(context.Background(), "https://test.com") + if err != nil { + t.Fatalf("unexpected error creating HTTP request: %s", err) + } + + if httpReq.URL.Path != "/projects/api/v3/workflows/123/stages/456/tasks.json" { + t.Errorf("unexpected request path: %s", httpReq.URL.Path) + } + if httpReq.Method != http.MethodPost { + t.Errorf("expected POST but got %s", httpReq.Method) + } + + body, err := io.ReadAll(httpReq.Body) + if err != nil { + t.Fatalf("failed to read request body: %s", err) + } + + var payload struct { + TaskIDs []int64 `json:"taskIds"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to decode request body %q: %s", body, err) + } + + // the whole set travels in the body, not the path + if want := []int64{789, 790, 791}; !slices.Equal(payload.TaskIDs, want) { + t.Errorf("expected taskIds %v but got %v (body %q)", want, payload.TaskIDs, body) + } +} + +func TestWorkflowStageTasksMove(t *testing.T) { + if engine == nil { + t.Skip("Skipping test because the engine is not initialized") + } + + firstTaskID, firstTaskCleanup, err := createTask(t, testResources.TasklistID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(firstTaskCleanup) + + secondTaskID, secondTaskCleanup, err := createTask(t, testResources.TasklistID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(secondTaskCleanup) + + ctx := t.Context() + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + t.Cleanup(cancel) + + _, err = projects.WorkflowStageTasksMove(ctx, engine, projects.NewWorkflowStageTasksMoveRequest( + testResources.WorkflowID, + testResources.WorkflowStageID, + firstTaskID, + secondTaskID, + )) + if err != nil { + t.Errorf("unexpected error: %s", err) + } +}