Released v1

This commit is contained in:
Steve Cliff
2026-02-12 10:37:19 +00:00
commit b07572fed5
77 changed files with 19518 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
package client
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) ListBoards(ctx context.Context) ([]model.Board, error) {
data, err := c.DoNoBody(ctx, "GET", "/api/projects")
if err != nil {
return nil, err
}
var response struct {
Included struct {
Boards []model.Board `json:"boards"`
} `json:"included"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal projects response: %w", err)
}
return response.Included.Boards, nil
}
func (c *Client) GetBoard(ctx context.Context, id string) (*model.Board, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/boards/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.Board `json:"item"`
Included struct {
Lists []model.List `json:"lists"`
Cards []model.Card `json:"cards"`
} `json:"included"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal board response: %w", err)
}
response.Item.Lists = response.Included.Lists
response.Item.Cards = response.Included.Cards
return &response.Item, nil
}
func (c *Client) ListBoardActions(ctx context.Context, boardId string, limit int) ([]model.Action, error) {
var all []model.Action
var beforeId string
for {
path := fmt.Sprintf("/api/boards/%s/actions", boardId)
if beforeId != "" {
path = fmt.Sprintf("%s?beforeId=%s", path, beforeId)
}
c.Logger.Debug("Fetching board actions page",
slog.String("boardId", boardId),
slog.String("beforeId", beforeId),
)
data, err := c.DoNoBody(ctx, "GET", path)
if err != nil {
return nil, err
}
var response struct {
Items []model.Action `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal board actions response: %w", err)
}
if len(response.Items) == 0 {
break
}
all = append(all, response.Items...)
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
beforeId = response.Items[len(response.Items)-1].ID
}
return all, nil
}
+250
View File
@@ -0,0 +1,250 @@
package client
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) GetCard(ctx context.Context, id string) (*model.Card, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/cards/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
}
func (c *Client) CreateCard(ctx context.Context, listId string, fields map[string]any) (*model.Card, error) {
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/lists/%s/cards", listId), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateCard(ctx context.Context, id string, fields map[string]any) (*model.Card, error) {
data, err := c.Do(ctx, "PATCH", fmt.Sprintf("/api/cards/%s", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteCard(ctx context.Context, id string) error {
_, err := c.DoNoBody(ctx, "DELETE", fmt.Sprintf("/api/cards/%s", id))
return err
}
func (c *Client) DuplicateCard(ctx context.Context, id string, name *string, position *float64) (*model.Card, error) {
fields := make(map[string]any)
if name != nil {
fields["name"] = *name
}
if position != nil {
fields["position"] = *position
}
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/cards/%s/duplicate", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
}
func (c *Client) ListCards(ctx context.Context, listId string, limit int) ([]model.Card, error) {
var all []model.Card
var before string
for {
path := fmt.Sprintf("/api/lists/%s/cards", listId)
if before != "" {
path = fmt.Sprintf("%s?before=%s", path, before)
}
c.Logger.Debug("Fetching cards page",
slog.String("listId", listId),
slog.String("before", before),
)
data, err := c.DoNoBody(ctx, "GET", path)
if err != nil {
return nil, err
}
var response struct {
Items []model.Card `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal cards response: %w", err)
}
if len(response.Items) == 0 {
break
}
all = append(all, response.Items...)
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
before = response.Items[len(response.Items)-1].ID
}
return all, nil
}
func (c *Client) ListCardActions(ctx context.Context, cardId string, limit int) ([]model.Action, error) {
var all []model.Action
var beforeId string
for {
path := fmt.Sprintf("/api/cards/%s/actions", cardId)
if beforeId != "" {
path = fmt.Sprintf("%s?beforeId=%s", path, beforeId)
}
c.Logger.Debug("Fetching card actions page",
slog.String("cardId", cardId),
slog.String("beforeId", beforeId),
)
data, err := c.DoNoBody(ctx, "GET", path)
if err != nil {
return nil, err
}
var response struct {
Items []model.Action `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card actions response: %w", err)
}
if len(response.Items) == 0 {
break
}
all = append(all, response.Items...)
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
beforeId = response.Items[len(response.Items)-1].ID
}
return all, nil
}
func (c *Client) ListCardsByBoard(ctx context.Context, boardId string, limit int) ([]model.CardWithList, error) {
board, err := c.GetBoard(ctx, boardId)
if err != nil {
return nil, fmt.Errorf("failed to get board: %w", err)
}
listNames := make(map[string]string)
for _, list := range board.Lists {
name := ""
if list.Name != nil {
name = *list.Name
}
listNames[list.ID] = name
}
var allCards []model.CardWithList
for _, card := range board.Cards {
cardWithList := model.CardWithList{
Card: card,
ListName: listNames[card.ListID],
}
allCards = append(allCards, cardWithList)
if limit > 0 && len(allCards) >= limit {
return allCards[:limit], nil
}
}
return allCards, nil
}
func (c *Client) AddCardLabel(ctx context.Context, cardId, labelId string) error {
fields := map[string]any{
"labelId": labelId,
}
_, err := c.DoWithFallback(ctx, "POST",
fmt.Sprintf("/api/cards/%s/card-labels", cardId),
fmt.Sprintf("/api/cards/%s/labels", cardId),
fields)
return err
}
func (c *Client) RemoveCardLabel(ctx context.Context, cardId, labelId string) error {
_, err := c.DoNoBodyWithFallback(ctx, "DELETE",
fmt.Sprintf("/api/cards/%s/card-labels/labelId:%s", cardId, labelId),
fmt.Sprintf("/api/cards/%s/labels/%s", cardId, labelId))
return err
}
func (c *Client) AddCardMember(ctx context.Context, cardId, userId string) error {
fields := map[string]any{
"userId": userId,
}
_, err := c.DoWithFallback(ctx, "POST",
fmt.Sprintf("/api/cards/%s/card-memberships", cardId),
fmt.Sprintf("/api/cards/%s/memberships", cardId),
fields)
return err
}
func (c *Client) RemoveCardMember(ctx context.Context, cardId, userId string) error {
_, err := c.DoNoBodyWithFallback(ctx, "DELETE",
fmt.Sprintf("/api/cards/%s/card-memberships/userId:%s", cardId, userId),
fmt.Sprintf("/api/cards/%s/memberships/%s", cardId, userId))
return err
}
+164
View File
@@ -0,0 +1,164 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.franklin.lab/steve.cliff/pcli/model"
)
type Client struct {
BaseURL string
APIKey string
HTTPClient *http.Client
Logger *slog.Logger
}
func NewClient(baseURL, apiKey string, logger *slog.Logger) *Client {
transport := &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 30 * time.Second,
}
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
Logger: logger,
}
}
func (c *Client) Do(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
start := time.Now()
url := c.BaseURL + path
var reqBody io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewReader(jsonData)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("x-api-key", c.APIKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.Logger.Debug("HTTP request",
slog.String("method", method),
slog.String("path", path),
)
resp, err := c.HTTPClient.Do(req)
if err != nil {
c.Logger.Warn("HTTP request failed",
slog.String("method", method),
slog.String("path", path),
slog.String("error", err.Error()),
)
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
duration := time.Since(start)
c.Logger.Debug("HTTP response",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.Duration("duration", duration),
)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if len(respBody) > 0 && !isJSONContentType(contentType) && respBody[0] == '<' {
c.Logger.Warn("Non-JSON response received",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.String("content_type", contentType),
)
return nil, &model.APIError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("unexpected non-JSON response (content-type: %s) — endpoint may not exist", contentType),
}
}
if resp.StatusCode >= 400 {
apiErr := &model.APIError{
StatusCode: resp.StatusCode,
Message: string(respBody),
}
var errResp map[string]any
if json.Unmarshal(respBody, &errResp) == nil {
if msg, ok := errResp["message"].(string); ok {
apiErr.Message = msg
} else if errMsg, ok := errResp["error"].(string); ok {
apiErr.Message = errMsg
}
}
c.Logger.Warn("API error",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.String("message", apiErr.Message),
)
return nil, apiErr
}
return json.RawMessage(respBody), nil
}
func (c *Client) DoNoBody(ctx context.Context, method, path string) (json.RawMessage, error) {
return c.Do(ctx, method, path, nil)
}
func (c *Client) DoWithFallback(ctx context.Context, method, primary, fallback string, body any) (json.RawMessage, error) {
data, err := c.Do(ctx, method, primary, body)
if err != nil {
if apiErr, ok := err.(*model.APIError); ok && apiErr.StatusCode == 404 {
c.Logger.Debug("Primary path failed, trying fallback",
slog.String("primary", primary),
slog.String("fallback", fallback),
)
return c.Do(ctx, method, fallback, body)
}
return nil, err
}
return data, nil
}
func (c *Client) DoNoBodyWithFallback(ctx context.Context, method, primary, fallback string) (json.RawMessage, error) {
return c.DoWithFallback(ctx, method, primary, fallback, nil)
}
func isJSONContentType(contentType string) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
return strings.HasPrefix(ct, "application/json") ||
strings.HasPrefix(ct, "text/json")
}
+114
View File
@@ -0,0 +1,114 @@
package client
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) ListComments(ctx context.Context, cardId string, limit int) ([]model.Comment, error) {
var all []model.Comment
var beforeId string
for {
path := fmt.Sprintf("/api/cards/%s/comments", cardId)
if beforeId != "" {
path = fmt.Sprintf("%s?beforeId=%s", path, beforeId)
}
fallback := fmt.Sprintf("/api/cards/%s/comment-actions", cardId)
if beforeId != "" {
fallback = fmt.Sprintf("%s?beforeId=%s", fallback, beforeId)
}
c.Logger.Debug("Fetching comments page",
slog.String("cardId", cardId),
slog.String("beforeId", beforeId),
)
data, err := c.DoNoBodyWithFallback(ctx, "GET", path, fallback)
if err != nil {
return nil, err
}
var response struct {
Items []model.Comment `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal comments response: %w", err)
}
if len(response.Items) == 0 {
break
}
all = append(all, response.Items...)
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
beforeId = response.Items[len(response.Items)-1].ID
}
return all, nil
}
func (c *Client) CreateComment(ctx context.Context, cardId, text string) (*model.Comment, error) {
fields := map[string]any{
"text": text,
}
data, err := c.DoWithFallback(ctx, "POST",
fmt.Sprintf("/api/cards/%s/comments", cardId),
fmt.Sprintf("/api/cards/%s/comment-actions", cardId),
fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Comment `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal comment response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateComment(ctx context.Context, id, text string) (*model.Comment, error) {
fields := map[string]any{
"text": text,
}
data, err := c.DoWithFallback(ctx, "PATCH",
fmt.Sprintf("/api/comments/%s", id),
fmt.Sprintf("/api/comment-actions/%s", id),
fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Comment `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal comment response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteComment(ctx context.Context, id string) error {
_, err := c.DoNoBodyWithFallback(ctx, "DELETE",
fmt.Sprintf("/api/comments/%s", id),
fmt.Sprintf("/api/comment-actions/%s", id))
return err
}
+48
View File
@@ -0,0 +1,48 @@
package client
import (
"context"
"encoding/json"
"fmt"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) CreateLabel(ctx context.Context, boardId string, fields map[string]any) (*model.Label, error) {
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/boards/%s/labels", boardId), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Label `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal label response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateLabel(ctx context.Context, id string, fields map[string]any) (*model.Label, error) {
data, err := c.Do(ctx, "PATCH", fmt.Sprintf("/api/labels/%s", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Label `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal label response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteLabel(ctx context.Context, id string) error {
_, err := c.DoNoBody(ctx, "DELETE", fmt.Sprintf("/api/labels/%s", id))
return err
}
+43
View File
@@ -0,0 +1,43 @@
package client
import (
"context"
"encoding/json"
"fmt"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) ListProjects(ctx context.Context) ([]model.Project, error) {
data, err := c.DoNoBody(ctx, "GET", "/api/projects")
if err != nil {
return nil, err
}
var response struct {
Items []model.Project `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal projects response: %w", err)
}
return response.Items, nil
}
func (c *Client) GetProject(ctx context.Context, id string) (*model.Project, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/projects/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.Project `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal project response: %w", err)
}
return &response.Item, nil
}
+65
View File
@@ -0,0 +1,65 @@
package client
import (
"context"
"encoding/json"
"fmt"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) CreateTaskList(ctx context.Context, cardId string, fields map[string]any) (*model.TaskList, error) {
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/cards/%s/task-lists", cardId), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.TaskList `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task list response: %w", err)
}
return &response.Item, nil
}
func (c *Client) GetTaskList(ctx context.Context, id string) (*model.TaskList, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/task-lists/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.TaskList `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task list response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateTaskList(ctx context.Context, id string, fields map[string]any) (*model.TaskList, error) {
data, err := c.Do(ctx, "PATCH", fmt.Sprintf("/api/task-lists/%s", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.TaskList `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task list response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteTaskList(ctx context.Context, id string) error {
_, err := c.DoNoBody(ctx, "DELETE", fmt.Sprintf("/api/task-lists/%s", id))
return err
}
+48
View File
@@ -0,0 +1,48 @@
package client
import (
"context"
"encoding/json"
"fmt"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) CreateTask(ctx context.Context, taskListId string, fields map[string]any) (*model.Task, error) {
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/task-lists/%s/tasks", taskListId), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Task `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateTask(ctx context.Context, id string, fields map[string]any) (*model.Task, error) {
data, err := c.Do(ctx, "PATCH", fmt.Sprintf("/api/tasks/%s", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Task `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteTask(ctx context.Context, id string) error {
_, err := c.DoNoBody(ctx, "DELETE", fmt.Sprintf("/api/tasks/%s", id))
return err
}