From 87c312fb2bde33c198dbb32b524db6056d2ba3cc Mon Sep 17 00:00:00 2001 From: Tink bot Date: Tue, 26 May 2026 22:38:27 +0200 Subject: [PATCH] feat(veans): add JSON HTTP client and wire types --- veans/internal/client/assignees.go | 16 +++ veans/internal/client/auth.go | 23 ++++ veans/internal/client/buckets.go | 30 +++++ veans/internal/client/client.go | 166 +++++++++++++++++++++++++ veans/internal/client/comments.go | 24 ++++ veans/internal/client/info.go | 12 ++ veans/internal/client/labels.go | 49 ++++++++ veans/internal/client/projects.go | 53 ++++++++ veans/internal/client/relations.go | 17 +++ veans/internal/client/tasks.go | 97 +++++++++++++++ veans/internal/client/tokens.go | 53 ++++++++ veans/internal/client/types.go | 186 +++++++++++++++++++++++++++++ veans/internal/client/users.go | 45 +++++++ 13 files changed, 771 insertions(+) create mode 100644 veans/internal/client/assignees.go create mode 100644 veans/internal/client/auth.go create mode 100644 veans/internal/client/buckets.go create mode 100644 veans/internal/client/client.go create mode 100644 veans/internal/client/comments.go create mode 100644 veans/internal/client/info.go create mode 100644 veans/internal/client/labels.go create mode 100644 veans/internal/client/projects.go create mode 100644 veans/internal/client/relations.go create mode 100644 veans/internal/client/tasks.go create mode 100644 veans/internal/client/tokens.go create mode 100644 veans/internal/client/types.go create mode 100644 veans/internal/client/users.go diff --git a/veans/internal/client/assignees.go b/veans/internal/client/assignees.go new file mode 100644 index 000000000..3ef1ab13e --- /dev/null +++ b/veans/internal/client/assignees.go @@ -0,0 +1,16 @@ +package client + +import ( + "context" + "fmt" +) + +// AddAssignee assigns a user (typically the bot) to a task. +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) +} + +// RemoveAssignee unassigns. +func (c *Client) RemoveAssignee(ctx context.Context, taskID, userID int64) error { + return c.Do(ctx, "DELETE", fmt.Sprintf("/tasks/%d/assignees/%d", taskID, userID), nil, nil, nil) +} diff --git a/veans/internal/client/auth.go b/veans/internal/client/auth.go new file mode 100644 index 000000000..7cea36559 --- /dev/null +++ b/veans/internal/client/auth.go @@ -0,0 +1,23 @@ +package client + +import "context" + +// Login posts to /login and returns the JWT bundle. The returned token is a +// JWT good for the user's normal API calls; we use it transiently during init. +func (c *Client) Login(ctx context.Context, req *LoginRequest) (*LoginResponse, error) { + var out LoginResponse + if err := c.Do(ctx, "POST", "/login", nil, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CurrentUser fetches /user — handy for resolving the bot's own user_id from +// its API token without poking the human's data. +func (c *Client) CurrentUser(ctx context.Context) (*User, error) { + var out User + if err := c.Do(ctx, "GET", "/user", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/veans/internal/client/buckets.go b/veans/internal/client/buckets.go new file mode 100644 index 000000000..db441cd94 --- /dev/null +++ b/veans/internal/client/buckets.go @@ -0,0 +1,30 @@ +package client + +import ( + "context" + "fmt" +) + +// ListBuckets returns the buckets configured on a Kanban view. +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) + if err := c.Do(ctx, "GET", path, nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// CreateBucket inserts a new bucket into a Kanban view. +func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *Bucket) (*Bucket, error) { + var out Bucket + path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID) + if b == nil { + b = &Bucket{} + } + b.ProjectViewID = viewID + if err := c.Do(ctx, "PUT", path, nil, b, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/veans/internal/client/client.go b/veans/internal/client/client.go new file mode 100644 index 000000000..d23ea5a85 --- /dev/null +++ b/veans/internal/client/client.go @@ -0,0 +1,166 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "code.vikunja.io/veans/internal/output" +) + +// 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 +// API token minted via PUT /tokens). Every method in this package is a thin +// shim over Do. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client + UserAgent string +} + +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + UserAgent: "veans/0.1", + } +} + +// vikunjaError matches `web.HTTPError` on the wire. +type vikunjaError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Do performs a single JSON request against /api/v1. body, if non-nil, +// is JSON-marshalled. out, if non-nil, is JSON-unmarshalled. query is appended +// as URL-encoded params. +func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error { + full := c.BaseURL + "/api/v1" + path + if len(query) > 0 { + full += "?" + query.Encode() + } + + var bodyReader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + bodyReader = bytes.NewReader(buf) + } + + req, err := http.NewRequestWithContext(ctx, method, full, bodyReader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return output.Wrap(output.CodeUnknown, err, "%s %s: %v", method, path, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return mapHTTPError(method, path, resp.StatusCode, respBody) + } + + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode %s %s: %w", method, path, err) + } + } + return nil +} + +// DoRaw is the escape hatch used by `veans api`. It returns the raw response +// body and status. Auth + base URL handling matches Do. +func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Values, body []byte) (status int, respBody []byte, err error) { + full := c.BaseURL + "/api/v1" + path + if len(query) > 0 { + full += "?" + query.Encode() + } + var br io.Reader + if len(body) > 0 { + br = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, full, br) + if err != nil { + return 0, nil, err + } + req.Header.Set("Accept", "application/json") + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + respBody, err = io.ReadAll(resp.Body) + return resp.StatusCode, respBody, err +} + +func mapHTTPError(method, path string, status int, body []byte) error { + var ve vikunjaError + _ = json.Unmarshal(body, &ve) + msg := strings.TrimSpace(ve.Message) + if msg == "" { + msg = strings.TrimSpace(string(body)) + if msg == "" { + msg = http.StatusText(status) + } + } + + var code output.Code + switch { + case status == http.StatusUnauthorized || status == http.StatusForbidden: + code = output.CodeAuth + case status == http.StatusNotFound: + code = output.CodeNotFound + case status == http.StatusConflict: + code = output.CodeConflict + case status == http.StatusTooManyRequests: + code = output.CodeRateLimited + case status >= 400 && status < 500: + code = output.CodeValidation + default: + code = output.CodeUnknown + } + + return &output.Error{ + Code: code, + Message: fmt.Sprintf("%s %s: %d %s", method, path, status, msg), + Cause: errors.New(msg), + } +} diff --git a/veans/internal/client/comments.go b/veans/internal/client/comments.go new file mode 100644 index 000000000..ad003914e --- /dev/null +++ b/veans/internal/client/comments.go @@ -0,0 +1,24 @@ +package client + +import ( + "context" + "fmt" +) + +// AddTaskComment posts a new comment on a task. +func (c *Client) AddTaskComment(ctx context.Context, taskID int64, body string) (*TaskComment, error) { + var out TaskComment + if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/comments", taskID), nil, &TaskComment{Comment: body}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListTaskComments returns all comments on a task. +func (c *Client) ListTaskComments(ctx context.Context, taskID int64) ([]*TaskComment, error) { + var out []*TaskComment + if err := c.Do(ctx, "GET", fmt.Sprintf("/tasks/%d/comments", taskID), nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/veans/internal/client/info.go b/veans/internal/client/info.go new file mode 100644 index 000000000..0432f86ea --- /dev/null +++ b/veans/internal/client/info.go @@ -0,0 +1,12 @@ +package client + +import "context" + +// Info fetches GET /info. No auth required. +func (c *Client) Info(ctx context.Context) (*Info, error) { + var out Info + if err := c.Do(ctx, "GET", "/info", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/veans/internal/client/labels.go b/veans/internal/client/labels.go new file mode 100644 index 000000000..d4b66c288 --- /dev/null +++ b/veans/internal/client/labels.go @@ -0,0 +1,49 @@ +package client + +import ( + "context" + "fmt" + "net/url" + "strconv" +) + +// ListLabels paginates GET /labels and returns every label visible to the +// authenticated user (labels are global per user, not scoped to a project). +func (c *Client) ListLabels(ctx context.Context, search string) ([]*Label, error) { + var all []*Label + for page := 1; ; page++ { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("per_page", "50") + if search != "" { + q.Set("s", search) + } + var batch []*Label + if err := c.Do(ctx, "GET", "/labels", q, nil, &batch); err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < 50 { + return all, nil + } + } +} + +// CreateLabel creates a new label owned by the authenticated user. +func (c *Client) CreateLabel(ctx context.Context, l *Label) (*Label, error) { + var out Label + if err := c.Do(ctx, "PUT", "/labels", nil, l, &out); err != nil { + return nil, err + } + return &out, nil +} + +// AddLabelToTask attaches an existing label to a task. +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) +} + +// RemoveLabelFromTask detaches a label. +func (c *Client) RemoveLabelFromTask(ctx context.Context, taskID, labelID int64) error { + return c.Do(ctx, "DELETE", fmt.Sprintf("/tasks/%d/labels/%d", taskID, labelID), nil, nil, nil) +} diff --git a/veans/internal/client/projects.go b/veans/internal/client/projects.go new file mode 100644 index 000000000..7634a6926 --- /dev/null +++ b/veans/internal/client/projects.go @@ -0,0 +1,53 @@ +package client + +import ( + "context" + "fmt" + "net/url" + "strconv" +) + +// ListProjects pages through GET /projects, accumulating until exhausted. +func (c *Client) ListProjects(ctx context.Context) ([]*Project, error) { + var all []*Project + for page := 1; ; page++ { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("per_page", "50") + var batch []*Project + if err := c.Do(ctx, "GET", "/projects", q, nil, &batch); err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < 50 { + return all, nil + } + } +} + +// GetProject fetches a single project by ID. +func (c *Client) GetProject(ctx context.Context, id int64) (*Project, error) { + var out Project + if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d", id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ShareProjectWithUser grants `username` `permission` on project `id`. +func (c *Client) ShareProjectWithUser(ctx context.Context, projectID int64, share *ProjectUser) (*ProjectUser, error) { + var out ProjectUser + if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/users", projectID), nil, share, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListProjectViews returns saved views (Kanban, List, …) on a project. +func (c *Client) ListProjectViews(ctx context.Context, projectID int64) ([]*ProjectView, error) { + var out []*ProjectView + if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d/views", projectID), nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/veans/internal/client/relations.go b/veans/internal/client/relations.go new file mode 100644 index 000000000..fba448629 --- /dev/null +++ b/veans/internal/client/relations.go @@ -0,0 +1,17 @@ +package client + +import ( + "context" + "fmt" +) + +// CreateRelation links two tasks. relationKind is "subtask", "parenttask", +// "blocking", "blocked", "related", etc. +func (c *Client) CreateRelation(ctx context.Context, taskID int64, otherTaskID int64, relationKind string) (*TaskRelation, error) { + var out TaskRelation + body := &TaskRelation{OtherTaskID: otherTaskID, RelationKind: relationKind} + if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/relations", taskID), nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/veans/internal/client/tasks.go b/veans/internal/client/tasks.go new file mode 100644 index 000000000..5363445d3 --- /dev/null +++ b/veans/internal/client/tasks.go @@ -0,0 +1,97 @@ +package client + +import ( + "context" + "fmt" + "net/url" + "strconv" +) + +// TaskListOptions selects which tasks to return from ListProjectTasks. +type TaskListOptions struct { + Filter string + Page int + PerPage int + SortBy []string + OrderBy []string + Expand []string +} + +func (o *TaskListOptions) values() url.Values { + q := url.Values{} + if o == nil { + return q + } + if o.Filter != "" { + q.Set("filter", o.Filter) + } + if o.Page > 0 { + q.Set("page", strconv.Itoa(o.Page)) + } + if o.PerPage > 0 { + q.Set("per_page", strconv.Itoa(o.PerPage)) + } + for _, s := range o.SortBy { + q.Add("sort_by", s) + } + for _, s := range o.OrderBy { + q.Add("order_by", s) + } + for _, e := range o.Expand { + q.Add("expand", e) + } + return q +} + +// ListProjectTasks paginates `GET /projects/{id}/tasks` exhaustively. +func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *TaskListOptions) ([]*Task, error) { + if opts == nil { + opts = &TaskListOptions{} + } + per := opts.PerPage + if per <= 0 { + per = 50 + } + var all []*Task + for page := 1; ; page++ { + o := *opts + o.Page = page + o.PerPage = per + var batch []*Task + if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d/tasks", projectID), o.values(), nil, &batch); err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < per { + return all, nil + } + } +} + +// GetTask fetches a single task by numeric ID. +func (c *Client) GetTask(ctx context.Context, id int64) (*Task, error) { + var out Task + if err := c.Do(ctx, "GET", fmt.Sprintf("/tasks/%d", id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateTask inserts a task into a project (PUT /projects/{id}/tasks). +func (c *Client) CreateTask(ctx context.Context, projectID int64, t *Task) (*Task, error) { + var out Task + if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/tasks", projectID), nil, t, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateTask updates a task (POST /tasks/{id}). bucket_id moves the task +// between buckets in the same view. +func (c *Client) UpdateTask(ctx context.Context, id int64, t *Task) (*Task, error) { + var out Task + if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d", id), nil, t, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/veans/internal/client/tokens.go b/veans/internal/client/tokens.go new file mode 100644 index 000000000..64c9d892e --- /dev/null +++ b/veans/internal/client/tokens.go @@ -0,0 +1,53 @@ +package client + +import ( + "context" + "fmt" +) + +// FullPermissions is the broadest set of API token scopes a veans bot needs: +// read+write on every resource it touches. Vikunja's permission map is +// `{resource: [actions]}` shaped; the keys here cover everything the CLI +// calls for normal operation. +// +// We over-grant intentionally — the bot needs to claim, comment, label, +// relate, and update tasks; revoking unused scopes after the fact is cheap. +func FullPermissions() map[string][]string { + return map[string][]string{ + "tasks": {"read_one", "read_all", "create", "update", "delete"}, + "projects": {"read_one", "read_all", "create", "update", "delete"}, + "labels": {"read_one", "read_all", "create", "update", "delete"}, + "task_comments": {"read_one", "read_all", "create", "update", "delete"}, + "task_assignees": {"create", "delete", "read_all"}, + "task_relations": {"create", "delete"}, + "task_attachments":{"create", "read_one", "delete"}, + "buckets": {"read_all", "create", "update", "delete"}, + "project_views": {"read_one", "read_all"}, + "users": {"read_all"}, + } +} + +// CreateToken mints an API token. If t.OwnerID is non-zero, the token is +// minted FOR that user — the caller must be the bot's owner (i.e. created +// the bot in step 8 of init). +func (c *Client) CreateToken(ctx context.Context, t *APIToken) (*APIToken, error) { + var out APIToken + if err := c.Do(ctx, "PUT", "/tokens", nil, t, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListTokens returns every API token the authenticated user can see. +func (c *Client) ListTokens(ctx context.Context) ([]*APIToken, error) { + var out []*APIToken + if err := c.Do(ctx, "GET", "/tokens", nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// DeleteToken revokes a token by ID. Used by `veans login` rotation. +func (c *Client) DeleteToken(ctx context.Context, id int64) error { + return c.Do(ctx, "DELETE", fmt.Sprintf("/tokens/%d", id), nil, nil, nil) +} diff --git a/veans/internal/client/types.go b/veans/internal/client/types.go new file mode 100644 index 000000000..e074e5361 --- /dev/null +++ b/veans/internal/client/types.go @@ -0,0 +1,186 @@ +// Package client is a hand-rolled JSON client for the Vikunja REST API. It +// mirrors the wire types as plain Go structs so we don't pull XORM into the +// CLI binary. +package client + +import "time" + +// User mirrors the public fields of pkg/user.User on the wire. +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +// BotUser is what `PUT /bots` returns. +type BotUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name,omitempty"` + Status int `json:"status,omitempty"` + Created time.Time `json:"created,omitempty"` +} + +// BotUserCreate is the request body for PUT /bots. +type BotUserCreate struct { + Username string `json:"username"` + Name string `json:"name,omitempty"` +} + +// Project mirrors pkg/models/project.Project. +type Project struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Identifier string `json:"identifier,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` +} + +// ProjectView is a saved view (Kanban/List/Gantt/Table) on a project. +type ProjectView struct { + ID int64 `json:"id"` + Title string `json:"title"` + ProjectID int64 `json:"project_id"` + ViewKind int `json:"view_kind"` + BucketConfMode int `json:"bucket_configuration_mode,omitempty"` +} + +const ( + ViewKindList = 0 + ViewKindGantt = 1 + ViewKindTable = 2 + ViewKindKanban = 3 +) + +// Bucket is a kanban bucket bound to a single project view. +type Bucket struct { + ID int64 `json:"id"` + Title string `json:"title"` + ProjectViewID int64 `json:"project_view_id"` + Limit int64 `json:"limit,omitempty"` + Position float64 `json:"position,omitempty"` +} + +// Task mirrors the on-the-wire task representation. Many fields are omitted — +// veans only consumes what its commands print or filter on. +type Task struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Done bool `json:"done"` + DoneAt *time.Time `json:"done_at,omitempty"` + Priority int64 `json:"priority,omitempty"` + ProjectID int64 `json:"project_id"` + Index int64 `json:"index,omitempty"` + Identifier string `json:"identifier,omitempty"` + Position float64 `json:"position,omitempty"` + Created time.Time `json:"created,omitempty"` + Updated time.Time `json:"updated,omitempty"` + BucketID int64 `json:"bucket_id,omitempty"` + Assignees []*User `json:"assignees,omitempty"` + Labels []*Label `json:"labels,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + DueDate *time.Time `json:"due_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` + PercentDone float64 `json:"percent_done,omitempty"` + Reactions interface{} `json:"reactions,omitempty"` +} + +// TaskComment matches pkg/models/task_comments.TaskComment. +type TaskComment struct { + ID int64 `json:"id"` + Comment string `json:"comment"` + Author *User `json:"author,omitempty"` + Created time.Time `json:"created,omitempty"` + Updated time.Time `json:"updated,omitempty"` +} + +// Label is a global (per-user) label. +type Label struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + HexColor string `json:"hex_color,omitempty"` + Created time.Time `json:"created,omitempty"` + Updated time.Time `json:"updated,omitempty"` +} + +// LabelTask is the body for `PUT /tasks/{id}/labels`. +type LabelTask struct { + LabelID int64 `json:"label_id"` +} + +// TaskRelation is the body for `PUT /tasks/{id}/relations` and the row +// returned. RelationKind is one of: subtask, parenttask, related, duplicates, +// duplicateof, blocking, blocked, precedes, follows, copiedfrom, copiedto. +type TaskRelation struct { + TaskID int64 `json:"task_id,omitempty"` + OtherTaskID int64 `json:"other_task_id"` + RelationKind string `json:"relation_kind"` +} + +// TaskAssignee is the body for `PUT /tasks/{id}/assignees`. +type TaskAssignee struct { + UserID int64 `json:"user_id"` +} + +// ProjectUser is the body and response for `PUT /projects/{id}/users`. +type ProjectUser struct { + ID int64 `json:"id,omitempty"` + Username string `json:"username"` + Permission int `json:"permission"` +} + +// Permission constants for project sharing. +const ( + PermissionRead = 0 + PermissionReadWrite = 1 + PermissionAdmin = 2 +) + +// APIToken is the request and response shape for `PUT /tokens`. The plaintext +// `Token` field is only populated on creation. +type APIToken struct { + ID int64 `json:"id,omitempty"` + Title string `json:"title"` + Token string `json:"token,omitempty"` + Permissions map[string][]string `json:"permissions"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + OwnerID int64 `json:"owner_id,omitempty"` + Created time.Time `json:"created,omitempty"` +} + +// Info is the parsed shape of `GET /info`. +type Info struct { + Version string `json:"version"` + FrontendURL string `json:"frontend_url"` + MOTD string `json:"motd,omitempty"` + LinkSharingEnabled bool `json:"link_sharing_enabled"` + RegistrationEnabled bool `json:"registration_enabled"` + Auth struct { + Local struct { + Enabled bool `json:"enabled"` + } `json:"local"` + OpenIDConnect struct { + Enabled bool `json:"enabled"` + Providers []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"providers"` + } `json:"openid_connect"` + } `json:"auth"` +} + +// LoginRequest is the body for `POST /login`. +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + TOTPPasscode string `json:"totp_passcode,omitempty"` + LongToken bool `json:"long_token,omitempty"` +} + +// LoginResponse is the JWT bundle. +type LoginResponse struct { + Token string `json:"token"` +} diff --git a/veans/internal/client/users.go b/veans/internal/client/users.go new file mode 100644 index 000000000..df83815b0 --- /dev/null +++ b/veans/internal/client/users.go @@ -0,0 +1,45 @@ +package client + +import ( + "context" + "errors" + "net/http" + + "code.vikunja.io/veans/internal/output" +) + +// CreateBotUser provisions a bot user via PUT /bots. The username must 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 +// PUT /tokens with owner_id. +// +// On Vikunja versions that predate the /bots endpoint, the server returns +// 404, which we surface as BOT_USERS_UNAVAILABLE so init can fail fast with +// a clear message. +func (c *Client) CreateBotUser(ctx context.Context, username, name string) (*BotUser, error) { + var out BotUser + err := c.Do(ctx, "PUT", "/bots", nil, &BotUserCreate{Username: username, Name: name}, &out) + if err != nil { + var oe *output.Error + if errors.As(err, &oe) && oe.Code == output.CodeNotFound { + return nil, output.Wrap(output.CodeBotUsersUnavailable, err, + "this Vikunja instance does not expose /bots — upgrade to a newer version") + } + return nil, err + } + return &out, nil +} + +// ListBotUsers returns all bot users owned by the authenticated user. +func (c *Client) ListBotUsers(ctx context.Context) ([]*BotUser, error) { + var out []*BotUser + if err := c.Do(ctx, "GET", "/bots", nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// statusCheck pulls the HTTP status off an error for callers that need to +// distinguish 404-on-/bots from other failures. Currently unused outside this +// file, but kept for symmetry. +var _ = http.StatusNotFound