package linear import ( "context" "os" "time" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" ) // mockEnvVar gates the in-memory mock client used in E2E tests. const mockEnvVar = "KANDEV_MOCK_LINEAR" // MockEnabled reports whether KANDEV_MOCK_LINEAR is set to "true". func MockEnabled() bool { return os.Getenv(mockEnvVar) == "true" } // Provide builds the Linear service. eventBus may be nil — used in tests and // during early boot before the bus is ready; the service falls back to a // no-op publish path. Cleanup is a no-op today — the service holds only // in-memory client caches — but the signature mirrors other integration // providers so callers can register it uniformly. // // When KANDEV_MOCK_LINEAR=true, the service is wired to a process-wide // MockClient and the same instance is exposed via Service.MockClient() so the // E2E mock controller can drive it. func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) { store, err := NewStore(writer, reader) if err != nil { return nil, nil, err } migrateLegacySecret(store, secrets, log) clientFn := DefaultClientFactory var mock *MockClient if MockEnabled() { mock = NewMockClient() clientFn = MockClientFactory(mock) log.Info("linear: using in-memory mock client (KANDEV_MOCK_LINEAR=true)") } svc := NewService(store, secrets, clientFn, log) svc.mockClient = mock if eventBus != nil { svc.SetEventBus(eventBus) } cleanup := func() error { return nil } return svc, cleanup, nil } // migrateLegacySecret copies the per-workspace API key from the pre-singleton // secret key onto the new install-wide key, then deletes the legacy entry. // Best-effort: any error is logged and ignored — the worst case is the user // re-pastes their key in the settings UI. func migrateLegacySecret(store *Store, secrets SecretStore, log *logger.Logger) { source := store.MigratedFromWorkspace() if source == "" || secrets == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if exists, err := secrets.Exists(ctx, SecretKey); err == nil && exists { return } legacyKey := LegacySecretKeyForWorkspace(source) value, err := secrets.Reveal(ctx, legacyKey) if err != nil || value == "" { return } if err := secrets.Set(ctx, SecretKey, "Linear API key", value); err != nil { log.Warn("linear: legacy secret migration failed", zap.Error(err)) return } if err := secrets.Delete(ctx, legacyKey); err != nil { log.Warn("linear: legacy secret cleanup failed", zap.Error(err)) } } // RegisterMockRoutes mounts the mock control routes when the service was built // with a MockClient. No-op otherwise. func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { mock := svc.MockClient() if mock == nil { return } NewMockController(mock, svc.Store(), log).RegisterRoutes(router) log.Info("registered Linear mock control endpoints") }