feat(integrations): single workspace switcher with deep links and copy-config dialog (#1616)

This commit is contained in:
Carlos Florêncio
2026-07-08 10:06:50 +01:00
committed by GitHub
parent c5a3d6a114
commit 164b80ac54
34 changed files with 1590 additions and 51 deletions
@@ -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
+83
View File
@@ -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
}
+117
View File
@@ -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)
}
}
+74
View File
@@ -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
}
+69
View File
@@ -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)
}
}
+37
View File
@@ -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
+71
View File
@@ -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
}
+93
View File
@@ -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)
}
}
+37
View File
@@ -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
+72
View File
@@ -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
}
+64
View File
@@ -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)
}
}
+37
View File
@@ -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"
+79
View File
@@ -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
}
+131
View File
@@ -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)
}
}
+37
View File
@@ -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 ---
@@ -312,7 +312,7 @@ export function GitHubIntegrationPage() {
<TooltipProvider>
<div className="space-y-8">
<GitHubConnectionSection />
<WorkspaceScopedSection showSelector={false}>
<WorkspaceScopedSection>
{(ws) => <PerWorkspaceSection key={ws} workspaceId={ws} />}
</WorkspaceScopedSection>
</div>
@@ -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 (
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Copy {label} configuration</DialogTitle>
<DialogDescription>
Copy the {label} settings
{slug === "github" ? "" : " and credentials"} from{" "}
<span className="font-medium text-foreground">{sourceName}</span> into another workspace.
This overwrites the target workspace&apos;s current {label} configuration. Watchers and
automations are not copied.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="copy-config-target" className="text-xs font-medium text-muted-foreground">
Target workspace
</label>
<Select value={targetId ?? undefined} onValueChange={setTargetId}>
<SelectTrigger
id="copy-config-target"
className="w-full"
data-testid="integration-copy-config-target"
>
<SelectValue placeholder="Select a workspace…" />
</SelectTrigger>
<SelectContent>
{targets.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="cursor-pointer" onClick={onCancel}>
Cancel
</Button>
<Button
type="button"
className="cursor-pointer"
disabled={!targetId || copying}
onClick={onCopy}
data-dialog-default-action
data-testid="integration-copy-config-confirm"
>
<IconCopy className="h-4 w-4" />
Copy config
</Button>
</DialogFooter>
</DialogContent>
);
}
// 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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-lg"
className="cursor-pointer"
aria-label="Copy config to another workspace"
data-testid="integration-copy-config-trigger"
onClick={() => setOpen(true)}
>
<IconCopy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy config to another workspace</TooltipContent>
</Tooltip>
<CopyConfigDialogBody
label={label}
slug={slug}
sourceName={sourceName}
targets={targets}
targetId={targetId}
setTargetId={setTargetId}
copying={copying}
onCopy={() => void onCopy()}
onCancel={() => onOpenChange(false)}
/>
</Dialog>
);
}
@@ -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");
});
});
@@ -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<IntegrationSlug, string> = {
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<void> {
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;
}
}
@@ -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<string | null>(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 (
<div className="space-y-3">
{showSelector && (
<div className="flex items-center gap-3 text-sm" data-testid="workspace-scoped-selector">
<span className="text-muted-foreground">{label}</span>
<WorkspaceSwitcher
workspaces={workspaces}
activeWorkspaceId={selected}
onSelect={setOverride}
/>
</div>
)}
{selected ? children(selected) : null}
</div>
);
return <div className="space-y-3">{selected ? children(selected) : null}</div>;
}
@@ -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<string>(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 (
<div data-testid="integration-workspace-switcher">
<div className="flex min-w-0 items-center gap-2" data-testid="integration-workspace-switcher">
<span className="hidden text-xs whitespace-nowrap text-muted-foreground sm:inline">
Editing workspace
</span>
<WorkspaceSwitcher workspaces={workspaces} activeWorkspaceId={selected} onSelect={onSelect} />
{integration && selected ? (
<IntegrationCopyConfigMenu
slug={integration}
sourceWorkspaceId={selected}
workspaces={workspaces}
/>
) : null}
</div>
);
}
@@ -32,7 +32,7 @@ export function TopbarMetrics({ activeSessionId }: TopbarMetricsProps) {
const sources = selectSources(snapshot?.sources ?? [], activeSessionId);
if (sources.length === 0) {
return (
<div className="flex h-8 items-center gap-1 rounded px-2 text-xs text-muted-foreground md:h-7 md:border md:border-border">
<div className="flex h-8 items-center gap-1 rounded px-2 text-xs text-muted-foreground md:border md:border-border">
<IconActivity className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Metrics</span>
</div>
@@ -83,7 +83,7 @@ function SourceMetrics({
const metrics = source.metrics.slice(0, 4);
return (
<div className="flex h-7 max-w-[220px] items-center gap-1 overflow-hidden rounded border border-border px-1.5 text-xs">
<div className="flex h-8 max-w-[220px] items-center gap-1 overflow-hidden rounded border border-border px-1.5 text-xs">
{showSource ? <SourceBadge source={source} updatedAt={updatedAt} /> : null}
{metrics.length > 0 ? (
metrics.map((metric) => (
@@ -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", () => {
@@ -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:
@@ -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",
)}
+11 -5
View File
@@ -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" });
}
}
@@ -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();
@@ -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" });
});
});
+24
View File
@@ -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<GitHubWorkspaceSettings>(
`/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 });
+9
View File
@@ -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<JiraConfig>(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),
@@ -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 }));
+9
View File
@@ -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<LinearConfig>(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),
+9
View File
@@ -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<SentryConfig>(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),
+9
View File
@@ -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<SlackConfig>(withWorkspace(`/api/v1/slack/config/copy`, options), {
...requestOptions(options),
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ targetWorkspaceId }) },
});
}