package esa import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" ) const defaultBaseURL = "https://api.esa.io" type Client struct { BaseURL string Team string Token string HTTPClient *http.Client } func NewClient(team, token string) *Client { return &Client{ BaseURL: defaultBaseURL, Team: team, Token: token, HTTPClient: &http.Client{Timeout: 30 * time.Second}, } } type User struct { Myself bool `json:"myself"` Name string `json:"name"` ScreenName string `json:"screen_name"` Icon string `json:"icon"` } type Post struct { Number int `json:"number"` Name string `json:"name"` FullName string `json:"full_name"` WIP bool `json:"wip"` BodyMD string `json:"body_md"` BodyHTML string `json:"body_html"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Message string `json:"message"` URL string `json:"url"` Tags []string `json:"tags"` Category string `json:"category"` RevisionNumber int `json:"revision_number"` CreatedBy User `json:"created_by"` UpdatedBy User `json:"updated_by"` Kind string `json:"kind"` CommentsCount int `json:"comments_count"` TasksCount int `json:"tasks_count"` DoneTasksCount int `json:"done_tasks_count"` StargazersCount int `json:"stargazers_count"` WatchersCount int `json:"watchers_count"` Star bool `json:"star"` Watch bool `json:"watch"` } type PostsResponse struct { Posts []Post `json:"posts"` PrevPage *int `json:"prev_page"` NextPage *int `json:"next_page"` TotalCount int `json:"total_count"` Page int `json:"page"` PerPage int `json:"per_page"` MaxPerPage int `json:"max_per_page"` } type PostInput struct { Name string `json:"name,omitempty"` BodyMD string `json:"body_md,omitempty"` Tags []string `json:"tags,omitempty"` Category string `json:"category,omitempty"` WIP *bool `json:"wip,omitempty"` Message string `json:"message,omitempty"` } type postCreateRequest struct { Post PostInput `json:"post"` } type APIError struct { StatusCode int ErrorCode string `json:"error"` Message string `json:"message"` } func (e *APIError) Error() string { if e.ErrorCode == "" { return fmt.Sprintf("esa API error: status=%d", e.StatusCode) } return fmt.Sprintf("esa API error: status=%d error=%s message=%s", e.StatusCode, e.ErrorCode, e.Message) } func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) ([]byte, error) { u, err := url.Parse(c.BaseURL + path) if err != nil { return nil, fmt.Errorf("parse url: %w", err) } if len(query) > 0 { u.RawQuery = query.Encode() } var reqBody io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal body: %w", err) } reqBody = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody) if err != nil { return nil, fmt.Errorf("new request: %w", 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.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("http: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read body: %w", err) } if resp.StatusCode >= 400 { apiErr := &APIError{StatusCode: resp.StatusCode} _ = json.Unmarshal(respBody, apiErr) return nil, apiErr } return respBody, nil } func (c *Client) GetPost(ctx context.Context, number int) (*Post, error) { path := fmt.Sprintf("/v1/teams/%s/posts/%d", url.PathEscape(c.Team), number) body, err := c.do(ctx, http.MethodGet, path, nil, nil) if err != nil { return nil, err } var p Post if err := json.Unmarshal(body, &p); err != nil { return nil, fmt.Errorf("unmarshal post: %w", err) } return &p, nil } type SearchOptions struct { Query string Page int PerPage int Sort string Order string } func (c *Client) SearchPosts(ctx context.Context, opts SearchOptions) (*PostsResponse, error) { path := fmt.Sprintf("/v1/teams/%s/posts", url.PathEscape(c.Team)) q := url.Values{} if opts.Query != "" { q.Set("q", opts.Query) } if opts.Page > 0 { q.Set("page", strconv.Itoa(opts.Page)) } if opts.PerPage > 0 { q.Set("per_page", strconv.Itoa(opts.PerPage)) } if opts.Sort != "" { q.Set("sort", opts.Sort) } if opts.Order != "" { q.Set("order", opts.Order) } body, err := c.do(ctx, http.MethodGet, path, q, nil) if err != nil { return nil, err } var r PostsResponse if err := json.Unmarshal(body, &r); err != nil { return nil, fmt.Errorf("unmarshal posts: %w", err) } return &r, nil } func (c *Client) CreatePost(ctx context.Context, input PostInput) (*Post, error) { path := fmt.Sprintf("/v1/teams/%s/posts", url.PathEscape(c.Team)) body, err := c.do(ctx, http.MethodPost, path, nil, postCreateRequest{Post: input}) if err != nil { return nil, err } var p Post if err := json.Unmarshal(body, &p); err != nil { return nil, fmt.Errorf("unmarshal post: %w", err) } return &p, nil } func (c *Client) UpdatePost(ctx context.Context, number int, input PostInput) (*Post, error) { path := fmt.Sprintf("/v1/teams/%s/posts/%d", url.PathEscape(c.Team), number) body, err := c.do(ctx, http.MethodPatch, path, nil, postCreateRequest{Post: input}) if err != nil { return nil, err } var p Post if err := json.Unmarshal(body, &p); err != nil { return nil, fmt.Errorf("unmarshal post: %w", err) } return &p, nil }