feat(integrations): single workspace switcher with deep links and copy-config dialog (#1616)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'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",
|
||||
)}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }) },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user