703 lines
25 KiB
Go
703 lines
25 KiB
Go
package jira
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// CloudClient is a Jira REST client. Despite the name, it supports both
|
|
// Atlassian Cloud and self-hosted Server / Data Center instances:
|
|
//
|
|
// - Cloud uses REST v3 (`/rest/api/3/...`) and the token-paginated
|
|
// `/search/jql` endpoint.
|
|
// - Server/DC exposes only REST v2 (`/rest/api/2/...`) and the legacy
|
|
// `startAt`-paginated `/search`.
|
|
//
|
|
// Auth modes:
|
|
//
|
|
// - api_token (Cloud-only): Basic auth with {email}:{token} minted at
|
|
// id.atlassian.com.
|
|
// - pat (Server-only): Personal Access Token sent as `Authorization: Bearer`.
|
|
// - session_cookie (Cloud-only): the secret is the JWT value of
|
|
// `cloud.session.token` (password accounts) or `tenant.session.token`
|
|
// (SSO), copied from DevTools → Application → Cookies.
|
|
// buildSessionCookieHeader emits those two Atlassian-specific cookie
|
|
// names, and validateAuthInstance rejects the combo on Server/DC where
|
|
// the equivalent cookie is JSESSIONID — Server/DC users authenticate
|
|
// via PAT until a Server-aware session-cookie path is added.
|
|
//
|
|
// The client holds no state beyond credentials so it can be recreated cheaply
|
|
// when config changes.
|
|
type CloudClient struct {
|
|
http *http.Client
|
|
siteURL string
|
|
email string
|
|
secret string
|
|
secretMu sync.RWMutex // guards secret for concurrent read/write
|
|
authMethod string
|
|
instanceType string
|
|
apiBase string // "/rest/api/3" for cloud, "/rest/api/2" for server.
|
|
maxBodySize int64
|
|
// refresher enables on-demand token refresh for OAuth. When non-nil and a
|
|
// request gets 401, the client refreshes the access token and retries once.
|
|
refresher TokenRefresher
|
|
// workspaceID identifies the workspace for token refresh.
|
|
workspaceID string
|
|
}
|
|
|
|
// TokenRefresher is implemented by the Service to allow CloudClient to refresh
|
|
// OAuth access tokens on-demand when a request hits a 401.
|
|
type TokenRefresher interface {
|
|
// staleToken is the access token that just hit a 401; the refresher skips
|
|
// rotation if the stored token already changed (another goroutine won the
|
|
// race). Pass "" to force a refresh.
|
|
RefreshOAuthToken(ctx context.Context, workspaceID, staleToken string) error
|
|
RevealAccessToken(ctx context.Context, workspaceID string) (string, error)
|
|
}
|
|
|
|
// SetRefresher wires the OAuth token refresher. Called by the service after
|
|
// constructing the client so the client can trigger a refresh on 401.
|
|
func (c *CloudClient) SetRefresher(workspaceID string, r TokenRefresher) {
|
|
c.workspaceID = workspaceID
|
|
c.refresher = r
|
|
}
|
|
|
|
// NewCloudClient builds a client from a JiraConfig + secret. siteURL is
|
|
// normalized: trailing slash stripped, https:// prepended when the user saved
|
|
// only a hostname (legacy rows; new rows are normalized on save). An empty
|
|
// InstanceType is treated as cloud — that's how every config row written by
|
|
// pre-Server-support releases looks, and we can't pick differently without
|
|
// breaking those installs.
|
|
func NewCloudClient(cfg *JiraConfig, secret string) *CloudClient {
|
|
site := strings.TrimRight(cfg.SiteURL, "/")
|
|
if site != "" && !strings.Contains(site, "://") {
|
|
site = "https://" + site
|
|
}
|
|
instance := cfg.InstanceType
|
|
if instance == "" {
|
|
instance = InstanceTypeCloud
|
|
}
|
|
apiBase := "/rest/api/3"
|
|
if instance == InstanceTypeServer {
|
|
apiBase = "/rest/api/2"
|
|
}
|
|
return &CloudClient{
|
|
http: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
// Don't follow redirects: Jira REST endpoints shouldn't redirect for
|
|
// authenticated calls. Atlassian redirects unauthenticated or
|
|
// step-up-required requests to a login HTML page (with a 200 status),
|
|
// which masks the real auth failure and breaks JSON decoding. By
|
|
// returning the last response as-is, we preserve the 3xx status and
|
|
// the informative body ("Step-up authentication is required...").
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
siteURL: site,
|
|
email: cfg.Email,
|
|
secret: secret,
|
|
authMethod: cfg.AuthMethod,
|
|
instanceType: instance,
|
|
apiBase: apiBase,
|
|
maxBodySize: 4 << 20, // 4 MB — Jira payloads are small by design.
|
|
}
|
|
}
|
|
|
|
// authorize applies the client's auth strategy to a request.
|
|
const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)"
|
|
|
|
func (c *CloudClient) authorize(req *http.Request) {
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", userAgent)
|
|
c.secretMu.RLock()
|
|
secret := c.secret
|
|
c.secretMu.RUnlock()
|
|
switch c.authMethod {
|
|
case AuthMethodAPIToken:
|
|
basic := base64.StdEncoding.EncodeToString([]byte(c.email + ":" + secret))
|
|
req.Header.Set("Authorization", "Basic "+basic)
|
|
case AuthMethodPAT:
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
case AuthMethodSessionCookie:
|
|
req.Header.Set("Cookie", buildSessionCookieHeader(secret))
|
|
case AuthMethodOAuth:
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
}
|
|
}
|
|
|
|
// buildSessionCookieHeader wraps a bare session-token JWT under both
|
|
// known Atlassian cookie names. A single paste works for password accounts
|
|
// (`cloud.session.token`) and SSO tenants (`tenant.session.token`) without
|
|
// asking the user to know which one they have.
|
|
func buildSessionCookieHeader(secret string) string {
|
|
return "cloud.session.token=" + secret + "; tenant.session.token=" + secret
|
|
}
|
|
|
|
// do executes a request and decodes a 2xx JSON body into out (may be nil).
|
|
// Non-2xx responses are returned as *APIError so callers can switch on status.
|
|
// For OAuth auth, a 401 triggers an automatic token refresh and single retry.
|
|
func (c *CloudClient) do(ctx context.Context, method, path string, body interface{}, out interface{}) error {
|
|
err := c.doOnce(ctx, method, path, body, out)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if c.tryRefreshOn401(ctx, err) {
|
|
return c.doOnce(ctx, method, path, body, out)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// tryRefreshOn401 checks if the error is a 401 and refreshes the OAuth token.
|
|
// Returns true if the token was refreshed and the caller should retry.
|
|
func (c *CloudClient) tryRefreshOn401(ctx context.Context, err error) bool {
|
|
if c.authMethod != AuthMethodOAuth || c.refresher == nil {
|
|
return false
|
|
}
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized {
|
|
return false
|
|
}
|
|
c.secretMu.RLock()
|
|
stale := c.secret
|
|
c.secretMu.RUnlock()
|
|
if refreshErr := c.refresher.RefreshOAuthToken(ctx, c.workspaceID, stale); refreshErr != nil {
|
|
return false
|
|
}
|
|
newToken, revealErr := c.refresher.RevealAccessToken(ctx, c.workspaceID)
|
|
if revealErr != nil {
|
|
return false
|
|
}
|
|
c.secretMu.Lock()
|
|
c.secret = newToken
|
|
c.secretMu.Unlock()
|
|
return true
|
|
}
|
|
|
|
// doOnce executes a single request attempt without retry logic.
|
|
func (c *CloudClient) doOnce(ctx context.Context, method, path string, body interface{}, out interface{}) error {
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal body: %w", err)
|
|
}
|
|
reqBody = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.siteURL+path, reqBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
c.authorize(req)
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBodySize))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return &APIError{StatusCode: resp.StatusCode, Message: c.summarizeBody(resp, raw, false)}
|
|
}
|
|
// Guardrail: some misconfigured Atlassian flows return a 200 HTML login
|
|
// page instead of JSON. If we accidentally get HTML, surface an auth error
|
|
// rather than letting json.Unmarshal fail with "invalid character '<'".
|
|
if isHTMLResponse(resp, raw) {
|
|
return &APIError{StatusCode: resp.StatusCode, Message: c.summarizeBody(resp, raw, true)}
|
|
}
|
|
if out == nil || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(raw, out)
|
|
}
|
|
|
|
// isHTMLResponse checks whether a response body is HTML rather than JSON, so
|
|
// we can convert Atlassian's implicit-login-page responses into a clear
|
|
// auth error message.
|
|
func isHTMLResponse(resp *http.Response, raw []byte) bool {
|
|
ct := resp.Header.Get("Content-Type")
|
|
if strings.Contains(strings.ToLower(ct), "text/html") {
|
|
return true
|
|
}
|
|
trimmed := bytes.TrimLeft(raw, " \t\r\n")
|
|
return bytes.HasPrefix(trimmed, []byte("<"))
|
|
}
|
|
|
|
// summarizeBody returns a short, useful error message from a Jira response.
|
|
// For HTML bodies it skips the page content in favor of an auth-method-aware
|
|
// hint; for plain text or JSON it returns the body verbatim (capped, so we
|
|
// don't spam logs with multi-KB pages). htmlOn2xx flags the "successful status
|
|
// but HTML body" case — that means the response went through an auth-filter
|
|
// login page instead of the REST API, so the hint adds "instead of JSON" to
|
|
// distinguish it from the more common error-status HTML case.
|
|
func (c *CloudClient) summarizeBody(resp *http.Response, raw []byte, htmlOn2xx bool) string {
|
|
if htmlOn2xx || isHTMLResponse(resp, raw) {
|
|
suffix := ". "
|
|
if htmlOn2xx {
|
|
suffix = " instead of JSON. "
|
|
}
|
|
return "Jira returned an HTML page (status " + strconv.Itoa(resp.StatusCode) +
|
|
")" + suffix + c.authHint()
|
|
}
|
|
const maxMsg = 500
|
|
if len(raw) > maxMsg {
|
|
return string(raw[:maxMsg]) + "…"
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// authHint picks an actionable explanation for an HTML-from-API response,
|
|
// based on auth method and instance type. The wording differs because the
|
|
// failure mode is different in each combo: a Cloud API token rejection looks
|
|
// the same on the wire as a Server PAT rejection, but the user's next step
|
|
// (check id.atlassian.com vs. check the instance's PAT page) is different.
|
|
func (c *CloudClient) authHint() string {
|
|
switch c.authMethod {
|
|
case AuthMethodAPIToken:
|
|
if c.instanceType == InstanceTypeServer {
|
|
// Mismatched config: API-token auth is Cloud-only. Server/DC will
|
|
// reject `Basic email:token` and redirect to /login.jsp, which the
|
|
// JSON decoder sees as HTML.
|
|
return "API token auth is Cloud-only — this site looks like Jira Server/DC. Switch the auth method to PAT (Personal Access Token)."
|
|
}
|
|
return "Check the email and API token; create a new one at id.atlassian.com/manage-profile/security/api-tokens if needed."
|
|
case AuthMethodPAT:
|
|
return "The Personal Access Token is rejected. Recreate it from your Jira profile → Personal Access Tokens and update the saved value."
|
|
case AuthMethodSessionCookie:
|
|
return "The session cookie likely expired or requires step-up auth. Sign in again in your Jira tab and re-copy the cookie."
|
|
default:
|
|
return "Check the saved credentials."
|
|
}
|
|
}
|
|
|
|
// TestAuth hits /rest/api/3/myself which is the cheapest authenticated
|
|
// endpoint; a 200 proves credentials work and identifies the user.
|
|
func (c *CloudClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) {
|
|
var body struct {
|
|
AccountID string `json:"accountId"`
|
|
DisplayName string `json:"displayName"`
|
|
EmailAddress string `json:"emailAddress"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, c.apiBase+"/myself", nil, &body); err != nil {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) {
|
|
return &TestConnectionResult{OK: false, Error: apiErr.Error()}, nil
|
|
}
|
|
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
|
|
}
|
|
return &TestConnectionResult{
|
|
OK: true,
|
|
AccountID: body.AccountID,
|
|
DisplayName: body.DisplayName,
|
|
Email: body.EmailAddress,
|
|
}, nil
|
|
}
|
|
|
|
// issueResponse mirrors the subset of the Atlassian issue payload we consume.
|
|
type issueResponse struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
Fields struct {
|
|
Summary string `json:"summary"`
|
|
Description interface{} `json:"description"` // ADF or string depending on API version
|
|
Updated string `json:"updated"`
|
|
Status struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
StatusCategory struct {
|
|
Key string `json:"key"` // "new" | "indeterminate" | "done"
|
|
} `json:"statusCategory"`
|
|
} `json:"status"`
|
|
Project struct {
|
|
Key string `json:"key"`
|
|
} `json:"project"`
|
|
IssueType struct {
|
|
Name string `json:"name"`
|
|
IconURL string `json:"iconUrl"`
|
|
} `json:"issuetype"`
|
|
Priority struct {
|
|
Name string `json:"name"`
|
|
IconURL string `json:"iconUrl"`
|
|
} `json:"priority"`
|
|
Assignee *jiraUser `json:"assignee"`
|
|
Reporter *jiraUser `json:"reporter"`
|
|
} `json:"fields"`
|
|
}
|
|
|
|
type jiraUser struct {
|
|
DisplayName string `json:"displayName"`
|
|
AvatarURLs struct {
|
|
Size24 string `json:"24x24"`
|
|
Size32 string `json:"32x32"`
|
|
} `json:"avatarUrls"`
|
|
}
|
|
|
|
func (u *jiraUser) avatar() string {
|
|
if u == nil {
|
|
return ""
|
|
}
|
|
if u.AvatarURLs.Size24 != "" {
|
|
return u.AvatarURLs.Size24
|
|
}
|
|
return u.AvatarURLs.Size32
|
|
}
|
|
|
|
func (u *jiraUser) name() string {
|
|
if u == nil {
|
|
return ""
|
|
}
|
|
return u.DisplayName
|
|
}
|
|
|
|
type transitionsResponse struct {
|
|
Transitions []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
To struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"to"`
|
|
} `json:"transitions"`
|
|
}
|
|
|
|
// GetTicket fetches the ticket + available transitions in two calls. We ask
|
|
// Jira for the ADF-rendered description so the UI gets plain-text rather than
|
|
// an opaque document tree. A 4xx on the transitions call is bubbled up so the
|
|
// UI can surface auth/permission failures rather than silently rendering a
|
|
// ticket with an empty transitions menu.
|
|
func (c *CloudClient) GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, error) {
|
|
var issue issueResponse
|
|
path := c.apiBase + "/issue/" + url.PathEscape(ticketKey) + "?expand=renderedFields"
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &issue); err != nil {
|
|
return nil, err
|
|
}
|
|
t := issueToTicket(&issue, c.siteURL)
|
|
transitions, terr := c.ListTransitions(ctx, ticketKey)
|
|
if terr != nil {
|
|
var apiErr *APIError
|
|
if errors.As(terr, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
|
return nil, terr
|
|
}
|
|
// Network blips or 5xx: keep the ticket and let the UI render without
|
|
// transitions. The user can still read the ticket and refresh.
|
|
} else {
|
|
t.Transitions = transitions
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// ListTransitions returns the transitions currently available for ticketKey.
|
|
func (c *CloudClient) ListTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) {
|
|
var resp transitionsResponse
|
|
path := c.apiBase + "/issue/" + url.PathEscape(ticketKey) + "/transitions"
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]JiraTransition, 0, len(resp.Transitions))
|
|
for _, t := range resp.Transitions {
|
|
out = append(out, JiraTransition{
|
|
ID: t.ID,
|
|
Name: t.Name,
|
|
ToStatusID: t.To.ID,
|
|
ToStatusName: t.To.Name,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DoTransition asks Jira to apply a specific transition by ID. The Jira API
|
|
// returns 204 on success.
|
|
func (c *CloudClient) DoTransition(ctx context.Context, ticketKey, transitionID string) error {
|
|
body := map[string]interface{}{
|
|
"transition": map[string]string{"id": transitionID},
|
|
}
|
|
path := c.apiBase + "/issue/" + url.PathEscape(ticketKey) + "/transitions"
|
|
return c.do(ctx, http.MethodPost, path, body, nil)
|
|
}
|
|
|
|
// ListProjects returns up to 200 projects (the Jira max per page for this
|
|
// endpoint). Fine for the settings dropdown; pagination can be added later if
|
|
// it ever becomes a problem.
|
|
func (c *CloudClient) ListProjects(ctx context.Context) ([]JiraProject, error) {
|
|
var body struct {
|
|
Values []struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
} `json:"values"`
|
|
}
|
|
// Server/DC exposes /project/search starting with Jira 8.0 (2019). For
|
|
// older instances the call returns 404, which surfaces as an API error the
|
|
// user can read. The legacy GET /project endpoint isn't paginated and we
|
|
// don't need to keep two code paths just for ancient deployments.
|
|
if err := c.do(ctx, http.MethodGet, c.apiBase+"/project/search?maxResults=200", nil, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]JiraProject, 0, len(body.Values))
|
|
for _, p := range body.Values {
|
|
out = append(out, JiraProject{ID: p.ID, Key: p.Key, Name: p.Name})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListProjectStatuses returns the workflow statuses defined for a project.
|
|
// Jira's GET /project/{key}/statuses returns one entry per issue type, each
|
|
// carrying that issue type's status list; the same status often appears under
|
|
// several issue types. We flatten them all and de-dupe by status id so the UI
|
|
// gets the union of statuses reachable anywhere in the project. Works against
|
|
// both Cloud (v3) and Server/DC (v2) since the payload shape is identical.
|
|
func (c *CloudClient) ListProjectStatuses(ctx context.Context, projectKey string) ([]JiraStatus, error) {
|
|
var body []struct {
|
|
Statuses []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
StatusCategory struct {
|
|
Key string `json:"key"` // "new" | "indeterminate" | "done"
|
|
} `json:"statusCategory"`
|
|
} `json:"statuses"`
|
|
}
|
|
path := c.apiBase + "/project/" + url.PathEscape(projectKey) + "/statuses"
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]JiraStatus, 0)
|
|
seen := make(map[string]struct{})
|
|
for _, it := range body {
|
|
for _, s := range it.Statuses {
|
|
if _, dup := seen[s.ID]; dup {
|
|
continue
|
|
}
|
|
seen[s.ID] = struct{}{}
|
|
out = append(out, JiraStatus{
|
|
ID: s.ID,
|
|
Name: s.Name,
|
|
StatusCategory: s.StatusCategory.Key,
|
|
})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// cloudSearchResponse mirrors the subset of /rest/api/3/search/jql we consume.
|
|
// The token-paginated endpoint exposes no total count; pagination is driven by
|
|
// `nextPageToken`. Transitions are intentionally omitted from search results
|
|
// (fetched lazily when the user opens a ticket).
|
|
type cloudSearchResponse struct {
|
|
Issues []issueResponse `json:"issues"`
|
|
NextPageToken string `json:"nextPageToken"`
|
|
IsLast bool `json:"isLast"`
|
|
}
|
|
|
|
// serverSearchResponse mirrors the legacy `startAt`-paginated `/search`
|
|
// endpoint that Jira Server / Data Center exposes. We synthesize a string
|
|
// page token from `startAt + maxResults` so the public SearchResult shape stays
|
|
// identical regardless of upstream pagination style.
|
|
type serverSearchResponse struct {
|
|
Issues []issueResponse `json:"issues"`
|
|
StartAt int `json:"startAt"`
|
|
MaxResults int `json:"maxResults"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// searchFields is the list of issue fields we ask Jira to return. Kept short
|
|
// because tickets are listed in dense table rows; the rest is fetched lazily
|
|
// on click.
|
|
const jiraFieldUpdated = "updated"
|
|
|
|
var searchFields = []string{
|
|
"summary", "status", "project", "issuetype",
|
|
"priority", "assignee", "reporter", jiraFieldUpdated,
|
|
}
|
|
|
|
var watcherSearchFields = []string{
|
|
"summary", "status", "project", "issuetype",
|
|
"priority", "assignee", "reporter", jiraFieldUpdated, "description",
|
|
}
|
|
|
|
// SearchTickets runs a JQL search and returns a page of tickets. The transport
|
|
// branches on instance type because Cloud and Server/DC expose different
|
|
// search endpoints with incompatible pagination shapes:
|
|
//
|
|
// - Cloud: POST /rest/api/3/search/jql — token-paginated, no total count.
|
|
// The legacy /search was removed by Atlassian in 2025.
|
|
// - Server/DC: POST /rest/api/2/search — startAt/total style; we encode the
|
|
// next startAt offset into the SearchResult.NextPageToken string so the
|
|
// caller can opaquely round-trip it.
|
|
//
|
|
// pageToken is the cursor returned in the previous page's NextPageToken;
|
|
// pass "" for the first page. maxResults is capped at 100.
|
|
func (c *CloudClient) SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) {
|
|
return c.searchTickets(ctx, jql, pageToken, maxResults, searchFields)
|
|
}
|
|
|
|
// SearchTicketsForWatch runs a JQL search with the description field included
|
|
// for watcher-created task prompts. It uses the same endpoint and pagination
|
|
// behavior as SearchTickets while keeping the interactive field set compact.
|
|
func (c *CloudClient) SearchTicketsForWatch(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) {
|
|
return c.searchTickets(ctx, jql, pageToken, maxResults, watcherSearchFields)
|
|
}
|
|
|
|
func (c *CloudClient) searchTickets(ctx context.Context, jql, pageToken string, maxResults int, fields []string) (*SearchResult, error) {
|
|
if maxResults <= 0 {
|
|
maxResults = 25
|
|
}
|
|
if maxResults > 100 {
|
|
maxResults = 100
|
|
}
|
|
if c.instanceType == InstanceTypeServer {
|
|
return c.searchTicketsServer(ctx, jql, pageToken, maxResults, fields)
|
|
}
|
|
return c.searchTicketsCloud(ctx, jql, pageToken, maxResults, fields)
|
|
}
|
|
|
|
func (c *CloudClient) searchTicketsCloud(ctx context.Context, jql, pageToken string, maxResults int, fields []string) (*SearchResult, error) {
|
|
body := map[string]interface{}{
|
|
"jql": jql,
|
|
"maxResults": maxResults,
|
|
"fields": fields,
|
|
}
|
|
if pageToken != "" {
|
|
body["nextPageToken"] = pageToken
|
|
}
|
|
var resp cloudSearchResponse
|
|
if err := c.do(ctx, http.MethodPost, c.apiBase+"/search/jql", body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
out := &SearchResult{
|
|
MaxResults: maxResults,
|
|
IsLast: resp.IsLast,
|
|
NextPageToken: resp.NextPageToken,
|
|
Tickets: make([]JiraTicket, 0, len(resp.Issues)),
|
|
}
|
|
for i := range resp.Issues {
|
|
out.Tickets = append(out.Tickets, issueToTicket(&resp.Issues[i], c.siteURL))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *CloudClient) searchTicketsServer(ctx context.Context, jql, pageToken string, maxResults int, fields []string) (*SearchResult, error) {
|
|
startAt := 0
|
|
if pageToken != "" {
|
|
// A malformed token is fixed by starting over from the first page —
|
|
// safer than 500-ing on what is ultimately UI-driven state.
|
|
if n, err := strconv.Atoi(pageToken); err == nil && n > 0 {
|
|
startAt = n
|
|
}
|
|
}
|
|
body := map[string]interface{}{
|
|
"jql": jql,
|
|
"startAt": startAt,
|
|
"maxResults": maxResults,
|
|
"fields": fields,
|
|
}
|
|
var resp serverSearchResponse
|
|
if err := c.do(ctx, http.MethodPost, c.apiBase+"/search", body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
nextStart := resp.StartAt + len(resp.Issues)
|
|
isLast := nextStart >= resp.Total || len(resp.Issues) == 0
|
|
nextToken := ""
|
|
if !isLast {
|
|
nextToken = strconv.Itoa(nextStart)
|
|
}
|
|
out := &SearchResult{
|
|
MaxResults: maxResults,
|
|
IsLast: isLast,
|
|
NextPageToken: nextToken,
|
|
Tickets: make([]JiraTicket, 0, len(resp.Issues)),
|
|
}
|
|
for i := range resp.Issues {
|
|
out.Tickets = append(out.Tickets, issueToTicket(&resp.Issues[i], c.siteURL))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// issueToTicket converts the API response shape to our public JiraTicket.
|
|
// Factored out so GetTicket and SearchTickets stay consistent.
|
|
func issueToTicket(issue *issueResponse, siteURL string) JiraTicket {
|
|
return JiraTicket{
|
|
ID: issue.ID,
|
|
Key: issue.Key,
|
|
Summary: issue.Fields.Summary,
|
|
StatusID: issue.Fields.Status.ID,
|
|
StatusName: issue.Fields.Status.Name,
|
|
StatusCategory: issue.Fields.Status.StatusCategory.Key,
|
|
ProjectKey: issue.Fields.Project.Key,
|
|
IssueType: issue.Fields.IssueType.Name,
|
|
IssueTypeIcon: issue.Fields.IssueType.IconURL,
|
|
Priority: issue.Fields.Priority.Name,
|
|
PriorityIcon: issue.Fields.Priority.IconURL,
|
|
AssigneeName: issue.Fields.Assignee.name(),
|
|
AssigneeAvatar: issue.Fields.Assignee.avatar(),
|
|
ReporterName: issue.Fields.Reporter.name(),
|
|
ReporterAvatar: issue.Fields.Reporter.avatar(),
|
|
Updated: issue.Fields.Updated,
|
|
URL: siteURL + "/browse/" + issue.Key,
|
|
Description: extractDescription(issue.Fields.Description),
|
|
}
|
|
}
|
|
|
|
// extractDescription handles the three shapes Jira's `description` field may
|
|
// return: nil (empty), a plain string (older APIs / some integrations), or an
|
|
// Atlassian Document Format node tree (API v3). For ADF we walk the tree
|
|
// pulling out text nodes so the UI gets a readable markdown-ish version.
|
|
func extractDescription(raw interface{}) string {
|
|
if raw == nil {
|
|
return ""
|
|
}
|
|
if s, ok := raw.(string); ok {
|
|
return s
|
|
}
|
|
m, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
walkADF(m, &b)
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
|
|
// walkADF is a minimal Atlassian Document Format walker. It recognizes text
|
|
// nodes, hard/soft breaks, and paragraphs; other node types (code blocks,
|
|
// tables, mentions) collapse to their text content. Good enough for task
|
|
// descriptions — we don't try to preserve rich formatting.
|
|
func walkADF(node map[string]interface{}, b *strings.Builder) {
|
|
switch node["type"] {
|
|
case "text":
|
|
if s, ok := node["text"].(string); ok {
|
|
b.WriteString(s)
|
|
}
|
|
case "hardBreak", "softBreak":
|
|
b.WriteString("\n")
|
|
}
|
|
content, ok := node["content"].([]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
for i, c := range content {
|
|
if child, ok := c.(map[string]interface{}); ok {
|
|
walkADF(child, b)
|
|
}
|
|
// Insert paragraph breaks between top-level siblings.
|
|
if t, _ := node["type"].(string); t == "doc" && i < len(content)-1 {
|
|
b.WriteString("\n\n")
|
|
}
|
|
}
|
|
}
|