You've already forked agentic-coding-workflow
890 lines
24 KiB
Go
890 lines
24 KiB
Go
// Package project 内的 handler.go 是三个聚合根的 HTTP 接入层。
|
|
//
|
|
// 路由约定(PM spec §3.1):
|
|
//
|
|
// /api/v1/projects CRUD + archive
|
|
// /api/v1/projects/{slug}/requirements/... CRUD + phase + close
|
|
// /api/v1/projects/{slug}/issues/... CRUD + assign + close
|
|
//
|
|
// 设计取舍:
|
|
// - 把 Caller 提取做成 callerFromCtx helper:从 middleware.UserIDFromContext
|
|
// 读 uid,再通过 AdminLookup 拿 is_admin。AdminLookup 是窄接口,避免直接
|
|
// 依赖 internal/user 的具体类型(也方便测试时注入 stub)。
|
|
// - phase 切换 / close / reopen / archive / assign 都走独立 POST endpoint:
|
|
// 便于 audit 区分语义;PATCH 仅改 title/description/visibility/owner_id。
|
|
package project
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
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。
|
|
type AdminLookup interface {
|
|
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
|
|
}
|
|
|
|
// Handler 把三个 service 与一个 AdminLookup 拼到 HTTP 路由上。
|
|
type Handler struct {
|
|
projects ProjectService
|
|
requirements RequirementService
|
|
issues IssueService
|
|
prototypes PrototypeService
|
|
resolver middleware.SessionResolver
|
|
users AdminLookup
|
|
}
|
|
|
|
// NewHandler 构造 Handler。
|
|
func NewHandler(p ProjectService, r RequirementService, i IssueService, proto PrototypeService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
|
|
return &Handler{projects: p, requirements: r, issues: i, prototypes: proto, resolver: resolver, users: users}
|
|
}
|
|
|
|
// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。
|
|
func (h *Handler) Mount(r chi.Router) {
|
|
r.Route("/api/v1/projects", func(r chi.Router) {
|
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
|
|
|
r.Post("/", h.createProject)
|
|
r.Get("/", h.listProjects)
|
|
r.Get("/{slug}", h.getProject)
|
|
r.Patch("/{slug}", h.updateProject)
|
|
r.Post("/{slug}/archive", h.archiveProject)
|
|
r.Post("/{slug}/unarchive", h.unarchiveProject)
|
|
|
|
r.Post("/{slug}/requirements", h.createRequirement)
|
|
r.Get("/{slug}/requirements", h.listRequirements)
|
|
r.Get("/{slug}/requirements/{number}", h.getRequirement)
|
|
r.Patch("/{slug}/requirements/{number}", h.updateRequirement)
|
|
r.Post("/{slug}/requirements/{number}/phase", h.changeRequirementPhase)
|
|
r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
|
|
r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
|
|
r.Post("/{slug}/requirements/{number}/prototypes", h.createPrototype)
|
|
r.Get("/{slug}/requirements/{number}/prototypes", h.listPrototypes)
|
|
r.Get("/{slug}/requirements/{number}/prototypes/{version}", h.getPrototype)
|
|
|
|
r.Post("/{slug}/issues", h.createIssue)
|
|
r.Get("/{slug}/issues", h.listIssues)
|
|
r.Get("/{slug}/issues/{number}", h.getIssue)
|
|
r.Patch("/{slug}/issues/{number}", h.updateIssue)
|
|
r.Post("/{slug}/issues/{number}/assign", h.assignIssue)
|
|
r.Post("/{slug}/issues/{number}/close", h.closeIssue)
|
|
r.Post("/{slug}/issues/{number}/reopen", h.reopenIssue)
|
|
})
|
|
}
|
|
|
|
// callerFromCtx 从 ctx 取出 uid,再调用 AdminLookup 取 is_admin。
|
|
// 没有 uid 时返回 401(Auth 中间件已经在前面拦了,这里是双保险)。
|
|
func (h *Handler) callerFromCtx(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
|
|
}
|
|
|
|
// numberParam 解析 chi URL 中的 {number}。失败时返回 invalid_input。
|
|
func numberParam(r *http.Request) (int, error) {
|
|
raw := chi.URLParam(r, "number")
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n <= 0 {
|
|
return 0, errs.New(errs.CodeInvalidInput, "number 非法")
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
|
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
|
}
|
|
|
|
func decodeBody(r *http.Request, dst any) error {
|
|
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
|
return errs.New(errs.CodeInvalidInput, "请求体无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ===== DTO =====
|
|
|
|
type projectDTO struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Visibility string `json:"visibility"`
|
|
OwnerID string `json:"owner_id"`
|
|
ArchivedAt *string `json:"archived_at,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type requirementDTO struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
WorkspaceID *string `json:"workspace_id"`
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Phase string `json:"phase"`
|
|
Status string `json:"status"`
|
|
OwnerID string `json:"owner_id"`
|
|
ClosedAt *string `json:"closed_at,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type issueDTO struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
RequirementID *string `json:"requirement_id"`
|
|
WorkspaceID *string `json:"workspace_id"`
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
CreatedBy string `json:"created_by"`
|
|
ClosedAt *string `json:"closed_at,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type prototypeDTO struct {
|
|
ID string `json:"id"`
|
|
RequirementID string `json:"requirement_id"`
|
|
Version int `json:"version"`
|
|
Content string `json:"content"`
|
|
Note string `json:"note"`
|
|
CreatedBy string `json:"created_by"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func toProjectDTO(p *Project) projectDTO {
|
|
d := projectDTO{
|
|
ID: p.ID.String(), Slug: p.Slug, Name: p.Name, Description: p.Description,
|
|
Visibility: string(p.Visibility), OwnerID: p.OwnerID.String(),
|
|
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: p.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
if p.ArchivedAt != nil {
|
|
s := p.ArchivedAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
d.ArchivedAt = &s
|
|
}
|
|
return d
|
|
}
|
|
|
|
func toRequirementDTO(r *Requirement) requirementDTO {
|
|
d := requirementDTO{
|
|
ID: r.ID.String(), ProjectID: r.ProjectID.String(),
|
|
Number: r.Number, Title: r.Title, Description: r.Description,
|
|
Phase: string(r.Phase), Status: string(r.Status), OwnerID: r.OwnerID.String(),
|
|
CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
if r.WorkspaceID != nil {
|
|
s := r.WorkspaceID.String()
|
|
d.WorkspaceID = &s
|
|
}
|
|
if r.ClosedAt != nil {
|
|
s := r.ClosedAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
d.ClosedAt = &s
|
|
}
|
|
return d
|
|
}
|
|
|
|
func toIssueDTO(i *Issue) issueDTO {
|
|
d := issueDTO{
|
|
ID: i.ID.String(), ProjectID: i.ProjectID.String(),
|
|
Number: i.Number, Title: i.Title, Description: i.Description,
|
|
Status: string(i.Status), CreatedBy: i.CreatedBy.String(),
|
|
CreatedAt: i.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: i.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
if i.RequirementID != nil {
|
|
s := i.RequirementID.String()
|
|
d.RequirementID = &s
|
|
}
|
|
if i.WorkspaceID != nil {
|
|
s := i.WorkspaceID.String()
|
|
d.WorkspaceID = &s
|
|
}
|
|
if i.AssigneeID != nil {
|
|
s := i.AssigneeID.String()
|
|
d.AssigneeID = &s
|
|
}
|
|
if i.ClosedAt != nil {
|
|
s := i.ClosedAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
d.ClosedAt = &s
|
|
}
|
|
return d
|
|
}
|
|
|
|
func toPrototypeDTO(p *Prototype) prototypeDTO {
|
|
return prototypeDTO{
|
|
ID: p.ID.String(), RequirementID: p.RequirementID.String(),
|
|
Version: p.Version, Content: p.Content, Note: p.Note,
|
|
CreatedBy: p.CreatedBy.String(),
|
|
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
// ===== Project endpoints =====
|
|
|
|
type createProjectReq struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
|
|
func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req createProjectReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
vis := Visibility(req.Visibility)
|
|
if req.Visibility == "" {
|
|
vis = VisibilityPrivate
|
|
}
|
|
out, err := h.projects.Create(r.Context(), c, CreateProjectInput{
|
|
Slug: req.Slug, Name: req.Name, Description: req.Description, Visibility: vis,
|
|
})
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, toProjectDTO(out))
|
|
}
|
|
|
|
func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
includeArchived := r.URL.Query().Get("archived") == "true"
|
|
out, err := h.projects.List(r.Context(), c, includeArchived)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
dtos := make([]projectDTO, 0, len(out))
|
|
for _, p := range out {
|
|
dtos = append(dtos, toProjectDTO(p))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
|
|
}
|
|
|
|
func (h *Handler) getProject(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out, err := h.projects.Get(r.Context(), c, chi.URLParam(r, "slug"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out))
|
|
}
|
|
|
|
type updateProjectReq struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
Visibility *string `json:"visibility,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) updateProject(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req updateProjectReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
in := UpdateProjectInput{Name: req.Name, Description: req.Description}
|
|
if req.Visibility != nil {
|
|
v := Visibility(*req.Visibility)
|
|
in.Visibility = &v
|
|
}
|
|
out, err := h.projects.Update(r.Context(), c, chi.URLParam(r, "slug"), in)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out))
|
|
}
|
|
|
|
func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, true) }
|
|
func (h *Handler) unarchiveProject(w http.ResponseWriter, r *http.Request) {
|
|
h.toggleArchive(w, r, false)
|
|
}
|
|
|
|
func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive bool) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
slug := chi.URLParam(r, "slug")
|
|
if archive {
|
|
err = h.projects.Archive(r.Context(), c, slug)
|
|
} else {
|
|
err = h.projects.Unarchive(r.Context(), c, slug)
|
|
}
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ===== Requirement endpoints =====
|
|
|
|
type createRequirementReq struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
OwnerID *string `json:"owner_id,omitempty"`
|
|
WorkspaceID *string `json:"workspace_id,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) createRequirement(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req createRequirementReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
in := CreateRequirementInput{Title: req.Title, Description: req.Description}
|
|
if req.OwnerID != nil {
|
|
uid, perr := uuid.Parse(*req.OwnerID)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "owner_id 非法"))
|
|
return
|
|
}
|
|
in.OwnerID = &uid
|
|
}
|
|
if req.WorkspaceID != nil {
|
|
wid, perr := uuid.Parse(*req.WorkspaceID)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "workspace_id 非法"))
|
|
return
|
|
}
|
|
in.WorkspaceID = &wid
|
|
}
|
|
out, err := h.requirements.Create(r.Context(), c, chi.URLParam(r, "slug"), in)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, toRequirementDTO(out))
|
|
}
|
|
|
|
func (h *Handler) listRequirements(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out, err := h.requirements.List(r.Context(), c, chi.URLParam(r, "slug"), RequirementFilter{
|
|
Phase: Phase(r.URL.Query().Get("phase")),
|
|
Status: Status(r.URL.Query().Get("status")),
|
|
})
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
dtos := make([]requirementDTO, 0, len(out))
|
|
for _, x := range out {
|
|
dtos = append(dtos, toRequirementDTO(x))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
|
|
}
|
|
|
|
func (h *Handler) getRequirement(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
req, err := h.requirements.Get(r.Context(), c, chi.URLParam(r, "slug"), num)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
// 详情携带挂在其下的 issues 摘要
|
|
issues, _ := h.issues.List(r.Context(), c, chi.URLParam(r, "slug"), IssueFilter{Requirement: &num})
|
|
idtos := make([]issueDTO, 0, len(issues))
|
|
for _, i := range issues {
|
|
idtos = append(idtos, toIssueDTO(i))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"requirement": toRequirementDTO(req),
|
|
"issues": idtos,
|
|
})
|
|
}
|
|
|
|
// updateRequirementReq 用 json.RawMessage 区分 workspace_id 的 "未传"/"显式 null"/"传值"。
|
|
type updateRequirementReq struct {
|
|
Title *string `json:"title,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
OwnerID *string `json:"owner_id,omitempty"`
|
|
WorkspaceID json.RawMessage `json:"workspace_id,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req updateRequirementReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
in := UpdateRequirementInput{Title: req.Title, Description: req.Description}
|
|
if req.OwnerID != nil {
|
|
uid, perr := uuid.Parse(*req.OwnerID)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "owner_id 非法"))
|
|
return
|
|
}
|
|
in.OwnerID = &uid
|
|
}
|
|
if err := parseWorkspaceIDPatch(req.WorkspaceID, &in.WorkspaceID); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out, err := h.requirements.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out))
|
|
}
|
|
|
|
// parseWorkspaceIDPatch 把 PATCH 请求里的 json.RawMessage workspace_id 字段
|
|
// 翻译为 *uuid.UUID 三态:
|
|
// - 字段缺省(len == 0):保留 *dst = nil
|
|
// - 显式 null:*dst = &uuid.Nil(解绑 sentinel)
|
|
// - UUID 字符串:解析并赋值;解析失败返回 invalid_input
|
|
func parseWorkspaceIDPatch(raw json.RawMessage, dst **uuid.UUID) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
if string(raw) == "null" {
|
|
nilUUID := uuid.Nil
|
|
*dst = &nilUUID
|
|
return nil
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return errs.New(errs.CodeInvalidInput, "workspace_id 非法")
|
|
}
|
|
wid, perr := uuid.Parse(s)
|
|
if perr != nil {
|
|
return errs.New(errs.CodeInvalidInput, "workspace_id 非法")
|
|
}
|
|
*dst = &wid
|
|
return nil
|
|
}
|
|
|
|
type phaseReq struct {
|
|
Phase string `json:"phase"`
|
|
}
|
|
|
|
func (h *Handler) changeRequirementPhase(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req phaseReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out, err := h.requirements.ChangePhase(r.Context(), c, chi.URLParam(r, "slug"), num, Phase(req.Phase))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out))
|
|
}
|
|
|
|
func (h *Handler) closeRequirement(w http.ResponseWriter, r *http.Request) {
|
|
h.toggleReqStatus(w, r, true)
|
|
}
|
|
func (h *Handler) reopenRequirement(w http.ResponseWriter, r *http.Request) {
|
|
h.toggleReqStatus(w, r, false)
|
|
}
|
|
|
|
func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if doClose {
|
|
err = h.requirements.Close(r.Context(), c, chi.URLParam(r, "slug"), num)
|
|
} else {
|
|
err = h.requirements.Reopen(r.Context(), c, chi.URLParam(r, "slug"), num)
|
|
}
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ===== Prototype endpoints =====
|
|
|
|
type createPrototypeReq struct {
|
|
Content string `json:"content"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req createPrototypeReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out, err := h.prototypes.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreatePrototypeInput{
|
|
Content: req.Content, Note: req.Note,
|
|
})
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, toPrototypeDTO(out))
|
|
}
|
|
|
|
func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out, err := h.prototypes.List(r.Context(), c, chi.URLParam(r, "slug"), num)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
dtos := make([]prototypeDTO, 0, len(out))
|
|
for _, x := range out {
|
|
dtos = append(dtos, toPrototypeDTO(x))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototypes": dtos})
|
|
}
|
|
|
|
func (h *Handler) getPrototype(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
ver, err := strconv.Atoi(chi.URLParam(r, "version"))
|
|
if err != nil || ver <= 0 {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
|
|
return
|
|
}
|
|
out, err := h.prototypes.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, ver)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototype": toPrototypeDTO(out)})
|
|
}
|
|
|
|
// ===== Issue endpoints =====
|
|
|
|
type createIssueReq struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
RequirementNumber *int `json:"requirement_number,omitempty"`
|
|
AssigneeID *string `json:"assignee_id,omitempty"`
|
|
WorkspaceID *string `json:"workspace_id,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) createIssue(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req createIssueReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
in := CreateIssueInput{Title: req.Title, Description: req.Description, RequirementNumber: req.RequirementNumber}
|
|
if req.AssigneeID != nil {
|
|
uid, perr := uuid.Parse(*req.AssigneeID)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee_id 非法"))
|
|
return
|
|
}
|
|
in.AssigneeID = &uid
|
|
}
|
|
if req.WorkspaceID != nil {
|
|
wid, perr := uuid.Parse(*req.WorkspaceID)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "workspace_id 非法"))
|
|
return
|
|
}
|
|
in.WorkspaceID = &wid
|
|
}
|
|
out, err := h.issues.Create(r.Context(), c, chi.URLParam(r, "slug"), in)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, toIssueDTO(out))
|
|
}
|
|
|
|
func (h *Handler) listIssues(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
filter := IssueFilter{Status: Status(q.Get("status"))}
|
|
switch req := q.Get("requirement"); req {
|
|
case "":
|
|
// 不过滤
|
|
case "none", "null":
|
|
zero := 0
|
|
filter.Requirement = &zero
|
|
default:
|
|
n, perr := strconv.Atoi(req)
|
|
if perr != nil || n <= 0 {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement 参数非法"))
|
|
return
|
|
}
|
|
filter.Requirement = &n
|
|
}
|
|
if a := q.Get("assignee"); a != "" {
|
|
uid, perr := uuid.Parse(a)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee 非法"))
|
|
return
|
|
}
|
|
filter.AssigneeID = &uid
|
|
}
|
|
out, err := h.issues.List(r.Context(), c, chi.URLParam(r, "slug"), filter)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
dtos := make([]issueDTO, 0, len(out))
|
|
for _, x := range out {
|
|
dtos = append(dtos, toIssueDTO(x))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
|
|
}
|
|
|
|
func (h *Handler) getIssue(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out, err := h.issues.Get(r.Context(), c, chi.URLParam(r, "slug"), num)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
|
|
}
|
|
|
|
// updateIssueReq 用 json.RawMessage 区分 "未传"、"显式传 null"、"传值"。
|
|
type updateIssueReq struct {
|
|
Title *string `json:"title,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
RequirementNumber json.RawMessage `json:"requirement_number,omitempty"`
|
|
WorkspaceID json.RawMessage `json:"workspace_id,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) updateIssue(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req updateIssueReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
in := UpdateIssueInput{Title: req.Title, Description: req.Description}
|
|
if len(req.RequirementNumber) > 0 {
|
|
// 字段在 JSON 中存在
|
|
if string(req.RequirementNumber) == "null" {
|
|
var nilPtr *int
|
|
in.RequirementNumber = &nilPtr
|
|
} else {
|
|
var n int
|
|
if jerr := json.Unmarshal(req.RequirementNumber, &n); jerr != nil || n <= 0 {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement_number 非法"))
|
|
return
|
|
}
|
|
pn := &n
|
|
in.RequirementNumber = &pn
|
|
}
|
|
}
|
|
if err := parseWorkspaceIDPatch(req.WorkspaceID, &in.WorkspaceID); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out, err := h.issues.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
|
|
}
|
|
|
|
type assignReq struct {
|
|
AssigneeID *string `json:"assignee_id"`
|
|
}
|
|
|
|
func (h *Handler) assignIssue(w http.ResponseWriter, r *http.Request) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req assignReq
|
|
if err := decodeBody(r, &req); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var assignee *uuid.UUID
|
|
if req.AssigneeID != nil {
|
|
uid, perr := uuid.Parse(*req.AssigneeID)
|
|
if perr != nil {
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee_id 非法"))
|
|
return
|
|
}
|
|
assignee = &uid
|
|
}
|
|
out, err := h.issues.Assign(r.Context(), c, chi.URLParam(r, "slug"), num, assignee)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
|
|
}
|
|
|
|
func (h *Handler) closeIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, true) }
|
|
func (h *Handler) reopenIssue(w http.ResponseWriter, r *http.Request) {
|
|
h.toggleIssueStatus(w, r, false)
|
|
}
|
|
|
|
func (h *Handler) toggleIssueStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
|
|
c, err := h.callerFromCtx(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
num, err := numberParam(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if doClose {
|
|
err = h.issues.Close(r.Context(), c, chi.URLParam(r, "slug"), num)
|
|
} else {
|
|
err = h.issues.Reopen(r.Context(), c, chi.URLParam(r, "slug"), num)
|
|
}
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|