diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index 972039c843..09e6a70b3c 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -92,6 +92,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.POST("/action-presets/reset", c.httpResetActionPresets) api.GET("/workspace-settings", c.httpGetWorkspaceSettings) api.PUT("/workspace-settings", c.httpUpdateWorkspaceSettings) + api.POST("/workspace-settings/copy", c.httpCopyWorkspaceSettings) api.GET("/stats", c.httpGetStats) } @@ -898,6 +899,41 @@ func (c *Controller) httpUpdateWorkspaceSettings(ctx *gin.Context) { ctx.JSON(http.StatusOK, settings) } +// copyWorkspaceSettingsRequest carries the target workspace for a copy. The +// source workspace comes from the workspace_id query param, matching the other +// integration copy endpoints. +type copyWorkspaceSettingsRequest struct { + TargetWorkspaceID string `json:"targetWorkspaceId"` +} + +func (c *Controller) httpCopyWorkspaceSettings(ctx *gin.Context) { + source := strings.TrimSpace(ctx.Query("workspace_id")) + if source == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + var req copyWorkspaceSettingsRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if strings.TrimSpace(req.TargetWorkspaceID) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"}) + return + } + settings, err := c.service.CopyWorkspaceSettingsToWorkspace(ctx.Request.Context(), source, req.TargetWorkspaceID) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrWorkspaceSettingsValidation): + status = http.StatusBadRequest + } + ctx.JSON(status, 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 diff --git a/apps/backend/internal/github/copy.go b/apps/backend/internal/github/copy.go new file mode 100644 index 0000000000..c4d3504951 --- /dev/null +++ b/apps/backend/internal/github/copy.go @@ -0,0 +1,83 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// ErrSameWorkspace is returned when a copy targets the same workspace it reads +// from. +var ErrSameWorkspace = errors.New("github: source and target workspaces are the same") + +// CopyWorkspaceSettingsToWorkspace copies the per-workspace GitHub operational +// settings (repo scope + saved/default query presets) and the workspace's +// quick-action presets from sourceWorkspaceID to targetWorkspaceID. GitHub +// authentication is install-wide, so there are no credentials to copy — only the +// workspace-scoped settings. Watchers are intentionally out of scope. +func (s *Service) CopyWorkspaceSettingsToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*WorkspaceSettings, error) { + sourceWorkspaceID = strings.TrimSpace(sourceWorkspaceID) + targetWorkspaceID = strings.TrimSpace(targetWorkspaceID) + if sourceWorkspaceID == "" || targetWorkspaceID == "" { + return nil, fmt.Errorf("%w: source and target workspace ids are required", ErrWorkspaceSettingsValidation) + } + if sourceWorkspaceID == targetWorkspaceID { + return nil, ErrSameWorkspace + } + if s.store == nil { + return nil, fmt.Errorf("github store not configured") + } + // GetWorkspaceSettings returns defaults (never nil) for a missing row. + source, err := s.store.GetWorkspaceSettings(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source github settings: %w", err) + } + target := &WorkspaceSettings{ + WorkspaceID: targetWorkspaceID, + RepoScopeMode: source.RepoScopeMode, + RepoScopeOrgs: append([]string(nil), source.RepoScopeOrgs...), + RepoScopeRepos: append([]RepoFilter(nil), source.RepoScopeRepos...), + SavedPresets: cloneRawMessage(source.SavedPresets), + DefaultQueryPresets: cloneRawMessage(source.DefaultQueryPresets), + } + // Copy the action presets before the workspace-settings write. These are two + // separate store writes without a shared transaction, so ordering the + // preset copy first means a preset failure leaves the target completely + // untouched; only a failure of the final settings write can leave a partial + // copy, and a full retry overwrites both. + if err := s.copyActionPresets(ctx, sourceWorkspaceID, targetWorkspaceID); err != nil { + return nil, err + } + if err := s.store.UpsertWorkspaceSettings(ctx, target); err != nil { + return nil, fmt.Errorf("write target github settings: %w", err) + } + return s.store.GetWorkspaceSettings(ctx, targetWorkspaceID) +} + +// copyActionPresets duplicates the source workspace's stored quick-action +// presets onto the target. When the source has no stored row it relies on +// built-in defaults, so the target's row is cleared as well — otherwise a copy +// would leave the target on its own customised presets while reporting success, +// diverging from the source. Both then fall back to the same defaults on read. +func (s *Service) copyActionPresets(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) error { + stored, err := s.store.GetActionPresets(ctx, sourceWorkspaceID) + if err != nil { + return fmt.Errorf("read source github action presets: %w", err) + } + if stored == nil { + if err := s.store.DeleteActionPresets(ctx, targetWorkspaceID); err != nil { + return fmt.Errorf("clear target github action presets: %w", err) + } + return nil + } + target := &ActionPresets{ + WorkspaceID: targetWorkspaceID, + PR: append([]ActionPreset(nil), stored.PR...), + Issue: append([]ActionPreset(nil), stored.Issue...), + } + if err := s.store.UpsertActionPresets(ctx, target); err != nil { + return fmt.Errorf("write target github action presets: %w", err) + } + return nil +} diff --git a/apps/backend/internal/github/copy_test.go b/apps/backend/internal/github/copy_test.go new file mode 100644 index 0000000000..23b2c6f2a6 --- /dev/null +++ b/apps/backend/internal/github/copy_test.go @@ -0,0 +1,117 @@ +package github + +import ( + "context" + "errors" + "testing" +) + +func newCopyTestService(t *testing.T) *Service { + t.Helper() + client := NewMockClient() + store := newTestStore(t) + return NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t)) +} + +func TestCopyWorkspaceSettingsToWorkspace_CopiesSettings(t *testing.T) { + svc := newCopyTestService(t) + ctx := context.Background() + const src, dst = "ws-src", "ws-dst" + + if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ + WorkspaceID: src, + RepoScopeMode: RepoScopeModeRepos, + RepoScopeRepos: []RepoFilter{{Owner: "kdlbs", Name: "kandev"}}, + SavedPresets: []byte(`[{"id":"p1","kind":"pr","label":"Mine"}]`), + DefaultQueryPresets: []byte(`{"pr":[],"issue":[]}`), + }); err != nil { + t.Fatalf("seed source: %v", err) + } + if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{ + WorkspaceID: src, + PR: &[]ActionPreset{{ID: "custom", Label: "Custom", PromptTemplate: "do {{url}}"}}, + }); err != nil { + t.Fatalf("seed source action presets: %v", err) + } + + got, err := svc.CopyWorkspaceSettingsToWorkspace(ctx, src, dst) + if err != nil { + t.Fatalf("copy: %v", err) + } + if got.WorkspaceID != dst || got.RepoScopeMode != RepoScopeModeRepos { + t.Errorf("copied settings identity/scope mismatch: %+v", got) + } + if len(got.RepoScopeRepos) != 1 || got.RepoScopeRepos[0].Owner != "kdlbs" || + got.RepoScopeRepos[0].Name != "kandev" { + t.Errorf("repo scope not copied: %+v", got.RepoScopeRepos) + } + if string(got.SavedPresets) != `[{"id":"p1","kind":"pr","label":"Mine"}]` { + t.Errorf("saved presets not copied: %s", got.SavedPresets) + } + if string(got.DefaultQueryPresets) != `{"pr":[],"issue":[]}` { + t.Errorf("default query presets not copied: %s", got.DefaultQueryPresets) + } + + presets, err := svc.GetActionPresets(ctx, dst) + if err != nil { + t.Fatalf("get copied action presets: %v", err) + } + if len(presets.PR) != 1 || presets.PR[0].ID != "custom" { + t.Errorf("action presets not copied: %+v", presets.PR) + } +} + +// When the source relies on default presets (no stored row), the copy must +// clear the target's customised presets so both fall back to the same defaults — +// otherwise the copy silently leaves the target diverged from the source. +func TestCopyWorkspaceSettingsToWorkspace_ClearsTargetPresetsWhenSourceHasNone(t *testing.T) { + svc := newCopyTestService(t) + ctx := context.Background() + const src, dst = "ws-src", "ws-dst" + + // Target has customised presets; source has none stored. + if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{ + WorkspaceID: dst, + PR: &[]ActionPreset{{ID: "target-custom", Label: "Target", PromptTemplate: "x {{url}}"}}, + }); err != nil { + t.Fatalf("seed target action presets: %v", err) + } + + if _, err := svc.CopyWorkspaceSettingsToWorkspace(ctx, src, dst); err != nil { + t.Fatalf("copy: %v", err) + } + + defaults, err := svc.GetActionPresets(ctx, "ws-fresh") + if err != nil { + t.Fatalf("get default presets: %v", err) + } + got, err := svc.GetActionPresets(ctx, dst) + if err != nil { + t.Fatalf("get target presets: %v", err) + } + if len(got.PR) != len(defaults.PR) { + t.Errorf("target presets not reset to defaults: got %d, want %d", len(got.PR), len(defaults.PR)) + } + for _, p := range got.PR { + if p.ID == "target-custom" { + t.Errorf("target retained its customised preset after copy from a source with none") + } + } +} + +func TestCopyWorkspaceSettingsToWorkspace_SameWorkspace(t *testing.T) { + svc := newCopyTestService(t) + if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) { + t.Fatalf("expected ErrSameWorkspace, got %v", err) + } +} + +func TestCopyWorkspaceSettingsToWorkspace_MissingIDs(t *testing.T) { + svc := newCopyTestService(t) + if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "", "ws-dst"); !errors.Is(err, ErrWorkspaceSettingsValidation) { + t.Fatalf("expected ErrWorkspaceSettingsValidation for empty source, got %v", err) + } + if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "ws-src", ""); !errors.Is(err, ErrWorkspaceSettingsValidation) { + t.Fatalf("expected ErrWorkspaceSettingsValidation for empty destination, got %v", err) + } +} diff --git a/apps/backend/internal/jira/copy.go b/apps/backend/internal/jira/copy.go new file mode 100644 index 0000000000..468e05c73b --- /dev/null +++ b/apps/backend/internal/jira/copy.go @@ -0,0 +1,74 @@ +package jira + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ErrSameWorkspace is returned when a copy targets the same workspace it reads +// from. +var ErrSameWorkspace = errors.New("jira: source and target workspaces are the same") + +// ErrNothingToCopy is returned when the source workspace has no Jira config. +var ErrNothingToCopy = errors.New("jira: source workspace has no configuration to copy") + +// CopyConfigToWorkspace copies the Jira provider config and credential (token) +// from sourceWorkspaceID to targetWorkspaceID. Watchers are intentionally out +// of scope — only the connection settings and secret are duplicated. +func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*JiraConfig, error) { + sourceWorkspaceID, err := s.normalizeWorkspaceID(sourceWorkspaceID) + if err != nil { + return nil, err + } + targetWorkspaceID, err = s.normalizeWorkspaceID(targetWorkspaceID) + if err != nil { + return nil, err + } + if sourceWorkspaceID == targetWorkspaceID { + return nil, ErrSameWorkspace + } + cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source jira config: %w", err) + } + if cfg == nil { + return nil, ErrNothingToCopy + } + secret, err := s.revealSecret(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source jira secret: %w", err) + } + // An empty Secret means "keep existing" in SetConfigForWorkspace, so copying + // from a workspace without a stored token would leave the target on its old + // credential paired with the copied site/email. Treat that as nothing to copy. + if secret == "" { + return nil, ErrNothingToCopy + } + req := &SetConfigRequest{ + SiteURL: cfg.SiteURL, + Email: cfg.Email, + AuthMethod: cfg.AuthMethod, + InstanceType: cfg.InstanceType, + DefaultProjectKey: cfg.DefaultProjectKey, + Secret: secret, + } + // Mark the target unhealthy before the write so the UI doesn't briefly flash + // a stale "connected" state from the target's previous credential while the + // async probe kicked off by SetConfigForWorkspace validates the copied one. + if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", time.Now().UTC()); err != nil { + return nil, fmt.Errorf("reset target jira health: %w", err) + } + cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req) + if err != nil { + // The write failed, so the target's config is unchanged but we already + // flipped its health row. Re-probe asynchronously (best effort) so the row + // reflects the target's real state again rather than a spurious + // "unhealthy", without delaying the error response by the probe timeout. + // Detach from the request context, which may already be cancelled. + go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID) + return nil, err + } + return cfgOut, nil +} diff --git a/apps/backend/internal/jira/copy_test.go b/apps/backend/internal/jira/copy_test.go new file mode 100644 index 0000000000..a37b0077d8 --- /dev/null +++ b/apps/backend/internal/jira/copy_test.go @@ -0,0 +1,69 @@ +package jira + +import ( + "context" + "errors" + "testing" + "time" +) + +// drainProbe waits for the async auth-health probe fired by a config write so +// the test does not race the background DB write. +func drainProbe(t *testing.T, f *svcFixture) { + t.Helper() + select { + case <-f.probed: + case <-time.After(2 * time.Second): + t.Fatalf("async probe hook did not fire within 2s") + } +} + +func TestCopyConfigToWorkspace_CopiesConfigAndSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + const src, dst = "ws-src", "ws-dst" + + if _, err := f.svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{ + SiteURL: "https://acme.atlassian.net", + Email: "u@example.com", + AuthMethod: AuthMethodAPIToken, + InstanceType: InstanceTypeCloud, + DefaultProjectKey: "ENG", + Secret: "tok-src", + }); err != nil { + t.Fatalf("seed source: %v", err) + } + drainProbe(t, f) + + got, err := f.svc.CopyConfigToWorkspace(ctx, src, dst) + if err != nil { + t.Fatalf("copy: %v", err) + } + drainProbe(t, f) + + if got.SiteURL != "https://acme.atlassian.net" || got.Email != "u@example.com" || + got.InstanceType != InstanceTypeCloud || got.DefaultProjectKey != "ENG" { + t.Errorf("copied config mismatch: %+v", got) + } + v, err := f.secrets.Reveal(ctx, SecretKeyForWorkspace(dst)) + if err != nil { + t.Fatalf("reveal copied secret: %v", err) + } + if v != "tok-src" { + t.Errorf("secret not copied: %q", v) + } +} + +func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) { + f := newSvcFixture(t) + if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) { + t.Fatalf("expected ErrSameWorkspace, got %v", err) + } +} + +func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) { + f := newSvcFixture(t) + if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) { + t.Fatalf("expected ErrNothingToCopy, got %v", err) + } +} diff --git a/apps/backend/internal/jira/handlers.go b/apps/backend/internal/jira/handlers.go index f1ebaebc7a..637b1380bd 100644 --- a/apps/backend/internal/jira/handlers.go +++ b/apps/backend/internal/jira/handlers.go @@ -34,6 +34,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.POST("/config", c.httpSetConfig) api.DELETE("/config", c.httpDeleteConfig) api.POST("/config/test", c.httpTestConfig) + api.POST("/config/copy", c.httpCopyConfig) api.GET("/projects", c.httpListProjects) api.GET("/projects/:key/statuses", c.httpListProjectStatuses) api.GET("/tickets", c.httpSearchTickets) @@ -184,6 +185,42 @@ func (c *Controller) workspaceID(ctx *gin.Context) string { return strings.TrimSpace(ctx.Query("workspace_id")) } +// copyConfigRequest is the payload for the copy-config endpoint. The source +// workspace comes from the workspace_id query param; the body carries the +// target. +type copyConfigRequest struct { + TargetWorkspaceID string `json:"targetWorkspaceId"` +} + +func (c *Controller) httpCopyConfig(ctx *gin.Context) { + sourceWorkspaceID := c.workspaceID(ctx) + if sourceWorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + var req copyConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID) + if targetWorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"}) + return + } + cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig): + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + // --- Issue watch HTTP handlers --- // httpListIssueWatches returns watches scoped to one workspace when diff --git a/apps/backend/internal/linear/copy.go b/apps/backend/internal/linear/copy.go new file mode 100644 index 0000000000..af5107a01f --- /dev/null +++ b/apps/backend/internal/linear/copy.go @@ -0,0 +1,71 @@ +package linear + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ErrSameWorkspace is returned when a copy targets the same workspace it reads +// from. +var ErrSameWorkspace = errors.New("linear: source and target workspaces are the same") + +// ErrNothingToCopy is returned when the source workspace has no Linear config. +var ErrNothingToCopy = errors.New("linear: source workspace has no configuration to copy") + +// CopyConfigToWorkspace copies the Linear provider config and credential (API +// key) from sourceWorkspaceID to targetWorkspaceID. Watchers are intentionally +// out of scope — only the connection settings and secret are duplicated. +func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*LinearConfig, error) { + sourceWorkspaceID, err := s.normalizeWorkspaceID(sourceWorkspaceID) + if err != nil { + return nil, err + } + targetWorkspaceID, err = s.normalizeWorkspaceID(targetWorkspaceID) + if err != nil { + return nil, err + } + if sourceWorkspaceID == targetWorkspaceID { + return nil, ErrSameWorkspace + } + cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source linear config: %w", err) + } + if cfg == nil { + return nil, ErrNothingToCopy + } + secret, err := s.revealSecret(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source linear secret: %w", err) + } + // An empty Secret means "keep existing" in SetConfigForWorkspace, so copying + // from a workspace without a stored API key would leave the target on its old + // credential paired with the copied settings. Treat that as nothing to copy. + if secret == "" { + return nil, ErrNothingToCopy + } + req := &SetConfigRequest{ + AuthMethod: cfg.AuthMethod, + DefaultTeamKey: cfg.DefaultTeamKey, + Secret: secret, + } + // Mark the target unhealthy before the write so the UI doesn't briefly flash + // a stale "connected" state (from a previously connected org) while the async + // probe kicked off by SetConfigForWorkspace validates the copied credential. + if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", "", time.Now().UTC()); err != nil { + return nil, fmt.Errorf("reset target linear health: %w", err) + } + cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req) + if err != nil { + // The write failed, so the target's config is unchanged but we already + // flipped its health row. Re-probe asynchronously (best effort) so the row + // reflects the target's real state again rather than a spurious + // "unhealthy", without delaying the error response by the probe timeout. + // Detach from the request context, which may already be cancelled. + go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID) + return nil, err + } + return cfgOut, nil +} diff --git a/apps/backend/internal/linear/copy_test.go b/apps/backend/internal/linear/copy_test.go new file mode 100644 index 0000000000..9507fc7850 --- /dev/null +++ b/apps/backend/internal/linear/copy_test.go @@ -0,0 +1,93 @@ +package linear + +import ( + "context" + "errors" + "testing" + "time" +) + +// drainProbe waits for the async auth-health probe fired by a config write. +func drainProbe(t *testing.T, f *svcFixture) { + t.Helper() + select { + case <-f.probed: + case <-time.After(2 * time.Second): + t.Fatalf("async probe hook did not fire within 2s") + } +} + +func TestCopyConfigToWorkspace_CopiesConfigAndSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + const src, dst = "ws-src", "ws-dst" + + if _, err := f.svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{ + AuthMethod: AuthMethodAPIKey, + DefaultTeamKey: "ENG", + Secret: "lin-src", + }); err != nil { + t.Fatalf("seed source: %v", err) + } + drainProbe(t, f) + + got, err := f.svc.CopyConfigToWorkspace(ctx, src, dst) + if err != nil { + t.Fatalf("copy: %v", err) + } + drainProbe(t, f) + + if got.DefaultTeamKey != "ENG" || got.AuthMethod != AuthMethodAPIKey { + t.Errorf("copied config mismatch: %+v", got) + } + v, err := f.secrets.Reveal(ctx, SecretKeyForWorkspace(dst)) + if err != nil { + t.Fatalf("reveal copied secret: %v", err) + } + if v != "lin-src" { + t.Errorf("secret not copied: %q", v) + } +} + +func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) { + f := newSvcFixture(t) + if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) { + t.Fatalf("expected ErrSameWorkspace, got %v", err) + } +} + +func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) { + f := newSvcFixture(t) + if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) { + t.Fatalf("expected ErrNothingToCopy, got %v", err) + } +} + +// TestCopyConfigToWorkspace_MissingSecret guards the empty-secret boundary: a +// source config row whose stored secret is empty must fail with ErrNothingToCopy +// rather than copy an empty secret (which SetConfigForWorkspace treats as +// "preserve existing", silently leaving the target on its old credential). +func TestCopyConfigToWorkspace_MissingSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + const src, dst = "ws-src", "ws-dst" + // Seed a config row directly and an empty stored secret so revealSecret + // returns ("", nil) instead of a not-found error. + if err := f.store.UpsertConfigForWorkspace(ctx, src, &LinearConfig{ + AuthMethod: AuthMethodAPIKey, + DefaultTeamKey: "ENG", + }); err != nil { + t.Fatalf("seed source config: %v", err) + } + // Store empty values for both the workspace-scoped and legacy keys so + // revealSecret returns ("", nil) rather than a not-found error. + if err := f.secrets.Set(ctx, SecretKeyForWorkspace(src), "Linear API key", ""); err != nil { + t.Fatalf("seed empty secret: %v", err) + } + if err := f.secrets.Set(ctx, SecretKey, "Linear API key", ""); err != nil { + t.Fatalf("seed empty legacy secret: %v", err) + } + if _, err := f.svc.CopyConfigToWorkspace(ctx, src, dst); !errors.Is(err, ErrNothingToCopy) { + t.Fatalf("expected ErrNothingToCopy for empty secret, got %v", err) + } +} diff --git a/apps/backend/internal/linear/handlers.go b/apps/backend/internal/linear/handlers.go index db944b6949..06e96e73a3 100644 --- a/apps/backend/internal/linear/handlers.go +++ b/apps/backend/internal/linear/handlers.go @@ -36,6 +36,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.POST("/config", c.httpSetConfig) api.DELETE("/config", c.httpDeleteConfig) api.POST("/config/test", c.httpTestConfig) + api.POST("/config/copy", c.httpCopyConfig) api.GET("/teams", c.httpListTeams) api.GET("/states", c.httpListStates) api.GET("/labels", c.httpListLabels) @@ -210,6 +211,42 @@ func (c *Controller) workspaceID(ctx *gin.Context) string { return strings.TrimSpace(ctx.Query("workspace_id")) } +// copyConfigRequest is the payload for the copy-config endpoint. The source +// workspace comes from the workspace_id query param; the body carries the +// target. +type copyConfigRequest struct { + TargetWorkspaceID string `json:"targetWorkspaceId"` +} + +func (c *Controller) httpCopyConfig(ctx *gin.Context) { + sourceWorkspaceID := c.workspaceID(ctx) + if sourceWorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + var req copyConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID) + if targetWorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"}) + return + } + cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig): + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + // 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/sentry/copy.go b/apps/backend/internal/sentry/copy.go new file mode 100644 index 0000000000..55b7c50fcc --- /dev/null +++ b/apps/backend/internal/sentry/copy.go @@ -0,0 +1,72 @@ +package sentry + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ErrSameWorkspace is returned when a copy targets the same workspace it reads +// from. +var ErrSameWorkspace = errors.New("sentry: source and target workspaces are the same") + +// ErrNothingToCopy is returned when the source workspace has no Sentry config. +var ErrNothingToCopy = errors.New("sentry: source workspace has no configuration to copy") + +// CopyConfigToWorkspace copies the Sentry provider config and credential (auth +// token) from sourceWorkspaceID to targetWorkspaceID. Watchers are +// intentionally out of scope — only the connection settings and secret are +// duplicated. +func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*SentryConfig, error) { + sourceWorkspaceID, err := s.normalizeWorkspaceID(sourceWorkspaceID) + if err != nil { + return nil, err + } + targetWorkspaceID, err = s.normalizeWorkspaceID(targetWorkspaceID) + if err != nil { + return nil, err + } + if sourceWorkspaceID == targetWorkspaceID { + return nil, ErrSameWorkspace + } + cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source sentry config: %w", err) + } + if cfg == nil { + return nil, ErrNothingToCopy + } + secret, err := s.revealSecret(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source sentry secret: %w", err) + } + // An empty Secret means "keep existing" in SetConfigForWorkspace, so copying + // from a workspace without a stored token would leave the target on its old + // credential paired with the copied settings. Treat that as nothing to copy. + if secret == "" { + return nil, ErrNothingToCopy + } + req := &SetConfigRequest{ + AuthMethod: cfg.AuthMethod, + URL: cfg.URL, + Secret: secret, + } + // Mark the target unhealthy before the write so the UI doesn't briefly flash + // a stale "connected" state from the target's previous credential while the + // async probe kicked off by SetConfigForWorkspace validates the copied one. + if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", time.Now().UTC()); err != nil { + return nil, fmt.Errorf("reset target sentry health: %w", err) + } + cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req) + if err != nil { + // The write failed, so the target's config is unchanged but we already + // flipped its health row. Re-probe asynchronously (best effort) so the row + // reflects the target's real state again rather than a spurious + // "unhealthy", without delaying the error response by the probe timeout. + // Detach from the request context, which may already be cancelled. + go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID) + return nil, err + } + return cfgOut, nil +} diff --git a/apps/backend/internal/sentry/copy_test.go b/apps/backend/internal/sentry/copy_test.go new file mode 100644 index 0000000000..14109541c7 --- /dev/null +++ b/apps/backend/internal/sentry/copy_test.go @@ -0,0 +1,64 @@ +package sentry + +import ( + "context" + "errors" + "testing" + "time" +) + +// drainProbe waits for the async auth-health probe fired by a config write. +func drainProbe(t *testing.T, f *svcFixture) { + t.Helper() + select { + case <-f.probed: + case <-time.After(2 * time.Second): + t.Fatalf("async probe hook did not fire within 2s") + } +} + +func TestCopyConfigToWorkspace_CopiesConfigAndSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + const src, dst = "ws-src", "ws-dst" + + if _, err := f.svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, + URL: "https://sentry.example.com", + Secret: "sen-src", + }); err != nil { + t.Fatalf("seed source: %v", err) + } + drainProbe(t, f) + + got, err := f.svc.CopyConfigToWorkspace(ctx, src, dst) + if err != nil { + t.Fatalf("copy: %v", err) + } + drainProbe(t, f) + + if got.URL != "https://sentry.example.com" || got.AuthMethod != AuthMethodAuthToken { + t.Errorf("copied config mismatch: %+v", got) + } + v, err := f.secrets.Reveal(ctx, SecretKeyForWorkspace(dst)) + if err != nil { + t.Fatalf("reveal copied secret: %v", err) + } + if v != "sen-src" { + t.Errorf("secret not copied: %q", v) + } +} + +func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) { + f := newSvcFixture(t) + if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) { + t.Fatalf("expected ErrSameWorkspace, got %v", err) + } +} + +func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) { + f := newSvcFixture(t) + if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) { + t.Fatalf("expected ErrNothingToCopy, got %v", err) + } +} diff --git a/apps/backend/internal/sentry/handlers.go b/apps/backend/internal/sentry/handlers.go index 58bf261f8a..1728c54dde 100644 --- a/apps/backend/internal/sentry/handlers.go +++ b/apps/backend/internal/sentry/handlers.go @@ -33,6 +33,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.PUT("/config", c.httpSetConfig) api.DELETE("/config", c.httpDeleteConfig) api.POST("/config/test", c.httpTestConfig) + api.POST("/config/copy", c.httpCopyConfig) api.GET("/organizations", c.httpListOrganizations) api.GET("/projects", c.httpListProjects) api.GET("/issues", c.httpSearchIssues) @@ -160,6 +161,42 @@ func (c *Controller) workspaceID(ctx *gin.Context) string { return strings.TrimSpace(ctx.Query("workspace_id")) } +// copyConfigRequest is the payload for the copy-config endpoint. The source +// workspace comes from the workspace_id query param; the body carries the +// target. +type copyConfigRequest struct { + TargetWorkspaceID string `json:"targetWorkspaceId"` +} + +func (c *Controller) httpCopyConfig(ctx *gin.Context) { + sourceWorkspaceID := c.workspaceID(ctx) + if sourceWorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + var req copyConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID) + if targetWorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"}) + return + } + cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig): + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + // 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/slack/copy.go b/apps/backend/internal/slack/copy.go new file mode 100644 index 0000000000..5039535a6a --- /dev/null +++ b/apps/backend/internal/slack/copy.go @@ -0,0 +1,79 @@ +package slack + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ErrSameWorkspace is returned when a copy targets the same workspace it reads +// from — a no-op the caller should surface as a client error rather than +// silently succeeding. +var ErrSameWorkspace = errors.New("slack: source and target workspaces are the same") + +// ErrNothingToCopy is returned when the source workspace has no Slack config to +// copy. +var ErrNothingToCopy = errors.New("slack: source workspace has no configuration to copy") + +// CopyConfigToWorkspace copies the Slack provider config and credentials +// (token + cookie) from sourceWorkspaceID to targetWorkspaceID. Watchers and +// automations are intentionally out of scope — only the connection settings and +// secrets are duplicated. The target's health probe re-runs so runtime fields +// (team/user id) repopulate from the copied credentials. +func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*SlackConfig, error) { + sourceWorkspaceID, err := s.normalizeWorkspaceID(sourceWorkspaceID) + if err != nil { + return nil, err + } + targetWorkspaceID, err = s.normalizeWorkspaceID(targetWorkspaceID) + if err != nil { + return nil, err + } + if sourceWorkspaceID == targetWorkspaceID { + return nil, ErrSameWorkspace + } + cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source slack config: %w", err) + } + if cfg == nil { + return nil, ErrNothingToCopy + } + token, cookie, err := s.revealSecrets(ctx, sourceWorkspaceID) + if err != nil { + return nil, err + } + // Empty Token/Cookie mean "preserve existing" in SetConfigForWorkspace, so a + // source without stored secrets would silently leave the target on its old + // credentials. Treat a missing source secret as nothing to copy instead. + if token == "" || cookie == "" { + return nil, ErrNothingToCopy + } + req := &SetConfigRequest{ + AuthMethod: cfg.AuthMethod, + CommandPrefix: cfg.CommandPrefix, + UtilityAgentID: cfg.UtilityAgentID, + PollIntervalSeconds: cfg.PollIntervalSeconds, + Token: token, + Cookie: cookie, + } + // Mark the target unhealthy before the write so the UI doesn't briefly flash + // a stale "connected" state (old team/user id) from the target's previous + // connection while the async probe validates the copied credentials. The + // probe repopulates team/user id from the new credentials. + if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", "", "", time.Now().UTC()); err != nil { + return nil, fmt.Errorf("reset target slack health: %w", err) + } + cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req) + if err != nil { + // The write failed, so the target's config is unchanged but we already + // flipped its health row. Re-probe asynchronously (best effort) so the row + // reflects the target's real state again rather than a spurious + // "unhealthy", without delaying the error response by the probe timeout. + // Detach from the request context, which may already be cancelled. + go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID) + return nil, err + } + return cfgOut, nil +} diff --git a/apps/backend/internal/slack/copy_test.go b/apps/backend/internal/slack/copy_test.go new file mode 100644 index 0000000000..d4673eb0a9 --- /dev/null +++ b/apps/backend/internal/slack/copy_test.go @@ -0,0 +1,131 @@ +package slack + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +// fakeSecrets is an in-memory SecretStore for copy tests. +type fakeSecrets struct { + mu sync.Mutex + values map[string]string +} + +func newFakeSecrets() *fakeSecrets { return &fakeSecrets{values: map[string]string{}} } + +func (f *fakeSecrets) Reveal(_ context.Context, id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.values[id], nil +} + +func (f *fakeSecrets) Set(_ context.Context, id, _, value string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.values[id] = value + return nil +} + +func (f *fakeSecrets) Delete(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.values, id) + return nil +} + +func (f *fakeSecrets) Exists(_ context.Context, id string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.values[id] + return ok, nil +} + +func (f *fakeSecrets) get(id string) string { + f.mu.Lock() + defer f.mu.Unlock() + return f.values[id] +} + +func newCopyTestService(t *testing.T, secrets SecretStore) *Service { + t.Helper() + store := newTestStore(t) + // A fake client keeps the async health probe from doing real IO. + factory := func(_ *SlackConfig, _, _ string) Client { return &fakeClient{} } + return NewService(store, secrets, nil, factory, logger.Default()) +} + +func TestCopyConfigToWorkspace_CopiesConfigAndSecrets(t *testing.T) { + ctx := context.Background() + secrets := newFakeSecrets() + svc := newCopyTestService(t, secrets) + // Block on the async probe so the test doesn't race the background write. + done := make(chan struct{}, 4) + svc.SetProbeHook(func() { done <- struct{}{} }) + + const src, dst = "ws-src", "ws-dst" + if _, err := svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{ + AuthMethod: AuthMethodCookie, + CommandPrefix: "!go", + UtilityAgentID: "ua-1", + PollIntervalSeconds: 45, + Token: "xoxc-token", + Cookie: "d-cookie", + }); err != nil { + t.Fatalf("seed source: %v", err) + } + <-done + + got, err := svc.CopyConfigToWorkspace(ctx, src, dst) + if err != nil { + t.Fatalf("copy: %v", err) + } + <-done + + if got.UtilityAgentID != "ua-1" || got.CommandPrefix != "!go" || got.PollIntervalSeconds != 45 { + t.Errorf("copied config mismatch: %+v", got) + } + if v := secrets.get(SecretKeyForToken(dst)); v != "xoxc-token" { + t.Errorf("token not copied: %q", v) + } + if v := secrets.get(SecretKeyForCookie(dst)); v != "d-cookie" { + t.Errorf("cookie not copied: %q", v) + } +} + +func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) { + svc := newCopyTestService(t, newFakeSecrets()) + if _, err := svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) { + t.Fatalf("expected ErrSameWorkspace, got %v", err) + } +} + +func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) { + svc := newCopyTestService(t, newFakeSecrets()) + if _, err := svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) { + t.Fatalf("expected ErrNothingToCopy, got %v", err) + } +} + +// TestCopyConfigToWorkspace_MissingSecret guards against silently leaving the +// target on its previous credentials: a source config row without stored +// token/cookie must fail rather than copy an empty secret (which +// SetConfigForWorkspace would treat as "preserve existing"). +func TestCopyConfigToWorkspace_MissingSecret(t *testing.T) { + ctx := context.Background() + svc := newCopyTestService(t, newFakeSecrets()) + // Seed a config row directly, without storing any token/cookie secret. + if err := svc.store.UpsertConfigForWorkspace(ctx, "ws-src", &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua-1", + PollIntervalSeconds: 30, + }); err != nil { + t.Fatalf("seed source config: %v", err) + } + if _, err := svc.CopyConfigToWorkspace(ctx, "ws-src", "ws-dst"); !errors.Is(err, ErrNothingToCopy) { + t.Fatalf("expected ErrNothingToCopy for missing secret, got %v", err) + } +} diff --git a/apps/backend/internal/slack/handlers.go b/apps/backend/internal/slack/handlers.go index d18b8014eb..4c01cd71e1 100644 --- a/apps/backend/internal/slack/handlers.go +++ b/apps/backend/internal/slack/handlers.go @@ -33,6 +33,43 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.POST("/config", c.httpSetConfig) api.DELETE("/config", c.httpDeleteConfig) api.POST("/config/test", c.httpTestConfig) + api.POST("/config/copy", c.httpCopyConfig) +} + +// copyConfigRequest is the payload for the copy-config endpoint. The source +// workspace is taken from the workspace_id query param (like every other +// handler here); the body carries the target. +type copyConfigRequest struct { + TargetWorkspaceID string `json:"targetWorkspaceId"` +} + +func (c *Controller) httpCopyConfig(ctx *gin.Context) { + sourceWorkspaceID := c.workspaceID(ctx) + if sourceWorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) + return + } + var req copyConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID) + if targetWorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"}) + return + } + cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig): + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) } // --- HTTP handlers --- diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx index 161978734a..466545703a 100644 --- a/apps/web/components/github/github-settings.tsx +++ b/apps/web/components/github/github-settings.tsx @@ -312,7 +312,7 @@ export function GitHubIntegrationPage() {
- + {(ws) => }
diff --git a/apps/web/components/integrations/integration-copy-config-menu.tsx b/apps/web/components/integrations/integration-copy-config-menu.tsx new file mode 100644 index 0000000000..5ca2dc72e6 --- /dev/null +++ b/apps/web/components/integrations/integration-copy-config-menu.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { IconCopy } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useToast } from "@/components/toast-provider"; +import { + copyIntegrationConfig, + integrationLabel, + type IntegrationSlug, +} from "./integration-copy-config"; + +type Workspace = { id: string; name: string }; + +type IntegrationCopyConfigMenuProps = { + slug: IntegrationSlug; + sourceWorkspaceId: string; + workspaces: Workspace[]; +}; + +function workspaceName(workspaces: Workspace[], id: string | null): string { + return workspaces.find((w) => w.id === id)?.name ?? ""; +} + +function CopyConfigDialogBody({ + label, + slug, + sourceName, + targets, + targetId, + setTargetId, + copying, + onCopy, + onCancel, +}: { + label: string; + slug: IntegrationSlug; + sourceName: string; + targets: Workspace[]; + targetId: string | null; + setTargetId: (id: string) => void; + copying: boolean; + onCopy: () => void; + onCancel: () => void; +}) { + return ( + + + Copy {label} configuration + + Copy the {label} settings + {slug === "github" ? "" : " and credentials"} from{" "} + {sourceName} into another workspace. + This overwrites the target workspace's current {label} configuration. Watchers and + automations are not copied. + + +
+ + +
+ + + + +
+ ); +} + +// IntegrationCopyConfigMenu renders an icon-only button next to the workspace +// switcher. Clicking it opens a dialog that explains what copying does and lets +// the user pick a target workspace to copy the current integration's config +// (and credentials, except GitHub) into. +export function IntegrationCopyConfigMenu({ + slug, + sourceWorkspaceId, + workspaces, +}: IntegrationCopyConfigMenuProps) { + const { toast, updateToast } = useToast(); + const [open, setOpen] = useState(false); + const [copying, setCopying] = useState(false); + const [targetId, setTargetId] = useState(null); + const label = integrationLabel(slug); + + const targets = useMemo( + () => workspaces.filter((w) => w.id !== sourceWorkspaceId), + [workspaces, sourceWorkspaceId], + ); + const sourceName = workspaceName(workspaces, sourceWorkspaceId); + + // Clear a selected target that is no longer valid — e.g. the user switched the + // editing workspace (making the old source a valid target, or the old target + // the new source) while a target was already picked. + useEffect(() => { + if (targetId && !targets.some((w) => w.id === targetId)) { + setTargetId(null); + } + }, [targetId, targets]); + + const onOpenChange = useCallback((next: boolean) => { + setOpen(next); + // Reset the selection when the dialog closes (ESC, backdrop, or Cancel) so + // reopening doesn't pre-select a previously chosen target. + if (!next) setTargetId(null); + }, []); + + const onCopy = useCallback(async () => { + if (!targetId) return; + const targetName = workspaceName(workspaces, targetId); + setCopying(true); + const pendingId = toast({ + variant: "loading", + description: `Copying ${label} config to ${targetName}…`, + }); + try { + await copyIntegrationConfig(slug, sourceWorkspaceId, targetId); + updateToast(pendingId, { + variant: "success", + description: `Copied ${label} config to ${targetName}.`, + }); + setOpen(false); + setTargetId(null); + } catch (err) { + updateToast(pendingId, { + variant: "error", + description: `Failed to copy config: ${String(err)}`, + }); + } finally { + setCopying(false); + } + }, [slug, sourceWorkspaceId, targetId, workspaces, label, toast, updateToast]); + + if (targets.length === 0) return null; + + return ( + + + + + + Copy config to another workspace + + void onCopy()} + onCancel={() => onOpenChange(false)} + /> + + ); +} diff --git a/apps/web/components/integrations/integration-copy-config.test.ts b/apps/web/components/integrations/integration-copy-config.test.ts new file mode 100644 index 0000000000..2632ed706c --- /dev/null +++ b/apps/web/components/integrations/integration-copy-config.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { integrationFromPathname, integrationLabel } from "./integration-copy-config"; + +describe("integrationFromPathname", () => { + it("resolves each supported integration slug", () => { + expect(integrationFromPathname("/settings/integrations/slack")).toBe("slack"); + expect(integrationFromPathname("/settings/integrations/jira")).toBe("jira"); + expect(integrationFromPathname("/settings/integrations/linear")).toBe("linear"); + expect(integrationFromPathname("/settings/integrations/sentry")).toBe("sentry"); + expect(integrationFromPathname("/settings/integrations/github")).toBe("github"); + }); + + it("ignores trailing path segments and query-like suffixes", () => { + expect(integrationFromPathname("/settings/integrations/slack/watchers")).toBe("slack"); + }); + + it("returns null for non-integration or unknown pages", () => { + expect(integrationFromPathname("/settings/integrations")).toBeNull(); + expect(integrationFromPathname("/settings/agents")).toBeNull(); + expect(integrationFromPathname("/settings/integrations/unknown")).toBeNull(); + }); +}); + +describe("integrationLabel", () => { + it("uses the app's canonical spelling", () => { + expect(integrationLabel("github")).toBe("GitHub"); + expect(integrationLabel("jira")).toBe("Jira"); + expect(integrationLabel("slack")).toBe("Slack"); + expect(integrationLabel("linear")).toBe("Linear"); + expect(integrationLabel("sentry")).toBe("Sentry"); + }); +}); diff --git a/apps/web/components/integrations/integration-copy-config.ts b/apps/web/components/integrations/integration-copy-config.ts new file mode 100644 index 0000000000..0dbe44f486 --- /dev/null +++ b/apps/web/components/integrations/integration-copy-config.ts @@ -0,0 +1,63 @@ +import { copySlackConfig } from "@/lib/api/domains/slack-api"; +import { copyJiraConfig } from "@/lib/api/domains/jira-api"; +import { copyLinearConfig } from "@/lib/api/domains/linear-api"; +import { copySentryConfig } from "@/lib/api/domains/sentry-api"; +import { copyGitHubWorkspaceSettings } from "@/lib/api/domains/github-api"; + +// IntegrationSlug is the set of integration settings pages that support copying +// their per-workspace config to another workspace. +export type IntegrationSlug = "slack" | "jira" | "linear" | "sentry" | "github"; + +// integrationLabels maps each slug to how the rest of the app spells it. +const integrationLabels: Record = { + slack: "Slack", + jira: "Jira", + linear: "Linear", + sentry: "Sentry", + github: "GitHub", +}; + +// integrationFromPathname returns the integration slug for a settings pathname +// like /settings/integrations/slack, or null when the current page is not a +// copyable integration page. +export function integrationFromPathname(pathname: string): IntegrationSlug | null { + const match = pathname.match(/^\/settings\/integrations\/([^/]+)/); + const slug = match?.[1]; + if (slug && Object.prototype.hasOwnProperty.call(integrationLabels, slug)) { + return slug as IntegrationSlug; + } + return null; +} + +// integrationLabel returns the display name for a slug. +export function integrationLabel(slug: IntegrationSlug): string { + return integrationLabels[slug]; +} + +// copyIntegrationConfig copies the config for the given integration from +// sourceWorkspaceId to targetWorkspaceId. GitHub copies settings only (its auth +// is install-wide); every other integration copies config + credentials. +export async function copyIntegrationConfig( + slug: IntegrationSlug, + sourceWorkspaceId: string, + targetWorkspaceId: string, +): Promise { + const options = { workspaceId: sourceWorkspaceId }; + switch (slug) { + case "slack": + await copySlackConfig(targetWorkspaceId, options); + return; + case "jira": + await copyJiraConfig(targetWorkspaceId, options); + return; + case "linear": + await copyLinearConfig(targetWorkspaceId, options); + return; + case "sentry": + await copySentryConfig(targetWorkspaceId, options); + return; + case "github": + await copyGitHubWorkspaceSettings(targetWorkspaceId, options); + return; + } +} diff --git a/apps/web/components/integrations/workspace-scoped-section.tsx b/apps/web/components/integrations/workspace-scoped-section.tsx index bad2c13bdc..0d8223b06f 100644 --- a/apps/web/components/integrations/workspace-scoped-section.tsx +++ b/apps/web/components/integrations/workspace-scoped-section.tsx @@ -1,38 +1,23 @@ "use client"; -import { useState, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { Card, CardContent } from "@kandev/ui/card"; import { useAppStore } from "@/components/state-provider"; -import { WorkspaceSwitcher } from "@/components/task/workspace-switcher"; type WorkspaceScopedSectionProps = { - label?: string; emptyMessage?: string; - showSelector?: boolean; children: (workspaceId: string) => ReactNode; }; -// WorkspaceScopedSection wraps watcher / per-workspace settings under a -// workspace selector so the install-wide integration settings page can still -// surface things that genuinely scope to one workspace at a time. The selector -// defaults to the active workspace from the global store; an explicit user -// override survives until the workspace list no longer contains it. -export function WorkspaceScopedSection({ - label = "Workspace", - emptyMessage, - showSelector = true, - children, -}: WorkspaceScopedSectionProps) { +// WorkspaceScopedSection renders per-workspace integration settings for the +// workspace currently selected in the top-right settings switcher. The switcher +// (settings-layout-client) is the single source of truth for which workspace is +// being edited, so this component just reads the active workspace from the +// store — it no longer renders its own selector. +export function WorkspaceScopedSection({ emptyMessage, children }: WorkspaceScopedSectionProps) { const workspaces = useAppStore((s) => s.workspaces.items); const activeId = useAppStore((s) => s.workspaces.activeId); - const [override, setOverride] = useState(null); - - // Derive the effective selection at render time: honour an in-bounds user - // override, otherwise fall back to the active workspace (or the first one). - // This avoids setState-in-effect when the workspace list hydrates after - // first render. - const overrideValid = override && workspaces.some((w) => w.id === override); - const selected = overrideValid ? override : (activeId ?? workspaces[0]?.id ?? null); + const selected = activeId ?? workspaces[0]?.id ?? null; if (workspaces.length === 0) { return ( @@ -44,19 +29,5 @@ export function WorkspaceScopedSection({ ); } - return ( -
- {showSelector && ( -
- {label} - -
- )} - {selected ? children(selected) : null} -
- ); + return
{selected ? children(selected) : null}
; } diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index 5988b5aab6..8e2549cfe5 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -1,12 +1,14 @@ "use client"; -import { useCallback, useMemo } from "react"; -import { usePathname } from "@/lib/routing/client-router"; +import { useCallback, useEffect, useMemo } from "react"; +import { usePathname, useRouter, useSearchParams } from "@/lib/routing/client-router"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { PageTopbar } from "@/components/page-topbar"; import { useAppStore } from "@/components/state-provider"; import { WorkspaceSwitcher } from "@/components/task/workspace-switcher"; import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; +import { IntegrationCopyConfigMenu } from "@/components/integrations/integration-copy-config-menu"; +import { integrationFromPathname } from "@/components/integrations/integration-copy-config"; const WORKSPACE_SYNC_FAILED_KEY = "kandev:settings:integration-workspace:sync-failed:v1"; @@ -110,11 +112,45 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode } ); } +// useWorkspaceQueryParamSync keeps the top-right switcher and the `?workspace` +// query param in agreement. On load (or when a shared deep link points at a +// valid workspace), it adopts the query param as the active workspace without +// persisting it as the user's global default. setWorkspaceParam writes the +// param back when the user picks a workspace. +function useWorkspaceQueryParamSync( + workspaces: Array<{ id: string }>, + activeId: string | null, + setActiveWorkspace: (id: string) => void, +) { + const router = useRouter(); + const searchParams = useSearchParams(); + const paramWorkspace = searchParams.get("workspace"); + + useEffect(() => { + if (!paramWorkspace || paramWorkspace === activeId) return; + if (!workspaces.some((w) => w.id === paramWorkspace)) return; + setActiveWorkspace(paramWorkspace); + }, [paramWorkspace, activeId, workspaces, setActiveWorkspace]); + + const setWorkspaceParam = useCallback( + (workspaceId: string) => { + const next = new URLSearchParams(window.location.search); + next.set("workspace", workspaceId); + router.replace(`${window.location.pathname}?${next.toString()}`, { scroll: false }); + }, + [router], + ); + + return setWorkspaceParam; +} + function IntegrationWorkspaceSwitcher() { + const pathname = usePathname(); const workspaces = useAppStore((s) => s.workspaces.items); const activeId = useAppStore((s) => s.workspaces.activeId); const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); const selected = activeId ?? workspaces[0]?.id ?? null; + const integration = integrationFromPathname(pathname); const persistWorkspace = useMemo( () => createQueuedUserSettingsSync(WORKSPACE_SYNC_FAILED_KEY, (workspaceId) => ({ @@ -122,20 +158,32 @@ function IntegrationWorkspaceSwitcher() { })), [], ); + const setWorkspaceParam = useWorkspaceQueryParamSync(workspaces, activeId, setActiveWorkspace); const onSelect = useCallback( (workspaceId: string) => { setActiveWorkspace(workspaceId); + setWorkspaceParam(workspaceId); void persistWorkspace(workspaceId); }, - [persistWorkspace, setActiveWorkspace], + [persistWorkspace, setActiveWorkspace, setWorkspaceParam], ); if (workspaces.length === 0) return null; return ( -
+
+ + Editing workspace + + {integration && selected ? ( + + ) : null}
); } diff --git a/apps/web/components/system-metrics/topbar-metrics.tsx b/apps/web/components/system-metrics/topbar-metrics.tsx index 028ab00d9d..bb91bbe62e 100644 --- a/apps/web/components/system-metrics/topbar-metrics.tsx +++ b/apps/web/components/system-metrics/topbar-metrics.tsx @@ -32,7 +32,7 @@ export function TopbarMetrics({ activeSessionId }: TopbarMetricsProps) { const sources = selectSources(snapshot?.sources ?? [], activeSessionId); if (sources.length === 0) { return ( -
+
Metrics
@@ -83,7 +83,7 @@ function SourceMetrics({ const metrics = source.metrics.slice(0, 4); return ( -
+
{showSource ? : null} {metrics.length > 0 ? ( metrics.map((metric) => ( diff --git a/apps/web/components/task/chat/message-list-shared.test.tsx b/apps/web/components/task/chat/message-list-shared.test.tsx index 78ca1670d3..ef9ba671b8 100644 --- a/apps/web/components/task/chat/message-list-shared.test.tsx +++ b/apps/web/components/task/chat/message-list-shared.test.tsx @@ -197,6 +197,21 @@ describe("getConversationLoadingState", () => { }), ).toEqual({ isInitialLoading: true, showLoadingState: false }); }); + + it("suppresses loading for CREATED sessions even when a synthetic task-description message is present", () => { + // Prepare-only launches keep the session in CREATED with the "Start agent" + // button as the primary CTA. useProcessedMessages injects a synthetic + // task-description message so messagesCount is 1, but there is nothing to + // load — the spinner would clash with the button. + expect( + getConversationLoadingState({ + messagesLoading: true, + messagesCount: 1, + isWorking: false, + sessionState: "CREATED", + }), + ).toEqual({ isInitialLoading: false, showLoadingState: false }); + }); }); describe("MessageListStatus", () => { diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index 5eb6f6289d..e9628ad516 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -68,6 +68,14 @@ export function getConversationLoadingState(params: { params.sessionState === "FAILED" || params.sessionState === "COMPLETED" || params.sessionState === "CANCELLED"; + // CREATED sessions are prepare-only: the agent hasn't been started yet, so + // there is no conversation to load and the "Start agent" button is the + // primary CTA. Suppress the spinner unconditionally to avoid a misleading + // overlay racing with that button (synthetic task-description counts as a + // message and would otherwise trip the messagesCount > 0 branch). + if (params.sessionState === "CREATED") { + return { isInitialLoading, showLoadingState: false }; + } return { isInitialLoading, showLoadingState: diff --git a/apps/web/components/task/workspace-switcher.tsx b/apps/web/components/task/workspace-switcher.tsx index 7cf79da5ed..0da732646c 100644 --- a/apps/web/components/task/workspace-switcher.tsx +++ b/apps/web/components/task/workspace-switcher.tsx @@ -43,7 +43,7 @@ export function WorkspaceSwitcher({ type="button" title="Switch Workspace" className={cn( - "group flex h-8 items-center gap-1.5 rounded-md border bg-background px-2.5 text-sm font-medium cursor-pointer", + "group flex h-8 min-w-0 items-center gap-1.5 rounded-md border bg-background px-2.5 text-sm font-medium cursor-pointer", "text-muted-foreground hover:text-foreground transition-colors duration-150", "focus:outline-none focus-visible:ring-2 focus-visible:ring-ring", )} diff --git a/apps/web/e2e/pages/linear-settings-page.ts b/apps/web/e2e/pages/linear-settings-page.ts index bc2214c2d8..652861c0c2 100644 --- a/apps/web/e2e/pages/linear-settings-page.ts +++ b/apps/web/e2e/pages/linear-settings-page.ts @@ -6,7 +6,11 @@ export class LinearSettingsPage { readonly saveButton: Locator; readonly deleteButton: Locator; readonly statusBanner: Locator; + readonly switcher: Locator; readonly workspaceTrigger: Locator; + readonly copyConfigTrigger: Locator; + readonly copyConfigTarget: Locator; + readonly copyConfigConfirm: Locator; constructor(private page: Page) { this.secretInput = page.getByTestId("linear-secret-input"); @@ -14,13 +18,15 @@ 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"); + this.switcher = page.getByTestId("integration-workspace-switcher"); + this.workspaceTrigger = this.switcher.getByTitle("Switch Workspace"); + this.copyConfigTrigger = page.getByTestId("integration-copy-config-trigger"); + this.copyConfigTarget = page.getByTestId("integration-copy-config-target"); + this.copyConfigConfirm = page.getByTestId("integration-copy-config-confirm"); } - async goto() { - await this.page.goto(`/settings/integrations/linear`); + async goto(query = "") { + await this.page.goto(`/settings/integrations/linear${query}`); await this.secretInput.waitFor({ state: "visible" }); } } diff --git a/apps/web/e2e/tests/integrations/linear-settings.spec.ts b/apps/web/e2e/tests/integrations/linear-settings.spec.ts index ec5587142f..85a1a51bd8 100644 --- a/apps/web/e2e/tests/integrations/linear-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/linear-settings.spec.ts @@ -66,6 +66,72 @@ test.describe("Linear settings", () => { await expect(testPage.getByText(/leave blank to keep the current value/i)).toHaveCount(0); }); + test("a ?workspace deep link adopts that workspace on load", async ({ testPage, apiClient }) => { + const other = await apiClient.createWorkspace("Linear Deep Link Workspace"); + // Seed the secondary workspace so the deep link lands on a configured row, + // proving the query param — not the user's global default — drove selection. + await apiClient.setLinearConfig({ secret: "lin_api_deeplink", workspaceId: other.id }); + + const settings = new LinearSettingsPage(testPage); + await settings.goto(`?workspace=${other.id}`); + + await expect(settings.switcher).toContainText("Editing workspace"); + await expect(settings.switcher).toContainText(other.name); + // The seeded row loads, confirming the deep link scoped the form to `other`. + await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible(); + }); + + test("selecting a workspace writes it to the ?workspace query param", async ({ + testPage, + apiClient, + }) => { + const other = await apiClient.createWorkspace("Linear Query Param Workspace"); + + const settings = new LinearSettingsPage(testPage); + await settings.goto(); + + await settings.workspaceTrigger.click(); + await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click(); + + await expect(testPage).toHaveURL(new RegExp(`[?&]workspace=${other.id}(&|$)`)); + await expect(settings.switcher).toContainText(other.name); + }); + + test("copy config duplicates the credentials to another workspace", async ({ + testPage, + apiClient, + }) => { + const other = await apiClient.createWorkspace("Linear Copy Target Workspace"); + await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]); + + const settings = new LinearSettingsPage(testPage); + await settings.goto(); + + // Configure the source (default) workspace. + await settings.secretInput.fill("lin_api_source"); + await settings.saveButton.click(); + await expect(settings.deleteButton).toBeVisible(); + + // Copy the config to the empty target workspace via the dialog. + await settings.copyConfigTrigger.click(); + await settings.copyConfigTarget.click(); + await testPage.getByRole("option", { name: new RegExp(other.name) }).click(); + await settings.copyConfigConfirm.click(); + await expect(testPage.getByText(/Copied Linear config/i)).toBeVisible(); + + // The target workspace should now report a saved secret via the API. + const res = await apiClient.rawRequest("GET", `/api/v1/linear/config?workspace_id=${other.id}`); + expect(res.status).toBe(200); + const cfg = (await res.json()) as { hasSecret?: boolean }; + expect(cfg.hasSecret).toBe(true); + + // Switching to the target in the UI shows the copied credentials loaded. + await settings.workspaceTrigger.click(); + await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click(); + await expect(settings.deleteButton).toBeVisible(); + await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible(); + }); + test("test connection surfaces inline success and failure", async ({ testPage, apiClient }) => { const settings = new LinearSettingsPage(testPage); await settings.goto(); diff --git a/apps/web/lib/api/domains/github-api.test.ts b/apps/web/lib/api/domains/github-api.test.ts index ddb853d0ed..425dd6fe3c 100644 --- a/apps/web/lib/api/domains/github-api.test.ts +++ b/apps/web/lib/api/domains/github-api.test.ts @@ -5,6 +5,7 @@ vi.mock("@/lib/config", () => ({ })); import { + copyGitHubWorkspaceSettings, createTaskPR, fetchAccessibleRepos, fetchIssueInfo, @@ -317,3 +318,18 @@ describe("task CI automation options", () => { }); }); }); + +describe("copyGitHubWorkspaceSettings", () => { + it("POSTs targetWorkspaceId to /workspace-settings/copy scoped to the source", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ workspace_id: "ws-dst" })); + + await copyGitHubWorkspaceSettings("ws-dst", { workspaceId: "ws-src" }); + + const call = fetchSpy.mock.calls.at(-1); + expect(String(call?.[0])).toBe( + "http://api.test/api/v1/github/workspace-settings/copy?workspace_id=ws-src", + ); + expect(call?.[1]?.method).toBe("POST"); + expect(JSON.parse(String(call?.[1]?.body))).toEqual({ targetWorkspaceId: "ws-dst" }); + }); +}); diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index 0b9164ae0f..db3f448bb8 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -654,6 +654,30 @@ export async function updateGitHubWorkspaceSettings( }); } +// copyGitHubWorkspaceSettings copies the per-workspace GitHub settings (repo +// scope + presets) from the source workspace (options.workspaceId) to +// targetWorkspaceId. GitHub auth is install-wide, so there are no credentials to +// copy. The signature mirrors the other integrations' copy helpers +// (targetWorkspaceId first, source via options) for consistency. +export async function copyGitHubWorkspaceSettings( + targetWorkspaceId: string, + options: { workspaceId: string } & ApiRequestOptions, +) { + const { workspaceId: sourceWorkspaceId, ...requestOptions } = options; + const query = new URLSearchParams({ workspace_id: sourceWorkspaceId }); + return fetchJson( + `/api/v1/github/workspace-settings/copy?${query.toString()}`, + { + ...requestOptions, + init: { + ...(requestOptions.init ?? {}), + method: "POST", + body: JSON.stringify({ targetWorkspaceId }), + }, + }, + ); +} + // 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 5d80e95ea8..6d4fb9fc5d 100644 --- a/apps/web/lib/api/domains/jira-api.ts +++ b/apps/web/lib/api/domains/jira-api.ts @@ -60,6 +60,15 @@ export async function testJiraConnection( }); } +// copyJiraConfig copies the Jira config + credential from the workspace in +// options (source) to targetWorkspaceId. +export async function copyJiraConfig(targetWorkspaceId: string, options?: WorkspaceApiOptions) { + return fetchJson(withWorkspace(`/api/v1/jira/config/copy`, options), { + ...requestOptions(options), + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ targetWorkspaceId }) }, + }); +} + export async function listJiraProjects(options?: WorkspaceApiOptions) { return fetchJson<{ projects: JiraProject[] }>( withWorkspace(`/api/v1/jira/projects`, options), diff --git a/apps/web/lib/api/domains/linear-api.test.ts b/apps/web/lib/api/domains/linear-api.test.ts index b5d2be6c03..142686b16e 100644 --- a/apps/web/lib/api/domains/linear-api.test.ts +++ b/apps/web/lib/api/domains/linear-api.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/config", () => ({ })); import { + copyLinearConfig, createLinearIssueWatch, deleteLinearConfig, deleteLinearIssueWatch, @@ -108,6 +109,17 @@ describe("setLinearConfig", () => { }); }); +describe("copyLinearConfig", () => { + it("POSTs targetWorkspaceId to /config/copy scoped to the source workspace", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ authMethod: AUTH })); + await copyLinearConfig("ws-dst", { workspaceId: "ws-src" }); + const { url, init } = lastCall(); + expect(url).toBe(`${CONFIG_URL}/copy?workspace_id=ws-src`); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ targetWorkspaceId: "ws-dst" }); + }); +}); + describe("deleteLinearConfig", () => { it("issues DELETE on /config", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true })); diff --git a/apps/web/lib/api/domains/linear-api.ts b/apps/web/lib/api/domains/linear-api.ts index 0814e22bf6..f73ff9bed1 100644 --- a/apps/web/lib/api/domains/linear-api.ts +++ b/apps/web/lib/api/domains/linear-api.ts @@ -68,6 +68,15 @@ export async function testLinearConnection( ); } +// copyLinearConfig copies the Linear config + credential from the workspace in +// options (source) to targetWorkspaceId. +export async function copyLinearConfig(targetWorkspaceId: string, options?: WorkspaceApiOptions) { + return fetchJson(withWorkspace(`/api/v1/linear/config/copy`, options), { + ...requestOptions(options), + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ targetWorkspaceId }) }, + }); +} + export async function listLinearTeams(options?: WorkspaceApiOptions) { return fetchJson<{ teams: LinearTeam[] }>( withWorkspace(`/api/v1/linear/teams`, options), diff --git a/apps/web/lib/api/domains/sentry-api.ts b/apps/web/lib/api/domains/sentry-api.ts index d1a0732d8a..09d8b3a85a 100644 --- a/apps/web/lib/api/domains/sentry-api.ts +++ b/apps/web/lib/api/domains/sentry-api.ts @@ -75,6 +75,15 @@ export async function testSentryConnection( ); } +// copySentryConfig copies the Sentry config + credential from the workspace in +// options (source) to targetWorkspaceId. +export async function copySentryConfig(targetWorkspaceId: string, options?: WorkspaceApiOptions) { + return fetchJson(withWorkspace(`/api/v1/sentry/config/copy`, options), { + ...requestOptions(options), + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ targetWorkspaceId }) }, + }); +} + export async function listSentryOrganizations(options?: WorkspaceApiOptions) { return fetchJson<{ organizations: SentryOrganization[] }>( withWorkspace(`/api/v1/sentry/organizations`, options), diff --git a/apps/web/lib/api/domains/slack-api.ts b/apps/web/lib/api/domains/slack-api.ts index 4d3748d2d1..86d2a799cf 100644 --- a/apps/web/lib/api/domains/slack-api.ts +++ b/apps/web/lib/api/domains/slack-api.ts @@ -54,3 +54,12 @@ export async function testSlackConnection( init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, }); } + +// copySlackConfig copies the Slack config + credentials from the workspace in +// options (source) to targetWorkspaceId. +export async function copySlackConfig(targetWorkspaceId: string, options?: WorkspaceApiOptions) { + return fetchJson(withWorkspace(`/api/v1/slack/config/copy`, options), { + ...requestOptions(options), + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ targetWorkspaceId }) }, + }); +}