You've already forked agentic-coding-workflow
feat(workspace): http handler with workspace/worktree/gitops endpoints
This commit is contained in:
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user