Files
2026-06-22 08:55:57 +08:00

661 lines
16 KiB
Go

// 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
}