Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions projects/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
115 changes: 113 additions & 2 deletions projects/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions projects/task_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading