// 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" "strconv" "strings" "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) { auth := middleware.Auth(h.resolver, middleware.AuthOptions{}) r.With(auth).Route("/api/v1/projects/{projectSlug}/workspaces", func(r chi.Router) { r.Post("/", h.createWorkspace) r.Get("/", h.listWorkspaces) }) r.With(auth).Route("/api/v1/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("/main/log", h.logOnMain) r.Get("/diff", h.diffOnMain) r.Get("/worktrees", h.listWorktrees) r.Post("/worktrees", h.createWorktree) r.Get("/branches", h.listBranches) }) r.With(auth).Route("/api/v1/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) r.Get("/log", h.logOnWorktree) r.Get("/diff", h.diffOnWorktree) }) } // 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) listBranches(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 } branches, err := h.ws.ListBranches(r.Context(), c, id) if err != nil { writeErr(w, r, err) return } writeJSON(w, http.StatusOK, branchListResp{Branches: branches}) } 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) } func (h *Handler) logOnMain(w http.ResponseWriter, r *http.Request) { h.logGeneric(w, r, true) } func (h *Handler) logOnWorktree(w http.ResponseWriter, r *http.Request) { h.logGeneric(w, r, false) } func (h *Handler) logGeneric(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 } n := parseLogN(r) var commits []git.Commit if onMain { commits, err = h.gop.LogOnMain(r.Context(), c, id, n) } else { commits, err = h.gop.LogOnWorktree(r.Context(), c, id, n) } if err != nil { writeErr(w, r, err) return } resp := make([]commitLogResp, 0, len(commits)) for _, cc := range commits { resp = append(resp, toCommitLogResp(cc)) } writeJSON(w, http.StatusOK, resp) } func parseLogN(r *http.Request) int { nStr := r.URL.Query().Get("n") if nStr == "" { return 50 } n, err := strconv.Atoi(nStr) if err != nil || n <= 0 { return 50 } if n > 200 { return 200 } return n } func (h *Handler) diffOnMain(w http.ResponseWriter, r *http.Request) { h.diffGeneric(w, r, true) } func (h *Handler) diffOnWorktree(w http.ResponseWriter, r *http.Request) { h.diffGeneric(w, r, false) } func (h *Handler) diffGeneric(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 } opts := parseDiffOptions(r) var res git.DiffResult if onMain { res, err = h.gop.DiffOnMain(r.Context(), c, id, opts) } else { res, err = h.gop.DiffOnWorktree(r.Context(), c, id, opts) } if err != nil { writeErr(w, r, err) return } writeJSON(w, http.StatusOK, diffResp{Diff: res.Diff, Truncated: res.Truncated}) } // parseDiffOptions reads the diff query params (ref, against, staged, paths, context). func parseDiffOptions(r *http.Request) git.DiffOptions { q := r.URL.Query() opts := git.DiffOptions{ Ref: q.Get("ref"), Against: q.Get("against"), Staged: q.Get("staged") == "true" || q.Get("staged") == "1", } if raw := q.Get("paths"); raw != "" { for _, p := range strings.Split(raw, ",") { if p = strings.TrimSpace(p); p != "" { opts.Paths = append(opts.Paths, p) } } } if cl := q.Get("context"); cl != "" { if n, err := strconv.Atoi(cl); err == nil && n >= 0 { opts.ContextLines = n } } return opts }