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;