You've already forked agentic-coding-workflow
feat(project): http handler with project/requirement/issue endpoints
This commit is contained in:
@@ -0,0 +1,713 @@
|
||||
// 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
|
||||
resolver middleware.SessionResolver
|
||||
users AdminLookup
|
||||
}
|
||||
|
||||
// NewHandler 构造 Handler。
|
||||
func NewHandler(p ProjectService, r RequirementService, i IssueService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
|
||||
return &Handler{projects: p, requirements: r, issues: i, 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}/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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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.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.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
|
||||
}
|
||||
|
||||
// ===== 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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
type updateRequirementReq struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
OwnerID *string `json:"owner_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
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ===== 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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user