feat(workspace): http handler with workspace/worktree/gitops endpoints

This commit is contained in:
2026-05-02 08:36:45 +08:00
parent e31710f04e
commit 78e74c9734
3 changed files with 720 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
// dto.go 定义 workspace 模块 HTTP 层的请求 / 响应结构以及与领域模型的映射函数。
// 设计取舍:
// - 请求 DTO 用 JSON tag 与前端约定的 snake_case 对齐;可选 patch 字段用 *T 区分"不传"和"显式置空"。
// - 响应 DTO 不暴露 secret/main_path 等敏感或内部字段,避免泄漏。
package workspace
import (
"time"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
)
// ===== Request DTO =====
type createWorkspaceReq struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
GitRemoteURL string `json:"git_remote_url"`
DefaultBranch string `json:"default_branch"`
Credential credentialBody `json:"credential"`
}
type updateWorkspaceReq struct {
Name *string `json:"name"`
Description *string `json:"description"`
DefaultBranch *string `json:"default_branch"`
}
type credentialBody struct {
Kind string `json:"kind"`
Username string `json:"username"`
Secret string `json:"secret"`
}
type createWorktreeReq struct {
Branch string `json:"branch"`
BaseBranch string `json:"base_branch"`
}
type commitReq struct {
Message string `json:"message"`
Push *bool `json:"push"`
AddAll *bool `json:"add_all"`
}
// ===== Response DTO =====
type workspaceResp struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
GitRemoteURL string `json:"git_remote_url"`
DefaultBranch string `json:"default_branch"`
SyncStatus string `json:"sync_status"`
LastSyncedAt *time.Time `json:"last_synced_at"`
LastSyncError string `json:"last_sync_error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toWorkspaceResp(w *Workspace) workspaceResp {
return workspaceResp{
ID: w.ID, ProjectID: w.ProjectID, Slug: w.Slug, Name: w.Name,
Description: w.Description, GitRemoteURL: w.GitRemoteURL, DefaultBranch: w.DefaultBranch,
SyncStatus: string(w.SyncStatus), LastSyncedAt: w.LastSyncedAt, LastSyncError: w.LastSyncError,
CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt,
}
}
type worktreeResp struct {
ID uuid.UUID `json:"id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
Branch string `json:"branch"`
Path string `json:"path"`
Status string `json:"status"`
ActiveHolder string `json:"active_holder"`
AcquiredAt *time.Time `json:"acquired_at"`
LastUsedAt time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
func toWorktreeResp(w *Worktree) worktreeResp {
return worktreeResp{
ID: w.ID, WorkspaceID: w.WorkspaceID, Branch: w.Branch, Path: w.Path,
Status: string(w.Status), ActiveHolder: w.ActiveHolder, AcquiredAt: w.AcquiredAt,
LastUsedAt: w.LastUsedAt, CreatedAt: w.CreatedAt,
}
}
type credentialResp struct {
Kind string `json:"kind"`
Username string `json:"username"`
Fingerprint string `json:"fingerprint"`
}
func toCredentialResp(c *Credential) credentialResp {
return credentialResp{Kind: string(c.Kind), Username: c.Username, Fingerprint: c.Fingerprint}
}
type fileStatusResp struct {
Path string `json:"path"`
IndexX string `json:"index_x"`
WorktreeY string `json:"worktree_y"`
}
func toFileStatusResp(f git.FileStatus) fileStatusResp {
return fileStatusResp{Path: f.Path, IndexX: string(f.IndexX), WorktreeY: string(f.WorktreeY)}
}
type commitResp struct {
SHA string `json:"sha"`
}
+519
View File
@@ -0,0 +1,519 @@
// handler.go 暴露 workspace 模块 HTTP 端点(spec 4.1–4.4 节)。沿 PM 模块(internal/project)
// 的 chi.Router 模式:单个 Handler 持有 3 个 service,Mount 用 sub-router 注册路由。
//
// 鉴权:复用 transport/http/middleware.Auth + 窄接口 AdminLookup 解析 is_admin。
// 错误格式:复用 httpx.WriteError 输出 Google API 风格 {code, message, request_id}。
package workspace
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// AdminLookup 根据 user id 判 is_admin。
// 沿 internal/project 同名窄接口,避免对 user 包具体类型的强依赖(也方便测试注入桩)。
type AdminLookup interface {
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
}
// Handler 同时持有 3 个 service,并在 Mount 时装上 Auth 中间件。
type Handler struct {
ws WorkspaceService
wt WorktreeService
gop GitOpsService
resolver middleware.SessionResolver
users AdminLookup
}
// NewHandler 构造 Handler。resolver 与 users 在 Mount 注册的 Auth 中间件中使用。
func NewHandler(ws WorkspaceService, wt WorktreeService, gop GitOpsService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{ws: ws, wt: wt, gop: gop, resolver: resolver, users: users}
}
// Mount 把所有 workspace / worktree / gitops 路由挂到 r 上,统一装入 Auth 中间件。
func (h *Handler) Mount(r chi.Router) {
r.Route("/api/v1", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Route("/projects/{projectSlug}/workspaces", func(r chi.Router) {
r.Post("/", h.createWorkspace)
r.Get("/", h.listWorkspaces)
})
r.Route("/workspaces/{wsID}", func(r chi.Router) {
r.Get("/", h.getWorkspace)
r.Patch("/", h.updateWorkspace)
r.Delete("/", h.deleteWorkspace)
r.Post("/sync", h.syncWorkspace)
r.Put("/credential", h.upsertCredential)
r.Get("/credential", h.getCredentialMeta)
r.Get("/main/status", h.statusOnMain)
r.Post("/main/commit", h.commitOnMain)
r.Post("/main/push", h.pushOnMain)
r.Get("/worktrees", h.listWorktrees)
r.Post("/worktrees", h.createWorktree)
})
r.Route("/worktrees/{wtID}", func(r chi.Router) {
r.Delete("/", h.deleteWorktree)
r.Post("/acquire", h.acquireWorktree)
r.Post("/release", h.releaseWorktree)
r.Get("/status", h.statusOnWorktree)
r.Post("/commit", h.commitOnWorktree)
r.Post("/push", h.pushOnWorktree)
})
})
}
// caller 从 ctx 取出 uid 并查 is_admin,组装 Caller。
// Auth 中间件已经把 401 兜在前面;此处的"未认证"检查是双保险。
func (h *Handler) caller(r *http.Request) (Caller, error) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
return Caller{}, errs.New(errs.CodeUnauthorized, "未认证")
}
isAdmin, err := h.users.IsAdmin(r.Context(), uid)
if err != nil {
return Caller{}, err
}
return Caller{UserID: uid, IsAdmin: isAdmin}, nil
}
func writeJSON(w http.ResponseWriter, status int, body any) {
httpx.WriteJSON(w, status, body)
}
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
}
// parseUUID 解析 URL 中的 uuid path 参数;失败时返回 invalid_input。
func parseUUID(s string) (uuid.UUID, error) {
id, err := uuid.Parse(s)
if err != nil {
return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid uuid")
}
return id, nil
}
// ===== workspace CRUD =====
func (h *Handler) createWorkspace(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
var req createWorkspaceReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
return
}
cred := UpsertCredentialInput{
Kind: CredentialKind(req.Credential.Kind),
Username: req.Credential.Username,
Secret: []byte(req.Credential.Secret),
}
in := CreateWorkspaceInput{
Slug: req.Slug, Name: req.Name, Description: req.Description,
GitRemoteURL: req.GitRemoteURL, DefaultBranch: req.DefaultBranch, Credential: cred,
}
ws, err := h.ws.Create(r.Context(), c, chi.URLParam(r, "projectSlug"), in)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusCreated, toWorkspaceResp(ws))
}
func (h *Handler) listWorkspaces(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
out, err := h.ws.List(r.Context(), c, chi.URLParam(r, "projectSlug"))
if err != nil {
writeErr(w, r, err)
return
}
resp := make([]workspaceResp, 0, len(out))
for _, ws := range out {
resp = append(resp, toWorkspaceResp(ws))
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) getWorkspace(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
ws, err := h.ws.Get(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toWorkspaceResp(ws))
}
func (h *Handler) updateWorkspace(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
var req updateWorkspaceReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode"))
return
}
ws, err := h.ws.Update(r.Context(), c, id, UpdateWorkspaceInput{
Name: req.Name, Description: req.Description, DefaultBranch: req.DefaultBranch,
})
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toWorkspaceResp(ws))
}
func (h *Handler) deleteWorkspace(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
if err := h.ws.Delete(r.Context(), c, id); err != nil {
writeErr(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) syncWorkspace(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
ws, err := h.ws.Sync(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toWorkspaceResp(ws))
}
// ===== credential =====
func (h *Handler) upsertCredential(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
var body credentialBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode"))
return
}
cred, err := h.ws.UpsertCredential(r.Context(), c, id, UpsertCredentialInput{
Kind: CredentialKind(body.Kind), Username: body.Username, Secret: []byte(body.Secret),
})
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toCredentialResp(cred))
}
func (h *Handler) getCredentialMeta(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
cred, err := h.ws.GetCredentialMeta(r.Context(), c, id)
if err != nil {
// "凭据未配置" 视为返回 kind=none + 空 fingerprint
if isNotFound(err) {
writeJSON(w, http.StatusOK, credentialResp{Kind: string(CredKindNone)})
return
}
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toCredentialResp(cred))
}
func isNotFound(err error) bool {
var ae *errs.AppError
return errors.As(err, &ae) && ae.Code == errs.CodeNotFound
}
// ===== worktree =====
func (h *Handler) listWorktrees(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
out, err := h.wt.List(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
resp := make([]worktreeResp, 0, len(out))
for _, x := range out {
resp = append(resp, toWorktreeResp(x))
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) createWorktree(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
var req createWorktreeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode"))
return
}
wt, err := h.wt.Create(r.Context(), c, id, CreateWorktreeInput{Branch: req.Branch, BaseBranch: req.BaseBranch})
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusCreated, toWorktreeResp(wt))
}
func (h *Handler) deleteWorktree(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wtID"))
if err != nil {
writeErr(w, r, err)
return
}
if err := h.wt.Delete(r.Context(), c, id); err != nil {
writeErr(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) acquireWorktree(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wtID"))
if err != nil {
writeErr(w, r, err)
return
}
wt, err := h.wt.Acquire(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toWorktreeResp(wt))
}
func (h *Handler) releaseWorktree(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wtID"))
if err != nil {
writeErr(w, r, err)
return
}
wt, err := h.wt.Release(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toWorktreeResp(wt))
}
// ===== git ops =====
func (h *Handler) statusOnMain(w http.ResponseWriter, r *http.Request) {
h.statusGeneric(w, r, true)
}
func (h *Handler) statusOnWorktree(w http.ResponseWriter, r *http.Request) {
h.statusGeneric(w, r, false)
}
func (h *Handler) statusGeneric(w http.ResponseWriter, r *http.Request, onMain bool) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
param := "wsID"
if !onMain {
param = "wtID"
}
id, err := parseUUID(chi.URLParam(r, param))
if err != nil {
writeErr(w, r, err)
return
}
var st []git.FileStatus
if onMain {
st, err = h.gop.StatusOnMain(r.Context(), c, id)
} else {
st, err = h.gop.StatusOnWorktree(r.Context(), c, id)
}
if err != nil {
writeErr(w, r, err)
return
}
out := make([]fileStatusResp, 0, len(st))
for _, f := range st {
out = append(out, toFileStatusResp(f))
}
writeJSON(w, http.StatusOK, out)
}
func (h *Handler) commitOnMain(w http.ResponseWriter, r *http.Request) {
h.commitGeneric(w, r, true)
}
func (h *Handler) commitOnWorktree(w http.ResponseWriter, r *http.Request) {
h.commitGeneric(w, r, false)
}
func (h *Handler) commitGeneric(w http.ResponseWriter, r *http.Request, onMain bool) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
param := "wsID"
if !onMain {
param = "wtID"
}
id, err := parseUUID(chi.URLParam(r, param))
if err != nil {
writeErr(w, r, err)
return
}
var req commitReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode"))
return
}
push := req.Push == nil || *req.Push
addAll := req.AddAll == nil || *req.AddAll
in := CommitInput{Message: req.Message, Push: push, AddAll: addAll}
var sha string
if onMain {
sha, err = h.gop.CommitOnMain(r.Context(), c, id, in)
} else {
sha, err = h.gop.CommitOnWorktree(r.Context(), c, id, in)
}
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, commitResp{SHA: sha})
}
func (h *Handler) pushOnMain(w http.ResponseWriter, r *http.Request) {
h.pushGeneric(w, r, true)
}
func (h *Handler) pushOnWorktree(w http.ResponseWriter, r *http.Request) {
h.pushGeneric(w, r, false)
}
func (h *Handler) pushGeneric(w http.ResponseWriter, r *http.Request, onMain bool) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
param := "wsID"
if !onMain {
param = "wtID"
}
id, err := parseUUID(chi.URLParam(r, param))
if err != nil {
writeErr(w, r, err)
return
}
if onMain {
err = h.gop.PushOnMain(r.Context(), c, id)
} else {
err = h.gop.PushOnWorktree(r.Context(), c, id)
}
if err != nil {
writeErr(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+84
View File
@@ -0,0 +1,84 @@
package workspace
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// fakeWS / fakeWT / fakeGOP 是 handler 单测用的桩(只覆盖必要方法)。
type fakeWS struct{ created *Workspace }
func (f *fakeWS) Create(_ context.Context, _ Caller, _ string, _ CreateWorkspaceInput) (*Workspace, error) {
w := &Workspace{ID: uuid.New(), Slug: "ws1", Name: "W1", SyncStatus: SyncStatusCloning}
f.created = w
return w, nil
}
func (f *fakeWS) Get(_ context.Context, _ Caller, id uuid.UUID) (*Workspace, error) {
return &Workspace{ID: id, Slug: "ws1"}, nil
}
func (f *fakeWS) List(_ context.Context, _ Caller, _ string) ([]*Workspace, error) { return nil, nil }
func (f *fakeWS) Update(_ context.Context, _ Caller, id uuid.UUID, _ UpdateWorkspaceInput) (*Workspace, error) {
return &Workspace{ID: id}, nil
}
func (f *fakeWS) Delete(_ context.Context, _ Caller, _ uuid.UUID) error { return nil }
func (f *fakeWS) Sync(_ context.Context, _ Caller, id uuid.UUID) (*Workspace, error) {
return &Workspace{ID: id, SyncStatus: SyncStatusIdle}, nil
}
func (f *fakeWS) UpsertCredential(_ context.Context, _ Caller, _ uuid.UUID, _ UpsertCredentialInput) (*Credential, error) {
return &Credential{Kind: CredKindPAT, Fingerprint: "****abcd"}, nil
}
func (f *fakeWS) GetCredentialMeta(_ context.Context, _ Caller, _ uuid.UUID) (*Credential, error) {
return &Credential{Kind: CredKindNone}, nil
}
// fakeResolver 把固定 token 解码为 uuid(沿 internal/project handler_test.go 模式)。
type fakeResolver struct{ valid map[string]uuid.UUID }
func (f *fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
uid, ok := f.valid[token]
return uid, ok, nil
}
// fakeAdmin 始终返回 false。
type fakeAdmin struct{}
func (fakeAdmin) IsAdmin(_ context.Context, _ uuid.UUID) (bool, error) { return false, nil }
func TestHandler_CreateWorkspace_Smoke(t *testing.T) {
t.Parallel()
ws := &fakeWS{}
uid := uuid.New()
tok := "tok-1"
resolver := &fakeResolver{valid: map[string]uuid.UUID{tok: uid}}
h := NewHandler(ws, nil, nil, resolver, fakeAdmin{})
r := chi.NewRouter()
r.Use(middleware.RequestID)
h.Mount(r)
body, _ := json.Marshal(createWorkspaceReq{
Slug: "ws1", Name: "W1", GitRemoteURL: "https://x",
Credential: credentialBody{Kind: "none"},
})
req := httptest.NewRequest("POST", "/api/v1/projects/p/workspaces", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+tok)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
}