You've already forked agentic-coding-workflow
feat(project): add workspace_id to issue/requirement with tri-state update
This commit is contained in:
@@ -21,6 +21,17 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Username *string `json:"username"`
|
||||
EncryptedSecret []byte `json:"encrypted_secret"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
@@ -34,6 +45,7 @@ type Issue struct {
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
@@ -73,6 +85,7 @@ type Requirement struct {
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -93,3 +106,31 @@ type UserSession struct {
|
||||
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GitRemoteUrl string `json:"git_remote_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
MainPath string `json:"main_path"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||
LastSyncError *string `json:"last_sync_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Branch string `json:"branch"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
ActiveHolder *string `json:"active_holder"`
|
||||
AcquiredAt pgtype.Timestamptz `json:"acquired_at"`
|
||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Username *string `json:"username"`
|
||||
EncryptedSecret []byte `json:"encrypted_secret"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
@@ -34,6 +45,7 @@ type Issue struct {
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
@@ -73,6 +85,7 @@ type Requirement struct {
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -93,3 +106,31 @@ type UserSession struct {
|
||||
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GitRemoteUrl string `json:"git_remote_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
MainPath string `json:"main_path"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||
LastSyncError *string `json:"last_sync_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Branch string `json:"branch"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
ActiveHolder *string `json:"active_holder"`
|
||||
AcquiredAt pgtype.Timestamptz `json:"acquired_at"`
|
||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -77,9 +77,12 @@ type Project struct {
|
||||
}
|
||||
|
||||
// Requirement 表示一个项目内的需求。Number 在 (project_id) 内自增。
|
||||
// WorkspaceID 为 nil 表示未关联任何 workspace;非 nil 时由 Service 层保证
|
||||
// 该 workspace 与本 requirement 同 project(Plan 3 引入)。
|
||||
type Requirement struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
WorkspaceID *uuid.UUID
|
||||
Number int
|
||||
Title string
|
||||
Description string
|
||||
@@ -93,10 +96,13 @@ type Requirement struct {
|
||||
|
||||
// Issue 可挂载到某个 Requirement,也可游离(RequirementID == nil)。
|
||||
// AssigneeID 为 nil 表示未指派。CreatedBy 永远存在(DELETE RESTRICT)。
|
||||
// WorkspaceID 为 nil 表示未关联 workspace;非 nil 时 DB 通过复合外键
|
||||
// (project_id, workspace_id) 拒绝跨 project 关联(Plan 3 引入)。
|
||||
type Issue struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
RequirementID *uuid.UUID
|
||||
WorkspaceID *uuid.UUID
|
||||
Number int
|
||||
Title string
|
||||
Description string
|
||||
@@ -172,18 +178,27 @@ type UpdateProjectInput struct {
|
||||
}
|
||||
|
||||
// CreateRequirementInput 创建一条需求。OwnerID 为 nil 时由 Service 默认为调用者。
|
||||
// WorkspaceID 为 nil 表示不关联任何 workspace;非 nil 时直接落库(FK 由 PG 校验)。
|
||||
type CreateRequirementInput struct {
|
||||
Title string
|
||||
Description string
|
||||
OwnerID *uuid.UUID
|
||||
WorkspaceID *uuid.UUID
|
||||
}
|
||||
|
||||
// UpdateRequirementInput 同 UpdateProjectInput 的 patch 语义。Phase / Status 不在此处修改,
|
||||
// 走专用方法 ChangePhase / Close / Reopen 以便 audit 区分。
|
||||
//
|
||||
// WorkspaceID 走"uuid.Nil sentinel"三态语义(与 RequirementNumber 的 **int 不同):
|
||||
//
|
||||
// nil —— 未提供(保持原 workspace_id 不变)
|
||||
// &uuid.Nil —— 已提供,要求"解绑"(workspace_id 置 NULL)
|
||||
// &id (非 Nil) —— 已提供,要求关联到指定 workspace
|
||||
type UpdateRequirementInput struct {
|
||||
Title *string
|
||||
Description *string
|
||||
OwnerID *uuid.UUID
|
||||
WorkspaceID *uuid.UUID
|
||||
}
|
||||
|
||||
// RequirementFilter 控制 List 时的过滤维度。Phase / Status 留零值("" / Status(""))表示
|
||||
@@ -196,11 +211,13 @@ type RequirementFilter struct {
|
||||
// CreateIssueInput 创建 Issue。RequirementNumber == nil 表示游离 Issue(不挂载到任何
|
||||
// Requirement);非 nil 时 Service 会校验 1) 该 Requirement 与目标 Project 同属,
|
||||
// 2) 未关闭。
|
||||
// WorkspaceID 为 nil 表示不关联 workspace;非 nil 时 DB 复合 FK 保证同 project。
|
||||
type CreateIssueInput struct {
|
||||
Title string
|
||||
Description string
|
||||
RequirementNumber *int
|
||||
AssigneeID *uuid.UUID
|
||||
WorkspaceID *uuid.UUID
|
||||
}
|
||||
|
||||
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
|
||||
@@ -210,10 +227,17 @@ type CreateIssueInput struct {
|
||||
// *x != nil —— 已提供,要求"挂载到本项目下 number=**x 的 Requirement"
|
||||
//
|
||||
// HTTP 层用 json.RawMessage 区分 "字段缺省 vs 显式 null vs 数字",再翻译为本三态。
|
||||
//
|
||||
// WorkspaceID 走"uuid.Nil sentinel"三态语义(不再嵌一层指针):
|
||||
//
|
||||
// nil —— 未提供(保持原 workspace_id 不变)
|
||||
// &uuid.Nil —— 已提供,要求"解绑"(workspace_id 置 NULL)
|
||||
// &id (非 Nil) —— 已提供,要求关联到指定 workspace(DB FK 校验同 project)
|
||||
type UpdateIssueInput struct {
|
||||
Title *string
|
||||
Description *string
|
||||
RequirementNumber **int
|
||||
WorkspaceID *uuid.UUID
|
||||
}
|
||||
|
||||
// IssueFilter 控制 List 时的过滤。Requirement 字段三态(与 sqlc requirement_filter
|
||||
|
||||
@@ -129,6 +129,7 @@ type projectDTO struct {
|
||||
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"`
|
||||
@@ -144,6 +145,7 @@ 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"`
|
||||
@@ -177,6 +179,10 @@ func toRequirementDTO(r *Requirement) requirementDTO {
|
||||
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
|
||||
@@ -196,6 +202,10 @@ func toIssueDTO(i *Issue) issueDTO {
|
||||
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
|
||||
@@ -332,6 +342,7 @@ 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) {
|
||||
@@ -354,6 +365,14 @@ func (h *Handler) createRequirement(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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)
|
||||
@@ -411,10 +430,12 @@ func (h *Handler) getRequirement(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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) {
|
||||
@@ -442,6 +463,10 @@ func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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)
|
||||
@@ -450,6 +475,32 @@ func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
}
|
||||
@@ -511,6 +562,7 @@ type createIssueReq struct {
|
||||
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) {
|
||||
@@ -533,6 +585,14 @@ func (h *Handler) createIssue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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)
|
||||
@@ -607,6 +667,7 @@ 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) {
|
||||
@@ -641,6 +702,10 @@ func (h *Handler) updateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
@@ -114,3 +114,102 @@ func TestHandler_SlugTaken_Returns409(t *testing.T) {
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, "project_slug_taken", body["code"])
|
||||
}
|
||||
|
||||
// decodeBodyMap 解析 200 响应里的 JSON 对象。
|
||||
func decodeBodyMap(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
return body
|
||||
}
|
||||
|
||||
// TestHandler_Issue_PATCH_WorkspaceID_TriState 验证 PATCH /issues/{n} 的
|
||||
// workspace_id 三态:缺失=不变 / null=解绑 / UUID 字符串=关联 / 非法=400。
|
||||
func TestHandler_Issue_PATCH_WorkspaceID_TriState(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
tok := "tok"
|
||||
_, h := mountFullHandler(t, tok, owner)
|
||||
checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated)
|
||||
wsID := uuid.New()
|
||||
resp := doJSON(t, h, "POST", "/api/v1/projects/demo/issues", tok, map[string]any{"title": "I", "workspace_id": wsID.String()})
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
createBody := decodeBodyMap(t, resp)
|
||||
require.Equal(t, wsID.String(), createBody["workspace_id"])
|
||||
|
||||
// 1) 缺失字段:不变
|
||||
resp2 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"title": "I-new"})
|
||||
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
body2 := decodeBodyMap(t, resp2)
|
||||
require.Equal(t, wsID.String(), body2["workspace_id"])
|
||||
|
||||
// 2) UUID 字符串:关联到新 workspace
|
||||
wsB := uuid.New()
|
||||
resp3 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"workspace_id": wsB.String()})
|
||||
require.Equal(t, http.StatusOK, resp3.StatusCode)
|
||||
body3 := decodeBodyMap(t, resp3)
|
||||
require.Equal(t, wsB.String(), body3["workspace_id"])
|
||||
|
||||
// 3) 显式 null:解绑(用 raw JSON 才能传 null,不能用 map[string]any{"workspace_id": nil},
|
||||
// 因为 map nil 也会被序列化成 null,这里直接拼字符串保证准确)
|
||||
rawNull := bytes.NewBufferString(`{"workspace_id": null}`)
|
||||
req := httptest.NewRequest("PATCH", "/api/v1/projects/demo/issues/1", rawNull)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
resp4 := rec.Result()
|
||||
require.Equal(t, http.StatusOK, resp4.StatusCode)
|
||||
body4 := decodeBodyMap(t, resp4)
|
||||
require.Nil(t, body4["workspace_id"])
|
||||
|
||||
// 4) 非法字符串:400
|
||||
resp5 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"workspace_id": "not-a-uuid"})
|
||||
t.Cleanup(func() { _ = resp5.Body.Close() })
|
||||
require.Equal(t, http.StatusBadRequest, resp5.StatusCode)
|
||||
}
|
||||
|
||||
// TestHandler_Requirement_PATCH_WorkspaceID_TriState 验证 PATCH /requirements/{n}
|
||||
// 的 workspace_id 三态。
|
||||
func TestHandler_Requirement_PATCH_WorkspaceID_TriState(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
tok := "tok"
|
||||
_, h := mountFullHandler(t, tok, owner)
|
||||
checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated)
|
||||
wsID := uuid.New()
|
||||
resp := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R", "workspace_id": wsID.String()})
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
createBody := decodeBodyMap(t, resp)
|
||||
require.Equal(t, wsID.String(), createBody["workspace_id"])
|
||||
|
||||
// 1) 缺失字段:不变
|
||||
resp2 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"title": "R-new"})
|
||||
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
body2 := decodeBodyMap(t, resp2)
|
||||
require.Equal(t, wsID.String(), body2["workspace_id"])
|
||||
|
||||
// 2) UUID 字符串:关联到新 workspace
|
||||
wsB := uuid.New()
|
||||
resp3 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"workspace_id": wsB.String()})
|
||||
require.Equal(t, http.StatusOK, resp3.StatusCode)
|
||||
body3 := decodeBodyMap(t, resp3)
|
||||
require.Equal(t, wsB.String(), body3["workspace_id"])
|
||||
|
||||
// 3) 显式 null:解绑
|
||||
rawNull := bytes.NewBufferString(`{"workspace_id": null}`)
|
||||
req := httptest.NewRequest("PATCH", "/api/v1/projects/demo/requirements/1", rawNull)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
resp4 := rec.Result()
|
||||
require.Equal(t, http.StatusOK, resp4.StatusCode)
|
||||
body4 := decodeBodyMap(t, resp4)
|
||||
require.Nil(t, body4["workspace_id"])
|
||||
|
||||
// 4) 非法字符串:400
|
||||
resp5 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"workspace_id": "not-a-uuid"})
|
||||
t.Cleanup(func() { _ = resp5.Body.Close() })
|
||||
require.Equal(t, http.StatusBadRequest, resp5.StatusCode)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre
|
||||
}
|
||||
in1 := &Issue{
|
||||
ID: uuid.New(), ProjectID: p.ID, RequirementID: reqID,
|
||||
Title: in.Title, Description: in.Description,
|
||||
WorkspaceID: in.WorkspaceID,
|
||||
Title: in.Title, Description: in.Description,
|
||||
AssigneeID: in.AssigneeID, CreatedBy: c.UserID,
|
||||
}
|
||||
out, err := s.createWithRetry(ctx, in1)
|
||||
@@ -72,6 +73,9 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre
|
||||
if reqID != nil {
|
||||
meta["requirement_id"] = reqID.String()
|
||||
}
|
||||
if in.WorkspaceID != nil {
|
||||
meta["workspace_id"] = in.WorkspaceID.String()
|
||||
}
|
||||
s.recordAudit(ctx, c, "issue.create", out.ID.String(), meta)
|
||||
return out, nil
|
||||
}
|
||||
@@ -165,11 +169,34 @@ func (s *issueService) Update(ctx context.Context, c Caller, slug string, number
|
||||
}
|
||||
}
|
||||
|
||||
// workspace 三态:nil(未提供)/ &uuid.Nil(解绑)/ &id(关联)
|
||||
nextWS := cur.WorkspaceID
|
||||
if in.WorkspaceID != nil {
|
||||
if *in.WorkspaceID == uuid.Nil {
|
||||
nextWS = nil
|
||||
} else {
|
||||
v := *in.WorkspaceID
|
||||
nextWS = &v
|
||||
}
|
||||
auditFrom := "<none>"
|
||||
if cur.WorkspaceID != nil {
|
||||
auditFrom = cur.WorkspaceID.String()
|
||||
}
|
||||
auditTo := "<none>"
|
||||
if nextWS != nil {
|
||||
auditTo = nextWS.String()
|
||||
}
|
||||
if auditFrom != auditTo {
|
||||
changed["from_workspace"] = auditFrom
|
||||
changed["to_workspace"] = auditTo
|
||||
}
|
||||
}
|
||||
|
||||
if len(changed) == 0 {
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
out, err := s.repo.UpdateIssueCore(ctx, cur.ID, title, desc, reqID)
|
||||
out, err := s.repo.UpdateIssueCore(ctx, cur.ID, title, desc, reqID, nextWS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -114,3 +114,72 @@ func TestIssueService_Update_BlockedWhenProjectArchived(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeProjectArchived, ae.Code)
|
||||
}
|
||||
|
||||
func TestIssueService_Create_WithWorkspaceID(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
issueSvc, _, spy := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
wsID := uuid.New()
|
||||
out, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{
|
||||
Title: "I", WorkspaceID: &wsID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.WorkspaceID)
|
||||
require.Equal(t, wsID, *out.WorkspaceID)
|
||||
|
||||
// 再 Get 一次确认持久化
|
||||
got, err := issueSvc.Get(context.Background(), caller, "demo", out.Number)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.WorkspaceID)
|
||||
require.Equal(t, wsID, *got.WorkspaceID)
|
||||
|
||||
// audit metadata 包含 workspace_id
|
||||
require.NotEmpty(t, spy.entries)
|
||||
last := spy.entries[len(spy.entries)-1]
|
||||
require.Equal(t, "issue.create", last.Action)
|
||||
require.Equal(t, wsID.String(), last.Metadata["workspace_id"])
|
||||
}
|
||||
|
||||
func TestIssueService_Update_WorkspaceID_TriState(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
issueSvc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
wsA := uuid.New()
|
||||
i, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{
|
||||
Title: "I", WorkspaceID: &wsA,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, i.WorkspaceID)
|
||||
require.Equal(t, wsA, *i.WorkspaceID)
|
||||
|
||||
// 1) nil → 不变(用 Title 强制 changed 非空,走持久化路径,验证 nextWS 不被错误清空)
|
||||
newTitle := "I-renamed"
|
||||
out, err := issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{Title: &newTitle})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newTitle, out.Title)
|
||||
require.NotNil(t, out.WorkspaceID)
|
||||
require.Equal(t, wsA, *out.WorkspaceID)
|
||||
|
||||
// 2) &id(非 Nil)→ 关联到新 workspace
|
||||
wsB := uuid.New()
|
||||
out, err = issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{
|
||||
WorkspaceID: &wsB,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.WorkspaceID)
|
||||
require.Equal(t, wsB, *out.WorkspaceID)
|
||||
|
||||
// 3) &uuid.Nil → 显式解绑
|
||||
nilUUID := uuid.Nil
|
||||
out, err = issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{
|
||||
WorkspaceID: &nilUUID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, out.WorkspaceID)
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ func (r *fakeRepo) UpdateRequirement(_ context.Context, in *Requirement) (*Requi
|
||||
return nil, errs.New(errs.CodeNotFound, "requirement 不存在")
|
||||
}
|
||||
cur.Title, cur.Description, cur.OwnerID = in.Title, in.Description, in.OwnerID
|
||||
cur.WorkspaceID = in.WorkspaceID
|
||||
cur.UpdatedAt = time.Now()
|
||||
return r.cloneReq(cur), nil
|
||||
}
|
||||
@@ -310,7 +311,7 @@ func (r *fakeRepo) ListIssuesByRequirement(_ context.Context, requirementID uuid
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateIssueCore(_ context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID) (*Issue, error) {
|
||||
func (r *fakeRepo) UpdateIssueCore(_ context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cur, ok := r.issues[id]
|
||||
@@ -318,6 +319,7 @@ func (r *fakeRepo) UpdateIssueCore(_ context.Context, id uuid.UUID, title, descr
|
||||
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||
}
|
||||
cur.Title, cur.Description, cur.RequirementID = title, description, requirementID
|
||||
cur.WorkspaceID = workspaceID
|
||||
cur.UpdatedAt = time.Now()
|
||||
return r.cloneIssue(cur), nil
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
-- name: CreateIssue :one
|
||||
INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by)
|
||||
INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
(SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE project_id = $2),
|
||||
$4, $5, $6, $7
|
||||
$4, $5, $6, $7, $8
|
||||
)
|
||||
RETURNING id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at;
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id;
|
||||
|
||||
-- name: GetIssueByNumber :one
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
FROM issues
|
||||
WHERE project_id = $1 AND number = $2;
|
||||
|
||||
-- name: ListIssues :many
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
FROM issues
|
||||
WHERE project_id = $1
|
||||
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||
@@ -30,7 +30,7 @@ ORDER BY number DESC;
|
||||
|
||||
-- name: ListIssuesByRequirement :many
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
FROM issues
|
||||
WHERE requirement_id = $1
|
||||
ORDER BY number DESC;
|
||||
@@ -40,17 +40,18 @@ UPDATE issues
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
requirement_id = $4,
|
||||
workspace_id = $5,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at;
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id;
|
||||
|
||||
-- name: UpdateIssueAssignee :one
|
||||
UPDATE issues
|
||||
SET assignee_id = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at;
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id;
|
||||
|
||||
-- name: CloseIssue :exec
|
||||
UPDATE issues SET status = 'closed', closed_at = now(), updated_at = now()
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
-- name: CreateRequirement :one
|
||||
INSERT INTO requirements (id, project_id, number, title, description, owner_id)
|
||||
INSERT INTO requirements (id, project_id, number, title, description, owner_id, workspace_id)
|
||||
VALUES (
|
||||
$1, $2,
|
||||
(SELECT COALESCE(MAX(number), 0) + 1 FROM requirements WHERE project_id = $2),
|
||||
$3, $4, $5
|
||||
$3, $4, $5, $6
|
||||
)
|
||||
RETURNING id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at;
|
||||
closed_at, created_at, updated_at, workspace_id;
|
||||
|
||||
-- name: GetRequirementByNumber :one
|
||||
SELECT id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
FROM requirements
|
||||
WHERE project_id = $1 AND number = $2;
|
||||
|
||||
-- name: GetRequirementByID :one
|
||||
SELECT id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
FROM requirements WHERE id = $1;
|
||||
|
||||
-- name: ListRequirements :many
|
||||
SELECT id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
FROM requirements
|
||||
WHERE project_id = $1
|
||||
AND (sqlc.narg('phase')::text IS NULL OR phase = sqlc.narg('phase'))
|
||||
@@ -30,20 +30,21 @@ ORDER BY number DESC;
|
||||
|
||||
-- name: UpdateRequirement :one
|
||||
UPDATE requirements
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
owner_id = $4,
|
||||
updated_at = now()
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
owner_id = $4,
|
||||
workspace_id = $5,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at;
|
||||
closed_at, created_at, updated_at, workspace_id;
|
||||
|
||||
-- name: UpdateRequirementPhase :one
|
||||
UPDATE requirements
|
||||
SET phase = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at;
|
||||
closed_at, created_at, updated_at, workspace_id;
|
||||
|
||||
-- name: CloseRequirement :exec
|
||||
UPDATE requirements
|
||||
|
||||
@@ -46,7 +46,7 @@ type Repository interface {
|
||||
GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Issue, error)
|
||||
ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error)
|
||||
ListIssuesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Issue, error)
|
||||
UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID) (*Issue, error)
|
||||
UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error)
|
||||
UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error)
|
||||
CloseIssue(ctx context.Context, id uuid.UUID) error
|
||||
ReopenIssue(ctx context.Context, id uuid.UUID) error
|
||||
@@ -59,6 +59,16 @@ func IsUniqueViolation(err error) bool {
|
||||
return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation
|
||||
}
|
||||
|
||||
// IsForeignKeyViolation 报告 err 是否为 PG 外键约束冲突。常见场景:
|
||||
// - issues.(project_id, workspace_id) 复合 FK 在跨 project 关联时被 PG 拒绝;
|
||||
// - requirements.workspace_id 关联到不存在的 workspace 时被 PG 拒绝。
|
||||
//
|
||||
// Repository 层据此把错误翻成 invalid_input(HTTP 400),避免泄漏 5xx。
|
||||
func IsForeignKeyViolation(err error) bool {
|
||||
var pg *pgconn.PgError
|
||||
return errors.As(err, &pg) && pg.Code == pgerrcode.ForeignKeyViolation
|
||||
}
|
||||
|
||||
type pgRepo struct{ q *projectsqlc.Queries }
|
||||
|
||||
// NewPostgresRepository 用现有的 pgxpool 构造 Repository。
|
||||
@@ -200,6 +210,7 @@ func rowToRequirement(row projectsqlc.Requirement) *Requirement {
|
||||
return &Requirement{
|
||||
ID: fromPgUUID(row.ID),
|
||||
ProjectID: fromPgUUID(row.ProjectID),
|
||||
WorkspaceID: fromPgUUIDPtr(row.WorkspaceID),
|
||||
Number: int(row.Number),
|
||||
Title: row.Title,
|
||||
Description: row.Description,
|
||||
@@ -219,11 +230,15 @@ func (r *pgRepo) CreateRequirement(ctx context.Context, in *Requirement) (*Requi
|
||||
Title: in.Title,
|
||||
Description: in.Description,
|
||||
OwnerID: toPgUUID(in.OwnerID),
|
||||
WorkspaceID: toPgUUIDPtr(in.WorkspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
if IsUniqueViolation(err) {
|
||||
return nil, err // service 层据此重试一次
|
||||
}
|
||||
if IsForeignKeyViolation(err) {
|
||||
return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "create requirement")
|
||||
}
|
||||
return rowToRequirement(row), nil
|
||||
@@ -285,11 +300,15 @@ func (r *pgRepo) UpdateRequirement(ctx context.Context, in *Requirement) (*Requi
|
||||
Title: in.Title,
|
||||
Description: in.Description,
|
||||
OwnerID: toPgUUID(in.OwnerID),
|
||||
WorkspaceID: toPgUUIDPtr(in.WorkspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "requirement 不存在")
|
||||
}
|
||||
if IsForeignKeyViolation(err) {
|
||||
return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "update requirement")
|
||||
}
|
||||
return rowToRequirement(row), nil
|
||||
@@ -330,6 +349,7 @@ func rowToIssue(row projectsqlc.Issue) *Issue {
|
||||
ID: fromPgUUID(row.ID),
|
||||
ProjectID: fromPgUUID(row.ProjectID),
|
||||
RequirementID: fromPgUUIDPtr(row.RequirementID),
|
||||
WorkspaceID: fromPgUUIDPtr(row.WorkspaceID),
|
||||
Number: int(row.Number),
|
||||
Title: row.Title,
|
||||
Description: row.Description,
|
||||
@@ -351,11 +371,15 @@ func (r *pgRepo) CreateIssue(ctx context.Context, in *Issue) (*Issue, error) {
|
||||
Description: in.Description,
|
||||
AssigneeID: toPgUUIDPtr(in.AssigneeID),
|
||||
CreatedBy: toPgUUID(in.CreatedBy),
|
||||
WorkspaceID: toPgUUIDPtr(in.WorkspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
if IsUniqueViolation(err) {
|
||||
return nil, err
|
||||
}
|
||||
if IsForeignKeyViolation(err) {
|
||||
return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "create issue")
|
||||
}
|
||||
return rowToIssue(row), nil
|
||||
@@ -420,17 +444,21 @@ func (r *pgRepo) ListIssuesByRequirement(ctx context.Context, requirementID uuid
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID) (*Issue, error) {
|
||||
func (r *pgRepo) UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error) {
|
||||
row, err := r.q.UpdateIssueCore(ctx, projectsqlc.UpdateIssueCoreParams{
|
||||
ID: toPgUUID(id),
|
||||
Title: title,
|
||||
Description: description,
|
||||
RequirementID: toPgUUIDPtr(requirementID),
|
||||
WorkspaceID: toPgUUIDPtr(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||
}
|
||||
if IsForeignKeyViolation(err) {
|
||||
return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "update issue core")
|
||||
}
|
||||
return rowToIssue(row), nil
|
||||
|
||||
@@ -65,16 +65,21 @@ func (s *requirementService) Create(ctx context.Context, c Caller, slug string,
|
||||
}
|
||||
in1 := &Requirement{
|
||||
ID: uuid.New(), ProjectID: p.ID,
|
||||
Title: in.Title, Description: in.Description,
|
||||
WorkspaceID: in.WorkspaceID,
|
||||
Title: in.Title, Description: in.Description,
|
||||
OwnerID: owner,
|
||||
}
|
||||
out, err := s.createWithRetry(ctx, in1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordAudit(ctx, c, "requirement.create", out.ID.String(), map[string]any{
|
||||
meta := map[string]any{
|
||||
"project_id": p.ID.String(), "number": out.Number, "title": out.Title,
|
||||
})
|
||||
}
|
||||
if in.WorkspaceID != nil {
|
||||
meta["workspace_id"] = in.WorkspaceID.String()
|
||||
}
|
||||
s.recordAudit(ctx, c, "requirement.create", out.ID.String(), meta)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -137,6 +142,27 @@ func (s *requirementService) Update(ctx context.Context, c Caller, slug string,
|
||||
r.OwnerID = *in.OwnerID
|
||||
changed["owner_id"] = in.OwnerID.String()
|
||||
}
|
||||
// workspace 三态:nil(未提供)/ &uuid.Nil(解绑)/ &id(关联)
|
||||
if in.WorkspaceID != nil {
|
||||
auditFrom := "<none>"
|
||||
if r.WorkspaceID != nil {
|
||||
auditFrom = r.WorkspaceID.String()
|
||||
}
|
||||
if *in.WorkspaceID == uuid.Nil {
|
||||
r.WorkspaceID = nil
|
||||
} else {
|
||||
v := *in.WorkspaceID
|
||||
r.WorkspaceID = &v
|
||||
}
|
||||
auditTo := "<none>"
|
||||
if r.WorkspaceID != nil {
|
||||
auditTo = r.WorkspaceID.String()
|
||||
}
|
||||
if auditFrom != auditTo {
|
||||
changed["from_workspace"] = auditFrom
|
||||
changed["to_workspace"] = auditTo
|
||||
}
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
@@ -99,3 +100,78 @@ func TestRequirementService_Reopen_AllowsPhaseAgain(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PhaseDone, out.Phase)
|
||||
}
|
||||
|
||||
func TestRequirementService_Create_WithWorkspaceID(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, spy := newReqSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
wsID := uuid.New()
|
||||
out, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{
|
||||
Title: "R", WorkspaceID: &wsID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.WorkspaceID)
|
||||
require.Equal(t, wsID, *out.WorkspaceID)
|
||||
|
||||
// 再 Get 一次确认持久化
|
||||
got, err := svc.Get(context.Background(), caller, "demo", out.Number)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.WorkspaceID)
|
||||
require.Equal(t, wsID, *got.WorkspaceID)
|
||||
|
||||
// audit metadata 包含 workspace_id
|
||||
require.NotEmpty(t, spy.entries)
|
||||
var createEntry *audit.Entry
|
||||
for i := range spy.entries {
|
||||
if spy.entries[i].Action == "requirement.create" {
|
||||
createEntry = &spy.entries[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, createEntry)
|
||||
require.Equal(t, wsID.String(), createEntry.Metadata["workspace_id"])
|
||||
}
|
||||
|
||||
func TestRequirementService_Update_WorkspaceID_TriState(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newReqSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
wsA := uuid.New()
|
||||
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{
|
||||
Title: "R", WorkspaceID: &wsA,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r.WorkspaceID)
|
||||
require.Equal(t, wsA, *r.WorkspaceID)
|
||||
|
||||
// 1) nil → 不变(结合一个其它 patch 字段触发 update)
|
||||
newTitle := "R2"
|
||||
out, err := svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{
|
||||
Title: &newTitle,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.WorkspaceID)
|
||||
require.Equal(t, wsA, *out.WorkspaceID)
|
||||
|
||||
// 2) &id(非 Nil)→ 关联到新 workspace
|
||||
wsB := uuid.New()
|
||||
out, err = svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{
|
||||
WorkspaceID: &wsB,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.WorkspaceID)
|
||||
require.Equal(t, wsB, *out.WorkspaceID)
|
||||
|
||||
// 3) &uuid.Nil → 显式解绑
|
||||
nilUUID := uuid.Nil
|
||||
out, err = svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{
|
||||
WorkspaceID: &nilUUID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, out.WorkspaceID)
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ func (q *Queries) CloseIssue(ctx context.Context, id pgtype.UUID) error {
|
||||
}
|
||||
|
||||
const createIssue = `-- name: CreateIssue :one
|
||||
INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by)
|
||||
INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
(SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE project_id = $2),
|
||||
$4, $5, $6, $7
|
||||
$4, $5, $6, $7, $8
|
||||
)
|
||||
RETURNING id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
`
|
||||
|
||||
type CreateIssueParams struct {
|
||||
@@ -40,6 +40,7 @@ type CreateIssueParams struct {
|
||||
Description string `json:"description"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
|
||||
@@ -51,6 +52,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
|
||||
arg.Description,
|
||||
arg.AssigneeID,
|
||||
arg.CreatedBy,
|
||||
arg.WorkspaceID,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
@@ -66,13 +68,14 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getIssueByNumber = `-- name: GetIssueByNumber :one
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
FROM issues
|
||||
WHERE project_id = $1 AND number = $2
|
||||
`
|
||||
@@ -98,13 +101,14 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listIssues = `-- name: ListIssues :many
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
FROM issues
|
||||
WHERE project_id = $1
|
||||
AND ($2::text IS NULL OR status = $2)
|
||||
@@ -153,6 +157,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,7 +171,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
|
||||
|
||||
const listIssuesByRequirement = `-- name: ListIssuesByRequirement :many
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
FROM issues
|
||||
WHERE requirement_id = $1
|
||||
ORDER BY number DESC
|
||||
@@ -194,6 +199,7 @@ func (q *Queries) ListIssuesByRequirement(ctx context.Context, requirementID pgt
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -220,7 +226,7 @@ UPDATE issues
|
||||
SET assignee_id = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
`
|
||||
|
||||
type UpdateIssueAssigneeParams struct {
|
||||
@@ -244,6 +250,7 @@ func (q *Queries) UpdateIssueAssignee(ctx context.Context, arg UpdateIssueAssign
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -253,10 +260,11 @@ UPDATE issues
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
requirement_id = $4,
|
||||
workspace_id = $5,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
`
|
||||
|
||||
type UpdateIssueCoreParams struct {
|
||||
@@ -264,6 +272,7 @@ type UpdateIssueCoreParams struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateIssueCore(ctx context.Context, arg UpdateIssueCoreParams) (Issue, error) {
|
||||
@@ -272,6 +281,7 @@ func (q *Queries) UpdateIssueCore(ctx context.Context, arg UpdateIssueCoreParams
|
||||
arg.Title,
|
||||
arg.Description,
|
||||
arg.RequirementID,
|
||||
arg.WorkspaceID,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
@@ -287,6 +297,7 @@ func (q *Queries) UpdateIssueCore(ctx context.Context, arg UpdateIssueCoreParams
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Username *string `json:"username"`
|
||||
EncryptedSecret []byte `json:"encrypted_secret"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
@@ -34,6 +45,7 @@ type Issue struct {
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
@@ -73,6 +85,7 @@ type Requirement struct {
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -93,3 +106,31 @@ type UserSession struct {
|
||||
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GitRemoteUrl string `json:"git_remote_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
MainPath string `json:"main_path"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||
LastSyncError *string `json:"last_sync_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Branch string `json:"branch"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
ActiveHolder *string `json:"active_holder"`
|
||||
AcquiredAt pgtype.Timestamptz `json:"acquired_at"`
|
||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@ func (q *Queries) CloseRequirement(ctx context.Context, id pgtype.UUID) error {
|
||||
}
|
||||
|
||||
const createRequirement = `-- name: CreateRequirement :one
|
||||
INSERT INTO requirements (id, project_id, number, title, description, owner_id)
|
||||
INSERT INTO requirements (id, project_id, number, title, description, owner_id, workspace_id)
|
||||
VALUES (
|
||||
$1, $2,
|
||||
(SELECT COALESCE(MAX(number), 0) + 1 FROM requirements WHERE project_id = $2),
|
||||
$3, $4, $5
|
||||
$3, $4, $5, $6
|
||||
)
|
||||
RETURNING id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
`
|
||||
|
||||
type CreateRequirementParams struct {
|
||||
@@ -39,6 +39,7 @@ type CreateRequirementParams struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRequirement(ctx context.Context, arg CreateRequirementParams) (Requirement, error) {
|
||||
@@ -48,6 +49,7 @@ func (q *Queries) CreateRequirement(ctx context.Context, arg CreateRequirementPa
|
||||
arg.Title,
|
||||
arg.Description,
|
||||
arg.OwnerID,
|
||||
arg.WorkspaceID,
|
||||
)
|
||||
var i Requirement
|
||||
err := row.Scan(
|
||||
@@ -62,13 +64,14 @@ func (q *Queries) CreateRequirement(ctx context.Context, arg CreateRequirementPa
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRequirementByID = `-- name: GetRequirementByID :one
|
||||
SELECT id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
FROM requirements WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -87,13 +90,14 @@ func (q *Queries) GetRequirementByID(ctx context.Context, id pgtype.UUID) (Requi
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRequirementByNumber = `-- name: GetRequirementByNumber :one
|
||||
SELECT id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
FROM requirements
|
||||
WHERE project_id = $1 AND number = $2
|
||||
`
|
||||
@@ -118,13 +122,14 @@ func (q *Queries) GetRequirementByNumber(ctx context.Context, arg GetRequirement
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listRequirements = `-- name: ListRequirements :many
|
||||
SELECT id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
FROM requirements
|
||||
WHERE project_id = $1
|
||||
AND ($2::text IS NULL OR phase = $2)
|
||||
@@ -159,6 +164,7 @@ func (q *Queries) ListRequirements(ctx context.Context, arg ListRequirementsPara
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -183,13 +189,14 @@ func (q *Queries) ReopenRequirement(ctx context.Context, id pgtype.UUID) error {
|
||||
|
||||
const updateRequirement = `-- name: UpdateRequirement :one
|
||||
UPDATE requirements
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
owner_id = $4,
|
||||
updated_at = now()
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
owner_id = $4,
|
||||
workspace_id = $5,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
`
|
||||
|
||||
type UpdateRequirementParams struct {
|
||||
@@ -197,6 +204,7 @@ type UpdateRequirementParams struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateRequirement(ctx context.Context, arg UpdateRequirementParams) (Requirement, error) {
|
||||
@@ -205,6 +213,7 @@ func (q *Queries) UpdateRequirement(ctx context.Context, arg UpdateRequirementPa
|
||||
arg.Title,
|
||||
arg.Description,
|
||||
arg.OwnerID,
|
||||
arg.WorkspaceID,
|
||||
)
|
||||
var i Requirement
|
||||
err := row.Scan(
|
||||
@@ -219,6 +228,7 @@ func (q *Queries) UpdateRequirement(ctx context.Context, arg UpdateRequirementPa
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -228,7 +238,7 @@ UPDATE requirements
|
||||
SET phase = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, number, title, description, phase, status, owner_id,
|
||||
closed_at, created_at, updated_at
|
||||
closed_at, created_at, updated_at, workspace_id
|
||||
`
|
||||
|
||||
type UpdateRequirementPhaseParams struct {
|
||||
@@ -251,6 +261,7 @@ func (q *Queries) UpdateRequirementPhase(ctx context.Context, arg UpdateRequirem
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Username *string `json:"username"`
|
||||
EncryptedSecret []byte `json:"encrypted_secret"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
@@ -34,6 +45,7 @@ type Issue struct {
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
@@ -73,6 +85,7 @@ type Requirement struct {
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -93,3 +106,31 @@ type UserSession struct {
|
||||
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GitRemoteUrl string `json:"git_remote_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
MainPath string `json:"main_path"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||
LastSyncError *string `json:"last_sync_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Branch string `json:"branch"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
ActiveHolder *string `json:"active_holder"`
|
||||
AcquiredAt pgtype.Timestamptz `json:"acquired_at"`
|
||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user