feat: added repositories support
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 npm install"
|
||||
value={repository.setup_script}
|
||||
onChange={(e) => onUpdate(repository.id, { setup_script: e.target.value })}
|
||||
placeholder="#!/bin/bash # 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 rm -rf node_modules"
|
||||
value={repository.cleanup_script}
|
||||
onChange={(e) => onUpdate(repository.id, { cleanup_script: e.target.value })}
|
||||
placeholder="#!/bin/bash # 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 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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user