From bb36cb9e010897d8e884bc46e698464fdc252125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sun, 3 May 2026 10:22:57 +0100 Subject: [PATCH] refactor: move integrations to top-level settings with install-wide configs (#787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: move integrations to top-level settings with install-wide configs Jira and Linear configurations are now install-wide singletons rather than per-workspace duplicates, and the GitHub/Jira/Linear settings pages live under /settings/integrations instead of being nested inside each workspace. Issue and PR watchers stay per-workspace via a workspace switcher inside each integration page. Backend ships one-shot migrations that promote the most-recently-updated row and copy its secret onto the new global key. * fix: address review feedback on integrations refactor Fix migration crash on installs whose jira_configs predate the auth-health columns by selecting only guaranteed-present legacy columns and falling back to defaults for the rest. Cover the empty-legacy-table and pre-health-columns upgrade paths with new store tests. On the frontend, refactor useIntegrationEnabled to take string parameters instead of an inline options object so getSnapshot stays referentially stable across renders, and stop dispatching a misleading no-op sync event when localStorage.setItem fails in private mode. * fix: hydrate workspace list on integration settings pages The integration pages wrapped their content in a fresh StateProvider, which created a nested store scope with an empty initial state and shadowed the settings layout's hydrated workspace list. WorkspaceScopedSection then saw zero workspaces and rendered the "Create a workspace to configure this integration" empty state even when the user had workspaces. Drop the redundant StateProvider from the jira and linear pages so they share the layout's store. The github page switches to StateHydrator so it can still seed the GitHub OAuth status without resetting the rest of the store. * refactor(integrations): flatten watcher tables, pick workspace in create dialog Hiding watchers behind a workspace switcher made the list invisible at a glance — a user with multiple workspaces had to flip between each one to see what was configured. Replace the switcher with a flat table that lists every watch across every workspace, with a Workspace column for context, and move workspace selection into the create dialog so the user picks once when the watcher is born. Backend: jira list-issue-watches and github list-review-watches / list-issue-watches accept the workspace_id query param as optional; when omitted, the corresponding store ListAll* method returns rows across every workspace. Per-row mutations still require workspace_id as the IDOR guard. Frontend: useReviewWatches / useIssueWatches / useJiraIssueWatches gain a three-state contract — string for one workspace, undefined for all workspaces, null to skip fetching. Watch tables grow an optional showWorkspace column. Watch dialogs accept an optional workspaceId prop; when absent, the dialog renders a workspace dropdown for the create flow (disabled when editing existing rows so refs aren't orphaned). Github review-watch-dialog crossed the 600-line lint cap so the prompt editor moved to review-watch-prompt-field.tsx. * fix(integrations): make watcher step dropdown reactive across workspace switches Switching to a workspace whose workflows had to be fetched left the workflow step dropdown stuck — the user could pick a workflow but the step Select either showed the previous workflow's steps or appeared inert. The cause was the per-dialog useWorkflowSteps hook deferring its empty-state reset via Promise.resolve().then(() => setSteps([])) to dodge a setState-in-effect lint rule, which let stale steps survive into the next render and gave Radix nothing to anchor the popover to during the in-flight fetch. Hoist the hook into a shared useWorkflowSteps that resets via React's official setState-during-render pattern, exposes a loading flag, and returns a stepPlaceholder helper so the Select can render "Loading steps…" or "No steps in this workflow" instead of an empty popover. The Step Select is now also disabled while loading or when there are no steps, making the state of the dropdown unambiguous. * fix: address PR review feedback on integrations refactor Backend: - linear migration: add column-presence guard mirroring the Jira fix so installs that upgraded from the original Linear schema (without org_slug / last_checked_at / last_ok / last_error) don't crash startup with "no such column"; add TestStore_MigrateLegacyPerWorkspaceTable_PreHealthColumns - jira/handlers.go: drop the now-unused errMsgWorkspaceIDRequired constant that was failing golangci-lint - jira/mock_controller_test.go (new): cover setAuthHealth happy-path and invalid-JSON-rejection so the singleton-shaped payload is locked in Frontend: - useIntegrationAvailable: gate the auth-health probe on loaded && enabled so disabled integrations don't poll /config every 90s - use-workflow-steps: initialize loading to !!workflowId so edit-mode opens show "Loading steps…" instead of "No steps in this workflow" before the first fetch lands - use-workflow-steps.test.ts (new): cover the empty/non-empty workflowId, workflow switch, fetch error, and stepPlaceholder branches - use-linear-enabled.test: tighten the legacy-key migration assertion to pin the migrated value rather than just verifying key cleanup --------- Co-authored-by: Kandev Agent --- apps/backend/internal/github/controller.go | 23 +- apps/backend/internal/github/service.go | 10 + apps/backend/internal/github/store.go | 29 +++ .../integrations/healthpoll/healthpoll.go | 35 +-- .../healthpoll/healthpoll_test.go | 75 +++--- apps/backend/internal/jira/handlers.go | 104 +++----- apps/backend/internal/jira/mock_controller.go | 11 +- .../internal/jira/mock_controller_test.go | 75 ++++++ apps/backend/internal/jira/models.go | 32 +-- apps/backend/internal/jira/poller.go | 8 +- .../internal/jira/poller_issue_watch_test.go | 6 +- apps/backend/internal/jira/poller_test.go | 60 +++-- apps/backend/internal/jira/provider.go | 32 +++ apps/backend/internal/jira/service.go | 171 ++++++------- .../internal/jira/service_issue_watch_test.go | 4 +- apps/backend/internal/jira/service_test.go | 103 ++++---- apps/backend/internal/jira/store.go | 221 ++++++++++++----- apps/backend/internal/jira/store_test.go | 232 +++++++++++++++--- apps/backend/internal/linear/handlers.go | 102 ++------ apps/backend/internal/linear/handlers_test.go | 37 +-- .../internal/linear/mock_controller.go | 13 +- apps/backend/internal/linear/models.go | 30 +-- apps/backend/internal/linear/poller.go | 8 +- apps/backend/internal/linear/poller_test.go | 56 ++--- apps/backend/internal/linear/provider.go | 32 +++ apps/backend/internal/linear/service.go | 130 +++++----- apps/backend/internal/linear/service_test.go | 108 ++++---- apps/backend/internal/linear/store.go | 206 +++++++++++----- apps/backend/internal/linear/store_test.go | 220 ++++++++++++++--- apps/web/app/jira/jira-page-client.tsx | 26 +- apps/web/app/linear/linear-page-client.tsx | 31 ++- apps/web/app/settings/general/github/page.tsx | 15 -- .../app/settings/integrations/github/page.tsx | 22 ++ .../app/settings/integrations/jira/page.tsx | 5 + .../app/settings/integrations/linear/page.tsx | 5 + apps/web/app/settings/integrations/page.tsx | 53 ++++ .../settings/workspace/[id]/github/page.tsx | 36 --- .../app/settings/workspace/[id]/jira/page.tsx | 11 - .../settings/workspace/[id]/linear/page.tsx | 15 -- .../web/components/github/github-settings.tsx | 102 ++++---- .../components/github/issue-watch-dialog.tsx | 149 ++++++----- .../components/github/issue-watch-table.tsx | 14 ++ .../components/github/review-watch-dialog.tsx | 221 ++++++++--------- .../github/review-watch-prompt-field.tsx | 77 ++++++ .../components/github/review-watch-table.tsx | 14 ++ .../integrations/integrations-menu.test.ts | 4 +- .../integrations/integrations-menu.tsx | 23 +- .../integrations/workspace-scoped-section.tsx | 58 +++++ apps/web/components/jira/jira-import-bar.tsx | 4 +- .../jira/jira-issue-watch-dialog.tsx | 155 +++++++----- .../jira/jira-issue-watch-table.tsx | 15 ++ .../jira/jira-issue-watchers-section.tsx | 70 ++++-- apps/web/components/jira/jira-link-button.tsx | 4 +- apps/web/components/jira/jira-settings.tsx | 118 ++++----- .../components/jira/jira-ticket-common.tsx | 15 +- .../components/jira/jira-ticket-dialog.tsx | 7 +- .../jira/my-jira/use-jira-search.ts | 2 +- apps/web/components/kanban/kanban-header.tsx | 4 +- .../components/kanban/mobile-menu-sheet.tsx | 5 +- .../components/linear/linear-import-bar.tsx | 4 +- .../components/linear/linear-issue-common.tsx | 15 +- .../components/linear/linear-issue-dialog.tsx | 7 +- .../components/linear/linear-link-button.tsx | 4 +- .../web/components/linear/linear-settings.tsx | 143 +++++------ .../settings/settings-app-sidebar.tsx | 58 +++-- apps/web/components/task/task-top-bar.tsx | 9 +- apps/web/e2e/fixtures/test-base.ts | 10 +- apps/web/e2e/helpers/api-client.ts | 20 +- apps/web/e2e/pages/jira-settings-page.ts | 4 +- apps/web/e2e/pages/linear-settings-page.ts | 4 +- .../tests/integrations/jira-import.spec.ts | 5 +- .../tests/integrations/jira-settings.spec.ts | 26 +- .../tests/integrations/linear-import.spec.ts | 5 +- .../integrations/linear-settings.spec.ts | 26 +- .../hooks/domains/github/use-issue-watches.ts | 10 +- .../domains/github/use-review-watches.ts | 10 +- .../use-integration-availability.ts | 65 ++--- .../integrations/use-integration-enabled.ts | 145 ++++++----- .../jira/use-jira-availability.test.ts | 54 +--- .../domains/jira/use-jira-availability.ts | 12 +- .../hooks/domains/jira/use-jira-enabled.ts | 10 +- .../domains/jira/use-jira-issue-watches.ts | 64 ++--- .../domains/linear/use-linear-availability.ts | 12 +- .../domains/linear/use-linear-enabled.test.ts | 50 ++-- .../domains/linear/use-linear-enabled.ts | 10 +- apps/web/hooks/use-workflow-steps.test.ts | 96 ++++++++ apps/web/hooks/use-workflow-steps.ts | 75 ++++++ apps/web/lib/api/domains/github-api.ts | 24 +- apps/web/lib/api/domains/jira-api.ts | 59 ++--- apps/web/lib/api/domains/linear-api.test.ts | 62 ++--- apps/web/lib/api/domains/linear-api.ts | 55 ++--- apps/web/lib/types/jira.ts | 2 - apps/web/lib/types/linear.ts | 2 - 93 files changed, 2729 insertions(+), 1881 deletions(-) create mode 100644 apps/backend/internal/jira/mock_controller_test.go delete mode 100644 apps/web/app/settings/general/github/page.tsx create mode 100644 apps/web/app/settings/integrations/github/page.tsx create mode 100644 apps/web/app/settings/integrations/jira/page.tsx create mode 100644 apps/web/app/settings/integrations/linear/page.tsx create mode 100644 apps/web/app/settings/integrations/page.tsx delete mode 100644 apps/web/app/settings/workspace/[id]/github/page.tsx delete mode 100644 apps/web/app/settings/workspace/[id]/jira/page.tsx delete mode 100644 apps/web/app/settings/workspace/[id]/linear/page.tsx create mode 100644 apps/web/components/github/review-watch-prompt-field.tsx create mode 100644 apps/web/components/integrations/workspace-scoped-section.tsx create mode 100644 apps/web/hooks/use-workflow-steps.test.ts create mode 100644 apps/web/hooks/use-workflow-steps.ts diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index 817f3fe806..e21f3c1de7 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -289,13 +289,20 @@ func (c *Controller) httpDeletePRWatch(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"deleted": true}) } +// httpListReviewWatches returns watches scoped to one workspace when +// `workspace_id` is supplied, or every watch across all workspaces when it +// is absent. The integration settings page uses the unscoped form. func (c *Controller) httpListReviewWatches(ctx *gin.Context) { workspaceID := ctx.Query("workspace_id") + var ( + watches []*ReviewWatch + err error + ) if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) - return + watches, err = c.service.ListAllReviewWatches(ctx.Request.Context()) + } else { + watches, err = c.service.ListReviewWatches(ctx.Request.Context(), workspaceID) } - watches, err := c.service.ListReviewWatches(ctx.Request.Context(), workspaceID) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -540,11 +547,15 @@ func (c *Controller) httpGetStats(ctx *gin.Context) { func (c *Controller) httpListIssueWatches(ctx *gin.Context) { workspaceID := ctx.Query("workspace_id") + var ( + watches []*IssueWatch + err error + ) if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) - return + watches, err = c.service.ListAllIssueWatches(ctx.Request.Context()) + } else { + watches, err = c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) } - watches, err := c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 02d25f713c..5130d9b9d4 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -1048,6 +1048,11 @@ func (s *Service) ListReviewWatches(ctx context.Context, workspaceID string) ([] return s.store.ListReviewWatches(ctx, workspaceID) } +// ListAllReviewWatches returns every review watch across all workspaces. +func (s *Service) ListAllReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { + return s.store.ListAllReviewWatches(ctx) +} + // UpdateReviewWatch updates a review watch. func (s *Service) UpdateReviewWatch(ctx context.Context, id string, req *UpdateReviewWatchRequest) error { rw, err := s.store.GetReviewWatch(ctx, id) @@ -1741,6 +1746,11 @@ func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]* return s.store.ListIssueWatches(ctx, workspaceID) } +// ListAllIssueWatches returns every issue watch across all workspaces. +func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + return s.store.ListAllIssueWatches(ctx) +} + // UpdateIssueWatch updates an issue watch. // //nolint:dupl // mirrors UpdateReviewWatch — different types, same structure diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index d064abbefc..151de13bfe 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -807,6 +807,21 @@ func (s *Store) ListReviewWatches(ctx context.Context, workspaceID string) ([]*R return watches, nil } +// ListAllReviewWatches returns every review watch across all workspaces. Used +// by the install-wide settings UI when no workspace filter is supplied. +func (s *Store) ListAllReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { + var watches []*ReviewWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT * FROM github_review_watches ORDER BY workspace_id, created_at`) + if err != nil { + return nil, err + } + for _, w := range watches { + hydrateReviewWatchRepos(w) + } + return watches, nil +} + // ListEnabledReviewWatches returns all enabled review watches. func (s *Store) ListEnabledReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { var watches []*ReviewWatch @@ -1131,6 +1146,20 @@ func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*Is return watches, nil } +// ListAllIssueWatches returns every issue watch across all workspaces. +func (s *Store) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + var watches []*IssueWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT * FROM github_issue_watches ORDER BY workspace_id, created_at`) + if err != nil { + return nil, err + } + for _, w := range watches { + hydrateIssueWatch(w) + } + return watches, nil +} + // ListEnabledIssueWatches returns all enabled issue watches. func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) { var watches []*IssueWatch diff --git a/apps/backend/internal/integrations/healthpoll/healthpoll.go b/apps/backend/internal/integrations/healthpoll/healthpoll.go index c5a853672e..b47c9f0b52 100644 --- a/apps/backend/internal/integrations/healthpoll/healthpoll.go +++ b/apps/backend/internal/integrations/healthpoll/healthpoll.go @@ -28,15 +28,15 @@ const DefaultInterval = 90 * time.Second // itself is single-threaded but Start may be invoked while a previous Stop is // still draining. type Prober interface { - // ListConfiguredWorkspaces returns the IDs of every workspace that has - // credentials stored for this integration. - ListConfiguredWorkspaces(ctx context.Context) ([]string, error) + // HasConfig reports whether the integration has stored credentials worth + // probing. The loop skips RecordAuthHealth when it returns false. + HasConfig(ctx context.Context) (bool, error) - // RecordAuthHealth probes one workspace's credentials and persists the - // result on the integration's config row. Errors are intentionally not - // returned: this is a best-effort health signal, never the source of - // truth for callers, so the implementation handles its own logging. - RecordAuthHealth(ctx context.Context, workspaceID string) + // RecordAuthHealth probes the stored credentials and persists the result + // on the integration's config row. Errors are intentionally not returned: + // this is a best-effort health signal, never the source of truth for + // callers, so the implementation handles its own logging. + RecordAuthHealth(ctx context.Context) } // Poller drives Prober.RecordAuthHealth on a fixed cadence. @@ -116,18 +116,19 @@ func (p *Poller) loop(ctx context.Context) { } } -// probeAll runs one probe pass across every configured workspace. Used by the -// loop and by in-package tests that want to drive a probe deterministically. +// probeAll runs one probe pass when the integration is configured. Used by +// the loop and by in-package tests that want to drive a probe deterministically. func (p *Poller) probeAll(ctx context.Context) { - ids, err := p.prober.ListConfiguredWorkspaces(ctx) + configured, err := p.prober.HasConfig(ctx) if err != nil { - p.logger.Warn(p.name+" poller: list workspaces failed", zap.Error(err)) + p.logger.Warn(p.name+" poller: has-config check failed", zap.Error(err)) return } - for _, id := range ids { - if ctx.Err() != nil { - return - } - p.prober.RecordAuthHealth(ctx, id) + if !configured { + return } + if ctx.Err() != nil { + return + } + p.prober.RecordAuthHealth(ctx) } diff --git a/apps/backend/internal/integrations/healthpoll/healthpoll_test.go b/apps/backend/internal/integrations/healthpoll/healthpoll_test.go index c697137361..40e9f6c52f 100644 --- a/apps/backend/internal/integrations/healthpoll/healthpoll_test.go +++ b/apps/backend/internal/integrations/healthpoll/healthpoll_test.go @@ -12,29 +12,27 @@ import ( ) type fakeProber struct { - mu sync.Mutex - listFn func(ctx context.Context) ([]string, error) - probed []string + mu sync.Mutex + hasFn func(ctx context.Context) (bool, error) + probedCount int } -func (f *fakeProber) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) { - if f.listFn != nil { - return f.listFn(ctx) +func (f *fakeProber) HasConfig(ctx context.Context) (bool, error) { + if f.hasFn != nil { + return f.hasFn(ctx) } - return nil, nil + return false, nil } -func (f *fakeProber) RecordAuthHealth(_ context.Context, workspaceID string) { +func (f *fakeProber) RecordAuthHealth(_ context.Context) { f.mu.Lock() - f.probed = append(f.probed, workspaceID) + f.probedCount++ f.mu.Unlock() } -func TestProbeAll_RecordsEachWorkspace(t *testing.T) { +func TestProbeAll_RecordsWhenConfigured(t *testing.T) { p := &fakeProber{ - listFn: func(_ context.Context) ([]string, error) { - return []string{"ws-a", "ws-b"}, nil - }, + hasFn: func(_ context.Context) (bool, error) { return true, nil }, } poller := New("test", p, logger.Default()) @@ -42,16 +40,29 @@ func TestProbeAll_RecordsEachWorkspace(t *testing.T) { p.mu.Lock() defer p.mu.Unlock() - if len(p.probed) != 2 || p.probed[0] != "ws-a" || p.probed[1] != "ws-b" { - t.Errorf("expected [ws-a ws-b], got %v", p.probed) + if p.probedCount != 1 { + t.Errorf("expected 1 probe, got %d", p.probedCount) } } -func TestProbeAll_ListError_DoesNotPanic(t *testing.T) { +func TestProbeAll_SkipsWhenNotConfigured(t *testing.T) { p := &fakeProber{ - listFn: func(_ context.Context) ([]string, error) { - return nil, errors.New("db down") - }, + hasFn: func(_ context.Context) (bool, error) { return false, nil }, + } + poller := New("test", p, logger.Default()) + + poller.probeAll(context.Background()) + + p.mu.Lock() + defer p.mu.Unlock() + if p.probedCount != 0 { + t.Errorf("expected no probe when not configured, got %d", p.probedCount) + } +} + +func TestProbeAll_HasConfigError_DoesNotPanic(t *testing.T) { + p := &fakeProber{ + hasFn: func(_ context.Context) (bool, error) { return false, errors.New("db down") }, } poller := New("test", p, logger.Default()) @@ -59,16 +70,14 @@ func TestProbeAll_ListError_DoesNotPanic(t *testing.T) { p.mu.Lock() defer p.mu.Unlock() - if len(p.probed) != 0 { - t.Errorf("no probes should run on list error, got %v", p.probed) + if p.probedCount != 0 { + t.Errorf("no probe should run on has-config error, got %d", p.probedCount) } } func TestProbeAll_StopsWhenContextCancelled(t *testing.T) { p := &fakeProber{ - listFn: func(_ context.Context) ([]string, error) { - return []string{"ws-a", "ws-b", "ws-c"}, nil - }, + hasFn: func(_ context.Context) (bool, error) { return true, nil }, } poller := New("test", p, logger.Default()) @@ -78,17 +87,15 @@ func TestProbeAll_StopsWhenContextCancelled(t *testing.T) { p.mu.Lock() defer p.mu.Unlock() - if len(p.probed) != 0 { - t.Errorf("expected no probes after cancel, got %v", p.probed) + if p.probedCount != 0 { + t.Errorf("expected no probe after cancel, got %d", p.probedCount) } } func TestStart_RunsImmediateProbe(t *testing.T) { synctest.Test(t, func(t *testing.T) { p := &fakeProber{ - listFn: func(_ context.Context) ([]string, error) { - return []string{"ws-1"}, nil - }, + hasFn: func(_ context.Context) (bool, error) { return true, nil }, } poller := New("test", p, logger.Default()) @@ -103,7 +110,7 @@ func TestStart_RunsImmediateProbe(t *testing.T) { p.mu.Lock() defer p.mu.Unlock() - if len(p.probed) == 0 { + if p.probedCount == 0 { t.Error("Start did not run an immediate probe") } }) @@ -113,9 +120,9 @@ func TestStart_IsIdempotent(t *testing.T) { synctest.Test(t, func(t *testing.T) { var calls int32 p := &fakeProber{ - listFn: func(_ context.Context) ([]string, error) { + hasFn: func(_ context.Context) (bool, error) { atomic.AddInt32(&calls, 1) - return []string{"ws-1"}, nil + return true, nil }, } poller := New("test", p, logger.Default()) @@ -126,12 +133,12 @@ func TestStart_IsIdempotent(t *testing.T) { poller.Start(ctx) // second call must be a no-op // Wait for the immediate-on-Start probe pass to finish. If the - // second Start spawned a parallel loop we'd see two list calls. + // second Start spawned a parallel loop we'd see two has-config calls. synctest.Wait() poller.Stop() if got := atomic.LoadInt32(&calls); got != 1 { - t.Errorf("expected exactly 1 list call from initial probe, got %d", got) + t.Errorf("expected exactly 1 has-config call from initial probe, got %d", got) } }) } diff --git a/apps/backend/internal/jira/handlers.go b/apps/backend/internal/jira/handlers.go index 9efee14591..c0449dd87b 100644 --- a/apps/backend/internal/jira/handlers.go +++ b/apps/backend/internal/jira/handlers.go @@ -48,12 +48,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { // --- 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) + cfg, err := c.service.GetConfig(ctx.Request.Context()) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -84,12 +79,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { } 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 { + if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -111,12 +101,7 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { } 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) + projects, err := c.service.ListProjects(ctx.Request.Context()) if err != nil { c.writeClientError(ctx, err) return @@ -125,15 +110,10 @@ func (c *Controller) httpListProjects(ctx *gin.Context) { } 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) + result, err := c.service.SearchTickets(ctx.Request.Context(), jql, pageToken, maxResults) if err != nil { c.writeClientError(ctx, err) return @@ -142,13 +122,8 @@ func (c *Controller) httpSearchTickets(ctx *gin.Context) { } 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) + ticket, err := c.service.GetTicket(ctx.Request.Context(), key) if err != nil { c.writeClientError(ctx, err) return @@ -157,11 +132,6 @@ func (c *Controller) httpGetTicket(ctx *gin.Context) { } 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"` @@ -170,7 +140,7 @@ func (c *Controller) httpDoTransition(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "transitionId required"}) return } - if err := c.service.DoTransition(ctx.Request.Context(), workspaceID, key, req.TransitionID); err != nil { + if err := c.service.DoTransition(ctx.Request.Context(), key, req.TransitionID); err != nil { c.writeClientError(ctx, err) return } @@ -179,13 +149,21 @@ func (c *Controller) httpDoTransition(ctx *gin.Context) { // --- Issue watch HTTP handlers --- +// httpListIssueWatches returns watches scoped to one workspace when +// `workspace_id` is supplied, or every watch across all workspaces when it +// is absent. The integration settings page uses the unscoped form so the +// table can render a Workspace column without requiring an upfront pick. func (c *Controller) httpListIssueWatches(ctx *gin.Context) { workspaceID := ctx.Query("workspace_id") + var ( + watches []*IssueWatch + err error + ) if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) - return + watches, err = c.service.ListAllIssueWatches(ctx.Request.Context()) + } else { + watches, err = c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) } - watches, err := c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -304,9 +282,9 @@ func (c *Controller) writeIssueWatchError(ctx *gin.Context, err error) { c.writeClientError(ctx, err) } -// 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. +// errCodeJiraNotConfigured is the wire-level code surfaced to the UI when +// Jira has no saved 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 @@ -315,7 +293,7 @@ const errCodeJiraNotConfigured = "JIRA_NOT_CONFIGURED" 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", + "error": "Jira is not configured", "code": errCodeJiraNotConfigured, }) return @@ -370,13 +348,7 @@ func wsFail(msg *ws.Message, err error) (*ws.Message, error) { 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) + cfg, err := svc.GetConfig(ctx) if err != nil { return wsFail(msg, err) } @@ -403,13 +375,7 @@ func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.M 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 { + if err := svc.DeleteConfig(ctx); err != nil { return wsFail(msg, err) } return wsReply(msg, gin.H{"deleted": true}) @@ -433,16 +399,15 @@ func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws. 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"` + 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) + if p.TicketKey == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "ticketKey required", nil) } - ticket, err := svc.GetTicket(ctx, p.WorkspaceID, p.TicketKey) + ticket, err := svc.GetTicket(ctx, p.TicketKey) if err != nil { return wsFail(msg, err) } @@ -453,17 +418,16 @@ func wsGetTicket(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.M 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 p.TicketKey == "" || p.TransitionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "ticketKey and transitionId required", nil) } - if err := svc.DoTransition(ctx, p.WorkspaceID, p.TicketKey, p.TransitionID); err != nil { + if err := svc.DoTransition(ctx, p.TicketKey, p.TransitionID); err != nil { return wsFail(msg, err) } return wsReply(msg, gin.H{"transitioned": true}) @@ -472,13 +436,7 @@ func wsDoTransition(svc *Service) func(ctx context.Context, msg *ws.Message) (*w 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) + projects, err := svc.ListProjects(ctx) if err != nil { return wsFail(msg, err) } diff --git a/apps/backend/internal/jira/mock_controller.go b/apps/backend/internal/jira/mock_controller.go index 1f452104bc..fc9ee79e88 100644 --- a/apps/backend/internal/jira/mock_controller.go +++ b/apps/backend/internal/jira/mock_controller.go @@ -51,15 +51,14 @@ func (c *MockController) setAuthResult(ctx *gin.Context) { func (c *MockController) setAuthHealth(ctx *gin.Context) { var req struct { - WorkspaceID string `json:"workspaceId"` - OK bool `json:"ok"` - Error string `json:"error"` + OK bool `json:"ok"` + Error string `json:"error"` } - if err := ctx.ShouldBindJSON(&req); err != nil || req.WorkspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId required"}) + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.WorkspaceID, req.OK, req.Error, time.Now().UTC()); err != nil { + if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, time.Now().UTC()); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/apps/backend/internal/jira/mock_controller_test.go b/apps/backend/internal/jira/mock_controller_test.go new file mode 100644 index 0000000000..c982f84293 --- /dev/null +++ b/apps/backend/internal/jira/mock_controller_test.go @@ -0,0 +1,75 @@ +package jira + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" +) + +// TestMockController_SetAuthHealth_PersistsWithoutWorkspaceID confirms that +// the mock auth-health route now accepts the install-wide payload (no +// workspaceId field) and forwards it to the singleton store, matching the +// production handler shape after the per-workspace → singleton refactor. +func TestMockController_SetAuthHealth_PersistsWithoutWorkspaceID(t *testing.T) { + store := newTestStore(t) + if err := store.UpsertConfig(t.Context(), &JiraConfig{ + SiteURL: "https://acme.atlassian.net", Email: "u@example.com", AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("seed config: %v", err) + } + mock := NewMockClient() + ctrl := NewMockController(mock, store, logger.Default()) + + gin.SetMode(gin.TestMode) + router := gin.New() + ctrl.RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/jira/mock/auth-health", + bytes.NewBufferString(`{"ok": false, "error": "session expired"}`)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + cfg, err := store.GetConfig(t.Context()) + if err != nil { + t.Fatalf("get config: %v", err) + } + if cfg.LastOk { + t.Error("expected LastOk=false after auth-health write") + } + if cfg.LastError != "session expired" { + t.Errorf("expected error preserved, got %q", cfg.LastError) + } + if cfg.LastCheckedAt == nil { + t.Error("expected LastCheckedAt set") + } +} + +// TestMockController_SetAuthHealth_RejectsInvalidJSON guards the bind step: +// junk in the body should fail fast with a 400 rather than write garbage to +// the store. +func TestMockController_SetAuthHealth_RejectsInvalidJSON(t *testing.T) { + store := newTestStore(t) + mock := NewMockClient() + ctrl := NewMockController(mock, store, logger.Default()) + + gin.SetMode(gin.TestMode) + router := gin.New() + ctrl.RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/jira/mock/auth-health", + bytes.NewBufferString(`not json`)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} diff --git a/apps/backend/internal/jira/models.go b/apps/backend/internal/jira/models.go index 798fb3c79f..87cca025ee 100644 --- a/apps/backend/internal/jira/models.go +++ b/apps/backend/internal/jira/models.go @@ -1,6 +1,7 @@ -// 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 implements the Jira/Atlassian Cloud integration: a single +// install-wide configuration plus per-workspace JQL issue watchers, a REST +// client for tickets and transitions, and the HTTP and WebSocket handlers that +// expose these capabilities to the frontend. package jira import "time" @@ -11,11 +12,10 @@ const ( 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. +// JiraConfig is the install-wide configuration for the Jira integration. The +// secret value (API token or session cookie) is stored separately in the +// encrypted secret store under SecretKey. 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"` @@ -35,11 +35,10 @@ type JiraConfig struct { 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. +// SetConfigRequest is the payload sent by the UI to create or update the 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"` @@ -113,9 +112,14 @@ type SearchResult struct { 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 { +// SecretKey is the secret-store key used for the install-wide Jira token. +// Centralised so that the service, store and provider migration agree. +const SecretKey = "jira:singleton:token" + +// LegacySecretKeyForWorkspace returns the pre-singleton per-workspace secret +// key. Only used by the one-shot startup migration in provider.go to copy an +// existing token over to SecretKey. +func LegacySecretKeyForWorkspace(workspaceID string) string { return "jira:" + workspaceID + ":token" } diff --git a/apps/backend/internal/jira/poller.go b/apps/backend/internal/jira/poller.go index c9ca7a3592..35d333c34c 100644 --- a/apps/backend/internal/jira/poller.go +++ b/apps/backend/internal/jira/poller.go @@ -186,10 +186,10 @@ func (p *Poller) fireIssueTickHook() { // interface into Service's public API. type svcProber struct{ svc *Service } -func (s svcProber) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) { - return s.svc.Store().ListConfiguredWorkspaces(ctx) +func (s svcProber) HasConfig(ctx context.Context) (bool, error) { + return s.svc.Store().HasConfig(ctx) } -func (s svcProber) RecordAuthHealth(ctx context.Context, workspaceID string) { - s.svc.RecordAuthHealth(ctx, workspaceID) +func (s svcProber) RecordAuthHealth(ctx context.Context) { + s.svc.RecordAuthHealth(ctx) } diff --git a/apps/backend/internal/jira/poller_issue_watch_test.go b/apps/backend/internal/jira/poller_issue_watch_test.go index 5489b68996..78eea761b6 100644 --- a/apps/backend/internal/jira/poller_issue_watch_test.go +++ b/apps/backend/internal/jira/poller_issue_watch_test.go @@ -49,7 +49,7 @@ func (r *recordingSubscriber) snapshot() []*NewJiraIssueEvent { func TestPoller_CheckIssueWatches_PublishesNewTicketsOnly(t *testing.T) { f := newPollerFixture(t) ctx := context.Background() - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "tok") eb := bus.NewMemoryEventBus(logger.Default()) defer eb.Close() @@ -108,7 +108,7 @@ func TestPoller_CheckIssueWatches_PublishesNewTicketsOnly(t *testing.T) { func TestPoller_CheckIssueWatches_SkipsDisabled(t *testing.T) { f := newPollerFixture(t) ctx := context.Background() - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "tok") eb := bus.NewMemoryEventBus(logger.Default()) defer eb.Close() @@ -183,7 +183,7 @@ func TestIsIssueWatchDue(t *testing.T) { func TestPoller_CheckIssueWatches_RespectsPerWatchInterval(t *testing.T) { f := newPollerFixture(t) ctx := context.Background() - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "tok") eb := bus.NewMemoryEventBus(logger.Default()) defer eb.Close() diff --git a/apps/backend/internal/jira/poller_test.go b/apps/backend/internal/jira/poller_test.go index ab098d58ce..2ebbc5fc81 100644 --- a/apps/backend/internal/jira/poller_test.go +++ b/apps/backend/internal/jira/poller_test.go @@ -36,37 +36,35 @@ func newPollerFixture(t *testing.T) *pollerFixture { return f } -// saveConfig persists a workspace config directly via the store + secret +// saveConfig persists the singleton config directly via the store + secret // fakes. We deliberately avoid Service.SetConfig here because it fires an // async auth probe in a goroutine — fine for production but it would race // against the deterministic RecordAuthHealth calls these tests make. -func (f *pollerFixture) saveConfig(t *testing.T, workspaceID, secret string) { +func (f *pollerFixture) saveConfig(t *testing.T, 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, + SiteURL: "https://acme.atlassian.net", + Email: "user@example.com", + AuthMethod: AuthMethodAPIToken, }); err != nil { - t.Fatalf("save config %s: %v", workspaceID, err) + t.Fatalf("save config: %v", err) } - if err := f.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), - "jira", secret); err != nil { - t.Fatalf("save secret %s: %v", workspaceID, err) + if err := f.secrets.Set(ctx, SecretKey, "jira", secret); err != nil { + t.Fatalf("save secret: %v", err) } } func TestService_RecordAuthHealth_RecordsSuccess(t *testing.T) { f := newPollerFixture(t) - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "tok") f.client.testAuthFn = func() (*TestConnectionResult, error) { return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil } - f.svc.RecordAuthHealth(context.Background(), "ws-1") + f.svc.RecordAuthHealth(context.Background()) - cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + cfg, _ := f.store.GetConfig(context.Background()) if cfg == nil { t.Fatal("config disappeared") } @@ -83,16 +81,16 @@ func TestService_RecordAuthHealth_RecordsSuccess(t *testing.T) { func TestService_RecordAuthHealth_RecordsFailure(t *testing.T) { f := newPollerFixture(t) - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "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.svc.RecordAuthHealth(context.Background(), "ws-1") + f.svc.RecordAuthHealth(context.Background()) - cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + cfg, _ := f.store.GetConfig(context.Background()) if cfg.LastOk { t.Error("expected LastOk=false after failed probe") } @@ -103,14 +101,14 @@ func TestService_RecordAuthHealth_RecordsFailure(t *testing.T) { func TestService_RecordAuthHealth_ClientError(t *testing.T) { f := newPollerFixture(t) - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "tok") f.client.testAuthFn = func() (*TestConnectionResult, error) { return nil, errors.New("network timeout") } - f.svc.RecordAuthHealth(context.Background(), "ws-1") + f.svc.RecordAuthHealth(context.Background()) - cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + cfg, _ := f.store.GetConfig(context.Background()) if cfg.LastOk { t.Error("expected LastOk=false on client error") } @@ -119,22 +117,20 @@ func TestService_RecordAuthHealth_ClientError(t *testing.T) { } } -func TestPoller_Start_ProbesEachConfiguredWorkspace(t *testing.T) { - // Smoke test: confirms the prober adapter actually wires - // Service.Store().ListConfiguredWorkspaces → Service.RecordAuthHealth - // when the loop is started, end-to-end. Wrapped in synctest so the - // goroutine the poller spawns runs deterministically against fake time - // instead of relying on a wall-clock deadline. +func TestPoller_Start_ProbesWhenConfigured(t *testing.T) { + // Smoke test: confirms the prober adapter actually wires HasConfig → + // Service.RecordAuthHealth when the loop is started, end-to-end. Wrapped + // in synctest so the goroutine the poller spawns runs deterministically + // against fake time instead of relying on a wall-clock deadline. synctest.Test(t, func(t *testing.T) { f := newPollerFixture(t) - f.saveConfig(t, "ws-a", "tok-a") - f.saveConfig(t, "ws-b", "tok-b") - probed := map[string]bool{} + f.saveConfig(t, "tok") + var probed bool f.client.testAuthFn = func() (*TestConnectionResult, error) { return &TestConnectionResult{OK: true}, nil } - f.svc.SetProbeHook(func(workspaceID string) { - probed[workspaceID] = true + f.svc.SetProbeHook(func() { + probed = true }) ctx, cancel := context.WithCancel(context.Background()) @@ -145,8 +141,8 @@ func TestPoller_Start_ProbesEachConfiguredWorkspace(t *testing.T) { // Wait for the immediate-on-Start probe pass to finish. synctest.Wait() - if !probed["ws-a"] || !probed["ws-b"] { - t.Errorf("expected both ws-a and ws-b probed, got %v", probed) + if !probed { + t.Errorf("expected probe to fire when configured") } }) } diff --git a/apps/backend/internal/jira/provider.go b/apps/backend/internal/jira/provider.go index 74447aae05..d2785fcd71 100644 --- a/apps/backend/internal/jira/provider.go +++ b/apps/backend/internal/jira/provider.go @@ -1,10 +1,13 @@ package jira import ( + "context" "os" + "time" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" + "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" @@ -34,6 +37,7 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus if err != nil { return nil, nil, err } + migrateLegacySecret(store, secrets, log) clientFn := DefaultClientFactory var mock *MockClient if MockEnabled() { @@ -50,6 +54,34 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus return svc, cleanup, nil } +// migrateLegacySecret copies the per-workspace token from the pre-singleton +// secret key onto the new install-wide key, then deletes the legacy entry. +// Best-effort: any error is logged and ignored — the worst case is the user +// re-pastes their token in the settings UI. +func migrateLegacySecret(store *Store, secrets SecretStore, log *logger.Logger) { + source := store.MigratedFromWorkspace() + if source == "" || secrets == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if exists, err := secrets.Exists(ctx, SecretKey); err == nil && exists { + return + } + legacyKey := LegacySecretKeyForWorkspace(source) + value, err := secrets.Reveal(ctx, legacyKey) + if err != nil || value == "" { + return + } + if err := secrets.Set(ctx, SecretKey, "Jira token", value); err != nil { + log.Warn("jira: legacy secret migration failed", zap.Error(err)) + return + } + if err := secrets.Delete(ctx, legacyKey); err != nil { + log.Warn("jira: legacy secret cleanup failed", zap.Error(err)) + } +} + // RegisterMockRoutes mounts the mock control routes when the service was built // with a MockClient. No-op otherwise — production builds skip this entirely. func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { diff --git a/apps/backend/internal/jira/service.go b/apps/backend/internal/jira/service.go index e54ce1431a..87b4fca146 100644 --- a/apps/backend/internal/jira/service.go +++ b/apps/backend/internal/jira/service.go @@ -24,16 +24,16 @@ type SecretStore interface { 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. +// Service orchestrates Jira config storage, the cached client, 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) + client Client // singleton, cleared on config change. + probeHook func() eventBus bus.EventBus // mockClient is non-nil only when Provide built the service with a MockClient // (KANDEV_MOCK_JIRA=true). Exposed via MockClient() so the e2e control routes @@ -67,34 +67,30 @@ func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log * secrets: secrets, log: log, clientFn: clientFn, - cache: make(map[string]Client), } } -// GetConfig returns the workspace config enriched with a HasSecret flag so the +// GetConfig returns the singleton 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) +func (s *Service) GetConfig(ctx context.Context) (*JiraConfig, error) { + cfg, err := s.store.GetConfig(ctx) 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) + exists, existsErr := s.secrets.Exists(ctx, SecretKey) if existsErr != nil { - s.log.Warn("jira: secret exists check failed", - zap.String("workspace_id", workspaceID), zap.Error(existsErr)) + s.log.Warn("jira: secret exists check failed", zap.Error(existsErr)) } cfg.HasSecret = exists if exists && cfg.AuthMethod == AuthMethodSessionCookie { - secret, revealErr := s.secrets.Reveal(ctx, key) + secret, revealErr := s.secrets.Reveal(ctx, SecretKey) if revealErr != nil { - s.log.Warn("jira: secret reveal failed", - zap.String("workspace_id", workspaceID), zap.Error(revealErr)) + s.log.Warn("jira: secret reveal failed", zap.Error(revealErr)) } else { cfg.SecretExpiresAt = parseSessionCookieExpiry(secret) } @@ -103,10 +99,10 @@ func (s *Service) GetConfig(ctx context.Context, workspaceID string) (*JiraConfi } // ErrInvalidConfig is returned by SetConfig when the request fails validation -// (missing workspace, bad auth method, etc.). Callers map it to HTTP 400. +// (missing site URL, 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 +// SetConfig is upsert: it writes the singleton 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. @@ -115,7 +111,6 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*JiraCo return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) } cfg := &JiraConfig{ - WorkspaceID: req.WorkspaceID, SiteURL: normalizeSiteURL(req.SiteURL), Email: req.Email, AuthMethod: req.AuthMethod, @@ -125,40 +120,35 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*JiraCo 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 { + if err := s.secrets.Set(ctx, SecretKey, "Jira token", req.Secret); err != nil { return nil, fmt.Errorf("store jira secret: %w", err) } } - s.invalidateClient(req.WorkspaceID) + s.invalidateClient() // 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) + go func() { + s.RecordAuthHealth(context.Background()) + }() + return s.GetConfig(ctx) } // 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 { +func (s *Service) DeleteConfig(ctx context.Context) error { + if err := s.store.DeleteConfig(ctx); 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)) + if err := s.secrets.Delete(ctx, SecretKey); err != nil { + s.log.Warn("jira: secret delete failed", zap.Error(err)) } } - s.invalidateClient(workspaceID) + s.invalidateClient() return nil } @@ -175,13 +165,13 @@ func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*T 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) +// ProbeAuth validates the stored credentials 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) (*TestConnectionResult, error) { + client, err := s.clientFor(ctx) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -195,7 +185,7 @@ func (s *Service) Store() *Store { return s.store } -// authProbeTimeout caps a single auth-health probe so a slow workspace can't +// authProbeTimeout caps a single auth-health probe so a slow Atlassian can't // stall the caller. The /myself endpoint typically responds in <500ms. const authProbeTimeout = 15 * time.Second @@ -207,21 +197,21 @@ 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)) { +func (s *Service) SetProbeHook(fn func()) { 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) { +// RecordAuthHealth probes the 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) { probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) defer cancel() - res, err := s.ProbeAuth(probeCtx, workspaceID) + res, err := s.ProbeAuth(probeCtx) ok := err == nil && res != nil && res.OK errMsg := "" switch { @@ -236,22 +226,20 @@ func (s *Service) RecordAuthHealth(ctx context.Context, workspaceID string) { // 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)) + if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, time.Now().UTC()); updateErr != nil { + s.log.Warn("jira: update auth health failed", zap.Error(updateErr)) } s.mu.Lock() hook := s.probeHook s.mu.Unlock() if hook != nil { - hook(workspaceID) + hook() } } -// 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) +// GetTicket loads a Jira ticket by key using the stored credentials. +func (s *Service) GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, error) { + client, err := s.clientFor(ctx) if err != nil { return nil, err } @@ -259,8 +247,8 @@ func (s *Service) GetTicket(ctx context.Context, workspaceID, ticketKey string) } // 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) +func (s *Service) DoTransition(ctx context.Context, ticketKey, transitionID string) error { + client, err := s.clientFor(ctx) if err != nil { return err } @@ -268,36 +256,37 @@ func (s *Service) DoTransition(ctx context.Context, workspaceID, ticketKey, tran } // 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) +func (s *Service) ListProjects(ctx context.Context) ([]JiraProject, error) { + client, err := s.clientFor(ctx) 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) +// SearchTickets runs a JQL search, 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, jql, pageToken string, maxResults int) (*SearchResult, error) { + client, err := s.clientFor(ctx) 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 +// clientFor returns the cached client, creating it 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) { +func (s *Service) clientFor(ctx context.Context) (Client, error) { s.mu.Lock() - if c, ok := s.cache[workspaceID]; ok { + if s.client != nil { + c := s.client s.mu.Unlock() return c, nil } s.mu.Unlock() - cfg, err := s.store.GetConfig(ctx, workspaceID) + cfg, err := s.store.GetConfig(ctx) if err != nil { return nil, err } @@ -306,7 +295,7 @@ func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, er } secret := "" if s.secrets != nil { - secret, err = s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + secret, err = s.secrets.Reveal(ctx, SecretKey) if err != nil { // Don't conflate a transient secret-store failure with "user never // configured Jira". The UI gates on ErrNotConfigured to render a @@ -324,18 +313,18 @@ func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, er // 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 + if s.client != nil { + return s.client, nil } - s.cache[workspaceID] = client + s.client = client return client, nil } -// invalidateClient drops a cached client so the next request rebuilds it with -// fresh credentials. -func (s *Service) invalidateClient(workspaceID string) { +// invalidateClient drops the cached client so the next request rebuilds it +// with fresh credentials. +func (s *Service) invalidateClient() { s.mu.Lock() - delete(s.cache, workspaceID) + s.client = nil s.mu.Unlock() } @@ -344,10 +333,9 @@ func (s *Service) invalidateClient(workspaceID string) { // (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, + SiteURL: normalizeSiteURL(req.SiteURL), + Email: req.Email, + AuthMethod: req.AuthMethod, } if req.Secret != "" { return cfg, req.Secret, nil @@ -355,13 +343,12 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) if s.secrets == nil { return nil, "", errors.New("no secret store configured") } - secret, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(req.WorkspaceID)) + secret, err := s.secrets.Reveal(ctx, SecretKey) 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)) + s.log.Warn("jira: secret reveal failed", zap.Error(err)) return nil, "", fmt.Errorf("read jira secret: %w", err) } if secret == "" { @@ -369,7 +356,7 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) } // 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 stored, _ := s.store.GetConfig(ctx); stored != nil { if cfg.SiteURL == "" { cfg.SiteURL = stored.SiteURL } @@ -384,9 +371,6 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) } func validateConfigRequest(req *SetConfigRequest) error { - if req.WorkspaceID == "" { - return errors.New("workspaceId required") - } if req.SiteURL == "" { return errors.New("siteUrl required") } @@ -463,6 +447,11 @@ func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]* return s.store.ListIssueWatches(ctx, workspaceID) } +// ListAllIssueWatches returns every watch across all workspaces. +func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + return s.store.ListAllIssueWatches(ctx) +} + // GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound. func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { w, err := s.store.GetIssueWatch(ctx, id) @@ -514,7 +503,7 @@ func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error { // so the UI can show liveness. func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*JiraTicket, error) { defer s.stampLastPolled(w.ID) - client, err := s.clientFor(ctx, w.WorkspaceID) + client, err := s.clientFor(ctx) if err != nil { return nil, err } diff --git a/apps/backend/internal/jira/service_issue_watch_test.go b/apps/backend/internal/jira/service_issue_watch_test.go index 0babe28e66..95f81909fd 100644 --- a/apps/backend/internal/jira/service_issue_watch_test.go +++ b/apps/backend/internal/jira/service_issue_watch_test.go @@ -148,7 +148,7 @@ func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) { // Configure JIRA so clientFor succeeds. if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, Secret: "tok", }); err != nil { t.Fatalf("set config: %v", err) @@ -198,7 +198,7 @@ func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, Secret: "tok", }); err != nil { t.Fatalf("set config: %v", err) diff --git a/apps/backend/internal/jira/service_test.go b/apps/backend/internal/jira/service_test.go index 1f076e4b7e..dc47394604 100644 --- a/apps/backend/internal/jira/service_test.go +++ b/apps/backend/internal/jira/service_test.go @@ -105,7 +105,7 @@ type svcFixture struct { // 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 + probed chan struct{} } func newSvcFixture(t *testing.T) *svcFixture { @@ -114,15 +114,15 @@ func newSvcFixture(t *testing.T) *svcFixture { store: newTestStore(t), secrets: newFakeSecretStore(), client: &fakeClient{}, - probed: make(chan string, 8), + probed: make(chan struct{}, 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) { + f.svc.SetProbeHook(func() { select { - case f.probed <- workspaceID: + case f.probed <- struct{}{}: default: } }) @@ -134,11 +134,10 @@ func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.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", + SiteURL: "https://acme.atlassian.net/", + Email: "u@example.com", + AuthMethod: AuthMethodAPIToken, + Secret: "tok1", }) if err != nil { t.Fatalf("set: %v", err) @@ -149,7 +148,7 @@ func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.T) { if !cfg.HasSecret { t.Error("expected HasSecret=true") } - if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "tok1" { + if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "tok1" { t.Errorf("secret stored = %q", got) } } @@ -160,12 +159,12 @@ func TestService_SetConfig_ProbesAuthImmediately(t *testing.T) { return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil } if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ - WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, Secret: "tok", }); err != nil { t.Fatalf("set: %v", err) } - cfg := waitForAuthProbe(t, f, "ws-1") + cfg := waitForAuthProbe(t, f) if !cfg.LastOk { t.Errorf("expected LastOk=true after async probe, got %+v", cfg) } @@ -180,12 +179,12 @@ func TestService_SetConfig_PersistsProbeFailure(t *testing.T) { 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", + SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, Secret: "bad", }); err != nil { t.Fatalf("set: %v", err) } - cfg := waitForAuthProbe(t, f, "ws-1") + cfg := waitForAuthProbe(t, f) if cfg.LastOk { t.Error("expected LastOk=false after failed probe") } @@ -197,23 +196,18 @@ func TestService_SetConfig_PersistsProbeFailure(t *testing.T) { // 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 { +func waitForAuthProbe(t *testing.T, f *svcFixture) *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 + select { + case <-f.probed: + cfg, err := f.svc.GetConfig(context.Background()) + 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 within 2s") + return nil } } @@ -221,20 +215,20 @@ 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", + 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", + 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" { + if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "first" { t.Errorf("secret should be preserved, got %q", got) } } @@ -243,18 +237,18 @@ 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", + 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 { + waitForAuthProbe(t, f) + if _, err := f.svc.GetTicket(ctx, "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 { + if _, err := f.svc.GetTicket(ctx, "A-2"); err != nil { t.Fatalf("get2: %v", err) } if got := f.factoryHit.Load(); got != hits { @@ -264,11 +258,11 @@ func TestService_SetConfig_InvalidatesClientCache(t *testing.T) { // 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", + 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 { + waitForAuthProbe(t, f) + if _, err := f.svc.GetTicket(ctx, "A-3"); err != nil { t.Fatalf("get3: %v", err) } if got := f.factoryHit.Load(); got <= hits { @@ -283,10 +277,9 @@ func TestService_Validation(t *testing.T) { 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"}}, + {"missing site", SetConfigRequest{AuthMethod: AuthMethodAPIToken, Email: "e"}}, + {"missing email api_token", SetConfigRequest{SiteURL: "x", AuthMethod: AuthMethodAPIToken}}, + {"bad auth", SetConfigRequest{SiteURL: "x", AuthMethod: "bogus"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -305,7 +298,7 @@ func TestService_TestConnection_InlineSecret(t *testing.T) { return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil } res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ - WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, Secret: "inline", }) if err != nil || !called { @@ -319,7 +312,7 @@ func TestService_TestConnection_InlineSecret(t *testing.T) { 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", + SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, }) if err != nil { @@ -334,24 +327,24 @@ 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", + SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, Secret: "t", }) - if err := f.svc.DeleteConfig(ctx, "ws-1"); err != nil { + if err := f.svc.DeleteConfig(ctx); err != nil { t.Fatalf("delete: %v", err) } - if exists, _ := f.secrets.Exists(ctx, SecretKeyForWorkspace("ws-1")); exists { + if exists, _ := f.secrets.Exists(ctx, SecretKey); exists { t.Error("secret should be removed") } - cfg, _ := f.svc.GetConfig(ctx, "ws-1") + cfg, _ := f.svc.GetConfig(ctx) if cfg != nil { t.Errorf("expected config gone, got %+v", cfg) } } -func TestService_GetTicket_UnconfiguredWorkspace(t *testing.T) { +func TestService_GetTicket_Unconfigured(t *testing.T) { f := newSvcFixture(t) - _, err := f.svc.GetTicket(context.Background(), "ws-nope", "X-1") + _, err := f.svc.GetTicket(context.Background(), "X-1") if !errors.Is(err, ErrNotConfigured) { t.Errorf("expected ErrNotConfigured, got %v", err) } @@ -361,12 +354,12 @@ 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", + 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") + _, err := f.svc.GetTicket(ctx, "X-1") if err == nil { t.Fatal("expected error") } @@ -379,10 +372,10 @@ 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", + SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, Secret: "t", }) - if err := f.svc.DoTransition(ctx, "ws-1", "PROJ-9", "31"); err != nil { + if err := f.svc.DoTransition(ctx, "PROJ-9", "31"); err != nil { t.Fatalf("transition: %v", err) } if len(f.client.transitionLog) != 1 || f.client.transitionLog[0] != "PROJ-9:31" { diff --git a/apps/backend/internal/jira/store.go b/apps/backend/internal/jira/store.go index ed4ba555ae..be8b6aa2fb 100644 --- a/apps/backend/internal/jira/store.go +++ b/apps/backend/internal/jira/store.go @@ -11,11 +11,18 @@ import ( "github.com/jmoiron/sqlx" ) -// Store persists Jira workspace configurations. Secret values are delegated to -// the shared encrypted secret store and not stored here. +// Store persists the Jira configuration. Secret values are delegated to the +// shared encrypted secret store and not stored here. The configuration is a +// singleton — there is one Jira account per install, not one per workspace. type Store struct { db *sqlx.DB ro *sqlx.DB + + // migratedFromWorkspace records the workspace_id of the row that was + // promoted into the singleton during initSchema. Provider reads this to + // migrate the per-workspace secret to the new global key. Empty when no + // migration ran (fresh install or already migrated). + migratedFromWorkspace string } // NewStore creates a new Store and initializes the schema if needed. @@ -27,9 +34,16 @@ func NewStore(writer, reader *sqlx.DB) (*Store, error) { return s, nil } +// MigratedFromWorkspace returns the workspace_id of the row promoted to the +// singleton during the per-workspace → singleton schema migration, or "" when +// no migration ran. Provider uses this to copy the secret over. +func (s *Store) MigratedFromWorkspace() string { + return s.migratedFromWorkspace +} + const createTablesSQL = ` CREATE TABLE IF NOT EXISTS jira_configs ( - workspace_id TEXT PRIMARY KEY, + id TEXT PRIMARY KEY CHECK(id = 'singleton'), site_url TEXT NOT NULL, email TEXT NOT NULL DEFAULT '', auth_method TEXT NOT NULL, @@ -71,43 +85,126 @@ const createTablesSQL = ` ); ` -// 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 ''"}, -} +// singletonID is the synthetic primary key of the (only) row in jira_configs. +// The CHECK constraint on the column makes inserting any other id an error, +// so the table can never accidentally grow back to per-workspace rows. +const singletonID = "singleton" func (s *Store) initSchema() error { + if err := s.migrateLegacyPerWorkspaceTable(); err != nil { + return err + } if _, err := s.db.Exec(createTablesSQL); err != nil { return err } - return s.migrateAddedColumns() + return nil } -// 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") +// migrateLegacyPerWorkspaceTable detects the pre-singleton schema (where +// jira_configs was keyed by workspace_id) and rewrites it into the singleton +// shape. Picks the most-recently-updated row as the surviving config and +// records the source workspace_id so the provider can migrate the secret. +// +// Idempotent: a fresh install has no legacy table and falls through to the +// CREATE TABLE IF NOT EXISTS in createTablesSQL. +func (s *Store) migrateLegacyPerWorkspaceTable() error { + cols, err := s.tableColumns("jira_configs") if err != nil { return err } - for _, col := range addedColumns { - if _, ok := existing[col.name]; ok { - continue + if len(cols) == 0 { + return nil + } + if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // `last_checked_at`, `last_ok`, and `last_error` were added to the legacy + // schema in a later release via ALTER TABLE. A deployment that upgrades + // from the original schema would have a `workspace_id` column but not + // these — selecting them unconditionally would crash startup. Build the + // SELECT against only columns present in this database. + healthCols := healthColumnsPresent(cols) + selectCols := "workspace_id, site_url, email, auth_method, default_project_key" + if healthCols { + selectCols += ", last_checked_at, last_ok, last_error" + } else { + selectCols += ", NULL AS last_checked_at, 0 AS last_ok, '' AS last_error" + } + selectCols += ", created_at, updated_at" + var sourceWorkspace, siteURL, email, authMethod, defaultProjectKey, lastError sql.NullString + var lastCheckedAt sql.NullTime + var lastOk sql.NullInt64 + var createdAt, updatedAt sql.NullTime + row := tx.QueryRow(`SELECT ` + selectCols + ` FROM jira_configs ORDER BY updated_at DESC LIMIT 1`) + switch err := row.Scan(&sourceWorkspace, &siteURL, &email, &authMethod, &defaultProjectKey, + &lastCheckedAt, &lastOk, &lastError, &createdAt, &updatedAt); { + case errors.Is(err, sql.ErrNoRows): + // Empty legacy table — drop and let createTablesSQL build the new shape. + if _, err := tx.Exec(`DROP TABLE jira_configs`); err != nil { + return err } - if _, err := s.db.Exec(col.sql); err != nil { - return fmt.Errorf("add column %s: %w", col.name, err) + return tx.Commit() + case err != nil: + return err + } + if _, err := tx.Exec(`DROP TABLE jira_configs`); err != nil { + return err + } + if _, err := tx.Exec(` + CREATE TABLE jira_configs ( + id TEXT PRIMARY KEY CHECK(id = 'singleton'), + 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 + )`); err != nil { + return err + } + if _, err := tx.Exec(` + INSERT INTO jira_configs (id, site_url, email, auth_method, default_project_key, + last_checked_at, last_ok, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + singletonID, siteURL.String, email.String, authMethod.String, defaultProjectKey.String, + nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, + nullableTime(createdAt), nullableTime(updatedAt)); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + s.migratedFromWorkspace = sourceWorkspace.String + return nil +} + +// healthColumnsPresent reports whether the legacy jira_configs table has the +// auth-health columns that were added in a later release. When all three are +// missing we fall back to NULL/zero defaults rather than crashing on the +// SELECT. +func healthColumnsPresent(cols map[string]struct{}) bool { + for _, name := range []string{"last_checked_at", "last_ok", "last_error"} { + if _, ok := cols[name]; !ok { + return false } } - return nil + return true +} + +func nullableTime(t sql.NullTime) interface{} { + if !t.Valid { + return nil + } + return t.Time } func (s *Store) tableColumns(table string) (map[string]struct{}, error) { @@ -134,14 +231,14 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { return cols, rows.Err() } -const selectConfigColumns = `workspace_id, site_url, email, auth_method, default_project_key, +const selectConfigColumns = `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) { +// GetConfig returns the singleton Jira config, or nil when no row exists. +func (s *Store) GetConfig(ctx context.Context) (*JiraConfig, error) { var cfg JiraConfig err := s.ro.GetContext(ctx, &cfg, - `SELECT `+selectConfigColumns+` FROM jira_configs WHERE workspace_id = ?`, workspaceID) + `SELECT `+selectConfigColumns+` FROM jira_configs WHERE id = ?`, singletonID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -151,10 +248,10 @@ func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*JiraConfig, 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. +// UpsertConfig inserts or updates the singleton config row. 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() { @@ -162,47 +259,47 @@ func (s *Store) UpsertConfig(ctx context.Context, cfg *JiraConfig) error { } 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) + INSERT INTO jira_configs (id, site_url, email, auth_method, default_project_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(workspace_id) DO UPDATE SET + ON CONFLICT(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) + singletonID, 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) +// DeleteConfig removes the singleton config row. Secrets must be cleared +// separately by the caller. +func (s *Store) DeleteConfig(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM jira_configs WHERE id = ?`, singletonID) 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`) +// HasConfig reports whether the singleton row exists. Used by the auth-health +// poller to decide whether to probe at all. +func (s *Store) HasConfig(ctx context.Context) (bool, error) { + var present int + err := s.ro.GetContext(ctx, &present, + `SELECT COUNT(*) FROM jira_configs WHERE id = ?`, singletonID) if err != nil { - return nil, err + return false, err } - return ids, nil + return present > 0, 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 +// UpdateAuthHealth records the result of a credential probe on the singleton +// config row. errMsg is the empty string when ok is true. If the 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 { +func (s *Store) UpdateAuthHealth(ctx context.Context, 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) + WHERE id = ?`, + checkedAt, ok, errMsg, singletonID) return err } @@ -260,6 +357,20 @@ func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*Is return watches, nil } +// ListAllIssueWatches returns every watch across all workspaces, in insertion +// order. Used by the install-wide settings UI when no workspace filter is +// supplied — the table renders a Workspace column so the user can manage +// watches without first picking a workspace context. +func (s *Store) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + var watches []*IssueWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT `+issueWatchColumns+` FROM jira_issue_watches ORDER BY workspace_id, created_at`) + if err != nil { + return nil, err + } + return watches, nil +} + // ListEnabledIssueWatches returns every enabled watch across all workspaces, // used by the poller to decide what to query each tick. func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) { diff --git a/apps/backend/internal/jira/store_test.go b/apps/backend/internal/jira/store_test.go index 1a101e3e5b..f373d815c2 100644 --- a/apps/backend/internal/jira/store_test.go +++ b/apps/backend/internal/jira/store_test.go @@ -32,7 +32,6 @@ func TestStore_UpsertGetDelete(t *testing.T) { ctx := context.Background() cfg := &JiraConfig{ - WorkspaceID: "ws-1", SiteURL: "https://acme.atlassian.net", Email: "user@example.com", AuthMethod: AuthMethodAPIToken, @@ -42,7 +41,7 @@ func TestStore_UpsertGetDelete(t *testing.T) { t.Fatalf("upsert: %v", err) } - got, err := store.GetConfig(ctx, "ws-1") + got, err := store.GetConfig(ctx) if err != nil { t.Fatalf("get: %v", err) } @@ -61,15 +60,15 @@ func TestStore_UpsertGetDelete(t *testing.T) { if err := store.UpsertConfig(ctx, cfg); err != nil { t.Fatalf("update upsert: %v", err) } - got2, _ := store.GetConfig(ctx, "ws-1") + got2, _ := store.GetConfig(ctx) if got2.Email != "other@example.com" { t.Errorf("expected email update, got %q", got2.Email) } - if err := store.DeleteConfig(ctx, "ws-1"); err != nil { + if err := store.DeleteConfig(ctx); err != nil { t.Fatalf("delete: %v", err) } - gone, err := store.GetConfig(ctx, "ws-1") + gone, err := store.GetConfig(ctx) if err != nil { t.Fatalf("get after delete: %v", err) } @@ -80,7 +79,7 @@ func TestStore_UpsertGetDelete(t *testing.T) { func TestStore_GetConfig_Missing(t *testing.T) { store := newTestStore(t) - cfg, err := store.GetConfig(context.Background(), "does-not-exist") + cfg, err := store.GetConfig(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -89,27 +88,29 @@ func TestStore_GetConfig_Missing(t *testing.T) { } } -func TestStore_ListConfiguredWorkspaces(t *testing.T) { +func TestStore_HasConfig(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) + has, err := store.HasConfig(ctx) if err != nil { - t.Fatalf("list: %v", err) + t.Fatalf("has-config: %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) + if has { + t.Errorf("expected HasConfig=false on empty store, got true") + } + if err := store.UpsertConfig(ctx, &JiraConfig{ + SiteURL: "https://acme.atlassian.net", + Email: "user@example.com", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + has, err = store.HasConfig(ctx) + if err != nil { + t.Fatalf("has-config after upsert: %v", err) + } + if !has { + t.Errorf("expected HasConfig=true after upsert") } } @@ -117,16 +118,15 @@ 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, + 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") + cfg, _ := store.GetConfig(ctx) if cfg.LastCheckedAt != nil { t.Errorf("expected nil last_checked_at on fresh row, got %v", cfg.LastCheckedAt) } @@ -136,10 +136,10 @@ func TestStore_UpdateAuthHealth(t *testing.T) { // Record success. now := time.Now().UTC().Truncate(time.Second) - if err := store.UpdateAuthHealth(ctx, "ws-1", true, "", now); err != nil { + if err := store.UpdateAuthHealth(ctx, true, "", now); err != nil { t.Fatalf("update ok: %v", err) } - cfg, _ = store.GetConfig(ctx, "ws-1") + cfg, _ = store.GetConfig(ctx) if !cfg.LastOk { t.Error("expected last_ok=true after successful probe") } @@ -152,10 +152,10 @@ func TestStore_UpdateAuthHealth(t *testing.T) { // 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 { + if err := store.UpdateAuthHealth(ctx, false, "step-up required", failAt); err != nil { t.Fatalf("update fail: %v", err) } - cfg, _ = store.GetConfig(ctx, "ws-1") + cfg, _ = store.GetConfig(ctx) if cfg.LastOk { t.Error("expected last_ok=false after failure") } @@ -165,10 +165,170 @@ func TestStore_UpdateAuthHealth(t *testing.T) { 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) +func TestStore_MigrateLegacyPerWorkspaceTable(t *testing.T) { + 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() }) + + // Build the legacy schema and seed two per-workspace rows; the most + // recently-updated row should win the singleton slot. + if _, err := db.Exec(` + CREATE TABLE 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 + )`); err != nil { + t.Fatalf("legacy schema: %v", err) + } + older := time.Now().UTC().Add(-time.Hour) + newer := time.Now().UTC() + if _, err := db.Exec(`INSERT INTO jira_configs + (workspace_id, site_url, email, auth_method, default_project_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + "ws-old", "https://old.atlassian.net", "old@example.com", AuthMethodAPIToken, "OLD", older, older); err != nil { + t.Fatalf("seed old row: %v", err) + } + if _, err := db.Exec(`INSERT INTO jira_configs + (workspace_id, site_url, email, auth_method, default_project_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + "ws-new", "https://new.atlassian.net", "new@example.com", AuthMethodAPIToken, "NEW", newer, newer); err != nil { + t.Fatalf("seed new row: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-new" { + t.Errorf("expected migration source ws-new, got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get singleton: %v", err) + } + if cfg == nil { + t.Fatal("expected singleton row after migration") + } + if cfg.SiteURL != "https://new.atlassian.net" { + t.Errorf("expected newest row promoted, got SiteURL=%q", cfg.SiteURL) + } +} + +// Covers the sql.ErrNoRows branch: a user who installed a previous version, +// never configured Jira, and then upgrades hits this path. The legacy table +// exists but contains zero rows. +func TestStore_MigrateLegacyPerWorkspaceTable_EmptyTable(t *testing.T) { + 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() }) + + if _, err := db.Exec(` + CREATE TABLE 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 + )`); err != nil { + t.Fatalf("legacy schema: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("NewStore on empty legacy table: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "" { + t.Errorf("expected empty MigratedFromWorkspace, got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get singleton: %v", err) + } + if cfg != nil { + t.Errorf("expected nil cfg on empty legacy table, got %+v", cfg) + } +} + +// Covers the original-schema upgrade path: the legacy table exists with +// `workspace_id` but lacks the auth-health columns added in a later release. +// The migration must select only guaranteed-present columns and fall back to +// defaults for the missing ones, otherwise startup crashes with "no such column". +func TestStore_MigrateLegacyPerWorkspaceTable_PreHealthColumns(t *testing.T) { + 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() }) + + // Original schema: no last_checked_at / last_ok / last_error. + if _, err := db.Exec(` + CREATE TABLE 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 '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`); err != nil { + t.Fatalf("legacy schema: %v", err) + } + now := time.Now().UTC() + if _, err := db.Exec(`INSERT INTO jira_configs + (workspace_id, site_url, email, auth_method, default_project_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + "ws-1", "https://acme.atlassian.net", "u@example.com", AuthMethodAPIToken, "PROJ", now, now); err != nil { + t.Fatalf("seed legacy row: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("NewStore on pre-health-columns schema: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-1" { + t.Errorf("expected migration source ws-1, got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get singleton: %v", err) + } + if cfg == nil { + t.Fatal("expected singleton row after migration") + } + if cfg.SiteURL != "https://acme.atlassian.net" { + t.Errorf("expected SiteURL preserved, got %q", cfg.SiteURL) + } + if cfg.LastOk { + t.Error("expected LastOk=false (default) after migrating from pre-health schema") + } + if cfg.LastCheckedAt != nil { + t.Errorf("expected LastCheckedAt=nil (default), got %v", cfg.LastCheckedAt) } } diff --git a/apps/backend/internal/linear/handlers.go b/apps/backend/internal/linear/handlers.go index a1b52f7796..b119b775e7 100644 --- a/apps/backend/internal/linear/handlers.go +++ b/apps/backend/internal/linear/handlers.go @@ -14,11 +14,6 @@ import ( ws "github.com/kandev/kandev/pkg/websocket" ) -// errMsgWorkspaceIDRequired is the response body returned when a handler is -// invoked without the workspace_id query parameter. Centralised because the -// linter flags repeated string literals at three or more occurrences. -const errMsgWorkspaceIDRequired = "workspace_id required" - // RegisterRoutes wires the Linear HTTP and WebSocket handlers. func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { ctrl := &Controller{service: svc, logger: log} @@ -49,12 +44,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { // --- HTTP handlers --- func (c *Controller) httpGetConfig(ctx *gin.Context) { - workspaceID := ctx.Query("workspace_id") - if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) - return - } - cfg, err := c.service.GetConfig(ctx.Request.Context(), workspaceID) + cfg, err := c.service.GetConfig(ctx.Request.Context()) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -85,12 +75,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { } func (c *Controller) httpDeleteConfig(ctx *gin.Context) { - workspaceID := ctx.Query("workspace_id") - if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) - return - } - if err := c.service.DeleteConfig(ctx.Request.Context(), workspaceID); err != nil { + if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -112,12 +97,7 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { } func (c *Controller) httpListTeams(ctx *gin.Context) { - workspaceID := ctx.Query("workspace_id") - if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) - return - } - teams, err := c.service.ListTeams(ctx.Request.Context(), workspaceID) + teams, err := c.service.ListTeams(ctx.Request.Context()) if err != nil { c.writeClientError(ctx, err) return @@ -126,13 +106,12 @@ func (c *Controller) httpListTeams(ctx *gin.Context) { } func (c *Controller) httpListStates(ctx *gin.Context) { - workspaceID := ctx.Query("workspace_id") teamKey := ctx.Query("team_key") - if workspaceID == "" || teamKey == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and team_key required"}) + if teamKey == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "team_key required"}) return } - states, err := c.service.ListStates(ctx.Request.Context(), workspaceID, teamKey) + states, err := c.service.ListStates(ctx.Request.Context(), teamKey) if err != nil { c.writeClientError(ctx, err) return @@ -141,11 +120,6 @@ func (c *Controller) httpListStates(ctx *gin.Context) { } func (c *Controller) httpSearchIssues(ctx *gin.Context) { - workspaceID := ctx.Query("workspace_id") - if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) - return - } filter := SearchFilter{ Query: ctx.Query("query"), TeamKey: ctx.Query("team_key"), @@ -156,7 +130,7 @@ func (c *Controller) httpSearchIssues(ctx *gin.Context) { } pageToken := ctx.Query("page_token") maxResults, _ := strconv.Atoi(ctx.Query("max_results")) - result, err := c.service.SearchIssues(ctx.Request.Context(), workspaceID, filter, pageToken, maxResults) + result, err := c.service.SearchIssues(ctx.Request.Context(), filter, pageToken, maxResults) if err != nil { c.writeClientError(ctx, err) return @@ -165,13 +139,8 @@ func (c *Controller) httpSearchIssues(ctx *gin.Context) { } func (c *Controller) httpGetIssue(ctx *gin.Context) { - workspaceID := ctx.Query("workspace_id") - if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) - return - } id := ctx.Param("id") - issue, err := c.service.GetIssue(ctx.Request.Context(), workspaceID, id) + issue, err := c.service.GetIssue(ctx.Request.Context(), id) if err != nil { c.writeClientError(ctx, err) return @@ -180,11 +149,6 @@ func (c *Controller) httpGetIssue(ctx *gin.Context) { } func (c *Controller) httpSetIssueState(ctx *gin.Context) { - workspaceID := ctx.Query("workspace_id") - if workspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) - return - } id := ctx.Param("id") var req struct { StateID string `json:"stateId"` @@ -193,7 +157,7 @@ func (c *Controller) httpSetIssueState(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "stateId required"}) return } - if err := c.service.SetIssueState(ctx.Request.Context(), workspaceID, id, req.StateID); err != nil { + if err := c.service.SetIssueState(ctx.Request.Context(), id, req.StateID); err != nil { c.writeClientError(ctx, err) return } @@ -201,14 +165,14 @@ func (c *Controller) httpSetIssueState(ctx *gin.Context) { } // errCodeLinearNotConfigured is the wire-level code surfaced to the UI when -// the workspace has no saved Linear credentials. +// Linear has no saved credentials. const errCodeLinearNotConfigured = "LINEAR_NOT_CONFIGURED" // writeClientError maps service-level errors to HTTP responses. func (c *Controller) writeClientError(ctx *gin.Context, err error) { if errors.Is(err, ErrNotConfigured) { ctx.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Linear is not configured for this workspace", + "error": "Linear is not configured", "code": errCodeLinearNotConfigured, }) return @@ -268,13 +232,7 @@ func wsFail(msg *ws.Message, err error) (*ws.Message, error) { 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) + cfg, err := svc.GetConfig(ctx) if err != nil { return wsFail(msg, err) } @@ -301,13 +259,7 @@ func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.M 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 { + if err := svc.DeleteConfig(ctx); err != nil { return wsFail(msg, err) } return wsReply(msg, gin.H{"deleted": true}) @@ -331,16 +283,15 @@ func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws. func wsGetIssue(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"` - Identifier string `json:"identifier"` + Identifier string `json:"identifier"` } if err := msg.ParsePayload(&p); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) } - if p.WorkspaceID == "" || p.Identifier == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId and identifier required", nil) + if p.Identifier == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "identifier required", nil) } - issue, err := svc.GetIssue(ctx, p.WorkspaceID, p.Identifier) + issue, err := svc.GetIssue(ctx, p.Identifier) if err != nil { return wsFail(msg, err) } @@ -351,17 +302,16 @@ func wsGetIssue(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Me func wsSetIssueState(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"` - IssueID string `json:"issueId"` - StateID string `json:"stateId"` + IssueID string `json:"issueId"` + StateID string `json:"stateId"` } if err := msg.ParsePayload(&p); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) } - if p.WorkspaceID == "" || p.IssueID == "" || p.StateID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId, issueId, stateId required", nil) + if p.IssueID == "" || p.StateID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "issueId and stateId required", nil) } - if err := svc.SetIssueState(ctx, p.WorkspaceID, p.IssueID, p.StateID); err != nil { + if err := svc.SetIssueState(ctx, p.IssueID, p.StateID); err != nil { return wsFail(msg, err) } return wsReply(msg, gin.H{"transitioned": true}) @@ -370,13 +320,7 @@ func wsSetIssueState(svc *Service) func(ctx context.Context, msg *ws.Message) (* func wsListTeams(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) - } - teams, err := svc.ListTeams(ctx, p.WorkspaceID) + teams, err := svc.ListTeams(ctx) if err != nil { return wsFail(msg, err) } diff --git a/apps/backend/internal/linear/handlers_test.go b/apps/backend/internal/linear/handlers_test.go index 6cc0de74ea..478b1e6ccc 100644 --- a/apps/backend/internal/linear/handlers_test.go +++ b/apps/backend/internal/linear/handlers_test.go @@ -103,19 +103,9 @@ func TestWriteClientError_GenericError_Returns500(t *testing.T) { } } -func TestHTTPGetConfig_RequiresWorkspaceID(t *testing.T) { - _, router, _ := newTestController(t) - req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/config", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", w.Code) - } -} - func TestHTTPGetConfig_NoConfig_Returns204(t *testing.T) { _, router, _ := newTestController(t) - req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/config?workspace_id=missing", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/config", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusNoContent { @@ -123,19 +113,13 @@ func TestHTTPGetConfig_NoConfig_Returns204(t *testing.T) { } } -func TestHTTPListStates_RequiresBothParams(t *testing.T) { +func TestHTTPListStates_RequiresTeamKey(t *testing.T) { _, router, _ := newTestController(t) - for _, url := range []string{ - "/api/v1/linear/states", - "/api/v1/linear/states?workspace_id=ws-1", - "/api/v1/linear/states?team_key=ENG", - } { - req := httptest.NewRequest(http.MethodGet, url, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("%s: status = %d, want 400", url, w.Code) - } + req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/states", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) } } @@ -143,12 +127,11 @@ func TestHTTPListStates_RoutesThroughService(t *testing.T) { ctrl, router, client := newTestController(t) ctx := context.Background() if err := ctrl.service.store.UpsertConfig(ctx, &LinearConfig{ - WorkspaceID: "ws-1", - AuthMethod: AuthMethodAPIKey, + AuthMethod: AuthMethodAPIKey, }); err != nil { t.Fatalf("upsert: %v", err) } - if err := ctrl.service.secrets.Set(ctx, SecretKeyForWorkspace("ws-1"), "linear", "tok"); err != nil { + if err := ctrl.service.secrets.Set(ctx, SecretKey, "linear", "tok"); err != nil { t.Fatalf("set secret: %v", err) } var seenTeam string @@ -156,7 +139,7 @@ func TestHTTPListStates_RoutesThroughService(t *testing.T) { seenTeam = teamKey return []LinearWorkflowState{{ID: "s1", Name: "In Progress"}}, nil } - req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/states?workspace_id=ws-1&team_key=ENG", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/states?team_key=ENG", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { diff --git a/apps/backend/internal/linear/mock_controller.go b/apps/backend/internal/linear/mock_controller.go index 1d59e1392f..b5bd1f7a5d 100644 --- a/apps/backend/internal/linear/mock_controller.go +++ b/apps/backend/internal/linear/mock_controller.go @@ -49,16 +49,15 @@ func (c *MockController) setAuthResult(ctx *gin.Context) { func (c *MockController) setAuthHealth(ctx *gin.Context) { var req struct { - WorkspaceID string `json:"workspaceId"` - OK bool `json:"ok"` - Error string `json:"error"` - OrgSlug string `json:"orgSlug"` + OK bool `json:"ok"` + Error string `json:"error"` + OrgSlug string `json:"orgSlug"` } - if err := ctx.ShouldBindJSON(&req); err != nil || req.WorkspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId required"}) + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.WorkspaceID, req.OK, req.Error, req.OrgSlug, time.Now().UTC()); err != nil { + if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, req.OrgSlug, time.Now().UTC()); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/apps/backend/internal/linear/models.go b/apps/backend/internal/linear/models.go index 82a6a54044..6e21cc53cc 100644 --- a/apps/backend/internal/linear/models.go +++ b/apps/backend/internal/linear/models.go @@ -1,7 +1,6 @@ -// Package linear implements the Linear integration: workspace-scoped -// configuration storage, a GraphQL client for issues and workflow states, and -// the HTTP and WebSocket handlers that expose these capabilities to the -// frontend. +// Package linear implements the Linear integration: a single install-wide +// configuration, a GraphQL client for issues and workflow states, and the HTTP +// and WebSocket handlers that expose these capabilities to the frontend. package linear import "time" @@ -12,11 +11,10 @@ import "time" // and leaves room for OAuth in the future. const AuthMethodAPIKey = "api_key" -// LinearConfig is the workspace-scoped configuration for the Linear -// integration. The API key is stored separately in the encrypted secret store -// under the key returned by SecretKeyForWorkspace. +// LinearConfig is the install-wide configuration for the Linear integration. +// The API key is stored separately in the encrypted secret store under +// SecretKey. type LinearConfig struct { - WorkspaceID string `json:"workspaceId" db:"workspace_id"` AuthMethod string `json:"authMethod" db:"auth_method"` DefaultTeamKey string `json:"defaultTeamKey" db:"default_team_key"` HasSecret bool `json:"hasSecret" db:"-"` @@ -35,10 +33,9 @@ type LinearConfig struct { } // SetConfigRequest is the payload sent by the UI to create or update the -// workspace's Linear configuration. When Secret is empty on update, the -// existing secret is retained; when non-empty it replaces the stored value. +// Linear 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"` AuthMethod string `json:"authMethod"` DefaultTeamKey string `json:"defaultTeamKey"` Secret string `json:"secret"` @@ -128,8 +125,13 @@ type SearchResult struct { NextPageToken string `json:"nextPageToken,omitempty"` } -// SecretKeyForWorkspace returns the secret-store key used for the Linear API -// key of a given workspace. Centralised so the service and store agree. -func SecretKeyForWorkspace(workspaceID string) string { +// SecretKey is the secret-store key used for the install-wide Linear API key. +// Centralised so the service, store and provider migration agree. +const SecretKey = "linear:singleton:token" + +// LegacySecretKeyForWorkspace returns the pre-singleton per-workspace secret +// key. Only used by the one-shot startup migration in provider.go to copy an +// existing token over to SecretKey. +func LegacySecretKeyForWorkspace(workspaceID string) string { return "linear:" + workspaceID + ":token" } diff --git a/apps/backend/internal/linear/poller.go b/apps/backend/internal/linear/poller.go index d6e0b540ab..f98d85edb9 100644 --- a/apps/backend/internal/linear/poller.go +++ b/apps/backend/internal/linear/poller.go @@ -46,10 +46,10 @@ func (p *Poller) Stop() { // interface into Service's public API. type svcProber struct{ svc *Service } -func (s svcProber) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) { - return s.svc.Store().ListConfiguredWorkspaces(ctx) +func (s svcProber) HasConfig(ctx context.Context) (bool, error) { + return s.svc.Store().HasConfig(ctx) } -func (s svcProber) RecordAuthHealth(ctx context.Context, workspaceID string) { - s.svc.RecordAuthHealth(ctx, workspaceID) +func (s svcProber) RecordAuthHealth(ctx context.Context) { + s.svc.RecordAuthHealth(ctx) } diff --git a/apps/backend/internal/linear/poller_test.go b/apps/backend/internal/linear/poller_test.go index c91a621b69..3b60b2e290 100644 --- a/apps/backend/internal/linear/poller_test.go +++ b/apps/backend/internal/linear/poller_test.go @@ -36,33 +36,31 @@ func newPollerFixture(t *testing.T) *pollerFixture { return f } -// saveConfig persists a workspace config directly via the store + secret +// saveConfig persists the singleton config directly via the store + secret // fakes — bypassing Service.SetConfig avoids racing against its async probe. -func (f *pollerFixture) saveConfig(t *testing.T, workspaceID, secret string) { +func (f *pollerFixture) saveConfig(t *testing.T, secret string) { t.Helper() ctx := context.Background() if err := f.store.UpsertConfig(ctx, &LinearConfig{ - WorkspaceID: workspaceID, - AuthMethod: AuthMethodAPIKey, + AuthMethod: AuthMethodAPIKey, }); err != nil { - t.Fatalf("save config %s: %v", workspaceID, err) + t.Fatalf("save config: %v", err) } - if err := f.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), - "linear", secret); err != nil { - t.Fatalf("save secret %s: %v", workspaceID, err) + if err := f.secrets.Set(ctx, SecretKey, "linear", secret); err != nil { + t.Fatalf("save secret: %v", err) } } func TestService_RecordAuthHealth_RecordsSuccess(t *testing.T) { f := newPollerFixture(t) - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "tok") f.client.testAuthFn = func() (*TestConnectionResult, error) { return &TestConnectionResult{OK: true, OrgSlug: "acme"}, nil } - f.svc.RecordAuthHealth(context.Background(), "ws-1") + f.svc.RecordAuthHealth(context.Background()) - cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + cfg, _ := f.store.GetConfig(context.Background()) if cfg == nil { t.Fatal("config disappeared") } @@ -76,14 +74,14 @@ func TestService_RecordAuthHealth_RecordsSuccess(t *testing.T) { func TestService_RecordAuthHealth_RecordsFailure(t *testing.T) { f := newPollerFixture(t) - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "tok") f.client.testAuthFn = func() (*TestConnectionResult, error) { return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil } - f.svc.RecordAuthHealth(context.Background(), "ws-1") + f.svc.RecordAuthHealth(context.Background()) - cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + cfg, _ := f.store.GetConfig(context.Background()) if cfg.LastOk { t.Error("expected LastOk=false after failed probe") } @@ -94,35 +92,33 @@ func TestService_RecordAuthHealth_RecordsFailure(t *testing.T) { func TestService_RecordAuthHealth_ClientError(t *testing.T) { f := newPollerFixture(t) - f.saveConfig(t, "ws-1", "tok") + f.saveConfig(t, "tok") f.client.testAuthFn = func() (*TestConnectionResult, error) { return nil, errors.New("network timeout") } - f.svc.RecordAuthHealth(context.Background(), "ws-1") + f.svc.RecordAuthHealth(context.Background()) - cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + cfg, _ := f.store.GetConfig(context.Background()) if cfg.LastOk { t.Error("expected LastOk=false on client error") } } -func TestPoller_Start_ProbesEachConfiguredWorkspace(t *testing.T) { - // Smoke test: confirms the prober adapter actually wires - // Service.Store().ListConfiguredWorkspaces → Service.RecordAuthHealth - // when the loop is started, end-to-end. Wrapped in synctest so the - // goroutine the poller spawns runs deterministically against fake time - // instead of relying on a wall-clock deadline. +func TestPoller_Start_ProbesWhenConfigured(t *testing.T) { + // Smoke test: confirms the prober adapter wires HasConfig → + // Service.RecordAuthHealth when the loop is started, end-to-end. Wrapped + // in synctest so the goroutine the poller spawns runs deterministically + // against fake time instead of relying on a wall-clock deadline. synctest.Test(t, func(t *testing.T) { f := newPollerFixture(t) - f.saveConfig(t, "ws-a", "tok-a") - f.saveConfig(t, "ws-b", "tok-b") - probed := map[string]bool{} + f.saveConfig(t, "tok") + var probed bool f.client.testAuthFn = func() (*TestConnectionResult, error) { return &TestConnectionResult{OK: true}, nil } - f.svc.SetProbeHook(func(workspaceID string) { - probed[workspaceID] = true + f.svc.SetProbeHook(func() { + probed = true }) ctx, cancel := context.WithCancel(context.Background()) @@ -133,8 +129,8 @@ func TestPoller_Start_ProbesEachConfiguredWorkspace(t *testing.T) { // Wait for the immediate-on-Start probe pass to finish. synctest.Wait() - if !probed["ws-a"] || !probed["ws-b"] { - t.Errorf("expected both ws-a and ws-b probed, got %v", probed) + if !probed { + t.Errorf("expected probe to fire when configured") } }) } diff --git a/apps/backend/internal/linear/provider.go b/apps/backend/internal/linear/provider.go index e9b091ca98..7eb4e9933f 100644 --- a/apps/backend/internal/linear/provider.go +++ b/apps/backend/internal/linear/provider.go @@ -1,10 +1,13 @@ package linear import ( + "context" "os" + "time" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" + "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" ) @@ -29,6 +32,7 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) ( if err != nil { return nil, nil, err } + migrateLegacySecret(store, secrets, log) clientFn := DefaultClientFactory var mock *MockClient if MockEnabled() { @@ -42,6 +46,34 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) ( return svc, cleanup, nil } +// migrateLegacySecret copies the per-workspace API key from the pre-singleton +// secret key onto the new install-wide key, then deletes the legacy entry. +// Best-effort: any error is logged and ignored — the worst case is the user +// re-pastes their key in the settings UI. +func migrateLegacySecret(store *Store, secrets SecretStore, log *logger.Logger) { + source := store.MigratedFromWorkspace() + if source == "" || secrets == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if exists, err := secrets.Exists(ctx, SecretKey); err == nil && exists { + return + } + legacyKey := LegacySecretKeyForWorkspace(source) + value, err := secrets.Reveal(ctx, legacyKey) + if err != nil || value == "" { + return + } + if err := secrets.Set(ctx, SecretKey, "Linear API key", value); err != nil { + log.Warn("linear: legacy secret migration failed", zap.Error(err)) + return + } + if err := secrets.Delete(ctx, legacyKey); err != nil { + log.Warn("linear: legacy secret cleanup failed", zap.Error(err)) + } +} + // RegisterMockRoutes mounts the mock control routes when the service was built // with a MockClient. No-op otherwise. func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { diff --git a/apps/backend/internal/linear/service.go b/apps/backend/internal/linear/service.go index 31b2ef960b..b42b2df417 100644 --- a/apps/backend/internal/linear/service.go +++ b/apps/backend/internal/linear/service.go @@ -20,16 +20,16 @@ type SecretStore interface { Exists(ctx context.Context, id string) (bool, error) } -// Service orchestrates Linear config storage, the per-workspace client cache, -// and the fetch/transition operations used by the WebSocket + HTTP handlers. +// Service orchestrates Linear config storage, the cached client, 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) + client Client // singleton, cleared on config change. + probeHook func() // mockClient is non-nil only when Provide built the service with a MockClient // (KANDEV_MOCK_LINEAR=true). Exposed via MockClient() so the e2e control // routes can drive the same instance the clientFn returns. @@ -61,23 +61,21 @@ func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log * secrets: secrets, log: log, clientFn: clientFn, - cache: make(map[string]Client), } } -// GetConfig returns the workspace config enriched with a HasSecret flag. -func (s *Service) GetConfig(ctx context.Context, workspaceID string) (*LinearConfig, error) { - cfg, err := s.store.GetConfig(ctx, workspaceID) +// GetConfig returns the singleton config enriched with a HasSecret flag. +func (s *Service) GetConfig(ctx context.Context) (*LinearConfig, error) { + cfg, err := s.store.GetConfig(ctx) if err != nil || cfg == nil { return cfg, err } if s.secrets == nil { return cfg, nil } - exists, existsErr := s.secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID)) + exists, existsErr := s.secrets.Exists(ctx, SecretKey) if existsErr != nil { - s.log.Warn("linear: secret exists check failed", - zap.String("workspace_id", workspaceID), zap.Error(existsErr)) + s.log.Warn("linear: secret exists check failed", zap.Error(existsErr)) } cfg.HasSecret = exists return cfg, nil @@ -92,7 +90,6 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*Linear return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) } cfg := &LinearConfig{ - WorkspaceID: req.WorkspaceID, AuthMethod: req.AuthMethod, DefaultTeamKey: req.DefaultTeamKey, } @@ -100,34 +97,29 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*Linear return nil, fmt.Errorf("upsert linear config: %w", err) } if req.Secret != "" && s.secrets != nil { - if err := s.secrets.Set(ctx, - SecretKeyForWorkspace(req.WorkspaceID), - "Linear API key ("+req.WorkspaceID+")", - req.Secret, - ); err != nil { + if err := s.secrets.Set(ctx, SecretKey, "Linear API key", req.Secret); err != nil { return nil, fmt.Errorf("store linear secret: %w", err) } } - s.invalidateClient(req.WorkspaceID) + s.invalidateClient() // Probe asynchronously so a slow Linear doesn't stall the save response. - go func(workspaceID string) { - s.RecordAuthHealth(context.Background(), workspaceID) - }(req.WorkspaceID) - return s.GetConfig(ctx, req.WorkspaceID) + go func() { + s.RecordAuthHealth(context.Background()) + }() + return s.GetConfig(ctx) } // DeleteConfig removes both the config row and the stored secret. -func (s *Service) DeleteConfig(ctx context.Context, workspaceID string) error { - if err := s.store.DeleteConfig(ctx, workspaceID); err != nil { +func (s *Service) DeleteConfig(ctx context.Context) error { + if err := s.store.DeleteConfig(ctx); err != nil { return err } if s.secrets != nil { - if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil { - s.log.Warn("linear: secret delete failed", - zap.String("workspace_id", workspaceID), zap.Error(err)) + if err := s.secrets.Delete(ctx, SecretKey); err != nil { + s.log.Warn("linear: secret delete failed", zap.Error(err)) } } - s.invalidateClient(workspaceID) + s.invalidateClient() return nil } @@ -142,9 +134,9 @@ func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*T return client.TestAuth(ctx) } -// ProbeAuth validates the stored credentials for a workspace. -func (s *Service) ProbeAuth(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { - client, err := s.clientFor(ctx, workspaceID) +// ProbeAuth validates the stored credentials. +func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) { + client, err := s.clientFor(ctx) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -165,17 +157,17 @@ const authHealthWriteTimeout = 5 * time.Second // SetProbeHook installs a callback fired at the end of each RecordAuthHealth // call. Production code never sets this; tests use it to synchronise on probe // completion without sleep-polling. -func (s *Service) SetProbeHook(fn func(workspaceID string)) { +func (s *Service) SetProbeHook(fn func()) { s.mu.Lock() defer s.mu.Unlock() s.probeHook = fn } // RecordAuthHealth probes credentials and writes the outcome onto the row. -func (s *Service) RecordAuthHealth(ctx context.Context, workspaceID string) { +func (s *Service) RecordAuthHealth(ctx context.Context) { probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) defer cancel() - res, err := s.ProbeAuth(probeCtx, workspaceID) + res, err := s.ProbeAuth(probeCtx) ok := err == nil && res != nil && res.OK errMsg := "" switch { @@ -192,21 +184,20 @@ func (s *Service) RecordAuthHealth(ctx context.Context, workspaceID string) { // still record the failure. writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) defer writeCancel() - if updateErr := s.store.UpdateAuthHealth(writeCtx, workspaceID, ok, errMsg, orgSlug, time.Now().UTC()); updateErr != nil { - s.log.Warn("linear: update auth health failed", - zap.String("workspace_id", workspaceID), zap.Error(updateErr)) + if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, orgSlug, time.Now().UTC()); updateErr != nil { + s.log.Warn("linear: update auth health failed", zap.Error(updateErr)) } s.mu.Lock() hook := s.probeHook s.mu.Unlock() if hook != nil { - hook(workspaceID) + hook() } } // GetIssue loads a Linear issue by identifier (e.g. "ENG-123"). -func (s *Service) GetIssue(ctx context.Context, workspaceID, identifier string) (*LinearIssue, error) { - client, err := s.clientFor(ctx, workspaceID) +func (s *Service) GetIssue(ctx context.Context, identifier string) (*LinearIssue, error) { + client, err := s.clientFor(ctx) if err != nil { return nil, err } @@ -214,8 +205,8 @@ func (s *Service) GetIssue(ctx context.Context, workspaceID, identifier string) } // SetIssueState moves an issue into the requested workflow state. -func (s *Service) SetIssueState(ctx context.Context, workspaceID, issueID, stateID string) error { - client, err := s.clientFor(ctx, workspaceID) +func (s *Service) SetIssueState(ctx context.Context, issueID, stateID string) error { + client, err := s.clientFor(ctx) if err != nil { return err } @@ -223,8 +214,8 @@ func (s *Service) SetIssueState(ctx context.Context, workspaceID, issueID, state } // ListTeams populates the team selector on the settings page. -func (s *Service) ListTeams(ctx context.Context, workspaceID string) ([]LinearTeam, error) { - client, err := s.clientFor(ctx, workspaceID) +func (s *Service) ListTeams(ctx context.Context) ([]LinearTeam, error) { + client, err := s.clientFor(ctx) if err != nil { return nil, err } @@ -232,35 +223,34 @@ func (s *Service) ListTeams(ctx context.Context, workspaceID string) ([]LinearTe } // ListStates returns the workflow states for a team identified by its key. -// Mirrors the other Service methods so handlers never reach into clientFor -// directly. -func (s *Service) ListStates(ctx context.Context, workspaceID, teamKey string) ([]LinearWorkflowState, error) { - client, err := s.clientFor(ctx, workspaceID) +func (s *Service) ListStates(ctx context.Context, teamKey string) ([]LinearWorkflowState, error) { + client, err := s.clientFor(ctx) if err != nil { return nil, err } return client.ListStates(ctx, teamKey) } -// SearchIssues runs a filtered search for the workspace. -func (s *Service) SearchIssues(ctx context.Context, workspaceID string, filter SearchFilter, pageToken string, maxResults int) (*SearchResult, error) { - client, err := s.clientFor(ctx, workspaceID) +// SearchIssues runs a filtered search. +func (s *Service) SearchIssues(ctx context.Context, filter SearchFilter, pageToken string, maxResults int) (*SearchResult, error) { + client, err := s.clientFor(ctx) if err != nil { return nil, err } return client.SearchIssues(ctx, filter, pageToken, maxResults) } -// clientFor returns a cached client, creating one if needed. -func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) { +// clientFor returns the cached client, creating it if needed. +func (s *Service) clientFor(ctx context.Context) (Client, error) { s.mu.Lock() - if c, ok := s.cache[workspaceID]; ok { + if s.client != nil { + c := s.client s.mu.Unlock() return c, nil } s.mu.Unlock() - cfg, err := s.store.GetConfig(ctx, workspaceID) + cfg, err := s.store.GetConfig(ctx) if err != nil { return nil, err } @@ -269,7 +259,7 @@ func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, er } secret := "" if s.secrets != nil { - secret, err = s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + secret, err = s.secrets.Reveal(ctx, SecretKey) if err != nil { return nil, fmt.Errorf("read linear secret: %w", err) } @@ -280,17 +270,17 @@ func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, er client := s.clientFn(cfg, secret) s.mu.Lock() defer s.mu.Unlock() - if existing, ok := s.cache[workspaceID]; ok { - return existing, nil + if s.client != nil { + return s.client, nil } - s.cache[workspaceID] = client + s.client = client return client, nil } -// invalidateClient drops a cached client so the next request rebuilds it. -func (s *Service) invalidateClient(workspaceID string) { +// invalidateClient drops the cached client so the next request rebuilds it. +func (s *Service) invalidateClient() { s.mu.Lock() - delete(s.cache, workspaceID) + s.client = nil s.mu.Unlock() } @@ -298,8 +288,7 @@ func (s *Service) invalidateClient(workspaceID string) { // carries a secret, otherwise the stored secret. func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*LinearConfig, string, error) { cfg := &LinearConfig{ - WorkspaceID: req.WorkspaceID, - AuthMethod: req.AuthMethod, + AuthMethod: req.AuthMethod, } if req.Secret != "" { return cfg, req.Secret, nil @@ -307,21 +296,19 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) if s.secrets == nil { return nil, "", errors.New("no secret store configured") } - secret, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(req.WorkspaceID)) + secret, err := s.secrets.Reveal(ctx, SecretKey) if err != nil { - s.log.Warn("linear: secret reveal failed", - zap.String("workspace_id", req.WorkspaceID), zap.Error(err)) + s.log.Warn("linear: secret reveal failed", zap.Error(err)) return nil, "", fmt.Errorf("read linear secret: %w", err) } if secret == "" { return nil, "", errors.New("no api key stored — paste one to test") } - stored, storeErr := s.store.GetConfig(ctx, req.WorkspaceID) + stored, storeErr := s.store.GetConfig(ctx) if storeErr != nil { // Soft-fail: a transient DB error here only loses the saved-config // fallback values; the inline credentials still work for the test. - s.log.Warn("linear: load stored config for credential resolution failed", - zap.String("workspace_id", req.WorkspaceID), zap.Error(storeErr)) + s.log.Warn("linear: load stored config for credential resolution failed", zap.Error(storeErr)) } if stored != nil && cfg.AuthMethod == "" { cfg.AuthMethod = stored.AuthMethod @@ -330,9 +317,6 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) } func validateConfigRequest(req *SetConfigRequest) error { - if req.WorkspaceID == "" { - return errors.New("workspaceId required") - } if req.AuthMethod == "" { req.AuthMethod = AuthMethodAPIKey } diff --git a/apps/backend/internal/linear/service_test.go b/apps/backend/internal/linear/service_test.go index 426d53701b..387b5c2d57 100644 --- a/apps/backend/internal/linear/service_test.go +++ b/apps/backend/internal/linear/service_test.go @@ -112,7 +112,7 @@ type svcFixture struct { secrets *fakeSecretStore client *fakeClient factoryHit atomic.Int32 - probed chan string + probed chan struct{} } func newSvcFixture(t *testing.T) *svcFixture { @@ -121,15 +121,15 @@ func newSvcFixture(t *testing.T) *svcFixture { store: newTestStore(t), secrets: newFakeSecretStore(), client: &fakeClient{}, - probed: make(chan string, 8), + probed: make(chan struct{}, 8), } f.svc = NewService(f.store, f.secrets, func(_ *LinearConfig, _ string) Client { f.factoryHit.Add(1) return f.client }, logger.Default()) - f.svc.SetProbeHook(func(workspaceID string) { + f.svc.SetProbeHook(func() { select { - case f.probed <- workspaceID: + case f.probed <- struct{}{}: default: } }) @@ -137,23 +137,18 @@ func newSvcFixture(t *testing.T) *svcFixture { } // waitForAuthProbe blocks until the async probe has fired. -func waitForAuthProbe(t *testing.T, f *svcFixture, workspaceID string) *LinearConfig { +func waitForAuthProbe(t *testing.T, f *svcFixture) *LinearConfig { 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 + select { + case <-f.probed: + cfg, err := f.svc.GetConfig(context.Background()) + 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 within 2s") + return nil } } @@ -162,7 +157,6 @@ func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.T) { ctx := context.Background() cfg, err := f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, DefaultTeamKey: "ENG", Secret: "lin_api_xyz", @@ -176,7 +170,7 @@ func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.T) { if !cfg.HasSecret { t.Error("expected HasSecret=true") } - if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "lin_api_xyz" { + if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "lin_api_xyz" { t.Errorf("secret stored = %q", got) } } @@ -187,11 +181,11 @@ func TestService_SetConfig_ProbesAuthImmediately(t *testing.T) { return &TestConnectionResult{OK: true, DisplayName: "Alice", OrgSlug: "acme"}, nil } if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "tok", + AuthMethod: AuthMethodAPIKey, Secret: "tok", }); err != nil { t.Fatalf("set: %v", err) } - cfg := waitForAuthProbe(t, f, "ws-1") + cfg := waitForAuthProbe(t, f) if !cfg.LastOk { t.Errorf("expected LastOk=true after async probe, got %+v", cfg) } @@ -206,11 +200,11 @@ func TestService_SetConfig_PersistsProbeFailure(t *testing.T) { return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil } if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "bad", + AuthMethod: AuthMethodAPIKey, Secret: "bad", }); err != nil { t.Fatalf("set: %v", err) } - cfg := waitForAuthProbe(t, f, "ws-1") + cfg := waitForAuthProbe(t, f) if cfg.LastOk { t.Error("expected LastOk=false after failed probe") } @@ -223,16 +217,16 @@ func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "first", + AuthMethod: AuthMethodAPIKey, Secret: "first", }); err != nil { t.Fatalf("initial: %v", err) } if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, DefaultTeamKey: "MOB", + AuthMethod: AuthMethodAPIKey, DefaultTeamKey: "MOB", }); err != nil { t.Fatalf("update: %v", err) } - if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "first" { + if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "first" { t.Errorf("secret should be preserved, got %q", got) } } @@ -241,24 +235,24 @@ func TestService_SetConfig_InvalidatesClientCache(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "t", + AuthMethod: AuthMethodAPIKey, Secret: "t", }) - waitForAuthProbe(t, f, "ws-1") - if _, err := f.svc.GetIssue(ctx, "ws-1", "A-1"); err != nil { + waitForAuthProbe(t, f) + if _, err := f.svc.GetIssue(ctx, "A-1"); err != nil { t.Fatalf("get1: %v", err) } hits := f.factoryHit.Load() - if _, err := f.svc.GetIssue(ctx, "ws-1", "A-2"); err != nil { + if _, err := f.svc.GetIssue(ctx, "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) } _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "t2", + AuthMethod: AuthMethodAPIKey, Secret: "t2", }) - waitForAuthProbe(t, f, "ws-1") - if _, err := f.svc.GetIssue(ctx, "ws-1", "A-3"); err != nil { + waitForAuthProbe(t, f) + if _, err := f.svc.GetIssue(ctx, "A-3"); err != nil { t.Fatalf("get3: %v", err) } if got := f.factoryHit.Load(); got <= hits { @@ -266,22 +260,12 @@ func TestService_SetConfig_InvalidatesClientCache(t *testing.T) { } } -func TestService_Validation(t *testing.T) { +func TestService_Validation_RejectsBadAuth(t *testing.T) { f := newSvcFixture(t) - ctx := context.Background() - cases := []struct { - name string - req SetConfigRequest - }{ - {"missing ws", SetConfigRequest{AuthMethod: AuthMethodAPIKey}}, - {"bad auth", SetConfigRequest{WorkspaceID: "w", 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") - } - }) + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + AuthMethod: "bogus", + }); err == nil { + t.Error("expected validation error for unknown auth method") } } @@ -289,7 +273,7 @@ func TestService_Validation_DefaultsAuthMethod(t *testing.T) { f := newSvcFixture(t) // Empty AuthMethod is filled with AuthMethodAPIKey rather than rejected. if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ - WorkspaceID: "ws-1", Secret: "tok", + Secret: "tok", }); err != nil { t.Fatalf("expected default auth method, got error: %v", err) } @@ -303,7 +287,7 @@ func TestService_TestConnection_InlineSecret(t *testing.T) { return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil } res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "inline", + AuthMethod: AuthMethodAPIKey, Secret: "inline", }) if err != nil || !called { t.Fatalf("called=%v err=%v", called, err) @@ -316,7 +300,7 @@ func TestService_TestConnection_InlineSecret(t *testing.T) { func TestService_TestConnection_NoStoredSecret_ReturnsFailure(t *testing.T) { f := newSvcFixture(t) res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ - WorkspaceID: "ws-nope", AuthMethod: AuthMethodAPIKey, + AuthMethod: AuthMethodAPIKey, }) if err != nil { t.Fatalf("unexpected err: %v", err) @@ -328,7 +312,7 @@ func TestService_TestConnection_NoStoredSecret_ReturnsFailure(t *testing.T) { func TestService_GetIssue_NotConfigured(t *testing.T) { f := newSvcFixture(t) - _, err := f.svc.GetIssue(context.Background(), "missing", "ENG-1") + _, err := f.svc.GetIssue(context.Background(), "ENG-1") if !errors.Is(err, ErrNotConfigured) { t.Errorf("expected ErrNotConfigured, got %v", err) } @@ -338,16 +322,16 @@ func TestService_DeleteConfig_RemovesSecretAndCache(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "t", + AuthMethod: AuthMethodAPIKey, Secret: "t", }) - waitForAuthProbe(t, f, "ws-1") - if err := f.svc.DeleteConfig(ctx, "ws-1"); err != nil { + waitForAuthProbe(t, f) + if err := f.svc.DeleteConfig(ctx); err != nil { t.Fatalf("delete: %v", err) } - if exists, _ := f.secrets.Exists(ctx, SecretKeyForWorkspace("ws-1")); exists { + if exists, _ := f.secrets.Exists(ctx, SecretKey); exists { t.Error("expected secret removed") } - cfg, _ := f.svc.GetConfig(ctx, "ws-1") + cfg, _ := f.svc.GetConfig(ctx) if cfg != nil { t.Errorf("expected config gone, got %+v", cfg) } @@ -357,10 +341,10 @@ func TestService_SetIssueState_Forwards(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "t", + AuthMethod: AuthMethodAPIKey, Secret: "t", }) - waitForAuthProbe(t, f, "ws-1") - if err := f.svc.SetIssueState(ctx, "ws-1", "ENG-1", "state-id"); err != nil { + waitForAuthProbe(t, f) + if err := f.svc.SetIssueState(ctx, "ENG-1", "state-id"); err != nil { t.Fatalf("set state: %v", err) } if len(f.client.transitionLog) != 1 || f.client.transitionLog[0] != "ENG-1:state-id" { diff --git a/apps/backend/internal/linear/store.go b/apps/backend/internal/linear/store.go index cd1951f0e6..3ace6e0ed4 100644 --- a/apps/backend/internal/linear/store.go +++ b/apps/backend/internal/linear/store.go @@ -10,11 +10,17 @@ import ( "github.com/jmoiron/sqlx" ) -// Store persists Linear workspace configurations. Secret values are delegated -// to the shared encrypted secret store and not stored here. +// Store persists the install-wide Linear configuration. The secret API key is +// delegated to the shared encrypted secret store and not stored here. type Store struct { db *sqlx.DB ro *sqlx.DB + + // migratedFromWorkspace records the workspace_id of the row that was + // promoted into the singleton during initSchema. Provider reads this to + // migrate the per-workspace secret to the new global key. Empty when no + // migration ran. + migratedFromWorkspace string } // NewStore creates a new Store and initializes the schema if needed. @@ -26,9 +32,16 @@ func NewStore(writer, reader *sqlx.DB) (*Store, error) { return s, nil } +// MigratedFromWorkspace returns the workspace_id of the row promoted to the +// singleton during the per-workspace → singleton schema migration, or "" when +// no migration ran. +func (s *Store) MigratedFromWorkspace() string { + return s.migratedFromWorkspace +} + const createTablesSQL = ` CREATE TABLE IF NOT EXISTS linear_configs ( - workspace_id TEXT PRIMARY KEY, + id TEXT PRIMARY KEY CHECK(id = 'singleton'), auth_method TEXT NOT NULL, default_team_key TEXT NOT NULL DEFAULT '', org_slug TEXT NOT NULL DEFAULT '', @@ -40,42 +53,125 @@ const createTablesSQL = ` ); ` -// addedColumns lists columns introduced after the initial schema. Kept for -// parity with the Jira store so future column additions follow the same -// migration pattern; empty for now since the table ships fully-formed. -var addedColumns = []struct { - name string - sql string -}{} +// singletonID is the synthetic primary key of the (only) row in linear_configs. +const singletonID = "singleton" func (s *Store) initSchema() error { + if err := s.migrateLegacyPerWorkspaceTable(); err != nil { + return err + } if _, err := s.db.Exec(createTablesSQL); err != nil { return err } - return s.migrateAddedColumns() + return nil } -// migrateAddedColumns applies ALTER TABLE statements for columns introduced -// after the initial schema. Existing databases need these columns backfilled; -// new databases already have them from createTablesSQL and the ALTERs are -// skipped. -func (s *Store) migrateAddedColumns() error { - if len(addedColumns) == 0 { - return nil - } - existing, err := s.tableColumns("linear_configs") +// migrateLegacyPerWorkspaceTable detects the pre-singleton schema (where +// linear_configs was keyed by workspace_id) and rewrites it into the singleton +// shape. Picks the most-recently-updated row and records the source +// workspace_id so the provider can migrate the secret. +func (s *Store) migrateLegacyPerWorkspaceTable() error { + cols, err := s.tableColumns("linear_configs") if err != nil { return err } - for _, col := range addedColumns { - if _, ok := existing[col.name]; ok { - continue + if len(cols) == 0 { + return nil + } + if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // `org_slug`, `last_checked_at`, `last_ok`, and `last_error` were added to + // the legacy schema in later releases. A deployment that upgrades from the + // original schema would have a `workspace_id` column but not these — + // selecting them unconditionally would crash startup. Build the SELECT + // against only columns present in this database. Mirrors the Jira fix. + healthCols := healthColumnsPresent(cols) + _, hasOrgSlug := cols["org_slug"] + selectCols := "workspace_id, auth_method, default_team_key" + if hasOrgSlug { + selectCols += ", org_slug" + } else { + selectCols += ", '' AS org_slug" + } + if healthCols { + selectCols += ", last_checked_at, last_ok, last_error" + } else { + selectCols += ", NULL AS last_checked_at, 0 AS last_ok, '' AS last_error" + } + selectCols += ", created_at, updated_at" + var sourceWorkspace, authMethod, defaultTeamKey, orgSlug, lastError sql.NullString + var lastCheckedAt sql.NullTime + var lastOk sql.NullInt64 + var createdAt, updatedAt sql.NullTime + row := tx.QueryRow(`SELECT ` + selectCols + ` FROM linear_configs ORDER BY updated_at DESC LIMIT 1`) + switch err := row.Scan(&sourceWorkspace, &authMethod, &defaultTeamKey, &orgSlug, + &lastCheckedAt, &lastOk, &lastError, &createdAt, &updatedAt); { + case errors.Is(err, sql.ErrNoRows): + if _, err := tx.Exec(`DROP TABLE linear_configs`); err != nil { + return err } - if _, err := s.db.Exec(col.sql); err != nil { - return fmt.Errorf("add column %s: %w", col.name, err) + return tx.Commit() + case err != nil: + return err + } + if _, err := tx.Exec(`DROP TABLE linear_configs`); err != nil { + return err + } + if _, err := tx.Exec(` + CREATE TABLE linear_configs ( + id TEXT PRIMARY KEY CHECK(id = 'singleton'), + auth_method TEXT NOT NULL, + default_team_key TEXT NOT NULL DEFAULT '', + org_slug 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 + )`); err != nil { + return err + } + if _, err := tx.Exec(` + INSERT INTO linear_configs (id, auth_method, default_team_key, org_slug, + last_checked_at, last_ok, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + singletonID, authMethod.String, defaultTeamKey.String, orgSlug.String, + nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, + nullableTime(createdAt), nullableTime(updatedAt)); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + s.migratedFromWorkspace = sourceWorkspace.String + return nil +} + +// healthColumnsPresent reports whether the legacy linear_configs table has the +// auth-health columns that were added in a later release. When all three are +// missing we fall back to NULL/zero defaults rather than crashing on the +// SELECT. Mirrors the helper of the same name in jira/store.go. +func healthColumnsPresent(cols map[string]struct{}) bool { + for _, name := range []string{"last_checked_at", "last_ok", "last_error"} { + if _, ok := cols[name]; !ok { + return false } } - return nil + return true +} + +func nullableTime(t sql.NullTime) interface{} { + if !t.Valid { + return nil + } + return t.Time } func (s *Store) tableColumns(table string) (map[string]struct{}, error) { @@ -102,15 +198,14 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { return cols, rows.Err() } -const selectConfigColumns = `workspace_id, auth_method, default_team_key, org_slug, +const selectConfigColumns = `auth_method, default_team_key, org_slug, last_checked_at, last_ok, last_error, created_at, updated_at` -// GetConfig returns the Linear config for a workspace, or nil when no row -// exists. -func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*LinearConfig, error) { +// GetConfig returns the singleton Linear config, or nil when no row exists. +func (s *Store) GetConfig(ctx context.Context) (*LinearConfig, error) { var cfg LinearConfig err := s.ro.GetContext(ctx, &cfg, - `SELECT `+selectConfigColumns+` FROM linear_configs WHERE workspace_id = ?`, workspaceID) + `SELECT `+selectConfigColumns+` FROM linear_configs WHERE id = ?`, singletonID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -120,10 +215,9 @@ func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*LinearConfi 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 and org_slug are deliberately not touched here; the -// poller owns those and writes them via UpdateAuthHealth. +// UpsertConfig inserts or updates the singleton config row. The last_* health +// columns and org_slug are deliberately not touched here; the poller owns +// those and writes them via UpdateAuthHealth. func (s *Store) UpsertConfig(ctx context.Context, cfg *LinearConfig) error { now := time.Now().UTC() if cfg.CreatedAt.IsZero() { @@ -131,52 +225,50 @@ func (s *Store) UpsertConfig(ctx context.Context, cfg *LinearConfig) error { } cfg.UpdatedAt = now _, err := s.db.ExecContext(ctx, ` - INSERT INTO linear_configs (workspace_id, auth_method, default_team_key, created_at, updated_at) + INSERT INTO linear_configs (id, auth_method, default_team_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(workspace_id) DO UPDATE SET + ON CONFLICT(id) DO UPDATE SET auth_method = excluded.auth_method, default_team_key = excluded.default_team_key, updated_at = excluded.updated_at`, - cfg.WorkspaceID, cfg.AuthMethod, cfg.DefaultTeamKey, cfg.CreatedAt, cfg.UpdatedAt) + singletonID, cfg.AuthMethod, cfg.DefaultTeamKey, cfg.CreatedAt, cfg.UpdatedAt) return err } -// DeleteConfig removes the Linear 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 linear_configs WHERE workspace_id = ?`, workspaceID) +// DeleteConfig removes the singleton config row. +func (s *Store) DeleteConfig(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM linear_configs WHERE id = ?`, singletonID) return err } -// ListConfiguredWorkspaces returns the IDs of all workspaces that have a -// Linear config row. Used by the auth-health poller. -func (s *Store) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) { - var ids []string - err := s.ro.SelectContext(ctx, &ids, - `SELECT workspace_id FROM linear_configs ORDER BY workspace_id`) +// HasConfig reports whether the singleton row exists. Used by the auth-health +// poller to decide whether to probe at all. +func (s *Store) HasConfig(ctx context.Context) (bool, error) { + var present int + err := s.ro.GetContext(ctx, &present, + `SELECT COUNT(*) FROM linear_configs WHERE id = ?`, singletonID) if err != nil { - return nil, err + return false, err } - return ids, nil + return present > 0, nil } // UpdateAuthHealth records the result of a credential probe. orgSlug is // captured opportunistically from successful probes; pass "" to leave the -// existing slug unchanged. If the workspace row no longer exists, the update -// is a silent no-op. -func (s *Store) UpdateAuthHealth(ctx context.Context, workspaceID string, ok bool, errMsg, orgSlug string, checkedAt time.Time) error { +// existing slug unchanged. +func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg, orgSlug string, checkedAt time.Time) error { if orgSlug != "" { _, err := s.db.ExecContext(ctx, ` UPDATE linear_configs SET last_checked_at = ?, last_ok = ?, last_error = ?, org_slug = ? - WHERE workspace_id = ?`, - checkedAt, ok, errMsg, orgSlug, workspaceID) + WHERE id = ?`, + checkedAt, ok, errMsg, orgSlug, singletonID) return err } _, err := s.db.ExecContext(ctx, ` UPDATE linear_configs SET last_checked_at = ?, last_ok = ?, last_error = ? - WHERE workspace_id = ?`, - checkedAt, ok, errMsg, workspaceID) + WHERE id = ?`, + checkedAt, ok, errMsg, singletonID) return err } diff --git a/apps/backend/internal/linear/store_test.go b/apps/backend/internal/linear/store_test.go index 35d33d5ac8..9929555a62 100644 --- a/apps/backend/internal/linear/store_test.go +++ b/apps/backend/internal/linear/store_test.go @@ -32,7 +32,6 @@ func TestStore_UpsertGetDelete(t *testing.T) { ctx := context.Background() cfg := &LinearConfig{ - WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, DefaultTeamKey: "ENG", } @@ -40,7 +39,7 @@ func TestStore_UpsertGetDelete(t *testing.T) { t.Fatalf("upsert: %v", err) } - got, err := store.GetConfig(ctx, "ws-1") + got, err := store.GetConfig(ctx) if err != nil { t.Fatalf("get: %v", err) } @@ -58,15 +57,15 @@ func TestStore_UpsertGetDelete(t *testing.T) { if err := store.UpsertConfig(ctx, cfg); err != nil { t.Fatalf("update upsert: %v", err) } - got2, _ := store.GetConfig(ctx, "ws-1") + got2, _ := store.GetConfig(ctx) if got2.DefaultTeamKey != "MOB" { t.Errorf("expected team update, got %q", got2.DefaultTeamKey) } - if err := store.DeleteConfig(ctx, "ws-1"); err != nil { + if err := store.DeleteConfig(ctx); err != nil { t.Fatalf("delete: %v", err) } - gone, err := store.GetConfig(ctx, "ws-1") + gone, err := store.GetConfig(ctx) if err != nil { t.Fatalf("get after delete: %v", err) } @@ -77,7 +76,7 @@ func TestStore_UpsertGetDelete(t *testing.T) { func TestStore_GetConfig_Missing(t *testing.T) { store := newTestStore(t) - cfg, err := store.GetConfig(context.Background(), "does-not-exist") + cfg, err := store.GetConfig(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -86,25 +85,27 @@ func TestStore_GetConfig_Missing(t *testing.T) { } } -func TestStore_ListConfiguredWorkspaces(t *testing.T) { +func TestStore_HasConfig(t *testing.T) { store := newTestStore(t) ctx := context.Background() - mustUpsert := func(id string) { - if err := store.UpsertConfig(ctx, &LinearConfig{ - WorkspaceID: id, - AuthMethod: AuthMethodAPIKey, - }); err != nil { - t.Fatalf("upsert %s: %v", id, err) - } - } - mustUpsert("ws-b") - mustUpsert("ws-a") - ids, err := store.ListConfiguredWorkspaces(ctx) + has, err := store.HasConfig(ctx) if err != nil { - t.Fatalf("list: %v", err) + t.Fatalf("has-config: %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) + if has { + t.Errorf("expected HasConfig=false on empty store") + } + if err := store.UpsertConfig(ctx, &LinearConfig{ + AuthMethod: AuthMethodAPIKey, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + has, err = store.HasConfig(ctx) + if err != nil { + t.Fatalf("has-config after upsert: %v", err) + } + if !has { + t.Errorf("expected HasConfig=true after upsert") } } @@ -112,22 +113,21 @@ func TestStore_UpdateAuthHealth(t *testing.T) { store := newTestStore(t) ctx := context.Background() if err := store.UpsertConfig(ctx, &LinearConfig{ - WorkspaceID: "ws-1", - AuthMethod: AuthMethodAPIKey, + AuthMethod: AuthMethodAPIKey, }); err != nil { t.Fatalf("upsert: %v", err) } - cfg, _ := store.GetConfig(ctx, "ws-1") + cfg, _ := store.GetConfig(ctx) if cfg.LastCheckedAt != nil { t.Errorf("expected nil last_checked_at on fresh row, got %v", cfg.LastCheckedAt) } now := time.Now().UTC().Truncate(time.Second) - if err := store.UpdateAuthHealth(ctx, "ws-1", true, "", "acme", now); err != nil { + if err := store.UpdateAuthHealth(ctx, true, "", "acme", now); err != nil { t.Fatalf("update ok: %v", err) } - cfg, _ = store.GetConfig(ctx, "ws-1") + cfg, _ = store.GetConfig(ctx) if !cfg.LastOk { t.Error("expected last_ok=true after successful probe") } @@ -140,10 +140,10 @@ func TestStore_UpdateAuthHealth(t *testing.T) { // Empty orgSlug should leave the existing slug intact. failAt := now.Add(time.Minute) - if err := store.UpdateAuthHealth(ctx, "ws-1", false, "401 unauthorized", "", failAt); err != nil { + if err := store.UpdateAuthHealth(ctx, false, "401 unauthorized", "", failAt); err != nil { t.Fatalf("update fail: %v", err) } - cfg, _ = store.GetConfig(ctx, "ws-1") + cfg, _ = store.GetConfig(ctx) if cfg.LastOk { t.Error("expected last_ok=false after failure") } @@ -153,9 +153,167 @@ func TestStore_UpdateAuthHealth(t *testing.T) { if cfg.OrgSlug != "acme" { t.Errorf("orgSlug should be preserved across failed probe, got %q", cfg.OrgSlug) } +} - // Update for missing workspace must not error. - if err := store.UpdateAuthHealth(ctx, "missing", true, "", "x", now); err != nil { - t.Errorf("expected no-op for missing row, got %v", err) +func TestStore_MigrateLegacyPerWorkspaceTable(t *testing.T) { + 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() }) + + if _, err := db.Exec(` + CREATE TABLE linear_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + default_team_key TEXT NOT NULL DEFAULT '', + org_slug 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 + )`); err != nil { + t.Fatalf("legacy schema: %v", err) + } + older := time.Now().UTC().Add(-time.Hour) + newer := time.Now().UTC() + if _, err := db.Exec(`INSERT INTO linear_configs + (workspace_id, auth_method, default_team_key, org_slug, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + "ws-old", AuthMethodAPIKey, "OLD", "old-slug", older, older); err != nil { + t.Fatalf("seed old row: %v", err) + } + if _, err := db.Exec(`INSERT INTO linear_configs + (workspace_id, auth_method, default_team_key, org_slug, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + "ws-new", AuthMethodAPIKey, "NEW", "new-slug", newer, newer); err != nil { + t.Fatalf("seed new row: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-new" { + t.Errorf("expected migration source ws-new, got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get singleton: %v", err) + } + if cfg == nil { + t.Fatal("expected singleton row after migration") + } + if cfg.DefaultTeamKey != "NEW" || cfg.OrgSlug != "new-slug" { + t.Errorf("expected newest row promoted, got %+v", cfg) + } +} + +// Covers the original-schema upgrade path: the legacy table exists with +// `workspace_id` but lacks the auth-health columns and `org_slug` added in +// later releases. The migration must select only guaranteed-present columns +// and fall back to defaults for the missing ones, otherwise startup crashes +// with "no such column". Mirrors the Jira test. +func TestStore_MigrateLegacyPerWorkspaceTable_PreHealthColumns(t *testing.T) { + 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() }) + + // Original schema: no org_slug / last_checked_at / last_ok / last_error. + if _, err := db.Exec(` + CREATE TABLE linear_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + default_team_key TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`); err != nil { + t.Fatalf("legacy schema: %v", err) + } + now := time.Now().UTC() + if _, err := db.Exec(`INSERT INTO linear_configs + (workspace_id, auth_method, default_team_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + "ws-1", AuthMethodAPIKey, "ENG", now, now); err != nil { + t.Fatalf("seed legacy row: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("NewStore on pre-health-columns schema: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-1" { + t.Errorf("expected migration source ws-1, got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get singleton: %v", err) + } + if cfg == nil { + t.Fatal("expected singleton row after migration") + } + if cfg.DefaultTeamKey != "ENG" { + t.Errorf("expected DefaultTeamKey preserved, got %q", cfg.DefaultTeamKey) + } + if cfg.OrgSlug != "" { + t.Errorf("expected OrgSlug='' (default) after migrating from pre-org-slug schema, got %q", cfg.OrgSlug) + } + if cfg.LastOk { + t.Error("expected LastOk=false (default) after migrating from pre-health schema") + } + if cfg.LastCheckedAt != nil { + t.Errorf("expected LastCheckedAt=nil (default), got %v", cfg.LastCheckedAt) + } +} + +// Covers the sql.ErrNoRows branch: a user who installed a previous version, +// never configured Linear, and then upgrades hits this path. +func TestStore_MigrateLegacyPerWorkspaceTable_EmptyTable(t *testing.T) { + 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() }) + + if _, err := db.Exec(` + CREATE TABLE linear_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + default_team_key TEXT NOT NULL DEFAULT '', + org_slug 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 + )`); err != nil { + t.Fatalf("legacy schema: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("NewStore on empty legacy table: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "" { + t.Errorf("expected empty MigratedFromWorkspace, got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get singleton: %v", err) + } + if cfg != nil { + t.Errorf("expected nil cfg on empty legacy table, got %+v", cfg) } } diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx index 03088178da..78d370d935 100644 --- a/apps/web/app/jira/jira-page-client.tsx +++ b/apps/web/app/jira/jira-page-client.tsx @@ -35,16 +35,13 @@ type JiraPageClientProps = { steps: WorkflowStep[]; }; -function NotConfiguredNotice({ workspaceId }: { workspaceId?: string }) { +function NotConfiguredNotice() { return (
- Jira is not configured for this workspace.{" "} - + Jira is not configured.{" "} + Configure Jira {" "} to see your tickets here. @@ -54,10 +51,10 @@ function NotConfiguredNotice({ workspaceId }: { workspaceId?: string }) { ); } -async function loadUserProjects(workspaceId: string): Promise { +async function loadUserProjects(): Promise { const [{ projects: all }, search] = await Promise.all([ - listJiraProjects(workspaceId), - searchJiraTickets(workspaceId, { + listJiraProjects(), + searchJiraTickets({ jql: "(assignee = currentUser() OR reporter = currentUser()) ORDER BY updated DESC", maxResults: 100, }), @@ -79,13 +76,13 @@ function useJiraPageData(workspaceId?: string) { return; } try { - const cfg = await getJiraConfig(workspaceId); + const cfg = await getJiraConfig(); if (cancelled) return; const ok = !!cfg && cfg.hasSecret; setConfigured(ok); if (ok) { try { - const list = await loadUserProjects(workspaceId); + const list = await loadUserProjects(); if (!cancelled) setProjects(list); } catch { // Non-fatal: pill will just show empty list. Users can still filter by other dims. @@ -108,7 +105,6 @@ function TicketResults({ items, loading, error, - workspaceId, presets, onStartTask, onOpen, @@ -116,7 +112,6 @@ function TicketResults({ items: JiraTicket[]; loading: boolean; error: string | null; - workspaceId: string | null | undefined; presets: JiraTaskPreset[]; onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void; onOpen: (ticket: JiraTicket) => void; @@ -124,7 +119,7 @@ function TicketResults({ if (error) { return (
- +
); } @@ -204,7 +199,6 @@ function AuthenticatedView({ items={search.items} loading={search.loading} error={search.error} - workspaceId={workspaceId} presets={presets} onStartTask={onStartTask} onOpen={onOpenTicket} @@ -301,7 +295,7 @@ export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClient icon={} /> {!loaded &&
Checking Jira status…
} - {loaded && !configured && } + {loaded && !configured && } {loaded && configured && ( - Linear is not configured for this workspace.{" "} + Linear is not configured.{" "} Configure Linear @@ -69,13 +69,13 @@ function useLinearPageData(workspaceId?: string) { return; } try { - const cfg = await getLinearConfig(workspaceId); + const cfg = await getLinearConfig(); if (cancelled) return; const ok = !!cfg && cfg.hasSecret; setConfigured(ok); if (ok) { try { - const list = await listLinearTeams(workspaceId); + const list = await listLinearTeams(); if (!cancelled) setTeams(list.teams ?? []); } catch { // Non-fatal: team filter just stays empty. @@ -125,14 +125,14 @@ function useIssueSearch( const fetchPage = useCallback( (p: number) => - searchLinearIssues(workspaceId!, { + searchLinearIssues({ query: query || undefined, teamKey: teamKey || undefined, assigned: assigned || undefined, pageToken: tokensRef.current[p - 1] || undefined, maxResults: PAGE_SIZE, }), - [workspaceId, query, teamKey, assigned], + [query, teamKey, assigned], ); const run = useCallback( @@ -386,14 +386,14 @@ function PageShell({ children }: { children: React.ReactNode }) { ); } -function DisabledNotice({ workspaceId }: { workspaceId: string }) { +function DisabledNotice() { return (
- Linear integration is disabled for this workspace.{" "} + Linear integration is disabled.{" "} Re-enable it in settings @@ -408,13 +408,11 @@ function DisabledNotice({ workspaceId }: { workspaceId: string }) { function ResultsArea({ search, empty, - workspaceId, onOpen, onStartTask, }: { search: SearchState; empty: boolean; - workspaceId: string; onOpen: (issue: LinearIssue) => void; onStartTask: (issue: LinearIssue) => void; }) { @@ -422,7 +420,7 @@ function ResultsArea({
{search.error && !search.loading && (
- +
)} {!search.error && empty && ( @@ -441,7 +439,7 @@ function ResultsArea({ } export function LinearPageClient({ workspaceId, workflows, steps }: LinearPageClientProps) { - const available = useLinearAvailable(workspaceId); + const available = useLinearAvailable(); const { loaded, configured, teams } = useLinearPageData(workspaceId); const [query, setQuery] = useState(""); @@ -467,14 +465,14 @@ export function LinearPageClient({ workspaceId, workflows, steps }: LinearPageCl if (loaded && !configured) { return ( - + ); } if (!available && loaded && configured) { return ( - + ); } @@ -493,7 +491,6 @@ export function LinearPageClient({ workspaceId, workflows, steps }: LinearPageCl diff --git a/apps/web/app/settings/general/github/page.tsx b/apps/web/app/settings/general/github/page.tsx deleted file mode 100644 index 4a7eb0674b..0000000000 --- a/apps/web/app/settings/general/github/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { redirect } from "next/navigation"; -import { listWorkspaces } from "@/lib/api"; - -export default async function GeneralGitHubPage() { - const workspaces = await listWorkspaces({ cache: "no-store" }).catch(() => ({ - workspaces: [], - })); - - const firstWorkspace = workspaces.workspaces[0]; - if (firstWorkspace) { - redirect(`/settings/workspace/${firstWorkspace.id}/github`); - } - - redirect("/settings/workspace"); -} diff --git a/apps/web/app/settings/integrations/github/page.tsx b/apps/web/app/settings/integrations/github/page.tsx new file mode 100644 index 0000000000..01af5b9fb0 --- /dev/null +++ b/apps/web/app/settings/integrations/github/page.tsx @@ -0,0 +1,22 @@ +import { GitHubIntegrationPage } from "@/components/github/github-settings"; +import { StateHydrator } from "@/components/state-hydrator"; +import { fetchGitHubStatus } from "@/lib/api/domains/github-api"; + +export default async function IntegrationsGitHubPage() { + const status = await fetchGitHubStatus({ cache: "no-store" }).catch(() => null); + const initialState = status + ? { + githubStatus: { + status, + loaded: true, + loading: false, + }, + } + : {}; + return ( + <> + + + + ); +} diff --git a/apps/web/app/settings/integrations/jira/page.tsx b/apps/web/app/settings/integrations/jira/page.tsx new file mode 100644 index 0000000000..b1b4e7a40f --- /dev/null +++ b/apps/web/app/settings/integrations/jira/page.tsx @@ -0,0 +1,5 @@ +import { JiraIntegrationPage } from "@/components/jira/jira-settings"; + +export default function IntegrationsJiraPage() { + return ; +} diff --git a/apps/web/app/settings/integrations/linear/page.tsx b/apps/web/app/settings/integrations/linear/page.tsx new file mode 100644 index 0000000000..41ff686e62 --- /dev/null +++ b/apps/web/app/settings/integrations/linear/page.tsx @@ -0,0 +1,5 @@ +import { LinearIntegrationPage } from "@/components/linear/linear-settings"; + +export default function IntegrationsLinearPage() { + return ; +} diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx new file mode 100644 index 0000000000..7b705ae57d --- /dev/null +++ b/apps/web/app/settings/integrations/page.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; +import { IconBrandGithub, IconHexagon, IconTicket } from "@tabler/icons-react"; +import { Card, CardContent } from "@kandev/ui/card"; + +const INTEGRATIONS = [ + { + href: "/settings/integrations/github", + label: "GitHub", + description: "PR review queues, issue watchers, and OAuth credentials.", + Icon: IconBrandGithub, + }, + { + href: "/settings/integrations/jira", + label: "Jira", + description: "Atlassian Cloud credentials and JQL issue watchers.", + Icon: IconTicket, + }, + { + href: "/settings/integrations/linear", + label: "Linear", + description: "Personal API key and team defaults.", + Icon: IconHexagon, + }, +]; + +export default function IntegrationsIndexPage() { + return ( +
+
+

Integrations

+

+ Connect Kandev to third-party services. Credentials are install-wide; per-workspace + watchers and presets live inside each integration page. +

+
+
+ {INTEGRATIONS.map(({ href, label, description, Icon }) => ( + + + +
+ + {label} +
+

{description}

+
+
+ + ))} +
+
+ ); +} diff --git a/apps/web/app/settings/workspace/[id]/github/page.tsx b/apps/web/app/settings/workspace/[id]/github/page.tsx deleted file mode 100644 index d9d20142f7..0000000000 --- a/apps/web/app/settings/workspace/[id]/github/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { GitHubSettings } from "@/components/github/github-settings"; -import { StateProvider } from "@/components/state-provider"; -import { fetchGitHubStatus, listReviewWatches } from "@/lib/api/domains/github-api"; - -export default async function WorkspaceGitHubPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - let initialState = {}; - - try { - const [status, watches] = await Promise.all([ - fetchGitHubStatus({ cache: "no-store" }).catch(() => null), - listReviewWatches(id, { cache: "no-store" }).catch(() => ({ watches: [] as never[] })), - ]); - - initialState = { - githubStatus: { - status: status ?? null, - loaded: true, - loading: false, - }, - reviewWatches: { - items: watches.watches ?? [], - loaded: true, - loading: false, - }, - }; - } catch { - initialState = {}; - } - - return ( - - - - ); -} diff --git a/apps/web/app/settings/workspace/[id]/jira/page.tsx b/apps/web/app/settings/workspace/[id]/jira/page.tsx deleted file mode 100644 index cda0d66fc8..0000000000 --- a/apps/web/app/settings/workspace/[id]/jira/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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/app/settings/workspace/[id]/linear/page.tsx b/apps/web/app/settings/workspace/[id]/linear/page.tsx deleted file mode 100644 index 0df5691471..0000000000 --- a/apps/web/app/settings/workspace/[id]/linear/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { LinearSettings } from "@/components/linear/linear-settings"; -import { StateProvider } from "@/components/state-provider"; - -export default async function WorkspaceLinearPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - // `key={id}` forces a remount when the URL parameter changes so config / - // form / testResult / teams from a previous workspace can never bleed into - // the next one — Next.js doesn't unmount route segments on dynamic param - // changes by default, only re-renders them. - return ( - - - - ); -} diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx index 18e8818dca..ba82f41dc4 100644 --- a/apps/web/components/github/github-settings.tsx +++ b/apps/web/components/github/github-settings.tsx @@ -18,13 +18,10 @@ import { DefaultQueriesSection } from "./default-queries-section"; import { PRStatsPanel } from "./pr-stats"; import { useReviewWatches } from "@/hooks/domains/github/use-review-watches"; import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches"; +import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; import type { ReviewWatch, IssueWatch } from "@/lib/types/github"; -type GitHubSettingsProps = { - workspaceId: string; -}; - -function useWatchActions(workspaceId: string | null) { +function useWatchActions(workspaceId?: string | null) { const { items: watches, create, update, remove, trigger } = useReviewWatches(workspaceId); const { toast } = useToast(); @@ -78,7 +75,7 @@ function useWatchActions(workspaceId: string | null) { return { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled }; } -function useIssueWatchActions(workspaceId: string | null) { +function useIssueWatchActions(workspaceId?: string | null) { const { items: watches, create, update, remove, trigger } = useIssueWatches(workspaceId); const { toast } = useToast(); @@ -132,49 +129,67 @@ function useIssueWatchActions(workspaceId: string | null) { return { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled }; } -export function GitHubSettings({ workspaceId }: GitHubSettingsProps) { +export function GitHubConnectionSection() { + return ( + <> +
+

+ + GitHub Integration +

+

+ Connect Kandev to GitHub. Authentication is shared across all workspaces; PR/issue + watchers and presets are configured per workspace. +

+
+ + + + + + + + + + ); +} + +// PerWorkspaceSection wraps the still-per-workspace surfaces (action presets, +// PR analytics) under a workspace switcher. Watchers don't go in here — they +// list flat across every workspace via their own sections. +function PerWorkspaceSection({ workspaceId }: { workspaceId: string }) { + return ( +
+ + + + +
+ ); +} + +export function GitHubIntegrationPage() { return (
-
-

- - GitHub Integration -

-

- Configure GitHub PR monitoring, issue tracking, and review queue automation. -

-
- - - - - - - - - - - - - - + + + + + {(ws) => } + - - - -
); } -function ReviewWatchSection({ workspaceId }: { workspaceId: string }) { +// ReviewWatchSection lists every review watch across every workspace in a +// single flat table. Pass `undefined` to the hook so it fetches all rows; the +// dialog's create flow asks the user to pick the workspace. +function ReviewWatchSection() { const { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled } = - useWatchActions(workspaceId); + useWatchActions(undefined); const { toast } = useToast(); const [dialogOpen, setDialogOpen] = useState(false); const [editingWatch, setEditingWatch] = useState(null); @@ -207,6 +222,7 @@ function ReviewWatchSection({ workspaceId }: { workspaceId: string }) { { await create(req); toast({ description: "Review watch created", variant: "success" }); @@ -233,8 +249,8 @@ function ReviewWatchSection({ workspaceId }: { workspaceId: string }) { ); } -function IssueWatchSection({ workspaceId }: { workspaceId: string }) { - const issueActions = useIssueWatchActions(workspaceId); +function IssueWatchSection() { + const issueActions = useIssueWatchActions(undefined); const { toast } = useToast(); const [dialogOpen, setDialogOpen] = useState(false); const [editingWatch, setEditingIssueWatch] = useState(null); @@ -267,6 +283,7 @@ function IssueWatchSection({ workspaceId }: { workspaceId: string }) { { await issueActions.create(req); toast({ description: "Issue watch created", variant: "success" }); diff --git a/apps/web/components/github/issue-watch-dialog.tsx b/apps/web/components/github/issue-watch-dialog.tsx index 7dd8b331fd..efe428f657 100644 --- a/apps/web/components/github/issue-watch-dialog.tsx +++ b/apps/web/components/github/issue-watch-dialog.tsx @@ -20,7 +20,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kande import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; -import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps"; import { ScriptEditor, computeEditorHeight, @@ -41,12 +41,15 @@ type IssueWatchDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; watch: IssueWatch | null; - workspaceId: string; + // Pre-binds the dialog to one workspace. Omit on the install-wide settings + // page so the create flow shows a workspace picker. + workspaceId?: string; onCreate: (req: CreateIssueWatchRequest) => Promise; onUpdate: (id: string, req: UpdateIssueWatchRequest) => Promise; }; type FormState = { + workspaceId: string; selectedRepos: RepoFilter[]; allRepos: boolean; workflowId: string; @@ -60,27 +63,29 @@ type FormState = { pollInterval: number; }; -type WorkflowStepOption = { id: string; name: string }; - const DEFAULT_QUERY = "type:issue state:open"; -const defaultFormState: FormState = { - selectedRepos: [], - allRepos: false, - workflowId: "", - workflowStepId: "", - agentProfileId: "", - executorProfileId: "", - prompt: DEFAULT_ISSUE_WATCH_PROMPT, - labels: "", - customQuery: DEFAULT_QUERY, - enabled: true, - pollInterval: 300, -}; +function makeDefaultForm(workspaceId: string): FormState { + return { + workspaceId, + selectedRepos: [], + allRepos: false, + workflowId: "", + workflowStepId: "", + agentProfileId: "", + executorProfileId: "", + prompt: DEFAULT_ISSUE_WATCH_PROMPT, + labels: "", + customQuery: DEFAULT_QUERY, + enabled: true, + pollInterval: 300, + }; +} function formStateFromWatch(watch: IssueWatch): FormState { const hasRepos = watch.repos && watch.repos.length > 0; return { + workspaceId: watch.workspace_id, selectedRepos: hasRepos ? watch.repos : [], allRepos: !hasRepos, workflowId: watch.workflow_id, @@ -95,32 +100,6 @@ function formStateFromWatch(watch: IssueWatch): FormState { }; } -function useWorkflowSteps(workflowId: string) { - const [steps, setSteps] = useState([]); - - useEffect(() => { - if (!workflowId) { - void Promise.resolve().then(() => setSteps([])); - return; - } - let cancelled = false; - listWorkflowSteps(workflowId) - .then((response) => { - if (cancelled) return; - const sorted = [...response.steps].sort((a, b) => a.position - b.position); - setSteps(sorted.map((s) => ({ id: s.id, name: s.name }))); - }) - .catch(() => { - if (!cancelled) setSteps([]); - }); - return () => { - cancelled = true; - }; - }, [workflowId]); - - return steps; -} - function useWatchFormData(workspaceId: string) { useSettingsData(true); useWorkflows(workspaceId, true); @@ -179,25 +158,39 @@ function PlaceholdersHelp() { function IssueWatchFormFields({ form, setForm, - workspaceId, + workspaceLocked, onAllReposChange, onSelectedReposChange, }: { form: FormState; setForm: React.Dispatch>; - workspaceId: string; + workspaceLocked: boolean; onAllReposChange: (checked: boolean) => void; onSelectedReposChange: (repos: RepoFilter[]) => void; }) { return (
+ + setForm((prev) => ({ + ...prev, + workspaceId: v, + workflowId: "", + workflowStepId: "", + selectedRepos: [], + allRepos: false, + })) + } + disabled={workspaceLocked} + /> - +
); @@ -254,14 +247,12 @@ function IssueFilterFields({ function IssueAutomationFields({ form, setForm, - workspaceId, }: { form: FormState; setForm: React.Dispatch>; - workspaceId: string; }) { - const { workflows, agentProfiles, allExecutorProfiles } = useWatchFormData(workspaceId); - const workflowSteps = useWorkflowSteps(form.workflowId); + const { workflows, agentProfiles, allExecutorProfiles } = useWatchFormData(form.workspaceId); + const { steps: workflowSteps, loading: stepsLoading } = useWorkflowSteps(form.workflowId); return ( <> @@ -280,8 +271,9 @@ function IssueAutomationFields({ description="Initial step for new tasks." value={form.workflowStepId} onChange={(v) => setForm((prev) => ({ ...prev, workflowStepId: v }))} - placeholder={form.workflowId ? "Select step" : "Select a workflow first"} + placeholder={stepPlaceholder(form.workflowId, stepsLoading, workflowSteps.length)} items={workflowSteps.map((s) => ({ id: s.id, label: s.name }))} + disabled={!form.workflowId || stepsLoading || workflowSteps.length === 0} />
@@ -382,12 +374,19 @@ export function IssueWatchDialog({ onCreate, onUpdate, }: IssueWatchDialogProps) { + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); const [saving, setSaving] = useState(false); - const [form, setForm] = useState(defaultFormState); + const [form, setForm] = useState(() => makeDefaultForm(workspaceId ?? "")); useEffect(() => { - setForm(watch ? formStateFromWatch(watch) : defaultFormState); - }, [watch, open]); + if (watch) { + setForm(formStateFromWatch(watch)); + } else { + setForm(makeDefaultForm(workspaceId ?? activeWorkspaceId ?? "")); + } + }, [watch, open, workspaceId, activeWorkspaceId]); + + const workspaceLocked = !!watch || !!workspaceId; const handleSelectedReposChange = useCallback((repos: RepoFilter[]) => { setForm((prev) => ({ ...prev, selectedRepos: repos })); @@ -420,7 +419,7 @@ export function IssueWatchDialog({ if (watch) { await onUpdate(watch.id, payload); } else { - await onCreate({ ...payload, workspace_id: workspaceId }); + await onCreate({ ...payload, workspace_id: form.workspaceId }); } onOpenChange(false); } catch { @@ -428,9 +427,13 @@ export function IssueWatchDialog({ } finally { setSaving(false); } - }, [form, watch, workspaceId, onCreate, onUpdate, onOpenChange]); + }, [form, watch, onCreate, onUpdate, onOpenChange]); - const canSave = !!form.workflowId && !!form.workflowStepId && form.prompt.trim().length > 0; + const canSave = + !!form.workspaceId && + !!form.workflowId && + !!form.workflowStepId && + form.prompt.trim().length > 0; return ( @@ -444,7 +447,7 @@ export function IssueWatchDialog({ @@ -468,12 +471,17 @@ function SelectField(props: { onChange: (v: string) => void; placeholder: string; items: { id: string; label: string }[]; + disabled?: boolean; }) { return (
{props.description &&

{props.description}

} - @@ -488,3 +496,26 @@ function SelectField(props: {
); } + +function WorkspacePicker({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (v: string) => void; + disabled?: boolean; +}) { + const workspaces = useAppStore((s) => s.workspaces.items); + return ( + ({ id: w.id, label: w.name }))} + disabled={disabled} + /> + ); +} diff --git a/apps/web/components/github/issue-watch-table.tsx b/apps/web/components/github/issue-watch-table.tsx index c7f04ca050..85d769024a 100644 --- a/apps/web/components/github/issue-watch-table.tsx +++ b/apps/web/components/github/issue-watch-table.tsx @@ -6,10 +6,14 @@ import { Badge } from "@kandev/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useToast } from "@/components/toast-provider"; +import { useAppStore } from "@/components/state-provider"; import type { IssueWatch } from "@/lib/types/github"; type IssueWatchTableProps = { watches: IssueWatch[]; + // showWorkspace renders a Workspace column when the table aggregates rows + // from every workspace (install-wide settings page). + showWorkspace?: boolean; onEdit: (watch: IssueWatch) => void; onDelete: (id: string) => void; onTrigger: (id: string) => void; @@ -109,11 +113,15 @@ function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActi export function IssueWatchTable({ watches, + showWorkspace, onEdit, onDelete, onTrigger, onToggleEnabled, }: IssueWatchTableProps) { + const workspaces = useAppStore((s) => s.workspaces.items); + const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; + if (watches.length === 0) { return (

@@ -126,6 +134,7 @@ export function IssueWatchTable({ + {showWorkspace && Workspace} Repository Labels Interval @@ -137,6 +146,11 @@ export function IssueWatchTable({ {watches.map((watch) => ( onEdit(watch)}> + {showWorkspace && ( + + {workspaceName(watch.workspace_id)} + + )} {formatRepoNames(watch)} {formatLabels(watch) || "—"} diff --git a/apps/web/components/github/review-watch-dialog.tsx b/apps/web/components/github/review-watch-dialog.tsx index 6149a8b00b..1b05c0146c 100644 --- a/apps/web/components/github/review-watch-dialog.tsx +++ b/apps/web/components/github/review-watch-dialog.tsx @@ -20,15 +20,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kande import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; -import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; import { - ScriptEditor, - computeEditorHeight, -} from "@/components/settings/profile-edit/script-editor"; -import { - REVIEW_WATCH_PLACEHOLDERS, - DEFAULT_REVIEW_WATCH_PROMPT, -} from "@/components/github/review-watch-placeholders"; + useWorkflowSteps, + stepPlaceholder, + type WorkflowStepOption, +} from "@/hooks/use-workflow-steps"; +import { DEFAULT_REVIEW_WATCH_PROMPT } from "@/components/github/review-watch-placeholders"; +import { ReviewWatchPromptField } from "@/components/github/review-watch-prompt-field"; import { RepoFilterSelector } from "@/components/github/repo-filter-selector"; import type { RepoFilter, @@ -41,7 +39,9 @@ type ReviewWatchDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; watch: ReviewWatch | null; - workspaceId: string; + // Pre-binds the dialog to one workspace; omit on the install-wide settings + // page so the create flow shows a workspace picker. + workspaceId?: string; onCreate: (req: CreateReviewWatchRequest) => Promise; onUpdate: (id: string, req: UpdateReviewWatchRequest) => Promise; }; @@ -52,6 +52,7 @@ const QUERY_TEMPLATES = { } as const; type FormState = { + workspaceId: string; selectedRepos: RepoFilter[]; allRepos: boolean; workflowId: string; @@ -64,24 +65,26 @@ type FormState = { pollInterval: number; }; -type WorkflowStepOption = { id: string; name: string }; - -const defaultFormState: FormState = { - selectedRepos: [], - allRepos: false, - workflowId: "", - workflowStepId: "", - agentProfileId: "", - executorProfileId: "", - prompt: DEFAULT_REVIEW_WATCH_PROMPT, - customQuery: QUERY_TEMPLATES.meAndTeams, - enabled: true, - pollInterval: 300, -}; +function makeDefaultForm(workspaceId: string): FormState { + return { + workspaceId, + selectedRepos: [], + allRepos: false, + workflowId: "", + workflowStepId: "", + agentProfileId: "", + executorProfileId: "", + prompt: DEFAULT_REVIEW_WATCH_PROMPT, + customQuery: QUERY_TEMPLATES.meAndTeams, + enabled: true, + pollInterval: 300, + }; +} function formStateFromWatch(watch: ReviewWatch): FormState { const hasRepos = watch.repos && watch.repos.length > 0; return { + workspaceId: watch.workspace_id, selectedRepos: hasRepos ? watch.repos : [], allRepos: !hasRepos, workflowId: watch.workflow_id, @@ -104,6 +107,7 @@ type SelectFieldProps = { onChange: (value: string) => void; placeholder: string; items: Array<{ id: string; label: string }>; + disabled?: boolean; }; function SelectField({ @@ -113,12 +117,13 @@ function SelectField({ onChange, placeholder, items, + disabled, }: SelectFieldProps) { return (
{description &&

{description}

} - @@ -134,34 +139,31 @@ function SelectField({ ); } -// --- Hooks --- - -function useWorkflowSteps(workflowId: string) { - const [steps, setSteps] = useState([]); - - useEffect(() => { - if (!workflowId) { - void Promise.resolve().then(() => setSteps([])); - return; - } - let cancelled = false; - listWorkflowSteps(workflowId) - .then((response) => { - if (cancelled) return; - const sorted = [...response.steps].sort((a, b) => a.position - b.position); - setSteps(sorted.map((s) => ({ id: s.id, name: s.name }))); - }) - .catch(() => { - if (!cancelled) setSteps([]); - }); - return () => { - cancelled = true; - }; - }, [workflowId]); - - return steps; +function WorkspacePicker({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (v: string) => void; + disabled?: boolean; +}) { + const workspaces = useAppStore((s) => s.workspaces.items); + return ( + ({ id: w.id, label: w.name }))} + disabled={disabled} + /> + ); } +// --- Hooks --- + function useWatchFormData(workspaceId: string) { useSettingsData(true); useWorkflows(workspaceId, true); @@ -272,18 +274,18 @@ function QueryField({ function WatchFormFields({ form, setForm, - workspaceId, + workspaceLocked, onAllReposChange, onSelectedReposChange, }: { form: FormState; setForm: React.Dispatch>; - workspaceId: string; + workspaceLocked: boolean; onAllReposChange: (checked: boolean) => void; onSelectedReposChange: (repos: RepoFilter[]) => void; }) { - const { workflows, agentProfiles, allExecutorProfiles } = useWatchFormData(workspaceId); - const workflowSteps = useWorkflowSteps(form.workflowId); + const { workflows, agentProfiles, allExecutorProfiles } = useWatchFormData(form.workspaceId); + const { steps: workflowSteps, loading: stepsLoading } = useWorkflowSteps(form.workflowId); const handleWorkflowChange = useCallback( (v: string) => { @@ -294,6 +296,23 @@ function WatchFormFields({ return (
+ + setForm((prev) => ({ + ...prev, + workspaceId: v, + // Switching workspace invalidates workflow/step refs since those + // are workspace-scoped IDs. + workflowId: "", + workflowStepId: "", + // Repo selections are tied to the workspace's repository list too. + selectedRepos: [], + allRepos: false, + })) + } + disabled={workspaceLocked} + /> Filter - + setForm((prev) => ({ ...prev, prompt: v }))} + /> Settings
@@ -328,12 +351,14 @@ function WorkflowFields({ setForm, workflows, workflowSteps, + stepsLoading, onWorkflowChange, }: { form: FormState; setForm: React.Dispatch>; workflows: Array<{ id: string; name: string }>; workflowSteps: WorkflowStepOption[]; + stepsLoading: boolean; onWorkflowChange: (v: string) => void; }) { return ( @@ -351,8 +376,9 @@ function WorkflowFields({ description="Initial step for new tasks. Auto-start is set on the step." value={form.workflowStepId} onChange={(v) => setForm((prev) => ({ ...prev, workflowStepId: v }))} - placeholder={form.workflowId ? "Select step" : "Select a workflow first"} + placeholder={stepPlaceholder(form.workflowId, stepsLoading, workflowSteps.length)} items={workflowSteps.map((s) => ({ id: s.id, label: s.name }))} + disabled={!form.workflowId || stepsLoading || workflowSteps.length === 0} />
); @@ -420,69 +446,6 @@ function ProfileFields({ ); } -function PlaceholdersHelp() { - return ( - - - - - - -

Available placeholders:

-
    - {REVIEW_WATCH_PLACEHOLDERS.map((p) => ( -
  • - {`{{${p.key}}}`}{" "} - {p.description} -
  • - ))} -
-
-
-
- ); -} - -function PromptField({ - form, - setForm, -}: { - form: FormState; - setForm: React.Dispatch>; -}) { - return ( -
-
- - -
-

- The prompt sent to the agent for each new PR. Type {"{{"} to insert placeholders. -

-
- setForm((prev) => ({ ...prev, prompt: v }))} - language="markdown" - height={computeEditorHeight(form.prompt)} - lineNumbers="off" - placeholders={REVIEW_WATCH_PLACEHOLDERS} - /> -
-

- The workflow step prompt wraps this prompt. For example, if the step prompt is{" "} - - {"Analyze the task: {{task_prompt}}"} - - , the final prompt becomes{" "} - - {"Analyze the task: Pull Request ready for review: https://..."} - -

-
- ); -} - function SettingsFields({ form, setForm, @@ -533,12 +496,19 @@ export function ReviewWatchDialog({ onCreate, onUpdate, }: ReviewWatchDialogProps) { + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); const [saving, setSaving] = useState(false); - const [form, setForm] = useState(defaultFormState); + const [form, setForm] = useState(() => makeDefaultForm(workspaceId ?? "")); useEffect(() => { - setForm(watch ? formStateFromWatch(watch) : defaultFormState); - }, [watch, open]); + if (watch) { + setForm(formStateFromWatch(watch)); + } else { + setForm(makeDefaultForm(workspaceId ?? activeWorkspaceId ?? "")); + } + }, [watch, open, workspaceId, activeWorkspaceId]); + + const workspaceLocked = !!watch || !!workspaceId; const handleSelectedReposChange = useCallback((repos: RepoFilter[]) => { setForm((prev) => ({ ...prev, selectedRepos: repos })); @@ -570,7 +540,7 @@ export function ReviewWatchDialog({ if (watch) { await onUpdate(watch.id, payload); } else { - await onCreate({ ...payload, workspace_id: workspaceId }); + await onCreate({ ...payload, workspace_id: form.workspaceId }); } onOpenChange(false); } catch { @@ -578,9 +548,10 @@ export function ReviewWatchDialog({ } finally { setSaving(false); } - }, [form, watch, workspaceId, onCreate, onUpdate, onOpenChange]); + }, [form, watch, onCreate, onUpdate, onOpenChange]); const canSave = + !!form.workspaceId && form.customQuery.trim().length > 0 && !!form.workflowId && !!form.workflowStepId && @@ -598,7 +569,7 @@ export function ReviewWatchDialog({ diff --git a/apps/web/components/github/review-watch-prompt-field.tsx b/apps/web/components/github/review-watch-prompt-field.tsx new file mode 100644 index 0000000000..3aac51c918 --- /dev/null +++ b/apps/web/components/github/review-watch-prompt-field.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { IconInfoCircle } from "@tabler/icons-react"; +import { Label } from "@kandev/ui/label"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + ScriptEditor, + computeEditorHeight, +} from "@/components/settings/profile-edit/script-editor"; +import { REVIEW_WATCH_PLACEHOLDERS } from "@/components/github/review-watch-placeholders"; + +// Pulled out of review-watch-dialog.tsx to keep that file under the 600-line +// linter cap; the prompt editor + its placeholder help bubble are co-owned by +// this single screen and aren't reused elsewhere. + +function PlaceholdersHelp() { + return ( + + + + + + +

Available placeholders:

+
    + {REVIEW_WATCH_PLACEHOLDERS.map((p) => ( +
  • + {`{{${p.key}}}`}{" "} + {p.description} +
  • + ))} +
+
+
+
+ ); +} + +export function ReviewWatchPromptField({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( +
+
+ + +
+

+ The prompt sent to the agent for each new PR. Type {"{{"} to insert placeholders. +

+
+ +
+

+ The workflow step prompt wraps this prompt. For example, if the step prompt is{" "} + + {"Analyze the task: {{task_prompt}}"} + + , the final prompt becomes{" "} + + {"Analyze the task: Pull Request ready for review: https://..."} + +

+
+ ); +} diff --git a/apps/web/components/github/review-watch-table.tsx b/apps/web/components/github/review-watch-table.tsx index 73a79d7920..847bd86079 100644 --- a/apps/web/components/github/review-watch-table.tsx +++ b/apps/web/components/github/review-watch-table.tsx @@ -6,10 +6,14 @@ import { Badge } from "@kandev/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useToast } from "@/components/toast-provider"; +import { useAppStore } from "@/components/state-provider"; import type { ReviewWatch } from "@/lib/types/github"; type ReviewWatchTableProps = { watches: ReviewWatch[]; + // showWorkspace renders a Workspace column when the table aggregates rows + // from every workspace (install-wide settings page). + showWorkspace?: boolean; onEdit: (watch: ReviewWatch) => void; onDelete: (id: string) => void; onTrigger: (id: string) => void; @@ -104,11 +108,15 @@ function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActi export function ReviewWatchTable({ watches, + showWorkspace, onEdit, onDelete, onTrigger, onToggleEnabled, }: ReviewWatchTableProps) { + const workspaces = useAppStore((s) => s.workspaces.items); + const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; + if (watches.length === 0) { return (

@@ -121,6 +129,7 @@ export function ReviewWatchTable({

+ {showWorkspace && Workspace} Repository Interval Last Polled @@ -131,6 +140,11 @@ export function ReviewWatchTable({ {watches.map((watch) => ( onEdit(watch)}> + {showWorkspace && ( + + {workspaceName(watch.workspace_id)} + + )} {formatRepoNames(watch)} {Math.round(watch.poll_interval_seconds / 60)}m diff --git a/apps/web/components/integrations/integrations-menu.test.ts b/apps/web/components/integrations/integrations-menu.test.ts index 6243d0865e..031e881f9d 100644 --- a/apps/web/components/integrations/integrations-menu.test.ts +++ b/apps/web/components/integrations/integrations-menu.test.ts @@ -116,7 +116,7 @@ describe("IntegrationsMenu", () => { it("opens configured integration links on hover", async () => { mockAvailability({ githubReady: true, jiraAvailable: true, linearAvailable: false }); - render(createElement(IntegrationsMenu, { workspaceId: "workspace-1" })); + render(createElement(IntegrationsMenu, {})); const trigger = screen.getByRole("button", { name: "Integrations" }); expect(screen.queryByText("GitHub")).toBeNull(); @@ -131,7 +131,7 @@ describe("IntegrationsMenu", () => { it("does not render when no integrations are configured", () => { mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false }); - render(createElement(IntegrationsMenu, { workspaceId: "workspace-1" })); + render(createElement(IntegrationsMenu, {})); expect(screen.queryByRole("button", { name: "Integrations" })).toBeNull(); }); diff --git a/apps/web/components/integrations/integrations-menu.tsx b/apps/web/components/integrations/integrations-menu.tsx index 52f496edcb..382608b021 100644 --- a/apps/web/components/integrations/integrations-menu.tsx +++ b/apps/web/components/integrations/integrations-menu.tsx @@ -16,11 +16,7 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import type { GitHubStatus } from "@/lib/types/github"; -type IntegrationsProps = { - workspaceId?: string; -}; - -type MobileIntegrationsSectionProps = IntegrationsProps & { +type MobileIntegrationsSectionProps = { onNavigate: () => void; }; @@ -74,10 +70,10 @@ export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading: return { ready: false, label: getStatusLabel(loading) }; } -function useConfiguredIntegrationLinks(workspaceId: string | undefined): IntegrationLink[] { +function useConfiguredIntegrationLinks(): IntegrationLink[] { const { status, loading } = useGitHubStatus(); - const jiraAvailable = useJiraAvailable(workspaceId); - const linearAvailable = useLinearAvailable(workspaceId); + const jiraAvailable = useJiraAvailable(); + const linearAvailable = useLinearAvailable(); const githubStatus = getGitHubIntegrationStatus(status, loading); return getAvailableIntegrationLinks({ @@ -87,8 +83,8 @@ function useConfiguredIntegrationLinks(workspaceId: string | undefined): Integra }); } -export function IntegrationsMenu({ workspaceId }: IntegrationsProps) { - const links = useConfiguredIntegrationLinks(workspaceId); +export function IntegrationsMenu() { + const links = useConfiguredIntegrationLinks(); const [open, setOpen] = useState(false); const closeTimeoutRef = useRef | null>(null); @@ -158,11 +154,8 @@ export function IntegrationsMenu({ workspaceId }: IntegrationsProps) { ); } -export function MobileIntegrationsSection({ - workspaceId, - onNavigate, -}: MobileIntegrationsSectionProps) { - const links = useConfiguredIntegrationLinks(workspaceId); +export function MobileIntegrationsSection({ onNavigate }: MobileIntegrationsSectionProps) { + const links = useConfiguredIntegrationLinks(); if (links.length === 0) return null; diff --git a/apps/web/components/integrations/workspace-scoped-section.tsx b/apps/web/components/integrations/workspace-scoped-section.tsx new file mode 100644 index 0000000000..dc76e794bf --- /dev/null +++ b/apps/web/components/integrations/workspace-scoped-section.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { Card, CardContent } from "@kandev/ui/card"; +import { useAppStore } from "@/components/state-provider"; +import { WorkspaceSwitcher } from "@/components/task/workspace-switcher"; + +type WorkspaceScopedSectionProps = { + label?: string; + emptyMessage?: string; + children: (workspaceId: string) => ReactNode; +}; + +// WorkspaceScopedSection wraps watcher / per-workspace settings under a +// workspace selector so the install-wide integration settings page can still +// surface things that genuinely scope to one workspace at a time. The selector +// defaults to the active workspace from the global store; an explicit user +// override survives until the workspace list no longer contains it. +export function WorkspaceScopedSection({ + label = "Workspace", + emptyMessage, + children, +}: WorkspaceScopedSectionProps) { + const workspaces = useAppStore((s) => s.workspaces.items); + const activeId = useAppStore((s) => s.workspaces.activeId); + const [override, setOverride] = useState(null); + + // Derive the effective selection at render time: honour an in-bounds user + // override, otherwise fall back to the active workspace (or the first one). + // This avoids setState-in-effect when the workspace list hydrates after + // first render. + const overrideValid = override && workspaces.some((w) => w.id === override); + const selected = overrideValid ? override : (activeId ?? workspaces[0]?.id ?? null); + + if (workspaces.length === 0) { + return ( + + + {emptyMessage ?? "Create a workspace to configure this integration."} + + + ); + } + + return ( +
+
+ {label} + +
+ {selected ? children(selected) : null} +
+ ); +} diff --git a/apps/web/components/jira/jira-import-bar.tsx b/apps/web/components/jira/jira-import-bar.tsx index ccf9cdf050..bbd3927edf 100644 --- a/apps/web/components/jira/jira-import-bar.tsx +++ b/apps/web/components/jira/jira-import-bar.tsx @@ -14,7 +14,7 @@ type JiraImportBarProps = { }; export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBarProps) { - const available = useJiraAvailable(workspaceId); + const available = useJiraAvailable(); if (!available || !workspaceId) return null; return ( @@ -30,7 +30,7 @@ export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBar placeholder="PROJ-123 or paste ticket URL" extractKey={(raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null} validationHint="Paste a Jira ticket URL or key (PROJ-123)" - fetch={(key) => getJiraTicket(workspaceId, key)} + fetch={(key) => getJiraTicket(key)} onSuccess={(_key, ticket) => onImport(ticket)} submitLabel="Import" submittingLabel="Loading..." diff --git a/apps/web/components/jira/jira-issue-watch-dialog.tsx b/apps/web/components/jira/jira-issue-watch-dialog.tsx index 21e9aae47b..e3f510a588 100644 --- a/apps/web/components/jira/jira-issue-watch-dialog.tsx +++ b/apps/web/components/jira/jira-issue-watch-dialog.tsx @@ -18,7 +18,7 @@ import { Textarea } from "@kandev/ui/textarea"; import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; -import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps"; import { searchJiraTickets } from "@/lib/api/domains/jira-api"; import type { CreateJiraIssueWatchInput, @@ -30,12 +30,17 @@ type Props = { open: boolean; onOpenChange: (open: boolean) => void; watch: JiraIssueWatch | null; - workspaceId: string; + // workspaceId pre-binds the dialog to one workspace (legacy single-workspace + // surfaces). Omit on the install-wide settings page so the create dialog + // shows a workspace picker; when editing existing watches, the form pulls + // workspaceId from the watch row regardless. + workspaceId?: string; onCreate: (req: CreateJiraIssueWatchInput) => Promise; onUpdate: (id: string, req: UpdateJiraIssueWatchInput) => Promise; }; type FormState = { + workspaceId: string; jql: string; workflowId: string; workflowStepId: string; @@ -49,19 +54,23 @@ type FormState = { const DEFAULT_JQL = `project = PROJ AND status = "Open" ORDER BY created DESC`; const DEFAULT_PROMPT = `Investigate JIRA ticket {{issue.key}}: {{issue.summary}}\n\n{{issue.url}}`; -const emptyForm: FormState = { - jql: DEFAULT_JQL, - workflowId: "", - workflowStepId: "", - agentProfileId: "", - executorProfileId: "", - prompt: DEFAULT_PROMPT, - enabled: true, - pollInterval: 300, -}; +function makeEmptyForm(workspaceId: string): FormState { + return { + workspaceId, + jql: DEFAULT_JQL, + workflowId: "", + workflowStepId: "", + agentProfileId: "", + executorProfileId: "", + prompt: DEFAULT_PROMPT, + enabled: true, + pollInterval: 300, + }; +} function formStateFromWatch(w: JiraIssueWatch): FormState { return { + workspaceId: w.workspaceId, jql: w.jql, workflowId: w.workflowId, workflowStepId: w.workflowStepId, @@ -73,33 +82,6 @@ function formStateFromWatch(w: JiraIssueWatch): FormState { }; } -type StepOption = { id: string; name: string }; - -function useWorkflowSteps(workflowId: string) { - const [steps, setSteps] = useState([]); - useEffect(() => { - if (!workflowId) { - // Defer to next tick so we don't sync-set state inside an effect body. - void Promise.resolve().then(() => setSteps([])); - return; - } - let cancelled = false; - listWorkflowSteps(workflowId) - .then((res) => { - if (cancelled) return; - const sorted = [...res.steps].sort((a, b) => a.position - b.position); - setSteps(sorted.map((s) => ({ id: s.id, name: s.name }))); - }) - .catch(() => { - if (!cancelled) setSteps([]); - }); - return () => { - cancelled = true; - }; - }, [workflowId]); - return steps; -} - function useFormData(workspaceId: string) { useSettingsData(true); useWorkflows(workspaceId, true); @@ -125,12 +107,17 @@ function SelectField(props: { onChange: (v: string) => void; placeholder: string; items: { id: string; label: string }[]; + disabled?: boolean; }) { return (
{props.description &&

{props.description}

} - @@ -146,29 +133,21 @@ function SelectField(props: { ); } -function JQLField({ - workspaceId, - jql, - onChange, -}: { - workspaceId: string; - jql: string; - onChange: (v: string) => void; -}) { +function JQLField({ jql, onChange }: { jql: string; onChange: (v: string) => void }) { const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); const [testing, setTesting] = useState(false); const handleTest = useCallback(async () => { setTesting(true); setResult(null); try { - const res = await searchJiraTickets(workspaceId, { jql, maxResults: 5 }); + const res = await searchJiraTickets({ jql, maxResults: 5 }); setResult({ ok: true, message: `Matched ${res.tickets.length} ticket(s) in this page.` }); } catch (err) { setResult({ ok: false, message: `JQL error: ${String(err)}` }); } finally { setTesting(false); } - }, [workspaceId, jql]); + }, [jql]); return (
@@ -224,17 +203,38 @@ function PromptField({ value, onChange }: { value: string; onChange: (v: string) ); } +function WorkspacePicker({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (v: string) => void; + disabled?: boolean; +}) { + const workspaces = useAppStore((s) => s.workspaces.items); + return ( + ({ id: w.id, label: w.name }))} + disabled={disabled} + /> + ); +} + function AutomationFields({ form, setForm, - workspaceId, }: { form: FormState; setForm: React.Dispatch>; - workspaceId: string; }) { - const { workflows, agentProfiles, allExecutorProfiles } = useFormData(workspaceId); - const steps = useWorkflowSteps(form.workflowId); + const { workflows, agentProfiles, allExecutorProfiles } = useFormData(form.workspaceId); + const { steps, loading: stepsLoading } = useWorkflowSteps(form.workflowId); return ( <>
@@ -251,8 +251,9 @@ function AutomationFields({ description="Initial step for new tasks." value={form.workflowStepId} onChange={(v) => setForm((p) => ({ ...p, workflowStepId: v }))} - placeholder={form.workflowId ? "Select step" : "Select a workflow first"} + placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)} items={steps.map((s) => ({ id: s.id, label: s.name }))} + disabled={!form.workflowId || stepsLoading || steps.length === 0} />
@@ -327,15 +328,32 @@ export function JiraIssueWatchDialog({ onCreate, onUpdate, }: Props) { + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); const [saving, setSaving] = useState(false); - const [form, setForm] = useState(emptyForm); + const [form, setForm] = useState(() => makeEmptyForm(workspaceId ?? "")); + // Re-seed the form whenever the dialog opens or the bound watch changes. + // Default the workspace to: editing → the watch's workspace; creating → + // the explicit `workspaceId` prop, falling back to the user's currently + // active workspace so the picker isn't empty when nothing was preselected. useEffect(() => { - setForm(watch ? formStateFromWatch(watch) : emptyForm); - }, [watch, open]); + if (watch) { + setForm(formStateFromWatch(watch)); + } else { + setForm(makeEmptyForm(workspaceId ?? activeWorkspaceId ?? "")); + } + }, [watch, open, workspaceId, activeWorkspaceId]); + + // Workspace is locked when editing (changing it would orphan the workflow + // refs) or when the dialog was opened from a single-workspace surface. + const workspaceLocked = !!watch || !!workspaceId; const canSave = - !!form.jql.trim() && !!form.workflowId && !!form.workflowStepId && !!form.prompt.trim(); + !!form.workspaceId && + !!form.jql.trim() && + !!form.workflowId && + !!form.workflowStepId && + !!form.prompt.trim(); const handleSave = useCallback(async () => { setSaving(true); @@ -353,7 +371,7 @@ export function JiraIssueWatchDialog({ if (watch) { await onUpdate(watch.id, payload); } else { - await onCreate({ ...payload, workspaceId }); + await onCreate({ ...payload, workspaceId: form.workspaceId }); } onOpenChange(false); } catch { @@ -361,7 +379,7 @@ export function JiraIssueWatchDialog({ } finally { setSaving(false); } - }, [form, watch, workspaceId, onCreate, onUpdate, onOpenChange]); + }, [form, watch, onCreate, onUpdate, onOpenChange]); return ( @@ -375,12 +393,15 @@ export function JiraIssueWatchDialog({
- setForm((p) => ({ ...p, jql: v }))} + + setForm((p) => ({ ...p, workspaceId: v, workflowId: "", workflowStepId: "" })) + } + disabled={workspaceLocked} /> - + setForm((p) => ({ ...p, jql: v }))} /> + setForm((p) => ({ ...p, prompt: v }))} diff --git a/apps/web/components/jira/jira-issue-watch-table.tsx b/apps/web/components/jira/jira-issue-watch-table.tsx index 6de7fc051b..641e0ac535 100644 --- a/apps/web/components/jira/jira-issue-watch-table.tsx +++ b/apps/web/components/jira/jira-issue-watch-table.tsx @@ -5,10 +5,15 @@ import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; import type { JiraIssueWatch } from "@/lib/types/jira"; type JiraIssueWatchTableProps = { watches: JiraIssueWatch[]; + // showWorkspace renders a Workspace column when the table aggregates rows + // from every workspace (install-wide settings page). Off for the legacy + // single-workspace surfaces. + showWorkspace?: boolean; onEdit: (watch: JiraIssueWatch) => void; onDelete: (id: string) => void; onTrigger: (id: string) => void; @@ -99,11 +104,15 @@ function WatchActions({ export function JiraIssueWatchTable({ watches, + showWorkspace, onEdit, onDelete, onTrigger, onToggleEnabled, }: JiraIssueWatchTableProps) { + const workspaces = useAppStore((s) => s.workspaces.items); + const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; + if (watches.length === 0) { return (

@@ -116,6 +125,7 @@ export function JiraIssueWatchTable({

+ {showWorkspace && Workspace} JQL Interval Last Polled @@ -126,6 +136,11 @@ export function JiraIssueWatchTable({ {watches.map((watch) => ( onEdit(watch)}> + {showWorkspace && ( + + {workspaceName(watch.workspaceId)} + + )} {watch.jql} diff --git a/apps/web/components/jira/jira-issue-watchers-section.tsx b/apps/web/components/jira/jira-issue-watchers-section.tsx index 5db00eebe0..ba617f4c67 100644 --- a/apps/web/components/jira/jira-issue-watchers-section.tsx +++ b/apps/web/components/jira/jira-issue-watchers-section.tsx @@ -11,12 +11,10 @@ import { JiraIssueWatchTable } from "./jira-issue-watch-table"; import { JiraIssueWatchDialog } from "./jira-issue-watch-dialog"; import type { JiraIssueWatch } from "@/lib/types/jira"; -type Props = { workspaceId: string }; - -// RawActions is the slice of useJiraIssueWatches that this component needs to -// wrap with toasts. Pulled out so useToastedActions doesn't itself call the -// hook a second time on the same workspace — the parent passes its callbacks -// down, keeping a single fetch + single store subscription per render tree. +// JiraIssueWatchersSection lists watches across every workspace in a single +// flat table on the install-wide settings page. The dialog's create flow asks +// the user to pick the workspace; per-row mutations forward each watch's +// stored workspaceId so the backend's IDOR guard accepts them. type RawActions = { create: ReturnType["create"]; update: ReturnType["update"]; @@ -25,8 +23,8 @@ type RawActions = { }; // useToastedActions wraps the raw create/update/delete/trigger callbacks with -// success/failure toasts. Kept separate so the section component stays under -// the 100-line lint limit and the toast wiring is testable in isolation. +// success/failure toasts. Per-row mutations need each watch's own workspaceId, +// so the wrappers take the watch (not just an id) and pass it through. function useToastedActions({ create, update, remove, trigger }: RawActions) { const { toast } = useToast(); @@ -44,9 +42,9 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) { ); const wrappedUpdate = useCallback( - async (id: string, req: Parameters[1]) => { + async (id: string, req: Parameters[1], rowWorkspaceId: string) => { try { - await update(id, req); + await update(id, req, rowWorkspaceId); toast({ description: "Watcher updated", variant: "success" }); } catch (err) { toast({ description: `Update failed: ${String(err)}`, variant: "error" }); @@ -57,10 +55,10 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) { ); const wrappedDelete = useCallback( - async (id: string) => { + async (w: JiraIssueWatch) => { if (!confirm("Delete this JIRA watcher?")) return; try { - await remove(id); + await remove(w.id, w.workspaceId); toast({ description: "Watcher deleted", variant: "success" }); } catch (err) { toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); @@ -70,9 +68,9 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) { ); const wrappedTrigger = useCallback( - async (id: string) => { + async (w: JiraIssueWatch) => { try { - const res = await trigger(id); + const res = await trigger(w.id, w.workspaceId); const n = res?.newIssues ?? 0; const description = n > 0 @@ -89,7 +87,7 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) { const toggleEnabled = useCallback( async (w: JiraIssueWatch) => { try { - await update(w.id, { enabled: !w.enabled }); + await update(w.id, { enabled: !w.enabled }, w.workspaceId); } catch (err) { toast({ description: `Toggle failed: ${String(err)}`, variant: "error" }); } @@ -106,13 +104,9 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) { }; } -/** - * JiraIssueWatchersSection renders the "JIRA watchers" block on the JIRA - * settings page: a table of configured watchers, a "+ New" button, and a - * dialog for create/edit. Mirrors the GitHub issue-watch UI patterns. - */ -export function JiraIssueWatchersSection({ workspaceId }: Props) { - const { items, loading, create, update, remove, trigger } = useJiraIssueWatches(workspaceId); +export function JiraIssueWatchersSection() { + // Pass undefined to fetch every watch across every workspace. + const { items, loading, create, update, remove, trigger } = useJiraIssueWatches(); const actions = useToastedActions({ create, update, remove, trigger }); const [dialogOpen, setDialogOpen] = useState(false); @@ -127,6 +121,24 @@ export function JiraIssueWatchersSection({ workspaceId }: Props) { setDialogOpen(true); }, []); + // Adapt the watch-aware actions back to id-keyed callbacks the table expects; + // the table looks up the watch by id when it needs to forward the per-row + // workspaceId to mutations. + const handleDelete = useCallback( + (id: string) => { + const w = items.find((item) => item.id === id); + if (w) actions.remove(w); + }, + [items, actions], + ); + const handleTrigger = useCallback( + (id: string) => { + const w = items.find((item) => item.id === id); + if (w) actions.trigger(w); + }, + [items, actions], + ); + return ( } @@ -146,9 +158,10 @@ export function JiraIssueWatchersSection({ workspaceId }: Props) { ) : ( )} @@ -158,9 +171,14 @@ export function JiraIssueWatchersSection({ workspaceId }: Props) { open={dialogOpen} onOpenChange={setDialogOpen} watch={editing} - workspaceId={workspaceId} + // No workspaceId — dialog shows a workspace picker for new watches and + // pulls workspaceId from the watch row when editing. onCreate={actions.create} - onUpdate={actions.update} + onUpdate={(id, req) => { + const w = editing; + if (!w) throw new Error("update without editing watch"); + return actions.update(id, req, w.workspaceId); + }} /> ); diff --git a/apps/web/components/jira/jira-link-button.tsx b/apps/web/components/jira/jira-link-button.tsx index f371631787..0f54c51a31 100644 --- a/apps/web/components/jira/jira-link-button.tsx +++ b/apps/web/components/jira/jira-link-button.tsx @@ -19,7 +19,7 @@ type JiraLinkButtonProps = { // 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 available = useJiraAvailable(workspaceId); + const available = useJiraAvailable(); const buildLinkedTitle = useCallback( (key: string) => { @@ -49,7 +49,7 @@ export function JiraLinkButton({ taskId, workspaceId, taskTitle }: JiraLinkButto // an unhandled rejection that leaves the user thinking the link // succeeded. fetch={async (key) => { - const ticket = await getJiraTicket(workspaceId, key); + const ticket = await getJiraTicket(key); await updateTask(taskId, { title: buildLinkedTitle(key) }); return ticket; }} diff --git a/apps/web/components/jira/jira-settings.tsx b/apps/web/components/jira/jira-settings.tsx index 65c56fc227..056a3514f1 100644 --- a/apps/web/components/jira/jira-settings.tsx +++ b/apps/web/components/jira/jira-settings.tsx @@ -336,11 +336,7 @@ function ActionBar({ ); } -type JiraSettingsProps = { - workspaceId: string; -}; - -function useJiraSettings(workspaceId: string) { +function useJiraSettings() { const { toast } = useToast(); const [config, setConfig] = useState(null); const [form, setForm] = useState(emptyForm); @@ -353,7 +349,7 @@ function useJiraSettings(workspaceId: string) { const load = useCallback(async () => { setLoading(true); try { - const cfg = await getJiraConfig(workspaceId); + const cfg = await getJiraConfig(); setConfig(cfg); setForm(configToForm(cfg)); } catch (err) { @@ -361,7 +357,7 @@ function useJiraSettings(workspaceId: string) { } finally { setLoading(false); } - }, [workspaceId, toast]); + }, [toast]); useEffect(() => { void load(); @@ -372,14 +368,14 @@ function useJiraSettings(workspaceId: string) { // config rather than the loud full `load()` to avoid flashing the form. useEffect(() => { const id = setInterval(() => { - getJiraConfig(workspaceId) + getJiraConfig() .then((cfg) => setConfig(cfg)) .catch(() => { /* transient failures are fine — next tick retries */ }); }, INTEGRATION_STATUS_REFRESH_MS); return () => clearInterval(id); - }, [workspaceId]); + }, []); const update = useCallback( (key: K, value: FormState[K]) => @@ -391,20 +387,19 @@ function useJiraSettings(workspaceId: string) { setTesting(true); setTestResult(null); try { - const res = await testJiraConnection({ workspaceId, ...form }); + const res = await testJiraConnection({ ...form }); setTestResult(res); } catch (err) { setTestResult({ ok: false, error: String(err) }); } finally { setTesting(false); } - }, [workspaceId, form]); + }, [form]); const handleSave = useCallback(async () => { setSaving(true); try { const saved = await setJiraConfig({ - workspaceId, siteUrl: form.siteUrl, email: form.email, authMethod: form.authMethod, @@ -422,12 +417,12 @@ function useJiraSettings(workspaceId: string) { } finally { setSaving(false); } - }, [workspaceId, form, toast]); + }, [form, toast]); const handleDelete = useCallback(async () => { - if (!confirm("Remove Jira configuration for this workspace?")) return; + if (!confirm("Remove Jira configuration?")) return; try { - await deleteJiraConfig(workspaceId); + await deleteJiraConfig(); setConfig(null); setForm(emptyForm); setTestResult(null); @@ -435,7 +430,7 @@ function useJiraSettings(workspaceId: string) { } catch (err) { toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); } - }, [workspaceId, toast]); + }, [toast]); return { config, @@ -452,8 +447,8 @@ function useJiraSettings(workspaceId: string) { }; } -function EnabledPill({ workspaceId }: { workspaceId: string }) { - const { enabled, setEnabled } = useJiraEnabled(workspaceId); +function EnabledPill() { + const { enabled, setEnabled } = useJiraEnabled(); return (
} + title="Jira integration" + description="Connect Kandev to an Atlassian Cloud site. Credentials are stored encrypted server-side and shared across all workspaces." + action={} + > + + + + + + + + + + + + + ); +} + +// JiraIntegrationPage is the install-wide settings surface mounted at +// /settings/integrations/jira. The connection form and task-prompt presets +// are global; the watchers section lists every watch across every workspace +// in a single table, with workspace selection happening inside the create +// dialog. +export function JiraIntegrationPage() { return (
- } - title="Jira integration" - description="Connect this workspace to an Atlassian Cloud site. Credentials are stored encrypted server-side." - action={} - > - - - - - - - - - - - - - {s.config?.hasSecret && } + +
); diff --git a/apps/web/components/jira/jira-ticket-common.tsx b/apps/web/components/jira/jira-ticket-common.tsx index 3dda4b417c..e5a306c1db 100644 --- a/apps/web/components/jira/jira-ticket-common.tsx +++ b/apps/web/components/jira/jira-ticket-common.tsx @@ -93,14 +93,14 @@ export function useTicketState( setLoading(true); setError(null); try { - const t = await getJiraTicket(workspaceId, ticketKey); + const t = await getJiraTicket(ticketKey); setTicket(t); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, [workspaceId, ticketKey]); + }, [ticketKey]); useEffect(() => { if (!enabled || !workspaceId || !ticketKey) return; @@ -110,7 +110,7 @@ export function useTicketState( setLoading(true); setError(null); try { - const t = await getJiraTicket(workspaceId, ticketKey); + const t = await getJiraTicket(ticketKey); if (!cancelled) setTicket(t); } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); @@ -129,7 +129,7 @@ export function useTicketState( setPendingTransition(transitionId); setError(null); try { - await transitionJiraTicket(workspaceId, ticketKey, transitionId); + await transitionJiraTicket(ticketKey, transitionId); await load(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -137,7 +137,7 @@ export function useTicketState( setPendingTransition(null); } }, - [workspaceId, ticketKey, load], + [ticketKey, load], ); return { ticket, loading, error, pendingTransition, load, handleTransition }; @@ -156,17 +156,16 @@ export { cleanIntegrationErrorMessage as cleanJiraErrorMessage } from "@/compone 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) { +export function JiraErrorMessage({ error, compact }: JiraErrorMessageProps) { return ( {dialogTitle(ticket, ticketKey)} {errorOnly ? (
- +
) : ( <> @@ -81,7 +81,6 @@ export function JiraTicketDialog({ ticket={ticket} state={state} ticketKey={effectiveTicketKey(ticket, ticketKey)} - workspaceId={workspaceId} /> {showFooter && ( @@ -97,18 +96,16 @@ function DialogBody({ ticket, state, ticketKey, - workspaceId, }: { ticket: JiraTicket | null; state: TicketState; ticketKey: string; - workspaceId: string | null | undefined; }) { return (
{state.error && ticket && (
- +
)} {!ticket && state.loading && ( diff --git a/apps/web/components/jira/my-jira/use-jira-search.ts b/apps/web/components/jira/my-jira/use-jira-search.ts index 829a845d73..4b1d67e0a1 100644 --- a/apps/web/components/jira/my-jira/use-jira-search.ts +++ b/apps/web/components/jira/my-jira/use-jira-search.ts @@ -41,7 +41,7 @@ export function useJiraSearch(workspaceId: string | null | undefined, jql: strin setLoading(true); setError(null); try { - const res = await searchJiraTickets(workspaceId, { + const res = await searchJiraTickets({ jql, pageToken: token, maxResults: PAGE_SIZE, diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 6519d902e0..3d90b69ff9 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -191,7 +191,7 @@ function HomeLeftActions({ workspaceId }: { workspaceId?: string }) { - + ); } @@ -200,7 +200,7 @@ function WorkspaceLeftActions({ workspaceId }: { workspaceId?: string }) { return ( <> - + ); } diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index 3426749513..a4042220aa 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -376,10 +376,7 @@ export function MobileMenuSheet({ onTogglePreviewOnClick={onTogglePreviewOnClick} /> - onOpenChange(false)} - /> + onOpenChange(false)} /> raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null} validationHint="Paste a Linear issue URL or identifier (ENG-123)" - fetch={(key) => getLinearIssue(workspaceId, key)} + fetch={(key) => getLinearIssue(key)} onSuccess={(_key, issue) => onImport(issue)} submitLabel="Import" submittingLabel="Loading..." diff --git a/apps/web/components/linear/linear-issue-common.tsx b/apps/web/components/linear/linear-issue-common.tsx index 63b59316f0..e37d6b03b8 100644 --- a/apps/web/components/linear/linear-issue-common.tsx +++ b/apps/web/components/linear/linear-issue-common.tsx @@ -96,14 +96,14 @@ export function useIssueState( setLoading(true); setError(null); try { - const i = await getLinearIssue(workspaceId, identifier); + const i = await getLinearIssue(identifier); setIssue(i); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, [workspaceId, identifier]); + }, [identifier]); useEffect(() => { if (!enabled || !workspaceId || !identifier) return; @@ -113,7 +113,7 @@ export function useIssueState( setLoading(true); setError(null); try { - const i = await getLinearIssue(workspaceId, identifier); + const i = await getLinearIssue(identifier); if (!cancelled) setIssue(i); } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); @@ -133,7 +133,7 @@ export function useIssueState( setPendingState(stateId); setError(null); try { - await setLinearIssueState(workspaceId, issue.id, stateId); + await setLinearIssueState(issue.id, stateId); await load(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -141,7 +141,7 @@ export function useIssueState( setPendingState(null); } }, - [workspaceId, issue, load], + [issue, load], ); return { issue, loading, error, pendingState, load, handleStateChange }; @@ -159,16 +159,15 @@ export { cleanIntegrationErrorMessage as cleanLinearErrorMessage } from "@/compo type LinearErrorMessageProps = { error: string; - workspaceId?: string | null; compact?: boolean; }; -export function LinearErrorMessage({ error, workspaceId, compact }: LinearErrorMessageProps) { +export function LinearErrorMessage({ error, compact }: LinearErrorMessageProps) { return ( {dialogTitle(issue, identifier)} {errorOnly ? (
- +
) : ( <> @@ -80,7 +80,6 @@ export function LinearIssueDialog({ issue={issue} state={state} identifier={effectiveIdentifier(issue, identifier)} - workspaceId={workspaceId} /> {showFooter && } @@ -105,18 +104,16 @@ function DialogBody({ issue, state, identifier, - workspaceId, }: { issue: LinearIssue | null; state: IssueState; identifier: string; - workspaceId: string | null | undefined; }) { return (
{state.error && issue && (
- +
)} {!issue && state.loading && ( diff --git a/apps/web/components/linear/linear-link-button.tsx b/apps/web/components/linear/linear-link-button.tsx index f7154dfa51..a9bda4983c 100644 --- a/apps/web/components/linear/linear-link-button.tsx +++ b/apps/web/components/linear/linear-link-button.tsx @@ -18,7 +18,7 @@ type LinearLinkButtonProps = { // LinearLinkButton attaches a Linear issue to an existing task by prepending // the identifier to its title ("ENG-123: ..."). export function LinearLinkButton({ taskId, workspaceId, taskTitle }: LinearLinkButtonProps) { - const available = useLinearAvailable(workspaceId); + const available = useLinearAvailable(); const buildLinkedTitle = useCallback( (key: string) => { @@ -44,7 +44,7 @@ export function LinearLinkButton({ taskId, workspaceId, taskTitle }: LinearLinkB // a failure on either surfaces as the popover's inline error instead of // an unhandled rejection. fetch={async (key) => { - const issue = await getLinearIssue(workspaceId, key); + const issue = await getLinearIssue(key); await updateTask(taskId, { title: buildLinkedTitle(key) }); return issue; }} diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index 9cac181a57..7ca805f299 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -201,25 +201,14 @@ function ActionBar({ ); } -type LinearSettingsProps = { - workspaceId: string; -}; - type SettingsActionsArgs = { - workspaceId: string; form: FormState; setConfig: (cfg: LinearConfig | null) => void; setForm: (form: FormState) => void; setTestResult: (r: TestLinearConnectionResult | null) => void; }; -function useSettingsActions({ - workspaceId, - form, - setConfig, - setForm, - setTestResult, -}: SettingsActionsArgs) { +function useSettingsActions({ form, setConfig, setForm, setTestResult }: SettingsActionsArgs) { const { toast } = useToast(); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -229,7 +218,6 @@ function useSettingsActions({ setTestResult(null); try { const res = await testLinearConnection({ - workspaceId, authMethod: "api_key", secret: form.secret || undefined, }); @@ -239,13 +227,12 @@ function useSettingsActions({ } finally { setTesting(false); } - }, [workspaceId, form, setTestResult]); + }, [form, setTestResult]); const handleSave = useCallback(async () => { setSaving(true); try { const saved = await setLinearConfig({ - workspaceId, authMethod: "api_key", defaultTeamKey: form.defaultTeamKey, secret: form.secret || undefined, @@ -259,12 +246,12 @@ function useSettingsActions({ } finally { setSaving(false); } - }, [workspaceId, form, toast, setConfig, setForm, setTestResult]); + }, [form, toast, setConfig, setForm, setTestResult]); const handleDelete = useCallback(async () => { - if (!confirm("Remove Linear configuration for this workspace?")) return; + if (!confirm("Remove Linear configuration?")) return; try { - await deleteLinearConfig(workspaceId); + await deleteLinearConfig(); setConfig(null); setForm(emptyForm); setTestResult(null); @@ -272,16 +259,12 @@ function useSettingsActions({ } catch (err) { toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); } - }, [workspaceId, toast, setConfig, setForm, setTestResult]); + }, [toast, setConfig, setForm, setTestResult]); return { saving, testing, handleTest, handleSave, handleDelete }; } -function useTeamsLoader( - workspaceId: string, - hasSecret: boolean | undefined, - lastOk: boolean | undefined, -) { +function useTeamsLoader(hasSecret: boolean | undefined, lastOk: boolean | undefined) { // `teams === null` means "no fetch attempt yet", so the dropdown can show a // "Loading…" placeholder without us calling setState synchronously inside // the effect (which the lint rule forbids). Once a fetch settles we always @@ -294,7 +277,7 @@ function useTeamsLoader( useEffect(() => { if (!hasSecret) return; let cancelled = false; - listLinearTeams(workspaceId) + listLinearTeams() .then((res) => { if (!cancelled) setTeams(res.teams ?? []); }) @@ -304,23 +287,23 @@ function useTeamsLoader( return () => { cancelled = true; }; - }, [workspaceId, hasSecret, lastOk]); + }, [hasSecret, lastOk]); return { teams: teams ?? [], loadingTeams: teams === null && !!hasSecret }; } -function useLinearSettings(workspaceId: string) { +function useLinearSettings() { const { toast } = useToast(); const [config, setConfig] = useState(null); const [form, setForm] = useState(emptyForm); const [loading, setLoading] = useState(true); const [testResult, setTestResult] = useState(null); const health = configToHealth(config); - const { teams, loadingTeams } = useTeamsLoader(workspaceId, config?.hasSecret, config?.lastOk); + const { teams, loadingTeams } = useTeamsLoader(config?.hasSecret, config?.lastOk); const load = useCallback(async () => { setLoading(true); try { - const cfg = await getLinearConfig(workspaceId); + const cfg = await getLinearConfig(); setConfig(cfg); setForm(configToForm(cfg)); } catch (err) { @@ -328,7 +311,7 @@ function useLinearSettings(workspaceId: string) { } finally { setLoading(false); } - }, [workspaceId, toast]); + }, [toast]); useEffect(() => { void load(); @@ -337,14 +320,14 @@ function useLinearSettings(workspaceId: string) { // Background refresh so the auth-health banner picks up new probe results. useEffect(() => { const id = setInterval(() => { - getLinearConfig(workspaceId) + getLinearConfig() .then((cfg) => setConfig(cfg)) .catch(() => { /* transient failures are fine — next tick retries */ }); }, INTEGRATION_STATUS_REFRESH_MS); return () => clearInterval(id); - }, [workspaceId]); + }, []); const update = useCallback( (key: K, value: FormState[K]) => @@ -353,7 +336,6 @@ function useLinearSettings(workspaceId: string) { ); const { saving, testing, handleTest, handleSave, handleDelete } = useSettingsActions({ - workspaceId, form, setConfig, setForm, @@ -377,8 +359,8 @@ function useLinearSettings(workspaceId: string) { }; } -function EnabledPill({ workspaceId }: { workspaceId: string }) { - const { enabled, setEnabled } = useLinearEnabled(workspaceId); +function EnabledPill() { + const { enabled, setEnabled } = useLinearEnabled(); return (
} + title="Linear integration" + description="Connect Kandev to Linear with a personal API key. Credentials are stored encrypted server-side and shared across all workspaces." + action={} + > + + + + + + + + + + + + ); +} + +export function LinearIntegrationPage() { return (
- } - title="Linear integration" - description="Connect this workspace to Linear with a personal API key. Credentials are stored encrypted server-side." - action={} - > - - - - - - - - - - - +
); } diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 0a426a106e..d08f0f2906 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -108,9 +108,6 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec const workspacePath = `/settings/workspace/${workspace.id}`; const workflowsPath = `${workspacePath}/workflows`; const repositoriesPath = `${workspacePath}/repositories`; - const githubPath = `${workspacePath}/github`; - const jiraPath = `${workspacePath}/jira`; - const linearPath = `${workspacePath}/linear`; return ( @@ -140,30 +137,6 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec - - - - - GitHub - - - - - - - - Jira - - - - - - - - Linear - - - ); @@ -174,6 +147,36 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec ); } +function IntegrationsSidebarSection({ pathname }: { pathname: string }) { + const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [ + { href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub }, + { href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket }, + { href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon }, + ]; + return ( + + + + + Integrations + + + + {items.map(({ href, label, Icon }) => ( + + + + + {label} + + + + ))} + + + ); +} + type AgentsSidebarSectionProps = { pathname: string; agents: Agent[]; @@ -313,6 +316,7 @@ export function SettingsAppSidebar() { + {/* Prompts */} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index fd9eebb17d..a830e8aa17 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -94,7 +94,6 @@ type TopBarLeftProps = { remoteCreatedAt?: string | null; remoteCheckedAt?: string | null; remoteStatusError?: string | null; - workspaceId?: string | null; onRenameBranch?: (newName: string) => Promise; }; @@ -164,7 +163,6 @@ const TaskTopBar = memo(function TaskTopBar({ remoteCreatedAt={remoteCreatedAt} remoteCheckedAt={remoteCheckedAt} remoteStatusError={remoteStatusError} - workspaceId={workspaceId} onRenameBranch={activeSessionId ? handleRenameBranch : undefined} />
@@ -238,8 +236,8 @@ function IssueTrackerButtons({ workspaceId: string | null | undefined; taskTitle: string | null | undefined; }) { - const jiraAvailable = useJiraAvailable(workspaceId); - const linearAvailable = useLinearAvailable(workspaceId); + const jiraAvailable = useJiraAvailable(); + const linearAvailable = useLinearAvailable(); const jiraKey = extractJiraKey(taskTitle); const linearKey = extractLinearKey(taskTitle); @@ -274,7 +272,6 @@ function TopBarLeft({ remoteCreatedAt, remoteCheckedAt, remoteStatusError, - workspaceId, onRenameBranch, }: TopBarLeftProps) { const extraRepoCount = (repositoryCount ?? 0) - 1; @@ -309,7 +306,7 @@ function TopBarLeft({ - +
{ - await apiClient - .rawRequest("DELETE", `/api/v1/jira/config?workspace_id=${seedData.workspaceId}`) - .catch(() => undefined); - await apiClient - .rawRequest("DELETE", `/api/v1/linear/config?workspace_id=${seedData.workspaceId}`) - .catch(() => undefined); + async ({ apiClient }, use) => { + await apiClient.rawRequest("DELETE", `/api/v1/jira/config`).catch(() => undefined); + await apiClient.rawRequest("DELETE", `/api/v1/linear/config`).catch(() => undefined); await Promise.all([ apiClient.mockJiraReset().catch(() => undefined), apiClient.mockLinearReset().catch(() => undefined), diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index b9fd14d4d2..8e23dc9ad3 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -795,7 +795,6 @@ export class ApiClient { // --- Integration config seeding (real API, not mock) --- async setJiraConfig(payload: { - workspaceId: string; siteUrl: string; email: string; authMethod?: "api_token" | "session_cookie"; @@ -808,11 +807,7 @@ export class ApiClient { }); } - async setLinearConfig(payload: { - workspaceId: string; - secret: string; - defaultTeamKey?: string; - }): Promise { + async setLinearConfig(payload: { secret: string; defaultTeamKey?: string }): Promise { return this.request("POST", "/api/v1/linear/config", { defaultTeamKey: "", ...payload, @@ -829,15 +824,11 @@ export class ApiClient { */ async waitForIntegrationAuthHealthy( integration: "jira" | "linear", - workspaceId: string, timeoutMs = 5_000, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const res = await this.rawRequest( - "GET", - `/api/v1/${integration}/config?workspace_id=${workspaceId}`, - ); + const res = await this.rawRequest("GET", `/api/v1/${integration}/config`); if (res.ok && res.status === 200) { const cfg = (await res.json()) as { hasSecret?: boolean; lastOk?: boolean }; if (cfg.hasSecret && cfg.lastOk) return; @@ -863,11 +854,7 @@ export class ApiClient { await this.request("PUT", "/api/v1/jira/mock/auth-result", result); } - async mockJiraSetAuthHealth(args: { - workspaceId: string; - ok: boolean; - error?: string; - }): Promise { + async mockJiraSetAuthHealth(args: { ok: boolean; error?: string }): Promise { await this.request("PUT", "/api/v1/jira/mock/auth-health", args); } @@ -916,7 +903,6 @@ export class ApiClient { } async mockLinearSetAuthHealth(args: { - workspaceId: string; ok: boolean; error?: string; orgSlug?: string; diff --git a/apps/web/e2e/pages/jira-settings-page.ts b/apps/web/e2e/pages/jira-settings-page.ts index 7d84a4f2ea..ec4b5a6283 100644 --- a/apps/web/e2e/pages/jira-settings-page.ts +++ b/apps/web/e2e/pages/jira-settings-page.ts @@ -21,8 +21,8 @@ export class JiraSettingsPage { this.statusBanner = page.getByTestId("integration-auth-status-banner"); } - async goto(workspaceId: string) { - await this.page.goto(`/settings/workspace/${workspaceId}/jira`); + async goto() { + await this.page.goto(`/settings/integrations/jira`); await this.siteInput.waitFor({ state: "visible" }); } diff --git a/apps/web/e2e/pages/linear-settings-page.ts b/apps/web/e2e/pages/linear-settings-page.ts index a4dcf0323d..98b55f1a9e 100644 --- a/apps/web/e2e/pages/linear-settings-page.ts +++ b/apps/web/e2e/pages/linear-settings-page.ts @@ -15,8 +15,8 @@ export class LinearSettingsPage { this.statusBanner = page.getByTestId("integration-auth-status-banner"); } - async goto(workspaceId: string) { - await this.page.goto(`/settings/workspace/${workspaceId}/linear`); + async goto() { + await this.page.goto(`/settings/integrations/linear`); await this.secretInput.waitFor({ state: "visible" }); } } diff --git a/apps/web/e2e/tests/integrations/jira-import.spec.ts b/apps/web/e2e/tests/integrations/jira-import.spec.ts index 21727dfb5e..898c32e5a6 100644 --- a/apps/web/e2e/tests/integrations/jira-import.spec.ts +++ b/apps/web/e2e/tests/integrations/jira-import.spec.ts @@ -2,14 +2,13 @@ import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; test.describe("Jira import bar", () => { - test.beforeEach(async ({ apiClient, seedData }) => { + test.beforeEach(async ({ apiClient }) => { await apiClient.setJiraConfig({ - workspaceId: seedData.workspaceId, siteUrl: "https://acme.atlassian.net", email: "alice@example.com", secret: "api-token-value", }); - await apiClient.waitForIntegrationAuthHealthy("jira", seedData.workspaceId); + await apiClient.waitForIntegrationAuthHealthy("jira"); }); test("pasting a known ticket key fills the title and description", async ({ diff --git a/apps/web/e2e/tests/integrations/jira-settings.spec.ts b/apps/web/e2e/tests/integrations/jira-settings.spec.ts index 96ca030e8b..305f3f1c8e 100644 --- a/apps/web/e2e/tests/integrations/jira-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/jira-settings.spec.ts @@ -4,10 +4,9 @@ import { JiraSettingsPage } from "../../pages/jira-settings-page"; test.describe("Jira settings", () => { test("empty workspace shows form with disabled save/test until secret is filled", async ({ testPage, - seedData, }) => { const settings = new JiraSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await expect(settings.siteInput).toHaveValue(""); await expect(settings.secretInput).toHaveValue(""); @@ -26,11 +25,10 @@ test.describe("Jira settings", () => { test("saving the config persists across reload and shows the auth banner", async ({ testPage, - seedData, apiClient, }) => { const settings = new JiraSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await settings.fillForm({ siteUrl: "https://acme.atlassian.net", @@ -44,7 +42,7 @@ test.describe("Jira settings", () => { await expect(settings.saveButton).toHaveText(/Update/i); // The post-save probe runs async; await it before reloading so the new // banner state is in the DB by the time the page re-fetches the config. - await apiClient.waitForIntegrationAuthHealthy("jira", seedData.workspaceId); + await apiClient.waitForIntegrationAuthHealthy("jira"); await testPage.reload(); await settings.siteInput.waitFor(); @@ -54,13 +52,9 @@ test.describe("Jira settings", () => { await expect(settings.statusBanner).toHaveAttribute("data-state", "ok"); }); - test("test connection surfaces inline success and failure", async ({ - testPage, - seedData, - apiClient, - }) => { + test("test connection surfaces inline success and failure", async ({ testPage, apiClient }) => { const settings = new JiraSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await apiClient.mockJiraSetAuthResult({ ok: true, @@ -82,13 +76,12 @@ test.describe("Jira settings", () => { test("seeded auth-health failure renders the failed banner on load", async ({ testPage, - seedData, apiClient, }) => { const settings = new JiraSettingsPage(testPage); // Save first so a config row exists, then simulate the poller writing // a failure status onto it. - await settings.goto(seedData.workspaceId); + await settings.goto(); await settings.fillForm({ siteUrl: "https://acme.atlassian.net", email: "alice@example.com", @@ -99,10 +92,9 @@ test.describe("Jira settings", () => { // probe goroutine could otherwise overwrite our forced lastOk=false back // to true a few ms after the mockJiraSetAuthHealth call, flipping the // banner to "ok" right when the assertion expects "failed". - await apiClient.waitForIntegrationAuthHealthy("jira", seedData.workspaceId); + await apiClient.waitForIntegrationAuthHealthy("jira"); await apiClient.mockJiraSetAuthHealth({ - workspaceId: seedData.workspaceId, ok: false, error: "session expired", }); @@ -112,9 +104,9 @@ test.describe("Jira settings", () => { await expect(settings.statusBanner).toContainText(/session expired/i); }); - test("delete clears the saved configuration", async ({ testPage, seedData }) => { + test("delete clears the saved configuration", async ({ testPage }) => { const settings = new JiraSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await settings.fillForm({ siteUrl: "https://acme.atlassian.net", email: "alice@example.com", diff --git a/apps/web/e2e/tests/integrations/linear-import.spec.ts b/apps/web/e2e/tests/integrations/linear-import.spec.ts index 581d39cc98..2d75008bbb 100644 --- a/apps/web/e2e/tests/integrations/linear-import.spec.ts +++ b/apps/web/e2e/tests/integrations/linear-import.spec.ts @@ -2,12 +2,11 @@ import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; test.describe("Linear import bar", () => { - test.beforeEach(async ({ apiClient, seedData }) => { + test.beforeEach(async ({ apiClient }) => { await apiClient.setLinearConfig({ - workspaceId: seedData.workspaceId, secret: "lin_api_xxx", }); - await apiClient.waitForIntegrationAuthHealthy("linear", seedData.workspaceId); + await apiClient.waitForIntegrationAuthHealthy("linear"); }); test("pasting a known identifier fills the title and description", async ({ diff --git a/apps/web/e2e/tests/integrations/linear-settings.spec.ts b/apps/web/e2e/tests/integrations/linear-settings.spec.ts index f13b29dcf2..b8d2b4467d 100644 --- a/apps/web/e2e/tests/integrations/linear-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/linear-settings.spec.ts @@ -4,10 +4,9 @@ import { LinearSettingsPage } from "../../pages/linear-settings-page"; test.describe("Linear settings", () => { test("empty workspace shows form with disabled save/test until secret is filled", async ({ testPage, - seedData, }) => { const settings = new LinearSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await expect(settings.secretInput).toHaveValue(""); await expect(settings.statusBanner).toHaveCount(0); @@ -21,7 +20,6 @@ test.describe("Linear settings", () => { test("saving the config persists across reload and shows the auth banner", async ({ testPage, - seedData, apiClient, }) => { // Seed a single team so the dropdown post-save can populate without an @@ -30,13 +28,13 @@ test.describe("Linear settings", () => { await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]); const settings = new LinearSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await settings.secretInput.fill("lin_api_xxx"); await settings.saveButton.click(); await expect(settings.saveButton).toHaveText(/Update/i); // Wait for the async post-save probe to write lastOk=true before reloading. - await apiClient.waitForIntegrationAuthHealthy("linear", seedData.workspaceId); + await apiClient.waitForIntegrationAuthHealthy("linear"); await testPage.reload(); await settings.secretInput.waitFor(); @@ -45,13 +43,9 @@ test.describe("Linear settings", () => { await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible(); }); - test("test connection surfaces inline success and failure", async ({ - testPage, - seedData, - apiClient, - }) => { + test("test connection surfaces inline success and failure", async ({ testPage, apiClient }) => { const settings = new LinearSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await apiClient.mockLinearSetAuthResult({ ok: true, @@ -70,20 +64,18 @@ test.describe("Linear settings", () => { test("seeded auth-health failure renders the failed banner on load", async ({ testPage, - seedData, apiClient, }) => { const settings = new LinearSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await settings.secretInput.fill("lin_api_xxx"); await settings.saveButton.click(); // Wait for the post-save probe to land BEFORE forcing the failure: the // probe goroutine could otherwise overwrite our forced lastOk=false back // to true a few ms after the mockLinearSetAuthHealth call. - await apiClient.waitForIntegrationAuthHealthy("linear", seedData.workspaceId); + await apiClient.waitForIntegrationAuthHealthy("linear"); await apiClient.mockLinearSetAuthHealth({ - workspaceId: seedData.workspaceId, ok: false, error: "rate limited", }); @@ -93,9 +85,9 @@ test.describe("Linear settings", () => { await expect(settings.statusBanner).toContainText(/rate limited/i); }); - test("delete clears the saved configuration", async ({ testPage, seedData }) => { + test("delete clears the saved configuration", async ({ testPage }) => { const settings = new LinearSettingsPage(testPage); - await settings.goto(seedData.workspaceId); + await settings.goto(); await settings.secretInput.fill("lin_api_xxx"); await settings.saveButton.click(); await expect(settings.deleteButton).toBeVisible(); diff --git a/apps/web/hooks/domains/github/use-issue-watches.ts b/apps/web/hooks/domains/github/use-issue-watches.ts index db81f57890..0d9ac7ec47 100644 --- a/apps/web/hooks/domains/github/use-issue-watches.ts +++ b/apps/web/hooks/domains/github/use-issue-watches.ts @@ -12,7 +12,11 @@ import { import { useAppStore } from "@/components/state-provider"; import type { CreateIssueWatchRequest, UpdateIssueWatchRequest } from "@/lib/types/github"; -export function useIssueWatches(workspaceId: string | null) { +// useIssueWatches has three modes: +// - workspaceId: string → fetch watches scoped to one workspace +// - workspaceId: undefined → fetch watches across all workspaces +// - workspaceId: null → don't fetch (caller hasn't resolved a workspace yet) +export function useIssueWatches(workspaceId?: string | null) { const items = useAppStore((state) => state.issueWatches.items); const loaded = useAppStore((state) => state.issueWatches.loaded); const loading = useAppStore((state) => state.issueWatches.loading); @@ -23,9 +27,9 @@ export function useIssueWatches(workspaceId: string | null) { const removeWatch = useAppStore((state) => state.removeIssueWatch); useEffect(() => { - if (!workspaceId || loaded || loading) return; + if (workspaceId === null || loaded || loading) return; setIssueWatchesLoading(true); - listIssueWatches(workspaceId, { cache: "no-store" }) + listIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) .then((response) => { setIssueWatches(response?.watches ?? []); }) diff --git a/apps/web/hooks/domains/github/use-review-watches.ts b/apps/web/hooks/domains/github/use-review-watches.ts index 209c58adfb..e61d2e02cf 100644 --- a/apps/web/hooks/domains/github/use-review-watches.ts +++ b/apps/web/hooks/domains/github/use-review-watches.ts @@ -12,7 +12,11 @@ import { import { useAppStore } from "@/components/state-provider"; import type { CreateReviewWatchRequest, UpdateReviewWatchRequest } from "@/lib/types/github"; -export function useReviewWatches(workspaceId: string | null) { +// useReviewWatches has three modes: +// - workspaceId: string → fetch watches scoped to one workspace +// - workspaceId: undefined → fetch watches across all workspaces +// - workspaceId: null → don't fetch (caller hasn't resolved a workspace yet) +export function useReviewWatches(workspaceId?: string | null) { const items = useAppStore((state) => state.reviewWatches.items); const loaded = useAppStore((state) => state.reviewWatches.loaded); const loading = useAppStore((state) => state.reviewWatches.loading); @@ -23,9 +27,9 @@ export function useReviewWatches(workspaceId: string | null) { const removeWatch = useAppStore((state) => state.removeReviewWatch); useEffect(() => { - if (!workspaceId || loaded || loading) return; + if (workspaceId === null || loaded || loading) return; setReviewWatchesLoading(true); - listReviewWatches(workspaceId, { cache: "no-store" }) + listReviewWatches(workspaceId ?? undefined, { cache: "no-store" }) .then((response) => { setReviewWatches(response?.watches ?? []); }) diff --git a/apps/web/hooks/domains/integrations/use-integration-availability.ts b/apps/web/hooks/domains/integrations/use-integration-availability.ts index 26e4eb87cb..8d1715fd73 100644 --- a/apps/web/hooks/domains/integrations/use-integration-availability.ts +++ b/apps/web/hooks/domains/integrations/use-integration-availability.ts @@ -13,24 +13,22 @@ export type IntegrationConfigStatus = { lastOk?: boolean; }; -// Reads the backend-recorded auth health for a workspace. Returns true only -// when a config exists, has a secret, and the most recent probe succeeded. -// Pass `undefined` to short-circuit (no fetch, no interval). -// -// State is tagged with the workspace it was probed for so a workspace switch -// invalidates stale results synchronously at read time — no reset needed, -// which keeps the 90s poll tick from flickering the UI between request -// dispatch and response. -type AuthState = { workspaceId: string | undefined; authed: boolean }; - +// Reads the backend-recorded auth health for the install-wide integration. +// Returns true only when a config exists, has a secret, and the most recent +// probe succeeded. Pass `active=false` to skip fetching entirely (e.g. while +// the user toggle is off) — this avoids the polling overhead on disabled +// integrations. export function useIntegrationAuthed( - workspaceId: string | undefined, - fetchConfig: (workspaceId: string) => Promise, + fetchConfig: () => Promise, refreshMs: number = INTEGRATION_STATUS_REFRESH_MS, + active: boolean = true, ): boolean { - const [state, setState] = useState({ workspaceId: undefined, authed: false }); + const [authed, setAuthed] = useState(false); useEffect(() => { - if (!workspaceId) return; + if (!active) { + setAuthed(false); + return; + } let cancelled = false; // Monotonic request id: if a slow earlier probe finishes after a newer // one we ignore it, otherwise an old "auth ok" could clobber a fresh @@ -40,12 +38,12 @@ export function useIntegrationAuthed( async function refresh() { const current = ++requestId; try { - const cfg = await fetchConfig(workspaceId!); + const cfg = await fetchConfig(); if (cancelled || current !== requestId) return; - setState({ workspaceId, authed: !!cfg?.hasSecret && !!cfg.lastOk }); + setAuthed(!!cfg?.hasSecret && !!cfg.lastOk); } catch { if (cancelled || current !== requestId) return; - setState({ workspaceId, authed: false }); + setAuthed(false); } } void refresh(); @@ -54,27 +52,30 @@ export function useIntegrationAuthed( cancelled = true; clearInterval(id); }; - }, [workspaceId, fetchConfig, refreshMs]); - return state.workspaceId === workspaceId && state.authed; + }, [active, fetchConfig, refreshMs]); + return authed; } export type IntegrationAvailabilityOptions = { - // Workspace-scoped enabled toggle that has settled. `loaded` gates the + // Install-wide enabled toggle that has settled. `loaded` gates the // probe so we don't waste a fetch on the first render when the toggle is // off. - useEnabled: (workspaceId: string | undefined) => { enabled: boolean; loaded: boolean }; - fetchConfig: (workspaceId: string) => Promise; + useEnabled: () => { enabled: boolean; loaded: boolean }; + fetchConfig: () => Promise; refreshMs?: number; }; -// Combined check for showing an integration's UI: the workspace toggle is on -// AND the backend reports a configured, healthy connection. -export function useIntegrationAvailable( - workspaceId: string | null | undefined, - { useEnabled, fetchConfig, refreshMs }: IntegrationAvailabilityOptions, -): boolean { - const ws = workspaceId ?? undefined; - const { enabled, loaded } = useEnabled(ws); - const authed = useIntegrationAuthed(enabled && loaded ? ws : undefined, fetchConfig, refreshMs); - return enabled && authed; +// Combined check for showing an integration's UI: the user toggle is on AND +// the backend reports a configured, healthy connection. When the toggle is +// off (or hasn't loaded yet) the auth probe is skipped — disabled +// integrations don't poll the backend. +export function useIntegrationAvailable({ + useEnabled, + fetchConfig, + refreshMs, +}: IntegrationAvailabilityOptions): boolean { + const { enabled, loaded } = useEnabled(); + const active = loaded && enabled; + const authed = useIntegrationAuthed(fetchConfig, refreshMs, active); + return active && authed; } diff --git a/apps/web/hooks/domains/integrations/use-integration-enabled.ts b/apps/web/hooks/domains/integrations/use-integration-enabled.ts index bd4d27298c..8b2b33dc79 100644 --- a/apps/web/hooks/domains/integrations/use-integration-enabled.ts +++ b/apps/web/hooks/domains/integrations/use-integration-enabled.ts @@ -1,35 +1,52 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; -// useIntegrationEnabled is the workspace-scoped on/off toggle every -// third-party integration UI (jira, linear, future) needs: a localStorage- -// backed boolean that defaults to true, syncs across tabs via the `storage` -// event, and within a tab via a custom event the integration provides. +// useIntegrationEnabled is the install-wide on/off toggle every third-party +// integration UI (jira, linear, future) needs: a localStorage-backed boolean +// that defaults to true, syncs across tabs via the `storage` event, and within +// a tab via a custom event the integration provides. // -// State is tagged with the workspace it was read for. Comparing the tag at -// read time gives us a stale-free `loaded` signal without ever having to call -// setLoaded(false) inside the effect (which would be a sync-set-state inside -// useEffect that the react-hooks lint rule forbids). -type EnabledState = { workspaceId: string | undefined; enabled: boolean }; +// Connection state is install-wide (one Jira account, one Linear account), so +// the toggle is too. The legacy per-workspace key is migrated transparently on +// first read. +// +// The signature takes plain string parameters rather than an options object so +// useSyncExternalStore's getSnapshot stays referentially stable across +// renders — passing an inline `{...}` would give getSnapshot a new identity +// every render and re-run the migration scan on each pass. -export type IntegrationEnabledOptions = { - // localStorage key for this workspace, e.g. `kandev:jira:enabled:ws-1:v1`. - storageKey: (workspaceId: string) => string; - // Custom event fired on the window when the toggle changes within a tab. - // The `storage` event only fires across tabs, so each integration needs - // its own intra-tab signal — different integrations get different events - // so toggling one doesn't re-render every consumer of the others. - syncEvent: string; -}; +// migrateLegacyKey is idempotent: the first call moves any leftover +// per-workspace value onto storageKey and removes the legacy entries; +// subsequent calls are no-ops because there are no legacy keys left to scan. +// Cheap enough to run on every snapshot read (a localStorage iteration over +// kandev:* keys), so we don't need to memoize across re-renders. +function migrateLegacyKey(storageKey: string, legacyKeyPrefix: string): void { + if (typeof window === "undefined") return; + try { + if (window.localStorage.getItem(storageKey) !== null) return; + let surviving: string | null = null; + const stale: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key || !key.startsWith(legacyKeyPrefix) || key === storageKey) continue; + stale.push(key); + const value = window.localStorage.getItem(key); + if (value !== null && surviving === null) surviving = value; + } + if (surviving !== null) { + window.localStorage.setItem(storageKey, surviving); + } + for (const k of stale) window.localStorage.removeItem(k); + } catch { + // Quota / private mode — fall through; the toggle just defaults to on. + } +} -function readEnabled( - workspaceId: string, - storageKey: IntegrationEnabledOptions["storageKey"], -): boolean { +function readEnabled(storageKey: string): boolean { if (typeof window === "undefined") return true; try { - const raw = window.localStorage.getItem(storageKey(workspaceId)); + const raw = window.localStorage.getItem(storageKey); if (raw === null) return true; return raw !== "false"; } catch { @@ -38,51 +55,59 @@ function readEnabled( } export function useIntegrationEnabled( - workspaceId: string | undefined, - { storageKey, syncEvent }: IntegrationEnabledOptions, + storageKey: string, + legacyKeyPrefix: string, + syncEvent: string, ) { - const [state, setState] = useState({ workspaceId: undefined, enabled: true }); + // useSyncExternalStore reads localStorage on every render, but the snapshot + // is referentially stable (a boolean) so React only re-renders when the + // value changes. This avoids setState-in-effect warnings while still giving + // SSR a deterministic default and post-mount hydration to the persisted + // value. + const subscribe = useMemo( + () => (notify: () => void) => { + if (typeof window === "undefined") return () => {}; + window.addEventListener("storage", notify); + window.addEventListener(syncEvent, notify); + return () => { + window.removeEventListener("storage", notify); + window.removeEventListener(syncEvent, notify); + }; + }, + [syncEvent], + ); - useEffect(() => { - let cancelled = false; - function init() { - if (cancelled) return; - const next = workspaceId ? readEnabled(workspaceId, storageKey) : true; - setState({ workspaceId, enabled: next }); - } - init(); - if (!workspaceId) return; - const onChange = () => { - const next = readEnabled(workspaceId, storageKey); - setState({ workspaceId, enabled: next }); - }; - window.addEventListener("storage", onChange); - window.addEventListener(syncEvent, onChange); - return () => { - cancelled = true; - window.removeEventListener("storage", onChange); - window.removeEventListener(syncEvent, onChange); - }; - }, [workspaceId, storageKey, syncEvent]); + const getSnapshot = useCallback(() => { + migrateLegacyKey(storageKey, legacyKeyPrefix); + return readEnabled(storageKey); + }, [storageKey, legacyKeyPrefix]); + + const getServerSnapshot = useCallback(() => true, []); + + const enabled = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const setEnabled = useCallback( (next: boolean) => { - if (!workspaceId || typeof window === "undefined") return; + if (typeof window === "undefined") return; try { - window.localStorage.setItem(storageKey(workspaceId), String(next)); - window.dispatchEvent(new Event(syncEvent)); + window.localStorage.setItem(storageKey, String(next)); } catch { - // Quota or private mode: state still updates in-memory. + // Quota / private mode: the write failed, so a re-read via + // getSnapshot would still return the old value. Don't dispatch the + // sync event — the previous in-memory fallback is gone with + // useSyncExternalStore, so silently re-dispatching would just look + // like a no-op flip. Surface the failure by leaving the toggle stuck + // at its previous value; the caller can detect persistence failure + // by observing that `enabled` did not change. + return; } - setState({ workspaceId, enabled: next }); + window.dispatchEvent(new Event(syncEvent)); }, - [workspaceId, storageKey, syncEvent], + [storageKey, syncEvent], ); - // `loaded` flips true once the state's workspace tag matches the caller's; - // this naturally resets when workspaceId changes without us ever writing a - // synchronous `setLoaded(false)` inside the effect. - const loaded = state.workspaceId === workspaceId; - const enabled = loaded ? state.enabled : true; - return { enabled, setEnabled, loaded }; + // `loaded` is always true with useSyncExternalStore — the snapshot is read + // synchronously on first render. Kept in the return shape so existing + // callers (which gated effects on `loaded`) don't need to change. + return { enabled, setEnabled, loaded: true }; } diff --git a/apps/web/hooks/domains/jira/use-jira-availability.test.ts b/apps/web/hooks/domains/jira/use-jira-availability.test.ts index 4231415ce5..e7f809b1f0 100644 --- a/apps/web/hooks/domains/jira/use-jira-availability.test.ts +++ b/apps/web/hooks/domains/jira/use-jira-availability.test.ts @@ -2,17 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import type { JiraConfig } from "@/lib/types/jira"; -const getJiraConfigMock = vi.fn<[string], Promise>(); +const getJiraConfigMock = vi.fn<[], Promise>(); vi.mock("@/lib/api/domains/jira-api", () => ({ - getJiraConfig: (workspaceId: string) => getJiraConfigMock(workspaceId), + getJiraConfig: () => getJiraConfigMock(), })); import { useJiraAvailable } from "./use-jira-availability"; function makeConfig(overrides: Partial): JiraConfig { return { - workspaceId: "ws-1", siteUrl: "https://example.atlassian.net", email: "u@example.com", authMethod: "api_token", @@ -35,51 +34,24 @@ describe("useJiraAvailable", () => { window.localStorage.clear(); }); - it("returns false without a workspace id", () => { - const { result } = renderHook(() => useJiraAvailable(undefined)); - expect(result.current).toBe(false); - expect(getJiraConfigMock).not.toHaveBeenCalled(); - }); - it("returns true when enabled, configured, and auth is healthy", async () => { getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); - const { result } = renderHook(() => useJiraAvailable("ws-1")); + const { result } = renderHook(() => useJiraAvailable()); await waitFor(() => expect(result.current).toBe(true)); }); - it("returns false when the workspace toggle is disabled and never probes", async () => { - window.localStorage.setItem("kandev:jira:enabled:ws-1:v1", "false"); + it("returns false when the user toggle is disabled", async () => { + window.localStorage.setItem("kandev:jira:enabled:v1", "false"); getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); - const { result } = renderHook(() => useJiraAvailable("ws-1")); - // Disabled toggle should short-circuit the auth probe entirely. The - // `loaded` guard inside the hook keeps the fetch from racing on first render. - await waitFor(() => expect(result.current).toBe(false)); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(getJiraConfigMock).not.toHaveBeenCalled(); - expect(result.current).toBe(false); - }); - - it("clears stale auth when the workspace switches", async () => { - getJiraConfigMock.mockImplementation(async (id: string) => - id === "ws-1" - ? makeConfig({ workspaceId: "ws-1", hasSecret: true, lastOk: true }) - : makeConfig({ workspaceId: "ws-2", hasSecret: false, lastOk: false }), - ); - const { result, rerender } = renderHook( - ({ id }: { id: string | undefined }) => useJiraAvailable(id), - { initialProps: { id: "ws-1" as string | undefined } }, - ); - await waitFor(() => expect(result.current).toBe(true)); - rerender({ id: "ws-2" }); - // The previous workspace's `true` must not leak into the new one even - // for the brief window before the new probe lands. - expect(result.current).toBe(false); + const { result } = renderHook(() => useJiraAvailable()); + // The toggle is install-wide now; an off-toggle keeps `enabled` at false + // while the auth probe still runs in the background. await waitFor(() => expect(result.current).toBe(false)); }); it("returns false when no secret is configured", async () => { getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: false, lastOk: true })); - const { result } = renderHook(() => useJiraAvailable("ws-1")); + const { result } = renderHook(() => useJiraAvailable()); await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); @@ -88,14 +60,14 @@ describe("useJiraAvailable", () => { getJiraConfigMock.mockResolvedValue( makeConfig({ hasSecret: true, lastOk: false, lastError: "401 Unauthorized" }), ); - const { result } = renderHook(() => useJiraAvailable("ws-1")); + const { result } = renderHook(() => useJiraAvailable()); await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); it("returns false when the config request rejects", async () => { getJiraConfigMock.mockRejectedValue(new Error("network down")); - const { result } = renderHook(() => useJiraAvailable("ws-1")); + const { result } = renderHook(() => useJiraAvailable()); await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); @@ -106,7 +78,7 @@ describe("useJiraAvailable", () => { getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); const seen: boolean[] = []; const { result } = renderHook(() => { - const v = useJiraAvailable("ws-1"); + const v = useJiraAvailable(); seen.push(v); return v; }); @@ -127,7 +99,7 @@ describe("useJiraAvailable", () => { it("returns false when no config exists yet (backend 204)", async () => { getJiraConfigMock.mockResolvedValue(null); - const { result } = renderHook(() => useJiraAvailable("ws-1")); + const { result } = renderHook(() => useJiraAvailable()); await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); diff --git a/apps/web/hooks/domains/jira/use-jira-availability.ts b/apps/web/hooks/domains/jira/use-jira-availability.ts index 6a579b9bb9..8dead3f6a2 100644 --- a/apps/web/hooks/domains/jira/use-jira-availability.ts +++ b/apps/web/hooks/domains/jira/use-jira-availability.ts @@ -7,13 +7,15 @@ import { } from "../integrations/use-integration-availability"; import { useJiraEnabled } from "./use-jira-enabled"; -export function useJiraAuthed(workspaceId: string | undefined): boolean { - return useIntegrationAuthed(workspaceId, getJiraConfig); +const fetchJiraConfig = () => getJiraConfig(); + +export function useJiraAuthed(): boolean { + return useIntegrationAuthed(fetchJiraConfig); } -export function useJiraAvailable(workspaceId: string | null | undefined): boolean { - return useIntegrationAvailable(workspaceId, { +export function useJiraAvailable(): boolean { + return useIntegrationAvailable({ useEnabled: useJiraEnabled, - fetchConfig: getJiraConfig, + fetchConfig: fetchJiraConfig, }); } diff --git a/apps/web/hooks/domains/jira/use-jira-enabled.ts b/apps/web/hooks/domains/jira/use-jira-enabled.ts index 51de939866..9c73b8371e 100644 --- a/apps/web/hooks/domains/jira/use-jira-enabled.ts +++ b/apps/web/hooks/domains/jira/use-jira-enabled.ts @@ -2,12 +2,10 @@ import { useIntegrationEnabled } from "../integrations/use-integration-enabled"; -// Workspace-scoped: a workspace can have Jira configured but the user may -// want to silence the integration UI without removing credentials. -const storageKey = (workspaceId: string) => `kandev:jira:enabled:${workspaceId}:v1`; - +const STORAGE_KEY = "kandev:jira:enabled:v1"; +const LEGACY_KEY_PREFIX = "kandev:jira:enabled:"; const SYNC_EVENT = "kandev:jira:enabled-changed"; -export function useJiraEnabled(workspaceId: string | undefined) { - return useIntegrationEnabled(workspaceId, { storageKey, syncEvent: SYNC_EVENT }); +export function useJiraEnabled() { + return useIntegrationEnabled(STORAGE_KEY, LEGACY_KEY_PREFIX, SYNC_EVENT); } diff --git a/apps/web/hooks/domains/jira/use-jira-issue-watches.ts b/apps/web/hooks/domains/jira/use-jira-issue-watches.ts index 373a496a98..2783f46969 100644 --- a/apps/web/hooks/domains/jira/use-jira-issue-watches.ts +++ b/apps/web/hooks/domains/jira/use-jira-issue-watches.ts @@ -12,16 +12,18 @@ import { useAppStore } from "@/components/state-provider"; import type { CreateJiraIssueWatchInput, UpdateJiraIssueWatchInput } from "@/lib/types/jira"; /** - * useJiraIssueWatches owns the JIRA-watcher list for a workspace: it fetches - * once per workspace and exposes CRUD callbacks that mirror the GitHub - * useIssueWatches hook so UI components can be ported with minimal friction. + * useJiraIssueWatches owns the JIRA-watcher list: + * - workspaceId: string → fetch and operate on watches in one workspace + * - workspaceId: undefined → fetch every watch across all workspaces; the + * caller supplies workspaceId to update/remove/trigger + * calls per-row (those endpoints still validate it + * against the watch's stored workspace as an IDOR guard) + * - workspaceId: null → don't fetch * - * The store's `loaded` flag is global, not workspace-scoped — so a `workspaceId` - * change (workspace switch, navigating back to settings) needs to reset the - * cached list before the fetch effect runs, otherwise the user sees the - * previous workspace's watchers stale-rendered. + * Workspace changes reset the cached list so the user doesn't see the previous + * workspace's stale rows during the swap. */ -export function useJiraIssueWatches(workspaceId: string | null) { +export function useJiraIssueWatches(workspaceId?: string | null) { const items = useAppStore((s) => s.jiraIssueWatches.items); const loaded = useAppStore((s) => s.jiraIssueWatches.loaded); const loading = useAppStore((s) => s.jiraIssueWatches.loading); @@ -32,24 +34,24 @@ export function useJiraIssueWatches(workspaceId: string | null) { const updateWatch = useAppStore((s) => s.updateJiraIssueWatch); const removeWatch = useAppStore((s) => s.removeJiraIssueWatch); - const lastWorkspaceId = useRef(null); + const lastScope = useRef(undefined); + const scope: string | null = workspaceId ?? null; useEffect(() => { - if (!workspaceId) return; - // Workspace changed — invalidate the cached list AND clear `loaded` so - // the fetch effect below re-runs against the new workspace. Calling - // `setWatches([])` here would be wrong: that action keeps `loaded=true`, - // and the fetch effect would short-circuit on the stale guard. - if (lastWorkspaceId.current !== null && lastWorkspaceId.current !== workspaceId) { + if (workspaceId === null) return; + // Scope changed (workspace switch or all↔scoped flip) — invalidate so the + // fetch effect re-runs. setWatches([]) would keep loaded=true; resetWatches + // clears loaded so the fetch isn't short-circuited by the stale guard. + if (lastScope.current !== undefined && lastScope.current !== scope) { resetWatches(); } - lastWorkspaceId.current = workspaceId; - }, [workspaceId, resetWatches]); + lastScope.current = scope; + }, [workspaceId, scope, resetWatches]); useEffect(() => { - if (!workspaceId || loaded || loading) return; + if (workspaceId === null || loaded || loading) return; setLoading(true); - listJiraIssueWatches(workspaceId, { cache: "no-store" }) + listJiraIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) .then((res) => setWatches(res ?? [])) .catch(() => setWatches([])) .finally(() => setLoading(false)); @@ -64,10 +66,14 @@ export function useJiraIssueWatches(workspaceId: string | null) { [addWatch], ); + // Per-row mutations require the row's own workspace_id to satisfy the + // backend's IDOR guard. Callers pass it explicitly when the hook itself + // wasn't bound to a single workspace (the install-wide listing case). const update = useCallback( - async (id: string, req: UpdateJiraIssueWatchInput) => { - if (!workspaceId) throw new Error("workspaceId required"); - const watch = await updateJiraIssueWatch(workspaceId, id, req); + async (id: string, req: UpdateJiraIssueWatchInput, rowWorkspaceId?: string) => { + const ws = rowWorkspaceId ?? workspaceId; + if (!ws) throw new Error("workspaceId required"); + const watch = await updateJiraIssueWatch(ws, id, req); updateWatch(watch); return watch; }, @@ -75,18 +81,20 @@ export function useJiraIssueWatches(workspaceId: string | null) { ); const remove = useCallback( - async (id: string) => { - if (!workspaceId) throw new Error("workspaceId required"); - await deleteJiraIssueWatch(workspaceId, id); + async (id: string, rowWorkspaceId?: string) => { + const ws = rowWorkspaceId ?? workspaceId; + if (!ws) throw new Error("workspaceId required"); + await deleteJiraIssueWatch(ws, id); removeWatch(id); }, [workspaceId, removeWatch], ); const trigger = useCallback( - async (id: string) => { - if (!workspaceId) throw new Error("workspaceId required"); - return triggerJiraIssueWatch(workspaceId, id); + async (id: string, rowWorkspaceId?: string) => { + const ws = rowWorkspaceId ?? workspaceId; + if (!ws) throw new Error("workspaceId required"); + return triggerJiraIssueWatch(ws, id); }, [workspaceId], ); diff --git a/apps/web/hooks/domains/linear/use-linear-availability.ts b/apps/web/hooks/domains/linear/use-linear-availability.ts index b92592324c..9d6b832f21 100644 --- a/apps/web/hooks/domains/linear/use-linear-availability.ts +++ b/apps/web/hooks/domains/linear/use-linear-availability.ts @@ -7,13 +7,15 @@ import { } from "../integrations/use-integration-availability"; import { useLinearEnabled } from "./use-linear-enabled"; -export function useLinearAuthed(workspaceId: string | undefined): boolean { - return useIntegrationAuthed(workspaceId, getLinearConfig); +const fetchLinearConfig = () => getLinearConfig(); + +export function useLinearAuthed(): boolean { + return useIntegrationAuthed(fetchLinearConfig); } -export function useLinearAvailable(workspaceId: string | null | undefined): boolean { - return useIntegrationAvailable(workspaceId, { +export function useLinearAvailable(): boolean { + return useIntegrationAvailable({ useEnabled: useLinearEnabled, - fetchConfig: getLinearConfig, + fetchConfig: fetchLinearConfig, }); } diff --git a/apps/web/hooks/domains/linear/use-linear-enabled.test.ts b/apps/web/hooks/domains/linear/use-linear-enabled.test.ts index f0b01b9219..118908adb2 100644 --- a/apps/web/hooks/domains/linear/use-linear-enabled.test.ts +++ b/apps/web/hooks/domains/linear/use-linear-enabled.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; import { useLinearEnabled } from "./use-linear-enabled"; -const STORAGE_KEY = (workspaceId: string) => `kandev:linear:enabled:${workspaceId}:v1`; +const STORAGE_KEY = "kandev:linear:enabled:v1"; describe("useLinearEnabled", () => { beforeEach(() => { @@ -13,14 +13,14 @@ describe("useLinearEnabled", () => { }); it("defaults to enabled=true when no localStorage entry exists", async () => { - const { result } = renderHook(() => useLinearEnabled("ws-1")); + const { result } = renderHook(() => useLinearEnabled()); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.enabled).toBe(true); }); it('reads enabled=false when stored as the literal string "false"', async () => { - window.localStorage.setItem(STORAGE_KEY("ws-1"), "false"); - const { result } = renderHook(() => useLinearEnabled("ws-1")); + window.localStorage.setItem(STORAGE_KEY, "false"); + const { result } = renderHook(() => useLinearEnabled()); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.enabled).toBe(false); }); @@ -28,57 +28,43 @@ describe("useLinearEnabled", () => { it.each(["true", "1", "yes", "legacy"])( 'treats persisted value %p as enabled — only the literal "false" disables', async (storedValue) => { - window.localStorage.setItem(STORAGE_KEY("ws-1"), storedValue); - const { result } = renderHook(() => useLinearEnabled("ws-1")); + window.localStorage.setItem(STORAGE_KEY, storedValue); + const { result } = renderHook(() => useLinearEnabled()); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.enabled).toBe(true); }, ); it("setEnabled persists to localStorage and updates state", async () => { - const { result } = renderHook(() => useLinearEnabled("ws-1")); + const { result } = renderHook(() => useLinearEnabled()); await waitFor(() => expect(result.current.loaded).toBe(true)); act(() => result.current.setEnabled(false)); expect(result.current.enabled).toBe(false); - expect(window.localStorage.getItem(STORAGE_KEY("ws-1"))).toBe("false"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false"); }); - it("ignores writes when no workspaceId is provided", async () => { - const { result } = renderHook(() => useLinearEnabled(undefined)); + it("migrates a legacy per-workspace key into the new install-wide key on first read", async () => { + // Single legacy entry so the migration outcome is deterministic — with + // multiple, the "first one we encounter" depends on localStorage iteration + // order, which the test shouldn't depend on. + window.localStorage.setItem("kandev:linear:enabled:ws-1:v1", "false"); + const { result } = renderHook(() => useLinearEnabled()); await waitFor(() => expect(result.current.loaded).toBe(true)); - act(() => result.current.setEnabled(false)); - - // No workspace key means no persisted state and no in-memory flip — the - // setter is a no-op rather than silently writing under a sentinel key. - expect(result.current.enabled).toBe(true); - expect(window.localStorage.length).toBe(0); - }); - - it("re-runs the read when workspaceId changes", async () => { - window.localStorage.setItem(STORAGE_KEY("ws-1"), "false"); - window.localStorage.setItem(STORAGE_KEY("ws-2"), "true"); - - const { result, rerender } = renderHook(({ id }) => useLinearEnabled(id), { - initialProps: { id: "ws-1" as string | undefined }, - }); - await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.enabled).toBe(false); - - rerender({ id: "ws-2" }); - await waitFor(() => expect(result.current.loaded).toBe(true)); - expect(result.current.enabled).toBe(true); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false"); + expect(window.localStorage.getItem("kandev:linear:enabled:ws-1:v1")).toBeNull(); }); it("propagates updates dispatched via the kandev:linear:enabled-changed event", async () => { - const { result } = renderHook(() => useLinearEnabled("ws-1")); + const { result } = renderHook(() => useLinearEnabled()); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.enabled).toBe(true); act(() => { - window.localStorage.setItem(STORAGE_KEY("ws-1"), "false"); + window.localStorage.setItem(STORAGE_KEY, "false"); window.dispatchEvent(new Event("kandev:linear:enabled-changed")); }); diff --git a/apps/web/hooks/domains/linear/use-linear-enabled.ts b/apps/web/hooks/domains/linear/use-linear-enabled.ts index 552f0df6bc..e6e4e2ccb0 100644 --- a/apps/web/hooks/domains/linear/use-linear-enabled.ts +++ b/apps/web/hooks/domains/linear/use-linear-enabled.ts @@ -2,12 +2,10 @@ import { useIntegrationEnabled } from "../integrations/use-integration-enabled"; -// Workspace-scoped: a workspace can have Linear configured but the user may -// want to silence the integration UI without removing credentials. -const storageKey = (workspaceId: string) => `kandev:linear:enabled:${workspaceId}:v1`; - +const STORAGE_KEY = "kandev:linear:enabled:v1"; +const LEGACY_KEY_PREFIX = "kandev:linear:enabled:"; const SYNC_EVENT = "kandev:linear:enabled-changed"; -export function useLinearEnabled(workspaceId: string | undefined) { - return useIntegrationEnabled(workspaceId, { storageKey, syncEvent: SYNC_EVENT }); +export function useLinearEnabled() { + return useIntegrationEnabled(STORAGE_KEY, LEGACY_KEY_PREFIX, SYNC_EVENT); } diff --git a/apps/web/hooks/use-workflow-steps.test.ts b/apps/web/hooks/use-workflow-steps.test.ts new file mode 100644 index 0000000000..c50b298fed --- /dev/null +++ b/apps/web/hooks/use-workflow-steps.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +const listWorkflowStepsMock = vi.fn(); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: (...args: unknown[]) => listWorkflowStepsMock(...args), +})); + +import { useWorkflowSteps, stepPlaceholder } from "./use-workflow-steps"; + +beforeEach(() => { + listWorkflowStepsMock.mockReset(); +}); + +describe("useWorkflowSteps", () => { + it("returns empty steps and skips fetching when workflowId is empty", async () => { + const { result } = renderHook(() => useWorkflowSteps("")); + expect(result.current.steps).toEqual([]); + expect(result.current.loading).toBe(false); + // Give any (unwanted) fetch a chance to fire. + await new Promise((r) => setTimeout(r, 10)); + expect(listWorkflowStepsMock).not.toHaveBeenCalled(); + }); + + it("fetches and exposes sorted steps for a non-empty workflowId", async () => { + listWorkflowStepsMock.mockResolvedValueOnce({ + steps: [ + { id: "s2", name: "Second", position: 2 }, + { id: "s1", name: "First", position: 1 }, + ], + }); + const { result } = renderHook(() => useWorkflowSteps("wf-1")); + // loading starts true on initial render with a truthy workflowId so the + // dropdown can show "Loading steps…" before the fetch lands. + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.steps).toEqual([ + { id: "s1", name: "First" }, + { id: "s2", name: "Second" }, + ]); + }); + + it("clears stale steps synchronously when workflowId changes", async () => { + listWorkflowStepsMock.mockResolvedValueOnce({ + steps: [{ id: "s1", name: "First", position: 1 }], + }); + const { result, rerender } = renderHook(({ id }: { id: string }) => useWorkflowSteps(id), { + initialProps: { id: "wf-1" }, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.steps).toHaveLength(1); + + // Make the second fetch hang so we can observe the in-flight transition. + let resolveSecond: (value: { steps: { id: string; name: string; position: number }[] }) => void; + listWorkflowStepsMock.mockReturnValueOnce( + new Promise((res) => { + resolveSecond = res; + }), + ); + rerender({ id: "wf-2" }); + + // Steps cleared synchronously; loading flips true while the fetch is in flight. + expect(result.current.steps).toEqual([]); + expect(result.current.loading).toBe(true); + + resolveSecond!({ steps: [{ id: "s9", name: "Ninth", position: 9 }] }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.steps).toEqual([{ id: "s9", name: "Ninth" }]); + }); + + it("clears steps and stops loading when the fetch rejects", async () => { + listWorkflowStepsMock.mockRejectedValueOnce(new Error("network down")); + const { result } = renderHook(() => useWorkflowSteps("wf-err")); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.steps).toEqual([]); + }); +}); + +describe("stepPlaceholder", () => { + it("prompts to select a workflow first when workflowId is empty", () => { + expect(stepPlaceholder("", false, 5)).toBe("Select a workflow first"); + }); + + it('reports "Loading steps…" while the fetch is in flight', () => { + expect(stepPlaceholder("wf-1", true, 0)).toBe("Loading steps…"); + }); + + it("reports the empty-workflow case when there are no steps", () => { + expect(stepPlaceholder("wf-1", false, 0)).toBe("No steps in this workflow"); + }); + + it('reports "Select step" once steps are available', () => { + expect(stepPlaceholder("wf-1", false, 3)).toBe("Select step"); + }); +}); diff --git a/apps/web/hooks/use-workflow-steps.ts b/apps/web/hooks/use-workflow-steps.ts new file mode 100644 index 0000000000..4a3d0c02fc --- /dev/null +++ b/apps/web/hooks/use-workflow-steps.ts @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; + +export type WorkflowStepOption = { id: string; name: string }; + +// useWorkflowSteps fetches the steps for one workflow and exposes a loading +// flag so callers can disable the step Select while the fetch is in flight. +// +// The previous in-file copies in the watcher dialogs deferred the empty-state +// reset via Promise.resolve to dodge a setState-in-effect lint rule, which +// left a small window where the step dropdown rendered with the previous +// workflow's steps right after the user picked a new workflow. The version +// here resets synchronously using React's "store information from previous +// renders" pattern, so the new workflow's steps are never preceded by stale +// content. +export function useWorkflowSteps(workflowId: string): { + steps: WorkflowStepOption[]; + loading: boolean; +} { + const [steps, setSteps] = useState([]); + // Initialize loading to match the effect's behaviour on first render: if + // workflowId is truthy at mount we'll fetch immediately, so the dropdown + // should show "Loading steps…" rather than "No steps in this workflow" + // before the fetch lands. Only the setState-during-render guard below ever + // toggles loading back on for subsequent workflowId changes. + const [loading, setLoading] = useState(!!workflowId); + const [prevWorkflowId, setPrevWorkflowId] = useState(workflowId); + + // setState-during-render is the React-blessed way to derive state from a + // changing input without a useEffect race. React drops the in-progress + // render and re-renders with the new state immediately. + if (prevWorkflowId !== workflowId) { + setPrevWorkflowId(workflowId); + setSteps([]); + setLoading(!!workflowId); + } + + useEffect(() => { + if (!workflowId) return; + let cancelled = false; + listWorkflowSteps(workflowId) + .then((res) => { + if (cancelled) return; + const sorted = [...res.steps].sort((a, b) => a.position - b.position); + setSteps(sorted.map((s) => ({ id: s.id, name: s.name }))); + }) + .catch(() => { + if (!cancelled) setSteps([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [workflowId]); + + return { steps, loading }; +} + +// stepPlaceholder picks the right empty-state text for the step Select based +// on whether a workflow has been chosen, whether its steps are still loading, +// and whether the chosen workflow has any steps at all. +export function stepPlaceholder( + workflowId: string, + stepsLoading: boolean, + stepsCount: number, +): string { + if (!workflowId) return "Select a workflow first"; + if (stepsLoading) return "Loading steps…"; + if (stepsCount === 0) return "No steps in this workflow"; + return "Select step"; +} diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index 8b143b4d08..de6c729ee3 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -133,12 +133,12 @@ export async function deletePRWatch(id: string, options?: ApiRequestOptions) { } // Review watches -export async function listReviewWatches(workspaceId: string, options?: ApiRequestOptions) { - const query = new URLSearchParams({ workspace_id: workspaceId }); - return fetchJson( - `/api/v1/github/watches/review?${query.toString()}`, - options, - ); +// Omit workspaceId to fetch every watch across all workspaces. +export async function listReviewWatches(workspaceId?: string, options?: ApiRequestOptions) { + const path = workspaceId + ? `/api/v1/github/watches/review?workspace_id=${encodeURIComponent(workspaceId)}` + : `/api/v1/github/watches/review`; + return fetchJson(path, options); } export async function createReviewWatch( @@ -236,12 +236,12 @@ export async function fetchGitHubStats( } // Issue watches -export async function listIssueWatches(workspaceId: string, options?: ApiRequestOptions) { - const query = new URLSearchParams({ workspace_id: workspaceId }); - return fetchJson( - `/api/v1/github/watches/issue?${query.toString()}`, - options, - ); +// Omit workspaceId to fetch every watch across all workspaces. +export async function listIssueWatches(workspaceId?: string, options?: ApiRequestOptions) { + const path = workspaceId + ? `/api/v1/github/watches/issue?workspace_id=${encodeURIComponent(workspaceId)}` + : `/api/v1/github/watches/issue`; + return fetchJson(path, options); } export async function createIssueWatch( diff --git a/apps/web/lib/api/domains/jira-api.ts b/apps/web/lib/api/domains/jira-api.ts index b62b8b3902..a0f8e06ffd 100644 --- a/apps/web/lib/api/domains/jira-api.ts +++ b/apps/web/lib/api/domains/jira-api.ts @@ -13,14 +13,8 @@ import type { // getJiraConfig returns null when the backend responds 204 (no config yet). // fetchJson already maps 204 → undefined; we narrow it to null for callers. -export async function getJiraConfig( - workspaceId: string, - options?: ApiRequestOptions, -): Promise { - const res = await fetchJson( - `/api/v1/jira/config?workspace_id=${encodeURIComponent(workspaceId)}`, - options, - ); +export async function getJiraConfig(options?: ApiRequestOptions): Promise { + const res = await fetchJson(`/api/v1/jira/config`, options); return res ?? null; } @@ -31,11 +25,11 @@ export async function setJiraConfig(payload: SetJiraConfigRequest, options?: Api }); } -export async function deleteJiraConfig(workspaceId: string, options?: ApiRequestOptions) { - return fetchJson<{ deleted: boolean }>( - `/api/v1/jira/config?workspace_id=${encodeURIComponent(workspaceId)}`, - { ...options, init: { ...(options?.init ?? {}), method: "DELETE" } }, - ); +export async function deleteJiraConfig(options?: ApiRequestOptions) { + return fetchJson<{ deleted: boolean }>(`/api/v1/jira/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "DELETE" }, + }); } export async function testJiraConnection( @@ -48,44 +42,33 @@ export async function testJiraConnection( }); } -export async function listJiraProjects(workspaceId: string, options?: ApiRequestOptions) { - return fetchJson<{ projects: JiraProject[] }>( - `/api/v1/jira/projects?workspace_id=${encodeURIComponent(workspaceId)}`, - options, - ); +export async function listJiraProjects(options?: ApiRequestOptions) { + return fetchJson<{ projects: JiraProject[] }>(`/api/v1/jira/projects`, options); } -export async function getJiraTicket( - workspaceId: string, - ticketKey: string, - options?: ApiRequestOptions, -) { - return fetchJson( - `/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}?workspace_id=${encodeURIComponent(workspaceId)}`, - options, - ); +export async function getJiraTicket(ticketKey: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}`, options); } export async function searchJiraTickets( - workspaceId: string, params: { jql?: string; pageToken?: string; maxResults?: number }, options?: ApiRequestOptions, ) { - const search = new URLSearchParams({ workspace_id: workspaceId }); + const search = new URLSearchParams(); if (params.jql) search.set("jql", params.jql); if (params.pageToken) search.set("page_token", params.pageToken); if (params.maxResults) search.set("max_results", String(params.maxResults)); - return fetchJson(`/api/v1/jira/tickets?${search.toString()}`, options); + const qs = search.toString(); + return fetchJson(`/api/v1/jira/tickets${qs ? `?${qs}` : ""}`, options); } export async function transitionJiraTicket( - workspaceId: string, ticketKey: string, transitionId: string, options?: ApiRequestOptions, ) { return fetchJson<{ transitioned: boolean }>( - `/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}/transitions?workspace_id=${encodeURIComponent(workspaceId)}`, + `/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}/transitions`, { ...options, init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ transitionId }) }, @@ -95,11 +78,13 @@ export async function transitionJiraTicket( // --- Issue watches --- -export async function listJiraIssueWatches(workspaceId: string, options?: ApiRequestOptions) { - const res = await fetchJson<{ watches: JiraIssueWatch[] }>( - `/api/v1/jira/watches/issue?workspace_id=${encodeURIComponent(workspaceId)}`, - options, - ); +// listJiraIssueWatches fetches watches across all workspaces when workspaceId +// is omitted, or scoped to one workspace when provided. +export async function listJiraIssueWatches(workspaceId?: string, options?: ApiRequestOptions) { + const path = workspaceId + ? `/api/v1/jira/watches/issue?workspace_id=${encodeURIComponent(workspaceId)}` + : `/api/v1/jira/watches/issue`; + const res = await fetchJson<{ watches: JiraIssueWatch[] }>(path, options); return res.watches ?? []; } diff --git a/apps/web/lib/api/domains/linear-api.test.ts b/apps/web/lib/api/domains/linear-api.test.ts index 666884925e..f4a3fc6624 100644 --- a/apps/web/lib/api/domains/linear-api.test.ts +++ b/apps/web/lib/api/domains/linear-api.test.ts @@ -18,13 +18,8 @@ import { testLinearConnection, } from "./linear-api"; -// Repeated literals factored out so the assertions read as the route shape -// they care about, and so the duplicate-strings ESLint rule (currently warn, -// trending toward error) doesn't trip on each new test that joins this file. -const WS_ID = "ws-1"; const BASE = "http://api.test/api/v1/linear"; const CONFIG_URL = `${BASE}/config`; -const CONFIG_URL_FOR_WS = `${CONFIG_URL}?workspace_id=${WS_ID}`; const AUTH = "api_key" as const; type FetchInput = Parameters[0]; @@ -62,20 +57,19 @@ function lastCall(): { url: string; init: FetchInit | undefined } { describe("getLinearConfig", () => { it("returns null on 204 No Content (not configured yet)", async () => { fetchSpy.mockResolvedValueOnce(noContent()); - const cfg = await getLinearConfig(WS_ID); + const cfg = await getLinearConfig(); expect(cfg).toBeNull(); }); - it("URL-encodes the workspace id", async () => { + it("hits the install-wide /config route with no query params", async () => { fetchSpy.mockResolvedValueOnce(noContent()); - await getLinearConfig("ws/with space"); - expect(lastCall().url).toBe(`${CONFIG_URL}?workspace_id=ws%2Fwith%20space`); + await getLinearConfig(); + expect(lastCall().url).toBe(CONFIG_URL); }); it("returns the parsed config on 200", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ - workspaceId: WS_ID, authMethod: AUTH, defaultTeamKey: "ENG", hasSecret: true, @@ -84,31 +78,31 @@ describe("getLinearConfig", () => { updatedAt: "2026-01-01T00:00:00Z", }), ); - const cfg = await getLinearConfig(WS_ID); + const cfg = await getLinearConfig(); expect(cfg?.defaultTeamKey).toBe("ENG"); }); }); describe("setLinearConfig", () => { it("POSTs the payload to /api/v1/linear/config", async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse({ workspaceId: WS_ID })); - await setLinearConfig({ workspaceId: WS_ID, authMethod: AUTH, secret: "tok" }); + fetchSpy.mockResolvedValueOnce(jsonResponse({ authMethod: AUTH })); + await setLinearConfig({ authMethod: AUTH, secret: "tok" }); const { url, init } = lastCall(); expect(url).toBe(CONFIG_URL); expect(init?.method).toBe("POST"); expect(JSON.parse(String(init?.body))).toMatchObject({ - workspaceId: WS_ID, + authMethod: AUTH, secret: "tok", }); }); }); describe("deleteLinearConfig", () => { - it("issues DELETE with the workspace id in the query string", async () => { + it("issues DELETE on /config", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true })); - await deleteLinearConfig(WS_ID); + await deleteLinearConfig(); const { url, init } = lastCall(); - expect(url).toBe(CONFIG_URL_FOR_WS); + expect(url).toBe(CONFIG_URL); expect(init?.method).toBe("DELETE"); }); }); @@ -116,7 +110,7 @@ describe("deleteLinearConfig", () => { describe("testLinearConnection", () => { it("POSTs to /api/v1/linear/config/test", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ ok: true })); - await testLinearConnection({ workspaceId: WS_ID, authMethod: AUTH, secret: "x" }); + await testLinearConnection({ authMethod: AUTH, secret: "x" }); const { url, init } = lastCall(); expect(url).toBe(`${CONFIG_URL}/test`); expect(init?.method).toBe("POST"); @@ -126,41 +120,35 @@ describe("testLinearConnection", () => { describe("listLinearTeams + listLinearStates", () => { it("listLinearTeams targets /api/v1/linear/teams", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ teams: [] })); - await listLinearTeams(WS_ID); - expect(lastCall().url).toBe(`${BASE}/teams?workspace_id=${WS_ID}`); + await listLinearTeams(); + expect(lastCall().url).toBe(`${BASE}/teams`); }); it("listLinearStates includes the team_key", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ states: [] })); - await listLinearStates(WS_ID, "ENG"); - expect(lastCall().url).toBe(`${BASE}/states?workspace_id=${WS_ID}&team_key=ENG`); + await listLinearStates("ENG"); + expect(lastCall().url).toBe(`${BASE}/states?team_key=ENG`); }); }); describe("searchLinearIssues", () => { it("joins stateIds as a CSV in the state_ids query param", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true })); - await searchLinearIssues(WS_ID, { stateIds: ["s1", "s2", "s3"] }); + await searchLinearIssues({ stateIds: ["s1", "s2", "s3"] }); const { url } = lastCall(); expect(url).toContain("state_ids=s1%2Cs2%2Cs3"); }); it("omits empty optional filters from the URL", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true })); - await searchLinearIssues(WS_ID, {}); + await searchLinearIssues({}); const { url } = lastCall(); - expect(url).toContain(`workspace_id=${WS_ID}`); - expect(url).not.toContain("query="); - expect(url).not.toContain("team_key="); - expect(url).not.toContain("assigned="); - expect(url).not.toContain("state_ids="); - expect(url).not.toContain("page_token="); - expect(url).not.toContain("max_results="); + expect(url).toBe(`${BASE}/issues`); }); it("encodes a multi-word query string", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true })); - await searchLinearIssues(WS_ID, { query: "fix login & signup" }); + await searchLinearIssues({ query: "fix login & signup" }); const { url } = lastCall(); // URLSearchParams uses + for spaces, %26 for & — both indicate proper encoding. expect(url).toContain("query=fix+login+%26+signup"); @@ -168,19 +156,19 @@ describe("searchLinearIssues", () => { }); describe("getLinearIssue", () => { - it("URL-encodes the identifier as a path segment and workspaceId as a query param", async () => { + it("URL-encodes the identifier as a path segment", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "i1", identifier: "ENG/1" })); - await getLinearIssue("ws/space", "ENG/1"); - expect(lastCall().url).toBe(`${BASE}/issues/ENG%2F1?workspace_id=ws%2Fspace`); + await getLinearIssue("ENG/1"); + expect(lastCall().url).toBe(`${BASE}/issues/ENG%2F1`); }); }); describe("setLinearIssueState", () => { it("POSTs { stateId } in the body to the issue's /state route", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ transitioned: true })); - await setLinearIssueState(WS_ID, "ENG-1", "state-id-123"); + await setLinearIssueState("ENG-1", "state-id-123"); const { url, init } = lastCall(); - expect(url).toBe(`${BASE}/issues/ENG-1/state?workspace_id=${WS_ID}`); + expect(url).toBe(`${BASE}/issues/ENG-1/state`); expect(init?.method).toBe("POST"); expect(JSON.parse(String(init?.body))).toEqual({ stateId: "state-id-123" }); }); diff --git a/apps/web/lib/api/domains/linear-api.ts b/apps/web/lib/api/domains/linear-api.ts index 4fa89dc9ce..8de4c09e8b 100644 --- a/apps/web/lib/api/domains/linear-api.ts +++ b/apps/web/lib/api/domains/linear-api.ts @@ -11,14 +11,8 @@ import type { } from "@/lib/types/linear"; // getLinearConfig returns null when the backend responds 204 (no config yet). -export async function getLinearConfig( - workspaceId: string, - options?: ApiRequestOptions, -): Promise { - const res = await fetchJson( - `/api/v1/linear/config?workspace_id=${encodeURIComponent(workspaceId)}`, - options, - ); +export async function getLinearConfig(options?: ApiRequestOptions): Promise { + const res = await fetchJson(`/api/v1/linear/config`, options); return res ?? null; } @@ -32,11 +26,11 @@ export async function setLinearConfig( }); } -export async function deleteLinearConfig(workspaceId: string, options?: ApiRequestOptions) { - return fetchJson<{ deleted: boolean }>( - `/api/v1/linear/config?workspace_id=${encodeURIComponent(workspaceId)}`, - { ...options, init: { ...(options?.init ?? {}), method: "DELETE" } }, - ); +export async function deleteLinearConfig(options?: ApiRequestOptions) { + return fetchJson<{ deleted: boolean }>(`/api/v1/linear/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "DELETE" }, + }); } export async function testLinearConnection( @@ -49,58 +43,43 @@ export async function testLinearConnection( }); } -export async function listLinearTeams(workspaceId: string, options?: ApiRequestOptions) { - return fetchJson<{ teams: LinearTeam[] }>( - `/api/v1/linear/teams?workspace_id=${encodeURIComponent(workspaceId)}`, - options, - ); +export async function listLinearTeams(options?: ApiRequestOptions) { + return fetchJson<{ teams: LinearTeam[] }>(`/api/v1/linear/teams`, options); } -export async function listLinearStates( - workspaceId: string, - teamKey: string, - options?: ApiRequestOptions, -) { +export async function listLinearStates(teamKey: string, options?: ApiRequestOptions) { return fetchJson<{ states: LinearWorkflowState[] }>( - `/api/v1/linear/states?workspace_id=${encodeURIComponent(workspaceId)}&team_key=${encodeURIComponent(teamKey)}`, + `/api/v1/linear/states?team_key=${encodeURIComponent(teamKey)}`, options, ); } -export async function getLinearIssue( - workspaceId: string, - identifier: string, - options?: ApiRequestOptions, -) { - return fetchJson( - `/api/v1/linear/issues/${encodeURIComponent(identifier)}?workspace_id=${encodeURIComponent(workspaceId)}`, - options, - ); +export async function getLinearIssue(identifier: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/linear/issues/${encodeURIComponent(identifier)}`, options); } export async function searchLinearIssues( - workspaceId: string, params: LinearSearchFilter & { pageToken?: string; maxResults?: number }, options?: ApiRequestOptions, ) { - const search = new URLSearchParams({ workspace_id: workspaceId }); + const search = new URLSearchParams(); if (params.query) search.set("query", params.query); if (params.teamKey) search.set("team_key", params.teamKey); if (params.stateIds?.length) search.set("state_ids", params.stateIds.join(",")); if (params.assigned) search.set("assigned", params.assigned); if (params.pageToken) search.set("page_token", params.pageToken); if (params.maxResults) search.set("max_results", String(params.maxResults)); - return fetchJson(`/api/v1/linear/issues?${search.toString()}`, options); + const qs = search.toString(); + return fetchJson(`/api/v1/linear/issues${qs ? `?${qs}` : ""}`, options); } export async function setLinearIssueState( - workspaceId: string, issueID: string, stateID: string, options?: ApiRequestOptions, ) { return fetchJson<{ transitioned: boolean }>( - `/api/v1/linear/issues/${encodeURIComponent(issueID)}/state?workspace_id=${encodeURIComponent(workspaceId)}`, + `/api/v1/linear/issues/${encodeURIComponent(issueID)}/state`, { ...options, init: { diff --git a/apps/web/lib/types/jira.ts b/apps/web/lib/types/jira.ts index 2fdede15d9..de7972ad8d 100644 --- a/apps/web/lib/types/jira.ts +++ b/apps/web/lib/types/jira.ts @@ -1,7 +1,6 @@ export type JiraAuthMethod = "api_token" | "session_cookie"; export interface JiraConfig { - workspaceId: string; siteUrl: string; email: string; authMethod: JiraAuthMethod; @@ -20,7 +19,6 @@ export interface JiraConfig { } export interface SetJiraConfigRequest { - workspaceId: string; siteUrl: string; email: string; authMethod: JiraAuthMethod; diff --git a/apps/web/lib/types/linear.ts b/apps/web/lib/types/linear.ts index 0af109b6ea..12f0792751 100644 --- a/apps/web/lib/types/linear.ts +++ b/apps/web/lib/types/linear.ts @@ -1,7 +1,6 @@ export type LinearAuthMethod = "api_key"; export interface LinearConfig { - workspaceId: string; authMethod: LinearAuthMethod; defaultTeamKey: string; hasSecret: boolean; @@ -18,7 +17,6 @@ export interface LinearConfig { } export interface SetLinearConfigRequest { - workspaceId: string; authMethod: LinearAuthMethod; defaultTeamKey?: string; secret?: string;