Files
kandev/apps/backend/internal/jira/cloud_client.go
T
João Salavisa 54b98ed9eb feat(jira): add Jira integration for ticket browsing, import, and task linking (#705)
* feat(jira): add Jira integration for ticket browsing, import, and task linking

Adds a workspace-scoped Atlassian Cloud integration: per-workspace settings
(site URL, email, API token or session cookie), encrypted secret storage,
and a REST client for tickets, projects, search, and transitions. A
background poller probes credentials on a 90s cadence and the keepalive
runs immediately on save so the UI's connected indicator updates without
delay.

The /jira page provides ticket browsing with filter pills, custom JQL,
saved views (including JQL), and configurable task-launch presets that
prefill TaskCreateDialog. JiraLinkButton on the task top bar links an
existing task to a ticket by prepending its key to the title; the ticket
popover surfaces details, transitions, and re-auth prompts on session
expiry. Topbar shows separate icon buttons for GitHub and Jira when each
is configured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): address PR review feedback

- Token-based pagination (pageToken/isLast) replaces broken offset/total pagination
- Race-fix in service.clientFor: re-check cache after acquiring write lock
- sync.Mutex guards poller started/cancel state against concurrent Start/Stop
- Channel-based + atomic.Int32 test sync replaces time.Sleep polling in poller_test
- Async auth probe on SetConfig with detached context.Background goroutine
- Distinguish 4xx vs 5xx in httpSetConfig via ErrInvalidConfig sentinel
- Propagate 4xx APIErrors from ListTransitions in GetTicket
- Distinguish "not found" from real errors in jiraSecretAdapter.Exists
- Log secret Exists errors instead of swallowing
- walkADF: handle softBreak (was only hardBreak despite the doc-comment)
- Consolidate JIRA_KEY_RE in jira-ticket-common; remove duplicates
- ticket-row imports formatRelative/statusBadgeClass from common
- isFilterState type guard validates SavedView.filters shape
- task-presets readStorage preserves user's "cleared all" intent (returns [])
- task-presets-section gates draft sync on `loaded` to avoid wiping in-progress edits
- jira-api: caller `init` no longer overrides method/body via spread order
- jira-settings: disable Test button when no saved/typed secret

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): record auth probe failure even when probe ctx expires

- RecordAuthHealth now uses a fresh context.Background-derived ctx with a
  5s timeout for the UpdateAuthHealth DB write, so a probe that consumes
  its full 15s deadline still persists the failure (greptile P1).
- SetConfig goroutine drops its redundant outer timeout — RecordAuthHealth
  manages its own probe timeout, so context.Background is enough.
- Add Service.SetProbeHook so tests can synchronize on probe completion;
  service_test waitForAuthProbe now blocks on the hook channel instead of
  time.Sleep polling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): address coderabbit review feedback (round 3)

- url.PathEscape ticketKey in GetTicket/ListTransitions/DoTransition paths
  to harden against malicious keys ('../../foo', 'KEY?override=x').
- factoryHit on the test fixture is now sync/atomic.Int32; the async probe
  goroutine spawned by SetConfig races the test thread under -race.
- GetConfig logs Reveal failures (was silently dropped, leaving the UI to
  show "no expiry info" indistinguishably from a freshly-saved cookie).
- DeleteConfig logs secret-delete errors instead of swallowing them.
- clientFor distinguishes a secret-store failure from "not configured" so
  the UI doesn't render the configure-CTA for transient backend errors.
- Unify ErrNotConfigured wire code: HTTP and WebSocket both return
  "JIRA_NOT_CONFIGURED" via a shared constant.
- JiraLinkButton strips a leading "PROJ-123: " before re-prefixing so
  re-linking a task replaces the prefix instead of stacking it.
- jira-settings clears testResult on save/delete so the alert reflects
  only the currently-saved credentials.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(jira): cover Reveal error path in clientFor

Reveal-error case is distinct from "no config row" — the former wraps
the underlying error, the latter returns ErrNotConfigured. Without
this test the changed contract has no regression coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): route secret adapter Set through Exists

Treating every Get error as "not found" turned a transient DB error on
an existing row into a constraint-violation Create that masked the real
cause. Reuse Exists so the "secret not found:" prefix is the only path
that falls through to Create.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(jira): cover jiraSecretAdapter.Set branching

Routing Set through Exists changed the contract: a transient DB error
in Get now propagates instead of cascading into a constraint-violation
Create. Add three tests so a future refactor cannot regress this without
CI catching it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): split Reveal errors and guard Save for new configs

- resolveCredentials now mirrors the clientFor split: a Reveal error
  surfaces as `read jira secret: %w` with a warn log instead of
  collapsing into "no token stored — paste one to test".
- disableSave gains the same missing-secret guard as disableTest so a
  brand-new config can no longer POST an empty secret and leave the
  workspace stuck with hasSecret=false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:47:45 +01:00

444 lines
15 KiB
Go

package jira
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// CloudClient is an Atlassian Cloud REST v3 client. It supports two auth modes:
//
// - api_token: Basic auth with {email}:{token} (the standard, recommended path).
// - session_cookie: the secret is the JWT value of `cloud.session.token`
// (password accounts) or `tenant.session.token` (SSO), copied from DevTools
// → Application → Cookies. The client wraps it under both cookie names so
// a single paste works for both account types.
//
// The client holds no state beyond credentials so it can be recreated cheaply
// per workspace if config changes.
type CloudClient struct {
http *http.Client
siteURL string
email string
secret string
authMethod string
maxBodySize int64
}
// 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).
func NewCloudClient(cfg *JiraConfig, secret string) *CloudClient {
site := strings.TrimRight(cfg.SiteURL, "/")
if site != "" && !strings.Contains(site, "://") {
site = "https://" + site
}
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,
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)
switch c.authMethod {
case AuthMethodAPIToken:
basic := base64.StdEncoding.EncodeToString([]byte(c.email + ":" + c.secret))
req.Header.Set("Authorization", "Basic "+basic)
case AuthMethodSessionCookie:
req.Header.Set("Cookie", buildSessionCookieHeader(c.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.
func (c *CloudClient) do(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: summarizeBody(resp, raw)}
}
// 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: "Atlassian returned an HTML page — the session likely requires step-up auth or has expired. Refresh your Jira tab and copy the cookie again."}
}
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 non-2xx response.
// For HTML bodies it skips the page content in favor of a step-up hint; for
// plain text or JSON it returns the body verbatim (capped, so we don't spam
// logs with multi-KB pages).
func summarizeBody(resp *http.Response, raw []byte) string {
if isHTMLResponse(resp, raw) {
return "Atlassian returned an HTML page (status " + strconv.Itoa(resp.StatusCode) +
"). This usually means step-up authentication is required; sign in again in your Jira tab and re-copy the cookie."
}
const maxMsg = 500
if len(raw) > maxMsg {
return string(raw[:maxMsg]) + "…"
}
return string(raw)
}
// 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, "/rest/api/3/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 {
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 := "/rest/api/3/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 := "/rest/api/3/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 := "/rest/api/3/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"`
}
if err := c.do(ctx, http.MethodGet, "/rest/api/3/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
}
// searchResponse mirrors the subset of /rest/api/3/search/jql we consume. The
// new endpoint is token-paginated: there's no total count and pagination uses
// nextPageToken rather than startAt. Transitions are intentionally omitted from
// search results (fetched lazily when the user opens a ticket).
type searchResponse struct {
Issues []issueResponse `json:"issues"`
NextPageToken string `json:"nextPageToken"`
IsLast bool `json:"isLast"`
}
// SearchTickets runs a JQL search and returns a page of tickets. Uses the
// /rest/api/3/search/jql endpoint (the legacy /search was removed by Atlassian
// in 2025). 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) {
if maxResults <= 0 {
maxResults = 25
}
if maxResults > 100 {
maxResults = 100
}
body := map[string]interface{}{
"jql": jql,
"maxResults": maxResults,
"fields": []string{
"summary", "status", "project", "issuetype",
"priority", "assignee", "reporter", "updated",
},
}
if pageToken != "" {
body["nextPageToken"] = pageToken
}
var resp searchResponse
if err := c.do(ctx, http.MethodPost, "/rest/api/3/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
}
// 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{
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")
}
}
}