You've already forked agentic-coding-workflow
598 lines
15 KiB
Go
598 lines
15 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"
|
|
|
|
"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("/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)
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|