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. Workspace // automation and personal authentication are intentionally excluded. Watchers // are also 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 err := s.authorizeWorkspaceAccess(ctx, sourceWorkspaceID); err != nil { return nil, err } if err := s.authorizeWorkspaceAccess(ctx, targetWorkspaceID); err != nil { return nil, err } 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, TaskGitCredentialsMode: source.TaskGitCredentialsMode, 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 }