refactor(veans): migrate API client from v1 to v2

veans is unreleased and targets bleeding-edge Vikunja, so the CLI now
speaks the Huma-backed /api/v2 exclusively (v1 is frozen and the kanban
bucket CRUD veans relies on only exists on v2).

- Transport: base path /api/v1 -> /api/v2 in Do/DoRaw; add a
  content-type-aware path (DoMerge for application/merge-patch+json).
- Pagination: drop the x-pagination-total-pages header reader; every v2
  list returns the {items,total,page,per_page,total_pages} envelope.
  Decode it with a generic Paginated[T]/doList[T] and page until
  page >= total_pages. Previously-single-GET lists (views, buckets,
  comments, bots) are enveloped too — unwrap .items.
- Verbs: creates flip PUT -> POST (projects, labels, tokens, bot users,
  shares, task create, comments, relations, assignees, label-attach,
  bucket create); the bucket-task move flips POST -> PUT with a bare
  {"task_id":N} body (URL owns project/view/bucket); task update moves
  to PATCH merge-patch with a partial body.
- Errors: parse the RFC 9457 problem+json body (detail/title/code)
  instead of v1's {code,message}; the status -> output.Code mapping is
  unchanged.
- Discovery probes /api/v2/info, which doubles as the "new enough" check.
- Label search param s -> q; add views_buckets_tasks_put to the bot's
  projects scope so the move is authorized regardless of route-init order.

Tests and the veans agent guide are updated for the new paths, verbs and
envelope. Verified end-to-end against a local v2 server: init, create,
show, list, claim, update and prime all work.
This commit is contained in:
kolaente 2026-06-26 11:48:17 +02:00 committed by kolaente
parent 0d043e80e4
commit 6cee626383
23 changed files with 523 additions and 237 deletions

View File

@ -46,9 +46,36 @@ this file is veans-specific.
## Vikunja wire-format gotchas ## Vikunja wire-format gotchas
Most failures surface when crossing the JSON boundary. The list below is veans targets the Huma-backed **`/api/v2`** exclusively (`apiBasePath` in
what's bitten me; if a new endpoint behaves oddly, suspect one of these: `internal/client/client.go`). v1 is frozen, and the kanban-bucket CRUD veans
relies on only exists on v2. Most failures surface when crossing the JSON
boundary. The list below is what's bitten me; if a new endpoint behaves
oddly, suspect one of these:
- **Lists come wrapped in the standard envelope.** Every v2 list returns
`{"items":[...],"total":N,"page":N,"per_page":N,"total_pages":N}`, not a
bare array, and there is no `x-pagination-total-pages` header anymore.
Decode with the generic `Paginated[T]` helper. **Most lists are
server-paginated** — their model's `ReadAll` applies a 50-item page limit:
tasks, projects, labels, comments and bots. Page through those with
`doListAll` until `page >= total_pages`; returning only page 1 silently
truncates (>50 comments on a task is realistic). **Buckets and project
views are the exception**: their `ReadAll` takes `_ int, _ int` and returns
every row in one page, so fetch them with a single `doList` and unwrap
`.items` — paging those would re-fetch the full set and duplicate it.
Single-object responses (create/update/read of one entity) stay UNWRAPPED.
- **v2 flips the create/update verbs.** Creates are **POST** (v1 used PUT):
projects, labels, tokens, bot users, project shares, task create,
comments, relations, assignees, label-attach, bucket create. Task update
is **PATCH** (see below). The bucket-task move is **PUT**.
- **Task update is `PATCH /tasks/{id}` with `application/merge-patch+json`**
(`client.DoMerge` → `UpdateTask(*TaskPatch)`). Only the fields present in
the body are written; absent fields are left intact. Build the body from
`TaskPatch` (pointer fields, omitempty) — never a whole `client.Task`,
whose no-omitempty `done`/`title` would clobber those columns on every
call (this was issue #2962).
- **List search is `q`**, not v1's `s` (`ListParams.Q`). Task-list
`filter`/`expand`/`page`/`per_page` keep their names.
- **`ProjectView.view_kind` and `bucket_configuration_mode` are - **`ProjectView.view_kind` and `bucket_configuration_mode` are
strings**, not ints. The parent enums (`ProjectViewKind`, strings**, not ints. The parent enums (`ProjectViewKind`,
`BucketConfigurationModeKind`) have custom `MarshalJSON` that emits `BucketConfigurationModeKind`) have custom `MarshalJSON` that emits
@ -58,11 +85,12 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
`xorm:"-"` on it — the actual bucket lives in a separate `xorm:"-"` on it — the actual bucket lives in a separate
`task_buckets` table. Fetch with `?expand=buckets` and use `task_buckets` table. Fetch with `?expand=buckets` and use
`task.CurrentBucketID(viewID)` to read it. `task.CurrentBucketID(viewID)` to read it.
- **`POST /tasks/{id}` does NOT move tasks between buckets.** The - **Task updates do NOT move tasks between buckets.** The task↔bucket
task↔bucket relation is row-shaped; use `client.MoveTaskToBucket()` relation is row-shaped; use `client.MoveTaskToBucket()` which hits
which hits `POST /projects/{p}/views/{v}/buckets/{b}/tasks`. The **`PUT /projects/{p}/views/{v}/buckets/{b}/tasks`** with a `{"task_id":N}`
Update path on the server only auto-moves on `done` flips. body (project/view/bucket all come from the URL). The Update path on the
- **Bot user creation is `PUT /user/bots`**, not `/bots` — the routes server only auto-moves on `done` flips.
- **Bot user creation is `POST /user/bots`**, not `/bots` — the routes
are registered under the `/user` subgroup. Same prefix for are registered under the `/user` subgroup. Same prefix for
`GET /user/bots`. `GET /user/bots`.
- **`APIToken.expires_at` is required.** The struct field has - **`APIToken.expires_at` is required.** The struct field has
@ -88,6 +116,16 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
- `/projects/:project/views/:view/buckets/:bucket/tasks` - `/projects/:project/views/:view/buckets/:bucket/tasks`
group `projects`, action `views_buckets_tasks` group `projects`, action `views_buckets_tasks`
- `/tasks/:task/comments` → group `tasks_comments`, action `create` - `/tasks/:task/comments` → group `tasks_comments`, action `create`
- v1 and v2 deliberately share `(group, permission)` keys:
`pkg/models/api_routes.go` normalizes the inverted verbs (v2 POST-create
and v1 PUT-create both → `create`; v2 PUT/PATCH-update and v1 POST-update
both → `update`), and `CanDoAPIRoute` consults both route tables, treating
PATCH as an alias for the stored PUT. So `PermissionsForBot`'s scope map
authorizes the v2 calls unchanged, including the PATCH task update.
- The bucket-task MOVE (`PUT …/buckets/:bucket/tasks`) and the
buckets-with-tasks LIST (`GET …/buckets/tasks`) collide on subkey
`views_buckets_tasks`; which one gets the bare key vs `views_buckets_tasks_put`
depends on unspecified route-init order, so the bot requests **both**.
- `client.PermissionsForBot()` calls `GET /routes` at runtime and - `client.PermissionsForBot()` calls `GET /routes` at runtime and
grants only the intersection of what we want and what the server grants only the intersection of what we want and what the server
exposes. **Don't hard-code permission group names** — they drift exposes. **Don't hard-code permission group names** — they drift
@ -96,9 +134,9 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
## Bot ownership and token minting ## Bot ownership and token minting
- Creating a bot via `PUT /user/bots` automatically sets the bot's - Creating a bot via `POST /user/bots` automatically sets the bot's
`bot_owner_id` to the calling user. Only the owner can mint tokens `bot_owner_id` to the calling user. Only the owner can mint tokens
for the bot via `PUT /tokens` with `owner_id=<bot_id>`. The init for the bot via `POST /tokens` with `owner_id=<bot_id>`. The init
flow does these as a single human-JWT-authenticated batch. flow does these as a single human-JWT-authenticated batch.
- Bots have no password and **cannot** authenticate via `POST /login`. - Bots have no password and **cannot** authenticate via `POST /login`.
After init, `veans login` re-authenticates as the human (not the After init, `veans login` re-authenticates as the human (not the
@ -115,9 +153,11 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
browser, and captures the callback. The `Shutdown` defer uses browser, and captures the callback. The `Shutdown` defer uses
`context.WithoutCancel(ctx)` so cancellation at the outer scope `context.WithoutCancel(ctx)` so cancellation at the outer scope
still drains the loopback server cleanly. still drains the loopback server cleanly.
- Token exchange is **JSON only**. Form-encoded POSTs to `/oauth/token` - Token exchange goes out as **JSON**. v2's `/oauth/token` accepts both JSON
fail; the standard `golang.org/x/oauth2` client speaks form encoding, and form-encoded bodies (Huma picks the decoder off the `Content-Type`
which is why we have a hand-rolled `client.ExchangeOAuthCode`. header), but the standard `golang.org/x/oauth2` client hard-codes form
encoding and its own response shape, so we keep the hand-rolled
`client.ExchangeOAuthCode` that speaks JSON.
## Credential store ## Credential store

View File

@ -102,11 +102,14 @@ func TestInit_HappyPath(t *testing.T) {
t.Fatalf("bot %q not found on server", ws.BotUsername) t.Fatalf("bot %q not found on server", ws.BotUsername)
} }
// Project shared with the bot at write permission. // Project shared with the bot at write permission. v2 lists come wrapped
var shares []map[string]any // in the standard {items,...} envelope.
var shares struct {
Items []map[string]any `json:"items"`
}
_ = h.AdminClient.Do(t.Context(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares) _ = h.AdminClient.Do(t.Context(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares)
shareFound := false shareFound := false
for _, s := range shares { for _, s := range shares.Items {
if u, _ := s["username"].(string); u == ws.BotUsername { if u, _ := s["username"].(string); u == ws.BotUsername {
if p, _ := s["permission"].(float64); int(p) >= 1 { if p, _ := s["permission"].(float64); int(p) >= 1 {
shareFound = true shareFound = true

View File

@ -257,7 +257,7 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
return nil, output.Wrap(output.CodeUnknown, err, "mint bot token: %v", err) return nil, output.Wrap(output.CodeUnknown, err, "mint bot token: %v", err)
} }
if mintedToken.Token == "" { if mintedToken.Token == "" {
return nil, output.New(output.CodeUnknown, "PUT /tokens did not return a token plaintext — cannot continue") return nil, output.New(output.CodeUnknown, "POST /tokens did not return a token plaintext — cannot continue")
} }
// 11. Persist credentials. Discard human JWT immediately after. // 11. Persist credentials. Discard human JWT immediately after.

View File

@ -211,8 +211,9 @@ func TestConfirmOverwriteExistingConfig(t *testing.T) {
} }
// bucketServer is a minimal httptest server modelling // bucketServer is a minimal httptest server modelling
// GET/PUT /api/v1/projects/{p}/views/{v}/buckets. The caller pre-seeds // GET/POST /api/v2/projects/{p}/views/{v}/buckets. The caller pre-seeds
// existing buckets; PUT requests append to that list with a synthetic ID. // existing buckets; POST requests append to that list with a synthetic ID.
// GET returns the standard v2 list envelope; POST returns the bare bucket.
type bucketServer struct { type bucketServer struct {
mu sync.Mutex mu sync.Mutex
existing []*client.Bucket existing []*client.Bucket
@ -232,7 +233,7 @@ func newBucketServer(seed []*client.Bucket) *bucketServer {
func (s *bucketServer) handler() http.Handler { func (s *bucketServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Path is /api/v1/projects/{p}/views/{v}/buckets. // Path is /api/v2/projects/{p}/views/{v}/buckets.
if !strings.HasSuffix(r.URL.Path, "/buckets") || !strings.Contains(r.URL.Path, "/views/") { if !strings.HasSuffix(r.URL.Path, "/buckets") || !strings.Contains(r.URL.Path, "/views/") {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusInternalServerError) http.Error(w, "unexpected path: "+r.URL.Path, http.StatusInternalServerError)
return return
@ -242,8 +243,15 @@ func (s *bucketServer) handler() http.Handler {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(s.existing) // v2 list envelope; the buckets list isn't server-paginated.
case http.MethodPut: _ = json.NewEncoder(w).Encode(map[string]any{
"items": s.existing,
"total": len(s.existing),
"page": 1,
"per_page": 50,
"total_pages": 1,
})
case http.MethodPost:
var b client.Bucket var b client.Bucket
if err := json.NewDecoder(r.Body).Decode(&b); err != nil { if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)

View File

@ -23,5 +23,5 @@ import (
// AddAssignee assigns a user (typically the bot) to a task. // AddAssignee assigns a user (typically the bot) to a task.
func (c *Client) AddAssignee(ctx context.Context, taskID, userID int64) error { func (c *Client) AddAssignee(ctx context.Context, taskID, userID int64) error {
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil) return c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
} }

View File

@ -21,25 +21,28 @@ import (
"fmt" "fmt"
) )
// ListBuckets returns the buckets configured on a Kanban view. // ListBuckets returns the buckets configured on a Kanban view. Bucket.ReadAll
// ignores page/per_page and returns every bucket in a single page (the envelope
// total reflects the full set), so one GET gets them all — paging would
// re-fetch the same buckets and duplicate them. Unwrap .items.
func (c *Client) ListBuckets(ctx context.Context, projectID, viewID int64) ([]*Bucket, error) { func (c *Client) ListBuckets(ctx context.Context, projectID, viewID int64) ([]*Bucket, error) {
var out []*Bucket
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID) path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
if err := c.Do(ctx, "GET", path, nil, nil, &out); err != nil { items, _, err := doList[*Bucket](ctx, c, path, nil)
if err != nil {
return nil, err return nil, err
} }
return out, nil return items, nil
} }
// CreateBucket inserts a new bucket into a Kanban view. // CreateBucket inserts a new bucket into a Kanban view. The project and view
// come from the URL; the v2 handler ignores project_view_id in the body.
func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *Bucket) (*Bucket, error) { func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *Bucket) (*Bucket, error) {
var out Bucket var out Bucket
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID) path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
if b == nil { if b == nil {
b = &Bucket{} b = &Bucket{}
} }
b.ProjectViewID = viewID if err := c.Do(ctx, "POST", path, nil, b, &out); err != nil {
if err := c.Do(ctx, "PUT", path, nil, b, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil
@ -47,17 +50,13 @@ func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *B
// MoveTaskToBucket positions an existing task in `bucketID` on the // MoveTaskToBucket positions an existing task in `bucketID` on the
// project's view. Vikunja stores task↔bucket relations in a separate // project's view. Vikunja stores task↔bucket relations in a separate
// table (`task_buckets`), so POST /tasks/{id} with bucket_id does not // table (`task_buckets`); a task update with bucket_id does not reliably
// reliably move tasks — this dedicated endpoint is the one the Kanban // move tasks — this dedicated endpoint is the one the Kanban UI's
// UI's drag-and-drop uses. // drag-and-drop uses. On v2 it's a PUT, and project/view/bucket all come
// from the URL, so the body only carries the task id.
func (c *Client) MoveTaskToBucket(ctx context.Context, projectID, viewID, bucketID, taskID int64) error { func (c *Client) MoveTaskToBucket(ctx context.Context, projectID, viewID, bucketID, taskID int64) error {
path := fmt.Sprintf("/projects/%d/views/%d/buckets/%d/tasks", path := fmt.Sprintf("/projects/%d/views/%d/buckets/%d/tasks",
projectID, viewID, bucketID) projectID, viewID, bucketID)
body := map[string]int64{ body := map[string]int64{"task_id": taskID}
"task_id": taskID, return c.Do(ctx, "PUT", path, nil, body, nil)
"project_view_id": viewID,
"bucket_id": bucketID,
"project_id": projectID,
}
return c.Do(ctx, "POST", path, nil, body, nil)
} }

View File

@ -33,7 +33,7 @@ import (
// Client is a thin JSON wrapper around the Vikunja REST API. It holds the // Client is a thin JSON wrapper around the Vikunja REST API. It holds the
// server base URL and a bearer token (either a JWT from POST /login or an // server base URL and a bearer token (either a JWT from POST /login or an
// API token minted via PUT /tokens). Every method in this package is a thin // API token minted via POST /tokens). Every method in this package is a thin
// shim over Do. // shim over Do.
type Client struct { type Client struct {
BaseURL string BaseURL string
@ -41,6 +41,19 @@ type Client struct {
HTTPClient *http.Client HTTPClient *http.Client
} }
// apiBasePath is the version prefix every request is mounted under. veans
// targets the Huma-backed /api/v2 exclusively — v1 is frozen and the bucket
// CRUD endpoints veans needs only exist on v2.
const apiBasePath = "/api/v2"
// contentTypeJSON / contentTypeMergePatch are the request body content types
// Do and DoMerge send. Merge-patch (RFC 7396) is how v2 does partial updates:
// only the fields present in the body are written, the rest are left intact.
const (
contentTypeJSON = "application/json"
contentTypeMergePatch = "application/merge-patch+json"
)
// UserAgent is the value sent in the User-Agent header on every request. // UserAgent is the value sent in the User-Agent header on every request.
// main sets this at startup with the linker-injected version + the // main sets this at startup with the linker-injected version + the
// runtime os/arch (e.g. "veans/0.3.1 (linux/amd64)"). Tests get the // runtime os/arch (e.g. "veans/0.3.1 (linux/amd64)"). Tests get the
@ -61,17 +74,36 @@ func New(baseURL, token string) *Client {
} }
} }
// vikunjaError matches `web.HTTPError` on the wire. // vikunjaError matches the RFC 9457 problem+json body /api/v2 returns
// (huma.ErrorModel augmented with Vikunja's numeric domain `code`). The
// human-readable message lives in `detail`; `title` is the status text
// fallback. `message` is v1's legacy field, kept only as a fallback so a
// stray legacy/proxy error body still yields a readable message instead of
// raw JSON. The HTTP status used for output.Code mapping comes from the
// response status line, not this body.
type vikunjaError struct { type vikunjaError struct {
Code int `json:"code"` Title string `json:"title"`
Detail string `json:"detail"`
Message string `json:"message"` Message string `json:"message"`
Code int `json:"code"`
} }
// Do performs a single JSON request against /api/v1<path>. body, if non-nil, // Do performs a single JSON request against /api/v2<path>. body, if non-nil,
// is JSON-marshalled. out, if non-nil, is JSON-unmarshalled. query is appended // is JSON-marshalled. out, if non-nil, is JSON-unmarshalled. query is appended
// as URL-encoded params. // as URL-encoded params.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error { func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
full := c.BaseURL + "/api/v1" + path return c.do(ctx, method, path, query, body, out, contentTypeJSON)
}
// DoMerge is like Do but sends the body as a JSON Merge Patch
// (application/merge-patch+json). Used for PATCH updates so only the fields
// present in `body` are written server-side — see UpdateTask.
func (c *Client) DoMerge(ctx context.Context, method, path string, query url.Values, body, out any) error {
return c.do(ctx, method, path, query, body, out, contentTypeMergePatch)
}
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any, contentType string) error {
full := c.BaseURL + apiBasePath + path
if len(query) > 0 { if len(query) > 0 {
full += "?" + query.Encode() full += "?" + query.Encode()
} }
@ -91,7 +123,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
if body != nil { if body != nil {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", contentType)
} }
if c.Token != "" { if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Authorization", "Bearer "+c.Token)
@ -122,51 +154,55 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
return nil return nil
} }
// DoPaginated is like Do but also returns the total page count parsed from // Paginated mirrors the standard /api/v2 list envelope. Every v2 list
// the `x-pagination-total-pages` response header (0 if the header is // operation returns this shape (v1 returned a bare array plus an
// missing or unparseable). Used by the list endpoints so paging terminates // x-pagination-total-pages header, which is gone). Single-object responses
// against the authoritative server count, not a `len(batch) < per_page` // stay unwrapped.
// heuristic that loops one extra time on exact-multiple totals. type Paginated[T any] struct {
func (c *Client) DoPaginated(ctx context.Context, method, path string, query url.Values, out any) (totalPages int, err error) { Items []T `json:"items"`
full := c.BaseURL + "/api/v1" + path Total int64 `json:"total"`
if len(query) > 0 { Page int `json:"page"`
full += "?" + query.Encode() PerPage int `json:"per_page"`
} TotalPages int `json:"total_pages"`
req, err := http.NewRequestWithContext(ctx, method, full, nil) }
if err != nil {
return 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
req.Header.Set("User-Agent", UserAgent)
resp, err := c.HTTPClient.Do(req) // doList GETs `path` and decodes the standard v2 list envelope, returning the
if err != nil { // items plus the server's total page count so a caller can page until
return 0, output.Wrap(output.CodeUnknown, err, "%s %s: %v", method, path, err) // page >= totalPages. Generic so each list endpoint reuses it without a
// per-type wrapper struct.
func doList[T any](ctx context.Context, c *Client, path string, query url.Values) (items []T, totalPages int, err error) {
var env Paginated[T]
if err := c.Do(ctx, "GET", path, query, nil, &env); err != nil {
return nil, 0, err
} }
defer resp.Body.Close() return env.Items, env.TotalPages, nil
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) // doListAll pages through a v2 list endpoint, accumulating every item until
// page >= total_pages.
//
// Use it ONLY for endpoints whose model honours page/per_page — the
// server-paginated lists (tasks, projects, labels, comments, bots). For the
// endpoints whose ReadAll ignores pagination and returns every row in a single
// page (buckets, views), call doList instead: looping those re-fetches the full
// set on every page and duplicates it.
func doListAll[T any](ctx context.Context, c *Client, path string) ([]T, error) {
var all []T
page := 1
for {
q := url.Values{}
q.Set("page", strconv.Itoa(page))
q.Set("per_page", "50")
batch, totalPages, err := doList[T](ctx, c, path, q)
if err != nil { if err != nil {
return 0, fmt.Errorf("read response: %w", err) return nil, err
} }
if resp.StatusCode >= 400 { all = append(all, batch...)
return 0, mapHTTPError(method, path, resp.StatusCode, respBody, if page >= totalPages {
parseRetryAfter(resp.Header.Get("Retry-After"))) return all, nil
} }
if out != nil && len(respBody) > 0 { page++
if err := json.Unmarshal(respBody, out); err != nil {
return 0, fmt.Errorf("decode %s %s: %w", method, path, err)
} }
}
if v := resp.Header.Get("x-pagination-total-pages"); v != "" {
if n, perr := strconv.Atoi(v); perr == nil {
totalPages = n
}
}
return totalPages, nil
} }
// DoRaw is the escape hatch used by `veans api`. It returns the raw response // DoRaw is the escape hatch used by `veans api`. It returns the raw response
@ -176,7 +212,7 @@ func (c *Client) DoPaginated(ctx context.Context, method, path string, query url
// "stdout is for the success payload; errors go through the envelope on // "stdout is for the success payload; errors go through the envelope on
// stderr"); see commands/api.go for the canonical handling. // stderr"); see commands/api.go for the canonical handling.
func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Values, body []byte) (status int, respBody []byte, retryAfter time.Duration, err error) { func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Values, body []byte) (status int, respBody []byte, retryAfter time.Duration, err error) {
full := c.BaseURL + "/api/v1" + path full := c.BaseURL + apiBasePath + path
if len(query) > 0 { if len(query) > 0 {
full += "?" + query.Encode() full += "?" + query.Encode()
} }
@ -205,18 +241,6 @@ func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Value
return resp.StatusCode, respBody, parseRetryAfter(resp.Header.Get("Retry-After")), err return resp.StatusCode, respBody, parseRetryAfter(resp.Header.Get("Retry-After")), err
} }
// paginationDone reports whether a paged GET has consumed every page,
// preferring the server's x-pagination-total-pages count when present and
// falling back to the len(batch) < per_page heuristic when the header is
// missing (older server / proxy stripped). Centralized so all list
// endpoints terminate identically.
func paginationDone(page, batchLen, perPage, totalPages int) bool {
if totalPages > 0 {
return page >= totalPages
}
return batchLen < perPage
}
// maxBodyBytes caps the size of any response body we'll read into memory. // maxBodyBytes caps the size of any response body we'll read into memory.
// Vikunja JSON payloads are far smaller; the cap exists so a misbehaving // Vikunja JSON payloads are far smaller; the cap exists so a misbehaving
// proxy can't OOM the CLI by streaming an unbounded body. // proxy can't OOM the CLI by streaming an unbounded body.
@ -244,7 +268,16 @@ func parseRetryAfter(v string) time.Duration {
func mapHTTPError(method, path string, status int, body []byte, retryAfter time.Duration) error { func mapHTTPError(method, path string, status int, body []byte, retryAfter time.Duration) error {
var ve vikunjaError var ve vikunjaError
_ = json.Unmarshal(body, &ve) _ = json.Unmarshal(body, &ve)
msg := strings.TrimSpace(ve.Message) // v2's problem+json carries the human-readable text in `detail`; fall back
// to `title`, then v1's legacy `message`, then the raw body, then the
// status text.
msg := strings.TrimSpace(ve.Detail)
if msg == "" {
msg = strings.TrimSpace(ve.Title)
}
if msg == "" {
msg = strings.TrimSpace(ve.Message)
}
if msg == "" { if msg == "" {
msg = strings.TrimSpace(string(body)) msg = strings.TrimSpace(string(body))
if msg == "" { if msg == "" {

View File

@ -45,7 +45,7 @@ func TestMapHTTPError_StatusCodeMapping(t *testing.T) {
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
err := mapHTTPError("GET", "/foo", tc.status, []byte(`{"message":"boom"}`), 0) err := mapHTTPError("GET", "/foo", tc.status, []byte(`{"detail":"boom"}`), 0)
var oe *output.Error var oe *output.Error
if !errors.As(err, &oe) { if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err) t.Fatalf("expected *output.Error, got %T", err)
@ -59,7 +59,7 @@ func TestMapHTTPError_StatusCodeMapping(t *testing.T) {
func TestMapHTTPError_RetryAfterAppendedToMessage(t *testing.T) { func TestMapHTTPError_RetryAfterAppendedToMessage(t *testing.T) {
retry := 7 * time.Second retry := 7 * time.Second
err := mapHTTPError("GET", "/foo", http.StatusTooManyRequests, []byte(`{"message":"slow down"}`), retry) err := mapHTTPError("GET", "/foo", http.StatusTooManyRequests, []byte(`{"detail":"slow down"}`), retry)
var oe *output.Error var oe *output.Error
if !errors.As(err, &oe) { if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err) t.Fatalf("expected *output.Error, got %T", err)
@ -89,20 +89,53 @@ func TestMapHTTPError_BodyTruncation(t *testing.T) {
} }
} }
func TestMapHTTPError_VikunjaJSONTakesPrecedenceOverRawBody(t *testing.T) { func TestMapHTTPError_VikunjaProblemJSONTakesPrecedenceOverRawBody(t *testing.T) {
body := []byte(`{"code":404,"message":"x"}`) // v2 returns RFC 9457 problem+json: the message is in `detail`, and `code`
// carries Vikunja's numeric domain error code (not the HTTP status).
body := []byte(`{"status":404,"title":"Not Found","detail":"x","code":3001}`)
err := mapHTTPError("GET", "/foo", http.StatusNotFound, body, 0) err := mapHTTPError("GET", "/foo", http.StatusNotFound, body, 0)
var oe *output.Error var oe *output.Error
if !errors.As(err, &oe) { if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err) t.Fatalf("expected *output.Error, got %T", err)
} }
// The formatted message is "METHOD PATH: STATUS MSG"; assert it carries // The formatted message is "METHOD PATH: STATUS MSG"; assert it carries
// the decoded message and not the raw JSON envelope. // the decoded `detail` and not the raw JSON envelope.
if !strings.HasSuffix(oe.Message, ": 404 x") { if !strings.HasSuffix(oe.Message, ": 404 x") {
t.Errorf("expected formatted message to end with %q, got %q", ": 404 x", oe.Message) t.Errorf("expected formatted message to end with %q, got %q", ": 404 x", oe.Message)
} }
if strings.Contains(oe.Message, `"code":404`) { if strings.Contains(oe.Message, `"code"`) {
t.Errorf("expected raw JSON body to be replaced by decoded message, got %q", oe.Message) t.Errorf("expected raw JSON body to be replaced by decoded detail, got %q", oe.Message)
}
}
func TestMapHTTPError_FallsBackToTitleWhenNoDetail(t *testing.T) {
// A problem+json body with no `detail` (e.g. Huma's own schema-validation
// 422 sometimes only sets title) falls back to `title`.
body := []byte(`{"status":422,"title":"Unprocessable Entity"}`)
err := mapHTTPError("PATCH", "/tasks/1", http.StatusUnprocessableEntity, body, 0)
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err)
}
if !strings.HasSuffix(oe.Message, ": 422 Unprocessable Entity") {
t.Errorf("expected title fallback, got %q", oe.Message)
}
}
func TestMapHTTPError_FallsBackToLegacyMessage(t *testing.T) {
// Defensive: a stray legacy/proxy body with only v1's `message` field
// still yields the message text rather than the raw JSON.
body := []byte(`{"code":403,"message":"forbidden"}`)
err := mapHTTPError("GET", "/foo", http.StatusForbidden, body, 0)
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err)
}
if !strings.HasSuffix(oe.Message, ": 403 forbidden") {
t.Errorf("expected legacy message fallback, got %q", oe.Message)
}
if strings.Contains(oe.Message, `"message"`) {
t.Errorf("expected raw JSON to be replaced by the message text, got %q", oe.Message)
} }
} }
@ -146,36 +179,9 @@ func TestParseRetryAfter(t *testing.T) {
} }
} }
func TestPaginationDone(t *testing.T) {
cases := []struct {
name string
page int
batchLen int
perPage int
totalPages int
want bool
}{
{"server says single page complete", 1, 50, 50, 1, true},
{"server says more pages remain", 1, 50, 50, 2, false},
{"server says we're on the last page", 2, 10, 50, 2, true},
{"no header, full page -> not done", 1, 50, 50, 0, false},
{"no header, short page -> done", 1, 10, 50, 0, true},
{"no header, empty page -> done", 1, 0, 50, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := paginationDone(tc.page, tc.batchLen, tc.perPage, tc.totalPages)
if got != tc.want {
t.Errorf("paginationDone(page=%d, batch=%d, per=%d, total=%d) = %v, want %v",
tc.page, tc.batchLen, tc.perPage, tc.totalPages, got, tc.want)
}
})
}
}
func TestCreateBotUser_404TranslatesToBotUsersUnavailable(t *testing.T) { func TestCreateBotUser_404TranslatesToBotUsersUnavailable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/bots" { if r.Method != http.MethodPost || r.URL.Path != "/api/v2/user/bots" {
http.Error(w, "unexpected route", http.StatusInternalServerError) http.Error(w, "unexpected route", http.StatusInternalServerError)
return return
} }
@ -196,3 +202,129 @@ func TestCreateBotUser_404TranslatesToBotUsersUnavailable(t *testing.T) {
t.Errorf("got code %q, want %q", oe.Code, output.CodeBotUsersUnavailable) t.Errorf("got code %q, want %q", oe.Code, output.CodeBotUsersUnavailable)
} }
} }
// TestListProjects_PaginatesEnvelope verifies the v2 list shape: each page is
// the {items,total,page,per_page,total_pages} envelope, and ListProjects keeps
// requesting until page >= total_pages, accumulating every item.
func TestListProjects_PaginatesEnvelope(t *testing.T) {
var gotPages []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/projects" {
http.Error(w, "unexpected path "+r.URL.Path, http.StatusInternalServerError)
return
}
page := r.URL.Query().Get("page")
gotPages = append(gotPages, page)
w.Header().Set("Content-Type", "application/json")
switch page {
case "1":
_, _ = w.Write([]byte(`{"items":[{"id":1,"title":"a"},{"id":2,"title":"b"}],"total":3,"page":1,"per_page":2,"total_pages":2}`))
case "2":
_, _ = w.Write([]byte(`{"items":[{"id":3,"title":"c"}],"total":3,"page":2,"per_page":2,"total_pages":2}`))
default:
t.Errorf("unexpected page %q (would loop past the end)", page)
http.Error(w, "no such page", http.StatusBadRequest)
}
}))
defer srv.Close()
projects, err := New(srv.URL, "tk").ListProjects(context.Background())
if err != nil {
t.Fatalf("ListProjects: %v", err)
}
if len(projects) != 3 {
t.Fatalf("expected 3 projects accumulated across 2 pages, got %d", len(projects))
}
if len(gotPages) != 2 || gotPages[0] != "1" || gotPages[1] != "2" {
t.Fatalf("expected exactly pages [1 2], got %v", gotPages)
}
}
// TestListTaskComments_PaginatesEnvelope guards the truncation bug: the v2
// comments endpoint is server-paginated, so a task with >50 comments spans
// multiple pages and ListTaskComments must accumulate them all, not stop at
// page 1.
func TestListTaskComments_PaginatesEnvelope(t *testing.T) {
var pages []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/tasks/9/comments" {
http.Error(w, "unexpected path "+r.URL.Path, http.StatusInternalServerError)
return
}
page := r.URL.Query().Get("page")
pages = append(pages, page)
w.Header().Set("Content-Type", "application/json")
switch page {
case "1":
_, _ = w.Write([]byte(`{"items":[{"id":1,"comment":"a"},{"id":2,"comment":"b"}],"total":3,"page":1,"per_page":2,"total_pages":2}`))
case "2":
_, _ = w.Write([]byte(`{"items":[{"id":3,"comment":"c"}],"total":3,"page":2,"per_page":2,"total_pages":2}`))
default:
t.Errorf("unexpected page %q", page)
http.Error(w, "no such page", http.StatusBadRequest)
}
}))
defer srv.Close()
comments, err := New(srv.URL, "tk").ListTaskComments(context.Background(), 9)
if err != nil {
t.Fatalf("ListTaskComments: %v", err)
}
if len(comments) != 3 {
t.Fatalf("expected 3 comments across 2 pages, got %d (truncation regression?)", len(comments))
}
if len(pages) != 2 {
t.Fatalf("expected to fetch 2 pages, got %v", pages)
}
}
// TestListBuckets_SingleFetchDoesNotPage pins the opposite invariant: the
// buckets model returns every row in one page, so ListBuckets must issue a
// single request even when the envelope's total_pages is >1 — paging would
// re-fetch and duplicate the buckets.
func TestListBuckets_SingleFetchDoesNotPage(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
if requests > 1 {
t.Errorf("ListBuckets paged a single-page endpoint (request %d) — would duplicate", requests)
}
w.Header().Set("Content-Type", "application/json")
// total_pages deliberately > 1 to prove ListBuckets ignores it.
_, _ = w.Write([]byte(`{"items":[{"id":1,"title":"Todo"},{"id":2,"title":"Doing"}],"total":2,"page":1,"per_page":1,"total_pages":2}`))
}))
defer srv.Close()
buckets, err := New(srv.URL, "tk").ListBuckets(context.Background(), 7, 3)
if err != nil {
t.Fatalf("ListBuckets: %v", err)
}
if requests != 1 {
t.Fatalf("expected exactly 1 request, got %d", requests)
}
if len(buckets) != 2 {
t.Fatalf("expected the 2 buckets from the single page, got %d", len(buckets))
}
}
// TestListProjectViews_UnwrapsEnvelope pins that a previously-single-GET list
// (project views) now unwraps .items from the standard list envelope.
func TestListProjectViews_UnwrapsEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/projects/7/views" {
http.Error(w, "unexpected path "+r.URL.Path, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"items":[{"id":10,"title":"Kanban","view_kind":"kanban"}],"total":1,"page":1,"per_page":50,"total_pages":1}`))
}))
defer srv.Close()
views, err := New(srv.URL, "tk").ListProjectViews(context.Background(), 7)
if err != nil {
t.Fatalf("ListProjectViews: %v", err)
}
if len(views) != 1 || views[0].ViewKind != ViewKindKanban {
t.Fatalf("expected one kanban view unwrapped from .items, got %+v", views)
}
}

View File

@ -24,17 +24,15 @@ import (
// AddTaskComment posts a new comment on a task. // AddTaskComment posts a new comment on a task.
func (c *Client) AddTaskComment(ctx context.Context, taskID int64, body string) (*TaskComment, error) { func (c *Client) AddTaskComment(ctx context.Context, taskID int64, body string) (*TaskComment, error) {
var out TaskComment var out TaskComment
if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/comments", taskID), nil, &TaskComment{Comment: body}, &out); err != nil { if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/comments", taskID), nil, &TaskComment{Comment: body}, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil
} }
// ListTaskComments returns all comments on a task. // ListTaskComments returns all comments on a task. The v2 comments endpoint is
// server-paginated (TaskComment.ReadAll applies a 50-item page limit), so page
// through to the end instead of returning only the first page.
func (c *Client) ListTaskComments(ctx context.Context, taskID int64) ([]*TaskComment, error) { func (c *Client) ListTaskComments(ctx context.Context, taskID int64) ([]*TaskComment, error) {
var out []*TaskComment return doListAll[*TaskComment](ctx, c, fmt.Sprintf("/tasks/%d/comments", taskID))
if err := c.Do(ctx, "GET", fmt.Sprintf("/tasks/%d/comments", taskID), nil, nil, &out); err != nil {
return nil, err
}
return out, nil
} }

View File

@ -31,11 +31,15 @@ import (
const defaultAPIPort = "3456" const defaultAPIPort = "3456"
// DiscoverServer normalizes `input` and probes a small set of plausible // DiscoverServer normalizes `input` and probes a small set of plausible
// URLs for /api/v1/info, returning the canonical base URL (without the // URLs for /api/v2/info, returning the canonical base URL (without the
// /api/v1 suffix — that's what client.New expects) and the parsed Info. // /api/v2 suffix — that's what client.New expects) and the parsed Info.
//
// Probing /api/v2/info doubles as the "is this server new enough" check: a
// Vikunja without /api/v2 fails discovery cleanly rather than limping along
// against endpoints veans needs.
// //
// Mirrors the discovery the Vikunja web frontend does in // Mirrors the discovery the Vikunja web frontend does in
// helpers/checkAndSetApiUrl.ts: try the URL as-given, with /api/v1 // helpers/checkAndSetApiUrl.ts: try the URL as-given, with the API path
// appended, and with the default :3456 port — across http / https. The // appended, and with the default :3456 port — across http / https. The
// first response that parses as Info wins. // first response that parses as Info wins.
func DiscoverServer(ctx context.Context, input string) (string, *Info, error) { func DiscoverServer(ctx context.Context, input string) (string, *Info, error) {
@ -53,7 +57,7 @@ func DiscoverServer(ctx context.Context, input string) (string, *Info, error) {
var attempts []string var attempts []string
var lastErr error var lastErr error
for _, base := range candidates { for _, base := range candidates {
attempts = append(attempts, base+"/api/v1/info") attempts = append(attempts, base+"/api/v2/info")
info, err := New(base, "").Info(ctx) info, err := New(base, "").Info(ctx)
if err == nil && info != nil { if err == nil && info != nil {
return base, info, nil return base, info, nil
@ -67,15 +71,16 @@ func DiscoverServer(ctx context.Context, input string) (string, *Info, error) {
} }
// serverCandidates expands `input` into the ordered list of base URLs // serverCandidates expands `input` into the ordered list of base URLs
// to probe for /api/v1/info. A "base URL" here is what client.New wants: // to probe for /api/v2/info. A "base URL" here is what client.New wants:
// the origin + the path that should sit BEFORE /api/v1 (typically empty // the origin + the path that should sit BEFORE /api/v2 (typically empty
// or a reverse-proxy prefix). The probe itself adds /api/v1/info. // or a reverse-proxy prefix). The probe itself adds /api/v2/info.
func serverCandidates(input string) ([]string, error) { func serverCandidates(input string) ([]string, error) {
// Strip a trailing /api/v1[/] the user might have copied from a // Strip a trailing /api/v1 or /api/v2[/] the user might have copied
// curl example. We add it back in the probe, and otherwise we'd // from a curl example. We add the API path back in the probe, and
// end up calling /api/v1/api/v1/info. // otherwise we'd end up calling /api/v2/api/v2/info.
trimmed := strings.TrimRight(input, "/") trimmed := strings.TrimRight(input, "/")
trimmed = strings.TrimSuffix(trimmed, "/api/v1") trimmed = strings.TrimSuffix(trimmed, "/api/v1")
trimmed = strings.TrimSuffix(trimmed, "/api/v2")
trimmed = strings.TrimRight(trimmed, "/") trimmed = strings.TrimRight(trimmed, "/")
withScheme := trimmed withScheme := trimmed

View File

@ -33,15 +33,15 @@ func (c *Client) ListLabels(ctx context.Context, search string) ([]*Label, error
q.Set("page", strconv.Itoa(page)) q.Set("page", strconv.Itoa(page))
q.Set("per_page", "50") q.Set("per_page", "50")
if search != "" { if search != "" {
q.Set("s", search) // v2's list search param is `q` (v1 used `s`).
q.Set("q", search)
} }
var batch []*Label batch, totalPages, err := doList[*Label](ctx, c, "/labels", q)
total, err := c.DoPaginated(ctx, "GET", "/labels", q, &batch)
if err != nil { if err != nil {
return nil, err return nil, err
} }
all = append(all, batch...) all = append(all, batch...)
if paginationDone(page, len(batch), 50, total) { if page >= totalPages {
return all, nil return all, nil
} }
page++ page++
@ -51,7 +51,7 @@ func (c *Client) ListLabels(ctx context.Context, search string) ([]*Label, error
// CreateLabel creates a new label owned by the authenticated user. // CreateLabel creates a new label owned by the authenticated user.
func (c *Client) CreateLabel(ctx context.Context, l *Label) (*Label, error) { func (c *Client) CreateLabel(ctx context.Context, l *Label) (*Label, error) {
var out Label var out Label
if err := c.Do(ctx, "PUT", "/labels", nil, l, &out); err != nil { if err := c.Do(ctx, "POST", "/labels", nil, l, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil
@ -59,7 +59,7 @@ func (c *Client) CreateLabel(ctx context.Context, l *Label) (*Label, error) {
// AddLabelToTask attaches an existing label to a task. // AddLabelToTask attaches an existing label to a task.
func (c *Client) AddLabelToTask(ctx context.Context, taskID, labelID int64) error { func (c *Client) AddLabelToTask(ctx context.Context, taskID, labelID int64) error {
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/labels", taskID), nil, &LabelTask{LabelID: labelID}, nil) return c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/labels", taskID), nil, &LabelTask{LabelID: labelID}, nil)
} }
// RemoveLabelFromTask detaches a label. // RemoveLabelFromTask detaches a label.

View File

@ -23,8 +23,8 @@ import (
"strconv" "strconv"
) )
// ListProjects pages through GET /projects, accumulating until the server's // ListProjects pages through GET /projects, accumulating until the list
// x-pagination-total-pages header says we're done. // envelope's total_pages says we're done.
func (c *Client) ListProjects(ctx context.Context) ([]*Project, error) { func (c *Client) ListProjects(ctx context.Context) ([]*Project, error) {
var all []*Project var all []*Project
page := 1 page := 1
@ -32,13 +32,12 @@ func (c *Client) ListProjects(ctx context.Context) ([]*Project, error) {
q := url.Values{} q := url.Values{}
q.Set("page", strconv.Itoa(page)) q.Set("page", strconv.Itoa(page))
q.Set("per_page", "50") q.Set("per_page", "50")
var batch []*Project batch, totalPages, err := doList[*Project](ctx, c, "/projects", q)
total, err := c.DoPaginated(ctx, "GET", "/projects", q, &batch)
if err != nil { if err != nil {
return nil, err return nil, err
} }
all = append(all, batch...) all = append(all, batch...)
if paginationDone(page, len(batch), 50, total) { if page >= totalPages {
return all, nil return all, nil
} }
page++ page++
@ -58,7 +57,7 @@ func (c *Client) GetProject(ctx context.Context, id int64) (*Project, error) {
// auto-creates the default views (List, Gantt, Table, Kanban) on insert. // auto-creates the default views (List, Gantt, Table, Kanban) on insert.
func (c *Client) CreateProject(ctx context.Context, p *Project) (*Project, error) { func (c *Client) CreateProject(ctx context.Context, p *Project) (*Project, error) {
var out Project var out Project
if err := c.Do(ctx, "PUT", "/projects", nil, p, &out); err != nil { if err := c.Do(ctx, "POST", "/projects", nil, p, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil
@ -67,17 +66,20 @@ func (c *Client) CreateProject(ctx context.Context, p *Project) (*Project, error
// ShareProjectWithUser grants `username` `permission` on project `id`. // ShareProjectWithUser grants `username` `permission` on project `id`.
func (c *Client) ShareProjectWithUser(ctx context.Context, projectID int64, share *ProjectUser) (*ProjectUser, error) { func (c *Client) ShareProjectWithUser(ctx context.Context, projectID int64, share *ProjectUser) (*ProjectUser, error) {
var out ProjectUser var out ProjectUser
if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/users", projectID), nil, share, &out); err != nil { if err := c.Do(ctx, "POST", fmt.Sprintf("/projects/%d/users", projectID), nil, share, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil
} }
// ListProjectViews returns saved views (Kanban, List, …) on a project. // ListProjectViews returns saved views (Kanban, List, …) on a project.
// ProjectView.ReadAll ignores page/per_page and returns every view in a single
// page, so one GET gets them all — paging would re-fetch the same views and
// duplicate them. Unwrap .items.
func (c *Client) ListProjectViews(ctx context.Context, projectID int64) ([]*ProjectView, error) { func (c *Client) ListProjectViews(ctx context.Context, projectID int64) ([]*ProjectView, error) {
var out []*ProjectView items, _, err := doList[*ProjectView](ctx, c, fmt.Sprintf("/projects/%d/views", projectID), nil)
if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d/views", projectID), nil, nil, &out); err != nil { if err != nil {
return nil, err return nil, err
} }
return out, nil return items, nil
} }

View File

@ -26,7 +26,7 @@ import (
func (c *Client) CreateRelation(ctx context.Context, taskID int64, otherTaskID int64, relationKind string) (*TaskRelation, error) { func (c *Client) CreateRelation(ctx context.Context, taskID int64, otherTaskID int64, relationKind string) (*TaskRelation, error) {
var out TaskRelation var out TaskRelation
body := &TaskRelation{OtherTaskID: otherTaskID, RelationKind: relationKind} body := &TaskRelation{OtherTaskID: otherTaskID, RelationKind: relationKind}
if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/relations", taskID), nil, body, &out); err != nil { if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/relations", taskID), nil, body, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil

View File

@ -40,7 +40,7 @@ func (c *Client) Routes(ctx context.Context) (map[string]RouteGroup, error) {
// PermissionsForBot picks a curated subset of route groups the veans bot // PermissionsForBot picks a curated subset of route groups the veans bot
// needs and projects the available actions of each. Groups not present on // needs and projects the available actions of each. Groups not present on
// the server are silently dropped, so the resulting permission map is // the server are silently dropped, so the resulting permission map is
// always valid for PUT /tokens regardless of Vikunja version. // always valid for POST /tokens regardless of Vikunja version.
// //
// The action names reflect Vikunja's actual route map (see GET /routes): // The action names reflect Vikunja's actual route map (see GET /routes):
// bucket CRUD and the bucket-task move endpoint live under the `projects` // bucket CRUD and the bucket-task move endpoint live under the `projects`
@ -56,10 +56,16 @@ func PermissionsForBot(routes map[string]RouteGroup) map[string][]string {
}, },
// Project access: read project metadata, manage buckets & move // Project access: read project metadata, manage buckets & move
// tasks between them. tasks_by-index resolves #NN / PROJ-NN. // tasks between them. tasks_by-index resolves #NN / PROJ-NN.
// The bucket-task MOVE (PUT .../buckets/:bucket/tasks) and the
// buckets-with-tasks LIST (GET .../buckets/tasks) collide on subkey
// `views_buckets_tasks`; which one gets the bare key vs the
// `_put`-suffixed key depends on unspecified route-init order, so we
// request BOTH and let the runtime intersection drop whichever the
// server didn't register.
"projects": { "projects": {
"read_one", "read_all", "tasks_by-index", "read_one", "read_all", "tasks_by-index",
"views_buckets", "views_buckets_put", "views_buckets_post", "views_buckets", "views_buckets_put", "views_buckets_post",
"views_buckets_delete", "views_buckets_tasks", "views_buckets_delete", "views_buckets_tasks", "views_buckets_tasks_put",
}, },
"projects_views": {"read_one", "read_all"}, "projects_views": {"read_one", "read_all"},
"labels": {"read_one", "read_all", "create", "update", "delete"}, "labels": {"read_one", "read_all", "create", "update", "delete"},

View File

@ -16,7 +16,10 @@
package client package client
import "testing" import (
"slices"
"testing"
)
func TestPermissionsForBot_DropsUnknownGroups(t *testing.T) { func TestPermissionsForBot_DropsUnknownGroups(t *testing.T) {
// Server only exposes a subset of what we ask for. // Server only exposes a subset of what we ask for.
@ -61,3 +64,42 @@ func TestPermissionsForBot_EmptyWhenServerIsEmpty(t *testing.T) {
t.Fatalf("expected empty map, got %v", got) t.Fatalf("expected empty map, got %v", got)
} }
} }
// TestPermissionsForBot_ProjectsBucketScopes pins the project-group scopes the
// bot needs for the v2 kanban-bucket calls: list/create/update/delete buckets
// plus the bucket-task MOVE. The MOVE and the buckets-with-tasks LIST collide
// on the `views_buckets_tasks` subkey and the bare-vs-_put assignment depends
// on unspecified route-init order, so the bot must request BOTH keys; the
// runtime intersection keeps whichever the server actually exposes.
func TestPermissionsForBot_ProjectsBucketScopes(t *testing.T) {
// A server that registered the move under the bare key and the list under
// the _put key (one of the two possible orderings).
server := map[string]RouteGroup{
"projects": {
"read_one": {},
"read_all": {},
"tasks_by-index": {},
"views_buckets": {}, // list buckets
"views_buckets_post": {}, // create bucket
"views_buckets_put": {}, // update bucket
"views_buckets_delete": {}, // delete bucket
"views_buckets_tasks": {}, // bucket-task move OR buckets-with-tasks list
"views_buckets_tasks_put": {}, // the other of the colliding pair
},
}
got := PermissionsForBot(server)
projects, ok := got["projects"]
if !ok {
t.Fatalf("expected projects group in result")
}
want := []string{
"read_one", "read_all", "tasks_by-index",
"views_buckets", "views_buckets_post", "views_buckets_put",
"views_buckets_delete", "views_buckets_tasks", "views_buckets_tasks_put",
}
for _, w := range want {
if !slices.Contains(projects, w) {
t.Errorf("projects scope %q missing from bot grant; got %v", w, projects)
}
}
}

View File

@ -52,7 +52,7 @@ func (o *TaskListOptions) values() url.Values {
} }
// ListProjectTasks paginates `GET /projects/{id}/tasks` exhaustively, // ListProjectTasks paginates `GET /projects/{id}/tasks` exhaustively,
// terminating against the server's x-pagination-total-pages header. // terminating against the list envelope's total_pages.
func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *TaskListOptions) ([]*Task, error) { func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *TaskListOptions) ([]*Task, error) {
if opts == nil { if opts == nil {
opts = &TaskListOptions{} opts = &TaskListOptions{}
@ -61,19 +61,19 @@ func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *Ta
if per <= 0 { if per <= 0 {
per = 50 per = 50
} }
path := fmt.Sprintf("/projects/%d/tasks", projectID)
var all []*Task var all []*Task
page := 1 page := 1
for { for {
o := *opts o := *opts
o.Page = page o.Page = page
o.PerPage = per o.PerPage = per
var batch []*Task batch, totalPages, err := doList[*Task](ctx, c, path, o.values())
total, err := c.DoPaginated(ctx, "GET", fmt.Sprintf("/projects/%d/tasks", projectID), o.values(), &batch)
if err != nil { if err != nil {
return nil, err return nil, err
} }
all = append(all, batch...) all = append(all, batch...)
if paginationDone(page, len(batch), per, total) { if page >= totalPages {
return all, nil return all, nil
} }
page++ page++
@ -114,24 +114,26 @@ func (t *Task) CurrentBucketID(viewID int64) int64 {
return 0 return 0
} }
// CreateTask inserts a task into a project (PUT /projects/{id}/tasks). // CreateTask inserts a task into a project (POST /projects/{id}/tasks).
func (c *Client) CreateTask(ctx context.Context, projectID int64, t *Task) (*Task, error) { func (c *Client) CreateTask(ctx context.Context, projectID int64, t *Task) (*Task, error) {
var out Task var out Task
if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/tasks", projectID), nil, t, &out); err != nil { if err := c.Do(ctx, "POST", fmt.Sprintf("/projects/%d/tasks", projectID), nil, t, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil
} }
// UpdateTask updates a task (POST /tasks/{id}). This endpoint does NOT // UpdateTask partially updates a task via PATCH /tasks/{id} with a JSON Merge
// move tasks between buckets — the task↔bucket relation is row-shaped in // Patch body: only the fields set on `patch` are written, the rest are left
// task_buckets, and bucket_id on the request body is ignored. Use // intact (the fix for issue #2962, where a status-only update used to zero
// MoveTaskToBucket() for that. The server does auto-flip the bucket // description and priority). This endpoint does NOT move tasks between
// when `done` toggles, but only between the canonical "todo" and "done" // buckets — the task↔bucket relation is row-shaped in task_buckets, and
// buckets the project view is configured with. // bucket_id on the request body is ignored. Use MoveTaskToBucket() for that.
func (c *Client) UpdateTask(ctx context.Context, id int64, t *Task) (*Task, error) { // The server still auto-flips the bucket when `done` toggles, between the
// canonical "todo" and "done" buckets the project view is configured with.
func (c *Client) UpdateTask(ctx context.Context, id int64, patch *TaskPatch) (*Task, error) {
var out Task var out Task
if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d", id), nil, t, &out); err != nil { if err := c.DoMerge(ctx, "PATCH", fmt.Sprintf("/tasks/%d", id), nil, patch, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil

View File

@ -23,7 +23,7 @@ import "context"
// the bot in step 8 of init). // the bot in step 8 of init).
func (c *Client) CreateToken(ctx context.Context, t *APIToken) (*APIToken, error) { func (c *Client) CreateToken(ctx context.Context, t *APIToken) (*APIToken, error) {
var out APIToken var out APIToken
if err := c.Do(ctx, "PUT", "/tokens", nil, t, &out); err != nil { if err := c.Do(ctx, "POST", "/tokens", nil, t, &out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil

View File

@ -29,7 +29,7 @@ type User struct {
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
} }
// BotUser is what `PUT /bots` returns. // BotUser is what `POST /user/bots` returns.
type BotUser struct { type BotUser struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
@ -38,7 +38,7 @@ type BotUser struct {
Created time.Time `json:"created,omitempty"` Created time.Time `json:"created,omitempty"`
} }
// BotUserCreate is the request body for PUT /bots. // BotUserCreate is the request body for POST /user/bots.
type BotUserCreate struct { type BotUserCreate struct {
Username string `json:"username"` Username string `json:"username"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@ -119,6 +119,20 @@ type Task struct {
PercentDone float64 `json:"percent_done,omitempty"` PercentDone float64 `json:"percent_done,omitempty"`
} }
// TaskPatch is the JSON Merge Patch body for UpdateTask (PATCH /tasks/{id}).
// Every field is a pointer with omitempty so only the fields the caller sets
// are serialized; absent fields are left untouched server-side. This is the
// fix for issue #2962 — a status-only update no longer zeroes description or
// priority the way the old whole-object write did. A non-nil pointer to a zero
// value (e.g. *Priority = 0, *Done = false) still serializes, which is how an
// explicit "clear priority" or "reopen" reaches the server.
type TaskPatch struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Done *bool `json:"done,omitempty"`
Priority *int64 `json:"priority,omitempty"`
}
// TaskComment matches pkg/models/task_comments.TaskComment. // TaskComment matches pkg/models/task_comments.TaskComment.
type TaskComment struct { type TaskComment struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@ -138,12 +152,12 @@ type Label struct {
Updated time.Time `json:"updated,omitempty"` Updated time.Time `json:"updated,omitempty"`
} }
// LabelTask is the body for `PUT /tasks/{id}/labels`. // LabelTask is the body for `POST /tasks/{id}/labels`.
type LabelTask struct { type LabelTask struct {
LabelID int64 `json:"label_id"` LabelID int64 `json:"label_id"`
} }
// TaskRelation is the body for `PUT /tasks/{id}/relations` and the row // TaskRelation is the body for `POST /tasks/{id}/relations` and the row
// returned. RelationKind is one of: subtask, parenttask, related, duplicates, // returned. RelationKind is one of: subtask, parenttask, related, duplicates,
// duplicateof, blocking, blocked, precedes, follows, copiedfrom, copiedto. // duplicateof, blocking, blocked, precedes, follows, copiedfrom, copiedto.
type TaskRelation struct { type TaskRelation struct {
@ -152,12 +166,12 @@ type TaskRelation struct {
RelationKind string `json:"relation_kind"` RelationKind string `json:"relation_kind"`
} }
// TaskAssignee is the body for `PUT /tasks/{id}/assignees`. // TaskAssignee is the body for `POST /tasks/{id}/assignees`.
type TaskAssignee struct { type TaskAssignee struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
} }
// ProjectUser is the body and response for `PUT /projects/{id}/users`. // ProjectUser is the body and response for `POST /projects/{id}/users`.
type ProjectUser struct { type ProjectUser struct {
ID int64 `json:"id,omitempty"` ID int64 `json:"id,omitempty"`
Username string `json:"username"` Username string `json:"username"`
@ -171,7 +185,7 @@ const (
PermissionAdmin = 2 PermissionAdmin = 2
) )
// APIToken is the request and response shape for `PUT /tokens`. The plaintext // APIToken is the request and response shape for `POST /tokens`. The plaintext
// `Token` field is only populated on creation. Vikunja requires ExpiresAt; // `Token` field is only populated on creation. Vikunja requires ExpiresAt;
// callers that want a long-lived token use FarFuture (year 9999). // callers that want a long-lived token use FarFuture (year 9999).
type APIToken struct { type APIToken struct {
@ -223,8 +237,9 @@ type LoginResponse struct {
Token string `json:"token"` Token string `json:"token"`
} }
// OAuthTokenRequest is the JSON body for POST /api/v1/oauth/token. Vikunja's // OAuthTokenRequest is the JSON body for POST /api/v2/oauth/token. The v2
// OAuth server explicitly rejects form-encoded requests; everything is JSON. // endpoint accepts both JSON and form-encoded bodies; veans sends JSON, which
// Huma decodes off the Content-Type header regardless of the declared form.
type OAuthTokenRequest struct { type OAuthTokenRequest struct {
GrantType string `json:"grant_type"` GrantType string `json:"grant_type"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`

View File

@ -23,17 +23,17 @@ import (
"code.vikunja.io/veans/internal/output" "code.vikunja.io/veans/internal/output"
) )
// CreateBotUser provisions a bot user via PUT /user/bots. The username must // CreateBotUser provisions a bot user via POST /user/bots. The username must
// be prefixed `bot-` (Vikunja enforces this). The caller becomes the bot's // be prefixed `bot-` (Vikunja enforces this). The caller becomes the bot's
// owner, which is what allows them to mint API tokens for the bot via // owner, which is what allows them to mint API tokens for the bot via
// PUT /tokens with owner_id. // POST /tokens with owner_id.
// //
// On Vikunja versions that predate the /user/bots endpoint, the server // On Vikunja versions that predate the /user/bots endpoint, the server
// returns 404, which we surface as BOT_USERS_UNAVAILABLE so init can fail // returns 404, which we surface as BOT_USERS_UNAVAILABLE so init can fail
// fast with a clear message. // fast with a clear message.
func (c *Client) CreateBotUser(ctx context.Context, username, name string) (*BotUser, error) { func (c *Client) CreateBotUser(ctx context.Context, username, name string) (*BotUser, error) {
var out BotUser var out BotUser
err := c.Do(ctx, "PUT", "/user/bots", nil, &BotUserCreate{Username: username, Name: name}, &out) err := c.Do(ctx, "POST", "/user/bots", nil, &BotUserCreate{Username: username, Name: name}, &out)
if err != nil { if err != nil {
var oe *output.Error var oe *output.Error
if errors.As(err, &oe) && oe.Code == output.CodeNotFound { if errors.As(err, &oe) && oe.Code == output.CodeNotFound {
@ -45,13 +45,11 @@ func (c *Client) CreateBotUser(ctx context.Context, username, name string) (*Bot
return &out, nil return &out, nil
} }
// ListBotUsers returns all bot users owned by the authenticated user. // ListBotUsers returns all bot users owned by the authenticated user. The v2
// endpoint is server-paginated (BotUser.ReadAll applies a 50-item page limit),
// so page through to the end instead of returning only the first page.
func (c *Client) ListBotUsers(ctx context.Context) ([]*BotUser, error) { func (c *Client) ListBotUsers(ctx context.Context) ([]*BotUser, error) {
var out []*BotUser return doListAll[*BotUser](ctx, c, "/user/bots")
if err := c.Do(ctx, "GET", "/user/bots", nil, nil, &out); err != nil {
return nil, err
}
return out, nil
} }
// FindMyBotByUsername scans the caller's owned bots for one with the given // FindMyBotByUsername scans the caller's owned bots for one with the given

View File

@ -37,14 +37,14 @@ func newAPICmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "api <METHOD> <PATH>", Use: "api <METHOD> <PATH>",
Short: "Raw REST passthrough — escape hatch for endpoints veans doesn't wrap", Short: "Raw REST passthrough — escape hatch for endpoints veans doesn't wrap",
Long: `Sends a request to /api/v1<PATH> as the bot. Use this when curated Long: `Sends a request to /api/v2<PATH> as the bot. Use this when curated
commands don't shape the data the way you need. The response body is commands don't shape the data the way you need. The response body is
written to stdout verbatim. written to stdout verbatim.
Examples: Examples:
veans api GET /projects veans api GET /projects
veans api GET /tasks/123 veans api GET /tasks/123
veans api POST /tasks/123 --data '{"description":"updated"}' veans api POST /tasks/123/comments --data '{"comment":"<p>note</p>"}'
veans api GET /tasks --query expand=reactions --query per_page=100`, veans api GET /tasks --query expand=reactions --query per_page=100`,
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {

View File

@ -99,7 +99,7 @@ you want to rotate.`,
return err return err
} }
if minted.Token == "" { if minted.Token == "" {
return output.New(output.CodeUnknown, "PUT /tokens did not return token plaintext") return output.New(output.CodeUnknown, "POST /tokens did not return token plaintext")
} }
if err := credentials.Default().Set(cfg.Server, cfg.Bot.Username, minted.Token); err != nil { if err := credentials.Default().Set(cfg.Server, cfg.Bot.Username, minted.Token); err != nil {

View File

@ -88,7 +88,7 @@ func newUpdateCmd() *cobra.Command {
} }
// runUpdate is intentionally a single linear flow — the steps it performs // runUpdate is intentionally a single linear flow — the steps it performs
// (concurrency check → status → field changes → comments → field POST // (concurrency check → status → field changes → comments → field PATCH
// bucket move → label add/remove → refetch) all share the same task, // bucket move → label add/remove → refetch) all share the same task,
// flag set, and error-handling shape. Splitting them produces five tiny // flag set, and error-handling shape. Splitting them produces five tiny
// functions that each take the same five arguments. // functions that each take the same five arguments.
@ -126,17 +126,18 @@ func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*cli
} }
} }
// Build the update payload incrementally so we don't clobber unmentioned // Build the merge-patch payload from only the changed fields. PATCH leaves
// fields. The base must include the ID; bucket/done are conditional. // absent fields untouched, so omitting a field preserves it — the id rides
body := &client.Task{ID: id} // in the URL, not the body.
body := &client.TaskPatch{}
dirty := false dirty := false
if f.title != "" { if f.title != "" {
body.Title = f.title body.Title = &f.title
dirty = true dirty = true
} }
if f.priorityIsSet { if f.priorityIsSet {
body.Priority = f.priority body.Priority = &f.priority
dirty = true dirty = true
} }
@ -147,7 +148,7 @@ func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*cli
return nil, err return nil, err
} }
if descChanged { if descChanged {
body.Description = newDesc body.Description = &newDesc
dirty = true dirty = true
} }
@ -162,7 +163,8 @@ func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*cli
return nil, err return nil, err
} }
bucketTransitionTarget = bid bucketTransitionTarget = bid
body.Done = newStatus.Done() done := newStatus.Done()
body.Done = &done
dirty = true dirty = true
} }

View File

@ -155,28 +155,29 @@ func startRecordingServer(t *testing.T) (*httptest.Server, *[]recordedCall) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/tasks/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/42":
// Initial fetch + the final refetch both land here. Return a // Initial fetch + the final refetch both land here. Return a
// fixed task with an empty label set — labels.go's // fixed task with an empty label set — labels.go's
// findLabelOnTask only iterates t.Labels. // findLabelOnTask only iterates t.Labels.
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"id": 42, "title": "t", "updated": "2026-01-01T00:00:00Z", "id": 42, "title": "t", "updated": "2026-01-01T00:00:00Z",
}) })
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/tasks/42/comments": case r.Method == http.MethodPost && r.URL.Path == "/api/v2/tasks/42/comments":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "comment": ""}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "comment": ""})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/tasks/42": case r.Method == http.MethodPatch && r.URL.Path == "/api/v2/tasks/42":
// UpdateTask. Echo back the id so the encoder downstream is // UpdateTask (merge-patch). Echo back the id so the encoder
// happy with a non-nil Task. // downstream is happy with a non-nil Task.
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 42})
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/projects/") && strings.HasSuffix(r.URL.Path, "/tasks"): case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/projects/") && strings.HasSuffix(r.URL.Path, "/tasks"):
// Bucket-task move (PUT .../buckets/{b}/tasks).
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 42})
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/labels": case r.Method == http.MethodGet && r.URL.Path == "/api/v2/labels":
// getOrCreateLabelByTitle's lookup. Empty array → falls through // getOrCreateLabelByTitle's lookup. Empty envelope → falls through
// to label creation. // to label creation.
_ = json.NewEncoder(w).Encode([]any{}) _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}, "total_pages": 1})
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/labels": case r.Method == http.MethodPost && r.URL.Path == "/api/v2/labels":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 99, "title": "veans:bug"}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 99, "title": "veans:bug"})
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/tasks/42/labels": case r.Method == http.MethodPost && r.URL.Path == "/api/v2/tasks/42/labels":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 99}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 99})
default: default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
@ -222,11 +223,11 @@ func TestRunUpdate_ScrappedOrdersCommentUpdateMove(t *testing.T) {
} }
want := []recordedCall{ want := []recordedCall{
{http.MethodGet, "/api/v1/tasks/42"}, // current task fetch {http.MethodGet, "/api/v2/tasks/42"}, // current task fetch
{http.MethodPut, "/api/v1/tasks/42/comments"}, // "Scrapped: obsolete" {http.MethodPost, "/api/v2/tasks/42/comments"}, // "Scrapped: obsolete"
{http.MethodPost, "/api/v1/tasks/42"}, // field update (done=true) {http.MethodPatch, "/api/v2/tasks/42"}, // field update (done=true)
{http.MethodPost, "/api/v1/projects/7/views/1/buckets/14/tasks"}, // bucket move to Scrapped {http.MethodPut, "/api/v2/projects/7/views/1/buckets/14/tasks"}, // bucket move to Scrapped
{http.MethodGet, "/api/v1/tasks/42"}, // refetch with new bucket {http.MethodGet, "/api/v2/tasks/42"}, // refetch with new bucket
} }
if !reflect.DeepEqual(*calls, want) { if !reflect.DeepEqual(*calls, want) {
t.Fatalf("call order mismatch:\nwant: %#v\ngot: %#v", want, *calls) t.Fatalf("call order mismatch:\nwant: %#v\ngot: %#v", want, *calls)
@ -253,13 +254,13 @@ func TestRunUpdate_BucketMoveBeforeLabelAdd(t *testing.T) {
} }
want := []recordedCall{ want := []recordedCall{
{http.MethodGet, "/api/v1/tasks/42"}, // current task fetch {http.MethodGet, "/api/v2/tasks/42"}, // current task fetch
{http.MethodPost, "/api/v1/tasks/42"}, // field update (done=false) {http.MethodPatch, "/api/v2/tasks/42"}, // field update (done=false)
{http.MethodPost, "/api/v1/projects/7/views/1/buckets/11/tasks"}, // bucket move to In Progress {http.MethodPut, "/api/v2/projects/7/views/1/buckets/11/tasks"}, // bucket move to In Progress
{http.MethodGet, "/api/v1/labels"}, // getOrCreateLabelByTitle lookup {http.MethodGet, "/api/v2/labels"}, // getOrCreateLabelByTitle lookup
{http.MethodPut, "/api/v1/labels"}, // create veans:bug {http.MethodPost, "/api/v2/labels"}, // create veans:bug
{http.MethodPut, "/api/v1/tasks/42/labels"}, // attach label {http.MethodPost, "/api/v2/tasks/42/labels"}, // attach label
{http.MethodGet, "/api/v1/tasks/42"}, // refetch {http.MethodGet, "/api/v2/tasks/42"}, // refetch
} }
if !reflect.DeepEqual(*calls, want) { if !reflect.DeepEqual(*calls, want) {
t.Fatalf("call order mismatch:\nwant: %#v\ngot: %#v", want, *calls) t.Fatalf("call order mismatch:\nwant: %#v\ngot: %#v", want, *calls)