- Plane Todo projection 및 idempotency/retry 메커니즘 구현 - Roadmap sync identity 및 step 모델/DB 마이그레이션/쿼리 추가 - OpenAI 및 Plane adapter 개선 - roadmaps sync package: plane_format, plane_projection, retry 모듈 추가 - Storage layer: roadmap_sync_identities, roadmap_sync_steps 추가 - agent-task 아카이브 정리
394 lines
10 KiB
Go
394 lines
10 KiB
Go
package plane
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/nomadcode/nomadcode-core/internal/workitem"
|
|
)
|
|
|
|
var (
|
|
_ workitem.Provider = (*Client)(nil)
|
|
_ workitem.Reader = (*Client)(nil)
|
|
_ workitem.Commenter = (*Client)(nil)
|
|
_ workitem.StatusProjector = (*Client)(nil)
|
|
_ workitem.Creator = (*Client)(nil)
|
|
_ workitem.BodyProjector = (*Client)(nil)
|
|
)
|
|
|
|
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 {
|
|
WorkspaceSlug string
|
|
ProjectID string
|
|
Title string
|
|
Description string
|
|
DescriptionHTML string
|
|
}
|
|
|
|
type AddCommentInput struct {
|
|
Ref WorkItemRef
|
|
CommentHTML string
|
|
Access string
|
|
ExternalSource string
|
|
ExternalID string
|
|
}
|
|
|
|
type UpdateIssueStatusInput struct {
|
|
Ref WorkItemRef
|
|
Status string
|
|
}
|
|
|
|
// UpdateWorkItemInput carries an additive PATCH of a Plane work item. Only
|
|
// non-empty fields are sent, so a blank Title/DescriptionHTML/Status never
|
|
// clears an existing value. Status maps to the Plane `state` field, the same
|
|
// field UpdateIssueStatus patches.
|
|
type UpdateWorkItemInput struct {
|
|
Ref WorkItemRef
|
|
Title string
|
|
DescriptionHTML string
|
|
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) ProviderID() workitem.ProviderID {
|
|
return workitem.ProviderID("plane")
|
|
}
|
|
|
|
func (c *Client) Capabilities() workitem.Capabilities {
|
|
return workitem.Capabilities{
|
|
workitem.CapabilityLookup: true,
|
|
workitem.CapabilityComment: true,
|
|
workitem.CapabilityStatusProjection: true,
|
|
workitem.CapabilityCreate: true,
|
|
workitem.CapabilityBodyProjection: true,
|
|
workitem.CapabilityLabelProjection: false,
|
|
}
|
|
}
|
|
|
|
func (c *Client) FetchWorkItem(ctx context.Context, ref workitem.Ref) (workitem.WorkItem, error) {
|
|
planeRef, err := planeWorkItemRef(ref)
|
|
if err != nil {
|
|
return workitem.WorkItem{}, err
|
|
}
|
|
item, err := c.GetWorkItem(ctx, planeRef)
|
|
if err != nil {
|
|
return workitem.WorkItem{}, err
|
|
}
|
|
return workitem.WorkItem{
|
|
Ref: ref,
|
|
Title: item.Name,
|
|
DescriptionText: firstNonEmpty(item.DescriptionStripped, item.Description),
|
|
DescriptionHTML: item.DescriptionHTML,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) AppendComment(ctx context.Context, input workitem.CommentInput) error {
|
|
planeRef, err := planeWorkItemRef(input.Ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.AddComment(ctx, AddCommentInput{
|
|
Ref: planeRef,
|
|
CommentHTML: input.Body,
|
|
Access: input.Visibility,
|
|
ExternalSource: input.ExternalSource,
|
|
ExternalID: input.ExternalID,
|
|
})
|
|
}
|
|
|
|
func (c *Client) ProjectStatus(ctx context.Context, input workitem.StatusProjection) error {
|
|
planeRef, err := planeWorkItemRef(input.Ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(input.ProviderState) == "" {
|
|
return ErrInvalidInput
|
|
}
|
|
return c.UpdateIssueStatus(ctx, UpdateIssueStatusInput{
|
|
Ref: planeRef,
|
|
Status: input.ProviderState,
|
|
})
|
|
}
|
|
|
|
func (c *Client) ProjectBody(ctx context.Context, input workitem.BodyProjection) error {
|
|
planeRef, err := planeWorkItemRef(input.Ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(input.Title) == "" && strings.TrimSpace(input.DescriptionHTML) == "" {
|
|
return ErrInvalidInput
|
|
}
|
|
return c.UpdateWorkItem(ctx, UpdateWorkItemInput{
|
|
Ref: planeRef,
|
|
Title: input.Title,
|
|
DescriptionHTML: input.DescriptionHTML,
|
|
})
|
|
}
|
|
|
|
func (c *Client) CreateWorkItem(ctx context.Context, input workitem.CreateInput) (workitem.CreateResult, error) {
|
|
workspace := strings.TrimSpace(input.Tenant)
|
|
project := strings.TrimSpace(input.Project)
|
|
item, err := c.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceSlug: workspace,
|
|
ProjectID: project,
|
|
Title: input.Title,
|
|
Description: input.DescriptionText,
|
|
DescriptionHTML: input.DescriptionHTML,
|
|
})
|
|
if err != nil {
|
|
return workitem.CreateResult{}, err
|
|
}
|
|
return workitem.CreateResult{
|
|
Ref: workitem.Ref{
|
|
Provider: c.ProviderID(),
|
|
Tenant: workspace,
|
|
Project: project,
|
|
ID: item.ID,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func planeWorkItemRef(ref workitem.Ref) (WorkItemRef, error) {
|
|
tenant := strings.TrimSpace(ref.Tenant)
|
|
project := strings.TrimSpace(ref.Project)
|
|
id := strings.TrimSpace(ref.ID)
|
|
if tenant == "" || project == "" || id == "" {
|
|
return WorkItemRef{}, ErrInvalidInput
|
|
}
|
|
return WorkItemRef{
|
|
WorkspaceSlug: tenant,
|
|
ProjectID: project,
|
|
WorkItemID: id,
|
|
}, nil
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) (WorkItem, error) {
|
|
title := strings.TrimSpace(input.Title)
|
|
if title == "" {
|
|
return WorkItem{}, ErrInvalidInput
|
|
}
|
|
path := workItemsPath(input.WorkspaceSlug, input.ProjectID)
|
|
if path == "" {
|
|
return WorkItem{}, ErrInvalidInput
|
|
}
|
|
|
|
body := map[string]any{
|
|
"name": title,
|
|
}
|
|
if descHTML := firstNonEmpty(input.DescriptionHTML, input.Description); descHTML != "" {
|
|
body["description_html"] = descHTML
|
|
}
|
|
|
|
var output WorkItem
|
|
if err := c.do(ctx, http.MethodPost, path, body, &output); err != nil {
|
|
return WorkItem{}, err
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
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 {
|
|
return c.UpdateWorkItem(ctx, UpdateWorkItemInput{
|
|
Ref: input.Ref,
|
|
Status: input.Status,
|
|
})
|
|
}
|
|
|
|
// UpdateWorkItem PATCHes the work item with only the non-empty fields in input.
|
|
// name, description_html, and state are sent independently so a body/title
|
|
// update never disturbs the state, and a status move never disturbs the body.
|
|
// An input with no non-empty field is an invalid no-op patch.
|
|
func (c *Client) UpdateWorkItem(ctx context.Context, input UpdateWorkItemInput) error {
|
|
body := map[string]string{}
|
|
if title := strings.TrimSpace(input.Title); title != "" {
|
|
body["name"] = title
|
|
}
|
|
if descHTML := strings.TrimSpace(input.DescriptionHTML); descHTML != "" {
|
|
body["description_html"] = descHTML
|
|
}
|
|
if status := strings.TrimSpace(input.Status); status != "" {
|
|
body["state"] = status
|
|
}
|
|
if len(body) == 0 {
|
|
return ErrInvalidInput
|
|
}
|
|
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 workItemsPath(workspaceSlug, projectID string) string {
|
|
workspaceSlug = strings.TrimSpace(workspaceSlug)
|
|
projectID = strings.TrimSpace(projectID)
|
|
if workspaceSlug == "" || projectID == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(
|
|
"/api/v1/workspaces/%s/projects/%s/work-items/",
|
|
url.PathEscape(workspaceSlug),
|
|
url.PathEscape(projectID),
|
|
)
|
|
}
|
|
|
|
func (c *Client) log(ctx context.Context, message string, args ...any) {
|
|
_ = ctx
|
|
if c.logger != nil {
|
|
c.logger.Info(message, args...)
|
|
}
|
|
}
|