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 ---