feat(project): add workspace_id to issue/requirement with tri-state update

This commit is contained in:
2026-05-02 14:20:15 +08:00
parent 7429a817bc
commit 0cadec343c
17 changed files with 655 additions and 51 deletions
+41
View File
@@ -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"`
}
+41
View File
@@ -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"`
}
+24
View File
@@ -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
+65
View File
@@ -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"`
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)
+99
View File
@@ -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)
}
+28 -1
View File
@@ -61,6 +61,7 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre
}
in1 := &Issue{
ID: uuid.New(), ProjectID: p.ID, RequirementID: reqID,
WorkspaceID: in.WorkspaceID,
Title: in.Title, Description: in.Description,
AssigneeID: in.AssigneeID, CreatedBy: c.UserID,
}
@@ -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
}
+69
View File
@@ -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)
}
+3 -1
View File
@@ -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
}
+9 -8
View File
@@ -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()
+9 -8
View File
@@ -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'))
@@ -33,17 +33,18 @@ UPDATE requirements
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
+30 -2
View File
@@ -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
+28 -2
View File
@@ -65,6 +65,7 @@ func (s *requirementService) Create(ctx context.Context, c Caller, slug string,
}
in1 := &Requirement{
ID: uuid.New(), ProjectID: p.ID,
WorkspaceID: in.WorkspaceID,
Title: in.Title, Description: in.Description,
OwnerID: owner,
}
@@ -72,9 +73,13 @@ func (s *requirementService) Create(ctx context.Context, c Caller, slug string,
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)
}
+19 -8
View File
@@ -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
}
+41
View File
@@ -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"`
}
+19 -8
View File
@@ -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
}
@@ -186,10 +192,11 @@ UPDATE requirements
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
}
+41
View File
@@ -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"`
}