Files
kandev/apps/backend/internal/jira/service.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

407 lines
14 KiB
Go

package jira
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// SecretStore is the subset of the secrets store the service needs. Kept small
// so tests can fake it easily.
type SecretStore interface {
Reveal(ctx context.Context, id string) (string, error)
Set(ctx context.Context, id, name, value string) error
Delete(ctx context.Context, id string) error
Exists(ctx context.Context, id string) (bool, error)
}
// Service orchestrates Jira config storage, the per-workspace client cache, and
// the fetch/transition operations used by the WebSocket + HTTP handlers.
type Service struct {
store *Store
secrets SecretStore
log *logger.Logger
mu sync.Mutex
clientFn ClientFactory
cache map[string]Client // workspaceID → client, cleared on config change.
probeHook func(workspaceID string)
}
// ClientFactory builds a Client for the given config + secret. Overridable so
// tests can inject fakes without touching HTTP.
type ClientFactory func(cfg *JiraConfig, secret string) Client
// DefaultClientFactory returns a real CloudClient.
func DefaultClientFactory(cfg *JiraConfig, secret string) Client {
return NewCloudClient(cfg, secret)
}
// NewService wires the service with a store, secret backend, and client
// factory. Pass nil for clientFn to use DefaultClientFactory.
func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *logger.Logger) *Service {
if clientFn == nil {
clientFn = DefaultClientFactory
}
return &Service{
store: store,
secrets: secrets,
log: log,
clientFn: clientFn,
cache: make(map[string]Client),
}
}
// GetConfig returns the workspace config enriched with a HasSecret flag so the
// UI can distinguish "configured but empty" from "needs credentials". For
// session_cookie auth, it also tries to decode the JWT in the stored cookie
// and surface its expiry so the UI can warn the user before the session dies.
func (s *Service) GetConfig(ctx context.Context, workspaceID string) (*JiraConfig, error) {
cfg, err := s.store.GetConfig(ctx, workspaceID)
if err != nil || cfg == nil {
return cfg, err
}
if s.secrets == nil {
return cfg, nil
}
key := SecretKeyForWorkspace(workspaceID)
exists, existsErr := s.secrets.Exists(ctx, key)
if existsErr != nil {
s.log.Warn("jira: secret exists check failed",
zap.String("workspace_id", workspaceID), zap.Error(existsErr))
}
cfg.HasSecret = exists
if exists && cfg.AuthMethod == AuthMethodSessionCookie {
secret, revealErr := s.secrets.Reveal(ctx, key)
if revealErr != nil {
s.log.Warn("jira: secret reveal failed",
zap.String("workspace_id", workspaceID), zap.Error(revealErr))
} else {
cfg.SecretExpiresAt = parseSessionCookieExpiry(secret)
}
}
return cfg, nil
}
// ErrInvalidConfig is returned by SetConfig when the request fails validation
// (missing workspace, bad auth method, etc.). Callers map it to HTTP 400.
var ErrInvalidConfig = errors.New("jira: invalid configuration")
// SetConfig is upsert: it writes the workspace row and, when a new secret is
// provided, stores it in the encrypted secret store. An empty Secret means
// "keep the existing token" — this lets the UI edit auxiliary fields without
// forcing the user to paste the token again.
func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*JiraConfig, error) {
if err := validateConfigRequest(req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error())
}
cfg := &JiraConfig{
WorkspaceID: req.WorkspaceID,
SiteURL: normalizeSiteURL(req.SiteURL),
Email: req.Email,
AuthMethod: req.AuthMethod,
DefaultProjectKey: req.DefaultProjectKey,
}
if err := s.store.UpsertConfig(ctx, cfg); err != nil {
return nil, fmt.Errorf("upsert jira config: %w", err)
}
if req.Secret != "" && s.secrets != nil {
if err := s.secrets.Set(ctx,
SecretKeyForWorkspace(req.WorkspaceID),
"Jira token ("+req.WorkspaceID+")",
req.Secret,
); err != nil {
return nil, fmt.Errorf("store jira secret: %w", err)
}
}
s.invalidateClient(req.WorkspaceID)
// Probe asynchronously so a slow Atlassian doesn't stall the save response.
// RecordAuthHealth manages its own probe timeout, so a fresh
// context.Background() is enough — the request ctx may be cancelled when
// this returns, but the probe and the DB write must still complete.
go func(workspaceID string) {
s.RecordAuthHealth(context.Background(), workspaceID)
}(req.WorkspaceID)
return s.GetConfig(ctx, req.WorkspaceID)
}
// DeleteConfig removes both the config row and the stored secret. A failure to
// delete the secret is logged (not returned) so the config row deletion still
// surfaces success — the orphaned secret is rare and the user can retry by
// re-saving + deleting again.
func (s *Service) DeleteConfig(ctx context.Context, workspaceID string) error {
if err := s.store.DeleteConfig(ctx, workspaceID); err != nil {
return err
}
if s.secrets != nil {
if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil {
s.log.Warn("jira: secret delete failed",
zap.String("workspace_id", workspaceID), zap.Error(err))
}
}
s.invalidateClient(workspaceID)
return nil
}
// TestConnection validates credentials either from a fresh SetConfigRequest
// (before persisting) or from the stored config (after saving). Returns a
// structured result rather than an error so the UI can render the failure
// inline.
func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) {
cfg, secret, err := s.resolveCredentials(ctx, req)
if err != nil {
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
}
client := s.clientFn(cfg, secret)
return client.TestAuth(ctx)
}
// ProbeAuth validates the stored credentials for a workspace by hitting the
// cheapest authenticated endpoint. Used by the background auth-health poller
// to detect expired session cookies / step-up auth before the user clicks
// through to a tab that would 303. Errors are returned as a result so the
// poller can persist them as a failure rather than dropping them.
func (s *Service) ProbeAuth(ctx context.Context, workspaceID string) (*TestConnectionResult, error) {
client, err := s.clientFor(ctx, workspaceID)
if err != nil {
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
}
return client.TestAuth(ctx)
}
// Store exposes the underlying store so background workers (e.g. the auth
// health poller) can persist state without re-implementing the secrets+config
// resolution that the service already encapsulates.
func (s *Service) Store() *Store {
return s.store
}
// authProbeTimeout caps a single auth-health probe so a slow workspace can't
// stall the caller. The /myself endpoint typically responds in <500ms.
const authProbeTimeout = 15 * time.Second
// authHealthWriteTimeout bounds the DB write that persists the probe outcome.
// Always derived from a fresh context.Background so a probe that exhausted its
// own deadline can still record the failure.
const authHealthWriteTimeout = 5 * time.Second
// SetProbeHook installs a callback fired at the end of each RecordAuthHealth
// call (after the DB write). Production code never sets this; tests use it to
// synchronize on probe completion without sleep-polling.
func (s *Service) SetProbeHook(fn func(workspaceID string)) {
s.mu.Lock()
defer s.mu.Unlock()
s.probeHook = fn
}
// RecordAuthHealth probes the workspace's stored credentials and writes the
// outcome onto the JiraConfig row. Used both at config-save time (so the UI
// reflects the new credentials immediately) and by the background poller.
// Errors during the persist step are logged but not returned: this is a
// best-effort health signal, never the source of truth for callers.
func (s *Service) RecordAuthHealth(ctx context.Context, workspaceID string) {
probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout)
defer cancel()
res, err := s.ProbeAuth(probeCtx, workspaceID)
ok := err == nil && res != nil && res.OK
errMsg := ""
switch {
case err != nil:
errMsg = err.Error()
case res != nil && !res.OK:
errMsg = res.Error
}
// Detach the DB write from ctx: a probe that exhausted its 15s deadline
// would otherwise pass an already-expired context to UpdateAuthHealth and
// drop the failure record on the floor (so the UI never flips to "auth
// failed" until the next poll).
writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout)
defer writeCancel()
if updateErr := s.store.UpdateAuthHealth(writeCtx, workspaceID, ok, errMsg, time.Now().UTC()); updateErr != nil {
s.log.Warn("jira: update auth health failed",
zap.String("workspace_id", workspaceID), zap.Error(updateErr))
}
s.mu.Lock()
hook := s.probeHook
s.mu.Unlock()
if hook != nil {
hook(workspaceID)
}
}
// GetTicket loads a Jira ticket by key using the workspace's stored
// credentials.
func (s *Service) GetTicket(ctx context.Context, workspaceID, ticketKey string) (*JiraTicket, error) {
client, err := s.clientFor(ctx, workspaceID)
if err != nil {
return nil, err
}
return client.GetTicket(ctx, ticketKey)
}
// DoTransition applies a transition to a ticket.
func (s *Service) DoTransition(ctx context.Context, workspaceID, ticketKey, transitionID string) error {
client, err := s.clientFor(ctx, workspaceID)
if err != nil {
return err
}
return client.DoTransition(ctx, ticketKey, transitionID)
}
// ListProjects is used by the settings UI to populate the project selector.
func (s *Service) ListProjects(ctx context.Context, workspaceID string) ([]JiraProject, error) {
client, err := s.clientFor(ctx, workspaceID)
if err != nil {
return nil, err
}
return client.ListProjects(ctx)
}
// SearchTickets runs a JQL search for the workspace, returning a page of
// tickets. pageToken is the cursor returned in the previous page's
// NextPageToken; pass "" for the first page.
func (s *Service) SearchTickets(ctx context.Context, workspaceID, jql, pageToken string, maxResults int) (*SearchResult, error) {
client, err := s.clientFor(ctx, workspaceID)
if err != nil {
return nil, err
}
return client.SearchTickets(ctx, jql, pageToken, maxResults)
}
// clientFor returns a cached client, creating one if needed. The cache is
// invalidated whenever the config changes so stale credentials never linger.
func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) {
s.mu.Lock()
if c, ok := s.cache[workspaceID]; ok {
s.mu.Unlock()
return c, nil
}
s.mu.Unlock()
cfg, err := s.store.GetConfig(ctx, workspaceID)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, ErrNotConfigured
}
secret := ""
if s.secrets != nil {
secret, err = s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID))
if err != nil {
// Don't conflate a transient secret-store failure with "user never
// configured Jira". The UI gates on ErrNotConfigured to render a
// configure-CTA; surfacing the real error gives the user a path to
// retry rather than leaving them stuck.
return nil, fmt.Errorf("read jira secret: %w", err)
}
if secret == "" {
return nil, ErrNotConfigured
}
}
client := s.clientFn(cfg, secret)
s.mu.Lock()
defer s.mu.Unlock()
// Re-check: another caller may have populated the cache while we were
// fetching the config and secret. Returning the existing client keeps the
// cache identity stable so callers comparing pointers don't see flapping.
if existing, ok := s.cache[workspaceID]; ok {
return existing, nil
}
s.cache[workspaceID] = client
return client, nil
}
// invalidateClient drops a cached client so the next request rebuilds it with
// fresh credentials.
func (s *Service) invalidateClient(workspaceID string) {
s.mu.Lock()
delete(s.cache, workspaceID)
s.mu.Unlock()
}
// resolveCredentials picks the credentials to test: if the request carries a
// secret, use it inline (pre-save); otherwise fall back to the stored secret
// (post-save re-test).
func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*JiraConfig, string, error) {
cfg := &JiraConfig{
WorkspaceID: req.WorkspaceID,
SiteURL: normalizeSiteURL(req.SiteURL),
Email: req.Email,
AuthMethod: req.AuthMethod,
}
if req.Secret != "" {
return cfg, req.Secret, nil
}
if s.secrets == nil {
return nil, "", errors.New("no secret store configured")
}
secret, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(req.WorkspaceID))
if err != nil {
// Don't conflate a transient secret-store failure with "no token
// stored". Surfacing the real error gives the user a path to retry
// instead of telling them to paste a token they already have.
s.log.Warn("jira: secret reveal failed",
zap.String("workspace_id", req.WorkspaceID), zap.Error(err))
return nil, "", fmt.Errorf("read jira secret: %w", err)
}
if secret == "" {
return nil, "", errors.New("no token stored — paste one to test")
}
// Merge with persisted config so the test uses saved site/email if the
// caller only passed a partial request.
if stored, _ := s.store.GetConfig(ctx, req.WorkspaceID); stored != nil {
if cfg.SiteURL == "" {
cfg.SiteURL = stored.SiteURL
}
if cfg.Email == "" {
cfg.Email = stored.Email
}
if cfg.AuthMethod == "" {
cfg.AuthMethod = stored.AuthMethod
}
}
return cfg, secret, nil
}
func validateConfigRequest(req *SetConfigRequest) error {
if req.WorkspaceID == "" {
return errors.New("workspaceId required")
}
if req.SiteURL == "" {
return errors.New("siteUrl required")
}
switch req.AuthMethod {
case AuthMethodAPIToken:
if req.Email == "" {
return errors.New("email required for api_token auth")
}
case AuthMethodSessionCookie:
// email is optional for session cookies.
default:
return fmt.Errorf("unknown auth method: %q", req.AuthMethod)
}
return nil
}
// normalizeSiteURL trims trailing slashes and prepends https:// when the user
// typed only a hostname (e.g. "acme.atlassian.net"). Without a scheme the Go
// HTTP client fails with "unsupported protocol scheme".
func normalizeSiteURL(raw string) string {
s := strings.TrimSpace(raw)
s = strings.TrimRight(s, "/")
if s == "" {
return s
}
if !strings.Contains(s, "://") {
s = "https://" + s
}
return s
}