mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-20 01:25:20 +00:00
dc83d7490c
Collections can now define a content_template in their settings — a markdown template that pre-fills new items. When creating a bug report, for example, the template can include "Steps to Reproduce", "Expected Behavior", "Actual Behavior" sections automatically. - Add content_template to CollectionSettings (Go model + TypeScript type) - Sidebar "New" button uses template when creating items - Dashboard quick-create buttons use template - Template is stored in collection settings JSON, configurable via the collection edit UI
407 lines
12 KiB
Go
407 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/xarmian/pad/internal/models"
|
|
)
|
|
|
|
// Client is a thin HTTP client for the Pad API.
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewClient(host string, port int) *Client {
|
|
return NewClientFromURL(fmt.Sprintf("http://%s:%d", host, port))
|
|
}
|
|
|
|
// NewClientFromURL creates a client from a full base URL (e.g., "https://api.getpad.dev").
|
|
func NewClientFromURL(baseURL string) *Client {
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
return &Client{
|
|
baseURL: baseURL + "/api/v1",
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Health checks if the server is running.
|
|
func (c *Client) Health() error {
|
|
resp, err := c.httpClient.Get(c.baseURL + "/health")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unhealthy: status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Workspaces ---
|
|
|
|
func (c *Client) ListWorkspaces() ([]models.Workspace, error) {
|
|
var result []models.Workspace
|
|
return result, c.get("/workspaces", &result)
|
|
}
|
|
|
|
func (c *Client) CreateWorkspace(input models.WorkspaceCreate) (*models.Workspace, error) {
|
|
var result models.Workspace
|
|
return &result, c.post("/workspaces", input, &result)
|
|
}
|
|
|
|
func (c *Client) GetWorkspace(slug string) (*models.Workspace, error) {
|
|
var result models.Workspace
|
|
return &result, c.get("/workspaces/"+slug, &result)
|
|
}
|
|
|
|
func (c *Client) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*models.Workspace, error) {
|
|
var result models.Workspace
|
|
return &result, c.patch("/workspaces/"+slug, input, &result)
|
|
}
|
|
|
|
// --- Collections ---
|
|
|
|
func (c *Client) ListCollections(wsSlug string) ([]models.Collection, error) {
|
|
var result []models.Collection
|
|
return result, c.get("/workspaces/"+wsSlug+"/collections", &result)
|
|
}
|
|
|
|
func (c *Client) CreateCollection(wsSlug string, input models.CollectionCreate) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.post("/workspaces/"+wsSlug+"/collections", input, &result)
|
|
}
|
|
|
|
func (c *Client) GetCollection(wsSlug, collSlug string) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.get("/workspaces/"+wsSlug+"/collections/"+collSlug, &result)
|
|
}
|
|
|
|
func (c *Client) UpdateCollection(wsSlug, collSlug string, input models.CollectionUpdate) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.patch("/workspaces/"+wsSlug+"/collections/"+collSlug, input, &result)
|
|
}
|
|
|
|
// --- Items ---
|
|
|
|
// ListItems returns items across all collections in a workspace.
|
|
// Use params for filtering, sorting, grouping, pagination, etc.
|
|
func (c *Client) ListItems(wsSlug string, params url.Values) ([]models.Item, error) {
|
|
var result []models.Item
|
|
path := "/workspaces/" + wsSlug + "/items"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// ListCollectionItems returns items within a specific collection.
|
|
func (c *Client) ListCollectionItems(wsSlug, collSlug string, params url.Values) ([]models.Item, error) {
|
|
var result []models.Item
|
|
path := "/workspaces/" + wsSlug + "/collections/" + collSlug + "/items"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
func (c *Client) CreateItem(wsSlug, collSlug string, input models.ItemCreate) (*models.Item, error) {
|
|
var result models.Item
|
|
return &result, c.post("/workspaces/"+wsSlug+"/collections/"+collSlug+"/items", input, &result)
|
|
}
|
|
|
|
func (c *Client) GetItem(wsSlug, itemSlug string) (*models.Item, error) {
|
|
var result models.Item
|
|
return &result, c.get("/workspaces/"+wsSlug+"/items/"+itemSlug, &result)
|
|
}
|
|
|
|
func (c *Client) UpdateItem(wsSlug, itemSlug string, input models.ItemUpdate) (*models.Item, error) {
|
|
var result models.Item
|
|
return &result, c.patch("/workspaces/"+wsSlug+"/items/"+itemSlug, input, &result)
|
|
}
|
|
|
|
func (c *Client) DeleteItem(wsSlug, itemSlug string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/items/" + itemSlug)
|
|
}
|
|
|
|
func (c *Client) MoveItem(wsSlug, itemSlug string, input map[string]any) (*models.Item, error) {
|
|
var result models.Item
|
|
return &result, c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/move", input, &result)
|
|
}
|
|
|
|
// --- Links ---
|
|
|
|
func (c *Client) GetItemLinks(wsSlug, itemSlug string) ([]models.ItemLink, error) {
|
|
var result []models.ItemLink
|
|
return result, c.get("/workspaces/"+wsSlug+"/items/"+itemSlug+"/links", &result)
|
|
}
|
|
|
|
func (c *Client) CreateItemLink(wsSlug, itemSlug string, input models.ItemLinkCreate) (*models.ItemLink, error) {
|
|
var result models.ItemLink
|
|
return &result, c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/links", input, &result)
|
|
}
|
|
|
|
func (c *Client) DeleteItemLink(wsSlug, linkID string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/links/" + linkID)
|
|
}
|
|
|
|
// --- Comments ---
|
|
|
|
func (c *Client) ListComments(wsSlug, itemSlug string) ([]models.Comment, error) {
|
|
var result []models.Comment
|
|
return result, c.get("/workspaces/"+wsSlug+"/items/"+itemSlug+"/comments", &result)
|
|
}
|
|
|
|
func (c *Client) CreateComment(wsSlug, itemSlug string, input models.CommentCreate) (*models.Comment, error) {
|
|
var result models.Comment
|
|
return &result, c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/comments", input, &result)
|
|
}
|
|
|
|
func (c *Client) DeleteComment(wsSlug, commentID string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/comments/" + commentID)
|
|
}
|
|
|
|
// --- Dashboard ---
|
|
|
|
// GetDashboard returns the workspace dashboard as raw JSON.
|
|
// The DashboardResponse type lives in the server package, so we use json.RawMessage.
|
|
func (c *Client) GetDashboard(wsSlug string) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
return result, c.get("/workspaces/"+wsSlug+"/dashboard", &result)
|
|
}
|
|
|
|
// --- Search ---
|
|
|
|
// SearchItems performs a cross-workspace search. Pass q, workspace, etc. via params.
|
|
func (c *Client) SearchItems(params url.Values) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
path := "/search"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// --- Activity ---
|
|
|
|
func (c *Client) ListActivity(wsSlug string, params url.Values) ([]models.Activity, error) {
|
|
var result []models.Activity
|
|
path := "/workspaces/" + wsSlug + "/activity"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// --- Convention Library ---
|
|
|
|
// ConventionLibraryResponse is the response from the convention-library endpoint.
|
|
type ConventionLibraryResponse struct {
|
|
Categories []LibraryCategory `json:"categories"`
|
|
}
|
|
|
|
// LibraryCategory groups related conventions under a named category.
|
|
type LibraryCategory struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Conventions []LibraryConvention `json:"conventions"`
|
|
}
|
|
|
|
// LibraryConvention holds a pre-built convention definition.
|
|
type LibraryConvention struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
Category string `json:"category"`
|
|
Trigger string `json:"trigger"`
|
|
Scope string `json:"scope"`
|
|
Priority string `json:"priority"`
|
|
}
|
|
|
|
// GetConventionLibrary fetches the convention library from the server.
|
|
func (c *Client) GetConventionLibrary() (*ConventionLibraryResponse, error) {
|
|
var result ConventionLibraryResponse
|
|
return &result, c.get("/convention-library", &result)
|
|
}
|
|
|
|
// --- Playbook Library ---
|
|
|
|
// PlaybookLibraryResponse is the response from the playbook-library endpoint.
|
|
type PlaybookLibraryResponse struct {
|
|
Categories []PlaybookCategory `json:"categories"`
|
|
}
|
|
|
|
// PlaybookCategory groups related playbooks under a named category.
|
|
type PlaybookCategory struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Playbooks []LibraryPlaybook `json:"playbooks"`
|
|
}
|
|
|
|
// LibraryPlaybook holds a pre-built playbook definition.
|
|
type LibraryPlaybook struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
Category string `json:"category"`
|
|
Trigger string `json:"trigger"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
|
|
// GetPlaybookLibrary fetches the playbook library from the server.
|
|
func (c *Client) GetPlaybookLibrary() (*PlaybookLibraryResponse, error) {
|
|
var result PlaybookLibraryResponse
|
|
return &result, c.get("/playbook-library", &result)
|
|
}
|
|
|
|
// --- Webhooks ---
|
|
|
|
// ListWebhooks returns all webhooks for a workspace.
|
|
func (c *Client) ListWebhooks(wsSlug string) ([]models.Webhook, error) {
|
|
var result []models.Webhook
|
|
return result, c.get("/workspaces/"+wsSlug+"/webhooks", &result)
|
|
}
|
|
|
|
// CreateWebhook registers a new webhook for a workspace.
|
|
func (c *Client) CreateWebhook(wsSlug string, input models.WebhookCreate) (*models.Webhook, error) {
|
|
var result models.Webhook
|
|
return &result, c.post("/workspaces/"+wsSlug+"/webhooks", input, &result)
|
|
}
|
|
|
|
// DeleteWebhook removes a webhook by ID.
|
|
func (c *Client) DeleteWebhook(wsSlug, webhookID string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/webhooks/" + webhookID)
|
|
}
|
|
|
|
// TestWebhook sends a test payload to a webhook.
|
|
func (c *Client) TestWebhook(wsSlug, webhookID string) error {
|
|
return c.post("/workspaces/"+wsSlug+"/webhooks/"+webhookID+"/test", nil, nil)
|
|
}
|
|
|
|
// --- Export / Import ---
|
|
|
|
// RawGet fetches raw bytes from the API.
|
|
func (c *Client) RawGet(path string) ([]byte, error) {
|
|
resp, err := c.httpClient.Get(c.baseURL + path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// PostRaw sends raw bytes to the API and decodes the JSON response.
|
|
func (c *Client) PostRaw(path string, data []byte, result interface{}) error {
|
|
resp, err := c.httpClient.Post(c.baseURL+path, "application/json", bytes.NewReader(data))
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return c.handleResponse(resp, result)
|
|
}
|
|
|
|
// --- HTTP helpers ---
|
|
|
|
type APIError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
func (c *Client) get(path string, result interface{}) error {
|
|
resp, err := c.httpClient.Get(c.baseURL + path)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return c.handleResponse(resp, result)
|
|
}
|
|
|
|
func (c *Client) post(path string, body interface{}, result interface{}) error {
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bodyReader = bytes.NewReader(data)
|
|
}
|
|
resp, err := c.httpClient.Post(c.baseURL+path, "application/json", bodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return c.handleResponse(resp, result)
|
|
}
|
|
|
|
func (c *Client) patch(path string, body interface{}, result interface{}) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequest("PATCH", c.baseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return c.handleResponse(resp, result)
|
|
}
|
|
|
|
func (c *Client) delete(path string) error {
|
|
req, err := http.NewRequest("DELETE", c.baseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
return nil
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return c.parseError(resp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) handleResponse(resp *http.Response, result interface{}) error {
|
|
if resp.StatusCode >= 400 {
|
|
return c.parseError(resp)
|
|
}
|
|
if result != nil {
|
|
return json.NewDecoder(resp.Body).Decode(result)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) parseError(resp *http.Response) error {
|
|
var errResp struct {
|
|
Error APIError `json:"error"`
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
|
|
return &errResp.Error
|
|
}
|
|
return fmt.Errorf("API error: %d %s", resp.StatusCode, string(body))
|
|
}
|