feat: added repositories support

This commit is contained in:
Carlos Florêncio
2026-01-11 19:59:28 +00:00
parent 00c65268bc
commit 473b336b13
24 changed files with 2823 additions and 276 deletions
+11 -1
View File
@@ -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)
+22 -7
View File
@@ -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())
}
}
+14
View File
@@ -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"
@@ -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()
@@ -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)
@@ -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)
@@ -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
}
+98
View File
@@ -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 != "" {
@@ -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
}
@@ -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})
}
+40 -8
View File
@@ -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
@@ -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
}
@@ -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{})
@@ -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()
}
@@ -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
}
@@ -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,
})
}
+271 -7
View File
@@ -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))
}
}
@@ -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
}
+25 -19
View File
@@ -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
+102
View File
@@ -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<Pick<Colum
export async function deleteColumnAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/columns/${id}`, { method: 'DELETE' });
}
export async function listRepositoriesAction(workspaceId: string): Promise<ListRepositoriesResponse> {
return fetchJson<ListRepositoriesResponse>(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories`);
}
export async function discoverRepositoriesAction(
workspaceId: string,
root?: string
): Promise<RepositoryDiscoveryResponse> {
const params = root ? `?root=${encodeURIComponent(root)}` : '';
return fetchJson<RepositoryDiscoveryResponse>(
`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/discover${params}`
);
}
export async function validateRepositoryPathAction(
workspaceId: string,
path: string
): Promise<RepositoryPathValidationResponse> {
const params = `?path=${encodeURIComponent(path)}`;
return fetchJson<RepositoryPathValidationResponse>(
`${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<Repository>(`${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<Repository>) {
return fetchJson<Repository>(`${apiBaseUrl}/api/v1/repositories/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function deleteRepositoryAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/repositories/${id}`, { method: 'DELETE' });
}
export async function listRepositoryScriptsAction(repositoryId: string): Promise<ListRepositoryScriptsResponse> {
return fetchJson<ListRepositoryScriptsResponse>(`${apiBaseUrl}/api/v1/repositories/${repositoryId}/scripts`);
}
export async function createRepositoryScriptAction(payload: {
repository_id: string;
name: string;
command: string;
position: number;
}) {
return fetchJson<RepositoryScript>(`${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<RepositoryScript>) {
return fetchJson<RepositoryScript>(`${apiBaseUrl}/api/v1/scripts/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteRepositoryScriptAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/scripts/${id}`, { method: 'DELETE' });
}
+23 -2
View File
@@ -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<Awaited<ReturnType<typeof listBoardsAction>>['boards'][number] & { columns: Awaited<ReturnType<typeof listColumnsAction>>['columns'] }> = [];
let repositoriesWithScripts: Array<Awaited<ReturnType<typeof listRepositoriesAction>>['repositories'][number] & { scripts: Awaited<ReturnType<typeof listRepositoryScriptsAction>>['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 <WorkspaceEditClient workspace={workspace} boards={boardsWithColumns} />;
return (
<WorkspaceEditClient
workspace={workspace}
boards={boardsWithColumns}
repositories={repositoriesWithScripts}
/>
);
}
@@ -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 | null>(workspace);
@@ -50,11 +67,28 @@ export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientPr
const [boardItems, setBoardItems] = useState<BoardWithColumns[]>(boards);
const [savedWorkspaceName, setSavedWorkspaceName] = useState(workspace?.name ?? '');
const [savedBoardItems, setSavedBoardItems] = useState<BoardWithColumns[]>(boards);
const [repositories, setRepositories] = useState<Repository[]>([]);
const [repositoryItems, setRepositoryItems] = useState<RepositoryWithScripts[]>(repositories);
const [savedRepositoryItems, setSavedRepositoryItems] = useState<RepositoryWithScripts[]>(repositories);
const [discoveredRepositories, setDiscoveredRepositories] = useState<LocalRepository[]>([]);
const [localRepoDialogOpen, setLocalRepoDialogOpen] = useState(false);
const [remoteRepoDialogOpen, setRemoteRepoDialogOpen] = useState(false);
const [repoSearch, setRepoSearch] = useState('');
const [selectedRepoPath, setSelectedRepoPath] = useState<string | null>(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<HTMLFormElement>) => {
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<Repository>) => {
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<RepositoryScript>) => {
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={<IconGitBranch className="h-5 w-5" />}
title="Repositories"
description="Repositories in this workspace"
action={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openLocalRepositoryDialog}>
Local Repository
</Button>
<Button size="sm" variant="outline" onClick={() => setRemoteRepoDialogOpen(true)}>
Remote Repository
</Button>
</div>
}
>
<div className="grid gap-3">
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddRepository} className="space-y-4">
<div className="space-y-2">
<Label>Repository Name</Label>
<Input name="name" placeholder="my-project" required />
</div>
<div className="space-y-2">
<Label>Directory Path</Label>
<Input name="path" placeholder="/path/to/repository" required />
</div>
<div className="space-y-2">
<Label>Setup Script</Label>
<Input name="setupScript" placeholder="npm install" />
</div>
<div className="space-y-2">
<Label>Cleanup Script</Label>
<Input name="cleanupScript" placeholder="npm run cleanup" />
</div>
<div className="flex justify-end">
<Button type="submit">Add Repository</Button>
</div>
</form>
</CardContent>
</Card>
{repositories.map((repo) => (
{repositoryItems.map((repo) => (
<RepositoryCard
key={repo.id}
repository={repo}
isRepositoryDirty={isRepositoryDirty(repo)}
areScriptsDirty={areRepositoryScriptsDirty(repo)}
onUpdate={handleUpdateRepository}
onDelete={() => handleDeleteRepository(repo.id)}
onAddScript={handleAddRepositoryScript}
onUpdateScript={handleUpdateRepositoryScript}
onDeleteScript={handleDeleteRepositoryScript}
onSave={handleSaveRepository}
onDelete={handleDeleteRepository}
/>
))}
</div>
@@ -444,6 +729,117 @@ export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientPr
<Separator />
<Dialog open={localRepoDialogOpen} onOpenChange={setLocalRepoDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Local Repository</DialogTitle>
<DialogDescription>
Select a discovered repository or provide an absolute path to validate.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Discovered repositories</Label>
<Input
placeholder="Filter repositories..."
value={repoSearch}
onChange={(e) => setRepoSearch(e.target.value)}
/>
<div className="max-h-56 overflow-auto rounded-md border border-border">
{discoverRepositoriesRequest.isLoading ? (
<div className="flex items-center gap-2 p-3 text-sm text-muted-foreground">
<IconLoader2 className="h-4 w-4 animate-spin" />
Scanning repositories...
</div>
) : filteredRepositories.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No repositories found.</div>
) : (
filteredRepositories.map((repo) => (
<button
key={repo.path}
type="button"
className={`flex w-full flex-col px-3 py-2 text-left text-sm hover:bg-muted ${
selectedRepoPath === repo.path ? 'bg-muted' : ''
}`}
onClick={() => {
setSelectedRepoPath(repo.path);
setManualRepoPath('');
setManualValidation({ status: 'idle' });
}}
>
<span className="font-medium">{repo.name}</span>
<span className="text-xs text-muted-foreground">{repo.path}</span>
</button>
))
)}
</div>
</div>
<div className="space-y-2">
<Label>Manual path</Label>
<div className="flex items-center gap-2">
<Input
placeholder="/absolute/path/to/repository"
value={manualRepoPath}
onChange={(e) => {
setManualRepoPath(e.target.value);
setSelectedRepoPath(null);
setManualValidation({ status: 'idle' });
}}
/>
<Button
type="button"
variant="outline"
onClick={handleValidateManualPath}
disabled={!manualRepoPath.trim() || validateRepositoryPathRequest.isLoading}
>
{validateRepositoryPathRequest.isLoading ? 'Checking...' : 'Validate'}
</Button>
</div>
{manualValidation.status === 'error' && (
<p className="text-xs text-destructive">{manualValidation.message}</p>
)}
{manualValidation.status === 'success' && (
<p className="text-xs text-emerald-500">{manualValidation.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setLocalRepoDialogOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={handleConfirmLocalRepository} disabled={!canSaveLocalRepo}>
Use Repository
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={remoteRepoDialogOpen} onOpenChange={setRemoteRepoDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Remote Repository</DialogTitle>
<DialogDescription>Remote providers are coming soon.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Button type="button" variant="outline" className="w-full justify-start" disabled>
GitHub (coming soon)
</Button>
<Button type="button" variant="outline" className="w-full justify-start" disabled>
GitLab (coming soon)
</Button>
<Button type="button" variant="outline" className="w-full justify-start" disabled>
Bitbucket (coming soon)
</Button>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRemoteRepoDialogOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Boards"
+160 -180
View File
@@ -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<Repository>) => void;
onAddScript: (repoId: string) => void;
onUpdateScript: (repoId: string, scriptId: string, updates: Partial<RepositoryScript>) => void;
onDeleteScript: (repoId: string, scriptId: string) => void;
onSave: (repoId: string) => Promise<void>;
onDelete: (repoId: string) => Promise<void> | void;
};
export function RepositoryCard({ repository, onUpdate, onDelete }: RepositoryCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [editedRepo, setEditedRepo] = useState<Repository>(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 (
<Card>
<CardContent className="pt-6">
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<IconGitBranch className="h-4 w-4 text-muted-foreground" />
<Label className="flex items-center gap-2">
<span>Repository</span>
{isRepositoryDirty && <UnsavedChangesBadge />}
</Label>
</div>
<UnsavedSaveButton
isDirty={isRepositoryDirty || areScriptsDirty}
isLoading={saveRequest.isLoading}
status={saveRequest.status}
onClick={handleSave}
/>
</div>
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 (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Repository Name</Label>
<Input
value={editedRepo.name}
onChange={(e) => setEditedRepo({ ...editedRepo, name: e.target.value })}
value={repository.name}
onChange={(e) => onUpdate(repository.id, { name: e.target.value })}
placeholder="my-repo"
/>
</div>
</div>
<div className="space-y-2">
<Label>Directory Path</Label>
<div className="flex gap-2">
<Input
value={editedRepo.path}
onChange={(e) => setEditedRepo({ ...editedRepo, path: e.target.value })}
placeholder="/path/to/repository"
/>
<Button type="button" variant="outline" onClick={handleFolderPick}>
Browse
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Local Path</Label>
<Input
value={repository.local_path}
onChange={(e) => onUpdate(repository.id, { local_path: e.target.value })}
placeholder="/path/to/repository"
disabled={repository.source_type !== 'local'}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Setup Script</Label>
<Textarea
value={editedRepo.setupScript}
onChange={(e) => setEditedRepo({ ...editedRepo, setupScript: e.target.value })}
placeholder="#!/bin/bash&#10;npm install"
value={repository.setup_script}
onChange={(e) => onUpdate(repository.id, { setup_script: e.target.value })}
placeholder="#!/bin/bash&#10;# any manual setup you need"
rows={3}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Runs when the repo is cloned or a git worktree is created.
</p>
</div>
<div className="space-y-2">
<Label>Cleanup Script</Label>
<Textarea
value={editedRepo.cleanupScript}
onChange={(e) => setEditedRepo({ ...editedRepo, cleanupScript: e.target.value })}
placeholder="#!/bin/bash&#10;rm -rf node_modules"
value={repository.cleanup_script}
onChange={(e) => onUpdate(repository.id, { cleanup_script: e.target.value })}
placeholder="#!/bin/bash&#10;# any manual clean up you need"
rows={3}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Runs when the task is completed to clean up the workspace.
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Custom Scripts</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddCustomScript}
>
<IconPlus className="h-4 w-4 mr-1" />
Add Script
</Button>
</div>
<div className="space-y-3">
{editedRepo.customScripts.map((script) => (
<div key={script.id} className="border rounded-md p-3 space-y-2">
<div className="flex gap-2">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label className="flex items-center gap-2">
<span>Custom Scripts</span>
{areScriptsDirty && <UnsavedChangesBadge />}
</Label>
<Button type="button" variant="outline" size="sm" onClick={() => onAddScript(repository.id)}>
<IconPlus className="h-4 w-4 mr-1" />
Add Script
</Button>
</div>
<div className="space-y-3">
{repository.scripts.length === 0 ? (
<p className="text-sm text-muted-foreground">No scripts yet.</p>
) : (
repository.scripts.map((script) => (
<div key={script.id} className="grid gap-2">
<div className="flex items-center gap-2">
<Input
placeholder="Script name"
value={script.name}
onChange={(e) =>
handleUpdateCustomScript(script.id, 'name', e.target.value)
onUpdateScript(repository.id, script.id, { name: e.target.value })
}
className="flex-1"
placeholder="Script name"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveCustomScript(script.id)}
onClick={() => onDeleteScript(repository.id, script.id)}
>
<IconX className="h-4 w-4" />
</Button>
</div>
<Textarea
placeholder="#!/bin/bash&#10;npm run dev"
value={script.command}
onChange={(e) =>
handleUpdateCustomScript(script.id, 'command', e.target.value)
onUpdateScript(repository.id, script.id, { command: e.target.value })
}
placeholder="#!/bin/bash&#10;npm run dev"
rows={3}
className="font-mono text-sm"
/>
</div>
))}
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}>
<IconX className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={handleSave}>
<IconCheck className="h-4 w-4 mr-2" />
Save
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="hover:bg-accent transition-colors cursor-pointer" onClick={() => setIsEditing(true)}>
<CardContent className="py-4">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconGitBranch className="h-4 w-4" />
</div>
<div className="flex-1">
<h4 className="font-medium">{repository.name}</h4>
<p className="text-sm text-muted-foreground">{repository.path}</p>
{repository.customScripts.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{repository.customScripts.map((script) => (
<Badge key={script.id} variant="secondary" className="text-xs">
{script.name}
</Badge>
))}
</div>
)}
</div>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={() => setIsEditing(true)}>
<IconEdit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onDelete}>
<IconTrash className="h-4 w-4 text-destructive" />
</Button>
))
)}
</div>
</div>
{isExpanded && (
<div className="pl-11 space-y-2 text-sm">
<div>
<p className="font-medium">Setup:</p>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto">
{repository.setupScript}
</pre>
</div>
<div>
<p className="font-medium">Cleanup:</p>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto">
{repository.cleanupScript}
</pre>
</div>
</div>
)}
{(repository.setupScript || repository.cleanupScript) && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="pl-11 text-xs text-muted-foreground hover:text-foreground"
<div className="flex justify-end">
<Button
type="button"
variant="destructive"
onClick={() => setDeleteOpen(true)}
disabled={deleteRequest.isLoading}
>
{isExpanded ? 'Hide' : 'Show'} scripts
</button>
)}
<IconTrash className="h-4 w-4 mr-2" />
Delete Repository
</Button>
</div>
</div>
</CardContent>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete repository</DialogTitle>
<DialogDescription>
This will remove the repository and its scripts. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={handleDelete}>
Delete Repository
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
+58
View File
@@ -36,6 +36,33 @@ export type Column = {
updated_at: string;
};
export type Repository = {
id: string;
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;
created_at: string;
updated_at: string;
};
export type RepositoryScript = {
id: string;
repository_id: string;
name: string;
command: string;
position: number;
created_at: string;
updated_at: string;
};
export type Task = {
id: string;
workspace_id: string;
@@ -71,6 +98,37 @@ export type ListColumnsResponse = {
total: number;
};
export type ListRepositoriesResponse = {
repositories: Repository[];
total: number;
};
export type ListRepositoryScriptsResponse = {
scripts: RepositoryScript[];
total: number;
};
export type LocalRepository = {
path: string;
name: string;
default_branch?: string;
};
export type RepositoryDiscoveryResponse = {
roots: string[];
repositories: LocalRepository[];
total: number;
};
export type RepositoryPathValidationResponse = {
path: string;
exists: boolean;
is_git: boolean;
allowed: boolean;
default_branch?: string;
message?: string;
};
export type ListWorkspacesResponse = {
workspaces: Workspace[];
total: number;