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