- Add plane adapter client with test coverage - Add task external refs migration - Update workflow service with tests - Add HTTP handler tests - Update router and middleware tests - Update database models and queries - Update scheduler jobs - Update storage store - Update server main and docker-compose - Update roadmaps and documentation
195 lines
4.6 KiB
Go
195 lines
4.6 KiB
Go
package plane
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrNotImplemented = errors.New("plane adapter method is not implemented")
|
|
ErrMissingConfig = errors.New("plane adapter requires base URL and token")
|
|
ErrInvalidInput = errors.New("invalid plane adapter input")
|
|
)
|
|
|
|
type Config struct {
|
|
BaseURL string
|
|
Token string
|
|
}
|
|
|
|
type Client struct {
|
|
cfg Config
|
|
http *http.Client
|
|
logger *slog.Logger
|
|
}
|
|
|
|
type WorkItemRef struct {
|
|
WorkspaceSlug string
|
|
ProjectID string
|
|
WorkItemID string
|
|
}
|
|
|
|
type WorkItem struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
DescriptionHTML string `json:"description_html"`
|
|
DescriptionStripped string `json:"description_stripped"`
|
|
}
|
|
|
|
type CreateIssueInput struct {
|
|
Title string
|
|
Description string
|
|
}
|
|
|
|
type AddCommentInput struct {
|
|
Ref WorkItemRef
|
|
CommentHTML string
|
|
Access string
|
|
ExternalSource string
|
|
ExternalID string
|
|
}
|
|
|
|
type UpdateIssueStatusInput struct {
|
|
Ref WorkItemRef
|
|
Status string
|
|
}
|
|
|
|
func NewClient(cfg Config, logger *slog.Logger) *Client {
|
|
return &Client{cfg: cfg, http: http.DefaultClient, logger: logger}
|
|
}
|
|
|
|
func NewClientWithHTTPClient(cfg Config, httpClient *http.Client, logger *slog.Logger) *Client {
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
return &Client{cfg: cfg, http: httpClient, logger: logger}
|
|
}
|
|
|
|
func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) error {
|
|
c.log(ctx, "plane create issue skipped", "title", input.Title)
|
|
return ErrNotImplemented
|
|
}
|
|
|
|
func (c *Client) GetWorkItem(ctx context.Context, ref WorkItemRef) (WorkItem, error) {
|
|
var output WorkItem
|
|
if err := c.do(ctx, http.MethodGet, workItemPath(ref), nil, &output); err != nil {
|
|
return WorkItem{}, err
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (c *Client) AddComment(ctx context.Context, input AddCommentInput) error {
|
|
body := map[string]any{
|
|
"comment_html": input.CommentHTML,
|
|
}
|
|
if input.Access != "" {
|
|
body["access"] = input.Access
|
|
}
|
|
if input.ExternalSource != "" {
|
|
body["external_source"] = input.ExternalSource
|
|
}
|
|
if input.ExternalID != "" {
|
|
body["external_id"] = input.ExternalID
|
|
}
|
|
return c.do(ctx, http.MethodPost, workItemPath(input.Ref)+"/comments/", body, nil)
|
|
}
|
|
|
|
func (c *Client) UpdateIssueStatus(ctx context.Context, input UpdateIssueStatusInput) error {
|
|
body := map[string]string{
|
|
"state": input.Status,
|
|
}
|
|
return c.do(ctx, http.MethodPatch, workItemPath(input.Ref)+"/", body, nil)
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, input any, output any) error {
|
|
baseURL, token, err := c.config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return ErrInvalidInput
|
|
}
|
|
|
|
endpoint := baseURL + path
|
|
var body io.Reader
|
|
if input != nil {
|
|
raw, err := json.Marshal(input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body = bytes.NewReader(raw)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("X-Api-Key", token)
|
|
if input != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("plane API request failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
if output == nil || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(raw, output); err != nil {
|
|
return fmt.Errorf("decode plane API response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) config() (string, string, error) {
|
|
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
|
|
token := strings.TrimSpace(c.cfg.Token)
|
|
if baseURL == "" || token == "" {
|
|
return "", "", ErrMissingConfig
|
|
}
|
|
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
|
return "", "", err
|
|
}
|
|
return baseURL, token, nil
|
|
}
|
|
|
|
func workItemPath(ref WorkItemRef) string {
|
|
workspaceSlug := strings.TrimSpace(ref.WorkspaceSlug)
|
|
projectID := strings.TrimSpace(ref.ProjectID)
|
|
workItemID := strings.TrimSpace(ref.WorkItemID)
|
|
if workspaceSlug == "" || projectID == "" || workItemID == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(
|
|
"/api/v1/workspaces/%s/projects/%s/work-items/%s",
|
|
url.PathEscape(workspaceSlug),
|
|
url.PathEscape(projectID),
|
|
url.PathEscape(workItemID),
|
|
)
|
|
}
|
|
|
|
func (c *Client) log(ctx context.Context, message string, args ...any) {
|
|
_ = ctx
|
|
if c.logger != nil {
|
|
c.logger.Info(message, args...)
|
|
}
|
|
}
|