- 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
390 lines
10 KiB
Go
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)
|
|
}
|