Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 129 additions & 98 deletions README.md

Large diffs are not rendered by default.

101 changes: 52 additions & 49 deletions internal/jira/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Client struct {
email string
token string
authType string // "basic" or "pat"/"bearer"
strategy Strategy
}

// NewClient creates a new Jira client.
Expand All @@ -54,6 +55,7 @@ func NewClient(baseURL, email, token, authType string, timeout float64) (*Client
email: email,
token: token,
authType: authType,
strategy: selectStrategy(baseURL, authType),
httpClient: &http.Client{
Transport: transport,
Timeout: time.Duration(timeout) * time.Second,
Expand All @@ -65,15 +67,15 @@ func NewClient(baseURL, email, token, authType string, timeout float64) (*Client

// GetMyself returns the currently authenticated user.
func (c *Client) GetMyself() (map[string]any, error) {
return c.getJSON("/rest/api/3/myself")
return c.getJSON(c.strategy.APIPath("myself"))
}

// --- Issues ---

// GetIssue fetches a single issue by key.
// fields optionally limits which fields are returned.
func (c *Client) GetIssue(key string, fields []string) (map[string]any, error) {
path := fmt.Sprintf("/rest/api/3/issue/%s", key)
path := c.strategy.APIPath(fmt.Sprintf("issue/%s", key))
if len(fields) > 0 {
path += "?fields=" + strings.Join(fields, ",")
}
Expand All @@ -88,7 +90,7 @@ func (c *Client) CreateIssue(project, summary, issueType, description, priority
"issuetype": map[string]any{"name": issueType},
}
if description != "" {
fields["description"] = textToADF(description)
fields["description"] = c.strategy.TextBody(description)
}
if priority != "" {
fields["priority"] = map[string]any{"name": priority}
Expand Down Expand Up @@ -117,12 +119,15 @@ func (c *Client) CreateIssue(project, summary, issueType, description, priority
body := map[string]any{
"fields": fields,
}
return c.postJSON("/rest/api/3/issue", body)
return c.postJSON(c.strategy.APIPath("issue"), body)
}

// UpdateIssue updates an existing issue's fields.
func (c *Client) UpdateIssue(key string, fields map[string]any) error {
path := fmt.Sprintf("/rest/api/3/issue/%s", key)
path := c.strategy.APIPath(fmt.Sprintf("issue/%s", key))
if desc, ok := fields["description"].(string); ok && desc != "" {
fields["description"] = c.strategy.TextBody(desc)
}
body := map[string]any{"fields": fields}
return c.putNoContent(path, body)
}
Expand All @@ -132,9 +137,9 @@ func (c *Client) UpdateIssue(key string, fields map[string]any) error {
// description, priority, custom fields) and "update" for array operations
// (add/remove on labels, components, fixVersions).
func (c *Client) EditIssue(key string, fields, update map[string]any) error {
path := fmt.Sprintf("/rest/api/3/issue/%s", key)
path := c.strategy.APIPath(fmt.Sprintf("issue/%s", key))
if desc, ok := fields["description"].(string); ok && desc != "" {
fields["description"] = textToADF(desc)
fields["description"] = c.strategy.TextBody(desc)
}
body := map[string]any{}
if len(fields) > 0 {
Expand All @@ -148,7 +153,7 @@ func (c *Client) EditIssue(key string, fields, update map[string]any) error {

// DeleteIssue deletes an issue by key.
func (c *Client) DeleteIssue(key string) error {
path := fmt.Sprintf("/rest/api/3/issue/%s", key)
path := c.strategy.APIPath(fmt.Sprintf("issue/%s", key))
return c.delete(path)
}

Expand Down Expand Up @@ -187,28 +192,19 @@ func (c *Client) CloneIssue(key string, overrides map[string]any) (map[string]an
}

body := map[string]any{"fields": newFields}
return c.postJSON("/rest/api/3/issue", body)
return c.postJSON(c.strategy.APIPath("issue"), body)
}

// AssignIssue assigns an issue to a user.
// Pass accountID for cloud, name for server. Pass empty strings for unassigned.
func (c *Client) AssignIssue(key, accountID, name, _ string) error {
path := fmt.Sprintf("/rest/api/3/issue/%s/assignee", key)
body := map[string]any{}
switch {
case accountID != "":
body["accountId"] = accountID
case name != "":
body["name"] = name
default:
body["accountId"] = nil
}
return c.putNoContent(path, body)
path := c.strategy.APIPath(fmt.Sprintf("issue/%s/assignee", key))
return c.putNoContent(path, c.strategy.AssignBody(accountID, name))
}

// GetIssueTransitions returns available transitions for an issue.
func (c *Client) GetIssueTransitions(key string) ([]map[string]any, error) {
path := fmt.Sprintf("/rest/api/3/issue/%s/transitions", key)
path := c.strategy.APIPath(fmt.Sprintf("issue/%s/transitions", key))
data, err := c.getJSON(path)
if err != nil {
return nil, err
Expand All @@ -218,33 +214,40 @@ func (c *Client) GetIssueTransitions(key string) ([]map[string]any, error) {

// TransitionIssue moves an issue to a new status via transition ID.
func (c *Client) TransitionIssue(key, transitionID string) error {
path := fmt.Sprintf("/rest/api/3/issue/%s/transitions", key)
return c.TransitionIssueWithUpdates(key, transitionID, nil, nil)
}

// CommentFieldValue formats a comment body for the selected Jira API strategy.
func (c *Client) CommentFieldValue(body string) any {
return c.strategy.CommentBody(body)
}

// AssigneeFieldValue formats an assignee field for the selected Jira API strategy.
func (c *Client) AssigneeFieldValue(accountID, name string) map[string]any {
return c.strategy.AssigneeField(accountID, name)
}

// TransitionIssueWithUpdates moves an issue and optionally sets fields/update operations.
func (c *Client) TransitionIssueWithUpdates(key, transitionID string, fields, update map[string]any) error {
path := c.strategy.APIPath(fmt.Sprintf("issue/%s/transitions", key))
body := map[string]any{
"transition": map[string]any{"id": transitionID},
}
if len(fields) > 0 {
body["fields"] = fields
}
if len(update) > 0 {
body["update"] = update
}
_, err := c.postJSON(path, body)
return err
}

// AddComment adds a comment to an issue.
func (c *Client) AddComment(key, body string) error {
path := fmt.Sprintf("/rest/api/3/issue/%s/comment", key)
path := c.strategy.APIPath(fmt.Sprintf("issue/%s/comment", key))
payload := map[string]any{
"body": map[string]any{
"type": "doc",
"version": 1,
"content": []any{
map[string]any{
"type": "paragraph",
"content": []any{
map[string]any{
"type": "text",
"text": body,
},
},
},
},
},
"body": c.strategy.CommentBody(body),
}
_, err := c.postJSON(path, payload)
return err
Expand All @@ -257,7 +260,7 @@ func (c *Client) LinkIssues(inward, outward, linkType string) error {
"outwardIssue": map[string]any{"key": outward},
"type": map[string]any{"name": linkType},
}
_, err := c.postJSON("/rest/api/3/issueLink", body)
_, err := c.postJSON(c.strategy.APIPath("issueLink"), body)
return err
}

Expand All @@ -276,30 +279,30 @@ func (c *Client) GetIssueLinks(key string) ([]map[string]any, error) {

// DeleteIssueLink deletes an issue link by ID.
func (c *Client) DeleteIssueLink(linkID string) error {
path := fmt.Sprintf("/rest/api/3/issueLink/%s", linkID)
path := c.strategy.APIPath(fmt.Sprintf("issueLink/%s", linkID))
return c.delete(path)
}

// --- Search ---

// Search executes a JQL search query using the /rest/api/3/search/jql endpoint.
// Search executes a JQL search query using the selected Jira REST API strategy.
// Requests key and common fields by default so results are useful.
func (c *Client) Search(jql string, startAt, maxResults int, extraFields ...string) (map[string]any, error) {
fields := "key,summary,status,assignee,priority,issuetype,reporter,resolution,created,updated,labels,description,comment"
if len(extraFields) > 0 {
fields += "," + strings.Join(extraFields, ",")
}
path := fmt.Sprintf("/rest/api/3/search/jql?jql=%s&startAt=%d&maxResults=%d&fields=%s",
urlEncode(jql), startAt, maxResults, fields)
path := fmt.Sprintf("%s?jql=%s&startAt=%d&maxResults=%d&fields=%s",
c.strategy.SearchPath(), urlEncode(jql), startAt, maxResults, fields)
return c.getJSON(path)
}

// SearchWithChangelog runs a JQL search and asks Jira to expand each issue's
// changelog, so callers can inspect change history. Used by `me audit` to
// reconstruct a user's activity from issue history.
func (c *Client) SearchWithChangelog(jql string, maxResults int) (map[string]any, error) {
path := fmt.Sprintf("/rest/api/3/search/jql?jql=%s&maxResults=%d&fields=%s&expand=changelog",
urlEncode(jql), maxResults,
path := fmt.Sprintf("%s?jql=%s&maxResults=%d&fields=%s&expand=changelog",
c.strategy.SearchPath(), urlEncode(jql), maxResults,
"key,summary,status,issuetype,created,updated")
return c.getJSON(path)
}
Expand Down Expand Up @@ -391,26 +394,26 @@ func (c *Client) MoveIssuesToSprint(sprintID int, issueKeys []string) error {

// ListProjects returns all accessible projects.
func (c *Client) ListProjects() ([]map[string]any, error) {
return c.getJSONArray("/rest/api/3/project")
return c.getJSONArray(c.strategy.APIPath("project"))
}

// GetProject fetches a single project by key.
func (c *Client) GetProject(projectKey string) (map[string]any, error) {
path := fmt.Sprintf("/rest/api/3/project/%s", projectKey)
path := c.strategy.APIPath(fmt.Sprintf("project/%s", projectKey))
return c.getJSON(path)
}

// --- Users ---

// ListUsers searches for users by query string.
func (c *Client) ListUsers(query string) ([]map[string]any, error) {
path := fmt.Sprintf("/rest/api/3/user/search?query=%s", query)
path := c.strategy.UserSearchPath(query)
return c.getJSONArray(path)
}

// GetUser fetches a user by account ID.
func (c *Client) GetUser(accountID string) (map[string]any, error) {
path := fmt.Sprintf("/rest/api/3/user?accountId=%s", accountID)
path := c.strategy.UserPath(accountID)
return c.getJSON(path)
}

Expand Down
66 changes: 66 additions & 0 deletions internal/jira/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ func newTestServer(handler http.HandlerFunc) (*httptest.Server, *jira.Client) {
return server, client
}

func newServerTestServer(handler http.HandlerFunc) (*httptest.Server, *jira.Client) {
server := httptest.NewServer(handler)
client, _ := jira.NewClient(server.URL, "", "test-token", "pat", 5)
return server, client
}

func TestNewClient(t *testing.T) {
t.Run("valid", func(t *testing.T) {
c, err := jira.NewClient("https://jira.example.com", "user@example.com", "token", "basic", 10)
Expand Down Expand Up @@ -187,6 +193,7 @@ func TestCreateIssue(t *testing.T) {
project := fields["project"].(map[string]any)
assert.Equal(t, "TEST", project["key"])
assert.Equal(t, "Fix bug", fields["summary"])
assert.IsType(t, map[string]any{}, fields["description"])

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
Expand All @@ -202,6 +209,41 @@ func TestCreateIssue(t *testing.T) {
assert.Equal(t, "TEST-42", data["key"])
}

func TestServerClientUsesRESTAPI2AndWikiBody(t *testing.T) {
server, client := newServerTestServer(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/rest/api/2/issue", r.URL.Path)
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
fields := body["fields"].(map[string]any)
assert.Equal(t, "Description", fields["description"])

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
_ = json.NewEncoder(w).Encode(map[string]any{"key": "TEST-42"})
})
defer server.Close()

data, err := client.CreateIssue("TEST", "Fix bug", "Bug", "Description", "High", nil, "", nil, nil)
require.NoError(t, err)
assert.Equal(t, "TEST-42", data["key"])
}

func TestServerClientUsesLegacySearchEndpoint(t *testing.T) {
server, client := newServerTestServer(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/rest/api/2/search", r.URL.Path)
assert.Equal(t, "project = TEST", r.URL.Query().Get("jql"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"total": 0, "issues": []any{}})
})
defer server.Close()

_, err := client.Search("project = TEST", 0, 25)
require.NoError(t, err)
}

func TestDeleteIssue(t *testing.T) {
server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/rest/api/3/issue/TEST-1", r.URL.Path)
Expand Down Expand Up @@ -462,6 +504,30 @@ func TestBasicAuth(t *testing.T) {
assert.NoError(t, err)
}

func TestTransitionIssueWithUpdates(t *testing.T) {
server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/rest/api/3/issue/TEST-1/transitions", r.URL.Path)
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, map[string]any{"id": "31"}, body["transition"])
fields := body["fields"].(map[string]any)
assert.Equal(t, map[string]any{"name": "Fixed"}, fields["resolution"])
update := body["update"].(map[string]any)
assert.Contains(t, update, "comment")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(204)
})
defer server.Close()

err := client.TransitionIssueWithUpdates(
"TEST-1",
"31",
map[string]any{"resolution": map[string]any{"name": "Fixed"}},
map[string]any{"comment": []map[string]any{{"add": map[string]any{"body": client.CommentFieldValue("done")}}}},
)
require.NoError(t, err)
}

func TestAssignIssue(t *testing.T) {
t.Run("assign by account ID", func(t *testing.T) {
server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading