nomadcode/services/core/internal/adapters/jira/client.go
toki 84408ab245 feat: external integration adapters and test improvements
- Add JIRA, Mattermost, Plane adapter implementations
- Add Mattermost client tests
- Update core server setup and main.go
- Improve config and validation tests
- Enhance HTTP handlers and middleware tests
- Update work item provider and notification tests
- Add protoSocket tasks tests
- Update agent-task and agent-roadmap documentation
2026-06-03 18:41:12 +09:00

390 lines
10 KiB
Go

package jira
import (
"bytes"
"context"
"encoding/base64"
"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)
)
var (
ErrNotImplemented = errors.New("jira adapter method is not implemented")
ErrMissingConfig = errors.New("jira adapter requires base URL, email, and API token")
ErrInvalidInput = errors.New("invalid jira adapter input")
)
type Config struct {
BaseURL string
Email string
APIToken string
}
type Client struct {
cfg Config
http *http.Client
logger *slog.Logger
}
// --- Jira-specific DTOS ---
type JiraIssueResponse struct {
Key string `json:"key"`
Fields JiraFields
}
type JiraFields struct {
Summary string `json:"summary"`
Description json.RawMessage `json:"description"`
Status *JiraStatus `json:"status"`
IssueType JiraIssueType `json:"issuetype"`
Labels []string `json:"labels"`
}
type JiraStatus struct {
Name string `json:"name"`
Color string `json:"color"`
StatusCategory JiraStatusCategory `json:"statusCategory"`
}
type JiraStatusCategory struct {
Name string `json:"name"`
ID int `json:"id"`
Key string `json:"key"`
ColorName string `json:"colorName"`
}
type JiraIssueType struct {
Name string `json:"name"`
}
type JiraCommentResponse struct {
ID string `json:"id"`
}
type JiraCommentInput struct {
Body json.RawMessage `json:"body"`
}
type JiraTransitionRequest struct {
Transition JiraTransitionInput `json:"transition"`
}
type JiraTransitionInput struct {
ID string `json:"id"`
}
type JiraTransitionResponse struct {
Transitions []JiraTransitionOption `json:"transitions"`
}
type JiraTransitionOption struct {
ID string `json:"id"`
Name string `json:"name"`
To JiraStatusTo `json:"to"`
}
type JiraStatusTo struct {
Name string `json:"name"`
}
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("jira")
}
func (c *Client) Capabilities() workitem.Capabilities {
return workitem.Capabilities{
workitem.CapabilityLookup: true,
workitem.CapabilityComment: true,
workitem.CapabilityStatusProjection: true,
workitem.CapabilityCreate: false,
workitem.CapabilityLabelProjection: false,
}
}
func (c *Client) FetchWorkItem(ctx context.Context, ref workitem.Ref) (workitem.WorkItem, error) {
if err := c.validateConfig(); err != nil {
return workitem.WorkItem{}, err
}
baseURL, _, _, _ := c.normalizedConfig()
issueKey := strings.TrimSpace(ref.ID)
if issueKey == "" {
return workitem.WorkItem{}, ErrInvalidInput
}
endpoint := baseURL + "/rest/api/3/issue/" + url.PathEscape(issueKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return workitem.WorkItem{}, err
}
c.setAuthHeader(req)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return workitem.WorkItem{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return workitem.WorkItem{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return workitem.WorkItem{}, fmt.Errorf("jira API request failed (GET /rest/api/3/issue/%s): status %d: %s", issueKey, resp.StatusCode, strings.TrimSpace(string(raw)))
}
var jiraResp JiraIssueResponse
if err := json.Unmarshal(raw, &jiraResp); err != nil {
return workitem.WorkItem{}, fmt.Errorf("decode jira issue response: %w", err)
}
descText := extractDescriptionText(jiraResp.Fields.Description)
statusName := ""
if jiraResp.Fields.Status != nil {
statusName = jiraResp.Fields.Status.Name
}
return workitem.WorkItem{
Ref: ref,
Title: jiraResp.Fields.Summary,
DescriptionText: descText,
DescriptionHTML: extractDescriptionHTML(jiraResp.Fields.Description),
State: statusName,
Labels: jiraResp.Fields.Labels,
}, nil
}
func (c *Client) AppendComment(ctx context.Context, input workitem.CommentInput) error {
if err := c.validateConfig(); err != nil {
return err
}
baseURL, _, _, _ := c.normalizedConfig()
issueKey := strings.TrimSpace(input.Ref.ID)
if issueKey == "" {
return ErrInvalidInput
}
body := convertCommentToADF(input.Body)
commentReq := JiraCommentInput{Body: json.RawMessage(body)}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(commentReq); err != nil {
return err
}
endpoint := baseURL + "/rest/api/3/issue/" + url.PathEscape(issueKey) + "/comment"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)
if err != nil {
return err
}
c.setAuthHeader(req)
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("jira API request failed (POST /rest/api/3/issue/%s/comment): status %d: %s", issueKey, resp.StatusCode, strings.TrimSpace(string(raw)))
}
return nil
}
func (c *Client) ProjectStatus(ctx context.Context, input workitem.StatusProjection) error {
if err := c.validateConfig(); err != nil {
return err
}
baseURL, _, _, _ := c.normalizedConfig()
issueKey := strings.TrimSpace(input.Ref.ID)
if issueKey == "" {
return ErrInvalidInput
}
providerState := strings.TrimSpace(input.ProviderState)
if providerState == "" {
return ErrInvalidInput
}
transitionReq := JiraTransitionRequest{
Transition: JiraTransitionInput{ID: providerState},
}
payload, err := json.Marshal(transitionReq)
if err != nil {
return err
}
endpoint := baseURL + "/rest/api/3/issue/" + url.PathEscape(issueKey) + "/transitions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
c.setAuthHeader(req)
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("jira API request failed (POST /rest/api/3/issue/%s/transitions): status %d: %s", issueKey, resp.StatusCode, strings.TrimSpace(string(raw)))
}
return nil
}
// --- Private helpers ---
func (c *Client) validateConfig() error {
baseURL, _, _, err := c.normalizedConfig()
if err != nil {
return err
}
if _, err := url.ParseRequestURI(baseURL); err != nil {
return err
}
return nil
}
func (c *Client) normalizedConfig() (string, string, string, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
email := strings.TrimSpace(c.cfg.Email)
token := strings.TrimSpace(c.cfg.APIToken)
if baseURL == "" || email == "" || token == "" {
return "", "", "", ErrMissingConfig
}
return baseURL, email, token, nil
}
func (c *Client) setAuthHeader(req *http.Request) {
_, email, token, err := c.normalizedConfig()
if err != nil {
return
}
encoded := base64.StdEncoding.EncodeToString([]byte(email + ":" + token))
req.Header.Set("Authorization", "Basic "+encoded)
}
// extractDescriptionText converts ADF description to plain text or falls back to the raw string.
func extractDescriptionText(raw json.RawMessage) string {
if raw == nil || len(raw) == 0 {
return ""
}
// Try parsing as ADF (Atlassian Document Format) first
var adf struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &adf); err == nil && adf.Type == "doc" {
return adfToText(raw)
}
// Fallback: treat as plain string
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
// Fallback: treat as raw string in the response
var v map[string]any
if err := json.Unmarshal(raw, &v); err == nil {
if val, ok := v["text"].(string); ok {
return val
}
}
return ""
}
// extractDescriptionHTML returns the ADF JSON string or plain text value.
func extractDescriptionHTML(raw json.RawMessage) string {
if raw == nil || len(raw) == 0 {
return ""
}
var adf struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &adf); err == nil && adf.Type == "doc" {
return string(raw)
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return ""
}
// adfToText converts ADF JSON to plain text by recursively walking nodes.
func adfToText(raw json.RawMessage) string {
var doc struct {
Content []map[string]any `json:"content"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return ""
}
var parts []string
for _, node := range doc.Content {
extractText(node, &parts)
}
return strings.Join(parts, "\n")
}
func extractText(node map[string]any, parts *[]string) {
if text, ok := node["text"].(string); ok {
*parts = append(*parts, text)
return
}
rawChildren, ok := node["content"].([]any)
if !ok {
return
}
for _, rc := range rawChildren {
if child, ok := rc.(map[string]any); ok {
extractText(child, parts)
}
}
}
// convertCommentToADF converts plain text to Jira ADF paragraph format with proper JSON escaping.
func convertCommentToADF(body string) string {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return `{"type":"doc","version":1}`
}
rawJSON, _ := json.Marshal(trimmed)
return fmt.Sprintf(`{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":%s}]}]}`, rawJSON)
}