Files
agentic-coding-workflow/internal/project/handler.go
T
2026-06-22 08:55:57 +08:00

1032 lines
28 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
artifacts ArtifactService
resolver middleware.SessionResolver
users AdminLookup
}
// NewHandler 构造 Handler。
func NewHandler(p ProjectService, r RequirementService, i IssueService, arts ArtifactService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{projects: p, requirements: r, issues: i, artifacts: arts, 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)
// 成员/角色 ACL(autonomy roadmap §11):list 需可读;add/remove 仅
// project-admin 或 global-admin(service 层 assertCanManageMembers 兜底)。
r.Get("/{slug}/members", h.listMembers)
r.Post("/{slug}/members", h.addMember)
r.Delete("/{slug}/members/{userID}", h.removeMember)
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}/artifacts", h.createArtifact)
r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts)
r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact)
r.Post("/{slug}/requirements/{number}/artifacts/{phase}/{version}/approve", h.approveArtifact)
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 artifactDTO struct {
ID string `json:"id"`
RequirementID string `json:"requirement_id"`
Phase string `json:"phase"`
Version int `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
Verdict string `json:"verdict"`
}
type memberDTO struct {
ProjectID string `json:"project_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
AddedBy *string `json:"added_by,omitempty"`
CreatedAt string `json:"created_at"`
}
func toMemberDTO(m *Member) memberDTO {
d := memberDTO{
ProjectID: m.ProjectID.String(),
UserID: m.UserID.String(),
Role: string(m.Role),
CreatedAt: m.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if m.AddedBy != nil {
s := m.AddedBy.String()
d.AddedBy = &s
}
return d
}
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 toArtifactDTO(a *Artifact) artifactDTO {
return artifactDTO{
ID: a.ID.String(), RequirementID: a.RequirementID.String(),
Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note,
SourceMessageID: a.SourceMessageID,
CreatedBy: a.CreatedBy.String(),
CreatedAt: a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
Verdict: string(a.Verdict),
}
}
// ===== 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)
}
// ===== Member endpoints(autonomy roadmap §11)=====
type addMemberReq struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
func (h *Handler) listMembers(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
out, err := h.projects.ListMembers(r.Context(), c, chi.URLParam(r, "slug"))
if err != nil {
writeErr(w, r, err)
return
}
dtos := make([]memberDTO, 0, len(out))
for _, m := range out {
dtos = append(dtos, toMemberDTO(m))
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
}
func (h *Handler) addMember(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
var req addMemberReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
uid, err := uuid.Parse(req.UserID)
if err != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 非法"))
return
}
out, err := h.projects.AddMember(r.Context(), c, chi.URLParam(r, "slug"), AddMemberInput{
UserID: uid, Role: Role(req.Role),
})
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toMemberDTO(out))
}
func (h *Handler) removeMember(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
uid, err := uuid.Parse(chi.URLParam(r, "userID"))
if err != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 非法"))
return
}
if err := h.projects.RemoveMember(r.Context(), c, chi.URLParam(r, "slug"), uid); 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)
}
// ===== Artifact endpoints =====
type createArtifactReq struct {
Phase string `json:"phase"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id"`
}
func (h *Handler) createArtifact(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 createArtifactReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
out, err := h.artifacts.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreateArtifactInput{
Phase: Phase(req.Phase), Content: req.Content, Note: req.Note,
SourceMessageID: req.SourceMessageID,
})
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, toArtifactDTO(out))
}
func (h *Handler) listArtifacts(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
}
phase := Phase(r.URL.Query().Get("phase"))
out, err := h.artifacts.List(r.Context(), c, chi.URLParam(r, "slug"), num, phase)
if err != nil {
writeErr(w, r, err)
return
}
dtos := make([]artifactDTO, 0, len(out))
for _, x := range out {
dtos = append(dtos, toArtifactDTO(x))
}
httpx.WriteJSON(w, http.StatusOK, dtos)
}
func (h *Handler) getArtifact(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
}
phase := Phase(chi.URLParam(r, "phase"))
out, err := h.artifacts.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
}
type approveArtifactReq struct {
Verdict string `json:"verdict"`
}
func (h *Handler) approveArtifact(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
}
var req approveArtifactReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
phase := Phase(chi.URLParam(r, "phase"))
out, err := h.artifacts.Approve(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver, Verdict(req.Verdict))
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(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)
}