Files
kandev/apps/backend/internal/task/handlers/repository_handlers.go
T
Kandev Agent 9e8079de69 feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create
Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.
2026-04-26 21:23:48 +01:00

484 lines
19 KiB
Go

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/dto"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
type RepositoryHandlers struct {
service *service.Service
logger *logger.Logger
}
func NewRepositoryHandlers(svc *service.Service, log *logger.Logger) *RepositoryHandlers {
return &RepositoryHandlers{
service: svc,
logger: log.WithFields(zap.String("component", "task-repository-handlers")),
}
}
func RegisterRepositoryRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *service.Service, log *logger.Logger) {
handlers := NewRepositoryHandlers(svc, 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)
// Unified branch listing — accepts either ?repository_id= for an imported
// workspace repo, or ?path= for an on-machine folder discovered but not
// yet imported. Both paths bottom out in `listGitBranches`; the only
// difference is how the absolute path is resolved.
api.GET("/workspaces/:id/branches", h.httpListBranches)
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(ws.ActionRepositoryList, h.wsListRepositories)
dispatcher.RegisterFunc(ws.ActionRepositoryCreate, h.wsCreateRepository)
dispatcher.RegisterFunc(ws.ActionRepositoryGet, h.wsGetRepository)
dispatcher.RegisterFunc(ws.ActionRepositoryUpdate, h.wsUpdateRepository)
dispatcher.RegisterFunc(ws.ActionRepositoryDelete, h.wsDeleteRepository)
dispatcher.RegisterFunc(ws.ActionRepositoryScriptList, h.wsListRepositoryScripts)
dispatcher.RegisterFunc(ws.ActionRepositoryScriptCreate, h.wsCreateRepositoryScript)
dispatcher.RegisterFunc(ws.ActionRepositoryScriptGet, h.wsGetRepositoryScript)
dispatcher.RegisterFunc(ws.ActionRepositoryScriptUpdate, h.wsUpdateRepositoryScript)
dispatcher.RegisterFunc(ws.ActionRepositoryScriptDelete, h.wsDeleteRepositoryScript)
}
// HTTP handlers
func (h *RepositoryHandlers) httpListRepositories(c *gin.Context) {
workspaceID := c.Param("id")
includeScripts := c.Query("include_scripts") == queryValueTrue
repositories, err := h.service.ListRepositories(c.Request.Context(), workspaceID)
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
}
resp := dto.ListRepositoriesResponse{
Repositories: make([]dto.RepositoryDTO, 0, len(repositories)),
Total: len(repositories),
}
var scriptsByRepo map[string][]*models.RepositoryScript
if includeScripts && len(repositories) > 0 {
repoIDs := make([]string, len(repositories))
for i, r := range repositories {
repoIDs[i] = r.ID
}
scriptsByRepo, _ = h.service.ListScriptsByRepositoryIDs(c.Request.Context(), repoIDs)
}
for _, repository := range repositories {
repoDTO := dto.FromRepository(repository)
if scripts, ok := scriptsByRepo[repository.ID]; ok {
repoDTO.Scripts = make([]dto.RepositoryScriptDTO, 0, len(scripts))
for _, script := range scripts {
repoDTO.Scripts = append(repoDTO.Scripts, dto.FromRepositoryScript(script))
}
}
resp.Repositories = append(resp.Repositories, repoDTO)
}
c.JSON(http.StatusOK, resp)
}
func (h *RepositoryHandlers) httpDiscoverRepositories(c *gin.Context) {
root := c.Query("root")
result, err := h.service.DiscoverLocalRepositories(c.Request.Context(), 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
}
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))
}
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
}
result, err := h.service.ValidateLocalRepositoryPath(c.Request.Context(), 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, dto.RepositoryPathValidationResponse{
Path: result.Path,
Exists: result.Exists,
IsGitRepo: result.IsGitRepo,
Allowed: result.Allowed,
DefaultBranch: result.DefaultBranch,
Message: result.Message,
})
}
// httpListBranches handles the unified branch endpoint:
//
// GET /api/v1/workspaces/:id/branches?repository_id=X
// GET /api/v1/workspaces/:id/branches?path=/abs/path
//
// Exactly one of the two query params must be set. Both bottom out in the
// same `listGitBranches` call; the difference is just where the absolute
// path is resolved from (DB row vs request param).
func (h *RepositoryHandlers) httpListBranches(c *gin.Context) {
repoID := c.Query("repository_id")
path := c.Query("path")
if repoID == "" && path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id or path is required"})
return
}
if repoID != "" && path != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "specify only one of repository_id or path"})
return
}
branches, err := h.service.ListBranches(c.Request.Context(), repoID, path)
if err != nil {
if errors.Is(err, service.ErrPathNotAllowed) {
c.JSON(http.StatusBadRequest, gin.H{"error": "path is not within allowed roots"})
return
}
h.logger.Error("failed to list branches", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list branches"})
return
}
dtoBranches := make([]dto.BranchDTO, len(branches))
for i, branch := range branches {
dtoBranches[i] = dto.FromBranch(branch)
}
c.JSON(http.StatusOK, dto.RepositoryBranchesResponse{
Branches: dtoBranches,
Total: len(dtoBranches),
})
}
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"`
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
PullBeforeWorktree *bool `json:"pull_before_worktree"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_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
}
repository, err := h.service.CreateRepository(c.Request.Context(), &service.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,
WorktreeBranchPrefix: body.WorktreeBranchPrefix,
PullBeforeWorktree: body.PullBeforeWorktree,
SetupScript: body.SetupScript,
CleanupScript: body.CleanupScript,
DevScript: body.DevScript,
})
if err != nil {
if errors.Is(err, service.ErrInvalidRepositorySettings) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repository settings"})
return
}
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, dto.FromRepository(repository))
}
func (h *RepositoryHandlers) httpGetRepository(c *gin.Context) {
repository, err := h.service.GetRepository(c.Request.Context(), c.Param("id"))
if err != nil {
handleNotFound(c, h.logger, err, "repository not found")
return
}
c.JSON(http.StatusOK, dto.FromRepository(repository))
}
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"`
WorktreeBranchPrefix *string `json:"worktree_branch_prefix"`
PullBeforeWorktree *bool `json:"pull_before_worktree"`
SetupScript *string `json:"setup_script"`
CleanupScript *string `json:"cleanup_script"`
DevScript *string `json:"dev_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
}
repository, err := h.service.UpdateRepository(c.Request.Context(), c.Param("id"), &service.UpdateRepositoryRequest{
Name: body.Name,
SourceType: body.SourceType,
LocalPath: body.LocalPath,
Provider: body.Provider,
ProviderRepoID: body.ProviderRepoID,
ProviderOwner: body.ProviderOwner,
ProviderName: body.ProviderName,
DefaultBranch: body.DefaultBranch,
WorktreeBranchPrefix: body.WorktreeBranchPrefix,
PullBeforeWorktree: body.PullBeforeWorktree,
SetupScript: body.SetupScript,
CleanupScript: body.CleanupScript,
DevScript: body.DevScript,
})
if err != nil {
if errors.Is(err, service.ErrInvalidRepositorySettings) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repository settings"})
return
}
handleNotFound(c, h.logger, err, "repository not found")
return
}
c.JSON(http.StatusOK, dto.FromRepository(repository))
}
func (h *RepositoryHandlers) httpDeleteRepository(c *gin.Context) {
if err := h.service.DeleteRepository(c.Request.Context(), c.Param("id")); err != nil {
if errors.Is(err, service.ErrActiveTaskSessions) {
c.JSON(http.StatusConflict, gin.H{"error": "repository is used by an active agent session"})
return
}
handleNotFound(c, h.logger, err, "repository not found")
return
}
c.Status(http.StatusNoContent)
}
// WS handlers
func reposToListResponse(repositories []*models.Repository) dto.ListRepositoriesResponse {
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
}
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)
}
repositories, err := h.service.ListRepositories(ctx, 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, reposToListResponse(repositories))
}
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"`
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_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)
}
repository, err := h.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,
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
DevScript: req.DevScript,
})
if err != nil {
if errors.Is(err, service.ErrInvalidRepositorySettings) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Invalid repository settings", 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, dto.FromRepository(repository))
}
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)
}
repository, err := h.service.GetRepository(ctx, 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, dto.FromRepository(repository))
}
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"`
WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"`
SetupScript *string `json:"setup_script,omitempty"`
CleanupScript *string `json:"cleanup_script,omitempty"`
DevScript *string `json:"dev_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)
}
repository, err := h.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,
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
DevScript: req.DevScript,
})
if err != nil {
if errors.Is(err, service.ErrInvalidRepositorySettings) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Invalid repository settings", 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, dto.FromRepository(repository))
}
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.service.DeleteRepository(ctx, req.ID); err != nil {
h.logger.Error("failed to delete repository", zap.Error(err))
if errors.Is(err, service.ErrActiveTaskSessions) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository is used by an active agent session", nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Repository not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true})
}