From 54b98ed9eb8471115f5b20ddca9df6d9e88e60cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 25 Apr 2026 22:47:45 +0100 Subject: [PATCH] feat(jira): add Jira integration for ticket browsing, import, and task linking (#705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- apps/backend/cmd/kandev/adapters_test.go | 84 +++ apps/backend/cmd/kandev/helpers.go | 6 + apps/backend/cmd/kandev/main.go | 12 + apps/backend/cmd/kandev/services.go | 61 ++ apps/backend/cmd/kandev/types.go | 2 + apps/backend/internal/jira/client.go | 33 + apps/backend/internal/jira/cloud_client.go | 443 ++++++++++++++ .../internal/jira/cloud_client_test.go | 253 ++++++++ apps/backend/internal/jira/handlers.go | 354 +++++++++++ apps/backend/internal/jira/jwt.go | 36 ++ apps/backend/internal/jira/jwt_test.go | 52 ++ apps/backend/internal/jira/models.go | 120 ++++ apps/backend/internal/jira/poller.go | 104 ++++ apps/backend/internal/jira/poller_test.go | 208 +++++++ apps/backend/internal/jira/provider.go | 20 + apps/backend/internal/jira/service.go | 406 +++++++++++++ apps/backend/internal/jira/service_test.go | 387 ++++++++++++ apps/backend/internal/jira/store.go | 177 ++++++ apps/backend/internal/jira/store_test.go | 174 ++++++ apps/backend/pkg/websocket/actions.go | 11 + apps/web/app/jira/jira-page-client.tsx | 337 +++++++++++ apps/web/app/jira/page.tsx | 65 ++ .../app/settings/workspace/[id]/jira/page.tsx | 11 + apps/web/components/jira/jira-import-bar.tsx | 109 ++++ apps/web/components/jira/jira-link-button.tsx | 128 ++++ apps/web/components/jira/jira-settings.tsx | 567 ++++++++++++++++++ .../components/jira/jira-ticket-button.tsx | 49 ++ .../components/jira/jira-ticket-common.tsx | 212 +++++++ .../components/jira/jira-ticket-dialog.tsx | 347 +++++++++++ .../components/jira/my-jira/filter-bar.tsx | 60 ++ .../jira/my-jira/filter-model.test.ts | 76 +++ .../components/jira/my-jira/filter-model.ts | 90 +++ .../components/jira/my-jira/filter-pills.tsx | 195 ++++++ .../components/jira/my-jira/jql-editor.tsx | 69 +++ .../components/jira/my-jira/list-toolbar.tsx | 335 +++++++++++ apps/web/components/jira/my-jira/presets.ts | 119 ++++ .../jira/my-jira/quick-task-launcher.tsx | 82 +++ .../jira/my-jira/results-pagination.tsx | 72 +++ .../components/jira/my-jira/ticket-row.tsx | 118 ++++ .../jira/my-jira/use-jira-enabled.ts | 62 ++ .../jira/my-jira/use-jira-search.ts | 91 +++ .../jira/my-jira/use-saved-views.ts | 134 +++++ .../jira/my-jira/use-task-presets.ts | 96 +++ .../components/jira/task-presets-section.tsx | 258 ++++++++ apps/web/components/kanban/kanban-header.tsx | 87 ++- .../settings/settings-app-sidebar.tsx | 10 + .../task-create-dialog-form-body.tsx | 15 + .../task-create-dialog-selectors.tsx | 64 +- apps/web/components/task-create-dialog.tsx | 27 + apps/web/components/task/task-top-bar.tsx | 13 + apps/web/lib/api/domains/jira-api.ts | 91 +++ apps/web/lib/types/jira.ts | 81 +++ 52 files changed, 6994 insertions(+), 19 deletions(-) create mode 100644 apps/backend/internal/jira/client.go create mode 100644 apps/backend/internal/jira/cloud_client.go create mode 100644 apps/backend/internal/jira/cloud_client_test.go create mode 100644 apps/backend/internal/jira/handlers.go create mode 100644 apps/backend/internal/jira/jwt.go create mode 100644 apps/backend/internal/jira/jwt_test.go create mode 100644 apps/backend/internal/jira/models.go create mode 100644 apps/backend/internal/jira/poller.go create mode 100644 apps/backend/internal/jira/poller_test.go create mode 100644 apps/backend/internal/jira/provider.go create mode 100644 apps/backend/internal/jira/service.go create mode 100644 apps/backend/internal/jira/service_test.go create mode 100644 apps/backend/internal/jira/store.go create mode 100644 apps/backend/internal/jira/store_test.go create mode 100644 apps/web/app/jira/jira-page-client.tsx create mode 100644 apps/web/app/jira/page.tsx create mode 100644 apps/web/app/settings/workspace/[id]/jira/page.tsx create mode 100644 apps/web/components/jira/jira-import-bar.tsx create mode 100644 apps/web/components/jira/jira-link-button.tsx create mode 100644 apps/web/components/jira/jira-settings.tsx create mode 100644 apps/web/components/jira/jira-ticket-button.tsx create mode 100644 apps/web/components/jira/jira-ticket-common.tsx create mode 100644 apps/web/components/jira/jira-ticket-dialog.tsx create mode 100644 apps/web/components/jira/my-jira/filter-bar.tsx create mode 100644 apps/web/components/jira/my-jira/filter-model.test.ts create mode 100644 apps/web/components/jira/my-jira/filter-model.ts create mode 100644 apps/web/components/jira/my-jira/filter-pills.tsx create mode 100644 apps/web/components/jira/my-jira/jql-editor.tsx create mode 100644 apps/web/components/jira/my-jira/list-toolbar.tsx create mode 100644 apps/web/components/jira/my-jira/presets.ts create mode 100644 apps/web/components/jira/my-jira/quick-task-launcher.tsx create mode 100644 apps/web/components/jira/my-jira/results-pagination.tsx create mode 100644 apps/web/components/jira/my-jira/ticket-row.tsx create mode 100644 apps/web/components/jira/my-jira/use-jira-enabled.ts create mode 100644 apps/web/components/jira/my-jira/use-jira-search.ts create mode 100644 apps/web/components/jira/my-jira/use-saved-views.ts create mode 100644 apps/web/components/jira/my-jira/use-task-presets.ts create mode 100644 apps/web/components/jira/task-presets-section.tsx create mode 100644 apps/web/lib/api/domains/jira-api.ts create mode 100644 apps/web/lib/types/jira.ts diff --git a/apps/backend/cmd/kandev/adapters_test.go b/apps/backend/cmd/kandev/adapters_test.go index 26a7100dd9..0a57fccfe1 100644 --- a/apps/backend/cmd/kandev/adapters_test.go +++ b/apps/backend/cmd/kandev/adapters_test.go @@ -2,10 +2,13 @@ package main import ( "context" + "errors" + "fmt" "sync" "testing" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" taskservice "github.com/kandev/kandev/internal/task/service" ) @@ -108,3 +111,84 @@ func TestMessageCreatorAdapter_StructFields(t *testing.T) { t.Error("expected nil svc") } } + +// fakeJiraSecretStore is a minimal SecretStore for exercising +// jiraSecretAdapter.Set's branching. Get is the only method whose return +// value drives behaviour; the rest record whether they were called so +// tests can assert that a Get failure does not cascade into Create. +type fakeJiraSecretStore struct { + getErr error + created bool + updated bool +} + +func (f *fakeJiraSecretStore) Create(_ context.Context, _ *secrets.SecretWithValue) error { + f.created = true + return nil +} + +func (f *fakeJiraSecretStore) Get(_ context.Context, _ string) (*secrets.Secret, error) { + if f.getErr != nil { + return nil, f.getErr + } + return &secrets.Secret{}, nil +} + +func (f *fakeJiraSecretStore) Reveal(_ context.Context, _ string) (string, error) { + return "", nil +} + +func (f *fakeJiraSecretStore) Update(_ context.Context, _ string, _ *secrets.UpdateSecretRequest) error { + f.updated = true + return nil +} + +func (f *fakeJiraSecretStore) Delete(_ context.Context, _ string) error { return nil } +func (f *fakeJiraSecretStore) List(_ context.Context) ([]*secrets.SecretListItem, error) { + return nil, nil +} +func (f *fakeJiraSecretStore) Close() error { return nil } + +func TestJiraSecretAdapter_Set_PropagatesRealDBError(t *testing.T) { + dbErr := errors.New("connection timeout") + store := &fakeJiraSecretStore{getErr: dbErr} + adapter := &jiraSecretAdapter{store: store} + + err := adapter.Set(context.Background(), "id", "name", "value") + if !errors.Is(err, dbErr) { + t.Fatalf("expected wrapped DB error, got %v", err) + } + if store.created || store.updated { + t.Fatal("Create/Update must not run when Get returns a real error") + } +} + +func TestJiraSecretAdapter_Set_CreatesWhenNotFound(t *testing.T) { + store := &fakeJiraSecretStore{getErr: fmt.Errorf("secret not found: id")} + adapter := &jiraSecretAdapter{store: store} + + if err := adapter.Set(context.Background(), "id", "name", "value"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !store.created { + t.Fatal("expected Create when secret is missing") + } + if store.updated { + t.Fatal("Update must not run when secret is missing") + } +} + +func TestJiraSecretAdapter_Set_UpdatesWhenExists(t *testing.T) { + store := &fakeJiraSecretStore{getErr: nil} + adapter := &jiraSecretAdapter{store: store} + + if err := adapter.Set(context.Background(), "id", "name", "value"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !store.updated { + t.Fatal("expected Update when secret exists") + } + if store.created { + t.Fatal("Create must not run when secret exists") + } +} diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 3950caba7c..33701c837d 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -33,6 +33,7 @@ import ( gateways "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/health" + "github.com/kandev/kandev/internal/jira" mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" @@ -469,6 +470,11 @@ func registerSecondaryRoutes( p.log.Debug("Registered GitHub handlers (HTTP + WebSocket)") } + if p.services.Jira != nil { + jira.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Jira, p.log) + p.log.Debug("Registered JIRA handlers (HTTP + WebSocket)") + } + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index f082ec9337..cbb6b8a10d 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -29,6 +29,9 @@ import ( // GitHub integration githubpkg "github.com/kandev/kandev/internal/github" + // JIRA integration + jirapkg "github.com/kandev/kandev/internal/jira" + // Agent infrastructure "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/lifecycle" @@ -333,6 +336,15 @@ func startAgentInfrastructure( log.Info("GitHub poller started") } + // Start JIRA auth-health poller. Probes stored credentials for every + // configured workspace on a fixed cadence so the UI can show a real + // connected/disconnected status without doing its own polling. + if services.Jira != nil { + jiraPoller := jirapkg.NewPoller(services.Jira, log) + jiraPoller.Start(ctx) + addCleanup(func() error { jiraPoller.Stop(); return nil }) + } + return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, addCleanup, runCleanups) } diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 5c23e4ffee..2c14cb19dd 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -2,6 +2,7 @@ package main import ( "context" + "strings" "go.uber.org/zap" @@ -14,6 +15,7 @@ import ( editorservice "github.com/kandev/kandev/internal/editors/service" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/jira" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" taskmodels "github.com/kandev/kandev/internal/task/models" @@ -92,6 +94,13 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories githubSvc.SetSecretManager(secretsAdapter) } + // Initialize JIRA service + jiraSecrets := &jiraSecretAdapter{store: repos.Secrets} + jiraSvc, _, jiraErr := jira.Provide(dbPool.Writer(), dbPool.Reader(), jiraSecrets, log) + if jiraErr != nil { + log.Warn("JIRA service initialization failed (non-fatal)", zap.Error(jiraErr)) + } + return &Services{ Task: taskSvc, User: userSvc, @@ -100,6 +109,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Utility: utilitySvc, Workflow: workflowSvc, GitHub: githubSvc, + Jira: jiraSvc, // Notification service is initialized after gateway is available. Notification: nil, }, agentSettingsController, nil @@ -213,6 +223,57 @@ func (a *githubSecretAdapter) Delete(ctx context.Context, id string) error { return a.store.Delete(ctx, id) } +// jiraSecretAdapter adapts secrets.SecretStore to jira.SecretStore. JIRA stores +// tokens keyed by "jira:{workspaceID}:token"; this adapter translates the +// upsert-style API the JIRA service expects onto the Create/Update methods the +// secret store exposes. +type jiraSecretAdapter struct { + store secrets.SecretStore +} + +func (a *jiraSecretAdapter) Reveal(ctx context.Context, id string) (string, error) { + return a.store.Reveal(ctx, id) +} + +func (a *jiraSecretAdapter) Set(ctx context.Context, id, name, value string) error { + // Try update first; on "not found", fall through to create. The secrets + // layer only exposes error strings (not typed errors), so we detect the + // missing row via Exists which inspects the "secret not found:" prefix. + // Treating every Get error as "not found" would turn a transient DB error + // on an existing row into a constraint-violation Create that masks the + // real cause. + exists, err := a.Exists(ctx, id) + if err != nil { + return err + } + if exists { + return a.store.Update(ctx, id, &secrets.UpdateSecretRequest{Value: &value}) + } + return a.store.Create(ctx, &secrets.SecretWithValue{ + Secret: secrets.Secret{ID: id, Name: name}, + Value: value, + }) +} + +func (a *jiraSecretAdapter) Delete(ctx context.Context, id string) error { + return a.store.Delete(ctx, id) +} + +func (a *jiraSecretAdapter) Exists(ctx context.Context, id string) (bool, error) { + _, err := a.store.Get(ctx, id) + if err != nil { + // The secrets layer reports "not found" via a fmt.Errorf string with + // the "secret not found:" prefix; treat that as the absence case and + // surface anything else (DB outage, decoding failure) so the caller + // can log it instead of silently reporting "not configured". + if strings.HasPrefix(err.Error(), "secret not found:") { + return false, nil + } + return false, err + } + return true, nil +} + // workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface. type workflowProviderAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 229972f3da..42fff0c4c0 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -6,6 +6,7 @@ import ( editorservice "github.com/kandev/kandev/internal/editors/service" editorstore "github.com/kandev/kandev/internal/editors/store" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/jira" notificationservice "github.com/kandev/kandev/internal/notifications/service" notificationstore "github.com/kandev/kandev/internal/notifications/store" promptservice "github.com/kandev/kandev/internal/prompts/service" @@ -43,4 +44,5 @@ type Services struct { Utility *utilityservice.Service Workflow *workflowservice.Service GitHub *github.Service + Jira *jira.Service } diff --git a/apps/backend/internal/jira/client.go b/apps/backend/internal/jira/client.go new file mode 100644 index 0000000000..0e4ccc556d --- /dev/null +++ b/apps/backend/internal/jira/client.go @@ -0,0 +1,33 @@ +package jira + +import ( + "context" + "errors" + "fmt" +) + +// ErrNotConfigured is returned when a Jira operation is attempted without a +// workspace configuration. +var ErrNotConfigured = errors.New("jira: workspace not configured") + +// Client is the minimal interface service needs from a Jira backend. The real +// implementation is CloudClient; tests can substitute a fake. +type Client interface { + TestAuth(ctx context.Context) (*TestConnectionResult, error) + GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, error) + ListTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) + DoTransition(ctx context.Context, ticketKey, transitionID string) error + ListProjects(ctx context.Context) ([]JiraProject, error) + SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) +} + +// APIError captures an upstream non-2xx response so handlers can surface a +// meaningful status to the UI. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("jira api: status %d: %s", e.StatusCode, e.Message) +} diff --git a/apps/backend/internal/jira/cloud_client.go b/apps/backend/internal/jira/cloud_client.go new file mode 100644 index 0000000000..f568749447 --- /dev/null +++ b/apps/backend/internal/jira/cloud_client.go @@ -0,0 +1,443 @@ +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") + } + } +} diff --git a/apps/backend/internal/jira/cloud_client_test.go b/apps/backend/internal/jira/cloud_client_test.go new file mode 100644 index 0000000000..241c5c679c --- /dev/null +++ b/apps/backend/internal/jira/cloud_client_test.go @@ -0,0 +1,253 @@ +package jira + +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + s := httptest.NewServer(handler) + t.Cleanup(s.Close) + return s +} + +func clientTo(ts *httptest.Server, method, secret string) *CloudClient { + cfg := &JiraConfig{ + SiteURL: ts.URL, + Email: "user@example.com", + AuthMethod: method, + } + return NewCloudClient(cfg, secret) +} + +func TestCloudClient_AuthHeaders_APIToken(t *testing.T) { + var gotAuth string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"accountId":"a","displayName":"d","emailAddress":"e"}`)) + }) + c := clientTo(ts, AuthMethodAPIToken, "secrettoken") + + res, err := c.TestAuth(context.Background()) + if err != nil { + t.Fatalf("test auth: %v", err) + } + if !res.OK { + t.Fatalf("expected OK=true, got %+v", res) + } + want := "Basic " + base64.StdEncoding.EncodeToString([]byte("user@example.com:secrettoken")) + if gotAuth != want { + t.Errorf("auth header = %q, want %q", gotAuth, want) + } +} + +func TestCloudClient_AuthHeaders_SessionCookie_BareJWT(t *testing.T) { + var gotCookie string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + gotCookie = r.Header.Get("Cookie") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"accountId":"a"}`)) + }) + // New flow: user pastes just the value of cloud.session.token / + // tenant.session.token from DevTools → Application → Cookies. The client + // wraps it under both names so a single paste works for password accounts + // and SSO tenants alike. + jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.sig" + c := clientTo(ts, AuthMethodSessionCookie, jwt) + if _, err := c.TestAuth(context.Background()); err != nil { + t.Fatalf("test auth: %v", err) + } + want := "cloud.session.token=" + jwt + "; tenant.session.token=" + jwt + if gotCookie != want { + t.Errorf("cookie header = %q, want %q", gotCookie, want) + } +} + +func TestCloudClient_TestAuth_BadCreds_ReportsError(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"errorMessages":["bad creds"]}`)) + }) + c := clientTo(ts, AuthMethodAPIToken, "bad") + res, err := c.TestAuth(context.Background()) + if err != nil { + t.Fatalf("test auth should not error on 401, got %v", err) + } + if res.OK { + t.Fatalf("expected OK=false, got %+v", res) + } + if !strings.Contains(res.Error, "401") { + t.Errorf("expected 401 in error, got %q", res.Error) + } +} + +func TestCloudClient_GetTicket_ParsesADFDescription(t *testing.T) { + issueBody := `{ + "key": "PROJ-42", + "fields": { + "summary": "Fix the thing", + "description": { + "type": "doc", + "content": [ + {"type":"paragraph","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}, + {"type":"paragraph","content":[{"type":"text","text":"line two"}]} + ] + }, + "status": {"id":"3","name":"In Progress"}, + "project": {"key":"PROJ"}, + "issuetype": {"name":"Bug"} + } + }` + transitionsBody := `{"transitions":[{"id":"11","name":"Start","to":{"id":"3","name":"In Progress"}}]}` + + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/PROJ-42/transitions"): + _, _ = w.Write([]byte(transitionsBody)) + case strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/PROJ-42"): + _, _ = w.Write([]byte(issueBody)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + } + }) + + c := clientTo(ts, AuthMethodAPIToken, "tok") + ticket, err := c.GetTicket(context.Background(), "PROJ-42") + if err != nil { + t.Fatalf("get ticket: %v", err) + } + if ticket.Summary != "Fix the thing" { + t.Errorf("summary: got %q", ticket.Summary) + } + if !strings.Contains(ticket.Description, "Hello world") { + t.Errorf("description missing first line: %q", ticket.Description) + } + if !strings.Contains(ticket.Description, "line two") { + t.Errorf("description missing second paragraph: %q", ticket.Description) + } + if ticket.StatusName != "In Progress" { + t.Errorf("status name: got %q", ticket.StatusName) + } + if len(ticket.Transitions) != 1 || ticket.Transitions[0].Name != "Start" { + t.Errorf("transitions: %+v", ticket.Transitions) + } + if ticket.URL != ts.URL+"/browse/PROJ-42" { + t.Errorf("url: got %q", ticket.URL) + } +} + +func TestCloudClient_GetTicket_PlainStringDescription(t *testing.T) { + body := `{"key":"X-1","fields":{"summary":"s","description":"plain","status":{},"project":{},"issuetype":{}}}` + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/X-1/transitions") { + _, _ = w.Write([]byte(`{"transitions":[]}`)) + return + } + _, _ = w.Write([]byte(body)) + }) + c := clientTo(ts, AuthMethodAPIToken, "t") + ticket, err := c.GetTicket(context.Background(), "X-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if ticket.Description != "plain" { + t.Errorf("description: got %q", ticket.Description) + } +} + +func TestCloudClient_GetTicket_NonOK_ReturnsAPIError(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errorMessages":["not found"]}`)) + }) + c := clientTo(ts, AuthMethodAPIToken, "t") + _, err := c.GetTicket(context.Background(), "NONE-1") + if err == nil { + t.Fatal("expected error") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != 404 { + t.Errorf("status: got %d", apiErr.StatusCode) + } +} + +func TestCloudClient_DoTransition_PostsBody(t *testing.T) { + var gotPath, gotBody, gotMethod string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + gotBody = string(buf) + w.WriteHeader(http.StatusNoContent) + }) + c := clientTo(ts, AuthMethodAPIToken, "t") + if err := c.DoTransition(context.Background(), "PROJ-1", "21"); err != nil { + t.Fatalf("transition: %v", err) + } + if gotMethod != http.MethodPost { + t.Errorf("method: got %q", gotMethod) + } + if gotPath != "/rest/api/3/issue/PROJ-1/transitions" { + t.Errorf("path: got %q", gotPath) + } + if !strings.Contains(gotBody, `"id":"21"`) { + t.Errorf("body missing transition id: %q", gotBody) + } +} + +func TestCloudClient_ListProjects(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/rest/api/3/project/search") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"values":[{"id":"1","key":"A","name":"Alpha"},{"id":"2","key":"B","name":"Beta"}]}`)) + }) + c := clientTo(ts, AuthMethodAPIToken, "t") + projects, err := c.ListProjects(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(projects) != 2 || projects[0].Key != "A" || projects[1].Name != "Beta" { + t.Errorf("unexpected projects: %+v", projects) + } +} + +func TestCloudClient_SiteURLTrailingSlash_Stripped(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + // If trailing slash wasn't stripped, we'd see "//rest/..." + if strings.HasPrefix(r.URL.Path, "//") { + t.Errorf("double slash in path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{}`)) + }) + cfg := &JiraConfig{SiteURL: ts.URL + "/", Email: "e", AuthMethod: AuthMethodAPIToken} + c := NewCloudClient(cfg, "t") + if _, err := c.TestAuth(context.Background()); err != nil { + t.Fatalf("test: %v", err) + } +} + +func TestExtractDescription_Nil(t *testing.T) { + if got := extractDescription(nil); got != "" { + t.Errorf("nil → %q", got) + } +} + +func TestExtractDescription_UnknownShape(t *testing.T) { + if got := extractDescription(42); got != "" { + t.Errorf("int → %q", got) + } +} diff --git a/apps/backend/internal/jira/handlers.go b/apps/backend/internal/jira/handlers.go new file mode 100644 index 0000000000..da43f1f719 --- /dev/null +++ b/apps/backend/internal/jira/handlers.go @@ -0,0 +1,354 @@ +package jira + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// RegisterRoutes wires the Jira HTTP and WebSocket handlers. +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + ctrl := &Controller{service: svc, logger: log} + ctrl.RegisterHTTPRoutes(router) + registerWSHandlers(dispatcher, svc) +} + +// Controller holds HTTP route handlers for the Jira integration. +type Controller struct { + service *Service + logger *logger.Logger +} + +// RegisterHTTPRoutes attaches the Jira HTTP endpoints to router. +func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { + api := router.Group("/api/v1/jira") + api.GET("/config", c.httpGetConfig) + api.POST("/config", c.httpSetConfig) + api.DELETE("/config", c.httpDeleteConfig) + api.POST("/config/test", c.httpTestConfig) + api.GET("/projects", c.httpListProjects) + api.GET("/tickets", c.httpSearchTickets) + api.GET("/tickets/:key", c.httpGetTicket) + api.POST("/tickets/:key/transitions", c.httpDoTransition) +} + +// --- HTTP handlers --- + +func (c *Controller) httpGetConfig(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + cfg, err := c.service.GetConfig(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if cfg == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpSetConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrInvalidConfig) { + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpDeleteConfig(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + if err := c.service.DeleteConfig(ctx.Request.Context(), workspaceID); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTestConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + result, err := c.service.TestConnection(ctx.Request.Context(), &req) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpListProjects(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + projects, err := c.service.ListProjects(ctx.Request.Context(), workspaceID) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"projects": projects}) +} + +func (c *Controller) httpSearchTickets(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + jql := ctx.Query("jql") + pageToken := ctx.Query("page_token") + maxResults, _ := strconv.Atoi(ctx.Query("max_results")) + result, err := c.service.SearchTickets(ctx.Request.Context(), workspaceID, jql, pageToken, maxResults) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpGetTicket(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + key := ctx.Param("key") + ticket, err := c.service.GetTicket(ctx.Request.Context(), workspaceID, key) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, ticket) +} + +func (c *Controller) httpDoTransition(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + key := ctx.Param("key") + var req struct { + TransitionID string `json:"transitionId"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || req.TransitionID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "transitionId required"}) + return + } + if err := c.service.DoTransition(ctx.Request.Context(), workspaceID, key, req.TransitionID); err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"transitioned": true}) +} + +// errCodeJiraNotConfigured is the wire-level code surfaced to the UI when the +// workspace has no saved Jira credentials. The same string is used by both the +// HTTP and WebSocket layers so the frontend can branch on a single value. +const errCodeJiraNotConfigured = "JIRA_NOT_CONFIGURED" + +// writeClientError maps service-level errors to HTTP responses. ErrNotConfigured +// surfaces as 503 so the UI can prompt the user to configure Jira; upstream +// API errors propagate their status codes. +func (c *Controller) writeClientError(ctx *gin.Context, err error) { + if errors.Is(err, ErrNotConfigured) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Jira is not configured for this workspace", + "code": errCodeJiraNotConfigured, + }) + return + } + var apiErr *APIError + if errors.As(err, &apiErr) { + status := http.StatusInternalServerError + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: + status = apiErr.StatusCode + } + // 3xx from the upstream means Atlassian redirected to login (step-up + // auth, expired session, etc.); treat it as unauthorized for the UI. + if apiErr.StatusCode >= 300 && apiErr.StatusCode < 400 { + status = http.StatusUnauthorized + } + ctx.JSON(status, gin.H{"error": apiErr.Error()}) + return + } + c.logger.Warn("jira handler error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +// --- WebSocket handlers --- + +func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service) { + dispatcher.RegisterFunc(ws.ActionJiraConfigGet, wsGetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionJiraConfigSet, wsSetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionJiraConfigDelete, wsDeleteConfig(svc)) + dispatcher.RegisterFunc(ws.ActionJiraConfigTest, wsTestConfig(svc)) + dispatcher.RegisterFunc(ws.ActionJiraTicketGet, wsGetTicket(svc)) + dispatcher.RegisterFunc(ws.ActionJiraTicketTransition, wsDoTransition(svc)) + dispatcher.RegisterFunc(ws.ActionJiraProjectsList, wsListProjects(svc)) +} + +// wsReply wraps the common boilerplate: try to build a response, fall back to +// an internal-error envelope if marshaling fails. +func wsReply(msg *ws.Message, payload interface{}) (*ws.Message, error) { + resp, err := ws.NewResponse(msg.ID, msg.Action, payload) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return resp, nil +} + +func wsFail(msg *ws.Message, err error) (*ws.Message, error) { + if errors.Is(err, ErrNotConfigured) { + return ws.NewError(msg.ID, msg.Action, errCodeJiraNotConfigured, err.Error(), nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) +} + +func wsGetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + cfg, err := svc.GetConfig(ctx, p.WorkspaceID) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + cfg, err := svc.SetConfig(ctx, &req) + if err != nil { + if errors.Is(err, ErrInvalidConfig) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsDeleteConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + if err := svc.DeleteConfig(ctx, p.WorkspaceID); err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"deleted": true}) + } +} + +func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + result, err := svc.TestConnection(ctx, &req) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, result) + } +} + +func wsGetTicket(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + TicketKey string `json:"ticketKey"` + } + if err := msg.ParsePayload(&p); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if p.WorkspaceID == "" || p.TicketKey == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId and ticketKey required", nil) + } + ticket, err := svc.GetTicket(ctx, p.WorkspaceID, p.TicketKey) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, ticket) + } +} + +func wsDoTransition(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + TicketKey string `json:"ticketKey"` + TransitionID string `json:"transitionId"` + } + if err := msg.ParsePayload(&p); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if p.WorkspaceID == "" || p.TicketKey == "" || p.TransitionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId, ticketKey, transitionId required", nil) + } + if err := svc.DoTransition(ctx, p.WorkspaceID, p.TicketKey, p.TransitionID); err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"transitioned": true}) + } +} + +func wsListProjects(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + projects, err := svc.ListProjects(ctx, p.WorkspaceID) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"projects": projects}) + } +} diff --git a/apps/backend/internal/jira/jwt.go b/apps/backend/internal/jira/jwt.go new file mode 100644 index 0000000000..b3166c21a5 --- /dev/null +++ b/apps/backend/internal/jira/jwt.go @@ -0,0 +1,36 @@ +package jira + +import ( + "encoding/base64" + "encoding/json" + "strings" + "time" +) + +// parseSessionCookieExpiry extracts the `exp` claim from a Jira session-token +// JWT. The stored secret is the bare token value (the user pastes it from +// DevTools → Application → Cookies). Returns nil when the secret isn't a JWT +// or when the `exp` claim is missing — callers treat that as "unknown" rather +// than an error, since some tenant tokens are opaque. +func parseSessionCookieExpiry(secret string) *time.Time { + parts := strings.Split(secret, ".") + if len(parts) != 3 { + return nil + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + // Some JWTs include padding; try the padded decoder as a fallback. + raw, err = base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 { + return nil + } + t := time.Unix(claims.Exp, 0).UTC() + return &t +} diff --git a/apps/backend/internal/jira/jwt_test.go b/apps/backend/internal/jira/jwt_test.go new file mode 100644 index 0000000000..bdeb35974f --- /dev/null +++ b/apps/backend/internal/jira/jwt_test.go @@ -0,0 +1,52 @@ +package jira + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +func makeJWT(t *testing.T, payload map[string]interface{}) string { + t.Helper() + header := `{"alg":"none","typ":"JWT"}` + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + enc := func(s []byte) string { return base64.RawURLEncoding.EncodeToString(s) } + return enc([]byte(header)) + "." + enc(body) + ".sig" +} + +func TestParseSessionCookieExpiry(t *testing.T) { + future := time.Now().Add(30 * 24 * time.Hour).Unix() + jwt := makeJWT(t, map[string]interface{}{"exp": future, "sub": "user"}) + + cases := []struct { + name string + input string + want int64 + }{ + {"jwt", jwt, future}, + {"not_a_jwt", "opaque-value", 0}, + {"jwt_without_exp", makeJWT(t, map[string]interface{}{"sub": "user"}), 0}, + {"empty", "", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseSessionCookieExpiry(tc.input) + if tc.want == 0 { + if got != nil { + t.Fatalf("want nil, got %v", got) + } + return + } + if got == nil { + t.Fatalf("want time=%d, got nil", tc.want) + } + if got.Unix() != tc.want { + t.Fatalf("want %d, got %d", tc.want, got.Unix()) + } + }) + } +} diff --git a/apps/backend/internal/jira/models.go b/apps/backend/internal/jira/models.go new file mode 100644 index 0000000000..c01ae370c7 --- /dev/null +++ b/apps/backend/internal/jira/models.go @@ -0,0 +1,120 @@ +// Package jira implements the Jira/Atlassian Cloud integration: workspace-scoped +// configuration storage, a REST client for tickets and transitions, and the HTTP +// and WebSocket handlers that expose these capabilities to the frontend. +package jira + +import "time" + +// Auth method identifiers persisted in JiraConfig.AuthMethod. +const ( + AuthMethodAPIToken = "api_token" + AuthMethodSessionCookie = "session_cookie" +) + +// JiraConfig is the workspace-scoped configuration for the Jira integration. +// The secret value (API token or session cookie) is stored separately in the +// encrypted secret store under the key returned by SecretKeyForWorkspace. +type JiraConfig struct { + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + SiteURL string `json:"siteUrl" db:"site_url"` + Email string `json:"email" db:"email"` + AuthMethod string `json:"authMethod" db:"auth_method"` + DefaultProjectKey string `json:"defaultProjectKey" db:"default_project_key"` + HasSecret bool `json:"hasSecret" db:"-"` + // SecretExpiresAt is populated for session_cookie auth when the cookie is + // a JWT (cloud.session.token / tenant.session.token). Nil for api_token or + // opaque session cookies. + SecretExpiresAt *time.Time `json:"secretExpiresAt,omitempty" db:"-"` + // LastCheckedAt / LastOk / LastError are written by the background auth + // poller. They let the UI render a "connected/disconnected + checked Xs ago" + // indicator without doing its own probing. + LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"` + LastOk bool `json:"lastOk" db:"last_ok"` + LastError string `json:"lastError,omitempty" db:"last_error"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// SetConfigRequest is the payload sent by the UI to create or update the +// workspace's Jira configuration. When Secret is empty on update, the existing +// secret is retained; when non-empty it replaces the stored value. +type SetConfigRequest struct { + WorkspaceID string `json:"workspaceId"` + SiteURL string `json:"siteUrl"` + Email string `json:"email"` + AuthMethod string `json:"authMethod"` + DefaultProjectKey string `json:"defaultProjectKey"` + Secret string `json:"secret"` +} + +// TestConnectionResult reports what the backend learned when pinging Jira with +// the supplied credentials. It is used both to verify newly-entered credentials +// and to surface a meaningful error to the UI when they fail. +type TestConnectionResult struct { + OK bool `json:"ok"` + AccountID string `json:"accountId,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Error string `json:"error,omitempty"` +} + +// JiraTicket is the subset of Atlassian's issue payload that Kandev consumes. +// Kept small intentionally: the UI needs enough to prefill a task, show status, +// and surface a few familiar fields (assignee, reporter, priority) in the +// popover so users don't have to switch tabs to Jira. +type JiraTicket struct { + Key string `json:"key"` + Summary string `json:"summary"` + Description string `json:"description"` + StatusID string `json:"statusId"` + StatusName string `json:"statusName"` + StatusCategory string `json:"statusCategory"` // "new" | "indeterminate" | "done" + ProjectKey string `json:"projectKey"` + IssueType string `json:"issueType"` + IssueTypeIcon string `json:"issueTypeIcon,omitempty"` + Priority string `json:"priority,omitempty"` + PriorityIcon string `json:"priorityIcon,omitempty"` + AssigneeName string `json:"assigneeName,omitempty"` + AssigneeAvatar string `json:"assigneeAvatar,omitempty"` + ReporterName string `json:"reporterName,omitempty"` + ReporterAvatar string `json:"reporterAvatar,omitempty"` + Updated string `json:"updated,omitempty"` + URL string `json:"url"` + Transitions []JiraTransition `json:"transitions"` + Fields map[string]string `json:"fields,omitempty"` +} + +// JiraTransition is one of the workflow transitions available for a ticket at +// the time of fetch. Transition IDs are stable within a project but the set of +// available transitions changes with ticket status, so the UI must re-fetch +// after a transition. +type JiraTransition struct { + ID string `json:"id"` + Name string `json:"name"` + ToStatusID string `json:"toStatusId"` + ToStatusName string `json:"toStatusName"` +} + +// JiraProject is the minimal shape used by the project selector on the settings +// page. +type JiraProject struct { + Key string `json:"key"` + Name string `json:"name"` + ID string `json:"id"` +} + +// SearchResult is a page of tickets from a JQL search. Atlassian's +// /rest/api/3/search/jql endpoint is token-paginated and returns no total +// count, so the UI relies on IsLast and NextPageToken to walk pages. +type SearchResult struct { + Tickets []JiraTicket `json:"tickets"` + MaxResults int `json:"maxResults"` + IsLast bool `json:"isLast"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +// SecretKeyForWorkspace returns the secret-store key used for the Jira token of +// a given workspace. Centralised so that the service and the store agree. +func SecretKeyForWorkspace(workspaceID string) string { + return "jira:" + workspaceID + ":token" +} diff --git a/apps/backend/internal/jira/poller.go b/apps/backend/internal/jira/poller.go new file mode 100644 index 0000000000..dfa0706830 --- /dev/null +++ b/apps/backend/internal/jira/poller.go @@ -0,0 +1,104 @@ +package jira + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// defaultAuthPollInterval is how often the auth-health poller probes each +// configured workspace. 90s is a compromise: short enough to keep +// session-cookie auth warm (Atlassian idle-times sessions out after a few +// minutes) and to surface expirations promptly in the UI, long enough that we +// don't hammer Atlassian when many workspaces are configured. +const defaultAuthPollInterval = 90 * time.Second + +// Poller probes the stored Jira credentials of every configured workspace on a +// fixed cadence and persists the result on the JiraConfig row. The frontend +// reads those fields via getJiraConfig to render the connected/disconnected +// indicator without doing its own probing. +type Poller struct { + service *Service + logger *logger.Logger + interval time.Duration + + // mu guards started/cancel/wg against concurrent Start/Stop calls. + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + started bool +} + +// NewPoller returns a poller that uses the default 90s cadence. +func NewPoller(svc *Service, log *logger.Logger) *Poller { + return &Poller{service: svc, logger: log, interval: defaultAuthPollInterval} +} + +// Start launches the background loop. Calling Start more than once without +// Stop is a no-op. +func (p *Poller) Start(ctx context.Context) { + p.mu.Lock() + defer p.mu.Unlock() + if p.started || p.service == nil { + return + } + p.started = true + ctx, p.cancel = context.WithCancel(ctx) + p.wg.Add(1) + go p.loop(ctx) + p.logger.Info("Jira auth poller started") +} + +// Stop cancels the loop and waits for it to drain. +func (p *Poller) Stop() { + p.mu.Lock() + if !p.started { + p.mu.Unlock() + return + } + cancel := p.cancel + p.mu.Unlock() + if cancel != nil { + cancel() + } + p.wg.Wait() + p.mu.Lock() + p.started = false + p.mu.Unlock() + p.logger.Info("Jira auth poller stopped") +} + +func (p *Poller) loop(ctx context.Context) { + defer p.wg.Done() + // Run an initial probe immediately so the UI gets a status without waiting + // the full interval after backend startup. + p.probeAll(ctx) + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.probeAll(ctx) + } + } +} + +func (p *Poller) probeAll(ctx context.Context) { + ids, err := p.service.Store().ListConfiguredWorkspaces(ctx) + if err != nil { + p.logger.Warn("jira poller: list workspaces failed", zap.Error(err)) + return + } + for _, id := range ids { + if ctx.Err() != nil { + return + } + p.service.RecordAuthHealth(ctx, id) + } +} diff --git a/apps/backend/internal/jira/poller_test.go b/apps/backend/internal/jira/poller_test.go new file mode 100644 index 0000000000..dad0f8b983 --- /dev/null +++ b/apps/backend/internal/jira/poller_test.go @@ -0,0 +1,208 @@ +package jira + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +// pollerFixture wires a Service with an in-memory store and fake client/secret +// store, then drives the Poller's probe step directly. We don't run the +// timer-driven loop because the unit value of testing the loop is mostly that +// it ticks on a context — which is just stdlib behavior. +type pollerFixture struct { + store *Store + secrets *fakeSecretStore + client *fakeClient + svc *Service + poller *Poller +} + +func newPollerFixture(t *testing.T) *pollerFixture { + t.Helper() + f := &pollerFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + client: &fakeClient{}, + } + f.svc = NewService(f.store, f.secrets, func(_ *JiraConfig, _ string) Client { + return f.client + }, logger.Default()) + f.poller = NewPoller(f.svc, logger.Default()) + return f +} + +// saveConfig persists a workspace config directly via the store + secret +// fakes. We deliberately avoid Service.SetConfig here because it now fires an +// async auth probe in a goroutine — fine for production but it would race +// against the deterministic `probeAll` calls these tests make. +func (f *pollerFixture) saveConfig(t *testing.T, workspaceID, secret string) { + t.Helper() + ctx := context.Background() + if err := f.store.UpsertConfig(ctx, &JiraConfig{ + WorkspaceID: workspaceID, + SiteURL: "https://" + workspaceID + ".atlassian.net", + Email: workspaceID + "@example.com", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("save config %s: %v", workspaceID, err) + } + if err := f.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), + "jira", secret); err != nil { + t.Fatalf("save secret %s: %v", workspaceID, err) + } +} + +func TestPoller_ProbeAll_RecordsSuccess(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg == nil { + t.Fatal("config disappeared") + } + if !cfg.LastOk { + t.Error("expected LastOk=true after successful probe") + } + if cfg.LastError != "" { + t.Errorf("expected empty error, got %q", cfg.LastError) + } + if cfg.LastCheckedAt == nil { + t.Error("expected LastCheckedAt to be set") + } +} + +func TestPoller_ProbeAll_RecordsFailure(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + // Service convention: TestAuth returns the failure inside the result + // rather than as an error, so the UI can render the message inline. + return &TestConnectionResult{OK: false, Error: "step-up auth required"}, nil + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false after failed probe") + } + if cfg.LastError != "step-up auth required" { + t.Errorf("expected error preserved, got %q", cfg.LastError) + } +} + +func TestPoller_ProbeAll_ClientError(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return nil, errors.New("network timeout") + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false on client error") + } + if cfg.LastError != "network timeout" { + t.Errorf("expected error from client preserved, got %q", cfg.LastError) + } +} + +func TestPoller_ProbeAll_NoWorkspaces(t *testing.T) { + f := newPollerFixture(t) + // Should be a clean no-op when nothing is configured. + f.poller.probeAll(context.Background()) +} + +func TestPoller_ProbeAll_MultipleWorkspaces(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-a", "tok-a") + f.saveConfig(t, "ws-b", "tok-b") + calls := 0 + f.client.testAuthFn = func() (*TestConnectionResult, error) { + calls++ + return &TestConnectionResult{OK: true}, nil + } + + f.poller.probeAll(context.Background()) + + if calls != 2 { + t.Errorf("expected 2 probe calls, got %d", calls) + } + for _, id := range []string{"ws-a", "ws-b"} { + cfg, _ := f.store.GetConfig(context.Background(), id) + if !cfg.LastOk || cfg.LastCheckedAt == nil { + t.Errorf("workspace %s missing health update: %+v", id, cfg) + } + } +} + +func TestPoller_StartStop(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + probed := make(chan struct{}, 1) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + select { + case probed <- struct{}{}: + default: + } + return &TestConnectionResult{OK: true}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.poller.Start(ctx) + defer f.poller.Stop() + + // The loop runs an initial probe immediately on Start; the fake client + // signals on `probed` so we wait without polling. + select { + case <-probed: + case <-time.After(2 * time.Second): + t.Fatal("poller did not record an initial probe within 2s") + } +} + +func TestPoller_StartIsIdempotent(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + var calls int32 + probed := make(chan struct{}, 1) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + atomic.AddInt32(&calls, 1) + select { + case probed <- struct{}{}: + default: + } + return &TestConnectionResult{OK: true}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.poller.Start(ctx) + f.poller.Start(ctx) // second call must be a no-op (no second goroutine). + + // Wait for the first probe to land. If the second Start spawned a parallel + // loop we'd see two probes back-to-back. + select { + case <-probed: + case <-time.After(2 * time.Second): + t.Fatal("poller did not run an initial probe within 2s") + } + f.poller.Stop() + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("expected exactly 1 probe call from the initial run, got %d", got) + } +} diff --git a/apps/backend/internal/jira/provider.go b/apps/backend/internal/jira/provider.go new file mode 100644 index 0000000000..e0ebcfa136 --- /dev/null +++ b/apps/backend/internal/jira/provider.go @@ -0,0 +1,20 @@ +package jira + +import ( + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Provide builds the Jira service. Cleanup is a no-op today — the service +// holds only in-memory client caches — but the signature mirrors other +// integration providers so callers can register it uniformly. +func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { + store, err := NewStore(writer, reader) + if err != nil { + return nil, nil, err + } + svc := NewService(store, secrets, DefaultClientFactory, log) + cleanup := func() error { return nil } + return svc, cleanup, nil +} diff --git a/apps/backend/internal/jira/service.go b/apps/backend/internal/jira/service.go new file mode 100644 index 0000000000..0cad41fe18 --- /dev/null +++ b/apps/backend/internal/jira/service.go @@ -0,0 +1,406 @@ +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 +} diff --git a/apps/backend/internal/jira/service_test.go b/apps/backend/internal/jira/service_test.go new file mode 100644 index 0000000000..269b61a941 --- /dev/null +++ b/apps/backend/internal/jira/service_test.go @@ -0,0 +1,387 @@ +package jira + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +// fakeSecretStore is an in-memory SecretStore for tests. +type fakeSecretStore struct { + mu sync.Mutex + secrets map[string]string +} + +func newFakeSecretStore() *fakeSecretStore { + return &fakeSecretStore{secrets: map[string]string{}} +} + +func (f *fakeSecretStore) Reveal(_ context.Context, id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.secrets[id] + if !ok { + return "", errors.New("not found") + } + return v, nil +} + +func (f *fakeSecretStore) Set(_ context.Context, id, _, value string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.secrets[id] = value + return nil +} + +func (f *fakeSecretStore) Delete(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.secrets, id) + return nil +} + +func (f *fakeSecretStore) Exists(_ context.Context, id string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.secrets[id] + return ok, nil +} + +// fakeClient is an in-memory Client for verifying service plumbing. +type fakeClient struct { + testAuthFn func() (*TestConnectionResult, error) + getTicketFn func(key string) (*JiraTicket, error) + transitionFn func(key, id string) error + listProjects func() ([]JiraProject, error) + transitionLog []string // "key:id" +} + +func (c *fakeClient) TestAuth(_ context.Context) (*TestConnectionResult, error) { + if c.testAuthFn != nil { + return c.testAuthFn() + } + return &TestConnectionResult{OK: true}, nil +} +func (c *fakeClient) GetTicket(_ context.Context, k string) (*JiraTicket, error) { + if c.getTicketFn != nil { + return c.getTicketFn(k) + } + return &JiraTicket{Key: k}, nil +} +func (c *fakeClient) ListTransitions(_ context.Context, _ string) ([]JiraTransition, error) { + return nil, nil +} +func (c *fakeClient) DoTransition(_ context.Context, key, id string) error { + c.transitionLog = append(c.transitionLog, key+":"+id) + if c.transitionFn != nil { + return c.transitionFn(key, id) + } + return nil +} +func (c *fakeClient) ListProjects(_ context.Context) ([]JiraProject, error) { + if c.listProjects != nil { + return c.listProjects() + } + return nil, nil +} +func (c *fakeClient) SearchTickets(_ context.Context, _, _ string, _ int) (*SearchResult, error) { + return &SearchResult{}, nil +} + +type svcFixture struct { + svc *Service + store *Store + secrets *fakeSecretStore + client *fakeClient + // atomic so the async probe goroutine spawned by SetConfig can call + // clientFor (and the injected factory) without racing the test thread. + factoryHit atomic.Int32 + probed chan string +} + +func newSvcFixture(t *testing.T) *svcFixture { + t.Helper() + f := &svcFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + client: &fakeClient{}, + probed: make(chan string, 8), + } + f.svc = NewService(f.store, f.secrets, func(_ *JiraConfig, _ string) Client { + f.factoryHit.Add(1) + return f.client + }, logger.Default()) + f.svc.SetProbeHook(func(workspaceID string) { + select { + case f.probed <- workspaceID: + default: + } + }) + return f +} + +func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + cfg, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", + SiteURL: "https://acme.atlassian.net/", + Email: "u@example.com", + AuthMethod: AuthMethodAPIToken, + Secret: "tok1", + }) + if err != nil { + t.Fatalf("set: %v", err) + } + if cfg.SiteURL != "https://acme.atlassian.net" { + t.Errorf("site url not trimmed: %q", cfg.SiteURL) + } + if !cfg.HasSecret { + t.Error("expected HasSecret=true") + } + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "tok1" { + t.Errorf("secret stored = %q", got) + } +} + +func TestService_SetConfig_ProbesAuthImmediately(t *testing.T) { + f := newSvcFixture(t) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "tok", + }); err != nil { + t.Fatalf("set: %v", err) + } + cfg := waitForAuthProbe(t, f, "ws-1") + if !cfg.LastOk { + t.Errorf("expected LastOk=true after async probe, got %+v", cfg) + } + if cfg.LastCheckedAt == nil { + t.Error("expected LastCheckedAt to be set after async probe") + } +} + +func TestService_SetConfig_PersistsProbeFailure(t *testing.T) { + f := newSvcFixture(t) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil + } + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "bad", + }); err != nil { + t.Fatalf("set: %v", err) + } + cfg := waitForAuthProbe(t, f, "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false after failed probe") + } + if cfg.LastError != "401 unauthorized" { + t.Errorf("expected probe error preserved, got %q", cfg.LastError) + } +} + +// waitForAuthProbe blocks until the async probe spawned by SetConfig has +// completed (signaled via the fixture's probeHook), then returns the persisted +// config. A 2s ceiling guards against bugs that prevent the hook from firing. +func waitForAuthProbe(t *testing.T, f *svcFixture, workspaceID string) *JiraConfig { + t.Helper() + for { + select { + case got := <-f.probed: + if got != workspaceID { + continue + } + cfg, err := f.svc.GetConfig(context.Background(), workspaceID) + if err != nil { + t.Fatalf("get config after probe: %v", err) + } + return cfg + case <-time.After(2 * time.Second): + t.Fatalf("async probe hook did not fire for %q within 2s", workspaceID) + return nil + } + } +} + +func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "first", + }) + if err != nil { + t.Fatalf("initial: %v", err) + } + _, err = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "new@x", + AuthMethod: AuthMethodAPIToken, Secret: "", + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "first" { + t.Errorf("secret should be preserved, got %q", got) + } +} + +func TestService_SetConfig_InvalidatesClientCache(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "t", + }) + // SetConfig spawns an async probe that also builds the client; wait for it + // so the rest of the test sees a stable factoryHit count. + waitForAuthProbe(t, f, "ws-1") + if _, err := f.svc.GetTicket(ctx, "ws-1", "A-1"); err != nil { + t.Fatalf("get1: %v", err) + } + hits := f.factoryHit.Load() + // Second call reuses cached client. + if _, err := f.svc.GetTicket(ctx, "ws-1", "A-2"); err != nil { + t.Fatalf("get2: %v", err) + } + if got := f.factoryHit.Load(); got != hits { + t.Errorf("factory should be cached, hits %d→%d", hits, got) + } + // Updating config invalidates cache. Wait for the async probe spawned by + // SetConfig to finish before sampling factoryHit so the second probe can't + // race with the GetTicket assertion. + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "t2", + }) + waitForAuthProbe(t, f, "ws-1") + if _, err := f.svc.GetTicket(ctx, "ws-1", "A-3"); err != nil { + t.Fatalf("get3: %v", err) + } + if got := f.factoryHit.Load(); got <= hits { + t.Errorf("factory should rebuild after config change, hits=%d", got) + } +} + +func TestService_Validation(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + cases := []struct { + name string + req SetConfigRequest + }{ + {"missing ws", SetConfigRequest{SiteURL: "x", AuthMethod: AuthMethodAPIToken, Email: "e"}}, + {"missing site", SetConfigRequest{WorkspaceID: "w", AuthMethod: AuthMethodAPIToken, Email: "e"}}, + {"missing email api_token", SetConfigRequest{WorkspaceID: "w", SiteURL: "x", AuthMethod: AuthMethodAPIToken}}, + {"bad auth", SetConfigRequest{WorkspaceID: "w", SiteURL: "x", AuthMethod: "bogus"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := f.svc.SetConfig(ctx, &tc.req); err == nil { + t.Error("expected validation error") + } + }) + } +} + +func TestService_TestConnection_InlineSecret(t *testing.T) { + f := newSvcFixture(t) + called := false + f.client.testAuthFn = func() (*TestConnectionResult, error) { + called = true + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "inline", + }) + if err != nil || !called { + t.Fatalf("called=%v err=%v", called, err) + } + if !res.OK || res.DisplayName != "Alice" { + t.Errorf("result: %+v", res) + } +} + +func TestService_TestConnection_NoStoredSecret_ReturnsFailure(t *testing.T) { + f := newSvcFixture(t) + res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-nope", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if res.OK { + t.Fatal("expected OK=false") + } +} + +func TestService_DeleteConfig_RemovesSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "t", + }) + if err := f.svc.DeleteConfig(ctx, "ws-1"); err != nil { + t.Fatalf("delete: %v", err) + } + if exists, _ := f.secrets.Exists(ctx, SecretKeyForWorkspace("ws-1")); exists { + t.Error("secret should be removed") + } + cfg, _ := f.svc.GetConfig(ctx, "ws-1") + if cfg != nil { + t.Errorf("expected config gone, got %+v", cfg) + } +} + +func TestService_GetTicket_UnconfiguredWorkspace(t *testing.T) { + f := newSvcFixture(t) + _, err := f.svc.GetTicket(context.Background(), "ws-nope", "X-1") + if !errors.Is(err, ErrNotConfigured) { + t.Errorf("expected ErrNotConfigured, got %v", err) + } +} + +func TestService_GetTicket_RevealError_NotConfusedWithUnconfigured(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if err := f.store.UpsertConfig(ctx, &JiraConfig{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("seed config: %v", err) + } + _, err := f.svc.GetTicket(ctx, "ws-1", "X-1") + if err == nil { + t.Fatal("expected error") + } + if errors.Is(err, ErrNotConfigured) { + t.Errorf("transient Reveal failure must not be ErrNotConfigured: %v", err) + } +} + +func TestService_DoTransition_PassThrough(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "t", + }) + if err := f.svc.DoTransition(ctx, "ws-1", "PROJ-9", "31"); err != nil { + t.Fatalf("transition: %v", err) + } + if len(f.client.transitionLog) != 1 || f.client.transitionLog[0] != "PROJ-9:31" { + t.Errorf("log: %v", f.client.transitionLog) + } +} diff --git a/apps/backend/internal/jira/store.go b/apps/backend/internal/jira/store.go new file mode 100644 index 0000000000..304080a230 --- /dev/null +++ b/apps/backend/internal/jira/store.go @@ -0,0 +1,177 @@ +package jira + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Store persists Jira workspace configurations. Secret values are delegated to +// the shared encrypted secret store and not stored here. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore creates a new Store and initializes the schema if needed. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + s := &Store{db: writer, ro: reader} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("jira schema init: %w", err) + } + return s, nil +} + +const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS jira_configs ( + workspace_id TEXT PRIMARY KEY, + site_url TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + auth_method TEXT NOT NULL, + default_project_key TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); +` + +// addedColumns lists columns introduced after the initial schema. SQLite has no +// portable `ADD COLUMN IF NOT EXISTS`, so we ask sqlite_master for the existing +// CREATE TABLE statement and only ALTER when the column name is missing. +var addedColumns = []struct { + name string + sql string +}{ + {"last_checked_at", "ALTER TABLE jira_configs ADD COLUMN last_checked_at DATETIME"}, + {"last_ok", "ALTER TABLE jira_configs ADD COLUMN last_ok INTEGER NOT NULL DEFAULT 0"}, + {"last_error", "ALTER TABLE jira_configs ADD COLUMN last_error TEXT NOT NULL DEFAULT ''"}, +} + +func (s *Store) initSchema() error { + if _, err := s.db.Exec(createTablesSQL); err != nil { + return err + } + return s.migrateAddedColumns() +} + +// migrateAddedColumns applies ALTER TABLE statements for columns introduced +// after the initial schema. Existing databases created with the old CREATE +// TABLE need these columns backfilled; new databases already have them from +// createTablesSQL and the ALTERs are skipped. +func (s *Store) migrateAddedColumns() error { + existing, err := s.tableColumns("jira_configs") + if err != nil { + return err + } + for _, col := range addedColumns { + if _, ok := existing[col.name]; ok { + continue + } + if _, err := s.db.Exec(col.sql); err != nil { + return fmt.Errorf("add column %s: %w", col.name, err) + } + } + return nil +} + +func (s *Store) tableColumns(table string) (map[string]struct{}, error) { + rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + cols := make(map[string]struct{}) + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return nil, err + } + cols[name] = struct{}{} + } + return cols, rows.Err() +} + +const selectConfigColumns = `workspace_id, site_url, email, auth_method, default_project_key, + last_checked_at, last_ok, last_error, created_at, updated_at` + +// GetConfig returns the Jira config for a workspace, or nil when no row exists. +func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*JiraConfig, error) { + var cfg JiraConfig + err := s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM jira_configs WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfig inserts or updates the config row for a workspace. It never +// touches the secret store — callers must persist the token separately. The +// last_* health columns are deliberately not touched here; the poller owns +// those and writes them via UpdateAuthHealth. +func (s *Store) UpsertConfig(ctx context.Context, cfg *JiraConfig) error { + now := time.Now().UTC() + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO jira_configs (workspace_id, site_url, email, auth_method, default_project_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + site_url = excluded.site_url, + email = excluded.email, + auth_method = excluded.auth_method, + default_project_key = excluded.default_project_key, + updated_at = excluded.updated_at`, + cfg.WorkspaceID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.DefaultProjectKey, cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// DeleteConfig removes the Jira config row for a workspace. Secrets must be +// cleared separately by the caller. +func (s *Store) DeleteConfig(ctx context.Context, workspaceID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM jira_configs WHERE workspace_id = ?`, workspaceID) + return err +} + +// ListConfiguredWorkspaces returns the IDs of all workspaces that have a Jira +// config row. Used by the auth-health poller to know which workspaces to probe. +func (s *Store) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) { + var ids []string + err := s.ro.SelectContext(ctx, &ids, + `SELECT workspace_id FROM jira_configs ORDER BY workspace_id`) + if err != nil { + return nil, err + } + return ids, nil +} + +// UpdateAuthHealth records the result of a credential probe for a workspace. +// errMsg is the empty string when ok is true. If the workspace row no longer +// exists (e.g. the user removed the config concurrently with the poller), the +// update is a silent no-op rather than an error. +func (s *Store) UpdateAuthHealth(ctx context.Context, workspaceID string, ok bool, errMsg string, checkedAt time.Time) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE jira_configs + SET last_checked_at = ?, last_ok = ?, last_error = ? + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, workspaceID) + return err +} diff --git a/apps/backend/internal/jira/store_test.go b/apps/backend/internal/jira/store_test.go new file mode 100644 index 0000000000..1a101e3e5b --- /dev/null +++ b/apps/backend/internal/jira/store_test.go @@ -0,0 +1,174 @@ +package jira + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func TestStore_UpsertGetDelete(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + cfg := &JiraConfig{ + WorkspaceID: "ws-1", + SiteURL: "https://acme.atlassian.net", + Email: "user@example.com", + AuthMethod: AuthMethodAPIToken, + DefaultProjectKey: "PROJ", + } + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, err := store.GetConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected config, got nil") + } + if got.SiteURL != cfg.SiteURL || got.Email != cfg.Email { + t.Errorf("round-trip mismatch: %+v vs %+v", got, cfg) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Error("timestamps not set") + } + + // Update-in-place + cfg.Email = "other@example.com" + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("update upsert: %v", err) + } + got2, _ := store.GetConfig(ctx, "ws-1") + if got2.Email != "other@example.com" { + t.Errorf("expected email update, got %q", got2.Email) + } + + if err := store.DeleteConfig(ctx, "ws-1"); err != nil { + t.Fatalf("delete: %v", err) + } + gone, err := store.GetConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if gone != nil { + t.Errorf("expected nil after delete, got %+v", gone) + } +} + +func TestStore_GetConfig_Missing(t *testing.T) { + store := newTestStore(t) + cfg, err := store.GetConfig(context.Background(), "does-not-exist") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Errorf("expected nil for missing config, got %+v", cfg) + } +} + +func TestStore_ListConfiguredWorkspaces(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + mustUpsert := func(id string) { + if err := store.UpsertConfig(ctx, &JiraConfig{ + WorkspaceID: id, + SiteURL: "https://" + id + ".atlassian.net", + Email: id + "@example.com", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("upsert %s: %v", id, err) + } + } + mustUpsert("ws-b") + mustUpsert("ws-a") + ids, err := store.ListConfiguredWorkspaces(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(ids) != 2 || ids[0] != "ws-a" || ids[1] != "ws-b" { + t.Errorf("expected sorted [ws-a ws-b], got %v", ids) + } +} + +func TestStore_UpdateAuthHealth(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.UpsertConfig(ctx, &JiraConfig{ + WorkspaceID: "ws-1", + SiteURL: "https://acme.atlassian.net", + Email: "u@example.com", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + + // Initial state: no health recorded yet. + cfg, _ := store.GetConfig(ctx, "ws-1") + if cfg.LastCheckedAt != nil { + t.Errorf("expected nil last_checked_at on fresh row, got %v", cfg.LastCheckedAt) + } + if cfg.LastOk { + t.Error("expected last_ok=false on fresh row") + } + + // Record success. + now := time.Now().UTC().Truncate(time.Second) + if err := store.UpdateAuthHealth(ctx, "ws-1", true, "", now); err != nil { + t.Fatalf("update ok: %v", err) + } + cfg, _ = store.GetConfig(ctx, "ws-1") + if !cfg.LastOk { + t.Error("expected last_ok=true after successful probe") + } + if cfg.LastError != "" { + t.Errorf("expected empty last_error, got %q", cfg.LastError) + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(now) { + t.Errorf("expected last_checked_at=%v, got %v", now, cfg.LastCheckedAt) + } + + // Record failure — error string is preserved, ok flips back to false. + failAt := now.Add(time.Minute) + if err := store.UpdateAuthHealth(ctx, "ws-1", false, "step-up required", failAt); err != nil { + t.Fatalf("update fail: %v", err) + } + cfg, _ = store.GetConfig(ctx, "ws-1") + if cfg.LastOk { + t.Error("expected last_ok=false after failure") + } + if cfg.LastError != "step-up required" { + t.Errorf("expected last_error preserved, got %q", cfg.LastError) + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(failAt) { + t.Errorf("expected last_checked_at=%v, got %v", failAt, cfg.LastCheckedAt) + } + + // Updating health for a non-existent workspace must not error (silent + // no-op — the row may have been deleted between probe and persist). + if err := store.UpdateAuthHealth(ctx, "missing", true, "", now); err != nil { + t.Errorf("expected no-op for missing row, got %v", err) + } +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index a864b449db..09651c48db 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -385,6 +385,17 @@ const ( ActionGitHubActionPresetsReset = "github.action_presets.reset" ) +// Jira integration actions +const ( + ActionJiraConfigGet = "jira.config.get" + ActionJiraConfigSet = "jira.config.set" + ActionJiraConfigDelete = "jira.config.delete" + ActionJiraConfigTest = "jira.config.test" + ActionJiraTicketGet = "jira.ticket.get" + ActionJiraTicketTransition = "jira.ticket.transition" + ActionJiraProjectsList = "jira.projects.list" +) + // Error codes const ( ErrorCodeBadRequest = "BAD_REQUEST" diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx new file mode 100644 index 0000000000..0f32ed6cd1 --- /dev/null +++ b/apps/web/app/jira/jira-page-client.tsx @@ -0,0 +1,337 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { IconTicket } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { PageTopbar } from "@/components/page-topbar"; +import { getJiraConfig, listJiraProjects, searchJiraTickets } from "@/lib/api/domains/jira-api"; +import type { JiraProject, JiraTicket } from "@/lib/types/jira"; +import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { TicketRow } from "@/components/jira/my-jira/ticket-row"; +import { useJiraSearch } from "@/components/jira/my-jira/use-jira-search"; +import { JiraErrorMessage } from "@/components/jira/jira-ticket-common"; +import { JiraTicketDialog } from "@/components/jira/jira-ticket-dialog"; +import { + QuickTaskLauncher, + type JiraLaunchPayload, +} from "@/components/jira/my-jira/quick-task-launcher"; +import { + DEFAULT_FILTERS, + filtersToJql, + type FilterState, +} from "@/components/jira/my-jira/filter-model"; +import { DEFAULT_VIEW, useSavedViews } from "@/components/jira/my-jira/use-saved-views"; +import { ListToolbar } from "@/components/jira/my-jira/list-toolbar"; +import { FilterBar, hasActiveFilters } from "@/components/jira/my-jira/filter-bar"; +import { ResultsPagination } from "@/components/jira/my-jira/results-pagination"; +import { JqlEditor } from "@/components/jira/my-jira/jql-editor"; +import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets"; +import type { JiraTaskPreset } from "@/components/jira/my-jira/presets"; + +type JiraPageClientProps = { + workspaceId?: string; + workflows: Workflow[]; + steps: WorkflowStep[]; +}; + +function NotConfiguredNotice({ workspaceId }: { workspaceId?: string }) { + return ( +
+ + + Jira is not configured for this workspace.{" "} + + Configure Jira + {" "} + to see your tickets here. + + +
+ ); +} + +async function loadUserProjects(workspaceId: string): Promise { + const [{ projects: all }, search] = await Promise.all([ + listJiraProjects(workspaceId), + searchJiraTickets(workspaceId, { + jql: "(assignee = currentUser() OR reporter = currentUser()) ORDER BY updated DESC", + maxResults: 100, + }), + ]); + const userKeys = new Set(search.tickets.map((t) => t.projectKey)); + return (all ?? []).filter((p) => userKeys.has(p.key)); +} + +function useJiraPageData(workspaceId?: string) { + const [loaded, setLoaded] = useState(false); + const [configured, setConfigured] = useState(false); + const [projects, setProjects] = useState([]); + + useEffect(() => { + let cancelled = false; + async function load() { + if (!workspaceId) { + setLoaded(true); + return; + } + try { + const cfg = await getJiraConfig(workspaceId); + if (cancelled) return; + const ok = !!cfg && cfg.hasSecret; + setConfigured(ok); + if (ok) { + try { + const list = await loadUserProjects(workspaceId); + if (!cancelled) setProjects(list); + } catch { + // Non-fatal: pill will just show empty list. Users can still filter by other dims. + } + } + } finally { + if (!cancelled) setLoaded(true); + } + } + void load(); + return () => { + cancelled = true; + }; + }, [workspaceId]); + + return { loaded, configured, projects }; +} + +function TicketResults({ + items, + loading, + error, + workspaceId, + presets, + onStartTask, + onOpen, +}: { + items: JiraTicket[]; + loading: boolean; + error: string | null; + workspaceId: string | null | undefined; + presets: JiraTaskPreset[]; + onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void; + onOpen: (ticket: JiraTicket) => void; +}) { + if (error) { + return ( +
+ +
+ ); + } + if (!loading && items.length === 0) { + return
No tickets found.
; + } + return ( +
+ {items.map((t) => ( + + ))} +
+ ); +} + +type AuthenticatedViewProps = { + workspaceId: string | undefined; + projects: JiraProject[]; + presets: JiraTaskPreset[]; + onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void; + onOpenTicket: (ticket: JiraTicket) => void; +}; + +function AuthenticatedView({ + workspaceId, + projects, + presets, + onStartTask, + onOpenTicket, +}: AuthenticatedViewProps) { + const state = useFilterState(); + const search = useJiraSearch(workspaceId ?? null, state.effectiveJql); + + return ( + <> + state.updateFilters({ ...state.filters, searchText })} + views={state.views} + activeViewId={state.activeViewId} + onSelectView={state.selectView} + onDeleteView={state.deleteView} + onSaveView={state.saveCurrentAsView} + count={search.items.length} + loading={search.loading} + sort={state.filters.sort} + onSortChange={(sort) => state.updateFilters({ ...state.filters, sort })} + onRefresh={search.refresh} + showJqlEditor={state.showJqlEditor} + onToggleJqlEditor={() => state.setShowJqlEditor(!state.showJqlEditor)} + /> + {state.showJqlEditor && ( + + )} + {state.customJql === null && ( + state.updateFilters(DEFAULT_FILTERS)} + /> + )} +
+ +
+ + + ); +} + +function useFilterState() { + const savedViews = useSavedViews(); + const [filters, setFilters] = useState(DEFAULT_VIEW.filters); + const [activeViewId, setActiveViewId] = useState(DEFAULT_VIEW.id); + const [customJql, setCustomJql] = useState(null); + const [showJqlEditor, setShowJqlEditor] = useState(false); + + const composedJql = useMemo(() => filtersToJql(filters), [filters]); + const effectiveJql = customJql ?? composedJql; + + const updateFilters = useCallback((next: FilterState) => { + setFilters(next); + setActiveViewId(null); + setCustomJql(null); + }, []); + + const selectView = useCallback( + (id: string) => { + const v = savedViews.views.find((x) => x.id === id); + if (!v) return; + setFilters(v.filters); + setActiveViewId(id); + const savedCustomJql = v.customJql ?? null; + setCustomJql(savedCustomJql); + // Auto-open the JQL editor when restoring a JQL-backed view so the user + // can see what's running. + if (savedCustomJql !== null) setShowJqlEditor(true); + }, + [savedViews.views], + ); + + const saveCurrentAsView = useCallback( + (name: string) => { + const view = savedViews.save(name, filters, customJql); + setActiveViewId(view.id); + }, + [savedViews, filters, customJql], + ); + + const resetCustomJql = useCallback(() => setCustomJql(null), []); + + return { + filters, + updateFilters, + views: savedViews.views, + activeViewId, + selectView, + deleteView: savedViews.remove, + saveCurrentAsView, + composedJql, + customJql, + effectiveJql, + applyCustomJql: setCustomJql, + resetCustomJql, + showJqlEditor, + setShowJqlEditor, + }; +} + +export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClientProps) { + const { loaded, configured, projects } = useJiraPageData(workspaceId); + const { taskPresets } = useJiraTaskPresets(); + const [launchPayload, setLaunchPayload] = useState(null); + const [openTicket, setOpenTicket] = useState(null); + + const onStartTask = useCallback((ticket: JiraTicket, preset: JiraTaskPreset) => { + setLaunchPayload({ ticket, preset }); + }, []); + const onCloseLaunch = useCallback(() => setLaunchPayload(null), []); + const onOpenTicket = useCallback((ticket: JiraTicket) => setOpenTicket(ticket), []); + + return ( +
+ } + /> + {!loaded &&
Checking Jira status…
} + {loaded && !configured && } + {loaded && configured && ( + + )} + + { + if (!v) setOpenTicket(null); + }} + workspaceId={workspaceId} + ticketKey={openTicket?.key} + initialTicket={openTicket} + presets={taskPresets} + onStartTask={(ticket, preset) => { + setOpenTicket(null); + onStartTask(ticket, preset); + }} + /> +
+ ); +} diff --git a/apps/web/app/jira/page.tsx b/apps/web/app/jira/page.tsx new file mode 100644 index 0000000000..daf99a1af2 --- /dev/null +++ b/apps/web/app/jira/page.tsx @@ -0,0 +1,65 @@ +import { + listWorkspacesAction, + listWorkflowsAction, + listWorkspaceWorkflowStepsAction, +} from "@/app/actions/workspaces"; +import { fetchUserSettings } from "@/lib/api"; +import { StateHydrator } from "@/components/state-hydrator"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { JiraPageClient } from "./jira-page-client"; +import type { Workflow, WorkflowStep, Workspace, UserSettingsResponse } from "@/lib/types/http"; +import type { AppState } from "@/lib/state/store"; + +export default async function JiraPage() { + let workspaces: Workspace[] = []; + let workflows: Workflow[] = []; + let steps: WorkflowStep[] = []; + let workspaceId: string | undefined; + let userSettingsResponse: UserSettingsResponse | null = null; + + try { + const [workspacesResponse, settingsResponse] = await Promise.all([ + listWorkspacesAction(), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + ]); + workspaces = workspacesResponse.workspaces; + userSettingsResponse = settingsResponse; + workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id; + + if (workspaceId) { + const [workflowsRes, stepsRes] = await Promise.all([ + listWorkflowsAction(workspaceId), + listWorkspaceWorkflowStepsAction(workspaceId), + ]); + workflows = workflowsRes.workflows; + steps = stepsRes.steps; + } + } catch (error) { + console.error("Failed to load Jira page data:", error); + } + + const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); + + const initialState: Partial = { + workspaces: { items: workspaces, activeId: workspaceId ?? null }, + workflows: { + items: workflows.map((w) => ({ + id: w.id, + workspaceId: w.workspace_id, + name: w.name, + description: w.description ?? null, + sortOrder: w.sort_order ?? 0, + ...(w.agent_profile_id ? { agent_profile_id: w.agent_profile_id } : {}), + })), + activeId: workflows[0]?.id ?? null, + }, + userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, + }; + + return ( + <> + + + + ); +} diff --git a/apps/web/app/settings/workspace/[id]/jira/page.tsx b/apps/web/app/settings/workspace/[id]/jira/page.tsx new file mode 100644 index 0000000000..cda0d66fc8 --- /dev/null +++ b/apps/web/app/settings/workspace/[id]/jira/page.tsx @@ -0,0 +1,11 @@ +import { JiraSettings } from "@/components/jira/jira-settings"; +import { StateProvider } from "@/components/state-provider"; + +export default async function WorkspaceJiraPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ( + + + + ); +} diff --git a/apps/web/components/jira/jira-import-bar.tsx b/apps/web/components/jira/jira-import-bar.tsx new file mode 100644 index 0000000000..8a7d7218a6 --- /dev/null +++ b/apps/web/components/jira/jira-import-bar.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { IconTicket } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { getJiraTicket } from "@/lib/api/domains/jira-api"; +import type { JiraTicket } from "@/lib/types/jira"; +import { JIRA_KEY_RE } from "./jira-ticket-common"; + +function extractKey(input: string): string | null { + const match = input.toUpperCase().match(JIRA_KEY_RE); + return match ? match[0] : null; +} + +type JiraImportBarProps = { + workspaceId: string | null; + disabled?: boolean; + onImport: (ticket: JiraTicket) => void; +}; + +export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBarProps) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + if (!workspaceId) { + setError("Select a workspace first"); + return; + } + const key = extractKey(value.trim()); + if (!key) { + setError("Paste a Jira ticket URL or key (PROJ-123)"); + return; + } + setLoading(true); + setError(null); + try { + const ticket = await getJiraTicket(workspaceId, key); + onImport(ticket); + setOpen(false); + setValue(""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [workspaceId, value, onImport]); + + return ( + + + + + + + + Import from Jira ticket URL or key + + +
+
Import Jira ticket
+ setValue(e.target.value)} + placeholder="PROJ-123 or paste ticket URL" + className="h-8 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void submit(); + } + }} + /> + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+
+ ); +} diff --git a/apps/web/components/jira/jira-link-button.tsx b/apps/web/components/jira/jira-link-button.tsx new file mode 100644 index 0000000000..051abb1b74 --- /dev/null +++ b/apps/web/components/jira/jira-link-button.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { IconLink } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { toast } from "sonner"; +import { getJiraConfig, getJiraTicket } from "@/lib/api/domains/jira-api"; +import { updateTask } from "@/lib/api/domains/kanban-api"; +import { JIRA_KEY_RE } from "./jira-ticket-common"; + +function useJiraConfigured(workspaceId: string | null | undefined): boolean { + const [configured, setConfigured] = useState(false); + useEffect(() => { + if (!workspaceId) return; + let cancelled = false; + void getJiraConfig(workspaceId) + .then((cfg) => { + if (!cancelled) setConfigured(!!cfg && cfg.hasSecret); + }) + .catch(() => { + if (!cancelled) setConfigured(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceId]); + return workspaceId ? configured : false; +} + +type JiraLinkButtonProps = { + taskId: string | null | undefined; + workspaceId: string | null | undefined; + taskTitle: string | undefined | null; +}; + +// JiraLinkButton lets the user attach a Jira ticket to an existing task by +// prepending the ticket key to its title ("PROJ-123: ..."). The existing +// JiraTicketButton picks up the key automatically once the title is updated. +export function JiraLinkButton({ taskId, workspaceId, taskTitle }: JiraLinkButtonProps) { + const configured = useJiraConfigured(workspaceId); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + if (!taskId || !workspaceId) return; + const match = value.toUpperCase().match(JIRA_KEY_RE); + if (!match) { + setError("Paste a Jira ticket URL or key (PROJ-123)"); + return; + } + const key = match[0]; + setLoading(true); + setError(null); + try { + await getJiraTicket(workspaceId, key); + // Strip an existing leading "PROJ-123: " so re-linking a task to a + // different ticket replaces the prefix instead of stacking + // ("PROJ-456: PROJ-123: ..."). + const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9]+-\d+:\s*/, ""); + const newTitle = stripped ? `${key}: ${stripped}` : key; + await updateTask(taskId, { title: newTitle }); + toast.success(`Linked to ${key}`); + setOpen(false); + setValue(""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [taskId, workspaceId, value, taskTitle]); + + if (!configured || !taskId || !workspaceId) return null; + + return ( + + + + + + + + Link this task to a Jira ticket + + +
+
Link to Jira ticket
+ setValue(e.target.value)} + placeholder="PROJ-123 or paste ticket URL" + className="h-8 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void submit(); + } + }} + /> + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+
+ ); +} diff --git a/apps/web/components/jira/jira-settings.tsx b/apps/web/components/jira/jira-settings.tsx new file mode 100644 index 0000000000..356a97efe3 --- /dev/null +++ b/apps/web/components/jira/jira-settings.tsx @@ -0,0 +1,567 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { IconTicket, IconCode, IconCheck, IconAlertTriangle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Separator } from "@kandev/ui/separator"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Switch } from "@kandev/ui/switch"; +import { useToast } from "@/components/toast-provider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { TaskPresetsSection } from "@/components/jira/task-presets-section"; +import { useJiraEnabled } from "@/components/jira/my-jira/use-jira-enabled"; +import { + getJiraConfig, + setJiraConfig, + deleteJiraConfig, + testJiraConnection, +} from "@/lib/api/domains/jira-api"; +import type { JiraAuthMethod, JiraConfig, TestJiraConnectionResult } from "@/lib/types/jira"; + +// How often to re-fetch the config so the auth-health indicator picks up new +// probe results from the backend poller (which runs every 90s). +const STATUS_REFRESH_MS = 90_000; + +// Session cookies are HttpOnly so document.cookie can't read them, but +// DevTools → Application → Cookies surfaces them in plain text. Users copy +// the Value cell of a single row; the backend wraps it under both +// cloud.session.token and tenant.session.token so a single paste works for +// password accounts and SSO tenants. +const COOKIE_INSTRUCTIONS = `Open DevTools (Cmd+Opt+I / Ctrl+Shift+I) on your Atlassian tab → +Application tab → Storage → Cookies → https://*.atlassian.net → +find the row named "cloud.session.token" (or "tenant.session.token" +on SSO tenants) → copy the Value cell → paste it below. +Don't include the cookie name or any "=" — just the token value.`; + +type FormState = { + siteUrl: string; + email: string; + authMethod: JiraAuthMethod; + defaultProjectKey: string; + secret: string; +}; + +const emptyForm: FormState = { + siteUrl: "", + email: "", + authMethod: "api_token", + defaultProjectKey: "", + secret: "", +}; + +function configToForm(cfg: JiraConfig | null): FormState { + if (!cfg) return emptyForm; + return { + siteUrl: cfg.siteUrl, + email: cfg.email, + authMethod: cfg.authMethod, + defaultProjectKey: cfg.defaultProjectKey, + secret: "", + }; +} + +function saveLabel(saving: boolean, hasConfig: boolean): string { + if (saving) return "Saving..."; + return hasConfig ? "Update" : "Save"; +} + +type FieldsRowProps = { + form: FormState; + loading: boolean; + update: (key: K, value: FormState[K]) => void; +}; + +function SiteFields({ form, loading, update }: FieldsRowProps) { + return ( +
+
+ + update("siteUrl", e.target.value)} + disabled={loading} + /> +
+
+ + update("defaultProjectKey", e.target.value.toUpperCase())} + disabled={loading} + /> +
+
+ ); +} + +function AuthFields({ form, loading, update }: FieldsRowProps) { + return ( +
+
+ + +
+
+ + update("email", e.target.value)} + disabled={loading} + /> +
+
+ ); +} + +function SessionSnippet() { + const [show, setShow] = useState(false); + return ( +
+ + {show && ( +
+          {COOKIE_INSTRUCTIONS}
+        
+ )} +
+ ); +} + +type SecretFieldProps = FieldsRowProps & { hasSavedSecret: boolean }; + +function secretPlaceholder(isApiToken: boolean, hasSavedSecret: boolean): string { + if (hasSavedSecret) return "••••••••"; + return isApiToken ? "paste token here" : "paste cloud.session.token value"; +} + +function formatExpiry(expiresAt: string): { label: string; tone: "ok" | "warn" | "danger" } { + const diffMs = new Date(expiresAt).getTime() - Date.now(); + if (Number.isNaN(diffMs)) return { label: "Expiry unknown", tone: "warn" }; + if (diffMs <= 0) return { label: "Cookie expired — paste a fresh one", tone: "danger" }; + const hours = diffMs / (60 * 60 * 1000); + if (hours < 24) { + const h = Math.max(1, Math.round(hours)); + return { label: `Cookie expires in ${h}h`, tone: "danger" }; + } + const days = Math.round(hours / 24); + return { + label: `Cookie expires in ${days} day${days === 1 ? "" : "s"}`, + tone: days < 7 ? "warn" : "ok", + }; +} + +const TONE_CLASSES: Record<"ok" | "warn" | "danger", string> = { + ok: "text-muted-foreground", + warn: "text-amber-600 dark:text-amber-400", + danger: "text-destructive", +}; + +function CookieExpiry({ expiresAt }: { expiresAt: string }) { + const { label, tone } = formatExpiry(expiresAt); + const absolute = new Date(expiresAt).toLocaleString(); + return ( +

+ {label} +

+ ); +} + +type SecretFieldPropsWithExpiry = SecretFieldProps & { secretExpiresAt?: string | null }; + +function SecretField({ + form, + loading, + update, + hasSavedSecret, + secretExpiresAt, +}: SecretFieldPropsWithExpiry) { + const isApiToken = form.authMethod === "api_token"; + return ( +
+ + update("secret", e.target.value)} + disabled={loading} + /> + {!isApiToken && hasSavedSecret && secretExpiresAt && ( + + )} + {isApiToken && ( +

+ Create a token at{" "} + + id.atlassian.com/manage-profile/security/api-tokens + +

+ )} + {!isApiToken && } +
+ ); +} + +function TestResultAlert({ result }: { result: TestJiraConnectionResult | null }) { + if (!result) return null; + return ( + + + {result.ok + ? `Connected as ${result.displayName || result.email || result.accountId}` + : `Failed: ${result.error}`} + + + ); +} + +type AuthHealth = { + ok: boolean; + error: string; + checkedAt: Date | null; +}; + +function configToHealth(config: JiraConfig | null): AuthHealth | null { + if (!config?.hasSecret) return null; + if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null }; + return { + ok: !!config.lastOk, + error: config.lastError ?? "", + checkedAt: new Date(config.lastCheckedAt), + }; +} + +// Re-render every 30s so "checked 1 minute ago" doesn't sit stale on a long- +// open settings tab. +function useTick(intervalMs: number) { + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((n) => n + 1), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); +} + +function LastCheckedLabel({ checkedAt }: { checkedAt: Date | null }) { + useTick(30_000); + if (!checkedAt) return null; + return ( + + · checked {formatDistanceToNow(checkedAt, { addSuffix: true })} + + ); +} + +function AuthStatusBanner({ health }: { health: AuthHealth | null }) { + if (!health) return null; + if (!health.checkedAt) { + return ( + + + Waiting for the next backend health check… + + + ); + } + if (health.ok) { + return ( + + + + Authenticated + + + + ); + } + return ( + + + + Authentication failed: {health.error || "unknown error"} + + + + ); +} + +type ActionBarProps = { + saving: boolean; + testing: boolean; + loading: boolean; + hasConfig: boolean; + disableSave: boolean; + disableTest: boolean; + onTest: () => void; + onSave: () => void; + onDelete: () => void; +}; + +function ActionBar({ + saving, + testing, + loading, + hasConfig, + disableSave, + disableTest, + onTest, + onSave, + onDelete, +}: ActionBarProps) { + return ( +
+ + + {hasConfig && ( + + )} +
+ ); +} + +type JiraSettingsProps = { + workspaceId: string; +}; + +function useJiraSettings(workspaceId: string) { + const { toast } = useToast(); + const [config, setConfig] = useState(null); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const health = configToHealth(config); + + const load = useCallback(async () => { + setLoading(true); + try { + const cfg = await getJiraConfig(workspaceId); + setConfig(cfg); + setForm(configToForm(cfg)); + } catch (err) { + toast({ description: `Failed to load Jira config: ${String(err)}`, variant: "error" }); + } finally { + setLoading(false); + } + }, [workspaceId, toast]); + + useEffect(() => { + void load(); + }, [load]); + + // Background refresh so the auth-health banner picks up new probe results + // from the backend poller without requiring a page reload. We re-fetch the + // config rather than the loud full `load()` to avoid flashing the form. + useEffect(() => { + const id = setInterval(() => { + getJiraConfig(workspaceId) + .then((cfg) => setConfig(cfg)) + .catch(() => { + /* transient failures are fine — next tick retries */ + }); + }, STATUS_REFRESH_MS); + return () => clearInterval(id); + }, [workspaceId]); + + const update = useCallback( + (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })), + [], + ); + + const handleTest = useCallback(async () => { + setTesting(true); + setTestResult(null); + try { + const res = await testJiraConnection({ workspaceId, ...form }); + setTestResult(res); + } catch (err) { + setTestResult({ ok: false, error: String(err) }); + } finally { + setTesting(false); + } + }, [workspaceId, form]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const saved = await setJiraConfig({ + workspaceId, + siteUrl: form.siteUrl, + email: form.email, + authMethod: form.authMethod, + defaultProjectKey: form.defaultProjectKey, + secret: form.secret || undefined, + }); + setConfig(saved); + setForm(configToForm(saved)); + // Clear any inline test result from the previous credentials so the + // alert reflects only the currently-saved state. + setTestResult(null); + toast({ description: "Jira configuration saved", variant: "success" }); + } catch (err) { + toast({ description: `Save failed: ${String(err)}`, variant: "error" }); + } finally { + setSaving(false); + } + }, [workspaceId, form, toast]); + + const handleDelete = useCallback(async () => { + if (!confirm("Remove Jira configuration for this workspace?")) return; + try { + await deleteJiraConfig(workspaceId); + setConfig(null); + setForm(emptyForm); + setTestResult(null); + toast({ description: "Jira configuration removed", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } + }, [workspaceId, toast]); + + return { + config, + form, + loading, + saving, + testing, + testResult, + health, + update, + handleTest, + handleSave, + handleDelete, + }; +} + +function EnabledPill({ workspaceId }: { workspaceId: string }) { + const { enabled, setEnabled } = useJiraEnabled(workspaceId); + return ( +
+ + +
+ ); +} + +export function JiraSettings({ workspaceId }: JiraSettingsProps) { + const s = useJiraSettings(workspaceId); + const missingSecret = !s.config?.hasSecret && !s.form.secret; + const disableSave = + s.saving || + !s.form.siteUrl || + (s.form.authMethod === "api_token" && !s.form.email) || + missingSecret; + const disableTest = missingSecret; + + return ( +
+ } + title="Jira integration" + description="Connect this workspace to an Atlassian Cloud site. Credentials are stored encrypted server-side." + action={} + > + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/components/jira/jira-ticket-button.tsx b/apps/web/components/jira/jira-ticket-button.tsx new file mode 100644 index 0000000000..593eeed1b1 --- /dev/null +++ b/apps/web/components/jira/jira-ticket-button.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import { IconTicket } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { JiraTicketDialog } from "./jira-ticket-dialog"; +import { extractJiraKey } from "./jira-ticket-common"; + +export { extractJiraKey }; + +type JiraTicketButtonProps = { + workspaceId: string | null | undefined; + taskTitle: string | undefined | null; +}; + +// JiraTicketButton sits in the task top bar. It extracts a Jira key from the +// task title and opens a full ticket dialog on click. +export function JiraTicketButton({ workspaceId, taskTitle }: JiraTicketButtonProps) { + const ticketKey = extractJiraKey(taskTitle); + const [open, setOpen] = useState(false); + + if (!workspaceId || !ticketKey) return null; + + return ( + <> + + + + + Open Jira ticket {ticketKey} + + + + ); +} diff --git a/apps/web/components/jira/jira-ticket-common.tsx b/apps/web/components/jira/jira-ticket-common.tsx new file mode 100644 index 0000000000..04377bfba8 --- /dev/null +++ b/apps/web/components/jira/jira-ticket-common.tsx @@ -0,0 +1,212 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { IconLockExclamation } from "@tabler/icons-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar"; +import { Button } from "@kandev/ui/button"; +import { getJiraTicket, transitionJiraTicket } from "@/lib/api/domains/jira-api"; +import type { JiraStatusCategory, JiraTicket } from "@/lib/types/jira"; + +// Matches PROJECT-123 anywhere in the string. Jira keys start with letters and +// include an uppercase prefix, followed by a dash and one or more digits. +export const JIRA_KEY_RE = /\b[A-Z][A-Z0-9]+-\d+\b/; + +export function extractJiraKey(title: string | undefined | null): string | null { + if (!title) return null; + const match = title.match(JIRA_KEY_RE); + return match ? match[0] : null; +} + +// Map Jira's statusCategory to Tailwind color classes. Jira returns one of +// three stable keys: "new" (to-do), "indeterminate" (in-progress), "done". +export function statusBadgeClass(category: JiraStatusCategory | undefined): string { + switch (category) { + case "done": + return "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30"; + case "indeterminate": + return "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30"; + case "new": + return "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30"; + default: + return ""; + } +} + +export function formatRelative(iso: string | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return formatDistanceToNow(d, { addSuffix: true }); +} + +export function PersonCell({ name, avatar }: { name?: string; avatar?: string }) { + if (!name) return Unassigned; + return ( + <> + + {avatar && } + {name.charAt(0)} + + {name} + + ); +} + +export function IconLabel({ icon, label }: { icon?: string; label?: string }) { + if (!label) return —; + return ( + <> + {icon && ( + // Jira serves issuetype/priority icons from its CDN at fixed 16x16; + // next/image would require per-host allowlisting for no real gain. + // eslint-disable-next-line @next/next/no-img-element + + )} + {label} + + ); +} + +export type TicketState = { + ticket: JiraTicket | null; + loading: boolean; + error: string | null; + pendingTransition: string | null; + load: () => Promise; + handleTransition: (id: string) => Promise; +}; + +// Shared hook for loading a Jira ticket and applying transitions. Consumers +// pass `enabled` so the fetch only kicks off when the UI (popover/dialog) is +// visible. Re-runs `load` when workspace/ticket key change. +export function useTicketState( + workspaceId: string, + ticketKey: string, + enabled: boolean, +): TicketState { + const [ticket, setTicket] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pendingTransition, setPendingTransition] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const t = await getJiraTicket(workspaceId, ticketKey); + setTicket(t); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [workspaceId, ticketKey]); + + useEffect(() => { + if (!enabled || !workspaceId || !ticketKey) return; + let cancelled = false; + async function run() { + setTicket(null); + setLoading(true); + setError(null); + try { + const t = await getJiraTicket(workspaceId, ticketKey); + if (!cancelled) setTicket(t); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setLoading(false); + } + } + void run(); + return () => { + cancelled = true; + }; + }, [enabled, workspaceId, ticketKey]); + + const handleTransition = useCallback( + async (transitionId: string) => { + setPendingTransition(transitionId); + setError(null); + try { + await transitionJiraTicket(workspaceId, ticketKey, transitionId); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPendingTransition(null); + } + }, + [workspaceId, ticketKey, load], + ); + + return { ticket, loading, error, pendingTransition, load, handleTransition }; +} + +// Backend wraps Jira responses as `jira api: status N: …`. 401/403 mean the +// session is invalid; 303 with "Step-up" means Atlassian wants re-auth. +const AUTH_STATUS_RE = /\bstatus (?:303|401|403)\b/i; +const STEP_UP_RE = /step-?up authentication/i; + +export function isJiraAuthError(error: string): boolean { + return AUTH_STATUS_RE.test(error) || STEP_UP_RE.test(error); +} + +// Drops support URLs Atlassian inlines into 3xx response bodies — they're +// noise once the user has a clear CTA. +const URL_RE = /\bhttps?:\/\/\S+/g; + +export function cleanJiraErrorMessage(error: string): string { + return error.replace(URL_RE, "").replace(/\s+/g, " ").trim(); +} + +type JiraErrorMessageProps = { + error: string; + workspaceId?: string | null; + /** Inline variant for cases where a ticket is already rendered above. */ + compact?: boolean; +}; + +export function JiraErrorMessage({ error, workspaceId, compact }: JiraErrorMessageProps) { + const isAuth = isJiraAuthError(error); + const settingsHref = workspaceId ? `/settings/workspace/${workspaceId}/jira` : "/settings"; + + if (compact) { + return ( +
+ + {isAuth ? "Jira authentication required." : cleanJiraErrorMessage(error)} + + {isAuth && ( + + )} +
+ ); + } + + return ( +
+ {isAuth ? ( + <> + +
+

Jira authentication required

+

+ Your Jira session expired or needs step-up authentication. Reconnect to view this + ticket. +

+
+ + + ) : ( +

{cleanJiraErrorMessage(error)}

+ )} +
+ ); +} diff --git a/apps/web/components/jira/jira-ticket-dialog.tsx b/apps/web/components/jira/jira-ticket-dialog.tsx new file mode 100644 index 0000000000..e7034440fe --- /dev/null +++ b/apps/web/components/jira/jira-ticket-dialog.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { IconExternalLink, IconRefresh, IconPlus } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import type { JiraTicket, JiraTransition } from "@/lib/types/jira"; +import { + IconLabel, + JiraErrorMessage, + PersonCell, + formatRelative, + statusBadgeClass, + useTicketState, + type TicketState, +} from "./jira-ticket-common"; +import type { JiraTaskPreset } from "./my-jira/presets"; + +type JiraTicketDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string | null | undefined; + ticketKey: string | null | undefined; + /** Shown while the full ticket is loading so the dialog doesn't flash empty. */ + initialTicket?: JiraTicket | null; + /** Presets shown in the footer "Start task" menu. Required when onStartTask is set. */ + presets?: JiraTaskPreset[]; + /** If provided, shows a "Start task" menu in the dialog footer. */ + onStartTask?: (ticket: JiraTicket, preset: JiraTaskPreset) => void; +}; + +function dialogTitle(ticket: JiraTicket | null, ticketKey: string | null | undefined): string { + return ticket?.summary ?? ticketKey ?? "Jira ticket"; +} + +function effectiveTicketKey( + ticket: JiraTicket | null, + ticketKey: string | null | undefined, +): string { + return ticketKey ?? ticket?.key ?? ""; +} + +export function JiraTicketDialog({ + open, + onOpenChange, + workspaceId, + ticketKey, + initialTicket, + presets, + onStartTask, +}: JiraTicketDialogProps) { + const enabled = open && !!workspaceId && !!ticketKey; + const state = useTicketState(workspaceId ?? "", ticketKey ?? "", enabled); + const ticket = state.ticket ?? initialTicket ?? null; + const showFooter = ticket !== null && onStartTask !== undefined && presets !== undefined; + + const errorOnly = state.error !== null && ticket === null; + + return ( + + + {dialogTitle(ticket, ticketKey)} + {errorOnly ? ( +
+ +
+ ) : ( + <> + void state.load()} + /> + + {showFooter && ( + + )} + + )} +
+
+ ); +} + +function DialogBody({ + ticket, + state, + ticketKey, + workspaceId, +}: { + ticket: JiraTicket | null; + state: TicketState; + ticketKey: string; + workspaceId: string | null | undefined; +}) { + return ( +
+ {state.error && ticket && ( +
+ +
+ )} + {!ticket && state.loading && ( +
Loading ticket…
+ )} + {ticket && } +
+ ); +} + +function TicketFooter({ + ticket, + presets, + onStartTask, +}: { + ticket: JiraTicket; + presets: JiraTaskPreset[]; + onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void; +}) { + return ( +
+ + + + + + {presets.map((p) => { + const Icon = p.icon; + return ( + onStartTask(ticket, p)} + className="cursor-pointer" + > + +
+ {p.label} + {p.hint} +
+
+ ); + })} +
+
+
+ ); +} + +type TopBarProps = { + ticket: JiraTicket | null; + loading: boolean; + onRefresh: () => void; +}; + +function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) { + return ( +
+ {ticket?.url && ( + + )} + +
+ ); +} + +function TicketBody({ + ticket, + state, + ticketKey, +}: { + ticket: JiraTicket; + state: TicketState; + ticketKey: string; +}) { + return ( +
+
+ + +
+ +
+ ); +} + +function TicketHeading({ ticket, ticketKey }: { ticket: JiraTicket; ticketKey: string }) { + return ( +
+
+ {ticketKey} + {ticket.issueType && ( + <> + · + + + )} +
+

{ticket.summary}

+
+ ); +} + +function SectionHeading({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function DescriptionSection({ description }: { description: string }) { + return ( +
+ Description +
+ {description ? ( +
{description}
+ ) : ( +
No description.
+ )} +
+
+ ); +} + +type StatusBlockProps = { + ticket: JiraTicket; + transitions: JiraTransition[] | null | undefined; + pending: string | null; + onTransition: (id: string) => void; +}; + +function StatusBlock({ ticket, transitions, pending, onTransition }: StatusBlockProps) { + const list = transitions ?? []; + const hasTransitions = list.length > 0; + return ( +
+ {ticket.statusName && ( +
+ + {ticket.statusName} + +
+ )} + {hasTransitions && ( +
+ {list.map((t) => ( + + ))} +
+ )} +
+ ); +} + +function DetailsCard({ ticket }: { ticket: JiraTicket }) { + return ( +
+
+ Details +
+
+ + + + + + + + + + + {ticket.projectKey} + +
+
+ ); +} + +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function MetaFooter({ ticket }: { ticket: JiraTicket }) { + const updated = formatRelative(ticket.updated); + if (!updated) return null; + return ( +
+ Updated {updated} +
+ ); +} diff --git a/apps/web/components/jira/my-jira/filter-bar.tsx b/apps/web/components/jira/my-jira/filter-bar.tsx new file mode 100644 index 0000000000..7bf0f8df20 --- /dev/null +++ b/apps/web/components/jira/my-jira/filter-bar.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Button } from "@kandev/ui/button"; +import type { JiraProject } from "@/lib/types/jira"; +import { AssigneePill, ProjectPill, StatusPill } from "./filter-pills"; +import type { FilterState } from "./filter-model"; +import { DEFAULT_FILTERS } from "./filter-model"; + +type FilterBarProps = { + filters: FilterState; + onChange: (filters: FilterState) => void; + projects: JiraProject[]; + hasActiveFilters: boolean; + onClear: () => void; +}; + +export function FilterBar({ + filters, + onChange, + projects, + hasActiveFilters, + onClear, +}: FilterBarProps) { + return ( +
+ onChange({ ...filters, projectKeys })} + /> + onChange({ ...filters, statusCategories })} + /> + onChange({ ...filters, assignee })} + /> + {hasActiveFilters && ( + + )} +
+ ); +} + +export function hasActiveFilters(f: FilterState): boolean { + return ( + f.projectKeys.length > 0 || + f.statusCategories.length > 0 || + f.assignee !== DEFAULT_FILTERS.assignee || + f.searchText.trim().length > 0 + ); +} diff --git a/apps/web/components/jira/my-jira/filter-model.test.ts b/apps/web/components/jira/my-jira/filter-model.test.ts new file mode 100644 index 0000000000..b98a203e3b --- /dev/null +++ b/apps/web/components/jira/my-jira/filter-model.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_FILTERS, filtersToJql, filtersEqual } from "./filter-model"; + +describe("filtersToJql", () => { + it("produces default query for default state", () => { + expect(filtersToJql(DEFAULT_FILTERS)).toBe("assignee = currentUser() ORDER BY updated DESC"); + }); + + it("omits where clause when everything is open", () => { + expect(filtersToJql({ ...DEFAULT_FILTERS, assignee: "anyone" })).toBe("ORDER BY updated DESC"); + }); + + it("quotes project keys", () => { + const jql = filtersToJql({ ...DEFAULT_FILTERS, projectKeys: ["SC", "ECOM"] }); + expect(jql).toBe( + `project in ("SC", "ECOM") AND assignee = currentUser() ORDER BY updated DESC`, + ); + }); + + it("maps status categories to Jira labels", () => { + const jql = filtersToJql({ + ...DEFAULT_FILTERS, + statusCategories: ["new", "indeterminate"], + assignee: "anyone", + }); + expect(jql).toBe(`statusCategory in ("To Do", "In Progress") ORDER BY updated DESC`); + }); + + it("detects ticket key in search text", () => { + const jql = filtersToJql({ ...DEFAULT_FILTERS, searchText: "SC-123", assignee: "anyone" }); + expect(jql).toBe(`key = "SC-123" ORDER BY updated DESC`); + }); + + it("uses text search for free-form query", () => { + const jql = filtersToJql({ + ...DEFAULT_FILTERS, + searchText: "login bug", + assignee: "anyone", + }); + expect(jql).toBe(`text ~ "login bug" ORDER BY updated DESC`); + }); + + it("escapes quotes in search text", () => { + const jql = filtersToJql({ + ...DEFAULT_FILTERS, + searchText: `"bad" query`, + assignee: "anyone", + }); + expect(jql).toBe(`text ~ "\\"bad\\" query" ORDER BY updated DESC`); + }); + + it("renders unassigned filter", () => { + expect(filtersToJql({ ...DEFAULT_FILTERS, assignee: "unassigned" })).toBe( + "assignee is EMPTY ORDER BY updated DESC", + ); + }); + + it("switches sort clause", () => { + expect(filtersToJql({ ...DEFAULT_FILTERS, sort: "priority", assignee: "anyone" })).toBe( + "ORDER BY priority DESC, updated DESC", + ); + expect(filtersToJql({ ...DEFAULT_FILTERS, sort: "created", assignee: "anyone" })).toBe( + "ORDER BY created DESC", + ); + }); +}); + +describe("filtersEqual", () => { + it("returns true for deep-equal state", () => { + expect(filtersEqual(DEFAULT_FILTERS, { ...DEFAULT_FILTERS })).toBe(true); + }); + + it("returns false when arrays differ", () => { + expect(filtersEqual(DEFAULT_FILTERS, { ...DEFAULT_FILTERS, projectKeys: ["X"] })).toBe(false); + }); +}); diff --git a/apps/web/components/jira/my-jira/filter-model.ts b/apps/web/components/jira/my-jira/filter-model.ts new file mode 100644 index 0000000000..eb8184aa9e --- /dev/null +++ b/apps/web/components/jira/my-jira/filter-model.ts @@ -0,0 +1,90 @@ +import type { JiraStatusCategory } from "@/lib/types/jira"; + +export type AssigneeFilter = "me" | "unassigned" | "anyone"; +export type SortKey = "updated" | "created" | "priority"; + +export type FilterState = { + projectKeys: string[]; + statusCategories: JiraStatusCategory[]; + assignee: AssigneeFilter; + searchText: string; + sort: SortKey; +}; + +export const DEFAULT_FILTERS: FilterState = { + projectKeys: [], + statusCategories: [], + assignee: "me", + searchText: "", + sort: "updated", +}; + +export const STATUS_CATEGORY_OPTIONS: { value: JiraStatusCategory; label: string }[] = [ + { value: "new", label: "To Do" }, + { value: "indeterminate", label: "In Progress" }, + { value: "done", label: "Done" }, +]; + +function quote(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +const TICKET_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/; + +function searchClause(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (TICKET_KEY_RE.test(trimmed)) return `key = ${quote(trimmed)}`; + return `text ~ ${quote(trimmed)}`; +} + +function statusClause(categories: JiraStatusCategory[]): string | null { + if (categories.length === 0) return null; + const labels = categories.map((c) => { + if (c === "new") return quote("To Do"); + if (c === "indeterminate") return quote("In Progress"); + return quote("Done"); + }); + return `statusCategory in (${labels.join(", ")})`; +} + +function assigneeClause(a: AssigneeFilter): string | null { + if (a === "me") return "assignee = currentUser()"; + if (a === "unassigned") return "assignee is EMPTY"; + return null; +} + +function projectClause(keys: string[]): string | null { + if (keys.length === 0) return null; + return `project in (${keys.map(quote).join(", ")})`; +} + +function sortClause(s: SortKey): string { + if (s === "created") return "ORDER BY created DESC"; + if (s === "priority") return "ORDER BY priority DESC, updated DESC"; + return "ORDER BY updated DESC"; +} + +export function filtersToJql(f: FilterState): string { + const clauses = [ + projectClause(f.projectKeys), + statusClause(f.statusCategories), + assigneeClause(f.assignee), + searchClause(f.searchText), + ].filter((c): c is string => c !== null); + const where = clauses.join(" AND "); + const order = sortClause(f.sort); + return where ? `${where} ${order}` : order; +} + +export function filtersEqual(a: FilterState, b: FilterState): boolean { + return ( + a.assignee === b.assignee && + a.sort === b.sort && + a.searchText === b.searchText && + a.projectKeys.length === b.projectKeys.length && + a.projectKeys.every((k, i) => k === b.projectKeys[i]) && + a.statusCategories.length === b.statusCategories.length && + a.statusCategories.every((c, i) => c === b.statusCategories[i]) + ); +} diff --git a/apps/web/components/jira/my-jira/filter-pills.tsx b/apps/web/components/jira/my-jira/filter-pills.tsx new file mode 100644 index 0000000000..0c42ee59bb --- /dev/null +++ b/apps/web/components/jira/my-jira/filter-pills.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { IconChevronDown, IconX } from "@tabler/icons-react"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { Input } from "@kandev/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import type { JiraProject, JiraStatusCategory } from "@/lib/types/jira"; +import { STATUS_CATEGORY_OPTIONS, type AssigneeFilter } from "./filter-model"; + +type PillShellProps = { + label: string; + summary: string | null; + active: boolean; + onClear?: () => void; + children: React.ReactNode; +}; + +function PillShell({ label, summary, active, onClear, children }: PillShellProps) { + const [open, setOpen] = useState(false); + return ( + +
+ + + + {active && onClear && ( + + )} +
+ + {children} + +
+ ); +} + +function joinSummary(items: string[], max: number): string { + if (items.length <= max) return items.join(", "); + return `${items.slice(0, max).join(", ")} +${items.length - max}`; +} + +type ProjectPillProps = { + projects: JiraProject[]; + value: string[]; + onChange: (keys: string[]) => void; +}; + +export function ProjectPill({ projects, value, onChange }: ProjectPillProps) { + const [query, setQuery] = useState(""); + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return projects; + return projects.filter( + (p) => p.key.toLowerCase().includes(q) || p.name.toLowerCase().includes(q), + ); + }, [projects, query]); + + const selected = new Set(value); + const toggle = (key: string) => { + if (selected.has(key)) onChange(value.filter((k) => k !== key)); + else onChange([...value, key]); + }; + + return ( + 0 ? joinSummary(value, 2) : null} + active={value.length > 0} + onClear={() => onChange([])} + > +
+ setQuery(e.target.value)} + placeholder="Search projects…" + className="h-7 text-xs" + /> +
+
+ {filtered.length === 0 && ( +
No projects match.
+ )} + {filtered.map((p) => ( + + ))} +
+
+ ); +} + +type StatusPillProps = { + value: JiraStatusCategory[]; + onChange: (cats: JiraStatusCategory[]) => void; +}; + +export function StatusPill({ value, onChange }: StatusPillProps) { + const selected = new Set(value); + const toggle = (cat: JiraStatusCategory) => { + if (selected.has(cat)) onChange(value.filter((c) => c !== cat)); + else onChange([...value, cat]); + }; + const summary = + value.length > 0 + ? joinSummary( + value.map((c) => STATUS_CATEGORY_OPTIONS.find((o) => o.value === c)?.label ?? c), + 2, + ) + : null; + + return ( + 0} + onClear={() => onChange([])} + > +
+ {STATUS_CATEGORY_OPTIONS.map((o) => ( + + ))} +
+
+ ); +} + +type AssigneePillProps = { + value: AssigneeFilter; + onChange: (a: AssigneeFilter) => void; +}; + +const ASSIGNEE_OPTIONS: { value: AssigneeFilter; label: string }[] = [ + { value: "anyone", label: "Anyone" }, + { value: "me", label: "Me" }, + { value: "unassigned", label: "Unassigned" }, +]; + +export function AssigneePill({ value, onChange }: AssigneePillProps) { + const active = value !== "anyone"; + const summary = active ? (ASSIGNEE_OPTIONS.find((o) => o.value === value)?.label ?? null) : null; + return ( + onChange("anyone")} + > +
+ {ASSIGNEE_OPTIONS.map((o) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/components/jira/my-jira/jql-editor.tsx b/apps/web/components/jira/my-jira/jql-editor.tsx new file mode 100644 index 0000000000..301cc26790 --- /dev/null +++ b/apps/web/components/jira/my-jira/jql-editor.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Textarea } from "@kandev/ui/textarea"; + +type JqlEditorProps = { + composedJql: string; + customJql: string | null; + onApply: (jql: string) => void; + onReset: () => void; +}; + +// Inline editor that exposes the JQL being sent to Jira. Users can tweak it +// for one-off advanced queries; pressing Reset returns to pill-driven mode. +export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEditorProps) { + const source = customJql ?? composedJql; + const [draft, setDraft] = useState(source); + const [prevSource, setPrevSource] = useState(source); + // React-recommended pattern for resetting state when a prop changes: compare + // in render and call setState — avoids an extra effect pass. + if (source !== prevSource) { + setPrevSource(source); + setDraft(source); + } + + const dirty = draft.trim() !== source.trim(); + const overriding = customJql !== null; + + return ( +
+
+ + {overriding ? ( + Custom JQL override — pill filters are paused. + ) : ( + JQL is generated from the pills above. Edit to override. + )} +
+