nomadcode/services/core/internal/adapters/plane/client.go
toki 424de604ee feat: plane integration and workflow updates
- 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
2026-05-22 13:37:47 +09:00

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...)
}
}