diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index 5cafc2d733..e8cb3b53f8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -1017,16 +1017,21 @@ func (m *Manager) RecoverAgentPromptStream(ctx context.Context, sessionID string return fmt.Errorf("agent stream not connected") } if execution.Status == v1.AgentStatusFailed && execution.sessionInitialized && execution.ACPSessionID != "" { - status, err := execution.agentctl.GetStatus(ctx) - if err != nil { - return fmt.Errorf("failed to verify agent status after stream recovery: %w", err) - } - if !status.IsAgentRunning() { - return fmt.Errorf("agent process is not running after stream recovery: %s", status.AgentStatus) - } - if err := m.markBootReadyFromFailed(ctx, execution.ID); err != nil { - return err - } + return m.restoreRecoveredFailedExecution(ctx, execution) + } + return nil +} + +func (m *Manager) restoreRecoveredFailedExecution(ctx context.Context, execution *AgentExecution) error { + status, err := execution.agentctl.GetStatus(ctx) + if err != nil { + return fmt.Errorf("failed to verify agent status after stream recovery: %w", err) + } + if !status.IsAgentRunning() { + return fmt.Errorf("agent process is not running after stream recovery: %s", status.AgentStatus) + } + if err := m.markBootReadyFromFailed(ctx, execution.ID); err != nil { + return err } return nil } diff --git a/apps/backend/internal/backendapp/e2e_reset.go b/apps/backend/internal/backendapp/e2e_reset.go index d10baf17b7..b12de05e75 100644 --- a/apps/backend/internal/backendapp/e2e_reset.go +++ b/apps/backend/internal/backendapp/e2e_reset.go @@ -89,6 +89,9 @@ func handleE2EReset( if _, err := repo.DB().ExecContext(ctx, `DELETE FROM runtime_flag_overrides`); err != nil { log.Warn("e2e reset: runtime flag override cleanup failed", zap.Error(err)) } + if _, err := repo.DB().ExecContext(ctx, `DELETE FROM github_workspace_settings WHERE workspace_id = ?`, workspaceID); err != nil { + log.Warn("e2e reset: GitHub workspace settings cleanup failed", zap.Error(err)) + } // Reset every agent's routing override to the inherit-markers // shape onboarding writes. Without this, an agent-override test diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index d56ed22aac..972039c843 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -90,6 +90,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/action-presets", c.httpGetActionPresets) api.PUT("/action-presets", c.httpUpdateActionPresets) api.POST("/action-presets/reset", c.httpResetActionPresets) + api.GET("/workspace-settings", c.httpGetWorkspaceSettings) + api.PUT("/workspace-settings", c.httpUpdateWorkspaceSettings) api.GET("/stats", c.httpGetStats) } @@ -616,6 +618,9 @@ func (c *Controller) httpCreateReviewWatch(ctx *gin.Context) { func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireReviewWatchInWorkspace(ctx, id) { + return + } var req UpdateReviewWatchRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) @@ -630,6 +635,9 @@ func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) { func (c *Controller) httpDeleteReviewWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireReviewWatchInWorkspace(ctx, id) { + return + } if err := c.service.DeleteReviewWatch(ctx.Request.Context(), id); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -812,8 +820,17 @@ func (c *Controller) httpGetRepoMergeMethods(ctx *gin.Context) { func (c *Controller) httpSearchUserPRs(ctx *gin.Context) { query := ctx.Query("query") filter := ctx.Query("filter") + workspaceID := ctx.Query("workspace_id") page, perPage := parsePaginationQuery(ctx) - result, err := c.service.SearchUserPRsPaged(ctx.Request.Context(), filter, query, page, perPage) + var ( + result *PRSearchPage + err error + ) + if workspaceID == "" { + result, err = c.service.SearchUserPRsPaged(ctx.Request.Context(), filter, query, page, perPage) + } else { + result, err = c.service.SearchUserPRsPagedForWorkspace(ctx.Request.Context(), workspaceID, filter, query, page, perPage) + } if err != nil { c.handleSearchError(ctx, err) return @@ -828,8 +845,17 @@ func (c *Controller) httpSearchUserPRs(ctx *gin.Context) { func (c *Controller) httpSearchUserIssues(ctx *gin.Context) { query := ctx.Query("query") filter := ctx.Query("filter") + workspaceID := ctx.Query("workspace_id") page, perPage := parsePaginationQuery(ctx) - result, err := c.service.SearchUserIssuesPaged(ctx.Request.Context(), filter, query, page, perPage) + var ( + result *IssueSearchPage + err error + ) + if workspaceID == "" { + result, err = c.service.SearchUserIssuesPaged(ctx.Request.Context(), filter, query, page, perPage) + } else { + result, err = c.service.SearchUserIssuesPagedForWorkspace(ctx.Request.Context(), workspaceID, filter, query, page, perPage) + } if err != nil { c.handleSearchError(ctx, err) return @@ -840,6 +866,38 @@ func (c *Controller) httpSearchUserIssues(ctx *gin.Context) { ctx.JSON(http.StatusOK, result) } +func (c *Controller) httpGetWorkspaceSettings(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if strings.TrimSpace(workspaceID) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + settings, err := c.service.GetWorkspaceSettings(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, settings) +} + +func (c *Controller) httpUpdateWorkspaceSettings(ctx *gin.Context) { + var req UpdateWorkspaceSettingsRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + settings, err := c.service.UpdateWorkspaceSettings(ctx.Request.Context(), &req) + if err != nil { + if errors.Is(err, ErrWorkspaceSettingsValidation) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } else { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + ctx.JSON(http.StatusOK, settings) +} + // parsePaginationQuery reads `page` and `per_page` query params. Missing or // non-positive values fall back to defaults (page=1, perPage=50). The upper // bound for perPage (100) is applied later by clampSearchPage in the client @@ -945,6 +1003,9 @@ func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) { func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireIssueWatchInWorkspace(ctx, id) { + return + } var req UpdateIssueWatchRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) @@ -964,6 +1025,9 @@ func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) { func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireIssueWatchInWorkspace(ctx, id) { + return + } if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -973,12 +1037,17 @@ func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { id := ctx.Param("id") + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } watch, err := c.service.GetIssueWatch(ctx.Request.Context(), id) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if watch == nil { + if watch == nil || watch.WorkspaceID != workspaceID { ctx.JSON(http.StatusNotFound, gin.H{"error": "issue watch not found"}) return } @@ -1072,7 +1141,16 @@ func (c *Controller) httpResetActionPresets(ctx *gin.Context) { // watch lived in a workspace they no longer have open. A future // multi-tenant rollout would add an optional workspace_id query param. func (c *Controller) httpCleanupReviewTasks(ctx *gin.Context) { - deleted, err := c.service.CleanupAllReviewTasks(ctx.Request.Context()) + workspaceID := ctx.Query("workspace_id") + var ( + deleted int + err error + ) + if workspaceID == "" { + deleted, err = c.service.CleanupAllReviewTasks(ctx.Request.Context()) + } else { + deleted, err = c.service.CleanupReviewTasksForWorkspace(ctx.Request.Context(), workspaceID) + } if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -1082,7 +1160,16 @@ func (c *Controller) httpCleanupReviewTasks(ctx *gin.Context) { // httpCleanupIssueTasks mirrors httpCleanupReviewTasks for issue watches. func (c *Controller) httpCleanupIssueTasks(ctx *gin.Context) { - deleted, err := c.service.CleanupAllIssueTasks(ctx.Request.Context()) + workspaceID := ctx.Query("workspace_id") + var ( + deleted int + err error + ) + if workspaceID == "" { + deleted, err = c.service.CleanupAllIssueTasks(ctx.Request.Context()) + } else { + deleted, err = c.service.CleanupIssueTasksForWorkspace(ctx.Request.Context(), workspaceID) + } if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/apps/backend/internal/github/handlers.go b/apps/backend/internal/github/handlers.go index a50681f1e3..932eec074e 100644 --- a/apps/backend/internal/github/handlers.go +++ b/apps/backend/internal/github/handlers.go @@ -484,11 +484,15 @@ func wsTriggerIssueWatch(svc *Service, log *logger.Logger) func(ctx context.Cont if id == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgIDRequired, nil) } + workspaceID, _ := payload["workspace_id"].(string) + if workspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id is required", nil) + } watch, err := svc.GetIssueWatch(ctx, id) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) } - if watch == nil { + if watch == nil || watch.WorkspaceID != workspaceID { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "issue watch not found", nil) } newIssues, err := svc.CheckIssueWatch(ctx, watch) diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index d505355a30..a5a2950149 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -2,7 +2,10 @@ // review queue management, and CI/check status tracking. package github -import "time" +import ( + "encoding/json" + "time" +) // TaskCIAutoFixMaxRounds is the server-enforced CI auto-fix loop guard. const TaskCIAutoFixMaxRounds = 10 @@ -628,6 +631,76 @@ type UpdateIssueWatchRequest struct { CleanupPolicy *string `json:"cleanup_policy,omitempty"` } +// --- Workspace GitHub scope/settings --- + +const ( + RepoScopeModeAll = "all" + RepoScopeModeOrgs = "orgs" + RepoScopeModeRepos = "repos" +) + +// WorkspaceSettings stores per-workspace GitHub operational settings. +// Authentication remains install-wide; these settings scope GitHub UI/search +// surfaces to the workspace's intended repositories. +type WorkspaceSettings struct { + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + RepoScopeMode string `json:"repo_scope_mode" db:"repo_scope_mode"` + RepoScopeOrgs []string `json:"repo_scope_orgs" db:"-"` + RepoScopeRepos []RepoFilter `json:"repo_scope_repos" db:"-"` + RepoScopeOrgsJSON string `json:"-" db:"repo_scope_orgs"` + RepoScopeReposJSON string `json:"-" db:"repo_scope_repos"` + SavedPresets json.RawMessage `json:"saved_presets,omitempty" db:"saved_presets"` + DefaultQueryPresets json.RawMessage `json:"default_query_presets,omitempty" db:"default_query_presets"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// UpdateWorkspaceSettingsRequest replaces the workspace GitHub scope and/or +// preference blobs. Nil blobs leave values unchanged; explicit JSON null clears +// default query presets back to built-ins. +type UpdateWorkspaceSettingsRequest struct { + WorkspaceID string `json:"workspace_id"` + RepoScopeMode *string `json:"repo_scope_mode,omitempty"` + RepoScopeOrgs *[]string `json:"repo_scope_orgs,omitempty"` + RepoScopeRepos *[]RepoFilter `json:"repo_scope_repos,omitempty"` + SavedPresets *json.RawMessage `json:"saved_presets,omitempty"` + DefaultQueryPresets *json.RawMessage `json:"default_query_presets,omitempty"` + SavedPresetsSet bool `json:"-"` + DefaultQueriesSet bool `json:"-"` +} + +func (r *UpdateWorkspaceSettingsRequest) UnmarshalJSON(data []byte) error { + type alias UpdateWorkspaceSettingsRequest + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + var decoded alias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = UpdateWorkspaceSettingsRequest(decoded) + if value, ok := raw["saved_presets"]; ok { + r.SavedPresetsSet = true + if string(value) == jsonNullLiteral { + r.SavedPresets = nil + } else { + next := cloneRawMessage(value) + r.SavedPresets = &next + } + } + if value, ok := raw["default_query_presets"]; ok { + r.DefaultQueriesSet = true + if string(value) == jsonNullLiteral { + r.DefaultQueryPresets = nil + } else { + next := cloneRawMessage(value) + r.DefaultQueryPresets = &next + } + } + return nil +} + // --- Action presets (quick-launch prompts on the /github page) --- // ActionPresetKind enumerates the two lists of quick-launch presets. diff --git a/apps/backend/internal/github/service_cleanup.go b/apps/backend/internal/github/service_cleanup.go index 4556997e57..b731769bb7 100644 --- a/apps/backend/internal/github/service_cleanup.go +++ b/apps/backend/internal/github/service_cleanup.go @@ -40,6 +40,41 @@ func (s *Service) CleanupAllReviewTasks(ctx context.Context) (int, error) { return s.cleanupAllReviewTasks(ctx, false) } +// CleanupReviewTasksForWorkspace sweeps review dedup rows owned by watches in +// one workspace. Rows for deleted watches are intentionally skipped because +// their workspace can no longer be proven. +// +//nolint:dupl // mirrors CleanupIssueTasksForWorkspace — different task/watch types +func (s *Service) CleanupReviewTasksForWorkspace(ctx context.Context, workspaceID string) (int, error) { + if s.client == nil || s.taskDeleter == nil { + return 0, nil + } + watches, err := s.store.ListReviewWatches(ctx, workspaceID) + if err != nil { + return 0, fmt.Errorf("list review watches: %w", err) + } + watchPolicies := make(map[string]string, len(watches)) + for _, watch := range watches { + watchPolicies[watch.ID] = NormalizeCleanupPolicy(watch.CleanupPolicy) + } + if len(watchPolicies) == 0 { + return 0, nil + } + prTasks, err := s.store.ListAllReviewPRTasks(ctx) + if err != nil { + return 0, fmt.Errorf("list all review PR tasks: %w", err) + } + candidates := make([]*ReviewPRTask, 0, len(prTasks)) + for _, rpt := range prTasks { + if _, ok := watchPolicies[rpt.ReviewWatchID]; ok { + candidates = append(candidates, rpt) + } + } + return s.cleanupReviewPRTaskBatch(ctx, candidates, func(rpt *ReviewPRTask) string { + return watchPolicies[rpt.ReviewWatchID] + }), nil +} + // cleanupAllReviewTasks is the shared body. When orphansOnly is true, rows // whose watch is currently enabled are skipped (the per-watch poller will // handle them in the same cycle). @@ -98,6 +133,8 @@ func (s *Service) cleanupAllReviewTasks(ctx context.Context, orphansOnly bool) ( // retries the fetch and recovers naturally. Missing watches (deleted) are // distinct: they're absent from both `policy` and `enabled` but NOT in // `unknown`, so callers treat them as legitimate orphans. +// +//nolint:dupl // mirrors buildIssueWatchCaches — different task/watch types func (s *Service) buildReviewWatchCaches(ctx context.Context, prTasks []*ReviewPRTask) (policy map[string]string, enabled map[string]bool, unknown map[string]bool) { seen := make(map[string]struct{}) for _, rpt := range prTasks { @@ -324,6 +361,40 @@ func (s *Service) CleanupAllIssueTasks(ctx context.Context) (int, error) { return s.cleanupAllIssueTasks(ctx, false) } +// CleanupIssueTasksForWorkspace mirrors CleanupReviewTasksForWorkspace for +// issue-watch dedup rows. +// +//nolint:dupl // mirrors CleanupReviewTasksForWorkspace — different task/watch types +func (s *Service) CleanupIssueTasksForWorkspace(ctx context.Context, workspaceID string) (int, error) { + if s.client == nil || s.taskDeleter == nil { + return 0, nil + } + watches, err := s.store.ListIssueWatches(ctx, workspaceID) + if err != nil { + return 0, fmt.Errorf("list issue watches: %w", err) + } + watchPolicies := make(map[string]string, len(watches)) + for _, watch := range watches { + watchPolicies[watch.ID] = NormalizeCleanupPolicy(watch.CleanupPolicy) + } + if len(watchPolicies) == 0 { + return 0, nil + } + issueTasks, err := s.store.ListAllIssueWatchTasks(ctx) + if err != nil { + return 0, fmt.Errorf("list all issue watch tasks: %w", err) + } + candidates := make([]*IssueWatchTask, 0, len(issueTasks)) + for _, it := range issueTasks { + if _, ok := watchPolicies[it.IssueWatchID]; ok { + candidates = append(candidates, it) + } + } + return s.cleanupIssueTaskBatch(ctx, candidates, func(it *IssueWatchTask) string { + return watchPolicies[it.IssueWatchID] + }), nil +} + //nolint:dupl // mirrors cleanupAllReviewTasks — different types, same orchestration func (s *Service) cleanupAllIssueTasks(ctx context.Context, orphansOnly bool) (int, error) { if s.client == nil || s.taskDeleter == nil { @@ -358,6 +429,7 @@ func (s *Service) cleanupAllIssueTasks(ctx context.Context, orphansOnly bool) (i }), nil } +//nolint:dupl // mirrors buildReviewWatchCaches — different task/watch types func (s *Service) buildIssueWatchCaches(ctx context.Context, issueTasks []*IssueWatchTask) (policy map[string]string, enabled map[string]bool, unknown map[string]bool) { seen := make(map[string]struct{}) for _, it := range issueTasks { diff --git a/apps/backend/internal/github/service_issues.go b/apps/backend/internal/github/service_issues.go index b47d4934db..bf7af817c8 100644 --- a/apps/backend/internal/github/service_issues.go +++ b/apps/backend/internal/github/service_issues.go @@ -192,10 +192,22 @@ func (s *Service) CheckIssueWatch(ctx context.Context, watch *IssueWatch) ([]*Is zap.String("custom_query", watch.CustomQuery), zap.Bool("enabled", watch.Enabled)) - issues, err := s.fetchIssues(ctx, watch) + settings, settingsErr := s.GetWorkspaceSettings(ctx, watch.WorkspaceID) + if settingsErr != nil { + return nil, settingsErr + } + if len(watch.Repos) == 0 && workspaceSettingsHasEmptyScope(settings) { + return nil, nil + } + fetchWatch := *watch + if len(fetchWatch.Repos) == 0 { + fetchWatch.Repos = workspaceScopeRepoFilters(settings) + } + issues, err := s.fetchIssues(ctx, &fetchWatch) if err != nil { return nil, err } + issues = filterIssuesByWorkspaceScope(issues, settings) var newIssues []*Issue for _, issue := range issues { diff --git a/apps/backend/internal/github/service_reviews.go b/apps/backend/internal/github/service_reviews.go index a3471ce515..6870baae12 100644 --- a/apps/backend/internal/github/service_reviews.go +++ b/apps/backend/internal/github/service_reviews.go @@ -219,10 +219,22 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* zap.String("review_scope", watch.ReviewScope), zap.Bool("enabled", watch.Enabled)) - prs, err := s.fetchReviewPRs(ctx, watch) + settings, settingsErr := s.GetWorkspaceSettings(ctx, watch.WorkspaceID) + if settingsErr != nil { + return nil, settingsErr + } + if len(watch.Repos) == 0 && workspaceSettingsHasEmptyScope(settings) { + return nil, nil + } + fetchWatch := *watch + if len(fetchWatch.Repos) == 0 { + fetchWatch.Repos = workspaceScopeRepoFilters(settings) + } + prs, err := s.fetchReviewPRs(ctx, &fetchWatch) if err != nil { return nil, err } + prs = filterPRsByWorkspaceScope(prs, settings) s.logger.Debug("fetched review-requested PRs", zap.String("watch_id", watch.ID), diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index 302c2982fe..67187fac20 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -162,6 +162,17 @@ const createTablesSQL = ` updated_at DATETIME NOT NULL ); + CREATE TABLE IF NOT EXISTS github_workspace_settings ( + workspace_id TEXT PRIMARY KEY, + repo_scope_mode TEXT NOT NULL DEFAULT 'all', + repo_scope_orgs TEXT NOT NULL DEFAULT '[]', + repo_scope_repos TEXT NOT NULL DEFAULT '[]', + saved_presets TEXT NOT NULL DEFAULT '[]', + default_query_presets TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS github_task_ci_options ( task_id TEXT PRIMARY KEY, auto_fix_enabled BOOLEAN NOT NULL DEFAULT 0, @@ -1937,6 +1948,354 @@ func (s *Store) ResetIssueWatchState(ctx context.Context, watchID string) error return tx.Commit() } +// --- Workspace settings operations --- + +func defaultWorkspaceSettings(workspaceID string) *WorkspaceSettings { + now := time.Now().UTC() + return &WorkspaceSettings{ + WorkspaceID: workspaceID, + RepoScopeMode: RepoScopeModeAll, + RepoScopeOrgs: []string{}, + RepoScopeRepos: []RepoFilter{}, + SavedPresets: json.RawMessage("[]"), + DefaultQueryPresets: nil, + CreatedAt: now, + UpdatedAt: now, + } +} + +func cloneRawMessage(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + out := make([]byte, len(raw)) + copy(out, raw) + return out +} + +const maxWorkspaceSettingsJSONBytes = 64 * 1024 + +func normalizeRepoScopeMode(mode string) string { + mode = strings.TrimSpace(strings.ToLower(mode)) + switch mode { + case RepoScopeModeOrgs, RepoScopeModeRepos: + return mode + default: + return RepoScopeModeAll + } +} + +func normalizeWorkspaceSettings(settings *WorkspaceSettings) *WorkspaceSettings { + if settings == nil { + return nil + } + out := *settings + out.WorkspaceID = strings.TrimSpace(out.WorkspaceID) + out.RepoScopeMode = normalizeRepoScopeMode(out.RepoScopeMode) + if out.RepoScopeMode != RepoScopeModeOrgs { + out.RepoScopeOrgs = nil + } + if out.RepoScopeMode != RepoScopeModeRepos { + out.RepoScopeRepos = nil + } + orgs := make([]string, 0, len(out.RepoScopeOrgs)) + seenOrgs := make(map[string]struct{}, len(out.RepoScopeOrgs)) + for _, org := range out.RepoScopeOrgs { + org = strings.TrimSpace(org) + if org == "" { + continue + } + key := strings.ToLower(org) + if _, ok := seenOrgs[key]; ok { + continue + } + seenOrgs[key] = struct{}{} + orgs = append(orgs, org) + } + out.RepoScopeOrgs = orgs + repos := make([]RepoFilter, 0, len(out.RepoScopeRepos)) + seenRepos := make(map[string]struct{}, len(out.RepoScopeRepos)) + for _, repo := range out.RepoScopeRepos { + owner := strings.TrimSpace(repo.Owner) + name := strings.TrimSpace(repo.Name) + if owner == "" || name == "" { + continue + } + key := strings.ToLower(owner + "/" + name) + if _, ok := seenRepos[key]; ok { + continue + } + seenRepos[key] = struct{}{} + repos = append(repos, RepoFilter{Owner: owner, Name: name}) + } + out.RepoScopeRepos = repos + if len(out.SavedPresets) == 0 { + out.SavedPresets = json.RawMessage("[]") + } else { + out.SavedPresets = cloneRawMessage(out.SavedPresets) + } + out.DefaultQueryPresets = cloneRawMessage(out.DefaultQueryPresets) + return &out +} + +// GetWorkspaceSettings returns per-workspace GitHub settings. Missing rows +// resolve to the backwards-compatible All repos defaults. +func (s *Store) GetWorkspaceSettings(ctx context.Context, workspaceID string) (*WorkspaceSettings, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, fmt.Errorf("workspace_id is required") + } + var row struct { + WorkspaceID string `db:"workspace_id"` + RepoScopeMode string `db:"repo_scope_mode"` + RepoScopeOrgsJSON string `db:"repo_scope_orgs"` + RepoScopeReposJSON string `db:"repo_scope_repos"` + SavedPresets string `db:"saved_presets"` + DefaultQueryPresets sql.NullString `db:"default_query_presets"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + err := s.ro.GetContext(ctx, &row, ` + SELECT workspace_id, repo_scope_mode, repo_scope_orgs, repo_scope_repos, + saved_presets, default_query_presets, created_at, updated_at + FROM github_workspace_settings + WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return defaultWorkspaceSettings(workspaceID), nil + } + if err != nil { + return nil, err + } + settings := &WorkspaceSettings{ + WorkspaceID: row.WorkspaceID, + RepoScopeMode: row.RepoScopeMode, + RepoScopeOrgsJSON: row.RepoScopeOrgsJSON, + RepoScopeReposJSON: row.RepoScopeReposJSON, + SavedPresets: json.RawMessage(row.SavedPresets), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.DefaultQueryPresets.Valid { + settings.DefaultQueryPresets = json.RawMessage(row.DefaultQueryPresets.String) + } + if err := json.Unmarshal([]byte(row.RepoScopeOrgsJSON), &settings.RepoScopeOrgs); err != nil { + return nil, fmt.Errorf("unmarshal repo scope orgs: %w", err) + } + if err := json.Unmarshal([]byte(row.RepoScopeReposJSON), &settings.RepoScopeRepos); err != nil { + return nil, fmt.Errorf("unmarshal repo scope repos: %w", err) + } + return normalizeWorkspaceSettings(settings), nil +} + +// UpsertWorkspaceSettings stores per-workspace GitHub settings. +func (s *Store) UpsertWorkspaceSettings(ctx context.Context, settings *WorkspaceSettings) error { + settings = normalizeWorkspaceSettings(settings) + if settings == nil || settings.WorkspaceID == "" { + return fmt.Errorf("workspace_id is required") + } + orgsJSON, err := json.Marshal(settings.RepoScopeOrgs) + if err != nil { + return fmt.Errorf("marshal repo scope orgs: %w", err) + } + reposJSON, err := json.Marshal(settings.RepoScopeRepos) + if err != nil { + return fmt.Errorf("marshal repo scope repos: %w", err) + } + now := time.Now().UTC() + defaults := sql.NullString{} + if len(settings.DefaultQueryPresets) > 0 { + defaults.Valid = true + defaults.String = string(settings.DefaultQueryPresets) + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO github_workspace_settings ( + workspace_id, repo_scope_mode, repo_scope_orgs, repo_scope_repos, + saved_presets, default_query_presets, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + repo_scope_mode = excluded.repo_scope_mode, + repo_scope_orgs = excluded.repo_scope_orgs, + repo_scope_repos = excluded.repo_scope_repos, + saved_presets = excluded.saved_presets, + default_query_presets = excluded.default_query_presets, + updated_at = excluded.updated_at`, + settings.WorkspaceID, settings.RepoScopeMode, string(orgsJSON), string(reposJSON), + string(settings.SavedPresets), defaults, now, now) + return err +} + +// PatchWorkspaceSettings applies only the fields present in the request. This +// avoids lost updates when independent preference migrations run concurrently. +func (s *Store) PatchWorkspaceSettings(ctx context.Context, req *UpdateWorkspaceSettingsRequest) (*WorkspaceSettings, error) { + if req == nil || strings.TrimSpace(req.WorkspaceID) == "" { + return nil, fmt.Errorf("%w: workspace_id is required", ErrWorkspaceSettingsValidation) + } + workspaceID := strings.TrimSpace(req.WorkspaceID) + now := time.Now().UTC() + if _, err := s.db.ExecContext(ctx, ` + INSERT OR IGNORE INTO github_workspace_settings ( + workspace_id, repo_scope_mode, repo_scope_orgs, repo_scope_repos, + saved_presets, default_query_presets, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + workspaceID, RepoScopeModeAll, "[]", "[]", "[]", nil, now, now); err != nil { + return nil, err + } + + patch := workspaceSettingsPatch{ + sets: make([]string, 0, 8), + args: make([]any, 0, 10), + } + if err := appendWorkspaceScopePatch(&patch, req); err != nil { + return nil, err + } + if err := appendWorkspacePresetPatch(&patch, req); err != nil { + return nil, err + } + if len(patch.sets) > 0 { + patch.add("updated_at = ?", now) + patch.args = append(patch.args, workspaceID) + query := "UPDATE github_workspace_settings SET " + strings.Join(patch.sets, ", ") + " WHERE workspace_id = ?" + if _, err := s.db.ExecContext(ctx, query, patch.args...); err != nil { + return nil, err + } + } + return s.GetWorkspaceSettings(ctx, workspaceID) +} + +type workspaceSettingsPatch struct { + sets []string + args []any +} + +func (p *workspaceSettingsPatch) add(set string, arg any) { + p.sets = append(p.sets, set) + p.args = append(p.args, arg) +} + +func appendWorkspaceScopePatch(patch *workspaceSettingsPatch, req *UpdateWorkspaceSettingsRequest) error { + if err := validateWorkspaceScopePatch(req); err != nil { + return err + } + if req.RepoScopeMode != nil { + if err := appendWorkspaceScopeModePatch(patch, *req.RepoScopeMode); err != nil { + return err + } + } + if req.RepoScopeOrgs != nil { + raw, err := marshalNormalizedRepoScopeOrgs(*req.RepoScopeOrgs) + if err != nil { + return err + } + patch.add("repo_scope_orgs = ?", string(raw)) + } + if req.RepoScopeRepos != nil { + raw, err := marshalNormalizedRepoScopeRepos(*req.RepoScopeRepos) + if err != nil { + return err + } + patch.add("repo_scope_repos = ?", string(raw)) + } + return nil +} + +func validateWorkspaceScopePatch(req *UpdateWorkspaceSettingsRequest) error { + if req.RepoScopeMode == nil { + return nil + } + mode := normalizeRepoScopeMode(*req.RepoScopeMode) + hasOrgs := req.RepoScopeOrgs != nil && len(*req.RepoScopeOrgs) > 0 + hasRepos := req.RepoScopeRepos != nil && len(*req.RepoScopeRepos) > 0 + if mode == RepoScopeModeAll && (hasOrgs || hasRepos) { + return fmt.Errorf("%w: repo_scope_mode all cannot be patched with repo scope filters", ErrWorkspaceSettingsValidation) + } + if mode == RepoScopeModeOrgs && hasRepos { + return fmt.Errorf("%w: repo_scope_mode orgs cannot be patched with repo_scope_repos", ErrWorkspaceSettingsValidation) + } + if mode == RepoScopeModeRepos && hasOrgs { + return fmt.Errorf("%w: repo_scope_mode repos cannot be patched with repo_scope_orgs", ErrWorkspaceSettingsValidation) + } + return nil +} + +func appendWorkspaceScopeModePatch(patch *workspaceSettingsPatch, rawMode string) error { + if !isValidRepoScopeMode(rawMode) { + return fmt.Errorf("%w: invalid repo_scope_mode %q", ErrWorkspaceSettingsValidation, rawMode) + } + mode := normalizeRepoScopeMode(rawMode) + patch.add("repo_scope_mode = ?", mode) + switch mode { + case RepoScopeModeAll: + patch.add("repo_scope_orgs = ?", "[]") + patch.add("repo_scope_repos = ?", "[]") + case RepoScopeModeOrgs: + patch.add("repo_scope_repos = ?", "[]") + case RepoScopeModeRepos: + patch.add("repo_scope_orgs = ?", "[]") + } + return nil +} + +func appendWorkspacePresetPatch(patch *workspaceSettingsPatch, req *UpdateWorkspaceSettingsRequest) error { + if req.SavedPresetsSet { + raw := json.RawMessage("[]") + if req.SavedPresets != nil { + raw = cloneRawMessage(*req.SavedPresets) + } + if err := validateWorkspaceSettingsJSON("saved_presets", raw, '['); err != nil { + return err + } + patch.add("saved_presets = ?", string(raw)) + } + if !req.DefaultQueriesSet { + return nil + } + if req.DefaultQueryPresets == nil { + patch.add("default_query_presets = ?", sql.NullString{}) + return nil + } + raw := cloneRawMessage(*req.DefaultQueryPresets) + if err := validateWorkspaceSettingsJSON("default_query_presets", raw, '{'); err != nil { + return err + } + patch.add("default_query_presets = ?", sql.NullString{String: string(raw), Valid: true}) + return nil +} + +func marshalNormalizedRepoScopeOrgs(orgs []string) ([]byte, error) { + settings := normalizeWorkspaceSettings(&WorkspaceSettings{ + RepoScopeMode: RepoScopeModeOrgs, + RepoScopeOrgs: orgs, + }) + raw, err := json.Marshal(settings.RepoScopeOrgs) + if err != nil { + return nil, fmt.Errorf("marshal repo scope orgs: %w", err) + } + return raw, nil +} + +func marshalNormalizedRepoScopeRepos(repos []RepoFilter) ([]byte, error) { + settings := normalizeWorkspaceSettings(&WorkspaceSettings{ + RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: repos, + }) + raw, err := json.Marshal(settings.RepoScopeRepos) + if err != nil { + return nil, fmt.Errorf("marshal repo scope repos: %w", err) + } + return raw, nil +} + +func validateWorkspaceSettingsJSON(field string, raw json.RawMessage, wantFirst byte) error { + raw = json.RawMessage(strings.TrimSpace(string(raw))) + if len(raw) == 0 || len(raw) > maxWorkspaceSettingsJSONBytes || !json.Valid(raw) { + return fmt.Errorf("%w: invalid %s", ErrWorkspaceSettingsValidation, field) + } + if raw[0] != wantFirst { + return fmt.Errorf("%w: invalid %s", ErrWorkspaceSettingsValidation, field) + } + return nil +} + // --- Action preset operations --- // GetActionPresets returns stored PR/Issue presets for a workspace. Returns diff --git a/apps/backend/internal/github/workspace_settings_service.go b/apps/backend/internal/github/workspace_settings_service.go new file mode 100644 index 0000000000..931d40a60a --- /dev/null +++ b/apps/backend/internal/github/workspace_settings_service.go @@ -0,0 +1,337 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strings" +) + +var ErrWorkspaceSettingsValidation = errors.New("invalid workspace settings") + +// GetWorkspaceSettings returns the GitHub operational settings for a workspace. +func (s *Service) GetWorkspaceSettings(ctx context.Context, workspaceID string) (*WorkspaceSettings, error) { + if s.store == nil { + return defaultWorkspaceSettings(workspaceID), nil + } + return s.store.GetWorkspaceSettings(ctx, workspaceID) +} + +// UpsertWorkspaceSettings stores the GitHub operational settings for a workspace. +func (s *Service) UpsertWorkspaceSettings(ctx context.Context, settings *WorkspaceSettings) error { + if s.store == nil { + return fmt.Errorf("github store not configured") + } + return s.store.UpsertWorkspaceSettings(ctx, settings) +} + +// UpdateWorkspaceSettings applies a partial update over the existing workspace +// settings. Scope fields are intentionally updated as a set so switching to +// All repos clears org/repo selections. Direct struct callers must set +// SavedPresetsSet/DefaultQueriesSet when they intend to write those blobs; +// JSON-bound requests set those flags in UpdateWorkspaceSettingsRequest.UnmarshalJSON. +func (s *Service) UpdateWorkspaceSettings(ctx context.Context, req *UpdateWorkspaceSettingsRequest) (*WorkspaceSettings, error) { + if req == nil || strings.TrimSpace(req.WorkspaceID) == "" { + return nil, fmt.Errorf("%w: workspace_id is required", ErrWorkspaceSettingsValidation) + } + if req.RepoScopeMode != nil && !isValidRepoScopeMode(*req.RepoScopeMode) { + return nil, fmt.Errorf("%w: invalid repo_scope_mode %q", ErrWorkspaceSettingsValidation, *req.RepoScopeMode) + } + if s.store == nil { + return nil, fmt.Errorf("github store not configured") + } + return s.store.PatchWorkspaceSettings(ctx, req) +} + +func (s *Service) SearchUserPRsPagedForWorkspace( + ctx context.Context, + workspaceID string, + filter string, + customQuery string, + page int, + perPage int, +) (*PRSearchPage, error) { + settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, err + } + if !workspaceSettingsHasScope(settings) { + return s.SearchUserPRsPaged(ctx, filter, customQuery, page, perPage) + } + return s.searchUserPRsPagedScoped(ctx, settings, filter, customQuery, page, perPage) +} + +func (s *Service) SearchUserIssuesPagedForWorkspace( + ctx context.Context, + workspaceID string, + filter string, + customQuery string, + page int, + perPage int, +) (*IssueSearchPage, error) { + settings, err := s.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, err + } + if !workspaceSettingsHasScope(settings) { + return s.SearchUserIssuesPaged(ctx, filter, customQuery, page, perPage) + } + return s.searchUserIssuesPagedScoped(ctx, settings, filter, customQuery, page, perPage) +} + +func (s *Service) searchUserPRsPagedScoped( + ctx context.Context, + settings *WorkspaceSettings, + filter string, + customQuery string, + page int, + perPage int, +) (*PRSearchPage, error) { + if workspaceSettingsHasEmptyScope(settings) { + return &PRSearchPage{PRs: []*PR{}, TotalCount: 0, Page: page, PerPage: perPage}, nil + } + filter, customQuery = appendWorkspaceScopeToSearch(filter, customQuery, settings) + v, err := s.searchUserPagedScoped("pr", settings, filter, customQuery, page, perPage, func(page, perPage int) (any, error) { + result, err := s.client.SearchPRsPaged(ctx, filter, customQuery, page, perPage) + if err != nil { + return nil, err + } + return scopedPRSearchPage(result, settings, page, perPage), nil + }) + if err != nil { + return nil, err + } + return v.(*PRSearchPage), nil +} + +func (s *Service) searchUserIssuesPagedScoped( + ctx context.Context, + settings *WorkspaceSettings, + filter string, + customQuery string, + page int, + perPage int, +) (*IssueSearchPage, error) { + if workspaceSettingsHasEmptyScope(settings) { + return &IssueSearchPage{Issues: []*Issue{}, TotalCount: 0, Page: page, PerPage: perPage}, nil + } + filter, customQuery = appendWorkspaceScopeToSearch(filter, customQuery, settings) + v, err := s.searchUserPagedScoped("issue", settings, filter, customQuery, page, perPage, func(page, perPage int) (any, error) { + result, err := s.client.ListIssuesPaged(ctx, filter, customQuery, page, perPage) + if err != nil { + return nil, err + } + return scopedIssueSearchPage(result, settings, page, perPage), nil + }) + if err != nil { + return nil, err + } + return v.(*IssueSearchPage), nil +} + +func (s *Service) searchUserPagedScoped( + kind string, + settings *WorkspaceSettings, + filter string, + customQuery string, + page int, + perPage int, + fetch func(page int, perPage int) (any, error), +) (any, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + page, perPage = clampSearchPage(page, perPage) + scopeKey := workspaceSearchScopeKey(settings) + key := searchCacheKey(kind+":"+scopeKey, filter, customQuery, page, perPage) + return s.searchCache.doOrFetch(key, func() (any, error) { + return fetch(page, perPage) + }) +} + +func scopedPRSearchPage(result *PRSearchPage, settings *WorkspaceSettings, page int, perPage int) *PRSearchPage { + if result == nil { + result = &PRSearchPage{Page: page, PerPage: perPage} + } + result.PRs = filterPRsByWorkspaceScope(result.PRs, settings) + result.Page = page + result.PerPage = perPage + return result +} + +func scopedIssueSearchPage(result *IssueSearchPage, settings *WorkspaceSettings, page int, perPage int) *IssueSearchPage { + if result == nil { + result = &IssueSearchPage{Page: page, PerPage: perPage} + } + result.Issues = filterIssuesByWorkspaceScope(result.Issues, settings) + result.Page = page + result.PerPage = perPage + return result +} + +func workspaceSettingsHasScope(settings *WorkspaceSettings) bool { + settings = normalizeWorkspaceSettings(settings) + if settings == nil { + return false + } + switch settings.RepoScopeMode { + case RepoScopeModeOrgs, RepoScopeModeRepos: + return true + default: + return false + } +} + +func workspaceSettingsHasEmptyScope(settings *WorkspaceSettings) bool { + return workspaceSettingsHasScope(settings) && len(workspaceScopeQualifiers(settings)) == 0 +} + +func isValidRepoScopeMode(mode string) bool { + switch strings.TrimSpace(strings.ToLower(mode)) { + case RepoScopeModeAll, RepoScopeModeOrgs, RepoScopeModeRepos: + return true + default: + return false + } +} + +func appendWorkspaceScopeQuery(customQuery string, settings *WorkspaceSettings) string { + qualifiers := workspaceScopeQualifiers(settings) + if len(qualifiers) == 0 { + return customQuery + } + scope := qualifiers[0] + if len(qualifiers) > 1 { + scope = "(" + strings.Join(qualifiers, " OR ") + ")" + } + return strings.TrimSpace(strings.Join([]string{customQuery, scope}, " ")) +} + +func appendWorkspaceScopeToSearch(filter, customQuery string, settings *WorkspaceSettings) (string, string) { + if strings.TrimSpace(customQuery) != "" { + return filter, appendWorkspaceScopeQuery(customQuery, settings) + } + return appendWorkspaceScopeQuery(filter, settings), customQuery +} + +func workspaceScopeQualifiers(settings *WorkspaceSettings) []string { + settings = normalizeWorkspaceSettings(settings) + if settings == nil { + return nil + } + switch settings.RepoScopeMode { + case RepoScopeModeOrgs: + qualifiers := make([]string, 0, len(settings.RepoScopeOrgs)*2) + for _, org := range settings.RepoScopeOrgs { + if org = strings.TrimSpace(org); org != "" { + qualifiers = append(qualifiers, "org:"+org) + qualifiers = append(qualifiers, "user:"+org) + } + } + return qualifiers + case RepoScopeModeRepos: + qualifiers := make([]string, 0, len(settings.RepoScopeRepos)) + for _, repo := range settings.RepoScopeRepos { + if repo.Owner != "" && repo.Name != "" { + qualifiers = append(qualifiers, repoFilterToQualifier(repo)) + } + } + return qualifiers + default: + return nil + } +} + +func workspaceScopeRepoFilters(settings *WorkspaceSettings) []RepoFilter { + settings = normalizeWorkspaceSettings(settings) + if settings == nil { + return nil + } + switch settings.RepoScopeMode { + case RepoScopeModeOrgs: + repos := make([]RepoFilter, 0, len(settings.RepoScopeOrgs)) + for _, org := range settings.RepoScopeOrgs { + if org = strings.TrimSpace(org); org != "" { + repos = append(repos, RepoFilter{Owner: org}) + } + } + return repos + case RepoScopeModeRepos: + return append([]RepoFilter(nil), settings.RepoScopeRepos...) + default: + return nil + } +} + +func workspaceSearchScopeKey(settings *WorkspaceSettings) string { + settings = normalizeWorkspaceSettings(settings) + if settings == nil { + return RepoScopeModeAll + } + var parts []string + switch settings.RepoScopeMode { + case RepoScopeModeOrgs: + parts = append(parts, settings.RepoScopeOrgs...) + case RepoScopeModeRepos: + for _, repo := range settings.RepoScopeRepos { + parts = append(parts, repo.Owner+"/"+repo.Name) + } + } + return settings.RepoScopeMode + ":" + strings.Join(parts, ",") +} + +func filterPRsByWorkspaceScope(prs []*PR, settings *WorkspaceSettings) []*PR { + if !workspaceSettingsHasScope(settings) { + if prs == nil { + return []*PR{} + } + return prs + } + out := make([]*PR, 0, len(prs)) + for _, pr := range prs { + if pr != nil && repoAllowedByWorkspaceScope(pr.RepoOwner, pr.RepoName, settings) { + out = append(out, pr) + } + } + return out +} + +func filterIssuesByWorkspaceScope(issues []*Issue, settings *WorkspaceSettings) []*Issue { + if !workspaceSettingsHasScope(settings) { + if issues == nil { + return []*Issue{} + } + return issues + } + out := make([]*Issue, 0, len(issues)) + for _, issue := range issues { + if issue != nil && repoAllowedByWorkspaceScope(issue.RepoOwner, issue.RepoName, settings) { + out = append(out, issue) + } + } + return out +} + +func repoAllowedByWorkspaceScope(owner, name string, settings *WorkspaceSettings) bool { + settings = normalizeWorkspaceSettings(settings) + if settings == nil || settings.RepoScopeMode == RepoScopeModeAll { + return true + } + owner = strings.ToLower(strings.TrimSpace(owner)) + name = strings.ToLower(strings.TrimSpace(name)) + switch settings.RepoScopeMode { + case RepoScopeModeOrgs: + for _, org := range settings.RepoScopeOrgs { + if strings.ToLower(org) == owner { + return true + } + } + case RepoScopeModeRepos: + for _, repo := range settings.RepoScopeRepos { + if strings.ToLower(repo.Owner) == owner && strings.ToLower(repo.Name) == name { + return true + } + } + } + return false +} diff --git a/apps/backend/internal/github/workspace_settings_test.go b/apps/backend/internal/github/workspace_settings_test.go new file mode 100644 index 0000000000..0bceadbef9 --- /dev/null +++ b/apps/backend/internal/github/workspace_settings_test.go @@ -0,0 +1,492 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestStore_GitHubWorkspaceSettingsRoundTrip(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + input := &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeRepos, + RepoScopeOrgs: []string{"kdlbs"}, + RepoScopeRepos: []RepoFilter{ + {Owner: "kdlbs", Name: "kandev"}, + }, + SavedPresets: []byte(`[{"id":"p1","kind":"pr","label":"Mine"}]`), + DefaultQueryPresets: []byte(`{"pr":[],"issue":[]}`), + } + + if err := store.UpsertWorkspaceSettings(ctx, input); err != nil { + t.Fatalf("upsert workspace settings: %v", err) + } + + got, err := store.GetWorkspaceSettings(ctx, "ws-1") + if err != nil { + t.Fatalf("get workspace settings: %v", err) + } + if got.WorkspaceID != "ws-1" || got.RepoScopeMode != RepoScopeModeRepos { + t.Fatalf("unexpected settings identity/scope: %+v", got) + } + if len(got.RepoScopeRepos) != 1 || got.RepoScopeRepos[0].Owner != "kdlbs" || got.RepoScopeRepos[0].Name != "kandev" { + t.Fatalf("repo scope lost on round trip: %+v", got.RepoScopeRepos) + } + if string(got.SavedPresets) != string(input.SavedPresets) { + t.Fatalf("saved presets = %s, want %s", got.SavedPresets, input.SavedPresets) + } + if string(got.DefaultQueryPresets) != string(input.DefaultQueryPresets) { + t.Fatalf("default query presets = %s, want %s", got.DefaultQueryPresets, input.DefaultQueryPresets) + } +} + +func TestService_SearchUserPRsPagedForWorkspace_FiltersToSelectedRepos(t *testing.T) { + client := NewMockClient() + client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "in scope"}) + client.AddPR(&PR{RepoOwner: "other", RepoName: "repo", Number: 2, Title: "out of scope"}) + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: []RepoFilter{{Owner: "kdlbs", Name: "kandev"}}, + }); err != nil { + t.Fatalf("save workspace settings: %v", err) + } + + page, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "", "repo:other/repo is:open", 1, 25) + if err != nil { + t.Fatalf("search scoped prs: %v", err) + } + if page.TotalCount != 2 || len(page.PRs) != 1 { + t.Fatalf("expected one scoped PR, got total=%d prs=%+v", page.TotalCount, page.PRs) + } + if page.PRs[0].RepoOwner != "kdlbs" || page.PRs[0].RepoName != "kandev" { + t.Fatalf("custom query escaped workspace scope: %+v", page.PRs[0]) + } +} + +func TestService_SearchUserPRsPagedForWorkspace_AppendsScopeToQuery(t *testing.T) { + client := &capturingSearchClient{MockClient: NewMockClient()} + client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "in scope"}) + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: []RepoFilter{ + {Owner: "kdlbs", Name: "kandev"}, + {Owner: "kdlbs", Name: "docs"}, + }, + }); err != nil { + t.Fatalf("save workspace settings: %v", err) + } + + page, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "", "is:open", 1, 25) + if err != nil { + t.Fatalf("search scoped prs: %v", err) + } + if page.TotalCount != 1 || len(page.PRs) != 1 { + t.Fatalf("expected one scoped PR, got total=%d prs=%+v", page.TotalCount, page.PRs) + } + if !strings.Contains(client.customQuery, "is:open") || + !strings.Contains(client.customQuery, "(repo:kdlbs/kandev OR repo:kdlbs/docs)") { + t.Fatalf("custom query was not workspace scoped: %q", client.customQuery) + } + + if _, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "review-requested:@me", "", 1, 25); err != nil { + t.Fatalf("search scoped prs with preset filter: %v", err) + } + if !strings.Contains(client.filter, "review-requested:@me") || + !strings.Contains(client.filter, "(repo:kdlbs/kandev OR repo:kdlbs/docs)") { + t.Fatalf("filter query was not workspace scoped: %q", client.filter) + } +} + +func TestService_SearchUserPRsPagedForWorkspace_OrgScopeIncludesPersonalOwner(t *testing.T) { + client := &capturingSearchClient{MockClient: NewMockClient()} + client.AddPR(&PR{RepoOwner: "octo", RepoName: "personal", Number: 1, Title: "in scope"}) + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeOrgs, + RepoScopeOrgs: []string{"octo"}, + }); err != nil { + t.Fatalf("save workspace settings: %v", err) + } + + if _, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "", "is:open", 1, 25); err != nil { + t.Fatalf("search scoped prs: %v", err) + } + if !strings.Contains(client.customQuery, "(org:octo OR user:octo)") { + t.Fatalf("org scope did not include personal-owner qualifier: %q", client.customQuery) + } +} + +func TestService_SearchUserPRsPagedForWorkspace_EmptyRepoScopeFailsClosed(t *testing.T) { + client := NewMockClient() + client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "hidden"}) + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: []RepoFilter{}, + }); err != nil { + t.Fatalf("save workspace settings: %v", err) + } + + page, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "", "is:open", 1, 25) + if err != nil { + t.Fatalf("search scoped prs: %v", err) + } + if page.TotalCount != 0 || len(page.PRs) != 0 { + t.Fatalf("empty repo scope should return no PRs, got total=%d prs=%+v", page.TotalCount, page.PRs) + } +} + +func TestService_UpdateWorkspaceSettings_PartialUpdateAndNullClear(t *testing.T) { + store := newTestStore(t) + svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + defaults := json.RawMessage(`{"pr":[{"value":"mine","label":"Mine","filter":"is:open","group":"inbox"}],"issue":[]}`) + saved := json.RawMessage(`[{"id":"p1","kind":"pr","label":"Mine"}]`) + mode := RepoScopeModeRepos + repos := []RepoFilter{{Owner: "kdlbs", Name: "kandev"}} + if _, err := svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{ + WorkspaceID: "ws-1", + RepoScopeMode: &mode, + RepoScopeRepos: &repos, + SavedPresets: &saved, + SavedPresetsSet: true, + DefaultQueryPresets: &defaults, + DefaultQueriesSet: true, + }); err != nil { + t.Fatalf("initial update: %v", err) + } + + nextSaved := json.RawMessage(`[{"id":"p2","kind":"issue","label":"Issues"}]`) + got, err := svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{ + WorkspaceID: "ws-1", + SavedPresets: &nextSaved, + SavedPresetsSet: true, + }) + if err != nil { + t.Fatalf("partial saved presets update: %v", err) + } + if string(got.SavedPresets) != string(nextSaved) { + t.Fatalf("saved presets = %s, want %s", got.SavedPresets, nextSaved) + } + if string(got.DefaultQueryPresets) != string(defaults) { + t.Fatalf("default presets should be preserved, got %s", got.DefaultQueryPresets) + } + + got, err = svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{ + WorkspaceID: "ws-1", + DefaultQueriesSet: true, + }) + if err != nil { + t.Fatalf("clear default query presets: %v", err) + } + if got.DefaultQueryPresets != nil { + t.Fatalf("default presets should be cleared, got %s", got.DefaultQueryPresets) + } + if string(got.SavedPresets) != string(nextSaved) { + t.Fatalf("saved presets should be preserved after clear, got %s", got.SavedPresets) + } + + got, err = svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{ + WorkspaceID: "ws-1", + SavedPresetsSet: true, + }) + if err != nil { + t.Fatalf("clear saved presets: %v", err) + } + if string(got.SavedPresets) != "[]" { + t.Fatalf("saved presets should be cleared to empty array, got %s", got.SavedPresets) + } +} + +func TestUpdateWorkspaceSettingsRequest_UnmarshalTracksExplicitNull(t *testing.T) { + var req UpdateWorkspaceSettingsRequest + if err := json.Unmarshal([]byte(`{"workspace_id":"ws-1","default_query_presets":null}`), &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if !req.DefaultQueriesSet { + t.Fatal("expected default query presets to be marked present") + } + if req.DefaultQueryPresets != nil { + t.Fatalf("expected explicit null to leave raw pointer nil, got %s", *req.DefaultQueryPresets) + } +} + +func TestUpdateWorkspaceSettingsRequest_UnmarshalTracksSavedPresetsNull(t *testing.T) { + var req UpdateWorkspaceSettingsRequest + if err := json.Unmarshal([]byte(`{"workspace_id":"ws-1","saved_presets":null}`), &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if !req.SavedPresetsSet { + t.Fatal("expected saved presets to be marked present") + } + if req.SavedPresets != nil { + t.Fatalf("expected explicit null to leave raw pointer nil, got %s", *req.SavedPresets) + } +} + +func TestService_UpdateWorkspaceSettings_RejectsUnknownRepoScopeMode(t *testing.T) { + store := newTestStore(t) + svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t)) + mode := "everything" + + _, err := svc.UpdateWorkspaceSettings(context.Background(), &UpdateWorkspaceSettingsRequest{ + WorkspaceID: "ws-1", + RepoScopeMode: &mode, + }) + if !errors.Is(err, ErrWorkspaceSettingsValidation) { + t.Fatalf("expected validation error, got %v", err) + } +} + +func TestService_UpdateWorkspaceSettings_RejectsConflictingScopePatch(t *testing.T) { + store := newTestStore(t) + svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t)) + mode := RepoScopeModeAll + orgs := []string{"octo"} + + _, err := svc.UpdateWorkspaceSettings(context.Background(), &UpdateWorkspaceSettingsRequest{ + WorkspaceID: "ws-1", + RepoScopeMode: &mode, + RepoScopeOrgs: &orgs, + }) + if !errors.Is(err, ErrWorkspaceSettingsValidation) { + t.Fatalf("expected validation error, got %v", err) + } +} + +func TestService_SearchUserIssuesPagedForWorkspace_AllScopePreservesResults(t *testing.T) { + client := &issueSearchClient{ + issues: []*Issue{ + {RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "one"}, + {RepoOwner: "other", RepoName: "repo", Number: 2, Title: "two"}, + }, + } + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + + page, err := svc.SearchUserIssuesPagedForWorkspace(context.Background(), "ws-1", "", "is:open", 1, 25) + if err != nil { + t.Fatalf("search scoped issues: %v", err) + } + if page.TotalCount != 2 || len(page.Issues) != 2 { + t.Fatalf("all scope should preserve results, got total=%d issues=%+v", page.TotalCount, page.Issues) + } +} + +func TestService_CheckReviewWatch_AppliesWorkspaceRepoScope(t *testing.T) { + client := NewMockClient() + client.AddPR(&PR{ + RepoOwner: "kdlbs", + RepoName: "kandev", + Number: 1, + Title: "in scope", + RequestedReviewers: []RequestedReviewer{{Login: "octo", Type: "user"}}, + }) + client.AddPR(&PR{ + RepoOwner: "other", + RepoName: "repo", + Number: 2, + Title: "out of scope", + RequestedReviewers: []RequestedReviewer{{Login: "octo", Type: "user"}}, + }) + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: []RepoFilter{{Owner: "kdlbs", Name: "kandev"}}, + }); err != nil { + t.Fatalf("save workspace settings: %v", err) + } + + prs, err := svc.CheckReviewWatch(ctx, &ReviewWatch{ + ID: "watch-1", + WorkspaceID: "ws-1", + Repos: nil, + ReviewScope: ReviewScopeUserAndTeams, + Enabled: true, + PollIntervalSeconds: 300, + }) + if err != nil { + t.Fatalf("check review watch: %v", err) + } + if len(prs) != 1 || prs[0].RepoOwner != "kdlbs" || prs[0].RepoName != "kandev" { + t.Fatalf("expected one in-scope PR, got %+v", prs) + } +} + +func TestService_CheckReviewWatch_EmptyWorkspaceScopeSkipsProviderFetch(t *testing.T) { + client := &countingReviewClient{MockClient: NewMockClient()} + client.AddPR(&PR{ + RepoOwner: "kdlbs", + RepoName: "kandev", + Number: 1, + Title: "hidden", + RequestedReviewers: []RequestedReviewer{{Login: "octo", Type: "user"}}, + }) + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: []RepoFilter{}, + }); err != nil { + t.Fatalf("save workspace settings: %v", err) + } + + prs, err := svc.CheckReviewWatch(ctx, &ReviewWatch{ + ID: "watch-1", + WorkspaceID: "ws-1", + Repos: nil, + ReviewScope: ReviewScopeUserAndTeams, + Enabled: true, + PollIntervalSeconds: 300, + }) + if err != nil { + t.Fatalf("check review watch: %v", err) + } + if len(prs) != 0 { + t.Fatalf("empty workspace scope should return no PRs, got %+v", prs) + } + if client.listReviewRequestedCalls != 0 { + t.Fatalf("expected no broad provider fetch, got %d calls", client.listReviewRequestedCalls) + } +} + +func TestService_CheckIssueWatch_AppliesWorkspaceRepoScope(t *testing.T) { + client := &issueSearchClient{ + issues: []*Issue{ + {RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "in scope"}, + {RepoOwner: "other", RepoName: "repo", Number: 2, Title: "out of scope"}, + }, + } + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeOrgs, + RepoScopeOrgs: []string{"kdlbs"}, + }); err != nil { + t.Fatalf("save workspace settings: %v", err) + } + + issues, err := svc.CheckIssueWatch(ctx, &IssueWatch{ + ID: "watch-1", + WorkspaceID: "ws-1", + Repos: nil, + Enabled: true, + PollIntervalSeconds: 300, + }) + if err != nil { + t.Fatalf("check issue watch: %v", err) + } + if len(issues) != 1 || issues[0].RepoOwner != "kdlbs" || issues[0].RepoName != "kandev" { + t.Fatalf("expected one in-scope issue, got %+v", issues) + } +} + +func TestService_CheckIssueWatch_EmptyWorkspaceScopeSkipsProviderFetch(t *testing.T) { + client := &countingWorkspaceIssueClient{MockClient: NewMockClient()} + store := newTestStore(t) + svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) + ctx := context.Background() + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: "ws-1", + RepoScopeMode: RepoScopeModeOrgs, + RepoScopeOrgs: []string{}, + }); err != nil { + t.Fatalf("save workspace settings: %v", err) + } + + issues, err := svc.CheckIssueWatch(ctx, &IssueWatch{ + ID: "watch-1", + WorkspaceID: "ws-1", + Repos: nil, + Enabled: true, + PollIntervalSeconds: 300, + }) + if err != nil { + t.Fatalf("check issue watch: %v", err) + } + if len(issues) != 0 { + t.Fatalf("empty workspace scope should return no issues, got %+v", issues) + } + if client.listIssuesCalls != 0 { + t.Fatalf("expected no broad provider fetch, got %d calls", client.listIssuesCalls) + } +} + +type issueSearchClient struct { + *MockClient + issues []*Issue +} + +type countingReviewClient struct { + *MockClient + listReviewRequestedCalls int +} + +func (c *countingReviewClient) ListReviewRequestedPRs(ctx context.Context, reviewScope, owner, repo string) ([]*PR, error) { + c.listReviewRequestedCalls++ + return c.MockClient.ListReviewRequestedPRs(ctx, reviewScope, owner, repo) +} + +type countingWorkspaceIssueClient struct { + *MockClient + listIssuesCalls int +} + +func (c *countingWorkspaceIssueClient) ListIssues(context.Context, string, string) ([]*Issue, error) { + c.listIssuesCalls++ + return []*Issue{{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "hidden"}}, nil +} + +type capturingSearchClient struct { + *MockClient + filter string + customQuery string +} + +func (c *capturingSearchClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) { + c.filter = filter + c.customQuery = customQuery + return c.MockClient.SearchPRsPaged(ctx, filter, customQuery, page, perPage) +} + +func (c *issueSearchClient) ListIssues(context.Context, string, string) ([]*Issue, error) { + return c.issues, nil +} + +func (c *issueSearchClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) { + return &IssueSearchPage{Issues: c.issues, TotalCount: len(c.issues), Page: 1, PerPage: 25}, nil +} diff --git a/apps/backend/internal/integrations/workspacescope/workspacescope.go b/apps/backend/internal/integrations/workspacescope/workspacescope.go new file mode 100644 index 0000000000..a196a269f6 --- /dev/null +++ b/apps/backend/internal/integrations/workspacescope/workspacescope.go @@ -0,0 +1,106 @@ +package workspacescope + +import ( + "database/sql" + "encoding/json" + "errors" + "sync" + + "github.com/jmoiron/sqlx" +) + +// FallbackWorkspaceID is used only in isolated tests or malformed databases +// where integration config exists before the workspaces table is available. +const FallbackWorkspaceID = "default" + +// CachedResolver memoizes the default workspace used by legacy integration +// methods that predate explicit workspace IDs. New request paths should pass a +// workspace ID and bypass this fallback entirely. +type CachedResolver struct { + mu sync.Mutex + db *sqlx.DB + workspaceID string +} + +func (r *CachedResolver) Resolve(db *sqlx.DB) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.workspaceID != "" && r.db == db { + return r.workspaceID, nil + } + workspaceID, err := ResolveMigrationTarget(db) + if err != nil { + return "", err + } + r.db = db + r.workspaceID = workspaceID + return workspaceID, nil +} + +// ResolveMigrationTarget returns the workspace that should receive an upgraded +// singleton integration config. It prefers the workspace stored in user +// settings, then the first workspace by creation time. +func ResolveMigrationTarget(db *sqlx.DB) (string, error) { + if db == nil { + return FallbackWorkspaceID, nil + } + hasWorkspaces, err := tableExists(db, "workspaces") + if err != nil || !hasWorkspaces { + return FallbackWorkspaceID, err + } + if workspaceID, err := activeWorkspaceFromUsers(db); err != nil { + return "", err + } else if workspaceID != "" { + return workspaceID, nil + } + var id string + err = db.Get(&id, `SELECT id FROM workspaces ORDER BY created_at ASC LIMIT 1`) + if errors.Is(err, sql.ErrNoRows) { + return FallbackWorkspaceID, nil + } + if err != nil { + return "", err + } + return id, nil +} + +func tableExists(db *sqlx.DB, name string) (bool, error) { + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name); err != nil { + return false, err + } + return count > 0, nil +} + +func activeWorkspaceFromUsers(db *sqlx.DB) (string, error) { + hasUsers, err := tableExists(db, "users") + if err != nil || !hasUsers { + return "", err + } + var settings []string + if err := db.Select(&settings, `SELECT settings FROM users ORDER BY updated_at DESC`); err != nil { + return "", err + } + for _, raw := range settings { + var parsed struct { + WorkspaceID string `json:"workspace_id"` + } + if err := json.Unmarshal([]byte(raw), &parsed); err != nil || parsed.WorkspaceID == "" { + continue + } + if ok, err := workspaceExists(db, parsed.WorkspaceID); err != nil { + return "", err + } else if ok { + return parsed.WorkspaceID, nil + } + } + return "", nil +} + +func workspaceExists(db *sqlx.DB, id string) (bool, error) { + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM workspaces WHERE id = ?`, id); err != nil { + return false, err + } + return count > 0, nil +} diff --git a/apps/backend/internal/integrations/workspacescope/workspacescope_test.go b/apps/backend/internal/integrations/workspacescope/workspacescope_test.go new file mode 100644 index 0000000000..a25b1c2e8c --- /dev/null +++ b/apps/backend/internal/integrations/workspacescope/workspacescope_test.go @@ -0,0 +1,135 @@ +package workspacescope + +import ( + "database/sql" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestDB(t *testing.T) *sqlx.DB { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func seedActiveWorkspace(t *testing.T, db *sqlx.DB, workspaceID string) { + t.Helper() + now := time.Now().UTC() + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + DELETE FROM workspaces; + DELETE FROM users; + INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES (?, 'Active', ?, ?); + INSERT INTO users (id, email, settings, created_at, updated_at) + VALUES ('default-user', 'default@kandev.local', ?, ?, ?); + `, workspaceID, now, now, `{"workspace_id":"`+workspaceID+`"}`, now, now); err != nil { + t.Fatalf("seed active workspace: %v", err) + } +} + +func TestCachedResolverReusesSuccessfulResolution(t *testing.T) { + db := newTestDB(t) + seedActiveWorkspace(t, db, "ws-first") + + var resolver CachedResolver + got, err := resolver.Resolve(db) + if err != nil { + t.Fatalf("first resolve: %v", err) + } + if got != "ws-first" { + t.Fatalf("expected ws-first, got %q", got) + } + + seedActiveWorkspace(t, db, "ws-second") + got, err = resolver.Resolve(db) + if err != nil { + t.Fatalf("second resolve: %v", err) + } + if got != "ws-first" { + t.Fatalf("expected cached ws-first, got %q", got) + } +} + +func TestCachedResolverCachesPerDB(t *testing.T) { + db1 := newTestDB(t) + db2 := newTestDB(t) + seedActiveWorkspace(t, db1, "ws-one") + seedActiveWorkspace(t, db2, "ws-two") + + var resolver CachedResolver + got, err := resolver.Resolve(db1) + if err != nil { + t.Fatalf("resolve db1: %v", err) + } + if got != "ws-one" { + t.Fatalf("expected ws-one, got %q", got) + } + got, err = resolver.Resolve(db2) + if err != nil { + t.Fatalf("resolve db2: %v", err) + } + if got != "ws-two" { + t.Fatalf("expected ws-two for second db, got %q", got) + } +} + +func TestCachedResolverDoesNotMemoizeErrors(t *testing.T) { + db := newTestDB(t) + now := time.Now().UTC() + if _, err := db.Exec(` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE users ( + id TEXT PRIMARY KEY + ); + INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES ('ws-active', 'Active', ?, ?); + `, now, now); err != nil { + t.Fatalf("seed malformed users table: %v", err) + } + + var resolver CachedResolver + if _, err := resolver.Resolve(db); err == nil { + t.Fatal("expected malformed users table to fail") + } + + if _, err := db.Exec(`DROP TABLE users`); err != nil { + t.Fatalf("drop malformed users table: %v", err) + } + seedActiveWorkspace(t, db, "ws-active") + + got, err := resolver.Resolve(db) + if err != nil { + t.Fatalf("resolve after fixing users table: %v", err) + } + if got != "ws-active" { + t.Fatalf("expected ws-active after retry, got %q", got) + } +} diff --git a/apps/backend/internal/jira/handlers.go b/apps/backend/internal/jira/handlers.go index c392aa297d..f1ebaebc7a 100644 --- a/apps/backend/internal/jira/handlers.go +++ b/apps/backend/internal/jira/handlers.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -51,7 +52,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { // --- HTTP handlers --- func (c *Controller) httpGetConfig(ctx *gin.Context) { - cfg, err := c.service.GetConfig(ctx.Request.Context()) + cfg, err := c.service.GetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -69,7 +70,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + cfg, err := c.service.SetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) if err != nil { status := http.StatusInternalServerError if errors.Is(err, ErrInvalidConfig) { @@ -82,7 +83,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { } func (c *Controller) httpDeleteConfig(ctx *gin.Context) { - if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { + if err := c.service.DeleteConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -95,7 +96,7 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - result, err := c.service.TestConnection(ctx.Request.Context(), &req) + result, err := c.service.TestConnectionForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -104,7 +105,12 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { } func (c *Controller) httpListProjects(ctx *gin.Context) { - projects, err := c.service.ListProjects(ctx.Request.Context()) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + projects, err := client.ListProjects(ctx.Request.Context()) if err != nil { c.writeClientError(ctx, err) return @@ -114,7 +120,11 @@ func (c *Controller) httpListProjects(ctx *gin.Context) { func (c *Controller) httpListProjectStatuses(ctx *gin.Context) { key := ctx.Param("key") - statuses, err := c.service.ListProjectStatuses(ctx.Request.Context(), key) + statuses, err := c.service.ListProjectStatusesForWorkspace( + ctx.Request.Context(), + c.workspaceID(ctx), + key, + ) if err != nil { c.writeClientError(ctx, err) return @@ -126,7 +136,7 @@ func (c *Controller) httpSearchTickets(ctx *gin.Context) { jql := ctx.Query("jql") pageToken := ctx.Query("page_token") maxResults, _ := strconv.Atoi(ctx.Query("max_results")) - result, err := c.service.SearchTickets(ctx.Request.Context(), jql, pageToken, maxResults) + result, err := c.service.SearchTicketsForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), jql, pageToken, maxResults) if err != nil { c.writeClientError(ctx, err) return @@ -136,7 +146,12 @@ func (c *Controller) httpSearchTickets(ctx *gin.Context) { func (c *Controller) httpGetTicket(ctx *gin.Context) { key := ctx.Param("key") - ticket, err := c.service.GetTicket(ctx.Request.Context(), key) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + ticket, err := client.GetTicket(ctx.Request.Context(), key) if err != nil { c.writeClientError(ctx, err) return @@ -153,13 +168,22 @@ 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(), key, req.TransitionID); err != nil { + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + if err := client.DoTransition(ctx.Request.Context(), key, req.TransitionID); err != nil { c.writeClientError(ctx, err) return } ctx.JSON(http.StatusOK, gin.H{"transitioned": true}) } +func (c *Controller) workspaceID(ctx *gin.Context) string { + return strings.TrimSpace(ctx.Query("workspace_id")) +} + // --- Issue watch HTTP handlers --- // httpListIssueWatches returns watches scoped to one workspace when diff --git a/apps/backend/internal/jira/mock_controller.go b/apps/backend/internal/jira/mock_controller.go index 5e0d5f5bc2..02ff9664c5 100644 --- a/apps/backend/internal/jira/mock_controller.go +++ b/apps/backend/internal/jira/mock_controller.go @@ -59,7 +59,14 @@ func (c *MockController) setAuthHealth(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, time.Now().UTC()); err != nil { + workspaceID := ctx.Query("workspace_id") + var err error + if workspaceID == "" { + err = c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, time.Now().UTC()) + } else { + err = c.store.UpdateAuthHealthForWorkspace(ctx.Request.Context(), workspaceID, req.OK, req.Error, time.Now().UTC()) + } + if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/apps/backend/internal/jira/models.go b/apps/backend/internal/jira/models.go index c1312e2549..69f14129f3 100644 --- a/apps/backend/internal/jira/models.go +++ b/apps/backend/internal/jira/models.go @@ -34,10 +34,11 @@ const ( InstanceTypeServer = "server" ) -// JiraConfig is the install-wide configuration for the Jira integration. The -// secret value (API token, PAT, or session cookie) is stored separately in the -// encrypted secret store under SecretKey. +// JiraConfig is the workspace-scoped configuration for the Jira integration. +// The secret value (API token, PAT, or session cookie) is stored separately in +// the encrypted secret store. type JiraConfig struct { + WorkspaceID string `json:"workspaceId,omitempty" db:"workspace_id"` SiteURL string `json:"siteUrl" db:"site_url"` Email string `json:"email" db:"email"` AuthMethod string `json:"authMethod" db:"auth_method"` @@ -146,17 +147,20 @@ type SearchResult struct { NextPageToken string `json:"nextPageToken,omitempty"` } -// SecretKey is the secret-store key used for the install-wide Jira token. -// Centralised so that the service, store and provider migration agree. +// SecretKey is the legacy secret-store key used for the old install-wide Jira +// token. New workspace-scoped configs use SecretKeyForWorkspace. 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 { +// SecretKeyForWorkspace returns the workspace-scoped Jira secret key. +func SecretKeyForWorkspace(workspaceID string) string { return "jira:" + workspaceID + ":token" } +// LegacySecretKeyForWorkspace is kept for older tests/callers. +func LegacySecretKeyForWorkspace(workspaceID string) string { + return SecretKeyForWorkspace(workspaceID) +} + // DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher // when the caller does not specify one. Five minutes balances freshness against // Atlassian rate limits when many workspaces have watches configured. diff --git a/apps/backend/internal/jira/poller_issue_watch_test.go b/apps/backend/internal/jira/poller_issue_watch_test.go index 78eea761b6..2ffbbfd7aa 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, "tok") + f.saveConfigForWorkspace(t, "ws-1", "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, "tok") + f.saveConfigForWorkspace(t, "ws-1", "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, "tok") + f.saveConfigForWorkspace(t, "ws-1", "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 2ebbc5fc81..4a65ece2d7 100644 --- a/apps/backend/internal/jira/poller_test.go +++ b/apps/backend/internal/jira/poller_test.go @@ -36,21 +36,32 @@ func newPollerFixture(t *testing.T) *pollerFixture { return f } -// saveConfig persists the singleton config directly via the store + secret +// saveConfig persists the default workspace 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, secret string) { + t.Helper() + f.saveConfigForWorkspace(t, "", secret) +} + +// saveConfigForWorkspace persists a workspace config directly via the store + +// secret fakes. +func (f *pollerFixture) saveConfigForWorkspace(t *testing.T, workspaceID, secret string) { t.Helper() ctx := context.Background() - if err := f.store.UpsertConfig(ctx, &JiraConfig{ + if err := f.store.UpsertConfigForWorkspace(ctx, workspaceID, &JiraConfig{ SiteURL: "https://acme.atlassian.net", Email: "user@example.com", AuthMethod: AuthMethodAPIToken, }); err != nil { t.Fatalf("save config: %v", err) } - if err := f.secrets.Set(ctx, SecretKey, "jira", secret); err != nil { + key := SecretKey + if workspaceID != "" { + key = SecretKeyForWorkspace(workspaceID) + } + if err := f.secrets.Set(ctx, key, "jira", secret); err != nil { t.Fatalf("save secret: %v", err) } } diff --git a/apps/backend/internal/jira/provider.go b/apps/backend/internal/jira/provider.go index d2785fcd71..859c9c3e7c 100644 --- a/apps/backend/internal/jira/provider.go +++ b/apps/backend/internal/jira/provider.go @@ -54,30 +54,31 @@ 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. +// migrateLegacySecret copies the old install-wide token onto the +// workspace-scoped key created by the config migration, then deletes the legacy +// entry. If the workspace key already exists, it leaves both values alone. // 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 { + target := store.MigratedFromWorkspace() + if target == "" || secrets == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if exists, err := secrets.Exists(ctx, SecretKey); err == nil && exists { + targetKey := SecretKeyForWorkspace(target) + if exists, err := secrets.Exists(ctx, targetKey); err == nil && exists { return } - legacyKey := LegacySecretKeyForWorkspace(source) - value, err := secrets.Reveal(ctx, legacyKey) + value, err := secrets.Reveal(ctx, SecretKey) if err != nil || value == "" { return } - if err := secrets.Set(ctx, SecretKey, "Jira token", value); err != nil { + if err := secrets.Set(ctx, targetKey, "Jira token", value); err != nil { log.Warn("jira: legacy secret migration failed", zap.Error(err)) return } - if err := secrets.Delete(ctx, legacyKey); err != nil { + if err := secrets.Delete(ctx, SecretKey); err != nil { log.Warn("jira: legacy secret cleanup failed", zap.Error(err)) } } diff --git a/apps/backend/internal/jira/service.go b/apps/backend/internal/jira/service.go index 92c4a0ac86..045b253106 100644 --- a/apps/backend/internal/jira/service.go +++ b/apps/backend/internal/jira/service.go @@ -43,7 +43,7 @@ type Service struct { log *logger.Logger mu sync.Mutex clientFn ClientFactory - client Client // singleton, cleared on config change. + clients map[string]Client probeHook func() eventBus bus.EventBus // taskDeleter is the cascade-delete entry point used by the watch @@ -111,6 +111,7 @@ func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log * secrets: secrets, log: log, clientFn: clientFn, + clients: make(map[string]Client), } } @@ -119,20 +120,35 @@ func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log * // 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) (*JiraConfig, error) { - cfg, err := s.store.GetConfig(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// GetConfigForWorkspace returns a workspace config enriched with a HasSecret +// flag so the UI can distinguish "configured but empty" from "needs +// credentials". +func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*JiraConfig, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil || cfg == nil { return cfg, err } if s.secrets == nil { return cfg, nil } - exists, existsErr := s.secrets.Exists(ctx, SecretKey) + exists, existsErr := s.secretExists(ctx, workspaceID) if existsErr != nil { 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, SecretKey) + secret, revealErr := s.revealSecret(ctx, workspaceID) if revealErr != nil { s.log.Warn("jira: secret reveal failed", zap.Error(revealErr)) } else { @@ -151,6 +167,19 @@ var ErrInvalidConfig = errors.New("jira: invalid configuration") // "keep the existing token" — this lets the UI edit auxiliary fields without // forcing the user to paste the token again. func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*JiraConfig, error) { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.SetConfigForWorkspace(ctx, workspaceID, req) +} + +// SetConfigForWorkspace is upsert for one workspace. +func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*JiraConfig, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } if err := validateConfigRequest(req); err != nil { return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) } @@ -161,23 +190,23 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*JiraCo InstanceType: req.InstanceType, DefaultProjectKey: req.DefaultProjectKey, } - if err := s.store.UpsertConfig(ctx, cfg); err != nil { + if err := s.store.UpsertConfigForWorkspace(ctx, workspaceID, cfg); err != nil { return nil, fmt.Errorf("upsert jira config: %w", err) } if req.Secret != "" && s.secrets != nil { - if err := s.secrets.Set(ctx, SecretKey, "Jira token", req.Secret); err != nil { + if err := s.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), "Jira token", req.Secret); err != nil { return nil, fmt.Errorf("store jira secret: %w", err) } } - s.invalidateClient() + s.invalidateClient(workspaceID) // Probe asynchronously so a slow Atlassian doesn't stall the save response. // RecordAuthHealth manages its own probe timeout, so a fresh // context.Background() is enough — the request ctx may be cancelled when // this returns, but the probe and the DB write must still complete. go func() { - s.RecordAuthHealth(context.Background()) + s.RecordAuthHealthForWorkspace(context.Background(), workspaceID) }() - return s.GetConfig(ctx) + return s.GetConfigForWorkspace(ctx, workspaceID) } // DeleteConfig removes both the config row and the stored secret. A failure to @@ -185,15 +214,28 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*JiraCo // surfaces success — the orphaned secret is rare and the user can retry by // re-saving + deleting again. func (s *Service) DeleteConfig(ctx context.Context) error { - if err := s.store.DeleteConfig(ctx); err != nil { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.DeleteConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes both the config row and the stored secret. +func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return err + } + if err := s.store.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { return err } if s.secrets != nil { - if err := s.secrets.Delete(ctx, SecretKey); err != nil { + if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil { s.log.Warn("jira: secret delete failed", zap.Error(err)) } } - s.invalidateClient() + s.invalidateClient(workspaceID) return nil } @@ -202,7 +244,20 @@ func (s *Service) DeleteConfig(ctx context.Context) error { // structured result rather than an error so the UI can render the failure // inline. func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { - cfg, secret, err := s.resolveCredentials(ctx, req) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.TestConnectionForWorkspace(ctx, workspaceID, req) +} + +// TestConnectionForWorkspace validates credentials for one workspace. +func (s *Service) TestConnectionForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*TestConnectionResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + cfg, secret, err := s.resolveCredentials(ctx, workspaceID, req) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -216,7 +271,20 @@ func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*T // 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) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.ProbeAuthForWorkspace(ctx, workspaceID) +} + +// ProbeAuthForWorkspace validates the stored credentials for one workspace. +func (s *Service) ProbeAuthForWorkspace(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -254,9 +322,31 @@ func (s *Service) SetProbeHook(fn func()) { // 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) { + workspaceIDs, err := s.store.ListConfigWorkspaceIDs(ctx) + if err != nil { + s.log.Warn("jira: list config workspaces failed", zap.Error(err)) + return + } + if len(workspaceIDs) == 0 { + s.fireProbeHook() + return + } + for _, workspaceID := range workspaceIDs { + s.RecordAuthHealthForWorkspace(ctx, workspaceID) + } +} + +// RecordAuthHealthForWorkspace probes credentials and writes the outcome onto +// one workspace row. +func (s *Service) RecordAuthHealthForWorkspace(ctx context.Context, workspaceID string) { + workspaceID, normalizeErr := s.normalizeWorkspaceID(workspaceID) + if normalizeErr != nil { + s.log.Warn("jira: resolve workspace for auth health failed", zap.Error(normalizeErr)) + return + } probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) defer cancel() - res, err := s.ProbeAuth(probeCtx) + res, err := s.ProbeAuthForWorkspace(probeCtx, workspaceID) ok := err == nil && res != nil && res.OK errMsg := "" switch { @@ -271,9 +361,13 @@ func (s *Service) RecordAuthHealth(ctx context.Context) { // failed" until the next poll). writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) defer writeCancel() - if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, time.Now().UTC()); updateErr != nil { + if updateErr := s.store.UpdateAuthHealthForWorkspace(writeCtx, workspaceID, ok, errMsg, time.Now().UTC()); updateErr != nil { s.log.Warn("jira: update auth health failed", zap.Error(updateErr)) } + s.fireProbeHook() +} + +func (s *Service) fireProbeHook() { s.mu.Lock() hook := s.probeHook s.mu.Unlock() @@ -284,7 +378,11 @@ func (s *Service) RecordAuthHealth(ctx context.Context) { // 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) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -293,7 +391,11 @@ func (s *Service) GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, // DoTransition applies a transition to a ticket. func (s *Service) DoTransition(ctx context.Context, ticketKey, transitionID string) error { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return err } @@ -302,7 +404,11 @@ func (s *Service) DoTransition(ctx context.Context, ticketKey, transitionID stri // ListProjects is used by the settings UI to populate the project selector. func (s *Service) ListProjects(ctx context.Context) ([]JiraProject, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -313,7 +419,19 @@ func (s *Service) ListProjects(ctx context.Context) ([]JiraProject, error) { // used by the ticket-list status filter so users can filter by the real // statuses in the selected project rather than the three coarse categories. func (s *Service) ListProjectStatuses(ctx context.Context, projectKey string) ([]JiraStatus, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.ListProjectStatusesForWorkspace(ctx, workspaceID, projectKey) +} + +func (s *Service) ListProjectStatusesForWorkspace(ctx context.Context, workspaceID, projectKey string) ([]JiraStatus, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -324,7 +442,20 @@ func (s *Service) ListProjectStatuses(ctx context.Context, projectKey string) ([ // 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) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.SearchTicketsForWorkspace(ctx, workspaceID, jql, pageToken, maxResults) +} + +// SearchTicketsForWorkspace runs a JQL search against one workspace's client. +func (s *Service) SearchTicketsForWorkspace(ctx context.Context, workspaceID, jql, pageToken string, maxResults int) (*SearchResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -333,16 +464,23 @@ func (s *Service) SearchTickets(ctx context.Context, jql, pageToken string, maxR // 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) (Client, error) { +func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } s.mu.Lock() - if s.client != nil { - c := s.client + if s.clients == nil { + s.clients = make(map[string]Client) + } + if s.clients[workspaceID] != nil { + c := s.clients[workspaceID] s.mu.Unlock() return c, nil } s.mu.Unlock() - cfg, err := s.store.GetConfig(ctx) + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil { return nil, err } @@ -351,7 +489,7 @@ func (s *Service) clientFor(ctx context.Context) (Client, error) { } secret := "" if s.secrets != nil { - secret, err = s.secrets.Reveal(ctx, SecretKey) + secret, err = s.revealSecret(ctx, workspaceID) if err != nil { // Don't conflate a transient secret-store failure with "user never // configured Jira". The UI gates on ErrNotConfigured to render a @@ -369,25 +507,30 @@ func (s *Service) clientFor(ctx context.Context) (Client, error) { // 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 s.client != nil { - return s.client, nil + if s.clients == nil { + s.clients = make(map[string]Client) } - s.client = client + if s.clients[workspaceID] != nil { + return s.clients[workspaceID], nil + } + s.clients[workspaceID] = client return client, nil } // invalidateClient drops the cached client so the next request rebuilds it // with fresh credentials. -func (s *Service) invalidateClient() { +func (s *Service) invalidateClient(workspaceID string) { s.mu.Lock() - s.client = nil + if s.clients != nil { + delete(s.clients, workspaceID) + } s.mu.Unlock() } // resolveCredentials picks the credentials to test: if the request carries a // secret, use it inline (pre-save); otherwise fall back to the stored secret // (post-save re-test). -func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*JiraConfig, string, error) { +func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*JiraConfig, string, error) { cfg := &JiraConfig{ SiteURL: normalizeSiteURL(req.SiteURL), Email: req.Email, @@ -402,7 +545,7 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) return nil, "", errors.New("no secret store configured") } var err error - secret, err = s.secrets.Reveal(ctx, SecretKey) + secret, err = s.revealSecret(ctx, workspaceID) if err != nil { // Don't conflate a transient secret-store failure with "no token // stored". Surfacing the real error gives the user a path to retry @@ -425,7 +568,7 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) cfg.InstanceType == "" || (cfg.AuthMethod == AuthMethodAPIToken && cfg.Email == "") if needsStoredConfig { - if err := s.fillConfigFromStored(ctx, cfg); err != nil { + if err := s.fillConfigFromStored(ctx, workspaceID, cfg); err != nil { return nil, "", err } } @@ -446,8 +589,8 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) // callers don't mask DB errors as opaque "missing field" validation errors; // a missing row (stored == nil) is treated as "nothing to fill" rather than // an error so first-time TestConnection requests still go through. -func (s *Service) fillConfigFromStored(ctx context.Context, cfg *JiraConfig) error { - stored, err := s.store.GetConfig(ctx) +func (s *Service) fillConfigFromStored(ctx context.Context, workspaceID string, cfg *JiraConfig) error { + stored, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil { return fmt.Errorf("read jira config: %w", err) } @@ -469,6 +612,46 @@ func (s *Service) fillConfigFromStored(ctx context.Context, cfg *JiraConfig) err return nil } +func (s *Service) defaultWorkspaceID() (string, error) { + return s.store.defaultWorkspaceID() +} + +func (s *Service) normalizeWorkspaceID(workspaceID string) (string, error) { + if workspaceID != "" { + return workspaceID, nil + } + return s.defaultWorkspaceID() +} + +func (s *Service) secretExists(ctx context.Context, workspaceID string) (bool, error) { + if s.secrets == nil { + return false, nil + } + exists, err := s.secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil || exists { + return exists, err + } + return s.secrets.Exists(ctx, SecretKey) +} + +func (s *Service) revealSecret(ctx context.Context, workspaceID string) (string, error) { + if s.secrets == nil { + return "", nil + } + secret, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + if err == nil && secret != "" { + return secret, nil + } + legacy, legacyErr := s.secrets.Reveal(ctx, SecretKey) + if legacyErr == nil && legacy != "" { + return legacy, nil + } + if err != nil { + return "", err + } + return "", legacyErr +} + func validateConfigRequest(req *SetConfigRequest) error { if req.SiteURL == "" { return errors.New("siteUrl required") @@ -700,7 +883,7 @@ func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, err // 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) + client, err := s.clientFor(ctx, w.WorkspaceID) 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 5eeb359aa5..f0166f4dfb 100644 --- a/apps/backend/internal/jira/service_issue_watch_test.go +++ b/apps/backend/internal/jira/service_issue_watch_test.go @@ -242,7 +242,7 @@ func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) { ctx := context.Background() // Configure JIRA so clientFor succeeds. - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ SiteURL: "https://a.net", Email: "e", AuthMethod: AuthMethodAPIToken, InstanceType: InstanceTypeCloud, Secret: "tok", }); err != nil { diff --git a/apps/backend/internal/jira/service_test.go b/apps/backend/internal/jira/service_test.go index 6bf4e7cb88..a832e4ea68 100644 --- a/apps/backend/internal/jira/service_test.go +++ b/apps/backend/internal/jira/service_test.go @@ -156,7 +156,7 @@ func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.T) { if !cfg.HasSecret { t.Error("expected HasSecret=true") } - if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "tok1" { + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("default")); got != "tok1" { t.Errorf("secret stored = %q", got) } } @@ -236,7 +236,7 @@ func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { if err != nil { t.Fatalf("update: %v", err) } - if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "first" { + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("default")); got != "first" { t.Errorf("secret should be preserved, got %q", got) } } diff --git a/apps/backend/internal/jira/store.go b/apps/backend/internal/jira/store.go index d04d4b5e04..6d77a05ee5 100644 --- a/apps/backend/internal/jira/store.go +++ b/apps/backend/internal/jira/store.go @@ -9,20 +9,22 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/integrations/workspacescope" ) -// 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. +// Store persists workspace-scoped Jira configuration. Secret values are +// 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 (fresh install or already migrated). - migratedFromWorkspace string + defaultWorkspace workspacescope.CachedResolver + + // migratedToWorkspace records the workspace_id that received a legacy + // singleton row during initSchema. Provider reads this to migrate the + // singleton secret to the workspace-scoped key. Empty when no migration ran. + migratedToWorkspace string } // NewStore creates a new Store and initializes the schema if needed. @@ -34,16 +36,15 @@ 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. +// MigratedFromWorkspace is kept for older provider call sites. It now returns +// the workspace_id that received a legacy singleton row during migration. func (s *Store) MigratedFromWorkspace() string { - return s.migratedFromWorkspace + return s.migratedToWorkspace } const createTablesSQL = ` CREATE TABLE IF NOT EXISTS jira_configs ( - id TEXT PRIMARY KEY CHECK(id = 'singleton'), + workspace_id TEXT PRIMARY KEY, site_url TEXT NOT NULL, email TEXT NOT NULL DEFAULT '', auth_method TEXT NOT NULL, @@ -97,13 +98,12 @@ const createTablesSQL = ` ); ` -// 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. +// singletonID is the synthetic primary key used by the legacy install-wide +// jira_configs table. const singletonID = "singleton" func (s *Store) initSchema() error { - if err := s.migrateLegacyPerWorkspaceTable(); err != nil { + if err := s.migrateLegacySingletonTable(); err != nil { return err } if _, err := s.db.Exec(createTablesSQL); err != nil { @@ -112,6 +112,9 @@ func (s *Store) initSchema() error { if err := s.addInstanceTypeColumn(); err != nil { return err } + if err := s.addConfigHealthColumns(); err != nil { + return err + } if err := s.addMaxInflightTasksColumn(); err != nil { return err } @@ -124,6 +127,32 @@ func (s *Store) initSchema() error { return nil } +func (s *Store) addConfigHealthColumns() error { + cols, err := s.tableColumns("jira_configs") + if err != nil { + return err + } + if len(cols) == 0 { + return nil + } + if _, ok := cols["last_checked_at"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN last_checked_at DATETIME`); err != nil { + return fmt.Errorf("add last_checked_at column: %w", err) + } + } + if _, ok := cols["last_ok"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN last_ok INTEGER NOT NULL DEFAULT 0`); err != nil { + return fmt.Errorf("add last_ok column: %w", err) + } + } + if _, ok := cols["last_error"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add last_error column: %w", err) + } + } + return nil +} + // addIssueWatchRepositoryColumns brings older databases up to the current // schema by appending repository_id / base_branch to jira_issue_watches when // missing. Both backfill to ” (unbound), so existing repo-less watches keep @@ -212,14 +241,10 @@ func (s *Store) addInstanceTypeColumn() error { return nil } -// 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 { +// migrateLegacySingletonTable detects the install-wide singleton schema and +// rewrites it into the workspace-scoped shape. Picks the active/default +// workspace as the target so startup is deterministic. +func (s *Store) migrateLegacySingletonTable() error { cols, err := s.tableColumns("jira_configs") if err != nil { return err @@ -227,34 +252,36 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { if len(cols) == 0 { return nil } - if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { + if _, hasWorkspace := cols["workspace_id"]; hasWorkspace { return nil } + if _, hasID := cols["id"]; !hasID { + return nil + } + targetWorkspace, err := workspacescope.ResolveMigrationTarget(s.db) + if err != nil { + return err + } 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" + selectCols := "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 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, + row := tx.QueryRow(`SELECT `+selectCols+` FROM jira_configs WHERE id = ? LIMIT 1`, singletonID) + switch err := row.Scan(&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. @@ -270,7 +297,7 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { } if _, err := tx.Exec(` CREATE TABLE jira_configs ( - id TEXT PRIMARY KEY CHECK(id = 'singleton'), + workspace_id TEXT PRIMARY KEY, site_url TEXT NOT NULL, email TEXT NOT NULL DEFAULT '', auth_method TEXT NOT NULL, @@ -284,14 +311,11 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { )`); err != nil { return err } - // instance_type column is intentionally omitted from the INSERT: the column - // default ('cloud') is the correct value for every legacy row, since prior - // releases only supported Atlassian Cloud. if _, err := tx.Exec(` - INSERT INTO jira_configs (id, site_url, email, auth_method, default_project_key, + INSERT INTO jira_configs (workspace_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, + targetWorkspace, siteURL.String, email.String, authMethod.String, defaultProjectKey.String, nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, nullableTime(createdAt), nullableTime(updatedAt)); err != nil { return err @@ -299,7 +323,7 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { if err := tx.Commit(); err != nil { return err } - s.migratedFromWorkspace = sourceWorkspace.String + s.migratedToWorkspace = targetWorkspace return nil } @@ -347,14 +371,29 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { return cols, rows.Err() } -const selectConfigColumns = `site_url, email, auth_method, instance_type, default_project_key, +const selectConfigColumns = `workspace_id, site_url, email, auth_method, instance_type, default_project_key, last_checked_at, last_ok, last_error, created_at, updated_at` -// GetConfig returns the singleton Jira config, or nil when no row exists. +// GetConfig returns the default workspace Jira config, or nil when no row +// exists. New code should call GetConfigForWorkspace. func (s *Store) GetConfig(ctx context.Context) (*JiraConfig, error) { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// GetConfigForWorkspace returns the Jira config for a workspace, or nil when +// no row exists. +func (s *Store) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*JiraConfig, error) { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return nil, err + } var cfg JiraConfig - err := s.ro.GetContext(ctx, &cfg, - `SELECT `+selectConfigColumns+` FROM jira_configs WHERE id = ?`, singletonID) + err = s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM jira_configs WHERE workspace_id = ?`, workspaceID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -364,11 +403,24 @@ func (s *Store) GetConfig(ctx context.Context) (*JiraConfig, error) { return &cfg, nil } -// UpsertConfig inserts or updates the singleton config row. It never touches +// UpsertConfig inserts or updates the default workspace 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 { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.UpsertConfigForWorkspace(ctx, workspaceID, cfg) +} + +// UpsertConfigForWorkspace inserts or updates a workspace config row. +func (s *Store) UpsertConfigForWorkspace(ctx context.Context, workspaceID string, cfg *JiraConfig) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } now := time.Now().UTC() if cfg.CreatedAt.IsZero() { cfg.CreatedAt = now @@ -380,52 +432,99 @@ func (s *Store) UpsertConfig(ctx context.Context, cfg *JiraConfig) error { if cfg.InstanceType == "" { cfg.InstanceType = InstanceTypeCloud } - _, err := s.db.ExecContext(ctx, ` - INSERT INTO jira_configs (id, site_url, email, auth_method, instance_type, default_project_key, created_at, updated_at) + _, err = s.db.ExecContext(ctx, ` + INSERT INTO jira_configs (workspace_id, site_url, email, auth_method, instance_type, default_project_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET + ON CONFLICT(workspace_id) DO UPDATE SET site_url = excluded.site_url, email = excluded.email, auth_method = excluded.auth_method, instance_type = excluded.instance_type, default_project_key = excluded.default_project_key, updated_at = excluded.updated_at`, - singletonID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.InstanceType, cfg.DefaultProjectKey, cfg.CreatedAt, cfg.UpdatedAt) + workspaceID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.InstanceType, cfg.DefaultProjectKey, cfg.CreatedAt, cfg.UpdatedAt) return err } -// DeleteConfig removes the singleton config row. Secrets must be cleared +// DeleteConfig removes the default workspace 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) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.DeleteConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes the workspace config row. +func (s *Store) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `DELETE FROM jira_configs WHERE workspace_id = ?`, workspaceID) return err } -// HasConfig reports whether the singleton row exists. Used by the auth-health +// HasConfig reports whether any config 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) + `SELECT COUNT(*) FROM jira_configs`) if err != nil { return false, err } return present > 0, nil } -// UpdateAuthHealth records the result of a credential probe on the singleton +// ListConfigWorkspaceIDs returns every workspace with a saved Jira config. +func (s *Store) ListConfigWorkspaceIDs(ctx context.Context) ([]string, error) { + var ids []string + if err := s.ro.SelectContext(ctx, &ids, `SELECT workspace_id FROM jira_configs ORDER BY workspace_id`); err != nil { + return nil, err + } + return ids, nil +} + +// UpdateAuthHealth records the result of a credential probe on the default workspace // 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, ok bool, errMsg string, checkedAt time.Time) error { - _, err := s.db.ExecContext(ctx, ` + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.UpdateAuthHealthForWorkspace(ctx, workspaceID, ok, errMsg, checkedAt) +} + +// UpdateAuthHealthForWorkspace records the result of a credential probe for a +// single workspace config row. +func (s *Store) UpdateAuthHealthForWorkspace(ctx context.Context, workspaceID string, ok bool, errMsg string, checkedAt time.Time) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` UPDATE jira_configs SET last_checked_at = ?, last_ok = ?, last_error = ? - WHERE id = ?`, - checkedAt, ok, errMsg, singletonID) + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, workspaceID) return err } +func (s *Store) defaultWorkspaceID() (string, error) { + return s.defaultWorkspace.Resolve(s.db) +} + +func (s *Store) resolveWorkspaceID(workspaceID string) (string, error) { + if workspaceID != "" { + return workspaceID, nil + } + return s.defaultWorkspaceID() +} + // --- Issue watch operations --- // issueWatchInsertColumns / issueWatchSelectColumns split insert vs read so diff --git a/apps/backend/internal/jira/store_test.go b/apps/backend/internal/jira/store_test.go index dd1bcc2266..50075d3164 100644 --- a/apps/backend/internal/jira/store_test.go +++ b/apps/backend/internal/jira/store_test.go @@ -114,6 +114,83 @@ func TestStore_HasConfig(t *testing.T) { } } +func TestStore_MigrateSingletonConfigToActiveWorkspace(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() }) + + now := time.Now().UTC() + checkedAt := now.Add(-5 * time.Minute).Truncate(time.Second) + if _, err := db.Exec(` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + 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 + ); + INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES ('ws-first', 'First', ?, ?), ('ws-active', 'Active', ?, ?); + INSERT INTO users (id, email, settings, created_at, updated_at) + VALUES ('default-user', 'default@kandev.local', '{"workspace_id":"ws-active"}', ?, ?); + 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 ('singleton', 'https://acme.atlassian.net', 'user@example.com', ?, 'ENG', ?, 1, 'healthy', ?, ?); + `, now.Add(-time.Hour), now.Add(-time.Hour), now, now, now, now, AuthMethodAPIToken, checkedAt, now, now); err != nil { + t.Fatalf("seed singleton schema: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-active" { + t.Fatalf("expected singleton config migrated to active workspace, got %q", got) + } + cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-active") + if err != nil { + t.Fatalf("get active workspace config: %v", err) + } + if cfg == nil { + t.Fatal("expected migrated config") + } + if cfg.SiteURL != "https://acme.atlassian.net" || cfg.DefaultProjectKey != "ENG" { + t.Errorf("unexpected migrated config: %+v", cfg) + } + if cfg.Email != "user@example.com" || cfg.AuthMethod != AuthMethodAPIToken { + t.Errorf("unexpected migrated auth fields: %+v", cfg) + } + if !cfg.LastOk || cfg.LastError != "healthy" { + t.Errorf("unexpected migrated health state: %+v", cfg) + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(checkedAt) { + t.Errorf("expected last_checked_at=%v, got %v", checkedAt, cfg.LastCheckedAt) + } +} + func TestStore_UpdateAuthHealth(t *testing.T) { store := newTestStore(t) ctx := context.Background() @@ -213,18 +290,18 @@ func TestStore_MigrateLegacyPerWorkspaceTable(t *testing.T) { 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) + if got := store.MigratedFromWorkspace(); got != "" { + t.Errorf("expected no singleton migration, got %q", got) } - cfg, err := store.GetConfig(context.Background()) + cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-new") if err != nil { - t.Fatalf("get singleton: %v", err) + t.Fatalf("get workspace config: %v", err) } if cfg == nil { - t.Fatal("expected singleton row after migration") + t.Fatal("expected workspace row after schema init") } if cfg.SiteURL != "https://new.atlassian.net" { - t.Errorf("expected newest row promoted, got SiteURL=%q", cfg.SiteURL) + t.Errorf("expected workspace row preserved, got SiteURL=%q", cfg.SiteURL) } } @@ -312,15 +389,15 @@ func TestStore_MigrateLegacyPerWorkspaceTable_PreHealthColumns(t *testing.T) { 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) + if got := store.MigratedFromWorkspace(); got != "" { + t.Errorf("expected no singleton migration, got %q", got) } - cfg, err := store.GetConfig(context.Background()) + cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-1") if err != nil { - t.Fatalf("get singleton: %v", err) + t.Fatalf("get workspace config: %v", err) } if cfg == nil { - t.Fatal("expected singleton row after migration") + t.Fatal("expected workspace row after schema init") } if cfg.SiteURL != "https://acme.atlassian.net" { t.Errorf("expected SiteURL preserved, got %q", cfg.SiteURL) diff --git a/apps/backend/internal/linear/handlers.go b/apps/backend/internal/linear/handlers.go index fa2554231f..db944b6949 100644 --- a/apps/backend/internal/linear/handlers.go +++ b/apps/backend/internal/linear/handlers.go @@ -56,7 +56,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { // --- HTTP handlers --- func (c *Controller) httpGetConfig(ctx *gin.Context) { - cfg, err := c.service.GetConfig(ctx.Request.Context()) + cfg, err := c.service.GetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -74,7 +74,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + cfg, err := c.service.SetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) if err != nil { status := http.StatusInternalServerError if errors.Is(err, ErrInvalidConfig) { @@ -87,7 +87,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { } func (c *Controller) httpDeleteConfig(ctx *gin.Context) { - if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { + if err := c.service.DeleteConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -100,7 +100,7 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - result, err := c.service.TestConnection(ctx.Request.Context(), &req) + result, err := c.service.TestConnectionForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -109,7 +109,12 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { } func (c *Controller) httpListTeams(ctx *gin.Context) { - teams, err := c.service.ListTeams(ctx.Request.Context()) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + teams, err := client.ListTeams(ctx.Request.Context()) if err != nil { c.writeClientError(ctx, err) return @@ -123,7 +128,12 @@ func (c *Controller) httpListStates(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": teamKeyRequired}) return } - states, err := c.service.ListStates(ctx.Request.Context(), teamKey) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + states, err := client.ListStates(ctx.Request.Context(), teamKey) if err != nil { c.writeClientError(ctx, err) return @@ -137,7 +147,12 @@ func (c *Controller) httpListLabels(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": teamKeyRequired}) return } - labels, err := c.service.ListLabels(ctx.Request.Context(), teamKey) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + labels, err := client.ListLabels(ctx.Request.Context(), teamKey) if err != nil { c.writeClientError(ctx, err) return @@ -151,7 +166,12 @@ func (c *Controller) httpListUsers(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": teamKeyRequired}) return } - users, err := c.service.ListUsers(ctx.Request.Context(), teamKey) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + users, err := client.ListUsers(ctx.Request.Context(), teamKey) if err != nil { c.writeClientError(ctx, err) return @@ -178,7 +198,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(), filter, pageToken, maxResults) + result, err := c.service.SearchIssuesForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), filter, pageToken, maxResults) if err != nil { c.writeClientError(ctx, err) return @@ -186,6 +206,10 @@ func (c *Controller) httpSearchIssues(ctx *gin.Context) { ctx.JSON(http.StatusOK, result) } +func (c *Controller) workspaceID(ctx *gin.Context) string { + return strings.TrimSpace(ctx.Query("workspace_id")) +} + // parseSearchNumericFilters parses priority + estimate query params onto the // filter and returns a 400-worthy error when any are malformed. Invalid input // is rejected rather than silently dropped so callers don't accidentally widen diff --git a/apps/backend/internal/linear/mock_controller.go b/apps/backend/internal/linear/mock_controller.go index b5bd1f7a5d..325c48657f 100644 --- a/apps/backend/internal/linear/mock_controller.go +++ b/apps/backend/internal/linear/mock_controller.go @@ -57,7 +57,14 @@ func (c *MockController) setAuthHealth(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, req.OrgSlug, time.Now().UTC()); err != nil { + workspaceID := ctx.Query("workspace_id") + var err error + if workspaceID == "" { + err = c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, req.OrgSlug, time.Now().UTC()) + } else { + err = c.store.UpdateAuthHealthForWorkspace(ctx.Request.Context(), workspaceID, req.OK, req.Error, req.OrgSlug, time.Now().UTC()) + } + if 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 8942bd054f..c9d3a55253 100644 --- a/apps/backend/internal/linear/models.go +++ b/apps/backend/internal/linear/models.go @@ -15,10 +15,10 @@ import ( // and leaves room for OAuth in the future. const AuthMethodAPIKey = "api_key" -// LinearConfig is the install-wide configuration for the Linear integration. -// The API key is stored separately in the encrypted secret store under -// SecretKey. +// LinearConfig is the workspace-scoped configuration for the Linear +// integration. The API key is stored separately in the encrypted secret store. type LinearConfig struct { + WorkspaceID string `json:"workspaceId,omitempty" db:"workspace_id"` AuthMethod string `json:"authMethod" db:"auth_method"` DefaultTeamKey string `json:"defaultTeamKey" db:"default_team_key"` HasSecret bool `json:"hasSecret" db:"-"` @@ -181,17 +181,20 @@ type SearchResult struct { NextPageToken string `json:"nextPageToken,omitempty"` } -// SecretKey is the secret-store key used for the install-wide Linear API key. -// Centralised so the service, store and provider migration agree. +// SecretKey is the legacy secret-store key used for the old install-wide Linear +// API key. New workspace-scoped configs use SecretKeyForWorkspace. 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 { +// SecretKeyForWorkspace returns the workspace-scoped Linear secret key. +func SecretKeyForWorkspace(workspaceID string) string { return "linear:" + workspaceID + ":token" } +// LegacySecretKeyForWorkspace is kept for older tests/callers. +func LegacySecretKeyForWorkspace(workspaceID string) string { + return SecretKeyForWorkspace(workspaceID) +} + // DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher // when the caller does not specify one. Five minutes balances freshness // against Linear rate limits when many workspaces have watches configured. diff --git a/apps/backend/internal/linear/poller_issue_watch_test.go b/apps/backend/internal/linear/poller_issue_watch_test.go index 03d8db61c5..c9ef51ac6f 100644 --- a/apps/backend/internal/linear/poller_issue_watch_test.go +++ b/apps/backend/internal/linear/poller_issue_watch_test.go @@ -49,7 +49,7 @@ func (r *recordingSubscriber) snapshot() []*NewLinearIssueEvent { func TestPoller_CheckIssueWatches_PublishesNewIssuesOnly(t *testing.T) { f := newPollerFixture(t) ctx := context.Background() - f.saveConfig(t, "tok") + f.saveConfigForWorkspace(t, "ws-1", "tok") eb := bus.NewMemoryEventBus(logger.Default()) defer eb.Close() @@ -108,7 +108,7 @@ func TestPoller_CheckIssueWatches_PublishesNewIssuesOnly(t *testing.T) { func TestPoller_CheckIssueWatches_SkipsDisabled(t *testing.T) { f := newPollerFixture(t) ctx := context.Background() - f.saveConfig(t, "tok") + f.saveConfigForWorkspace(t, "ws-1", "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, "tok") + f.saveConfigForWorkspace(t, "ws-1", "tok") eb := bus.NewMemoryEventBus(logger.Default()) defer eb.Close() diff --git a/apps/backend/internal/linear/poller_test.go b/apps/backend/internal/linear/poller_test.go index 3b60b2e290..aaf20b902e 100644 --- a/apps/backend/internal/linear/poller_test.go +++ b/apps/backend/internal/linear/poller_test.go @@ -36,17 +36,29 @@ func newPollerFixture(t *testing.T) *pollerFixture { return f } -// saveConfig persists the singleton config directly via the store + secret +// saveConfig persists the default workspace config directly via the store + secret // fakes — bypassing Service.SetConfig avoids racing against its async probe. func (f *pollerFixture) saveConfig(t *testing.T, secret string) { + t.Helper() + f.saveConfigForWorkspace(t, "", secret) +} + +// saveConfigForWorkspace persists a workspace config directly via the store + +// secret fakes — bypassing Service.SetConfig avoids racing against its async +// probe. +func (f *pollerFixture) saveConfigForWorkspace(t *testing.T, workspaceID, secret string) { t.Helper() ctx := context.Background() - if err := f.store.UpsertConfig(ctx, &LinearConfig{ + if err := f.store.UpsertConfigForWorkspace(ctx, workspaceID, &LinearConfig{ AuthMethod: AuthMethodAPIKey, }); err != nil { t.Fatalf("save config: %v", err) } - if err := f.secrets.Set(ctx, SecretKey, "linear", secret); err != nil { + key := SecretKey + if workspaceID != "" { + key = SecretKeyForWorkspace(workspaceID) + } + if err := f.secrets.Set(ctx, key, "linear", secret); err != nil { t.Fatalf("save secret: %v", err) } } diff --git a/apps/backend/internal/linear/provider.go b/apps/backend/internal/linear/provider.go index 62c385e62c..0b2c46297f 100644 --- a/apps/backend/internal/linear/provider.go +++ b/apps/backend/internal/linear/provider.go @@ -52,30 +52,31 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus 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. +// migrateLegacySecret copies the old install-wide API key onto the +// workspace-scoped key created by the config migration, then deletes the legacy +// entry. If the workspace key already exists, it leaves both values alone. // 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 { + target := store.MigratedFromWorkspace() + if target == "" || secrets == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if exists, err := secrets.Exists(ctx, SecretKey); err == nil && exists { + targetKey := SecretKeyForWorkspace(target) + if exists, err := secrets.Exists(ctx, targetKey); err == nil && exists { return } - legacyKey := LegacySecretKeyForWorkspace(source) - value, err := secrets.Reveal(ctx, legacyKey) + value, err := secrets.Reveal(ctx, SecretKey) if err != nil || value == "" { return } - if err := secrets.Set(ctx, SecretKey, "Linear API key", value); err != nil { + if err := secrets.Set(ctx, targetKey, "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 { + if err := secrets.Delete(ctx, SecretKey); err != nil { log.Warn("linear: legacy secret cleanup failed", zap.Error(err)) } } diff --git a/apps/backend/internal/linear/service.go b/apps/backend/internal/linear/service.go index 403759935e..7ead0e93ba 100644 --- a/apps/backend/internal/linear/service.go +++ b/apps/backend/internal/linear/service.go @@ -39,7 +39,7 @@ type Service struct { log *logger.Logger mu sync.Mutex clientFn ClientFactory - client Client // singleton, cleared on config change. + clients map[string]Client probeHook func() eventBus bus.EventBus // taskDeleter is the cascade-delete entry point used by ResetIssueWatch. @@ -105,19 +105,33 @@ func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log * secrets: secrets, log: log, clientFn: clientFn, + clients: make(map[string]Client), } } -// GetConfig returns the singleton config enriched with a HasSecret flag. +// GetConfig returns the default workspace config enriched with a HasSecret flag. func (s *Service) GetConfig(ctx context.Context) (*LinearConfig, error) { - cfg, err := s.store.GetConfig(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// GetConfigForWorkspace returns a workspace config enriched with a HasSecret flag. +func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*LinearConfig, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil || cfg == nil { return cfg, err } if s.secrets == nil { return cfg, nil } - exists, existsErr := s.secrets.Exists(ctx, SecretKey) + exists, existsErr := s.secretExists(ctx, workspaceID) if existsErr != nil { s.log.Warn("linear: secret exists check failed", zap.Error(existsErr)) } @@ -130,6 +144,20 @@ var ErrInvalidConfig = errors.New("linear: invalid configuration") // SetConfig is upsert. An empty Secret on update keeps the existing token. func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*LinearConfig, error) { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.SetConfigForWorkspace(ctx, workspaceID, req) +} + +// SetConfigForWorkspace is upsert for one workspace. An empty Secret on update +// keeps the existing token. +func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*LinearConfig, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } if err := validateConfigRequest(req); err != nil { return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) } @@ -137,40 +165,66 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*Linear AuthMethod: req.AuthMethod, DefaultTeamKey: req.DefaultTeamKey, } - if err := s.store.UpsertConfig(ctx, cfg); err != nil { + if err := s.store.UpsertConfigForWorkspace(ctx, workspaceID, cfg); err != nil { return nil, fmt.Errorf("upsert linear config: %w", err) } if req.Secret != "" && s.secrets != nil { - if err := s.secrets.Set(ctx, SecretKey, "Linear API key", req.Secret); err != nil { + if err := s.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), "Linear API key", req.Secret); err != nil { return nil, fmt.Errorf("store linear secret: %w", err) } } - s.invalidateClient() + s.invalidateClient(workspaceID) // Probe asynchronously so a slow Linear doesn't stall the save response. go func() { - s.RecordAuthHealth(context.Background()) + s.RecordAuthHealthForWorkspace(context.Background(), workspaceID) }() - return s.GetConfig(ctx) + return s.GetConfigForWorkspace(ctx, workspaceID) } // DeleteConfig removes both the config row and the stored secret. func (s *Service) DeleteConfig(ctx context.Context) error { - if err := s.store.DeleteConfig(ctx); err != nil { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.DeleteConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes both the config row and the stored secret. +func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return err + } + if err := s.store.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { return err } if s.secrets != nil { - if err := s.secrets.Delete(ctx, SecretKey); err != nil { + if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil { s.log.Warn("linear: secret delete failed", zap.Error(err)) } } - s.invalidateClient() + s.invalidateClient(workspaceID) return nil } // TestConnection validates credentials either from a fresh SetConfigRequest // (before persisting) or from the stored config (after saving). func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { - cfg, secret, err := s.resolveCredentials(ctx, req) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.TestConnectionForWorkspace(ctx, workspaceID, req) +} + +// TestConnectionForWorkspace validates credentials for one workspace. +func (s *Service) TestConnectionForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*TestConnectionResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + cfg, secret, err := s.resolveCredentials(ctx, workspaceID, req) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -180,7 +234,20 @@ func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*T // ProbeAuth validates the stored credentials. func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.ProbeAuthForWorkspace(ctx, workspaceID) +} + +// ProbeAuthForWorkspace validates the stored credentials for one workspace. +func (s *Service) ProbeAuthForWorkspace(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -209,9 +276,31 @@ func (s *Service) SetProbeHook(fn func()) { // RecordAuthHealth probes credentials and writes the outcome onto the row. func (s *Service) RecordAuthHealth(ctx context.Context) { + workspaceIDs, err := s.store.ListConfigWorkspaceIDs(ctx) + if err != nil { + s.log.Warn("linear: list config workspaces failed", zap.Error(err)) + return + } + if len(workspaceIDs) == 0 { + s.fireProbeHook() + return + } + for _, workspaceID := range workspaceIDs { + s.RecordAuthHealthForWorkspace(ctx, workspaceID) + } +} + +// RecordAuthHealthForWorkspace probes credentials and writes the outcome onto +// one workspace row. +func (s *Service) RecordAuthHealthForWorkspace(ctx context.Context, workspaceID string) { + workspaceID, normalizeErr := s.normalizeWorkspaceID(workspaceID) + if normalizeErr != nil { + s.log.Warn("linear: resolve workspace for auth health failed", zap.Error(normalizeErr)) + return + } probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) defer cancel() - res, err := s.ProbeAuth(probeCtx) + res, err := s.ProbeAuthForWorkspace(probeCtx, workspaceID) ok := err == nil && res != nil && res.OK errMsg := "" switch { @@ -228,9 +317,13 @@ func (s *Service) RecordAuthHealth(ctx context.Context) { // still record the failure. writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) defer writeCancel() - if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, orgSlug, time.Now().UTC()); updateErr != nil { + if updateErr := s.store.UpdateAuthHealthForWorkspace(writeCtx, workspaceID, ok, errMsg, orgSlug, time.Now().UTC()); updateErr != nil { s.log.Warn("linear: update auth health failed", zap.Error(updateErr)) } + s.fireProbeHook() +} + +func (s *Service) fireProbeHook() { s.mu.Lock() hook := s.probeHook s.mu.Unlock() @@ -241,7 +334,11 @@ func (s *Service) RecordAuthHealth(ctx context.Context) { // GetIssue loads a Linear issue by identifier (e.g. "ENG-123"). func (s *Service) GetIssue(ctx context.Context, identifier string) (*LinearIssue, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -250,7 +347,11 @@ func (s *Service) GetIssue(ctx context.Context, identifier string) (*LinearIssue // SetIssueState moves an issue into the requested workflow state. func (s *Service) SetIssueState(ctx context.Context, issueID, stateID string) error { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return err } @@ -259,7 +360,11 @@ func (s *Service) SetIssueState(ctx context.Context, issueID, stateID string) er // ListTeams populates the team selector on the settings page. func (s *Service) ListTeams(ctx context.Context) ([]LinearTeam, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -268,7 +373,11 @@ func (s *Service) ListTeams(ctx context.Context) ([]LinearTeam, error) { // ListStates returns the workflow states for a team identified by its key. func (s *Service) ListStates(ctx context.Context, teamKey string) ([]LinearWorkflowState, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -277,7 +386,11 @@ func (s *Service) ListStates(ctx context.Context, teamKey string) ([]LinearWorkf // ListLabels returns the issue labels for a team identified by its key. func (s *Service) ListLabels(ctx context.Context, teamKey string) ([]LinearLabel, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -287,7 +400,11 @@ func (s *Service) ListLabels(ctx context.Context, teamKey string) ([]LinearLabel // ListUsers returns the members of a team identified by its key. Used to // populate creator / assignee selectors on the watcher filter UI. func (s *Service) ListUsers(ctx context.Context, teamKey string) ([]LinearUser, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -296,7 +413,20 @@ func (s *Service) ListUsers(ctx context.Context, teamKey string) ([]LinearUser, // 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) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.SearchIssuesForWorkspace(ctx, workspaceID, filter, pageToken, maxResults) +} + +// SearchIssuesForWorkspace runs a filtered search against one workspace's client. +func (s *Service) SearchIssuesForWorkspace(ctx context.Context, workspaceID string, filter SearchFilter, pageToken string, maxResults int) (*SearchResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -304,16 +434,23 @@ func (s *Service) SearchIssues(ctx context.Context, filter SearchFilter, pageTok } // clientFor returns the cached client, creating it if needed. -func (s *Service) clientFor(ctx context.Context) (Client, error) { +func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } s.mu.Lock() - if s.client != nil { - c := s.client + if s.clients == nil { + s.clients = make(map[string]Client) + } + if s.clients[workspaceID] != nil { + c := s.clients[workspaceID] s.mu.Unlock() return c, nil } s.mu.Unlock() - cfg, err := s.store.GetConfig(ctx) + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil { return nil, err } @@ -322,7 +459,7 @@ func (s *Service) clientFor(ctx context.Context) (Client, error) { } secret := "" if s.secrets != nil { - secret, err = s.secrets.Reveal(ctx, SecretKey) + secret, err = s.revealSecret(ctx, workspaceID) if err != nil { return nil, fmt.Errorf("read linear secret: %w", err) } @@ -333,23 +470,28 @@ func (s *Service) clientFor(ctx context.Context) (Client, error) { client := s.clientFn(cfg, secret) s.mu.Lock() defer s.mu.Unlock() - if s.client != nil { - return s.client, nil + if s.clients == nil { + s.clients = make(map[string]Client) } - s.client = client + if s.clients[workspaceID] != nil { + return s.clients[workspaceID], nil + } + s.clients[workspaceID] = client return client, nil } // invalidateClient drops the cached client so the next request rebuilds it. -func (s *Service) invalidateClient() { +func (s *Service) invalidateClient(workspaceID string) { s.mu.Lock() - s.client = nil + if s.clients != nil { + delete(s.clients, workspaceID) + } s.mu.Unlock() } // resolveCredentials picks credentials for a test: inline if the request // carries a secret, otherwise the stored secret. -func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*LinearConfig, string, error) { +func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*LinearConfig, string, error) { cfg := &LinearConfig{ AuthMethod: req.AuthMethod, } @@ -359,7 +501,7 @@ 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, SecretKey) + secret, err := s.revealSecret(ctx, workspaceID) if err != nil { s.log.Warn("linear: secret reveal failed", zap.Error(err)) return nil, "", fmt.Errorf("read linear secret: %w", err) @@ -367,7 +509,7 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) if secret == "" { return nil, "", errors.New("no api key stored — paste one to test") } - stored, storeErr := s.store.GetConfig(ctx) + stored, storeErr := s.store.GetConfigForWorkspace(ctx, workspaceID) 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. @@ -379,6 +521,46 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) return cfg, secret, nil } +func (s *Service) defaultWorkspaceID() (string, error) { + return s.store.defaultWorkspaceID() +} + +func (s *Service) normalizeWorkspaceID(workspaceID string) (string, error) { + if workspaceID != "" { + return workspaceID, nil + } + return s.defaultWorkspaceID() +} + +func (s *Service) secretExists(ctx context.Context, workspaceID string) (bool, error) { + if s.secrets == nil { + return false, nil + } + exists, err := s.secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil || exists { + return exists, err + } + return s.secrets.Exists(ctx, SecretKey) +} + +func (s *Service) revealSecret(ctx context.Context, workspaceID string) (string, error) { + if s.secrets == nil { + return "", nil + } + secret, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + if err == nil && secret != "" { + return secret, nil + } + legacy, legacyErr := s.secrets.Reveal(ctx, SecretKey) + if legacyErr == nil && legacy != "" { + return legacy, nil + } + if err != nil { + return "", err + } + return "", legacyErr +} + func validateConfigRequest(req *SetConfigRequest) error { if req.AuthMethod == "" { req.AuthMethod = AuthMethodAPIKey diff --git a/apps/backend/internal/linear/service_issue_watch.go b/apps/backend/internal/linear/service_issue_watch.go index aa308b566a..17b6d1fffc 100644 --- a/apps/backend/internal/linear/service_issue_watch.go +++ b/apps/backend/internal/linear/service_issue_watch.go @@ -187,7 +187,7 @@ func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, err // the JIRA watcher. func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*LinearIssue, error) { defer s.stampWatchLastPolled(w.ID) - client, err := s.clientFor(ctx) + client, err := s.clientFor(ctx, w.WorkspaceID) if err != nil { return nil, err } diff --git a/apps/backend/internal/linear/service_issue_watch_test.go b/apps/backend/internal/linear/service_issue_watch_test.go index 55f59e50f3..547a8c2044 100644 --- a/apps/backend/internal/linear/service_issue_watch_test.go +++ b/apps/backend/internal/linear/service_issue_watch_test.go @@ -468,7 +468,7 @@ func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ AuthMethod: AuthMethodAPIKey, Secret: "lin_api", }); err != nil { t.Fatalf("set config: %v", err) @@ -516,7 +516,7 @@ func TestService_CheckIssueWatch_AppliesSort(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ AuthMethod: AuthMethodAPIKey, Secret: "lin_api", }); err != nil { t.Fatalf("set config: %v", err) @@ -558,7 +558,7 @@ func TestService_CheckIssueWatch_AppliesSort(t *testing.T) { func TestService_CheckIssueWatch_PaginatesBacklog(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ AuthMethod: AuthMethodAPIKey, Secret: "lin_api", }); err != nil { t.Fatalf("set config: %v", err) @@ -605,7 +605,7 @@ func TestService_CheckIssueWatch_PaginatesBacklog(t *testing.T) { func TestService_CheckIssueWatch_BoundsPages(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ AuthMethod: AuthMethodAPIKey, Secret: "lin_api", }); err != nil { t.Fatalf("set config: %v", err) @@ -648,7 +648,7 @@ func TestService_CheckIssueWatch_BoundsPages(t *testing.T) { func TestService_CheckIssueWatch_DefaultSortFetchesSinglePage(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ AuthMethod: AuthMethodAPIKey, Secret: "lin_api", }); err != nil { t.Fatalf("set config: %v", err) @@ -691,7 +691,7 @@ func TestService_CheckIssueWatch_DefaultSortFetchesSinglePage(t *testing.T) { func TestService_CheckIssueWatch_CancelStopsPagination(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ AuthMethod: AuthMethodAPIKey, Secret: "lin_api", }); err != nil { t.Fatalf("set config: %v", err) @@ -727,7 +727,7 @@ func TestService_CheckIssueWatch_CancelStopsPagination(t *testing.T) { func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ AuthMethod: AuthMethodAPIKey, Secret: "lin_api", }); err != nil { t.Fatalf("set config: %v", err) diff --git a/apps/backend/internal/linear/service_test.go b/apps/backend/internal/linear/service_test.go index 7ad3df030b..3ecb4ac8c4 100644 --- a/apps/backend/internal/linear/service_test.go +++ b/apps/backend/internal/linear/service_test.go @@ -186,7 +186,7 @@ func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.T) { if !cfg.HasSecret { t.Error("expected HasSecret=true") } - if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "lin_api_xyz" { + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("default")); got != "lin_api_xyz" { t.Errorf("secret stored = %q", got) } } @@ -242,7 +242,7 @@ func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { }); err != nil { t.Fatalf("update: %v", err) } - if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "first" { + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("default")); got != "first" { t.Errorf("secret should be preserved, got %q", got) } } diff --git a/apps/backend/internal/linear/store.go b/apps/backend/internal/linear/store.go index b490e71ea4..acfa766b03 100644 --- a/apps/backend/internal/linear/store.go +++ b/apps/backend/internal/linear/store.go @@ -8,19 +8,22 @@ import ( "time" "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/integrations/workspacescope" ) -// Store persists the install-wide Linear configuration. The secret API key is +// Store persists workspace-scoped 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 + defaultWorkspace workspacescope.CachedResolver + + // migratedToWorkspace records the workspace_id that received a legacy + // singleton row during initSchema. Provider reads this to migrate the + // singleton secret to the workspace-scoped key. Empty when no migration ran. + migratedToWorkspace string } // NewStore creates a new Store and initializes the schema if needed. @@ -32,16 +35,15 @@ 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. +// MigratedFromWorkspace is kept for older provider call sites. It now returns +// the workspace_id that received a legacy singleton row during migration. func (s *Store) MigratedFromWorkspace() string { - return s.migratedFromWorkspace + return s.migratedToWorkspace } const createTablesSQL = ` CREATE TABLE IF NOT EXISTS linear_configs ( - id TEXT PRIMARY KEY CHECK(id = 'singleton'), + workspace_id TEXT PRIMARY KEY, auth_method TEXT NOT NULL, default_team_key TEXT NOT NULL DEFAULT '', org_slug TEXT NOT NULL DEFAULT '', @@ -96,16 +98,20 @@ const createTablesSQL = ` ); ` -// singletonID is the synthetic primary key of the (only) row in linear_configs. +// singletonID is the synthetic primary key used by the legacy install-wide +// linear_configs table. const singletonID = "singleton" func (s *Store) initSchema() error { - if err := s.migrateLegacyPerWorkspaceTable(); err != nil { + if err := s.migrateLegacySingletonTable(); err != nil { return err } if _, err := s.db.Exec(createTablesSQL); err != nil { return err } + if err := s.addConfigColumns(); err != nil { + return err + } if err := s.addMaxInflightTasksColumn(); err != nil { return err } @@ -121,6 +127,39 @@ func (s *Store) initSchema() error { return nil } +// addConfigColumns brings old per-workspace tables up to the current +// workspace-scoped shape. +func (s *Store) addConfigColumns() error { + cols, err := s.tableColumns("linear_configs") + if err != nil { + return err + } + if len(cols) == 0 { + return nil + } + if _, ok := cols["org_slug"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE linear_configs ADD COLUMN org_slug TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add org_slug column: %w", err) + } + } + if _, ok := cols["last_checked_at"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE linear_configs ADD COLUMN last_checked_at DATETIME`); err != nil { + return fmt.Errorf("add last_checked_at column: %w", err) + } + } + if _, ok := cols["last_ok"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE linear_configs ADD COLUMN last_ok INTEGER NOT NULL DEFAULT 0`); err != nil { + return fmt.Errorf("add last_ok column: %w", err) + } + } + if _, ok := cols["last_error"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE linear_configs ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add last_error column: %w", err) + } + } + return nil +} + // addIssueWatchRepositoryColumns brings older databases up to the current // schema by appending repository_id / base_branch to linear_issue_watches when // missing. Both backfill to ” (unbound), so existing repo-less watches keep @@ -213,20 +252,20 @@ func (s *Store) addIssueWatchLastErrorColumns() error { return nil } -// 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 { +// migrateLegacySingletonTable detects the install-wide singleton schema and +// rewrites it into the workspace-scoped shape. Picks the active/default +// workspace as the target so startup is deterministic. +func (s *Store) migrateLegacySingletonTable() error { cols, err := s.tableColumns("linear_configs") if err != nil { return err } - if len(cols) == 0 { + if !isLegacySingletonConfig(cols) { return nil } - if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { - return nil + targetWorkspace, err := workspacescope.ResolveMigrationTarget(s.db) + if err != nil { + return err } tx, err := s.db.Begin() if err != nil { @@ -234,14 +273,9 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { } 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" + selectCols := "auth_method, default_team_key" if hasOrgSlug { selectCols += ", org_slug" } else { @@ -253,12 +287,12 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { 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 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, + row := tx.QueryRow(`SELECT `+selectCols+` FROM linear_configs WHERE id = ? LIMIT 1`, singletonID) + switch err := row.Scan(&authMethod, &defaultTeamKey, &orgSlug, &lastCheckedAt, &lastOk, &lastError, &createdAt, &updatedAt); { case errors.Is(err, sql.ErrNoRows): if _, err := tx.Exec(`DROP TABLE linear_configs`); err != nil { @@ -273,7 +307,7 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { } if _, err := tx.Exec(` CREATE TABLE linear_configs ( - id TEXT PRIMARY KEY CHECK(id = 'singleton'), + workspace_id TEXT PRIMARY KEY, auth_method TEXT NOT NULL, default_team_key TEXT NOT NULL DEFAULT '', org_slug TEXT NOT NULL DEFAULT '', @@ -286,10 +320,10 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { return err } if _, err := tx.Exec(` - INSERT INTO linear_configs (id, auth_method, default_team_key, org_slug, + INSERT INTO linear_configs (workspace_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, + targetWorkspace, authMethod.String, defaultTeamKey.String, orgSlug.String, nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, nullableTime(createdAt), nullableTime(updatedAt)); err != nil { return err @@ -297,10 +331,21 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { if err := tx.Commit(); err != nil { return err } - s.migratedFromWorkspace = sourceWorkspace.String + s.migratedToWorkspace = targetWorkspace return nil } +func isLegacySingletonConfig(cols map[string]struct{}) bool { + if len(cols) == 0 { + return false + } + if _, hasWorkspace := cols["workspace_id"]; hasWorkspace { + return false + } + _, hasID := cols["id"] + return hasID +} + // 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 @@ -345,14 +390,29 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { return cols, rows.Err() } -const selectConfigColumns = `auth_method, default_team_key, org_slug, +const selectConfigColumns = `workspace_id, auth_method, default_team_key, org_slug, last_checked_at, last_ok, last_error, created_at, updated_at` -// GetConfig returns the singleton Linear config, or nil when no row exists. +// GetConfig returns the default workspace Linear config, or nil when no row +// exists. New code should call GetConfigForWorkspace. func (s *Store) GetConfig(ctx context.Context) (*LinearConfig, error) { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// GetConfigForWorkspace returns the Linear config for a workspace, or nil when +// no row exists. +func (s *Store) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*LinearConfig, error) { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return nil, err + } var cfg LinearConfig - err := s.ro.GetContext(ctx, &cfg, - `SELECT `+selectConfigColumns+` FROM linear_configs WHERE id = ?`, singletonID) + err = s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM linear_configs WHERE workspace_id = ?`, workspaceID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -362,60 +422,135 @@ func (s *Store) GetConfig(ctx context.Context) (*LinearConfig, error) { return &cfg, nil } -// UpsertConfig inserts or updates the singleton config row. The last_* health +// UpsertConfig inserts or updates the default workspace 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 { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.UpsertConfigForWorkspace(ctx, workspaceID, cfg) +} + +// UpsertConfigForWorkspace inserts or updates a workspace config row. +func (s *Store) UpsertConfigForWorkspace(ctx context.Context, workspaceID string, cfg *LinearConfig) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } now := time.Now().UTC() if cfg.CreatedAt.IsZero() { cfg.CreatedAt = now } cfg.UpdatedAt = now - _, err := s.db.ExecContext(ctx, ` - INSERT INTO linear_configs (id, auth_method, default_team_key, created_at, updated_at) + _, err = s.db.ExecContext(ctx, ` + INSERT INTO linear_configs (workspace_id, auth_method, default_team_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET + ON CONFLICT(workspace_id) DO UPDATE SET auth_method = excluded.auth_method, default_team_key = excluded.default_team_key, updated_at = excluded.updated_at`, - singletonID, cfg.AuthMethod, cfg.DefaultTeamKey, cfg.CreatedAt, cfg.UpdatedAt) + workspaceID, cfg.AuthMethod, cfg.DefaultTeamKey, cfg.CreatedAt, cfg.UpdatedAt) return err } -// DeleteConfig removes the singleton config row. +// DeleteConfig removes the default workspace config row. func (s *Store) DeleteConfig(ctx context.Context) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM linear_configs WHERE id = ?`, singletonID) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.DeleteConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes the workspace config row. +func (s *Store) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `DELETE FROM linear_configs WHERE workspace_id = ?`, workspaceID) return err } -// HasConfig reports whether the singleton row exists. Used by the auth-health +// HasConfig reports whether any config 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) + `SELECT COUNT(*) FROM linear_configs`) if err != nil { return false, err } return present > 0, nil } +// HasConfigForWorkspace reports whether a config row exists for one workspace. +func (s *Store) HasConfigForWorkspace(ctx context.Context, workspaceID string) (bool, error) { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return false, err + } + var present int + err = s.ro.GetContext(ctx, &present, + `SELECT COUNT(*) FROM linear_configs WHERE workspace_id = ?`, workspaceID) + if err != nil { + return false, err + } + return present > 0, nil +} + +// ListConfigWorkspaceIDs returns every workspace with a saved Linear config. +func (s *Store) ListConfigWorkspaceIDs(ctx context.Context) ([]string, error) { + var ids []string + if err := s.ro.SelectContext(ctx, &ids, `SELECT workspace_id FROM linear_configs ORDER BY workspace_id`); err != nil { + return nil, err + } + return ids, nil +} + // UpdateAuthHealth records the result of a credential probe. orgSlug is // captured opportunistically from successful probes; pass "" to leave the // 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 id = ?`, - checkedAt, ok, errMsg, orgSlug, singletonID) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { return err } - _, err := s.db.ExecContext(ctx, ` + return s.UpdateAuthHealthForWorkspace(ctx, workspaceID, ok, errMsg, orgSlug, checkedAt) +} + +// UpdateAuthHealthForWorkspace records the result of a credential probe for a +// single workspace. +func (s *Store) UpdateAuthHealthForWorkspace(ctx context.Context, workspaceID string, ok bool, errMsg, orgSlug string, checkedAt time.Time) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } + 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) + return err + } + _, err = s.db.ExecContext(ctx, ` UPDATE linear_configs SET last_checked_at = ?, last_ok = ?, last_error = ? - WHERE id = ?`, - checkedAt, ok, errMsg, singletonID) + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, workspaceID) return err } + +func (s *Store) defaultWorkspaceID() (string, error) { + return s.defaultWorkspace.Resolve(s.db) +} + +func (s *Store) resolveWorkspaceID(workspaceID string) (string, error) { + if workspaceID != "" { + return workspaceID, nil + } + return s.defaultWorkspaceID() +} diff --git a/apps/backend/internal/linear/store_test.go b/apps/backend/internal/linear/store_test.go index 9929555a62..fd17f29fa0 100644 --- a/apps/backend/internal/linear/store_test.go +++ b/apps/backend/internal/linear/store_test.go @@ -85,6 +85,87 @@ func TestStore_GetConfig_Missing(t *testing.T) { } } +func TestStore_ConfigSchemaUsesWorkspaceID(t *testing.T) { + store := newTestStore(t) + cols, err := store.tableColumns("linear_configs") + if err != nil { + t.Fatalf("table columns: %v", err) + } + if _, ok := cols["workspace_id"]; !ok { + t.Fatal("linear_configs must be keyed by workspace_id") + } + if _, ok := cols["id"]; ok { + t.Fatal("linear_configs must not keep singleton id column") + } +} + +func TestStore_MigrateSingletonConfigToActiveWorkspace(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() }) + + now := time.Now().UTC() + if _, err := db.Exec(` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + 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 + ); + INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES ('ws-first', 'First', ?, ?), ('ws-active', 'Active', ?, ?); + INSERT INTO users (id, email, settings, created_at, updated_at) + VALUES ('default-user', 'default@kandev.local', '{"workspace_id":"ws-active"}', ?, ?); + INSERT INTO linear_configs + (id, auth_method, default_team_key, org_slug, last_ok, created_at, updated_at) + VALUES ('singleton', ?, 'ENG', 'acme', 1, ?, ?); + `, now.Add(-time.Hour), now.Add(-time.Hour), now, now, now, now, AuthMethodAPIKey, now, now); err != nil { + t.Fatalf("seed singleton schema: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get migrated config: %v", err) + } + if cfg == nil { + t.Fatal("expected migrated config") + } + var workspaceID string + if err := db.Get(&workspaceID, `SELECT workspace_id FROM linear_configs`); err != nil { + t.Fatalf("read migrated workspace_id: %v", err) + } + if workspaceID != "ws-active" { + t.Fatalf("expected singleton config migrated to active workspace, got %q", workspaceID) + } +} + func TestStore_HasConfig(t *testing.T) { store := newTestStore(t) ctx := context.Background() @@ -155,7 +236,7 @@ func TestStore_UpdateAuthHealth(t *testing.T) { } } -func TestStore_MigrateLegacyPerWorkspaceTable(t *testing.T) { +func TestStore_PreservesPerWorkspaceConfigTable(t *testing.T) { raw, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) @@ -198,18 +279,18 @@ func TestStore_MigrateLegacyPerWorkspaceTable(t *testing.T) { 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) + if got := store.MigratedFromWorkspace(); got != "" { + t.Errorf("expected no singleton migration, got %q", got) } - cfg, err := store.GetConfig(context.Background()) + cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-new") if err != nil { - t.Fatalf("get singleton: %v", err) + t.Fatalf("get workspace config: %v", err) } if cfg == nil { - t.Fatal("expected singleton row after migration") + t.Fatal("expected workspace row after schema init") } if cfg.DefaultTeamKey != "NEW" || cfg.OrgSlug != "new-slug" { - t.Errorf("expected newest row promoted, got %+v", cfg) + t.Errorf("expected workspace row preserved, got %+v", cfg) } } @@ -218,7 +299,7 @@ func TestStore_MigrateLegacyPerWorkspaceTable(t *testing.T) { // 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) { +func TestStore_PreservesPerWorkspaceConfigTable_PreHealthColumns(t *testing.T) { raw, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) @@ -251,15 +332,15 @@ func TestStore_MigrateLegacyPerWorkspaceTable_PreHealthColumns(t *testing.T) { 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) + if got := store.MigratedFromWorkspace(); got != "" { + t.Errorf("expected no singleton migration, got %q", got) } - cfg, err := store.GetConfig(context.Background()) + cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-1") if err != nil { - t.Fatalf("get singleton: %v", err) + t.Fatalf("get workspace config: %v", err) } if cfg == nil { - t.Fatal("expected singleton row after migration") + t.Fatal("expected workspace row after schema init") } if cfg.DefaultTeamKey != "ENG" { t.Errorf("expected DefaultTeamKey preserved, got %q", cfg.DefaultTeamKey) @@ -277,7 +358,7 @@ func TestStore_MigrateLegacyPerWorkspaceTable_PreHealthColumns(t *testing.T) { // 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) { +func TestStore_PreservesPerWorkspaceConfigTable_EmptyTable(t *testing.T) { raw, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) @@ -311,7 +392,7 @@ func TestStore_MigrateLegacyPerWorkspaceTable_EmptyTable(t *testing.T) { } cfg, err := store.GetConfig(context.Background()) if err != nil { - t.Fatalf("get singleton: %v", err) + t.Fatalf("get config: %v", err) } if cfg != nil { t.Errorf("expected nil cfg on empty legacy table, got %+v", cfg) diff --git a/apps/backend/internal/sentry/handlers.go b/apps/backend/internal/sentry/handlers.go index 236c73b976..58bf261f8a 100644 --- a/apps/backend/internal/sentry/handlers.go +++ b/apps/backend/internal/sentry/handlers.go @@ -41,7 +41,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { } func (c *Controller) httpGetConfig(ctx *gin.Context) { - cfg, err := c.service.GetConfig(ctx.Request.Context()) + cfg, err := c.service.GetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -59,7 +59,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + cfg, err := c.service.SetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) if err != nil { status := http.StatusInternalServerError if errors.Is(err, ErrInvalidConfig) { @@ -72,7 +72,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { } func (c *Controller) httpDeleteConfig(ctx *gin.Context) { - if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { + if err := c.service.DeleteConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -85,7 +85,7 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - result, err := c.service.TestConnection(ctx.Request.Context(), &req) + result, err := c.service.TestConnectionForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -94,7 +94,12 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { } func (c *Controller) httpListOrganizations(ctx *gin.Context) { - organizations, err := c.service.ListOrganizations(ctx.Request.Context()) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + organizations, err := client.ListOrganizations(ctx.Request.Context()) if err != nil { c.writeClientError(ctx, err) return @@ -103,7 +108,12 @@ func (c *Controller) httpListOrganizations(ctx *gin.Context) { } func (c *Controller) httpListProjects(ctx *gin.Context) { - projects, err := c.service.ListProjects(ctx.Request.Context()) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + projects, err := client.ListProjects(ctx.Request.Context()) if err != nil { c.writeClientError(ctx, err) return @@ -123,7 +133,7 @@ func (c *Controller) httpSearchIssues(ctx *gin.Context) { Statuses: trimAll(q["status"]), } cursor := q.Get("cursor") - result, err := c.service.SearchIssues(ctx.Request.Context(), filter, cursor) + result, err := c.service.SearchIssuesForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), filter, cursor) if err != nil { c.writeClientError(ctx, err) return @@ -133,7 +143,12 @@ func (c *Controller) httpSearchIssues(ctx *gin.Context) { func (c *Controller) httpGetIssue(ctx *gin.Context) { id := ctx.Param("id") - issue, err := c.service.GetIssue(ctx.Request.Context(), id) + client, err := c.service.clientFor(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + c.writeClientError(ctx, err) + return + } + issue, err := client.GetIssue(ctx.Request.Context(), id) if err != nil { c.writeClientError(ctx, err) return @@ -141,6 +156,10 @@ func (c *Controller) httpGetIssue(ctx *gin.Context) { ctx.JSON(http.StatusOK, issue) } +func (c *Controller) workspaceID(ctx *gin.Context) string { + return strings.TrimSpace(ctx.Query("workspace_id")) +} + // errCodeSentryNotConfigured is the wire-level code surfaced to the UI when // Sentry has no saved credentials. const errCodeSentryNotConfigured = "SENTRY_NOT_CONFIGURED" diff --git a/apps/backend/internal/sentry/mock_controller.go b/apps/backend/internal/sentry/mock_controller.go index 3983cf9bc8..43414ada2e 100644 --- a/apps/backend/internal/sentry/mock_controller.go +++ b/apps/backend/internal/sentry/mock_controller.go @@ -56,7 +56,14 @@ func (c *MockController) setAuthHealth(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, time.Now().UTC()); err != nil { + workspaceID := ctx.Query("workspace_id") + var err error + if workspaceID == "" { + err = c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, time.Now().UTC()) + } else { + err = c.store.UpdateAuthHealthForWorkspace(ctx.Request.Context(), workspaceID, req.OK, req.Error, time.Now().UTC()) + } + if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/apps/backend/internal/sentry/models.go b/apps/backend/internal/sentry/models.go index 43e3ba8377..f12669af70 100644 --- a/apps/backend/internal/sentry/models.go +++ b/apps/backend/internal/sentry/models.go @@ -13,13 +13,20 @@ import ( // user or organization auth token sent as `Authorization: Bearer `. const AuthMethodAuthToken = "auth_token" -// SecretKey is the secret-store key used for the install-wide Sentry token. +// SecretKey is the legacy secret-store key used for the old install-wide Sentry +// token. New workspace-scoped configs use SecretKeyForWorkspace. const SecretKey = "sentry:singleton:token" -// SentryConfig is the install-wide configuration for the Sentry integration. -// The token is stored separately in the encrypted secret store under SecretKey. +// SecretKeyForWorkspace returns the workspace-scoped Sentry secret key. +func SecretKeyForWorkspace(workspaceID string) string { + return "sentry:" + workspaceID + ":token" +} + +// SentryConfig is the workspace-scoped configuration for the Sentry +// integration. The token is stored separately in the encrypted secret store. type SentryConfig struct { - AuthMethod string `json:"authMethod" db:"auth_method"` + WorkspaceID string `json:"workspaceId,omitempty" db:"workspace_id"` + AuthMethod string `json:"authMethod" db:"auth_method"` // URL is the base URL of the Sentry instance (e.g. https://sentry.io for // SaaS, or a self-hosted host). The REST client appends /api/0. Defaults // to the sentry.io SaaS endpoint when left blank. diff --git a/apps/backend/internal/sentry/poller_issue_watch_test.go b/apps/backend/internal/sentry/poller_issue_watch_test.go index 656b632ce5..86dc23237d 100644 --- a/apps/backend/internal/sentry/poller_issue_watch_test.go +++ b/apps/backend/internal/sentry/poller_issue_watch_test.go @@ -49,7 +49,7 @@ func (r *recordingSubscriber) snapshot() []*NewSentryIssueEvent { func TestPoller_CheckIssueWatches_PublishesNewIssuesOnly(t *testing.T) { f := newPollerFixture(t) ctx := context.Background() - f.saveConfig(t, "tok") + f.saveConfigForWorkspace(t, "ws-1", "tok") eb := bus.NewMemoryEventBus(logger.Default()) defer eb.Close() @@ -108,7 +108,7 @@ func TestPoller_CheckIssueWatches_PublishesNewIssuesOnly(t *testing.T) { func TestPoller_CheckIssueWatches_SkipsDisabled(t *testing.T) { f := newPollerFixture(t) ctx := context.Background() - f.saveConfig(t, "tok") + f.saveConfigForWorkspace(t, "ws-1", "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, "tok") + f.saveConfigForWorkspace(t, "ws-1", "tok") eb := bus.NewMemoryEventBus(logger.Default()) defer eb.Close() diff --git a/apps/backend/internal/sentry/poller_test.go b/apps/backend/internal/sentry/poller_test.go index d154835679..ad9c063184 100644 --- a/apps/backend/internal/sentry/poller_test.go +++ b/apps/backend/internal/sentry/poller_test.go @@ -30,15 +30,26 @@ func newPollerFixture(t *testing.T) *pollerFixture { return f } -// saveConfig persists the singleton directly via store + secret fakes, +// saveConfig persists the default workspace directly via store + secret fakes, // bypassing Service.SetConfig (and its async probe). func (f *pollerFixture) saveConfig(t *testing.T, secret string) { + t.Helper() + f.saveConfigForWorkspace(t, "", secret) +} + +// saveConfigForWorkspace persists a workspace config directly via store + +// secret fakes, bypassing Service.SetConfig (and its async probe). +func (f *pollerFixture) saveConfigForWorkspace(t *testing.T, workspaceID, secret string) { t.Helper() ctx := context.Background() - if err := f.store.UpsertConfig(ctx, &SentryConfig{AuthMethod: AuthMethodAuthToken}); err != nil { + if err := f.store.UpsertConfigForWorkspace(ctx, workspaceID, &SentryConfig{AuthMethod: AuthMethodAuthToken}); err != nil { t.Fatalf("save config: %v", err) } - if err := f.secrets.Set(ctx, SecretKey, "sentry", secret); err != nil { + key := SecretKey + if workspaceID != "" { + key = SecretKeyForWorkspace(workspaceID) + } + if err := f.secrets.Set(ctx, key, "sentry", secret); err != nil { t.Fatalf("save secret: %v", err) } } diff --git a/apps/backend/internal/sentry/provider.go b/apps/backend/internal/sentry/provider.go index 6fc93029f9..d6c4c2ed61 100644 --- a/apps/backend/internal/sentry/provider.go +++ b/apps/backend/internal/sentry/provider.go @@ -1,10 +1,13 @@ package sentry 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" @@ -30,6 +33,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() { @@ -46,6 +50,30 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus return svc, cleanup, nil } +func migrateLegacySecret(store *Store, secrets SecretStore, log *logger.Logger) { + target := store.MigratedFromWorkspace() + if target == "" || secrets == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + targetKey := SecretKeyForWorkspace(target) + if exists, err := secrets.Exists(ctx, targetKey); err == nil && exists { + return + } + value, err := secrets.Reveal(ctx, SecretKey) + if err != nil || value == "" { + return + } + if err := secrets.Set(ctx, targetKey, "Sentry auth token", value); err != nil { + log.Warn("sentry: legacy secret migration failed", zap.Error(err)) + return + } + if err := secrets.Delete(ctx, SecretKey); err != nil { + log.Warn("sentry: 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/sentry/service.go b/apps/backend/internal/sentry/service.go index 80a666b71d..33bba20acf 100644 --- a/apps/backend/internal/sentry/service.go +++ b/apps/backend/internal/sentry/service.go @@ -41,12 +41,12 @@ type Service struct { log *logger.Logger mu sync.Mutex clientFn ClientFactory - client Client - // clientGen is incremented by invalidateClient each time the cached client + clients map[string]Client + // clientGen is incremented by invalidateClient each time a cached client // is discarded. clientFor captures it before I/O and only stores a newly // built client when the value is unchanged, preventing a stale client from // clobbering a concurrent invalidation. - clientGen uint64 + clientGen map[string]uint64 probeHook func() // mockClient is non-nil only when Provide built the service with a MockClient. mockClient *MockClient @@ -109,10 +109,12 @@ func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log * clientFn = DefaultClientFactory } return &Service{ - store: store, - secrets: secrets, - log: log, - clientFn: clientFn, + store: store, + secrets: secrets, + log: log, + clientFn: clientFn, + clients: make(map[string]Client), + clientGen: make(map[string]uint64), } } @@ -123,14 +125,27 @@ func (s *Service) Store() *Store { // GetConfig returns the singleton config enriched with a HasSecret flag. func (s *Service) GetConfig(ctx context.Context) (*SentryConfig, error) { - cfg, err := s.store.GetConfig(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// GetConfigForWorkspace returns a workspace config enriched with a HasSecret flag. +func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*SentryConfig, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil || cfg == nil { return cfg, err } if s.secrets == nil { return cfg, nil } - exists, existsErr := s.secrets.Exists(ctx, SecretKey) + exists, existsErr := s.secretExists(ctx, workspaceID) if existsErr != nil { s.log.Warn("sentry: secret exists check failed", zap.Error(existsErr)) } @@ -143,44 +158,84 @@ var ErrInvalidConfig = errors.New("sentry: invalid configuration") // SetConfig is upsert. An empty Secret on update keeps the existing token. func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*SentryConfig, error) { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.SetConfigForWorkspace(ctx, workspaceID, req) +} + +// SetConfigForWorkspace is upsert for one workspace. An empty Secret on update +// keeps the existing token. +func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*SentryConfig, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } if err := validateConfigRequest(req); err != nil { return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) } cfg := &SentryConfig{AuthMethod: req.AuthMethod, URL: req.URL} - if err := s.store.UpsertConfig(ctx, cfg); err != nil { + if err := s.store.UpsertConfigForWorkspace(ctx, workspaceID, cfg); err != nil { return nil, fmt.Errorf("upsert sentry config: %w", err) } if req.Secret != "" && s.secrets != nil { - if err := s.secrets.Set(ctx, SecretKey, "Sentry auth token", req.Secret); err != nil { + if err := s.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), "Sentry auth token", req.Secret); err != nil { return nil, fmt.Errorf("store sentry secret: %w", err) } } - s.invalidateClient() + s.invalidateClient(workspaceID) // Probe asynchronously so a slow Sentry doesn't stall the save response. go func() { - s.RecordAuthHealth(context.Background()) + s.RecordAuthHealthForWorkspace(context.Background(), workspaceID) }() - return s.GetConfig(ctx) + return s.GetConfigForWorkspace(ctx, workspaceID) } // DeleteConfig removes both the config row and the stored secret. func (s *Service) DeleteConfig(ctx context.Context) error { - if err := s.store.DeleteConfig(ctx); err != nil { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.DeleteConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes both the config row and the stored secret. +func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return err + } + if err := s.store.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { return err } if s.secrets != nil { - if err := s.secrets.Delete(ctx, SecretKey); err != nil { + if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil { s.log.Warn("sentry: secret delete failed", zap.Error(err)) } } - s.invalidateClient() + s.invalidateClient(workspaceID) return nil } // TestConnection validates credentials either from a fresh SetConfigRequest // (before persisting) or from the stored config (after saving). func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { - cfg, secret, err := s.resolveCredentials(ctx, req) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.TestConnectionForWorkspace(ctx, workspaceID, req) +} + +// TestConnectionForWorkspace validates credentials for one workspace. +func (s *Service) TestConnectionForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*TestConnectionResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + cfg, secret, err := s.resolveCredentials(ctx, workspaceID, req) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -190,7 +245,20 @@ func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*T // ProbeAuth validates the stored credentials. func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.ProbeAuthForWorkspace(ctx, workspaceID) +} + +// ProbeAuthForWorkspace validates the stored credentials for one workspace. +func (s *Service) ProbeAuthForWorkspace(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -214,9 +282,31 @@ func (s *Service) SetProbeHook(fn func()) { // RecordAuthHealth probes credentials and writes the outcome onto the row. func (s *Service) RecordAuthHealth(ctx context.Context) { + workspaceIDs, err := s.store.ListConfigWorkspaceIDs(ctx) + if err != nil { + s.log.Warn("sentry: list config workspaces failed", zap.Error(err)) + return + } + if len(workspaceIDs) == 0 { + s.fireProbeHook() + return + } + for _, workspaceID := range workspaceIDs { + s.RecordAuthHealthForWorkspace(ctx, workspaceID) + } +} + +// RecordAuthHealthForWorkspace probes credentials and writes the outcome onto +// one workspace row. +func (s *Service) RecordAuthHealthForWorkspace(ctx context.Context, workspaceID string) { + workspaceID, normalizeErr := s.normalizeWorkspaceID(workspaceID) + if normalizeErr != nil { + s.log.Warn("sentry: resolve workspace for auth health failed", zap.Error(normalizeErr)) + return + } probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) defer cancel() - res, err := s.ProbeAuth(probeCtx) + res, err := s.ProbeAuthForWorkspace(probeCtx, workspaceID) ok := err == nil && res != nil && res.OK errMsg := "" switch { @@ -229,9 +319,13 @@ func (s *Service) RecordAuthHealth(ctx context.Context) { // still record the failure. writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) defer writeCancel() - if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, time.Now().UTC()); updateErr != nil { + if updateErr := s.store.UpdateAuthHealthForWorkspace(writeCtx, workspaceID, ok, errMsg, time.Now().UTC()); updateErr != nil { s.log.Warn("sentry: update auth health failed", zap.Error(updateErr)) } + s.fireProbeHook() +} + +func (s *Service) fireProbeHook() { s.mu.Lock() hook := s.probeHook s.mu.Unlock() @@ -242,7 +336,11 @@ func (s *Service) RecordAuthHealth(ctx context.Context) { // ListOrganizations returns the organizations the stored token can access. func (s *Service) ListOrganizations(ctx context.Context) ([]SentryOrganization, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -251,7 +349,11 @@ func (s *Service) ListOrganizations(ctx context.Context) ([]SentryOrganization, // ListProjects returns the projects the stored token can access. func (s *Service) ListProjects(ctx context.Context) ([]SentryProject, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -261,7 +363,20 @@ func (s *Service) ListProjects(ctx context.Context) ([]SentryProject, error) { // SearchIssues runs a filtered search. The caller supplies the org/project to // search — there is no install-wide default to fall back on. func (s *Service) SearchIssues(ctx context.Context, filter SearchFilter, cursor string) (*SearchResult, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.SearchIssuesForWorkspace(ctx, workspaceID, filter, cursor) +} + +// SearchIssuesForWorkspace runs a filtered search against one workspace's client. +func (s *Service) SearchIssuesForWorkspace(ctx context.Context, workspaceID string, filter SearchFilter, cursor string) (*SearchResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -270,7 +385,11 @@ func (s *Service) SearchIssues(ctx context.Context, filter SearchFilter, cursor // GetIssue loads a single issue by short ID or numeric ID. func (s *Service) GetIssue(ctx context.Context, idOrShortID string) (*SentryIssue, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return nil, err } @@ -283,17 +402,27 @@ func (s *Service) GetIssue(ctx context.Context, idOrShortID string) (*SentryIssu // silently overwritten: if the counter changed the freshly built client is // returned to the caller without being cached, and the next call rebuilds // with the updated config. -func (s *Service) clientFor(ctx context.Context) (Client, error) { +func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } s.mu.Lock() - if s.client != nil { - c := s.client + if s.clients == nil { + s.clients = make(map[string]Client) + } + if s.clientGen == nil { + s.clientGen = make(map[string]uint64) + } + if s.clients[workspaceID] != nil { + c := s.clients[workspaceID] s.mu.Unlock() return c, nil } - gen := s.clientGen + gen := s.clientGen[workspaceID] s.mu.Unlock() - cfg, err := s.store.GetConfig(ctx) + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil { return nil, err } @@ -302,7 +431,7 @@ func (s *Service) clientFor(ctx context.Context) (Client, error) { } secret := "" if s.secrets != nil { - secret, err = s.secrets.Reveal(ctx, SecretKey) + secret, err = s.revealSecret(ctx, workspaceID) if err != nil { return nil, fmt.Errorf("read sentry secret: %w", err) } @@ -313,33 +442,44 @@ func (s *Service) clientFor(ctx context.Context) (Client, error) { client := s.clientFn(cfg, secret) s.mu.Lock() defer s.mu.Unlock() - if s.client != nil { - // Another goroutine already cached a client; use that one. - return s.client, nil + if s.clients == nil { + s.clients = make(map[string]Client) } - if s.clientGen != gen { + if s.clientGen == nil { + s.clientGen = make(map[string]uint64) + } + if s.clients[workspaceID] != nil { + // Another goroutine already cached a client; use that one. + return s.clients[workspaceID], nil + } + if s.clientGen[workspaceID] != gen { // invalidateClient ran while we were doing I/O — the config/secret we // read is now stale. Return the client to the caller for this call only; // the next call will rebuild with fresh credentials. return client, nil } - s.client = client + s.clients[workspaceID] = client return client, nil } // invalidateClient drops the cached client so the next request rebuilds it. // The generation counter is incremented so a concurrent clientFor build does // not restore a client built from the now-stale config. -func (s *Service) invalidateClient() { +func (s *Service) invalidateClient(workspaceID string) { s.mu.Lock() - s.client = nil - s.clientGen++ + if s.clients != nil { + delete(s.clients, workspaceID) + } + if s.clientGen == nil { + s.clientGen = make(map[string]uint64) + } + s.clientGen[workspaceID]++ s.mu.Unlock() } // resolveCredentials picks credentials for a test: inline if the request // carries a secret, otherwise the stored secret. -func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*SentryConfig, string, error) { +func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*SentryConfig, string, error) { cfg := &SentryConfig{AuthMethod: req.AuthMethod, URL: normalizeSentryURL(req.URL)} if req.Secret != "" { if cfg.URL == "" { @@ -350,7 +490,7 @@ 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, SecretKey) + secret, err := s.revealSecret(ctx, workspaceID) if err != nil { s.log.Warn("sentry: secret reveal failed", zap.Error(err)) return nil, "", fmt.Errorf("read sentry secret: %w", err) @@ -358,7 +498,7 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) if secret == "" { return nil, "", errors.New("no auth token stored — paste one to test") } - stored, storeErr := s.store.GetConfig(ctx) + stored, storeErr := s.store.GetConfigForWorkspace(ctx, workspaceID) if storeErr != nil { s.log.Warn("sentry: load stored config for credential resolution failed", zap.Error(storeErr)) } @@ -376,6 +516,46 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) return cfg, secret, nil } +func (s *Service) defaultWorkspaceID() (string, error) { + return s.store.defaultWorkspaceID() +} + +func (s *Service) normalizeWorkspaceID(workspaceID string) (string, error) { + if workspaceID != "" { + return workspaceID, nil + } + return s.defaultWorkspaceID() +} + +func (s *Service) secretExists(ctx context.Context, workspaceID string) (bool, error) { + if s.secrets == nil { + return false, nil + } + exists, err := s.secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil || exists { + return exists, err + } + return s.secrets.Exists(ctx, SecretKey) +} + +func (s *Service) revealSecret(ctx context.Context, workspaceID string) (string, error) { + if s.secrets == nil { + return "", nil + } + secret, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + if err == nil && secret != "" { + return secret, nil + } + legacy, legacyErr := s.secrets.Reveal(ctx, SecretKey) + if legacyErr == nil && legacy != "" { + return legacy, nil + } + if err != nil { + return "", err + } + return "", legacyErr +} + // validateConfigRequest normalizes and validates a config request in place: it // defaults a blank auth method and instance URL, rejects unknown auth methods, // and rejects URLs the HTTP client could not use (see validateSentryURL). diff --git a/apps/backend/internal/sentry/service_issue_watch.go b/apps/backend/internal/sentry/service_issue_watch.go index 1940631fb2..e18c614028 100644 --- a/apps/backend/internal/sentry/service_issue_watch.go +++ b/apps/backend/internal/sentry/service_issue_watch.go @@ -182,7 +182,7 @@ func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, err // bails. Same pattern as the Linear / Jira watchers. func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*SentryIssue, error) { defer s.stampWatchLastPolled(w.ID) - client, err := s.clientFor(ctx) + client, err := s.clientFor(ctx, w.WorkspaceID) if err != nil { return nil, err } diff --git a/apps/backend/internal/sentry/service_issue_watch_test.go b/apps/backend/internal/sentry/service_issue_watch_test.go index fb91a22946..0f3a60f86f 100644 --- a/apps/backend/internal/sentry/service_issue_watch_test.go +++ b/apps/backend/internal/sentry/service_issue_watch_test.go @@ -253,7 +253,7 @@ func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() - if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ AuthMethod: AuthMethodAuthToken, Secret: "sntrys_abc", }); err != nil { t.Fatalf("set config: %v", err) diff --git a/apps/backend/internal/sentry/service_test.go b/apps/backend/internal/sentry/service_test.go index 48cec91208..2740e9b8ae 100644 --- a/apps/backend/internal/sentry/service_test.go +++ b/apps/backend/internal/sentry/service_test.go @@ -170,7 +170,7 @@ func TestService_SetConfig_PersistsAndStoresSecret(t *testing.T) { if !cfg.HasSecret { t.Error("expected HasSecret=true") } - if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "sntrys_xyz" { + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("default")); got != "sntrys_xyz" { t.Errorf("secret stored = %q", got) } } @@ -206,7 +206,7 @@ func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { }); err != nil { t.Fatalf("update: %v", err) } - if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "first" { + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("default")); got != "first" { t.Errorf("secret should be preserved, got %q", got) } } @@ -351,7 +351,7 @@ func TestService_DeleteConfig_RemovesSecretAndCache(t *testing.T) { // rebuild, hitting the factory a second time. func TestService_ClientFor_InvalidateDuringBuild(t *testing.T) { fakes := newFakeSecretStore() - _ = fakes.Set(context.Background(), SecretKey, "tok", "sntrys_xyz") + _ = fakes.Set(context.Background(), SecretKeyForWorkspace("default"), "tok", "sntrys_xyz") store := newTestStore(t) ctx := context.Background() @@ -389,13 +389,13 @@ func TestService_ClientFor_InvalidateDuringBuild(t *testing.T) { // First clientFor call — will block in Reveal while we invalidate. errCh := make(chan error, 1) go func() { - _, err := svc.clientFor(ctx) + _, err := svc.clientFor(ctx, "default") errCh <- err }() // Wait until clientFor is inside Reveal, then invalidate. <-invalidateCh - svc.invalidateClient() + svc.invalidateClient("default") // Wait for the first clientFor to finish. if err := <-errCh; err != nil { @@ -405,7 +405,7 @@ func TestService_ClientFor_InvalidateDuringBuild(t *testing.T) { // The invalidation must have reset the cache, so s.client should be nil now. // A second clientFor must hit the factory again (not return the cached stale client). slow.revealHook = nil // no more blocking - if _, err := svc.clientFor(ctx); err != nil { + if _, err := svc.clientFor(ctx, "default"); err != nil { t.Fatalf("second clientFor: %v", err) } diff --git a/apps/backend/internal/sentry/store.go b/apps/backend/internal/sentry/store.go index 75f9137dfa..c14bd351c8 100644 --- a/apps/backend/internal/sentry/store.go +++ b/apps/backend/internal/sentry/store.go @@ -8,13 +8,17 @@ import ( "time" "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/integrations/workspacescope" ) -// Store persists the install-wide Sentry configuration. The secret token is +// Store persists workspace-scoped Sentry configuration. The secret token is // delegated to the shared encrypted secret store and not stored here. type Store struct { - db *sqlx.DB - ro *sqlx.DB + db *sqlx.DB + ro *sqlx.DB + defaultWorkspace workspacescope.CachedResolver + migratedToWorkspace string } // NewStore creates a new Store and initializes the schema if needed. @@ -28,7 +32,7 @@ func NewStore(writer, reader *sqlx.DB) (*Store, error) { const createTablesSQL = ` CREATE TABLE IF NOT EXISTS sentry_configs ( - id TEXT PRIMARY KEY CHECK(id = 'singleton'), + workspace_id TEXT PRIMARY KEY, auth_method TEXT NOT NULL, url TEXT NOT NULL DEFAULT 'https://sentry.io', last_checked_at DATETIME, @@ -76,12 +80,22 @@ const createTablesSQL = ` ); ` -// singletonID is the synthetic primary key of the (only) row in sentry_configs. +// singletonID is the synthetic primary key used by the legacy install-wide +// sentry_configs table. const singletonID = "singleton" +// MigratedFromWorkspace is kept for parity with Jira/Linear provider +// migrations. It returns the workspace_id that received a legacy singleton row. +func (s *Store) MigratedFromWorkspace() string { + return s.migratedToWorkspace +} + // initSchema creates the integration tables when absent and applies the // additive column migrations that bring older databases to the current schema. func (s *Store) initSchema() error { + if err := s.migrateLegacySingletonTable(); err != nil { + return err + } if _, err := s.db.Exec(createTablesSQL); err != nil { return err } @@ -97,6 +111,109 @@ func (s *Store) initSchema() error { return s.addIssueWatchRepositoryColumns() } +func (s *Store) migrateLegacySingletonTable() error { + cols, err := s.tableColumns("sentry_configs") + if err != nil { + return err + } + if !isLegacySingletonConfig(cols) { + return nil + } + targetWorkspace, err := workspacescope.ResolveMigrationTarget(s.db) + if err != nil { + return err + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + selectCols := "auth_method" + if _, ok := cols["url"]; ok { + selectCols += ", url" + } else { + selectCols += ", 'https://sentry.io' AS url" + } + if healthColumnsPresent(cols) { + 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 authMethod, rawURL, lastError sql.NullString + var lastCheckedAt sql.NullTime + var lastOk sql.NullInt64 + var createdAt, updatedAt sql.NullTime + row := tx.QueryRow(`SELECT `+selectCols+` FROM sentry_configs WHERE id = ? LIMIT 1`, singletonID) + switch err := row.Scan(&authMethod, &rawURL, &lastCheckedAt, &lastOk, &lastError, &createdAt, &updatedAt); { + case errors.Is(err, sql.ErrNoRows): + if _, err := tx.Exec(`DROP TABLE sentry_configs`); err != nil { + return err + } + return tx.Commit() + case err != nil: + return err + } + if _, err := tx.Exec(`DROP TABLE sentry_configs`); err != nil { + return err + } + if _, err := tx.Exec(` + CREATE TABLE sentry_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + url TEXT NOT NULL DEFAULT 'https://sentry.io', + 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 sentry_configs (workspace_id, auth_method, url, + last_checked_at, last_ok, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + targetWorkspace, authMethod.String, rawURL.String, + nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, + nullableTime(createdAt), nullableTime(updatedAt)); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + s.migratedToWorkspace = targetWorkspace + return nil +} + +func isLegacySingletonConfig(cols map[string]struct{}) bool { + if len(cols) == 0 { + return false + } + if _, hasWorkspace := cols["workspace_id"]; hasWorkspace { + return false + } + _, hasID := cols["id"] + return hasID +} + +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 true +} + +func nullableTime(t sql.NullTime) interface{} { + if !t.Valid { + return nil + } + return t.Time +} + // addIssueWatchRepositoryColumns brings older databases up to the current // schema by appending repository_id / base_branch to sentry_issue_watches when // missing. Both backfill to ” (unbound), so existing repo-less watches keep @@ -219,14 +336,29 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { return cols, rows.Err() } -const selectConfigColumns = `auth_method, url, +const selectConfigColumns = `workspace_id, auth_method, url, last_checked_at, last_ok, last_error, created_at, updated_at` -// GetConfig returns the singleton Sentry config, or nil when no row exists. +// GetConfig returns the default workspace Sentry config, or nil when no row +// exists. New code should call GetConfigForWorkspace. func (s *Store) GetConfig(ctx context.Context) (*SentryConfig, error) { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// GetConfigForWorkspace returns the Sentry config for a workspace, or nil when +// no row exists. +func (s *Store) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*SentryConfig, error) { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return nil, err + } var cfg SentryConfig - err := s.ro.GetContext(ctx, &cfg, - `SELECT `+selectConfigColumns+` FROM sentry_configs WHERE id = ?`, singletonID) + err = s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM sentry_configs WHERE workspace_id = ?`, workspaceID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -236,49 +368,109 @@ func (s *Store) GetConfig(ctx context.Context) (*SentryConfig, error) { return &cfg, nil } -// UpsertConfig inserts or updates the singleton config row. The last_* health +// UpsertConfig inserts or updates the default workspace config row. The last_* health // columns are owned by the poller and written via UpdateAuthHealth. func (s *Store) UpsertConfig(ctx context.Context, cfg *SentryConfig) error { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.UpsertConfigForWorkspace(ctx, workspaceID, cfg) +} + +// UpsertConfigForWorkspace inserts or updates a workspace config row. +func (s *Store) UpsertConfigForWorkspace(ctx context.Context, workspaceID string, cfg *SentryConfig) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } now := time.Now().UTC() if cfg.CreatedAt.IsZero() { cfg.CreatedAt = now } cfg.UpdatedAt = now - _, err := s.db.ExecContext(ctx, ` - INSERT INTO sentry_configs (id, auth_method, url, created_at, updated_at) + _, err = s.db.ExecContext(ctx, ` + INSERT INTO sentry_configs (workspace_id, auth_method, url, created_at, updated_at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET + ON CONFLICT(workspace_id) DO UPDATE SET auth_method = excluded.auth_method, url = excluded.url, updated_at = excluded.updated_at`, - singletonID, cfg.AuthMethod, cfg.URL, cfg.CreatedAt, cfg.UpdatedAt) + workspaceID, cfg.AuthMethod, cfg.URL, cfg.CreatedAt, cfg.UpdatedAt) return err } -// DeleteConfig removes the singleton config row. +// DeleteConfig removes the default workspace config row. func (s *Store) DeleteConfig(ctx context.Context) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM sentry_configs WHERE id = ?`, singletonID) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.DeleteConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes the workspace config row. +func (s *Store) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `DELETE FROM sentry_configs WHERE workspace_id = ?`, workspaceID) return err } -// HasConfig reports whether the singleton row exists. Used by the auth-health +// HasConfig reports whether any config 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 sentry_configs WHERE id = ?`, singletonID) + `SELECT COUNT(*) FROM sentry_configs`) if err != nil { return false, err } return present > 0, nil } +// ListConfigWorkspaceIDs returns every workspace with a saved Sentry config. +func (s *Store) ListConfigWorkspaceIDs(ctx context.Context) ([]string, error) { + var ids []string + if err := s.ro.SelectContext(ctx, &ids, `SELECT workspace_id FROM sentry_configs ORDER BY workspace_id`); err != nil { + return nil, err + } + return ids, nil +} + // UpdateAuthHealth records the result of a credential probe. func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg string, checkedAt time.Time) error { - _, err := s.db.ExecContext(ctx, ` + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.UpdateAuthHealthForWorkspace(ctx, workspaceID, ok, errMsg, checkedAt) +} + +// UpdateAuthHealthForWorkspace records the result of a credential probe for a +// single workspace config row. +func (s *Store) UpdateAuthHealthForWorkspace(ctx context.Context, workspaceID string, ok bool, errMsg string, checkedAt time.Time) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` UPDATE sentry_configs SET last_checked_at = ?, last_ok = ?, last_error = ? - WHERE id = ?`, - checkedAt, ok, errMsg, singletonID) + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, workspaceID) return err } + +func (s *Store) defaultWorkspaceID() (string, error) { + return s.defaultWorkspace.Resolve(s.db) +} + +func (s *Store) resolveWorkspaceID(workspaceID string) (string, error) { + if workspaceID != "" { + return workspaceID, nil + } + return s.defaultWorkspaceID() +} diff --git a/apps/backend/internal/sentry/store_test.go b/apps/backend/internal/sentry/store_test.go index 028a768d19..94a8117f34 100644 --- a/apps/backend/internal/sentry/store_test.go +++ b/apps/backend/internal/sentry/store_test.go @@ -149,6 +149,81 @@ func TestStore_UpsertGetConfig_RoundTripsURL(t *testing.T) { } } +func TestStore_MigrateSingletonConfigToActiveWorkspace(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() }) + + now := time.Now().UTC() + checkedAt := now.Add(-5 * time.Minute).Truncate(time.Second) + if _, err := db.Exec(` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE sentry_configs ( + id TEXT PRIMARY KEY CHECK(id = 'singleton'), + auth_method TEXT NOT NULL, + url TEXT NOT NULL DEFAULT 'https://sentry.io', + 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 + ); + INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES ('ws-first', 'First', ?, ?), ('ws-active', 'Active', ?, ?); + INSERT INTO users (id, email, settings, created_at, updated_at) + VALUES ('default-user', 'default@kandev.local', '{"workspace_id":"ws-active"}', ?, ?); + INSERT INTO sentry_configs + (id, auth_method, url, last_checked_at, last_ok, last_error, created_at, updated_at) + VALUES ('singleton', ?, 'https://sentry.example.com', ?, 1, 'healthy', ?, ?); + `, now.Add(-time.Hour), now.Add(-time.Hour), now, now, now, now, AuthMethodAuthToken, checkedAt, now, now); err != nil { + t.Fatalf("seed singleton schema: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-active" { + t.Fatalf("expected singleton config migrated to active workspace, got %q", got) + } + cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-active") + if err != nil { + t.Fatalf("get active workspace config: %v", err) + } + if cfg == nil { + t.Fatal("expected migrated config") + } + if cfg.URL != "https://sentry.example.com" { + t.Errorf("expected migrated URL, got %q", cfg.URL) + } + if cfg.AuthMethod != AuthMethodAuthToken { + t.Errorf("expected migrated auth method %q, got %q", AuthMethodAuthToken, cfg.AuthMethod) + } + if !cfg.LastOk || cfg.LastError != "healthy" { + t.Errorf("unexpected migrated health state: %+v", cfg) + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(checkedAt) { + t.Errorf("expected last_checked_at=%v, got %v", checkedAt, cfg.LastCheckedAt) + } +} + // TestStore_MigratesURLColumn seeds a pre-self-hosted sentry_configs table (no // url column) and verifies NewStore adds the column, backfilling the existing // SaaS row to the sentry.io default rather than an empty string. diff --git a/apps/backend/internal/slack/handlers.go b/apps/backend/internal/slack/handlers.go index 0b9c4acf14..d18b8014eb 100644 --- a/apps/backend/internal/slack/handlers.go +++ b/apps/backend/internal/slack/handlers.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -37,7 +38,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { // --- HTTP handlers --- func (c *Controller) httpGetConfig(ctx *gin.Context) { - cfg, err := c.service.GetConfig(ctx.Request.Context()) + cfg, err := c.service.GetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -55,7 +56,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + cfg, err := c.service.SetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) if err != nil { status := http.StatusInternalServerError if errors.Is(err, ErrInvalidConfig) { @@ -68,7 +69,7 @@ func (c *Controller) httpSetConfig(ctx *gin.Context) { } func (c *Controller) httpDeleteConfig(ctx *gin.Context) { - if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { + if err := c.service.DeleteConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -81,7 +82,7 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - result, err := c.service.TestConnection(ctx.Request.Context(), &req) + result, err := c.service.TestConnectionForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) if err != nil { c.writeClientError(ctx, err) return @@ -89,6 +90,10 @@ func (c *Controller) httpTestConfig(ctx *gin.Context) { ctx.JSON(http.StatusOK, result) } +func (c *Controller) workspaceID(ctx *gin.Context) string { + return strings.TrimSpace(ctx.Query("workspace_id")) +} + // errCodeSlackNotConfigured is the wire-level code surfaced to the UI when // no Slack credentials are saved. const errCodeSlackNotConfigured = "SLACK_NOT_CONFIGURED" diff --git a/apps/backend/internal/slack/models.go b/apps/backend/internal/slack/models.go index 22cdc3193f..39abd0a26c 100644 --- a/apps/backend/internal/slack/models.go +++ b/apps/backend/internal/slack/models.go @@ -39,10 +39,11 @@ const MinPollIntervalSeconds = 5 // session shows up in the auth-status banner promptly. const MaxPollIntervalSeconds = 600 -// SlackConfig is the install-wide Slack integration configuration. The xoxc -// token and the d cookie live in the encrypted secret store under SecretKeyToken -// and SecretKeyCookie respectively. +// SlackConfig is the workspace-scoped Slack integration configuration. The xoxc +// token and the d cookie live in the encrypted secret store under workspace +// secret keys. type SlackConfig struct { + WorkspaceID string `json:"workspaceId,omitempty" db:"workspace_id"` AuthMethod string `json:"authMethod" db:"auth_method"` CommandPrefix string `json:"commandPrefix" db:"command_prefix"` // UtilityAgentID points at a row in `utility_agents`. The agent is @@ -111,22 +112,32 @@ type ThreadContext struct { Messages []SlackMessage `json:"messages"` } -// SecretKeyToken is the secret-store key for the install-wide xoxc- token. +// SecretKeyToken is the legacy secret-store key for the old install-wide xoxc- +// token. const SecretKeyToken = "slack:singleton:token" -// SecretKeyCookie is the secret-store key for the install-wide `d` cookie. +// SecretKeyCookie is the legacy secret-store key for the old install-wide `d` +// cookie. // Stored separately from the token so each can be rotated/replaced // independently — Slack rotates the d cookie much more often than the token. const SecretKeyCookie = "slack:singleton:cookie" -// LegacySecretKeyForToken returns the pre-singleton per-workspace token key, -// kept so the provider can promote an existing per-workspace token onto -// SecretKeyToken during the schema migration. -func LegacySecretKeyForToken(workspaceID string) string { +// SecretKeyForToken returns the workspace-scoped token key. +func SecretKeyForToken(workspaceID string) string { return "slack:" + workspaceID + ":token" } -// LegacySecretKeyForCookie returns the pre-singleton per-workspace cookie key. -func LegacySecretKeyForCookie(workspaceID string) string { +// SecretKeyForCookie returns the workspace-scoped cookie key. +func SecretKeyForCookie(workspaceID string) string { return "slack:" + workspaceID + ":cookie" } + +// LegacySecretKeyForToken is kept for older tests/callers. +func LegacySecretKeyForToken(workspaceID string) string { + return SecretKeyForToken(workspaceID) +} + +// LegacySecretKeyForCookie is kept for older tests/callers. +func LegacySecretKeyForCookie(workspaceID string) string { + return SecretKeyForCookie(workspaceID) +} diff --git a/apps/backend/internal/slack/provider.go b/apps/backend/internal/slack/provider.go index b8f6dd8596..2a3eb70774 100644 --- a/apps/backend/internal/slack/provider.go +++ b/apps/backend/internal/slack/provider.go @@ -27,19 +27,19 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) ( return svc, cleanup, nil } -// migrateLegacySecrets copies the per-workspace token + cookie from the -// pre-singleton secret keys onto the new install-wide keys, then deletes the +// migrateLegacySecrets copies the old install-wide token + cookie onto the +// workspace-scoped keys created by the config migration, then deletes the // legacy entries. Best-effort: any error is logged and ignored — the worst // case is the user re-pastes their credentials in the settings UI. func migrateLegacySecrets(store *Store, secrets SecretStore, log *logger.Logger) { - source := store.MigratedFromWorkspace() - if source == "" || secrets == nil { + target := store.MigratedFromWorkspace() + if target == "" || secrets == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - migrateOne(ctx, secrets, LegacySecretKeyForToken(source), SecretKeyToken, "Slack token", log) - migrateOne(ctx, secrets, LegacySecretKeyForCookie(source), SecretKeyCookie, "Slack d cookie", log) + migrateOne(ctx, secrets, SecretKeyToken, SecretKeyForToken(target), "Slack token", log) + migrateOne(ctx, secrets, SecretKeyCookie, SecretKeyForCookie(target), "Slack d cookie", log) } func migrateOne(ctx context.Context, secrets SecretStore, legacyKey, newKey, displayName string, log *logger.Logger) { diff --git a/apps/backend/internal/slack/service.go b/apps/backend/internal/slack/service.go index 5fc580886e..802e85ec57 100644 --- a/apps/backend/internal/slack/service.go +++ b/apps/backend/internal/slack/service.go @@ -34,7 +34,7 @@ type Service struct { mu sync.Mutex clientFn ClientFactory - client Client // singleton, cleared on config change. + clients map[string]Client probeHook func() } @@ -71,6 +71,7 @@ func NewService( runner: runner, log: log, clientFn: clientFn, + clients: make(map[string]Client), } } @@ -89,43 +90,64 @@ func (s *Service) Runner() AgentRunner { return s.runner } -// GetConfig returns the singleton config enriched with HasToken/HasCookie. +// GetConfig returns the default workspace config enriched with HasToken/HasCookie. func (s *Service) GetConfig(ctx context.Context) (*SlackConfig, error) { - cfg, err := s.store.GetConfig(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// GetConfigForWorkspace returns a workspace config enriched with HasToken/HasCookie. +func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*SlackConfig, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil || cfg == nil { return cfg, err } if s.secrets == nil { return cfg, nil } - cfg.HasToken = s.secretExists(ctx, SecretKeyToken, "token") - cfg.HasCookie = s.secretExists(ctx, SecretKeyCookie, "cookie") + cfg.HasToken = s.secretExists(ctx, SecretKeyForToken(workspaceID), SecretKeyToken, "token") + cfg.HasCookie = s.secretExists(ctx, SecretKeyForCookie(workspaceID), SecretKeyCookie, "cookie") return cfg, nil } -func (s *Service) secretExists(ctx context.Context, id, kind string) bool { +func (s *Service) secretExists(ctx context.Context, id, legacyID, kind string) bool { exists, err := s.secrets.Exists(ctx, id) if err != nil { s.log.Warn("slack: secret exists check failed", zap.String("kind", kind), zap.Error(err)) } + if exists { + return true + } + exists, err = s.secrets.Exists(ctx, legacyID) + if err != nil { + s.log.Warn("slack: legacy secret exists check failed", + zap.String("kind", kind), zap.Error(err)) + } return exists } // ErrInvalidConfig is returned by SetConfig when the request fails validation. var ErrInvalidConfig = errors.New("slack: invalid configuration") -func (s *Service) persistSecrets(ctx context.Context, req *SetConfigRequest) error { +func (s *Service) persistSecrets(ctx context.Context, workspaceID string, req *SetConfigRequest) error { if s.secrets == nil { return nil } if req.Token != "" { - if err := s.secrets.Set(ctx, SecretKeyToken, "Slack token", req.Token); err != nil { + if err := s.secrets.Set(ctx, SecretKeyForToken(workspaceID), "Slack token", req.Token); err != nil { return fmt.Errorf("store slack token: %w", err) } } if req.Cookie != "" { - if err := s.secrets.Set(ctx, SecretKeyCookie, "Slack d cookie", req.Cookie); err != nil { + if err := s.secrets.Set(ctx, SecretKeyForCookie(workspaceID), "Slack d cookie", req.Cookie); err != nil { return fmt.Errorf("store slack cookie: %w", err) } } @@ -134,6 +156,19 @@ func (s *Service) persistSecrets(ctx context.Context, req *SetConfigRequest) err // SetConfig is upsert. Empty Token/Cookie on update keeps the existing values. func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*SlackConfig, error) { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.SetConfigForWorkspace(ctx, workspaceID, req) +} + +// SetConfigForWorkspace is upsert. Empty Token/Cookie on update keeps existing values. +func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*SlackConfig, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } if err := validateConfigRequest(req); err != nil { return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) } @@ -143,29 +178,42 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*SlackC UtilityAgentID: req.UtilityAgentID, PollIntervalSeconds: req.PollIntervalSeconds, } - if err := s.store.UpsertConfig(ctx, cfg); err != nil { + if err := s.store.UpsertConfigForWorkspace(ctx, workspaceID, cfg); err != nil { return nil, fmt.Errorf("upsert slack config: %w", err) } - if err := s.persistSecrets(ctx, req); err != nil { + if err := s.persistSecrets(ctx, workspaceID, req); err != nil { return nil, err } - s.invalidateClient() + s.invalidateClient(workspaceID) go func() { - s.RecordAuthHealth(context.Background()) + s.RecordAuthHealthForWorkspace(context.Background(), workspaceID) }() - return s.GetConfig(ctx) + return s.GetConfigForWorkspace(ctx, workspaceID) } // DeleteConfig removes both the row and the stored secrets. func (s *Service) DeleteConfig(ctx context.Context) error { - if err := s.store.DeleteConfig(ctx); err != nil { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.DeleteConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes both the row and the stored secrets. +func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return err + } + if err := s.store.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { return err } if s.secrets != nil { - s.deleteSecret(ctx, SecretKeyToken, "token") - s.deleteSecret(ctx, SecretKeyCookie, "cookie") + s.deleteSecret(ctx, SecretKeyForToken(workspaceID), "token") + s.deleteSecret(ctx, SecretKeyForCookie(workspaceID), "cookie") } - s.invalidateClient() + s.invalidateClient(workspaceID) return nil } @@ -179,7 +227,20 @@ func (s *Service) deleteSecret(ctx context.Context, id, kind string) { // TestConnection validates credentials either inline (from a fresh request) // or from the stored secrets. func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { - cfg, token, cookie, err := s.resolveCredentials(ctx, req) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.TestConnectionForWorkspace(ctx, workspaceID, req) +} + +// TestConnectionForWorkspace validates credentials for one workspace. +func (s *Service) TestConnectionForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*TestConnectionResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + cfg, token, cookie, err := s.resolveCredentials(ctx, workspaceID, req) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -189,7 +250,20 @@ func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*T // ProbeAuth validates the stored credentials. func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) { - client, err := s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.ProbeAuthForWorkspace(ctx, workspaceID) +} + +// ProbeAuthForWorkspace validates the stored credentials for one workspace. +func (s *Service) ProbeAuthForWorkspace(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client, err := s.clientFor(ctx, workspaceID) if err != nil { return &TestConnectionResult{OK: false, Error: err.Error()}, nil } @@ -216,9 +290,31 @@ func (s *Service) SetProbeHook(fn func()) { // RecordAuthHealth probes credentials and writes the outcome onto the row. func (s *Service) RecordAuthHealth(ctx context.Context) { + workspaceIDs, err := s.store.ListConfigWorkspaceIDs(ctx) + if err != nil { + s.log.Warn("slack: list config workspaces failed", zap.Error(err)) + return + } + if len(workspaceIDs) == 0 { + s.fireProbeHook() + return + } + for _, workspaceID := range workspaceIDs { + s.RecordAuthHealthForWorkspace(ctx, workspaceID) + } +} + +// RecordAuthHealthForWorkspace probes credentials and writes the outcome onto +// one workspace row. +func (s *Service) RecordAuthHealthForWorkspace(ctx context.Context, workspaceID string) { + workspaceID, normalizeErr := s.normalizeWorkspaceID(workspaceID) + if normalizeErr != nil { + s.log.Warn("slack: resolve workspace for auth health failed", zap.Error(normalizeErr)) + return + } probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) defer cancel() - res, err := s.ProbeAuth(probeCtx) + res, err := s.ProbeAuthForWorkspace(probeCtx, workspaceID) ok := err == nil && res != nil && res.OK errMsg := "" switch { @@ -234,12 +330,16 @@ func (s *Service) RecordAuthHealth(ctx context.Context) { } writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) defer writeCancel() - if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, teamID, userID, time.Now().UTC()); updateErr != nil { + if updateErr := s.store.UpdateAuthHealthForWorkspace(writeCtx, workspaceID, ok, errMsg, teamID, userID, time.Now().UTC()); updateErr != nil { s.log.Warn("slack: update auth health failed", zap.Error(updateErr)) } if !ok { - s.invalidateClient() + s.invalidateClient(workspaceID) } + s.fireProbeHook() +} + +func (s *Service) fireProbeHook() { s.mu.Lock() hook := s.probeHook s.mu.Unlock() @@ -250,26 +350,42 @@ func (s *Service) RecordAuthHealth(ctx context.Context) { // Client exposes the cached client to the trigger and runtime. func (s *Service) Client(ctx context.Context) (Client, error) { - return s.clientFor(ctx) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.ClientForWorkspace(ctx, workspaceID) } -func (s *Service) clientFor(ctx context.Context) (Client, error) { +// ClientForWorkspace exposes a cached client for one workspace. +func (s *Service) ClientForWorkspace(ctx context.Context, workspaceID string) (Client, error) { + return s.clientFor(ctx, workspaceID) +} + +func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) { + workspaceID, err := s.normalizeWorkspaceID(workspaceID) + if err != nil { + return nil, err + } s.mu.Lock() - if s.client != nil { - c := s.client + if s.clients == nil { + s.clients = make(map[string]Client) + } + if s.clients[workspaceID] != nil { + c := s.clients[workspaceID] s.mu.Unlock() return c, nil } s.mu.Unlock() - cfg, err := s.store.GetConfig(ctx) + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) if err != nil { return nil, err } if cfg == nil { return nil, ErrNotConfigured } - token, cookie, err := s.revealSecrets(ctx) + token, cookie, err := s.revealSecrets(ctx, workspaceID) if err != nil { return nil, err } @@ -279,35 +395,40 @@ func (s *Service) clientFor(ctx context.Context) (Client, error) { client := s.clientFn(cfg, token, cookie) s.mu.Lock() defer s.mu.Unlock() - if s.client != nil { - return s.client, nil + if s.clients == nil { + s.clients = make(map[string]Client) } - s.client = client + if s.clients[workspaceID] != nil { + return s.clients[workspaceID], nil + } + s.clients[workspaceID] = client return client, nil } -func (s *Service) revealSecrets(ctx context.Context) (string, string, error) { +func (s *Service) revealSecrets(ctx context.Context, workspaceID string) (string, string, error) { if s.secrets == nil { return "", "", nil } - token, err := s.secrets.Reveal(ctx, SecretKeyToken) + token, err := s.revealSecret(ctx, SecretKeyForToken(workspaceID), SecretKeyToken) if err != nil { return "", "", fmt.Errorf("read slack token: %w", err) } - cookie, err := s.secrets.Reveal(ctx, SecretKeyCookie) + cookie, err := s.revealSecret(ctx, SecretKeyForCookie(workspaceID), SecretKeyCookie) if err != nil { return "", "", fmt.Errorf("read slack cookie: %w", err) } return token, cookie, nil } -func (s *Service) invalidateClient() { +func (s *Service) invalidateClient(workspaceID string) { s.mu.Lock() - s.client = nil + if s.clients != nil { + delete(s.clients, workspaceID) + } s.mu.Unlock() } -func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*SlackConfig, string, string, error) { +func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*SlackConfig, string, string, error) { cfg := &SlackConfig{AuthMethod: req.AuthMethod} token, cookie := req.Token, req.Cookie if token != "" && cookie != "" { @@ -317,7 +438,7 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) return nil, "", "", errors.New("no secret store configured") } if token == "" { - stored, err := s.secrets.Reveal(ctx, SecretKeyToken) + stored, err := s.revealSecret(ctx, SecretKeyForToken(workspaceID), SecretKeyToken) if err != nil { s.log.Warn("slack: token reveal failed", zap.Error(err)) return nil, "", "", fmt.Errorf("read slack token: %w", err) @@ -325,7 +446,7 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) token = stored } if cookie == "" { - stored, err := s.secrets.Reveal(ctx, SecretKeyCookie) + stored, err := s.revealSecret(ctx, SecretKeyForCookie(workspaceID), SecretKeyCookie) if err != nil { s.log.Warn("slack: cookie reveal failed", zap.Error(err)) return nil, "", "", fmt.Errorf("read slack cookie: %w", err) @@ -338,6 +459,32 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) return cfg, token, cookie, nil } +func (s *Service) revealSecret(ctx context.Context, key, legacyKey string) (string, error) { + value, err := s.secrets.Reveal(ctx, key) + if err == nil && value != "" { + return value, nil + } + legacy, legacyErr := s.secrets.Reveal(ctx, legacyKey) + if legacyErr == nil && legacy != "" { + return legacy, nil + } + if err != nil { + return "", err + } + return "", legacyErr +} + +func (s *Service) defaultWorkspaceID() (string, error) { + return s.store.defaultWorkspaceID() +} + +func (s *Service) normalizeWorkspaceID(workspaceID string) (string, error) { + if workspaceID != "" { + return workspaceID, nil + } + return s.defaultWorkspaceID() +} + func validateConfigRequest(req *SetConfigRequest) error { if req.AuthMethod == "" { req.AuthMethod = AuthMethodCookie diff --git a/apps/backend/internal/slack/store.go b/apps/backend/internal/slack/store.go index 9cd5b2e23b..08a7504115 100644 --- a/apps/backend/internal/slack/store.go +++ b/apps/backend/internal/slack/store.go @@ -8,19 +8,22 @@ import ( "time" "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/integrations/workspacescope" ) -// Store persists the install-wide Slack configuration. Secret values (xoxc +// Store persists workspace-scoped Slack configuration. Secret values (xoxc // token, d cookie) live in the shared encrypted secrets store, not 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 secrets to the new global keys. Empty when - // no migration ran. - migratedFromWorkspace string + defaultWorkspace workspacescope.CachedResolver + + // migratedToWorkspace records the workspace_id that received a legacy + // singleton row during initSchema. Provider reads this to migrate the + // singleton secrets to the workspace-scoped keys. Empty when no migration ran. + migratedToWorkspace string } // NewStore creates a new Store and initializes the schema if needed. @@ -32,16 +35,15 @@ 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. +// MigratedFromWorkspace is kept for older provider call sites. It now returns +// the workspace_id that received a legacy singleton row during migration. func (s *Store) MigratedFromWorkspace() string { - return s.migratedFromWorkspace + return s.migratedToWorkspace } const createTablesSQL = ` CREATE TABLE IF NOT EXISTS slack_configs ( - id TEXT PRIMARY KEY CHECK(id = 'singleton'), + workspace_id TEXT PRIMARY KEY, auth_method TEXT NOT NULL, command_prefix TEXT NOT NULL DEFAULT '', utility_agent_id TEXT NOT NULL DEFAULT '', @@ -57,24 +59,24 @@ const createTablesSQL = ` ); ` -// singletonID is the synthetic primary key of the (only) row in slack_configs. +// singletonID is the synthetic primary key used by the legacy install-wide +// slack_configs table. const singletonID = "singleton" func (s *Store) initSchema() error { - if err := s.migrateLegacyPerWorkspaceTable(); err != nil { + if err := s.migrateLegacySingletonTable(); err != nil { return err } if _, err := s.db.Exec(createTablesSQL); err != nil { return err } + if err := s.addConfigColumns(); err != nil { + return err + } return nil } -// migrateLegacyPerWorkspaceTable detects the pre-singleton schema (where -// slack_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 secrets. -func (s *Store) migrateLegacyPerWorkspaceTable() error { +func (s *Store) addConfigColumns() error { cols, err := s.tableColumns() if err != nil { return err @@ -82,45 +84,52 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { if len(cols) == 0 { return nil } - if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { + for _, col := range []struct { + name string + sql string + }{ + {"command_prefix", `ALTER TABLE slack_configs ADD COLUMN command_prefix TEXT NOT NULL DEFAULT ''`}, + {"utility_agent_id", `ALTER TABLE slack_configs ADD COLUMN utility_agent_id TEXT NOT NULL DEFAULT ''`}, + {"poll_interval_seconds", `ALTER TABLE slack_configs ADD COLUMN poll_interval_seconds INTEGER NOT NULL DEFAULT 30`}, + {"slack_team_id", `ALTER TABLE slack_configs ADD COLUMN slack_team_id TEXT NOT NULL DEFAULT ''`}, + {"slack_user_id", `ALTER TABLE slack_configs ADD COLUMN slack_user_id TEXT NOT NULL DEFAULT ''`}, + {"last_seen_ts", `ALTER TABLE slack_configs ADD COLUMN last_seen_ts TEXT NOT NULL DEFAULT ''`}, + {"last_checked_at", `ALTER TABLE slack_configs ADD COLUMN last_checked_at DATETIME`}, + {"last_ok", `ALTER TABLE slack_configs ADD COLUMN last_ok INTEGER NOT NULL DEFAULT 0`}, + {"last_error", `ALTER TABLE slack_configs ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`}, + } { + if _, ok := cols[col.name]; !ok { + if _, err := s.db.Exec(col.sql); err != nil { + return fmt.Errorf("add %s column: %w", col.name, err) + } + } + } + return nil +} + +// migrateLegacySingletonTable detects the install-wide singleton schema and +// rewrites it into the workspace-scoped shape. Existing workspace-keyed tables +// are already canonical and are left in place. +func (s *Store) migrateLegacySingletonTable() error { + cols, err := s.tableColumns() + if err != nil { + return err + } + if !isLegacySingletonConfig(cols) { return nil } + targetWorkspace, err := workspacescope.ResolveMigrationTarget(s.db) + if err != nil { + return err + } tx, err := s.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() - // Build the SELECT against only columns present in this database — the - // per-workspace shape evolved across patches (utility_agent_id, - // poll_interval_seconds, the slack_team/slack_user/last_seen trio, and - // the auth-health trio were all added incrementally), so a deployment - // upgrading from any earlier intermediate would crash on a SELECT that - // assumed every column. Mirrors the Linear/Jira fix. - selectCols := "workspace_id, auth_method" - selectCols += pickCol(cols, "command_prefix", "''") - selectCols += pickCol(cols, "utility_agent_id", "''") - selectCols += pickCol(cols, "poll_interval_seconds", "30") - selectCols += pickCol(cols, "slack_team_id", "''") - selectCols += pickCol(cols, "slack_user_id", "''") - selectCols += pickCol(cols, "last_seen_ts", "''") - selectCols += pickCol(cols, "last_checked_at", "NULL") - selectCols += pickCol(cols, "last_ok", "0") - selectCols += pickCol(cols, "last_error", "''") - selectCols += ", created_at, updated_at" - - var sourceWorkspace, authMethod, commandPrefix, utilityAgentID sql.NullString - var pollIntervalSeconds sql.NullInt64 - var slackTeamID, slackUserID, lastSeenTS, lastError sql.NullString - var lastCheckedAt sql.NullTime - var lastOk sql.NullInt64 - var createdAt, updatedAt sql.NullTime - row := tx.QueryRow(`SELECT ` + selectCols + ` FROM slack_configs ORDER BY updated_at DESC, workspace_id DESC LIMIT 1`) - switch err := row.Scan(&sourceWorkspace, &authMethod, - &commandPrefix, &utilityAgentID, &pollIntervalSeconds, - &slackTeamID, &slackUserID, &lastSeenTS, - &lastCheckedAt, &lastOk, &lastError, - &createdAt, &updatedAt); { + cfg, err := scanLegacySingletonConfig(tx, cols) + switch { case errors.Is(err, sql.ErrNoRows): if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil { return err @@ -135,29 +144,85 @@ func (s *Store) migrateLegacyPerWorkspaceTable() error { if _, err := tx.Exec(createTablesSQL); err != nil { return err } - pollSeconds := int(pollIntervalSeconds.Int64) - if pollSeconds == 0 { - pollSeconds = DefaultPollIntervalSeconds - } - if _, err := tx.Exec(` - INSERT INTO slack_configs ( - id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, - slack_team_id, slack_user_id, last_seen_ts, - last_checked_at, last_ok, last_error, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - singletonID, authMethod.String, commandPrefix.String, utilityAgentID.String, pollSeconds, - slackTeamID.String, slackUserID.String, lastSeenTS.String, - nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, - nullableTime(createdAt), nullableTime(updatedAt)); err != nil { + if err := insertMigratedConfig(tx, targetWorkspace, cfg); err != nil { return err } if err := tx.Commit(); err != nil { return err } - s.migratedFromWorkspace = sourceWorkspace.String + s.migratedToWorkspace = targetWorkspace return nil } +func isLegacySingletonConfig(cols map[string]struct{}) bool { + if len(cols) == 0 { + return false + } + if _, hasWorkspace := cols["workspace_id"]; hasWorkspace { + return false + } + _, hasID := cols["id"] + return hasID +} + +type legacySlackConfig struct { + authMethod sql.NullString + commandPrefix sql.NullString + utilityAgentID sql.NullString + pollIntervalSeconds sql.NullInt64 + slackTeamID sql.NullString + slackUserID sql.NullString + lastSeenTS sql.NullString + lastCheckedAt sql.NullTime + lastOk sql.NullInt64 + lastError sql.NullString + createdAt, updatedAt sql.NullTime +} + +func scanLegacySingletonConfig(tx *sql.Tx, cols map[string]struct{}) (*legacySlackConfig, error) { + // Build the SELECT against only columns present in this database — the + // per-workspace shape evolved across patches, so upgrades from earlier + // intermediates need defaults for missing columns. + selectCols := "auth_method" + selectCols += pickCol(cols, "command_prefix", "''") + selectCols += pickCol(cols, "utility_agent_id", "''") + selectCols += pickCol(cols, "poll_interval_seconds", "30") + selectCols += pickCol(cols, "slack_team_id", "''") + selectCols += pickCol(cols, "slack_user_id", "''") + selectCols += pickCol(cols, "last_seen_ts", "''") + selectCols += pickCol(cols, "last_checked_at", "NULL") + selectCols += pickCol(cols, "last_ok", "0") + selectCols += pickCol(cols, "last_error", "''") + selectCols += ", created_at, updated_at" + + cfg := &legacySlackConfig{} + row := tx.QueryRow(`SELECT `+selectCols+` FROM slack_configs WHERE id = ? LIMIT 1`, singletonID) + err := row.Scan(&cfg.authMethod, + &cfg.commandPrefix, &cfg.utilityAgentID, &cfg.pollIntervalSeconds, + &cfg.slackTeamID, &cfg.slackUserID, &cfg.lastSeenTS, + &cfg.lastCheckedAt, &cfg.lastOk, &cfg.lastError, + &cfg.createdAt, &cfg.updatedAt) + return cfg, err +} + +func insertMigratedConfig(tx *sql.Tx, workspaceID string, cfg *legacySlackConfig) error { + pollSeconds := int(cfg.pollIntervalSeconds.Int64) + if pollSeconds == 0 { + pollSeconds = DefaultPollIntervalSeconds + } + _, err := tx.Exec(` + INSERT INTO slack_configs ( + workspace_id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, + slack_team_id, slack_user_id, last_seen_ts, + last_checked_at, last_ok, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspaceID, cfg.authMethod.String, cfg.commandPrefix.String, cfg.utilityAgentID.String, pollSeconds, + cfg.slackTeamID.String, cfg.slackUserID.String, cfg.lastSeenTS.String, + nullableTime(cfg.lastCheckedAt), cfg.lastOk.Int64, cfg.lastError.String, + nullableTime(cfg.createdAt), nullableTime(cfg.updatedAt)) + return err +} + // pickCol returns ", " if the column is present in the legacy schema // or ", AS " otherwise. Lets the migration SELECT defaults // for columns that didn't exist in earlier intermediate schemas. @@ -203,16 +268,31 @@ func (s *Store) tableColumns() (map[string]struct{}, error) { return cols, rows.Err() } -const selectConfigColumns = `auth_method, command_prefix, utility_agent_id, +const selectConfigColumns = `workspace_id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, slack_team_id, slack_user_id, last_seen_ts, last_checked_at, last_ok, last_error, created_at, updated_at` -// GetConfig returns the singleton Slack config, or nil when no row exists. +// GetConfig returns the default workspace Slack config, or nil when no row +// exists. New code should call GetConfigForWorkspace. func (s *Store) GetConfig(ctx context.Context) (*SlackConfig, error) { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// GetConfigForWorkspace returns the Slack config for a workspace, or nil when +// no row exists. +func (s *Store) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*SlackConfig, error) { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return nil, err + } var cfg SlackConfig - err := s.ro.GetContext(ctx, &cfg, - `SELECT `+selectConfigColumns+` FROM slack_configs WHERE id = ?`, singletonID) + err = s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM slack_configs WHERE workspace_id = ?`, workspaceID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -222,76 +302,157 @@ func (s *Store) GetConfig(ctx context.Context) (*SlackConfig, error) { return &cfg, nil } -// UpsertConfig inserts or updates the singleton row. Health columns +// UpsertConfig inserts or updates the default workspace row. Health columns // (last_*) and watermark columns (last_seen_ts, slack_team_id, slack_user_id) // are owned by the poller / trigger and aren't touched here. func (s *Store) UpsertConfig(ctx context.Context, cfg *SlackConfig) error { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.UpsertConfigForWorkspace(ctx, workspaceID, cfg) +} + +// UpsertConfigForWorkspace inserts or updates a workspace row. +func (s *Store) UpsertConfigForWorkspace(ctx context.Context, workspaceID string, cfg *SlackConfig) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } now := time.Now().UTC() if cfg.CreatedAt.IsZero() { cfg.CreatedAt = now } cfg.UpdatedAt = now - _, err := s.db.ExecContext(ctx, ` + _, err = s.db.ExecContext(ctx, ` INSERT INTO slack_configs ( - id, auth_method, + workspace_id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET + ON CONFLICT(workspace_id) DO UPDATE SET auth_method = excluded.auth_method, command_prefix = excluded.command_prefix, utility_agent_id = excluded.utility_agent_id, poll_interval_seconds = excluded.poll_interval_seconds, updated_at = excluded.updated_at`, - singletonID, cfg.AuthMethod, + workspaceID, cfg.AuthMethod, cfg.CommandPrefix, cfg.UtilityAgentID, cfg.PollIntervalSeconds, cfg.CreatedAt, cfg.UpdatedAt) return err } -// DeleteConfig removes the singleton row. Secrets must be cleared separately. +// DeleteConfig removes the default workspace row. Secrets must be cleared separately. func (s *Store) DeleteConfig(ctx context.Context) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM slack_configs WHERE id = ?`, singletonID) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.DeleteConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes the workspace row. Secrets must be cleared separately. +func (s *Store) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `DELETE FROM slack_configs WHERE workspace_id = ?`, workspaceID) return err } -// HasConfig reports whether the singleton row exists. Used by the auth-health +// HasConfig reports whether any config 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 slack_configs WHERE id = ?`, singletonID) + `SELECT COUNT(*) FROM slack_configs`) if err != nil { return false, err } return present > 0, nil } +// ListConfigWorkspaceIDs returns every workspace with a saved Slack config. +func (s *Store) ListConfigWorkspaceIDs(ctx context.Context) ([]string, error) { + var ids []string + if err := s.ro.SelectContext(ctx, &ids, `SELECT workspace_id FROM slack_configs ORDER BY workspace_id`); err != nil { + return nil, err + } + return ids, nil +} + +// ListConfigs returns every Slack config row ordered by workspace. +func (s *Store) ListConfigs(ctx context.Context) ([]*SlackConfig, error) { + var configs []*SlackConfig + if err := s.ro.SelectContext(ctx, &configs, `SELECT `+selectConfigColumns+` FROM slack_configs ORDER BY workspace_id`); err != nil { + return nil, err + } + return configs, nil +} + // UpdateAuthHealth records the result of a credential probe and (when // supplied) the user/team identifiers captured during the same probe so the // trigger can scope its searches without an extra round-trip. func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg, teamID, userID string, checkedAt time.Time) error { + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.UpdateAuthHealthForWorkspace(ctx, workspaceID, ok, errMsg, teamID, userID, checkedAt) +} + +// UpdateAuthHealthForWorkspace records the result of a credential probe for one workspace. +func (s *Store) UpdateAuthHealthForWorkspace(ctx context.Context, workspaceID string, ok bool, errMsg, teamID, userID string, checkedAt time.Time) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } if teamID != "" && userID != "" { - _, err := s.db.ExecContext(ctx, ` + _, err = s.db.ExecContext(ctx, ` UPDATE slack_configs SET last_checked_at = ?, last_ok = ?, last_error = ?, slack_team_id = ?, slack_user_id = ? - WHERE id = ?`, - checkedAt, ok, errMsg, teamID, userID, singletonID) + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, teamID, userID, workspaceID) return err } - _, err := s.db.ExecContext(ctx, ` + _, err = s.db.ExecContext(ctx, ` UPDATE slack_configs SET last_checked_at = ?, last_ok = ?, last_error = ? - WHERE id = ?`, - checkedAt, ok, errMsg, singletonID) + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, workspaceID) return err } // UpdateLastSeenTS advances the trigger's watermark. func (s *Store) UpdateLastSeenTS(ctx context.Context, ts string) error { - _, err := s.db.ExecContext(ctx, - `UPDATE slack_configs SET last_seen_ts = ? WHERE id = ?`, - ts, singletonID) + workspaceID, err := s.defaultWorkspaceID() + if err != nil { + return err + } + return s.UpdateLastSeenTSForWorkspace(ctx, workspaceID, ts) +} + +// UpdateLastSeenTSForWorkspace advances one workspace trigger watermark. +func (s *Store) UpdateLastSeenTSForWorkspace(ctx context.Context, workspaceID, ts string) error { + workspaceID, err := s.resolveWorkspaceID(workspaceID) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `UPDATE slack_configs SET last_seen_ts = ? WHERE workspace_id = ?`, + ts, workspaceID) return err } + +func (s *Store) defaultWorkspaceID() (string, error) { + return s.defaultWorkspace.Resolve(s.db) +} + +func (s *Store) resolveWorkspaceID(workspaceID string) (string, error) { + if workspaceID != "" { + return workspaceID, nil + } + return s.defaultWorkspaceID() +} diff --git a/apps/backend/internal/slack/store_test.go b/apps/backend/internal/slack/store_test.go index 8bd3176991..e517957f71 100644 --- a/apps/backend/internal/slack/store_test.go +++ b/apps/backend/internal/slack/store_test.go @@ -157,6 +157,84 @@ func TestStore_UpdateLastSeenTS(t *testing.T) { } } +func TestStore_MigrateSingletonConfigToActiveWorkspace(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() }) + + now := time.Now().UTC() + checkedAt := now.Add(-5 * time.Minute).Truncate(time.Second) + if _, err := db.Exec(` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE slack_configs ( + id TEXT PRIMARY KEY CHECK(id = 'singleton'), + auth_method TEXT NOT NULL, + command_prefix TEXT NOT NULL DEFAULT '', + utility_agent_id TEXT NOT NULL DEFAULT '', + poll_interval_seconds INTEGER NOT NULL DEFAULT 30, + slack_team_id TEXT NOT NULL DEFAULT '', + slack_user_id TEXT NOT NULL DEFAULT '', + last_seen_ts 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 + ); + INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES ('ws-first', 'First', ?, ?), ('ws-active', 'Active', ?, ?); + INSERT INTO users (id, email, settings, created_at, updated_at) + VALUES ('default-user', 'default@kandev.local', '{"workspace_id":"ws-active"}', ?, ?); + INSERT INTO slack_configs ( + id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, + slack_team_id, slack_user_id, last_seen_ts, last_checked_at, last_ok, last_error, created_at, updated_at + ) VALUES ('singleton', 'cookie', '!kandev', 'ua-active', 60, 'T-active', 'U-active', '200.0', ?, 1, 'healthy', ?, ?); + `, now.Add(-time.Hour), now.Add(-time.Hour), now, now, now, now, checkedAt, now, now); err != nil { + t.Fatalf("seed singleton schema: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-active" { + t.Fatalf("expected singleton config migrated to active workspace, got %q", got) + } + cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-active") + if err != nil || cfg == nil { + t.Fatalf("get active workspace config: cfg=%v err=%v", cfg, err) + } + if cfg.UtilityAgentID != "ua-active" || cfg.CommandPrefix != "!kandev" || cfg.PollIntervalSeconds != 60 { + t.Errorf("unexpected migrated settings: %+v", cfg) + } + if cfg.SlackTeamID != "T-active" || cfg.SlackUserID != "U-active" || cfg.LastSeenTS != "200.0" { + t.Errorf("unexpected migrated runtime state: %+v", cfg) + } + if !cfg.LastOk || cfg.LastError != "healthy" { + t.Errorf("unexpected migrated health state: %+v", cfg) + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(checkedAt) { + t.Errorf("expected last_checked_at=%v, got %v", checkedAt, cfg.LastCheckedAt) + } +} + func TestStore_LegacyPerWorkspaceMigration(t *testing.T) { // Simulate a deployment running the pre-singleton schema: a slack_configs // table keyed by workspace_id, with the most-recently-updated row to be @@ -205,12 +283,12 @@ func TestStore_LegacyPerWorkspaceMigration(t *testing.T) { if err != nil { t.Fatalf("new store: %v", err) } - if got := store.MigratedFromWorkspace(); got != "ws-new" { - t.Errorf("expected migration to promote ws-new (most recently updated), got %q", got) + if got := store.MigratedFromWorkspace(); got != "" { + t.Errorf("expected no singleton migration, got %q", got) } - cfg, err := store.GetConfig(context.Background()) + cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-new") if err != nil || cfg == nil { - t.Fatalf("get post-migration: cfg=%v err=%v", cfg, err) + t.Fatalf("get workspace config: cfg=%v err=%v", cfg, err) } if cfg.UtilityAgentID != "ua-new" || cfg.CommandPrefix != "!kandev" || cfg.PollIntervalSeconds != 60 { t.Errorf("expected ws-new fields preserved, got %+v", cfg) diff --git a/apps/backend/internal/slack/trigger.go b/apps/backend/internal/slack/trigger.go index 7d1dfa89a6..85d4365b30 100644 --- a/apps/backend/internal/slack/trigger.go +++ b/apps/backend/internal/slack/trigger.go @@ -38,11 +38,11 @@ type Trigger struct { wg sync.WaitGroup started bool - // lastScannedAt tracks when we last ran a scan so the configured - // PollIntervalSeconds is honoured exactly. In-memory by design — backend - // restart triggers an immediate scan, which is fine. + // lastScannedAt tracks when each workspace last ran a scan so its + // PollIntervalSeconds is honoured independently. In-memory by design — + // backend restart triggers an immediate scan, which is fine. scannedMu sync.Mutex - lastScannedAt time.Time + lastScannedAt map[string]time.Time } // NewTrigger returns a Trigger using the default base ticker. @@ -112,28 +112,34 @@ func (t *Trigger) tick(ctx context.Context) { if t.svc.Runner() == nil { return } - cfg, err := t.svc.Store().GetConfig(ctx) - if err != nil || cfg == nil { + configs, err := t.svc.Store().ListConfigs(ctx) + if err != nil || len(configs) == 0 { return } + for _, cfg := range configs { + if !t.dueForScan(cfg.WorkspaceID, cfg.PollIntervalSeconds) { + continue + } + t.markScanned(cfg.WorkspaceID) + t.scanWorkspace(ctx, cfg) + } +} + +func (t *Trigger) scanWorkspace(ctx context.Context, cfg *SlackConfig) { if !cfg.LastOk || cfg.SlackUserID == "" { return } if cfg.UtilityAgentID == "" { return } - if !t.dueForScan(cfg.PollIntervalSeconds) { - return - } prefix := cfg.CommandPrefix if prefix == "" { prefix = DefaultCommandPrefix } - client, err := t.svc.Client(ctx) + client, err := t.svc.ClientForWorkspace(ctx, cfg.WorkspaceID) if err != nil { return } - t.markScanned() query := fmt.Sprintf("from:<@%s> %q", cfg.SlackUserID, prefix) matches, err := client.SearchMessages(ctx, query) if err != nil { @@ -174,7 +180,7 @@ func (t *Trigger) processMatches(ctx context.Context, cfg *SlackConfig, prefix s } } if highest != cfg.LastSeenTS { - if err := t.svc.Store().UpdateLastSeenTS(ctx, highest); err != nil { + if err := t.svc.Store().UpdateLastSeenTSForWorkspace(ctx, cfg.WorkspaceID, highest); err != nil { t.log.Warn("slack trigger: advance watermark failed", zap.Error(err)) } } @@ -223,24 +229,38 @@ func (t *Trigger) replyInThread(ctx context.Context, client Client, msg SlackMes // dueForScan reports whether the configured interval has elapsed since the // last scan. A zero/never timestamp counts as due. -func (t *Trigger) dueForScan(intervalSeconds int) bool { +func (t *Trigger) dueForScan(workspaceID string, intervalSeconds int) bool { if intervalSeconds < MinPollIntervalSeconds { intervalSeconds = DefaultPollIntervalSeconds } t.scannedMu.Lock() defer t.scannedMu.Unlock() - if t.lastScannedAt.IsZero() { + if t.lastScannedAt == nil { return true } - return time.Since(t.lastScannedAt) >= time.Duration(intervalSeconds)*time.Second + last := t.lastScannedAt[normalizeWorkspaceID(workspaceID)] + if last.IsZero() { + return true + } + return time.Since(last) >= time.Duration(intervalSeconds)*time.Second } -func (t *Trigger) markScanned() { +func (t *Trigger) markScanned(workspaceID string) { t.scannedMu.Lock() - t.lastScannedAt = time.Now() + if t.lastScannedAt == nil { + t.lastScannedAt = make(map[string]time.Time) + } + t.lastScannedAt[normalizeWorkspaceID(workspaceID)] = time.Now() t.scannedMu.Unlock() } +func normalizeWorkspaceID(workspaceID string) string { + if workspaceID == "" { + return "default" + } + return workspaceID +} + func newMatchesAfter(matches []SlackMessage, watermark, prefix string) []SlackMessage { out := make([]SlackMessage, 0, len(matches)) for _, m := range matches { diff --git a/apps/backend/internal/slack/trigger_test.go b/apps/backend/internal/slack/trigger_test.go index 158da36b91..4836b4681b 100644 --- a/apps/backend/internal/slack/trigger_test.go +++ b/apps/backend/internal/slack/trigger_test.go @@ -404,7 +404,7 @@ func TestTrigger_HandleOne_PostsAgentReplyInThread(t *testing.T) { }) client := &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev fix it"}}, permalink: "https://example/p100"} - svc.client = client + svc.clients["default"] = client cfg, _ := svc.Store().GetConfig(ctx) trig := NewTrigger(svc, logger.Default()) @@ -504,7 +504,7 @@ func TestTrigger_ProcessMatches_BreaksOnTransientError(t *testing.T) { } svc := NewService(store, nil, runner, factory, logger.Default()) client := &fakeClient{thread: []SlackMessage{{TS: "100.000001", Text: "!kandev a"}}} - svc.client = client + svc.clients["default"] = client trig := NewTrigger(svc, logger.Default()) matches := []SlackMessage{ @@ -552,7 +552,7 @@ func TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak(t *testing.T) { factory := func(_ *SlackConfig, _ string, _ string) Client { return &fakeClient{} } svc := NewService(store, nil, noAgentRunner, factory, logger.Default()) - svc.client = &fakeClient{} + svc.clients["default"] = &fakeClient{} trig := NewTrigger(svc, logger.Default()) matches := []SlackMessage{ @@ -603,29 +603,32 @@ func TestComposePrompt_GenericTemplateGetsResolvedSubstitution(t *testing.T) { func TestTrigger_DueForScan(t *testing.T) { trig := &Trigger{} - if !trig.dueForScan(30) { + if !trig.dueForScan("ws-a", 30) { t.Error("expected fresh trigger to be due immediately") } - trig.markScanned() - if trig.dueForScan(30) { + trig.markScanned("ws-a") + if trig.dueForScan("ws-a", 30) { t.Error("expected trigger to not be due immediately after scan") } + if !trig.dueForScan("ws-b", 30) { + t.Error("expected a different workspace to remain due") + } // Backdate the marker to simulate enough elapsed time. Using a direct // field write here rather than time.Sleep keeps the test deterministic. trig.scannedMu.Lock() - trig.lastScannedAt = time.Now().Add(-31 * time.Second) + trig.lastScannedAt[normalizeWorkspaceID("ws-a")] = time.Now().Add(-31 * time.Second) trig.scannedMu.Unlock() - if !trig.dueForScan(30) { + if !trig.dueForScan("ws-a", 30) { t.Error("expected trigger to be due again after 31s with 30s interval") } // Bogus interval (below floor) falls back to the default — guards against // a config row with 0 (or a corrupted row) holding the loop hostage. trig.scannedMu.Lock() - trig.lastScannedAt = time.Now() + trig.lastScannedAt[normalizeWorkspaceID("ws-a")] = time.Now() trig.scannedMu.Unlock() - if trig.dueForScan(0) { + if trig.dueForScan("ws-a", 0) { t.Error("expected zero interval to behave as default 30s, not as 'always due'") } } diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index 03180b5458..fa612adb9f 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "@/components/routing/app-link"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import { IconBrandGithub, IconMenu2 } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; @@ -165,6 +165,7 @@ function ResultsList({ type GitHubPageState = ReturnType; function useRepoOptions( + workspaceId: string | null, selection: SidebarSelection, committedQuery: string, items: Array, @@ -180,7 +181,7 @@ function useRepoOptions( // Reset the accumulator whenever the query context changes (preset, saved, // custom query). Repo filter is deliberately excluded so narrowing doesn't // reset — that's the whole point of the accumulator. - const reposResetKey = `${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`; + const reposResetKey = `${workspaceId ?? "global"}:${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`; const knownRepos = useKnownRepos(reposResetKey, pageRepos); return useMemo(() => { const set = new Set(knownRepos); @@ -189,83 +190,206 @@ function useRepoOptions( }, [knownRepos, repoFilter]); } -function useResolvedQueryPresets() { - const { prPresets: storedPr, issuePresets: storedIssue } = useDefaultQueryPresets(); +function useResolvedQueryPresets(workspaceId: string | null = null) { + const { prPresets: storedPr, issuePresets: storedIssue } = useDefaultQueryPresets(workspaceId); const pr = useMemo(() => resolvePresetOptions(storedPr, PR_PRESETS), [storedPr]); const issue = useMemo(() => resolvePresetOptions(storedIssue, ISSUE_PRESETS), [storedIssue]); return { pr, issue }; } -function useGitHubPageState() { - const { pr: resolvedPrPresets, issue: resolvedIssuePresets } = useResolvedQueryPresets(); - +function useInitialSidebarSelection( + workspaceId: string | null, + resolvedPrPresets: PresetOption[], + autoResetSearchRef: MutableRefObject, + setQueryImmediate: (query: string) => void, + setRepoFilter: (repo: string) => void, +) { + const userSelectedRef = useRef(false); const [selection, setSelection] = useState(() => ({ kind: "pr", source: "preset", id: resolvedPrPresets[0]?.value ?? "", })); - const { - draft: customQuery, - committed: committedQuery, - setDraft: setCustomQuery, - setImmediate: setQueryImmediate, - commit: commitCustomQuery, - } = useCommittedQuery(resolvedPrPresets[0]?.filter ?? ""); - const [repoFilter, setRepoFilter] = useState(""); - const [saveDialogOpen, setSaveDialogOpen] = useState(false); - const { - presets: savedPresets, - save: saveSavedPreset, - remove: removeSavedPreset, - } = useSavedPresets(); - const presets = selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets; - const search = useGitHubSearch( - selection.kind, - presets, - selection.source === "preset" ? selection.id : "", - committedQuery, - repoFilter, - ); - const repoOptions = useRepoOptions(selection, committedQuery, search.items, repoFilter); - const title = useMemo( - () => resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets), - [selection, savedPresets, resolvedPrPresets, resolvedIssuePresets], - ); + useEffect(() => { + userSelectedRef.current = false; + autoResetSearchRef.current = true; + }, [workspaceId]); - const onSelect = useCallback( + useEffect(() => { + if (userSelectedRef.current || !autoResetSearchRef.current) return; + const first = resolvedPrPresets[0]; + setSelection({ kind: "pr", source: "preset", id: first?.value ?? "" }); + setQueryImmediate(first?.filter ?? ""); + setRepoFilter(""); + }, [workspaceId, resolvedPrPresets, autoResetSearchRef, setQueryImmediate, setRepoFilter]); + + const setUserSelection = useCallback((next: SidebarSelection) => { + userSelectedRef.current = true; + setSelection(next); + }, []); + + return { selection, setProgrammaticSelection: setSelection, setUserSelection }; +} + +function firstPresetSelection( + kind: SidebarSelection["kind"], + pr: PresetOption[], + issue: PresetOption[], +) { + const preset = (kind === "pr" ? pr : issue)[0]; + return { + selection: { kind, source: "preset", id: preset?.value ?? "" } as SidebarSelection, + filter: preset?.filter ?? "", + }; +} + +function useSidebarSelectionHandler({ + savedPresets, + resolvedPrPresets, + resolvedIssuePresets, + setQueryImmediate, + setRepoFilter, + setUserSelection, + markSearchInteracted, +}: { + savedPresets: SavedPreset[]; + resolvedPrPresets: PresetOption[]; + resolvedIssuePresets: PresetOption[]; + setQueryImmediate: (query: string) => void; + setRepoFilter: (repo: string) => void; + setUserSelection: (next: SidebarSelection) => void; + markSearchInteracted: () => void; +}) { + return useCallback( (s: SidebarSelection) => { - setSelection(s); + markSearchInteracted(); + setUserSelection(s); if (s.source === "saved") { const found = savedPresets.find((p) => p.id === s.id); setQueryImmediate(found?.customQuery ?? ""); setRepoFilter(found?.repoFilter ?? ""); return; } - const presetList = s.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets; - const preset = presetList.find((p) => p.value === s.id); + const preset = (s.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets).find( + (p) => p.value === s.id, + ); setQueryImmediate(preset?.filter ?? ""); setRepoFilter(""); }, - [savedPresets, setQueryImmediate, resolvedPrPresets, resolvedIssuePresets], + [ + savedPresets, + setQueryImmediate, + resolvedPrPresets, + resolvedIssuePresets, + setUserSelection, + markSearchInteracted, + ], ); +} + +function useSearchInteractionControls( + setCustomQueryRaw: (query: string) => void, + setRepoFilterRaw: (repo: string) => void, +) { + const autoResetSearchRef = useRef(true); + const markSearchInteracted = useCallback(() => { + autoResetSearchRef.current = false; + }, []); + const setCustomQuery = useCallback( + (query: string) => { + markSearchInteracted(); + setCustomQueryRaw(query); + }, + [markSearchInteracted, setCustomQueryRaw], + ); + const setRepoFilter = useCallback( + (repo: string) => { + markSearchInteracted(); + setRepoFilterRaw(repo); + }, + [markSearchInteracted, setRepoFilterRaw], + ); + return { autoResetSearchRef, markSearchInteracted, setCustomQuery, setRepoFilter }; +} + +function useGitHubPageState(workspaceId: string | null) { + const { pr: resolvedPrPresets, issue: resolvedIssuePresets } = + useResolvedQueryPresets(workspaceId); + const { + draft: customQuery, + committed: committedQuery, + setDraft: setCustomQueryRaw, + setImmediate: setQueryImmediate, + commit: commitCustomQuery, + } = useCommittedQuery(resolvedPrPresets[0]?.filter ?? ""); + const [repoFilter, setRepoFilterRaw] = useState(""); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const { autoResetSearchRef, markSearchInteracted, setCustomQuery, setRepoFilter } = + useSearchInteractionControls(setCustomQueryRaw, setRepoFilterRaw); + const { + presets: savedPresets, + save: saveSavedPreset, + remove: removeSavedPreset, + } = useSavedPresets(workspaceId); + const { selection, setProgrammaticSelection, setUserSelection } = useInitialSidebarSelection( + workspaceId, + resolvedPrPresets, + autoResetSearchRef, + setQueryImmediate, + setRepoFilterRaw, + ); + + const presets = selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets; + const search = useGitHubSearch({ + kind: selection.kind, + presets, + preset: selection.source === "preset" ? selection.id : "", + customQuery: committedQuery, + repoFilter, + workspaceId, + }); + const repoOptions = useRepoOptions( + workspaceId, + selection, + committedQuery, + search.items, + repoFilter, + ); + const title = useMemo( + () => resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets), + [selection, savedPresets, resolvedPrPresets, resolvedIssuePresets], + ); + + const onSelect = useSidebarSelectionHandler({ + savedPresets, + resolvedPrPresets, + resolvedIssuePresets, + setQueryImmediate, + setRepoFilter: setRepoFilterRaw, + setUserSelection, + markSearchInteracted, + }); const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0; const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query"); - const onOpenSaveDialog = () => { - if (canSaveCurrent) setSaveDialogOpen(true); - }; + const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true); const onConfirmSave = (label: string) => { const created = saveSavedPreset({ kind: selection.kind, label, customQuery, repoFilter }); - setSelection({ kind: selection.kind, source: "saved", id: created.id }); + if (!created) return; + setProgrammaticSelection({ kind: selection.kind, source: "saved", id: created.id }); }; const onDeleteSaved = (id: string) => { removeSavedPreset(id); if (selection.source === "saved" && selection.id === id) { - const fallbackPresets = selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets; - setSelection({ kind: selection.kind, source: "preset", id: fallbackPresets[0]?.value ?? "" }); - setQueryImmediate(fallbackPresets[0]?.filter ?? ""); - setRepoFilter(""); + const fallback = firstPresetSelection( + selection.kind, + resolvedPrPresets, + resolvedIssuePresets, + ); + setProgrammaticSelection(fallback.selection); + setQueryImmediate(fallback.filter); + setRepoFilterRaw(""); } }; @@ -369,7 +493,7 @@ export function GitHubPageClient({ const { status, loaded } = useGitHubStatus(); const [launchPayload, setLaunchPayload] = useState(null); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const state = useGitHubPageState(); + const state = useGitHubPageState(workspaceId ?? null); const { presets: storedPresets } = useGitHubActionPresets(workspaceId ?? null); const prPresets = useMemo(() => resolvePRPresets(storedPresets), [storedPresets]); const issuePresets = useMemo(() => resolveIssuePresets(storedPresets), [storedPresets]); diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx index bf5e66afc1..baed82730f 100644 --- a/apps/web/app/jira/jira-page-client.tsx +++ b/apps/web/app/jira/jira-page-client.tsx @@ -55,13 +55,16 @@ function NotConfiguredNotice() { ); } -async function loadUserProjects(): Promise { +async function loadUserProjects(workspaceId: string): Promise { const [{ projects: all }, search] = await Promise.all([ - listJiraProjects(), - searchJiraTickets({ - jql: "(assignee = currentUser() OR reporter = currentUser()) ORDER BY updated DESC", - maxResults: 100, - }), + listJiraProjects({ workspaceId }), + searchJiraTickets( + { + jql: "(assignee = currentUser() OR reporter = currentUser()) ORDER BY updated DESC", + maxResults: 100, + }, + { workspaceId }, + ), ]); const userKeys = new Set(search.tickets.map((t) => t.projectKey)); return (all ?? []).filter((p) => userKeys.has(p.key)); @@ -81,14 +84,14 @@ function useJiraPageData(workspaceId?: string) { return; } try { - const cfg = await getJiraConfig(); + const cfg = await getJiraConfig({ workspaceId }); if (cancelled) return; const ok = !!cfg && cfg.hasSecret; setConfigured(ok); setDefaultProjectKey(cfg?.defaultProjectKey ?? ""); if (ok) { try { - const list = await loadUserProjects(); + const list = await loadUserProjects(workspaceId); if (!cancelled) setProjects(list); } catch { // Non-fatal: pill will just show empty list. Users can still filter by other dims. @@ -177,6 +180,7 @@ function AuthenticatedView({ const search = useJiraSearch(workspaceId ?? null, state.effectiveJql); const { options: statusOptions, loaded: statusesLoaded } = useProjectStatuses( state.filters.projectKeys, + workspaceId, ); // When the available status union changes (project selection changed, or diff --git a/apps/web/app/linear/linear-page-client.tsx b/apps/web/app/linear/linear-page-client.tsx index 29b8840146..4d9686b1b9 100644 --- a/apps/web/app/linear/linear-page-client.tsx +++ b/apps/web/app/linear/linear-page-client.tsx @@ -69,13 +69,13 @@ function useLinearPageData(workspaceId?: string) { return; } try { - const cfg = await getLinearConfig(); + const cfg = await getLinearConfig({ workspaceId }); if (cancelled) return; const ok = !!cfg && cfg.hasSecret; setConfigured(ok); if (ok) { try { - const list = await listLinearTeams(); + const list = await listLinearTeams({ workspaceId }); if (!cancelled) setTeams(list.teams ?? []); } catch { // Non-fatal: team filter just stays empty. @@ -125,14 +125,17 @@ function useIssueSearch( const fetchPage = useCallback( (p: number) => - searchLinearIssues({ - query: query || undefined, - teamKey: teamKey || undefined, - assigned: assigned || undefined, - pageToken: tokensRef.current[p - 1] || undefined, - maxResults: PAGE_SIZE, - }), - [query, teamKey, assigned], + searchLinearIssues( + { + query: query || undefined, + teamKey: teamKey || undefined, + assigned: assigned || undefined, + pageToken: tokensRef.current[p - 1] || undefined, + maxResults: PAGE_SIZE, + }, + { workspaceId }, + ), + [workspaceId, query, teamKey, assigned], ); const run = useCallback( @@ -439,7 +442,7 @@ function ResultsArea({ } export function LinearPageClient({ workspaceId, workflows, steps }: LinearPageClientProps) { - const available = useLinearAvailable(); + const available = useLinearAvailable(workspaceId); const { loaded, configured, teams } = useLinearPageData(workspaceId); const [query, setQuery] = useState(""); diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx index af38f0ec43..191abcf332 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx @@ -188,6 +188,15 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { expect(screen.getByTestId(OFFICE_WORKSPACE_ITEM).textContent).toContain("Office"); }); + it("renders the trigger as an obvious selector", () => { + render(); + + const trigger = screen.getByTestId("sidebar-workspace-trigger"); + expect(trigger.getAttribute("aria-label")).toBe("Switch workspace"); + expect(trigger.textContent).toContain("Default Workspace"); + expect(screen.getByTestId("sidebar-workspace-trigger-chevron")).not.toBeNull(); + }); + it("routes to the active kanban workspace home when office routing is enabled", () => { storeState.features.office = true; render(); diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index 36db614758..caf25a0b04 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -45,13 +45,16 @@ const WorkspaceTrigger = forwardRef< data-testid="sidebar-workspace-trigger" aria-label="Switch workspace" className={cn( - "group/ws flex min-w-0 flex-1 items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground cursor-pointer transition-colors", + "group/ws flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-border/70 bg-background px-2.5 text-sm font-medium text-foreground shadow-sm cursor-pointer transition-colors hover:border-border hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", className, )} {...props} > {activeName} - + ); }); diff --git a/apps/web/components/github/default-queries-section.tsx b/apps/web/components/github/default-queries-section.tsx index 614e8f5345..bee835045c 100644 --- a/apps/web/components/github/default-queries-section.tsx +++ b/apps/web/components/github/default-queries-section.tsx @@ -127,9 +127,11 @@ function QueryEditor({ ); } -export function DefaultQueriesSection() { +export function DefaultQueriesSection({ workspaceId }: { workspaceId?: string }) { const { toast } = useToast(); - const { prPresets, issuePresets, save, reset, isCustomized } = useDefaultQueryPresets(); + const { prPresets, issuePresets, save, reset, isCustomized } = useDefaultQueryPresets( + workspaceId ?? null, + ); const [prDraft, setPrDraft] = useState(prPresets); const [issueDraft, setIssueDraft] = useState(issuePresets); diff --git a/apps/web/components/github/github-repo-scope-section.tsx b/apps/web/components/github/github-repo-scope-section.tsx new file mode 100644 index 0000000000..b341c56890 --- /dev/null +++ b/apps/web/components/github/github-repo-scope-section.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { useToast } from "@/components/toast-provider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { + fetchGitHubWorkspaceSettings, + updateGitHubWorkspaceSettings, +} from "@/lib/api/domains/github-api"; +import type { GitHubRepoScopeMode, RepoFilter } from "@/lib/types/github"; + +function splitCSV(value: string): string[] { + return value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); +} + +function parseRepoFilters(value: string): RepoFilter[] { + return splitCSV(value) + .map((repo) => { + const [owner, name, ...rest] = repo.split("/"); + if (!owner || !name || rest.length > 0) return null; + return { owner, name }; + }) + .filter((repo): repo is RepoFilter => repo !== null); +} + +function repoFiltersToInput(repos: RepoFilter[]): string { + return repos.map((repo) => `${repo.owner}/${repo.name}`).join(", "); +} + +type ScopeFieldsProps = { + mode: GitHubRepoScopeMode; + orgs: string; + repos: string; + loading: boolean; + invalidRepos: boolean; + onModeChange: (mode: GitHubRepoScopeMode) => void; + onOrgsChange: (orgs: string) => void; + onReposChange: (repos: string) => void; +}; + +function RepositoryScopeFields({ + mode, + orgs, + repos, + loading, + invalidRepos, + onModeChange, + onOrgsChange, + onReposChange, +}: ScopeFieldsProps) { + return ( + + +
+ + +
+
+
+ + onOrgsChange(event.target.value)} + disabled={loading || mode !== "orgs"} + placeholder="kdlbs, example-org" + data-testid="github-scope-orgs-input" + /> +
+
+ + onReposChange(event.target.value)} + disabled={loading || mode !== "repos"} + aria-invalid={invalidRepos} + placeholder="kdlbs/kandev, example/api" + data-testid="github-scope-repos-input" + /> + {invalidRepos && ( +

Use comma-separated owner/repo values.

+ )} +
+
+
+
+ ); +} + +export function GitHubRepoScopeSection({ workspaceId }: { workspaceId: string }) { + const { toast } = useToast(); + const [mode, setMode] = useState("all"); + const [orgs, setOrgs] = useState(""); + const [repos, setRepos] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const parsedRepos = useMemo(() => parseRepoFilters(repos), [repos]); + const invalidRepos = useMemo(() => { + const entries = splitCSV(repos); + return entries.length > 0 && parsedRepos.length !== entries.length; + }, [parsedRepos.length, repos]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + void fetchGitHubWorkspaceSettings(workspaceId) + .then((settings) => { + if (cancelled) return; + setMode(settings.repo_scope_mode ?? "all"); + setOrgs((settings.repo_scope_orgs ?? []).join(", ")); + setRepos(repoFiltersToInput(settings.repo_scope_repos ?? [])); + }) + .catch(() => { + if (!cancelled) + toast({ description: "Failed to load GitHub workspace settings", variant: "error" }); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [toast, workspaceId]); + + const save = async () => { + if (invalidRepos) { + toast({ description: "Repository filters must use owner/repo format", variant: "error" }); + return; + } + setSaving(true); + try { + const updated = await updateGitHubWorkspaceSettings({ + workspace_id: workspaceId, + repo_scope_mode: mode, + repo_scope_orgs: splitCSV(orgs), + repo_scope_repos: parsedRepos, + }); + setMode(updated.repo_scope_mode); + setOrgs((updated.repo_scope_orgs ?? []).join(", ")); + setRepos(repoFiltersToInput(updated.repo_scope_repos ?? [])); + toast({ description: "GitHub workspace settings saved", variant: "success" }); + } catch { + toast({ description: "Failed to save GitHub workspace settings", variant: "error" }); + } finally { + setSaving(false); + } + }; + + return ( + + {saving ? "Saving..." : "Save"} + + } + > + + + ); +} diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx index 46fae9b464..161978734a 100644 --- a/apps/web/components/github/github-settings.tsx +++ b/apps/web/components/github/github-settings.tsx @@ -15,6 +15,7 @@ import { IssueWatchTable } from "./issue-watch-table"; import { IssueWatchDialog } from "./issue-watch-dialog"; import { ActionPresetsSection } from "./action-presets-section"; import { DefaultQueriesSection } from "./default-queries-section"; +import { GitHubRepoScopeSection } from "./github-repo-scope-section"; import { PRStatsPanel } from "./pr-stats"; import { useReviewWatches } from "@/hooks/domains/github/use-review-watches"; import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches"; @@ -80,14 +81,19 @@ function useWatchActions(workspaceId?: string | null) { const handleDelete = useCallback( async (id: string) => { + const watch = watches.find((item) => item.id === id); + if (!watch) { + toast({ description: "Review watch not found", variant: "error" }); + return; + } try { - await remove(id); + await remove(id, watch.workspace_id); toast({ description: "Review watch deleted", variant: "success" }); } catch { toast({ description: "Failed to delete review watch", variant: "error" }); } }, - [remove, toast], + [remove, toast, watches], ); const handleTrigger = useCallback( @@ -118,7 +124,7 @@ function useWatchActions(workspaceId?: string | null) { const handleToggleEnabled = useCallback( async (watch: ReviewWatch) => { try { - await update(watch.id, { enabled: !watch.enabled }); + await update(watch.id, watch.workspace_id, { enabled: !watch.enabled }); toast({ description: watch.enabled ? "Watch paused" : "Watch enabled", variant: "success", @@ -175,20 +181,30 @@ function useIssueWatchActions(workspaceId?: string | null) { const handleDelete = useCallback( async (id: string) => { + const watch = watches.find((item) => item.id === id); + if (!watch) { + toast({ description: "Issue watch not found", variant: "error" }); + return; + } try { - await remove(id); + await remove(id, watch.workspace_id); toast({ description: "Issue watch deleted", variant: "success" }); } catch { toast({ description: "Failed to delete issue watch", variant: "error" }); } }, - [remove, toast], + [remove, toast, watches], ); const handleTrigger = useCallback( async (id: string) => { + const watch = watches.find((item) => item.id === id); + if (!watch) { + toast({ description: "Issue watch not found", variant: "error" }); + return; + } try { - const result = await trigger(id); + const result = await trigger(id, watch.workspace_id); const count = result?.new_issues_found ?? 0; if (count > 0) { toast({ @@ -202,13 +218,13 @@ function useIssueWatchActions(workspaceId?: string | null) { toast({ description: "Failed to check for issues", variant: "error" }); } }, - [trigger, toast], + [trigger, toast, watches], ); const handleToggleEnabled = useCallback( async (watch: IssueWatch) => { try { - await update(watch.id, { enabled: !watch.enabled }); + await update(watch.id, watch.workspace_id, { enabled: !watch.enabled }); toast({ description: watch.enabled ? "Watch paused" : "Watch enabled", variant: "success", @@ -276,16 +292,17 @@ export function GitHubConnectionSection() { ); } -// 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 (
+ + + +
); } @@ -295,21 +312,15 @@ export function GitHubIntegrationPage() {
- - - + {(ws) => } -
); } -// 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() { +function ReviewWatchSection({ workspaceId }: { workspaceId: string }) { const { watches, create, @@ -319,7 +330,7 @@ function ReviewWatchSection() { handleTrigger, handleReset, handleToggleEnabled, - } = useWatchActions(undefined); + } = useWatchActions(workspaceId); const { toast } = useToast(); const [dialogOpen, setDialogOpen] = useState(false); const [editingWatch, setEditingWatch] = useState(null); @@ -349,7 +360,10 @@ function ReviewWatchSection() { description="Automatically create tasks for PRs that need your review." action={
- + cleanupMergedReviewTasks(workspaceId)} + /> diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index fa0c96ac53..dcc457a745 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -193,12 +193,22 @@ export const test = backendFixture.extend< }, integrationCleanup: [ - async ({ apiClient }, use) => { + async ({ apiClient, seedData }, use) => { + const scoped = `workspace_id=${encodeURIComponent(seedData.workspaceId)}`; + await apiClient.rawRequest("DELETE", `/api/v1/jira/config?${scoped}`).catch(() => undefined); + await apiClient + .rawRequest("DELETE", `/api/v1/linear/config?${scoped}`) + .catch(() => undefined); + await apiClient + .rawRequest("DELETE", `/api/v1/sentry/config?${scoped}`) + .catch(() => undefined); await apiClient.rawRequest("DELETE", `/api/v1/jira/config`).catch(() => undefined); await apiClient.rawRequest("DELETE", `/api/v1/linear/config`).catch(() => undefined); + await apiClient.rawRequest("DELETE", `/api/v1/sentry/config`).catch(() => undefined); await Promise.all([ apiClient.mockJiraReset().catch(() => undefined), apiClient.mockLinearReset().catch(() => undefined), + apiClient.mockSentryReset().catch(() => undefined), ]); await use(); }, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 324528ccd5..fabc7d0171 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -209,6 +209,21 @@ export class ApiClient { return res.json() as Promise; } + private async activeWorkspaceId(): Promise { + const { settings } = await this.getUserSettings(); + const workspaceId = settings.workspace_id; + return typeof workspaceId === "string" && workspaceId.trim() !== "" + ? workspaceId.trim() + : undefined; + } + + private async withActiveWorkspace(path: string, workspaceId?: string): Promise { + const resolved = workspaceId?.trim() || (await this.activeWorkspaceId()); + if (!resolved) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}workspace_id=${encodeURIComponent(resolved)}`; + } + async healthCheck(): Promise { await this.request("GET", "/health"); } @@ -1343,6 +1358,7 @@ export class ApiClient { async updateReviewWatch( watchId: string, + workspaceId: string, patch: { enabled?: boolean; cleanup_policy?: "auto" | "always" | "never"; @@ -1350,11 +1366,18 @@ export class ApiClient { repos?: Array<{ owner: string; name: string }>; }, ): Promise { - await this.request("PUT", `/api/v1/github/watches/review/${watchId}`, patch); + await this.request( + "PUT", + `/api/v1/github/watches/review/${watchId}?workspace_id=${encodeURIComponent(workspaceId)}`, + patch, + ); } - async deleteReviewWatch(watchId: string): Promise { - await this.request("DELETE", `/api/v1/github/watches/review/${watchId}`); + async deleteReviewWatch(watchId: string, workspaceId: string): Promise { + await this.request( + "DELETE", + `/api/v1/github/watches/review/${watchId}?workspace_id=${encodeURIComponent(workspaceId)}`, + ); } async triggerReviewWatch( @@ -1425,18 +1448,27 @@ export class ApiClient { instanceType?: "cloud" | "server"; defaultProjectKey?: string; secret: string; + workspaceId?: string; }): Promise { - return this.request("POST", "/api/v1/jira/config", { - ...payload, + const { workspaceId, ...config } = payload; + const path = await this.withActiveWorkspace("/api/v1/jira/config", workspaceId); + return this.request("POST", path, { + ...config, authMethod: payload.authMethod ?? "api_token", instanceType: payload.instanceType ?? "cloud", }); } - async setLinearConfig(payload: { secret: string; defaultTeamKey?: string }): Promise { - return this.request("POST", "/api/v1/linear/config", { + async setLinearConfig(payload: { + secret: string; + defaultTeamKey?: string; + workspaceId?: string; + }): Promise { + const { workspaceId, ...config } = payload; + const path = await this.withActiveWorkspace("/api/v1/linear/config", workspaceId); + return this.request("POST", path, { defaultTeamKey: "", - ...payload, + ...config, authMethod: "api_key", }); } @@ -1450,11 +1482,14 @@ export class ApiClient { */ async waitForIntegrationAuthHealthy( integration: "jira" | "linear" | "sentry", - timeoutMs = 5_000, + options: number | { timeoutMs?: number; workspaceId?: string } = 5_000, ): Promise { + const timeoutMs = typeof options === "number" ? options : (options.timeoutMs ?? 5_000); + const workspaceId = typeof options === "number" ? undefined : options.workspaceId; + const path = await this.withActiveWorkspace(`/api/v1/${integration}/config`, workspaceId); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const res = await this.rawRequest("GET", `/api/v1/${integration}/config`); + const res = await this.rawRequest("GET", path); if (res.ok && res.status === 200) { const cfg = (await res.json()) as { hasSecret?: boolean; lastOk?: boolean }; if (cfg.hasSecret && cfg.lastOk) return; @@ -1480,8 +1515,14 @@ export class ApiClient { await this.request("PUT", "/api/v1/jira/mock/auth-result", result); } - async mockJiraSetAuthHealth(args: { ok: boolean; error?: string }): Promise { - await this.request("PUT", "/api/v1/jira/mock/auth-health", args); + async mockJiraSetAuthHealth(args: { + ok: boolean; + error?: string; + workspaceId?: string; + }): Promise { + const { workspaceId, ...payload } = args; + const path = await this.withActiveWorkspace("/api/v1/jira/mock/auth-health", workspaceId); + await this.request("PUT", path, payload); } async mockJiraSetProjects(projects: MockJiraProject[]): Promise { @@ -1539,8 +1580,11 @@ export class ApiClient { ok: boolean; error?: string; orgSlug?: string; + workspaceId?: string; }): Promise { - await this.request("PUT", "/api/v1/linear/mock/auth-health", args); + const { workspaceId, ...payload } = args; + const path = await this.withActiveWorkspace("/api/v1/linear/mock/auth-health", workspaceId); + await this.request("PUT", path, payload); } async mockLinearSetTeams(teams: MockLinearTeam[]): Promise { @@ -1575,8 +1619,14 @@ export class ApiClient { await this.request("PUT", "/api/v1/sentry/mock/auth-result", result); } - async mockSentrySetAuthHealth(args: { ok: boolean; error?: string }): Promise { - await this.request("PUT", "/api/v1/sentry/mock/auth-health", args); + async mockSentrySetAuthHealth(args: { + ok: boolean; + error?: string; + workspaceId?: string; + }): Promise { + const { workspaceId, ...payload } = args; + const path = await this.withActiveWorkspace("/api/v1/sentry/mock/auth-health", workspaceId); + await this.request("PUT", path, payload); } async mockSentrySetOrganizations(organizations: MockSentryOrganization[]): Promise { diff --git a/apps/web/e2e/pages/linear-settings-page.ts b/apps/web/e2e/pages/linear-settings-page.ts index 98b55f1a9e..bc2214c2d8 100644 --- a/apps/web/e2e/pages/linear-settings-page.ts +++ b/apps/web/e2e/pages/linear-settings-page.ts @@ -6,6 +6,7 @@ export class LinearSettingsPage { readonly saveButton: Locator; readonly deleteButton: Locator; readonly statusBanner: Locator; + readonly workspaceTrigger: Locator; constructor(private page: Page) { this.secretInput = page.getByTestId("linear-secret-input"); @@ -13,6 +14,9 @@ export class LinearSettingsPage { this.saveButton = page.getByTestId("linear-save-button"); this.deleteButton = page.getByTestId("linear-delete-button"); this.statusBanner = page.getByTestId("integration-auth-status-banner"); + this.workspaceTrigger = page + .getByTestId("workspace-scoped-selector") + .getByTitle("Switch Workspace"); } async goto() { diff --git a/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts new file mode 100644 index 0000000000..49a3cf832d --- /dev/null +++ b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("GitHub workspace settings", () => { + test("repository scope is saved per workspace and filters the GitHub PR list", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + await apiClient.mockGitHubAddPRs([ + { + number: 6101, + title: "Scoped PR", + state: "open", + head_branch: "feature/scoped", + base_branch: "main", + author_login: "contributor", + repo_owner: "kdlbs", + repo_name: "kandev", + requested_reviewers: [{ login: "test-user", type: "User" }], + }, + { + number: 6102, + title: "Out of scope PR", + state: "open", + head_branch: "feature/out-of-scope", + base_branch: "main", + author_login: "contributor", + repo_owner: "other", + repo_name: "repo", + requested_reviewers: [{ login: "test-user", type: "User" }], + }, + ]); + + await testPage.goto("/settings/integrations/github"); + await expect(testPage.getByTestId("integration-workspace-switcher")).toBeVisible(); + + await testPage.getByTestId("github-scope-mode").click(); + await testPage.getByRole("option", { name: "Selected repositories" }).click(); + await testPage.getByTestId("github-scope-repos-input").fill("kdlbs/kandev"); + await testPage.getByTestId("github-scope-save").click(); + await expect(testPage.getByText("GitHub workspace settings saved")).toBeVisible({ + timeout: 10_000, + }); + + const settingsResponse = await apiClient.rawRequest( + "GET", + `/api/v1/github/workspace-settings?workspace_id=${seedData.workspaceId}`, + ); + expect(settingsResponse.status).toBe(200); + const settings = (await settingsResponse.json()) as { + repo_scope_mode: string; + repo_scope_repos: Array<{ owner: string; name: string }>; + }; + expect(settings.repo_scope_mode).toBe("repos"); + expect(settings.repo_scope_repos).toEqual([{ owner: "kdlbs", name: "kandev" }]); + + await testPage.goto("/github"); + + await expect(testPage.getByTestId("pr-row").filter({ hasText: "Scoped PR" })).toBeVisible({ + timeout: 15_000, + }); + await expect(testPage.getByText("kdlbs/kandev#6101")).toBeVisible(); + await expect(testPage.getByText("Out of scope PR")).toHaveCount(0); + await expect(testPage.getByText("other/repo#6102")).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/integrations/linear-settings.spec.ts b/apps/web/e2e/tests/integrations/linear-settings.spec.ts index 21dbea86a4..ec5587142f 100644 --- a/apps/web/e2e/tests/integrations/linear-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/linear-settings.spec.ts @@ -45,6 +45,27 @@ test.describe("Linear settings", () => { await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible(); }); + test("workspace selector scopes the saved credentials form", async ({ testPage, apiClient }) => { + const other = await apiClient.createWorkspace("Linear Secondary Workspace"); + await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]); + + const settings = new LinearSettingsPage(testPage); + await settings.goto(); + + await settings.secretInput.fill("lin_api_default"); + await settings.saveButton.click(); + await expect(settings.deleteButton).toBeVisible(); + await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible(); + + await settings.workspaceTrigger.click(); + await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click(); + + await expect(settings.secretInput).toHaveValue(""); + await expect(settings.saveButton).toBeDisabled(); + await expect(settings.deleteButton).toHaveCount(0); + await expect(testPage.getByText(/leave blank to keep the current value/i)).toHaveCount(0); + }); + test("test connection surfaces inline success and failure", async ({ testPage, apiClient }) => { const settings = new LinearSettingsPage(testPage); await settings.goto(); diff --git a/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts b/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts new file mode 100644 index 0000000000..5e457b21fb --- /dev/null +++ b/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from "../../fixtures/test-base"; +import { LinearSettingsPage } from "../../pages/linear-settings-page"; + +test.describe("Linear settings on mobile", () => { + test("workspace selector scopes the credentials form", async ({ testPage, apiClient }) => { + const other = await apiClient.createWorkspace("Mobile Linear Workspace"); + + const settings = new LinearSettingsPage(testPage); + await settings.goto(); + + await settings.secretInput.fill("lin_api_mobile"); + await settings.saveButton.click(); + await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible(); + + await settings.workspaceTrigger.click(); + await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click(); + + await expect(settings.secretInput).toHaveValue(""); + await expect(settings.saveButton).toBeDisabled(); + await expect(testPage.getByText(/leave blank to keep the current value/i)).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/pr/pr-watcher-cleanup-policy.spec.ts b/apps/web/e2e/tests/pr/pr-watcher-cleanup-policy.spec.ts index 0be5472950..b970637618 100644 --- a/apps/web/e2e/tests/pr/pr-watcher-cleanup-policy.spec.ts +++ b/apps/web/e2e/tests/pr/pr-watcher-cleanup-policy.spec.ts @@ -385,7 +385,7 @@ test.describe("PR watcher cleanup policy", () => { // Disable the watch + merge the PR. The disabled watch is invisible to // the per-watch loop now, so without the global sweep the task survives. - await apiClient.updateReviewWatch(watch.id, { enabled: false }); + await apiClient.updateReviewWatch(watch.id, watch.workspace_id, { enabled: false }); await apiClient.mockGitHubAddPRs([ { number: 701, @@ -463,7 +463,7 @@ test.describe("PR watcher cleanup policy", () => { }); // Delete the watch — service cascade should reap the task too. - await apiClient.deleteReviewWatch(watch.id); + await apiClient.deleteReviewWatch(watch.id, watch.workspace_id); await expect(kanban.taskCardByTitle("PR #801: Cascade-delete me")).not.toBeVisible({ timeout: 15_000, diff --git a/apps/web/hooks/domains/github/use-issue-watches.ts b/apps/web/hooks/domains/github/use-issue-watches.ts index 59f5faf4c2..7898c6ddb8 100644 --- a/apps/web/hooks/domains/github/use-issue-watches.ts +++ b/apps/web/hooks/domains/github/use-issue-watches.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useCallback } from "react"; +import { useEffect, useCallback, useRef } from "react"; import { listIssueWatches, createIssueWatch, @@ -27,25 +27,36 @@ export function useIssueWatches(workspaceId?: string | null) { const addWatch = useAppStore((state) => state.addIssueWatch); const updateWatch = useAppStore((state) => state.updateIssueWatch); const removeWatch = useAppStore((state) => state.removeIssueWatch); + const loadedScopeRef = useRef(null); // storeApi exposes getState() without subscribing — used in reset() to // read the current watch row outside of the React render cycle so the // callback doesn't need `items` as a dependency. const storeApi = useAppStoreApi(); useEffect(() => { - if (workspaceId === null || loaded || loading) return; + if (workspaceId === null) return; + const scopeKey = workspaceId ?? "__all__"; + if (loadedScopeRef.current === scopeKey) return; + let cancelled = false; + loadedScopeRef.current = scopeKey; setIssueWatchesLoading(true); listIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) .then((response) => { + if (cancelled) return; setIssueWatches(response?.watches ?? []); }) .catch(() => { + if (cancelled) return; setIssueWatches([]); }) .finally(() => { + if (cancelled) return; setIssueWatchesLoading(false); }); - }, [workspaceId, loaded, loading, setIssueWatches, setIssueWatchesLoading]); + return () => { + cancelled = true; + }; + }, [workspaceId, setIssueWatches, setIssueWatchesLoading]); const create = useCallback( async (req: CreateIssueWatchRequest) => { @@ -57,8 +68,8 @@ export function useIssueWatches(workspaceId?: string | null) { ); const update = useCallback( - async (id: string, req: UpdateIssueWatchRequest) => { - const watch = await updateIssueWatch(id, req); + async (id: string, watchWorkspaceId: string, req: UpdateIssueWatchRequest) => { + const watch = await updateIssueWatch(id, watchWorkspaceId, req); updateWatch(watch); return watch; }, @@ -66,15 +77,15 @@ export function useIssueWatches(workspaceId?: string | null) { ); const remove = useCallback( - async (id: string) => { - await deleteIssueWatch(id); + async (id: string, watchWorkspaceId: string) => { + await deleteIssueWatch(id, watchWorkspaceId); removeWatch(id); }, [removeWatch], ); - const trigger = useCallback(async (id: string) => { - return triggerIssueWatch(id); + const trigger = useCallback(async (id: string, watchWorkspaceId: string) => { + return triggerIssueWatch(id, watchWorkspaceId); }, []); const triggerAll = useCallback(async () => { diff --git a/apps/web/hooks/domains/github/use-review-watches.ts b/apps/web/hooks/domains/github/use-review-watches.ts index 4ce23467fc..40890f701b 100644 --- a/apps/web/hooks/domains/github/use-review-watches.ts +++ b/apps/web/hooks/domains/github/use-review-watches.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useCallback } from "react"; +import { useEffect, useCallback, useRef } from "react"; import { listReviewWatches, createReviewWatch, @@ -27,21 +27,32 @@ export function useReviewWatches(workspaceId?: string | null) { const addWatch = useAppStore((state) => state.addReviewWatch); const updateWatch = useAppStore((state) => state.updateReviewWatch); const removeWatch = useAppStore((state) => state.removeReviewWatch); + const loadedScopeRef = useRef(null); useEffect(() => { - if (workspaceId === null || loaded || loading) return; + if (workspaceId === null) return; + const scopeKey = workspaceId ?? "__all__"; + if (loadedScopeRef.current === scopeKey) return; + let cancelled = false; + loadedScopeRef.current = scopeKey; setReviewWatchesLoading(true); listReviewWatches(workspaceId ?? undefined, { cache: "no-store" }) .then((response) => { + if (cancelled) return; setReviewWatches(response?.watches ?? []); }) .catch(() => { + if (cancelled) return; setReviewWatches([]); }) .finally(() => { + if (cancelled) return; setReviewWatchesLoading(false); }); - }, [workspaceId, loaded, loading, setReviewWatches, setReviewWatchesLoading]); + return () => { + cancelled = true; + }; + }, [workspaceId, setReviewWatches, setReviewWatchesLoading]); const create = useCallback( async (req: CreateReviewWatchRequest) => { @@ -53,8 +64,8 @@ export function useReviewWatches(workspaceId?: string | null) { ); const update = useCallback( - async (id: string, req: UpdateReviewWatchRequest) => { - const watch = await updateReviewWatch(id, req); + async (id: string, watchWorkspaceId: string, req: UpdateReviewWatchRequest) => { + const watch = await updateReviewWatch(id, watchWorkspaceId, req); updateWatch(watch); return watch; }, @@ -62,8 +73,8 @@ export function useReviewWatches(workspaceId?: string | null) { ); const remove = useCallback( - async (id: string) => { - await deleteReviewWatch(id); + async (id: string, watchWorkspaceId: string) => { + await deleteReviewWatch(id, watchWorkspaceId); removeWatch(id); }, [removeWatch], diff --git a/apps/web/hooks/domains/jira/use-jira-availability.ts b/apps/web/hooks/domains/jira/use-jira-availability.ts index 8dead3f6a2..564842bf31 100644 --- a/apps/web/hooks/domains/jira/use-jira-availability.ts +++ b/apps/web/hooks/domains/jira/use-jira-availability.ts @@ -1,5 +1,6 @@ "use client"; +import { useCallback } from "react"; import { getJiraConfig } from "@/lib/api/domains/jira-api"; import { useIntegrationAuthed, @@ -7,15 +8,21 @@ import { } from "../integrations/use-integration-availability"; import { useJiraEnabled } from "./use-jira-enabled"; -const fetchJiraConfig = () => getJiraConfig(); - -export function useJiraAuthed(): boolean { - return useIntegrationAuthed(fetchJiraConfig); +export function useJiraAuthed(workspaceId?: string | null): boolean { + const fetchConfig = useCallback( + () => getJiraConfig(workspaceId ? { workspaceId } : undefined), + [workspaceId], + ); + return useIntegrationAuthed(fetchConfig); } -export function useJiraAvailable(): boolean { +export function useJiraAvailable(workspaceId?: string | null): boolean { + const fetchConfig = useCallback( + () => getJiraConfig(workspaceId ? { workspaceId } : undefined), + [workspaceId], + ); return useIntegrationAvailable({ useEnabled: useJiraEnabled, - fetchConfig: fetchJiraConfig, + fetchConfig, }); } diff --git a/apps/web/hooks/domains/linear/use-linear-availability.ts b/apps/web/hooks/domains/linear/use-linear-availability.ts index 9d6b832f21..162745154a 100644 --- a/apps/web/hooks/domains/linear/use-linear-availability.ts +++ b/apps/web/hooks/domains/linear/use-linear-availability.ts @@ -1,5 +1,6 @@ "use client"; +import { useCallback } from "react"; import { getLinearConfig } from "@/lib/api/domains/linear-api"; import { useIntegrationAuthed, @@ -7,15 +8,21 @@ import { } from "../integrations/use-integration-availability"; import { useLinearEnabled } from "./use-linear-enabled"; -const fetchLinearConfig = () => getLinearConfig(); - -export function useLinearAuthed(): boolean { - return useIntegrationAuthed(fetchLinearConfig); +export function useLinearAuthed(workspaceId?: string | null): boolean { + const fetchConfig = useCallback( + () => getLinearConfig(workspaceId ? { workspaceId } : undefined), + [workspaceId], + ); + return useIntegrationAuthed(fetchConfig); } -export function useLinearAvailable(): boolean { +export function useLinearAvailable(workspaceId?: string | null): boolean { + const fetchConfig = useCallback( + () => getLinearConfig(workspaceId ? { workspaceId } : undefined), + [workspaceId], + ); return useIntegrationAvailable({ useEnabled: useLinearEnabled, - fetchConfig: fetchLinearConfig, + fetchConfig, }); } diff --git a/apps/web/hooks/domains/sentry/use-sentry-availability.ts b/apps/web/hooks/domains/sentry/use-sentry-availability.ts index f8097fb44a..38ffc6ee70 100644 --- a/apps/web/hooks/domains/sentry/use-sentry-availability.ts +++ b/apps/web/hooks/domains/sentry/use-sentry-availability.ts @@ -1,5 +1,6 @@ "use client"; +import { useCallback } from "react"; import { fetchSentryConfig } from "@/lib/api/domains/sentry-api"; import { useIntegrationAuthed, @@ -7,15 +8,21 @@ import { } from "../integrations/use-integration-availability"; import { useSentryEnabled } from "./use-sentry-enabled"; -const loadSentryConfig = async () => (await fetchSentryConfig()) ?? null; - -export function useSentryAuthed(): boolean { - return useIntegrationAuthed(loadSentryConfig); +export function useSentryAuthed(workspaceId?: string | null): boolean { + const fetchConfig = useCallback( + async () => (await fetchSentryConfig(workspaceId ? { workspaceId } : undefined)) ?? null, + [workspaceId], + ); + return useIntegrationAuthed(fetchConfig); } -export function useSentryAvailable(): boolean { +export function useSentryAvailable(workspaceId?: string | null): boolean { + const fetchConfig = useCallback( + async () => (await fetchSentryConfig(workspaceId ? { workspaceId } : undefined)) ?? null, + [workspaceId], + ); return useIntegrationAvailable({ useEnabled: useSentryEnabled, - fetchConfig: loadSentryConfig, + fetchConfig, }); } diff --git a/apps/web/hooks/domains/slack/use-slack-availability.ts b/apps/web/hooks/domains/slack/use-slack-availability.ts index 4625bfef71..7acf1bf86a 100644 --- a/apps/web/hooks/domains/slack/use-slack-availability.ts +++ b/apps/web/hooks/domains/slack/use-slack-availability.ts @@ -1,5 +1,6 @@ "use client"; +import { useCallback } from "react"; import { getSlackConfig } from "@/lib/api/domains/slack-api"; import { useIntegrationAuthed, @@ -11,22 +12,25 @@ import { useSlackEnabled } from "./use-slack-enabled"; // Slack stores two secrets (token + cookie) instead of one — the shared // availability hook only checks `hasSecret`, so adapt by reporting authed // when *both* halves are present. -async function fetchSlackStatus(): Promise { - const cfg = await getSlackConfig(); - if (!cfg) return null; - return { - hasSecret: cfg.hasToken && cfg.hasCookie, - lastOk: cfg.lastOk, - }; +function useSlackStatusLoader(workspaceId?: string | null) { + return useCallback(async (): Promise => { + const cfg = await getSlackConfig(workspaceId ? { workspaceId } : undefined); + if (!cfg) return null; + return { + hasSecret: cfg.hasToken && cfg.hasCookie, + lastOk: cfg.lastOk, + }; + }, [workspaceId]); } -export function useSlackAuthed(): boolean { - return useIntegrationAuthed(fetchSlackStatus); +export function useSlackAuthed(workspaceId?: string | null): boolean { + return useIntegrationAuthed(useSlackStatusLoader(workspaceId)); } -export function useSlackAvailable(): boolean { +export function useSlackAvailable(workspaceId?: string | null): boolean { + const fetchConfig = useSlackStatusLoader(workspaceId); return useIntegrationAvailable({ useEnabled: useSlackEnabled, - fetchConfig: fetchSlackStatus, + fetchConfig, }); } diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index 55d035d541..0b9164ae0f 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -26,6 +26,8 @@ import type { GitHubPRStatus, GitHubActionPresets, UpdateGitHubActionPresetsRequest, + GitHubWorkspaceSettings, + UpdateGitHubWorkspaceSettingsRequest, CleanupTasksResponse, MergeMethod, RepoMergeMethods, @@ -258,17 +260,24 @@ export async function createReviewWatch( export async function updateReviewWatch( id: string, + workspaceId: string, payload: UpdateReviewWatchRequest, options?: ApiRequestOptions, ) { - return fetchJson(`/api/v1/github/watches/review/${id}`, { + const params = new URLSearchParams({ workspace_id: workspaceId }); + return fetchJson(`/api/v1/github/watches/review/${id}?${params}`, { ...options, init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); } -export async function deleteReviewWatch(id: string, options?: ApiRequestOptions) { - return fetchJson<{ success: boolean }>(`/api/v1/github/watches/review/${id}`, { +export async function deleteReviewWatch( + id: string, + workspaceId: string, + options?: ApiRequestOptions, +) { + const params = new URLSearchParams({ workspace_id: workspaceId }); + return fetchJson<{ success: boolean }>(`/api/v1/github/watches/review/${id}?${params}`, { ...options, init: { method: "DELETE", ...(options?.init ?? {}) }, }); @@ -487,24 +496,36 @@ export async function createIssueWatch( export async function updateIssueWatch( id: string, + workspaceId: string, payload: UpdateIssueWatchRequest, options?: ApiRequestOptions, ) { - return fetchJson(`/api/v1/github/watches/issue/${id}`, { + const params = new URLSearchParams({ workspace_id: workspaceId }); + return fetchJson(`/api/v1/github/watches/issue/${id}?${params}`, { ...options, init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); } -export async function deleteIssueWatch(id: string, options?: ApiRequestOptions) { - return fetchJson<{ deleted: boolean }>(`/api/v1/github/watches/issue/${id}`, { +export async function deleteIssueWatch( + id: string, + workspaceId: string, + options?: ApiRequestOptions, +) { + const params = new URLSearchParams({ workspace_id: workspaceId }); + return fetchJson<{ deleted: boolean }>(`/api/v1/github/watches/issue/${id}?${params}`, { ...options, init: { method: "DELETE", ...(options?.init ?? {}) }, }); } -export async function triggerIssueWatch(id: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/github/watches/issue/${id}/trigger`, { +export async function triggerIssueWatch( + id: string, + workspaceId: string, + options?: ApiRequestOptions, +) { + const params = new URLSearchParams({ workspace_id: workspaceId }); + return fetchJson(`/api/v1/github/watches/issue/${id}/trigger?${params}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }); @@ -558,15 +579,17 @@ export async function triggerAllIssueWatches(workspaceId: string, options?: ApiR // Manual cleanup sweeps. The poller runs these every 5min per watch, but a // user with a pile of legacy merged-PR tasks (created before the cleanup // policy was in place) can invoke them on demand from the settings page. -export async function cleanupMergedReviewTasks(options?: ApiRequestOptions) { - return fetchJson("/api/v1/github/cleanup/review-tasks", { +export async function cleanupMergedReviewTasks(workspaceId?: string, options?: ApiRequestOptions) { + const query = workspaceId ? `?${new URLSearchParams({ workspace_id: workspaceId })}` : ""; + return fetchJson(`/api/v1/github/cleanup/review-tasks${query}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }); } -export async function cleanupClosedIssueTasks(options?: ApiRequestOptions) { - return fetchJson("/api/v1/github/cleanup/issue-tasks", { +export async function cleanupClosedIssueTasks(workspaceId?: string, options?: ApiRequestOptions) { + const query = workspaceId ? `?${new URLSearchParams({ workspace_id: workspaceId })}` : ""; + return fetchJson(`/api/v1/github/cleanup/issue-tasks${query}`, { ...options, init: { method: "POST", ...(options?.init ?? {}) }, }); @@ -580,6 +603,7 @@ type SearchParams = { filter?: string; page?: number; perPage?: number; + workspaceId?: string | null; }; function buildSearchQuery(params: SearchParams) { @@ -588,6 +612,7 @@ function buildSearchQuery(params: SearchParams) { if (params.filter) search.set("filter", params.filter); if (params.page && params.page > 1) search.set("page", String(params.page)); if (params.perPage) search.set("per_page", String(params.perPage)); + if (params.workspaceId) search.set("workspace_id", params.workspaceId); return search.toString(); } @@ -607,6 +632,28 @@ export async function searchUserIssues(params: SearchParams, options?: ApiReques ); } +// Workspace settings for GitHub repo visibility/scope. +export async function fetchGitHubWorkspaceSettings( + workspaceId: string, + options?: ApiRequestOptions, +) { + const query = new URLSearchParams({ workspace_id: workspaceId }); + return fetchJson( + `/api/v1/github/workspace-settings?${query.toString()}`, + options, + ); +} + +export async function updateGitHubWorkspaceSettings( + payload: UpdateGitHubWorkspaceSettingsRequest, + options?: ApiRequestOptions, +) { + return fetchJson("/api/v1/github/workspace-settings", { + ...options, + init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(payload) }, + }); +} + // Action presets (quick-launch prompts on the /github page). export async function fetchGitHubActionPresets(workspaceId: string, options?: ApiRequestOptions) { const query = new URLSearchParams({ workspace_id: workspaceId }); diff --git a/apps/web/lib/api/domains/jira-api.ts b/apps/web/lib/api/domains/jira-api.ts index 208e925970..5d80e95ea8 100644 --- a/apps/web/lib/api/domains/jira-api.ts +++ b/apps/web/lib/api/domains/jira-api.ts @@ -12,73 +12,99 @@ import type { UpdateJiraIssueWatchInput, } from "@/lib/types/jira"; +type WorkspaceApiOptions = ApiRequestOptions & { workspaceId?: string }; + +function withWorkspace(path: string, options?: WorkspaceApiOptions): string { + if (!options?.workspaceId) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}workspace_id=${encodeURIComponent(options.workspaceId)}`; +} + +function requestOptions(options?: WorkspaceApiOptions): ApiRequestOptions | undefined { + if (!options) return undefined; + const { workspaceId: _workspaceId, ...rest } = options; + return rest; +} + // 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(options?: ApiRequestOptions): Promise { - const res = await fetchJson(`/api/v1/jira/config`, options); +export async function getJiraConfig(options?: WorkspaceApiOptions): Promise { + const res = await fetchJson( + withWorkspace(`/api/v1/jira/config`, options), + requestOptions(options), + ); return res ?? null; } -export async function setJiraConfig(payload: SetJiraConfigRequest, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/jira/config`, { - ...options, +export async function setJiraConfig(payload: SetJiraConfigRequest, options?: WorkspaceApiOptions) { + return fetchJson(withWorkspace(`/api/v1/jira/config`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, }); } -export async function deleteJiraConfig(options?: ApiRequestOptions) { - return fetchJson<{ deleted: boolean }>(`/api/v1/jira/config`, { - ...options, +export async function deleteJiraConfig(options?: WorkspaceApiOptions) { + return fetchJson<{ deleted: boolean }>(withWorkspace(`/api/v1/jira/config`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "DELETE" }, }); } export async function testJiraConnection( payload: SetJiraConfigRequest, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { - return fetchJson(`/api/v1/jira/config/test`, { - ...options, + return fetchJson(withWorkspace(`/api/v1/jira/config/test`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, }); } -export async function listJiraProjects(options?: ApiRequestOptions) { - return fetchJson<{ projects: JiraProject[] }>(`/api/v1/jira/projects`, options); -} - -export async function listJiraProjectStatuses(projectKey: string, options?: ApiRequestOptions) { - return fetchJson<{ statuses: JiraStatus[] }>( - `/api/v1/jira/projects/${encodeURIComponent(projectKey)}/statuses`, - options, +export async function listJiraProjects(options?: WorkspaceApiOptions) { + return fetchJson<{ projects: JiraProject[] }>( + withWorkspace(`/api/v1/jira/projects`, options), + requestOptions(options), ); } -export async function getJiraTicket(ticketKey: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}`, options); +export async function listJiraProjectStatuses(projectKey: string, options?: WorkspaceApiOptions) { + return fetchJson<{ statuses: JiraStatus[] }>( + withWorkspace(`/api/v1/jira/projects/${encodeURIComponent(projectKey)}/statuses`, options), + requestOptions(options), + ); +} + +export async function getJiraTicket(ticketKey: string, options?: WorkspaceApiOptions) { + return fetchJson( + withWorkspace(`/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}`, options), + requestOptions(options), + ); } export async function searchJiraTickets( params: { jql?: string; pageToken?: string; maxResults?: number }, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { 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)); const qs = search.toString(); - return fetchJson(`/api/v1/jira/tickets${qs ? `?${qs}` : ""}`, options); + return fetchJson( + withWorkspace(`/api/v1/jira/tickets${qs ? `?${qs}` : ""}`, options), + requestOptions(options), + ); } export async function transitionJiraTicket( ticketKey: string, transitionId: string, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { return fetchJson<{ transitioned: boolean }>( - `/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}/transitions`, + withWorkspace(`/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}/transitions`, options), { - ...options, + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ transitionId }) }, }, ); diff --git a/apps/web/lib/api/domains/linear-api.test.ts b/apps/web/lib/api/domains/linear-api.test.ts index e4e409915e..b5d2be6c03 100644 --- a/apps/web/lib/api/domains/linear-api.test.ts +++ b/apps/web/lib/api/domains/linear-api.test.ts @@ -72,6 +72,12 @@ describe("getLinearConfig", () => { expect(lastCall().url).toBe(CONFIG_URL); }); + it("scopes config reads to a workspace when provided", async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await getLinearConfig({ workspaceId: "ws-123" }); + expect(lastCall().url).toBe(`${CONFIG_URL}?workspace_id=ws-123`); + }); + it("returns the parsed config on 200", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ @@ -134,6 +140,12 @@ describe("listLinearTeams + listLinearStates", () => { await listLinearStates("ENG"); expect(lastCall().url).toBe(`${BASE}/states?team_key=ENG`); }); + + it("keeps existing query params when appending workspace_id", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ states: [] })); + await listLinearStates("ENG", { workspaceId: "ws-123" }); + expect(lastCall().url).toBe(`${BASE}/states?team_key=ENG&workspace_id=ws-123`); + }); }); describe("searchLinearIssues", () => { diff --git a/apps/web/lib/api/domains/linear-api.ts b/apps/web/lib/api/domains/linear-api.ts index 54f851b17a..0814e22bf6 100644 --- a/apps/web/lib/api/domains/linear-api.ts +++ b/apps/web/lib/api/domains/linear-api.ts @@ -15,66 +15,92 @@ import type { UpdateLinearIssueWatchInput, } from "@/lib/types/linear"; +type WorkspaceApiOptions = ApiRequestOptions & { workspaceId?: string }; + +function withWorkspace(path: string, options?: WorkspaceApiOptions): string { + if (!options?.workspaceId) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}workspace_id=${encodeURIComponent(options.workspaceId)}`; +} + +function requestOptions(options?: WorkspaceApiOptions): ApiRequestOptions | undefined { + if (!options) return undefined; + const { workspaceId: _workspaceId, ...rest } = options; + return rest; +} + // getLinearConfig returns null when the backend responds 204 (no config yet). -export async function getLinearConfig(options?: ApiRequestOptions): Promise { - const res = await fetchJson(`/api/v1/linear/config`, options); +export async function getLinearConfig(options?: WorkspaceApiOptions): Promise { + const res = await fetchJson( + withWorkspace(`/api/v1/linear/config`, options), + requestOptions(options), + ); return res ?? null; } export async function setLinearConfig( payload: SetLinearConfigRequest, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { - return fetchJson(`/api/v1/linear/config`, { - ...options, + return fetchJson(withWorkspace(`/api/v1/linear/config`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, }); } -export async function deleteLinearConfig(options?: ApiRequestOptions) { - return fetchJson<{ deleted: boolean }>(`/api/v1/linear/config`, { - ...options, +export async function deleteLinearConfig(options?: WorkspaceApiOptions) { + return fetchJson<{ deleted: boolean }>(withWorkspace(`/api/v1/linear/config`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "DELETE" }, }); } export async function testLinearConnection( payload: SetLinearConfigRequest, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { - return fetchJson(`/api/v1/linear/config/test`, { - ...options, - init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, - }); + return fetchJson( + withWorkspace(`/api/v1/linear/config/test`, options), + { + ...requestOptions(options), + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }, + ); } -export async function listLinearTeams(options?: ApiRequestOptions) { - return fetchJson<{ teams: LinearTeam[] }>(`/api/v1/linear/teams`, options); +export async function listLinearTeams(options?: WorkspaceApiOptions) { + return fetchJson<{ teams: LinearTeam[] }>( + withWorkspace(`/api/v1/linear/teams`, options), + requestOptions(options), + ); } -export async function listLinearStates(teamKey: string, options?: ApiRequestOptions) { +export async function listLinearStates(teamKey: string, options?: WorkspaceApiOptions) { return fetchJson<{ states: LinearWorkflowState[] }>( - `/api/v1/linear/states?team_key=${encodeURIComponent(teamKey)}`, - options, + withWorkspace(`/api/v1/linear/states?team_key=${encodeURIComponent(teamKey)}`, options), + requestOptions(options), ); } -export async function listLinearLabels(teamKey: string, options?: ApiRequestOptions) { +export async function listLinearLabels(teamKey: string, options?: WorkspaceApiOptions) { return fetchJson<{ labels: LinearLabel[] }>( - `/api/v1/linear/labels?team_key=${encodeURIComponent(teamKey)}`, - options, + withWorkspace(`/api/v1/linear/labels?team_key=${encodeURIComponent(teamKey)}`, options), + requestOptions(options), ); } -export async function listLinearUsers(teamKey: string, options?: ApiRequestOptions) { +export async function listLinearUsers(teamKey: string, options?: WorkspaceApiOptions) { return fetchJson<{ users: LinearUser[] }>( - `/api/v1/linear/users?team_key=${encodeURIComponent(teamKey)}`, - options, + withWorkspace(`/api/v1/linear/users?team_key=${encodeURIComponent(teamKey)}`, options), + requestOptions(options), ); } -export async function getLinearIssue(identifier: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/linear/issues/${encodeURIComponent(identifier)}`, options); +export async function getLinearIssue(identifier: string, options?: WorkspaceApiOptions) { + return fetchJson( + withWorkspace(`/api/v1/linear/issues/${encodeURIComponent(identifier)}`, options), + requestOptions(options), + ); } function buildSearchIssuesQuery( @@ -105,21 +131,24 @@ function buildSearchIssuesQuery( export async function searchLinearIssues( params: LinearSearchFilter & { pageToken?: string; maxResults?: number }, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { const qs = buildSearchIssuesQuery(params); - return fetchJson(`/api/v1/linear/issues${qs ? `?${qs}` : ""}`, options); + return fetchJson( + withWorkspace(`/api/v1/linear/issues${qs ? `?${qs}` : ""}`, options), + requestOptions(options), + ); } export async function setLinearIssueState( issueID: string, stateID: string, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { return fetchJson<{ transitioned: boolean }>( - `/api/v1/linear/issues/${encodeURIComponent(issueID)}/state`, + withWorkspace(`/api/v1/linear/issues/${encodeURIComponent(issueID)}/state`, options), { - ...options, + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "POST", diff --git a/apps/web/lib/api/domains/sentry-api.ts b/apps/web/lib/api/domains/sentry-api.ts index 3d90d14413..d1a0732d8a 100644 --- a/apps/web/lib/api/domains/sentry-api.ts +++ b/apps/web/lib/api/domains/sentry-api.ts @@ -13,26 +13,43 @@ import type { UpdateSentryIssueWatchRequest, } from "@/lib/types/sentry"; +type WorkspaceApiOptions = ApiRequestOptions & { workspaceId?: string }; + +function withWorkspace(path: string, options?: WorkspaceApiOptions): string { + if (!options?.workspaceId) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}workspace_id=${encodeURIComponent(options.workspaceId)}`; +} + +function requestOptions(options?: WorkspaceApiOptions): ApiRequestOptions | undefined { + if (!options) return undefined; + const { workspaceId: _workspaceId, ...rest } = options; + return rest; +} + // fetchSentryConfig returns undefined when the backend responds 204 (no config yet). export async function fetchSentryConfig( - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ): Promise { - return fetchJson(`/api/v1/sentry/config`, options); + return fetchJson( + withWorkspace(`/api/v1/sentry/config`, options), + requestOptions(options), + ); } export async function saveSentryConfig( payload: SetSentryConfigRequest, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { - return fetchJson(`/api/v1/sentry/config`, { - ...options, + return fetchJson(withWorkspace(`/api/v1/sentry/config`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(payload) }, }); } -export async function deleteSentryConfig(options?: ApiRequestOptions) { - return fetchJson<{ deleted: boolean }>(`/api/v1/sentry/config`, { - ...options, +export async function deleteSentryConfig(options?: WorkspaceApiOptions) { + return fetchJson<{ deleted: boolean }>(withWorkspace(`/api/v1/sentry/config`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "DELETE" }, }); } @@ -40,30 +57,36 @@ export async function deleteSentryConfig(options?: ApiRequestOptions) { export async function testSentryConnection( secret?: string, url?: string, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { const payload: { secret?: string; url?: string } = {}; if (secret) payload.secret = secret; if (url) payload.url = url; - return fetchJson(`/api/v1/sentry/config/test`, { - ...options, - init: { - ...(options?.init ?? {}), - method: "POST", - body: JSON.stringify(payload), + return fetchJson( + withWorkspace(`/api/v1/sentry/config/test`, options), + { + ...requestOptions(options), + init: { + ...(options?.init ?? {}), + method: "POST", + body: JSON.stringify(payload), + }, }, - }); -} - -export async function listSentryOrganizations(options?: ApiRequestOptions) { - return fetchJson<{ organizations: SentryOrganization[] }>( - `/api/v1/sentry/organizations`, - options, ); } -export async function listSentryProjects(options?: ApiRequestOptions) { - return fetchJson<{ projects: SentryProject[] }>(`/api/v1/sentry/projects`, options); +export async function listSentryOrganizations(options?: WorkspaceApiOptions) { + return fetchJson<{ organizations: SentryOrganization[] }>( + withWorkspace(`/api/v1/sentry/organizations`, options), + requestOptions(options), + ); +} + +export async function listSentryProjects(options?: WorkspaceApiOptions) { + return fetchJson<{ projects: SentryProject[] }>( + withWorkspace(`/api/v1/sentry/projects`, options), + requestOptions(options), + ); } function appendFilter(search: URLSearchParams, filter: SentrySearchFilter): void { @@ -79,18 +102,21 @@ function appendFilter(search: URLSearchParams, filter: SentrySearchFilter): void export async function searchSentryIssues( filter: SentrySearchFilter, cursor?: string, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { const search = new URLSearchParams(); appendFilter(search, filter); if (cursor) search.set("cursor", cursor); - return fetchJson(`/api/v1/sentry/issues?${search.toString()}`, options); + return fetchJson( + withWorkspace(`/api/v1/sentry/issues?${search.toString()}`, options), + requestOptions(options), + ); } -export async function getSentryIssue(idOrShortId: string, options?: ApiRequestOptions) { +export async function getSentryIssue(idOrShortId: string, options?: WorkspaceApiOptions) { return fetchJson( - `/api/v1/sentry/issues/${encodeURIComponent(idOrShortId)}`, - options, + withWorkspace(`/api/v1/sentry/issues/${encodeURIComponent(idOrShortId)}`, options), + requestOptions(options), ); } diff --git a/apps/web/lib/api/domains/slack-api.ts b/apps/web/lib/api/domains/slack-api.ts index 3dc5c36584..4d3748d2d1 100644 --- a/apps/web/lib/api/domains/slack-api.ts +++ b/apps/web/lib/api/domains/slack-api.ts @@ -5,32 +5,52 @@ import type { TestSlackConnectionResult, } from "@/lib/types/slack"; +type WorkspaceApiOptions = ApiRequestOptions & { workspaceId?: string }; + +function withWorkspace(path: string, options?: WorkspaceApiOptions): string { + if (!options?.workspaceId) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}workspace_id=${encodeURIComponent(options.workspaceId)}`; +} + +function requestOptions(options?: WorkspaceApiOptions): ApiRequestOptions | undefined { + if (!options) return undefined; + const { workspaceId: _workspaceId, ...rest } = options; + return rest; +} + // getSlackConfig returns null when the backend responds 204 (no config yet). -export async function getSlackConfig(options?: ApiRequestOptions): Promise { - const res = await fetchJson(`/api/v1/slack/config`, options); +export async function getSlackConfig(options?: WorkspaceApiOptions): Promise { + const res = await fetchJson( + withWorkspace(`/api/v1/slack/config`, options), + requestOptions(options), + ); return res ?? null; } -export async function setSlackConfig(payload: SetSlackConfigRequest, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/slack/config`, { - ...options, +export async function setSlackConfig( + payload: SetSlackConfigRequest, + options?: WorkspaceApiOptions, +) { + return fetchJson(withWorkspace(`/api/v1/slack/config`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, }); } -export async function deleteSlackConfig(options?: ApiRequestOptions) { - return fetchJson<{ deleted: boolean }>(`/api/v1/slack/config`, { - ...options, +export async function deleteSlackConfig(options?: WorkspaceApiOptions) { + return fetchJson<{ deleted: boolean }>(withWorkspace(`/api/v1/slack/config`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "DELETE" }, }); } export async function testSlackConnection( payload: SetSlackConfigRequest, - options?: ApiRequestOptions, + options?: WorkspaceApiOptions, ) { - return fetchJson(`/api/v1/slack/config/test`, { - ...options, + return fetchJson(withWorkspace(`/api/v1/slack/config/test`, options), { + ...requestOptions(options), init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, }); } diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index 7e820cad9b..d98f20532a 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -239,6 +239,28 @@ export type RepoFilter = { name: string; }; +export type GitHubRepoScopeMode = "all" | "orgs" | "repos"; + +export type GitHubWorkspaceSettings = { + workspace_id: string; + repo_scope_mode: GitHubRepoScopeMode; + repo_scope_orgs: string[]; + repo_scope_repos: RepoFilter[]; + saved_presets?: unknown; + default_query_presets?: unknown | null; + created_at: string; + updated_at: string; +}; + +export type UpdateGitHubWorkspaceSettingsRequest = { + workspace_id: string; + repo_scope_mode?: GitHubRepoScopeMode; + repo_scope_orgs?: string[]; + repo_scope_repos?: RepoFilter[]; + saved_presets?: unknown; + default_query_presets?: unknown | null; +}; + export type GitHubOrg = { login: string; avatar_url: string; diff --git a/apps/web/lib/types/jira.ts b/apps/web/lib/types/jira.ts index dc3dd814b9..05375ee8c8 100644 --- a/apps/web/lib/types/jira.ts +++ b/apps/web/lib/types/jira.ts @@ -14,6 +14,7 @@ export type JiraAuthMethod = "api_token" | "pat" | "session_cookie"; export type JiraInstanceType = "cloud" | "server"; export interface JiraConfig { + 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 cf2dbf5407..2df05bb870 100644 --- a/apps/web/lib/types/linear.ts +++ b/apps/web/lib/types/linear.ts @@ -1,6 +1,7 @@ export type LinearAuthMethod = "api_key"; export interface LinearConfig { + workspaceId?: string; authMethod: LinearAuthMethod; defaultTeamKey: string; hasSecret: boolean; diff --git a/apps/web/lib/types/sentry.ts b/apps/web/lib/types/sentry.ts index 85d75f3e05..7e4a867116 100644 --- a/apps/web/lib/types/sentry.ts +++ b/apps/web/lib/types/sentry.ts @@ -8,6 +8,7 @@ export const SENTRY_AUTH_METHOD: SentryAuthMethod = "auth_token"; export const SENTRY_DEFAULT_URL = "https://sentry.io"; export interface SentryConfig { + workspaceId?: string; authMethod: SentryAuthMethod; url: string; hasSecret: boolean; diff --git a/apps/web/lib/types/slack.ts b/apps/web/lib/types/slack.ts index f660eaebff..a88bd833fe 100644 --- a/apps/web/lib/types/slack.ts +++ b/apps/web/lib/types/slack.ts @@ -1,6 +1,7 @@ export type SlackAuthMethod = "cookie"; export interface SlackConfig { + workspaceId?: string; authMethod: SlackAuthMethod; commandPrefix: string; /** ID of a utility agent (`utility_agents.id`) the trigger invokes for each match. */ diff --git a/docs/decisions/0030-workspace-scoped-integration-settings.md b/docs/decisions/0030-workspace-scoped-integration-settings.md new file mode 100644 index 0000000000..6828b2b9a0 --- /dev/null +++ b/docs/decisions/0030-workspace-scoped-integration-settings.md @@ -0,0 +1,99 @@ +# Workspace-scoped integration settings + +## Status + +accepted + +## Context + +GitHub integration settings mixed install-wide authentication with operational +settings such as watch configuration, action presets, default queries, and repo +filters. That made multi-workspace installs confusing: a GitHub watch or query +configured for one workspace could appear beside settings for another, and the +`/github` PR/issue lists had no workspace boundary beyond ad hoc repo filters. + +Jira, Linear, Sentry, Slack, GitLab, and GitHub all have settings that users +expect to vary by Kandev workspace. Keeping credentials or provider defaults +install-wide makes multi-workspace installs ambiguous: a "work" workspace and a +"personal" workspace may require different tokens, hosts, orgs, projects, +teams, or utility agents. + +## Decision + +Third-party integration configuration is workspace-owned by default. Provider +credentials, host URLs, default project/team/org fields, health status, query +presets, and watcher settings are read and written for an explicit +`workspace_id`. + +For GitHub, each workspace has a `github_workspace_settings` row containing: + +- repository scope mode: all repositories, selected organizations, or selected + repositories; +- selected org/repo scope values; +- workspace-owned GitHub query presets. + +The settings UI exposes a topbar workspace switcher on integration settings +routes. GitHub settings render for the active workspace, and review/issue watch +dialogs are locked to that workspace from the settings page. + +The backend enforces GitHub repository scope on `/github` PR/issue searches and +watch polling results. The frontend also uses the scope to narrow repo selector +options, but that is only a usability layer; backend filtering is the source of +truth. + +For Jira, Linear, Sentry, Slack, and GitLab, singleton config tables migrate to +workspace-keyed config rows. Startup migration is deterministic and does not +prompt: copy the old singleton config and secret into the user's active +workspace when it exists, otherwise the first workspace by creation time. If no +workspace exists, leave the singleton data untouched until a workspace is +created or the user reconnects. + +A follow-up should add a copy action that copies the current workspace's +provider config and credentials to another workspace. Copying automation/watch +rows should remain separate and opt-in so users do not accidentally duplicate +task-creating pollers. + +## Consequences + +- Existing installs migrate their singleton integration config to one + deterministic workspace. If that is not the user's intended workspace, users + can reconnect the integration in the correct workspace today; a future copy + action should make that correction path faster. +- Existing GitHub installs default to `all` repository scope, preserving + current behavior until a workspace changes its GitHub scope. +- Workspace-scoped GitHub searches have cache keys that include the workspace + scope so scoped and unscoped result pages do not share cached data. +- Default query presets migrate opportunistically from the older global + browser/user-settings path into the first workspace where the user opens + GitHub settings or the `/github` page. +- Task creation repo/branch pickers remain governed by workspace repositories; + GitHub repository scope only affects GitHub integration surfaces. +- Config/status APIs must either require `workspace_id` or derive it from the + active workspace route state. Install-wide `/config` semantics are deprecated + for integrations that support workspace-scoped settings. + +## Alternatives Considered + +### Keep GitHub settings global + +Rejected. It would keep the current ambiguity and diverge from Jira/Linear, +where operational settings are already per-workspace. + +### Keep credentials install-wide and only scope watchers + +Rejected. It solves only part of the ambiguity. Different workspaces often map +to different external accounts or tenants, especially for GitLab self-managed +hosts, Jira sites, Sentry orgs, Linear teams, and Slack utility-agent routing. + +### Prompt during migration + +Rejected. Database migrations must be deterministic and able to run during +backend startup, CI, desktop launch, and headless server upgrades. A +copy-to-workspace affordance gives users a reversible correction path without +blocking startup. + +### Treat selected repositories as workspace repositories + +Rejected. GitHub repo scope is not the same as task execution repositories. A +workspace may monitor many GitHub repos for PRs/issues without attaching all of +them as local task repositories. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 7021f187d3..7cc22e1542 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -35,3 +35,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0027 | [Replayable schema migrations across SQLite and Postgres](0027-replayable-schema-migrations.md) | accepted | backend | 2026-06-24 | | 0028 | [Backend-owned task-create last-used preferences](0028-task-create-last-used-source-of-truth.md) | accepted | backend, frontend | 2026-06-29 | | 0029 | [Release backfill and desktop diagnostics](0029-release-backfill-and-desktop-diagnostics.md) | accepted | infra, workflow | 2026-07-01 | +| 0030 | [Workspace-scoped integration settings](0030-workspace-scoped-integration-settings.md) | accepted | backend, frontend | 2026-07-01 | diff --git a/docs/specs/gitlab-integration/spec.md b/docs/specs/gitlab-integration/spec.md index ffc2ff2163..6a56356780 100644 --- a/docs/specs/gitlab-integration/spec.md +++ b/docs/specs/gitlab-integration/spec.md @@ -21,13 +21,13 @@ Tracked in [#820](https://github.com/kdlbs/kandev/issues/820). - A GitLab integration runs alongside the GitHub one. Both can be configured at the same time; neither replaces the other. -- Users authenticate GitLab via one of: +- Users authenticate GitLab per Kandev workspace via one of: - `glab` CLI (auto-detected if installed and logged in). - - Personal access token stored in the secret store as `GITLAB_TOKEN` (or set - via the env var of the same name on the backend host). + - Personal access token stored in the secret store for that workspace (or set + via `GITLAB_TOKEN` on the backend host as a process-wide fallback). - Self-managed instances are supported by configuring a GitLab host URL - (default `https://gitlab.com`). The host applies to API calls, clone URLs, - and web links. + per workspace (default `https://gitlab.com`). The host applies to API calls, + clone URLs, and web links for that workspace. - Repositories can be added with `provider = "gitlab"`. The repo cloner builds SSH (`git@:/.git`) and HTTPS clone URLs against the configured host, and authenticates HTTPS clones with the GitLab token when @@ -48,8 +48,9 @@ Tracked in [#820](https://github.com/kdlbs/kandev/issues/820). REST API with the stored token), targeting the same base branch logic used for GitHub. - Settings live under `/settings/integrations/gitlab` with a page parallel to - the GitHub one: connection status, host URL field, auth method (CLI vs PAT), - reconnect CTA, and a token field that writes to the secret store. + the GitHub one: workspace selector, connection status, host URL field, auth + method (CLI vs PAT), reconnect CTA, token field that writes to the secret + store, and a copy action for duplicating the config to another workspace. - The orchestrator and credential providers pass `GITLAB_TOKEN` into agent environments the same way `GITHUB_TOKEN` is passed today, so agents running in containers can run `glab` or `git push` without extra setup. @@ -63,9 +64,9 @@ Tracked in [#820](https://github.com/kdlbs/kandev/issues/820). - **GIVEN** a user on a self-managed GitLab at `https://gitlab.acme.corp`, **WHEN** they enter the host URL and a personal access token in - Settings → Integrations → GitLab, **THEN** the connection status flips to - connected, MR/issue lists populate against the custom host, and clone URLs - for added repos use `gitlab.acme.corp`. + Settings → Integrations → GitLab for Workspace A, **THEN** Workspace A's + connection status flips to connected, MR/issue lists populate against the + custom host, and clone URLs for Workspace A repos use `gitlab.acme.corp`. - **GIVEN** a task whose repository has `provider = "gitlab"`, **WHEN** the agent runs the `pr` skill, **THEN** a merge request is opened on the @@ -82,8 +83,9 @@ Tracked in [#820](https://github.com/kdlbs/kandev/issues/820). - **GIVEN** the user revokes their GitLab token externally, **WHEN** the background poller next runs, **THEN** the integration's status flips to - "auth required" with a reconnect banner on the GitLab page and the settings - page, and no further API calls are issued until the user reconnects. + "auth required" for the affected workspace with a reconnect banner on the + GitLab page and the settings page, and no further API calls are issued for + that workspace until the user reconnects. - **GIVEN** no GitLab auth is configured, **WHEN** the GitLab page is opened, **THEN** it shows a "not connected" notice linking to settings, and no diff --git a/docs/specs/integrations/slack.md b/docs/specs/integrations/slack.md index 88e7dbef2c..e8173cdc73 100644 --- a/docs/specs/integrations/slack.md +++ b/docs/specs/integrations/slack.md @@ -12,16 +12,16 @@ Users live in Slack and surface task-worthy work there: bug reports in `#support ## What (v1, shipped) -- Install-wide Slack credentials (one Slack user/team per Kandev install), modeled on the Jira/Linear integrations: settings page with auth-status banner, reconnect CTA, and 90s auth-health polling. +- Workspace-scoped Slack credentials and routing defaults, modeled on the Jira/Linear integrations: settings page with a workspace switcher, auth-status banner, reconnect CTA, and 90s auth-health polling. Existing install-wide credentials migrate to the active/default workspace; users can copy settings to other workspaces. - Single auth mode: **Browser session** — user pastes the `xoxc-` token + `d` cookie pair extracted from their browser; works on locked-down Enterprise Slack tenants where bot installs are not possible. -- Single task-creation trigger: **`!kandev` thread triage** — the trigger polls the connected user's own messages for `!kandev `, hands the surrounding thread context to a configured utility agent (with the Kandev MCP server attached), and posts the agent's reply back in-thread. The agent picks the destination Kandev workspace/workflow/repo via MCP tools. +- Single task-creation trigger: **`!kandev` thread triage** — each configured workspace polls the connected Slack user's own messages for `!kandev `, hands the surrounding thread context to that workspace's configured utility agent (with the Kandev MCP server attached), and posts the agent's reply back in-thread. The agent picks the destination workflow/repo within the configured Kandev workspace via MCP tools. - An 👀 reaction is added to the triggering message on detection so the user can confirm it was picked up. - Token storage reuses the shared `secretadapter`; no Slack credentials live in plaintext on disk. ## Scenarios - **GIVEN** a user has pasted their `xoxc-`/`d` cookie pair in settings, **WHEN** they type `!kandev fix the safari login bug` in any Slack channel or DM, **THEN** the trigger reacts with 👀, the configured utility agent picks a workspace/workflow/repo via the Kandev MCP, creates a task, and posts the result back as a threaded reply. -- **GIVEN** a Slack credential is connected, **WHEN** the stored token becomes invalid (revoked, rotated, expired), **THEN** the auth-health poller flips the install to "Reconnect required" within 90 seconds and `RecordAuthHealth` invalidates the cached client so the next probe rebuilds it from current secrets. +- **GIVEN** a Slack credential is connected for Workspace A, **WHEN** the stored token becomes invalid (revoked, rotated, expired), **THEN** the auth-health poller flips Workspace A to "Reconnect required" within 90 seconds and `RecordAuthHealth` invalidates that workspace's cached client so the next probe rebuilds it from current secrets. - **GIVEN** a `!kandev` message has already been processed (its TS is at or below the watermark), **WHEN** the next poll picks it up, **THEN** it is filtered out and no duplicate task is created. ## Out of scope (v1) @@ -30,7 +30,6 @@ Users live in Slack and surface task-worthy work there: bug reports in `#support - Reaction trigger (`:kandev:` emoji), `/kandev ` slash command, and "Create Kandev task" message-shortcut entry. - Posting Kandev task updates back into Slack beyond the initial in-thread confirmation (no live status mirroring). - Slack as a chat surface for talking to a running agent (no `@kandev <prompt>` runtime, no streaming agent output to Slack). -- Multi-Slack-workspace fan-out within one Kandev install (one Slack user/team per install). - Routing tasks to specific workflows based on channel or keyword — the utility agent picks the workflow via MCP. - Importing historical Slack messages. @@ -38,7 +37,7 @@ Users live in Slack and surface task-worthy work there: bug reports in `#support - Bot-install auth mode with the full trigger set (reaction emoji, slash command, message shortcut) for teams whose admins are willing to install the Kandev Slack app. - User OAuth auth mode for users who want a properly-scoped token without admin involvement. -- Multi-Slack-workspace fan-out for Enterprise Grid tenants. +- Enterprise Grid fan-out across many Slack workspaces from one Kandev workspace. ## Open questions