Go module with the task tree screen; worktrees on and measured; Go installed
This commit is contained in:
258
tui/paperclip/client.go
Normal file
258
tui/paperclip/client.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Package paperclip is a thin read/write client for the Paperclip control plane.
|
||||
//
|
||||
// It holds no state. Every value it returns came from Paperclip on this call.
|
||||
// Nothing here caches, and nothing here writes to disk.
|
||||
package paperclip
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client talks to one Paperclip instance.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Company string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds a client from the environment.
|
||||
//
|
||||
// The token is read from PAPERCLIP_BOARD_KEY (the person's key) and only falls
|
||||
// back to PAPERCLIP_API_KEY for read-only use. The token is never logged.
|
||||
func New() (*Client, error) {
|
||||
base := os.Getenv("PAPERCLIP_API_URL")
|
||||
if base == "" {
|
||||
base = "http://127.0.0.1:3100"
|
||||
}
|
||||
company := os.Getenv("PAPERCLIP_COMPANY_ID")
|
||||
if company == "" {
|
||||
company = "d4921013-e5da-4d88-8016-57d5746e2d03"
|
||||
}
|
||||
token := os.Getenv("PAPERCLIP_BOARD_KEY")
|
||||
if token == "" {
|
||||
token = os.Getenv("PAPERCLIP_API_KEY")
|
||||
}
|
||||
if token == "" {
|
||||
return nil, errors.New("no key in the environment: set PAPERCLIP_BOARD_KEY")
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(base, "/"),
|
||||
Company: company,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 20 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UsingUserKey reports whether the person's key is in use. Writes need it.
|
||||
func (c *Client) UsingUserKey() bool { return os.Getenv("PAPERCLIP_BOARD_KEY") != "" }
|
||||
|
||||
type Issue struct {
|
||||
ID string `json:"id"`
|
||||
Identifier string `json:"identifier"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
ProjectID string `json:"projectId"`
|
||||
ParentID string `json:"parentId"`
|
||||
AssigneeAgent string `json:"assigneeAgentId"`
|
||||
AssigneeUser string `json:"assigneeUserId"`
|
||||
ExecutionState string `json:"-"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URLKey string `json:"urlKey"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Comment is one entry on a task's thread.
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
Body string `json:"body"`
|
||||
AuthorType string `json:"authorType"`
|
||||
AuthorID string `json:"authorAgentId"`
|
||||
AuthorUser string `json:"authorUserId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// BlockerInfo is what Paperclip says about one task's blockers.
|
||||
//
|
||||
// The list endpoint does not carry blockers, so the tree asks per task.
|
||||
type BlockerInfo struct {
|
||||
IssueID string
|
||||
UnresolvedCount int
|
||||
AllBlockersDone bool
|
||||
DependencyReady bool
|
||||
Diagnosis string
|
||||
BlockingIdentities []string
|
||||
}
|
||||
|
||||
type blockerResponse struct {
|
||||
Diagnosis string `json:"diagnosis"`
|
||||
Readiness struct {
|
||||
AllBlockersDone bool `json:"allBlockersDone"`
|
||||
DependencyReady bool `json:"isDependencyReady"`
|
||||
UnresolvedBlockerCount int `json:"unresolvedBlockerCount"`
|
||||
} `json:"readiness"`
|
||||
Blockers []struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
} `json:"blockers"`
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, body any, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.BaseURL+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return fmt.Errorf("cannot reach Paperclip at %s: %w", c.BaseURL, ue.Err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, snippet(raw))
|
||||
}
|
||||
if out == nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("%s %s returned unreadable JSON: %w", method, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snippet(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > 300 {
|
||||
s = s[:300] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Projects lists the company's projects.
|
||||
func (c *Client) Projects() ([]Project, error) {
|
||||
var out []Project
|
||||
err := c.do("GET", "/api/companies/"+c.Company+"/projects", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ResolveProject accepts a project id or url key and returns the project.
|
||||
func (c *Client) ResolveProject(ref string) (Project, error) {
|
||||
if ref == "" {
|
||||
return Project{}, errors.New("no project given: use -project <id or urlKey>")
|
||||
}
|
||||
ps, err := c.Projects()
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
for _, p := range ps {
|
||||
if p.ID == ref || p.URLKey == ref {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return Project{}, fmt.Errorf("no project matches %q", ref)
|
||||
}
|
||||
|
||||
// Issues returns every task in the company. The caller filters by project,
|
||||
// because the list endpoint takes no project filter.
|
||||
func (c *Client) Issues() ([]Issue, error) {
|
||||
var out []Issue
|
||||
err := c.do("GET", "/api/companies/"+c.Company+"/issues", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Blockers asks Paperclip what blocks one task.
|
||||
func (c *Client) Blockers(issueID string) (BlockerInfo, error) {
|
||||
var raw blockerResponse
|
||||
err := c.do("GET", "/api/issues/"+issueID+"/diagnostics/blockers", nil, &raw)
|
||||
info := BlockerInfo{
|
||||
IssueID: issueID,
|
||||
UnresolvedCount: raw.Readiness.UnresolvedBlockerCount,
|
||||
AllBlockersDone: raw.Readiness.AllBlockersDone,
|
||||
DependencyReady: raw.Readiness.DependencyReady,
|
||||
Diagnosis: raw.Diagnosis,
|
||||
}
|
||||
for _, b := range raw.Blockers {
|
||||
info.BlockingIdentities = append(info.BlockingIdentities, b.Identifier+" "+b.Status)
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
// Comments returns a task's thread, oldest first.
|
||||
func (c *Client) Comments(issueID string) ([]Comment, error) {
|
||||
var out []Comment
|
||||
err := c.do("GET", "/api/issues/"+issueID+"/comments?order=asc", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PostComment writes a comment as the person, which requires the user key.
|
||||
func (c *Client) PostComment(issueID, body string) (Comment, error) {
|
||||
if !c.UsingUserKey() {
|
||||
return Comment{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
||||
}
|
||||
var out Comment
|
||||
err := c.do("POST", "/api/issues/"+issueID+"/comments", map[string]string{"body": body}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SetStatus changes one task's status as the person.
|
||||
func (c *Client) SetStatus(issueID, status, comment string) (Issue, error) {
|
||||
if !c.UsingUserKey() {
|
||||
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
||||
}
|
||||
payload := map[string]any{"status": status}
|
||||
if comment != "" {
|
||||
payload["comment"] = comment
|
||||
}
|
||||
var out Issue
|
||||
err := c.do("PATCH", "/api/issues/"+issueID, payload, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Agents lists the company's agents, used only to put a name on a task.
|
||||
func (c *Client) Agents() ([]Agent, error) {
|
||||
var out []Agent
|
||||
err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
Reference in New Issue
Block a user