54b98ed9eb
* 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>
407 lines
14 KiB
Go
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
|
|
}
|