// 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) 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}/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"` } 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"), } } // ===== 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) } // ===== 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)) } // ===== 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) }