diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index cbc9fd6227..d0a14b14ee 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -128,7 +128,15 @@ func main() { defer taskRepo.Close() log.Info("SQLite database initialized", zap.String("db_path", dbPath)) - taskSvc := taskservice.NewService(taskRepo, eventBus, log) + taskSvc := taskservice.NewService( + taskRepo, + eventBus, + log, + taskservice.RepositoryDiscoveryConfig{ + Roots: cfg.RepositoryDiscovery.Roots, + MaxDepth: cfg.RepositoryDiscovery.MaxDepth, + }, + ) log.Info("Task Service initialized") // Create SQLite message store for ACP log persistence @@ -206,6 +214,7 @@ func main() { workspaceController := taskcontroller.NewWorkspaceController(taskSvc) boardController := taskcontroller.NewBoardController(taskSvc) taskController := taskcontroller.NewTaskController(taskSvc) + repositoryController := taskcontroller.NewRepositoryController(taskSvc) orchestratorWSHandlers := orchestratorwshandlers.NewHandlers(orchestratorSvc, log) orchestratorWSHandlers.SetACPHandler(acpHandler) @@ -386,6 +395,7 @@ func main() { taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) + taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) log.Info("Registered Task Service handlers (HTTP + WebSocket)") // Health check (simple HTTP for load balancers/monitoring) diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index f7e7591a66..7040822217 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -12,12 +12,13 @@ import ( // Config holds all configuration sections for Kandev. type Config struct { - Server ServerConfig `mapstructure:"server"` - Database DatabaseConfig `mapstructure:"database"` - NATS NATSConfig `mapstructure:"nats"` - Docker DockerConfig `mapstructure:"docker"` - Auth AuthConfig `mapstructure:"auth"` - Logging LoggingConfig `mapstructure:"logging"` + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + NATS NATSConfig `mapstructure:"nats"` + Docker DockerConfig `mapstructure:"docker"` + Auth AuthConfig `mapstructure:"auth"` + Logging LoggingConfig `mapstructure:"logging"` + RepositoryDiscovery RepositoryDiscoveryConfig `mapstructure:"repositoryDiscovery"` } // ServerConfig holds HTTP server configuration. @@ -70,6 +71,12 @@ type LoggingConfig struct { OutputPath string `mapstructure:"outputPath"` } +// RepositoryDiscoveryConfig holds configuration for local repository scanning. +type RepositoryDiscoveryConfig struct { + Roots []string `mapstructure:"roots"` + MaxDepth int `mapstructure:"maxDepth"` +} + // ReadTimeoutDuration returns the read timeout as a time.Duration. func (s *ServerConfig) ReadTimeoutDuration() time.Duration { return time.Duration(s.ReadTimeout) * time.Second @@ -124,6 +131,10 @@ func setDefaults(v *viper.Viper) { v.SetDefault("logging.level", "info") v.SetDefault("logging.format", "json") v.SetDefault("logging.outputPath", "stdout") + + // Repository discovery defaults + v.SetDefault("repositoryDiscovery.roots", []string{}) + v.SetDefault("repositoryDiscovery.maxDepth", 5) } // Load reads configuration from environment variables, config file, and defaults. @@ -221,6 +232,10 @@ func validate(cfg *Config) error { errs = append(errs, "logging.format must be one of: json, text") } + if cfg.RepositoryDiscovery.MaxDepth <= 0 { + errs = append(errs, "repositoryDiscovery.maxDepth must be positive") + } + if len(errs) > 0 { return fmt.Errorf("%s", strings.Join(errs, "; ")) } @@ -241,4 +256,4 @@ func generateDevSecret() string { // Use a fixed dev secret with a warning prefix // In production, users should set KANDEV_AUTH_JWTSECRET return "dev-secret-change-in-production-" + fmt.Sprintf("%d", time.Now().UnixNano()) -} \ No newline at end of file +} diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index c911ecc9f9..9b2296eb1a 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -35,6 +35,20 @@ const ( CommentAdded = "comment.added" ) +// Event types for repositories +const ( + RepositoryCreated = "repository.created" + RepositoryUpdated = "repository.updated" + RepositoryDeleted = "repository.deleted" +) + +// Event types for repository scripts +const ( + RepositoryScriptCreated = "repository.script.created" + RepositoryScriptUpdated = "repository.script.updated" + RepositoryScriptDeleted = "repository.script.deleted" +) + // Event types for agents const ( AgentStarted = "agent.started" diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 696a99fb2f..dc1bd03012 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -34,6 +34,12 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.ColumnCreated, ws.ActionColumnCreated) b.subscribe(eventBus, events.ColumnUpdated, ws.ActionColumnUpdated) b.subscribe(eventBus, events.ColumnDeleted, ws.ActionColumnDeleted) + b.subscribe(eventBus, events.RepositoryCreated, ws.ActionRepositoryCreated) + b.subscribe(eventBus, events.RepositoryUpdated, ws.ActionRepositoryUpdated) + b.subscribe(eventBus, events.RepositoryDeleted, ws.ActionRepositoryDeleted) + b.subscribe(eventBus, events.RepositoryScriptCreated, ws.ActionRepositoryScriptCreated) + b.subscribe(eventBus, events.RepositoryScriptUpdated, ws.ActionRepositoryScriptUpdated) + b.subscribe(eventBus, events.RepositoryScriptDeleted, ws.ActionRepositoryScriptDeleted) go func() { <-ctx.Done() diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 0a5694292c..3f134139eb 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -61,7 +61,7 @@ func NewTestServer(t *testing.T) *TestServer { taskRepo := repository.NewMemoryRepository() // Initialize task service - taskSvc := taskservice.NewService(taskRepo, eventBus, log) + taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) // Create WebSocket gateway gateway := gateways.NewGateway(log) @@ -957,7 +957,7 @@ func NewTestServerWithACP(t *testing.T) *TestServerWithACP { taskRepo := repository.NewMemoryRepository() // Initialize task service - taskSvc := taskservice.NewService(taskRepo, eventBus, log) + taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) // Create ACP message store and handler messageStore := acp.NewMemoryMessageStore(1000) diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 52626c0cb8..60f3a991ec 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -443,7 +443,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { taskRepo := repository.NewMemoryRepository() // Initialize task service - taskSvc := taskservice.NewService(taskRepo, eventBus, log) + taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) // Create simulated agent manager agentManager := NewSimulatedAgentManager(eventBus, log) diff --git a/apps/backend/internal/task/controller/repository.go b/apps/backend/internal/task/controller/repository.go new file mode 100644 index 0000000000..3aa9cb005e --- /dev/null +++ b/apps/backend/internal/task/controller/repository.go @@ -0,0 +1,171 @@ +package controller + +import ( + "context" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/service" +) + +type RepositoryController struct { + service *service.Service +} + +func NewRepositoryController(svc *service.Service) *RepositoryController { + return &RepositoryController{service: svc} +} + +func (c *RepositoryController) ListRepositories(ctx context.Context, req dto.ListRepositoriesRequest) (dto.ListRepositoriesResponse, error) { + repositories, err := c.service.ListRepositories(ctx, req.WorkspaceID) + if err != nil { + return dto.ListRepositoriesResponse{}, err + } + resp := dto.ListRepositoriesResponse{ + Repositories: make([]dto.RepositoryDTO, 0, len(repositories)), + Total: len(repositories), + } + for _, repository := range repositories { + resp.Repositories = append(resp.Repositories, dto.FromRepository(repository)) + } + return resp, nil +} + +func (c *RepositoryController) GetRepository(ctx context.Context, req dto.GetRepositoryRequest) (dto.RepositoryDTO, error) { + repository, err := c.service.GetRepository(ctx, req.ID) + if err != nil { + return dto.RepositoryDTO{}, err + } + return dto.FromRepository(repository), nil +} + +func (c *RepositoryController) CreateRepository(ctx context.Context, req dto.CreateRepositoryRequest) (dto.RepositoryDTO, error) { + repository, err := c.service.CreateRepository(ctx, &service.CreateRepositoryRequest{ + WorkspaceID: req.WorkspaceID, + Name: req.Name, + SourceType: req.SourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + }) + if err != nil { + return dto.RepositoryDTO{}, err + } + return dto.FromRepository(repository), nil +} + +func (c *RepositoryController) UpdateRepository(ctx context.Context, req dto.UpdateRepositoryRequest) (dto.RepositoryDTO, error) { + repository, err := c.service.UpdateRepository(ctx, req.ID, &service.UpdateRepositoryRequest{ + Name: req.Name, + SourceType: req.SourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + }) + if err != nil { + return dto.RepositoryDTO{}, err + } + return dto.FromRepository(repository), nil +} + +func (c *RepositoryController) DeleteRepository(ctx context.Context, req dto.DeleteRepositoryRequest) (dto.SuccessResponse, error) { + if err := c.service.DeleteRepository(ctx, req.ID); err != nil { + return dto.SuccessResponse{}, err + } + return dto.SuccessResponse{Success: true}, nil +} + +func (c *RepositoryController) ListRepositoryScripts(ctx context.Context, req dto.ListRepositoryScriptsRequest) (dto.ListRepositoryScriptsResponse, error) { + scripts, err := c.service.ListRepositoryScripts(ctx, req.RepositoryID) + if err != nil { + return dto.ListRepositoryScriptsResponse{}, err + } + resp := dto.ListRepositoryScriptsResponse{ + Scripts: make([]dto.RepositoryScriptDTO, 0, len(scripts)), + Total: len(scripts), + } + for _, script := range scripts { + resp.Scripts = append(resp.Scripts, dto.FromRepositoryScript(script)) + } + return resp, nil +} + +func (c *RepositoryController) GetRepositoryScript(ctx context.Context, req dto.GetRepositoryScriptRequest) (dto.RepositoryScriptDTO, error) { + script, err := c.service.GetRepositoryScript(ctx, req.ID) + if err != nil { + return dto.RepositoryScriptDTO{}, err + } + return dto.FromRepositoryScript(script), nil +} + +func (c *RepositoryController) CreateRepositoryScript(ctx context.Context, req dto.CreateRepositoryScriptRequest) (dto.RepositoryScriptDTO, error) { + script, err := c.service.CreateRepositoryScript(ctx, &service.CreateRepositoryScriptRequest{ + RepositoryID: req.RepositoryID, + Name: req.Name, + Command: req.Command, + Position: req.Position, + }) + if err != nil { + return dto.RepositoryScriptDTO{}, err + } + return dto.FromRepositoryScript(script), nil +} + +func (c *RepositoryController) UpdateRepositoryScript(ctx context.Context, req dto.UpdateRepositoryScriptRequest) (dto.RepositoryScriptDTO, error) { + script, err := c.service.UpdateRepositoryScript(ctx, req.ID, &service.UpdateRepositoryScriptRequest{ + Name: req.Name, + Command: req.Command, + Position: req.Position, + }) + if err != nil { + return dto.RepositoryScriptDTO{}, err + } + return dto.FromRepositoryScript(script), nil +} + +func (c *RepositoryController) DeleteRepositoryScript(ctx context.Context, req dto.DeleteRepositoryScriptRequest) (dto.SuccessResponse, error) { + if err := c.service.DeleteRepositoryScript(ctx, req.ID); err != nil { + return dto.SuccessResponse{}, err + } + return dto.SuccessResponse{Success: true}, nil +} + +func (c *RepositoryController) DiscoverRepositories(ctx context.Context, req dto.DiscoverRepositoriesRequest) (dto.RepositoryDiscoveryResponse, error) { + result, err := c.service.DiscoverLocalRepositories(ctx, req.Root) + if err != nil { + return dto.RepositoryDiscoveryResponse{}, err + } + resp := dto.RepositoryDiscoveryResponse{ + Roots: result.Roots, + Repositories: make([]dto.LocalRepositoryDTO, 0, len(result.Repositories)), + Total: len(result.Repositories), + } + for _, repo := range result.Repositories { + resp.Repositories = append(resp.Repositories, dto.FromLocalRepository(repo)) + } + return resp, nil +} + +func (c *RepositoryController) ValidateRepositoryPath(ctx context.Context, req dto.ValidateRepositoryPathRequest) (dto.RepositoryPathValidationResponse, error) { + result, err := c.service.ValidateLocalRepositoryPath(ctx, req.Path) + if err != nil { + return dto.RepositoryPathValidationResponse{}, err + } + return dto.RepositoryPathValidationResponse{ + Path: result.Path, + Exists: result.Exists, + IsGitRepo: result.IsGitRepo, + Allowed: result.Allowed, + DefaultBranch: result.DefaultBranch, + Message: result.Message, + }, nil +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 3533785086..faeb061bb0 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -4,6 +4,7 @@ import ( "time" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -36,6 +37,33 @@ type ColumnDTO struct { UpdatedAt time.Time `json:"updated_at"` } +type RepositoryDTO struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path,omitempty"` + Provider string `json:"provider,omitempty"` + ProviderRepoID string `json:"provider_repo_id,omitempty"` + ProviderOwner string `json:"provider_owner,omitempty"` + ProviderName string `json:"provider_name,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + SetupScript string `json:"setup_script,omitempty"` + CleanupScript string `json:"cleanup_script,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RepositoryScriptDTO struct { + ID string `json:"id"` + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + Command string `json:"command"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type TaskDTO struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` @@ -76,6 +104,37 @@ type ListColumnsResponse struct { Total int `json:"total"` } +type ListRepositoriesResponse struct { + Repositories []RepositoryDTO `json:"repositories"` + Total int `json:"total"` +} + +type ListRepositoryScriptsResponse struct { + Scripts []RepositoryScriptDTO `json:"scripts"` + Total int `json:"total"` +} + +type LocalRepositoryDTO struct { + Path string `json:"path"` + Name string `json:"name"` + DefaultBranch string `json:"default_branch,omitempty"` +} + +type RepositoryDiscoveryResponse struct { + Roots []string `json:"roots"` + Repositories []LocalRepositoryDTO `json:"repositories"` + Total int `json:"total"` +} + +type RepositoryPathValidationResponse struct { + Path string `json:"path"` + Exists bool `json:"exists"` + IsGitRepo bool `json:"is_git"` + Allowed bool `json:"allowed"` + DefaultBranch string `json:"default_branch,omitempty"` + Message string `json:"message,omitempty"` +} + type ListTasksResponse struct { Tasks []TaskDTO `json:"tasks"` Total int `json:"total"` @@ -130,6 +189,45 @@ func FromColumn(column *models.Column) ColumnDTO { } } +func FromRepository(repository *models.Repository) RepositoryDTO { + return RepositoryDTO{ + ID: repository.ID, + WorkspaceID: repository.WorkspaceID, + Name: repository.Name, + SourceType: repository.SourceType, + LocalPath: repository.LocalPath, + Provider: repository.Provider, + ProviderRepoID: repository.ProviderRepoID, + ProviderOwner: repository.ProviderOwner, + ProviderName: repository.ProviderName, + DefaultBranch: repository.DefaultBranch, + SetupScript: repository.SetupScript, + CleanupScript: repository.CleanupScript, + CreatedAt: repository.CreatedAt, + UpdatedAt: repository.UpdatedAt, + } +} + +func FromRepositoryScript(script *models.RepositoryScript) RepositoryScriptDTO { + return RepositoryScriptDTO{ + ID: script.ID, + RepositoryID: script.RepositoryID, + Name: script.Name, + Command: script.Command, + Position: script.Position, + CreatedAt: script.CreatedAt, + UpdatedAt: script.UpdatedAt, + } +} + +func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO { + return LocalRepositoryDTO{ + Path: repo.Path, + Name: repo.Name, + DefaultBranch: repo.DefaultBranch, + } +} + func FromTask(task *models.Task) TaskDTO { var agentType *string if task.AgentType != "" { diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index bcde30757b..520fd27161 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -72,6 +72,82 @@ type UpdateColumnRequest struct { Color *string } +type ListRepositoriesRequest struct { + WorkspaceID string +} + +type GetRepositoryRequest struct { + ID string +} + +type CreateRepositoryRequest struct { + WorkspaceID string + Name string + SourceType string + LocalPath string + Provider string + ProviderRepoID string + ProviderOwner string + ProviderName string + DefaultBranch string + SetupScript string + CleanupScript string +} + +type UpdateRepositoryRequest struct { + ID string + Name *string + SourceType *string + LocalPath *string + Provider *string + ProviderRepoID *string + ProviderOwner *string + ProviderName *string + DefaultBranch *string + SetupScript *string + CleanupScript *string +} + +type DeleteRepositoryRequest struct { + ID string +} + +type ListRepositoryScriptsRequest struct { + RepositoryID string +} + +type GetRepositoryScriptRequest struct { + ID string +} + +type CreateRepositoryScriptRequest struct { + RepositoryID string + Name string + Command string + Position int +} + +type UpdateRepositoryScriptRequest struct { + ID string + Name *string + Command *string + Position *int +} + +type DeleteRepositoryScriptRequest struct { + ID string +} + +type DiscoverRepositoriesRequest struct { + WorkspaceID string + Root string +} + +type ValidateRepositoryPathRequest struct { + WorkspaceID string + Path string +} + type GetBoardSnapshotRequest struct { BoardID string } diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go new file mode 100644 index 0000000000..4392529000 --- /dev/null +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -0,0 +1,541 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/controller" + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type RepositoryHandlers struct { + controller *controller.RepositoryController + logger *logger.Logger +} + +func NewRepositoryHandlers(ctrl *controller.RepositoryController, log *logger.Logger) *RepositoryHandlers { + return &RepositoryHandlers{ + controller: ctrl, + logger: log.WithFields(zap.String("component", "task-repository-handlers")), + } +} + +func RegisterRepositoryRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.RepositoryController, log *logger.Logger) { + handlers := NewRepositoryHandlers(ctrl, log) + handlers.registerHTTP(router) + handlers.registerWS(dispatcher) +} + +func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.GET("/workspaces/:id/repositories", h.httpListRepositories) + api.POST("/workspaces/:id/repositories", h.httpCreateRepository) + api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories) + api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath) + api.GET("/repositories/:id", h.httpGetRepository) + api.PATCH("/repositories/:id", h.httpUpdateRepository) + api.DELETE("/repositories/:id", h.httpDeleteRepository) + api.GET("/repositories/:id/scripts", h.httpListRepositoryScripts) + api.POST("/repositories/:id/scripts", h.httpCreateRepositoryScript) + api.GET("/scripts/:id", h.httpGetRepositoryScript) + api.PUT("/scripts/:id", h.httpUpdateRepositoryScript) + api.DELETE("/scripts/:id", h.httpDeleteRepositoryScript) +} + +func (h *RepositoryHandlers) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc("repository.list", h.wsListRepositories) + dispatcher.RegisterFunc("repository.create", h.wsCreateRepository) + dispatcher.RegisterFunc("repository.get", h.wsGetRepository) + dispatcher.RegisterFunc("repository.update", h.wsUpdateRepository) + dispatcher.RegisterFunc("repository.delete", h.wsDeleteRepository) + dispatcher.RegisterFunc("repository.script.list", h.wsListRepositoryScripts) + dispatcher.RegisterFunc("repository.script.create", h.wsCreateRepositoryScript) + dispatcher.RegisterFunc("repository.script.get", h.wsGetRepositoryScript) + dispatcher.RegisterFunc("repository.script.update", h.wsUpdateRepositoryScript) + dispatcher.RegisterFunc("repository.script.delete", h.wsDeleteRepositoryScript) +} + +// HTTP handlers + +func (h *RepositoryHandlers) httpListRepositories(c *gin.Context) { + resp, err := h.controller.ListRepositories(c.Request.Context(), dto.ListRepositoriesRequest{WorkspaceID: c.Param("id")}) + if err != nil { + h.logger.Error("failed to list repositories", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list repositories"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *RepositoryHandlers) httpDiscoverRepositories(c *gin.Context) { + root := c.Query("root") + resp, err := h.controller.DiscoverRepositories(c.Request.Context(), dto.DiscoverRepositoriesRequest{ + WorkspaceID: c.Param("id"), + Root: root, + }) + if err != nil { + if errors.Is(err, service.ErrPathNotAllowed) { + c.JSON(http.StatusBadRequest, gin.H{"error": "root is not within allowed paths"}) + return + } + h.logger.Error("failed to discover repositories", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to discover repositories"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *RepositoryHandlers) httpValidateRepositoryPath(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) + return + } + resp, err := h.controller.ValidateRepositoryPath(c.Request.Context(), dto.ValidateRepositoryPathRequest{ + WorkspaceID: c.Param("id"), + Path: path, + }) + if err != nil { + h.logger.Error("failed to validate repository path", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to validate repository path"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpCreateRepositoryRequest struct { + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` +} + +func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) { + var body httpCreateRepositoryRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + if body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + resp, err := h.controller.CreateRepository(c.Request.Context(), dto.CreateRepositoryRequest{ + WorkspaceID: c.Param("id"), + Name: body.Name, + SourceType: body.SourceType, + LocalPath: body.LocalPath, + Provider: body.Provider, + ProviderRepoID: body.ProviderRepoID, + ProviderOwner: body.ProviderOwner, + ProviderName: body.ProviderName, + DefaultBranch: body.DefaultBranch, + SetupScript: body.SetupScript, + CleanupScript: body.CleanupScript, + }) + if err != nil { + h.logger.Error("failed to create repository", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create repository"}) + return + } + c.JSON(http.StatusCreated, resp) +} + +func (h *RepositoryHandlers) httpGetRepository(c *gin.Context) { + resp, err := h.controller.GetRepository(c.Request.Context(), dto.GetRepositoryRequest{ID: c.Param("id")}) + if err != nil { + handleNotFound(c, h.logger, err, "repository not found") + return + } + c.JSON(http.StatusOK, resp) +} + +type httpUpdateRepositoryRequest struct { + Name *string `json:"name"` + SourceType *string `json:"source_type"` + LocalPath *string `json:"local_path"` + Provider *string `json:"provider"` + ProviderRepoID *string `json:"provider_repo_id"` + ProviderOwner *string `json:"provider_owner"` + ProviderName *string `json:"provider_name"` + DefaultBranch *string `json:"default_branch"` + SetupScript *string `json:"setup_script"` + CleanupScript *string `json:"cleanup_script"` +} + +func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) { + var body httpUpdateRepositoryRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + resp, err := h.controller.UpdateRepository(c.Request.Context(), dto.UpdateRepositoryRequest{ + ID: c.Param("id"), + Name: body.Name, + SourceType: body.SourceType, + LocalPath: body.LocalPath, + Provider: body.Provider, + ProviderRepoID: body.ProviderRepoID, + ProviderOwner: body.ProviderOwner, + ProviderName: body.ProviderName, + DefaultBranch: body.DefaultBranch, + SetupScript: body.SetupScript, + CleanupScript: body.CleanupScript, + }) + if err != nil { + handleNotFound(c, h.logger, err, "repository not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *RepositoryHandlers) httpDeleteRepository(c *gin.Context) { + if _, err := h.controller.DeleteRepository(c.Request.Context(), dto.DeleteRepositoryRequest{ID: c.Param("id")}); err != nil { + handleNotFound(c, h.logger, err, "repository not found") + return + } + c.Status(http.StatusNoContent) +} + +func (h *RepositoryHandlers) httpListRepositoryScripts(c *gin.Context) { + resp, err := h.controller.ListRepositoryScripts(c.Request.Context(), dto.ListRepositoryScriptsRequest{RepositoryID: c.Param("id")}) + if err != nil { + h.logger.Error("failed to list repository scripts", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list repository scripts"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpCreateRepositoryScriptRequest struct { + Name string `json:"name"` + Command string `json:"command"` + Position int `json:"position"` +} + +func (h *RepositoryHandlers) httpCreateRepositoryScript(c *gin.Context) { + var body httpCreateRepositoryScriptRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + if body.Name == "" || body.Command == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name and command are required"}) + return + } + resp, err := h.controller.CreateRepositoryScript(c.Request.Context(), dto.CreateRepositoryScriptRequest{ + RepositoryID: c.Param("id"), + Name: body.Name, + Command: body.Command, + Position: body.Position, + }) + if err != nil { + h.logger.Error("failed to create repository script", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create repository script"}) + return + } + c.JSON(http.StatusCreated, resp) +} + +func (h *RepositoryHandlers) httpGetRepositoryScript(c *gin.Context) { + resp, err := h.controller.GetRepositoryScript(c.Request.Context(), dto.GetRepositoryScriptRequest{ID: c.Param("id")}) + if err != nil { + handleNotFound(c, h.logger, err, "repository script not found") + return + } + c.JSON(http.StatusOK, resp) +} + +type httpUpdateRepositoryScriptRequest struct { + Name *string `json:"name"` + Command *string `json:"command"` + Position *int `json:"position"` +} + +func (h *RepositoryHandlers) httpUpdateRepositoryScript(c *gin.Context) { + var body httpUpdateRepositoryScriptRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + resp, err := h.controller.UpdateRepositoryScript(c.Request.Context(), dto.UpdateRepositoryScriptRequest{ + ID: c.Param("id"), + Name: body.Name, + Command: body.Command, + Position: body.Position, + }) + if err != nil { + handleNotFound(c, h.logger, err, "repository script not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *RepositoryHandlers) httpDeleteRepositoryScript(c *gin.Context) { + if _, err := h.controller.DeleteRepositoryScript(c.Request.Context(), dto.DeleteRepositoryScriptRequest{ID: c.Param("id")}); err != nil { + handleNotFound(c, h.logger, err, "repository script not found") + return + } + c.Status(http.StatusNoContent) +} + +// WS handlers + +func (h *RepositoryHandlers) wsListRepositories(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + WorkspaceID string `json:"workspace_id"` + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + resp, err := h.controller.ListRepositories(ctx, dto.ListRepositoriesRequest{WorkspaceID: req.WorkspaceID}) + if err != nil { + h.logger.Error("failed to list repositories", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list repositories", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsCreateRepositoryRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` +} + +func (h *RepositoryHandlers) wsCreateRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsCreateRepositoryRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.WorkspaceID == "" || req.Name == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id and name are required", nil) + } + resp, err := h.controller.CreateRepository(ctx, dto.CreateRepositoryRequest{ + WorkspaceID: req.WorkspaceID, + Name: req.Name, + SourceType: req.SourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + }) + if err != nil { + h.logger.Error("failed to create repository", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create repository", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsGetRepositoryRequest struct { + ID string `json:"id"` +} + +func (h *RepositoryHandlers) wsGetRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetRepositoryRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := h.controller.GetRepository(ctx, dto.GetRepositoryRequest{ID: req.ID}) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Repository not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsUpdateRepositoryRequest struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + SourceType *string `json:"source_type,omitempty"` + LocalPath *string `json:"local_path,omitempty"` + Provider *string `json:"provider,omitempty"` + ProviderRepoID *string `json:"provider_repo_id,omitempty"` + ProviderOwner *string `json:"provider_owner,omitempty"` + ProviderName *string `json:"provider_name,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + SetupScript *string `json:"setup_script,omitempty"` + CleanupScript *string `json:"cleanup_script,omitempty"` +} + +func (h *RepositoryHandlers) wsUpdateRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateRepositoryRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := h.controller.UpdateRepository(ctx, dto.UpdateRepositoryRequest{ + ID: req.ID, + Name: req.Name, + SourceType: req.SourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + }) + if err != nil { + h.logger.Error("failed to update repository", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update repository", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsDeleteRepositoryRequest struct { + ID string `json:"id"` +} + +func (h *RepositoryHandlers) wsDeleteRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsDeleteRepositoryRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + if _, err := h.controller.DeleteRepository(ctx, dto.DeleteRepositoryRequest{ID: req.ID}); err != nil { + h.logger.Error("failed to delete repository", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Repository not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true}) +} + +func (h *RepositoryHandlers) wsListRepositoryScripts(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + RepositoryID string `json:"repository_id"` + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + resp, err := h.controller.ListRepositoryScripts(ctx, dto.ListRepositoryScriptsRequest{RepositoryID: req.RepositoryID}) + if err != nil { + h.logger.Error("failed to list repository scripts", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list repository scripts", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsCreateRepositoryScriptRequest struct { + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + Command string `json:"command"` + Position int `json:"position"` +} + +func (h *RepositoryHandlers) wsCreateRepositoryScript(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsCreateRepositoryScriptRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.RepositoryID == "" || req.Name == "" || req.Command == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id, name, and command are required", nil) + } + resp, err := h.controller.CreateRepositoryScript(ctx, dto.CreateRepositoryScriptRequest{ + RepositoryID: req.RepositoryID, + Name: req.Name, + Command: req.Command, + Position: req.Position, + }) + if err != nil { + h.logger.Error("failed to create repository script", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create repository script", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsGetRepositoryScriptRequest struct { + ID string `json:"id"` +} + +func (h *RepositoryHandlers) wsGetRepositoryScript(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetRepositoryScriptRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := h.controller.GetRepositoryScript(ctx, dto.GetRepositoryScriptRequest{ID: req.ID}) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Repository script not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsUpdateRepositoryScriptRequest struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Command *string `json:"command,omitempty"` + Position *int `json:"position,omitempty"` +} + +func (h *RepositoryHandlers) wsUpdateRepositoryScript(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateRepositoryScriptRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + resp, err := h.controller.UpdateRepositoryScript(ctx, dto.UpdateRepositoryScriptRequest{ + ID: req.ID, + Name: req.Name, + Command: req.Command, + Position: req.Position, + }) + if err != nil { + h.logger.Error("failed to update repository script", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update repository script", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsDeleteRepositoryScriptRequest struct { + ID string `json:"id"` +} + +func (h *RepositoryHandlers) wsDeleteRepositoryScript(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsDeleteRepositoryScriptRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + if _, err := h.controller.DeleteRepositoryScript(ctx, dto.DeleteRepositoryScriptRequest{ID: req.ID}); err != nil { + h.logger.Error("failed to delete repository script", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Repository script not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true}) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 6b531cd69b..bad115cfbd 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -70,14 +70,14 @@ const ( // Comment represents a comment on a task type Comment struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - AuthorType CommentAuthorType `json:"author_type"` - AuthorID string `json:"author_id,omitempty"` // User ID or Agent Instance ID - Content string `json:"content"` - RequestsInput bool `json:"requests_input"` // True if agent is requesting user input - ACPSessionID string `json:"acp_session_id,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TaskID string `json:"task_id"` + AuthorType CommentAuthorType `json:"author_type"` + AuthorID string `json:"author_id,omitempty"` // User ID or Agent Instance ID + Content string `json:"content"` + RequestsInput bool `json:"requests_input"` // True if agent is requesting user input + ACPSessionID string `json:"acp_session_id,omitempty"` + CreatedAt time.Time `json:"created_at"` } // ToAPI converts internal Comment to API type @@ -93,6 +93,38 @@ func (c *Comment) ToAPI() *v1.Comment { } } +// Repository represents a workspace repository +type Repository struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + // LocalPath is the path to a local checkout; for provider-backed repos, this is + // populated after the repo is cloned/synced on the agent host. + LocalPath string `json:"local_path"` + // Provider fields describe the upstream source (e.g. github/gitlab) for future syncing. + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RepositoryScript represents a custom script for a repository +type RepositoryScript struct { + ID string `json:"id"` + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + Command string `json:"command"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ToAPI converts internal Task to API type func (t *Task) ToAPI() *v1.Task { var agentType *string diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index cfaba5562b..e48a32d9a6 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -47,6 +47,20 @@ type Repository interface { ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) DeleteComment(ctx context.Context, id string) error + // Repository operations + CreateRepository(ctx context.Context, repository *models.Repository) error + GetRepository(ctx context.Context, id string) (*models.Repository, error) + UpdateRepository(ctx context.Context, repository *models.Repository) error + DeleteRepository(ctx context.Context, id string) error + ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) + + // Repository script operations + CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error + GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) + UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error + DeleteRepositoryScript(ctx context.Context, id string) error + ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) + // Close closes the repository (for database connections) Close() error } diff --git a/apps/backend/internal/task/repository/memory.go b/apps/backend/internal/task/repository/memory.go index 964b93d6cc..1136665d2a 100644 --- a/apps/backend/internal/task/repository/memory.go +++ b/apps/backend/internal/task/repository/memory.go @@ -18,6 +18,8 @@ type MemoryRepository struct { boards map[string]*models.Board columns map[string]*models.Column comments map[string]*models.Comment + repositories map[string]*models.Repository + repoScripts map[string]*models.RepositoryScript taskBoards map[string]map[string]struct{} taskPlacements map[string]map[string]*taskPlacement mu sync.RWMutex @@ -34,6 +36,8 @@ func NewMemoryRepository() *MemoryRepository { boards: make(map[string]*models.Board), columns: make(map[string]*models.Column), comments: make(map[string]*models.Comment), + repositories: make(map[string]*models.Repository), + repoScripts: make(map[string]*models.RepositoryScript), taskBoards: make(map[string]map[string]struct{}), taskPlacements: make(map[string]map[string]*taskPlacement), } @@ -407,6 +411,137 @@ func (r *MemoryRepository) ListColumns(ctx context.Context, boardID string) ([]* return result, nil } +// Repository operations + +func (r *MemoryRepository) CreateRepository(ctx context.Context, repository *models.Repository) error { + r.mu.Lock() + defer r.mu.Unlock() + + if repository.ID == "" { + repository.ID = uuid.New().String() + } + now := time.Now().UTC() + repository.CreatedAt = now + repository.UpdatedAt = now + r.repositories[repository.ID] = repository + return nil +} + +func (r *MemoryRepository) GetRepository(ctx context.Context, id string) (*models.Repository, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + repository, ok := r.repositories[id] + if !ok { + return nil, fmt.Errorf("repository not found: %s", id) + } + return repository, nil +} + +func (r *MemoryRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.repositories[repository.ID]; !ok { + return fmt.Errorf("repository not found: %s", repository.ID) + } + repository.UpdatedAt = time.Now().UTC() + r.repositories[repository.ID] = repository + return nil +} + +func (r *MemoryRepository) DeleteRepository(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.repositories[id]; !ok { + return fmt.Errorf("repository not found: %s", id) + } + delete(r.repositories, id) + for scriptID, script := range r.repoScripts { + if script.RepositoryID == id { + delete(r.repoScripts, scriptID) + } + } + return nil +} + +func (r *MemoryRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.Repository, 0) + for _, repository := range r.repositories { + if repository.WorkspaceID == workspaceID { + result = append(result, repository) + } + } + return result, nil +} + +// Repository script operations + +func (r *MemoryRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + r.mu.Lock() + defer r.mu.Unlock() + + if script.ID == "" { + script.ID = uuid.New().String() + } + now := time.Now().UTC() + script.CreatedAt = now + script.UpdatedAt = now + r.repoScripts[script.ID] = script + return nil +} + +func (r *MemoryRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + script, ok := r.repoScripts[id] + if !ok { + return nil, fmt.Errorf("repository script not found: %s", id) + } + return script, nil +} + +func (r *MemoryRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.repoScripts[script.ID]; !ok { + return fmt.Errorf("repository script not found: %s", script.ID) + } + script.UpdatedAt = time.Now().UTC() + r.repoScripts[script.ID] = script + return nil +} + +func (r *MemoryRepository) DeleteRepositoryScript(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.repoScripts[id]; !ok { + return fmt.Errorf("repository script not found: %s", id) + } + delete(r.repoScripts, id) + return nil +} + +func (r *MemoryRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*models.RepositoryScript, 0) + for _, script := range r.repoScripts { + if script.RepositoryID == repositoryID { + result = append(result, script) + } + } + return result, nil +} + func (r *MemoryRepository) addTaskPlacement(taskID, boardID, columnID string, position int) { if _, ok := r.taskBoards[taskID]; !ok { r.taskBoards[taskID] = make(map[string]struct{}) diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index 13f22d9c4e..4ae196b21a 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -135,6 +135,35 @@ func (r *SQLiteRepository) initSchema() error { FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS repositories ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT 'local', + local_path TEXT DEFAULT '', + provider TEXT DEFAULT '', + provider_repo_id TEXT DEFAULT '', + provider_owner TEXT DEFAULT '', + provider_name TEXT DEFAULT '', + default_branch TEXT DEFAULT '', + setup_script TEXT DEFAULT '', + cleanup_script TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS repository_scripts ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + name TEXT NOT NULL, + command TEXT NOT NULL, + position INTEGER DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS task_boards ( task_id TEXT NOT NULL, board_id TEXT NOT NULL, @@ -160,6 +189,8 @@ func (r *SQLiteRepository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_task_boards_board_id ON task_boards(board_id); CREATE INDEX IF NOT EXISTS idx_task_columns_board_id ON task_columns(board_id); CREATE INDEX IF NOT EXISTS idx_task_columns_column_id ON task_columns(column_id); + CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id); + CREATE INDEX IF NOT EXISTS idx_repository_scripts_repo_id ON repository_scripts(repository_id); CREATE TABLE IF NOT EXISTS task_agent_execution_logs ( id TEXT PRIMARY KEY, @@ -980,3 +1011,194 @@ func (r *SQLiteRepository) DeleteComment(ctx context.Context, id string) error { } return nil } + +// Repository operations + +// CreateRepository creates a new repository +func (r *SQLiteRepository) CreateRepository(ctx context.Context, repository *models.Repository) error { + if repository.ID == "" { + repository.ID = uuid.New().String() + } + now := time.Now().UTC() + repository.CreatedAt = now + repository.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO repositories ( + id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, + provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, + repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, + repository.SetupScript, repository.CleanupScript, repository.CreatedAt, repository.UpdatedAt) + + return err +} + +// GetRepository retrieves a repository by ID +func (r *SQLiteRepository) GetRepository(ctx context.Context, id string) (*models.Repository, error) { + repository := &models.Repository{} + + err := r.db.QueryRowContext(ctx, ` + SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, + provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at + FROM repositories WHERE id = ? + `, id).Scan( + &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, + &repository.DefaultBranch, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("repository not found: %s", id) + } + return repository, err +} + +// UpdateRepository updates an existing repository +func (r *SQLiteRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error { + repository.UpdatedAt = time.Now().UTC() + + result, err := r.db.ExecContext(ctx, ` + UPDATE repositories SET + name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, + provider_name = ?, default_branch = ?, setup_script = ?, cleanup_script = ?, updated_at = ? + WHERE id = ? + `, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, + repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.SetupScript, repository.CleanupScript, + repository.UpdatedAt, repository.ID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("repository not found: %s", repository.ID) + } + return nil +} + +// DeleteRepository deletes a repository by ID +func (r *SQLiteRepository) DeleteRepository(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM repositories WHERE id = ?`, id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("repository not found: %s", id) + } + return nil +} + +// ListRepositories returns all repositories for a workspace +func (r *SQLiteRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, + provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at + FROM repositories WHERE workspace_id = ? ORDER BY created_at DESC + `, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []*models.Repository + for rows.Next() { + repository := &models.Repository{} + err := rows.Scan( + &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, + &repository.DefaultBranch, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, + ) + if err != nil { + return nil, err + } + result = append(result, repository) + } + return result, rows.Err() +} + +// Repository script operations + +// CreateRepositoryScript creates a new repository script +func (r *SQLiteRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + if script.ID == "" { + script.ID = uuid.New().String() + } + now := time.Now().UTC() + script.CreatedAt = now + script.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO repository_scripts (id, repository_id, name, command, position, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, script.ID, script.RepositoryID, script.Name, script.Command, script.Position, script.CreatedAt, script.UpdatedAt) + + return err +} + +// GetRepositoryScript retrieves a repository script by ID +func (r *SQLiteRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { + script := &models.RepositoryScript{} + err := r.db.QueryRowContext(ctx, ` + SELECT id, repository_id, name, command, position, created_at, updated_at + FROM repository_scripts WHERE id = ? + `, id).Scan(&script.ID, &script.RepositoryID, &script.Name, &script.Command, &script.Position, &script.CreatedAt, &script.UpdatedAt) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("repository script not found: %s", id) + } + return script, err +} + +// UpdateRepositoryScript updates an existing repository script +func (r *SQLiteRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + script.UpdatedAt = time.Now().UTC() + result, err := r.db.ExecContext(ctx, ` + UPDATE repository_scripts SET name = ?, command = ?, position = ?, updated_at = ? WHERE id = ? + `, script.Name, script.Command, script.Position, script.UpdatedAt, script.ID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("repository script not found: %s", script.ID) + } + return nil +} + +// DeleteRepositoryScript deletes a repository script by ID +func (r *SQLiteRepository) DeleteRepositoryScript(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM repository_scripts WHERE id = ?`, id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("repository script not found: %s", id) + } + return nil +} + +// ListRepositoryScripts returns all scripts for a repository +func (r *SQLiteRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, repository_id, name, command, position, created_at, updated_at + FROM repository_scripts WHERE repository_id = ? ORDER BY position + `, repositoryID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []*models.RepositoryScript + for rows.Next() { + script := &models.RepositoryScript{} + err := rows.Scan(&script.ID, &script.RepositoryID, &script.Name, &script.Command, &script.Position, &script.CreatedAt, &script.UpdatedAt) + if err != nil { + return nil, err + } + result = append(result, script) + } + return result, rows.Err() +} diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go new file mode 100644 index 0000000000..afeb02b3e5 --- /dev/null +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -0,0 +1,313 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type RepositoryDiscoveryConfig struct { + Roots []string + MaxDepth int +} + +type LocalRepository struct { + Path string + Name string + DefaultBranch string +} + +type RepositoryDiscoveryResult struct { + Roots []string + Repositories []LocalRepository +} + +type RepositoryPathValidation struct { + Path string + Exists bool + IsGitRepo bool + Allowed bool + DefaultBranch string + Message string +} + +var ErrPathNotAllowed = errors.New("path is not within an allowed root") + +func (s *Service) DiscoverLocalRepositories(ctx context.Context, root string) (RepositoryDiscoveryResult, error) { + roots := s.discoveryRoots() + if root != "" { + absRoot, err := filepath.Abs(root) + if err != nil { + return RepositoryDiscoveryResult{}, fmt.Errorf("invalid root path: %w", err) + } + if !isPathAllowed(absRoot, roots) { + return RepositoryDiscoveryResult{}, ErrPathNotAllowed + } + roots = []string{absRoot} + } + + repos := make([]LocalRepository, 0) + seen := make(map[string]struct{}) + for _, scanRoot := range roots { + select { + case <-ctx.Done(): + return RepositoryDiscoveryResult{}, ctx.Err() + default: + } + found, err := scanRootForRepos(ctx, scanRoot, s.discoveryMaxDepth()) + if err != nil { + return RepositoryDiscoveryResult{}, err + } + for _, repo := range found { + if _, ok := seen[repo.Path]; ok { + continue + } + seen[repo.Path] = struct{}{} + repos = append(repos, repo) + } + } + + return RepositoryDiscoveryResult{ + Roots: roots, + Repositories: repos, + }, nil +} + +func (s *Service) ValidateLocalRepositoryPath(ctx context.Context, path string) (RepositoryPathValidation, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return RepositoryPathValidation{}, fmt.Errorf("invalid path: %w", err) + } + roots := s.discoveryRoots() + allowed := isPathAllowed(absPath, roots) + info, statErr := os.Stat(absPath) + exists := statErr == nil + isDir := exists && info.IsDir() + isGit := false + defaultBranch := "" + message := "" + + if !allowed { + message = "Path is outside the allowed roots" + } else if !exists { + message = "Path does not exist" + } else if !isDir { + message = "Path is not a directory" + } else { + var gitErr error + defaultBranch, gitErr = readGitDefaultBranch(absPath) + isGit = gitErr == nil + if !isGit { + message = "Not a git repository" + } + } + + return RepositoryPathValidation{ + Path: absPath, + Exists: exists, + IsGitRepo: isGit, + Allowed: allowed, + DefaultBranch: defaultBranch, + Message: message, + }, nil +} + +func (s *Service) discoveryRoots() []string { + if len(s.discoveryConfig.Roots) > 0 { + return normalizeRoots(s.discoveryConfig.Roots) + } + homeDir, err := os.UserHomeDir() + if err != nil { + return []string{} + } + return []string{filepath.Clean(homeDir)} +} + +func (s *Service) discoveryMaxDepth() int { + if s.discoveryConfig.MaxDepth > 0 { + return s.discoveryConfig.MaxDepth + } + return 5 +} + +func normalizeRoots(roots []string) []string { + normalized := make([]string, 0, len(roots)) + seen := make(map[string]struct{}) + for _, root := range roots { + if root == "" { + continue + } + abs, err := filepath.Abs(root) + if err != nil { + continue + } + clean := filepath.Clean(abs) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + normalized = append(normalized, clean) + } + return normalized +} + +func scanRootForRepos(ctx context.Context, root string, maxDepth int) ([]LocalRepository, error) { + repos := make([]LocalRepository, 0) + libraryRoot := filepath.Join(root, "Library") + cacheRoot := filepath.Join(root, ".cache") + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if path == root { + return nil + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return nil + } + depth := strings.Count(rel, string(os.PathSeparator)) + if d.IsDir() && depth > maxDepth { + return fs.SkipDir + } + + if isWithinRoot(path, libraryRoot) { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if isWithinRoot(path, cacheRoot) { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + + name := d.Name() + if d.IsDir() && name == "Library" && filepath.Dir(path) == root { + return fs.SkipDir + } + if d.IsDir() && name == ".cache" && filepath.Dir(path) == root { + return fs.SkipDir + } + if d.IsDir() && strings.HasPrefix(name, ".") && name != ".git" { + return fs.SkipDir + } + if d.IsDir() && name == "node_modules" { + return fs.SkipDir + } + if name == ".git" { + repoPath := filepath.Dir(path) + repo := LocalRepository{ + Path: repoPath, + Name: filepath.Base(repoPath), + DefaultBranch: "", + } + if branch, err := readGitDefaultBranch(repoPath); err == nil { + repo.DefaultBranch = branch + } + repos = append(repos, repo) + if d.IsDir() { + return fs.SkipDir + } + } + return nil + }) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + if err != nil { + return nil, err + } + return repos, nil +} + +func isPathAllowed(path string, roots []string) bool { + for _, root := range roots { + if root == "" { + continue + } + if isWithinRoot(path, root) { + return true + } + } + return false +} + +func isWithinRoot(path string, root string) bool { + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + absRoot, err := filepath.Abs(root) + if err != nil { + return false + } + absPath = filepath.Clean(absPath) + absRoot = filepath.Clean(absRoot) + if absPath == absRoot { + return true + } + separator := string(os.PathSeparator) + if !strings.HasSuffix(absRoot, separator) { + absRoot += separator + } + return strings.HasPrefix(absPath, absRoot) +} + +func readGitDefaultBranch(repoPath string) (string, error) { + gitDir, err := resolveGitDir(repoPath) + if err != nil { + return "", err + } + headPath := filepath.Join(gitDir, "HEAD") + content, err := os.ReadFile(headPath) + if err != nil { + return "", err + } + trimmed := strings.TrimSpace(string(content)) + if strings.HasPrefix(trimmed, "ref: ") { + ref := strings.TrimPrefix(trimmed, "ref: ") + parts := strings.Split(ref, "/") + if len(parts) > 0 { + return parts[len(parts)-1], nil + } + } + if trimmed != "" { + return "HEAD", nil + } + return "", fmt.Errorf("unable to determine branch") +} + +func resolveGitDir(repoPath string) (string, error) { + gitPath := filepath.Join(repoPath, ".git") + info, err := os.Stat(gitPath) + if err != nil { + return "", err + } + if info.IsDir() { + return gitPath, nil + } + content, err := os.ReadFile(gitPath) + if err != nil { + return "", err + } + line := strings.TrimSpace(string(content)) + if !strings.HasPrefix(line, "gitdir:") { + return "", fmt.Errorf("invalid gitdir reference") + } + gitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:")) + if filepath.IsAbs(gitDir) { + return gitDir, nil + } + return filepath.Clean(filepath.Join(repoPath, gitDir)), nil +} diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go new file mode 100644 index 0000000000..d104db78fe --- /dev/null +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/repository" +) + +func TestDiscoverLocalRepositoriesSkipsIgnoredRoots(t *testing.T) { + root := t.TempDir() + makeRepo(t, filepath.Join(root, "ProjectA")) + makeRepo(t, filepath.Join(root, "ProjectB")) + makeRepo(t, filepath.Join(root, "Library", "Caches", "mise", "python", "pyenv", "ProjectC")) + makeRepo(t, filepath.Join(root, ".cache", "ProjectD")) + makeRepo(t, filepath.Join(root, "node_modules", "ProjectE")) + makeRepo(t, filepath.Join(root, "ProjectA", "node_modules", "ProjectF")) + + svc := newDiscoveryService(t, root) + result, err := svc.DiscoverLocalRepositories(context.Background(), "") + if err != nil { + t.Fatalf("DiscoverLocalRepositories error: %v", err) + } + + paths := make([]string, 0, len(result.Repositories)) + for _, repo := range result.Repositories { + paths = append(paths, repo.Path) + } + sort.Strings(paths) + + expected := []string{ + filepath.Join(root, "ProjectA"), + filepath.Join(root, "ProjectB"), + } + sort.Strings(expected) + + if len(paths) != len(expected) { + t.Fatalf("expected %d repos, got %d: %#v", len(expected), len(paths), paths) + } + for i, path := range paths { + if path != expected[i] { + t.Fatalf("expected repo %q, got %q", expected[i], path) + } + } +} + +func makeRepo(t *testing.T, path string) { + t.Helper() + gitDir := filepath.Join(path, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + headPath := filepath.Join(gitDir, "HEAD") + if err := os.WriteFile(headPath, []byte("ref: refs/heads/main\n"), 0o644); err != nil { + t.Fatalf("write HEAD: %v", err) + } +} + +func newDiscoveryService(t *testing.T, root string) *Service { + t.Helper() + repo := repository.NewMemoryRepository() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + eventBus := bus.NewMemoryEventBus(log) + return NewService(repo, eventBus, log, RepositoryDiscoveryConfig{ + Roots: []string{root}, + MaxDepth: 6, + }) +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 98c4fa1d55..4d5166022d 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -17,17 +17,19 @@ import ( // Service provides task business logic type Service struct { - repo repository.Repository - eventBus bus.EventBus - logger *logger.Logger + repo repository.Repository + eventBus bus.EventBus + logger *logger.Logger + discoveryConfig RepositoryDiscoveryConfig } // NewService creates a new task service -func NewService(repo repository.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { +func NewService(repo repository.Repository, eventBus bus.EventBus, log *logger.Logger, discoveryConfig RepositoryDiscoveryConfig) *Service { return &Service{ - repo: repo, - eventBus: eventBus, - logger: log, + repo: repo, + eventBus: eventBus, + logger: log, + discoveryConfig: discoveryConfig, } } @@ -103,6 +105,50 @@ type UpdateColumnRequest struct { Color *string `json:"color,omitempty"` } +// CreateRepositoryRequest contains the data for creating a new repository +type CreateRepositoryRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` +} + +// UpdateRepositoryRequest contains the data for updating a repository +type UpdateRepositoryRequest struct { + Name *string `json:"name,omitempty"` + SourceType *string `json:"source_type,omitempty"` + LocalPath *string `json:"local_path,omitempty"` + Provider *string `json:"provider,omitempty"` + ProviderRepoID *string `json:"provider_repo_id,omitempty"` + ProviderOwner *string `json:"provider_owner,omitempty"` + ProviderName *string `json:"provider_name,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + SetupScript *string `json:"setup_script,omitempty"` + CleanupScript *string `json:"cleanup_script,omitempty"` +} + +// CreateRepositoryScriptRequest contains the data for creating a repository script +type CreateRepositoryScriptRequest struct { + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + Command string `json:"command"` + Position int `json:"position"` +} + +// UpdateRepositoryScriptRequest contains the data for updating a repository script +type UpdateRepositoryScriptRequest struct { + Name *string `json:"name,omitempty"` + Command *string `json:"command,omitempty"` + Position *int `json:"position,omitempty"` +} + // Task operations // CreateTask creates a new task and publishes a task.created event @@ -531,6 +577,173 @@ func (s *Service) DeleteColumn(ctx context.Context, id string) error { return nil } +// Repository operations + +func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryRequest) (*models.Repository, error) { + sourceType := req.SourceType + if sourceType == "" { + sourceType = "local" + } + repository := &models.Repository{ + ID: uuid.New().String(), + WorkspaceID: req.WorkspaceID, + Name: req.Name, + SourceType: sourceType, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + DefaultBranch: req.DefaultBranch, + SetupScript: req.SetupScript, + CleanupScript: req.CleanupScript, + } + + if err := s.repo.CreateRepository(ctx, repository); err != nil { + s.logger.Error("failed to create repository", zap.Error(err)) + return nil, err + } + + s.publishRepositoryEvent(ctx, events.RepositoryCreated, repository) + s.logger.Info("repository created", zap.String("repository_id", repository.ID)) + return repository, nil +} + +func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) { + return s.repo.GetRepository(ctx, id) +} + +func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRepositoryRequest) (*models.Repository, error) { + repository, err := s.repo.GetRepository(ctx, id) + if err != nil { + return nil, err + } + if req.Name != nil { + repository.Name = *req.Name + } + if req.SourceType != nil { + repository.SourceType = *req.SourceType + } + if req.LocalPath != nil { + repository.LocalPath = *req.LocalPath + } + if req.Provider != nil { + repository.Provider = *req.Provider + } + if req.ProviderRepoID != nil { + repository.ProviderRepoID = *req.ProviderRepoID + } + if req.ProviderOwner != nil { + repository.ProviderOwner = *req.ProviderOwner + } + if req.ProviderName != nil { + repository.ProviderName = *req.ProviderName + } + if req.DefaultBranch != nil { + repository.DefaultBranch = *req.DefaultBranch + } + if req.SetupScript != nil { + repository.SetupScript = *req.SetupScript + } + if req.CleanupScript != nil { + repository.CleanupScript = *req.CleanupScript + } + repository.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateRepository(ctx, repository); err != nil { + s.logger.Error("failed to update repository", zap.String("repository_id", id), zap.Error(err)) + return nil, err + } + + s.publishRepositoryEvent(ctx, events.RepositoryUpdated, repository) + s.logger.Info("repository updated", zap.String("repository_id", repository.ID)) + return repository, nil +} + +func (s *Service) DeleteRepository(ctx context.Context, id string) error { + repository, err := s.repo.GetRepository(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteRepository(ctx, id); err != nil { + s.logger.Error("failed to delete repository", zap.String("repository_id", id), zap.Error(err)) + return err + } + s.publishRepositoryEvent(ctx, events.RepositoryDeleted, repository) + s.logger.Info("repository deleted", zap.String("repository_id", id)) + return nil +} + +func (s *Service) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { + return s.repo.ListRepositories(ctx, workspaceID) +} + +// Repository script operations + +func (s *Service) CreateRepositoryScript(ctx context.Context, req *CreateRepositoryScriptRequest) (*models.RepositoryScript, error) { + script := &models.RepositoryScript{ + ID: uuid.New().String(), + RepositoryID: req.RepositoryID, + Name: req.Name, + Command: req.Command, + Position: req.Position, + } + if err := s.repo.CreateRepositoryScript(ctx, script); err != nil { + s.logger.Error("failed to create repository script", zap.Error(err)) + return nil, err + } + s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptCreated, script) + s.logger.Info("repository script created", zap.String("script_id", script.ID)) + return script, nil +} + +func (s *Service) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { + return s.repo.GetRepositoryScript(ctx, id) +} + +func (s *Service) UpdateRepositoryScript(ctx context.Context, id string, req *UpdateRepositoryScriptRequest) (*models.RepositoryScript, error) { + script, err := s.repo.GetRepositoryScript(ctx, id) + if err != nil { + return nil, err + } + if req.Name != nil { + script.Name = *req.Name + } + if req.Command != nil { + script.Command = *req.Command + } + if req.Position != nil { + script.Position = *req.Position + } + script.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateRepositoryScript(ctx, script); err != nil { + s.logger.Error("failed to update repository script", zap.String("script_id", id), zap.Error(err)) + return nil, err + } + s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptUpdated, script) + s.logger.Info("repository script updated", zap.String("script_id", script.ID)) + return script, nil +} + +func (s *Service) DeleteRepositoryScript(ctx context.Context, id string) error { + script, err := s.repo.GetRepositoryScript(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteRepositoryScript(ctx, id); err != nil { + s.logger.Error("failed to delete repository script", zap.String("script_id", id), zap.Error(err)) + return err + } + s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptDeleted, script) + s.logger.Info("repository script deleted", zap.String("script_id", id)) + return nil +} + +func (s *Service) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { + return s.repo.ListRepositoryScripts(ctx, repositoryID) +} + // publishTaskEvent publishes task events to the event bus func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) { if s.eventBus == nil { @@ -748,3 +961,54 @@ func (s *Service) publishCommentEvent(ctx context.Context, eventType string, com zap.Error(err)) } } + +func (s *Service) publishRepositoryEvent(ctx context.Context, eventType string, repository *models.Repository) { + if s.eventBus == nil || repository == nil { + return + } + data := map[string]interface{}{ + "id": repository.ID, + "workspace_id": repository.WorkspaceID, + "name": repository.Name, + "source_type": repository.SourceType, + "local_path": repository.LocalPath, + "provider": repository.Provider, + "provider_repo_id": repository.ProviderRepoID, + "provider_owner": repository.ProviderOwner, + "provider_name": repository.ProviderName, + "default_branch": repository.DefaultBranch, + "setup_script": repository.SetupScript, + "cleanup_script": repository.CleanupScript, + "created_at": repository.CreatedAt.Format(time.RFC3339), + "updated_at": repository.UpdatedAt.Format(time.RFC3339), + } + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish repository event", + zap.String("event_type", eventType), + zap.String("repository_id", repository.ID), + zap.Error(err)) + } +} + +func (s *Service) publishRepositoryScriptEvent(ctx context.Context, eventType string, script *models.RepositoryScript) { + if s.eventBus == nil || script == nil { + return + } + data := map[string]interface{}{ + "id": script.ID, + "repository_id": script.RepositoryID, + "name": script.Name, + "command": script.Command, + "position": script.Position, + "created_at": script.CreatedAt.Format(time.RFC3339), + "updated_at": script.UpdatedAt.Format(time.RFC3339), + } + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish repository script event", + zap.String("event_type", eventType), + zap.String("script_id", script.ID), + zap.Error(err)) + } +} diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index a64628c54f..cc95bc99c6 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -72,7 +72,7 @@ func createTestService(t *testing.T) (*Service, *MockEventBus, *repository.Memor repo := repository.NewMemoryRepository() eventBus := NewMockEventBus() log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) - svc := NewService(repo, eventBus, log) + svc := NewService(repo, eventBus, log, RepositoryDiscoveryConfig{}) return svc, eventBus, repo } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 1fad66b6b2..3b43161aeb 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -68,25 +68,31 @@ const ( ActionCommentList = "comment.list" // Notification actions (server -> client) - ActionACPProgress = "acp.progress" - ActionACPLog = "acp.log" - ActionACPResult = "acp.result" - ActionACPError = "acp.error" - ActionACPStatus = "acp.status" - ActionACPHeartbeat = "acp.heartbeat" - ActionTaskUpdated = "task.updated" - ActionAgentUpdated = "agent.updated" - ActionWorkspaceCreated = "workspace.created" - ActionWorkspaceUpdated = "workspace.updated" - ActionWorkspaceDeleted = "workspace.deleted" - ActionBoardCreated = "board.created" - ActionBoardUpdated = "board.updated" - ActionBoardDeleted = "board.deleted" - ActionColumnCreated = "column.created" - ActionColumnUpdated = "column.updated" - ActionColumnDeleted = "column.deleted" - ActionCommentAdded = "comment.added" - ActionInputRequested = "input.requested" + ActionACPProgress = "acp.progress" + ActionACPLog = "acp.log" + ActionACPResult = "acp.result" + ActionACPError = "acp.error" + ActionACPStatus = "acp.status" + ActionACPHeartbeat = "acp.heartbeat" + ActionTaskUpdated = "task.updated" + ActionAgentUpdated = "agent.updated" + ActionWorkspaceCreated = "workspace.created" + ActionWorkspaceUpdated = "workspace.updated" + ActionWorkspaceDeleted = "workspace.deleted" + ActionBoardCreated = "board.created" + ActionBoardUpdated = "board.updated" + ActionBoardDeleted = "board.deleted" + ActionColumnCreated = "column.created" + ActionColumnUpdated = "column.updated" + ActionColumnDeleted = "column.deleted" + ActionCommentAdded = "comment.added" + ActionInputRequested = "input.requested" + ActionRepositoryCreated = "repository.created" + ActionRepositoryUpdated = "repository.updated" + ActionRepositoryDeleted = "repository.deleted" + ActionRepositoryScriptCreated = "repository.script.created" + ActionRepositoryScriptUpdated = "repository.script.updated" + ActionRepositoryScriptDeleted = "repository.script.deleted" ) // Error codes diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 1ea579efee..ebe1e882fb 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -6,7 +6,13 @@ import type { Column, ListBoardsResponse, ListColumnsResponse, + RepositoryDiscoveryResponse, + ListRepositoriesResponse, + ListRepositoryScriptsResponse, ListWorkspacesResponse, + RepositoryPathValidationResponse, + Repository, + RepositoryScript, Workspace, } from '@/lib/types/http'; @@ -116,3 +122,99 @@ export async function updateColumnAction(id: string, payload: Partial(`${apiBaseUrl}/api/v1/columns/${id}`, { method: 'DELETE' }); } + +export async function listRepositoriesAction(workspaceId: string): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories`); +} + +export async function discoverRepositoriesAction( + workspaceId: string, + root?: string +): Promise { + const params = root ? `?root=${encodeURIComponent(root)}` : ''; + return fetchJson( + `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/discover${params}` + ); +} + +export async function validateRepositoryPathAction( + workspaceId: string, + path: string +): Promise { + const params = `?path=${encodeURIComponent(path)}`; + return fetchJson( + `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/validate${params}` + ); +} + +export async function createRepositoryAction(payload: { + workspace_id: string; + name: string; + source_type: string; + local_path: string; + provider: string; + provider_repo_id: string; + provider_owner: string; + provider_name: string; + default_branch: string; + setup_script: string; + cleanup_script: string; +}) { + return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${payload.workspace_id}/repositories`, { + method: 'POST', + body: JSON.stringify({ + name: payload.name, + source_type: payload.source_type, + local_path: payload.local_path, + provider: payload.provider, + provider_repo_id: payload.provider_repo_id, + provider_owner: payload.provider_owner, + provider_name: payload.provider_name, + default_branch: payload.default_branch, + setup_script: payload.setup_script, + cleanup_script: payload.cleanup_script, + }), + }); +} + +export async function updateRepositoryAction(id: string, payload: Partial) { + return fetchJson(`${apiBaseUrl}/api/v1/repositories/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); +} + +export async function deleteRepositoryAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/repositories/${id}`, { method: 'DELETE' }); +} + +export async function listRepositoryScriptsAction(repositoryId: string): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/repositories/${repositoryId}/scripts`); +} + +export async function createRepositoryScriptAction(payload: { + repository_id: string; + name: string; + command: string; + position: number; +}) { + return fetchJson(`${apiBaseUrl}/api/v1/repositories/${payload.repository_id}/scripts`, { + method: 'POST', + body: JSON.stringify({ + name: payload.name, + command: payload.command, + position: payload.position, + }), + }); +} + +export async function updateRepositoryScriptAction(id: string, payload: Partial) { + return fetchJson(`${apiBaseUrl}/api/v1/scripts/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export async function deleteRepositoryScriptAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/scripts/${id}`, { method: 'DELETE' }); +} diff --git a/apps/web/app/settings/workspace/[id]/page.tsx b/apps/web/app/settings/workspace/[id]/page.tsx index 87fd8cdef4..254681afc7 100644 --- a/apps/web/app/settings/workspace/[id]/page.tsx +++ b/apps/web/app/settings/workspace/[id]/page.tsx @@ -1,10 +1,17 @@ -import { getWorkspaceAction, listBoardsAction, listColumnsAction } from '@/app/actions/workspaces'; +import { + getWorkspaceAction, + listBoardsAction, + listColumnsAction, + listRepositoriesAction, + listRepositoryScriptsAction, +} from '@/app/actions/workspaces'; import { WorkspaceEditClient } from '@/app/settings/workspace/workspace-edit-client'; export default async function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; let workspace = null; let boardsWithColumns: Array>['boards'][number] & { columns: Awaited>['columns'] }> = []; + let repositoriesWithScripts: Array>['repositories'][number] & { scripts: Awaited>['scripts'] }> = []; try { workspace = await getWorkspaceAction(id); @@ -15,10 +22,24 @@ export default async function WorkspaceEditPage({ params }: { params: Promise<{ return { ...board, columns: columns.columns }; }) ); + const repositories = await listRepositoriesAction(id); + repositoriesWithScripts = await Promise.all( + repositories.repositories.map(async (repository) => { + const scripts = await listRepositoryScriptsAction(repository.id); + return { ...repository, scripts: scripts.scripts }; + }) + ); } catch { workspace = null; boardsWithColumns = []; + repositoriesWithScripts = []; } - return ; + return ( + + ); } diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 6b220f7074..28da9345cb 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { IconGitBranch, IconPlus, IconTrash } from '@tabler/icons-react'; +import { IconGitBranch, IconLoader2, IconPlus, IconTrash } from '@tabler/icons-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -22,27 +22,44 @@ import { BoardCard } from '@/components/settings/board-card'; import { createBoardAction, createColumnAction, + discoverRepositoriesAction, + createRepositoryAction, + createRepositoryScriptAction, deleteBoardAction, deleteColumnAction, + deleteRepositoryAction, + deleteRepositoryScriptAction, deleteWorkspaceAction, updateBoardAction, updateColumnAction, + validateRepositoryPathAction, + updateRepositoryAction, + updateRepositoryScriptAction, updateWorkspaceAction, } from '@/app/actions/workspaces'; -import type { Board, Column, Workspace } from '@/lib/types/http'; -import type { Repository } from '@/lib/settings/types'; +import type { + Board, + Column, + LocalRepository, + Repository, + RepositoryScript, + Workspace, +} from '@/lib/types/http'; import { useRequest } from '@/lib/http/use-request'; import { useToast } from '@/components/toast-provider'; +import { useAppStore } from '@/components/state-provider'; import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; type BoardWithColumns = Board & { columns: Column[] }; +type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; type WorkspaceEditClientProps = { workspace: Workspace | null; boards: BoardWithColumns[]; + repositories: RepositoryWithScripts[]; }; -export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientProps) { +export function WorkspaceEditClient({ workspace, boards, repositories }: WorkspaceEditClientProps) { const router = useRouter(); const { toast } = useToast(); const [currentWorkspace, setCurrentWorkspace] = useState(workspace); @@ -50,11 +67,28 @@ export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientPr const [boardItems, setBoardItems] = useState(boards); const [savedWorkspaceName, setSavedWorkspaceName] = useState(workspace?.name ?? ''); const [savedBoardItems, setSavedBoardItems] = useState(boards); - const [repositories, setRepositories] = useState([]); + const [repositoryItems, setRepositoryItems] = useState(repositories); + const [savedRepositoryItems, setSavedRepositoryItems] = useState(repositories); + const [discoveredRepositories, setDiscoveredRepositories] = useState([]); + const [localRepoDialogOpen, setLocalRepoDialogOpen] = useState(false); + const [remoteRepoDialogOpen, setRemoteRepoDialogOpen] = useState(false); + const [repoSearch, setRepoSearch] = useState(''); + const [selectedRepoPath, setSelectedRepoPath] = useState(null); + const [manualRepoPath, setManualRepoPath] = useState(''); + const [manualValidation, setManualValidation] = useState<{ + status: 'idle' | 'loading' | 'success' | 'error'; + message?: string; + isValid?: boolean; + path?: string; + }>({ status: 'idle' }); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(''); const saveWorkspaceRequest = useRequest(updateWorkspaceAction); const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction); + const discoverRepositoriesRequest = useRequest(discoverRepositoriesAction); + const validateRepositoryPathRequest = useRequest(validateRepositoryPathAction); + const workspaces = useAppStore((state) => state.workspaces.items); + const setWorkspaces = useAppStore((state) => state.setWorkspaces); const handleSaveWorkspaceName = async () => { if (!currentWorkspace) return; @@ -64,6 +98,11 @@ export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientPr const updated = await saveWorkspaceRequest.run(currentWorkspace.id, { name: trimmed }); setCurrentWorkspace((prev) => (prev ? { ...prev, ...updated } : prev)); setSavedWorkspaceName(updated.name); + setWorkspaces( + workspaces.map((workspace) => + workspace.id === updated.id ? { ...workspace, name: updated.name } : workspace + ) + ); } catch (error) { toast({ title: 'Failed to save workspace', @@ -113,26 +152,281 @@ export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientPr const isWorkspaceDirty = workspaceNameDraft.trim() !== savedWorkspaceName; - const handleAddRepository = (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const newRepo: Repository = { - id: crypto.randomUUID(), - name: formData.get('name') as string, - path: formData.get('path') as string, - setupScript: formData.get('setupScript') as string, - cleanupScript: formData.get('cleanupScript') as string, - customScripts: [], + const cloneRepository = (repo: RepositoryWithScripts): RepositoryWithScripts => ({ + ...repo, + scripts: repo.scripts.map((script) => ({ ...script })), + }); + + const savedRepositoriesById = useMemo(() => { + return new Map(savedRepositoryItems.map((repo) => [repo.id, repo])); + }, [savedRepositoryItems]); + + const filteredRepositories = useMemo(() => { + const query = repoSearch.trim().toLowerCase(); + if (!query) return discoveredRepositories; + return discoveredRepositories.filter( + (repo) => repo.name.toLowerCase().includes(query) || repo.path.toLowerCase().includes(query) + ); + }, [discoveredRepositories, repoSearch]); + + const canSaveLocalRepo = + Boolean(selectedRepoPath) || (manualValidation.status === 'success' && manualValidation.isValid); + + const isRepositoryDirty = (repo: RepositoryWithScripts) => { + const saved = savedRepositoriesById.get(repo.id); + if (!saved) return true; + return ( + repo.name !== saved.name || + repo.source_type !== saved.source_type || + repo.local_path !== saved.local_path || + repo.provider !== saved.provider || + repo.provider_repo_id !== saved.provider_repo_id || + repo.provider_owner !== saved.provider_owner || + repo.provider_name !== saved.provider_name || + repo.default_branch !== saved.default_branch || + repo.setup_script !== saved.setup_script || + repo.cleanup_script !== saved.cleanup_script + ); + }; + + const areRepositoryScriptsDirty = (repo: RepositoryWithScripts) => { + const saved = savedRepositoriesById.get(repo.id); + if (!saved) return repo.scripts.length > 0; + if (repo.scripts.length !== saved.scripts.length) return true; + const savedScripts = new Map(saved.scripts.map((script) => [script.id, script])); + for (const script of repo.scripts) { + const savedScript = savedScripts.get(script.id); + if (!savedScript) return true; + if ( + script.name !== savedScript.name || + script.command !== savedScript.command || + script.position !== savedScript.position + ) { + return true; + } + } + return false; + }; + + const handleUpdateRepository = (repoId: string, updates: Partial) => { + setRepositoryItems((prev) => + prev.map((repo) => (repo.id === repoId ? { ...repo, ...updates } : repo)) + ); + }; + + const handleDiscoverRepositories = async () => { + if (!currentWorkspace) return; + try { + const result = await discoverRepositoriesRequest.run(currentWorkspace.id); + setDiscoveredRepositories(result.repositories); + } catch (error) { + toast({ + title: 'Failed to discover repositories', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } + }; + + const openLocalRepositoryDialog = async () => { + setLocalRepoDialogOpen(true); + setRepoSearch(''); + setSelectedRepoPath(null); + setManualRepoPath(''); + setManualValidation({ status: 'idle' }); + await handleDiscoverRepositories(); + }; + + const handleValidateManualPath = async () => { + if (!currentWorkspace || !manualRepoPath.trim()) return; + setManualValidation({ status: 'loading' }); + try { + const result = await validateRepositoryPathRequest.run(currentWorkspace.id, manualRepoPath.trim()); + if (result.allowed && result.exists && result.is_git) { + setManualValidation({ + status: 'success', + isValid: true, + message: 'Valid git repository', + path: result.path, + }); + } else { + setManualValidation({ + status: 'error', + isValid: false, + message: result.message || 'Invalid repository path', + path: result.path, + }); + } + } catch (error) { + setManualValidation({ + status: 'error', + isValid: false, + message: error instanceof Error ? error.message : 'Request failed', + }); + } + }; + + const handleConfirmLocalRepository = () => { + if (!currentWorkspace) return; + const selectedRepo = discoveredRepositories.find((repo) => repo.path === selectedRepoPath); + const path = selectedRepo?.path || manualValidation.path || manualRepoPath.trim(); + if (!path) return; + const name = selectedRepo?.name || path.split('/').filter(Boolean).slice(-1)[0] || 'New Repository'; + const draftId = `temp-repo-${crypto.randomUUID()}`; + const draftRepo: RepositoryWithScripts = { + id: draftId, + workspace_id: currentWorkspace.id, + name, + source_type: 'local', + local_path: path, + provider: '', + provider_repo_id: '', + provider_owner: '', + provider_name: '', + default_branch: selectedRepo?.default_branch || '', + setup_script: '', + cleanup_script: '', + created_at: '', + updated_at: '', + scripts: [], }; - setRepositories((prev) => [...prev, newRepo]); + setRepositoryItems((prev) => [draftRepo, ...prev]); + setLocalRepoDialogOpen(false); }; - const handleUpdateRepository = (repo: Repository) => { - setRepositories((prev) => prev.map((item) => (item.id === repo.id ? repo : item))); + const handleAddRepositoryScript = (repoId: string) => { + const script: RepositoryScript = { + id: `temp-script-${crypto.randomUUID()}`, + repository_id: repoId, + name: '', + command: '', + position: repositoryItems.find((repo) => repo.id === repoId)?.scripts.length ?? 0, + created_at: '', + updated_at: '', + }; + setRepositoryItems((prev) => + prev.map((repo) => + repo.id === repoId ? { ...repo, scripts: [...repo.scripts, script] } : repo + ) + ); }; - const handleDeleteRepository = (id: string) => { - setRepositories((prev) => prev.filter((repo) => repo.id !== id)); + const handleUpdateRepositoryScript = (repoId: string, scriptId: string, updates: Partial) => { + setRepositoryItems((prev) => + prev.map((repo) => + repo.id === repoId + ? { + ...repo, + scripts: repo.scripts.map((script) => + script.id === scriptId ? { ...script, ...updates } : script + ), + } + : repo + ) + ); + }; + + const handleDeleteRepositoryScript = (repoId: string, scriptId: string) => { + setRepositoryItems((prev) => + prev.map((repo) => + repo.id === repoId + ? { ...repo, scripts: repo.scripts.filter((script) => script.id !== scriptId) } + : repo + ) + ); + }; + + const handleSaveRepository = async (repoId: string) => { + const repo = repositoryItems.find((item) => item.id === repoId); + if (!repo) return; + if (repoId.startsWith('temp-repo-')) { + const created = await createRepositoryAction({ + workspace_id: currentWorkspace?.id ?? repo.workspace_id, + name: repo.name.trim() || 'New Repository', + source_type: repo.source_type || 'local', + local_path: repo.local_path, + provider: repo.provider, + provider_repo_id: repo.provider_repo_id, + provider_owner: repo.provider_owner, + provider_name: repo.provider_name, + default_branch: repo.default_branch, + setup_script: repo.setup_script, + cleanup_script: repo.cleanup_script, + }); + const scripts = await Promise.all( + repo.scripts.map((script, index) => + createRepositoryScriptAction({ + repository_id: created.id, + name: script.name.trim() || 'New Script', + command: script.command.trim() || 'echo ""', + position: script.position ?? index, + }) + ) + ); + const nextRepo: RepositoryWithScripts = { ...created, scripts }; + setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item))); + setSavedRepositoryItems((prev) => [cloneRepository(nextRepo), ...prev]); + return; + } + + const updated = await updateRepositoryAction(repoId, { + name: repo.name, + source_type: repo.source_type, + local_path: repo.local_path, + provider: repo.provider, + provider_repo_id: repo.provider_repo_id, + provider_owner: repo.provider_owner, + provider_name: repo.provider_name, + default_branch: repo.default_branch, + setup_script: repo.setup_script, + cleanup_script: repo.cleanup_script, + }); + + const saved = savedRepositoriesById.get(repoId); + const savedScripts = saved ? saved.scripts : []; + const currentScriptIds = new Set(repo.scripts.map((script) => script.id)); + + await Promise.all( + savedScripts + .filter((script) => !currentScriptIds.has(script.id)) + .map((script) => deleteRepositoryScriptAction(script.id)) + ); + + const nextScripts = await Promise.all( + repo.scripts.map((script, index) => { + if (script.id.startsWith('temp-script-')) { + return createRepositoryScriptAction({ + repository_id: repoId, + name: script.name.trim() || 'New Script', + command: script.command.trim() || 'echo ""', + position: script.position ?? index, + }); + } + return updateRepositoryScriptAction(script.id, { + name: script.name, + command: script.command, + position: script.position ?? index, + }); + }) + ); + + const nextRepo: RepositoryWithScripts = { ...updated, scripts: nextScripts }; + setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item))); + setSavedRepositoryItems((prev) => + prev.some((item) => item.id === repoId) + ? prev.map((item) => (item.id === repoId ? cloneRepository(nextRepo) : item)) + : [...prev, cloneRepository(nextRepo)] + ); + }; + + const handleDeleteRepository = async (repoId: string) => { + if (repoId.startsWith('temp-repo-')) { + setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId)); + return; + } + await deleteRepositoryAction(repoId); + setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId)); + setSavedRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId)); }; const handleAddBoard = () => { @@ -336,6 +630,7 @@ export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientPr if (deleteConfirmText !== 'delete') return; try { await deleteWorkspaceRequest.run(currentWorkspace.id); + setWorkspaces(workspaces.filter((workspace) => workspace.id !== currentWorkspace.id)); router.push('/settings/workspace'); } catch (error) { toast({ @@ -403,40 +698,30 @@ export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientPr icon={} title="Repositories" description="Repositories in this workspace" + action={ +
+ + +
+ } >
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
- - {repositories.map((repo) => ( + {repositoryItems.map((repo) => ( handleDeleteRepository(repo.id)} + onAddScript={handleAddRepositoryScript} + onUpdateScript={handleUpdateRepositoryScript} + onDeleteScript={handleDeleteRepositoryScript} + onSave={handleSaveRepository} + onDelete={handleDeleteRepository} /> ))}
@@ -444,6 +729,117 @@ export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientPr + + + + Add Local Repository + + Select a discovered repository or provide an absolute path to validate. + + +
+
+ + setRepoSearch(e.target.value)} + /> +
+ {discoverRepositoriesRequest.isLoading ? ( +
+ + Scanning repositories... +
+ ) : filteredRepositories.length === 0 ? ( +
No repositories found.
+ ) : ( + filteredRepositories.map((repo) => ( + + )) + )} +
+
+ +
+ +
+ { + setManualRepoPath(e.target.value); + setSelectedRepoPath(null); + setManualValidation({ status: 'idle' }); + }} + /> + +
+ {manualValidation.status === 'error' && ( +

{manualValidation.message}

+ )} + {manualValidation.status === 'success' && ( +

{manualValidation.message}

+ )} +
+
+ + + + +
+
+ + + + + Add Remote Repository + Remote providers are coming soon. + +
+ + + +
+ + + +
+
+ } title="Boards" diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx index 87f3818cdc..72feabf7ef 100644 --- a/apps/web/components/settings/repository-card.tsx +++ b/apps/web/components/settings/repository-card.tsx @@ -1,250 +1,230 @@ 'use client'; import { useState } from 'react'; -import { IconGitBranch, IconTrash, IconEdit, IconCheck, IconX, IconPlus } from '@tabler/icons-react'; +import { IconGitBranch, IconPlus, IconTrash, IconX } from '@tabler/icons-react'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { Badge } from '@/components/ui/badge'; -import type { Repository } from '@/lib/settings/types'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useRequest } from '@/lib/http/use-request'; +import { useToast } from '@/components/toast-provider'; +import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; +import type { Repository, RepositoryScript } from '@/lib/types/http'; + +type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; type RepositoryCardProps = { - repository: Repository; - onUpdate: (repo: Repository) => void; - onDelete: () => void; + repository: RepositoryWithScripts; + isRepositoryDirty: boolean; + areScriptsDirty: boolean; + onUpdate: (repoId: string, updates: Partial) => void; + onAddScript: (repoId: string) => void; + onUpdateScript: (repoId: string, scriptId: string, updates: Partial) => void; + onDeleteScript: (repoId: string, scriptId: string) => void; + onSave: (repoId: string) => Promise; + onDelete: (repoId: string) => Promise | void; }; -export function RepositoryCard({ repository, onUpdate, onDelete }: RepositoryCardProps) { - const [isEditing, setIsEditing] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - const [editedRepo, setEditedRepo] = useState(repository); +export function RepositoryCard({ + repository, + isRepositoryDirty, + areScriptsDirty, + onUpdate, + onAddScript, + onUpdateScript, + onDeleteScript, + onSave, + onDelete, +}: RepositoryCardProps) { + const { toast } = useToast(); + const [deleteOpen, setDeleteOpen] = useState(false); + const saveRequest = useRequest(() => onSave(repository.id)); + const deleteRequest = useRequest(() => onDelete(repository.id)); - const handleSave = () => { - onUpdate(editedRepo); - setIsEditing(false); + const handleSave = async () => { + try { + await saveRequest.run(); + } catch (error) { + toast({ + title: 'Failed to save repository', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } }; - const handleCancel = () => { - setEditedRepo(repository); - setIsEditing(false); + const handleDelete = async () => { + try { + await deleteRequest.run(); + setDeleteOpen(false); + } catch (error) { + toast({ + title: 'Failed to delete repository', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } }; - const handleFolderPick = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.webkitdirectory = true; - input.onchange = (event: Event) => { - const target = event.target as HTMLInputElement | null; - const files = target?.files; - if (files && files[0]) { - const path = files[0].webkitRelativePath.split('/')[0]; - setEditedRepo({ ...editedRepo, path: `/${path}` }); - } - }; - input.click(); - }; + return ( + + +
+
+
+ + +
+ +
- const handleAddCustomScript = () => { - setEditedRepo({ - ...editedRepo, - customScripts: [ - ...editedRepo.customScripts, - { id: crypto.randomUUID(), name: '', command: '#!/bin/bash\n' }, - ], - }); - }; - - const handleRemoveCustomScript = (id: string) => { - setEditedRepo({ - ...editedRepo, - customScripts: editedRepo.customScripts.filter((s) => s.id !== id), - }); - }; - - const handleUpdateCustomScript = (id: string, field: 'name' | 'command', value: string) => { - setEditedRepo({ - ...editedRepo, - customScripts: editedRepo.customScripts.map((s) => - s.id === id ? { ...s, [field]: value } : s - ), - }); - }; - - if (isEditing) { - return ( - - -
+
setEditedRepo({ ...editedRepo, name: e.target.value })} + value={repository.name} + onChange={(e) => onUpdate(repository.id, { name: e.target.value })} + placeholder="my-repo" />
+
-
- -
- setEditedRepo({ ...editedRepo, path: e.target.value })} - placeholder="/path/to/repository" - /> - -
-
+
+ + onUpdate(repository.id, { local_path: e.target.value })} + placeholder="/path/to/repository" + disabled={repository.source_type !== 'local'} + /> +
+